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"