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}")
# Находим спутник в базе
# Находим спутник в базе по имени или альтернативному имени (lowercase)
from django.db.models import Q
sat_name_lower = sat_name.lower()
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})")
except Satellite.DoesNotExist:
error_msg = f"Спутник '{sat_name}' не найден в базе данных"

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,8 +43,13 @@ function showSatelliteModal(satelliteId) {
'<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-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">NORAD ID:</td><td>' + data.norad + '</td></tr>' +
'<tr><td class="text-muted" style="width: 40%;">Название:</td><td><strong>' + data.name + '</strong></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>' + data.bands + '</td></tr>' +
'</tbody></table></div></div></div>' +

View File

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

View File

@@ -86,6 +86,25 @@
</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="mb-3">
<label for="{{ form.norad.id_for_label }}" class="form-label">
@@ -102,9 +121,7 @@
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.international_code.id_for_label }}" class="form-label">
@@ -121,7 +138,9 @@
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.undersat_point.id_for_label }}" class="form-label">
@@ -138,6 +157,23 @@
{% endif %}
</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 class="row">
@@ -160,24 +196,7 @@
</div>
<div class="row">
<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 class="col-md-6">
<div class="col-md-12">
<div class="mb-3">
<label for="{{ form.url.id_for_label }}" class="form-label">
{{ form.url.label }}

View File

@@ -190,6 +190,16 @@
{% endif %}
</a>
</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;">
<a href="javascript:void(0)" onclick="updateSort('norad')" class="text-white text-decoration-none">
NORAD ID
@@ -274,6 +284,7 @@
</td>
<td class="text-center">{{ satellite.id }}</td>
<td>{{ satellite.name }}</td>
<td>{{ satellite.alternative_name|default:"-" }}</td>
<td>{{ satellite.norad }}</td>
<td>{{ satellite.international_code|default:"-" }}</td>
<td>{{ satellite.bands }}</td>
@@ -307,7 +318,7 @@
</tr>
{% empty %}
<tr>
<td colspan="13" class="text-center text-muted">Нет данных для отображения</td>
<td colspan="14" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>

View File

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

View File

@@ -7,6 +7,7 @@ from datetime import datetime, time
# Django imports
from django.contrib.gis.geos import Point
from django.db.models import F
from django.utils import timezone
# Third-party imports
import pandas as pd
@@ -137,7 +138,7 @@ def find_mirror_satellites(mirror_names: list) -> list:
Алгоритм:
1. Для каждого имени зеркала:
- Обрезать пробелы и привести к нижнему регистру
- Найти все спутники, в имени которых содержится это имя
- Найти все спутники, в имени или альтернативном имени которых содержится это имя
2. Вернуть список найденных спутников
Args:
@@ -146,6 +147,8 @@ def find_mirror_satellites(mirror_names: list) -> list:
Returns:
list: список объектов Satellite
"""
from django.db.models import Q
found_satellites = []
for mirror_name in mirror_names:
@@ -158,9 +161,9 @@ def find_mirror_satellites(mirror_names: list) -> list:
if not mirror_name_clean:
continue
# Ищем спутники, в имени которых содержится имя зеркала
# Ищем спутники, в имени или альтернативном имени которых содержится имя зеркала
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)
@@ -691,6 +694,10 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
"mir_1",
"mir_2",
"mir_3",
"mir_4",
"mir_5",
"mir_6",
"mir_7",
],
)
df[["lat", "lon", "freq", "f_range"]] = (
@@ -719,7 +726,7 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
sat_name = row["sat"]
# Извлекаем время для проверки дубликатов
timestamp = row["time"]
timestamp = timezone.make_aware(row["time"])
# Проверяем дубликаты по координатам и времени
if _is_duplicate_by_coords_and_time(coord_tuple, timestamp):
@@ -727,10 +734,13 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
continue
# Получаем или создаем объект спутника
sat_obj, _ = Satellite.objects.get_or_create(
name=sat_name, defaults={"norad": row["norad_id"]}
)
# sat_obj, _ = Satellite.objects.get_or_create(
# name=sat_name, defaults={"norad": row["norad_id"]}
# )
sat_obj, _ = Satellite.objects.get_or_create(
norad=row["norad_id"], defaults={"name": sat_name}
)
source = None
# Если is_automatic=False, работаем с Source
@@ -784,7 +794,7 @@ def get_points_from_csv(file_content, current_user=None, is_automatic=False):
return new_sources_count
def _is_duplicate_by_coords_and_time(coord_tuple, timestamp, tolerance_km=0.1):
def _is_duplicate_by_coords_and_time(coord_tuple, timestamp, tolerance_km=0.001):
"""
Проверяет, существует ли уже ObjItem с такими же координатами и временем ГЛ.
@@ -934,7 +944,7 @@ def _create_objitem_from_csv_row(row, source, user_to_use, is_automatic=False):
# Создаем Geo объект
geo_obj, _ = Geo.objects.get_or_create(
timestamp=row["time"],
timestamp=timezone.make_aware(row["time"]),
coords=Point(row["lon"], row["lat"], srid=4326),
defaults={
"is_average": False,
@@ -1259,6 +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]:
"""
@@ -1279,6 +1445,23 @@ def calculate_mean_coords(coord1: tuple, coord2: tuple) -> tuple[tuple, float]:
return (geod_direct['lon2'], geod_direct['lat2']), distance/1000
def calculate_distance_wgs84(coord1: tuple, coord2: tuple) -> float:
"""
Вычисляет расстояние между двумя точками в WGS84 (в километрах).
Args:
coord1: Первая точка (longitude, latitude)
coord2: Вторая точка (longitude, latitude)
Returns:
float: Расстояние в километрах
"""
lon1, lat1 = coord1
lon2, lat2 = coord2
geod_inv = Geodesic.WGS84.Inverse(lat1, lon1, lat2, lon2)
return geod_inv['s12'] / 1000
def calculate_average_coords_incremental(
current_average: tuple, new_coord: tuple

View File

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

View File

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

View File

@@ -5,12 +5,99 @@ from io import BytesIO
# Third-party imports
import requests
from django.db.models import Q
# Local imports
from mainapp.models import Polarization, Satellite
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):
for pos, value in data.get('page', {}).get('positions').items():
for name in value['satellites']:
@@ -82,13 +169,19 @@ def parse_transponders_from_json(filepath: str, user=None):
"""
Парсит транспондеры из JSON файла.
Если имя спутника содержит альтернативное имя в скобках, оно извлекается
и сохраняется в поле alternative_name.
Args:
filepath: путь к JSON файлу
user: пользователь для установки created_by и updated_by (optional)
"""
with open(filepath, encoding="utf-8") as 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 tran in trans:
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)
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(
name=tran["name"],
@@ -124,6 +229,9 @@ def parse_transponders_from_xml(data_in: BytesIO, user=None):
"""
Парсит транспондеры из XML файла.
Если имя спутника содержит альтернативное имя в скобках, оно извлекается
и сохраняется в поле alternative_name.
Args:
data_in: BytesIO объект с XML данными
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)
for sat in satellites[:]:
name = sat.xpath('./ns:name/text()', namespaces=ns)[0]
if name == 'X' or 'DONT USE' in name:
name_full = sat.xpath('./ns:name/text()', namespaces=ns)[0]
if name_full == 'X' or 'DONT USE' in name_full:
continue
# Парсим имя спутника и альтернативное имя
main_name, alt_name = parse_satellite_name(name_full)
norad = sat.xpath('./ns:norad/text()', namespaces=ns)
beams = sat.xpath('.//ns:BeamMemo', 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_end = float(transponder.xpath('./ns:uplinkFrequency/tr:end/text()', namespaces=ns)[0])
tr_data = zones[tr_id]
# p = tr_data['pol'][0] if tr_data['pol'] else '-'
match tr_data['pol']:
case 'Horizontal':
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]
pol_obj, _ = Polarization.objects.get_or_create(name=pol)
sat_obj, _ = Satellite.objects.get_or_create(
name=name,
defaults={
"norad": int(norad[0]) if norad else -1,
"international_code": intl_code[0] if intl_code else "",
"undersat_point": sub_sat_point[0 if sub_sat_point else ""]
})
# Ищем спутник по имени или альтернативному имени
sat_obj = find_satellite_by_name(main_name)
if not sat_obj:
# Если не найден, создаём новый с альтернативным именем
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(
polarization=pol_obj,
downlink=(downlink_start+downlink_end)/2/1000000,

View File

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

49
dbapp/uv.lock generated
View File

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