Начал с усреднениями
This commit is contained in:
@@ -40,6 +40,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:points_averaging' %}">Усреднение</a>
|
||||
</li>
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
|
||||
</li> -->
|
||||
|
||||
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.tech_analyze import tech_analyze_entry, tech_analyze_save
|
||||
from .views.points_averaging import PointsAveragingView, PointsAveragingAPIView, RecalculateGroupAPIView
|
||||
|
||||
app_name = 'mainapp'
|
||||
|
||||
@@ -129,5 +130,8 @@ urlpatterns = [
|
||||
path('api/search-objitem/', SearchObjItemAPIView.as_view(), name='search_objitem_api'),
|
||||
path('tech-analyze/', tech_analyze_entry, name='tech_analyze_entry'),
|
||||
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'),
|
||||
]
|
||||
@@ -65,6 +65,11 @@ from .data_entry import (
|
||||
DataEntryView,
|
||||
SearchObjItemAPIView,
|
||||
)
|
||||
from .points_averaging import (
|
||||
PointsAveragingView,
|
||||
PointsAveragingAPIView,
|
||||
RecalculateGroupAPIView,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
@@ -133,4 +138,8 @@ __all__ = [
|
||||
# Data Entry
|
||||
'DataEntryView',
|
||||
'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