Skip to content
Closed
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
93 changes: 93 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
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())
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:]))
4 changes: 3 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, 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
Expand All @@ -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)
3 changes: 3 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests
2 changes: 2 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading