Merge branch 'feat/AEB_20_create_csv_parser' into 'main'
feat / AEB-20 create csv parser See merge request wedeving/aerbim-www!4
This commit is contained in:
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
### Дизайн
|
### Дизайн
|
||||||
|
|
||||||
- **Figma** - [Дизайн проекта.](https://www.figma.com/design/)
|
- **Figma** - [Дизайн проекта.](https://www.figma.com/design/BrqSrc886HCCAYRONObOut/%D0%90%D1%8D%D1%80%D0%91%D0%98%D0%9C?node-id=711-11782&p=f&t=nMUhO2Od7DacnQkO-0)
|
||||||
|
|
||||||
### Тасклист
|
### Тасклист
|
||||||
|
|
||||||
|
|||||||
64
backend/api/management/commands/parse_multiplexor_data.py
Normal file
64
backend/api/management/commands/parse_multiplexor_data.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.conf import settings
|
||||||
|
from ...parser.parse_csv import parse_multiplexor_data
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Парсит данные из CSV файлов мультиплексоров'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--data-dir',
|
||||||
|
type=str,
|
||||||
|
help='Директория с CSV файлами',
|
||||||
|
default=os.path.join(settings.BASE_DIR, 'data', 'multiplexors')
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
data_dir = options['data_dir']
|
||||||
|
|
||||||
|
# проверяем существование директории
|
||||||
|
if not os.path.exists(data_dir):
|
||||||
|
logger.error(f"Директория {data_dir} не существует")
|
||||||
|
return
|
||||||
|
|
||||||
|
# получаем список CSV файлов
|
||||||
|
csv_files = [f for f in os.listdir(data_dir) if f.endswith('.csv')]
|
||||||
|
if not csv_files:
|
||||||
|
logger.info(f"CSV файлы не найдены в директории {data_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
total_metrics = 0
|
||||||
|
total_alerts = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# обрабатываем каждый файл
|
||||||
|
for csv_file in csv_files:
|
||||||
|
file_path = os.path.join(data_dir, csv_file)
|
||||||
|
logger.info(f"Обработка файла: {file_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = parse_multiplexor_data(file_path)
|
||||||
|
|
||||||
|
if result['success']:
|
||||||
|
total_metrics += result['metrics_count']
|
||||||
|
total_alerts += result['alerts_count']
|
||||||
|
if result['errors']:
|
||||||
|
errors.extend([f"{csv_file}: {err}" for err in result['errors']])
|
||||||
|
else:
|
||||||
|
errors.extend([f"{csv_file}: {err}" for err in result['errors']])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при обработке файла {csv_file}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
errors.append(error_msg)
|
||||||
|
|
||||||
|
# выводим итоговую статистику
|
||||||
|
logger.info(f"Обработка завершена. Создано метрик: {total_metrics}, алертов: {total_alerts}")
|
||||||
|
if errors:
|
||||||
|
logger.warning("Обнаружены ошибки:")
|
||||||
|
for error in errors:
|
||||||
|
logger.warning(error)
|
||||||
191
backend/api/parser/parse_csv.py
Normal file
191
backend/api/parser/parse_csv.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import os
|
||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from sitemanagement.models import Multiplexor, Channel, Sensor, Alert, Metric
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def parse_multiplexor_data(csv_file_path: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Парсит данные из CSV файла с показаниями мультиплексора.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
csv_file_path (str): Путь к CSV файлу с данными
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Словарь с результатами парсинга:
|
||||||
|
- success (bool): Успешно ли выполнен парсинг
|
||||||
|
- errors (List[str]): Список ошибок, если они есть
|
||||||
|
- metrics_count (int): Количество созданных метрик
|
||||||
|
- alerts_count (int): Количество созданных алертов
|
||||||
|
"""
|
||||||
|
if not csv_file_path:
|
||||||
|
logger.error("Не указан путь к файлу")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"errors": ["Не указан путь к файлу"],
|
||||||
|
"metrics_count": 0,
|
||||||
|
"alerts_count": 0
|
||||||
|
}
|
||||||
|
# проверяем существование файла
|
||||||
|
if not os.path.exists(csv_file_path):
|
||||||
|
logger.error(f"Файл не найден: {csv_file_path}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"errors": ["Файл не найден"],
|
||||||
|
"metrics_count": 0,
|
||||||
|
"alerts_count": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#! шаг 1: извлекаем имя мультиплексора из имени файла
|
||||||
|
file_name = Path(csv_file_path).stem # получаем имя файла без расширения
|
||||||
|
|
||||||
|
# ищем мультиплексор в базе данных по имени
|
||||||
|
try:
|
||||||
|
multiplexor = Multiplexor.objects.get(name=file_name)
|
||||||
|
except Multiplexor.DoesNotExist:
|
||||||
|
logger.error(f"Мультиплексор с именем {file_name} не найден")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"errors": [f"Мультиплексор с именем {file_name} не найден"],
|
||||||
|
"metrics_count": 0,
|
||||||
|
"alerts_count": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#!TODO -- читать Vmux (напряжение мультиплексора) Tmux (температура мультиплексора) и записывать в базу
|
||||||
|
|
||||||
|
#! шаг 2: проверяем каналы
|
||||||
|
try:
|
||||||
|
metrics_count = 0
|
||||||
|
alerts_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
with open(csv_file_path, 'r') as csvfile:
|
||||||
|
csv_reader = csv.DictReader(csvfile)
|
||||||
|
# получаем заголовки CSV - они должны содержать номера каналов
|
||||||
|
headers = csv_reader.fieldnames
|
||||||
|
if not headers:
|
||||||
|
logger.error("CSV файл пуст или неверного формата")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"errors": ["CSV файл пуст или неверного формата"],
|
||||||
|
"metrics_count": 0,
|
||||||
|
"alerts_count": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# получаем список каналов из CSV
|
||||||
|
csv_channels = [h for h in headers if h.startswith('CH-')]
|
||||||
|
|
||||||
|
# получаем каналы мультиплексора из базы
|
||||||
|
db_channels = Channel.objects.filter(multiplexor=multiplexor)
|
||||||
|
db_channel_numbers = set(ch.number for ch in db_channels)
|
||||||
|
|
||||||
|
# проверяем соответствие каналов
|
||||||
|
csv_channel_numbers = set(int(ch.split('-')[1]) for ch in csv_channels)
|
||||||
|
missing_channels = db_channel_numbers - csv_channel_numbers
|
||||||
|
extra_channels = csv_channel_numbers - db_channel_numbers
|
||||||
|
|
||||||
|
if missing_channels or extra_channels:
|
||||||
|
error_msg = []
|
||||||
|
if missing_channels:
|
||||||
|
error_msg.append(f"Отсутствуют каналы: {', '.join(f'CH-{num}' for num in missing_channels)}")
|
||||||
|
if extra_channels:
|
||||||
|
error_msg.append(f"Лишние каналы: {', '.join(f'CH-{num}' for num in extra_channels)}")
|
||||||
|
logger.error(". ".join(error_msg))
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"errors": error_msg,
|
||||||
|
"metrics_count": 0,
|
||||||
|
"alerts_count": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#! шаг 3: проверяем датчики в каналах
|
||||||
|
for row in csv_reader:
|
||||||
|
# если нет timestamp в CSV, используем текущее время
|
||||||
|
timestamp = row.get('timestamp', datetime.now().isoformat())
|
||||||
|
|
||||||
|
for channel_header in csv_channels:
|
||||||
|
channel_number = int(channel_header.split('-')[1])
|
||||||
|
value = row[channel_header]
|
||||||
|
|
||||||
|
# получаем канал и его датчики из базы
|
||||||
|
try:
|
||||||
|
channel = db_channels.get(number=channel_number)
|
||||||
|
sensors = Sensor.objects.filter(channel=channel)
|
||||||
|
|
||||||
|
if not sensors.exists():
|
||||||
|
errors.append(f"Канал CH-{channel_number} не имеет настроенных датчиков")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# проверяем значение для каждого датчика в канале ++ TODO полуканальность
|
||||||
|
for sensor in sensors:
|
||||||
|
try:
|
||||||
|
float_value = float(value)
|
||||||
|
|
||||||
|
|
||||||
|
#!TODO -- математические формулы для rowdata
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# создаем метрику для каждого значения
|
||||||
|
metric = Metric.objects.create(
|
||||||
|
sensor=sensor,
|
||||||
|
value=float_value,
|
||||||
|
timestamp=timestamp
|
||||||
|
)
|
||||||
|
metrics_count += 1
|
||||||
|
|
||||||
|
# проверяем пороговые значения, если они заданы
|
||||||
|
if sensor.sensor_type.min_value is not None and float_value < float(sensor.sensor_type.min_value):
|
||||||
|
message = f"Значение {value} в канале CH-{channel_number} ниже минимального порога {sensor.sensor_type.min_value}"
|
||||||
|
errors.append(f"{message} для датчика {sensor.name or sensor.sensor_type.code}")
|
||||||
|
|
||||||
|
# создаем алерт для нижнего порога
|
||||||
|
Alert.objects.create(
|
||||||
|
sensor=sensor,
|
||||||
|
metric=metric,
|
||||||
|
sensor_type=sensor.sensor_type,
|
||||||
|
message=message,
|
||||||
|
severity="critical" if float_value < float(sensor.sensor_type.min_value) * 0.9 else "warning"
|
||||||
|
)
|
||||||
|
alerts_count += 1
|
||||||
|
|
||||||
|
if sensor.sensor_type.max_value is not None and float_value > float(sensor.sensor_type.max_value):
|
||||||
|
message = f"Значение {value} в канале CH-{channel_number} выше максимального порога {sensor.sensor_type.max_value}"
|
||||||
|
errors.append(f"{message} для датчика {sensor.name or sensor.sensor_type.code}")
|
||||||
|
|
||||||
|
# создаем алерт для верхнего порога
|
||||||
|
Alert.objects.create(
|
||||||
|
sensor=sensor,
|
||||||
|
metric=metric,
|
||||||
|
sensor_type=sensor.sensor_type,
|
||||||
|
message=message,
|
||||||
|
severity="critical" if float_value > float(sensor.sensor_type.max_value) * 1.1 else "warning"
|
||||||
|
)
|
||||||
|
alerts_count += 1
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f"Некорректное значение {value} в канале CH-{channel_number}")
|
||||||
|
|
||||||
|
except Channel.DoesNotExist:
|
||||||
|
errors.append(f"Канал CH-{channel_number} не найден в базе данных")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"errors": errors,
|
||||||
|
"metrics_count": metrics_count,
|
||||||
|
"alerts_count": alerts_count
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при чтении CSV файла: {str(e)}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"errors": [f"Ошибка при чтении CSV файла: {str(e)}"],
|
||||||
|
"metrics_count": metrics_count,
|
||||||
|
"alerts_count": alerts_count
|
||||||
|
}
|
||||||
12
backend/scripts/parse_multiplexor_cron.sh
Executable file
12
backend/scripts/parse_multiplexor_cron.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cd /Users/timofey/Desktop/aerbim-www/backend
|
||||||
|
|
||||||
|
# активация виртуального окружения
|
||||||
|
source /path/to/virtualenv/bin/activate #!! замени в проде на реальный путь
|
||||||
|
|
||||||
|
# установка переменных окружения
|
||||||
|
export DJANGO_SETTINGS_MODULE=base.settings
|
||||||
|
|
||||||
|
# запуск команды
|
||||||
|
python manage.py parse_multiplexor_data --data-dir=/path/to/csv/files >> /var/log/aerbim/multiplexor_parser.log 2>&1 #!! замени в проде на реальный путь
|
||||||
@@ -28,4 +28,4 @@ class SignalFormatAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Sensor)
|
@admin.register(Sensor)
|
||||||
class SensorAdmin(admin.ModelAdmin):
|
class SensorAdmin(admin.ModelAdmin):
|
||||||
list_display = ('channel', 'sensor_type', 'signal_format', 'serial_number', 'name')
|
list_display = ('channel', 'sensor_type', 'signal_format', 'serial_number', 'name', 'math_formula')
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-09-04 06:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sitemanagement', '0003_metric_sitemanagem_timesta_ac22b7_idx_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sensor',
|
||||||
|
name='math_formula',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -85,6 +85,8 @@ class Sensor(models.Model):
|
|||||||
name = models.CharField(max_length=50, blank=True, null=True) # GA-1, HLE-1 и т.п.
|
name = models.CharField(max_length=50, blank=True, null=True) # GA-1, HLE-1 и т.п.
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
math_formula = models.CharField(null=True, blank=True, max_length=255)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Датчик"
|
verbose_name = "Датчик"
|
||||||
verbose_name_plural = "Датчики"
|
verbose_name_plural = "Датчики"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 2.9 KiB |
Reference in New Issue
Block a user