Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ CORS_ORIGINS=
RUL_WARNING=75
RUL_CRITICAL=30
RUL_CLIP=125
WEATHER_LATITUDE=55.7558
WEATHER_LONGITUDE=37.6173
BOT_TOKEN=
CHAT_ID=
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ jobs:
working-directory: ml
run: python baseline_model_check.py ../data/test_FD001.txt --rul ../data/RUL_FD001.txt

- name: Run physics check
working-directory: ml
run: python physics_check.py ../data/train_FD001.txt

pipeline:
needs: [migrations, model]
runs-on: ubuntu-latest
Expand Down Expand Up @@ -154,5 +158,7 @@ jobs:
EQ=$(psql "$PSQL_URL" -tAc "SELECT count(*) FROM equipment")
SR=$(psql "$PSQL_URL" -tAc "SELECT count(*) FROM sensor_readings")
ML=$(psql "$PSQL_URL" -tAc "SELECT count(*) FROM ml_predictions")
echo "equipment=$EQ sensor_readings=$SR ml_predictions=$ML"
[ "$EQ" -eq 20 ] && [ "$SR" -gt 0 ] && [ "$ML" -eq "$SR" ]
PH=$(psql "$PSQL_URL" -tAc "SELECT count(*) FROM physics_predictions")
RS=$(psql "$PSQL_URL" -tAc "SELECT count(*) FROM ml_predictions WHERE residual <> 0")
echo "equipment=$EQ sensor_readings=$SR ml_predictions=$ML physics_predictions=$PH nonzero_residual=$RS"
[ "$EQ" -eq 20 ] && [ "$SR" -gt 0 ] && [ "$ML" -eq "$SR" ] && [ "$PH" -eq "$SR" ] && [ "$RS" -gt 0 ]
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Frontend: React

**Статусы оборудования по прогнозу RUL:** `normal` — более 75 циклов, `warning` — от 31 до 75, `critical` — 30 циклов и меньше.

**Физическая модель** (`ml/physics.py`) считается воркером вместе с RUL. Фактическая деградация — проекция нормированных показаний 14 датчиков на первую главную компоненту, отмасштабированная так, что 0 — исправный двигатель, 1 — отказ (по двигателям 1–80), со сглаживанием EWMA. Ожидаемая деградация — экспоненциальный закон накопления повреждений D(τ) = a·(e^(b·τ) − 1), которым NASA моделировала износ в C-MAPSS (Saxena et al., 2008). Эквивалентная наработка τ за цикл растёт на коэффициент ускорения по Аррениусу от температуры воздуха из Open-Meteo: при 15 °C он равен 1, при 0 °C — 0,83, при 30 °C — 1,18. Координаты площадки — `WEATHER_LATITUDE` и `WEATHER_LONGITUDE` (по умолчанию Москва). Ожидаемая деградация пишется в `physics_predictions`, невязка «фактическая минус ожидаемая» — в `ml_predictions.residual`. Проверка модели: `python physics_check.py ../data/train_FD001.txt` из `ml/`.

## Масштаб системы

Целевая аудитория системы - инженеры по эксплуатации и ответственные за производство на промышленных предприятиях. При оценке масштаба в 10 000 уникальных пользователей в сутки предполагается, что система обслуживает порядка N предприятий с M единицами оборудования на каждом, где каждый пользователь в среднем совершает K запросов в день (просмотр дашборда, проверка истории, получение алертов). Основная нагрузка - read-запросы (просмотр данных), запись происходит асинхронно от симулятора/датчиков оборудования, а не от действий пользователей напрямую.
Expand Down Expand Up @@ -125,6 +127,7 @@ api/v1 (эндпоинты, валидация) → services (бизнес-ло
| GET | `/equipment/{id}/status` | Текущее состояние: статус, RUL, вероятность дефекта, невязка | < 150 мс |
| GET | `/equipment/{id}/history` | История показаний датчиков (`from_cycle`, `to_cycle`, `limit`) | < 500 мс |
| GET | `/equipment/{id}/forecast` | Прогноз деградации: RUL по будущим циклам с доверительным интервалом | < 500 мс |
| GET | `/equipment/{id}/physics` | Физическая модель против фактических показаний: ожидаемая и фактическая деградация, невязка (`limit`) | < 500 мс |

### Алерты

Expand Down
15 changes: 14 additions & 1 deletion backend/app/api/equipment.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

from app.core.db import DB
from app.core.errors import NOT_FOUND, equipment_not_found
from app.schemas.equipment import EquipmentOut, ForecastOut, ReadingOut, StatusOut
from app.schemas.equipment import (
EquipmentOut,
ForecastOut,
PhysicsPointOut,
ReadingOut,
StatusOut,
)
from app.services import equipment_service

router = APIRouter(prefix="/equipment", tags=["Оборудование"])
Expand Down Expand Up @@ -42,6 +48,13 @@ def get_history(equipment_id: int, db: DB,
return _found(equipment_service.get_history(db, equipment_id, from_cycle, to_cycle, limit), equipment_id)


@router.get("/{equipment_id}/physics", response_model=list[PhysicsPointOut], responses=NOT_FOUND,
summary="Физическая модель против фактических показаний: ожидаемая и фактическая деградация")
def get_physics(equipment_id: int, db: DB,
limit: int = Query(500, ge=1, le=5000, description="Максимум записей (берутся последние)")):
return _found(equipment_service.get_physics(db, equipment_id, limit), equipment_id)


@router.get("/{equipment_id}/forecast", response_model=ForecastOut, responses=NOT_FOUND,
summary="Прогноз деградации: RUL по будущим циклам с доверительным интервалом")
def get_forecast(equipment_id: int, db: DB):
Expand Down
7 changes: 7 additions & 0 deletions backend/app/schemas/equipment.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ class ReadingOut(BaseModel):
sensors: dict[str, float] = Field(description="Показания 14 рабочих датчиков: код → значение")


class PhysicsPointOut(BaseModel):
cycle: int
expected: float = Field(description="Ожидаемая деградация по физической модели: 0 — исправный двигатель, 1 — отказ")
actual: float = Field(description="Фактическая деградация по показаниям датчиков")
residual: float = Field(description="Невязка: фактическая деградация минус ожидаемая")


class ForecastPoint(BaseModel):
cycle: int
rul: float = Field(description="Ожидаемый остаточный ресурс на этом цикле")
Expand Down
21 changes: 21 additions & 0 deletions backend/app/services/equipment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from app.core.config import RUL_CLIP, RUL_CRITICAL, RUL_WARNING
from app.models.equipment import Equipment
from app.models.ml_predictions import MlPrediction
from app.models.physics_predictions import PhysicsPrediction
from app.models.sensor_readings import SensorReading

SENSOR_CODES = ["s2", "s3", "s4", "s7", "s8", "s9", "s11", "s12", "s13", "s14", "s15", "s17", "s20", "s21"]
Expand Down Expand Up @@ -115,6 +116,26 @@ def get_history(db: Connection, equipment_id: int, from_cycle: int | None, to_cy
return rows


def get_physics(db: Connection, equipment_id: int, limit: int) -> list[dict] | None:
if not equipment_exists(db, equipment_id):
return None

query = (
select(PhysicsPrediction.cycle, PhysicsPrediction.predicted_degradation, MlPrediction.residual)
.join(MlPrediction, and_(MlPrediction.equipment_id == PhysicsPrediction.equipment_id,
MlPrediction.cycle == PhysicsPrediction.cycle))
.where(PhysicsPrediction.equipment_id == equipment_id)
.order_by(PhysicsPrediction.cycle.desc())
.limit(limit)
)

points = [{"cycle": row.cycle, "expected": row.predicted_degradation,
"actual": row.predicted_degradation + row.residual, "residual": row.residual}
for row in db.execute(query)]
points.reverse()
return points


def get_forecast(db: Connection, equipment_id: int) -> dict | None:
items = _latest_predictions(db, equipment_id)
if not items:
Expand Down
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
76 changes: 76 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions backend/tests/test_physics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import pytest
from sqlalchemy import insert


@pytest.fixture
def add_physics(db):
from app.models import MlPrediction, PhysicsPrediction

def add(equipment_id: int, cycle: int, expected: float, residual: float) -> None:
db.execute(insert(PhysicsPrediction).values(
equipment_id=equipment_id, cycle=cycle, predicted_degradation=expected,
))
db.execute(insert(MlPrediction).values(
equipment_id=equipment_id, cycle=cycle, predicted_rul=100,
defect_probability=0, residual=residual, model_version="test",
))
db.commit()

return add


def test_physics_returns_expected_actual_and_residual(client, add_equipment, add_physics):
equipment_id = add_equipment()
add_physics(equipment_id, 2, expected=0.2, residual=-0.05)
add_physics(equipment_id, 1, expected=0.1, residual=0.05)

response = client.get(f"/equipment/{equipment_id}/physics")

assert response.status_code == 200
points = response.json()
assert [p["cycle"] for p in points] == [1, 2]
assert points[0]["expected"] == pytest.approx(0.1)
assert points[0]["actual"] == pytest.approx(0.15)
assert points[0]["residual"] == pytest.approx(0.05)
assert points[1]["actual"] == pytest.approx(0.15)


def test_physics_limit_keeps_latest_cycles(client, add_equipment, add_physics):
equipment_id = add_equipment()
for cycle in (1, 2, 3):
add_physics(equipment_id, cycle, expected=cycle / 10, residual=0)

response = client.get(f"/equipment/{equipment_id}/physics", params={"limit": 2})

assert [p["cycle"] for p in response.json()] == [2, 3]


def test_physics_is_empty_before_worker(client, add_equipment, add_reading):
equipment_id = add_equipment()
add_reading(equipment_id, 1)

response = client.get(f"/equipment/{equipment_id}/physics")

assert response.status_code == 200
assert response.json() == []


def test_physics_unknown_equipment(client, db):
response = client.get("/equipment/999/physics")

assert response.status_code == 404
8 changes: 8 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export interface Reading {
sensors: Record<string, number>;
}

export interface PhysicsPoint {
cycle: number;
expected: number;
actual: number;
residual: number;
}

export interface ForecastPoint {
cycle: number;
rul: number;
Expand Down Expand Up @@ -69,6 +76,7 @@ export const api = {
getEquipment: (id: number) => request<Equipment>(`/equipment/${id}`),
getHistory: (id: number, limit = 500) => request<Reading[]>(`/equipment/${id}/history?limit=${limit}`),
getForecast: (id: number) => request<Forecast>(`/equipment/${id}/forecast`),
getPhysics: (id: number, limit = 500) => request<PhysicsPoint[]>(`/equipment/${id}/physics?limit=${limit}`),
listAlerts: (limit = 100) => request<Alert[]>(`/alerts?limit=${limit}`),
alertsByEquipment: (id: number, limit = 50) => request<Alert[]>(`/alerts/${id}?limit=${limit}`),
predict: (id: number) =>
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,12 @@ export function formatDateTime(value: string): string {
export function formatRul(value: number): string {
return Math.round(value).toString();
}

export function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}

export function formatResidual(value: number): string {
const points = Math.round(value * 100);
return `${points > 0 ? "+" : ""}${points} п.п.`;
}
11 changes: 0 additions & 11 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -205,17 +205,6 @@ h2 {
.grid > .card:last-child {
grid-column: 1 / -1;
}
.placeholder {
height: 260px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
color: #6b7280;
border: 2px dashed #e5e7eb;
border-radius: 6px;
}

.muted {
color: #6b7280;
Expand Down
30 changes: 24 additions & 6 deletions frontend/src/pages/EquipmentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
LineChart,
ReferenceLine,
Expand All @@ -18,7 +19,7 @@ import { AlertsTable } from "../components/AlertsTable";
import { LiveIndicator } from "../components/LiveIndicator";
import { Empty, ErrorMessage, Loading } from "../components/State";
import { StatusBadge } from "../components/StatusBadge";
import { SENSORS, formatDate, formatRul } from "../format";
import { SENSORS, formatDate, formatPercent, formatResidual, formatRul } from "../format";
import { useLiveFetch } from "../useLiveFetch";

const RUL_WARNING = 75;
Expand Down Expand Up @@ -84,7 +85,7 @@ export function EquipmentCard() {
<div className="grid">
<ForecastChart id={id} />
<HistoryChart id={id} />
<PhysicsChart />
<PhysicsChart id={id} />
<EquipmentAlerts id={id} />
</div>
</section>
Expand Down Expand Up @@ -158,15 +159,32 @@ function ForecastChart({ id }: { id: number }) {
);
}

function PhysicsChart() {
function PhysicsChart({ id }: { id: number }) {
const { data, error } = useLiveFetch(() => api.getPhysics(id), [id]);
const last = data && data.length > 0 ? data[data.length - 1] : null;

return (
<div className="card">
<div className="card-title">
<h2>Физическая модель против фактических показаний</h2>
{last && <span className="muted">невязка {formatResidual(last.residual)}</span>}
</div>
<div className="placeholder">
Заглушка
</div>
{error && <ErrorMessage message={error} />}
{!data && !error && <Loading />}
{data && data.length === 0 && <Empty text="Физическая модель ещё не посчитана." />}
{data && data.length > 0 && (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="cycle" label={{ value: "цикл", position: "insideBottomRight", offset: -4 }} />
<YAxis width={50} tickFormatter={formatPercent} />
<Tooltip formatter={(v: number, name: string) => [formatPercent(v), name]} labelFormatter={(c) => `Цикл ${c}`} />
<Legend />
<Line dataKey="expected" name="ожидаемая деградация" stroke="#d97706" strokeDasharray="5 4" dot={false} isAnimationActive={false} />
<Line dataKey="actual" name="фактическая по датчикам" stroke="#2563eb" dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
)}
</div>
);
}
Expand Down
Loading
Loading