Начал с усреднениями

This commit is contained in:
2025-11-28 00:18:04 +03:00
parent 908e11879d
commit d521b6baad
5 changed files with 1345 additions and 0 deletions

View File

@@ -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> -->

View 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 %}

View File

@@ -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'),
] ]

View File

@@ -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',
] ]

View 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