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

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"> <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a> <a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:points_averaging' %}">Усреднение</a>
</li>
<!-- <li class="nav-item"> <!-- <li class="nav-item">
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a> <a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
</li> --> </li> -->

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ from datetime import datetime, time
# Django imports # Django imports
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point
from django.db.models import F from django.db.models import F
from django.utils import timezone
# Third-party imports # Third-party imports
import pandas as pd import pandas as pd
@@ -691,6 +692,10 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
"mir_1", "mir_1",
"mir_2", "mir_2",
"mir_3", "mir_3",
"mir_4",
"mir_5",
"mir_6",
"mir_7",
], ],
) )
df[["lat", "lon", "freq", "f_range"]] = ( 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"] sat_name = row["sat"]
# Извлекаем время для проверки дубликатов # Извлекаем время для проверки дубликатов
timestamp = row["time"] timestamp = timezone.make_aware(row["time"])
# Проверяем дубликаты по координатам и времени # Проверяем дубликаты по координатам и времени
if _is_duplicate_by_coords_and_time(coord_tuple, timestamp): 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 continue
# Получаем или создаем объект спутника # Получаем или создаем объект спутника
sat_obj, _ = Satellite.objects.get_or_create( # sat_obj, _ = Satellite.objects.get_or_create(
name=sat_name, defaults={"norad": row["norad_id"]} # 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 source = None
# Если is_automatic=False, работаем с Source # Если 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 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 с такими же координатами и временем ГЛ. Проверяет, существует ли уже ObjItem с такими же координатами и временем ГЛ.
@@ -934,7 +942,7 @@ def _create_objitem_from_csv_row(row, source, user_to_use, is_automatic=False):
# Создаем Geo объект # Создаем Geo объект
geo_obj, _ = Geo.objects.get_or_create( geo_obj, _ = Geo.objects.get_or_create(
timestamp=row["time"], timestamp=timezone.make_aware(row["time"]),
coords=Point(row["lon"], row["lat"], srid=4326), coords=Point(row["lon"], row["lat"], srid=4326),
defaults={ defaults={
"is_average": False, "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]: 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 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( def calculate_average_coords_incremental(
current_average: tuple, new_coord: tuple current_average: tuple, new_coord: tuple

View File

@@ -9,7 +9,18 @@ from django.views import View
from django.utils import timezone from django.utils import timezone
from ..models import ObjItem, Satellite 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): class PointsAveragingView(LoginRequiredMixin, View):
@@ -117,7 +128,8 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
if not objitem.geo_obj or not objitem.geo_obj.timestamp: if not objitem.geo_obj or not objitem.geo_obj.timestamp:
continue 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}" source_name = objitem.name or f"Объект #{objitem.id}"
# Determine interval # Determine interval
@@ -177,6 +189,7 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
# Collect coordinates and build points_data # Collect coordinates and build points_data
points_data = [] points_data = []
timestamp_objects = [] # Store datetime objects separately
for objitem in points: for objitem in points:
geo = objitem.geo_obj geo = objitem.geo_obj
@@ -191,9 +204,14 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
# Format timestamp # Format timestamp
timestamp_str = '-' timestamp_str = '-'
timestamp_unix = None
if geo.timestamp: if geo.timestamp:
local_time = timezone.localtime(geo.timestamp) local_time = timezone.localtime(geo.timestamp)
timestamp_str = local_time.strftime("%d.%m.%Y %H:%M") 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({ points_data.append({
'id': objitem.id, 'id': objitem.id,
@@ -204,6 +222,7 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
'modulation': param.modulation.name if param and param.modulation else '-', 'modulation': param.modulation.name if param and param.modulation else '-',
'snr': f"{param.snr:.0f}" if param and param.snr else '-', 'snr': f"{param.snr:.0f}" if param and param.snr else '-',
'timestamp': timestamp_str, 'timestamp': timestamp_str,
'timestamp_unix': timestamp_unix,
'mirrors': mirrors, 'mirrors': mirrors,
'location': geo.location or '-', 'location': geo.location or '-',
'coordinates': format_coords_display(geo.coords), 'coordinates': format_coords_display(geo.coords),
@@ -221,7 +240,7 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
for i, point_data in enumerate(points_data): for i, point_data in enumerate(points_data):
coord = point_data['coord_tuple'] 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) point_data['distance_from_avg'] = round(distance, 2)
if i in valid_indices: 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) # 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 {}) 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 { return {
'source_name': source_name, 'source_name': source_name,
'interval_key': interval_key, 'interval_key': interval_key,
@@ -251,12 +307,13 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
'has_outliers': len(outliers) > 0, 'has_outliers': len(outliers) > 0,
'avg_coordinates': avg_coords_str, 'avg_coordinates': avg_coords_str,
'avg_coord_tuple': avg_coord, 'avg_coord_tuple': avg_coord,
'avg_time': median_time_str,
'frequency': first_point.get('frequency', '-'), 'frequency': first_point.get('frequency', '-'),
'freq_range': first_point.get('freq_range', '-'), 'freq_range': first_point.get('freq_range', '-'),
'bod_velocity': first_point.get('bod_velocity', '-'), 'bod_velocity': first_point.get('bod_velocity', '-'),
'modulation': first_point.get('modulation', '-'), 'modulation': first_point.get('modulation', '-'),
'snr': first_point.get('snr', '-'), 'snr': first_point.get('snr', '-'),
'mirrors': first_point.get('mirrors', '-'), 'mirrors': combined_mirrors,
'points': points_data, 'points': points_data,
'outliers': outliers, 'outliers': outliers,
'valid_points': valid_points, 'valid_points': valid_points,
@@ -265,13 +322,12 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
def _find_cluster_center(self, points_data): def _find_cluster_center(self, points_data):
""" """
Find cluster center using the following algorithm: Find cluster center using the following algorithm:
1. Find first pair of points within 56 km of each other 1. Take the first point as reference
2. Calculate their average as initial center 2. Find all points within 56 km of the first point
3. Iteratively add points within 56 km of current average 3. Calculate average of all found points using Gauss-Kruger projection
4. Return final average and indices of valid points 4. Return final average and indices of valid points
If only 1 point, return it as center. If only 1 point, return it as center.
If no pair found within 56 km, use first point as center.
Returns: Returns:
tuple: (avg_coord, set of valid point indices) tuple: (avg_coord, set of valid point indices)
@@ -282,69 +338,46 @@ class PointsAveragingAPIView(LoginRequiredMixin, View):
if len(points_data) == 1: if len(points_data) == 1:
return points_data[0]['coord_tuple'], {0} return points_data[0]['coord_tuple'], {0}
# Step 1: Find first pair of points within 56 km # Step 1: Take first point as reference
initial_pair = None first_coord = points_data[0]['coord_tuple']
for i in range(len(points_data)): valid_indices = {0}
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
# If no pair found within 56 km, use first point as center # Step 2: Find all points within 56 km of the first point
if not initial_pair: for i in range(1, len(points_data)):
# All points are outliers except the first one coord_i = points_data[i]['coord_tuple']
return points_data[0]['coord_tuple'], {0} distance = calculate_distance_wgs84(first_coord, coord_i)
if distance <= RANGE_DISTANCE:
valid_indices.add(i)
# Step 2: Calculate initial average from the pair # Step 3: Calculate average of all valid points using Gauss-Kruger projection
i, j = initial_pair avg_coord = self._calculate_average_from_indices(points_data, valid_indices)
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
return avg_coord, valid_indices return avg_coord, valid_indices
def _calculate_average_from_indices(self, points_data, indices): def _calculate_average_from_indices(self, points_data, indices):
""" """
Calculate average coordinate from points at given 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) indices_list = sorted(indices)
if not indices_list: if not indices_list:
return (0, 0) 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:]: # Collect coordinates for averaging
coord = points_data[idx]['coord_tuple'] coords = [points_data[idx]['coord_tuple'] for idx in indices_list]
avg_coord, _ = calculate_mean_coords(avg_coord, coord)
# Use Gauss-Kruger projection for averaging
avg_coord = average_coords_in_gk(coords)
return avg_coord return avg_coord
@@ -368,21 +401,26 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
if not points: if not points:
return JsonResponse({'error': 'No points provided'}, status=400) return JsonResponse({'error': 'No points provided'}, status=400)
# If include_all is True, recalculate with all points using clustering algorithm # If include_all is True, average ALL points without clustering (no outliers)
# If include_all is False, use only non-outlier points # If include_all is False, use only non-outlier points and apply clustering
if not include_all: 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)] points = [p for p in points if not p.get('is_outlier', False)]
if not points: if not points:
return JsonResponse({'error': 'No valid points after filtering'}, status=400) return JsonResponse({'error': 'No valid points after filtering'}, status=400)
# Apply clustering algorithm # Apply clustering algorithm
avg_coord, valid_indices = self._find_cluster_center(points) avg_coord, valid_indices = self._find_cluster_center(points)
# Mark outliers and calculate distances # Mark outliers and calculate distances
for i, point in enumerate(points): for i, point in enumerate(points):
coord = tuple(point['coord_tuple']) 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['distance_from_avg'] = round(distance, 2)
point['is_outlier'] = i not in valid_indices 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)] 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)] 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({ return JsonResponse({
'success': True, 'success': True,
'avg_coordinates': avg_coords_str, 'avg_coordinates': avg_coords_str,
@@ -404,15 +480,17 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
'valid_points_count': len(valid_points), 'valid_points_count': len(valid_points),
'outliers_count': len(outliers), 'outliers_count': len(outliers),
'has_outliers': len(outliers) > 0, 'has_outliers': len(outliers) > 0,
'mirrors': combined_mirrors,
'avg_time': median_time_str,
'points': points, 'points': points,
}) })
def _find_cluster_center(self, points): def _find_cluster_center(self, points):
""" """
Find cluster center using the following algorithm: Find cluster center using the following algorithm:
1. Find first pair of points within 56 km of each other 1. Take the first point as reference
2. Calculate their average as initial center 2. Find all points within 56 km of the first point
3. Iteratively add points within 56 km of current average 3. Calculate average of all found points using Gauss-Kruger projection
4. Return final average and indices of valid points 4. Return final average and indices of valid points
""" """
if len(points) == 0: if len(points) == 0:
@@ -421,60 +499,39 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
if len(points) == 1: if len(points) == 1:
return tuple(points[0]['coord_tuple']), {0} return tuple(points[0]['coord_tuple']), {0}
# Step 1: Find first pair of points within 56 km # Step 1: Take first point as reference
initial_pair = None first_coord = tuple(points[0]['coord_tuple'])
for i in range(len(points)): valid_indices = {0}
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
# If no pair found within 56 km, use first point as center # Step 2: Find all points within 56 km of the first point
if not initial_pair: for i in range(1, len(points)):
return tuple(points[0]['coord_tuple']), {0} 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 # Step 3: Calculate average of all valid points using Gauss-Kruger projection
i, j = initial_pair avg_coord = self._calculate_average_from_indices(points, valid_indices)
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
return avg_coord, valid_indices return avg_coord, valid_indices
def _calculate_average_from_indices(self, points, 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) indices_list = sorted(indices)
if not indices_list: if not indices_list:
return (0, 0) 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:]: # Collect coordinates for averaging
coord = tuple(points[idx]['coord_tuple']) coords = [tuple(points[idx]['coord_tuple']) for idx in indices_list]
avg_coord, _ = calculate_mean_coords(avg_coord, coord)
# Use Gauss-Kruger projection for averaging
avg_coord = average_coords_in_gk(coords)
return avg_coord return avg_coord

View File

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

49
dbapp/uv.lock generated
View File

@@ -312,6 +312,7 @@ dependencies = [
{ name = "pandas" }, { name = "pandas" },
{ name = "psycopg" }, { name = "psycopg" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "pyproj" },
{ name = "redis" }, { name = "redis" },
{ name = "requests" }, { name = "requests" },
{ name = "selenium" }, { name = "selenium" },
@@ -347,6 +348,7 @@ requires-dist = [
{ name = "pandas", specifier = ">=2.3.3" }, { name = "pandas", specifier = ">=2.3.3" },
{ name = "psycopg", specifier = ">=3.2.10" }, { name = "psycopg", specifier = ">=3.2.10" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "pyproj", specifier = ">=3.6.0" },
{ name = "redis", specifier = ">=6.4.0" }, { name = "redis", specifier = ">=6.4.0" },
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
{ name = "selenium", specifier = ">=4.38.0" }, { 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" }, { 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]] [[package]]
name = "pysocks" name = "pysocks"
version = "1.7.1" version = "1.7.1"