From 50498166e5a610228ec4fe0390088ffa95d6804f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=88=D0=BA=D0=B8=D0=BD=20=D0=A1=D0=B5=D1=80?= =?UTF-8?q?=D0=B3=D0=B5=D0=B9?= Date: Wed, 12 Nov 2025 23:49:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B8=D1=81=D1=87=D1=82=D0=BE=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8E=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B0=20=D0=B8=D0=B7=20Excel=20c?= =?UTF-8?q?sv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dbapp/fix_objitems_without_source.py | 99 +++ ...s_average_alter_objitem_source_and_more.py | 31 + .../templates/mainapp/source_list.html | 453 +++++++++++ dbapp/mainapp/urls.py | 3 +- dbapp/mainapp/utils.py | 742 +++++++++++++----- dbapp/mainapp/views.py | 278 ++++++- 6 files changed, 1395 insertions(+), 211 deletions(-) create mode 100644 dbapp/fix_objitems_without_source.py create mode 100644 dbapp/mainapp/migrations/0003_source_coords_average_alter_objitem_source_and_more.py create mode 100644 dbapp/mainapp/templates/mainapp/source_list.html diff --git a/dbapp/fix_objitems_without_source.py b/dbapp/fix_objitems_without_source.py new file mode 100644 index 0000000..c1a6aea --- /dev/null +++ b/dbapp/fix_objitems_without_source.py @@ -0,0 +1,99 @@ +""" +Скрипт для исправления ObjItems без связи с Source. + +Для каждого ObjItem без source: +1. Получить координаты из geo_obj +2. Найти ближайший Source (по coords_average) +3. Если расстояние <= 0.5 градуса, связать ObjItem с этим Source +4. Иначе создать новый Source с coords_average = координаты geo_obj +""" + +import os +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dbapp.settings") +django.setup() + +from mainapp.models import ObjItem, Source, CustomUser +from django.contrib.gis.geos import Point +from django.contrib.gis.measure import D +from django.contrib.gis.db.models.functions import Distance + + +def calculate_distance_degrees(coord1, coord2): + """Вычисляет расстояние между двумя координатами в градусах.""" + import math + + lon1, lat1 = coord1 + lon2, lat2 = coord2 + + return math.sqrt((lon2 - lon1) ** 2 + (lat2 - lat1) ** 2) + + +def fix_objitems_without_source(): + """Исправляет ObjItems без связи с Source.""" + + # Получаем пользователя по умолчанию + default_user = CustomUser.objects.get(id=1) + + # Получаем все ObjItems без source + objitems_without_source = ObjItem.objects.filter(source__isnull=True) + total_count = objitems_without_source.count() + + print(f"Найдено {total_count} ObjItems без source") + + if total_count == 0: + print("Нечего исправлять!") + return + + fixed_count = 0 + new_sources_count = 0 + + for objitem in objitems_without_source: + # Проверяем, есть ли geo_obj + if not hasattr(objitem, 'geo_obj') or not objitem.geo_obj or not objitem.geo_obj.coords: + print(f"ObjItem {objitem.id} не имеет geo_obj или координат, пропускаем") + continue + + geo_coords = objitem.geo_obj.coords + coord_tuple = (geo_coords.x, geo_coords.y) + + # Ищем ближайший Source + sources_with_coords = Source.objects.filter(coords_average__isnull=False) + + closest_source = None + min_distance = float('inf') + + for source in sources_with_coords: + source_coord = (source.coords_average.x, source.coords_average.y) + distance = calculate_distance_degrees(coord_tuple, source_coord) + + if distance < min_distance: + min_distance = distance + closest_source = source + + # Если нашли близкий Source (расстояние <= 0.5 градуса) + if closest_source and min_distance <= 0.5: + objitem.source = closest_source + objitem.save() + print(f"ObjItem {objitem.id} связан с Source {closest_source.id} (расстояние: {min_distance:.4f}°)") + fixed_count += 1 + else: + # Создаем новый Source + new_source = Source.objects.create( + coords_average=Point(coord_tuple, srid=4326), + created_by=default_user + ) + objitem.source = new_source + objitem.save() + print(f"ObjItem {objitem.id} связан с новым Source {new_source.id}") + fixed_count += 1 + new_sources_count += 1 + + print(f"\nГотово!") + print(f"Исправлено ObjItems: {fixed_count}") + print(f"Создано новых Source: {new_sources_count}") + + +if __name__ == "__main__": + fix_objitems_without_source() diff --git a/dbapp/mainapp/migrations/0003_source_coords_average_alter_objitem_source_and_more.py b/dbapp/mainapp/migrations/0003_source_coords_average_alter_objitem_source_and_more.py new file mode 100644 index 0000000..a66aa0a --- /dev/null +++ b/dbapp/mainapp/migrations/0003_source_coords_average_alter_objitem_source_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.7 on 2025-11-12 19:41 + +import django.contrib.gis.db.models.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0002_initial'), + ('mapsapp', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='source', + name='coords_average', + field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты, полученные от в ходе геолокации (WGS84)', null=True, srid=4326, verbose_name='Координаты ГЛ'), + ), + migrations.AlterField( + model_name='objitem', + name='source', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_objitems', to='mainapp.source', verbose_name='ИРИ'), + ), + migrations.AlterField( + model_name='objitem', + name='transponder', + field=models.ForeignKey(blank=True, help_text='Транспондер, с помощью которого была получена точка', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transponder_objitems', to='mapsapp.transponders', verbose_name='Транспондер'), + ), + ] diff --git a/dbapp/mainapp/templates/mainapp/source_list.html b/dbapp/mainapp/templates/mainapp/source_list.html new file mode 100644 index 0000000..bf04666 --- /dev/null +++ b/dbapp/mainapp/templates/mainapp/source_list.html @@ -0,0 +1,453 @@ +{% extends 'mainapp/base.html' %} + +{% block title %}Список источников{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+

Список источников

+
+
+ + +
+
+
+
+
+ +
+
+ + + +
+
+ + +
+ + +
+ + +
+ +
+ + +
+ {% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %} +
+
+
+
+
+
+ + +
+
+
Фильтры
+ +
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + Сбросить +
+
+
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + {% for source in processed_sources %} + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
+ + ID + {% if sort == 'id' %} + + {% elif sort == '-id' %} + + {% endif %} + + Усредненные координатыКоординаты КубсатаКоординаты оперативниковКоординаты справочные + + Кол-во ObjItem + {% if sort == 'objitem_count' %} + + {% elif sort == '-objitem_count' %} + + {% endif %} + + + + Создано + {% if sort == 'created_at' %} + + {% elif sort == '-created_at' %} + + {% endif %} + + + + Обновлено + {% if sort == 'updated_at' %} + + {% elif sort == '-updated_at' %} + + {% endif %} + + Детали
{{ source.id }}{{ source.coords_average }}{{ source.coords_kupsat }}{{ source.coords_valid }}{{ source.coords_reference }}{{ source.objitem_count }}{{ source.created_at|date:"d.m.Y H:i" }}{{ source.updated_at|date:"d.m.Y H:i" }} + +
Нет данных для отображения
+
+
+
+
+
+
+ + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/dbapp/mainapp/urls.py b/dbapp/mainapp/urls.py index 0808daf..0e1dbee 100644 --- a/dbapp/mainapp/urls.py +++ b/dbapp/mainapp/urls.py @@ -6,7 +6,7 @@ from . import views app_name = 'mainapp' urlpatterns = [ - path('', views.HomePageView.as_view(), name='home'), # Home page that redirects based on auth + path('', views.SourceListView.as_view(), name='home'), # Source list page path('objitems/', views.ObjItemListView.as_view(), name='objitem_list'), # Objects list page path('actions/', views.ActionsPageView.as_view(), name='actions'), # Move actions to a separate page path('excel-data', views.LoadExcelDataView.as_view(), name='load_excel_data'), @@ -23,6 +23,7 @@ urlpatterns = [ path('link-lyngsat/', views.LinkLyngsatSourcesView.as_view(), name='link_lyngsat'), path('api/lyngsat//', views.LyngsatDataAPIView.as_view(), name='lyngsat_data_api'), path('api/sigma-parameter//', views.SigmaParameterDataAPIView.as_view(), name='sigma_parameter_data_api'), + path('api/source//objitems/', views.SourceObjItemsAPIView.as_view(), name='source_objitems_api'), path('kubsat-excel/', views.ProcessKubsatView.as_view(), name='kubsat_excel'), path('object/create/', views.ObjItemCreateView.as_view(), name='objitem_create'), path('object//edit/', views.ObjItemUpdateView.as_view(), name='objitem_update'), diff --git a/dbapp/mainapp/utils.py b/dbapp/mainapp/utils.py index 2942aef..6737264 100644 --- a/dbapp/mainapp/utils.py +++ b/dbapp/mainapp/utils.py @@ -24,6 +24,7 @@ from .models import ( Polarization, Satellite, SigmaParameter, + Source, Standard, ) @@ -78,139 +79,233 @@ def remove_str(s: str): def fill_data_from_df(df: pd.DataFrame, sat: Satellite, current_user=None): + """ + Импортирует данные из DataFrame с группировкой близких координат. + + Алгоритм: + 1. Извлечь все координаты и данные строк из DataFrame + 2. Создать список необработанных записей (координата + данные строки) + 3. Пока список не пуст: + a. Взять первую запись из списка + b. Создать новый Source с coords_average = эта координата + c. Создать ObjItem для этой записи и связать с Source + d. Удалить запись из списка + e. Для каждой оставшейся записи в списке: + - Вычислить расстояние от её координаты до coords_average + - Если расстояние <= 0.5 градуса: + * Вычислить новое среднее ИНКРЕМЕНТАЛЬНО: + new_avg = (coords_average + current_coord) / 2 + * Обновить coords_average в Source + * Создать ObjItem для этой записи и связать с Source + * Удалить запись из списка + - Иначе: пропустить и проверить следующую запись + 4. Сохранить все изменения в БД + + Важно: Среднее вычисляется инкрементально - каждая новая точка + усредняется с текущим средним, а не со всеми точками кластера. + + Args: + df: DataFrame с данными + sat: объект Satellite + current_user: текущий пользователь (optional) + + Returns: + int: количество созданных Source + """ try: df.rename(columns={"Модуляция ": "Модуляция"}, inplace=True) except Exception as e: print(e) + consts = get_all_constants() df.fillna(-1, inplace=True) - for stroka in df.iterrows(): - geo_point = Point(coords_transform(stroka[1]["Координаты"]), srid=4326) - valid_point = None - kupsat_point = None + + # Шаг 1: Извлечь все координаты и данные строк из DataFrame + unprocessed_records = [] + + for idx, row in df.iterrows(): try: - if ( - stroka[1]["Координаты объекта"] != -1 - and stroka[1]["Координаты Кубсата"] != "+" - ): - if ( - "ИРИ" not in stroka[1]["Координаты объекта"] - and "БЛА" not in stroka[1]["Координаты объекта"] - ): - valid_point = list( - map( - float, - stroka[1]["Координаты объекта"] - .replace(",", ".") - .split(". "), - ) - ) - valid_point = Point(valid_point[1], valid_point[0], srid=4326) - if ( - stroka[1]["Координаты Кубсата"] != -1 - and stroka[1]["Координаты Кубсата"] != "+" - ): - kupsat_point = list( - map( - float, - stroka[1]["Координаты Кубсата"].replace(",", ".").split(". "), - ) + # Извлекаем координату + coord_tuple = coords_transform(row["Координаты"]) + + # Сохраняем запись с координатой и данными строки + unprocessed_records.append({"coord": coord_tuple, "row": row, "index": idx}) + except Exception as e: + print(f"Ошибка при обработке строки {idx}: {e}") + continue + + user_to_use = current_user if current_user else CustomUser.objects.get(id=1) + source_count = 0 + + # Шаг 3: Цикл обработки пока список не пуст + while unprocessed_records: + # Шаг 3a: Взять первую запись из списка + first_record = unprocessed_records.pop(0) + first_coord = first_record["coord"] + + # Шаг 3b: Создать новый Source с coords_average = эта координата + source = Source.objects.create( + coords_average=Point(first_coord, srid=4326), created_by=user_to_use + ) + source_count += 1 + + # Шаг 3c: Создать ObjItem для этой записи и связать с Source + _create_objitem_from_row(first_record["row"], sat, source, user_to_use, consts) + + # Шаг 3e: Для каждой оставшейся записи в списке + records_to_remove = [] + + for i, record in enumerate(unprocessed_records): + current_coord = record["coord"] + + # Вычислить расстояние от координаты до coords_average + current_avg = (source.coords_average.x, source.coords_average.y) + distance = calculate_distance_degrees(current_avg, current_coord) + + # Если расстояние <= 0.5 градуса + if distance <= 0.5: + # Вычислить новое среднее ИНКРЕМЕНТАЛЬНО + new_avg = calculate_average_coords_incremental( + current_avg, current_coord ) - kupsat_point = Point(kupsat_point[1], kupsat_point[0], srid=4326) - except KeyError: - print("В таблице нет столбцов с координатами кубсата") - try: - polarization_obj, _ = Polarization.objects.get_or_create( - name=stroka[1]["Поляризация"].strip() - ) - except KeyError: - polarization_obj, _ = Polarization.objects.get_or_create(name="-") - freq = remove_str(stroka[1]["Частота, МГц"]) - freq_line = remove_str(stroka[1]["Полоса, МГц"]) - v = remove_str(stroka[1]["Символьная скорость, БОД"]) - try: - mod_obj, _ = Modulation.objects.get_or_create( - name=stroka[1]["Модуляция"].strip() - ) - except AttributeError: - mod_obj, _ = Modulation.objects.get_or_create(name="-") - snr = remove_str(stroka[1]["ОСШ"]) - date = stroka[1]["Дата"].date() - time_ = stroka[1]["Время"] - if isinstance(time_, str): - time_ = time_.strip() - time_ = time(0, 0, 0) - timestamp = datetime.combine(date, time_) - current_mirrors = [] - mirror_1 = stroka[1]["Зеркало 1"].strip().split("\n") - mirror_2 = stroka[1]["Зеркало 2"].strip().split("\n") - if len(mirror_1) > 1: - for mir in mirror_1: - mir_obj, _ = Mirror.objects.get_or_create(name=mir.strip()) - current_mirrors.append(mir.strip()) - elif mirror_1[0] not in consts[3]: - mir_obj, _ = Mirror.objects.get_or_create(name=mirror_1[0].strip()) - current_mirrors.append(mirror_1[0].strip()) - if len(mirror_2) > 1: - for mir in mirror_2: - mir_obj, _ = Mirror.objects.get_or_create(name=mir.strip()) - current_mirrors.append(mir.strip()) - elif mirror_2[0] not in consts[3]: - mir_obj, _ = Mirror.objects.get_or_create(name=mirror_2[0].strip()) - current_mirrors.append(mirror_2[0].strip()) - location = stroka[1]["Местоопределение"].strip() - comment = stroka[1]["Комментарий"] - source = stroka[1]["Объект наблюдения"] - user_to_use = current_user if current_user else CustomUser.objects.get(id=1) - geo, _ = Geo.objects.get_or_create( - timestamp=timestamp, - coords=geo_point, - defaults={ - "coords_kupsat": kupsat_point, - "coords_valid": valid_point, - "location": location, - "comment": comment, - "is_average": (comment != -1.0), - }, - ) - geo.save() - geo.mirrors.set(Mirror.objects.filter(name__in=current_mirrors)) + # Обновить coords_average в Source + source.coords_average = Point(new_avg, srid=4326) + source.save() - # Check if ObjItem with same geo already exists - existing_obj_item = ObjItem.objects.filter(geo_obj=geo).first() - if existing_obj_item: - # Check if parameter with same values exists for this object - if ( - hasattr(existing_obj_item, 'parameter_obj') and - existing_obj_item.parameter_obj and - existing_obj_item.parameter_obj.id_satellite == sat and - existing_obj_item.parameter_obj.polarization == polarization_obj and - existing_obj_item.parameter_obj.frequency == freq and - existing_obj_item.parameter_obj.freq_range == freq_line and - existing_obj_item.parameter_obj.bod_velocity == v and - existing_obj_item.parameter_obj.modulation == mod_obj and - existing_obj_item.parameter_obj.snr == snr - ): - # Skip creating duplicate - continue - - # Create new ObjItem and Parameter - obj_item = ObjItem.objects.create(name=source, created_by=user_to_use) - - vch_load_obj = Parameter.objects.create( - id_satellite=sat, - polarization=polarization_obj, - frequency=freq, - freq_range=freq_line, - bod_velocity=v, - modulation=mod_obj, - snr=snr, - objitem=obj_item + # Создать ObjItem для этой записи и связать с Source + _create_objitem_from_row( + record["row"], sat, source, user_to_use, consts + ) + + # Пометить запись для удаления + records_to_remove.append(i) + + # Удалить обработанные записи из списка (в обратном порядке, чтобы не сбить индексы) + for i in reversed(records_to_remove): + unprocessed_records.pop(i) + + return source_count + + +def _create_objitem_from_row(row, sat, source, user_to_use, consts): + """ + Вспомогательная функция для создания ObjItem из строки DataFrame. + + Args: + row: строка DataFrame + sat: объект Satellite + source: объект Source для связи + user_to_use: пользователь для created_by + consts: константы из get_all_constants() + """ + # Извлекаем координату + geo_point = Point(coords_transform(row["Координаты"]), srid=4326) + + # Обработка поляризации + try: + polarization_obj, _ = Polarization.objects.get_or_create( + name=row["Поляризация"].strip() ) - - geo.objitem = obj_item - geo.save() + except KeyError: + polarization_obj, _ = Polarization.objects.get_or_create(name="-") + + # Обработка ВЧ параметров + freq = remove_str(row["Частота, МГц"]) + freq_line = remove_str(row["Полоса, МГц"]) + v = remove_str(row["Символьная скорость, БОД"]) + + try: + mod_obj, _ = Modulation.objects.get_or_create(name=row["Модуляция"].strip()) + except AttributeError: + mod_obj, _ = Modulation.objects.get_or_create(name="-") + + snr = remove_str(row["ОСШ"]) + + # Обработка времени + date = row["Дата"].date() + time_ = row["Время"] + if isinstance(time_, str): + time_ = time_.strip() + time_ = time(0, 0, 0) + timestamp = datetime.combine(date, time_) + + # Обработка зеркал + current_mirrors = [] + mirror_1 = row["Зеркало 1"].strip().split("\n") + mirror_2 = row["Зеркало 2"].strip().split("\n") + + if len(mirror_1) > 1: + for mir in mirror_1: + Mirror.objects.get_or_create(name=mir.strip()) + current_mirrors.append(mir.strip()) + elif mirror_1[0] not in consts[3]: + Mirror.objects.get_or_create(name=mirror_1[0].strip()) + current_mirrors.append(mirror_1[0].strip()) + + if len(mirror_2) > 1: + for mir in mirror_2: + Mirror.objects.get_or_create(name=mir.strip()) + current_mirrors.append(mir.strip()) + elif mirror_2[0] not in consts[3]: + Mirror.objects.get_or_create(name=mirror_2[0].strip()) + current_mirrors.append(mirror_2[0].strip()) + + location = row["Местоопределение"].strip() + comment = row["Комментарий"] + source_name = row["Объект наблюдения"] + + # Создаем Geo объект (БЕЗ coords_kupsat и coords_valid) + geo, _ = Geo.objects.get_or_create( + timestamp=timestamp, + coords=geo_point, + defaults={ + "location": location, + "comment": comment, + "is_average": (comment != -1.0), + }, + ) + geo.save() + geo.mirrors.set(Mirror.objects.filter(name__in=current_mirrors)) + + # Проверяем, существует ли уже ObjItem с таким же geo + existing_obj_item = ObjItem.objects.filter(geo_obj=geo).first() + if existing_obj_item: + # Проверяем, существует ли parameter с такими же значениями + if ( + hasattr(existing_obj_item, "parameter_obj") + and existing_obj_item.parameter_obj + and existing_obj_item.parameter_obj.id_satellite == sat + and existing_obj_item.parameter_obj.polarization == polarization_obj + and existing_obj_item.parameter_obj.frequency == freq + and existing_obj_item.parameter_obj.freq_range == freq_line + and existing_obj_item.parameter_obj.bod_velocity == v + and existing_obj_item.parameter_obj.modulation == mod_obj + and existing_obj_item.parameter_obj.snr == snr + ): + # Пропускаем создание дубликата + return + + # Создаем новый ObjItem и связываем с Source + obj_item = ObjItem.objects.create( + name=source_name, source=source, created_by=user_to_use + ) + + # Создаем Parameter + Parameter.objects.create( + id_satellite=sat, + polarization=polarization_obj, + frequency=freq, + freq_range=freq_line, + bod_velocity=v, + modulation=mod_obj, + snr=snr, + objitem=obj_item, + ) + + # Связываем geo с objitem + geo.objitem = obj_item + geo.save() def add_satellite_list(): @@ -281,6 +376,33 @@ def get_point_from_json(filepath: str): def get_points_from_csv(file_content, current_user=None): + """ + Импортирует данные из CSV с группировкой близких координат. + + Улучшенный алгоритм с учетом существующих Source: + 1. Извлечь все координаты и данные строк из DataFrame + 2. Создать список необработанных записей (координата + данные строки) + 3. Получить все существующие Source из БД + 4. Для каждой записи: + a. Проверить, существует ли дубликат (координаты + частота) + b. Если дубликат найден, пропустить запись + c. Найти ближайший существующий Source (расстояние <= 0.5 градуса) + d. Если найден: + - Обновить coords_average этого Source (инкрементально) + - Создать ObjItem и связать с этим Source + e. Если не найден: + - Создать новый Source + - Создать ObjItem и связать с новым Source + - Добавить новый Source в список существующих + 5. Сохранить все изменения в БД + + Args: + file_content: содержимое CSV файла + current_user: текущий пользователь (optional) + + Returns: + int: количество созданных Source + """ df = pd.read_csv( io.StringIO(file_content), sep=";", @@ -308,68 +430,196 @@ def get_points_from_csv(file_content, current_user=None): .astype(float) ) df["time"] = pd.to_datetime(df["time"], format="%d.%m.%Y %H:%M:%S") - for row in df.iterrows(): - row = row[1] - match row["obj"].split(" ")[-1]: - case "V": - pol = "Вертикальная" - case "H": - pol = "Горизонтальная" - case "R": - pol = "Правая" - case "L": - pol = "Левая" - case _: - pol = "-" - pol_obj, _ = Polarization.objects.get_or_create(name=pol) - sat_obj, _ = Satellite.objects.get_or_create( - name=row["sat"], defaults={"norad": row["norad_id"]} - ) - mir_1_obj, _ = Mirror.objects.get_or_create(name=row["mir_1"]) - mir_2_obj, _ = Mirror.objects.get_or_create(name=row["mir_2"]) - mir_lst = [row["mir_1"], row["mir_2"]] - if not pd.isna(row["mir_3"]): - mir_3_obj, _ = Mirror.objects.get_or_create(name=row["mir_3"]) - user_to_use = current_user if current_user else CustomUser.objects.get(id=1) - geo_obj, _ = Geo.objects.get_or_create( - timestamp=row["time"], - coords=Point(row["lon"], row["lat"], srid=4326), - defaults={ - "is_average": False, - # 'id_user_add': user_to_use, - }, - ) - geo_obj.mirrors.set(Mirror.objects.filter(name__in=mir_lst)) + # Шаг 1: Извлечь все координаты и данные строк из DataFrame + records = [] - # Check if ObjItem with same geo already exists - existing_obj_item = ObjItem.objects.filter(geo_obj=geo_obj).first() - if existing_obj_item: - # Check if parameter with same values exists for this object - if ( - hasattr(existing_obj_item, 'parameter_obj') and - existing_obj_item.parameter_obj and - existing_obj_item.parameter_obj.id_satellite == sat_obj and - existing_obj_item.parameter_obj.polarization == pol_obj and - existing_obj_item.parameter_obj.frequency == row["freq"] and - existing_obj_item.parameter_obj.freq_range == row["f_range"] - ): - # Skip creating duplicate - continue + for idx, row in df.iterrows(): + try: + # Извлекаем координату из колонок lat и lon + coord_tuple = (row["lon"], row["lat"]) + + # Сохраняем запись с координатой и данными строки + records.append({"coord": coord_tuple, "row": row, "index": idx}) + except Exception as e: + print(f"Ошибка при обработке строки {idx}: {e}") + continue + + user_to_use = current_user if current_user else CustomUser.objects.get(id=1) + + # Шаг 3: Получить все существующие Source из БД + existing_sources = list(Source.objects.filter(coords_average__isnull=False)) + new_sources_count = 0 + added_count = 0 + skipped_count = 0 + + # Шаг 4: Обработка каждой записи + for record in records: + current_coord = record["coord"] + row = record["row"] - # Create new ObjItem and Parameter - obj_item = ObjItem.objects.create(name=row["obj"], created_by=user_to_use) + # Шаг 4a: Проверить, существует ли дубликат (координаты + частота) + if _is_duplicate_objitem(current_coord, row["freq"], row["f_range"]): + skipped_count += 1 + continue - vch_load_obj = Parameter.objects.create( - id_satellite=sat_obj, - polarization=pol_obj, - frequency=row["freq"], - freq_range=row["f_range"], - objitem=obj_item - ) + # Шаг 4c: Найти ближайший существующий Source + closest_source = None + min_distance = float('inf') - geo_obj.objitem = obj_item - geo_obj.save() + for source in existing_sources: + source_coord = (source.coords_average.x, source.coords_average.y) + distance = calculate_distance_degrees(source_coord, current_coord) + + if distance < min_distance: + min_distance = distance + closest_source = source + + # Шаг 4d: Если найден близкий Source (расстояние <= 0.5 градуса) + if closest_source and min_distance <= 0.5: + # Обновить coords_average инкрементально + current_avg = (closest_source.coords_average.x, closest_source.coords_average.y) + new_avg = calculate_average_coords_incremental(current_avg, current_coord) + closest_source.coords_average = Point(new_avg, srid=4326) + closest_source.save() + + # Создать ObjItem и связать с существующим Source + _create_objitem_from_csv_row(row, closest_source, user_to_use) + added_count += 1 + else: + # Шаг 4e: Создать новый Source + new_source = Source.objects.create( + coords_average=Point(current_coord, srid=4326), + created_by=user_to_use + ) + new_sources_count += 1 + + # Создать ObjItem и связать с новым Source + _create_objitem_from_csv_row(row, new_source, user_to_use) + added_count += 1 + + # Добавить новый Source в список существующих + existing_sources.append(new_source) + + print(f"Импорт завершен: создано {new_sources_count} новых источников, " + f"добавлено {added_count} точек, пропущено {skipped_count} дубликатов") + + return new_sources_count + + +def _is_duplicate_objitem(coord_tuple, frequency, freq_range, tolerance=0.001): + """ + Проверяет, существует ли уже ObjItem с такими же координатами и частотой. + + Args: + coord_tuple: кортеж (lon, lat) координат + frequency: частота в МГц + freq_range: полоса частот в МГц + tolerance: допуск для сравнения координат в градусах (по умолчанию 0.001 ≈ 100м) + + Returns: + bool: True если дубликат найден, False иначе + """ + # Ищем ObjItems с близкими координатами через geo_obj + nearby_objitems = ObjItem.objects.filter( + geo_obj__coords__isnull=False + ).select_related('parameter_obj', 'geo_obj') + + for objitem in nearby_objitems: + if not objitem.geo_obj or not objitem.geo_obj.coords: + continue + + # Проверяем расстояние между координатами + geo_coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y) + distance = calculate_distance_degrees(coord_tuple, geo_coord) + + if distance <= tolerance: + # Координаты совпадают, проверяем частоту + if hasattr(objitem, 'parameter_obj') and objitem.parameter_obj: + param = objitem.parameter_obj + # Проверяем совпадение частоты с небольшим допуском (0.1 МГц) + if (abs(param.frequency - frequency) < 0.1 and + abs(param.freq_range - freq_range) < 0.1): + return True + + return False + + +def _create_objitem_from_csv_row(row, source, user_to_use): + """ + Вспомогательная функция для создания ObjItem из строки CSV DataFrame. + + Args: + row: строка DataFrame + source: объект Source для связи + user_to_use: пользователь для created_by + """ + # Определяем поляризацию + match row["obj"].split(" ")[-1]: + case "V": + pol = "Вертикальная" + case "H": + pol = "Горизонтальная" + case "R": + pol = "Правая" + case "L": + pol = "Левая" + case _: + pol = "-" + + pol_obj, _ = Polarization.objects.get_or_create(name=pol) + sat_obj, _ = Satellite.objects.get_or_create( + name=row["sat"], defaults={"norad": row["norad_id"]} + ) + mir_1_obj, _ = Mirror.objects.get_or_create(name=row["mir_1"]) + mir_2_obj, _ = Mirror.objects.get_or_create(name=row["mir_2"]) + mir_lst = [row["mir_1"], row["mir_2"]] + if not pd.isna(row["mir_3"]): + mir_3_obj, _ = Mirror.objects.get_or_create(name=row["mir_3"]) + mir_lst.append(row["mir_3"]) + + # Создаем Geo объект (БЕЗ coords_kupsat и coords_valid) + geo_obj, _ = Geo.objects.get_or_create( + timestamp=row["time"], + coords=Point(row["lon"], row["lat"], srid=4326), + defaults={ + "is_average": False, + }, + ) + geo_obj.mirrors.set(Mirror.objects.filter(name__in=mir_lst)) + + # Проверяем, существует ли уже ObjItem с таким же geo + existing_obj_item = ObjItem.objects.filter(geo_obj=geo_obj).first() + if existing_obj_item: + # Проверяем, существует ли parameter с такими же значениями + if ( + hasattr(existing_obj_item, "parameter_obj") + and existing_obj_item.parameter_obj + and existing_obj_item.parameter_obj.id_satellite == sat_obj + and existing_obj_item.parameter_obj.polarization == pol_obj + and existing_obj_item.parameter_obj.frequency == row["freq"] + and existing_obj_item.parameter_obj.freq_range == row["f_range"] + ): + # Пропускаем создание дубликата + return + + # Создаем новый ObjItem и связываем с Source + obj_item = ObjItem.objects.create( + name=row["obj"], source=source, created_by=user_to_use + ) + + # Создаем Parameter + Parameter.objects.create( + id_satellite=sat_obj, + polarization=pol_obj, + frequency=row["freq"], + freq_range=row["f_range"], + objitem=obj_item, + ) + + # Связываем geo с objitem + geo_obj.objitem = obj_item + geo_obj.save() def get_vch_load_from_html(file, sat: Satellite) -> None: @@ -450,13 +700,13 @@ def get_vch_load_from_html(file, sat: Satellite) -> None: def get_frequency_tolerance_percent(freq_range_mhz: float) -> float: """ Определяет процент погрешности центральной частоты в зависимости от полосы частот. - + Args: freq_range_mhz (float): Полоса частот в МГц - + Returns: float: Процент погрешности для центральной частоты - + Диапазоны: - 0 - 0.5 МГц (0 - 500 кГц): 0.1% - 0.5 - 1.5 МГц (500 кГц - 1.5 МГц): 0.5% @@ -481,62 +731,66 @@ def compare_and_link_vch_load( ): """ Привязывает SigmaParameter к Parameter на основе совпадения параметров. - + Погрешность центральной частоты определяется автоматически в зависимости от полосы частот: - 0-500 кГц: 0.1% - 500 кГц-1.5 МГц: 0.5% - 1.5-5 МГц: 1% - 5-10 МГц: 2% - >10 МГц: 5% - + Args: sat_id (Satellite): Спутник для фильтрации eps_freq (float): Не используется (оставлен для обратной совместимости) eps_frange (float): Погрешность полосы частот в процентах ku_range (float): Не используется (оставлен для обратной совместимости) - + Returns: tuple: (количество объектов, количество привязок) """ # Получаем все ObjItem с Parameter для данного спутника item_obj = ObjItem.objects.filter( parameter_obj__id_satellite=sat_id - ).select_related('parameter_obj', 'parameter_obj__polarization') - - vch_sigma = SigmaParameter.objects.filter( - id_satellite=sat_id - ).select_related('polarization') - + ).select_related("parameter_obj", "parameter_obj__polarization") + + vch_sigma = SigmaParameter.objects.filter(id_satellite=sat_id).select_related( + "polarization" + ) + link_count = 0 obj_count = item_obj.count() - + for obj in item_obj: vch_load = obj.parameter_obj - + # Пропускаем объекты с некорректной частотой if not vch_load or vch_load.frequency == -1.0: continue - + # Определяем погрешность частоты на основе полосы freq_tolerance_percent = get_frequency_tolerance_percent(vch_load.freq_range) - + # Вычисляем допустимое отклонение частоты в МГц freq_tolerance_mhz = vch_load.frequency * freq_tolerance_percent / 100 - + # Вычисляем допустимое отклонение полосы в МГц frange_tolerance_mhz = vch_load.freq_range * eps_frange / 100 - + for sigma in vch_sigma: # Проверяем совпадение по всем параметрам - freq_match = abs(sigma.transfer_frequency - vch_load.frequency) <= freq_tolerance_mhz - frange_match = abs(sigma.freq_range - vch_load.freq_range) <= frange_tolerance_mhz + freq_match = ( + abs(sigma.transfer_frequency - vch_load.frequency) <= freq_tolerance_mhz + ) + frange_match = ( + abs(sigma.freq_range - vch_load.freq_range) <= frange_tolerance_mhz + ) pol_match = sigma.polarization == vch_load.polarization - + if freq_match and frange_match and pol_match: sigma.parameter = vch_load sigma.save() link_count += 1 - + return obj_count, link_count @@ -614,6 +868,92 @@ def kub_report(data_in: io.StringIO) -> pd.DataFrame: return df +# ============================================================================ +# Утилиты для работы с координатами +# ============================================================================ + + +def calculate_distance_degrees(coord1: tuple, coord2: tuple) -> float: + """ + Вычисляет расстояние между двумя координатами в градусах. + + Использует простую евклидову метрику для малых расстояний. + Подходит для определения близости точек в радиусе до нескольких градусов. + + Args: + coord1 (tuple): Первая координата в формате (longitude, latitude) + coord2 (tuple): Вторая координата в формате (longitude, latitude) + + Returns: + float: Расстояние в градусах + + Example: + >>> dist = calculate_distance_degrees((37.62, 55.75), (37.63, 55.76)) + >>> print(f"{dist:.4f}") # ~0.0141 градусов + 0.0141 + + >>> dist = calculate_distance_degrees((37.62, 55.75), (37.62, 55.75)) + >>> print(dist) # Одинаковые координаты + 0.0 + """ + lon1, lat1 = coord1 + lon2, lat2 = coord2 + + # Простая евклидова метрика для малых расстояний + # Для более точных расчетов на больших расстояниях можно использовать формулу гаверсинуса + delta_lon = lon2 - lon1 + delta_lat = lat2 - lat1 + + distance = (delta_lon**2 + delta_lat**2) ** 0.5 + + return distance + + +def calculate_average_coords_incremental( + current_average: tuple, new_coord: tuple +) -> tuple: + """ + Вычисляет новое среднее между текущим средним и новой координатой. + + Использует инкрементальное усреднение: каждая новая точка усредняется + с текущим средним, а не со всеми точками кластера. Это упрощенный подход, + где новое среднее = (текущее_среднее + новая_координата) / 2. + + Важно: Это НЕ среднее арифметическое всех точек кластера, а инкрементальное + усреднение между двумя точками (текущим средним и новой точкой). + + Args: + current_average (tuple): Текущее среднее в формате (longitude, latitude) + new_coord (tuple): Новая координата в формате (longitude, latitude) + + Returns: + tuple: Новое среднее в формате (longitude, latitude) + + Example: + >>> avg1 = (37.62, 55.75) # Первая точка + >>> avg2 = calculate_average_coords_incremental(avg1, (37.63, 55.76)) + >>> print(avg2) + (37.625, 55.755) + + >>> avg3 = calculate_average_coords_incremental(avg2, (37.64, 55.77)) + >>> print(avg3) + (37.6325, 55.7625) + + >>> # Проверка: среднее между одинаковыми точками + >>> avg = calculate_average_coords_incremental((37.62, 55.75), (37.62, 55.75)) + >>> print(avg) + (37.62, 55.75) + """ + current_lon, current_lat = current_average + new_lon, new_lat = new_coord + + # Инкрементальное усреднение: (current + new) / 2 + avg_lon = (current_lon + new_lon) / 2 + avg_lat = (current_lat + new_lat) / 2 + + return (avg_lon, avg_lat) + + # ============================================================================ # Утилиты для форматирования # ============================================================================ diff --git a/dbapp/mainapp/views.py b/dbapp/mainapp/views.py index 4018fc1..b62ac68 100644 --- a/dbapp/mainapp/views.py +++ b/dbapp/mainapp/views.py @@ -104,15 +104,6 @@ class ActionsPageView(View): return render(request, "mainapp/login_required.html") -class HomePageView(View): - def get(self, request): - if request.user.is_authenticated: - # Redirect to objitem list if authenticated - return redirect("mainapp:objitem_list") - else: - return render(request, "mainapp/login_required.html") - - class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView): template_name = "mainapp/add_data_from_excel.html" form_class = LoadExcelData @@ -558,6 +549,275 @@ class SigmaParameterDataAPIView(LoginRequiredMixin, View): return JsonResponse({'error': str(e)}, status=500) +class SourceObjItemsAPIView(LoginRequiredMixin, View): + """API для получения списка ObjItem, связанных с источником""" + + def get(self, request, source_id): + from .models import Source + + try: + # Загружаем Source с prefetch_related для ObjItem + source = Source.objects.prefetch_related( + 'source_objitems', + 'source_objitems__parameter_obj', + 'source_objitems__parameter_obj__id_satellite', + 'source_objitems__parameter_obj__polarization', + 'source_objitems__parameter_obj__modulation', + 'source_objitems__geo_obj' + ).get(id=source_id) + + # Получаем все связанные ObjItem, отсортированные по created_at + objitems = source.source_objitems.all().order_by('created_at') + + objitems_data = [] + for objitem in objitems: + # Получаем данные параметра + param = getattr(objitem, 'parameter_obj', None) + satellite_name = '-' + frequency = '-' + freq_range = '-' + polarization = '-' + bod_velocity = '-' + modulation = '-' + snr = '-' + + if param: + if hasattr(param, 'id_satellite') and param.id_satellite: + satellite_name = param.id_satellite.name + frequency = f"{param.frequency:.3f}" if param.frequency is not None else '-' + freq_range = f"{param.freq_range:.3f}" if param.freq_range is not None else '-' + if hasattr(param, 'polarization') and param.polarization: + polarization = param.polarization.name + bod_velocity = f"{param.bod_velocity:.0f}" if param.bod_velocity is not None else '-' + if hasattr(param, 'modulation') and param.modulation: + modulation = param.modulation.name + snr = f"{param.snr:.0f}" if param.snr is not None else '-' + + # Получаем геоданные + geo_timestamp = '-' + geo_location = '-' + geo_coords = '-' + + if hasattr(objitem, 'geo_obj') and objitem.geo_obj: + if objitem.geo_obj.timestamp: + local_time = timezone.localtime(objitem.geo_obj.timestamp) + geo_timestamp = local_time.strftime("%d.%m.%Y %H:%M") + + geo_location = objitem.geo_obj.location or '-' + + if objitem.geo_obj.coords: + longitude = objitem.geo_obj.coords.coords[0] + latitude = objitem.geo_obj.coords.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + geo_coords = f"{lat} {lon}" + + objitems_data.append({ + 'id': objitem.id, + 'name': objitem.name or '-', + 'satellite_name': satellite_name, + 'frequency': frequency, + 'freq_range': freq_range, + 'polarization': polarization, + 'bod_velocity': bod_velocity, + 'modulation': modulation, + 'snr': snr, + 'geo_timestamp': geo_timestamp, + 'geo_location': geo_location, + 'geo_coords': geo_coords + }) + + return JsonResponse({ + 'source_id': source_id, + 'objitems': objitems_data + }) + except Source.DoesNotExist: + return JsonResponse({'error': 'Источник не найден'}, status=404) + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +class SourceListView(LoginRequiredMixin, View): + """ + Представление для отображения списка источников (Source). + """ + + def get(self, request): + from .models import Source + from django.db.models import Count + from datetime import datetime + + # Получаем параметры пагинации + page_number, items_per_page = parse_pagination_params(request) + + # Получаем параметры сортировки + sort_param = request.GET.get("sort", "-created_at") + + # Получаем параметры фильтров + search_query = request.GET.get("search", "").strip() + has_coords_average = request.GET.get("has_coords_average") + has_coords_kupsat = request.GET.get("has_coords_kupsat") + has_coords_valid = request.GET.get("has_coords_valid") + has_coords_reference = request.GET.get("has_coords_reference") + objitem_count_min = request.GET.get("objitem_count_min", "").strip() + objitem_count_max = request.GET.get("objitem_count_max", "").strip() + date_from = request.GET.get("date_from", "").strip() + date_to = request.GET.get("date_to", "").strip() + + # Получаем все Source объекты с оптимизацией запросов + sources = Source.objects.select_related( + 'created_by__user', + 'updated_by__user' + ).prefetch_related( + 'source_objitems', + 'source_objitems__parameter_obj', + 'source_objitems__geo_obj' + ).annotate( + objitem_count=Count('source_objitems') + ) + + # Применяем фильтры + # Фильтр по наличию coords_average + if has_coords_average == "1": + sources = sources.filter(coords_average__isnull=False) + elif has_coords_average == "0": + sources = sources.filter(coords_average__isnull=True) + + # Фильтр по наличию coords_kupsat + if has_coords_kupsat == "1": + sources = sources.filter(coords_kupsat__isnull=False) + elif has_coords_kupsat == "0": + sources = sources.filter(coords_kupsat__isnull=True) + + # Фильтр по наличию coords_valid + if has_coords_valid == "1": + sources = sources.filter(coords_valid__isnull=False) + elif has_coords_valid == "0": + sources = sources.filter(coords_valid__isnull=True) + + # Фильтр по наличию coords_reference + if has_coords_reference == "1": + sources = sources.filter(coords_reference__isnull=False) + elif has_coords_reference == "0": + sources = sources.filter(coords_reference__isnull=True) + + # Фильтр по количеству ObjItem + if objitem_count_min: + try: + min_count = int(objitem_count_min) + sources = sources.filter(objitem_count__gte=min_count) + except ValueError: + pass + + if objitem_count_max: + try: + max_count = int(objitem_count_max) + sources = sources.filter(objitem_count__lte=max_count) + except ValueError: + pass + + # Фильтр по диапазону дат создания + if date_from: + try: + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") + sources = sources.filter(created_at__gte=date_from_obj) + except (ValueError, TypeError): + pass + + if date_to: + try: + from datetime import timedelta + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") + # Добавляем один день чтобы включить весь конечный день + date_to_obj = date_to_obj + timedelta(days=1) + sources = sources.filter(created_at__lt=date_to_obj) + except (ValueError, TypeError): + pass + + # Поиск по ID + if search_query: + try: + search_id = int(search_query) + sources = sources.filter(id=search_id) + except ValueError: + # Если не число, игнорируем + pass + + # Применяем сортировку + valid_sort_fields = { + "id": "id", + "-id": "-id", + "created_at": "created_at", + "-created_at": "-created_at", + "updated_at": "updated_at", + "-updated_at": "-updated_at", + "objitem_count": "objitem_count", + "-objitem_count": "-objitem_count", + } + + if sort_param in valid_sort_fields: + sources = sources.order_by(valid_sort_fields[sort_param]) + + # Создаем пагинатор + paginator = Paginator(sources, items_per_page) + page_obj = paginator.get_page(page_number) + + # Подготавливаем данные для отображения + processed_sources = [] + for source in page_obj: + # Форматируем координаты + def format_coords(point): + if point: + longitude = point.coords[0] + latitude = point.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + return f"{lat} {lon}" + return "-" + + coords_average_str = format_coords(source.coords_average) + coords_kupsat_str = format_coords(source.coords_kupsat) + coords_valid_str = format_coords(source.coords_valid) + coords_reference_str = format_coords(source.coords_reference) + + # Получаем количество связанных ObjItem + objitem_count = source.objitem_count + + processed_sources.append({ + 'id': source.id, + 'coords_average': coords_average_str, + 'coords_kupsat': coords_kupsat_str, + 'coords_valid': coords_valid_str, + 'coords_reference': coords_reference_str, + 'objitem_count': objitem_count, + 'created_at': source.created_at, + 'updated_at': source.updated_at, + 'created_by': source.created_by, + 'updated_by': source.updated_by, + }) + + # Подготавливаем контекст для шаблона + context = { + 'page_obj': page_obj, + 'processed_sources': processed_sources, + 'items_per_page': items_per_page, + 'available_items_per_page': [50, 100, 500, 1000], + 'sort': sort_param, + 'search_query': search_query, + 'has_coords_average': has_coords_average, + 'has_coords_kupsat': has_coords_kupsat, + 'has_coords_valid': has_coords_valid, + 'has_coords_reference': has_coords_reference, + 'objitem_count_min': objitem_count_min, + 'objitem_count_max': objitem_count_max, + 'date_from': date_from, + 'date_to': date_to, + 'full_width_page': True, + } + + return render(request, "mainapp/source_list.html", context) + + class ProcessKubsatView(LoginRequiredMixin, FormMessageMixin, FormView): template_name = "mainapp/process_kubsat.html" form_class = NewEventForm