Начал с усреднениями
This commit is contained in:
@@ -40,6 +40,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
|
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'mainapp:points_averaging' %}">Усреднение</a>
|
||||||
|
</li>
|
||||||
<!-- <li class="nav-item">
|
<!-- <li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
|
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
|
||||||
</li> -->
|
</li> -->
|
||||||
|
|||||||
849
dbapp/mainapp/templates/mainapp/points_averaging.html
Normal file
849
dbapp/mainapp/templates/mainapp/points_averaging.html
Normal file
@@ -0,0 +1,849 @@
|
|||||||
|
{% extends "mainapp/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Усреднение точек{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.averaging-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.form-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.table-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
#result-table {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#result-table .tabulator-header {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
#result-table .tabulator-header .tabulator-col {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
height: auto;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
#result-table .tabulator-header .tabulator-col-content {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
#result-table .tabulator-cell {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
.btn-group-custom {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.outlier-row {
|
||||||
|
background-color: #ffcccc !important;
|
||||||
|
}
|
||||||
|
.outlier-warning {
|
||||||
|
color: #dc3545;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.group-header {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.points-detail-table {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.modal-xl {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255,255,255,0.8);
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.loading-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
/* Цвета для групп */
|
||||||
|
.group-color-0 { background-color: #cce5ff !important; }
|
||||||
|
.group-color-1 { background-color: #d4edda !important; }
|
||||||
|
.group-color-2 { background-color: #fff3cd !important; }
|
||||||
|
.group-color-3 { background-color: #f8d7da !important; }
|
||||||
|
.group-color-4 { background-color: #d1ecf1 !important; }
|
||||||
|
.group-color-5 { background-color: #e2d5f1 !important; }
|
||||||
|
.group-color-6 { background-color: #ffeeba !important; }
|
||||||
|
.group-color-7 { background-color: #c3e6cb !important; }
|
||||||
|
.group-color-8 { background-color: #bee5eb !important; }
|
||||||
|
.group-color-9 { background-color: #f5c6cb !important; }
|
||||||
|
|
||||||
|
.all-points-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
#all-points-table {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
#all-points-table .tabulator-header {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
#all-points-table .tabulator-cell {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 3px;
|
||||||
|
}
|
||||||
|
.legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 15px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.legend-color {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-right: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.legend-container {
|
||||||
|
padding: 10px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="loading-overlay" id="loading-overlay">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="averaging-container">
|
||||||
|
<h2>Усреднение точек спутников</h2>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="satellite-select" class="form-label">Спутник</label>
|
||||||
|
<select id="satellite-select" class="form-select">
|
||||||
|
<option value="">Выберите спутник</option>
|
||||||
|
{% for satellite in satellites %}
|
||||||
|
<option value="{{ satellite.id }}">{{ satellite.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="date-from" class="form-label">Дата с</label>
|
||||||
|
<input type="date" id="date-from" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<label for="date-to" class="form-label">Дата по</label>
|
||||||
|
<input type="date" id="date-to" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3 d-flex align-items-end">
|
||||||
|
<button id="btn-process" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-play-fill"></i> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-section">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h5>Результаты усреднения <span id="group-count" class="badge bg-primary">0</span></h5>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group-custom">
|
||||||
|
<button id="export-xlsx" class="btn btn-success" disabled>
|
||||||
|
<i class="bi bi-file-earmark-excel"></i> Сохранить в Excel
|
||||||
|
</button>
|
||||||
|
<button id="clear-table" class="btn btn-danger ms-2">
|
||||||
|
<i class="bi bi-trash"></i> Очистить таблицу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="result-table"></div>
|
||||||
|
|
||||||
|
<!-- All points section -->
|
||||||
|
<div class="all-points-section" id="all-points-section" style="display: none;">
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5>Все точки, участвующие в усреднении <span id="all-points-count" class="badge bg-secondary">0</span></h5>
|
||||||
|
<button id="toggle-all-points" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-eye"></i> Показать/Скрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="all-points-wrapper" style="display: none;">
|
||||||
|
<div class="legend-container" id="legend-container"></div>
|
||||||
|
<div id="all-points-table"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for group details -->
|
||||||
|
<div class="modal fade" id="groupDetailsModal" tabindex="-1" aria-labelledby="groupDetailsModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="groupDetailsModalLabel">Детали группы</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="group-alert-container"></div>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<i class="bi bi-geo-alt"></i> Усреднённый результат
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<strong>Усреднённые координаты:</strong>
|
||||||
|
<span id="modal-avg-coords" class="fs-5 text-primary"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<strong>Всего точек:</strong>
|
||||||
|
<span id="modal-total-points" class="badge bg-secondary"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<strong>Валидных:</strong>
|
||||||
|
<span id="modal-valid-points" class="badge bg-success"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<strong>Выбросов:</strong>
|
||||||
|
<span id="modal-outliers-count" class="badge bg-danger"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="group-points-container"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||||
|
<button type="button" class="btn btn-warning" id="btn-average-all" style="display: none;">
|
||||||
|
<i class="bi bi-calculator"></i> Усреднить все точки
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="btn-average-valid" style="display: none;">
|
||||||
|
<i class="bi bi-check-circle"></i> Усреднить без выбросов
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||||
|
<script src="{% static 'sheetjs/xlsx.full.min.js' %}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
let allGroupsData = [];
|
||||||
|
let currentGroupIndex = null;
|
||||||
|
let allPointsTable = null;
|
||||||
|
|
||||||
|
// Color palette for groups
|
||||||
|
const groupColors = [
|
||||||
|
'#cce5ff', '#d4edda', '#fff3cd', '#f8d7da', '#d1ecf1',
|
||||||
|
'#e2d5f1', '#ffeeba', '#c3e6cb', '#bee5eb', '#f5c6cb'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Initialize Tabulator
|
||||||
|
const table = new Tabulator("#result-table", {
|
||||||
|
layout: "fitDataStretch",
|
||||||
|
height: "500px",
|
||||||
|
placeholder: "Нет данных. Выберите спутник и диапазон дат, затем нажмите 'Добавить'.",
|
||||||
|
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},
|
||||||
|
{title: "Модуляция", field: "modulation", minWidth: 100, widthGrow: 1},
|
||||||
|
{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: "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,
|
||||||
|
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>`;
|
||||||
|
},
|
||||||
|
cellClick: function(e, cell) {
|
||||||
|
const data = cell.getRow().getData();
|
||||||
|
if (e.target.closest('.btn-view-details')) {
|
||||||
|
showGroupDetails(data._groupIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
data: [],
|
||||||
|
rowFormatter: function(row) {
|
||||||
|
const data = row.getData();
|
||||||
|
if (data.has_outliers) {
|
||||||
|
row.getElement().style.backgroundColor = "#fff3cd";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update group count
|
||||||
|
function updateGroupCount() {
|
||||||
|
document.getElementById('group-count').textContent = allGroupsData.length;
|
||||||
|
document.getElementById('export-xlsx').disabled = allGroupsData.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading overlay
|
||||||
|
function showLoading() {
|
||||||
|
document.getElementById('loading-overlay').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loading overlay
|
||||||
|
function hideLoading() {
|
||||||
|
document.getElementById('loading-overlay').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process button click
|
||||||
|
document.getElementById('btn-process').addEventListener('click', async function() {
|
||||||
|
const satelliteId = document.getElementById('satellite-select').value;
|
||||||
|
const dateFrom = document.getElementById('date-from').value;
|
||||||
|
const dateTo = document.getElementById('date-to').value;
|
||||||
|
|
||||||
|
if (!satelliteId) {
|
||||||
|
alert('Выберите спутник');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dateFrom || !dateTo) {
|
||||||
|
alert('Укажите диапазон дат');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
satellite_id: satelliteId,
|
||||||
|
date_from: dateFrom,
|
||||||
|
date_to: dateTo
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/points-averaging/?${params.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
alert(data.error || 'Ошибка при обработке данных');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add groups to table
|
||||||
|
const startIndex = allGroupsData.length;
|
||||||
|
data.groups.forEach((group, idx) => {
|
||||||
|
group._groupIndex = startIndex + idx;
|
||||||
|
allGroupsData.push(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update table
|
||||||
|
table.setData(allGroupsData.map(g => ({
|
||||||
|
...g,
|
||||||
|
status: g.has_outliers ? 'outliers' : 'ok'
|
||||||
|
})));
|
||||||
|
|
||||||
|
updateGroupCount();
|
||||||
|
updateAllPointsTable();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Произошла ошибка при обработке данных');
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update all points table
|
||||||
|
function updateAllPointsTable() {
|
||||||
|
if (allGroupsData.length === 0) {
|
||||||
|
document.getElementById('all-points-section').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('all-points-section').style.display = 'block';
|
||||||
|
|
||||||
|
// Collect all points with group info
|
||||||
|
const allPoints = [];
|
||||||
|
allGroupsData.forEach((group, groupIdx) => {
|
||||||
|
group.points.forEach(point => {
|
||||||
|
allPoints.push({
|
||||||
|
...point,
|
||||||
|
group_name: group.source_name,
|
||||||
|
group_interval: group.interval_label,
|
||||||
|
group_index: groupIdx,
|
||||||
|
group_color: groupColors[groupIdx % groupColors.length],
|
||||||
|
avg_coordinates: group.avg_coordinates
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('all-points-count').textContent = allPoints.length;
|
||||||
|
|
||||||
|
// Build legend
|
||||||
|
let legendHtml = '';
|
||||||
|
allGroupsData.forEach((group, idx) => {
|
||||||
|
const color = groupColors[idx % groupColors.length];
|
||||||
|
legendHtml += `
|
||||||
|
<span class="legend-item">
|
||||||
|
<span class="legend-color" style="background-color: ${color};"></span>
|
||||||
|
<small>${group.source_name} - ${group.interval_label}</small>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
document.getElementById('legend-container').innerHTML = legendHtml;
|
||||||
|
|
||||||
|
// Initialize or update all points table
|
||||||
|
if (!allPointsTable) {
|
||||||
|
allPointsTable = new Tabulator("#all-points-table", {
|
||||||
|
layout: "fitDataStretch",
|
||||||
|
height: "400px",
|
||||||
|
placeholder: "Нет точек",
|
||||||
|
headerWordWrap: true,
|
||||||
|
columns: [
|
||||||
|
{title: "ID", field: "id", minWidth: 60, widthGrow: 0.5},
|
||||||
|
{title: "Группа", field: "group_name", minWidth: 150, widthGrow: 1.5},
|
||||||
|
{title: "Интервал", field: "group_interval", minWidth: 140, widthGrow: 1.2},
|
||||||
|
{title: "Имя точки", field: "name", minWidth: 150, widthGrow: 1.5},
|
||||||
|
{title: "Частота", field: "frequency", minWidth: 80, widthGrow: 0.8},
|
||||||
|
{title: "Полоса", field: "freq_range", minWidth: 80, widthGrow: 0.8},
|
||||||
|
{title: "Символьная скорость", field: "bod_velocity", minWidth: 100, widthGrow: 1},
|
||||||
|
{title: "Модуляция", field: "modulation", minWidth: 80, widthGrow: 0.8},
|
||||||
|
{title: "ОСШ", field: "snr", minWidth: 50, widthGrow: 0.5},
|
||||||
|
{title: "Дата/Время", field: "timestamp", minWidth: 120, widthGrow: 1},
|
||||||
|
{title: "Зеркала", field: "mirrors", minWidth: 100, widthGrow: 1},
|
||||||
|
{title: "Местоположение", field: "location", minWidth: 100, widthGrow: 1},
|
||||||
|
{title: "Координаты точки", field: "coordinates", minWidth: 130, widthGrow: 1.2},
|
||||||
|
{title: "Усреднённые коорд.", field: "avg_coordinates", minWidth: 130, widthGrow: 1.2},
|
||||||
|
{title: "Расст. от среднего, км", field: "distance_from_avg", minWidth: 100, widthGrow: 0.8, hozAlign: "right"},
|
||||||
|
{
|
||||||
|
title: "Статус",
|
||||||
|
field: "is_outlier",
|
||||||
|
minWidth: 80,
|
||||||
|
widthGrow: 0.7,
|
||||||
|
formatter: function(cell) {
|
||||||
|
return cell.getValue()
|
||||||
|
? '<span class="badge bg-danger">Выброс</span>'
|
||||||
|
: '<span class="badge bg-success">OK</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
data: allPoints,
|
||||||
|
rowFormatter: function(row) {
|
||||||
|
const data = row.getData();
|
||||||
|
row.getElement().style.backgroundColor = data.group_color;
|
||||||
|
if (data.is_outlier) {
|
||||||
|
row.getElement().style.border = '2px solid #dc3545';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
allPointsTable.setData(allPoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle all points visibility
|
||||||
|
document.getElementById('toggle-all-points').addEventListener('click', function() {
|
||||||
|
const wrapper = document.getElementById('all-points-wrapper');
|
||||||
|
if (wrapper.style.display === 'none') {
|
||||||
|
wrapper.style.display = 'block';
|
||||||
|
this.innerHTML = '<i class="bi bi-eye-slash"></i> Скрыть';
|
||||||
|
} else {
|
||||||
|
wrapper.style.display = 'none';
|
||||||
|
this.innerHTML = '<i class="bi bi-eye"></i> Показать/Скрыть';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show group details modal
|
||||||
|
function showGroupDetails(groupIndex) {
|
||||||
|
currentGroupIndex = groupIndex;
|
||||||
|
const group = allGroupsData[groupIndex];
|
||||||
|
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
// Update modal title
|
||||||
|
document.getElementById('groupDetailsModalLabel').textContent =
|
||||||
|
`Детали группы: ${group.source_name} - ${group.interval_label}`;
|
||||||
|
|
||||||
|
// Update summary info
|
||||||
|
document.getElementById('modal-avg-coords').textContent = group.avg_coordinates;
|
||||||
|
document.getElementById('modal-total-points').textContent = group.total_points;
|
||||||
|
document.getElementById('modal-valid-points').textContent = group.valid_points_count;
|
||||||
|
document.getElementById('modal-outliers-count').textContent = group.outliers_count;
|
||||||
|
|
||||||
|
// Show/hide alert and buttons based on outliers
|
||||||
|
const alertContainer = document.getElementById('group-alert-container');
|
||||||
|
const btnAverageAll = document.getElementById('btn-average-all');
|
||||||
|
const btnAverageValid = document.getElementById('btn-average-valid');
|
||||||
|
|
||||||
|
if (group.has_outliers) {
|
||||||
|
alertContainer.innerHTML = `
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
<strong>Внимание!</strong> Обнаружены точки на расстоянии более 56 км от среднего значения.
|
||||||
|
Они выделены красным цветом в таблице ниже.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
btnAverageAll.style.display = 'inline-block';
|
||||||
|
btnAverageValid.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
alertContainer.innerHTML = `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<i class="bi bi-check-circle"></i>
|
||||||
|
Все точки находятся в пределах 56 км от среднего значения.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
btnAverageAll.style.display = 'none';
|
||||||
|
btnAverageValid.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build points table - first valid points, then outliers
|
||||||
|
let html = `
|
||||||
|
<h6 class="text-success"><i class="bi bi-check-circle"></i> Валидные точки (${group.valid_points_count})</h6>
|
||||||
|
<table class="table table-sm table-bordered points-detail-table">
|
||||||
|
<thead class="table-success">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Частота</th>
|
||||||
|
<th>Полоса</th>
|
||||||
|
<th>Дата/Время</th>
|
||||||
|
<th>Координаты</th>
|
||||||
|
<th>Расстояние от среднего, км</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Valid points
|
||||||
|
group.points.forEach((point, idx) => {
|
||||||
|
if (!point.is_outlier) {
|
||||||
|
html += `
|
||||||
|
<tr class="table-success" data-point-index="${idx}">
|
||||||
|
<td>${point.id}</td>
|
||||||
|
<td>${point.name}</td>
|
||||||
|
<td>${point.frequency}</td>
|
||||||
|
<td>${point.freq_range}</td>
|
||||||
|
<td>${point.timestamp}</td>
|
||||||
|
<td>${point.coordinates}</td>
|
||||||
|
<td>${point.distance_from_avg}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
|
||||||
|
// Outliers section (if any)
|
||||||
|
if (group.has_outliers) {
|
||||||
|
html += `
|
||||||
|
<h6 class="text-danger mt-4"><i class="bi bi-exclamation-triangle"></i> Выбросы - точки за пределами 56 км (${group.outliers_count})</h6>
|
||||||
|
<table class="table table-sm table-bordered points-detail-table">
|
||||||
|
<thead class="table-danger">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Частота</th>
|
||||||
|
<th>Полоса</th>
|
||||||
|
<th>Дата/Время</th>
|
||||||
|
<th>Координаты</th>
|
||||||
|
<th>Расстояние от среднего, км</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
group.points.forEach((point, idx) => {
|
||||||
|
if (point.is_outlier) {
|
||||||
|
html += `
|
||||||
|
<tr class="table-danger" data-point-index="${idx}">
|
||||||
|
<td>${point.id}</td>
|
||||||
|
<td>${point.name}</td>
|
||||||
|
<td>${point.frequency}</td>
|
||||||
|
<td>${point.freq_range}</td>
|
||||||
|
<td>${point.timestamp}</td>
|
||||||
|
<td>${point.coordinates}</td>
|
||||||
|
<td><strong>${point.distance_from_avg}</strong></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-danger btn-remove-point" data-index="${idx}" title="Удалить точку">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('group-points-container').innerHTML = html;
|
||||||
|
|
||||||
|
// Add event listeners for remove buttons
|
||||||
|
document.querySelectorAll('.btn-remove-point').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const pointIndex = parseInt(this.dataset.index);
|
||||||
|
removePointFromGroup(groupIndex, pointIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('groupDetailsModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove point from group and recalculate
|
||||||
|
async function removePointFromGroup(groupIndex, pointIndex) {
|
||||||
|
const group = allGroupsData[groupIndex];
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
// Check if this is the last point
|
||||||
|
if (group.points.length <= 1) {
|
||||||
|
alert('Нельзя удалить последнюю точку. Удалите всю группу из основной таблицы.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove point
|
||||||
|
group.points.splice(pointIndex, 1);
|
||||||
|
|
||||||
|
// Recalculate with all remaining points
|
||||||
|
await recalculateGroup(groupIndex, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate group
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/points-averaging/recalculate/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': getCookie('csrftoken')
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
points: group.points,
|
||||||
|
include_all: includeAll
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
alert(data.error || 'Ошибка при пересчёте');
|
||||||
|
hideLoading();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update group data
|
||||||
|
group.avg_coordinates = data.avg_coordinates;
|
||||||
|
group.avg_coord_tuple = data.avg_coord_tuple;
|
||||||
|
group.total_points = data.total_points;
|
||||||
|
group.valid_points_count = data.valid_points_count;
|
||||||
|
group.outliers_count = data.outliers_count;
|
||||||
|
group.has_outliers = data.has_outliers;
|
||||||
|
group.points = data.points;
|
||||||
|
|
||||||
|
// Update table
|
||||||
|
table.setData(allGroupsData.map(g => ({
|
||||||
|
...g,
|
||||||
|
status: g.has_outliers ? 'outliers' : 'ok'
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Update all points table
|
||||||
|
updateAllPointsTable();
|
||||||
|
|
||||||
|
// Update modal if open
|
||||||
|
if (currentGroupIndex === groupIndex) {
|
||||||
|
showGroupDetails(groupIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Произошла ошибка при пересчёте');
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average all points button
|
||||||
|
document.getElementById('btn-average-all').addEventListener('click', function() {
|
||||||
|
if (currentGroupIndex !== null) {
|
||||||
|
recalculateGroup(currentGroupIndex, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Average valid points button
|
||||||
|
document.getElementById('btn-average-valid').addEventListener('click', function() {
|
||||||
|
if (currentGroupIndex !== null) {
|
||||||
|
recalculateGroup(currentGroupIndex, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export to Excel
|
||||||
|
document.getElementById('export-xlsx').addEventListener('click', function() {
|
||||||
|
if (allGroupsData.length === 0) {
|
||||||
|
alert('Нет данных для экспорта');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare summary data for export
|
||||||
|
const summaryData = allGroupsData.map(group => ({
|
||||||
|
'Объект наблюдения': group.source_name,
|
||||||
|
'Интервал': group.interval_label,
|
||||||
|
'Частота, МГц': group.frequency,
|
||||||
|
'Полоса, МГц': group.freq_range,
|
||||||
|
'Символьная скорость, БОД': group.bod_velocity,
|
||||||
|
'Модуляция': group.modulation,
|
||||||
|
'ОСШ': group.snr,
|
||||||
|
'Зеркала': group.mirrors,
|
||||||
|
'Усреднённые координаты': group.avg_coordinates,
|
||||||
|
'Кол-во точек': group.total_points,
|
||||||
|
'Выбросов': group.outliers_count,
|
||||||
|
'Статус': group.has_outliers ? 'Есть выбросы' : 'OK'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Prepare all points data for export
|
||||||
|
const allPointsData = [];
|
||||||
|
allGroupsData.forEach((group, groupIdx) => {
|
||||||
|
group.points.forEach(point => {
|
||||||
|
allPointsData.push({
|
||||||
|
'Группа': group.source_name,
|
||||||
|
'Интервал': group.interval_label,
|
||||||
|
'ID точки': point.id,
|
||||||
|
'Имя точки': point.name,
|
||||||
|
'Частота, МГц': point.frequency,
|
||||||
|
'Полоса, МГц': point.freq_range,
|
||||||
|
'Символьная скорость, БОД': point.bod_velocity,
|
||||||
|
'Модуляция': point.modulation,
|
||||||
|
'ОСШ': point.snr,
|
||||||
|
'Дата/Время': point.timestamp,
|
||||||
|
'Зеркала': point.mirrors,
|
||||||
|
'Местоположение': point.location,
|
||||||
|
'Координаты точки': point.coordinates,
|
||||||
|
'Усреднённые координаты': group.avg_coordinates,
|
||||||
|
'Расстояние от среднего, км': point.distance_from_avg,
|
||||||
|
'Статус': point.is_outlier ? 'Выброс' : 'OK'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create workbook with two sheets
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
// Summary sheet
|
||||||
|
const wsSummary = XLSX.utils.json_to_sheet(summaryData);
|
||||||
|
XLSX.utils.book_append_sheet(wb, wsSummary, "Усреднение");
|
||||||
|
|
||||||
|
// All points sheet
|
||||||
|
const wsPoints = XLSX.utils.json_to_sheet(allPointsData);
|
||||||
|
XLSX.utils.book_append_sheet(wb, wsPoints, "Все точки");
|
||||||
|
|
||||||
|
// Generate filename with date
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = now.toISOString().slice(0, 10);
|
||||||
|
const filename = `averaging_${dateStr}.xlsx`;
|
||||||
|
|
||||||
|
// Download
|
||||||
|
XLSX.writeFile(wb, filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear table
|
||||||
|
document.getElementById('clear-table').addEventListener('click', function() {
|
||||||
|
if (confirm('Вы уверены, что хотите очистить таблицу?')) {
|
||||||
|
allGroupsData = [];
|
||||||
|
table.clearData();
|
||||||
|
updateGroupCount();
|
||||||
|
if (allPointsTable) {
|
||||||
|
allPointsTable.clearData();
|
||||||
|
}
|
||||||
|
document.getElementById('all-points-section').style.display = 'none';
|
||||||
|
document.getElementById('all-points-wrapper').style.display = 'none';
|
||||||
|
document.getElementById('toggle-all-points').innerHTML = '<i class="bi bi-eye"></i> Показать/Скрыть';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get CSRF token
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
updateGroupCount();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -61,6 +61,7 @@ from .views import (
|
|||||||
)
|
)
|
||||||
from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
|
from .views.marks import ObjectMarksListView, AddObjectMarkView, UpdateObjectMarkView
|
||||||
from .views.tech_analyze import tech_analyze_entry, tech_analyze_save
|
from .views.tech_analyze import tech_analyze_entry, tech_analyze_save
|
||||||
|
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
|
||||||
|
|
||||||
app_name = 'mainapp'
|
app_name = 'mainapp'
|
||||||
|
|
||||||
@@ -129,5 +130,8 @@ urlpatterns = [
|
|||||||
path('api/search-objitem/', SearchObjItemAPIView.as_view(), name='search_objitem_api'),
|
path('api/search-objitem/', SearchObjItemAPIView.as_view(), name='search_objitem_api'),
|
||||||
path('tech-analyze/', tech_analyze_entry, name='tech_analyze_entry'),
|
path('tech-analyze/', tech_analyze_entry, name='tech_analyze_entry'),
|
||||||
path('tech-analyze/save/', tech_analyze_save, name='tech_analyze_save'),
|
path('tech-analyze/save/', tech_analyze_save, name='tech_analyze_save'),
|
||||||
|
path('points-averaging/', PointsAveragingView.as_view(), name='points_averaging'),
|
||||||
|
path('api/points-averaging/', PointsAveragingAPIView.as_view(), name='points_averaging_api'),
|
||||||
|
path('api/points-averaging/recalculate/', RecalculateGroupAPIView.as_view(), name='points_averaging_recalculate'),
|
||||||
path('logout/', custom_logout, name='logout'),
|
path('logout/', custom_logout, name='logout'),
|
||||||
]
|
]
|
||||||
@@ -65,6 +65,11 @@ from .data_entry import (
|
|||||||
DataEntryView,
|
DataEntryView,
|
||||||
SearchObjItemAPIView,
|
SearchObjItemAPIView,
|
||||||
)
|
)
|
||||||
|
from .points_averaging import (
|
||||||
|
PointsAveragingView,
|
||||||
|
PointsAveragingAPIView,
|
||||||
|
RecalculateGroupAPIView,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Base
|
# Base
|
||||||
@@ -133,4 +138,8 @@ __all__ = [
|
|||||||
# Data Entry
|
# Data Entry
|
||||||
'DataEntryView',
|
'DataEntryView',
|
||||||
'SearchObjItemAPIView',
|
'SearchObjItemAPIView',
|
||||||
|
# Points Averaging
|
||||||
|
'PointsAveragingView',
|
||||||
|
'PointsAveragingAPIView',
|
||||||
|
'RecalculateGroupAPIView',
|
||||||
]
|
]
|
||||||
|
|||||||
480
dbapp/mainapp/views/points_averaging.py
Normal file
480
dbapp/mainapp/views/points_averaging.py
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
"""
|
||||||
|
Points averaging view for satellite data grouping by day/night intervals.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class PointsAveragingView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
View for points averaging form with date range selection and grouping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# Get satellites that have points with geo data
|
||||||
|
satellites = Satellite.objects.filter(
|
||||||
|
parameters__objitem__geo_obj__coords__isnull=False
|
||||||
|
).distinct().order_by('name')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'satellites': satellites,
|
||||||
|
'full_width_page': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'mainapp/points_averaging.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
class PointsAveragingAPIView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API endpoint for grouping and averaging points by day/night intervals.
|
||||||
|
|
||||||
|
Groups points into:
|
||||||
|
- Day: 08:00 - 19:00
|
||||||
|
- Night: 19:00 - 08:00 (next day)
|
||||||
|
|
||||||
|
For each group, calculates average coordinates and checks for outliers (>56 km).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
satellite_id = request.GET.get('satellite_id', '').strip()
|
||||||
|
date_from = request.GET.get('date_from', '').strip()
|
||||||
|
date_to = request.GET.get('date_to', '').strip()
|
||||||
|
|
||||||
|
if not satellite_id:
|
||||||
|
return JsonResponse({'error': 'Выберите спутник'}, status=400)
|
||||||
|
|
||||||
|
if not date_from or not date_to:
|
||||||
|
return JsonResponse({'error': 'Укажите диапазон дат'}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
satellite = Satellite.objects.get(id=int(satellite_id))
|
||||||
|
except (Satellite.DoesNotExist, ValueError):
|
||||||
|
return JsonResponse({'error': 'Спутник не найден'}, status=404)
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
try:
|
||||||
|
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d")
|
||||||
|
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") + timedelta(days=1)
|
||||||
|
except ValueError:
|
||||||
|
return JsonResponse({'error': 'Неверный формат даты'}, status=400)
|
||||||
|
|
||||||
|
# Get all points for the satellite in the date range
|
||||||
|
objitems = ObjItem.objects.filter(
|
||||||
|
parameter_obj__id_satellite=satellite,
|
||||||
|
geo_obj__coords__isnull=False,
|
||||||
|
geo_obj__timestamp__gte=date_from_obj,
|
||||||
|
geo_obj__timestamp__lt=date_to_obj,
|
||||||
|
).select_related(
|
||||||
|
'parameter_obj',
|
||||||
|
'parameter_obj__id_satellite',
|
||||||
|
'parameter_obj__polarization',
|
||||||
|
'parameter_obj__modulation',
|
||||||
|
'parameter_obj__standard',
|
||||||
|
'geo_obj',
|
||||||
|
'source',
|
||||||
|
).prefetch_related(
|
||||||
|
'geo_obj__mirrors'
|
||||||
|
).order_by('geo_obj__timestamp')
|
||||||
|
|
||||||
|
if not objitems.exists():
|
||||||
|
return JsonResponse({'error': 'Точки не найдены в указанном диапазоне'}, status=404)
|
||||||
|
|
||||||
|
# Group points by source name and day/night intervals
|
||||||
|
groups = self._group_points_by_intervals(objitems)
|
||||||
|
|
||||||
|
# Process each group: calculate average and check for outliers
|
||||||
|
result_groups = []
|
||||||
|
for group_key, points in groups.items():
|
||||||
|
group_result = self._process_group(group_key, points)
|
||||||
|
result_groups.append(group_result)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'satellite': satellite.name,
|
||||||
|
'date_from': date_from,
|
||||||
|
'date_to': date_to,
|
||||||
|
'groups': result_groups,
|
||||||
|
'total_groups': len(result_groups),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _group_points_by_intervals(self, objitems):
|
||||||
|
"""
|
||||||
|
Group points by source name and day/night intervals.
|
||||||
|
|
||||||
|
Day: 08:00 - 19:00
|
||||||
|
Night: 19:00 - 08:00 (next day)
|
||||||
|
"""
|
||||||
|
groups = {}
|
||||||
|
|
||||||
|
for objitem in objitems:
|
||||||
|
if not objitem.geo_obj or not objitem.geo_obj.timestamp:
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = objitem.geo_obj.timestamp
|
||||||
|
source_name = objitem.name or f"Объект #{objitem.id}"
|
||||||
|
|
||||||
|
# Determine interval
|
||||||
|
interval_key = self._get_interval_key(timestamp)
|
||||||
|
|
||||||
|
# Create group key: (source_name, interval_key)
|
||||||
|
group_key = (source_name, interval_key)
|
||||||
|
|
||||||
|
if group_key not in groups:
|
||||||
|
groups[group_key] = []
|
||||||
|
|
||||||
|
groups[group_key].append(objitem)
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
def _get_interval_key(self, timestamp):
|
||||||
|
"""
|
||||||
|
Get interval key for a timestamp.
|
||||||
|
|
||||||
|
Day: 08:00 - 19:00 -> "YYYY-MM-DD_day"
|
||||||
|
Night: 19:00 - 08:00 -> "YYYY-MM-DD_night" (date of the start of night)
|
||||||
|
"""
|
||||||
|
hour = timestamp.hour
|
||||||
|
date = timestamp.date()
|
||||||
|
|
||||||
|
if 8 <= hour < 19:
|
||||||
|
# Day interval
|
||||||
|
return f"{date.strftime('%Y-%m-%d')}_day"
|
||||||
|
elif hour >= 19:
|
||||||
|
# Night interval starting this day
|
||||||
|
return f"{date.strftime('%Y-%m-%d')}_night"
|
||||||
|
else:
|
||||||
|
# Night interval (00:00 - 08:00), belongs to previous day's night
|
||||||
|
prev_date = date - timedelta(days=1)
|
||||||
|
return f"{prev_date.strftime('%Y-%m-%d')}_night"
|
||||||
|
|
||||||
|
def _process_group(self, group_key, points):
|
||||||
|
"""
|
||||||
|
Process a group of points: calculate average and check for outliers.
|
||||||
|
|
||||||
|
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
|
||||||
|
4. Points not within 56 km of final average are outliers
|
||||||
|
"""
|
||||||
|
source_name, interval_key = group_key
|
||||||
|
|
||||||
|
# Parse interval info
|
||||||
|
date_str, interval_type = interval_key.rsplit('_', 1)
|
||||||
|
interval_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
|
||||||
|
if interval_type == 'day':
|
||||||
|
interval_label = f"{interval_date.strftime('%d.%m.%Y')} День (08:00-19:00)"
|
||||||
|
else:
|
||||||
|
interval_label = f"{interval_date.strftime('%d.%m.%Y')} Ночь (19:00-08:00)"
|
||||||
|
|
||||||
|
# Collect coordinates and build points_data
|
||||||
|
points_data = []
|
||||||
|
|
||||||
|
for objitem in points:
|
||||||
|
geo = objitem.geo_obj
|
||||||
|
param = getattr(objitem, 'parameter_obj', None)
|
||||||
|
|
||||||
|
coord = (geo.coords.x, geo.coords.y)
|
||||||
|
|
||||||
|
# Get mirrors
|
||||||
|
mirrors = '-'
|
||||||
|
if geo.mirrors.exists():
|
||||||
|
mirrors = ', '.join([m.name for m in geo.mirrors.all()])
|
||||||
|
|
||||||
|
# Format timestamp
|
||||||
|
timestamp_str = '-'
|
||||||
|
if geo.timestamp:
|
||||||
|
local_time = timezone.localtime(geo.timestamp)
|
||||||
|
timestamp_str = local_time.strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
points_data.append({
|
||||||
|
'id': objitem.id,
|
||||||
|
'name': objitem.name or '-',
|
||||||
|
'frequency': format_frequency(param.frequency) if param else '-',
|
||||||
|
'freq_range': format_frequency(param.freq_range) if param else '-',
|
||||||
|
'bod_velocity': format_symbol_rate(param.bod_velocity) if param else '-',
|
||||||
|
'modulation': param.modulation.name if param and param.modulation else '-',
|
||||||
|
'snr': f"{param.snr:.0f}" if param and param.snr else '-',
|
||||||
|
'timestamp': timestamp_str,
|
||||||
|
'mirrors': mirrors,
|
||||||
|
'location': geo.location or '-',
|
||||||
|
'coordinates': format_coords_display(geo.coords),
|
||||||
|
'coord_tuple': coord,
|
||||||
|
'is_outlier': False,
|
||||||
|
'distance_from_avg': 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Apply clustering algorithm
|
||||||
|
avg_coord, valid_indices = self._find_cluster_center(points_data)
|
||||||
|
|
||||||
|
# Mark outliers and calculate distances
|
||||||
|
outliers = []
|
||||||
|
valid_points = []
|
||||||
|
|
||||||
|
for i, point_data in enumerate(points_data):
|
||||||
|
coord = point_data['coord_tuple']
|
||||||
|
_, distance = calculate_mean_coords(avg_coord, coord)
|
||||||
|
point_data['distance_from_avg'] = round(distance, 2)
|
||||||
|
|
||||||
|
if i in valid_indices:
|
||||||
|
point_data['is_outlier'] = False
|
||||||
|
valid_points.append(point_data)
|
||||||
|
else:
|
||||||
|
point_data['is_outlier'] = True
|
||||||
|
outliers.append(point_data)
|
||||||
|
|
||||||
|
# Format average coordinates
|
||||||
|
avg_lat = avg_coord[1]
|
||||||
|
avg_lon = avg_coord[0]
|
||||||
|
lat_str = f"{abs(avg_lat):.4f}N" if avg_lat >= 0 else f"{abs(avg_lat):.4f}S"
|
||||||
|
lon_str = f"{abs(avg_lon):.4f}E" if avg_lon >= 0 else f"{abs(avg_lon):.4f}W"
|
||||||
|
avg_coords_str = f"{lat_str} {lon_str}"
|
||||||
|
|
||||||
|
# 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 {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'source_name': source_name,
|
||||||
|
'interval_key': interval_key,
|
||||||
|
'interval_label': interval_label,
|
||||||
|
'total_points': len(points_data),
|
||||||
|
'valid_points_count': len(valid_points),
|
||||||
|
'outliers_count': len(outliers),
|
||||||
|
'has_outliers': len(outliers) > 0,
|
||||||
|
'avg_coordinates': avg_coords_str,
|
||||||
|
'avg_coord_tuple': avg_coord,
|
||||||
|
'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', '-'),
|
||||||
|
'points': points_data,
|
||||||
|
'outliers': outliers,
|
||||||
|
'valid_points': valid_points,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
"""
|
||||||
|
if len(points_data) == 0:
|
||||||
|
return (0, 0), set()
|
||||||
|
|
||||||
|
if len(points_data) == 1:
|
||||||
|
return points_data[0]['coord_tuple'], {0}
|
||||||
|
|
||||||
|
# Step 1: Find first pair of points within 56 km
|
||||||
|
initial_pair = None
|
||||||
|
for i in range(len(points_data)):
|
||||||
|
for j in range(i + 1, len(points_data)):
|
||||||
|
coord_i = points_data[i]['coord_tuple']
|
||||||
|
coord_j = points_data[j]['coord_tuple']
|
||||||
|
_, distance = calculate_mean_coords(coord_i, coord_j)
|
||||||
|
|
||||||
|
if distance <= RANGE_DISTANCE:
|
||||||
|
initial_pair = (i, j)
|
||||||
|
break
|
||||||
|
if initial_pair:
|
||||||
|
break
|
||||||
|
|
||||||
|
# If no pair found within 56 km, use first point as center
|
||||||
|
if not initial_pair:
|
||||||
|
# All points are outliers except the first one
|
||||||
|
return points_data[0]['coord_tuple'], {0}
|
||||||
|
|
||||||
|
# Step 2: 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
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
indices_list = sorted(indices)
|
||||||
|
if not indices_list:
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
avg_coord = 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)
|
||||||
|
|
||||||
|
return avg_coord
|
||||||
|
|
||||||
|
|
||||||
|
class RecalculateGroupAPIView(LoginRequiredMixin, View):
|
||||||
|
"""
|
||||||
|
API endpoint for recalculating a group after removing outliers or including all points.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({'error': 'Invalid JSON'}, status=400)
|
||||||
|
|
||||||
|
points = data.get('points', [])
|
||||||
|
include_all = data.get('include_all', False)
|
||||||
|
|
||||||
|
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:
|
||||||
|
points = [p for p in points if not p.get('is_outlier', False)]
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
return JsonResponse({'error': 'No valid points after filtering'}, status=400)
|
||||||
|
|
||||||
|
# Apply clustering algorithm
|
||||||
|
avg_coord, valid_indices = self._find_cluster_center(points)
|
||||||
|
|
||||||
|
# Mark outliers and calculate distances
|
||||||
|
for i, point in enumerate(points):
|
||||||
|
coord = tuple(point['coord_tuple'])
|
||||||
|
_, distance = calculate_mean_coords(avg_coord, coord)
|
||||||
|
point['distance_from_avg'] = round(distance, 2)
|
||||||
|
point['is_outlier'] = i not in valid_indices
|
||||||
|
|
||||||
|
# Format average coordinates
|
||||||
|
avg_lat = avg_coord[1]
|
||||||
|
avg_lon = avg_coord[0]
|
||||||
|
lat_str = f"{abs(avg_lat):.4f}N" if avg_lat >= 0 else f"{abs(avg_lat):.4f}S"
|
||||||
|
lon_str = f"{abs(avg_lon):.4f}E" if avg_lon >= 0 else f"{abs(avg_lon):.4f}W"
|
||||||
|
avg_coords_str = f"{lat_str} {lon_str}"
|
||||||
|
|
||||||
|
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)]
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'avg_coordinates': avg_coords_str,
|
||||||
|
'avg_coord_tuple': avg_coord,
|
||||||
|
'total_points': len(points),
|
||||||
|
'valid_points_count': len(valid_points),
|
||||||
|
'outliers_count': len(outliers),
|
||||||
|
'has_outliers': len(outliers) > 0,
|
||||||
|
'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
|
||||||
|
4. Return final average and indices of valid points
|
||||||
|
"""
|
||||||
|
if len(points) == 0:
|
||||||
|
return (0, 0), set()
|
||||||
|
|
||||||
|
if len(points) == 1:
|
||||||
|
return tuple(points[0]['coord_tuple']), {0}
|
||||||
|
|
||||||
|
# Step 1: Find first pair of points within 56 km
|
||||||
|
initial_pair = None
|
||||||
|
for i in range(len(points)):
|
||||||
|
for j in range(i + 1, len(points)):
|
||||||
|
coord_i = tuple(points[i]['coord_tuple'])
|
||||||
|
coord_j = tuple(points[j]['coord_tuple'])
|
||||||
|
_, distance = calculate_mean_coords(coord_i, coord_j)
|
||||||
|
|
||||||
|
if distance <= RANGE_DISTANCE:
|
||||||
|
initial_pair = (i, j)
|
||||||
|
break
|
||||||
|
if initial_pair:
|
||||||
|
break
|
||||||
|
|
||||||
|
# If no pair found within 56 km, use first point as center
|
||||||
|
if not initial_pair:
|
||||||
|
return tuple(points[0]['coord_tuple']), {0}
|
||||||
|
|
||||||
|
# Step 2: 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
|
||||||
|
|
||||||
|
return avg_coord, valid_indices
|
||||||
|
|
||||||
|
def _calculate_average_from_indices(self, points, indices):
|
||||||
|
"""Calculate average coordinate from points at given indices."""
|
||||||
|
indices_list = sorted(indices)
|
||||||
|
if not indices_list:
|
||||||
|
return (0, 0)
|
||||||
|
|
||||||
|
avg_coord = 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)
|
||||||
|
|
||||||
|
return avg_coord
|
||||||
Reference in New Issue
Block a user