diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py index bd81fba..5368a66 100644 --- a/backend/app/api/alerts.py +++ b/backend/app/api/alerts.py @@ -1,5 +1,6 @@ -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Query +from app.core.errors import NOT_FOUND, equipment_not_found from app.schemas.alert import AlertOut from app.services import alert_service, equipment_service @@ -7,12 +8,14 @@ @router.get("", response_model=list[AlertOut], summary="Список последних алертов") -def list_alerts(limit: int = Query(50, ge=1, le=500)): +def list_alerts(limit: int = Query(50, ge=1, le=500, description="Максимум записей")): return alert_service.list_alerts(limit=limit) -@router.get("/{equipment_id}", response_model=list[AlertOut], summary="Алерты по оборудованию") -def alerts_by_equipment(equipment_id: int, limit: int = Query(50, ge=1, le=500)): +@router.get("/{equipment_id}", response_model=list[AlertOut], responses=NOT_FOUND, + summary="Алерты по конкретному оборудованию") +def alerts_by_equipment(equipment_id: int, + limit: int = Query(50, ge=1, le=500, description="Максимум записей")): if equipment_service.get_equipment(equipment_id) is None: - raise HTTPException(status_code=404, detail="Оборудование не найдено") + raise equipment_not_found(equipment_id) return alert_service.list_alerts(equipment_id, limit) diff --git a/backend/app/api/equipment.py b/backend/app/api/equipment.py index 673f7c1..41c0d1f 100644 --- a/backend/app/api/equipment.py +++ b/backend/app/api/equipment.py @@ -1,14 +1,15 @@ from fastapi import APIRouter, HTTPException, Query +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 router = APIRouter(prefix="/equipment", tags=["Оборудование"]) -def _or_404(value): +def _found(value, equipment_id: int): if value is None: - raise HTTPException(status_code=404, detail="Оборудование не найдено") + raise equipment_not_found(equipment_id) return value @@ -17,25 +18,30 @@ def list_equipment(): return equipment_service.list_equipment() -@router.get("/{equipment_id}", response_model=EquipmentOut, summary="Информация об одной единице") +@router.get("/{equipment_id}", response_model=EquipmentOut, responses=NOT_FOUND, + summary="Информация об одной единице") def get_equipment(equipment_id: int): - return _or_404(equipment_service.get_equipment(equipment_id)) + return _found(equipment_service.get_equipment(equipment_id), equipment_id) -@router.get("/{equipment_id}/status", response_model=StatusOut, summary="Текущее состояние") +@router.get("/{equipment_id}/status", response_model=StatusOut, responses=NOT_FOUND, + summary="Текущее состояние: статус, RUL, вероятность дефекта, невязка") def get_status(equipment_id: int): - return _or_404(equipment_service.get_status(equipment_id)) + return _found(equipment_service.get_status(equipment_id), equipment_id) -@router.get("/{equipment_id}/history", response_model=list[ReadingOut], - summary="История показаний датчиков") +@router.get("/{equipment_id}/history", response_model=list[ReadingOut], responses=NOT_FOUND, + summary="История показаний датчиков за период") def get_history(equipment_id: int, - from_cycle: int | None = Query(None, ge=0), - to_cycle: int | None = Query(None, ge=0), - limit: int = Query(500, ge=1, le=5000)): - return _or_404(equipment_service.get_history(equipment_id, from_cycle, to_cycle, limit)) + 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) -@router.get("/{equipment_id}/forecast", response_model=ForecastOut, summary="Прогноз деградации") +@router.get("/{equipment_id}/forecast", response_model=ForecastOut, responses=NOT_FOUND, + summary="Прогноз деградации: RUL по будущим циклам с доверительным интервалом") def get_forecast(equipment_id: int): - return _or_404(equipment_service.get_forecast(equipment_id)) + return _found(equipment_service.get_forecast(equipment_id), equipment_id) diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py index 312cd59..8def7ab 100644 --- a/backend/app/api/predict.py +++ b/backend/app/api/predict.py @@ -1,5 +1,6 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter +from app.core.errors import NOT_FOUND, equipment_not_found from app.schemas.equipment import StatusOut from app.schemas.predict import PredictIn from app.services import predict_service @@ -7,9 +8,9 @@ router = APIRouter(prefix="/predict", tags=["Прогнозирование"]) -@router.post("", response_model=StatusOut, summary="Ручной запрос инференса") +@router.post("", response_model=StatusOut, responses=NOT_FOUND, summary="Ручной запрос инференса") def predict(body: PredictIn): result = predict_service.run_prediction(body.equipment_id) if result is None: - raise HTTPException(status_code=404, detail="Оборудование не найдено") + raise equipment_not_found(body.equipment_id) return result diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..bc59196 --- /dev/null +++ b/backend/app/core/errors.py @@ -0,0 +1,8 @@ +from fastapi import HTTPException + + +def equipment_not_found(equipment_id: int) -> HTTPException: + return HTTPException(status_code=404, detail=f"Оборудование с id={equipment_id} не найдено") + + +NOT_FOUND = {404: {"description": "Оборудование не найдено"}} diff --git a/backend/app/schemas/equipment.py b/backend/app/schemas/equipment.py index a7f16cb..b3b79e4 100644 --- a/backend/app/schemas/equipment.py +++ b/backend/app/schemas/equipment.py @@ -1,6 +1,6 @@ from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field Status = Literal["normal", "warning", "critical"] @@ -12,33 +12,33 @@ class EquipmentOut(BaseModel): unit_number: int current_cycle: int status: Status - predicted_rul: float + predicted_rul: float = Field(description="Прогноз остаточного ресурса, циклов") class StatusOut(BaseModel): equipment_id: int - status: Status + status: Status = Field(description="normal: RUL > 75, warning: 31–75, critical: ≤ 30 циклов") current_cycle: int - predicted_rul: float - defect_probability: float - residual: float + predicted_rul: float = Field(description="Прогноз остаточного ресурса, циклов") + defect_probability: float = Field(ge=0, le=1, description="Вероятность дефекта, 0–1") + residual: float = Field(description="Невязка: отклонение от физически ожидаемого поведения") class ReadingOut(BaseModel): cycle: int - sensors: dict[str, float] + sensors: dict[str, float] = Field(description="Показания 14 рабочих датчиков: код → значение") class ForecastPoint(BaseModel): cycle: int - rul: float - rul_low: float - rul_high: float + rul: float = Field(description="Ожидаемый остаточный ресурс на этом цикле") + rul_low: float = Field(description="Нижняя граница доверительного интервала") + rul_high: float = Field(description="Верхняя граница доверительного интервала") class ForecastOut(BaseModel): equipment_id: int current_cycle: int predicted_rul: float - predicted_failure_cycle: int - points: list[ForecastPoint] + predicted_failure_cycle: int = Field(description="Цикл, на котором ожидается отказ") + points: list[ForecastPoint] = Field(description="От текущего цикла до ожидаемого отказа, шаг — 1 цикл") diff --git a/backend/app/services/equipment_service.py b/backend/app/services/equipment_service.py index 3297815..51a1331 100644 --- a/backend/app/services/equipment_service.py +++ b/backend/app/services/equipment_service.py @@ -38,10 +38,10 @@ def get_forecast(equipment_id: int) -> dict | None: if e is None: return None rul, cycle = e["predicted_rul"], e["current_cycle"] - horizon = round(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 index 2163c94..bad233b 100644 --- a/backend/app/services/mock_data.py +++ b/backend/app/services/mock_data.py @@ -34,7 +34,7 @@ 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)) + 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)