Усредение точек в проекции ГК

This commit is contained in:
2025-12-01 09:54:22 +03:00
parent d521b6baad
commit 01871c3e13
7 changed files with 437 additions and 154 deletions

View File

@@ -40,9 +40,6 @@
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:points_averaging' %}">Усреднение</a>
</li>
<!-- <li class="nav-item">
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
</li> -->

View File

@@ -280,7 +280,6 @@ document.addEventListener('DOMContentLoaded', function() {
headerWordWrap: true,
columns: [
{title: "Объект наблюдения", field: "source_name", minWidth: 180, widthGrow: 2},
{title: "Интервал", field: "interval_label", minWidth: 150, widthGrow: 1.5},
{title: "Частота, МГц", field: "frequency", minWidth: 100, widthGrow: 1},
{title: "Полоса, МГц", field: "freq_range", minWidth: 100, widthGrow: 1},
{title: "Символьная скорость, БОД", field: "bod_velocity", minWidth: 120, widthGrow: 1.5},
@@ -288,20 +287,8 @@ document.addEventListener('DOMContentLoaded', function() {
{title: "ОСШ", field: "snr", minWidth: 70, widthGrow: 0.8},
{title: "Зеркала", field: "mirrors", minWidth: 130, widthGrow: 1.5},
{title: "Усреднённые координаты", field: "avg_coordinates", minWidth: 150, widthGrow: 2},
{title: "Медианное время", field: "avg_time", minWidth: 120, widthGrow: 1},
{title: "Кол-во точек", field: "total_points", minWidth: 80, widthGrow: 0.8, hozAlign: "center"},
{
title: "Статус",
field: "status",
minWidth: 120,
widthGrow: 1,
formatter: function(cell, formatterParams, onRendered) {
const data = cell.getRow().getData();
if (data.has_outliers) {
return `<span class="outlier-warning"><i class="bi bi-exclamation-triangle"></i> Выбросы (${data.outliers_count})</span>`;
}
return '<span class="text-success"><i class="bi bi-check-circle"></i> OK</span>';
}
},
{
title: "Действия",
field: "actions",
@@ -504,7 +491,8 @@ document.addEventListener('DOMContentLoaded', function() {
});
// Show group details modal
function showGroupDetails(groupIndex) {
// skipShow=true means just update content without calling modal.show()
function showGroupDetails(groupIndex, skipShow = false) {
currentGroupIndex = groupIndex;
const group = allGroupsData[groupIndex];
@@ -637,9 +625,15 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// Show modal
const modal = new bootstrap.Modal(document.getElementById('groupDetailsModal'));
modal.show();
// Show modal - use getOrCreateInstance to avoid creating multiple instances
if (!skipShow) {
const modalElement = document.getElementById('groupDetailsModal');
let modal = bootstrap.Modal.getInstance(modalElement);
if (!modal) {
modal = new bootstrap.Modal(modalElement);
}
modal.show();
}
}
// Remove point from group and recalculate
@@ -653,6 +647,10 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
if (!confirm('Удалить эту точку из выборки и пересчитать усреднение?')) {
return;
}
// Remove point
group.points.splice(pointIndex, 1);
@@ -664,14 +662,12 @@ document.addEventListener('DOMContentLoaded', function() {
async function recalculateGroup(groupIndex, includeAll) {
const group = allGroupsData[groupIndex];
if (!group) {
hideLoading();
return;
}
// Check if there are points to process
if (!group.points || group.points.length === 0) {
alert('Нет точек для пересчёта');
hideLoading();
return;
}
@@ -694,7 +690,6 @@ document.addEventListener('DOMContentLoaded', function() {
if (!response.ok) {
alert(data.error || 'Ошибка при пересчёте');
hideLoading();
return;
}
@@ -705,6 +700,8 @@ document.addEventListener('DOMContentLoaded', function() {
group.valid_points_count = data.valid_points_count;
group.outliers_count = data.outliers_count;
group.has_outliers = data.has_outliers;
group.mirrors = data.mirrors || group.mirrors;
group.avg_time = data.avg_time || group.avg_time;
group.points = data.points;
// Update table
@@ -716,9 +713,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Update all points table
updateAllPointsTable();
// Update modal if open
// Update modal content without calling show() again
if (currentGroupIndex === groupIndex) {
showGroupDetails(groupIndex);
showGroupDetails(groupIndex, true);
}
} catch (error) {
@@ -730,16 +727,16 @@ document.addEventListener('DOMContentLoaded', function() {
}
// Average all points button
document.getElementById('btn-average-all').addEventListener('click', function() {
document.getElementById('btn-average-all').addEventListener('click', async function() {
if (currentGroupIndex !== null) {
recalculateGroup(currentGroupIndex, true);
await recalculateGroup(currentGroupIndex, true);
}
});
// Average valid points button
document.getElementById('btn-average-valid').addEventListener('click', function() {
document.getElementById('btn-average-valid').addEventListener('click', async function() {
if (currentGroupIndex !== null) {
recalculateGroup(currentGroupIndex, false);
await recalculateGroup(currentGroupIndex, false);
}
});
@@ -753,7 +750,6 @@ document.addEventListener('DOMContentLoaded', function() {
// Prepare summary data for export
const summaryData = allGroupsData.map(group => ({
'Объект наблюдения': group.source_name,
'Интервал': group.interval_label,
'Частота, МГц': group.frequency,
'Полоса, МГц': group.freq_range,
'Символьная скорость, БОД': group.bod_velocity,
@@ -761,9 +757,8 @@ document.addEventListener('DOMContentLoaded', function() {
'ОСШ': group.snr,
'Зеркала': group.mirrors,
'Усреднённые координаты': group.avg_coordinates,
'Кол-во точек': group.total_points,
'Выбросов': group.outliers_count,
'Статус': group.has_outliers ? 'Есть выбросы' : 'OK'
'Медианное время': group.avg_time || '-',
'Кол-во точек': group.total_points
}));
// Prepare all points data for export

View File

@@ -98,6 +98,9 @@
onclick="showSelectedOnMap()">
<i class="bi bi-map"></i> Карта
</button>
<a href="{% url 'mainapp:points_averaging' %}" class="btn btn-warning btn-sm" title="Усреднение точек">
<i class="bi bi-calculator"></i> Усреднение
</a>
</div>
<!-- Add to List Button -->

View File

@@ -7,6 +7,7 @@ from datetime import datetime, time
# Django imports
from django.contrib.gis.geos import Point
from django.db.models import F
from django.utils import timezone
# Third-party imports
import pandas as pd
@@ -691,6 +692,10 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
"mir_1",
"mir_2",
"mir_3",
"mir_4",
"mir_5",
"mir_6",
"mir_7",
],
)
df[["lat", "lon", "freq", "f_range"]] = (
@@ -719,7 +724,7 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
sat_name = row["sat"]
# Извлекаем время для проверки дубликатов
timestamp = row["time"]
timestamp = timezone.make_aware(row["time"])
# Проверяем дубликаты по координатам и времени
if _is_duplicate_by_coords_and_time(coord_tuple, timestamp):
@@ -727,10 +732,13 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
continue
# Получаем или создаем объект спутника
sat_obj, _ = Satellite.objects.get_or_create(
name=sat_name, defaults={"norad": row["norad_id"]}
)
# sat_obj, _ = Satellite.objects.get_or_create(
# name=sat_name, defaults={"norad": row["norad_id"]}
# )
sat_obj, _ = Satellite.objects.get_or_create(
norad=row["norad_id"], defaults={"name": sat_name}
)
source = None
# Если is_automatic=False, работаем с Source
@@ -784,7 +792,7 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
return new_sources_count
def _is_duplicate_by_coords_and_time(coord_tuple, timestamp, tolerance_km=0.1):
def _is_duplicate_by_coords_and_time(coord_tuple, timestamp, tolerance_km=0.001):
"""
Проверяет, существует ли уже ObjItem с такими же координатами и временем ГЛ.
@@ -934,7 +942,7 @@ def _create_objitem_from_csv_row(row, source, user_to_use, is_automatic=False):
# Создаем Geo объект
geo_obj, _ = Geo.objects.get_or_create(
timestamp=row["time"],
timestamp=timezone.make_aware(row["time"]),
coords=Point(row["lon"], row["lat"], srid=4326),
defaults={
"is_average": False,
@@ -1259,6 +1267,162 @@ def kub_report(data_in: io.StringIO) -> pd.DataFrame:
# Утилиты для работы с координатами
# ============================================================================
# Импорт pyproj для работы с проекциями
from pyproj import CRS, Transformer
def get_gauss_kruger_zone(longitude: float) -> int:
"""
Определяет номер зоны Гаусса-Крюгера по долготе.
Зоны ГК нумеруются от 1 до 60, каждая зона охватывает 6° долготы.
Центральный меридиан зоны N: (6*N - 3)°
Args:
longitude: Долгота в градусах (от -180 до 180)
Returns:
int: Номер зоны ГК (1-60)
"""
# Нормализуем долготу к диапазону 0-360
lon_normalized = longitude if longitude >= 0 else longitude + 360
# Вычисляем номер зоны (1-60)
zone = int((lon_normalized + 6) / 6)
if zone > 60:
zone = 60
if zone < 1:
zone = 1
return zone
def get_gauss_kruger_epsg(zone: int) -> int:
"""
Возвращает EPSG код для зоны Гаусса-Крюгера (Pulkovo 1942 / Gauss-Kruger).
EPSG коды для Pulkovo 1942 GK зон:
- Зона 4: EPSG:28404
- Зона 5: EPSG:28405
- ...
- Зона N: EPSG:28400 + N
Args:
zone: Номер зоны ГК (1-60)
Returns:
int: EPSG код проекции
"""
return 28400 + zone
def transform_wgs84_to_gk(coord: tuple, zone: int = None) -> tuple:
"""
Преобразует координаты из WGS84 (EPSG:4326) в проекцию Гаусса-Крюгера.
Args:
coord: Координаты в формате (longitude, latitude) в WGS84
zone: Номер зоны ГК (если None, определяется автоматически по долготе)
Returns:
tuple: Координаты (x, y) в метрах в проекции ГК
"""
lon, lat = coord
if zone is None:
zone = get_gauss_kruger_zone(lon)
epsg_gk = get_gauss_kruger_epsg(zone)
# Создаём трансформер WGS84 -> GK
transformer = Transformer.from_crs(
CRS.from_epsg(4326),
CRS.from_epsg(epsg_gk),
always_xy=True
)
x, y = transformer.transform(lon, lat)
return (x, y)
def transform_gk_to_wgs84(coord: tuple, zone: int) -> tuple:
"""
Преобразует координаты из проекции Гаусса-Крюгера в WGS84 (EPSG:4326).
Args:
coord: Координаты (x, y) в метрах в проекции ГК
zone: Номер зоны ГК
Returns:
tuple: Координаты (longitude, latitude) в WGS84
"""
x, y = coord
epsg_gk = get_gauss_kruger_epsg(zone)
# Создаём трансформер GK -> WGS84
transformer = Transformer.from_crs(
CRS.from_epsg(epsg_gk),
CRS.from_epsg(4326),
always_xy=True
)
lon, lat = transformer.transform(x, y)
return (lon, lat)
def calculate_distance_gk(coord1_gk: tuple, coord2_gk: tuple) -> float:
"""
Вычисляет расстояние между двумя точками в проекции ГК (в километрах).
Args:
coord1_gk: Первая точка (x, y) в метрах
coord2_gk: Вторая точка (x, y) в метрах
Returns:
float: Расстояние в километрах
"""
import math
x1, y1 = coord1_gk
x2, y2 = coord2_gk
distance_m = math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
return distance_m / 1000
def average_coords_in_gk(coords: list[tuple], zone: int = None) -> tuple:
"""
Вычисляет среднее арифметическое координат в проекции Гаусса-Крюгера.
Алгоритм:
1. Определяет зону ГК по первой точке (если не указана)
2. Преобразует все координаты в проекцию ГК
3. Вычисляет среднее арифметическое X и Y
4. Преобразует результат обратно в WGS84
Args:
coords: Список координат в формате [(lon1, lat1), (lon2, lat2), ...]
zone: Номер зоны ГК (если None, определяется по первой точке)
Returns:
tuple: Средние координаты (longitude, latitude) в WGS84
"""
if not coords:
return (0, 0)
if len(coords) == 1:
return coords[0]
# Определяем зону по первой точке
if zone is None:
zone = get_gauss_kruger_zone(coords[0][0])
# Преобразуем все координаты в ГК
coords_gk = [transform_wgs84_to_gk(c, zone) for c in coords]
# Вычисляем среднее арифметическое
avg_x = sum(c[0] for c in coords_gk) / len(coords_gk)
avg_y = sum(c[1] for c in coords_gk) / len(coords_gk)
# Преобразуем обратно в WGS84
return transform_gk_to_wgs84((avg_x, avg_y), zone)
def calculate_mean_coords(coord1: tuple, coord2: tuple) -> tuple[tuple, float]:
"""
@@ -1279,6 +1443,23 @@ def calculate_mean_coords(coord1: tuple, coord2: tuple) -> tuple[tuple, float]:
return (geod_direct['lon2'], geod_direct['lat2']), distance/1000
def calculate_distance_wgs84(coord1: tuple, coord2: tuple) -> float:
"""
Вычисляет расстояние между двумя точками в WGS84 (в километрах).
Args:
coord1: Первая точка (longitude, latitude)
coord2: Вторая точка (longitude, latitude)
Returns:
float: Расстояние в километрах
"""
lon1, lat1 = coord1
lon2, lat2 = coord2
geod_inv = Geodesic.WGS84.Inverse(lat1, lon1, lat2, lon2)
return geod_inv['s12'] / 1000
def calculate_average_coords_incremental(
current_average: tuple, new_coord: tuple

View File

@@ -9,7 +9,18 @@ from django.views import View
from django.utils import timezone
from ..models import ObjItem, Satellite
from ..utils import calculate_mean_coords, format_frequency, format_symbol_rate, format_coords_display, RANGE_DISTANCE
from ..utils import (
calculate_mean_coords,
calculate_distance_wgs84,
format_frequency,
format_symbol_rate,
format_coords_display,
RANGE_DISTANCE,
get_gauss_kruger_zone,
transform_wgs84_to_gk,
transform_gk_to_wgs84,
average_coords_in_gk,
)
class PointsAveragingView(LoginRequiredMixin, View):
@@ -117,7 +128,8 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
if not objitem.geo_obj or not objitem.geo_obj.timestamp:
continue
timestamp = objitem.geo_obj.timestamp
timestamp = timezone.localtime(objitem.geo_obj.timestamp)
# timestamp = objitem.geo_obj.timestamp
source_name = objitem.name or f"Объект #{objitem.id}"
# Determine interval
@@ -177,6 +189,7 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
# Collect coordinates and build points_data
points_data = []
timestamp_objects = [] # Store datetime objects separately
for objitem in points:
geo = objitem.geo_obj
@@ -191,9 +204,14 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
# Format timestamp
timestamp_str = '-'
timestamp_unix = None
if geo.timestamp:
local_time = timezone.localtime(geo.timestamp)
timestamp_str = local_time.strftime("%d.%m.%Y %H:%M")
timestamp_unix = geo.timestamp.timestamp()
timestamp_objects.append(geo.timestamp)
else:
timestamp_objects.append(None)
points_data.append({
'id': objitem.id,
@@ -204,6 +222,7 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
'modulation': param.modulation.name if param and param.modulation else '-',
'snr': f"{param.snr:.0f}" if param and param.snr else '-',
'timestamp': timestamp_str,
'timestamp_unix': timestamp_unix,
'mirrors': mirrors,
'location': geo.location or '-',
'coordinates': format_coords_display(geo.coords),
@@ -221,7 +240,7 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
for i, point_data in enumerate(points_data):
coord = point_data['coord_tuple']
_, distance = calculate_mean_coords(avg_coord, coord)
distance = calculate_distance_wgs84(avg_coord, coord)
point_data['distance_from_avg'] = round(distance, 2)
if i in valid_indices:
@@ -241,6 +260,43 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
# Get common parameters from first valid point (or first point if no valid)
first_point = valid_points[0] if valid_points else (points_data[0] if points_data else {})
# Collect all unique mirrors from valid points
all_mirrors = set()
for point in valid_points:
mirrors_str = point.get('mirrors', '-')
if mirrors_str and mirrors_str != '-':
# Split by comma and add each mirror
for mirror in mirrors_str.split(','):
mirror = mirror.strip()
if mirror and mirror != '-':
all_mirrors.add(mirror)
combined_mirrors = ', '.join(sorted(all_mirrors)) if all_mirrors else '-'
# Calculate median time from valid points using timestamp_objects array
valid_timestamps = []
for i in valid_indices:
if timestamp_objects[i]:
valid_timestamps.append(timestamp_objects[i])
median_time_str = '-'
if valid_timestamps:
# Sort timestamps and get median
sorted_timestamps = sorted(valid_timestamps, key=lambda ts: ts.timestamp())
n = len(sorted_timestamps)
if n % 2 == 1:
# Odd number of timestamps - take middle one
median_datetime = sorted_timestamps[n // 2]
else:
# Even number of timestamps - take average of two middle ones
mid1 = sorted_timestamps[n // 2 - 1]
mid2 = sorted_timestamps[n // 2]
avg_seconds = (mid1.timestamp() + mid2.timestamp()) / 2
median_datetime = datetime.fromtimestamp(avg_seconds, tz=mid1.tzinfo)
median_time_str = timezone.localtime(median_datetime).strftime("%d.%m.%Y %H:%M")
return {
'source_name': source_name,
'interval_key': interval_key,
@@ -251,12 +307,13 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
'has_outliers': len(outliers) > 0,
'avg_coordinates': avg_coords_str,
'avg_coord_tuple': avg_coord,
'avg_time': median_time_str,
'frequency': first_point.get('frequency', '-'),
'freq_range': first_point.get('freq_range', '-'),
'bod_velocity': first_point.get('bod_velocity', '-'),
'modulation': first_point.get('modulation', '-'),
'snr': first_point.get('snr', '-'),
'mirrors': first_point.get('mirrors', '-'),
'mirrors': combined_mirrors,
'points': points_data,
'outliers': outliers,
'valid_points': valid_points,
@@ -265,13 +322,12 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
def _find_cluster_center(self, points_data):
"""
Find cluster center using the following algorithm:
1. Find first pair of points within 56 km of each other
2. Calculate their average as initial center
3. Iteratively add points within 56 km of current average
1. Take the first point as reference
2. Find all points within 56 km of the first point
3. Calculate average of all found points using Gauss-Kruger projection
4. Return final average and indices of valid points
If only 1 point, return it as center.
If no pair found within 56 km, use first point as center.
Returns:
tuple: (avg_coord, set of valid point indices)
@@ -282,69 +338,46 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
if len(points_data) == 1:
return points_data[0]['coord_tuple'], {0}
# Step 1: Find first pair of points within 56 km
initial_pair = None
for i in range(len(points_data)):
for j in range(i + 1, len(points_data)):
coord_i = points_data[i]['coord_tuple']
coord_j = points_data[j]['coord_tuple']
_, distance = calculate_mean_coords(coord_i, coord_j)
if distance <= RANGE_DISTANCE:
initial_pair = (i, j)
break
if initial_pair:
break
# Step 1: Take first point as reference
first_coord = points_data[0]['coord_tuple']
valid_indices = {0}
# If no pair found within 56 km, use first point as center
if not initial_pair:
# All points are outliers except the first one
return points_data[0]['coord_tuple'], {0}
# Step 2: Find all points within 56 km of the first point
for i in range(1, len(points_data)):
coord_i = points_data[i]['coord_tuple']
distance = calculate_distance_wgs84(first_coord, coord_i)
if distance <= RANGE_DISTANCE:
valid_indices.add(i)
# Step 2: Calculate initial average from the pair
i, j = initial_pair
coord_i = points_data[i]['coord_tuple']
coord_j = points_data[j]['coord_tuple']
avg_coord, _ = calculate_mean_coords(coord_i, coord_j)
valid_indices = {i, j}
# Step 3: Iteratively add points within 56 km of current average
# Keep iterating until no new points are added
changed = True
while changed:
changed = False
for k in range(len(points_data)):
if k in valid_indices:
continue
coord_k = points_data[k]['coord_tuple']
_, distance = calculate_mean_coords(avg_coord, coord_k)
if distance <= RANGE_DISTANCE:
# Add point to cluster and recalculate average
valid_indices.add(k)
# Recalculate average with all valid points
avg_coord = self._calculate_average_from_indices(points_data, valid_indices)
changed = True
# Step 3: Calculate average of all valid points using Gauss-Kruger projection
avg_coord = self._calculate_average_from_indices(points_data, valid_indices)
return avg_coord, valid_indices
def _calculate_average_from_indices(self, points_data, indices):
"""
Calculate average coordinate from points at given indices.
Uses incremental averaging.
Uses arithmetic averaging in Gauss-Kruger projection.
Algorithm:
1. Determine GK zone from the first point
2. Transform all coordinates to GK projection
3. Calculate arithmetic mean of X and Y
4. Transform result back to WGS84
"""
indices_list = sorted(indices)
if not indices_list:
return (0, 0)
avg_coord = points_data[indices_list[0]]['coord_tuple']
if len(indices_list) == 1:
return points_data[indices_list[0]]['coord_tuple']
for idx in indices_list[1:]:
coord = points_data[idx]['coord_tuple']
avg_coord, _ = calculate_mean_coords(avg_coord, coord)
# Collect coordinates for averaging
coords = [points_data[idx]['coord_tuple'] for idx in indices_list]
# Use Gauss-Kruger projection for averaging
avg_coord = average_coords_in_gk(coords)
return avg_coord
@@ -368,21 +401,26 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
if not points:
return JsonResponse({'error': 'No points provided'}, status=400)
# If include_all is True, recalculate with all points using clustering algorithm
# If include_all is False, use only non-outlier points
if not include_all:
# If include_all is True, average ALL points without clustering (no outliers)
# If include_all is False, use only non-outlier points and apply clustering
if include_all:
# Average all points - no outliers, all points are valid
avg_coord = self._calculate_average_from_indices(points, set(range(len(points))))
valid_indices = set(range(len(points)))
else:
# Filter out outliers first
points = [p for p in points if not p.get('is_outlier', False)]
if not points:
return JsonResponse({'error': 'No valid points after filtering'}, status=400)
# Apply clustering algorithm
avg_coord, valid_indices = self._find_cluster_center(points)
if not points:
return JsonResponse({'error': 'No valid points after filtering'}, status=400)
# Apply clustering algorithm
avg_coord, valid_indices = self._find_cluster_center(points)
# Mark outliers and calculate distances
for i, point in enumerate(points):
coord = tuple(point['coord_tuple'])
_, distance = calculate_mean_coords(avg_coord, coord)
distance = calculate_distance_wgs84(avg_coord, coord)
point['distance_from_avg'] = round(distance, 2)
point['is_outlier'] = i not in valid_indices
@@ -396,6 +434,44 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
outliers = [p for p in points if p.get('is_outlier', False)]
valid_points = [p for p in points if not p.get('is_outlier', False)]
# Collect all unique mirrors from valid points
all_mirrors = set()
for point in valid_points:
mirrors_str = point.get('mirrors', '-')
if mirrors_str and mirrors_str != '-':
for mirror in mirrors_str.split(','):
mirror = mirror.strip()
if mirror and mirror != '-':
all_mirrors.add(mirror)
combined_mirrors = ', '.join(sorted(all_mirrors)) if all_mirrors else '-'
# Calculate median time from valid points using timestamp_unix
valid_timestamps_unix = []
for point in valid_points:
if point.get('timestamp_unix'):
valid_timestamps_unix.append(point['timestamp_unix'])
median_time_str = '-'
if valid_timestamps_unix:
from datetime import datetime
# Sort timestamps and get median
sorted_timestamps = sorted(valid_timestamps_unix)
n = len(sorted_timestamps)
if n % 2 == 1:
# Odd number of timestamps - take middle one
median_unix = sorted_timestamps[n // 2]
else:
# Even number of timestamps - take average of two middle ones
mid1 = sorted_timestamps[n // 2 - 1]
mid2 = sorted_timestamps[n // 2]
median_unix = (mid1 + mid2) / 2
# Convert Unix timestamp to datetime
median_datetime = datetime.fromtimestamp(median_unix, tz=timezone.get_current_timezone())
median_time_str = timezone.localtime(median_datetime).strftime("%d.%m.%Y %H:%M")
return JsonResponse({
'success': True,
'avg_coordinates': avg_coords_str,
@@ -404,15 +480,17 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
'valid_points_count': len(valid_points),
'outliers_count': len(outliers),
'has_outliers': len(outliers) > 0,
'mirrors': combined_mirrors,
'avg_time': median_time_str,
'points': points,
})
def _find_cluster_center(self, points):
"""
Find cluster center using the following algorithm:
1. Find first pair of points within 56 km of each other
2. Calculate their average as initial center
3. Iteratively add points within 56 km of current average
1. Take the first point as reference
2. Find all points within 56 km of the first point
3. Calculate average of all found points using Gauss-Kruger projection
4. Return final average and indices of valid points
"""
if len(points) == 0:
@@ -421,60 +499,39 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
if len(points) == 1:
return tuple(points[0]['coord_tuple']), {0}
# Step 1: Find first pair of points within 56 km
initial_pair = None
for i in range(len(points)):
for j in range(i + 1, len(points)):
coord_i = tuple(points[i]['coord_tuple'])
coord_j = tuple(points[j]['coord_tuple'])
_, distance = calculate_mean_coords(coord_i, coord_j)
if distance <= RANGE_DISTANCE:
initial_pair = (i, j)
break
if initial_pair:
break
# Step 1: Take first point as reference
first_coord = tuple(points[0]['coord_tuple'])
valid_indices = {0}
# If no pair found within 56 km, use first point as center
if not initial_pair:
return tuple(points[0]['coord_tuple']), {0}
# Step 2: Find all points within 56 km of the first point
for i in range(1, len(points)):
coord_i = tuple(points[i]['coord_tuple'])
distance = calculate_distance_wgs84(first_coord, coord_i)
if distance <= RANGE_DISTANCE:
valid_indices.add(i)
# Step 2: Calculate initial average from the pair
i, j = initial_pair
coord_i = tuple(points[i]['coord_tuple'])
coord_j = tuple(points[j]['coord_tuple'])
avg_coord, _ = calculate_mean_coords(coord_i, coord_j)
valid_indices = {i, j}
# Step 3: Iteratively add points within 56 km of current average
changed = True
while changed:
changed = False
for k in range(len(points)):
if k in valid_indices:
continue
coord_k = tuple(points[k]['coord_tuple'])
_, distance = calculate_mean_coords(avg_coord, coord_k)
if distance <= RANGE_DISTANCE:
valid_indices.add(k)
avg_coord = self._calculate_average_from_indices(points, valid_indices)
changed = True
# Step 3: Calculate average of all valid points using Gauss-Kruger projection
avg_coord = self._calculate_average_from_indices(points, valid_indices)
return avg_coord, valid_indices
def _calculate_average_from_indices(self, points, indices):
"""Calculate average coordinate from points at given indices."""
"""
Calculate average coordinate from points at given indices.
Uses arithmetic averaging in Gauss-Kruger projection.
"""
indices_list = sorted(indices)
if not indices_list:
return (0, 0)
avg_coord = tuple(points[indices_list[0]]['coord_tuple'])
if len(indices_list) == 1:
return tuple(points[indices_list[0]]['coord_tuple'])
for idx in indices_list[1:]:
coord = tuple(points[idx]['coord_tuple'])
avg_coord, _ = calculate_mean_coords(avg_coord, coord)
# Collect coordinates for averaging
coords = [tuple(points[idx]['coord_tuple']) for idx in indices_list]
# Use Gauss-Kruger projection for averaging
avg_coord = average_coords_in_gk(coords)
return avg_coord

View File

@@ -30,6 +30,7 @@ dependencies = [
"pandas>=2.3.3",
"psycopg>=3.2.10",
"psycopg2-binary>=2.9.11",
"pyproj>=3.6.0",
"redis>=6.4.0",
"django-redis>=5.4.0",
"requests>=2.32.5",

49
dbapp/uv.lock generated
View File

@@ -312,6 +312,7 @@ dependencies = [
{ name = "pandas" },
{ name = "psycopg" },
{ name = "psycopg2-binary" },
{ name = "pyproj" },
{ name = "redis" },
{ name = "requests" },
{ name = "selenium" },
@@ -347,6 +348,7 @@ requires-dist = [
{ name = "pandas", specifier = ">=2.3.3" },
{ name = "psycopg", specifier = ">=3.2.10" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "pyproj", specifier = ">=3.6.0" },
{ name = "redis", specifier = ">=6.4.0" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "selenium", specifier = ">=4.38.0" },
@@ -938,6 +940,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pyproj"
version = "3.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" },
{ url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" },
{ url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" },
{ url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" },
{ url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" },
{ url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" },
{ url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" },
{ url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" },
{ url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" },
{ url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" },
{ url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" },
{ url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" },
{ url = "https://files.pythonhosted.org/packages/42/17/c2b050d3f5b71b6edd0d96ae16c990fdc42a5f1366464a5c2772146de33a/pyproj-3.7.2-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:2aaa328605ace41db050d06bac1adc11f01b71fe95c18661497763116c3a0f02", size = 6214541, upload-time = "2025-08-14T12:05:03.166Z" },
{ url = "https://files.pythonhosted.org/packages/03/68/68ada9c8aea96ded09a66cfd9bf87aa6db8c2edebe93f5bf9b66b0143fbc/pyproj-3.7.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:35dccbce8201313c596a970fde90e33605248b66272595c061b511c8100ccc08", size = 4617456, upload-time = "2025-08-14T12:05:04.563Z" },
{ url = "https://files.pythonhosted.org/packages/81/e4/4c50ceca7d0e937977866b02cb64e6ccf4df979a5871e521f9e255df6073/pyproj-3.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:25b0b7cb0042444c29a164b993c45c1b8013d6c48baa61dc1160d834a277e83b", size = 9615590, upload-time = "2025-08-14T12:05:06.094Z" },
{ url = "https://files.pythonhosted.org/packages/05/1e/ada6fb15a1d75b5bd9b554355a69a798c55a7dcc93b8d41596265c1772e3/pyproj-3.7.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:85def3a6388e9ba51f964619aa002a9d2098e77c6454ff47773bb68871024281", size = 9474960, upload-time = "2025-08-14T12:05:07.973Z" },
{ url = "https://files.pythonhosted.org/packages/51/07/9d48ad0a8db36e16f842f2c8a694c1d9d7dcf9137264846bef77585a71f3/pyproj-3.7.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b1bccefec3875ab81eabf49059e2b2ea77362c178b66fd3528c3e4df242f1516", size = 10799478, upload-time = "2025-08-14T12:05:14.102Z" },
{ url = "https://files.pythonhosted.org/packages/85/cf/2f812b529079f72f51ff2d6456b7fef06c01735e5cfd62d54ffb2b548028/pyproj-3.7.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d5371ca114d6990b675247355a801925814eca53e6c4b2f1b5c0a956336ee36e", size = 10710030, upload-time = "2025-08-14T12:05:16.317Z" },
{ url = "https://files.pythonhosted.org/packages/99/9b/4626a19e1f03eba4c0e77b91a6cf0f73aa9cb5d51a22ee385c22812bcc2c/pyproj-3.7.2-cp314-cp314-win32.whl", hash = "sha256:77f066626030f41be543274f5ac79f2a511fe89860ecd0914f22131b40a0ec25", size = 5991181, upload-time = "2025-08-14T12:05:19.492Z" },
{ url = "https://files.pythonhosted.org/packages/04/b2/5a6610554306a83a563080c2cf2c57565563eadd280e15388efa00fb5b33/pyproj-3.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a964da1696b8522806f4276ab04ccfff8f9eb95133a92a25900697609d40112", size = 6434721, upload-time = "2025-08-14T12:05:21.022Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ce/6c910ea2e1c74ef673c5d48c482564b8a7824a44c4e35cca2e765b68cfcc/pyproj-3.7.2-cp314-cp314-win_arm64.whl", hash = "sha256:e258ab4dbd3cf627809067c0ba8f9884ea76c8e5999d039fb37a1619c6c3e1f6", size = 6363821, upload-time = "2025-08-14T12:05:22.627Z" },
{ url = "https://files.pythonhosted.org/packages/e4/e4/5532f6f7491812ba782a2177fe9de73fd8e2912b59f46a1d056b84b9b8f2/pyproj-3.7.2-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:bbbac2f930c6d266f70ec75df35ef851d96fdb3701c674f42fd23a9314573b37", size = 6241773, upload-time = "2025-08-14T12:05:24.577Z" },
{ url = "https://files.pythonhosted.org/packages/20/1f/0938c3f2bbbef1789132d1726d9b0e662f10cfc22522743937f421ad664e/pyproj-3.7.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b7544e0a3d6339dc9151e9c8f3ea62a936ab7cc446a806ec448bbe86aebb979b", size = 4652537, upload-time = "2025-08-14T12:05:26.391Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a8/488b1ed47d25972f33874f91f09ca8f2227902f05f63a2b80dc73e7b1c97/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7f5133dca4c703e8acadf6f30bc567d39a42c6af321e7f81975c2518f3ed357", size = 9940864, upload-time = "2025-08-14T12:05:27.985Z" },
{ url = "https://files.pythonhosted.org/packages/c7/cc/7f4c895d0cb98e47b6a85a6d79eaca03eb266129eed2f845125c09cf31ff/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5aff3343038d7426aa5076f07feb88065f50e0502d1b0d7c22ddfdd2c75a3f81", size = 9688868, upload-time = "2025-08-14T12:05:30.425Z" },
{ url = "https://files.pythonhosted.org/packages/b2/b7/c7e306b8bb0f071d9825b753ee4920f066c40fbfcce9372c4f3cfb2fc4ed/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b0552178c61f2ac1c820d087e8ba6e62b29442debddbb09d51c4bf8acc84d888", size = 11045910, upload-time = "2025-08-14T12:05:32.507Z" },
{ url = "https://files.pythonhosted.org/packages/42/fb/538a4d2df695980e2dde5c04d965fbdd1fe8c20a3194dc4aaa3952a4d1be/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:47d87db2d2c436c5fd0409b34d70bb6cdb875cca2ebe7a9d1c442367b0ab8d59", size = 10895724, upload-time = "2025-08-14T12:05:35.465Z" },
{ url = "https://files.pythonhosted.org/packages/e8/8b/a3f0618b03957de9db5489a04558a8826f43906628bb0b766033aa3b5548/pyproj-3.7.2-cp314-cp314t-win32.whl", hash = "sha256:c9b6f1d8ad3e80a0ee0903a778b6ece7dca1d1d40f6d114ae01bc8ddbad971aa", size = 6056848, upload-time = "2025-08-14T12:05:37.553Z" },
{ url = "https://files.pythonhosted.org/packages/bc/56/413240dd5149dd3291eda55aa55a659da4431244a2fd1319d0ae89407cfb/pyproj-3.7.2-cp314-cp314t-win_amd64.whl", hash = "sha256:1914e29e27933ba6f9822663ee0600f169014a2859f851c054c88cf5ea8a333c", size = 6517676, upload-time = "2025-08-14T12:05:39.126Z" },
{ url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" },
]
[[package]]
name = "pysocks"
version = "1.7.1"