diff --git a/dbapp/mainapp/templates/mainapp/components/_navbar.html b/dbapp/mainapp/templates/mainapp/components/_navbar.html index b3a8a04..c9156cb 100644 --- a/dbapp/mainapp/templates/mainapp/components/_navbar.html +++ b/dbapp/mainapp/templates/mainapp/components/_navbar.html @@ -40,9 +40,6 @@ - diff --git a/dbapp/mainapp/templates/mainapp/points_averaging.html b/dbapp/mainapp/templates/mainapp/points_averaging.html index bd62f39..c44c87d 100644 --- a/dbapp/mainapp/templates/mainapp/points_averaging.html +++ b/dbapp/mainapp/templates/mainapp/points_averaging.html @@ -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 ` Выбросы (${data.outliers_count})`; - } - return ' OK'; - } - }, { 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 diff --git a/dbapp/mainapp/templates/mainapp/source_list.html b/dbapp/mainapp/templates/mainapp/source_list.html index 82b2251..435c103 100644 --- a/dbapp/mainapp/templates/mainapp/source_list.html +++ b/dbapp/mainapp/templates/mainapp/source_list.html @@ -98,6 +98,9 @@ onclick="showSelectedOnMap()"> Карта + + Усреднение + diff --git a/dbapp/mainapp/utils.py b/dbapp/mainapp/utils.py index 2a5cfa3..6086942 100644 --- a/dbapp/mainapp/utils.py +++ b/dbapp/mainapp/utils.py @@ -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 diff --git a/dbapp/mainapp/views/points_averaging.py b/dbapp/mainapp/views/points_averaging.py index f6c5dce..e9266f9 100644 --- a/dbapp/mainapp/views/points_averaging.py +++ b/dbapp/mainapp/views/points_averaging.py @@ -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 diff --git a/dbapp/pyproject.toml b/dbapp/pyproject.toml index 3d12b2a..ac76dbe 100644 --- a/dbapp/pyproject.toml +++ b/dbapp/pyproject.toml @@ -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", diff --git a/dbapp/uv.lock b/dbapp/uv.lock index 3b52f93..dace06e 100644 --- a/dbapp/uv.lock +++ b/dbapp/uv.lock @@ -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"