diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 066a9bf..3dc9eba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,99 @@ jobs: - name: Lint run: ruff check backend ml + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: pdm + POSTGRES_PASSWORD: pdm + POSTGRES_DB: pdm + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U pdm -d pdm" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + env: + DATABASE_URL: postgresql+psycopg://pdm:pdm@localhost:5432/pdm + TEST_DATABASE_URL: postgresql+psycopg://pdm:pdm@localhost:5432/pdm + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: pip install -r backend/requirements.txt + + - name: Apply migrations + working-directory: backend + run: alembic upgrade head + + - name: Run tests + working-directory: backend + run: pytest + + frontend: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Build + working-directory: frontend + run: npm run build + + compose: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Prepare env + run: | + cp .env.example .env + sed -i 's/^SIMULATOR_WAIT_TIME=.*/SIMULATOR_WAIT_TIME=1/; s/^WORKER_POLL_INTERVAL=.*/WORKER_POLL_INTERVAL=1/' .env + + - name: Start system + run: docker compose up -d --build + + - name: Check system through frontend + run: | + for _ in $(seq 1 90); do + READY=$(curl -fs http://localhost:5173/api/equipment | jq '[.[] | select(.current_cycle > 0)] | length' || echo 0) + [ "$READY" -eq 20 ] && break + sleep 2 + done + echo "equipment with predictions: $READY" + [ "$READY" -eq 20 ] + [ "$(curl -fs http://localhost:5173/api/sensors | jq length)" -eq 21 ] + curl -fs -X POST -H "Content-Type: application/json" -d '{"equipment_id": 1}' http://localhost:5173/api/predict + curl -fs http://localhost:5173/ | grep -q 'id="root"' + + - name: Show logs + if: failure() + run: docker compose logs + migrations: runs-on: ubuntu-latest diff --git a/README.md b/README.md index de2d9f4..aa4e2ee 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,42 @@ Frontend: React База данных: PostgreSQL Инструменты автоматизации: Ollama +## Запуск + +Нужен только Docker: + +```bash +cp .env.example .env +docker compose up --build +``` + +Команда поднимает БД, API с миграциями, симулятор, воркер, отправку алертов и интерфейс. При первом запуске воркер обучает модель, поэтому прогнозы появляются не сразу. + +| Что | Адрес | +| ------------------ | -------------------------- | +| Интерфейс | http://localhost:5173 | +| API и документация | http://localhost:8000/docs | + +Настройки лежат в `.env`: + +- `POSTGRES_PORT` — порт БД на хосте, если стандартный занят. +- `SIMULATOR_WAIT_TIME` — секунд на один цикл. По умолчанию 30, для демонстрации хватит 1. +- `BOT_TOKEN`, `CHAT_ID` — доступ к Telegram для отправки алертов. + +Начать с чистой БД: `docker compose down -v`. + +## Тесты + +Тесты очищают таблицы, поэтому им нужна отдельная БД: + +```bash +docker compose up -d db +docker compose exec db createdb -U pdm pdm_test +cd backend +DATABASE_URL=postgresql+psycopg://pdm:pdm@localhost:5432/pdm_test alembic upgrade head +TEST_DATABASE_URL=postgresql+psycopg://pdm:pdm@localhost:5432/pdm_test pytest +``` + ## Определение проблемы и формулировка темы Промышленные предприятия вынуждены проводить плановое техническое обслуживание оборудования по жёсткому графику, даже когда оборудование находится в исправном состоянии. Это приводит к избыточным затратам на человеческие ресурсы и простоям производства. При этом отказ от планового обслуживания в пользу обслуживания «по факту поломки» несёт риск внезапных аварий. В результате предприятия вынуждены выбирать между переплатой за избыточное обслуживание и риском пропустить развивающийся дефект. diff --git a/backend/Dockerfile b/backend/Dockerfile index 50eb893..e27edaf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,6 +3,10 @@ FROM python:3.12-slim ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends libgomp1 \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY requirements.txt . diff --git a/backend/alembic/versions/c4f1a2d9e8b7_seed_sensor_catalog.py b/backend/alembic/versions/c4f1a2d9e8b7_seed_sensor_catalog.py new file mode 100644 index 0000000..1f22ccc --- /dev/null +++ b/backend/alembic/versions/c4f1a2d9e8b7_seed_sensor_catalog.py @@ -0,0 +1,62 @@ +"""seed sensor catalog + +Revision ID: c4f1a2d9e8b7 +Revises: e74d5734bc65 +Create Date: 2026-11-05 01:50:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'c4f1a2d9e8b7' +down_revision: str | Sequence[str] | None = 'e74d5734bc65' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +sensor_catalog = sa.table( + 'sensor_catalog', + sa.column('code', sa.Text), + sa.column('name', sa.Text), + sa.column('unit', sa.Text), + sa.column('is_used', sa.Boolean), +) + +SENSORS = [ + ('s1', 'T2 — температура на входе вентилятора', '°R', False), + ('s2', 'T24 — температура на выходе КНД', '°R', True), + ('s3', 'T30 — температура на выходе КВД', '°R', True), + ('s4', 'T50 — температура на выходе ТНД', '°R', True), + ('s5', 'P2 — давление на входе вентилятора', 'psia', False), + ('s6', 'P15 — давление в байпасном канале', 'psia', False), + ('s7', 'P30 — давление на выходе КВД', 'psia', True), + ('s8', 'Nf — физ. скорость вентилятора', 'об/мин', True), + ('s9', 'Nc — физ. скорость ядра', 'об/мин', True), + ('s10', 'epr — степень повышения давления', None, False), + ('s11', 'Ps30 — статическое давление на выходе КВД', 'psia', True), + ('s12', 'phi — расход топлива / Ps30', 'pps/psia', True), + ('s13', 'NRf — приведённая скорость вентилятора', 'об/мин', True), + ('s14', 'NRc — приведённая скорость ядра', 'об/мин', True), + ('s15', 'BPR — степень двухконтурности', None, True), + ('s16', 'farB — топливо/воздух в камере сгорания', None, False), + ('s17', 'htBleed — энтальпия отбора', None, True), + ('s18', 'Nf_dmd — заданная скорость вентилятора', 'об/мин', False), + ('s19', 'PCNfR_dmd — заданная приведённая скорость', 'об/мин', False), + ('s20', 'W31 — охлаждающий отбор ТВД', 'lbm/s', True), + ('s21', 'W32 — охлаждающий отбор ТНД', 'lbm/s', True), +] + + +def upgrade() -> None: + """Upgrade schema.""" + op.bulk_insert(sensor_catalog, [ + {'code': code, 'name': name, 'unit': unit, 'is_used': is_used} + for code, name, unit, is_used in SENSORS + ]) + + +def downgrade() -> None: + """Downgrade schema.""" + op.execute(sensor_catalog.delete()) diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..fd02c9d --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, HTTPException +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +from app.core.db import engine + +router = APIRouter(prefix="/health", tags=["Служебное"]) + + +@router.get("", summary="Проверка работоспособности") +def health(): + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + except SQLAlchemyError as exc: + raise HTTPException(status_code=503, detail="База данных недоступна") from exc + return {"status": "ok"} diff --git a/backend/app/api/predict.py b/backend/app/api/predict.py index 973f81f..5f2913d 100644 --- a/backend/app/api/predict.py +++ b/backend/app/api/predict.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from app.core.db import DB -from app.core.errors import NOT_FOUND, equipment_not_found +from app.core.errors import MODEL_UNAVAILABLE, NOT_FOUND, equipment_not_found from app.schemas.equipment import StatusOut from app.schemas.predict import PredictIn from app.services import predict_service @@ -9,7 +9,8 @@ router = APIRouter(prefix="/predict", tags=["Прогнозирование"]) -@router.post("", response_model=StatusOut, responses=NOT_FOUND, summary="Ручной запрос инференса") +@router.post("", response_model=StatusOut, responses={**NOT_FOUND, **MODEL_UNAVAILABLE}, + summary="Ручной запрос инференса") def predict(body: PredictIn, db: DB): result = predict_service.run_prediction(db, body.equipment_id) if result is None: diff --git a/backend/app/api/sensors.py b/backend/app/api/sensors.py new file mode 100644 index 0000000..d03d69f --- /dev/null +++ b/backend/app/api/sensors.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from app.core.db import DB +from app.schemas.sensor import SensorOut +from app.services import sensor_service + +router = APIRouter(prefix="/sensors", tags=["Справочники"]) + + +@router.get("", response_model=list[SensorOut], summary="Каталог датчиков") +def list_sensors(db: DB): + return sensor_service.list_sensors(db) diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index bc59196..a9f7baf 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -5,4 +5,9 @@ def equipment_not_found(equipment_id: int) -> HTTPException: return HTTPException(status_code=404, detail=f"Оборудование с id={equipment_id} не найдено") +def model_unavailable() -> HTTPException: + return HTTPException(status_code=503, detail="Модель прогноза недоступна") + + NOT_FOUND = {404: {"description": "Оборудование не найдено"}} +MODEL_UNAVAILABLE = {503: {"description": "Модель прогноза недоступна"}} diff --git a/backend/app/schemas/sensor.py b/backend/app/schemas/sensor.py new file mode 100644 index 0000000..b5c91e2 --- /dev/null +++ b/backend/app/schemas/sensor.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, Field + + +class SensorOut(BaseModel): + code: str + name: str + unit: str | None = Field(description="Единица измерения") + is_used: bool = Field(description="Участвует ли датчик в модели") diff --git a/backend/app/services/predict_service.py b/backend/app/services/predict_service.py index 57bacd9..16dc6d9 100644 --- a/backend/app/services/predict_service.py +++ b/backend/app/services/predict_service.py @@ -1,7 +1,59 @@ -from sqlalchemy import Connection +import os +from functools import lru_cache +import numpy as np +import xgboost as xgb +from sqlalchemy import Connection, select +from sqlalchemy.dialects.postgresql import insert + +from app.core.errors import model_unavailable +from app.models.ml_predictions import MlPrediction +from app.models.sensor_readings import SensorReading from app.services import equipment_service +from app.services.equipment_service import SENSOR_CODES + +MODEL_VERSION = "first_xgboost_model" + + +@lru_cache(maxsize=1) +def _load_model(path: str) -> xgb.Booster: + model = xgb.Booster() + model.load_model(path) + return model + + +def _predict_rul(sensors: list[float | None]) -> float: + path = os.getenv("MODEL_PATH") + if not path or not os.path.exists(path): + raise model_unavailable() + features = xgb.DMatrix(np.array([sensors], dtype=float), feature_names=SENSOR_CODES) + return float(_load_model(path).predict(features)[0]) def run_prediction(db: Connection, equipment_id: int) -> dict | None: + """Инференс по последнему показанию. Результат записывается в ml_predictions.""" + if not equipment_service.equipment_exists(db, equipment_id): + return None + + columns = [getattr(SensorReading, code) for code in SENSOR_CODES] + query = ( + select(SensorReading.cycle, *columns) + .where(SensorReading.equipment_id == equipment_id) + .order_by(SensorReading.cycle.desc()) + .limit(1) + ) + reading = db.execute(query).mappings().first() + + if reading is not None: + rul = _predict_rul([reading[code] for code in SENSOR_CODES]) + statement = insert(MlPrediction).values( + equipment_id=equipment_id, cycle=reading["cycle"], predicted_rul=rul, + defect_probability=0, residual=0, model_version=MODEL_VERSION, + ) + db.execute(statement.on_conflict_do_update( + index_elements=["equipment_id", "cycle"], + set_={"predicted_rul": rul, "model_version": MODEL_VERSION}, + )) + db.commit() + return equipment_service.get_status(db, equipment_id) diff --git a/backend/app/services/sensor_service.py b/backend/app/services/sensor_service.py new file mode 100644 index 0000000..c17ae64 --- /dev/null +++ b/backend/app/services/sensor_service.py @@ -0,0 +1,8 @@ +from sqlalchemy import Connection, select + +from app.models.sensor_catalog import SensorCatalog + + +def list_sensors(db: Connection) -> list[dict]: + rows = [dict(row) for row in db.execute(select(SensorCatalog)).mappings()] + return sorted(rows, key=lambda row: int(row["code"][1:])) diff --git a/backend/main.py b/backend/main.py index b3376de..9fabf67 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,4 @@ -from app.api import alerts, equipment, predict +from app.api import alerts, equipment, health, predict, sensors from app.core.config import CORS_ORIGINS from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -11,3 +11,5 @@ app.include_router(equipment.router) app.include_router(alerts.router) app.include_router(predict.router) +app.include_router(sensors.router) +app.include_router(health.router) diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/backend/requirements.txt b/backend/requirements.txt index 8b88dc1..7b34a3c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,9 +3,11 @@ uvicorn[standard]==0.32.0 sqlalchemy[asyncio]==2.0.36 pydantic==2.9.2 python-dotenv==1.0.1 +pytest==8.3.5 pytest-asyncio==0.24.0 httpx==0.27.2 alembic==1.20.0 psycopg[binary]==3.3.6 +xgboost==3.4.1 alembic==1.20.0 python-dotenv==1.0.1 \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..13285c2 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,76 @@ +import os +from datetime import date + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import insert, text + +# Тесты очищают таблицы, поэтому БД для них задаётся отдельной переменной: +# рабочий DATABASE_URL здесь намеренно не используется. +os.environ["DATABASE_URL"] = os.environ["TEST_DATABASE_URL"] +os.environ["RUL_WARNING"] = "75" +os.environ["RUL_CRITICAL"] = "30" +os.environ["RUL_CLIP"] = "125" + +TABLES = "alerts, ml_predictions, physics_predictions, sensor_readings, equipment" + + +@pytest.fixture +def db(): + from app.core.db import engine + + with engine.begin() as connection: + connection.execute(text(f"TRUNCATE {TABLES} RESTART IDENTITY CASCADE")) + with engine.connect() as connection: + yield connection + + +@pytest.fixture +def client(db): + from main import app + + return TestClient(app) + + +@pytest.fixture +def add_equipment(db): + from app.models import Equipment + + def add(unit_number: int = 81) -> int: + statement = insert(Equipment).values( + name=f"Turbofan №{unit_number}", type="turbofan", dataset="FD001", + unit_number=unit_number, install_date=date(2024, 1, 1), + ) + equipment_id = db.execute(statement.returning(Equipment.id)).scalar_one() + db.commit() + return equipment_id + + return add + + +@pytest.fixture +def add_prediction(db): + from app.models import MlPrediction + + def add(equipment_id: int, cycle: int, rul: float) -> None: + db.execute(insert(MlPrediction).values( + equipment_id=equipment_id, cycle=cycle, predicted_rul=rul, + defect_probability=0, residual=0, model_version="test", + )) + db.commit() + + return add + + +@pytest.fixture +def add_reading(db): + from app.models import SensorReading + from app.services.equipment_service import SENSOR_CODES + + def add(equipment_id: int, cycle: int) -> dict[str, float]: + sensors = {code: float(i + cycle) for i, code in enumerate(SENSOR_CODES)} + db.execute(insert(SensorReading).values(equipment_id=equipment_id, cycle=cycle, **sensors)) + db.commit() + return sensors + + return add diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..aafcbfc --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,128 @@ +from datetime import datetime + +import pytest +from app.models import Alert +from sqlalchemy import insert + + +def test_health(client): + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_sensor_catalog(client): + sensors = client.get("/sensors").json() + + assert [s["code"] for s in sensors] == [f"s{i}" for i in range(1, 22)] + unused = [s["code"] for s in sensors if not s["is_used"]] + assert unused == ["s1", "s5", "s6", "s10", "s16", "s18", "s19"] + + +def test_equipment_without_predictions_has_defaults(client, add_equipment): + add_equipment() + + equipment = client.get("/equipment").json()[0] + + assert equipment["current_cycle"] == 0 + assert equipment["predicted_rul"] == 125 + assert equipment["status"] == "normal" + + +@pytest.mark.parametrize(("rul", "status"), [ + (76, "normal"), + (75, "warning"), + (31, "warning"), + (30, "critical"), +]) +def test_status_thresholds(client, add_equipment, add_prediction, rul, status): + equipment_id = add_equipment() + add_prediction(equipment_id, cycle=5, rul=rul) + + body = client.get(f"/equipment/{equipment_id}/status").json() + + assert body["status"] == status + assert body["current_cycle"] == 5 + + +def test_status_uses_latest_cycle(client, add_equipment, add_prediction): + equipment_id = add_equipment() + add_prediction(equipment_id, cycle=6, rul=20) + add_prediction(equipment_id, cycle=5, rul=100) + + body = client.get(f"/equipment/{equipment_id}").json() + + assert body["predicted_rul"] == 20 + assert body["current_cycle"] == 6 + + +@pytest.mark.parametrize("path", [ + "/equipment/999", + "/equipment/999/status", + "/equipment/999/history", + "/equipment/999/forecast", + "/alerts/999", +]) +def test_unknown_equipment(client, path): + assert client.get(path).status_code == 404 + + +def test_history_filters_by_cycle(client, add_equipment, add_reading): + equipment_id = add_equipment() + for cycle in range(1, 6): + add_reading(equipment_id, cycle) + + rows = client.get(f"/equipment/{equipment_id}/history?from_cycle=2&to_cycle=4").json() + + assert [row["cycle"] for row in rows] == [2, 3, 4] + assert len(rows[0]["sensors"]) == 14 + + +def test_history_limit_keeps_latest(client, add_equipment, add_reading): + equipment_id = add_equipment() + for cycle in range(1, 6): + add_reading(equipment_id, cycle) + + rows = client.get(f"/equipment/{equipment_id}/history?limit=2").json() + + assert [row["cycle"] for row in rows] == [4, 5] + + +def test_history_rejects_inverted_range(client, add_equipment): + equipment_id = add_equipment() + + response = client.get(f"/equipment/{equipment_id}/history?from_cycle=5&to_cycle=1") + + assert response.status_code == 400 + + +def test_forecast_counts_down_to_failure(client, add_equipment, add_prediction): + equipment_id = add_equipment() + add_prediction(equipment_id, cycle=100, rul=10) + + forecast = client.get(f"/equipment/{equipment_id}/forecast").json() + + assert forecast["predicted_failure_cycle"] == 110 + assert [p["cycle"] for p in forecast["points"]] == list(range(100, 111)) + assert forecast["points"][0]["rul"] == 10 + assert forecast["points"][-1]["rul"] == 0 + assert all(p["rul_low"] <= p["rul"] <= p["rul_high"] for p in forecast["points"]) + + +def test_alerts_filtered_by_equipment_newest_first(client, db, add_equipment): + first, second = add_equipment(81), add_equipment(82) + db.execute(insert(Alert), [ + {"equipment_id": first, "cycle": 1, "severity": "warning", "message": "old", + "triggered_at": datetime(2026, 1, 1)}, + {"equipment_id": first, "cycle": 2, "severity": "critical", "message": "new", + "triggered_at": datetime(2026, 1, 2)}, + {"equipment_id": second, "cycle": 1, "severity": "warning", "message": "other", + "triggered_at": datetime(2026, 1, 3)}, + ]) + db.commit() + + alerts = client.get(f"/alerts/{first}").json() + + assert [a["message"] for a in alerts] == ["new", "old"] + assert len(client.get("/alerts").json()) == 3 diff --git a/backend/tests/test_circuit_breaker.py b/backend/tests/test_circuit_breaker.py new file mode 100644 index 0000000..ad36dd5 --- /dev/null +++ b/backend/tests/test_circuit_breaker.py @@ -0,0 +1,53 @@ +from app.core.circuit_breaker import CircuitBreaker + + +def fail(breaker: CircuitBreaker, times: int) -> None: + for _ in range(times): + breaker.check_half_open() + breaker.errors_counter() + + +def test_stays_closed_below_threshold(): + breaker = CircuitBreaker(error_threesold=3, time=60) + + fail(breaker, 2) + + assert breaker.allow_request() + + +def test_blocks_requests_after_threshold(): + breaker = CircuitBreaker(error_threesold=3, time=60) + + fail(breaker, 3) + + assert not breaker.allow_request() + + +def test_allows_probe_after_timeout(): + breaker = CircuitBreaker(error_threesold=3, time=0) + + fail(breaker, 3) + + assert breaker.allow_request() + assert breaker.state == "half-open" + + +def test_failed_probe_reopens(): + breaker = CircuitBreaker(error_threesold=3, time=60) + fail(breaker, 3) + breaker.half_open() + + breaker.check_half_open() + + assert breaker.state == "open" + assert not breaker.allow_request() + + +def test_success_closes(): + breaker = CircuitBreaker(error_threesold=3, time=60) + fail(breaker, 3) + + breaker.set_closed() + + assert breaker.allow_request() + assert breaker.errors == 0 diff --git a/backend/tests/test_predict.py b/backend/tests/test_predict.py new file mode 100644 index 0000000..c0d6dc3 --- /dev/null +++ b/backend/tests/test_predict.py @@ -0,0 +1,76 @@ +import numpy as np +import pytest +import xgboost as xgb +from app.models import MlPrediction +from app.services.equipment_service import SENSOR_CODES +from sqlalchemy import select + + +@pytest.fixture +def model(tmp_path, monkeypatch) -> xgb.Booster: + x = np.random.default_rng(0).normal(size=(20, len(SENSOR_CODES))) + y = np.linspace(0, 125, 20) + booster = xgb.train({"objective": "reg:squarederror"}, + xgb.DMatrix(x, label=y, feature_names=SENSOR_CODES), num_boost_round=3) + path = tmp_path / "model.json" + booster.save_model(path) + monkeypatch.setenv("MODEL_PATH", str(path)) + return booster + + +def expected_rul(model: xgb.Booster, sensors: dict[str, float]) -> float: + values = np.array([[sensors[code] for code in SENSOR_CODES]]) + return float(model.predict(xgb.DMatrix(values, feature_names=SENSOR_CODES))[0]) + + +def stored_predictions(db, equipment_id: int) -> list[tuple[int, float]]: + query = select(MlPrediction.cycle, MlPrediction.predicted_rul).where(MlPrediction.equipment_id == equipment_id) + rows = db.execute(query).all() + db.rollback() + return [tuple(row) for row in rows] + + +def test_unknown_equipment(client): + assert client.post("/predict", json={"equipment_id": 999}).status_code == 404 + + +def test_without_readings_keeps_defaults(client, db, model, add_equipment): + equipment_id = add_equipment() + + body = client.post("/predict", json={"equipment_id": equipment_id}).json() + + assert body["predicted_rul"] == 125 + assert stored_predictions(db, equipment_id) == [] + + +def test_runs_inference_on_latest_reading(client, db, model, add_equipment, add_reading): + equipment_id = add_equipment() + add_reading(equipment_id, cycle=1) + latest = add_reading(equipment_id, cycle=2) + + body = client.post("/predict", json={"equipment_id": equipment_id}).json() + + rul = expected_rul(model, latest) + assert body["current_cycle"] == 2 + assert body["predicted_rul"] == pytest.approx(rul) + assert stored_predictions(db, equipment_id) == [(2, pytest.approx(rul))] + + +def test_replaces_existing_prediction(client, db, model, add_equipment, add_prediction, add_reading): + equipment_id = add_equipment() + sensors = add_reading(equipment_id, cycle=1) + add_prediction(equipment_id, cycle=1, rul=999) + + client.post("/predict", json={"equipment_id": equipment_id}) + + assert stored_predictions(db, equipment_id) == [(1, pytest.approx(expected_rul(model, sensors)))] + + +def test_missing_model(client, add_equipment, add_reading, monkeypatch): + equipment_id = add_equipment() + add_reading(equipment_id, cycle=1) + monkeypatch.setenv("MODEL_PATH", "/nonexistent/model.json") + + response = client.post("/predict", json={"equipment_id": equipment_id}) + + assert response.status_code == 503 diff --git a/docker-compose.yml b/docker-compose.yml index 26f1c6a..2fae10e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,8 @@ services: <<: *app-env ports: - "8000:8000" + volumes: + - models:/models:ro depends_on: db: condition: service_healthy diff --git a/frontend/src/pages/EquipmentCard.tsx b/frontend/src/pages/EquipmentCard.tsx index 508a419..2848459 100644 --- a/frontend/src/pages/EquipmentCard.tsx +++ b/frontend/src/pages/EquipmentCard.tsx @@ -29,6 +29,7 @@ export function EquipmentCard() { const [recalculating, setRecalculating] = useState(false); const [recalcError, setRecalcError] = useState(null); + const [forecastVersion, setForecastVersion] = useState(0); async function recalculate() { setRecalculating(true); @@ -38,6 +39,7 @@ export function EquipmentCard() { equipment.setData((prev) => prev ? { ...prev, status: s.status, predicted_rul: s.predicted_rul, current_cycle: s.current_cycle } : prev, ); + setForecastVersion((v) => v + 1); } catch (e) { setRecalcError((e as Error).message); } finally { @@ -76,7 +78,7 @@ export function EquipmentCard() { {recalcError && }
- + @@ -121,8 +123,8 @@ function HistoryChart({ id }: { id: number }) { ); } -function ForecastChart({ id }: { id: number }) { - const { data, error } = useFetch(() => api.getForecast(id), [id]); +function ForecastChart({ id, version }: { id: number; version: number }) { + const { data, error } = useFetch(() => api.getForecast(id), [id, version]); const points = (data?.points ?? []).map((p) => ({ ...p, range: [p.rul_low, p.rul_high] }));