From 9450725cd62fefe798424406d1985b9caf70306b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A4=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B4=D1=83=D0=BD?= Date: Tue, 3 Nov 2026 00:37:10 +0300 Subject: [PATCH 1/3] fast api --- backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/alerts.py | 18 +++++++ backend/app/api/equipment.py | 41 ++++++++++++++ backend/app/api/predict.py | 15 ++++++ backend/app/core/__init__.py | 0 backend/app/core/config.py | 9 ++++ backend/app/schemas/__init__.py | 0 backend/app/schemas/alert.py | 13 +++++ backend/app/schemas/equipment.py | 44 +++++++++++++++ backend/app/schemas/predict.py | 5 ++ backend/app/services/__init__.py | 0 backend/app/services/alert_service.py | 6 +++ backend/app/services/equipment_service.py | 48 +++++++++++++++++ backend/app/services/mock_data.py | 66 +++++++++++++++++++++++ backend/app/services/predict_service.py | 12 +++++ backend/main.py | 14 +++++ backend/requirements.txt | 2 + 18 files changed, 293 insertions(+) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/alerts.py create mode 100644 backend/app/api/equipment.py create mode 100644 backend/app/api/predict.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/alert.py create mode 100644 backend/app/schemas/equipment.py create mode 100644 backend/app/schemas/predict.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/alert_service.py create mode 100644 backend/app/services/equipment_service.py create mode 100644 backend/app/services/mock_data.py create mode 100644 backend/app/services/predict_service.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py new file mode 100644 index 0000000..bd81fba --- /dev/null +++ b/backend/app/api/alerts.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, HTTPException, Query + +from app.schemas.alert import AlertOut +from app.services import alert_service, equipment_service + +router = APIRouter(prefix="/alerts", tags=["Алерты"]) + + +@router.get("", response_model=list[AlertOut], summary="Список последних алертов") +def list_alerts(limit: int = Query(50, ge=1, le=500)): + 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)): + if equipment_service.get_equipment(equipment_id) is None: + raise HTTPException(status_code=404, detail="Оборудование не найдено") + return alert_service.list_alerts(equipment_id, limit) diff --git a/backend/app/api/equipment.py b/backend/app/api/equipment.py new file mode 100644 index 0000000..673f7c1 --- /dev/null +++ b/backend/app/api/equipment.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, HTTPException, Query + +from app.schemas.equipment import EquipmentOut, ForecastOut, ReadingOut, StatusOut +from app.services import equipment_service + +router = APIRouter(prefix="/equipment", tags=["Оборудование"]) + + +def _or_404(value): + if value is None: + raise HTTPException(status_code=404, detail="Оборудование не найдено") + return value + + +@router.get("", response_model=list[EquipmentOut], summary="Список всех единиц оборудования") +def list_equipment(): + return equipment_service.list_equipment() + + +@router.get("/{equipment_id}", response_model=EquipmentOut, summary="Информация об одной единице") +def get_equipment(equipment_id: int): + return _or_404(equipment_service.get_equipment(equipment_id)) + + +@router.get("/{equipment_id}/status", response_model=StatusOut, summary="Текущее состояние") +def get_status(equipment_id: int): + return _or_404(equipment_service.get_status(equipment_id)) + + +@router.get("/{equipment_id}/history", response_model=list[ReadingOut], + 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)) + + +@router.get("/{equipment_id}/forecast", response_model=ForecastOut, summary="Прогноз деградации") +def get_forecast(equipment_id: int): + return _or_404(equipment_service.get_forecast(equipment_id)) diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py new file mode 100644 index 0000000..312cd59 --- /dev/null +++ b/backend/app/api/predict.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter, HTTPException + +from app.schemas.equipment import StatusOut +from app.schemas.predict import PredictIn +from app.services import predict_service + +router = APIRouter(prefix="/predict", tags=["Прогнозирование"]) + + +@router.post("", response_model=StatusOut, summary="Ручной запрос инференса") +def predict(body: PredictIn): + result = predict_service.run_prediction(body.equipment_id) + if result is None: + raise HTTPException(status_code=404, detail="Оборудование не найдено") + return result diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..e264804 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,9 @@ +import os + +# Адреса фронтенда, которым разрешены запросы (через запятую в переменной окружения) +CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",") + +# Пороги статуса по прогнозу RUL (в циклах) +RUL_WARNING = 75 +RUL_CRITICAL = 30 +RUL_CLIP = 125 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/alert.py b/backend/app/schemas/alert.py new file mode 100644 index 0000000..b773957 --- /dev/null +++ b/backend/app/schemas/alert.py @@ -0,0 +1,13 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel + + +class AlertOut(BaseModel): + id: int + equipment_id: int + cycle: int + triggered_at: datetime + severity: Literal["info", "warning", "critical"] + message: str diff --git a/backend/app/schemas/equipment.py b/backend/app/schemas/equipment.py new file mode 100644 index 0000000..a7f16cb --- /dev/null +++ b/backend/app/schemas/equipment.py @@ -0,0 +1,44 @@ +from typing import Literal + +from pydantic import BaseModel + +Status = Literal["normal", "warning", "critical"] + + +class EquipmentOut(BaseModel): + id: int + name: str + type: str + unit_number: int + current_cycle: int + status: Status + predicted_rul: float + + +class StatusOut(BaseModel): + equipment_id: int + status: Status + current_cycle: int + predicted_rul: float + defect_probability: float + residual: float + + +class ReadingOut(BaseModel): + cycle: int + sensors: dict[str, float] + + +class ForecastPoint(BaseModel): + cycle: int + rul: float + rul_low: float + rul_high: float + + +class ForecastOut(BaseModel): + equipment_id: int + current_cycle: int + predicted_rul: float + predicted_failure_cycle: int + points: list[ForecastPoint] diff --git a/backend/app/schemas/predict.py b/backend/app/schemas/predict.py new file mode 100644 index 0000000..2e90adf --- /dev/null +++ b/backend/app/schemas/predict.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + + +class PredictIn(BaseModel): + equipment_id: int = Field(ge=1) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/alert_service.py b/backend/app/services/alert_service.py new file mode 100644 index 0000000..2cbeb6d --- /dev/null +++ b/backend/app/services/alert_service.py @@ -0,0 +1,6 @@ +from app.services.mock_data import ALERTS + + +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] diff --git a/backend/app/services/equipment_service.py b/backend/app/services/equipment_service.py new file mode 100644 index 0000000..9b33366 --- /dev/null +++ b/backend/app/services/equipment_service.py @@ -0,0 +1,48 @@ +import math + +from app.services.mock_data import EQUIPMENT, READINGS + + +def list_equipment() -> list[dict]: + return list(EQUIPMENT.values()) + + +def get_equipment(equipment_id: int) -> dict | None: + return EQUIPMENT.get(equipment_id) + + +def get_status(equipment_id: int) -> dict | None: + e = EQUIPMENT.get(equipment_id) + if e is None: + return None + rul = e["predicted_rul"] + 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), + } + + +def get_history(equipment_id: int, from_cycle: int | None, to_cycle: int | None, + limit: int) -> list[dict] | None: + if equipment_id not in EQUIPMENT: + 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:] + + +def get_forecast(equipment_id: int) -> dict | None: + e = EQUIPMENT.get(equipment_id) + if e is None: + return None + rul, cycle = e["predicted_rul"], e["current_cycle"] + horizon = round(rul) + points = [] + for k in range(horizon + 1): + 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, + "predicted_failure_cycle": cycle + horizon, "points": points} diff --git a/backend/app/services/mock_data.py b/backend/app/services/mock_data.py new file mode 100644 index 0000000..2016262 --- /dev/null +++ b/backend/app/services/mock_data.py @@ -0,0 +1,66 @@ +"""Временный источник данных в памяти (пока нет БД). Данные похожи на FD001. +Когда появится PostgreSQL — сервисы начнут читать из БД, а этот файл можно удалить.""" +import random +from datetime import datetime, timedelta, timezone + +from app.core.config import RUL_CLIP, RUL_CRITICAL, RUL_WARNING + +# 14 рабочих датчиков FD001: код -> (базовое значение, std, тренд при износе) +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: + """Имитация ответа модели: истинный RUL + шум.""" + return round(min(max(min(true_rul, RUL_CLIP) + _rnd.gauss(0, 4), 0), RUL_CLIP), 1) + + +def _build() -> None: + now = datetime.now(timezone.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 new file mode 100644 index 0000000..26994f2 --- /dev/null +++ b/backend/app/services/predict_service.py @@ -0,0 +1,12 @@ +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: + """Ручной инференс. Сейчас — заглушка; здесь будет вызов модели (XGBoost / PyTorch).""" + 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) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..83401e6 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,14 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api import alerts, equipment, predict +from app.core.config import CORS_ORIGINS + +app = FastAPI(title="OFF-Tech: оценка состояния оборудования", version="0.1.0") + +app.add_middleware(CORSMiddleware, allow_origins=CORS_ORIGINS, + allow_methods=["*"], allow_headers=["*"]) + +app.include_router(equipment.router) +app.include_router(alerts.router) +app.include_router(predict.router) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c60a450 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 From 8abcce47113fd602323c03343ebe4196a6313031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A4=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B4=D1=83=D0=BD?= Date: Tue, 3 Nov 2026 00:58:55 +0300 Subject: [PATCH 2/3] fixed the damn linters --- backend/app/core/config.py | 2 -- backend/app/services/mock_data.py | 8 ++------ backend/app/services/predict_service.py | 1 - backend/main.py | 5 ++--- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e264804..d845c6d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,9 +1,7 @@ import os -# Адреса фронтенда, которым разрешены запросы (через запятую в переменной окружения) CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",") -# Пороги статуса по прогнозу RUL (в циклах) RUL_WARNING = 75 RUL_CRITICAL = 30 RUL_CLIP = 125 diff --git a/backend/app/services/mock_data.py b/backend/app/services/mock_data.py index 2016262..ff08b0b 100644 --- a/backend/app/services/mock_data.py +++ b/backend/app/services/mock_data.py @@ -1,11 +1,8 @@ -"""Временный источник данных в памяти (пока нет БД). Данные похожи на FD001. -Когда появится PostgreSQL — сервисы начнут читать из БД, а этот файл можно удалить.""" import random -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from app.core.config import RUL_CLIP, RUL_CRITICAL, RUL_WARNING -# 14 рабочих датчиков FD001: код -> (базовое значение, std, тренд при износе) 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), @@ -30,12 +27,11 @@ def status_from_rul(rul: float) -> str: def noisy_rul(true_rul: float) -> float: - """Имитация ответа модели: истинный RUL + шум.""" return round(min(max(min(true_rul, RUL_CLIP) + _rnd.gauss(0, 4), 0), RUL_CLIP), 1) def _build() -> None: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) for i in range(1, 13): life = _rnd.randint(130, 300) # цикл отказа cut = int(life * _rnd.uniform(0.35, 0.97)) # текущий цикл diff --git a/backend/app/services/predict_service.py b/backend/app/services/predict_service.py index 26994f2..c24e6f4 100644 --- a/backend/app/services/predict_service.py +++ b/backend/app/services/predict_service.py @@ -3,7 +3,6 @@ def run_prediction(equipment_id: int) -> dict | None: - """Ручной инференс. Сейчас — заглушка; здесь будет вызов модели (XGBoost / PyTorch).""" e = EQUIPMENT.get(equipment_id) if e is None: return None diff --git a/backend/main.py b/backend/main.py index 83401e6..b3376de 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,8 +1,7 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - from app.api import alerts, equipment, predict from app.core.config import CORS_ORIGINS +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware app = FastAPI(title="OFF-Tech: оценка состояния оборудования", version="0.1.0") From 478bdcfb3c1f6cd6e07190991fd32a906b912101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A4=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B4=D1=83=D0=BD?= Date: Tue, 3 Nov 2026 01:16:08 +0300 Subject: [PATCH 3/3] fix mock data --- backend/app/services/equipment_service.py | 2 +- backend/app/services/mock_data.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/app/services/equipment_service.py b/backend/app/services/equipment_service.py index 9b33366..3297815 100644 --- a/backend/app/services/equipment_service.py +++ b/backend/app/services/equipment_service.py @@ -41,7 +41,7 @@ def get_forecast(equipment_id: int) -> dict | None: horizon = round(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 ff08b0b..2163c94 100644 --- a/backend/app/services/mock_data.py +++ b/backend/app/services/mock_data.py @@ -33,8 +33,8 @@ def noisy_rul(true_rul: float) -> float: 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)) # текущий цикл + 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)