diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py index 5368a66..64f256b 100644 --- a/backend/app/api/alerts.py +++ b/backend/app/api/alerts.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Query +from app.core.db import DB from app.core.errors import NOT_FOUND, equipment_not_found from app.schemas.alert import AlertOut from app.services import alert_service, equipment_service @@ -8,14 +9,14 @@ @router.get("", response_model=list[AlertOut], summary="Список последних алертов") -def list_alerts(limit: int = Query(50, ge=1, le=500, description="Максимум записей")): - return alert_service.list_alerts(limit=limit) +def list_alerts(db: DB, limit: int = Query(50, ge=1, le=500, description="Максимум записей")): + return alert_service.list_alerts(db, limit=limit) @router.get("/{equipment_id}", response_model=list[AlertOut], responses=NOT_FOUND, summary="Алерты по конкретному оборудованию") -def alerts_by_equipment(equipment_id: int, +def alerts_by_equipment(equipment_id: int, db: DB, limit: int = Query(50, ge=1, le=500, description="Максимум записей")): - if equipment_service.get_equipment(equipment_id) is None: + if not equipment_service.equipment_exists(db, equipment_id): raise equipment_not_found(equipment_id) - return alert_service.list_alerts(equipment_id, limit) + return alert_service.list_alerts(db, equipment_id, limit) diff --git a/backend/app/api/equipment.py b/backend/app/api/equipment.py index 41c0d1f..d82907e 100644 --- a/backend/app/api/equipment.py +++ b/backend/app/api/equipment.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, HTTPException, Query +from app.core.db import DB from app.core.errors import NOT_FOUND, equipment_not_found from app.schemas.equipment import EquipmentOut, ForecastOut, ReadingOut, StatusOut from app.services import equipment_service @@ -14,34 +15,34 @@ def _found(value, equipment_id: int): @router.get("", response_model=list[EquipmentOut], summary="Список всех единиц оборудования") -def list_equipment(): - return equipment_service.list_equipment() +def list_equipment(db: DB): + return equipment_service.list_equipment(db) @router.get("/{equipment_id}", response_model=EquipmentOut, responses=NOT_FOUND, summary="Информация об одной единице") -def get_equipment(equipment_id: int): - return _found(equipment_service.get_equipment(equipment_id), equipment_id) +def get_equipment(equipment_id: int, db: DB): + return _found(equipment_service.get_equipment(db, equipment_id), equipment_id) @router.get("/{equipment_id}/status", response_model=StatusOut, responses=NOT_FOUND, summary="Текущее состояние: статус, RUL, вероятность дефекта, невязка") -def get_status(equipment_id: int): - return _found(equipment_service.get_status(equipment_id), equipment_id) +def get_status(equipment_id: int, db: DB): + return _found(equipment_service.get_status(db, equipment_id), equipment_id) @router.get("/{equipment_id}/history", response_model=list[ReadingOut], responses=NOT_FOUND, summary="История показаний датчиков за период") -def get_history(equipment_id: int, +def get_history(equipment_id: int, db: DB, from_cycle: int | None = Query(None, ge=0, description="С какого цикла (включительно)"), to_cycle: int | None = Query(None, ge=0, description="По какой цикл (включительно)"), limit: int = Query(500, ge=1, le=5000, description="Максимум записей (берутся последние)")): if from_cycle is not None and to_cycle is not None and from_cycle > to_cycle: raise HTTPException(status_code=400, detail="from_cycle не может быть больше to_cycle") - return _found(equipment_service.get_history(equipment_id, from_cycle, to_cycle, limit), equipment_id) + return _found(equipment_service.get_history(db, equipment_id, from_cycle, to_cycle, limit), equipment_id) @router.get("/{equipment_id}/forecast", response_model=ForecastOut, responses=NOT_FOUND, summary="Прогноз деградации: RUL по будущим циклам с доверительным интервалом") -def get_forecast(equipment_id: int): - return _found(equipment_service.get_forecast(equipment_id), equipment_id) +def get_forecast(equipment_id: int, db: DB): + return _found(equipment_service.get_forecast(db, equipment_id), equipment_id) diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py index 8def7ab..973f81f 100644 --- a/backend/app/api/predict.py +++ b/backend/app/api/predict.py @@ -1,5 +1,6 @@ from fastapi import APIRouter +from app.core.db import DB from app.core.errors import NOT_FOUND, equipment_not_found from app.schemas.equipment import StatusOut from app.schemas.predict import PredictIn @@ -9,8 +10,8 @@ @router.post("", response_model=StatusOut, responses=NOT_FOUND, summary="Ручной запрос инференса") -def predict(body: PredictIn): - result = predict_service.run_prediction(body.equipment_id) +def predict(body: PredictIn, db: DB): + result = predict_service.run_prediction(db, body.equipment_id) if result is None: raise equipment_not_found(body.equipment_id) return result diff --git a/backend/app/core/db.py b/backend/app/core/db.py index 3a60ede..17f90e1 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,6 +1,22 @@ +import os +from typing import Annotated + +from dotenv import load_dotenv +from fastapi import Depends +from sqlalchemy import Connection, create_engine from sqlalchemy.orm import DeclarativeBase +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL") + +engine = create_engine(DATABASE_URL) class Base(DeclarativeBase): pass +def get_db(): + with engine.connect() as connection: + yield connection + +DB = Annotated[Connection, Depends(get_db)] diff --git a/backend/app/services/alert_service.py b/backend/app/services/alert_service.py index 2cbeb6d..02bead6 100644 --- a/backend/app/services/alert_service.py +++ b/backend/app/services/alert_service.py @@ -1,6 +1,12 @@ -from app.services.mock_data import ALERTS +from sqlalchemy import Connection, select +from app.models.alerts import Alert -def list_alerts(equipment_id: int | None = None, limit: int = 50) -> list[dict]: - items = [a for a in ALERTS if equipment_id is None or a["equipment_id"] == equipment_id] - return items[:limit] + +def list_alerts(db: Connection, equipment_id: int | None = None, limit: int = 50) -> list[dict]: + query = select(Alert) + if equipment_id is not None: + query = query.where(Alert.equipment_id == equipment_id) + query = query.order_by(Alert.triggered_at.desc()).limit(limit) + + return [dict(row) for row in db.execute(query).mappings()] diff --git a/backend/app/services/equipment_service.py b/backend/app/services/equipment_service.py index 51a1331..556c0b0 100644 --- a/backend/app/services/equipment_service.py +++ b/backend/app/services/equipment_service.py @@ -1,47 +1,128 @@ -import math +from sqlalchemy import Connection, and_, func, select -from app.services.mock_data import EQUIPMENT, READINGS +from app.core.config import RUL_CLIP, RUL_CRITICAL, RUL_WARNING +from app.models.equipment import Equipment +from app.models.ml_predictions import MlPrediction +from app.models.sensor_readings import SensorReading +SENSOR_CODES = ["s2", "s3", "s4", "s7", "s8", "s9", "s11", "s12", "s13", "s14", "s15", "s17", "s20", "s21"] -def list_equipment() -> list[dict]: - return list(EQUIPMENT.values()) +def status_from_rul(rul: float) -> str: + if rul <= RUL_CRITICAL: + return "critical" + if rul <= RUL_WARNING: + return "warning" + return "normal" -def get_equipment(equipment_id: int) -> dict | None: - return EQUIPMENT.get(equipment_id) +def equipment_exists(db: Connection, equipment_id: int) -> bool: + query = select(Equipment.id).where(Equipment.id == equipment_id) + return db.execute(query).first() is not None -def get_status(equipment_id: int) -> dict | None: - e = EQUIPMENT.get(equipment_id) - if e is None: + +def _latest_predictions(db: Connection, equipment_id: int | None = None) -> list[dict]: + """Оборудование + его последний прогноз (по максимальному циклу).""" + last = ( + select(MlPrediction.equipment_id, func.max(MlPrediction.cycle).label("cycle")) + .group_by(MlPrediction.equipment_id) + .subquery() + ) + + query = ( + select( + Equipment.id, + Equipment.name, + Equipment.type, + Equipment.unit_number, + MlPrediction.cycle.label("current_cycle"), + MlPrediction.predicted_rul, + MlPrediction.defect_probability, + MlPrediction.residual, + ) + .outerjoin(last, last.c.equipment_id == Equipment.id) + .outerjoin( + MlPrediction, + and_(MlPrediction.equipment_id == last.c.equipment_id, MlPrediction.cycle == last.c.cycle), + ) + .order_by(Equipment.id) + ) + if equipment_id is not None: + query = query.where(Equipment.id == equipment_id) + + items = [] + for row in db.execute(query).mappings(): + item = dict(row) + if item["predicted_rul"] is None: + item["current_cycle"] = 0 + item["predicted_rul"] = float(RUL_CLIP) + item["defect_probability"] = 0.0 + item["residual"] = 0.0 + item["status"] = status_from_rul(item["predicted_rul"]) + items.append(item) + return items + + +def _to_equipment_out(item: dict) -> dict: + keys = ("id", "name", "type", "unit_number", "current_cycle", "status", "predicted_rul") + return {k: item[k] for k in keys} + + +def list_equipment(db: Connection) -> list[dict]: + return [_to_equipment_out(item) for item in _latest_predictions(db)] + + +def get_equipment(db: Connection, equipment_id: int) -> dict | None: + items = _latest_predictions(db, equipment_id) + return _to_equipment_out(items[0]) if items else None + + +def get_status(db: Connection, equipment_id: int) -> dict | None: + items = _latest_predictions(db, equipment_id) + if not items: return None - rul = e["predicted_rul"] + e = items[0] return { - "equipment_id": equipment_id, "status": e["status"], "current_cycle": e["current_cycle"], - "predicted_rul": rul, "residual": e["residual"], - "defect_probability": round(1 / (1 + math.exp((rul - 50) / 12)), 3), + "equipment_id": e["id"], + "status": e["status"], + "current_cycle": e["current_cycle"], + "predicted_rul": e["predicted_rul"], + "defect_probability": e["defect_probability"], + "residual": e["residual"], } -def get_history(equipment_id: int, from_cycle: int | None, to_cycle: int | None, +def get_history(db: Connection, equipment_id: int, from_cycle: int | None, to_cycle: int | None, limit: int) -> list[dict] | None: - if equipment_id not in EQUIPMENT: + if not equipment_exists(db, equipment_id): return None - rows = [r for r in READINGS[equipment_id] - if (from_cycle is None or r["cycle"] >= from_cycle) - and (to_cycle is None or r["cycle"] <= to_cycle)] - return rows[-limit:] + + columns = [getattr(SensorReading, code) for code in SENSOR_CODES] + query = select(SensorReading.cycle, *columns).where(SensorReading.equipment_id == equipment_id) + if from_cycle is not None: + query = query.where(SensorReading.cycle >= from_cycle) + if to_cycle is not None: + query = query.where(SensorReading.cycle <= to_cycle) + query = query.order_by(SensorReading.cycle.desc()).limit(limit) + + rows = [] + for row in db.execute(query).mappings(): + sensors = {code: row[code] for code in SENSOR_CODES if row[code] is not None} + rows.append({"cycle": row["cycle"], "sensors": sensors}) + rows.reverse() + return rows -def get_forecast(equipment_id: int) -> dict | None: - e = EQUIPMENT.get(equipment_id) - if e is None: +def get_forecast(db: Connection, equipment_id: int) -> dict | None: + items = _latest_predictions(db, equipment_id) + if not items: return None + e = items[0] rul, cycle = e["predicted_rul"], e["current_cycle"] - horizon = int(rul) + horizon = int(rul) points = [] for k in range(horizon + 1): - value, spread = rul - k, 2 + 0.12 * k + value, spread = rul - k, 2 + 0.12 * k points.append({"cycle": cycle + k, "rul": round(value, 1), "rul_low": round(max(0.0, value - spread), 1), "rul_high": round(value + spread, 1)}) return {"equipment_id": equipment_id, "current_cycle": cycle, "predicted_rul": rul, diff --git a/backend/app/services/mock_data.py b/backend/app/services/mock_data.py deleted file mode 100644 index bad233b..0000000 --- a/backend/app/services/mock_data.py +++ /dev/null @@ -1,62 +0,0 @@ -import random -from datetime import UTC, datetime, timedelta - -from app.core.config import RUL_CLIP, RUL_CRITICAL, RUL_WARNING - -SENSORS = { - "s2": (641.82, 0.50, 1), "s3": (1589.70, 6.13, 1), "s4": (1400.60, 9.00, 1), - "s7": (554.36, 0.89, -1), "s8": (2388.06, 0.07, 1), "s9": (9046.19, 22.08, 1), - "s11": (47.47, 0.27, 1), "s12": (521.66, 0.74, -1), "s13": (2388.02, 0.07, 1), - "s14": (8138.62, 19.08, 1), "s15": (8.4195, 0.04, 1), "s17": (392.0, 1.55, 1), - "s20": (39.06, 0.18, -1), "s21": (23.419, 0.11, -1), -} - -_rnd = random.Random(42) - -EQUIPMENT: dict[int, dict] = {} -READINGS: dict[int, list[dict]] = {} -ALERTS: list[dict] = [] - - -def status_from_rul(rul: float) -> str: - if rul <= RUL_CRITICAL: - return "critical" - if rul <= RUL_WARNING: - return "warning" - return "normal" - - -def noisy_rul(true_rul: float) -> float: - return round(min(max(min(true_rul, RUL_CLIP) + _rnd.gauss(0, 4), 0), RUL_CLIP), 1) - - -def _build() -> None: - now = datetime.now(UTC) - for i in range(1, 13): - life = _rnd.randint(130, 300) - cut = int(life * _rnd.uniform(0.35, 0.97)) - READINGS[i] = [ - {"cycle": c, "sensors": { - code: round(base + trend * std * 3 * (c / life) ** 2 + _rnd.gauss(0, std * 0.4), 4) - for code, (base, std, trend) in SENSORS.items()}} - for c in range(1, cut + 1) - ] - predicted = noisy_rul(life - cut) - EQUIPMENT[i] = { - "id": i, "name": f"ТРДД-{i:03d}", "type": "Турбовентиляторный двигатель", - "unit_number": i, "current_cycle": cut, "predicted_rul": predicted, - "status": status_from_rul(predicted), "residual": round(_rnd.gauss(0, 0.5), 3), - "true_rul": life - cut, - } - for threshold, severity, text in ( - (RUL_WARNING, "warning", "Остаточный ресурс ниже 75 циклов — запланируйте обслуживание"), - (RUL_CRITICAL, "critical", "Остаточный ресурс ниже 30 циклов — требуется срочная проверка"), - ): - if life - threshold <= cut: - ALERTS.append({"id": len(ALERTS) + 1, "equipment_id": i, "cycle": life - threshold, - "triggered_at": now - timedelta(seconds=30 * (cut - (life - threshold))), - "severity": severity, "message": text}) - ALERTS.sort(key=lambda a: a["triggered_at"], reverse=True) - - -_build() diff --git a/backend/app/services/predict_service.py b/backend/app/services/predict_service.py index c24e6f4..57bacd9 100644 --- a/backend/app/services/predict_service.py +++ b/backend/app/services/predict_service.py @@ -1,11 +1,7 @@ +from sqlalchemy import Connection + from app.services import equipment_service -from app.services.mock_data import EQUIPMENT, noisy_rul, status_from_rul -def run_prediction(equipment_id: int) -> dict | None: - e = EQUIPMENT.get(equipment_id) - if e is None: - return None - e["predicted_rul"] = noisy_rul(e["true_rul"]) - e["status"] = status_from_rul(e["predicted_rul"]) - return equipment_service.get_status(equipment_id) +def run_prediction(db: Connection, equipment_id: int) -> dict | None: + return equipment_service.get_status(db, equipment_id)