Compare commits

..

11 Commits

Author SHA1 Message Date
5251e9629f ui: simplify diagnostics before demo
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 1m1s
2026-08-25 16:38:18 +02:00
2eb1e293c8 ui: balance navigation and restore dark charts
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 43s
2026-08-25 16:03:13 +02:00
9032c49993 ui: align cylinder controls with service workflow
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 43s
2026-08-25 15:52:36 +02:00
dab5264ed7 ui: focus mechanic workflow and clarify health map
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 36s
2026-08-25 15:22:35 +02:00
7159c01123 ui: balance and localize application views
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 32s
2026-08-25 15:01:49 +02:00
82ab439452 ui: streamline cylinder diagnosis views
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 43s
2026-08-25 13:55:56 +02:00
5ebfdc389a prod: run container with restricted privileges
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 56s
2026-08-25 13:16:23 +02:00
8e6dcb750d prod: load versioned model artifact at runtime
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 2m58s
2026-08-25 13:06:14 +02:00
8981ae0a00 ci: enforce Ruff lint checks
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 31s
2026-08-25 12:37:41 +02:00
f2df616285 ci: add production build test and smoke workflow
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 3m44s
2026-08-25 12:14:40 +02:00
0fc96c6232 Update README.md 2026-08-25 09:40:08 +00:00
36 changed files with 1960 additions and 620 deletions

View File

@ -10,3 +10,9 @@ robustness_quick
severity_outputs severity_outputs
severity_quick severity_quick
ml_polish_outputs ml_polish_outputs
archive
docs
presentation
train.csv
1_przebiegi_usterek.png
2_przebiegi_silnika.png

133
.gitea/workflows/ci.yaml Normal file
View File

@ -0,0 +1,133 @@
name: ENGIN CI
on:
push:
branches:
- main
- prod-hardening
pull_request:
branches:
- main
jobs:
production-check:
name: Build, test and smoke
runs-on: engin-ci
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Clean previous CI resources
run: |
docker rm -f engin-ci-app 2>/dev/null || true
docker image rm -f engin-console:ci 2>/dev/null || true
docker image rm -f engin-console:test 2>/dev/null || true
- name: Build test image
run: docker build --target test --tag engin-console:test .
- name: Run Ruff
run: |
docker run --rm \
--entrypoint sh \
engin-console:test \
-c "python -m pip install --quiet \
--disable-pip-version-check \
--root-user-action=ignore \
ruff==0.16.4 &&
ruff check --no-cache \
app.py engin tests final_pipeline.py ml_polish_benchmark.py scripts"
- name: Check installed dependencies
run: |
docker run --rm \
--entrypoint python \
engin-console:test \
-m pip check
- name: Run test suite
run: |
docker run --rm \
--entrypoint python \
engin-console:test \
-m unittest discover -v
- name: Verify frozen model artifact
run: |
docker run --rm \
--entrypoint python \
engin-console:test \
-m scripts.build_model_artifact --verify-only
- name: Validate Python syntax
run: |
docker run --rm \
--entrypoint python \
engin-console:test \
-m compileall -q app.py engin tests final_pipeline.py scripts
- name: Build production image
run: docker build --target runtime --tag engin-console:ci .
- name: Assert production image contract
run: |
docker run --rm \
--entrypoint sh \
engin-console:ci \
-c "test \"\$(id -u)\" = 10001 &&
test \"\$(id -g)\" = 10001 &&
test ! -w /app &&
test ! -e val.csv &&
test ! -e train.csv &&
test ! -e final_pipeline.py &&
test ! -e severity_benchmark.py &&
test -s artifacts/engin-2026.08.25-1/model.pkl &&
test -s artifacts/engin-2026.08.25-1/manifest.json"
- name: Start application
run: |
docker run -d \
--name engin-ci-app \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--pids-limit 256 \
--memory 1g \
--cpus 2 \
engin-console:ci
- name: Verify runtime isolation
run: |
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' engin-ci-app)" = true
test "$(docker inspect --format '{{.HostConfig.PidsLimit}}' engin-ci-app)" = 256
test "$(docker inspect --format '{{.HostConfig.Memory}}' engin-ci-app)" = 1073741824
test "$(docker inspect --format '{{.HostConfig.NanoCpus}}' engin-ci-app)" = 2000000000
- name: Verify Streamlit health
run: |
set -eu
attempt=1
while [ "$attempt" -le 30 ]; do
if docker exec engin-ci-app python -c \
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3).read()"; then
exit 0
fi
sleep 2
attempt=$((attempt + 1))
done
docker logs engin-ci-app
exit 1
- name: Cleanup
if: always()
run: |
docker logs engin-ci-app 2>/dev/null || true
docker rm -f engin-ci-app 2>/dev/null || true
docker image rm -f engin-console:ci 2>/dev/null || true
docker image rm -f engin-console:test 2>/dev/null || true

View File

@ -10,7 +10,11 @@ font = "sans-serif"
showErrorDetails = "none" showErrorDetails = "none"
toolbarMode = "minimal" toolbarMode = "minimal"
[browser]
gatherUsageStats = false
[server] [server]
headless = true headless = true
maxUploadSize = 10 maxUploadSize = 10
runOnSave = false runOnSave = false
fileWatcherType = "none"

View File

@ -1,4 +1,7 @@
FROM python:3.12-slim ARG PYTHON_IMAGE=python:3.12-slim@sha256:7a8b475003c4fe15a2cd4e55e5cfc2f3560bdc9333d624f24cdd6d4340fd7a17
ARG APP_UID=10001
ARG APP_GID=10001
FROM ${PYTHON_IMAGE} AS base
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@ -9,11 +12,40 @@ WORKDIR /app
COPY requirements-lock.txt ./ COPY requirements-lock.txt ./
RUN pip install --no-cache-dir -r requirements-lock.txt RUN pip install --no-cache-dir -r requirements-lock.txt
FROM base AS test
COPY . . COPY . .
FROM base AS runtime
ARG APP_UID
ARG APP_GID
RUN groupadd --gid "${APP_GID}" engin \
&& useradd --uid "${APP_UID}" \
--gid "${APP_GID}" \
--create-home \
--home-dir /home/engin \
--shell /usr/sbin/nologin \
engin
ENV STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \
XDG_CACHE_HOME=/tmp/.cache \
MPLCONFIGDIR=/tmp/matplotlib
COPY app.py ./
COPY engin/ ./engin/
COPY assets/ ./assets/
COPY .streamlit/ ./.streamlit/
COPY artifacts/ ./artifacts/
COPY test.csv predictions.csv prediction_diagnostics.csv ./
USER ${APP_UID}:${APP_GID}
EXPOSE 8501 EXPOSE 8501
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)" CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"
STOPSIGNAL SIGTERM
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501"] CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=8501"]

View File

@ -4,7 +4,8 @@ Gotowy do demonstracji system diagnostyki cylindrów przemysłowych silników Di
## Uruchomienie ## Uruchomienie
Wymagany jest Python 3.12 (3.11 również powinien działać). Wymagany jest Python 3.12. Artefakt modelu sprawdza zgodność wersji Pythona
i bibliotek przed deserializacją.
```bash ```bash
python -m venv .venv python -m venv .venv
@ -27,19 +28,24 @@ Aplikacja otworzy się pod `http://localhost:8501`. Od razu uruchamia bezpieczne
Alternatywnie przez Docker: Alternatywnie przez Docker:
```bash ```bash
docker build -t engin-console . docker build --target runtime -t engin-console .
docker run --rm -p 8501:8501 engin-console docker run --rm -p 127.0.0.1:8501:8501 engin-console
``` ```
Zalecane uruchomienie produkcyjne korzysta z `compose.prod.yaml`: kontener działa
jako UID/GID `10001`, z systemem plików tylko do odczytu, bez Linux capabilities
oraz z limitami CPU, pamięci i procesów. Pełna procedura startu, aktualizacji i
rollbacku znajduje się w `docs/PRODUCTION_RUNBOOK.md`.
## Co zawiera produkt ## Co zawiera produkt
- mapa 8-, 12- i 16-cylindrowych silników z diagnozą każdego cylindra, - przegląd silników 8-, 12- i 16-cylindrowych z diagnozą każdego cylindra,
- typ usterki: `ok`, `zakoksowany`, `lejacy`, `pompa`, `iglica` lub `unknown`, - typ usterki: `ok`, `zakoksowany`, `lejacy`, `pompa`, `iglica` lub `unknown`,
- nasilenie: `male`, `srednie`, `duze`; dla `ok` i `unknown` zawsze `nie_dotyczy`, - nasilenie: `male`, `srednie`, `duze`; dla `ok` i `unknown` zawsze `nie_dotyczy`,
- status silnika, najwyższe rzeczywiste severity i kolejka cylindrów do kontroli, - status silnika, najwyższe rzeczywiste nasilenie i kolejka cylindrów do kontroli,
- heatmapa odchyleń całego silnika, - mapa odchyleń całego silnika ze stałą, porównywalną skalą,
- porównanie widma cylindra z medianą pozostałych cylindrów tej samej jednostki, - porównanie widma cylindra z medianą pozostałych cylindrów tej samej jednostki,
- trzy najbardziej anomalne pasma, niekalibrowany score modelu oraz rekomendowany następny krok, - priorytet kontroli, trzy najbardziej anomalne pasma oraz krótkie uzasadnienie diagnozy,
- pobieranie `predictions.csv` i rozszerzonej diagnostyki, - pobieranie `predictions.csv` i rozszerzonej diagnostyki,
- ścisła walidacja CSV i bezpieczne komunikaty błędów, - ścisła walidacja CSV i bezpieczne komunikaty błędów,
- lokalne działanie na CPU, bez wysyłania danych do zewnętrznych usług. - lokalne działanie na CPU, bez wysyłania danych do zewnętrznych usług.
@ -48,6 +54,8 @@ docker run --rm -p 8501:8501 engin-console
Interfejs jest cienką warstwą prezentacji. Zależności są wstrzykiwane do `DiagnosticService`, dlatego odczyt pliku, walidator i model można niezależnie wymieniać oraz testować. Interfejs jest cienką warstwą prezentacji. Zależności są wstrzykiwane do `DiagnosticService`, dlatego odczyt pliku, walidator i model można niezależnie wymieniać oraz testować.
Proces produkcyjny nie trenuje modelu przy starcie. Aplikacja weryfikuje checksumę, schemat wejścia i wersje bibliotek, a następnie ładuje artefakt `engin-2026.08.25-1`. Uszkodzony lub niezgodny artefakt uruchamia ograniczony fallback demonstracyjny.
```mermaid ```mermaid
flowchart TD flowchart TD
UI["Streamlit UI"] --> S["DiagnosticService"] UI["Streamlit UI"] --> S["DiagnosticService"]
@ -64,6 +72,9 @@ app.py cienka warstwa Streamlit
engin/io.py adapter odczytu CSV engin/io.py adapter odczytu CSV
engin/validation.py kontrakt i walidacja danych engin/validation.py kontrakt i walidacja danych
engin/model.py adapter modelu i fallback demo engin/model.py adapter modelu i fallback demo
engin/artifact.py manifest, checksumy i ładowanie artefaktu
engin/features.py stabilne transformacje używane w train/inference
engin/inference.py runtime predykcji bez zależności od benchmarków
engin/service.py przypadek użycia / dependency injection engin/service.py przypadek użycia / dependency injection
engin/explainability.py status, porządkowy triage i uzasadnienia engin/explainability.py status, porządkowy triage i uzasadnienia
engin/charts.py czyste, testowalne fabryki Plotly engin/charts.py czyste, testowalne fabryki Plotly
@ -102,6 +113,17 @@ python final_pipeline.py
Powstają `predictions.csv` (600/600 rekordów, bez duplikatów i braków) oraz `prediction_diagnostics.csv` używany przez aplikację do explainability. Powstają `predictions.csv` (600/600 rekordów, bez duplikatów i braków) oraz `prediction_diagnostics.csv` używany przez aplikację do explainability.
Wersjonowany artefakt modelu buduje osobny krok release:
```bash
python -m scripts.build_model_artifact \
--source-revision "$(git rev-parse HEAD)"
python -m scripts.build_model_artifact --verify-only
```
Druga komenda nie trenuje modelu. Ładuje artefakt, sprawdza manifest i checksumę oraz wymaga dokładnej zgodności wszystkich 600 predykcji z zamrożonym `predictions.csv`.
## Kontrakt danych wejściowych ## Kontrakt danych wejściowych
Każdy wiersz reprezentuje cylinder. Wymagane kolumny: Każdy wiersz reprezentuje cylinder. Wymagane kolumny:
@ -121,13 +143,14 @@ Pełna kontrola projektu:
python -m unittest discover -v python -m unittest discover -v
``` ```
Pakiet zawiera 32 testy: Pakiet zawiera 38 testów:
- testy braku leakage, cech, OOD i kontraktu submission, - testy braku leakage, cech, OOD i kontraktu submission,
- testy jednostkowe walidatora i błędnych CSV, - testy jednostkowe walidatora i błędnych CSV,
- testy serwisu z fake reader/validator/model — weryfikują dependency injection, - testy serwisu z fake reader/validator/model — weryfikują dependency injection,
- testy explainability, rankingu i wykresów, - testy explainability, rankingu i wykresów,
- testy smoke Streamlit dla demo, pustego uploadu i synchronizacji mapy cylindrów ze szczegółami. - testy smoke Streamlit dla demo, pustego uploadu i synchronizacji mapy cylindrów ze szczegółami.
- testy artefaktu: checksumy, zgodność bibliotek, brak odczytu danych treningowych przy starcie i regresja predykcji 600/600.
Szybka kontrola serwera: Szybka kontrola serwera:
@ -141,20 +164,12 @@ Oczekiwana odpowiedź: `ok`.
## Odporność operacyjna ## Odporność operacyjna
- Model jest trenowany raz i przechowywany w cache procesu. - Model jest trenowany poza aplikacją i ładowany raz z wersjonowanego artefaktu do cache procesu.
- Finalny obraz runtime nie zawiera `val.csv`, `train.csv` ani skryptów treningowych.
- Proces aplikacji działa jako użytkownik bez uprawnień roota; produkcyjny Compose
dodatkowo wymusza read-only root filesystem, usuwa capabilities i ustawia limity zasobów.
- Nieoczekiwany błąd inicjalizacji modelu uruchamia read-only fallback dla danych demo. - Nieoczekiwany błąd inicjalizacji modelu uruchamia read-only fallback dla danych demo.
- Nieprawidłowe dane nie docierają do modelu. - Nieprawidłowe dane nie docierają do modelu.
- Błędy użytkownika mają stabilne kody i nie pokazują tracebacków w interfejsie. - Błędy użytkownika mają stabilne kody i nie pokazują tracebacków w interfejsie.
- Zależności produkcyjne są przypięte w `requirements-lock.txt`. - Zależności produkcyjne są przypięte w `requirements-lock.txt`.
- Aplikacja nie zmienia przesłanego pliku i nie wykonuje połączeń zewnętrznych. - Aplikacja nie zmienia przesłanego pliku i nie wykonuje połączeń zewnętrznych.
## Materiały konkursowe
- [Scenariusz demo](docs/DEMO_SCENARIO.md)
- [Pytania i odpowiedzi dla jury](docs/JURY_QA.md)
- [Odpowiedź na przegląd i testy regresyjne](docs/REVIEW_RESPONSE.md)
- `predictions.csv` — finalny submission
- `prediction_diagnostics.csv` — rozszerzone dane do produktu
- `presentation/ENGIN_pitch_deck.pptx` — prezentacja konkursowa
Formuła konkursowa: `Raw Score = 0.75 × Macro F1(label) + 0.25 × Accuracy(severity dla uszkodzonych)`. Część ML daje maksymalnie 40 punktów; produkt, explainability, wydajność i prezentacja — 60 punktów.

363
app.py
View File

@ -7,18 +7,15 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import numpy as np
import pandas as pd import pandas as pd
import streamlit as st import streamlit as st
from engin.charts import cylinder_spectrum, deviation_chart, engine_heatmap from engin.charts import cylinder_spectrum, deviation_chart, engine_heatmap
from engin.config import ( from engin.config import (
AppConfig,
LABEL_COLORS, LABEL_COLORS,
LABEL_DISPLAY, LABEL_DISPLAY,
LABEL_ICONS,
NOT_APPLICABLE,
SEVERITY_DISPLAY, SEVERITY_DISPLAY,
AppConfig,
) )
from engin.errors import UserFacingError from engin.errors import UserFacingError
from engin.explainability import ( from engin.explainability import (
@ -32,9 +29,34 @@ from engin.model import PrecomputedPredictionModel, SklearnPredictionModel
from engin.service import DiagnosisResult, DiagnosticService from engin.service import DiagnosisResult, DiagnosticService
from engin.validation import SpectrumFrameValidator from engin.validation import SpectrumFrameValidator
LOGGER = logging.getLogger("engin.app") LOGGER = logging.getLogger("engin.app")
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
MODEL_VERSION = "engin-2026.08.25-1"
MODEL_ARTIFACT_DIR = BASE_DIR / "artifacts" / MODEL_VERSION
VIEW_OVERVIEW = "Przegląd"
VIEW_DETAIL = "Szczegóły cylindra"
NO_COMPARISON = 0
PLOTLY_CONFIG = {"displayModeBar": False, "displaylogo": False}
DIAGNOSTIC_COLUMN_DISPLAY = {
"engine_id": "Silnik",
"cylinder": "Cylinder",
"label": "Diagnoza",
"severity": "Nasilenie",
"raw_model_label": "Surowa diagnoza modelu",
"decision_source": "Źródło decyzji",
"label_confidence": "Wynik diagnozy",
"raw_model_confidence": "Surowy wynik diagnozy",
"label_margin": "Margines decyzji",
"severity_confidence": "Wynik oceny nasilenia",
"anomaly_score_mean_abs_mv": "Średnie odchylenie bezwzględne [mV]",
"anomaly_ratio_to_engine_median": "Odchylenie względem mediany silnika",
"ood_absolute_threshold_mv": "Bezwzględny próg anomalii [mV]",
"ood_ratio_threshold": "Względny próg anomalii",
"top_anomalous_frequencies_khz": "Anomalne częstotliwości [kHz]",
"missing_spectral_cells": "Brakujące pomiary widma",
"model_version": "Wersja modelu",
"model_artifact_sha256": "Suma SHA-256 artefaktu",
}
@dataclass(frozen=True) @dataclass(frozen=True)
@ -43,26 +65,30 @@ class AppDependencies:
demo_payload: bytes demo_payload: bytes
live_inference: bool = True live_inference: bool = True
startup_warning: str | None = None startup_warning: str | None = None
model_version: str = "unknown"
def _reader() -> PandasCsvReader: def _reader() -> PandasCsvReader:
return PandasCsvReader(AppConfig().max_upload_bytes) return PandasCsvReader(AppConfig().max_upload_bytes)
@st.cache_resource(show_spinner="Przygotowanie modelu diagnostycznego…") @st.cache_resource(show_spinner="Ładowanie modelu diagnostycznego…")
def build_dependencies() -> AppDependencies: def build_dependencies() -> AppDependencies:
reader = _reader() reader = _reader()
validator = SpectrumFrameValidator() validator = SpectrumFrameValidator()
demo_path = BASE_DIR / "test.csv" demo_path = BASE_DIR / "test.csv"
demo_payload = demo_path.read_bytes() demo_payload = demo_path.read_bytes()
try: try:
reference = reader.read_path(BASE_DIR / "val.csv") model = SklearnPredictionModel.from_artifact(
model = SklearnPredictionModel.train(reference, random_state=42, n_jobs=-1) MODEL_ARTIFACT_DIR,
expected_model_version=MODEL_VERSION,
)
return AppDependencies( return AppDependencies(
service=DiagnosticService(reader=reader, validator=validator, model=model), service=DiagnosticService(reader=reader, validator=validator, model=model),
demo_payload=demo_payload, demo_payload=demo_payload,
model_version=MODEL_VERSION,
) )
except Exception as exc: except Exception:
LOGGER.exception("Live model initialization failed; enabling demo fallback") LOGGER.exception("Live model initialization failed; enabling demo fallback")
submission = pd.read_csv(BASE_DIR / "predictions.csv") submission = pd.read_csv(BASE_DIR / "predictions.csv")
diagnostics = pd.read_csv(BASE_DIR / "prediction_diagnostics.csv") diagnostics = pd.read_csv(BASE_DIR / "prediction_diagnostics.csv")
@ -72,9 +98,10 @@ def build_dependencies() -> AppDependencies:
demo_payload=demo_payload, demo_payload=demo_payload,
live_inference=False, live_inference=False,
startup_warning=( startup_warning=(
"Model live nie został przygotowany. Aplikacja działa w bezpiecznym " "Artefakt modelu nie został załadowany. Aplikacja działa w bezpiecznym "
"trybie demonstracyjnym na zapisanych predykcjach." "trybie demonstracyjnym na zapisanych predykcjach."
), ),
model_version="tryb-demonstracyjny",
) )
@ -95,23 +122,36 @@ def _render_error(error: UserFacingError) -> None:
st.caption(f"Kod błędu: `{error.code}`") st.caption(f"Kod błędu: `{error.code}`")
def _format_confidence(value: float | int | None) -> str: def _diagnostics_for_display(frame: pd.DataFrame) -> pd.DataFrame:
if value is None or pd.isna(value): display = frame.copy()
return "" for column in ("label", "raw_model_label"):
return f"{100 * float(value):.0f}%" if column in display:
display[column] = display[column].map(LABEL_DISPLAY).fillna(display[column])
if "severity" in display:
display["severity"] = (
display["severity"].map(SEVERITY_DISPLAY).fillna(display["severity"])
)
if "decision_source" in display:
display["decision_source"] = display["decision_source"].replace(
{
"classifier": "Klasyfikator spektralny",
"ood_override": "Reguła anomalii",
}
)
if "model_version" in display:
display["model_version"] = display["model_version"].replace(
{"demo-precomputed": "tryb demonstracyjny"}
)
return display.rename(columns=DIAGNOSTIC_COLUMN_DISPLAY)
def _render_header(live_inference: bool) -> None: def _render_header() -> None:
mode = "LIVE INFERENCE" if live_inference else "DEMO FALLBACK"
st.markdown( st.markdown(
f""" """
<div class="product-header"> <div class="product-header">
<div> <div class="eyebrow">AESTEEL · DIAGNOSTYKA WTRYSKU DIESEL</div>
<div class="eyebrow">AESTEEL · DIESEL INJECTION DIAGNOSTICS</div> <h1>Konsola diagnostyczna ENGIN</h1>
<h1>ENGIN Diagnostic Console</h1> <p>Diagnoza cylindra, nasilenie i wyjaśnienie oparte na widmie.</p>
<p>Akustyczna diagnostyka każdego cylindra typ usterki, nasilenie i uzasadnienie.</p>
</div>
<div class="runtime-badge"> {mode}<br><span>CPU · LEAKAGE-SAFE</span></div>
</div> </div>
""", """,
unsafe_allow_html=True, unsafe_allow_html=True,
@ -125,67 +165,65 @@ def _render_summary(summary) -> None:
<div><span>STATUS SILNIKA</span><strong>{html.escape(summary.status)}</strong></div> <div><span>STATUS SILNIKA</span><strong>{html.escape(summary.status)}</strong></div>
<div><span>NAJWYŻSZE NASILENIE</span><strong>{html.escape(summary.highest_severity_display)}</strong></div> <div><span>NAJWYŻSZE NASILENIE</span><strong>{html.escape(summary.highest_severity_display)}</strong></div>
<div><span>WYMAGA UWAGI</span><strong>{summary.attention} / {summary.cylinders}</strong></div> <div><span>WYMAGA UWAGI</span><strong>{summary.attention} / {summary.cylinders}</strong></div>
<div><span>ŚR. SCORE MODELU</span><strong>{_format_confidence(summary.mean_confidence)}</strong></div> <div><span>PIERWSZY DO KONTROLI</span><strong>C{summary.top_cylinder:02d}</strong></div>
</div> </div>
""", """,
unsafe_allow_html=True, unsafe_allow_html=True,
) )
def _select_cylinder(session_key: str, cylinder: int) -> None:
st.session_state[session_key] = cylinder
def _render_cylinder_grid(analysis, session_key: str) -> None:
st.markdown("### Mapa cylindrów")
columns = st.columns(4)
selected_cylinder = int(st.session_state[session_key])
for index, row in analysis.diagnostics.iterrows():
cylinder = int(row["cylinder"])
label = str(row["label"])
severity = str(row["severity"])
icon = LABEL_ICONS[label]
short_label = LABEL_DISPLAY[label]
if severity != NOT_APPLICABLE:
short_label += f" · {SEVERITY_DISPLAY[severity]}"
button_label = f"{icon} C{cylinder:02d}\n{short_label}"
with columns[index % 4]:
st.button(
button_label,
key=f"cylinder_{analysis.engine_id}_{cylinder}",
width="stretch",
type="primary" if cylinder == selected_cylinder else "secondary",
on_click=_select_cylinder,
args=(session_key, cylinder),
)
def _render_engine_overview(analysis) -> None: def _render_engine_overview(analysis) -> None:
left, right = st.columns([1.45, 1.0], gap="large") left, right = st.columns(2, gap="large", vertical_alignment="top")
with left: with left:
st.plotly_chart(engine_heatmap(analysis), width="stretch", key=f"heatmap_{analysis.engine_id}") st.markdown(
'<div class="overview-panel-title">Mapa odchyleń</div>',
unsafe_allow_html=True,
)
st.plotly_chart(
engine_heatmap(analysis),
width="stretch",
key=f"heatmap_{analysis.engine_id}",
theme=None,
config=PLOTLY_CONFIG,
)
with right: with right:
st.markdown("### Priorytet kontroli") st.markdown(
'<div class="overview-panel-title">Priorytet kontroli</div>',
unsafe_allow_html=True,
)
ranking = rank_cylinders(analysis).head(6).copy() ranking = rank_cylinders(analysis).head(6).copy()
ranking["Kolejność"] = range(1, len(ranking) + 1)
ranking["Cylinder"] = ranking["cylinder"].map(lambda value: f"C{int(value):02d}") ranking["Cylinder"] = ranking["cylinder"].map(lambda value: f"C{int(value):02d}")
ranking["Diagnoza"] = ranking["label_display"] ranking["Diagnoza"] = ranking["label_display"]
ranking["Nasilenie"] = ranking["severity_display"] ranking["Nasilenie"] = ranking["severity_display"]
ranking["Score modelu"] = ranking["label_confidence"].map(_format_confidence) ranking["Odchylenie [mV]"] = ranking["anomaly_score_mean_abs_mv"].round(1)
ranking["Priorytet"] = ranking["priority_display"]
st.dataframe( st.dataframe(
ranking[["Cylinder", "Diagnoza", "Nasilenie", "Score modelu", "Priorytet"]], ranking[
[
"Kolejność",
"Cylinder",
"Diagnoza",
"Nasilenie",
"Odchylenie [mV]",
]
],
hide_index=True, hide_index=True,
width="stretch", width="stretch",
height=315, height=360,
) )
st.caption("Kolejność: severity, odchylenie widma, następnie score modelu. Score nie jest skalibrowanym prawdopodobieństwem.")
def _render_cylinder_detail(analysis, cylinder: int) -> None: def _render_cylinder_detail(
analysis,
cylinder: int,
comparison_cylinders: list[int],
) -> None:
explanation = explain_cylinder(analysis, cylinder) explanation = explain_cylinder(analysis, cylinder)
row = analysis.diagnostics[analysis.diagnostics["cylinder"].eq(cylinder)].iloc[0]
color = LABEL_COLORS[explanation.label] color = LABEL_COLORS[explanation.label]
severity_confidence = row.get("severity_confidence", np.nan) priority = rank_cylinders(analysis).loc[
lambda frame: frame["cylinder"].eq(cylinder), "priority_display"
].iloc[0]
top_bands = ", ".join(f"{value} kHz" for value in explanation.top_frequencies)
st.markdown( st.markdown(
f""" f"""
<div class="diagnosis-card" style="--diagnosis-color:{color}"> <div class="diagnosis-card" style="--diagnosis-color:{color}">
@ -195,71 +233,135 @@ def _render_cylinder_detail(analysis, cylinder: int) -> None:
<p>{html.escape(explanation.severity_display)}</p> <p>{html.escape(explanation.severity_display)}</p>
</div> </div>
<div class="diagnosis-metrics"> <div class="diagnosis-metrics">
<div><span>Score label</span><strong>{_format_confidence(explanation.confidence)}</strong></div> <div><span>Priorytet</span><strong>{html.escape(priority)}</strong></div>
<div><span>Score severity</span><strong>{_format_confidence(severity_confidence)}</strong></div>
<div><span>Średnie odchylenie</span><strong>{explanation.anomaly_score:.1f} mV</strong></div> <div><span>Średnie odchylenie</span><strong>{explanation.anomaly_score:.1f} mV</strong></div>
<div><span>Główne pasma</span><strong>{html.escape(top_bands)}</strong></div>
</div> </div>
</div> </div>
""", """,
unsafe_allow_html=True, unsafe_allow_html=True,
) )
spectrum_col, deviation_col = st.columns([1.25, 1.0], gap="large") st.markdown("#### Widma porównawcze")
with spectrum_col: st.plotly_chart(
st.plotly_chart( cylinder_spectrum(analysis, cylinder, comparison_cylinders),
cylinder_spectrum(analysis, cylinder), width="stretch",
width="stretch", key=(
key=f"spectrum_{analysis.engine_id}_{cylinder}", f"spectrum_{analysis.engine_id}_"
) + "_".join(str(value) for value in comparison_cylinders)
with deviation_col: ),
st.plotly_chart( theme=None,
deviation_chart(analysis, cylinder), config=PLOTLY_CONFIG,
width="stretch", )
key=f"deviation_{analysis.engine_id}_{cylinder}",
)
why, next_step = st.columns(2, gap="large") st.markdown("#### Odchylenie cylindra głównego")
with why: st.plotly_chart(
st.markdown("#### Dlaczego taka diagnoza?") deviation_chart(analysis, cylinder),
st.write(explanation.reason) width="stretch",
frequencies = " · ".join(f"{value} kHz" for value in explanation.top_frequencies) key=f"deviation_{analysis.engine_id}_{cylinder}",
st.markdown(f"**Najbardziej anomalne pasma:** `{frequencies}`") theme=None,
with next_step: config=PLOTLY_CONFIG,
st.markdown("#### Rekomendowany następny krok") )
st.write(explanation.recommendation)
source_label = "Reguła OOD" if explanation.decision_source == "ood_override" else "Klasyfikator spektralny" st.markdown("#### Uzasadnienie diagnozy")
st.caption(f"Źródło decyzji: {source_label}") st.write(explanation.reason)
st.caption("Score modelu służy do porównania predykcji; nie jest kalibrowanym prawdopodobieństwem awarii.")
def _store_selected_cylinder(state_key: str, widget_key: str) -> None:
st.session_state[state_key] = int(st.session_state[widget_key])
def _comparison_label(value: int) -> str:
if value == NO_COMPARISON:
return "Bez porównania"
return f"Cylinder {value:02d}"
def _render_cylinder_selectors(
analysis,
available: list[int],
selected_state_key: str,
) -> tuple[int, list[int]]:
selector_key = f"cylinder_selector_{analysis.engine_id}"
selected = int(st.session_state[selected_state_key])
if selector_key not in st.session_state or st.session_state[selector_key] not in available:
st.session_state[selector_key] = selected
with st.container(border=True):
st.markdown("### Wybór cylindra")
columns = st.columns([1.35, 1.0, 1.0, 1.0], gap="medium")
with columns[0]:
primary = int(
st.selectbox(
"Cylinder do analizy",
available,
format_func=lambda value: f"Cylinder {value:02d}",
key=selector_key,
on_change=_store_selected_cylinder,
args=(selected_state_key, selector_key),
)
)
comparisons: list[int] = []
for slot, column in enumerate(columns[1:], start=1):
comparison_key = f"comparison_slot_{analysis.engine_id}_{slot}"
options = [
NO_COMPARISON,
*(
cylinder
for cylinder in available
if cylinder != primary and cylinder not in comparisons
),
]
if (
comparison_key not in st.session_state
or st.session_state[comparison_key] not in options
):
st.session_state[comparison_key] = NO_COMPARISON
with column:
comparison = int(
st.selectbox(
f"Porównanie {slot}",
options,
format_func=_comparison_label,
key=comparison_key,
)
)
if comparison != NO_COMPARISON:
comparisons.append(comparison)
st.session_state[selected_state_key] = primary
return primary, comparisons
def _render_technical(result: DiagnosisResult) -> None: def _render_technical(result: DiagnosisResult) -> None:
st.markdown("### Kontrola jakości i architektura") st.markdown("### Walidacja i model")
c1, c2, c3, c4 = st.columns(4) c1, c2, c3, c4 = st.columns(4)
c1.metric("Grouped Macro F1", "0.981") c1.metric("Makro F1 (grupowe)", "0.981")
c2.metric("Severity accuracy", "0.930") c2.metric("Trafność nasilenia", "0.930")
c3.metric("Walidacyjne ML", "33.65 / 40") c3.metric("Punkty walidacyjne", "33.65 / 40")
c4.metric("Testy", "32/32") c4.metric("Testy", "38 / 38")
st.markdown( st.caption(
""" "Walidacja grupowa według silników · średnia z 5 uruchomień · "
- **Zero leakage:** każdy fold zawiera kompletne, wcześniej niewidziane silniki. "makro F1 przy 5% braków: 0.983 · wnioskowanie lokalne na CPU."
- **Odporność na braki:** wynik sprawdzony przy dokładnie 5% zamaskowanych komórek widma. )
- **CPU-first:** Logistic Regression + Extra Trees, bez GPU i z deterministycznymi seedami. st.markdown("#### Dane diagnostyczne")
- **Explainability:** każda diagnoza korzysta z referencji pozostałych cylindrów tego samego silnika. st.dataframe(
""" _diagnostics_for_display(result.diagnostics),
width="stretch",
hide_index=True,
) )
with st.expander("Pokaż dane diagnostyczne"):
st.dataframe(result.diagnostics, width="stretch", hide_index=True)
def render_app(dependencies: AppDependencies | None = None) -> None: def render_app(dependencies: AppDependencies | None = None) -> None:
st.set_page_config( st.set_page_config(
page_title="ENGIN Diagnostic Console", page_title="Konsola diagnostyczna ENGIN",
page_icon="⚙️", page_icon="⚙️",
layout="wide", layout="wide",
initial_sidebar_state="expanded", initial_sidebar_state="expanded",
) )
_load_css() _load_css()
deps = dependencies or build_dependencies() deps = dependencies or build_dependencies()
_render_header(deps.live_inference) _render_header()
with st.sidebar: with st.sidebar:
st.markdown("## Centrum diagnostyczne") st.markdown("## Centrum diagnostyczne")
@ -271,7 +373,7 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
payload: bytes | None payload: bytes | None
if source == "Dane demonstracyjne": if source == "Dane demonstracyjne":
payload = deps.demo_payload payload = deps.demo_payload
st.success("Załadowano bezpieczny zestaw demonstracyjny") st.success("Dane demonstracyjne załadowane")
else: else:
uploaded = st.file_uploader("Plik pomiarowy CSV", type=["csv"]) uploaded = st.file_uploader("Plik pomiarowy CSV", type=["csv"])
payload = uploaded.getvalue() if uploaded is not None else None payload = uploaded.getvalue() if uploaded is not None else None
@ -303,46 +405,59 @@ def render_app(dependencies: AppDependencies | None = None) -> None:
with st.sidebar: with st.sidebar:
engine_id = st.selectbox("Aktywny silnik", result.engine_ids) engine_id = st.selectbox("Aktywny silnik", result.engine_ids)
st.download_button( st.download_button(
"Pobierz predictions.csv", "Pobierz wyniki (CSV)",
data=result.submission.to_csv(index=False).encode("utf-8"), data=result.submission.to_csv(index=False).encode("utf-8"),
file_name="predictions.csv", file_name="predictions.csv",
mime="text/csv", mime="text/csv",
width="stretch", width="stretch",
) )
st.download_button( st.download_button(
"Pobierz diagnostykę", "Pobierz dane diagnostyczne (CSV)",
data=result.diagnostics.to_csv(index=False).encode("utf-8"), data=result.diagnostics.to_csv(index=False).encode("utf-8"),
file_name="prediction_diagnostics.csv", file_name="prediction_diagnostics.csv",
mime="text/csv", mime="text/csv",
width="stretch", width="stretch",
) )
st.divider()
st.caption("Model ENGIN v1 · CPU inference · brak połączeń zewnętrznych")
analysis = analyze_engine(result, str(engine_id)) analysis = analyze_engine(result, str(engine_id))
summary = summarize_engine(analysis) summary = summarize_engine(analysis)
_render_summary(summary) _render_summary(summary)
session_key = f"selected_cylinder_{analysis.engine_id}" session_key = f"active_cylinder_{analysis.engine_id}"
available = analysis.measurements["cylinder"].astype(int).tolist() available = analysis.measurements["cylinder"].astype(int).tolist()
if session_key not in st.session_state or st.session_state[session_key] not in available: if session_key not in st.session_state or st.session_state[session_key] not in available:
st.session_state[session_key] = summary.top_cylinder st.session_state[session_key] = summary.top_cylinder
_render_cylinder_grid(analysis, session_key)
overview_tab, detail_tab, technical_tab = st.tabs( view_key = f"active_view_{analysis.engine_id}"
["Przegląd silnika", "Szczegóły cylindra", "Walidacja i model"] if view_key not in st.session_state:
st.session_state[view_key] = VIEW_OVERVIEW
view = st.segmented_control(
"Widok",
[VIEW_OVERVIEW, VIEW_DETAIL],
required=True,
key=view_key,
label_visibility="collapsed",
width="stretch",
) )
with overview_tab:
if view == VIEW_OVERVIEW:
_render_engine_overview(analysis) _render_engine_overview(analysis)
with detail_tab: elif view == VIEW_DETAIL:
selected_from_box = st.selectbox( selected_from_box, additional_cylinders = _render_cylinder_selectors(
"Cylinder do analizy", analysis,
available, available,
format_func=lambda value: f"Cylinder {value:02d}", session_key,
key=session_key,
) )
_render_cylinder_detail(analysis, int(selected_from_box)) comparison_cylinders = [
with technical_tab: int(selected_from_box),
*(int(value) for value in additional_cylinders),
]
_render_cylinder_detail(
analysis,
int(selected_from_box),
comparison_cylinders,
)
with st.expander("Informacje techniczne", expanded=False):
_render_technical(result) _render_technical(result)

View File

@ -0,0 +1,49 @@
{
"artifact_format_version": 1,
"created_at_utc": "2026-08-25T10:57:21.704599+00:00",
"input_columns": [
"engine_id",
"cylinder",
"n_cylinders",
"mV_0",
"mV_1",
"mV_2",
"mV_3",
"mV_4",
"mV_5",
"mV_6",
"mV_7",
"mV_8",
"mV_9",
"mV_10",
"mV_11",
"mV_12",
"mV_13",
"mV_14",
"mV_15",
"mV_16",
"mV_17",
"mV_18",
"mV_19",
"mV_20"
],
"label_model": "deviation_logistic_c10",
"model_sha256": "a649e96ab13dd49a69d73e12fab46f830d128dac49a93464998a4bdfe8cbec2f",
"model_version": "engin-2026.08.25-1",
"n_jobs": 1,
"ood_absolute_threshold_mv": 7.25,
"ood_ratio_threshold": 2.5,
"python_version": "3.12.13",
"random_state": 42,
"runtime_versions": {
"joblib": "1.5.3",
"numpy": "2.3.5",
"pandas": "2.2.3",
"scikit-learn": "1.8.0",
"scipy": "1.18.1",
"threadpoolctl": "3.6.0"
},
"severity_model": "deviation_extra_trees_mf03",
"source_revision": "8981ae0a0086e1bd6d8ab6900ddd5a78bc92a304",
"training_data_sha256": "82120af46fa62eefc1cc62f7cd953c4af830207f1d5083dccff5900fb57737f1"
}

Binary file not shown.

View File

@ -9,6 +9,7 @@
--green: #38d996; --green: #38d996;
--orange: #f5a524; --orange: #f5a524;
--red: #ff6b57; --red: #ff6b57;
--radius: 6px;
} }
[data-testid="stAppViewContainer"] { [data-testid="stAppViewContainer"] {
@ -22,86 +23,142 @@
[data-testid="stSidebar"] { background: #0b171d; border-right: 1px solid var(--line); } [data-testid="stSidebar"] { background: #0b171d; border-right: 1px solid var(--line); }
[data-testid="stMainBlockContainer"] { padding-top: 2.1rem; max-width: 1500px; } [data-testid="stMainBlockContainer"] { padding-top: 2.1rem; max-width: 1500px; }
/* Streamlit sizes segmented-control items from their labels. The main view
switch is intentionally a balanced two-column control. */
[data-testid="stMainBlockContainer"] [data-testid="stButtonGroup"] [role="radiogroup"] {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
width: 100% !important;
}
[data-testid="stMainBlockContainer"] [data-testid="stButtonGroup"] [role="radiogroup"] > button {
width: 100% !important;
min-width: 0 !important;
justify-content: center !important;
}
.product-header { .product-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 2rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
padding-bottom: 1.25rem; padding-bottom: 1.25rem;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.product-header h1 { margin: .2rem 0 .25rem; color: #f3f8fa; font-size: 2rem; letter-spacing: -.035em; } .product-header h1 { margin: .2rem 0 .25rem; color: #f3f8fa; font-size: 2rem; line-height: 1.12; letter-spacing: -.035em; }
.product-header p { margin: 0; color: var(--muted); } .product-header p { margin: 0; color: var(--muted); }
.eyebrow { color: var(--cyan); font-size: .7rem; font-weight: 800; letter-spacing: .17em; } .eyebrow { color: var(--cyan); font-size: .7rem; font-weight: 800; letter-spacing: .17em; }
.runtime-badge {
min-width: 170px;
padding: .7rem .9rem;
border: 1px solid rgba(56, 217, 150, .32);
background: rgba(56, 217, 150, .07);
color: var(--green);
font: 700 .72rem/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: .06em;
}
.runtime-badge span { color: var(--muted); font-size: .64rem; }
.status-strip { .status-strip {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
border: 1px solid var(--line); border: 1px solid var(--line);
border-left: 4px solid var(--green); border-left: 4px solid var(--green);
border-radius: var(--radius);
background: linear-gradient(90deg, rgba(56, 217, 150, .07), rgba(16, 27, 34, .92)); background: linear-gradient(90deg, rgba(56, 217, 150, .07), rgba(16, 27, 34, .92));
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
overflow: hidden;
} }
.status-strip > div { padding: 1rem 1.2rem; border-right: 1px solid var(--line); } .status-strip > div { padding: 1rem 1.2rem; border-right: 1px solid var(--line); text-align: center; }
.status-strip > div:last-child { border-right: 0; } .status-strip > div:last-child { border-right: 0; }
.status-strip span, .diagnosis-metrics span { display: block; color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .11em; } .status-strip span, .diagnosis-metrics span { display: block; color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .11em; }
.status-strip strong { display: block; margin-top: .2rem; font-size: 1.28rem; color: var(--text); } .status-strip strong { display: block; margin-top: .2rem; font-size: 1.28rem; color: var(--text); }
.status-warning { border-left-color: var(--orange); background: linear-gradient(90deg, rgba(245, 165, 36, .08), rgba(16, 27, 34, .92)); } .status-warning { border-left-color: var(--orange); background: linear-gradient(90deg, rgba(245, 165, 36, .08), rgba(16, 27, 34, .92)); }
.status-critical { border-left-color: var(--red); background: linear-gradient(90deg, rgba(255, 107, 87, .09), rgba(16, 27, 34, .92)); } .status-critical { border-left-color: var(--red); background: linear-gradient(90deg, rgba(255, 107, 87, .09), rgba(16, 27, 34, .92)); }
.status-unknown { border-left-color: #d56cff; } .status-unknown {
border-left-color: #d56cff;
background: linear-gradient(90deg, rgba(213, 108, 255, .08), rgba(16, 27, 34, .92));
}
.diagnosis-card { .diagnosis-card {
--diagnosis-color: var(--cyan); --diagnosis-color: var(--cyan);
display: flex; display: grid;
grid-template-columns: minmax(220px, .8fr) minmax(0, 1.6fr);
align-items: center; align-items: center;
justify-content: space-between;
gap: 2rem; gap: 2rem;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
margin: .5rem 0 1rem; margin: .5rem 0 1rem;
background: linear-gradient(90deg, color-mix(in srgb, var(--diagnosis-color) 12%, transparent), var(--panel)); background: linear-gradient(90deg, color-mix(in srgb, var(--diagnosis-color) 12%, transparent), var(--panel));
border: 1px solid var(--line); border: 1px solid var(--line);
border-left: 4px solid var(--diagnosis-color); border-left: 4px solid var(--diagnosis-color);
border-radius: var(--radius);
} }
.diagnosis-card h2 { margin: .08rem 0; color: var(--text); } .diagnosis-card h2 { margin: .08rem 0; color: var(--text); }
.diagnosis-card p { margin: 0; color: var(--muted); } .diagnosis-card p { margin: 0; color: var(--muted); }
.diagnosis-kicker { color: var(--diagnosis-color); font: 800 .68rem ui-monospace, monospace; letter-spacing: .12em; } .diagnosis-kicker { color: var(--diagnosis-color); font: 800 .68rem ui-monospace, monospace; letter-spacing: .12em; }
.diagnosis-metrics { display: flex; gap: 2rem; } .diagnosis-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(110px, 1fr));
gap: 1rem;
}
.diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; } .diagnosis-metrics strong { display: block; margin-top: .2rem; color: var(--text); font-size: 1.05rem; }
[data-testid="stButton"] button { .overview-panel-title {
min-height: 4.2rem; display: flex;
white-space: pre-line; min-height: 2.75rem;
border-radius: 3px; align-items: center;
border-color: var(--line); justify-content: center;
background: var(--panel); color: var(--text);
font-weight: 650; font-size: 1.15rem;
font-weight: 700;
text-align: center;
} }
[data-testid="stButton"] button:hover { border-color: var(--cyan); color: #fff; }
[data-testid="stButton"] button[kind="primary"] { border-color: var(--cyan); background: rgba(40, 183, 217, .13); }
[data-testid="stMetric"] { padding: .7rem; border: 1px solid var(--line); background: var(--panel); } [data-testid="stVerticalBlockBorderWrapper"] {
[data-testid="stDataFrame"], [data-testid="stPlotlyChart"] { border: 1px solid var(--line); } border-color: rgba(40, 183, 217, .32) !important;
border-left: 4px solid var(--cyan) !important;
border-radius: var(--radius);
background: linear-gradient(90deg, rgba(40, 183, 217, .06), rgba(16, 27, 34, .72));
}
@media (max-width: 900px) { [data-testid="stMetric"] {
.product-header, .diagnosis-card { flex-direction: column; } min-height: 5.2rem;
.runtime-badge { width: 100%; } padding: .75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
}
[data-testid="stDataFrame"], [data-testid="stPlotlyChart"] {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
overflow: hidden;
}
[data-testid="stDownloadButton"] button,
[data-testid="stFileUploaderDropzone"] {
border-color: var(--line);
border-radius: var(--radius);
}
[data-testid="stDownloadButton"] button:hover { border-color: var(--cyan); }
[data-testid="stExpander"] details {
border-color: var(--line);
border-radius: var(--radius);
background: rgba(16, 27, 34, .58);
}
[data-baseweb="select"] > div { border-radius: var(--radius); }
/* Streamlit 1.62 has no interface locale setting, so localize its uploader chrome. */
[data-testid="stFileUploaderDropzone"] button [data-testid="stMarkdownContainer"] { display: none; }
[data-testid="stFileUploaderDropzone"] button::after { content: "Wybierz plik"; }
[data-testid="stFileUploaderDropzoneInstructions"] > div { display: none; }
[data-testid="stFileUploaderDropzoneInstructions"]::after {
content: "Maks. 10 MB na plik · CSV";
color: var(--muted);
font-size: .875rem;
white-space: nowrap;
}
[data-testid="stFileUploaderDropzone"] > div:not([data-testid]) > span { font-size: 0; }
[data-testid="stFileUploaderDropzone"] > div:not([data-testid]) > span::after {
content: "Upuść plik tutaj";
font-size: .875rem;
}
@media (max-width: 1000px) {
.diagnosis-card { grid-template-columns: 1fr; }
.status-strip { grid-template-columns: repeat(2, 1fr); } .status-strip { grid-template-columns: repeat(2, 1fr); }
.diagnosis-metrics { width: 100%; flex-wrap: wrap; gap: 1rem 1.6rem; } .diagnosis-metrics { width: 100%; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.product-header h1 { font-size: 1.65rem; }
.status-strip { grid-template-columns: 1fr; } .status-strip { grid-template-columns: 1fr; }
.status-strip > div { border-right: 0; border-bottom: 1px solid var(--line); } .status-strip > div { border-right: 0; border-bottom: 1px solid var(--line); }
.status-strip > div:last-child { border-bottom: 0; }
.diagnosis-metrics { grid-template-columns: 1fr; }
} }

View File

@ -33,13 +33,9 @@ from sklearn.model_selection import GroupKFold, StratifiedGroupKFold
from sklearn.pipeline import Pipeline from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import StandardScaler
from engin.config import FAULT_LABELS, FREQ_COLS, LABELS, NOT_APPLICABLE, SEVERITIES
RANDOM_STATE = 42 RANDOM_STATE = 42
FREQ_COLS = [f"mV_{frequency}" for frequency in range(21)]
LABELS = ["ok", "zakoksowany", "lejacy", "pompa", "iglica", "unknown"]
FAULT_LABELS = ["zakoksowany", "lejacy", "pompa", "iglica"]
SEVERITIES = ["male", "srednie", "duze"]
NOT_APPLICABLE = "nie_dotyczy"
FEATURE_SETS = ("raw", "relative", "combined") FEATURE_SETS = ("raw", "relative", "combined")
MODEL_NAMES = ("logistic", "extra_trees") MODEL_NAMES = ("logistic", "extra_trees")

31
compose.prod.yaml Normal file
View File

@ -0,0 +1,31 @@
services:
engin:
build:
context: .
target: runtime
image: "engin-console:${ENGIN_IMAGE_TAG:-local}"
restart: unless-stopped
init: true
user: "10001:10001"
ports:
- "${ENGIN_BIND_ADDRESS:-127.0.0.1}:${ENGIN_PORT:-8501}:8501"
environment:
STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
XDG_CACHE_HOME: /tmp/.cache
MPLCONFIGDIR: /tmp/matplotlib
read_only: true
tmpfs:
- "/tmp:rw,noexec,nosuid,size=64m,mode=1777"
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
pids_limit: 256
mem_limit: 1g
cpus: 2.0
stop_grace_period: 30s
logging:
driver: local
options:
max-size: "10m"
max-file: "3"

View File

@ -2,43 +2,44 @@
## 0:000:35 — problem ## 0:000:35 — problem
„Jeden przemysłowy silnik ma 8, 12 lub 16 cylindrów. Mechanik dostaje 21 punktów widma dla każdego cylindra, ale potrzebuje decyzji: który cylinder sprawdzić, jakiej usterki szukać i jak pilna jest interwencja. ENGIN zamienia te pomiary w kolejkę konkretnych działań.” „Jeden przemysłowy silnik ma 8, 12 lub 16 cylindrów. Mechanik dostaje 21 punktów widma dla każdego cylindra, ale potrzebuje szybkiej odpowiedzi: który cylinder odstaje, jaki typ usterki wskazuje sygnał i jak duże jest nasilenie. ENGIN porządkuje cylindry do kontroli i pokazuje dowód w widmie.”
## 0:351:15 — ekran silnika ## 0:351:15 — ekran silnika
1. Uruchom `streamlit run app.py` i pozostaw „Dane demonstracyjne”. 1. Uruchom `streamlit run app.py` i pozostaw „Dane demonstracyjne”.
2. Wskaż status silnika, najwyższe severity i liczbę cylindrów wymagających uwagi. 2. Wskaż status silnika, najwyższe nasilenie i liczbę cylindrów wymagających uwagi.
3. Pokaż mapę cylindrów oraz ranking kontroli. 3. Pokaż mapę odchyleń: diagnoza jest zapisana przy każdym cylindrze, a stała skala kolorów pozwala porównywać silniki.
Komunikat: „Nie zmuszamy mechanika do czytania 21 wykresów. Najpierw widzi najważniejszy cylinder i pilność.” Komunikat: „Nie zmuszamy mechanika do czytania 21 wykresów. Najpierw widzi najważniejszy cylinder i pilność.”
## 1:152:20 — explainability ## 1:152:20 — uzasadnienie diagnozy
1. Otwórz „Szczegóły cylindra”. 1. Otwórz „Szczegóły cylindra”.
2. Wybierz najwyżej sklasyfikowany cylinder. 2. Wybierz najwyżej sklasyfikowany cylinder.
3. Porównaj jego widmo z medianą pozostałych cylindrów tego samego silnika. 3. Opcjonalnie dodaj drugi cylinder do porównania.
4. Wskaż zaznaczone pasma, score modelu, severity i rekomendowany następny krok. 4. Porównaj widmo z medianą pozostałych cylindrów tego samego silnika.
5. Wskaż priorytet, zaznaczone pasma, nasilenie i krótkie uzasadnienie diagnozy.
Komunikat: „Referencją jest konkretny silnik, nie abstrakcyjna średnia całej floty. Dzięki temu wynik jest łatwiejszy do zweryfikowania przez człowieka.” Komunikat: „Referencją jest konkretny silnik, nie abstrakcyjna średnia całej floty. Dzięki temu wynik jest łatwiejszy do zweryfikowania przez człowieka.”
## 2:203:05 — odporność ## 2:203:05 — odporność
1. Przejdź do „Walidacja i model”. 1. Rozwiń „Informacje techniczne”.
2. Pokaż grouped Macro F1 0.981, severity 0.930 i wynik przy 5% braków. 2. Pokaż grupowe makro F1 0.981, trafność nasilenia 0.930 i wynik przy 5% braków.
3. Powiedz, że cały silnik jest zawsze w jednym foldzie i że inference działa na CPU. 3. Powiedz, że cały silnik jest zawsze w jednym zbiorze walidacyjnym, a model działa na CPU.
Komunikat: „Wynik nie powstał przez przeciek między cylindrami tego samego silnika. Pipeline jest też testowany na brakach odpowiadających realnym danym warsztatowym.” Komunikat: „Wynik nie powstał przez przeciek między cylindrami tego samego silnika. Proces jest też testowany na brakach odpowiadających realnym danym warsztatowym.”
## 3:053:40 — własny plik i wartość ## 3:053:40 — własny plik i wartość
1. Pokaż opcję „Wgraj plik CSV”, ale nie przełączaj jej, jeśli czas jest napięty. 1. Pokaż opcję „Wgraj plik CSV”, ale nie przełączaj jej, jeśli czas jest napięty.
2. Pokaż przyciski pobrania submission oraz pełnej diagnostyki. 2. Pokaż przyciski pobrania wyników oraz pełnej diagnostyki.
Zamknięcie: „ENGIN daje trzy rzeczy naraz: konkursowo skuteczny model, weryfikowalne uzasadnienie i prostą decyzję serwisową. Działa lokalnie na CPU i jest gotowy na podłączenie do warsztatowego procesu.” Zamknięcie: „ENGIN daje trzy rzeczy naraz: skuteczną klasyfikację, weryfikowalne uzasadnienie i jasną kolejność kontroli cylindrów. Działa lokalnie na CPU i jest gotowy na podłączenie do warsztatowego procesu.”
## Plan awaryjny ## Plan awaryjny
- Jeśli nie ma internetu: aplikacja nie potrzebuje internetu. - Jeśli nie ma internetu: aplikacja nie potrzebuje internetu.
- Jeśli model nie wystartuje: automatycznie działa fallback na zapisanych predykcjach demo. - Jeśli model nie wystartuje: automatycznie działa tryb awaryjny na zapisanych predykcjach demonstracyjnych.
- Jeśli brakuje czasu: pokaż ekran główny, jeden cylinder i metryki — około 90 sekund. - Jeśli brakuje czasu: pokaż ekran główny, jeden cylinder i metryki — około 90 sekund.
- Przed wystąpieniem uruchom `python -m unittest discover -v` i zachowaj terminal z wynikiem 32/32. - Przed wystąpieniem uruchom `python -m unittest discover -v` i zachowaj terminal z wynikiem 38/38.

View File

@ -28,7 +28,7 @@ Nie. Status jest deterministyczną regułą biznesową opartą bezpośrednio na
## Czy score modelu jest prawdopodobieństwem awarii? ## Czy score modelu jest prawdopodobieństwem awarii?
Nie. To niekalibrowany wynik `predict_proba`, używany wyłącznie do względnego porównania decyzji klasyfikatora. Interfejs nazywa go „score”, nie „pewnością”. Dla `unknown` utworzonego przez regułę OOD pozostaje pusty zamiast sztucznie generować wartość 5099%. Nie. To niekalibrowany wynik `predict_proba`, używany wyłącznie do diagnostyki technicznej. Główny widok mechanika go nie pokazuje; zamiast tego prezentuje diagnozę, nasilenie, priorytet oraz dowód w widmie i porównanie z pozostałymi cylindrami. Dla `unknown` utworzonego przez regułę OOD wartość pozostaje pusta.
## Co robi aplikacja przy błędnym pliku? ## Co robi aplikacja przy błędnym pliku?

103
docs/PRODUCTION_RUNBOOK.md Normal file
View File

@ -0,0 +1,103 @@
# ENGIN — production runbook
## Kontrakt wdrożenia
- Docker Engine i Docker Compose v2.
- Aplikacja działa jako UID/GID `10001:10001`.
- Port jest domyślnie dostępny tylko na `127.0.0.1:8501`.
- Root filesystem kontenera jest tylko do odczytu; zapisywalny jest wyłącznie
tymczasowy `/tmp` o rozmiarze 64 MiB.
- Kontener nie ma Linux capabilities i nie może uzyskać nowych uprawnień.
- Limity: 2 CPU, 1 GiB RAM i 256 procesów.
- Przesłane CSV nie są zapisywane na dysku ani w wolumenie.
## Pierwszy start
Wykonuj polecenia z katalogu repozytorium na czystym, zatwierdzonym commicie:
```bash
export ENGIN_IMAGE_TAG="$(git rev-parse --short HEAD)"
docker compose -f compose.prod.yaml config --quiet
docker compose -f compose.prod.yaml build
docker compose -f compose.prod.yaml up -d --no-build
docker compose -f compose.prod.yaml ps
curl --fail http://127.0.0.1:8501/_stcore/health
```
Oczekiwana odpowiedź healthchecku: `ok`.
## Kontrola ograniczeń
```bash
CONTAINER_ID="$(docker compose -f compose.prod.yaml ps -q engin)"
docker inspect --format \
'user={{.Config.User}} readonly={{.HostConfig.ReadonlyRootfs}} pids={{.HostConfig.PidsLimit}} memory={{.HostConfig.Memory}} nanocpus={{.HostConfig.NanoCpus}}' \
"$CONTAINER_ID"
```
Oczekiwane wartości:
```text
user=10001:10001 readonly=true pids=256 memory=1073741824 nanocpus=2000000000
```
## Logi i diagnostyka
```bash
docker compose -f compose.prod.yaml logs --tail 200 engin
docker compose -f compose.prod.yaml ps
```
Logi są rotowane przez sterownik `local`: maksymalnie trzy pliki po 10 MiB.
## Aktualizacja
Najpierw zachowaj SHA aktualnie działającego obrazu, następnie zbuduj nową,
jednoznacznie otagowaną wersję:
```bash
docker compose -f compose.prod.yaml ps
git pull --ff-only
export ENGIN_IMAGE_TAG="$(git rev-parse --short HEAD)"
docker compose -f compose.prod.yaml build
docker compose -f compose.prod.yaml up -d --no-build
curl --fail http://127.0.0.1:8501/_stcore/health
```
Nie usuwaj poprzedniego obrazu przed zakończeniem smoke testu.
## Rollback
Jeżeli smoke test nowej wersji nie przejdzie, uruchom poprzedni lokalny tag bez
ponownego budowania:
```bash
ENGIN_IMAGE_TAG=<poprzedni_git_sha> \
docker compose -f compose.prod.yaml up -d --no-build
curl --fail http://127.0.0.1:8501/_stcore/health
```
Jeżeli obrazu o wskazanym tagu już nie ma, przełącz repo na zatwierdzony commit,
odbuduj obraz i ponownie wykonaj smoke test. Nie używaj niezaufanego artefaktu
modelu jako skrótu do rollbacku.
## Udostępnienie poza hostem
Domyślne wiązanie `127.0.0.1` jest celowe. Dostęp sieciowy włączaj dopiero za
reverse proxy z TLS i regułami firewalla. W takim środowisku można ustawić:
```bash
ENGIN_BIND_ADDRESS=0.0.0.0 \
docker compose -f compose.prod.yaml up -d --no-build
```
## Zatrzymanie
```bash
docker compose -f compose.prod.yaml down
```

View File

@ -2,7 +2,10 @@
| Uwaga z przeglądu | Zmiana | Dowód regresji | | Uwaga z przeglądu | Zmiana | Dowód regresji |
| --- | --- | --- | | --- | --- | --- |
| Mapa cylindrów i dropdown nadpisywały sobie stan | oba widgety korzystają z jednego klucza session state; kafelki ustawiają go callbackiem | `test_cylinder_grid_and_detail_selector_share_state` | | Wybór cylindra niepotrzebnie obciążał przegląd silnika | wybór jest dostępny wyłącznie w widoku szczegółów; przegląd pokazuje tylko stan i priorytety | `test_cylinder_selector_exists_only_in_detail_view` |
| Selektor i karta mogły po pierwszym wejściu wskazywać różne cylindry | trwały stan wyboru i stan widżetu mają osobne klucze; test porównuje wartość selektora z nagłówkiem karty | `test_cylinder_selector_exists_only_in_detail_view` |
| Wielokrotny wybór pokazywał angielskie i sprzeczne komunikaty | zastąpiono go trzema opcjonalnymi, polskimi polami porównawczymi bez możliwości duplikatów | `test_cylinder_selector_exists_only_in_detail_view` |
| Automatyczna skala mapy wyolbrzymiała różnice w zdrowych silnikach | mapa ma stałą skalę ±20 mV i opis diagnozy przy każdym cylindrze | `test_all_chart_factories_return_populated_figures` |
| Status `WYMAGA WERYFIKACJI` był nieosiągalny | status zależy bezpośrednio od label/severity; `unknown` bez nazwanej usterki ma dedykowany status | `test_unknown_only_engine_requires_verification` | | Status `WYMAGA WERYFIKACJI` był nieosiągalny | status zależy bezpośrednio od label/severity; `unknown` bez nazwanej usterki ma dedykowany status | `test_unknown_only_engine_requires_verification` |
| Engine Health 0100 był arbitralny | liczba została usunięta; UI pokazuje najwyższe rzeczywiste severity i porządkowy triage | testy explainability + smoke UI | | Engine Health 0100 był arbitralny | liczba została usunięta; UI pokazuje najwyższe rzeczywiste severity i porządkowy triage | testy explainability + smoke UI |
| OOD otrzymywał sztuczny confidence 5099% | score OOD jest `NaN`; UI nazywa pozostałe wartości score modelu i wyjaśnia brak kalibracji | `test_diagnostics_are_complete_for_the_application` | | OOD otrzymywał sztuczny confidence 5099% | score OOD jest `NaN`; UI nazywa pozostałe wartości score modelu i wyjaśnia brak kalibracji | `test_diagnostics_are_complete_for_the_application` |
@ -15,4 +18,4 @@ Po zmianie OOD ponownie wykonano pięć seedów grouped CV dla clean i masked 5%
- clean: Macro F1 `0.9811`, severity `0.9298`, Raw Score `0.9683`, - clean: Macro F1 `0.9811`, severity `0.9298`, Raw Score `0.9683`,
- masked 5%: Macro F1 `0.9829`, severity `0.9333`, Raw Score `0.9705`. - masked 5%: Macro F1 `0.9829`, severity `0.9333`, Raw Score `0.9705`.
Pełny pakiet po iteracji: `32/32` testów oraz poprawny healthcheck Streamlit `ok`. Pełny pakiet po iteracji: `38/38` testów oraz poprawny test stanu Streamlit `ok`.

View File

@ -1,5 +1,5 @@
"""Core application package for the ENGIN diagnostic console.""" """Core application package for the ENGIN diagnostic console."""
from .service import DiagnosticService, DiagnosisResult from .service import DiagnosisResult, DiagnosticService
__all__ = ["DiagnosticService", "DiagnosisResult"] __all__ = ["DiagnosticService", "DiagnosisResult"]

257
engin/artifact.py Normal file
View File

@ -0,0 +1,257 @@
"""Versioned, checksummed model-artifact persistence for runtime loading."""
from __future__ import annotations
import hashlib
import json
import os
import pickle
import platform
import tempfile
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from importlib.metadata import version
from pathlib import Path
from .inference import (
INFERENCE_COLUMNS,
OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD,
OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
DiagnosticModels,
)
ARTIFACT_FORMAT_VERSION = 1
MODEL_FILENAME = "model.pkl"
MANIFEST_FILENAME = "manifest.json"
RUNTIME_PACKAGES = (
"joblib",
"numpy",
"pandas",
"scikit-learn",
"scipy",
"threadpoolctl",
)
class ModelArtifactError(RuntimeError):
"""Raised when a model artifact is missing, corrupt, or incompatible."""
@dataclass(frozen=True)
class ArtifactMetadata:
artifact_format_version: int
model_version: str
created_at_utc: str
source_revision: str
training_data_sha256: str
model_sha256: str
input_columns: tuple[str, ...]
python_version: str
random_state: int
n_jobs: int
label_model: str
severity_model: str
ood_absolute_threshold_mv: float
ood_ratio_threshold: float
runtime_versions: dict[str, str]
@classmethod
def from_dict(cls, payload: dict[str, object]) -> "ArtifactMetadata":
try:
return cls(
artifact_format_version=int(payload["artifact_format_version"]),
model_version=str(payload["model_version"]),
created_at_utc=str(payload["created_at_utc"]),
source_revision=str(payload["source_revision"]),
training_data_sha256=str(payload["training_data_sha256"]),
model_sha256=str(payload["model_sha256"]),
input_columns=tuple(str(value) for value in payload["input_columns"]),
python_version=str(payload["python_version"]),
random_state=int(payload["random_state"]),
n_jobs=int(payload["n_jobs"]),
label_model=str(payload["label_model"]),
severity_model=str(payload["severity_model"]),
ood_absolute_threshold_mv=float(
payload["ood_absolute_threshold_mv"]
),
ood_ratio_threshold=float(payload["ood_ratio_threshold"]),
runtime_versions={
str(name): str(package_version)
for name, package_version in dict(
payload["runtime_versions"]
).items()
},
)
except (KeyError, TypeError, ValueError) as exc:
raise ModelArtifactError("Model manifest has an invalid schema.") from exc
def to_dict(self) -> dict[str, object]:
payload = asdict(self)
payload["input_columns"] = list(self.input_columns)
return payload
@dataclass(frozen=True)
class LoadedModelArtifact:
models: DiagnosticModels
metadata: ArtifactMetadata
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _runtime_versions() -> dict[str, str]:
return {package: version(package) for package in RUNTIME_PACKAGES}
def _atomic_write(path: Path, payload: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
)
temporary_path = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_path, path)
except Exception:
temporary_path.unlink(missing_ok=True)
raise
def save_model_artifact(
models: DiagnosticModels,
directory: Path,
*,
model_version: str,
source_revision: str,
training_data_sha256: str,
random_state: int,
n_jobs: int,
label_model: str,
severity_model: str,
) -> ArtifactMetadata:
if not model_version.strip():
raise ValueError("model_version cannot be empty.")
if len(training_data_sha256) != 64 or any(
character not in "0123456789abcdef" for character in training_data_sha256
):
raise ValueError("training_data_sha256 must be a SHA-256 hex digest.")
envelope = {
"artifact_format_version": ARTIFACT_FORMAT_VERSION,
"model_version": model_version,
"models": models,
}
model_payload = pickle.dumps(envelope, protocol=pickle.HIGHEST_PROTOCOL)
model_sha256 = hashlib.sha256(model_payload).hexdigest()
metadata = ArtifactMetadata(
artifact_format_version=ARTIFACT_FORMAT_VERSION,
model_version=model_version,
created_at_utc=datetime.now(UTC).isoformat(),
source_revision=source_revision,
training_data_sha256=training_data_sha256,
model_sha256=model_sha256,
input_columns=tuple(INFERENCE_COLUMNS),
python_version=platform.python_version(),
random_state=random_state,
n_jobs=n_jobs,
label_model=label_model,
severity_model=severity_model,
ood_absolute_threshold_mv=OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
ood_ratio_threshold=OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD,
runtime_versions=_runtime_versions(),
)
directory.mkdir(parents=True, exist_ok=True)
_atomic_write(directory / MODEL_FILENAME, model_payload)
manifest_payload = json.dumps(
metadata.to_dict(), ensure_ascii=False, indent=2, sort_keys=True
).encode("utf-8") + b"\n"
_atomic_write(directory / MANIFEST_FILENAME, manifest_payload)
return metadata
def load_model_artifact(
directory: Path, *, expected_model_version: str | None = None
) -> LoadedModelArtifact:
manifest_path = directory / MANIFEST_FILENAME
model_path = directory / MODEL_FILENAME
try:
manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ModelArtifactError(f"Model manifest is missing: {manifest_path}") from exc
except (OSError, json.JSONDecodeError) as exc:
raise ModelArtifactError("Model manifest cannot be read.") from exc
if not isinstance(manifest_payload, dict):
raise ModelArtifactError("Model manifest must be a JSON object.")
metadata = ArtifactMetadata.from_dict(manifest_payload)
if metadata.artifact_format_version != ARTIFACT_FORMAT_VERSION:
raise ModelArtifactError(
"Unsupported model artifact format: "
f"{metadata.artifact_format_version}."
)
if expected_model_version and metadata.model_version != expected_model_version:
raise ModelArtifactError(
f"Expected model {expected_model_version}, received {metadata.model_version}."
)
if metadata.input_columns != tuple(INFERENCE_COLUMNS):
raise ModelArtifactError("Model input schema does not match this application.")
expected_python = metadata.python_version.split(".")[:2]
installed_python = platform.python_version().split(".")[:2]
if expected_python != installed_python:
raise ModelArtifactError(
"Model Python version does not match the runtime. "
f"expected={metadata.python_version}, installed={platform.python_version()}"
)
current_versions = _runtime_versions()
if metadata.runtime_versions != current_versions:
raise ModelArtifactError(
"Model runtime versions do not match installed dependencies. "
f"expected={metadata.runtime_versions}, installed={current_versions}"
)
try:
model_payload = model_path.read_bytes()
except OSError as exc:
raise ModelArtifactError(f"Model file cannot be read: {model_path}") from exc
actual_sha256 = hashlib.sha256(model_payload).hexdigest()
if actual_sha256 != metadata.model_sha256:
raise ModelArtifactError("Model checksum verification failed.")
try:
envelope = pickle.loads(model_payload) # noqa: S301 - trusted, checksummed release artifact
except Exception as exc:
raise ModelArtifactError("Model payload cannot be deserialized.") from exc
if not isinstance(envelope, dict):
raise ModelArtifactError("Model payload has an invalid envelope.")
if envelope.get("artifact_format_version") != ARTIFACT_FORMAT_VERSION:
raise ModelArtifactError("Model payload format does not match the manifest.")
if envelope.get("model_version") != metadata.model_version:
raise ModelArtifactError("Model payload version does not match the manifest.")
models = envelope.get("models")
if not isinstance(models, DiagnosticModels):
raise ModelArtifactError("Model payload has an unexpected object type.")
return LoadedModelArtifact(models=models, metadata=metadata)
__all__ = [
"ARTIFACT_FORMAT_VERSION",
"MANIFEST_FILENAME",
"MODEL_FILENAME",
"ArtifactMetadata",
"LoadedModelArtifact",
"ModelArtifactError",
"load_model_artifact",
"save_model_artifact",
"sha256_file",
]

View File

@ -2,66 +2,110 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
import numpy as np import numpy as np
import plotly.graph_objects as go import plotly.graph_objects as go
from .config import FREQ_COLS, LABEL_COLORS from .config import FREQ_COLS, LABEL_COLORS, LABEL_DISPLAY
from .explainability import EngineAnalysis from .explainability import EngineAnalysis
PLOT_BG = "#101820" PLOT_BG = "#101820"
GRID = "rgba(148, 163, 184, 0.14)" GRID = "rgba(148, 163, 184, 0.14)"
TEXT = "#dce8ee" TEXT = "#dce8ee"
MUTED = "#8fa6b2" MUTED = "#8fa6b2"
COMPARISON_COLORS = ("#28b7d9", "#f5a524", "#38d996", "#d56cff", "#2f78ed")
HEATMAP_DEVIATION_LIMIT_MV = 20.0
def _base_layout(fig: go.Figure, *, height: int) -> go.Figure: def _base_layout(
fig: go.Figure,
*,
height: int,
margin_top: int = 42,
margin_bottom: int = 28,
) -> go.Figure:
fig.update_layout( fig.update_layout(
template=None,
height=height, height=height,
margin=dict(l=24, r=24, t=42, b=28), margin=dict(l=72, r=24, t=margin_top, b=margin_bottom),
paper_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor=PLOT_BG, plot_bgcolor=PLOT_BG,
font=dict(color=TEXT, family="Inter, system-ui, sans-serif"), font=dict(color=TEXT, family="Inter, system-ui, sans-serif"),
hoverlabel=dict(bgcolor="#16232c", font_color="#ffffff"), legend=dict(bgcolor="rgba(0,0,0,0)", font=dict(color=TEXT)),
hoverlabel=dict(
bgcolor="#16232c",
bordercolor="#36505d",
font_color="#ffffff",
),
) )
fig.update_xaxes(gridcolor=GRID, zeroline=False) axis_style = dict(
fig.update_yaxes(gridcolor=GRID, zeroline=False) color=TEXT,
gridcolor=GRID,
linecolor=GRID,
tickfont=dict(color=TEXT),
title_font=dict(color=TEXT),
zeroline=False,
)
fig.update_xaxes(**axis_style, automargin=True, title_standoff=12)
fig.update_yaxes(**axis_style, automargin=True, title_standoff=18)
return fig return fig
def engine_heatmap(analysis: EngineAnalysis) -> go.Figure: def engine_heatmap(analysis: EngineAnalysis) -> go.Figure:
cylinders = analysis.measurements["cylinder"].astype(int).tolist() row_labels = [
max_abs = max(float(np.percentile(analysis.absolute_deviation, 95)), 1.0) f"C{int(row.cylinder):02d} · {LABEL_DISPLAY[str(row.label)]}"
for row in analysis.diagnostics.itertuples()
]
fig = go.Figure( fig = go.Figure(
go.Heatmap( go.Heatmap(
z=analysis.deviation, z=analysis.deviation,
x=list(range(len(FREQ_COLS))), x=list(range(len(FREQ_COLS))),
y=[f"C{value:02d}" for value in cylinders], y=row_labels,
zmin=-max_abs, zmin=-HEATMAP_DEVIATION_LIMIT_MV,
zmax=max_abs, zmax=HEATMAP_DEVIATION_LIMIT_MV,
zmid=0, zmid=0,
colorscale=[ colorscale=[
[0.0, "#28b7d9"], [0.0, "#28b7d9"],
[0.5, "#13222b"], [0.5, "#13222b"],
[1.0, "#ff6b57"], [1.0, "#ff6b57"],
], ],
colorbar=dict(title="Δ mV", thickness=12), colorbar=dict(
hovertemplate="Cylinder %{y}<br>%{x} kHz<br>Odchylenie %{z:.1f} mV<extra></extra>", title=dict(text="Δ mV", font=dict(color=TEXT)),
tickfont=dict(color=TEXT),
bgcolor="rgba(0,0,0,0)",
borderwidth=0,
thickness=12,
),
hovertemplate="%{y}<br>%{x} kHz<br>Odchylenie %{z:.1f} mV<extra></extra>",
) )
) )
fig.update_layout(title="Engine Diagnostic Fingerprint") fig.update_yaxes(autorange="reversed", automargin=True, title=None)
fig.update_yaxes(autorange="reversed", title="Cylinder")
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2) fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2)
return _base_layout(fig, height=420) return _base_layout(fig, height=360, margin_top=18, margin_bottom=42)
def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure: def _cylinder_position(analysis: EngineAnalysis, cylinder: int) -> int:
positions = np.flatnonzero(analysis.measurements["cylinder"].to_numpy() == cylinder) positions = np.flatnonzero(analysis.measurements["cylinder"].to_numpy() == cylinder)
if len(positions) != 1: if len(positions) != 1:
raise KeyError(f"Unknown cylinder={cylinder}") raise KeyError(f"Unknown cylinder={cylinder}")
position = int(positions[0]) return int(positions[0])
def cylinder_spectrum(
analysis: EngineAnalysis,
cylinder: int,
comparison_cylinders: Sequence[int] | None = None,
) -> go.Figure:
selected = list(
dict.fromkeys([cylinder, *(comparison_cylinders or ())])
)
if len(selected) > 4:
raise ValueError("At most four cylinders can be compared.")
position = _cylinder_position(analysis, cylinder)
row = analysis.diagnostics.iloc[position] row = analysis.diagnostics.iloc[position]
color = LABEL_COLORS[str(row["label"])] primary_color = LABEL_COLORS[str(row["label"])]
frequency = np.arange(len(FREQ_COLS)) frequency = np.arange(len(FREQ_COLS))
fig = go.Figure() fig = go.Figure()
fig.add_trace( fig.add_trace(
@ -69,20 +113,33 @@ def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
x=frequency, x=frequency,
y=analysis.reference[position], y=analysis.reference[position],
mode="lines", mode="lines",
name="Mediana pozostałych cylindrów", name=f"Referencja C{cylinder:02d}",
line=dict(color="#7f95a1", width=2, dash="dash"), line=dict(color="#7f95a1", width=2, dash="dash"),
) )
) )
fig.add_trace(
go.Scatter( extra_colors = iter(
x=frequency, color
y=analysis.spectra[position], for color in COMPARISON_COLORS
mode="lines+markers", if color.lower() != primary_color.lower()
name=f"Cylinder {cylinder}",
line=dict(color=color, width=3),
marker=dict(size=6),
)
) )
for selected_cylinder in selected:
selected_position = _cylinder_position(analysis, selected_cylinder)
color = primary_color if selected_cylinder == cylinder else next(extra_colors)
fig.add_trace(
go.Scatter(
x=frequency,
y=analysis.spectra[selected_position],
mode="lines+markers",
name=f"C{selected_cylinder:02d}",
line=dict(
color=color,
width=3 if selected_cylinder == cylinder else 2,
),
marker=dict(size=6 if selected_cylinder == cylinder else 4),
)
)
top = { top = {
int(value) int(value)
for value in str(row["top_anomalous_frequencies_khz"]).split("|") for value in str(row["top_anomalous_frequencies_khz"]).split("|")
@ -92,14 +149,22 @@ def cylinder_spectrum(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
fig.add_vrect( fig.add_vrect(
x0=value - 0.35, x0=value - 0.35,
x1=value + 0.35, x1=value + 0.35,
fillcolor=color, fillcolor=primary_color,
opacity=0.10, opacity=0.10,
line_width=0, line_width=0,
) )
fig.update_layout(title="Widmo cylindra vs referencja", legend=dict(orientation="h", y=1.14)) fig.update_layout(
legend=dict(
orientation="h",
x=0,
xanchor="left",
y=1.04,
yanchor="bottom",
)
)
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=1) fig.update_xaxes(title="Częstotliwość [kHz]", dtick=1)
fig.update_yaxes(title="Amplituda [mV]") fig.update_yaxes(title="Amplituda [mV]")
return _base_layout(fig, height=410) return _base_layout(fig, height=440, margin_top=76, margin_bottom=42)
def deviation_chart(analysis: EngineAnalysis, cylinder: int) -> go.Figure: def deviation_chart(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
@ -117,7 +182,6 @@ def deviation_chart(analysis: EngineAnalysis, cylinder: int) -> go.Figure:
) )
) )
fig.add_hline(y=0, line_color="#6e8794", line_width=1) fig.add_hline(y=0, line_color="#6e8794", line_width=1)
fig.update_layout(title="Odchylenie od referencji")
fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2) fig.update_xaxes(title="Częstotliwość [kHz]", dtick=2)
fig.update_yaxes(title="Różnica [mV]") fig.update_yaxes(title="Różnica [mV]")
return _base_layout(fig, height=320) return _base_layout(fig, height=340, margin_top=24, margin_bottom=42)

View File

@ -4,9 +4,11 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from benchmark_grouped import FAULT_LABELS, FREQ_COLS, LABELS, NOT_APPLICABLE, SEVERITIES FREQ_COLS = [f"mV_{frequency}" for frequency in range(21)]
LABELS = ["ok", "zakoksowany", "lejacy", "pompa", "iglica", "unknown"]
FAULT_LABELS = ["zakoksowany", "lejacy", "pompa", "iglica"]
SEVERITIES = ["male", "srednie", "duze"]
NOT_APPLICABLE = "nie_dotyczy"
ALLOWED_ENGINE_SIZES = (8, 12, 16) ALLOWED_ENGINE_SIZES = (8, 12, 16)
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 MAX_UPLOAD_BYTES = 10 * 1024 * 1024
MAX_MISSING_FRACTION_PER_CYLINDER = 0.50 MAX_MISSING_FRACTION_PER_CYLINDER = 0.50

171
engin/features.py Normal file
View File

@ -0,0 +1,171 @@
"""Stable feature transformers shared by model training and runtime inference."""
from __future__ import annotations
from typing import Iterable
import numpy as np
import pandas as pd
from .config import FAULT_LABELS, FREQ_COLS
FEATURE_SETS = ("raw", "deviation", "all")
class SeverityFeatures:
"""Create engine-relative spectral features without using labels.
Engine-relative values use a leave-one-cylinder-out median reference. Input
to ``transform`` must therefore contain every cylinder of each engine.
``diagnostic_label`` is optional and may contain only a model-predicted
label at validation or inference time.
"""
def __init__(self, feature_set: str, include_fault_type: bool = False) -> None:
if feature_set not in FEATURE_SETS:
raise ValueError(f"Unknown feature_set={feature_set!r}")
self.feature_set = feature_set
self.include_fault_type = include_fault_type
def fit(
self, X: pd.DataFrame, y: Iterable[str] | None = None
) -> "SeverityFeatures":
self._validate(X)
spectra = X[FREQ_COLS].apply(pd.to_numeric, errors="coerce")
fallback = spectra.median(axis=0).to_numpy(dtype=float)
if np.isnan(fallback).any():
raise ValueError("A frequency column is entirely NaN in training.")
self.fallback_medians_ = fallback
return self
def transform(self, X: pd.DataFrame) -> np.ndarray:
self._validate(X)
if not hasattr(self, "fallback_medians_"):
raise RuntimeError("SeverityFeatures must be fitted before transform().")
raw = self._clean_spectra(X)
reference = self._leave_one_out_engine_median(
raw, X["engine_id"].to_numpy()
)
relative = raw - reference
absolute_relative = np.abs(relative)
ratio_delta = raw / np.maximum(np.abs(reference), 1e-6) - 1.0
if self.feature_set == "raw":
features = raw
elif self.feature_set == "deviation":
features = np.hstack(
[
relative,
absolute_relative,
ratio_delta,
self._summary_features(raw, relative, ratio_delta),
]
)
else:
features = np.hstack(
[
raw,
relative,
absolute_relative,
ratio_delta,
self._summary_features(raw, relative, ratio_delta),
]
)
if self.include_fault_type:
if "diagnostic_label" not in X.columns:
raise ValueError(
"diagnostic_label is required when include_fault_type=True"
)
labels = X["diagnostic_label"].to_numpy(dtype=object)
one_hot = np.column_stack(
[labels == label for label in FAULT_LABELS]
).astype(float)
features = np.hstack([features, one_hot])
if np.isnan(features).any() or np.isinf(features).any():
raise ValueError("Severity features contain NaN or infinity.")
return features
def fit_transform(
self, X: pd.DataFrame, y: Iterable[str] | None = None
) -> np.ndarray:
return self.fit(X).transform(X)
def _clean_spectra(self, X: pd.DataFrame) -> np.ndarray:
spectra = X[FREQ_COLS].apply(pd.to_numeric, errors="coerce")
spectra = spectra.interpolate(axis=1, limit_direction="both")
spectra = spectra.fillna(pd.Series(self.fallback_medians_, index=FREQ_COLS))
return spectra.to_numpy(dtype=float)
@staticmethod
def _leave_one_out_engine_median(
raw: np.ndarray, groups: np.ndarray
) -> np.ndarray:
reference = np.empty_like(raw)
for engine_id in pd.unique(groups):
positions = np.flatnonzero(groups == engine_id)
if len(positions) < 2:
raise ValueError(
f"Engine {engine_id!r} has fewer than two cylinders in transform()."
)
engine_values = raw[positions]
for local_position, global_position in enumerate(positions):
keep = np.arange(len(positions)) != local_position
reference[global_position] = np.median(engine_values[keep], axis=0)
return reference
@staticmethod
def _summary_features(
raw: np.ndarray, relative: np.ndarray, ratio_delta: np.ndarray
) -> np.ndarray:
absolute_relative = np.abs(relative)
gradients = np.diff(raw, axis=1)
frequency = np.arange(raw.shape[1], dtype=float)
centered_frequency = frequency - frequency.mean()
slope = raw @ centered_frequency / np.sum(centered_frequency**2)
if hasattr(np, "trapezoid"):
spectral_auc = np.trapezoid(raw, axis=1)
else: # pragma: no cover - compatibility with NumPy 1.x
spectral_auc = np.trapz(raw, axis=1)
columns = [
raw.mean(axis=1),
raw.std(axis=1),
raw.min(axis=1),
raw.max(axis=1),
np.ptp(raw, axis=1),
spectral_auc,
raw.argmax(axis=1) / 20.0,
raw.argmin(axis=1) / 20.0,
slope,
np.abs(gradients).mean(axis=1),
np.abs(gradients).max(axis=1),
relative.mean(axis=1),
relative.std(axis=1),
relative.min(axis=1),
relative.max(axis=1),
absolute_relative.mean(axis=1),
absolute_relative.max(axis=1),
np.sqrt(np.mean(relative**2, axis=1)),
ratio_delta.mean(axis=1),
ratio_delta.std(axis=1),
]
for start, stop in ((0, 5), (5, 10), (10, 15), (15, 21)):
columns.extend(
[
raw[:, start:stop].mean(axis=1),
relative[:, start:stop].mean(axis=1),
absolute_relative[:, start:stop].mean(axis=1),
]
)
return np.column_stack(columns)
@staticmethod
def _validate(X: pd.DataFrame) -> None:
required = {"engine_id", *FREQ_COLS}
missing = sorted(required.difference(X.columns))
if missing:
raise ValueError(f"Missing columns: {missing}")
__all__ = ["FEATURE_SETS", "SeverityFeatures"]

205
engin/inference.py Normal file
View File

@ -0,0 +1,205 @@
"""Runtime-only prediction contract for the frozen ENGIN model."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
from .config import FAULT_LABELS, FREQ_COLS, LABELS, NOT_APPLICABLE, SEVERITIES
OOD_OK_TO_UNKNOWN_THRESHOLD_MV = 7.25
OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD = 2.5
SUBMISSION_COLUMNS = ["engine_id", "cylinder", "label", "severity"]
KEY_COLUMNS = ["engine_id", "cylinder"]
INFERENCE_COLUMNS = ["engine_id", "cylinder", "n_cylinders", *FREQ_COLS]
@dataclass
class DiagnosticModels:
label_pipeline: object
severity_transformer: object
severity_estimator: object
def apply_ood_override(
predicted_label: np.ndarray,
deviation_features: np.ndarray,
engine_ids: np.ndarray | pd.Series,
threshold_mv: float = OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
threshold_ratio: float = OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Turn an implausibly anomalous ``ok`` into ``unknown``."""
result = np.asarray(predicted_label, dtype=object).copy()
absolute_deviation = deviation_features[
:, len(FREQ_COLS) : 2 * len(FREQ_COLS)
]
anomaly_score = absolute_deviation.mean(axis=1)
ids = np.asarray(engine_ids, dtype=object)
if len(ids) != len(result):
raise ValueError("engine_ids must align with predicted labels.")
score_frame = pd.DataFrame({"engine_id": ids, "anomaly_score": anomaly_score})
engine_median = (
score_frame.groupby("engine_id", sort=False)["anomaly_score"]
.transform("median")
.to_numpy(dtype=float)
)
anomaly_ratio = anomaly_score / np.maximum(engine_median, 1e-6)
override = (
(result == "ok")
& (anomaly_score > threshold_mv)
& (anomaly_ratio > threshold_ratio)
)
result[override] = "unknown"
return result, override, anomaly_score, anomaly_ratio
def validate_inference_data(df: pd.DataFrame) -> None:
required = set(INFERENCE_COLUMNS)
missing = sorted(required.difference(df.columns))
if missing:
raise ValueError(f"Inference data is missing columns: {missing}")
if df.empty:
raise ValueError("Inference data is empty.")
if df[["engine_id", "cylinder", "n_cylinders"]].isna().any().any():
raise ValueError("Inference identifiers cannot contain NaN.")
if df.duplicated(KEY_COLUMNS).any():
raise ValueError("Duplicate engine_id + cylinder keys found in inference data.")
engine_sizes = df.groupby("engine_id").agg(
rows=("cylinder", "size"),
expected=("n_cylinders", "first"),
size_values=("n_cylinders", "nunique"),
unique_cylinders=("cylinder", "nunique"),
)
complete = (
(engine_sizes["rows"] == engine_sizes["expected"])
& (engine_sizes["unique_cylinders"] == engine_sizes["expected"])
& (engine_sizes["size_values"] == 1)
& (engine_sizes["rows"] >= 2)
)
if not complete.all():
bad = engine_sizes.index[~complete].tolist()
raise ValueError(f"Incomplete or inconsistent inference engines: {bad}")
def _prepare_labeled_frame(df: pd.DataFrame, labels: np.ndarray) -> pd.DataFrame:
result = df.copy()
result["diagnostic_label"] = labels
return result
def predict_test(
models: DiagnosticModels, test: pd.DataFrame
) -> tuple[pd.DataFrame, pd.DataFrame]:
validate_inference_data(test)
raw_model_label = models.label_pipeline.predict(test).astype(object)
label_features = models.label_pipeline.named_steps["features"].transform(test)
predicted_label, ood_override, anomaly_score, anomaly_ratio = apply_ood_override(
raw_model_label,
label_features,
test["engine_id"],
)
label_probabilities = models.label_pipeline.predict_proba(test)
raw_model_confidence = label_probabilities.max(axis=1)
label_confidence = raw_model_confidence.copy()
label_confidence[ood_override] = np.nan
ordered_probabilities = np.sort(label_probabilities, axis=1)
label_margin = ordered_probabilities[:, -1] - ordered_probabilities[:, -2]
test_with_labels = _prepare_labeled_frame(test, predicted_label)
severity_features = models.severity_transformer.transform(test_with_labels)
predicted_severity_all = models.severity_estimator.predict(
severity_features
).astype(object)
predicted_fault = np.isin(predicted_label, FAULT_LABELS)
emitted_severity = np.full(len(test), NOT_APPLICABLE, dtype=object)
emitted_severity[predicted_fault] = predicted_severity_all[predicted_fault]
severity_probabilities = models.severity_estimator.predict_proba(
severity_features
)
severity_confidence = np.full(len(test), np.nan, dtype=float)
severity_confidence[predicted_fault] = severity_probabilities.max(axis=1)[
predicted_fault
]
absolute_deviation = severity_features[:, len(FREQ_COLS) : 2 * len(FREQ_COLS)]
top_frequency_indices = np.argsort(absolute_deviation, axis=1)[:, -3:][:, ::-1]
top_frequencies = [
"|".join(str(int(index)) for index in row) for row in top_frequency_indices
]
submission = test[KEY_COLUMNS].copy()
submission["label"] = predicted_label
submission["severity"] = emitted_severity
diagnostics = submission.copy()
diagnostics["raw_model_label"] = raw_model_label
diagnostics["decision_source"] = np.where(
ood_override, "ood_override", "classifier"
)
diagnostics["label_confidence"] = label_confidence
diagnostics["raw_model_confidence"] = raw_model_confidence
diagnostics["label_margin"] = label_margin
diagnostics["severity_confidence"] = severity_confidence
diagnostics["anomaly_score_mean_abs_mv"] = anomaly_score
diagnostics["anomaly_ratio_to_engine_median"] = anomaly_ratio
diagnostics["ood_absolute_threshold_mv"] = OOD_OK_TO_UNKNOWN_THRESHOLD_MV
diagnostics["ood_ratio_threshold"] = OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD
diagnostics["top_anomalous_frequencies_khz"] = top_frequencies
diagnostics["missing_spectral_cells"] = (
test[FREQ_COLS].isna().sum(axis=1).to_numpy(dtype=int)
)
return submission, diagnostics
def validate_submission(submission: pd.DataFrame, test: pd.DataFrame) -> None:
if submission.columns.tolist() != SUBMISSION_COLUMNS:
raise ValueError(
f"Submission columns must be exactly {SUBMISSION_COLUMNS}; "
f"received={submission.columns.tolist()}"
)
if len(submission) != len(test):
raise ValueError("Submission row count does not match test.csv.")
if submission.isna().any().any():
raise ValueError("Submission contains NaN.")
if submission.duplicated(KEY_COLUMNS).any():
raise ValueError("Submission contains duplicate engine_id + cylinder keys.")
if not submission[KEY_COLUMNS].reset_index(drop=True).equals(
test[KEY_COLUMNS].reset_index(drop=True)
):
raise ValueError("Submission keys or row order do not match test.csv.")
invalid_labels = sorted(set(submission["label"]).difference(LABELS))
if invalid_labels:
raise ValueError(f"Submission contains invalid labels: {invalid_labels}")
fault = submission["label"].isin(FAULT_LABELS)
invalid_fault_severity = sorted(
set(submission.loc[fault, "severity"]).difference(SEVERITIES)
)
if invalid_fault_severity:
raise ValueError(
f"Fault predictions contain invalid severity: {invalid_fault_severity}"
)
if not submission.loc[~fault, "severity"].eq(NOT_APPLICABLE).all():
raise ValueError("ok/unknown predictions must use severity=nie_dotyczy.")
__all__ = [
"DiagnosticModels",
"INFERENCE_COLUMNS",
"KEY_COLUMNS",
"OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD",
"OOD_OK_TO_UNKNOWN_THRESHOLD_MV",
"SUBMISSION_COLUMNS",
"apply_ood_override",
"predict_test",
"validate_inference_data",
"validate_submission",
]

View File

@ -3,13 +3,14 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Protocol from typing import Protocol
import pandas as pd import pandas as pd
from final_pipeline import DiagnosticModels, predict_test, train_models, validate_submission from .artifact import ArtifactMetadata, load_model_artifact
from .errors import InferenceError from .errors import InferenceError
from .inference import DiagnosticModels, predict_test, validate_submission
@dataclass(frozen=True) @dataclass(frozen=True)
@ -23,31 +24,31 @@ class PredictionModel(Protocol):
class SklearnPredictionModel: class SklearnPredictionModel:
"""Thin adapter around the frozen, validated competition pipeline.""" """Runtime adapter around a verified, pre-trained model artifact."""
def __init__(self, models: DiagnosticModels) -> None: def __init__(
self, models: DiagnosticModels, metadata: ArtifactMetadata | None = None
) -> None:
self._models = models self._models = models
self.metadata = metadata
@classmethod @classmethod
def train( def from_artifact(
cls, cls,
reference_frame: pd.DataFrame, directory: Path,
*, *,
random_state: int = 42, expected_model_version: str | None = None,
n_jobs: int = -1,
) -> "SklearnPredictionModel": ) -> "SklearnPredictionModel":
try: try:
models = train_models( artifact = load_model_artifact(
reference_frame, directory, expected_model_version=expected_model_version
random_state=random_state,
n_jobs=n_jobs,
) )
except Exception as exc: except Exception as exc:
raise InferenceError( raise InferenceError(
"Nie udało się przygotować modelu referencyjnego.", "Nie udało się załadować zweryfikowanego artefaktu modelu.",
hint="Sprawdź kompletność val.csv oraz zgodność wersji zależności.", hint="Sprawdź manifest, checksumę oraz zgodność wersji zależności.",
) from exc ) from exc
return cls(models) return cls(artifact.models, artifact.metadata)
def predict(self, frame: pd.DataFrame) -> PredictionBundle: def predict(self, frame: pd.DataFrame) -> PredictionBundle:
try: try:
@ -58,6 +59,13 @@ class SklearnPredictionModel:
"Model odrzucił pomiary podczas predykcji.", "Model odrzucił pomiary podczas predykcji.",
hint="Zweryfikuj kompletność silników oraz brak nietypowych wartości w widmie.", hint="Zweryfikuj kompletność silników oraz brak nietypowych wartości w widmie.",
) from exc ) from exc
diagnostics = diagnostics.copy()
diagnostics["model_version"] = (
self.metadata.model_version if self.metadata else "unversioned"
)
diagnostics["model_artifact_sha256"] = (
self.metadata.model_sha256 if self.metadata else "unavailable"
)
return PredictionBundle(submission, diagnostics) return PredictionBundle(submission, diagnostics)
@ -73,9 +81,9 @@ class PrecomputedPredictionModel:
if not frame[keys].reset_index(drop=True).equals(self._submission[keys]): if not frame[keys].reset_index(drop=True).equals(self._submission[keys]):
raise InferenceError( raise InferenceError(
"Tryb awaryjny obsługuje wyłącznie dołączony zestaw demonstracyjny.", "Tryb awaryjny obsługuje wyłącznie dołączony zestaw demonstracyjny.",
hint="Przywróć val.csv i uruchom ponownie aplikację, aby diagnozować własne pliki.", hint="Przywróć artefakt modelu i uruchom ponownie aplikację, aby diagnozować własne pliki.",
) )
return PredictionBundle( diagnostics = self._diagnostics.copy()
self._submission.copy(), diagnostics["model_version"] = "demo-precomputed"
self._diagnostics.copy(), diagnostics["model_artifact_sha256"] = "unavailable"
) return PredictionBundle(self._submission.copy(), diagnostics)

View File

@ -8,7 +8,7 @@ from typing import Protocol
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from .config import AppConfig, FREQ_COLS from .config import FREQ_COLS, AppConfig
from .errors import InputDataError from .errors import InputDataError

View File

@ -13,48 +13,47 @@ used because its benefit has not been established in grouped validation.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import numpy as np
import pandas as pd import pandas as pd
from sklearn.linear_model import LogisticRegression from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import StandardScaler
from benchmark_grouped import ( from benchmark_grouped import validate_data
FAULT_LABELS, from engin.config import FAULT_LABELS, LABELS, NOT_APPLICABLE, SEVERITIES
FREQ_COLS, from engin.features import SeverityFeatures
LABELS, from engin.inference import (
NOT_APPLICABLE, KEY_COLUMNS,
SEVERITIES, OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD,
make_pipeline, OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
validate_data, DiagnosticModels,
apply_ood_override,
predict_test,
validate_inference_data,
validate_submission,
) )
from severity_benchmark import ( from severity_benchmark import (
CANDIDATE_BY_ID, CANDIDATE_BY_ID,
SeverityFeatures,
fit_candidate, fit_candidate,
predict_candidate,
prepare_labeled_frame, prepare_labeled_frame,
) )
__all__ = [
"DiagnosticModels",
"OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD",
"OOD_OK_TO_UNKNOWN_THRESHOLD_MV",
"apply_ood_override",
"predict_test",
"run_pipeline",
"train_models",
"validate_inference_data",
"validate_submission",
]
LABEL_MODEL_NAME = "deviation_logistic_c10" LABEL_MODEL_NAME = "deviation_logistic_c10"
LABEL_FEATURE_SET = "deviation" LABEL_FEATURE_SET = "deviation"
SEVERITY_CANDIDATE_ID = "deviation_extra_trees_mf03" SEVERITY_CANDIDATE_ID = "deviation_extra_trees_mf03"
OOD_OK_TO_UNKNOWN_THRESHOLD_MV = 7.25
OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD = 2.5
SUBMISSION_COLUMNS = ["engine_id", "cylinder", "label", "severity"]
KEY_COLUMNS = ["engine_id", "cylinder"]
@dataclass
class DiagnosticModels:
label_pipeline: object
severity_candidate: object
severity_transformer: object
severity_estimator: object
def make_final_label_pipeline(random_state: int = 42) -> Pipeline: def make_final_label_pipeline(random_state: int = 42) -> Pipeline:
@ -77,45 +76,6 @@ def make_final_label_pipeline(random_state: int = 42) -> Pipeline:
) )
def apply_ood_override(
predicted_label: np.ndarray,
deviation_features: np.ndarray,
engine_ids: np.ndarray | pd.Series,
threshold_mv: float = OOD_OK_TO_UNKNOWN_THRESHOLD_MV,
threshold_ratio: float = OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Turn an implausibly anomalous ``ok`` into ``unknown``.
The absolute threshold was frozen after repeated grouped validation on
clean spectra and spectra with 5% masked measurements. A second guard
requires the cylinder to be anomalous relative to the median anomaly level
of its own engine. This prevents a globally noisy unit from being treated
as a collection of isolated OOD cylinders. The rule never changes a named
fault into another class.
"""
result = np.asarray(predicted_label, dtype=object).copy()
absolute_deviation = deviation_features[
:, len(FREQ_COLS) : 2 * len(FREQ_COLS)
]
anomaly_score = absolute_deviation.mean(axis=1)
ids = np.asarray(engine_ids, dtype=object)
if len(ids) != len(result):
raise ValueError("engine_ids must align with predicted labels.")
score_frame = pd.DataFrame({"engine_id": ids, "anomaly_score": anomaly_score})
engine_median = score_frame.groupby("engine_id", sort=False)[
"anomaly_score"
].transform("median").to_numpy(dtype=float)
anomaly_ratio = anomaly_score / np.maximum(engine_median, 1e-6)
override = (
(result == "ok")
& (anomaly_score > threshold_mv)
& (anomaly_ratio > threshold_ratio)
)
result[override] = "unknown"
return result, override, anomaly_score, anomaly_ratio
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--val", type=Path, default=Path("val.csv")) parser.add_argument("--val", type=Path, default=Path("val.csv"))
@ -135,35 +95,6 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args() return parser.parse_args()
def validate_inference_data(df: pd.DataFrame) -> None:
required = {"engine_id", "cylinder", "n_cylinders", *FREQ_COLS}
missing = sorted(required.difference(df.columns))
if missing:
raise ValueError(f"Inference data is missing columns: {missing}")
if df.empty:
raise ValueError("Inference data is empty.")
if df[["engine_id", "cylinder", "n_cylinders"]].isna().any().any():
raise ValueError("Inference identifiers cannot contain NaN.")
if df.duplicated(KEY_COLUMNS).any():
raise ValueError("Duplicate engine_id + cylinder keys found in inference data.")
engine_sizes = df.groupby("engine_id").agg(
rows=("cylinder", "size"),
expected=("n_cylinders", "first"),
size_values=("n_cylinders", "nunique"),
unique_cylinders=("cylinder", "nunique"),
)
complete = (
(engine_sizes["rows"] == engine_sizes["expected"])
& (engine_sizes["unique_cylinders"] == engine_sizes["expected"])
& (engine_sizes["size_values"] == 1)
& (engine_sizes["rows"] >= 2)
)
if not complete.all():
bad = engine_sizes.index[~complete].tolist()
raise ValueError(f"Incomplete or inconsistent inference engines: {bad}")
def validate_sample_keys(sample: pd.DataFrame, test: pd.DataFrame) -> None: def validate_sample_keys(sample: pd.DataFrame, test: pd.DataFrame) -> None:
missing = sorted(set(KEY_COLUMNS).difference(sample.columns)) missing = sorted(set(KEY_COLUMNS).difference(sample.columns))
if missing: if missing:
@ -212,119 +143,11 @@ def train_models(
) )
return DiagnosticModels( return DiagnosticModels(
label_pipeline=label_pipeline, label_pipeline=label_pipeline,
severity_candidate=candidate,
severity_transformer=transformer, severity_transformer=transformer,
severity_estimator=estimator, severity_estimator=estimator,
) )
def predict_test(
models: DiagnosticModels, test: pd.DataFrame
) -> tuple[pd.DataFrame, pd.DataFrame]:
validate_inference_data(test)
raw_model_label = models.label_pipeline.predict(test).astype(object)
label_features = models.label_pipeline.named_steps["features"].transform(test)
predicted_label, ood_override, anomaly_score, anomaly_ratio = apply_ood_override(
raw_model_label,
label_features,
test["engine_id"],
)
label_probabilities = models.label_pipeline.predict_proba(test)
raw_model_confidence = label_probabilities.max(axis=1)
label_confidence = raw_model_confidence.copy()
# OOD is a deterministic safety rule, not a probabilistic classifier.
# Emitting NaN is more honest than manufacturing a probability-like score.
label_confidence[ood_override] = np.nan
ordered_probabilities = np.sort(label_probabilities, axis=1)
label_margin = ordered_probabilities[:, -1] - ordered_probabilities[:, -2]
test_with_labels = prepare_labeled_frame(test, predicted_label)
predicted_severity_all = predict_candidate(
models.severity_candidate,
models.severity_transformer,
models.severity_estimator,
test_with_labels,
predicted_label,
).astype(object)
predicted_fault = np.isin(predicted_label, FAULT_LABELS)
emitted_severity = np.full(len(test), NOT_APPLICABLE, dtype=object)
emitted_severity[predicted_fault] = predicted_severity_all[predicted_fault]
severity_features = models.severity_transformer.transform(test_with_labels)
severity_probabilities = models.severity_estimator.predict_proba(severity_features)
severity_confidence = np.full(len(test), np.nan, dtype=float)
severity_confidence[predicted_fault] = severity_probabilities.max(axis=1)[
predicted_fault
]
# The selected deviation representation starts with 21 signed deviations
# followed by their 21 absolute values. Reuse them for UI explainability.
absolute_deviation = severity_features[:, len(FREQ_COLS) : 2 * len(FREQ_COLS)]
top_frequency_indices = np.argsort(absolute_deviation, axis=1)[:, -3:][:, ::-1]
top_frequencies = [
"|".join(str(int(index)) for index in row)
for row in top_frequency_indices
]
submission = test[KEY_COLUMNS].copy()
submission["label"] = predicted_label
submission["severity"] = emitted_severity
diagnostics = submission.copy()
diagnostics["raw_model_label"] = raw_model_label
diagnostics["decision_source"] = np.where(
ood_override, "ood_override", "classifier"
)
diagnostics["label_confidence"] = label_confidence
diagnostics["raw_model_confidence"] = raw_model_confidence
diagnostics["label_margin"] = label_margin
diagnostics["severity_confidence"] = severity_confidence
diagnostics["anomaly_score_mean_abs_mv"] = anomaly_score
diagnostics["anomaly_ratio_to_engine_median"] = anomaly_ratio
diagnostics["ood_absolute_threshold_mv"] = OOD_OK_TO_UNKNOWN_THRESHOLD_MV
diagnostics["ood_ratio_threshold"] = OOD_OK_TO_UNKNOWN_RATIO_THRESHOLD
diagnostics["top_anomalous_frequencies_khz"] = top_frequencies
diagnostics["missing_spectral_cells"] = (
test[FREQ_COLS].isna().sum(axis=1).to_numpy(dtype=int)
)
return submission, diagnostics
def validate_submission(submission: pd.DataFrame, test: pd.DataFrame) -> None:
if submission.columns.tolist() != SUBMISSION_COLUMNS:
raise ValueError(
f"Submission columns must be exactly {SUBMISSION_COLUMNS}; "
f"received={submission.columns.tolist()}"
)
if len(submission) != len(test):
raise ValueError("Submission row count does not match test.csv.")
if submission.isna().any().any():
raise ValueError("Submission contains NaN.")
if submission.duplicated(KEY_COLUMNS).any():
raise ValueError("Submission contains duplicate engine_id + cylinder keys.")
if not submission[KEY_COLUMNS].reset_index(drop=True).equals(
test[KEY_COLUMNS].reset_index(drop=True)
):
raise ValueError("Submission keys or row order do not match test.csv.")
invalid_labels = sorted(set(submission["label"]).difference(LABELS))
if invalid_labels:
raise ValueError(f"Submission contains invalid labels: {invalid_labels}")
fault = submission["label"].isin(FAULT_LABELS)
invalid_fault_severity = sorted(
set(submission.loc[fault, "severity"]).difference(SEVERITIES)
)
if invalid_fault_severity:
raise ValueError(
f"Fault predictions contain invalid severity: {invalid_fault_severity}"
)
if not submission.loc[~fault, "severity"].eq(NOT_APPLICABLE).all():
raise ValueError("ok/unknown predictions must use severity=nie_dotyczy.")
def run_pipeline( def run_pipeline(
val: pd.DataFrame, val: pd.DataFrame,
test: pd.DataFrame, test: pd.DataFrame,

View File

@ -19,8 +19,8 @@ from benchmark_grouped import (
FAULT_LABELS, FAULT_LABELS,
LABELS, LABELS,
NOT_APPLICABLE, NOT_APPLICABLE,
make_splits,
macro_f1, macro_f1,
make_splits,
ml_points, ml_points,
raw_score, raw_score,
validate_data, validate_data,
@ -38,7 +38,6 @@ from severity_benchmark import (
prepare_labeled_frame, prepare_labeled_frame,
) )
DEFAULT_SEEDS = [7, 21, 42, 77, 123] DEFAULT_SEEDS = [7, 21, 42, 77, 123]
@ -166,7 +165,7 @@ def main() -> None:
"ood_overrides": ood_count[scenario], "ood_overrides": ood_count[scenario],
} }
row.update( row.update(
{f"f1_{label}": float(value) for label, value in zip(LABELS, per_class)} {f"f1_{label}": float(value) for label, value in zip(LABELS, per_class, strict=True)}
) )
run_rows.append(row) run_rows.append(row)

View File

@ -1,6 +1,50 @@
altair==6.2.2
anyio==4.14.2
attrs==26.1.0
blinker==1.9.0
certifi==2026.7.22
charset-normalizer==3.5.1
click==8.4.2
contourpy==1.3.3
cycler==0.12.1
fonttools==4.63.0
h11==0.16.0
httptools==0.8.0
idna==3.19
itsdangerous==2.2.0
Jinja2==3.1.6
joblib==1.5.3
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
kiwisolver==1.5.0
MarkupSafe==3.0.3
matplotlib==3.10.8 matplotlib==3.10.8
narwhals==2.25.0
numpy==2.3.5 numpy==2.3.5
packaging==26.3
pandas==2.2.3 pandas==2.2.3
pillow==12.3.0
plotly==6.9.0 plotly==6.9.0
protobuf==7.36.0
pyarrow==25.0.1
pydeck==0.9.3
pyparsing==3.3.2
python-dateutil==2.9.0.post0
python-multipart==0.0.32
pytz==2026.3.post1
referencing==0.37.0
requests==2.34.2
rpds-py==2026.6.3
scikit-learn==1.8.0 scikit-learn==1.8.0
scipy==1.18.1
six==1.17.0
starlette==1.6.0
streamlit==1.62.0 streamlit==1.62.0
threadpoolctl==3.6.0
toml==0.10.2
typing_extensions==4.16.0
tzdata==2026.3
urllib3==2.7.0
uvicorn==0.52.4
watchdog==6.0.0
websockets==16.1.1

5
ruff.toml Normal file
View File

@ -0,0 +1,5 @@
target-version = "py312"
extend-exclude = ["archive/legacy_experiments"]
[lint]
select = ["E4", "E7", "E9", "F", "I", "B"]

1
scripts/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Operational scripts for building and verifying ENGIN releases."""

View File

@ -0,0 +1,130 @@
"""Build or verify the versioned ENGIN production model artifact."""
from __future__ import annotations
import argparse
from pathlib import Path
import pandas as pd
from pandas.testing import assert_frame_equal
from engin.artifact import (
LoadedModelArtifact,
load_model_artifact,
save_model_artifact,
sha256_file,
)
from engin.inference import predict_test, validate_submission
from final_pipeline import (
LABEL_MODEL_NAME,
SEVERITY_CANDIDATE_ID,
train_models,
)
DEFAULT_MODEL_VERSION = "engin-2026.08.25-1"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--val", type=Path, default=Path("val.csv"))
parser.add_argument("--test", type=Path, default=Path("test.csv"))
parser.add_argument(
"--expected-submission", type=Path, default=Path("predictions.csv")
)
parser.add_argument(
"--expected-diagnostics",
type=Path,
default=Path("prediction_diagnostics.csv"),
)
parser.add_argument("--model-version", default=DEFAULT_MODEL_VERSION)
parser.add_argument("--source-revision", default="unknown")
parser.add_argument("--random-state", type=int, default=42)
parser.add_argument("--n-jobs", type=int, default=-1)
parser.add_argument("--artifact-dir", type=Path)
parser.add_argument(
"--verify-only",
action="store_true",
help="Load and verify the existing artifact without retraining.",
)
return parser.parse_args()
def _artifact_dir(args: argparse.Namespace) -> Path:
return args.artifact_dir or Path("artifacts") / args.model_version
def build_artifact(args: argparse.Namespace) -> LoadedModelArtifact:
artifact_dir = _artifact_dir(args)
if not args.verify_only:
val = pd.read_csv(args.val).reset_index(drop=True)
models = train_models(
val,
random_state=args.random_state,
n_jobs=args.n_jobs,
)
save_model_artifact(
models,
artifact_dir,
model_version=args.model_version,
source_revision=args.source_revision,
training_data_sha256=sha256_file(args.val),
random_state=args.random_state,
n_jobs=args.n_jobs,
label_model=LABEL_MODEL_NAME,
severity_model=SEVERITY_CANDIDATE_ID,
)
return load_model_artifact(
artifact_dir,
expected_model_version=args.model_version,
)
def verify_predictions(
artifact: LoadedModelArtifact,
*,
test_path: Path,
expected_submission_path: Path,
expected_diagnostics_path: Path,
) -> None:
test = pd.read_csv(test_path).reset_index(drop=True)
submission, diagnostics = predict_test(artifact.models, test)
validate_submission(submission, test)
expected_submission = pd.read_csv(expected_submission_path).reset_index(drop=True)
expected_diagnostics = pd.read_csv(expected_diagnostics_path).reset_index(drop=True)
assert_frame_equal(
submission,
expected_submission,
check_dtype=False,
check_exact=True,
)
assert_frame_equal(
diagnostics,
expected_diagnostics,
check_dtype=False,
check_exact=False,
rtol=1e-12,
atol=1e-12,
)
def main() -> None:
args = parse_args()
artifact = build_artifact(args)
verify_predictions(
artifact,
test_path=args.test,
expected_submission_path=args.expected_submission,
expected_diagnostics_path=args.expected_diagnostics,
)
metadata = artifact.metadata
print(f"Artifact verified: {_artifact_dir(args).resolve()}")
print(f"Model version: {metadata.model_version}")
print(f"Model SHA-256: {metadata.model_sha256}")
print(f"Training data SHA-256: {metadata.training_data_sha256}")
print(f"Python: {metadata.python_version} | inference n_jobs: {metadata.n_jobs}")
print("Predictions: exact 600-row regression match")
if __name__ == "__main__":
main()

View File

@ -1,5 +1,7 @@
#!/usr/bin/env sh #!/usr/bin/env sh
set -eu set -eu
python -m py_compile app.py engin/*.py tests/*.py python -m compileall -q app.py engin tests final_pipeline.py scripts
python -m pip check
python -m unittest discover -v python -m unittest discover -v
python -m scripts.build_model_artifact --verify-only

View File

@ -32,6 +32,7 @@ from sklearn.pipeline import make_pipeline as sklearn_pipeline
from sklearn.preprocessing import StandardScaler from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC from sklearn.svm import SVC
from engin.features import FEATURE_SETS, SeverityFeatures
from benchmark_grouped import ( from benchmark_grouped import (
FAULT_LABELS, FAULT_LABELS,
FREQ_COLS, FREQ_COLS,
@ -50,7 +51,6 @@ from robustness_grouped import mask_spectral_cells, missing_scenario_name
DEFAULT_SEEDS = [7, 21, 42, 77, 123] DEFAULT_SEEDS = [7, 21, 42, 77, 123]
SEVERITY_TO_INT = {"male": 0, "srednie": 1, "duze": 2} SEVERITY_TO_INT = {"male": 0, "srednie": 1, "duze": 2}
INT_TO_SEVERITY = np.asarray(SEVERITIES, dtype=object) INT_TO_SEVERITY = np.asarray(SEVERITIES, dtype=object)
FEATURE_SETS = ("raw", "deviation", "all")
@dataclass(frozen=True) @dataclass(frozen=True)
@ -91,146 +91,6 @@ CANDIDATES = [
CANDIDATE_BY_ID = {candidate.candidate_id: candidate for candidate in CANDIDATES} CANDIDATE_BY_ID = {candidate.candidate_id: candidate for candidate in CANDIDATES}
class SeverityFeatures:
"""Create severity-oriented features without using validation labels.
Engine-relative values use a leave-one-cylinder-out median reference. Input
to ``transform`` must therefore contain every cylinder of each engine.
``diagnostic_label`` is optional and may contain only the model-predicted
label at validation or test time.
"""
def __init__(self, feature_set: str, include_fault_type: bool = False) -> None:
if feature_set not in FEATURE_SETS:
raise ValueError(f"Unknown feature_set={feature_set!r}")
self.feature_set = feature_set
self.include_fault_type = include_fault_type
def fit(
self, X: pd.DataFrame, y: Iterable[str] | None = None
) -> "SeverityFeatures":
self._validate(X)
spectra = X[FREQ_COLS].apply(pd.to_numeric, errors="coerce")
fallback = spectra.median(axis=0).to_numpy(dtype=float)
if np.isnan(fallback).any():
raise ValueError("A frequency column is entirely NaN in training.")
self.fallback_medians_ = fallback
return self
def transform(self, X: pd.DataFrame) -> np.ndarray:
self._validate(X)
if not hasattr(self, "fallback_medians_"):
raise RuntimeError("SeverityFeatures must be fitted before transform().")
raw = self._clean_spectra(X)
reference = self._leave_one_out_engine_median(raw, X["engine_id"].to_numpy())
relative = raw - reference
absolute_relative = np.abs(relative)
ratio_delta = raw / np.maximum(np.abs(reference), 1e-6) - 1.0
if self.feature_set == "raw":
features = raw
elif self.feature_set == "deviation":
features = np.hstack(
[relative, absolute_relative, ratio_delta, self._summary_features(raw, relative, ratio_delta)]
)
else:
features = np.hstack(
[raw, relative, absolute_relative, ratio_delta, self._summary_features(raw, relative, ratio_delta)]
)
if self.include_fault_type:
if "diagnostic_label" not in X.columns:
raise ValueError("diagnostic_label is required when include_fault_type=True")
labels = X["diagnostic_label"].to_numpy(dtype=object)
one_hot = np.column_stack([labels == label for label in FAULT_LABELS]).astype(float)
features = np.hstack([features, one_hot])
if np.isnan(features).any() or np.isinf(features).any():
raise ValueError("Severity features contain NaN or infinity.")
return features
def fit_transform(
self, X: pd.DataFrame, y: Iterable[str] | None = None
) -> np.ndarray:
return self.fit(X).transform(X)
def _clean_spectra(self, X: pd.DataFrame) -> np.ndarray:
spectra = X[FREQ_COLS].apply(pd.to_numeric, errors="coerce")
spectra = spectra.interpolate(axis=1, limit_direction="both")
spectra = spectra.fillna(pd.Series(self.fallback_medians_, index=FREQ_COLS))
return spectra.to_numpy(dtype=float)
@staticmethod
def _leave_one_out_engine_median(raw: np.ndarray, groups: np.ndarray) -> np.ndarray:
reference = np.empty_like(raw)
for engine_id in pd.unique(groups):
positions = np.flatnonzero(groups == engine_id)
if len(positions) < 2:
raise ValueError(
f"Engine {engine_id!r} has fewer than two cylinders in transform()."
)
engine_values = raw[positions]
for local_position, global_position in enumerate(positions):
keep = np.arange(len(positions)) != local_position
reference[global_position] = np.median(engine_values[keep], axis=0)
return reference
@staticmethod
def _summary_features(
raw: np.ndarray, relative: np.ndarray, ratio_delta: np.ndarray
) -> np.ndarray:
absolute_relative = np.abs(relative)
gradients = np.diff(raw, axis=1)
frequency = np.arange(raw.shape[1], dtype=float)
centered_frequency = frequency - frequency.mean()
slope = raw @ centered_frequency / np.sum(centered_frequency**2)
# ``np.trapezoid`` was added after NumPy 1.26, which is still allowed by
# requirements.txt. Keep the fresh-install path compatible with both
# NumPy 1.x and 2.x.
if hasattr(np, "trapezoid"):
spectral_auc = np.trapezoid(raw, axis=1)
else: # pragma: no cover - exercised only with NumPy 1.x
spectral_auc = np.trapz(raw, axis=1)
columns = [
raw.mean(axis=1),
raw.std(axis=1),
raw.min(axis=1),
raw.max(axis=1),
np.ptp(raw, axis=1),
spectral_auc,
raw.argmax(axis=1) / 20.0,
raw.argmin(axis=1) / 20.0,
slope,
np.abs(gradients).mean(axis=1),
np.abs(gradients).max(axis=1),
relative.mean(axis=1),
relative.std(axis=1),
relative.min(axis=1),
relative.max(axis=1),
absolute_relative.mean(axis=1),
absolute_relative.max(axis=1),
np.sqrt(np.mean(relative**2, axis=1)),
ratio_delta.mean(axis=1),
ratio_delta.std(axis=1),
]
for start, stop in ((0, 5), (5, 10), (10, 15), (15, 21)):
columns.extend(
[
raw[:, start:stop].mean(axis=1),
relative[:, start:stop].mean(axis=1),
absolute_relative[:, start:stop].mean(axis=1),
]
)
return np.column_stack(columns)
@staticmethod
def _validate(X: pd.DataFrame) -> None:
required = {"engine_id", *FREQ_COLS}
missing = sorted(required.difference(X.columns))
if missing:
raise ValueError(f"Missing columns: {missing}")
class OrdinalRidge(BaseEstimator): class OrdinalRidge(BaseEstimator):
"""Weighted ridge regression rounded to the three ordered severities.""" """Weighted ridge regression rounded to the three ordered severities."""

View File

@ -5,7 +5,6 @@ from pathlib import Path
from streamlit.testing.v1 import AppTest from streamlit.testing.v1 import AppTest
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@ -13,9 +12,72 @@ class StreamlitSmokeTests(unittest.TestCase):
def test_default_demo_renders_without_exception(self) -> None: def test_default_demo_renders_without_exception(self) -> None:
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
self.assertEqual(len(app.exception), 0) self.assertEqual(len(app.exception), 0)
self.assertGreaterEqual(len(app.button), 8) self.assertGreaterEqual(len(app.selectbox), 1)
self.assertGreaterEqual(len(app.selectbox), 2)
self.assertEqual(app.radio[0].value, "Dane demonstracyjne") self.assertEqual(app.radio[0].value, "Dane demonstracyjne")
self.assertEqual(app.segmented_control[0].value, "Przegląd")
self.assertEqual(
app.segmented_control[0].options,
["Przegląd", "Szczegóły cylindra"],
)
stylesheet = (ROOT / "assets" / "app.css").read_text(encoding="utf-8")
self.assertIn(
"grid-template-columns: repeat(2, minmax(0, 1fr))",
stylesheet,
)
self.assertFalse(any("C01" in button.label for button in app.button))
self.assertFalse(
any(selector.label == "Cylinder do analizy" for selector in app.selectbox)
)
rendered = "\n".join(markdown.value for markdown in app.markdown)
self.assertIn("Konsola diagnostyczna ENGIN", rendered)
self.assertNotIn("MODEL GOTOWY", rendered)
self.assertNotIn("ARTEFAKT ZWERYFIKOWANY", rendered)
self.assertNotIn("CPU · DANE LOKALNE", rendered)
for english_fragment in (
"Diagnostic Console",
"LIVE INFERENCE",
"DEMO FALLBACK",
"LEAKAGE-SAFE",
"Grouped Macro F1",
"Severity accuracy",
):
self.assertNotIn(english_fragment, rendered)
self.assertEqual(
[metric.label for metric in app.metric],
[
"Makro F1 (grupowe)",
"Trafność nasilenia",
"Punkty walidacyjne",
"Testy",
],
)
technical_expander = next(
expander for expander in app.expander if expander.label == "Informacje techniczne"
)
self.assertFalse(technical_expander.proto.expanded)
diagnostic_table = next(
table
for table in app.dataframe
if "Źródło decyzji" in table.value.columns
)
diagnostic_columns = set(diagnostic_table.value.columns)
self.assertIn("Źródło decyzji", diagnostic_columns)
self.assertNotIn("decision_source", diagnostic_columns)
overview_table = next(
table for table in app.dataframe if "Kolejność" in table.value.columns
)
self.assertEqual(
list(overview_table.value.columns),
[
"Kolejność",
"Cylinder",
"Diagnoza",
"Nasilenie",
"Odchylenie [mV]",
],
)
self.assertNotIn("Priorytet", overview_table.value.columns)
def test_upload_mode_has_safe_empty_state(self) -> None: def test_upload_mode_has_safe_empty_state(self) -> None:
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
@ -23,13 +85,40 @@ class StreamlitSmokeTests(unittest.TestCase):
self.assertEqual(len(app.exception), 0) self.assertEqual(len(app.exception), 0)
self.assertGreaterEqual(len(app.info), 1) self.assertGreaterEqual(len(app.info), 1)
def test_cylinder_grid_and_detail_selector_share_state(self) -> None: def test_cylinder_selector_exists_only_in_detail_view(self) -> None:
app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run() app = AppTest.from_file(str(ROOT / "app.py"), default_timeout=30).run()
target = next(button for button in app.button if "C03" in button.label) self.assertFalse(
target.click().run() any(selector.label == "Cylinder do analizy" for selector in app.selectbox)
)
app.segmented_control[0].set_value("Szczegóły cylindra").run()
self.assertEqual(app.segmented_control[0].value, "Szczegóły cylindra")
detail_selector = next( detail_selector = next(
selector for selector in app.selectbox if selector.label == "Cylinder do analizy" selector
for selector in app.selectbox
if selector.label == "Cylinder do analizy"
)
rendered = "\n".join(markdown.value for markdown in app.markdown)
self.assertIn(f"CYLINDER {int(detail_selector.value):02d}", rendered)
detail_selector.set_value(3).run()
detail_selector = next(
selector
for selector in app.selectbox
if selector.label == "Cylinder do analizy"
) )
self.assertEqual(detail_selector.value, 3) self.assertEqual(detail_selector.value, 3)
for slot, cylinder in enumerate((1, 2, 4), start=1):
comparison = next(
selector
for selector in app.selectbox
if selector.label == f"Porównanie {slot}"
)
comparison.set_value(cylinder).run()
self.assertEqual(len(app.exception), 0)
self.assertEqual(len(app.multiselect), 0)
rendered = "\n".join(markdown.value for markdown in app.markdown) rendered = "\n".join(markdown.value for markdown in app.markdown)
self.assertIn("CYLINDER 03", rendered) self.assertIn("CYLINDER 03", rendered)
self.assertIn("### Wybór cylindra", rendered)
self.assertIn("#### Uzasadnienie diagnozy", rendered)
self.assertNotIn("#### Następny krok", rendered)
self.assertNotIn("Select all", rendered)
self.assertNotIn("You can select up to", rendered)

View File

@ -14,7 +14,6 @@ from engin.explainability import (
) )
from engin.service import DiagnosisResult from engin.service import DiagnosisResult
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@ -63,6 +62,29 @@ class ExplainabilityTests(unittest.TestCase):
def test_all_chart_factories_return_populated_figures(self) -> None: def test_all_chart_factories_return_populated_figures(self) -> None:
cylinder = int(self.analysis.measurements["cylinder"].iloc[0]) cylinder = int(self.analysis.measurements["cylinder"].iloc[0])
self.assertEqual(len(engine_heatmap(self.analysis).data), 1) compared = self.analysis.measurements["cylinder"].astype(int).head(4).tolist()
self.assertEqual(len(cylinder_spectrum(self.analysis, cylinder).data), 2) heatmap = engine_heatmap(self.analysis)
self.assertEqual(len(heatmap.data), 1)
self.assertEqual(heatmap.layout.height, 360)
self.assertEqual(heatmap.data[0].zmin, -20.0)
self.assertEqual(heatmap.data[0].zmax, 20.0)
self.assertTrue(any("Sprawny" in label for label in heatmap.data[0].y))
self.assertTrue(any("·" in label for label in heatmap.data[0].y))
self.assertTrue(all(label.startswith("C") for label in heatmap.data[0].y))
self.assertEqual(heatmap.layout.plot_bgcolor, "#101820")
self.assertEqual(heatmap.data[0].colorbar.bgcolor, "rgba(0,0,0,0)")
self.assertEqual(heatmap.data[0].colorbar.tickfont.color, "#dce8ee")
spectrum = cylinder_spectrum(self.analysis, cylinder)
self.assertEqual(spectrum.layout.margin.l, 72)
self.assertTrue(spectrum.layout.yaxis.automargin)
self.assertEqual(spectrum.layout.yaxis.title.standoff, 18)
deviation = deviation_chart(self.analysis, cylinder)
self.assertEqual(deviation.layout.margin.l, 72)
self.assertTrue(deviation.layout.yaxis.automargin)
self.assertEqual(deviation.layout.yaxis.title.standoff, 18)
self.assertEqual(len(spectrum.data), 2)
self.assertEqual(
len(cylinder_spectrum(self.analysis, cylinder, compared).data),
5,
)
self.assertEqual(len(deviation_chart(self.analysis, cylinder).data), 1) self.assertEqual(len(deviation_chart(self.analysis, cylinder).data), 1)

View File

@ -0,0 +1,114 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import pandas as pd
from pandas.testing import assert_frame_equal
import app
from engin.artifact import (
MANIFEST_FILENAME,
MODEL_FILENAME,
ModelArtifactError,
load_model_artifact,
save_model_artifact,
)
from engin.inference import DiagnosticModels
from engin.model import SklearnPredictionModel
ROOT = Path(__file__).resolve().parents[1]
MODEL_VERSION = "engin-2026.08.25-1"
ARTIFACT_DIR = ROOT / "artifacts" / MODEL_VERSION
def _save_fake_artifact(directory: Path) -> None:
save_model_artifact(
DiagnosticModels("label", "transformer", "estimator"),
directory,
model_version="test-v1",
source_revision="unit-test",
training_data_sha256="a" * 64,
random_state=42,
n_jobs=1,
label_model="fake-label",
severity_model="fake-severity",
)
class ModelArtifactContractTests(unittest.TestCase):
def test_round_trip_preserves_metadata_and_models(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
_save_fake_artifact(directory)
loaded = load_model_artifact(
directory, expected_model_version="test-v1"
)
self.assertEqual(loaded.metadata.model_version, "test-v1")
self.assertEqual(loaded.models.label_pipeline, "label")
def test_corrupt_model_is_rejected_before_deserialization(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
_save_fake_artifact(directory)
with (directory / MODEL_FILENAME).open("ab") as handle:
handle.write(b"corruption")
with self.assertRaisesRegex(ModelArtifactError, "checksum"):
load_model_artifact(directory)
def test_runtime_version_mismatch_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
directory = Path(temporary_directory)
_save_fake_artifact(directory)
manifest_path = directory / MANIFEST_FILENAME
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["runtime_versions"]["scikit-learn"] = "0.0-invalid"
manifest_path.write_text(
json.dumps(manifest), encoding="utf-8"
)
with self.assertRaisesRegex(ModelArtifactError, "runtime versions"):
load_model_artifact(directory)
class ShippedArtifactTests(unittest.TestCase):
def test_artifact_reproduces_frozen_submission(self) -> None:
model = SklearnPredictionModel.from_artifact(
ARTIFACT_DIR,
expected_model_version=MODEL_VERSION,
)
test = pd.read_csv(ROOT / "test.csv").reset_index(drop=True)
expected = pd.read_csv(ROOT / "predictions.csv").reset_index(drop=True)
actual = model.predict(test).submission
assert_frame_equal(actual, expected, check_dtype=False, check_exact=True)
def test_diagnostics_identify_model_release(self) -> None:
model = SklearnPredictionModel.from_artifact(
ARTIFACT_DIR,
expected_model_version=MODEL_VERSION,
)
test = pd.read_csv(ROOT / "test.csv").reset_index(drop=True)
diagnostics = model.predict(test).diagnostics
self.assertEqual(set(diagnostics["model_version"]), {MODEL_VERSION})
self.assertEqual(
set(diagnostics["model_artifact_sha256"]),
{model.metadata.model_sha256},
)
def test_application_startup_does_not_read_training_data(self) -> None:
app.build_dependencies.clear()
with patch.object(
app.PandasCsvReader,
"read_path",
side_effect=AssertionError("runtime attempted to read training data"),
):
dependencies = app.build_dependencies()
app.build_dependencies.clear()
self.assertTrue(dependencies.live_inference)
self.assertEqual(dependencies.model_version, MODEL_VERSION)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -4,7 +4,7 @@ import unittest
import pandas as pd import pandas as pd
from engin.model import PredictionBundle, PrecomputedPredictionModel from engin.model import PrecomputedPredictionModel, PredictionBundle
from engin.service import DiagnosticService from engin.service import DiagnosticService
from engin.validation import ValidationResult from engin.validation import ValidationResult

View File

@ -10,7 +10,6 @@ from engin.config import FREQ_COLS
from engin.errors import InputDataError from engin.errors import InputDataError
from engin.validation import SpectrumFrameValidator from engin.validation import SpectrumFrameValidator
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]