Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

## Определение проблемы и формулировка темы

Промышленные предприятия вынуждены проводить плановое техническое обслуживание оборудования по жёсткому графику, даже когда оборудование находится в исправном состоянии. Это приводит к избыточным затратам на человеческие ресурсы и простоям производства. При этом отказ от планового обслуживания в пользу обслуживания «по факту поломки» несёт риск внезапных аварий. В результате предприятия вынуждены выбирать между переплатой за избыточное обслуживание и риском пропустить развивающийся дефект.
Expand Down
4 changes: 4 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
62 changes: 62 additions & 0 deletions backend/alembic/versions/c4f1a2d9e8b7_seed_sensor_catalog.py
Original file line number Diff line number Diff line change
@@ -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())
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""merge sensor catalog and managed accounts

Revision ID: e876b434ef1c
Revises: b48a001, c4f1a2d9e8b7
Create Date: 2026-11-12 12:54:27.548828

"""
from collections.abc import Sequence

# revision identifiers, used by Alembic.
revision: str = 'e876b434ef1c'
down_revision: str | Sequence[str] | None = ('b48a001', 'c4f1a2d9e8b7')
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""


def downgrade() -> None:
"""Downgrade schema."""
17 changes: 17 additions & 0 deletions backend/app/api/health.py
Original file line number Diff line number Diff line change
@@ -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"}
5 changes: 3 additions & 2 deletions backend/app/api/predict.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
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

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:
Expand Down
12 changes: 12 additions & 0 deletions backend/app/api/sensors.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions backend/app/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Модель прогноза недоступна"}}
8 changes: 8 additions & 0 deletions backend/app/schemas/sensor.py
Original file line number Diff line number Diff line change
@@ -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="Участвует ли датчик в модели")
54 changes: 53 additions & 1 deletion backend/app/services/predict_service.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions backend/app/services/sensor_service.py
Original file line number Diff line number Diff line change
@@ -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:]))
5 changes: 4 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from app.api import alerts, auth, equipment, predict, users
from app.api import alerts, auth, equipment, health, predict, sensors, users
from app.api.auth import UNAUTHORIZED, get_current_user
from app.core.config import CORS_ORIGINS
from fastapi import Depends, FastAPI
Expand All @@ -18,6 +18,9 @@
app.include_router(equipment.router, dependencies=PROTECTED, responses=UNAUTHORIZED)
app.include_router(alerts.router, dependencies=PROTECTED, responses=UNAUTHORIZED)
app.include_router(predict.router, dependencies=PROTECTED, responses=UNAUTHORIZED)
app.include_router(sensors.router, dependencies=PROTECTED, responses=UNAUTHORIZED)
# Проверка живости — без токена, чтобы её могли дёргать healthcheck и мониторинг.
app.include_router(health.router)

app.include_router(users.router)

Expand Down
2 changes: 2 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ 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
PyJWT==2.15.1
Expand Down
Loading
Loading