Compare commits

...

2 Commits

19 changed files with 1157 additions and 374 deletions

View File

@@ -53,9 +53,13 @@ def process_single_satellite(
logger.info(f"Найдено {len(sources)} источников для {sat_name}") logger.info(f"Найдено {len(sources)} источников для {sat_name}")
# Находим спутник в базе # Находим спутник в базе по имени или альтернативному имени (lowercase)
from django.db.models import Q
sat_name_lower = sat_name.lower()
try: try:
sat_obj = Satellite.objects.get(name__icontains=sat_name) sat_obj = Satellite.objects.get(
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
)
logger.debug(f"Спутник {sat_name} найден в базе (ID: {sat_obj.id})") logger.debug(f"Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
except Satellite.DoesNotExist: except Satellite.DoesNotExist:
error_msg = f"Спутник '{sat_name}' не найден в базе данных" error_msg = f"Спутник '{sat_name}' не найден в базе данных"

View File

@@ -76,9 +76,13 @@ def fill_lyngsat_data(
logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}") logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}")
# Находим спутник в базе # Находим спутник в базе по имени или альтернативному имени (lowercase)
from django.db.models import Q
sat_name_lower = sat_name.lower()
try: try:
sat_obj = Satellite.objects.get(name__icontains=sat_name) sat_obj = Satellite.objects.get(
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
)
logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})") logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
except Satellite.DoesNotExist: except Satellite.DoesNotExist:
error_msg = f"Спутник '{sat_name}' не найден в базе данных" error_msg = f"Спутник '{sat_name}' не найден в базе данных"

View File

@@ -573,6 +573,7 @@ class SatelliteAdmin(BaseAdmin):
list_display = ( list_display = (
"name", "name",
"alternative_name",
"norad", "norad",
"international_code", "international_code",
"undersat_point", "undersat_point",
@@ -580,7 +581,7 @@ class SatelliteAdmin(BaseAdmin):
"created_at", "created_at",
"updated_at", "updated_at",
) )
search_fields = ("name", "norad", "international_code") search_fields = ("name", "alternative_name", "norad", "international_code")
ordering = ("name",) ordering = ("name",)
filter_horizontal = ("band",) filter_horizontal = ("band",)
autocomplete_fields = ("band",) autocomplete_fields = ("band",)

View File

@@ -815,6 +815,7 @@ class SatelliteForm(forms.ModelForm):
model = Satellite model = Satellite
fields = [ fields = [
'name', 'name',
'alternative_name',
'norad', 'norad',
'international_code', 'international_code',
'band', 'band',
@@ -829,6 +830,10 @@ class SatelliteForm(forms.ModelForm):
'placeholder': 'Введите название спутника', 'placeholder': 'Введите название спутника',
'required': True 'required': True
}), }),
'alternative_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Введите альтернативное название (необязательно)'
}),
'norad': forms.NumberInput(attrs={ 'norad': forms.NumberInput(attrs={
'class': 'form-control', 'class': 'form-control',
'placeholder': 'Введите NORAD ID' 'placeholder': 'Введите NORAD ID'
@@ -862,6 +867,7 @@ class SatelliteForm(forms.ModelForm):
} }
labels = { labels = {
'name': 'Название спутника', 'name': 'Название спутника',
'alternative_name': 'Альтернативное название',
'norad': 'NORAD ID', 'norad': 'NORAD ID',
'international_code': 'Международный код', 'international_code': 'Международный код',
'band': 'Диапазоны работы', 'band': 'Диапазоны работы',
@@ -872,6 +878,7 @@ class SatelliteForm(forms.ModelForm):
} }
help_texts = { help_texts = {
'name': 'Уникальное название спутника', 'name': 'Уникальное название спутника',
'alternative_name': 'Альтернативное название спутника (например, на другом языке)',
'norad': 'Идентификатор NORAD для отслеживания спутника', 'norad': 'Идентификатор NORAD для отслеживания спутника',
'international_code': 'Международный идентификатор спутника (например, 2011-074A)', 'international_code': 'Международный идентификатор спутника (например, 2011-074A)',
'band': 'Выберите диапазоны работы спутника (удерживайте Ctrl для множественного выбора)', 'band': 'Выберите диапазоны работы спутника (удерживайте Ctrl для множественного выбора)',

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2025-12-01 08:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0016_alter_satellite_international_code_techanalyze'),
]
operations = [
migrations.AddField(
model_name='satellite',
name='alternative_name',
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника (например, из скобок)', max_length=100, null=True, verbose_name='Альтернативное имя'),
),
migrations.AlterField(
model_name='standard',
name='name',
field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=80, unique=True, verbose_name='Стандарт'),
),
]

View File

@@ -344,6 +344,14 @@ class Satellite(models.Model):
db_index=True, db_index=True,
help_text="Название спутника", help_text="Название спутника",
) )
alternative_name = models.CharField(
max_length=100,
blank=True,
null=True,
verbose_name="Альтернативное имя",
db_index=True,
help_text="Альтернативное название спутника (например, из скобок)",
)
norad = models.IntegerField( norad = models.IntegerField(
blank=True, blank=True,
null=True, null=True,

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

@@ -43,8 +43,13 @@ function showSatelliteModal(satelliteId) {
'<div class="col-md-6"><div class="card h-100">' + '<div class="col-md-6"><div class="card h-100">' +
'<div class="card-header bg-light"><strong><i class="bi bi-info-circle"></i> Основная информация</strong></div>' + '<div class="card-header bg-light"><strong><i class="bi bi-info-circle"></i> Основная информация</strong></div>' +
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' + '<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
'<tr><td class="text-muted" style="width: 40%;">Название:</td><td><strong>' + data.name + '</strong></td></tr>' + '<tr><td class="text-muted" style="width: 40%;">Название:</td><td><strong>' + data.name + '</strong></td></tr>';
'<tr><td class="text-muted">NORAD ID:</td><td>' + data.norad + '</td></tr>' +
if (data.alternative_name && data.alternative_name !== '-') {
html += '<tr><td class="text-muted">Альтернативное название:</td><td><strong>' + data.alternative_name + '</strong></td></tr>';
}
html += '<tr><td class="text-muted">NORAD ID:</td><td>' + data.norad + '</td></tr>' +
'<tr><td class="text-muted">Подспутниковая точка:</td><td><strong>' + data.undersat_point + '</strong></td></tr>' + '<tr><td class="text-muted">Подспутниковая точка:</td><td><strong>' + data.undersat_point + '</strong></td></tr>' +
'<tr><td class="text-muted">Диапазоны:</td><td>' + data.bands + '</td></tr>' + '<tr><td class="text-muted">Диапазоны:</td><td>' + data.bands + '</td></tr>' +
'</tbody></table></div></div></div>' + '</tbody></table></div></div></div>' +

View File

@@ -180,6 +180,9 @@
<button id="export-xlsx" class="btn btn-success" disabled> <button id="export-xlsx" class="btn btn-success" disabled>
<i class="bi bi-file-earmark-excel"></i> Сохранить в Excel <i class="bi bi-file-earmark-excel"></i> Сохранить в Excel
</button> </button>
<button id="export-json" class="btn btn-info ms-2" disabled>
<i class="bi bi-filetype-json"></i> Сохранить в JSON
</button>
<button id="clear-table" class="btn btn-danger ms-2"> <button id="clear-table" class="btn btn-danger ms-2">
<i class="bi bi-trash"></i> Очистить таблицу <i class="bi bi-trash"></i> Очистить таблицу
</button> </button>
@@ -280,7 +283,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,35 +290,26 @@ 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",
minWidth: 100, minWidth: 120,
widthGrow: 1, widthGrow: 1,
hozAlign: "center", hozAlign: "center",
formatter: function(cell, formatterParams, onRendered) { formatter: function(cell, formatterParams, onRendered) {
const data = cell.getRow().getData(); const data = cell.getRow().getData();
const btnClass = data.has_outliers ? 'btn-warning' : 'btn-info'; const btnClass = data.has_outliers ? 'btn-warning' : 'btn-info';
return `<button class="btn btn-sm ${btnClass} btn-view-details" title="Просмотр точек"><i class="bi bi-eye"></i></button>`; return `<button class="btn btn-sm ${btnClass} btn-view-details" title="Просмотр точек"><i class="bi bi-eye"></i></button>
<button class="btn btn-sm btn-danger btn-delete-row ms-1" title="Удалить строку"><i class="bi bi-trash"></i></button>`;
}, },
cellClick: function(e, cell) { cellClick: function(e, cell) {
const data = cell.getRow().getData(); const data = cell.getRow().getData();
if (e.target.closest('.btn-view-details')) { if (e.target.closest('.btn-view-details')) {
showGroupDetails(data._groupIndex); showGroupDetails(data._groupIndex);
} else if (e.target.closest('.btn-delete-row')) {
deleteGroupRow(data._groupIndex);
} }
} }
} }
@@ -334,6 +327,31 @@ document.addEventListener('DOMContentLoaded', function() {
function updateGroupCount() { function updateGroupCount() {
document.getElementById('group-count').textContent = allGroupsData.length; document.getElementById('group-count').textContent = allGroupsData.length;
document.getElementById('export-xlsx').disabled = allGroupsData.length === 0; document.getElementById('export-xlsx').disabled = allGroupsData.length === 0;
document.getElementById('export-json').disabled = allGroupsData.length === 0;
}
// Delete group row
function deleteGroupRow(groupIndex) {
if (!confirm('Удалить эту группу из таблицы?')) {
return;
}
// Удаляем группу из массива
allGroupsData.splice(groupIndex, 1);
// Пересчитываем индексы для оставшихся групп
allGroupsData.forEach((group, idx) => {
group._groupIndex = idx;
});
// Обновляем таблицу
table.setData(allGroupsData.map(g => ({
...g,
status: g.has_outliers ? 'outliers' : 'ok'
})));
updateGroupCount();
updateAllPointsTable();
} }
// Show loading overlay // Show loading overlay
@@ -504,7 +522,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,10 +656,16 @@ 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) {
const modalElement = document.getElementById('groupDetailsModal');
let modal = bootstrap.Modal.getInstance(modalElement);
if (!modal) {
modal = new bootstrap.Modal(modalElement);
}
modal.show(); modal.show();
} }
}
// Remove point from group and recalculate // Remove point from group and recalculate
async function removePointFromGroup(groupIndex, pointIndex) { async function removePointFromGroup(groupIndex, pointIndex) {
@@ -653,6 +678,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 +693,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 +721,6 @@ document.addEventListener('DOMContentLoaded', function() {
if (!response.ok) { if (!response.ok) {
alert(data.error || 'Ошибка при пересчёте'); alert(data.error || 'Ошибка при пересчёте');
hideLoading();
return; return;
} }
@@ -705,6 +731,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 +744,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 +758,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 +781,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 +788,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
@@ -842,6 +868,252 @@ document.addEventListener('DOMContentLoaded', function() {
return cookieValue; return cookieValue;
} }
// Generate UUID v4
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Export to JSON
document.getElementById('export-json').addEventListener('click', function() {
if (allGroupsData.length === 0) {
alert('Нет данных для экспорта');
return;
}
// Creator ID - можно изменить на нужное значение
const CREATOR_ID = '6fd12c90-7f17-43d9-a03e-ee14e880f757';
// Начальный объект path (фиксированный)
const pathObject = {
"tacticObjectType": "path",
"captionPosition": "right",
"points": [
{
"id": "b92b9cbb-dd27-49aa-bcb6-e89a147bc02c",
"latitude": 57,
"longitude": -13,
"altitude": 0,
"customActions": [],
"tags": { "creator": CREATOR_ID },
"tacticObjectType": "point"
},
{
"id": "8e3666d4-4990-4cb9-9594-63ad06333489",
"latitude": 57,
"longitude": 64,
"altitude": 0,
"customActions": [],
"tags": { "creator": CREATOR_ID },
"tacticObjectType": "point"
},
{
"id": "5f137485-d2fc-443d-8507-c936f02f3569",
"latitude": 11,
"longitude": 64,
"altitude": 0,
"customActions": [],
"tags": { "creator": CREATOR_ID },
"tacticObjectType": "point"
},
{
"id": "0fb90df7-8eb0-49fa-9d00-336389171bf5",
"latitude": 11,
"longitude": -13,
"altitude": 0,
"customActions": [],
"tags": { "creator": CREATOR_ID },
"tacticObjectType": "point"
},
{
"id": "3ef12637-585e-40a4-b0ee-8f1786c89ce6",
"latitude": 57,
"longitude": -13,
"altitude": 0,
"customActions": [],
"tags": { "creator": CREATOR_ID },
"tacticObjectType": "point"
}
],
"isCycle": false,
"id": "2f604051-4984-4c2f-8c4c-c0cb64008f5f",
"draggable": false,
"selectable": false,
"editable": false,
"caption": "Ограничение для работы с поверхностями",
"line": {
"color": "rgb(148,0,211)",
"thickness": 1,
"dash": "solid",
"border": null
},
"customActions": [],
"tags": { "creator": CREATOR_ID }
};
// Результирующий массив
const result = [pathObject];
// Палитра цветов для групп (RGB формат)
const jsonGroupColors = [
"rgb(0,128,0)", // зелёный
"rgb(0,0,255)", // синий
"rgb(255,0,0)", // красный
"rgb(255,165,0)", // оранжевый
"rgb(128,0,128)", // фиолетовый
"rgb(0,128,128)", // бирюзовый
"rgb(255,20,147)", // розовый
"rgb(139,69,19)", // коричневый
"rgb(0,100,0)", // тёмно-зелёный
"rgb(70,130,180)" // стальной синий
];
// Обрабатываем каждую группу
allGroupsData.forEach((group, groupIndex) => {
// Цвет для текущей группы
const groupColor = jsonGroupColors[groupIndex % jsonGroupColors.length];
// Формируем имя для усреднённой точки с пометкой "(усредн)"
const avgName = group.source_name;
const avgTime = group.avg_time || '-';
const avgCaption = `${avgName} (усредн) - ${avgTime}`;
// Получаем координаты усреднённой точки
const avgCoord = group.avg_coord_tuple;
const avgLat = avgCoord[1];
const avgLon = avgCoord[0];
// ID для усреднённой точки (source)
const avgSourceId = generateUUID();
// Создаём source для усреднённой точки (triangle)
const avgSource = {
"tacticObjectType": "source",
"captionPosition": "right",
"id": avgSourceId,
"icon": {
"type": "triangle",
"color": groupColor
},
"caption": avgCaption,
"name": avgCaption,
"customActions": [],
"trackBehavior": {},
"bearingStyle": {
"color": groupColor,
"thickness": 2,
"dash": "solid",
"border": null
},
"bearingBehavior": {},
"tags": { "creator": CREATOR_ID }
};
result.push(avgSource);
// Создаём position для усреднённой точки
const avgPosition = {
"tacticObjectType": "position",
"id": generateUUID(),
"parentId": avgSourceId,
"timeStamp": Date.now() / 1000,
"latitude": avgLat,
"altitude": 0,
"longitude": avgLon,
"caption": "",
"tooltip": "",
"customActions": [],
"tags": {
"layers": [],
"creator": CREATOR_ID
}
};
result.push(avgPosition);
// Обрабатываем все точки группы (не выбросы)
group.points.forEach(point => {
if (point.is_outlier) return; // Пропускаем выбросы
const pointCoord = point.coord_tuple;
const pointLat = pointCoord[1];
const pointLon = pointCoord[0];
const pointName = point.name || '-';
const pointTime = point.timestamp || '-';
const pointCaption = `${pointName} - ${pointTime}`;
// ID для source точки
const pointSourceId = generateUUID();
// Создаём source для точки (circle) с тем же цветом группы
const pointSource = {
"tacticObjectType": "source",
"captionPosition": "right",
"id": pointSourceId,
"icon": {
"type": "circle",
"color": groupColor
},
"caption": pointCaption,
"name": pointCaption,
"customActions": [],
"trackBehavior": {},
"bearingStyle": {
"color": groupColor,
"thickness": 2,
"dash": "solid",
"border": null
},
"bearingBehavior": {},
"tags": { "creator": CREATOR_ID }
};
result.push(pointSource);
// Создаём position для точки
const pointPosition = {
"tacticObjectType": "position",
"id": generateUUID(),
"parentId": pointSourceId,
"timeStamp": point.timestamp_unix || (Date.now() / 1000),
"latitude": pointLat,
"altitude": 0,
"longitude": pointLon,
"caption": "",
"tooltip": "",
"customActions": [],
"tags": {
"layers": [],
"creator": CREATOR_ID
}
};
result.push(pointPosition);
});
});
// Конвертируем в JSON строку
const jsonString = JSON.stringify(result, null, 2);
// Добавляем BOM для UTF-8
const BOM = '\uFEFF';
const blob = new Blob([BOM + jsonString], { type: 'application/json;charset=utf-8' });
// Генерируем имя файла
const now = new Date();
const dateStr = now.toISOString().slice(0, 10);
const filename = `averaging_${dateStr}.json`;
// Скачиваем файл
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// Initialize // Initialize
updateGroupCount(); updateGroupCount();
}); });

View File

@@ -86,6 +86,25 @@
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.alternative_name.id_for_label }}" class="form-label">
{{ form.alternative_name.label }}
</label>
{{ form.alternative_name }}
{% if form.alternative_name.errors %}
<div class="invalid-feedback d-block">
{{ form.alternative_name.errors.0 }}
</div>
{% endif %}
{% if form.alternative_name.help_text %}
<div class="form-text">{{ form.alternative_name.help_text }}</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.norad.id_for_label }}" class="form-label"> <label for="{{ form.norad.id_for_label }}" class="form-label">
@@ -102,9 +121,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
<div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.international_code.id_for_label }}" class="form-label"> <label for="{{ form.international_code.id_for_label }}" class="form-label">
@@ -121,7 +138,9 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
<div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.undersat_point.id_for_label }}" class="form-label"> <label for="{{ form.undersat_point.id_for_label }}" class="form-label">
@@ -138,6 +157,23 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.launch_date.id_for_label }}" class="form-label">
{{ form.launch_date.label }}
</label>
{{ form.launch_date }}
{% if form.launch_date.errors %}
<div class="invalid-feedback d-block">
{{ form.launch_date.errors.0 }}
</div>
{% endif %}
{% if form.launch_date.help_text %}
<div class="form-text">{{ form.launch_date.help_text }}</div>
{% endif %}
</div>
</div>
</div> </div>
<div class="row"> <div class="row">
@@ -160,24 +196,7 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-12">
<div class="mb-3">
<label for="{{ form.launch_date.id_for_label }}" class="form-label">
{{ form.launch_date.label }}
</label>
{{ form.launch_date }}
{% if form.launch_date.errors %}
<div class="invalid-feedback d-block">
{{ form.launch_date.errors.0 }}
</div>
{% endif %}
{% if form.launch_date.help_text %}
<div class="form-text">{{ form.launch_date.help_text }}</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.url.id_for_label }}" class="form-label"> <label for="{{ form.url.id_for_label }}" class="form-label">
{{ form.url.label }} {{ form.url.label }}

View File

@@ -190,6 +190,16 @@
{% endif %} {% endif %}
</a> </a>
</th> </th>
<th scope="col" style="min-width: 150px;">
<a href="javascript:void(0)" onclick="updateSort('alternative_name')" class="text-white text-decoration-none">
Альт. название
{% if sort == 'alternative_name' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-alternative_name' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;"> <th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('norad')" class="text-white text-decoration-none"> <a href="javascript:void(0)" onclick="updateSort('norad')" class="text-white text-decoration-none">
NORAD ID NORAD ID
@@ -274,6 +284,7 @@
</td> </td>
<td class="text-center">{{ satellite.id }}</td> <td class="text-center">{{ satellite.id }}</td>
<td>{{ satellite.name }}</td> <td>{{ satellite.name }}</td>
<td>{{ satellite.alternative_name|default:"-" }}</td>
<td>{{ satellite.norad }}</td> <td>{{ satellite.norad }}</td>
<td>{{ satellite.international_code|default:"-" }}</td> <td>{{ satellite.international_code|default:"-" }}</td>
<td>{{ satellite.bands }}</td> <td>{{ satellite.bands }}</td>
@@ -307,7 +318,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="13" class="text-center text-muted">Нет данных для отображения</td> <td colspan="14" class="text-center text-muted">Нет данных для отображения</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

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
@@ -137,7 +138,7 @@ def find_mirror_satellites(mirror_names: list) -> list:
Алгоритм: Алгоритм:
1. Для каждого имени зеркала: 1. Для каждого имени зеркала:
- Обрезать пробелы и привести к нижнему регистру - Обрезать пробелы и привести к нижнему регистру
- Найти все спутники, в имени которых содержится это имя - Найти все спутники, в имени или альтернативном имени которых содержится это имя
2. Вернуть список найденных спутников 2. Вернуть список найденных спутников
Args: Args:
@@ -146,6 +147,8 @@ def find_mirror_satellites(mirror_names: list) -> list:
Returns: Returns:
list: список объектов Satellite list: список объектов Satellite
""" """
from django.db.models import Q
found_satellites = [] found_satellites = []
for mirror_name in mirror_names: for mirror_name in mirror_names:
@@ -158,9 +161,9 @@ def find_mirror_satellites(mirror_names: list) -> list:
if not mirror_name_clean: if not mirror_name_clean:
continue continue
# Ищем спутники, в имени которых содержится имя зеркала # Ищем спутники, в имени или альтернативном имени которых содержится имя зеркала
satellites = Satellite.objects.filter( satellites = Satellite.objects.filter(
name__icontains=mirror_name_clean Q(name__icontains=mirror_name_clean) | Q(alternative_name__icontains=mirror_name_clean)
) )
found_satellites.extend(satellites) found_satellites.extend(satellites)
@@ -691,6 +694,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 +726,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 +734,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 +794,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 +944,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 +1269,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 +1445,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

@@ -605,6 +605,7 @@ class SatelliteDataAPIView(LoginRequiredMixin, View):
data = { data = {
'id': satellite.id, 'id': satellite.id,
'name': satellite.name, 'name': satellite.name,
'alternative_name': satellite.alternative_name or '-',
'norad': satellite.norad if satellite.norad else None, 'norad': satellite.norad if satellite.norad else None,
'bands': bands_str, 'bands': bands_str,
'undersat_point': satellite.undersat_point if satellite.undersat_point is not None else None, 'undersat_point': satellite.undersat_point if satellite.undersat_point is not None else None,

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)):
# 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'] coord_i = points_data[i]['coord_tuple']
coord_j = points_data[j]['coord_tuple'] distance = calculate_distance_wgs84(first_coord, coord_i)
_, distance = calculate_mean_coords(coord_i, coord_j)
if distance <= RANGE_DISTANCE: if distance <= RANGE_DISTANCE:
initial_pair = (i, j) valid_indices.add(i)
break
if initial_pair:
break
# If no pair found within 56 km, use first point as center # Step 3: Calculate average of all valid points using Gauss-Kruger projection
if not initial_pair:
# All points are outliers except the first one
return points_data[0]['coord_tuple'], {0}
# 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) 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,9 +401,14 @@ 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:
@@ -382,7 +420,7 @@ class RecalculateGroupAPIView(LoginRequiredMixin, View):
# 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)):
# 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']) coord_i = tuple(points[i]['coord_tuple'])
coord_j = tuple(points[j]['coord_tuple']) distance = calculate_distance_wgs84(first_coord, coord_i)
_, distance = calculate_mean_coords(coord_i, coord_j)
if distance <= RANGE_DISTANCE: if distance <= RANGE_DISTANCE:
initial_pair = (i, j) valid_indices.add(i)
break
if initial_pair:
break
# If no pair found within 56 km, use first point as center # Step 3: Calculate average of all valid points using Gauss-Kruger projection
if not initial_pair:
return tuple(points[0]['coord_tuple']), {0}
# 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) 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

@@ -124,10 +124,11 @@ class SatelliteListView(LoginRequiredMixin, View):
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
# Search by name # Search by name, alternative_name, or comment
if search_query: if search_query:
satellites = satellites.filter( satellites = satellites.filter(
Q(name__icontains=search_query) | Q(name__icontains=search_query) |
Q(alternative_name__icontains=search_query) |
Q(comment__icontains=search_query) Q(comment__icontains=search_query)
) )
@@ -137,6 +138,8 @@ class SatelliteListView(LoginRequiredMixin, View):
"-id": "-id", "-id": "-id",
"name": "name", "name": "name",
"-name": "-name", "-name": "-name",
"alternative_name": "alternative_name",
"-alternative_name": "-alternative_name",
"norad": "norad", "norad": "norad",
"-norad": "-norad", "-norad": "-norad",
"international_code": "international_code", "international_code": "international_code",
@@ -169,6 +172,7 @@ class SatelliteListView(LoginRequiredMixin, View):
processed_satellites.append({ processed_satellites.append({
'id': satellite.id, 'id': satellite.id,
'name': satellite.name or "-", 'name': satellite.name or "-",
'alternative_name': satellite.alternative_name or "-",
'norad': satellite.norad if satellite.norad else "-", 'norad': satellite.norad if satellite.norad else "-",
'international_code': satellite.international_code or "-", 'international_code': satellite.international_code or "-",
'bands': ", ".join(band_names) if band_names else "-", 'bands': ", ".join(band_names) if band_names else "-",

View File

@@ -5,12 +5,99 @@ from io import BytesIO
# Third-party imports # Third-party imports
import requests import requests
from django.db.models import Q
# Local imports # Local imports
from mainapp.models import Polarization, Satellite from mainapp.models import Polarization, Satellite
from .models import Transponders from .models import Transponders
def parse_satellite_name(full_name: str) -> tuple[str, str | None]:
"""
Парсит полное имя спутника и извлекает основное и альтернативное имя.
Альтернативное имя находится в скобках после основного названия.
Примеры:
"Koreasat 116 (ANASIS-II)" -> ("Koreasat 116", "ANASIS-II")
"Thaicom 6 (Africom 1)" -> ("Thaicom 6", "Africom 1")
"Express AM6" -> ("Express AM6", None)
Args:
full_name: Полное имя спутника (может содержать альтернативное имя в скобках)
Returns:
tuple: (основное_имя, альтернативное_имя или None)
"""
if not full_name:
return (full_name, None)
# Ищем текст в скобках в конце строки
match = re.match(r'^(.+?)\s*\(([^)]+)\)\s*$$', full_name.strip())
if match:
main_name = match.group(1).strip()
alt_name = match.group(2).strip()
return (main_name, alt_name)
return (full_name.strip(), None)
def find_satellite_by_name(name: str):
"""
Ищет спутник по имени или альтернативному имени.
Все сравнения выполняются в lowercase.
Алгоритм поиска:
1. Точное совпадение по name (lowercase)
2. Точное совпадение по alternative_name (lowercase)
3. Частичное совпадение по name (lowercase)
4. Частичное совпадение по alternative_name (lowercase)
Args:
name: Имя спутника для поиска
Returns:
Satellite или None: Найденный спутник или None
Raises:
Satellite.MultipleObjectsReturned: Если найдено несколько спутников
"""
if not name:
return None
name_lower = name.strip().lower()
# 1. Точное совпадение по name (lowercase)
try:
return Satellite.objects.get(name__iexact=name_lower)
except Satellite.DoesNotExist:
pass
except Satellite.MultipleObjectsReturned:
raise
# 2. Точное совпадение по alternative_name (lowercase)
try:
return Satellite.objects.get(alternative_name__iexact=name_lower)
except Satellite.DoesNotExist:
pass
except Satellite.MultipleObjectsReturned:
raise
# 3. Частичное совпадение по name или alternative_name (lowercase)
satellites = Satellite.objects.filter(
Q(name__icontains=name_lower) | Q(alternative_name__icontains=name_lower)
)
if satellites.count() == 1:
return satellites.first()
elif satellites.count() > 1:
raise Satellite.MultipleObjectsReturned(
f"Найдено несколько спутников с именем '{name_lower}'"
)
return None
def search_satellite_on_page(data: dict, satellite_name: str): def search_satellite_on_page(data: dict, satellite_name: str):
for pos, value in data.get('page', {}).get('positions').items(): for pos, value in data.get('page', {}).get('positions').items():
for name in value['satellites']: for name in value['satellites']:
@@ -82,13 +169,19 @@ def parse_transponders_from_json(filepath: str, user=None):
""" """
Парсит транспондеры из JSON файла. Парсит транспондеры из JSON файла.
Если имя спутника содержит альтернативное имя в скобках, оно извлекается
и сохраняется в поле alternative_name.
Args: Args:
filepath: путь к JSON файлу filepath: путь к JSON файлу
user: пользователь для установки created_by и updated_by (optional) user: пользователь для установки created_by и updated_by (optional)
""" """
with open(filepath, encoding="utf-8") as jf: with open(filepath, encoding="utf-8") as jf:
data = json.load(jf) data = json.load(jf)
for sat_name, trans_zone in data["satellites"].items(): for sat_name_full, trans_zone in data["satellites"].items():
# Парсим имя спутника и альтернативное имя
main_name, alt_name = parse_satellite_name(sat_name_full)
for zone, trans in trans_zone.items(): for zone, trans in trans_zone.items():
for tran in trans: for tran in trans:
f_b, f_e = tran["freq"][0].split("-") f_b, f_e = tran["freq"][0].split("-")
@@ -96,7 +189,19 @@ def parse_transponders_from_json(filepath: str, user=None):
f_range = round(abs(float(f_e) - float(f_b)), 3) f_range = round(abs(float(f_e) - float(f_b)), 3)
pol_obj = Polarization.objects.get(name=tran["pol"]) pol_obj = Polarization.objects.get(name=tran["pol"])
sat_obj = Satellite.objects.get(name__iexact=sat_name)
# Ищем спутник по имени или альтернативному имени
sat_obj = find_satellite_by_name(main_name)
if not sat_obj:
# Если не найден, создаём новый с альтернативным именем
sat_obj = Satellite.objects.create(
name=main_name,
alternative_name=alt_name
)
elif alt_name and not sat_obj.alternative_name:
# Если найден, но альтернативное имя не установлено - обновляем
sat_obj.alternative_name = alt_name
sat_obj.save()
tran_obj, created = Transponders.objects.get_or_create( tran_obj, created = Transponders.objects.get_or_create(
name=tran["name"], name=tran["name"],
@@ -124,6 +229,9 @@ def parse_transponders_from_xml(data_in: BytesIO, user=None):
""" """
Парсит транспондеры из XML файла. Парсит транспондеры из XML файла.
Если имя спутника содержит альтернативное имя в скобках, оно извлекается
и сохраняется в поле alternative_name.
Args: Args:
data_in: BytesIO объект с XML данными data_in: BytesIO объект с XML данными
user: пользователь для установки created_by и updated_by (optional) user: пользователь для установки created_by и updated_by (optional)
@@ -136,9 +244,13 @@ def parse_transponders_from_xml(data_in: BytesIO, user=None):
} }
satellites = tree.xpath('//ns:SatelliteMemo', namespaces=ns) satellites = tree.xpath('//ns:SatelliteMemo', namespaces=ns)
for sat in satellites[:]: for sat in satellites[:]:
name = sat.xpath('./ns:name/text()', namespaces=ns)[0] name_full = sat.xpath('./ns:name/text()', namespaces=ns)[0]
if name == 'X' or 'DONT USE' in name: if name_full == 'X' or 'DONT USE' in name_full:
continue continue
# Парсим имя спутника и альтернативное имя
main_name, alt_name = parse_satellite_name(name_full)
norad = sat.xpath('./ns:norad/text()', namespaces=ns) norad = sat.xpath('./ns:norad/text()', namespaces=ns)
beams = sat.xpath('.//ns:BeamMemo', namespaces=ns) beams = sat.xpath('.//ns:BeamMemo', namespaces=ns)
intl_code = sat.xpath('.//ns:internationalCode/text()', namespaces=ns) intl_code = sat.xpath('.//ns:internationalCode/text()', namespaces=ns)
@@ -158,7 +270,6 @@ def parse_transponders_from_xml(data_in: BytesIO, user=None):
uplink_start = float(transponder.xpath('./ns:uplinkFrequency/tr:start/text()', namespaces=ns)[0]) uplink_start = float(transponder.xpath('./ns:uplinkFrequency/tr:start/text()', namespaces=ns)[0])
uplink_end = float(transponder.xpath('./ns:uplinkFrequency/tr:end/text()', namespaces=ns)[0]) uplink_end = float(transponder.xpath('./ns:uplinkFrequency/tr:end/text()', namespaces=ns)[0])
tr_data = zones[tr_id] tr_data = zones[tr_id]
# p = tr_data['pol'][0] if tr_data['pol'] else '-'
match tr_data['pol']: match tr_data['pol']:
case 'Horizontal': case 'Horizontal':
pol = 'Горизонтальная' pol = 'Горизонтальная'
@@ -173,13 +284,36 @@ def parse_transponders_from_xml(data_in: BytesIO, user=None):
tr_name = transponder.xpath('./ns:name/text()', namespaces=ns)[0] tr_name = transponder.xpath('./ns:name/text()', namespaces=ns)[0]
pol_obj, _ = Polarization.objects.get_or_create(name=pol) pol_obj, _ = Polarization.objects.get_or_create(name=pol)
sat_obj, _ = Satellite.objects.get_or_create(
name=name, # Ищем спутник по имени или альтернативному имени
defaults={ sat_obj = find_satellite_by_name(main_name)
"norad": int(norad[0]) if norad else -1, if not sat_obj:
"international_code": intl_code[0] if intl_code else "", # Если не найден, создаём новый с альтернативным именем
"undersat_point": sub_sat_point[0 if sub_sat_point else ""] sat_obj = Satellite.objects.create(
}) name=main_name,
alternative_name=alt_name,
norad=int(norad[0]) if norad else -1,
international_code=intl_code[0] if intl_code else "",
undersat_point=float(sub_sat_point[0]) if sub_sat_point else None
)
else:
# Если найден, обновляем альтернативное имя если не установлено
updated = False
if alt_name and not sat_obj.alternative_name:
sat_obj.alternative_name = alt_name
updated = True
if norad and not sat_obj.norad:
sat_obj.norad = int(norad[0])
updated = True
if intl_code and not sat_obj.international_code:
sat_obj.international_code = intl_code[0]
updated = True
if sub_sat_point and not sat_obj.undersat_point:
sat_obj.undersat_point = float(sub_sat_point[0])
updated = True
if updated:
sat_obj.save()
trans_obj, created = Transponders.objects.get_or_create( trans_obj, created = Transponders.objects.get_or_create(
polarization=pol_obj, polarization=pol_obj,
downlink=(downlink_start+downlink_end)/2/1000000, downlink=(downlink_start+downlink_end)/2/1000000,

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"