diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 469eb8f..bc1596b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements.lock.txt - name: Run Ruff (Formatting & Linting) run: | @@ -33,5 +33,10 @@ jobs: - name: Run mypy run: mypy source - - name: Run Pytest - run: pytest + - name: Run Pytest + run: pytest + + - name: Run deterministic acceptance episodes + run: | + python -m source.main validate-stage0 + python -m source.main acceptance --output reports/ci-acceptance diff --git a/.gitignore b/.gitignore index b6662c6..97d5ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage.xml .pytest_cache/ .pytest_tmp/ .test_tmp/ +.test_tmp_seq_*/ cover/ # Translations diff --git a/DESIGN.md b/DESIGN.md index 7e9aed6..0b4cd42 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,9 +1,30 @@ -# Технический дизайн системы рекомендаций Нефтекода - +# Технический дизайн системы рекомендаций Нефтекода + Версия контракта: **1.0**. Статус: **частично реализованная спецификация**. - -Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–5 и Tkinter UI -для `model_demo`; исторический ML-артефакт ещё не подключён к UI. +<<<<<<< HEAD + +Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–6, полный +======= + +<<<<<<< HEAD +Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–8, полный +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) +синтетический паспорт S/T95/CN с присадкой в Tkinter UI и CLI для frozen +training/evaluation/replay. Исторический ML-артефакт ещё не подключён к UI. + +Основания: [техническое задание](materials/ТЗ_нефтекод.docx), [материалы](materials/README.md), [поэтапный план](IMPLEMENTATION_PLAN.md). ТЗ определяет обязательные требования, этот документ — технические контракты, план — порядок реализации. При изменении контракта документ и тестовые примеры обновляются в том же PR. + +## 1. Зафиксированные решения + +| Вопрос | Решение для версии 1 | +| --- | --- | +| Команда и ресурсы | Два исполнителя (человека или агента): backend и ML; 1–2 недели; CPU; без обязательных платных API | +======= +Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–10, полный +синтетический паспорт S/T95/CN с присадкой в Tkinter UI и CLI для frozen +training/evaluation/replay. UI также умеет выполнить history-replay доверенного +forecast artifact, исследовательский P8/F19 scenario и отдельный ПАК--ЛИМС +контроль; schema 1.2 и action-effect остаются shadow/read-only. Основания: [техническое задание](materials/ТЗ_нефтекод.docx), [материалы](materials/README.md), [поэтапный план](IMPLEMENTATION_PLAN.md). ТЗ определяет обязательные требования, этот документ — технические контракты, план — порядок реализации. При изменении контракта документ и тестовые примеры обновляются в том же PR. @@ -12,94 +33,190 @@ | Вопрос | Решение для версии 1 | | --- | --- | | Команда и ресурсы | Два исполнителя (человека или агента): backend и ML; 1–2 недели; CPU; без обязательных платных API | +>>>>>>> 1527107 (Extend UI and ML analysis materials) | Развёртывание | Одно локальное Python-приложение; toolchain нацелен на Python 3.11, локальный Stage-5 artifact собран в Python 3.12.3 | -| Мультиагентность | Агент качества, агент надёжности, агент оптимизации и оркестратор с явными входами и выходами | -| Взаимодействие | Синхронные вызовы Python-функций в одном процессе; структурированные результаты | +| Мультиагентность | Агент качества, агент надёжности, агент оптимизации и оркестратор с явными входами и выходами | +| Взаимодействие | Синхронные вызовы Python-функций в одном процессе; структурированные результаты | | UI | Tkinter desktop UI для model-demo; CLI для `prepare`, `build-state` и demo | -| Хранение | Исходные файлы; подготовленные CSV.gz; артефакты модели; JSON/JSONL с результатами | -| Модели | Сохранение последнего значения → Ridge → HistGradientBoostingRegressor; улучшение допускается по результатам временной валидации | -| Оптимизация | Детерминированный ограниченный перебор; жёсткий фильтр перед ранжированием | -| Блендинг | Модель массового смешения с явно заданными компонентами и допущениями | -| Объяснение | Шаблон из численных результатов и причин проверок; не влияет на решение | -| Управление установкой | Только рекомендации оператору; команд исполнительным устройствам нет | - -В версии 1 не вводятся HTTP API, брокер сообщений, микросервисы, СУБД, оркестратор LLM, обучение в интерфейсе или собственный фреймворк агентов. Новые зависимости: `openpyxl` для Excel; `tzdata` для часовых поясов на Windows, где системная база IANA может отсутствовать ([документация Python](https://docs.python.org/3.11/library/zoneinfo.html)). Остальной обязательный стек уже указан в репозитории. - -### Три режима работы - -| Режим | Что является фактом | Что система вправе утверждать | -| --- | --- | --- | -| `history` | Исторические измерения, доступные к выбранному моменту | Текущее состояние, прогноз и предупреждения. Изменения реальных уставок разрешены только при прошедшей проверку модели последствий | -| `model_demo` | Численные входы из сценария, помеченные как модельные | Допустимость и эффект внутри объявленной модели; не промышленная гарантия | -| `hybrid` | История АВТ/гидроочистки плюс модельная рецептура | Прогноз качества реального потока и условный результат его модельного смешения | - -Прогноз `y(t + h)` на истории и оценка `y(t + h | действие)` — разные возможности. У модели отдельные признаки `supports_forecast` и `supports_actions`. Хороший MAE не включает вторую возможность автоматически. При `supports_actions=false` оптимизатор не подставляет новые уставки в обычный предиктор. - -В режиме `history` без модели последствий возвращается `abstain` с причиной `ACTION_MODEL_UNAVAILABLE`, но оценки состояния и качества сохраняются и показываются. Проверенный пример `hold` демонстрируется в `model_demo`; после проверки модели последствий он доступен и на истории. - -## 2. Компоненты и зависимости - -```mermaid -flowchart TD - Raw[CSV и Excel из materials] --> Prepare[Подготовка данных] - Prepare --> Store[Подготовленные данные и manifest] - Store --> State[Состояние на момент t] - Store --> Train[Офлайн обучение и оценка] - Train --> Model[Модель и metadata] +| Хранение | Исходные файлы; подготовленные CSV.gz; артефакты модели; JSON/JSONL с результатами | +| Модели | Сохранение последнего значения → Ridge → HistGradientBoostingRegressor; улучшение допускается по результатам временной валидации | +| Оптимизация | Детерминированный ограниченный перебор; жёсткий фильтр перед ранжированием | +| Блендинг | Модель S/T95/CN, сценарные границы и конфигурируемая цетановая присадка | +| Объяснение | Шаблон из численных результатов и причин проверок; не влияет на решение | +| Управление установкой | Только рекомендации оператору; команд исполнительным устройствам нет | + +Q&A 11.09 подтверждает локальный запуск без внешнего интернета и допустимость +обоснованных инженерных моделей без обязательной LLM. Данные, зависимости и модели +готовятся до переноса в закрытую среду; наличие внутренней LLM не является обещанием +ресурсов команде. Основания и открытые вопросы: [разбор встречи](QA_CLARIFICATIONS.md). + +В версии 1 не вводятся HTTP API, брокер сообщений, микросервисы, СУБД, оркестратор LLM, обучение в интерфейсе или собственный фреймворк агентов. Новые зависимости: `openpyxl` для Excel; `tzdata` для часовых поясов на Windows, где системная база IANA может отсутствовать ([документация Python](https://docs.python.org/3.11/library/zoneinfo.html)). Остальной обязательный стек уже указан в репозитории. + +### Три режима работы + +| Режим | Что является фактом | Что система вправе утверждать | +| --- | --- | --- | +| `history` | Исторические измерения, доступные к выбранному моменту | Текущее состояние, прогноз и предупреждения. Изменения реальных уставок разрешены только при прошедшей проверку модели последствий | +| `model_demo` | Численные входы из сценария, помеченные как модельные | Допустимость и эффект внутри объявленной модели; не промышленная гарантия | +| `hybrid` | История АВТ/гидроочистки плюс модельная рецептура | Прогноз качества реального потока и условный результат его модельного смешения | + +Прогноз `y(t + h)` на истории и оценка `y(t + h | действие)` — разные возможности. У модели отдельные признаки `supports_forecast` и `supports_actions`. Хороший MAE не включает вторую возможность автоматически. При `supports_actions=false` оптимизатор не подставляет новые уставки в обычный предиктор. + +В режиме `history` без модели последствий возвращается `abstain` с причиной `ACTION_MODEL_UNAVAILABLE`, но оценки состояния и качества сохраняются и показываются. Проверенный пример `hold` демонстрируется в `model_demo`; после проверки модели последствий он доступен и на истории. + +## 2. Компоненты и зависимости + +```mermaid +flowchart TD + Raw[CSV и Excel из materials] --> Prepare[Подготовка данных] + Prepare --> Store[Подготовленные данные и manifest] + Store --> State[Состояние на момент t] + Store --> Train[Офлайн обучение и оценка] + Train --> Model[Модель и metadata] UI[Tkinter UI или CLI] --> Orch[Оркестратор] - State --> Orch - Model --> Quality[Агент качества] - Orch --> Quality - Orch --> Reliability[Агент надёжности] - Orch --> Optimizer[Агент оптимизации] - Optimizer --> Effects[Расчёт последствий и блендинг] - Effects --> Quality - Effects --> Reliability - Optimizer --> Constraints[Единая проверка ограничений] - Orch --> Constraints - Orch --> Explain[Шаблон объяснения] - Orch --> Journal[Журнал решения] - Explain --> UI -``` - -Диаграмма — поток данных и вызовов, не сетевые сервисы. Результат возвращается через `return`. - -### Правила импортов - -- `contracts.py` не импортирует другие модули проекта. -- `config.py` зависит только от контрактов и стандартного чтения TOML/JSON. -- `data/*` и `ml/features.py` не импортируют UI, оркестратор или оптимизатор. -- `ml/train.py` и `ml/evaluate.py` используются только офлайн; при открытии UI они не импортируются. + State --> Orch + Model --> Quality[Агент качества] + Orch --> Quality + Orch --> Reliability[Агент надёжности] + Orch --> Optimizer[Агент оптимизации] + Optimizer --> Effects[Расчёт последствий и блендинг] + Effects --> Quality + Effects --> Reliability + Optimizer --> Constraints[Единая проверка ограничений] + Orch --> Constraints + Orch --> Explain[Шаблон объяснения] + Orch --> Journal[Журнал решения] + Explain --> UI +``` + +Диаграмма — поток данных и вызовов, не сетевые сервисы. Результат возвращается через `return`. + +### Правила импортов + +- `contracts.py` не импортирует другие модули проекта. +- `config.py` зависит только от контрактов и стандартного чтения TOML/JSON. +- `data/*` и `ml/features.py` не импортируют UI, оркестратор или оптимизатор. +- `ml/train.py` и `ml/evaluate.py` используются только офлайн; при открытии UI они не импортируются. - `agents/*` используют контракты, признаки и численные модели, но не UI и не журнал. -- `constraints.py` — единственная реализация жёстких проверок; его вызывают оптимизатор и оркестратор. -- `orchestrator.py` координирует расчёт, не содержит формул обучения и смешения. -- `main.py` и `ui.py` собирают зависимости явно и вызывают общий `run_cycle`; DI-контейнер не нужен. -- Только слой подготовки пишет подготовленные данные; только обучение пишет модели; только `journal.py` пишет журналы решений. - -## 3. Структура файлов и владельцы - -Файлы создаются по этапам, без пустых заготовок «на будущее». - -```text -HackathonPetrolCode/ -├── README.md # запуск и ограничения готовой версии -├── IMPLEMENTATION_PLAN.md # сроки, этапы и критерии готовности -├── DESIGN.md # архитектура и контракты -├── pyproject.toml # Ruff и pytest; заменяет config.toml -├── requirements.txt # проверенные зависимости Python 3.11 -├── .github/workflows/ci.yml # проверки кода и малых сценариев -├── config/ -│ ├── runtime.toml # общие экспериментальные настройки -│ ├── tags.csv # подтверждённые соответствия и единицы -│ └── scenarios/ -│ ├── history.json # режим истории; реальные controls отключены -│ ├── blend_normal.json # нормальный модельный период -│ ├── blend_risk.json # модельное превышение серы -│ └── blend_missing.json # модельная нехватка данных +- `constraints.py` — единственная реализация жёстких проверок; его вызывают оптимизатор и оркестратор. +- `orchestrator.py` координирует расчёт, не содержит формул обучения и смешения. +- `main.py` и `ui.py` собирают зависимости явно и вызывают общий `run_cycle`; DI-контейнер не нужен. +- Только слой подготовки пишет подготовленные данные; только обучение пишет модели; только `journal.py` пишет журналы решений. + +## 3. Структура файлов и владельцы + +Файлы создаются по этапам, без пустых заготовок «на будущее». + +```text +HackathonPetrolCode/ +├── README.md # запуск и ограничения готовой версии +├── IMPLEMENTATION_PLAN.md # сроки, этапы и критерии готовности +├── DESIGN.md # архитектура и контракты +├── pyproject.toml # Ruff и pytest; заменяет config.toml +├── requirements.txt # проверенные зависимости Python 3.11 +├── .github/workflows/ci.yml # проверки кода и малых сценариев +├── config/ +│ ├── runtime.toml # общие экспериментальные настройки +│ ├── tags.csv # подтверждённые соответствия и единицы +│ └── scenarios/ +│ ├── history.json # режим истории; реальные controls отключены +│ ├── blend_normal.json # нормальный модельный период +│ ├── blend_risk.json # модельное превышение серы +│ ├── blend_t95_risk.json # модельное превышение T95 +│ ├── blend_cetane_risk.json # модельный дефицит цетанового числа +│ └── blend_missing.json # модельная нехватка данных +<<<<<<< HEAD +├── source/ +│ ├── __init__.py +│ ├── main.py # CLI и сборка зависимостей +│ ├── ui.py # Tkinter; model-demo и диагностические команды +│ ├── contracts.py # Pydantic-типы обмена +│ ├── config.py # чтение и валидация настроек +│ ├── constraints.py # единый фильтр допустимости +│ ├── orchestrator.py # run_cycle и выбор результата +│ ├── explain.py # русские шаблоны объяснений +│ ├── journal.py # JSON/JSONL и атомарная запись +│ ├── data/ +│ │ ├── __init__.py +│ │ ├── ingest.py # чтение исходных CSV и Excel +│ │ ├── prepare.py # нормализация, manifest, диагностика +│ │ └── state.py # доступные на момент t данные +│ ├── ml/ +│ │ ├── __init__.py +│ │ ├── features.py # общие признаки обучения и применения +│ │ ├── train.py # baselines, подбор, сохранение модели +│ │ ├── evaluate.py # временная оценка и отчёты +│ │ └── artifacts.py # ModelBundle, загрузка и metadata +│ └── agents/ +│ ├── __init__.py +│ ├── quality.py # текущее качество и прогноз +│ ├── reliability.py # тяжесть режима и объясняющие факторы +│ ├── optimizer.py # кандидаты, оценка и ранжирование +│ └── effects.py # последствия действий и формулы смеси +├── global_tests/ +│ ├── workflow_test.py # существующий smoke заменяется запуском цикла +│ ├── test_data.py # источники, единицы, время +│ ├── test_features.py # отсутствие утечек и train/serve parity +│ ├── test_decisions.py # ограничения, выбор, отказы +│ ├── test_artifacts.py # несовместимые и отсутствующие модели +│ └── fixtures/ # малые синтетические данные для CI +├── materials/ # неизменяемые выданные материалы +├── data/processed// # локальные производные данные +├── artifacts/models// # локальные артефакты моделей +├── reports// # метрики и параметры эксперимента +└── runs// # воспроизводимый журнал одного запуска +``` + +| Владелец | Файлы и обязательства | +| --- | --- | +| Backend | `main`, `ui`, `contracts`, `config`, `constraints`, `orchestrator`, `explain`, `journal`, `data/*`, CI и запуск | +| ML | `ml/*`, `agents/*`, смысл `tags.csv`, формулы, выбранные ограничения, модельные сценарии и отчёты | +| Общие файлы с одним автором | `contracts.py`, `runtime.toml`, зависимости и этот дизайн редактирует backend после согласования семантики с ML; `tags.csv` и JSON-сценарии редактирует ML после проверки совместимости с backend | + +Backend не дублирует признаки в UI; ML не пишет альтернативный загрузчик данных в ноутбуке. Ноутбуки — для исследования; итоговый запуск использует указанные `.py`-модули. + +Производные данные, модели и журналы исключаются из обычного Git. Малые синтетические fixtures, настройки, отчёты с агрегированными метриками и документация могут храниться в Git. Существующий `materials/data.rar` остаётся в Git LFS; его не дублируют распакованными CSV в коммитах. + +## 4. Подготовленные данные и время + +### Форматы на диске + +| Файл в наборе данных | Формат и назначение | +| --- | --- | +| `telemetry.csv.gz` | Одна строка на timestamp; `timestamp` и численные колонки с ключами `avt:T33`, `ht:F26` и т. п. | +| `quality.csv.gz` | Длинная таблица: `observation_id, signal_id, stage, source, measured_at, available_at, value, unit, validity, source_ref` | +| `issues.csv.gz` | `source_ref, code, detail`; ошибки и конфликты исходников, без молчаливого удаления | +| `manifest.json` | SHA-256 исходников, настроек и словаря; временные диапазоны, число строк, версия подготовки, допущения | + +`dataset_id` — первые 12 символов SHA-256 от отсортированного списка полных хешей исходников, конфигурации подготовки, словаря и версии кода подготовки. Полные хеши сохраняются в manifest; при совпадении короткого ID и разных полных хешах запись запрещается. Кэш переиспользуется только при полном совпадении manifest. + +### Нормализация + +1. RAR распаковывается локально в отдельный каталог; ожидаются только `data/avt_tags.csv` и `data/242000_tags.csv`. Имена и пути проверяются до извлечения. Исходный архив остаётся неизменным. Для Windows использовать доступный `tar`, для Linux в инструкции указать `bsdtar`. +2. CSV объединяются по `date`. Из первой таблицы удаляются `Unnamed:*`, из второй — пустой индексный заголовок. Технологические теги получают префикс установки. +3. Excel читается по заголовкам точек и показателей. Каждая пара «время — значение» преобразуется независимо; номера строк разных показателей не связываются. +4. `Pt Created`, текст вместо числа, бесконечности и повреждённые даты получают запись в `issues`. Численное значение становится отсутствующим, не нулём. +5. Одинаковые дубликаты схлопываются с сохранением происхождения. Противоречащие значения одного источника в один момент получают `conflict` и не выбираются произвольно. +6. Единицы преобразуются только по подтверждённому правилу: массовые ppm → мг/кг без изменения числа; массовые проценты серы → мг/кг умножением на 10 000. Для неоднозначного `ppm` сначала подтверждается массовая база. Массовый и объёмный расход не отождествляются. +7. Значения вне привычного диапазона не удаляются автоматически. Ошибка датчика, насыщение анализатора и реальная аварийная ситуация различаются только при наличии основания; иначе остаётся предупреждение. + +### Словарь тегов + +`config/tags.csv` содержит поля `signal_id, raw_name, stage, meaning, raw_unit, canonical_unit, conversion, mapping_status, controllable, evidence_ref`. + +- `mapping_status`: `confirmed`, `ambiguous`, `excluded`. +- `conversion`: именованная функция из фиксированного набора, а не исполняемая строка. +- `controllable=true` требует подтверждения в `evidence_ref`; наличие численного ряда этого не подтверждает. +- В формулах и UI используются канонические ключи. Короткие имена вроде `T6` не определяют физический смысл. +- Некорректную формулу ВАК не исправляют догадкой. Её исключают до проверки; утверждённые формулы переносят в явные Python-функции, без `eval` содержимого Excel. + +### Временные правила +======= ├── source/ │ ├── __init__.py │ ├── main.py # CLI и сборка зависимостей -│ ├── ui.py # Tkinter; model-demo и диагностические команды +│ ├── ui.py # Tkinter; stage-aware shell и diagnostic dialogs +│ ├── ui_data.py # headless read-only UI projections │ ├── contracts.py # Pydantic-типы обмена │ ├── config.py # чтение и валидация настроек │ ├── constraints.py # единый фильтр допустимости @@ -181,6 +298,58 @@ Backend не дублирует признаки в UI; ML не пишет ал - Некорректную формулу ВАК не исправляют догадкой. Её исключают до проверки; утверждённые формулы переносят в явные Python-функции, без `eval` содержимого Excel. ### Временные правила +>>>>>>> e70cafe (fix) + +Точки ЛИМС АВТ — разные физические места/стадии, не варианты времени одного анализа +(Q&A 11.09, 00:09:33). Общая подпись «Дизельное топливо» не разрешает объединять точки. +<<<<<<< HEAD +Схема АВТ не подтверждает теги `ht:*`; точная карта потоков и имя `Pipeline` остаются +открытыми вопросами [Q1–Q3](QA_CLARIFICATIONS.md#незакрытые-вопросы-и-границы-использования). + +Внутри приложения — timezone-aware UTC. Исходные даты без timezone интерпретируются согласно `source_timezone`. `Europe/Moscow` по умолчанию — **экспериментальное допущение**, не установленное свойство пакета; записывается в manifest. UI показывает часовой пояс рядом со временем. + +- `measured_at` — время измерения/отбора пробы. +- `available_at` — время, с которого результат мог быть известен системе. +- Для телеметрии и ПАК начальное допущение: `available_at = measured_at`. +- Для ЛИМС при отсутствии точного времени публикации: `available_at = measured_at + lims_delay_hours`. По ответу эксперта от 10.09.2026 публикация занимает до 4 часов; консервативное значение по умолчанию — 4 часа. Чувствительность к меньшей задержке оценивается без подбора на финальном test. +- В состоянии на `t` разрешены только записи с `measured_at <= t` и `available_at <= t`. +- Возраст всегда считается от `measured_at`, не от времени загрузки файла. +- Для каждого сигнала выбирается последнее доступное измерение по времени. +- Начальные пределы свежести: телеметрия 20 минут, ПАК 30 минут, ЛИМС 48 часов. Это модельные настройки, проверяемые на валидации, а не технологические нормативы. +- Пригодный свежий ЛИМС имеет приоритет над ПАК; затем идёт проверенный ВАК. Остальные источники сохраняются для сравнения. +- Устаревший ЛИМС показывается отдельно. Свежий пригодный ПАК может стать оперативным источником с предупреждением; устаревший анализ не становится текущей истиной. +- Расхождение источников не проверяется на произвольных несинхронных значениях: ПАК сопоставляется с временем отбора лабораторной пробы, но сам факт конфликта появляется только после `available_at` ЛИМС. Допуск расхождения обязателен в настройках активного показателя; неподтверждённый допуск не придумывается в коде. +- Неразрешённый конфликт обязательного показателя блокирует рекомендацию. Лабораторный результат остаётся контрольным фактом. + +Отсутствие необязательного сигнала даёт предупреждение. Отсутствие обязательного входа модели или ограничения блокирует соответствующий расчёт. Условия определяются явно в scenario/model metadata. + +## 5. Типы обмена между компонентами + +Типы реализуются в `contracts.py` через Pydantic. Числа конечны; отсутствие — `None`. Во внешнем JSON — `null`, никогда `NaN`. Неизвестные поля отклоняются. Строковые перечисления сериализуются своими значениями. Агентам передаются новые результаты; они не меняют входной `ProcessState` и конфигурацию. + +Обозначения: `Timestamp` — timezone-aware `datetime`; `SignalId`, `CandidateId` и `RunId` — строки; `Unit` — каноническая строка единицы. Поля таблиц обязательны; `| None` разрешает отсутствие значения, но поле остаётся в JSON. + +### Состояние и измерения + +| Тип | Поля | +| --- | --- | +| `Issue` | `code: str`, `severity: Literal['warning','blocking']`, `signal_id: str \| None`, `detail: str`, `source_ref: str \| None` | +| `Observation` | `id: str`, `signal_id: str`, `stage: Literal['avt','ht','blend']`, `source: Literal['telemetry','lims','pak','vak','scenario']`, `measured_at: Timestamp`, `available_at: Timestamp`, `value: float \| None`, `unit: str`, `validity: Literal['valid','missing','invalid','conflict']`, `source_ref: str` | +| `SignalSnapshot` | `selected: Observation \| None`, `alternatives: list[Observation]`, `age_seconds: float \| None`, `fresh: bool`, `issues: list[Issue]` | +| `ProcessState` | `schema_version: Literal['1.0']`, `state_id: str`, `as_of: Timestamp`, `dataset_id: str`, `mode: Literal['history','model_demo','hybrid']`, `signals: dict[str, SignalSnapshot]`, `issues: list[Issue]` | + +`signals` содержит все ожидаемые сценарием сигналы, в том числе отсутствующие. Исторические окна для модели не вкладываются целиком в состояние: признаки строятся через ограниченный моментом `as_of` доступ к подготовленным данным и отдельно сохраняются в журнале. + +### Действия, оценки и проверки + +| Тип | Поля | +| --- | --- | +======= +Новые схемы организаторов от 14.09 локализуют короткие `avt:*` на К-1, К-2 и К-10 +и подтверждают внутренний маршрут АВТ до продуктовой ветви ДТ 240-350 °C. Они не +привязывают ЛИМС 1/2/2.1/3 к стрелкам, не показывают маршрут/резервуар до 24-2000 +и не подтверждают теги `ht:*`; имя `Pipeline` также остаётся открытым вопросом. +Подробная карта и границы: [разбор схем](SCHEME_ANALYSIS_2026_09_14.md). Внутри приложения — timezone-aware UTC. Исходные даты без timezone интерпретируются согласно `source_timezone`. `Europe/Moscow` по умолчанию — **экспериментальное допущение**, не установленное свойство пакета; записывается в manifest. UI показывает часовой пояс рядом со временем. @@ -220,46 +389,176 @@ Backend не дублирует признаки в UI; ML не пишет ал | Тип | Поля | | --- | --- | -| `CandidateAction` | `id: str`, `kind: Literal['hold','setpoints','blend']`, `setpoints: dict[str,float]`, `blend_mass_fractions: dict[str,float]`, `horizon_minutes: int`, `is_model_scenario: bool` | -| `MetricEstimate` | `value: float \| None`, `lower: float \| None`, `upper: float \| None`, `unit: str`, `basis: Literal['measured','forecast','formula','proxy']`, `interval_kind: Literal['none','empirical','scenario_bound']`, `interval_level: float \| None`, `reference: str`, `assumptions: list[str]` | -| `AgentAssessment` | `agent: Literal['quality','reliability','optimizer']`, `state_id: str`, `candidate_id: str`, `evaluated_for: Timestamp`, `status: Literal['ok','degraded','unavailable']`, `metrics: dict[str,MetricEstimate]`, `issues: list[Issue]` | -| `ConstraintResult` | `constraint_id: str`, `candidate_id: str`, `status: Literal['pass','fail','unknown']`, `actual: float \| None`, `lower: float \| None`, `upper: float \| None`, `unit: str \| None`, `basis: Literal['tz','confirmed','model_assumption']`, `evidence_ref: str`, `reason_code: str` | -| `CandidateEvaluation` | `candidate: CandidateAction`, `assessments: list[AgentAssessment]`, `checks: list[ConstraintResult]`, `feasible: bool`, `rank_key: tuple[float,float,float,float,str] \| None` | - -Правила: - -- `setpoints` содержит **новые абсолютные значения**, не приращения; единицы определяются `ControlSpec`. Разницу UI вычисляет относительно состояния. -- Для `hold` обе карты пустые: сохраняются текущие уставки и текущая рецептура. Для `blend` задаётся полная рецептура, не только изменённые доли. В первой версии кандидат не совмещает изменение уставок и рецептуры. -- У всех оценок кандидата совпадают `state_id`, `candidate_id` и горизонт. Прогноз вычисляется на `as_of + horizon`; измерение текущего состояния подписывается отдельно. -- `MetricEstimate` с `basis='proxy'` не называется физической величиной без установленного соответствия. Прокси затрат имеют единицу `proxy_unit`, риска — `index_0_1`. -- `interval_kind='scenario_bound'` — заданная граница сценария, не статистическая вероятность. `interval_level` для неё равен `None`. -- `feasible=true` только если все обязательные проверки имеют `pass`. `unknown` не равно успешной проверке. При отсутствии обязательной оценки `rank_key=None`. - -### Итог цикла - -| Тип | Поля | -| --- | --- | -| `Recommendation` | `schema_version: Literal['1.0']`, `run_id: str`, `state_id: str`, `as_of: Timestamp`, `scenario_id: str`, `mode: str`, `status: Literal['recommend','hold','abstain']`, `baseline: CandidateEvaluation \| None`, `selected: CandidateEvaluation \| None`, `alternatives: list[CandidateEvaluation]`, `reason_codes: list[str]`, `explanation: str`, `assumptions: list[str]`, `model_id: str \| None` | -| `RunFailure` | `run_id: str`, `occurred_at: Timestamp`, `error_code: str`, `stage: str`, `message: str` | - -Инварианты: при `recommend` выбран допустимый ненулевой кандидат; при `hold` выбран допустимый кандидат `hold`; при `abstain` `selected=None` и есть причина. `baseline` может быть недопустимым — это исходная точка сравнения, а не разрешённое действие. В `alternatives` идут до трёх допустимых альтернатив, полный список сохраняется в журнале. - -`RunFailure` — техническая ошибка выполнения, не технологический отказ. Система не превращает исключение Python в ответ «безопасных режимов нет». - -## 6. Конфигурация и интерфейсы функций - -### Конфигурационные типы - -| Тип | Содержание | -| --- | --- | -| `RuntimeConfig` | `source_timezone`, задержки и пределы свежести; `horizon_minutes=60`; `seed=42`; `max_candidates=125`; пути к данным/моделям/журналам | -| `ControlSpec` | `signal_id`, `unit`, `lower`, `upper`, `max_step`, `step`, `evidence_ref`, `basis`, `enabled` | -| `ConstraintSpec` | `id`, `metric`, `stage`, `lower`, `upper`, `unit`, `use_upper_estimate`, `required`, `basis`, `evidence_ref` | -| `BlendComponent` | `id`, `sulfur: MetricEstimate`, `available_mass_t`, `cost_proxy_per_t`, `risk_index`, `source_state_id: str \| None` | -| `ScenarioConfig` | `id`, `mode`, обязательные сигналы, controls, constraints, компоненты, `total_mass_t`, текущая рецептура, список активных критериев, пороги существенности улучшения, `require_upper_bound`, `action_cooldown_minutes`, допущения | -| `DecisionContext` | `last_recommended_at: Timestamp \| None`; время последней выданной рекомендации для подавления повторов | - -Численные технологические границы без источника не имеют значения по умолчанию. Тогда `enabled=false`. Активное ограничение без предела, единицы или обоснования — ошибка конфигурации до запуска цикла. Граница серы смеси `upper=10`, `unit='mg/kg'`, `basis='tz'` обязательна для любого сценария товарного смешения. +>>>>>>> 1527107 (Extend UI and ML analysis materials) +| `CandidateAction` | `id: str`, `kind: Literal['hold','setpoints','blend']`, `setpoints: dict[str,float]`, `blend_mass_fractions: dict[str,float]`, `additive_mass_fraction: float`, `horizon_minutes: int`, `is_model_scenario: bool` | +| `MetricEstimate` | `value: float \| None`, `lower: float \| None`, `upper: float \| None`, `unit: str`, `basis: Literal['measured','forecast','formula','proxy']`, `interval_kind: Literal['none','empirical','scenario_bound']`, `interval_level: float \| None`, `reference: str`, `assumptions: list[str]` | +| `AgentAssessment` | `agent: Literal['quality','reliability','optimizer']`, `state_id: str`, `candidate_id: str`, `evaluated_for: Timestamp`, `status: Literal['ok','degraded','unavailable']`, `metrics: dict[str,MetricEstimate]`, `issues: list[Issue]` | +| `ConstraintResult` | `constraint_id: str`, `candidate_id: str`, `status: Literal['pass','fail','unknown']`, `actual: float \| None`, `lower: float \| None`, `upper: float \| None`, `unit: str \| None`, `basis: Literal['tz','confirmed','model_assumption']`, `evidence_ref: str`, `reason_code: str` | +| `CandidateEvaluation` | `candidate: CandidateAction`, `assessments: list[AgentAssessment]`, `checks: list[ConstraintResult]`, `feasible: bool`, `rank_key: tuple[float,float,float,float,str] \| None` | + +Правила: + +- `setpoints` содержит **новые абсолютные значения**, не приращения; единицы определяются `ControlSpec`. Разницу UI вычисляет относительно состояния. +- Для `hold` обе карты пустые: сохраняются текущие уставки, рецептура и доза присадки. Для `blend` задаётся полная рецептура и доза, не только изменения. Сумма долей компонентов и присадки равна 1. Кандидат не совмещает изменение уставок и рецептуры. +- У всех оценок кандидата совпадают `state_id`, `candidate_id` и горизонт. Прогноз вычисляется на `as_of + horizon`; измерение текущего состояния подписывается отдельно. +- `MetricEstimate` с `basis='proxy'` не называется физической величиной без установленного соответствия. Прокси затрат имеют единицу `proxy_unit`, риска — `index_0_1`. +- `interval_kind='scenario_bound'` — заданная граница сценария, не статистическая вероятность. `interval_level` для неё равен `None`. +- `feasible=true` только если все обязательные проверки имеют `pass`. `unknown` не равно успешной проверке. При отсутствии обязательной оценки `rank_key=None`. + +### Итог цикла + +| Тип | Поля | +| --- | --- | +| `Recommendation` | `schema_version: Literal['1.0','1.1']` (новая выдача `1.1`, чтение старых журналов `1.0` сохранено), `run_id: str`, `state_id: str`, `as_of: Timestamp`, `scenario_id: str`, `mode: str`, `status: Literal['recommend','hold','abstain']`, `baseline: CandidateEvaluation \| None`, `selected: CandidateEvaluation \| None`, `alternatives: list[CandidateEvaluation]`, `reason_codes: list[str]`, `explanation: str`, `assumptions: list[str]`, `model_id: str \| None` | +| `RunFailure` | `run_id: str`, `occurred_at: Timestamp`, `error_code: str`, `stage: str`, `message: str` | + +Инварианты: при `recommend` выбран допустимый ненулевой кандидат; при `hold` выбран допустимый кандидат `hold`; при `abstain` `selected=None` и есть причина. `baseline` может быть недопустимым — это исходная точка сравнения, а не разрешённое действие. В `alternatives` идут до трёх допустимых альтернатив, полный список сохраняется в журнале. + +`RunFailure` — техническая ошибка выполнения, не технологический отказ. Система не превращает исключение Python в ответ «безопасных режимов нет». + +## 6. Конфигурация и интерфейсы функций + +### Конфигурационные типы + +| Тип | Содержание | +| --- | --- | +| `RuntimeConfig` | `source_timezone`, задержки и пределы свежести; `horizon_minutes=60`; `seed=42`; `max_candidates=125`; пути к данным/моделям/журналам | +| `ControlSpec` | `signal_id`, `unit`, `lower`, `upper`, `max_step`, `step`, `evidence_ref`, `basis`, `enabled` | +| `ConstraintSpec` | `id`, `metric`, `stage`, `lower`, `upper`, `unit`, `use_upper_estimate`, `use_lower_estimate`, `required`, `basis`, `evidence_ref` | +| `BlendComponent` | `id`, оценки `sulfur`, `t95`, `cetane_number`, `available_mass_t`, `cost_proxy_per_t`, `risk_index`, `source_state_id` | +| `CetaneAdditiveSpec` | `id`, максимум/шаг/запас/стоимость, монотонная таблица `mass_fraction -> cetane_gain`, источник и допущения | +| `ScenarioConfig` | `id`, `mode`, сигналы, controls, constraints, компоненты, присадка, `total_mass_t`, текущие рецептура/доза, критерии, пороги, cooldown и допущения | +| `DecisionContext` | `last_recommended_at: Timestamp \| None`; время последней выданной рекомендации для подавления повторов | + +Численные технологические границы без источника не имеют значения по умолчанию. Тогда `enabled=false`. Активное ограничение без предела, единицы или обоснования — ошибка конфигурации до запуска цикла. В `model_demo` обязательны верхние оценки серы и T95 и нижняя оценка цетанового числа. Сера имеет `upper=10 mg/kg` из ТЗ; T95/CN и их границы явно помечены `model_assumption`. +<<<<<<< HEAD + +Доли считаются равными 1 при абсолютной погрешности не более `1e-9`. Этот допуск используется только для арифметики долей, не для разрешения серы выше 10 мг/кг. Сравнение качества выполняется до округления UI. + +### Вызываемые интерфейсы + +`PreparedData` и `ModelBundle` — обычные локальные dataclass-контейнеры, а не сетевые DTO. `FeatureFrame` — `pandas.DataFrame` с фиксированным порядком признаков. + +```text +prepare_dataset(materials_dir: Path, config: RuntimeConfig) -> PreparedData +build_state(data: PreparedData, as_of: datetime, + scenario: ScenarioConfig, config: RuntimeConfig) -> ProcessState +build_features(data: PreparedData, as_of: datetime, + state: ProcessState, model: ModelBundle) -> FeatureFrame +predict_quality(state: ProcessState, features: FeatureFrame, + model: ModelBundle | None, scenario: ScenarioConfig) -> AgentAssessment +assess_reliability(state: ProcessState, + scenario: ScenarioConfig) -> AgentAssessment +generate_candidates(state: ProcessState, + scenario: ScenarioConfig) -> list[CandidateAction] +evaluate_candidates(state: ProcessState, features: FeatureFrame, + candidates: list[CandidateAction], model: ModelBundle | None, + scenario: ScenarioConfig) -> list[CandidateEvaluation] +check_constraints(state: ProcessState, candidate: CandidateAction, + assessments: list[AgentAssessment], + scenario: ScenarioConfig) -> list[ConstraintResult] +run_cycle(data: PreparedData, as_of: datetime, model: ModelBundle | None, + scenario: ScenarioConfig, config: RuntimeConfig, + context: DecisionContext, run_dir: Path) -> Recommendation +``` + +`predict_quality` и `assess_reliability` оценивают исходное состояние с `candidate_id='hold'`. `effects.py` оценивает действие в отдельном прогнозном состоянии/оценках; исходный объект не изменяется. Данные, рассчитанные моделью, получают соответствующий `basis`, а не `measured`. + +В `model_demo` без ML-модели `build_features` не вызывается: передаётся пустой `FeatureFrame`, а качество считается из компонентов сценария. Baseline на истории оформляется как `ModelBundle` с явным типом predictor; отсутствие необходимой модели не переключает режим автоматически. + +`PreparedData` хранит отсортированную телеметрию, длинную таблицу качества, issues и manifest. `ModelBundle` хранит predictor, порядок признаков, preprocessing, метаданные и при наличии модель последствий. Backend не должен знать внутреннюю структуру sklearn estimator. + +## 7. Один цикл принятия решения + +```mermaid +sequenceDiagram + participant UI as UI или CLI + participant O as Оркестратор + participant D as Данные и признаки + participant Q as Качество + participant R as Надёжность + participant P as Оптимизатор + participant C as Ограничения + participant J as Журнал + UI->>O: run_cycle(t, scenario, model) + O->>D: build_state и build_features только до t + D-->>O: состояние и признаки + O->>Q: predict_quality + Q-->>O: качество, доверие, причины + O->>R: assess_reliability + R-->>O: индекс и факторы + alt Обязательных данных или модели последствий нет + O->>J: оценки и abstain с причиной + else Оценка действий доступна + O->>P: кандидаты, включая hold + loop Каждый кандидат + P->>Q: прогноз качества последствий + P->>R: оценка риска последствий + P->>C: check_constraints + C-->>P: pass, fail или unknown + end + P-->>O: оценки и ранжирование + O->>C: повторная проверка выбранного кандидата + O->>J: входы, все оценки, решение + end + O-->>UI: Recommendation и объяснение +``` + +Порядок внутри `run_cycle`: + +1. Проверить совместимость config/scenario/model и создать `run_id`. +2. Построить состояние и признаки. Сохранить применённые допущения и идентификаторы выбранных наблюдений. +3. Получить оценки качества и надёжности. Если роль недоступна, сохранить доступные результаты остальных. +4. При блокирующих входах вернуть `abstain`; не генерировать видимость оптимизации. +5. Создать `hold` и разрешённые кандидаты. Предварительно отсеять нарушение формата, управления и рецептуры. +6. Рассчитать последствия, затем выполнить полный `check_constraints`. Никакие баллы не используются до этого фильтра. +7. Выбрать лучший допустимый вариант по правилу ниже. При отсутствии вариантов — `abstain`. +8. Повторно проверить выбранный кандидат тем же кодом ограничений. Расхождение результатов — `RunFailure`, а не автоматический переход к другому ответу. +9. Построить объяснение исключительно из сохранённых оценок; записать журнал; вернуть результат UI/CLI. + +### Правило выбора + +Ключ сортировки: `(risk_index, -throughput, cost_proxy, change_size, candidate_id)`, по возрастанию. Последний элемент разрешает равенство. Индексы/затраты сравниваются только внутри одного сценария с одинаковыми определениями. Если критерий не обеспечен данными, его **заранее** отключают в конфигурации для всех кандидатов с нейтральным местом в ключе; это отображается в UI. Пропуск у отдельного кандидата по активному критерию делает его оценку недоступной. + +`change_size` для уставок — сумма `abs(new-current)/step`; для рецептуры — сумма абсолютных изменений массовых долей. + +Если `hold` допустим, изменение выдаётся только при существенном улучшении первого отличающегося активного критерия. Начальные экспериментальные пороги: риск 0.02 абсолютных пункта, выпуск 2%, затраты 2%. При сравнении около нулевой базы используются обязательные абсолютные допуски сценария. Разница только в `change_size` не является улучшением процесса. Иначе возвращается `hold`. + +Если `hold` недопустим, порог экономической существенности не мешает выбрать допустимое действие. Начальный cooldown — 60 минут: он подавляет повторную выдачу изменений только при допустимом `hold`. При нарушении качества система повторно предупреждает и оценивает варианты независимо от cooldown. Совет не означает его выполнение: состояние меняется только по новым данным/явно запущенной симуляции. + +`DecisionContext` относится к одному сценарию и последовательному воспроизведению. При смене сценария или переходе назад по времени он сбрасывается; иначе обновляется только после `recommend`. Контекст сохраняется в журнале и учитывается при сравнении повторных запусков. + +## 8. ML, неопределённость и модель последствий + +### Признаки и обучение + +- Первая цель — сера на выходе гидроочистки через 60 минут. Эта точка не отождествляется автоматически с товарной смесью. +- Телеметрические лаги первой версии: 10, 30, 60 и 180 минут; прошлые средние и стандартные отклонения в окнах 60 и 180 минут. Окно заканчивается на `as_of`, будущие значения не участвуют. +- Исходные признаки берутся только из подтверждённого словаря. Сера анализаторов и её лаги явно маркируются как autoregressive-признаки; тот же будущий целевой анализ не может попасть в входы. +- Признаки, отсутствующие во всём обучающем периоде, исключаются. Появление ПАК-плотности в 2025 году не позволяет использовать её как обученный признак модели 2023–2024 без отдельного эксперимента. +- Imputer/scaler и отбор признаков обучаются только на train и сохраняются вместе с моделью. Для Ridge — медианная импутация и масштабирование; для HGB — фиксированная обработка пропусков. Строки без цели не становятся обучающими примерами. +- Для ПАК цель выбирается на сетке `t+60 минут`; без точного допустимого наблюдения пример исключается; цель не переносится из будущего. Для ЛИМС создаётся один пример на реальный анализ: `as_of = measured_at - 60 минут`. +- ПАК и ЛИМС не объединяются в одну безымянную цель. Модель ПАК оценивается по ПАК и отдельно по контрольным ЛИМС; перенос источника отражается в отчёте. +- Начальное разделение: train до 2025-01-01; validation до 2026-01-01; test с 2026-01-01 по конец доступной телеметрии. Границы задаются в исходном часовом поясе и преобразуются в UTC. +- Внутри train — три последовательных временных блока для подбора. Цели, пересекающие границу следующего блока, исключаются. Внутреннее early stopping у HGB отключается (`early_stopping=False`); число итераций выбирается временной валидацией, без автоматической внутренней проверочной выборки ([описание параметров](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html)). +- Метрики: MAE, MAE в области 8–12 мг/кг, пропущенные превышения 10 мг/кг, ложные тревоги; для каждого показателя число примеров. Если превышений нет, соответствующая доля — `null` с пояснением. +- Выбор модели выполняется до test: минимизировать MAE при числе пропущенных превышений не больше baseline; при равенстве оставить более простую модель. Если подходящих моделей нет — baseline сохраняется, ограничение качества указывается в отчёте. + +### Неопределённость + +До этапа 5 прогноз без интервала имеет `interval_kind='none'`. Он не удовлетворяет сценарию с `require_upper_bound=true`. Первая сквозная демонстрация использует явные границы модельных компонентов, поэтому не зависит от готовности статистических интервалов. + +На этапе 5: верхняя квантильная оценка 0.95; первая половина validation по времени — выбор настроек, вторая — проверка покрытия/калибровка. Сдвиг границы равен `max(0, quantile_0.95(y - upper_raw))` на калибровочной части. Финальный test не участвует в настройке. Реальное покрытие и средняя ширина интервала публикуются; 0.95 — номинальный уровень, не гарантия безопасности при сдвиге режима. + +Без требуемой верхней оценки кандидат получает `unknown` по ограничению серы. Обычная ошибка модели не трактуется как вероятность отказа оборудования. + +### Модель последствий + +`effects.py` имеет два явных пути: формулы модельного смешения и подтверждённая модель изменений уставок. Это обычное ветвление по режиму/возможностям, без реестра плагинов. + +Для `supports_actions=true` по реальным уставкам нужны: подтверждённый смысл и управляемость, описанные пределы шага, проверка совместной области применимости, отдельная временная оценка на эпизодах изменений, описанные задержки и ограничения причинной интерпретации. ML сохраняет ссылку на этот отчёт в metadata. До этого реальные controls отключены. + +======= Доли считаются равными 1 при абсолютной погрешности не более `1e-9`. Этот допуск используется только для арифметики долей, не для разрешения серы выше 10 мг/кг. Сравнение качества выполняется до округления UI. @@ -356,7 +655,15 @@ sequenceDiagram `DecisionContext` относится к одному сценарию и последовательному воспроизведению. При смене сценария или переходе назад по времени он сбрасывается; иначе обновляется только после `recommend`. Контекст сохраняется в журнале и учитывается при сравнении повторных запусков. -## 8. ML, неопределённость и модель последствий +## 8. ML, неопределённость и модель последствий + +Словарь 24-2000 и формулы ВАК версии организаторов от 15.09.2026 сохранены в +`materials/теги АВТ_24-2000.xlsx` и `materials/формулы_ВАК.xlsx`. Они +заменяют прежнюю спорную семантику: P8 — перепад давления Р-202, F19 — расход +бензина в К-201, T11 — температура продукта на выходе Р-202. Q21 — сера на +выходе гидроочистки (ppm); код 307 — подтверждённый выброс и маскируется +явным правилом `config/telemetry_rules.json`. Ни один из этих фактов сам по +себе не включает управление уставками. ### Признаки и обучение @@ -370,70 +677,169 @@ sequenceDiagram - Начальное разделение: train до 2025-01-01; validation до 2026-01-01; test с 2026-01-01 по конец доступной телеметрии. Границы задаются в исходном часовом поясе и преобразуются в UTC. - Внутри train — три последовательных временных блока для подбора. Цели, пересекающие границу следующего блока, исключаются. Внутреннее early stopping у HGB отключается (`early_stopping=False`); число итераций выбирается временной валидацией, без автоматической внутренней проверочной выборки ([описание параметров](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html)). - Метрики: MAE, MAE в области 8–12 мг/кг, пропущенные превышения 10 мг/кг, ложные тревоги; для каждого показателя число примеров. Если превышений нет, соответствующая доля — `null` с пояснением. -- Выбор модели выполняется до test: минимизировать MAE при числе пропущенных превышений не больше baseline; при равенстве оставить более простую модель. Если подходящих моделей нет — baseline сохраняется, ограничение качества указывается в отчёте. +- Выбор модели выполняется до test: минимизировать MAE при числе пропущенных превышений не больше baseline; при равенстве оставить более простую модель. Если подходящих моделей нет — baseline сохраняется, ограничение качества указывается в отчёте. + +Схемы АВТ от 14.09 разрешают исследовать не все `avt:*` сразу, а три заранее +определённые группы К-2: состояние/нагрузка, циркуляционные орошения и дизельная +ветвь. Это только отдельная train-only ablation после PAK-only baseline. Группа +не попадает в production feature list без устойчивого выигрыша event-FNR при +FPR не выше 20%, проверки missingness/OOD и подтверждения межустановочного +маршрута. Audit-2026 не используется для её выбора. -### Неопределённость +### Неопределённость До этапа 5 прогноз без интервала имеет `interval_kind='none'`. Он не удовлетворяет сценарию с `require_upper_bound=true`. Первая сквозная демонстрация использует явные границы модельных компонентов, поэтому не зависит от готовности статистических интервалов. На этапе 5: верхняя квантильная оценка 0.95; первая половина validation по времени — выбор настроек, вторая — проверка покрытия/калибровка. Сдвиг границы равен `max(0, quantile_0.95(y - upper_raw))` на калибровочной части. Финальный test не участвует в настройке. Реальное покрытие и средняя ширина интервала публикуются; 0.95 — номинальный уровень, не гарантия безопасности при сдвиге режима. -Без требуемой верхней оценки кандидат получает `unknown` по ограничению серы. Обычная ошибка модели не трактуется как вероятность отказа оборудования. +Без требуемой верхней оценки кандидат получает `unknown` по ограничению серы. Обычная ошибка модели не трактуется как вероятность отказа оборудования. + +### Safety-first alarm и совместная применимость + +Stage 7 не заменяет численный прогноз бинарной классификацией. Artifact schema 1.1 +объединяет point, upper и отдельную калиброванную вероятность `S(t+60) > 10 mg/kg`. +Классификатор обучается с весами положительного класса из фиксированной сетки на +purged temporal folds. Первая половина validation калибрует вероятность, вторая выбирает +порог с минимальным числом пропусков при `false-positive rate <= 20%`. Test не участвует +в выборе. Продвижение запрещено, если validation `false-negative rate > 10%`. + +Обязательный autoregressive sulfur-признак проверяется жёстко. Остальные признаки после +train-only median imputation и scaling проверяются совместно в whitened PCA-пространстве; +порог квадрата расстояния равен train-квантилю 0.99. Это заменяет пересечение множества +одномерных квантильных диапазонов, но не превращает статистическую область в инженерные +пределы. Старые artifacts schema 1.0 продолжают загружаться без вероятности риска. ### Модель последствий `effects.py` имеет два явных пути: формулы модельного смешения и подтверждённая модель изменений уставок. Это обычное ветвление по режиму/возможностям, без реестра плагинов. -Для `supports_actions=true` по реальным уставкам нужны: подтверждённый смысл и управляемость, описанные пределы шага, проверка совместной области применимости, отдельная временная оценка на эпизодах изменений, описанные задержки и ограничения причинной интерпретации. ML сохраняет ссылку на этот отчёт в metadata. До этого реальные controls отключены. - -За пределами области применимости модель не экстраполирует. Результат — `unavailable` с причиной. Сравнение прогнозов на истории не выдаётся за доказанный производственный эффект вмешательства. - -### Надёжность - -Без разметки отказов используется индекс тяжести режима, а не модель вероятности аварии. Для подтверждённых факторов ML задаёт нормальную область, неблагоприятное направление и модельную границу. Вклад фактора растёт линейно от 0 на краю нормальной области до 1 на модельной границе; итог — максимум вкладов. Отдельные жёсткие пределы проверяются независимо от индекса. - -Области, оценённые на истории, вычисляются только по train и помечаются `model_assumption`. Нет подтверждённых факторов — оценка надёжности `unavailable`; нулевой риск не подставляется. В чистом модельном сценарии разрешён явно заданный индекс с `basis='proxy'`. - -### Проверка качества и жизненный цикл ML - -Пользователь — оператор. ML предупреждает о риске качества; допустимость совета проверяется детерминированно. Пропущенное превышение опаснее ложной тревоги; стоимость ошибки в рублях без экономических данных не оценивается. - -| Аспект | Решение | -| --- | --- | -| Функции потерь | Ridge — сумма квадратов ошибок с L2-регуляризацией; точечный HGB — `squared_error`; верхняя граница — квантильная потеря уровня 0.95. Минимум обучающей потери не заменяет критерий выбора модели выше. | -| Определения метрик | MAE — среднее `abs(y-y_hat)`; область 8–12 мг/кг определяется фактическим `y`. Для точечного прогноза тревога — `y_hat > 10`; доля пропусков — `FN/(TP+FN)`, ложных тревог — `FP/(FP+TN)`. Нулевой знаменатель даёт `null`. Решения по верхней границе оцениваются отдельно. | -| Честное сравнение | Baseline и кандидат сравниваются на одних timestamp и одном источнике цели. Отдельно указываются исключённые примеры, недоступные прогнозы и покрытие: число прогнозов / число пригодных целей. Отказы не считаются правильными прогнозами. Результаты ПАК и ЛИМС не усредняются между собой. | -| Разбор ошибок | `ml/evaluate.py` сохраняет остатки и ошибки по месяцам, источнику цели, возрасту анализа, области около порога и наличию пропусков. Для каждого среза — размер; отдельно разобрать крупнейшие недооценки, пропущенные превышения и возможное насыщение ПАК. Это гипотезы для проверки, не основания автоматически удалить выбросы. | -| Эксперименты с признаками | Сравнить baseline, только прошлую серу и её лаги, затем добавленную телеметрию на одинаковом временном разбиении. ML фиксирует, улучшает ли телеметрия прогноз относительно авторегрессии; корреляция не доказывает эффект управления. | -| Замена модели | ML передаёт проверенный каталог модели и отчёт; backend проверяет совместимость, три сценария и общую историческую точку. Новая модель выбирается явно в CLI/UI, предыдущая сохраняется. После ошибки — явный возврат к совместимой предыдущей версии или baseline; если такой версии нет, действуют существующие отказ/RunFailure. | -| Контроль после замены | Backend проверяет ошибки, задержку и отсутствие обязательных входов при каждом запуске; применяются существующие временные пределы и бюджет цикла. ML при поступлении новых ЛИМС пересчитывает метрики на общем с baseline наборе и разбирает недооценки/покрытие. Схемная несовместимость блокирует запуск; потеря критерия выбора модели блокирует её дальнейшее продвижение и запускает разбор. Автоматического переобучения нет. | -| Граница доказательств | История подтверждает точность прогноза, модельный replay — эффект внутри модели. Реальная экономия и полезность оператору потребуют отдельного пилота с технологом; хакатонный прототип их не доказывает. Обучение новой версии запускается явно после разбора ошибок, с новым `model_id` и временным тестом. | - -Верхняя граница проверяется на отложенном test; оценка покрытия на калибровочных данных не считается независимой. Для этапа 5 выбор настроек ограничен первой половиной validation, вторая используется только для калибровки; обе части сохраняются в metadata. Финальный test не используется для исправления выбранной версии. - -## 9. Блендинг и фиксированный демонстрационный пример - -Первая модель смешивает два компонента по массе. Сера смеси `S = Σ(w_i × S_i)`, верхняя граница `S_upper = Σ(w_i × S_upper_i)` при неотрицательных долях. Это консервативная операция над границами, а не утверждение о совместном статистическом покрытии. - -Для каждого компонента `w_i × total_mass_t <= available_mass_t`. Дозировка присадок, плотность смеси и низкотемпературные свойства не моделируются без отдельного обоснования. Активный профиль ограничений перечисляет, что проверено; формулировка «полностью соответствует товарному стандарту» запрещена, если проверена только сера. - -### Параметры синтетического примера - -Все числа таблицы — **заданные параметры демонстрации**, не оценка заводских данных. - -| Параметр | Компонент A | Компонент B | -| --- | --- | --- | -| Сера, мг/кг | 6 | 30 | -| Верхняя граница серы, мг/кг | 7 | 31 | -| Доступная масса, т | 100 | 30 | -| Стоимостной прокси на тонну | 1.0 | 0.8 | -| Индекс риска сценария | 0 | 0 | - -Партия — 100 т; горизонт — 60 минут. Выпуск модели — масса партии за горизонт, одинаковая для всех рецептур. Критерий риска — взвешенный индекс компонентов. Генерируются доли B от 0 до 1 с шагом 0.05; доля A равна `1-B`. Проверки доступности и качества отбрасывают неподходящие варианты. Добавляется отдельный `hold`, дубликаты рецептуры схлопываются в пользу `hold`. - -- Model-demo рецептуры рассчитывают серу, сценарные `T95`, сценарное цетановое число - и запас компонентов. Эти `T95`/цетановые значения нужны только для демонстрации - `hold`/`recommend`/`abstain`; они не являются промышленным паспортом продукта. +Для `supports_actions=true` по реальным уставкам нужен отдельный production +`artifact_kind=action_effect`: подтверждённый смысл и управляемость, описанные +пределы шага, проверка совместной области применимости, отдельная временная +оценка на эпизодах изменений, описанные задержки и ограничения причинной +интерпретации. ML сохраняет ссылки и hashes gate-отчётов в metadata. Обычный +forecast artifact не может сам заявить action support; backend объединяет forecast +и action artifact только после проверки dataset/config/tag/rules hashes и всех gates. +До этого реальные controls отключены, а `history` возвращает `ACTION_MODEL_UNAVAILABLE`. + +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) +Эксперт подтвердил наличие обратной связи на АВТ и 24-2000 (Q&A 11.09, 00:16:15). +Поэтому рост температуры после роста серы может отражать реакцию управления: +историческая зависимость не задаёт причинный коэффициент. Числа из вопросов +участников, включая 0.03 мг/кг на градус и отклик около часа, не подтверждены. +Для action model ML отдельно обосновывает отделение воздействия от реакции +управления; при недостаточных данных способность к рекомендациям не включается. + +За пределами области применимости модель не экстраполирует. Результат — `unavailable` с причиной. Сравнение прогнозов на истории не выдаётся за доказанный производственный эффект вмешательства. + +### Надёжность + +Без разметки отказов используется индекс тяжести режима, а не модель вероятности аварии. Для подтверждённых факторов ML задаёт нормальную область, неблагоприятное направление и модельную границу. Вклад фактора растёт линейно от 0 на краю нормальной области до 1 на модельной границе; итог — максимум вкладов. Отдельные жёсткие пределы проверяются независимо от индекса. + +Области, оценённые на истории, вычисляются только по train и помечаются `model_assumption`. Нет подтверждённых факторов — оценка надёжности `unavailable`; нулевой риск не подставляется. В чистом модельном сценарии разрешён явно заданный индекс с `basis='proxy'`. + +### Проверка качества и жизненный цикл ML + +Пользователь — оператор. ML предупреждает о риске качества; допустимость совета проверяется детерминированно. Пропущенное превышение опаснее ложной тревоги; стоимость ошибки в рублях без экономических данных не оценивается. + +| Аспект | Решение | +| --- | --- | +| Функции потерь | Ridge — сумма квадратов ошибок с L2-регуляризацией; точечный HGB — `squared_error`; верхняя граница — квантильная потеря уровня 0.95. Минимум обучающей потери не заменяет критерий выбора модели выше. | +| Определения метрик | MAE — среднее `abs(y-y_hat)`; область 8–12 мг/кг определяется фактическим `y`. Для точечного прогноза тревога — `y_hat > 10`; доля пропусков — `FN/(TP+FN)`, ложных тревог — `FP/(FP+TN)`. Нулевой знаменатель даёт `null`. Решения по верхней границе оцениваются отдельно. | +| Честное сравнение | Baseline и кандидат сравниваются на одних timestamp и одном источнике цели. Отдельно указываются исключённые примеры, недоступные прогнозы и покрытие: число прогнозов / число пригодных целей. Отказы не считаются правильными прогнозами. Результаты ПАК и ЛИМС не усредняются между собой. | +| Разбор ошибок | `ml/evaluate.py` сохраняет остатки и ошибки по месяцам, источнику цели, возрасту анализа, области около порога и наличию пропусков. Для каждого среза — размер; отдельно разобрать крупнейшие недооценки, пропущенные превышения и возможное насыщение ПАК. Это гипотезы для проверки, не основания автоматически удалить выбросы. | +| Эксперименты с признаками | Сравнить baseline, только прошлую серу и её лаги, затем добавленную телеметрию на одинаковом временном разбиении. ML фиксирует, улучшает ли телеметрия прогноз относительно авторегрессии; корреляция не доказывает эффект управления. | +| Замена модели | ML передаёт проверенный каталог модели и отчёт; backend проверяет совместимость, три сценария и общую историческую точку. Новая модель выбирается явно в CLI/UI, предыдущая сохраняется. После ошибки — явный возврат к совместимой предыдущей версии или baseline; если такой версии нет, действуют существующие отказ/RunFailure. | +| Контроль после замены | Backend проверяет ошибки, задержку и отсутствие обязательных входов при каждом запуске; применяются существующие временные пределы и бюджет цикла. ML при поступлении новых ЛИМС пересчитывает метрики на общем с baseline наборе и разбирает недооценки/покрытие. Схемная несовместимость блокирует запуск; потеря критерия выбора модели блокирует её дальнейшее продвижение и запускает разбор. Автоматического переобучения нет. | +| Граница доказательств | История подтверждает точность прогноза, модельный replay — эффект внутри модели. Реальная экономия и полезность оператору потребуют отдельного пилота с технологом; хакатонный прототип их не доказывает. Обучение новой версии запускается явно после разбора ошибок, с новым `model_id` и временным тестом. | + +Верхняя граница проверяется на отложенном test; оценка покрытия на калибровочных данных не считается независимой. Для этапа 5 выбор настроек ограничен первой половиной validation, вторая используется только для калибровки; обе части сохраняются в metadata. Финальный test не используется для исправления выбранной версии. + +## 9. Блендинг и фиксированный демонстрационный пример + +Модель смешивает два дизельных компонента и цетаноповышающую присадку по массе. Сера смеси `S = Σ(w_i × S_i)`, верхняя граница `S_upper = Σ(w_i × S_upper_i)` при неотрицательных долях. Сумма долей дизельных компонентов и присадки равна 1. Это консервативная операция над заданными границами, а не утверждение о статистическом покрытии. + +T95 и базовое цетановое число считаются линейно по нормированным долям дизельных компонентов. Это **явное модельное приближение**, а не физический закон. Присадка считается не влияющей на S/T95 и добавляет к CN величину из монотонной кусочно-линейной таблицы сценария. Доза ограничена 3%; стоимость на тонну равна 100 стоимостным единицам при стоимости ДТ около 1. Сама кривая эффективности синтетическая и изменяемая. + +Для каждого компонента и присадки проверяется `fraction × total_mass_t <= available_mass_t`. Кандидат допустим только при `S_upper <= 10 mg/kg`, `T95_upper <= 360 °C` и `CN_lower >= 51` в текущем синтетическом профиле. Последние две границы — параметры модельного сценария, не универсальная промышленная спецификация. Формулировка «полностью соответствует товарному стандарту» запрещена: система доказывает лишь соответствие объявленной модели. + +### Параметры синтетического примера + +Все числа таблицы — **заданные параметры демонстрации**, не оценка заводских данных. + +| Параметр | Компонент A | Компонент B | +| --- | --- | --- | +| Сера, мг/кг | 6 | 30 | +| Верхняя граница серы, мг/кг | 7 | 31 | +| T95 / верхняя граница, °C | 350 / 352 | 370 / 372 | +| Цетановое / нижняя граница | 50.5 / 50 | 46.5 / 46 | +| Доступная масса, т | 100 | 30 | +| Стоимостной прокси на тонну | 1.0 | 0.8 | +| Индекс риска сценария | 0 | 0 | + +Партия — 100 т; горизонт — 60 минут. Генерируются доли B от 0 до 1 с шагом 0.05 и дозы присадки 0–3% с шагом 1%; доли A/B масштабируются на оставшуюся массу. Всего не более 84 кандидатов с `hold`. Сначала проверяются паспорт и запасы, затем допустимые варианты ранжируются по риску, выпуску, стоимости и размеру изменения. + +`blend_normal` возвращает `hold`; `blend_risk`, `blend_t95_risk` и `blend_cetane_risk` возвращают изменяемые рекомендации; `blend_missing` возвращает `abstain`. Каждый положительный результат проходит все три обязательные границы и явно подписан как результат синтетической модели. +<<<<<<< HEAD + +Для уставок после их подтверждения — до трёх параметров, до пяти значений каждого вокруг текущего режима, не больше 125 комбинаций плюс `hold`. При превышении бюджета запуск останавливается с понятной ошибкой, не обрезает пространство скрыто. Реальные шаги и границы фиксируются после анализа источников, без выдуманных температур и давлений в этом документе. + +В `hybrid` качество компонента A заменяется прогнозом гидроочистки с его доверительной границей; остальные свойства компонентов и рецепт остаются модельными. История АВТ используется как входной контекст модели гидроочистки только при подтверждённом соответствии потоков и задержек. Если связь не установлена, UI показывает доступный контекст АВТ отдельно и не рисует доказанный перенос влияния. + +## 10. Ошибки, отказ и журнал + +### Коды причин + +| Код | Поведение | +| --- | --- | +| `MISSING_REQUIRED_SIGNAL`, `STALE_REQUIRED_SIGNAL` | `abstain`, список входов и их возраст | +| `SOURCE_CONFLICT`, `UNIT_UNCONFIRMED`, `TAG_UNCONFIRMED` | Блокирование зависящего расчёта; при обязательности — `abstain` | +| `UNIT_MISMATCH`, `UNASSESSED_REQUIRED_PROPERTY` | `unknown`/`abstain`; численное сравнение или операторское действие запрещено | +| `ACTION_MODEL_UNAVAILABLE`, `OUT_OF_DOMAIN` | Прогноз состояния можно показать, рекомендация изменения блокируется | +| `NON_FINITE_ACTION_FORECAST`, `ACTION_EFFECT_METRICS_NON_FINITE` | Action-кандидат или evidence отклоняется fail-closed | +| `QUALITY_LIMIT`, `CONTROL_LIMIT`, `BLEND_SUM`, `COMPONENT_STOCK` | Кандидат отклоняется с actual/limit | +| `UNCERTAINTY_UNAVAILABLE`, `RELIABILITY_UNAVAILABLE` | `unknown` по обязательной проверке | +| `NO_FEASIBLE_CANDIDATE` | `abstain` и сводка причин отбраковки | +| `NO_MATERIAL_IMPROVEMENT`, `ACTION_COOLDOWN` | `hold` только если исходный вариант прошёл ограничения | +| `MODEL_INCOMPATIBLE`, `CONFIG_INVALID`, `INPUT_CORRUPT` | Технический `RunFailure`, CLI exit code 1 | +| `AGENT_ERROR`, `JOURNAL_WRITE_FAILED` | Технический `RunFailure`; решение не отображается как успешно завершённое | + +Необязательные предупреждения не останавливают весь запуск. При отказе одного кандидата остальные проверяются; при неожиданном исключении агента цикл останавливается. + +### Содержимое запуска + +```text +runs// +├── metadata.json # git SHA, dataset/model/scenario/config hashes, версии +├── input.json # ProcessState, ScenarioConfig, DecisionContext +├── features.json # фактические значения и порядок признаков +├── trace.jsonl # вызовы ролей, результаты, причины, длительности +├── candidates.jsonl # все кандидаты, оценки и проверки +└── result.json # Recommendation либо RunFailure +``` + +`run_id` — UUID; `state_id` — хеш нормализованного состояния. Время и UUID исключены из критерия численной воспроизводимости. На одинаковых входах, версиях и `DecisionContext` совпадают оценки, выбранный кандидат, статусы и причины. + +Итоговые JSON записываются через временный файл и переименование в пределах каталога запуска. Если журнал не записался, UI сообщает об ошибке сохранения; успешный завершённый запуск не заявляется. Логи не содержат credentials и не отправляются во внешние сервисы. + +## 11. Артефакты модели + +```text +artifacts/models// +├── model.joblib # predictor и preprocessing +├── metadata.json # версии и совместимость +└── metrics.json # baseline, validation и ограничения +``` + +Joblib используется из стека scikit-learn. Загружаются только локально созданные доверенные артефакты; UI не принимает произвольные пользовательские файлы моделей. + +Metadata обязательно содержит: `model_id`, `schema_version`, `model_sha256`, `training_dataset_id`, `git_commit`, версии Python/sklearn, `target_signal`, `target_source`, `target_unit`, `horizon_minutes`, упорядоченные `feature_names`, хеш словаря, `feature_schema_hash`, параметры обработки/времени, границы train/validation/calibration, `seed`, capabilities, ограничения применимости и ссылки на отчёты. + +Режим применения проверяет версии сериализации, схему признаков, единицы, горизонт, словарь и параметры подготовки. `training_dataset_id` сохраняется для происхождения, но не обязан совпадать с набором применения: оценка на другом периоде допустима при совместимой схеме. Нельзя переобучать модель автоматически при несовместимости. Если baseline нужен без обученной модели, он выбирается явно в сценарии и подписывается как baseline. + +## 12. Интерфейс и команды запуска + +======= Для уставок после их подтверждения — до трёх параметров, до пяти значений каждого вокруг текущего режима, не больше 125 комбинаций плюс `hold`. При превышении бюджета запуск останавливается с понятной ошибкой, не обрезает пространство скрыто. Реальные шаги и границы фиксируются после анализа источников, без выдуманных температур и давлений в этом документе. @@ -486,82 +892,194 @@ artifacts/models// Joblib используется из стека scikit-learn. Загружаются только локально созданные доверенные артефакты; UI не принимает произвольные пользовательские файлы моделей. -Metadata обязательно содержит: `model_id`, `schema_version`, `model_sha256`, `training_dataset_id`, `git_commit`, версии Python/sklearn, `target_signal`, `target_source`, `target_unit`, `horizon_minutes`, упорядоченные `feature_names`, хеш словаря, `feature_schema_hash`, параметры обработки/времени, границы train/validation/calibration, `seed`, capabilities, ограничения применимости и ссылки на отчёты. +Metadata обязательно содержит: `model_id`, `schema_version`, `model_sha256`, `training_dataset_id`, `git_commit`, версии Python/sklearn, `target_signal`, `target_source`, `target_unit`, `horizon_minutes`, упорядоченные `feature_names`, хеш словаря, `feature_schema_hash`, параметры обработки/времени, границы train/validation/calibration, `seed`, capabilities, ограничения применимости и ссылки на отчёты. Для safety/v2-артефактов отдельно сохраняются `alarm_threshold`, `false_alarm_budget`, схема калибровки, раздельные метрики PAK/ЛИМС и описание OOD-порога. Режим применения проверяет версии сериализации, схему признаков, единицы, горизонт, словарь и параметры подготовки. `training_dataset_id` сохраняется для происхождения, но не обязан совпадать с набором применения: оценка на другом периоде допустима при совместимой схеме. Нельзя переобучать модель автоматически при несовместимости. Если baseline нужен без обученной модели, он выбирается явно в сценарии и подписывается как baseline. ## 12. Интерфейс и команды запуска +>>>>>>> 1527107 (Extend UI and ML analysis materials) ### Текущий desktop UI и целевой экран -Сейчас `source/ui.py` — локальное Tkinter-приложение. Оно запускает `model_demo`, -отображает checks и журнал, вызывает `prepare`/`build-state` как диагностические -операции и имеет отдельный экран trusted history forecast. History forecast загружает -совместимый `ModelBundle`, но не включает реальные actions. +Сейчас `source/ui.py` — локальное Tkinter-приложение со stage-aware навигацией: +`overview`, `avt`, `hydrotreating`, `blend`, `history`, `journal` и legacy alias +`recommendation -> blend`. Оно запускает `model_demo`, отображает S/T95/CN, дозу +присадки, checks и журнал, а также вызывает `prepare`, `build-state`, historical +replay доверенного `ModelBundle`, schema-1.2 multi-horizon shadow replay, P8/F19 +action-shadow и контроль ПАК--ЛИМС. Логика read-only проекций вынесена в +`source/ui_data.py`, поэтому АВТ/гидроочистка/history проверяются без открытия Tk. + +Вкладки АВТ и гидроочистки показывают подготовленные сигналы с единицей, источником, +возрастом, freshness и read-only причиной. Вкладка смеси содержит synthetic +model-demo и hybrid-панель: компонент A подставляется только из history forecast, +иначе UI показывает недоступность прогноза. Вкладка `История/ML` выполняет replay, +показывает point/upper серы, status, issues, reason codes и путь к journal. +Actionable setpoints появляются только при verified action artifact; без него это +read-only forecast/`не совет`. Целевой экран должен поддерживать: - -1. Выбор режима, сценария, времени истории и совместимой модели; кнопка «Рассчитать». -2. Состояние: измерения, единицы, источник и возраст; отдельные блоки АВТ, гидроочистки и смеси. -3. Качество и риск: факт, прогноз, граница, применимость и конкретные предупреждения. -4. Итог: статус, текущее → рекомендуемое значение, ожидаемый эффект, проверенные ограничения. -5. Альтернативы и полный след взаимодействия агентов; возможность скачать результат. - + +1. Выбор режима, сценария, времени истории и совместимой модели; кнопка «Рассчитать». +2. Состояние: измерения, единицы, источник и возраст; отдельные блоки АВТ, гидроочистки и смеси. +3. Качество и риск: факт, прогноз, граница, применимость и конкретные предупреждения. +4. Итог: статус, текущее → рекомендуемое значение, ожидаемый эффект, проверенные ограничения. +5. Альтернативы и полный след взаимодействия агентов; возможность скачать результат. + Расчёт запускается только явным действием пользователя; UI не меняет значения, выбранные оптимизатором, и не округляет числа перед проверками. При будущей загрузке моделей prepared data и artifacts кэшируются только по идентификаторам и хешам. - -Модельный режим и неполнота проверенной спецификации всегда видны рядом с рекомендацией. Экран ошибки выполнения отличается от технологического отказа. - + +Модельный режим и граница применимости синтетического паспорта всегда видны рядом с рекомендацией. Экран ошибки выполнения отличается от технологического отказа. + ### Реализованные и целевые команды - + Команды выполняются из корня репозитория после активации совместимого окружения. -Сейчас реализованы `validate-stage0`, `run-model-demo`, `prepare`, `build-state`, -`train`, `evaluate`, `run-history` и `python -m source.ui`. `replay` остаётся -целевой командой; `run-history` закрывает один forecast-cycle. - -```bash -python -m pip install -r requirements.txt -git lfs pull +Реализованы `validate-stage0`, `run-model-demo`/`demo`, `prepare`, `build-state`, +`train`, `evaluate`, `replay`, `acceptance`, `verify-model-freeze`, `export-journal` +и `python -m source.ui`. + +```bash +python -m pip install -r requirements.txt +git lfs pull python -m source.main prepare --materials materials --config config/runtime.toml -python -m source.main train --dataset data/processed/ --target-source pak +python -m source.main train --dataset data/processed/ --config config/runtime.toml python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --split test -python -m source.main run-history --dataset data/processed/ --model artifacts/models/ --trusted-model --as-of 2026-01-15T12:00:00+03:00 -python -m source.main run-model-demo blend_risk +python -m source.main replay --dataset data/processed/ --model artifacts/models/ --scenario config/scenarios/history.json --at 2026-01-15T12:00:00+03:00 +python -m source.main demo blend_risk python -m source.ui python -m pytest -python -m ruff check . -python -m ruff format --check . -``` - +python -m ruff check . +python -m ruff format --check . +``` + `prepare/train/evaluate` печатают путь созданного результата; `run-model-demo` и `run-history` — итог и путь журнала. `abstain` — успешный расчёт: exit code 0. Ошибки данных/конфигурации/кода — exit code 1. CLI строится на `argparse`; параметры и конфигурация разрешаются один раз в `main.py`. В UI используется тот же загрузчик конфигурации и те же функции. - -## 13. Проверки и критерии приёмки - -CI работает на малых синтетических fixtures без загрузки 100 МБ LFS и без полного обучения. Полная оценка истории запускается отдельно перед сдачей. Вместо `assert True` нужен сквозной `model_demo`. - -| Проверка | Что доказывает | Владелец | -| --- | --- | --- | -| Разные даты в соседних парах Excel | Отсутствие объединения по номеру строки | Backend | -| Дубликаты, `Pt Created`, неизвестная единица | Нет тихих численных подстановок | Backend | -| Проба взята до t, опубликована после t | Будущий лабораторный факт не попал в состояние/признаки | Backend + ML | -| Признаки для обучения и запуска на одном t | Совпадают имена, порядок и значения | ML | -| Граница train/test и вычисление статистик | Ни цели, ни preprocessing не используют будущие данные | ML | -| Сера 10, 10.0001 и верхняя граница выше 10 | Порог проверяется до округления; граница используется по настройке | Backend | -| Неверные доли, запас и неподтверждённая уставка | Недопустимый кандидат не получает итоговую рекомендацию | Backend | -| Пропуск оценки при активном критерии | `unknown` не превращается в допустимость или нулевой риск | Backend + ML | -| Очень выгодный, но недопустимый вариант | Экономика не отменяет жёсткое ограничение | Backend | -| Отсутствует action capability | Нельзя менять уставки через обычный прогноз | ML + Backend | -| Три фиксированных сценария раздела 9 | `hold`, `recommend`, `abstain` с известными численными результатами | Оба | -| Исключение агента и ошибка записи | Техническая ошибка не замаскирована технологическим отказом | Backend | -| Повтор запуска | Совпадают решение и численные оценки, кроме служебных ID/времени | Оба | - -Перед финальной сдачей: запустить временную оценку на реальных данных, проверить минимум нормальный эпизод, риск качества и неполные/аномальные данные; дополнительно показать полный агентный цикл модельной оптимизации. Искусственно повреждённый эпизод явно подписать. В отчёте отдельно указать ошибки прогноза на истории и условный эффект в модельной среде. - + +## 13. Проверки и критерии приёмки + +CI работает на малых синтетических fixtures без загрузки 100 МБ LFS и без полного обучения. Полная оценка истории запускается отдельно перед сдачей. Вместо `assert True` нужен сквозной `model_demo`. + +| Проверка | Что доказывает | Владелец | +| --- | --- | --- | +| Разные даты в соседних парах Excel | Отсутствие объединения по номеру строки | Backend | +| Дубликаты, `Pt Created`, неизвестная единица | Нет тихих численных подстановок | Backend | +| Проба взята до t, опубликована после t | Будущий лабораторный факт не попал в состояние/признаки | Backend + ML | +| Признаки для обучения и запуска на одном t | Совпадают имена, порядок и значения | ML | +| Граница train/test и вычисление статистик | Ни цели, ни preprocessing не используют будущие данные | ML | +| Сера 10, 10.0001 и верхняя граница выше 10 | Порог проверяется до округления; граница используется по настройке | Backend | +| Неверные доли, запас и неподтверждённая уставка | Недопустимый кандидат не получает итоговую рекомендацию | Backend | +| Пропуск оценки при активном критерии | `unknown` не превращается в допустимость или нулевой риск | Backend + ML | +| Очень выгодный, но недопустимый вариант | Экономика не отменяет жёсткое ограничение | Backend | +| Отсутствует action capability | Нельзя менять уставки через обычный прогноз | ML + Backend | +| Пять фиксированных сценариев раздела 9 | S/T95/CN, присадка, `hold`, `recommend`, `abstain` с известными числами | Оба | +| Исключение агента и ошибка записи | Техническая ошибка не замаскирована технологическим отказом | Backend | +| Повтор запуска | Совпадают решение и численные оценки, кроме служебных ID/времени | Оба | + +Перед финальной сдачей: запустить временную оценку на реальных данных, проверить минимум нормальный эпизод, риск качества и неполные/аномальные данные; дополнительно показать полный агентный цикл модельной оптимизации. Искусственно повреждённый эпизод явно подписать. В отчёте отдельно указать ошибки прогноза на истории и условный эффект в модельной среде. + +Q&A 11.09 добавляет явную приёмочную проверку: на заранее подготовленном окружении +выполнить расчёт без внешней сети, с локальными зависимостями, данными и артефактами. +Backend фиксирует команду и фактический исход; ML — идентификатор модели и границы +применимости. Это требование к проверке, не утверждение, что она уже выполнена. + +<<<<<<< HEAD +Целевой бюджет после загрузки данных: один цикл с максимум 126 кандидатами до 5 секунд на рабочем CPU-ноутбуке; это инженерная цель, не измеренный результат. В отчёте указать CPU, объём памяти, число кандидатов и фактическое время. + +## 14. Работа двух исполнителей + +### Владение и изоляция изменений + +Интеграционная ветка — `dev`. Рабочие ветки — `backend/` и `ml/` от актуальной `dev`, по одной небольшой поставке. У каждого исполнителя отдельный checkout или Git worktree. Два агента не переключают ветки, не выполняют reset и не меняют общий индекс Git в одном каталоге. + +В поставке — одна завершённая возможность с проверкой. Автор меняет свою область из раздела 3. Если нужен файл партнёра, передаёт описание изменения владельцу; владение можно временно передать явно, с указанием файлов и commit базы. Одновременное редактирование общего файла не считается способом интеграции. + +Backend собирает интеграционную версию и координирует слияние. ML проверяет семантику признаков, единиц, ограничений и численных результатов. Проверка партнёром обязательна для общих контрактов и сквозной логики; отдельный третий ревьюер не требуется. + +### Первая совместная поставка + +До независимой разработки оба исполнителя фиксируют: + +1. Pydantic-типы из раздела 5, сигнатуры раздела 6 и один сериализованный пример `ProcessState`/`Recommendation`. +2. В `global_tests/fixtures/` — три состояния для модельного смешения, ожидаемые статусы и численные значения из раздела 9. ML задаёт значения, backend оформляет контрактные fixtures. +3. Малую телеметрию и ЛИМС с задержанным анализом для проверки времени; подготовленный manifest и известный порядок признаков. +4. Команду проверки контрактов и модельного цикла. До реализации цикла допускаются отдельные тесты DTO; этап 1 закрывается только реальным сквозным запуском. + +Затем backend строит приложение с фиксированными результатами агентов **только в тестовых fixtures**. ML разрабатывает настоящие функции с теми же входами/выходами. В демонстрационном режиме используются реальные формулы сценария, а не заранее записанный успешный ответ. + +### Изменение контракта + +1. Инициатор описывает проблему, старую и новую форму входа/выхода, затронутые функции и единицы. Сообщение «нужен ещё один параметр» без примера недостаточно. +2. Второй исполнитель проверяет влияние на свою часть. Backend фиксирует согласованное изменение в DTO, этом дизайне и fixtures одной поставкой. +3. Потребитель обновляется в том же PR либо отдельным зависимым PR до слияния в `dev`. Ветка `dev` не остаётся в состоянии, где один модуль возвращает новый контракт, а другой читает старый. +4. Изменение схемы сопровождается версией и проверкой совместимости артефактов. Старые модели не пересохраняются под новым ID без повторной проверки признаков. + +Для гипотез ML не меняет общий контракт: эксперимент ведётся внутри своей ветки. В контракт попадает только принятая потребность, которую использует другая сторона. + +### Передача результата + +Каждая передача через PR или сообщение содержит: + +```text +Задача и владелец: +Базовый commit dev и commit результата: +Что изменилось для второго исполнителя: +Версия контракта; dataset_id / model_id при наличии: +Как воспроизвести: команда и пример входа: +Ожидаемый результат: статус, ключевые числа, путь артефакта: +Что проверено: команды и фактический исход: +Известные ограничения и блокеры: +Следующее действие второго исполнителя: +``` + +Модель передаётся не одним `model.joblib`, а полным каталогом из раздела 11, manifest данных/командой их подготовки и примером применения. Локальный абсолютный путь одного исполнителя не передаёт артефакт: второй должен иметь доступ к артефакту или воспроизводимой команде создания по закреплённому commit. Для первого этапа достаточно небольшой baseline-модели, которую оба могут обучить на CPU. + +Backend → ML: подготовленные таблицы, manifest, отчёт проблем и тестовую точку времени. ML → backend: модель/формулы, metadata, ограничения применимости и ожидаемый результат на этой точке. Получатель воспроизводит результат, а не подтверждает только получение файла. + +### Регулярная интеграция + +Минимум раз в рабочий день и после изменения контракта: + +1. Каждый сообщает готовую поставку, зависимость от партнёра и блокер с конкретным входом/ошибкой. +2. Backend собирает оба изменения от актуальной `dev`; конфликт семантики разрешается вместе с владельцем, не автоматическим выбором одной стороны. +3. Оба запускают три модельных сценария и проверяют `hold/recommend/abstain`. Для имеющейся модели проверяются загрузка и одна общая историческая точка. +4. Сравниваются выбранные источники, возраст анализов, список признаков, оценки, ограничения и итог; расхождения разбираются до нового улучшения. +5. Фиксируется один интеграционный commit с зелёными проверками. Следующая работа начинается от него. + +Общий критерий завершения задачи: код/конфигурация доступны в интеграционной версии, контракт совместим, целевой сценарий проходит, второй исполнитель воспроизвёл результат, существенные ограничения описаны. «Ноутбук работает у автора» или «UI показывает заглушку» не закрывает совместную поставку. + +### Что делать при блокировке + +- ML исследует данные или модель: backend продолжает на согласованных fixtures и модельных формулах, не придумывает новые численные правила. +- Backend меняет загрузчик: ML работает на закреплённом prepared dataset и не создаёт второй production-пайплайн чтения Excel. +- Не подтверждён технологический параметр: действие остаётся отключённым; интерфейс и сценарии отказа продолжают разрабатываться. +- Общий контракт ещё обсуждается: зависимая часть не сливается; независимые задачи выполняются без изменения интерфейса. + +## 15. Порядок фиксации и расширения + +| Этап плана | Какие части дизайна вводятся | +| --- | --- | +| 0 | Структура пакета, нормализация, словарь, временные правила и contracts | +| 1 | Сквозной цикл, baseline, модельные fixtures, журнал и минимальный UI | +| 2 | Общие признаки, обученная модель, metadata и временная оценка | +| 3 | Единый фильтр, кандидаты, ранжирование, проверка возможности оценки действий | +| 4 | Полный интерфейс модельного блендинга и hybrid-сценарий при подтверждённых связях | +| 5 | Статистические границы, область применимости, существенность и cooldown | +| 6 | Чистый запуск, отчёты, негативные сценарии и демонстрация | + +Незавершённая возможность обозначается через capabilities и понятный отказ, не скрывается условной константой. + +На этапе 0 исследуются, а не назначаются архитектурой: истинные единицы спорных тегов, управляющие параметры гидроочистки, технологические пределы, подтверждённые связи потоков, задержки, реальная товарная спецификация сверх серы. До подтверждения соответствующие действия и заявления о полноте проверки отключены. Владелец исследования — ML; backend обеспечивает исполнение запрета. + +Изменения полей DTO, единиц или временной семантики требуют обновления `schema_version` и явной проверки совместимости. Замена baseline на более точную модель с теми же входами меняет `model_id`, но не контракт. HTTP API, внешние интеграции и промышленное управление рассматриваются отдельным будущим дизайном после готовности этой версии. + +## 16. Подтверждения экспертов от 10.09.2026 + +Основание этого раздела — ответы составителей задания в рабочем чате, переданные команде 10.09.2026. Они заменяют противоречащие им начальные допущения выше. + +======= Целевой бюджет после загрузки данных: один цикл с максимум 126 кандидатами до 5 секунд на рабочем CPU-ноутбуке; это инженерная цель, не измеренный результат. В отчёте указать CPU, объём памяти, число кандидатов и фактическое время. ## 14. Работа двух исполнителей @@ -643,7 +1161,12 @@ Backend → ML: подготовленные таблицы, manifest, отчё | 3 | Единый фильтр, кандидаты, ранжирование, проверка возможности оценки действий | | 4 | Полный интерфейс модельного блендинга и hybrid-сценарий при подтверждённых связях | | 5 | Статистические границы, область применимости, существенность и cooldown | -| 6 | Чистый запуск, отчёты, негативные сценарии и демонстрация | +| 6 | Чистый запуск, отчёты, негативные сценарии и демонстрация | +| 7 | Safety-first alarm, joint applicability и усиленный action capability gate | +| 8 | Read-only диагностика временного drift, режимов, ПАК--ЛИМС и устойчивости признаков | +| 9 | Эпизодный multi-horizon shadow-прогноз schema 1.2 и отложенный LIMS-контроль | +| 10 | Matched-episode P8/F19 action study с UI-дисклеймером и `supports_actions=false` | +| 11 | Verified P8/F19 action artifact contract, freeze gate и actionable history replay | Незавершённая возможность обозначается через capabilities и понятный отказ, не скрывается условной константой. @@ -655,6 +1178,28 @@ Backend → ML: подготовленные таблицы, manifest, отчё Основание этого раздела — ответы составителей задания в рабочем чате, переданные команде 10.09.2026. Они заменяют противоречащие им начальные допущения выше. +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) +**Оговорка после Q&A 11.09:** в [вопросах участников](QA_CLARIFICATIONS.md) оспорены +физические соответствия `P8/T11/F19` и полнота проверки ВАК. Нового содержательного +ответа в предоставленной записи нет. Утверждения ниже сохраняются как история +прежнего подтверждения, но не закрывают противоречие по этим тегам. Их нельзя +переставлять по диапазонам значений или допускать к реальному управлению без +контрольного примера с единицами; зависимые физические интерпретации требуют проверки. + +<<<<<<< HEAD +- Короткие имена колонок `avt_tags.csv` и `242000_tags.csv` напрямую соответствуют листу «КИП» в `Теги_хакатон.xlsx`; дополнительное масштабирование значений не требуется. Это подтверждает идентичность тегов, но не создаёт отсутствующие в материалах технологические пределы. +- Строка единиц в ЛИМС содержит ошибки. Каноническая единица определяется подтверждённым смыслом показателя: температуры кипения — `degC`, `D15` — `kg/m3`, `I250/I350` — `vol%`. Исходный ошибочный заголовок сохраняется в provenance. +- Время ЛИМС — момент отбора пробы; публикация занимает до 4 часов. Все источники используют один общий часовой пояс. До получения точного IANA-идентификатора сохраняется настроенный `Europe/Moscow`, явно записываемый в manifest. +- `24-2000:Mg.Sulfur` соответствует `Mg.Sulfur` точки 2 гидроочистки. Подтверждение корректности единиц принимается как массовый `ppm`, численно эквивалентный `mg/kg`; источники ПАК и ЛИМС всё равно остаются раздельными в обучении и отчёте. +- Исправленные формулы ВАК фиксируются отдельно от исходного Excel: `T90` использует `59.57*(F15/2000)`; коэффициент `T6` в `T50` равен `0.471`; `CloudPoint` начинается с `0.0002*F22`; первый член `CFPP` равен `0.22088*T23`; коэффициент `T6` в `T95` равен `0.50`. В формуле `AVT6:240-350:CFPP` последняя скобка лишняя, член читается как `F65/F32 + F30`. +- Подтверждённые доступные оператору переменные гидроочистки: `P8` — температура ГСС на входе Р-202, `T11` — массовый расход сырья, `F19` — давление на входе Р-202. Пределы скорости не предоставлены; задержку эффекта следует оценивать в диапазоне 0–3 часов. До определения единиц, диапазонов, совместной области и отдельной проверки модели действий реальные controls в сценариях остаются выключенными. +- Допустимый шаг рекомендаций — 15–60 минут, горизонт — 0–3 часа. Для Stage 2 сохраняется заранее выбранная точка 60 минут. +======= +**Дополнение по схемам от 14.09:** три новых листа относятся только к АВТ и +добавляют рукописную привязку `avt:*` к К-1/К-2/К-10. Они частично закрывают Q1 +по аппаратам и внутренним потокам АВТ, но не закрывают карту ЛИМС-точек, маршрут +до 24-2000, спор Q2 по `ht:P8/T11/F19`, единицы или action limits. + - Короткие имена колонок `avt_tags.csv` и `242000_tags.csv` напрямую соответствуют листу «КИП» в `Теги_хакатон.xlsx`; дополнительное масштабирование значений не требуется. Это подтверждает идентичность тегов, но не создаёт отсутствующие в материалах технологические пределы. - Строка единиц в ЛИМС содержит ошибки. Каноническая единица определяется подтверждённым смыслом показателя: температуры кипения — `degC`, `D15` — `kg/m3`, `I250/I350` — `vol%`. Исходный ошибочный заголовок сохраняется в provenance. - Время ЛИМС — момент отбора пробы; публикация занимает до 4 часов. Все источники используют один общий часовой пояс. До получения точного IANA-идентификатора сохраняется настроенный `Europe/Moscow`, явно записываемый в manifest. @@ -662,5 +1207,11 @@ Backend → ML: подготовленные таблицы, manifest, отчё - Исправленные формулы ВАК фиксируются отдельно от исходного Excel: `T90` использует `59.57*(F15/2000)`; коэффициент `T6` в `T50` равен `0.471`; `CloudPoint` начинается с `0.0002*F22`; первый член `CFPP` равен `0.22088*T23`; коэффициент `T6` в `T95` равен `0.50`. В формуле `AVT6:240-350:CFPP` последняя скобка лишняя, член читается как `F65/F32 + F30`. - Подтверждённые доступные оператору переменные гидроочистки: `P8` — температура ГСС на входе Р-202, `T11` — массовый расход сырья, `F19` — давление на входе Р-202. Пределы скорости не предоставлены; задержку эффекта следует оценивать в диапазоне 0–3 часов. До определения единиц, диапазонов, совместной области и отдельной проверки модели действий реальные controls в сценариях остаются выключенными. - Допустимый шаг рекомендаций — 15–60 минут, горизонт — 0–3 часа. Для Stage 2 сохраняется заранее выбранная точка 60 минут. -- Для модельного блендинга обязательны сера, `T95` и цетановое число. Разрешена явно модельная имитация резервуаров; присадка до 3% и её цена в 100 раз выше цены ДТ относятся к будущему сценарию и не подменяют отсутствующие реальные данные. -- Отложенная историческая оценка прогноза и собственная модель альтернативных действий показываются раздельно; история прогноза сама по себе не доказывает эффект невыполненных воздействий. +>>>>>>> 1527107 (Extend UI and ML analysis materials) +- Для модельного блендинга обязательны сера, `T95` и цетановое число. Реализована явно модельная имитация резервуаров; присадка до 3% и её цена в 100 раз выше цены ДТ заданы в сценариях. Кривая эффективности остаётся синтетическим настраиваемым допущением и не подменяет отсутствующие реальные данные. +- Отложенная историческая оценка прогноза и собственная модель альтернативных действий показываются раздельно; история прогноза сама по себе не доказывает эффект невыполненных воздействий. +<<<<<<< HEAD +======= +- Подтверждение пользователя от 13.09.2026: шесть известных экстремальных LIMS-наблюдений серы (`34`, `35.1`, `45.9`, `107`, `120`, `2120 mg/kg`) считаются выбросами для ML. Они сохраняются в prepared data и provenance, а обучение исключает только их стабильные `observation_id`; общий численный cap для будущих наблюдений не вводится. +- Метка LIMS означает момент отбора пробы. Четырёхчасовая задержка относится к доступности результата: `available_at = measured_at + 4h`. Историческое обучение вправе использовать результат как отложенную метку, но признаки и состояние на момент `t` используют его только при `available_at <= t`. +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 63a91fe..40f22de 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,168 +1,210 @@ -# План реализации Нефтекода с разделением backend и ML - -## 1. Цель и исходное состояние - -За **1–2 недели силами двух исполнителей (людей или агентов) на CPU** собрать воспроизводимый прототип: система получает состояние производства, оценивает качество и риск, сравнивает допустимые изменения режима и выдаёт оператору рекомендацию либо объяснённый отказ. - -Основа — [ТЗ из репозитория](materials/ТЗ_нефтекод.docx), [технологические схемы](materials/АВТ_схемы.pdf) и [исходные данные](materials/README.md). Архитектура, контракты и порядок совместной работы зафиксированы в [техническом дизайне](DESIGN.md). Блендинг включаем как **явно обозначенный модельный сценарий**, поскольку отдельных исторических рецептур в пакете не найдено. - -Этот раздел фиксирует исходную точку плана. Актуальный статус: реализованы Stages 0–5, -включая preparation/state pipeline, safety guardrails, Stage-5 uncertainty layer и -локальный Tkinter UI для model-demo. Интеграция исторического артефакта в UI и action -model остаются следующими задачами. - -В данных: - -- Телеметрия АВТ и гидроочистки находится в `materials/data.rar`: по **189 217 строк**, с 01.01.2023 по 07.08.2026. -- ЛИМС содержит отдельные пары «время — значение» для каждого показателя. На выходе гидроочистки есть **1 462 измерения серы**, но только **42 измерения цетанового числа**. -- У показателей ПАК разные временные шкалы; плотность начинается только в марте 2025 года. -- Встречаются служебные значения, противоречивые единицы и неоднозначные соответствия тегов. Формулы ВАК также требуют проверки. -- Максимум лабораторной серы — **2 120 мг/кг**, ПАК — около **20 мг/кг**. Причину расхождения предстоит установить; автоматически удалять такие эпизоды нельзя. - -**Основной результат:** работающая система по всему циклу ТЗ. Более сложные модели добавляются только после готовности предыдущего этапа. Сроки — ориентиры для двух параллельных направлений; этап завершается по критерию готовности. - -## 2. Разделение ответственности и архитектура - -«Backend» и «ML» — два направления работы. Интерфейс — backend; подготовка признаков и оптимизация — ML. - -| Область | Backend | ML | -| --- | --- | --- | -| Данные | Чтение файлов, нормализация формата, временное объединение, кэш | Смысл тегов, единицы, правила качества данных, выбор показателей | -| Модели | Загрузка артефактов, проверка совместимости, вызов | Признаки, обучение, валидация, неопределённость | -| Ограничения | Исполнение единой проверки, запрет недопустимого результата | Обоснование границ, формулы риска, критерии допустимости | -| Оптимизация | Подключение к циклу решения, обработка ошибок | Генерация кандидатов, прогноз последствий, ранжирование | -| Мультиагентность | Оркестратор, последовательность вызовов, журнал | Агент качества, агент надёжности, агент оптимизации | +# План реализации Нефтекода с разделением backend и ML + +## 1. Цель и исходное состояние + +За **1–2 недели силами двух исполнителей (людей или агентов) на CPU** собрать воспроизводимый прототип: система получает состояние производства, оценивает качество и риск, сравнивает допустимые изменения режима и выдаёт оператору рекомендацию либо объяснённый отказ. + +Основа — [ТЗ из репозитория](materials/ТЗ_нефтекод.docx), [технологические схемы](materials/АВТ_схемы.pdf) и [исходные данные](materials/README.md). Архитектура, контракты и порядок совместной работы зафиксированы в [техническом дизайне](DESIGN.md). Блендинг включаем как **явно обозначенный модельный сценарий**, поскольку отдельных исторических рецептур в пакете не найдено. + +Этот раздел фиксирует исходную точку плана. Актуальный статус: реализованы Stages 0–6, +включая preparation/state pipeline, safety guardrails, uncertainty, frozen evaluation, +пять приёмочных эпизодов, полный синтетический паспорт S/T95/CN, модель присадки и +локальный Tkinter UI для model-demo. Интеграция исторического артефакта в UI и +промышленная action model остаются за границей версии 1. + +В данных: + +- Телеметрия АВТ и гидроочистки находится в `materials/data.rar`: по **189 217 строк**, с 01.01.2023 по 07.08.2026. +- ЛИМС содержит отдельные пары «время — значение» для каждого показателя. На выходе гидроочистки есть **1 462 измерения серы**, но только **42 измерения цетанового числа**. +- У показателей ПАК разные временные шкалы; плотность начинается только в марте 2025 года. +- Встречаются служебные значения, противоречивые единицы и неоднозначные соответствия тегов. Формулы ВАК также требуют проверки. +- Максимум лабораторной серы — **2 120 мг/кг**, ПАК — около **20 мг/кг**. Причину расхождения предстоит установить; автоматически удалять такие эпизоды нельзя. + +**Основной результат:** работающая система по всему циклу ТЗ. Более сложные модели добавляются только после готовности предыдущего этапа. Сроки — ориентиры для двух параллельных направлений; этап завершается по критерию готовности. + +## 2. Разделение ответственности и архитектура + +«Backend» и «ML» — два направления работы. Интерфейс — backend; подготовка признаков и оптимизация — ML. + +| Область | Backend | ML | +| --- | --- | --- | +| Данные | Чтение файлов, нормализация формата, временное объединение, кэш | Смысл тегов, единицы, правила качества данных, выбор показателей | +| Модели | Загрузка артефактов, проверка совместимости, вызов | Признаки, обучение, валидация, неопределённость | +| Ограничения | Исполнение единой проверки, запрет недопустимого результата | Обоснование границ, формулы риска, критерии допустимости | +| Оптимизация | Подключение к циклу решения, обработка ошибок | Генерация кандидатов, прогноз последствий, ранжирование | +| Мультиагентность | Оркестратор, последовательность вызовов, журнал | Агент качества, агент надёжности, агент оптимизации | | Демонстрация | Desktop UI на Tkinter, воспроизведение model-demo, отображение объяснений | Выбор эпизодов, метрики, проверка содержательных выводов | -| Приёмка | Интеграционные тесты и воспроизводимый запуск | Проверка утечек, качества прогнозов и корректности экспериментов | - -Каждый отвечает за тесты своей части. **ML задаёт формулы и ограничения; backend реализует их единое применение**, для одинаковой логики оптимизатора и интерфейса. - -### Совместная работа двух исполнителей - -- Backend владеет общими контрактами и интеграцией, ML — признаками, численными моделями и сценариями. Изменение общего интерфейса сначала фиксируется примером входа/выхода и тестом совместимости. -- Каждый работает в отдельной ветке и отдельном checkout/worktree. Файлы партнёра одновременно не редактируются; изменения согласуются через конкретный commit и описание ожидаемого поведения. -- Первая совместная поставка — DTO, тестовые состояния и фиксированный модельный сценарий. После неё оба направления работают параллельно: backend со структурированными fixtures, ML с тем же контрактом. -- Минимум раз в рабочий день и после изменения контракта — интеграция: три сквозных сценария, загрузка актуального артефакта при его наличии и проверка журнала. -- Передача задачи: commit, выполненные проверки, пример результата, ограничения и следующий необходимый шаг партнёра. Задача завершена после запуска результата вторым исполнителем. -- Каждый этап заканчивается совместно запускаемым приложением. Улучшения моделей не откладывают интеграцию до последнего дня. - -Архитектура — одно Python-приложение с обычными функциями и классами: - -```text -Источники → состояние процесса → оценки качества и риска → кандидаты - → проверка ограничений → выбор → объяснение и журнал -``` - +| Приёмка | Интеграционные тесты и воспроизводимый запуск | Проверка утечек, качества прогнозов и корректности экспериментов | + +Каждый отвечает за тесты своей части. **ML задаёт формулы и ограничения; backend реализует их единое применение**, для одинаковой логики оптимизатора и интерфейса. + +### Совместная работа двух исполнителей + +- Backend владеет общими контрактами и интеграцией, ML — признаками, численными моделями и сценариями. Изменение общего интерфейса сначала фиксируется примером входа/выхода и тестом совместимости. +- Каждый работает в отдельной ветке и отдельном checkout/worktree. Файлы партнёра одновременно не редактируются; изменения согласуются через конкретный commit и описание ожидаемого поведения. +- Первая совместная поставка — DTO, тестовые состояния и фиксированный модельный сценарий. После неё оба направления работают параллельно: backend со структурированными fixtures, ML с тем же контрактом. +- Минимум раз в рабочий день и после изменения контракта — интеграция: три сквозных сценария, загрузка актуального артефакта при его наличии и проверка журнала. +- Передача задачи: commit, выполненные проверки, пример результата, ограничения и следующий необходимый шаг партнёра. Задача завершена после запуска результата вторым исполнителем. +- Каждый этап заканчивается совместно запускаемым приложением. Улучшения моделей не откладывают интеграцию до последнего дня. + +Архитектура — одно Python-приложение с обычными функциями и классами: + +```text +Источники → состояние процесса → оценки качества и риска → кандидаты + → проверка ограничений → выбор → объяснение и журнал +``` + Используем имеющиеся pandas, NumPy, scikit-learn, Pydantic, Tkinter и pytest. Добавляем `openpyxl` для чтения Excel и `tzdata` для единообразной работы часовых поясов. HTTP API, отдельные сервисы и база данных для первой версии не нужны. - -Объяснение формируется шаблоном из результатов расчёта. LLM в обязательную часть не входит. - -### Общий контракт - -Контракт фиксируем в первый день: backend работает с тестовыми оценками, пока ML готовит модели. - -| Объект | Минимальное содержание | -| --- | --- | -| `ProcessState` | Время решения, значения и единицы, источники, время измерения и доступности, возраст, флаги качества | -| `CandidateAction` | Идентификатор, изменения подтверждённых управляющих параметров, горизонт прогноза, признак модельного сценария | -| `AgentAssessment` | Показатели качества, риск, неопределённость, применимость модели, причины ограничений | -| `Recommendation` | `recommend`, `hold` или `abstain`; действие, альтернативы, ожидаемые эффекты, результаты проверок и объяснение | - -ML предоставляет `predict_quality`, `assess_reliability` и `evaluate_candidates`. Backend вызывает их из `run_cycle` и сохраняет полный результат в JSONL. Обучение отделено от запуска приложения. - -## 3. Этапы реализации - -### Этап 0 — согласованные данные и правила, дни 1–2 - -**Backend:** - -- Подготовить воспроизводимое чтение архива, CSV и Excel без изменения исходников. -- Разбирать каждую пару «дата — значение» ЛИМС/ПАК отдельно. -- Ввести пространства имён тегов АВТ и гидроочистки, удалить только служебные индексные столбцы. -- Сохранять происхождение значения и причину признания его недостоверным. -- Привести конфигурацию инструментов к распознаваемому формату: существующий `config.toml` заменить на `pyproject.toml`, указать реальную папку тестов. - -**ML:** - -- Составить таблицу соответствий: тег → физический смысл → единица → источник подтверждения → возможность управления. -- Выделить признаки, цели прогнозирования и признаки, способные раскрывать целевое значение. -- Разобрать расхождения ЛИМС/ПАК, ошибки единиц и неоднозначные формулы. -- Зафиксировать первоначальный сценарий качества по сере и перечень остальных показателей, которые можно обоснованно проверять. -- Определить правила свежести и доступности анализов. По ответу эксперта от 10.09.2026 время ЛИМС означает отбор пробы, публикация занимает до 4 часов; использовать 4 часа консервативно и проверить чувствительность к меньшей задержке. - -**Готовность:** оба направления используют один формат данных и один словарь. Неподтверждённые теги не допускаются к управляющим воздействиям. - -### Этап 1 — первый сквозной прототип, дни 2–3 - -**Backend:** - -- Реализовать воспроизведение состояния на выбранный момент истории. -- Подключить четыре логические роли: качество, надёжность, оптимизация, оркестратор. + +Объяснение формируется шаблоном из результатов расчёта. LLM в обязательную часть не входит. + +### Общий контракт + +Контракт фиксируем в первый день: backend работает с тестовыми оценками, пока ML готовит модели. + +| Объект | Минимальное содержание | +| --- | --- | +| `ProcessState` | Время решения, значения и единицы, источники, время измерения и доступности, возраст, флаги качества | +| `CandidateAction` | Идентификатор, изменения подтверждённых управляющих параметров, горизонт прогноза, признак модельного сценария | +| `AgentAssessment` | Показатели качества, риск, неопределённость, применимость модели, причины ограничений | +| `Recommendation` | `recommend`, `hold` или `abstain`; действие, альтернативы, ожидаемые эффекты, результаты проверок и объяснение | + +ML предоставляет `predict_quality`, `assess_reliability` и `evaluate_candidates`. Backend вызывает их из `run_cycle` и сохраняет полный результат в JSONL. Обучение отделено от запуска приложения. + +## 3. Этапы реализации + +### Этап 0 — согласованные данные и правила, дни 1–2 + +**Backend:** + +- Подготовить воспроизводимое чтение архива, CSV и Excel без изменения исходников. +- Разбирать каждую пару «дата — значение» ЛИМС/ПАК отдельно. +- Ввести пространства имён тегов АВТ и гидроочистки, удалить только служебные индексные столбцы. +- Сохранять происхождение значения и причину признания его недостоверным. +- Привести конфигурацию инструментов к распознаваемому формату: существующий `config.toml` заменить на `pyproject.toml`, указать реальную папку тестов. + +**ML:** + +- Составить таблицу соответствий: тег → физический смысл → единица → источник подтверждения → возможность управления. +- Выделить признаки, цели прогнозирования и признаки, способные раскрывать целевое значение. +- Разобрать расхождения ЛИМС/ПАК, ошибки единиц и неоднозначные формулы. +- Зафиксировать первоначальный сценарий качества по сере и перечень остальных показателей, которые можно обоснованно проверять. +- Определить правила свежести и доступности анализов. По ответу эксперта от 10.09.2026 время ЛИМС означает отбор пробы, публикация занимает до 4 часов; использовать 4 часа консервативно и проверить чувствительность к меньшей задержке. + +**Готовность:** оба направления используют один формат данных и один словарь. Неподтверждённые теги не допускаются к управляющим воздействиям. + +### Этап 1 — первый сквозной прототип, дни 2–3 + +**Backend:** + +- Реализовать воспроизведение состояния на выбранный момент истории. +- Подключить четыре логические роли: качество, надёжность, оптимизация, оркестратор. - Добавить минимальный desktop UI: состояние, свежесть данных, результаты агентов, итог. -- Реализовать статусы рекомендации, сохранения режима и отказа. - -**ML:** - -- Дать базовую оценку качества по последним доступным пригодным измерениям. -- Реализовать прозрачный индекс тяжести режима по подтверждённым параметрам. -- Подготовить небольшой модельный пример смешения с известными свойствами компонентов. -- Добавить несколько вариантов, включая «ничего не менять». - -**Готовность:** показан полный цикл — от данных до объяснения. Модельные примеры явно подписаны; неподготовленная ML-модель не маскируется заглушкой. - -### Этап 2 — прогноз серы, дни 3–6 - -**Backend:** - -- Подключить сохранённую модель и общий с обучением код подготовки признаков. -- Добавить проверку версии модели, набора признаков и единиц. -- Показывать прогноз, горизонт и причины недоверия. - -**ML:** - -- Начать с прогноза серы гидроочищенного продукта. Горизонт первой версии — **60 минут**, как экспериментальное допущение. -- Сравнить сохранение последнего значения, Ridge и `HistGradientBoostingRegressor`. Последний уже входит в выбранный стек и поддерживает квантильную регрессию для последующего этапа неопределённости: [документация scikit-learn](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html). -- Использовать только доступные на момент решения признаки: прошлые значения, изменения, агрегаты и возраст анализов. -- ПАК использовать как отдельный источник обучающих целей; качество относительно ЛИМС оценивать отдельно на реальных лабораторных измерениях. -- Взять начальное разделение: 2023–2024 — обучение, 2025 — валидация, 2026 до конца телеметрии — финальный тест. -- Подбор параметров проводить внутри обучающего периода последовательными временными блоками. На границах исключать примеры с пересекающимися горизонтами целей. Принцип временного разбиения соответствует [документации TimeSeriesSplit](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html). - -**Готовность:** отчёт с MAE, ошибкой около порога 10 мг/кг, пропущенными превышениями и ложными тревогами. Если сложная модель не улучшает базовую на валидации, сохраняется базовая. - -### Этап 3 — ограниченная оптимизация и надёжность, дни 6–8 - -**Backend:** - -- Реализовать общий фильтр жёстких ограничений и повторную проверку выбранного результата. -- Сохранять причины отбраковки каждого кандидата. -- Показывать текущее и рекомендуемое значения, альтернативы и происхождение границ. - -**ML:** - -- Выбрать **2–3 подтверждённых управляющих параметра**. Для каждого описать допустимый диапазон, максимальный шаг и основание. -- Использовать небольшой детерминированный перебор вблизи текущего режима. -- Проверять совместную правдоподобность комбинаций параметров, а не только отдельные min/max. -- Сначала исключать недопустимые варианты, затем ранжировать: меньший риск, больший выпуск, меньшие затраты, меньшее изменение режима. -- Энергию и стоимость при отсутствии прямых данных выражать прозрачными прокси. Индекс тяжести режима не называть вероятностью аварии. -- Отдельно проверить пригодность модели для сравнения действий. Простая подстановка новой уставки в прогнозную модель сама по себе этого не доказывает. - -**Готовность:** система находит допустимые варианты в демонстрационном сценарии и отказывается, когда все варианты нарушают ограничения. Без достаточных оснований влияния реальных уставок оптимизация остаётся в модельном сценарии, а на истории доступны прогноз и предупреждения. - -### Этап 4 — связанная цепочка и модельный блендинг, дни 8–10 - -**Backend:** - -- Разделить отображение исторических фактов и модельных предположений. -- Показать прохождение состояния через АВТ, гидроочистку и блендинг. -- Добавить карточку сценария с происхождением свойств компонентов. - -**ML:** - -- Учитывать качество входящего потока при оценке следующей стадии; транспортные задержки считать допущениями, пока они не подтверждены. -- Для модельного смешения применять массовые доли: `S_mix = Σ(w_i × S_i)`. -- Проверять неотрицательность долей, сумму 1 и ограничения доступности компонентов. -- Прогноз гидроочистки — характеристика соответствующего компонента. -- Остальные свойства смешения рассчитывать только по обоснованной зависимости. Линейное усреднение цетанового числа и низкотемпературных свойств по умолчанию не применять. +- Реализовать статусы рекомендации, сохранения режима и отказа. + +**ML:** + +- Дать базовую оценку качества по последним доступным пригодным измерениям. +- Реализовать прозрачный индекс тяжести режима по подтверждённым параметрам. +- Подготовить небольшой модельный пример смешения с известными свойствами компонентов. +- Добавить несколько вариантов, включая «ничего не менять». + +**Готовность:** показан полный цикл — от данных до объяснения. Модельные примеры явно подписаны; неподготовленная ML-модель не маскируется заглушкой. + +### Этап 2 — прогноз серы, дни 3–6 + +**Backend:** + +- Подключить сохранённую модель и общий с обучением код подготовки признаков. +- Добавить проверку версии модели, набора признаков и единиц. +- Показывать прогноз, горизонт и причины недоверия. + +**ML:** + +- Начать с прогноза серы гидроочищенного продукта. Горизонт первой версии — **60 минут**, как экспериментальное допущение. +- Сравнить сохранение последнего значения, Ridge и `HistGradientBoostingRegressor`. Последний уже входит в выбранный стек и поддерживает квантильную регрессию для последующего этапа неопределённости: [документация scikit-learn](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.HistGradientBoostingRegressor.html). +- Использовать только доступные на момент решения признаки: прошлые значения, изменения, агрегаты и возраст анализов. +- ПАК использовать как отдельный источник обучающих целей; качество относительно ЛИМС оценивать отдельно на реальных лабораторных измерениях. +- Взять начальное разделение: 2023–2024 — обучение, 2025 — валидация, 2026 до конца телеметрии — финальный тест. +- Подбор параметров проводить внутри обучающего периода последовательными временными блоками. На границах исключать примеры с пересекающимися горизонтами целей. Принцип временного разбиения соответствует [документации TimeSeriesSplit](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html). + +**Готовность:** отчёт с MAE, ошибкой около порога 10 мг/кг, пропущенными превышениями и ложными тревогами. Если сложная модель не улучшает базовую на валидации, сохраняется базовая. + +### Этап 3 — ограниченная оптимизация и надёжность, дни 6–8 + +**Backend:** + +- Реализовать общий фильтр жёстких ограничений и повторную проверку выбранного результата. +- Сохранять причины отбраковки каждого кандидата. +- Показывать текущее и рекомендуемое значения, альтернативы и происхождение границ. + +**ML:** + +- Выбрать **2–3 подтверждённых управляющих параметра**. Для каждого описать допустимый диапазон, максимальный шаг и основание. +- Использовать небольшой детерминированный перебор вблизи текущего режима. +- Проверять совместную правдоподобность комбинаций параметров, а не только отдельные min/max. +- Сначала исключать недопустимые варианты, затем ранжировать: меньший риск, больший выпуск, меньшие затраты, меньшее изменение режима. +- Энергию и стоимость при отсутствии прямых данных выражать прозрачными прокси. Индекс тяжести режима не называть вероятностью аварии. +- Отдельно проверить пригодность модели для сравнения действий. Простая подстановка новой уставки в прогнозную модель сама по себе этого не доказывает. + +**Готовность:** система находит допустимые варианты в демонстрационном сценарии и отказывается, когда все варианты нарушают ограничения. Без достаточных оснований влияния реальных уставок оптимизация остаётся в модельном сценарии, а на истории доступны прогноз и предупреждения. + +### Этап 4 — связанная цепочка и модельный блендинг, дни 8–10 + +**Backend:** + +- Разделить отображение исторических фактов и модельных предположений. +- Показать прохождение состояния через АВТ, гидроочистку и блендинг. +- Добавить карточку сценария с происхождением свойств компонентов. + +**ML:** + +- Учитывать качество входящего потока при оценке следующей стадии; транспортные задержки считать допущениями, пока они не подтверждены. +- Для модельного смешения применять массовые доли: `S_mix = Σ(w_i × S_i)`. +- Проверять неотрицательность долей, сумму 1 и ограничения доступности компонентов. +- Прогноз гидроочистки — характеристика соответствующего компонента. +- Остальные свойства смешения рассчитывать только по объявленной зависимости. В финальном `model_demo` линейные T95/CN и кривая присадки разрешены как явно синтетические, изменяемые допущения; на реальные данные автоматически не переносятся. +<<<<<<< HEAD + +**Готовность:** демонстрируется вся цепочка, включая влияние качества компонента на допустимую рецептуру. Соответствие по одной сере не выдаётся за соответствие всей товарной спецификации. + +### Этап 5 — устойчивость и неопределённость, дни 10–12 + +**Backend:** + +- Добавить обработку пропусков, устаревших анализов, конфликтов источников и неприменимости модели. +- Предотвращать лишние и постоянно меняющиеся рекомендации. +- Сохранять версии данных, модели, правил и сценария. + +**ML:** + +- Построить оценку верхней границы серы и проверить её покрытие на отложенных данных. +- Для допуска действия проверять верхнюю оценку относительно 10 мг/кг. +- Подобрать порог существенности улучшения и ограничения частоты изменений на валидации. +- Проверить устойчивость к задержкам лаборатории, ошибкам измерений и изменению режима. + +**Готовность:** нормальный период не вызывает лишних действий; нехватка данных снижает доверие или приводит к объяснённому отказу. + +### Этап 6 — приёмка и демонстрация, дни 12–14 + +**Backend:** + +- Подготовить запуск из чистого окружения, закрепить проверенные версии зависимостей. +- Описать получение LFS-данных, подготовку данных, обучение и запуск. +- Добавить воспроизводимый выбор демонстрационных эпизодов и экспорт журнала. + +**ML:** + +- Зафиксировать модели до финального тестирования. +- Сравнить базовый прогноз и итоговую модель; в модельной среде — сохранение режима и оптимизацию. +- Отдельно представить точность на истории и модельный эффект действий. +- Подготовить ограничения решения и объяснение основных ошибок. + +**Готовность:** другой участник запускает приложение по README и воспроизводит результаты. Дни 13–14 — резерв на ошибки и защиту, без новых крупных функций. + +## 4. Обязательные ограничения и проверки + +======= **Готовность:** демонстрируется вся цепочка, включая влияние качества компонента на допустимую рецептуру. Соответствие по одной сере не выдаётся за соответствие всей товарной спецификации. @@ -183,7 +225,7 @@ ML предоставляет `predict_quality`, `assess_reliability` и `evalua **Готовность:** нормальный период не вызывает лишних действий; нехватка данных снижает доверие или приводит к объяснённому отказу. -### Этап 6 — приёмка и демонстрация, дни 12–14 +### Этап 6 — приёмка и демонстрация, дни 12–14 **Backend:** @@ -198,32 +240,85 @@ ML предоставляет `predict_quality`, `assess_reliability` и `evalua - Отдельно представить точность на истории и модельный эффект действий. - Подготовить ограничения решения и объяснение основных ошибок. -**Готовность:** другой участник запускает приложение по README и воспроизводит результаты. Дни 13–14 — резерв на ошибки и защиту, без новых крупных функций. +**Готовность:** другой участник запускает приложение по README и воспроизводит результаты. Дни 13–14 — резерв на ошибки и защиту, без новых крупных функций. + +### Актуальный план после Q&A 15.09 и реализации ML v2 + +Базовый план выше описывает стартовую версию. Фактическая последовательность для +хакатонной поставки теперь такая: + +1. **Данные и семантика.** Использовать prepared dataset `a2fe8577752a`, + скорректированный словарь 24-2000 и явное правило `ht:Q21=307 -> missing`; + исходные файлы не переписывать. ЛИМС считать доступным только после + `available_at`, без сдвига `measured_at`. +2. **Оперативный риск ПАК.** Обучать episode-aware LightGBM/HGB на локально + доступных признаках ПАК и `P8/F19/T11`, с 10/20/30/60-минутными crossing-labels, + inverse-episode/month weights, calibrated probabilities и q95 upper. Отбирать + только на rolling-origin 2024–2025; 2026 оставить audit. +3. **Двухслойный контроль.** Хранить прогноз ПАК и независимый LIMS-контроль в + отдельных артефактах. LIMS-коррекция допускается только при улучшении MAE не + менее 5% и coverage не ниже 95%; иначе показывать `research_only`. +4. **Исторический эффект.** Для P8/F19 оценивать только реальные эпизоды и лаги + 0–3 часа; T11/F26 оставить контекстом. UI показывает point/upper, + uncertainty и применимость как «модельный эффект по историческим эпизодам + (не совет)». `supports_actions=false` сохраняется. +5. **Сценарная демонстрация.** На защите давать заранее воспроизводимые сценарии + и replay; простой линейный симулятор допустим как baseline, эксперты могут + менять входы. Любой кандидат проходит инженерные границы, upper-bound и + hard-feasibility до ранжирования; неизвестный исход даёт `abstain`. +6. **Продвижение.** В текущем hackathon scope production controller не включать: + selection gate пройден, но audit показывает drift ложных тревог и недостаточное + upper coverage; action/LIMS gates также закрыты. Следующая версия требует + подтверждённых инженерных min/max, shadow replay и технологической проверки. + +Q&A 15.09 добавила только контекст: публикация ЛИМС занимает 3–4 часа, пребывание +между АВТ и гидроочисткой обычно несколько часов (максимум около суток), запас по +сере ориентировочно 1–2 ppm, а повторная переработка некондиции может быть +существенно дороже. Эти сведения уточняют интерфейс и будущую cost/transport-модель, +но не являются паспортными пределами и не ослабляют safety-gates. ## 4. Обязательные ограничения и проверки -### Правила для всех этапов - -- Сера товарной смеси — не более **10 мг/кг**; остальные ограничения берутся из подтверждённой спецификации выбранного сценария. -- Экономическая выгода не компенсирует нарушение жёсткого ограничения. -- Приоритет источников — ЛИМС → ПАК → ВАК, с учётом пригодности, свежести и времени доступности. Лабораторный факт сохраняется даже при расхождении с ПАК. -- Исторические границы — только модельные ограничения, пока нет подтверждения промышленного диапазона. -- Запрещены использование будущих анализов, заполнение признаков из будущего и обучение на размноженных через forward-fill лабораторных целях. -- Состояние «нет данных» не превращается в «нарушений нет». -- `hold` означает обоснованное сохранение режима; `abstain` — невозможность надёжно рекомендовать действие. - -### Приёмочные сценарии - -| Сценарий | Ожидаемый результат | -| --- | --- | -| Устойчивый допустимый режим | Сохранение режима без лишних действий | -| Риск превышения серы | Допустимый кандидат либо объяснённый отказ | -| Все кандидаты нарушают ограничения | Отказ независимо от выгоды | -| Устаревшие или отсутствующие данные | Корректное снижение доверия либо отказ | -| Конфликт ЛИМС и ПАК | Видимое расхождение и применение зафиксированного правила | -| Отрицательная доля или сумма долей ≠ 1 | Отклонение рецептуры | -| Неподтверждённый тег, неверная единица | Запрет соответствующего расчёта или действия | -| Состояние вне области применимости | Отказ от неподтверждённого прогноза действий | -| Повтор одного запуска | Одинаковые расчёты и выбранный результат | - -Метрики системы: частота отказов, частота действий, число нарушений фильтра, время одного цикла. **Всеотказывающаяся система не считается готовой:** нужны и рабочие допустимые сценарии, и корректные отказы. +>>>>>>> d0bbf23 (Update project sources and materials) +Уточнения встречи 11.09 и незакрытые вопросы собраны в [QA_CLARIFICATIONS.md](QA_CLARIFICATIONS.md). +Они дополняют этапы выше, не подтверждают новые возможности runtime. + +| Приоритет после Q&A | Backend | ML | Критерий | +| --- | --- | --- | --- | +| Проверить семантику входов | Сохранять происхождение и исполнять запреты для неподтверждённых действий | Разрешить противоречия `P8/T11/F19`, `F25`, `Pipeline`, карты точек и формул на контрольных примерах | Связанные единицы/формулы подтверждены; отсутствие ответа явно остаётся блокером | +| Уточнить применимость прогноза | Показать причины отказа и воспроизвести согласованные эпизоды | Проверить режимы и задержки с учётом обратной связи; не выводить причинный эффект из корреляции | Общие входы/выходы воспроизводимы; прогноз и основание для действий разделены | +| Подготовить защиту | Проверить расчёт без внешней сети на подготовленном окружении; описать взаимодействия компонентов | Представить метрики, ограничения и условность модельного эффекта | Есть фактический результат офлайн-проверки и описание архитектуры | + +Обоснованные инженерные модели разрешены; LLM не обязательна. Реальные коэффициенты +воздействий, паспортные пределы, межустановочная задержка и экономическая функция +по вопросам без ответов не назначаются. Пуск/останов вне проверенной области +применимости требует предупреждения/отказа от совета; конкретный порог режима ещё +нужно обосновать. Подробная передача между двумя исполнителями — в разборе Q&A. + +### Правила для всех этапов + +- Сера товарной смеси — не более **10 мг/кг**; остальные ограничения берутся из подтверждённой спецификации выбранного сценария. +- Экономическая выгода не компенсирует нарушение жёсткого ограничения. +- Приоритет источников — ЛИМС → ПАК → ВАК, с учётом пригодности, свежести и времени доступности. Лабораторный факт сохраняется даже при расхождении с ПАК. +- Исторические границы — только модельные ограничения, пока нет подтверждения промышленного диапазона. +- Запрещены использование будущих анализов, заполнение признаков из будущего и обучение на размноженных через forward-fill лабораторных целях. +- Состояние «нет данных» не превращается в «нарушений нет». +- `hold` означает обоснованное сохранение режима; `abstain` — невозможность надёжно рекомендовать действие. + +### Приёмочные сценарии + +| Сценарий | Ожидаемый результат | +| --- | --- | +| Устойчивый допустимый режим | Сохранение режима без лишних действий | +| Риск превышения серы | Допустимый кандидат либо объяснённый отказ | +| Риск превышения T95 | Рецептура с `T95_upper` в пределах модельной границы | +| Низкое цетановое число | Рецептура/доза с `CN_lower` в пределах и учётом дорогой присадки | +| Все кандидаты нарушают ограничения | Отказ независимо от выгоды | +| Устаревшие или отсутствующие данные | Корректное снижение доверия либо отказ | +| Конфликт ЛИМС и ПАК | Видимое расхождение и применение зафиксированного правила | +| Отрицательная доля или сумма долей ≠ 1 | Отклонение рецептуры | +| Неподтверждённый тег, неверная единица | Запрет соответствующего расчёта или действия | +| Состояние вне области применимости | Отказ от неподтверждённого прогноза действий | +| Повтор одного запуска | Одинаковые расчёты и выбранный результат | + +Метрики системы: частота отказов, частота действий, число нарушений фильтра, время одного цикла. **Всеотказывающаяся система не считается готовой:** нужны и рабочие допустимые сценарии, и корректные отказы. diff --git a/PHYSICAL_CHEMISTRY_REPORT.md b/PHYSICAL_CHEMISTRY_REPORT.md new file mode 100644 index 0000000..0ee8768 --- /dev/null +++ b/PHYSICAL_CHEMISTRY_REPORT.md @@ -0,0 +1,199 @@ +# Физико-химический смысл Нефтекода + +## Вывод + +Проект моделирует не абстрактные таблицы, а цепочку превращения нефти в дизельное топливо: АВТ выделяет дизельную фракцию по летучести, гидроочистка удаляет сероорганические соединения и изменяет состав топлива, после чего блендинг собирает товарную партию. Главная технологическая цель в выданном ТЗ - массовая сера в дизельном топливе не выше 10 мг/кг. Модель полезна прежде всего как раннее предупреждение: она может заметить, что через час качество окажется у границы, раньше лабораторного подтверждения. + +Важно различать три уровня достоверности. + +1. Измерения ПАК, ЛИМС и телеметрия описывают реальное наблюдаемое состояние. +2. Прогноз серы на 60 минут - статистическая оценка будущего измерения, а не доказательство того, что изменение уставки даст нужный эффект. +3. Расчёт S/T95/CN и дозы присадки - синтетическая модель демонстратора. Его физические формулы полезны, но его коэффициенты, запасы и пределы не являются паспортом реального завода. + +## Что происходит в установке + +```text +сырая нефть + -> обессоливание и нагрев + -> АВТ, ректификация по температурам кипения + -> дизельная фракция примерно 240-350 C + -> гидроочистка с H2 на катализаторе + -> гидроочищенный компонент дизеля + -> резервуарное смешение компонентов и присадки + -> товарное дизельное топливо +``` + +### АВТ + +В атмосферной колонне К-2 нагретая нефть частично испаряется. Внизу остаются наиболее тяжёлые молекулы, выше конденсируются более лёгкие. Температурный профиль колонны, давление, орошение и отпарной пар сдвигают границы отсечки: сколько молекул попадёт в каждую фракцию. На выданной схеме дизельный поток подписан как фракция 240-350 C; рядом показаны расход, лабораторные свойства и точки T95/плотности/серы. + +Физический смысл ключевых сигналов АВТ: + +| Сигнал/свойство | Что означает физически | Почему может быть важно | +| --- | --- | --- | +| Температуры К-2, печи, боковых погонов | Где лежит граница испарения и конденсации | Меняют состав дизельной отсечки и её кривую кипения | +| Давление верха/низа колонны | Равновесие жидкость-пар | Сдвигает температуры кипения и разделение фракций | +| Орошение и циркуляционное орошение | Теплоотвод и внутренний рефлюкс | Делает разделение резче, но влияет на тепловой баланс | +| Расход фракции 240-350 C | Количество дизельного компонента | Нужен для материального баланса и времени прохождения | +| T95, плотность, сера | Паспорт конкретной отсечки | Это не уставки, а свойства результата разделения | + +Новые листы организаторов от 14.09.2026 делают эту картину конкретнее: + +- К-1 получает обессоленную нефть после ЭЛОУ; `F7/F8/F9` относятся к трём + параллельным ходам подачи, а `T1/P2/F3` описывают верх и орошение К-1; +- после печей П-1/2 и П-1/3 поток поступает в К-2; `F65` описывает загрузку, + `T20/T33` - температурный профиль, а группы `F14/F12/F64` - три + циркуляционных орошения; +- возле боковой К-9 и ветви ДТ 240-350 °C находятся `T66/F28/F32` и + `T71/F30/W70`; это физически осмысленная группа кандидатов для отдельной + ablation, но не доказанный вход гидроочистки; +- мазут из К-2 через П-3 поступает в вакуумную К-10. Теги К-10 полезны для + диагностики общего режима АВТ, но расположены после атмосферной дизельной + отсечки и не должны автоматически попадать в краткосрочный прогноз серы 24-2000. + +Полная постраничная карта находится в +[разборе схем](SCHEME_ANALYSIS_2026_09_14.md). Схемы не содержат привязки +ЛИМС-точек 1/2/2.1/3 и не показывают маршрут от ветви ДТ до 24-2000. + +АВТ почти не уничтожает серу: она перераспределяет серосодержащие молекулы между фракциями. Поэтому тяжёлая, более высококипящая отсечка часто несёт иной серный профиль, чем лёгкая. Но из одного факта «стало тяжелее по T95» нельзя количественно восстановить серу без подтверждённого состава и данных. + +### Гидроочистка 24-2000 + +Центральная химическая реакция - гидрообессеривание (HDS). Сероорганические соединения дизельной фракции на сульфидном CoMo/NiMo-катализаторе реагируют с водородом; C-S связь разрушается, а сера уходит как H2S. Упрощённо: + +```text +R-S-R' + H2 -> углеводороды + H2S +``` + +В действительности в сырье есть тиофены, бензотиофены и дибензотиофены. Простые сернистые соединения удаляются легче; стерически экранированные дибензотиофены требуют гидрирования ароматического кольца либо труднее доступного прямого пути десульфуризации. Поэтому последние миллиграммы серы к пределу особенно «дороги» по водороду, температуре и ресурсу катализатора. + +Для HDS одновременно существенны температура, парциальное давление H2, отношение H2/сырьё, объёмная скорость сырья и состояние катализатора. Умеренно более высокая температура, высокое давление водорода и большее время контакта обычно повышают глубину HDS, но это не означает правило «поднять температуру всегда хорошо»: растут энергозатраты, побочные реакции, риск закоксовывания/дезактивации и меняется весь тепловой режим. [Weng et al., 2020](https://pubs.acs.org/doi/10.1021/acs.iecr.0c04049) + +По скорректированному словарю организаторов от 15.09.2026 `ht:P8` — перепад +давления в реакторе Р-202 (МПа), `ht:F19` — расход бензина в колонну К-201 +(т/ч), а `ht:T11` — температура продукта на выходе реактора (°C). Для +action-effect исследования P8 и F19 остаются единственными историческими +кандидатами: эффект считается по реальным эпизодам и физически допустимым лагам, +но не является причинной гарантией. T11 и `ht:F26` (объёмный выход +гидроочищенного дизеля) используются только как контекст; ни один сигнал не +получает право менять уставки без подтверждённых инженерных границ и пилота. + +`ht:Q21` — сера на выходе гидроочистки в ppm. Значение 307 ppm подтверждено +как выброс; оно удаляется из ML-признаков только явным правилом по сигналу, а +исходная телеметрия и provenance сохраняются. + +### Что меняется во времени + +У причинной цепочки есть инерция: + +```text +смена состава/режима +-> смешение в аппаратах и трубопроводах +-> реактор и катализатор +-> сепарация H2/H2S/жидкости +-> резервуар/линия продукта +-> ПАК или лабораторная проба +-> публикация результата +``` + +Поэтому «значение серы сейчас» и «результат действия сейчас» - разные вещи. ПАК может обновляться быстро, ЛИМС даёт более надёжный контрольный результат позднее. В проекте эта физическая задержка отражена через `measured_at` и `available_at`: лабораторная проба не должна попадать в признаки до того, как её реально могли узнать. + +Q&A 15.09 уточняет две практические задержки. ЛИМС помечает момент отбора +пробы, а публикация обычно занимает 3–4 часа; это задержка доступности, а не +сдвиг измерения. Между АВТ и гидроочисткой продукт находится в резервуарном +парке обычно несколько часов, максимум около суток. Без точной схемы, объёма и +распределения времени эти сведения задают только широкий контекст: они не +разрешают подбирать причинный lag корреляционным поиском и не отменяют +`available_at <= as_of`. + +## Зачем нужны S, T95 и цетановое число + +### Сера S + +Сера здесь измеряется массово в мг/кг (для массового ppm число то же). Это ключевой жёсткий лимит ТЗ. Низкая сера снижает образование SOx/сульфатной составляющей выбросов и нужна для совместимости с современными системами очистки выхлопа; для ориентира, стандарт ULSD США задаёт максимум 15 ppm, а в задании принят более строгий лимит 10 мг/кг. [EPA](https://www.epa.gov/diesel-fuel-standards/diesel-fuel-standards-and-rulemakings) + +Для решения важна не только точка прогноза, но и консервативная верхняя граница. Если точка равна 9.5, а верхняя оценка выше 10, безопасный контур должен не разрешать смесь как доказанно проходящую. Именно так работает `unknown`, а не ложный `pass`. + +### T95 + +T95 - температура, при которой в стандартной перегонке отогнано 95% объёма топлива. Это компактное описание тяжёлого хвоста: чем выше T95, тем больше тяжёлых высококипящих молекул. Он связан с испаряемостью, склонностью к неполному сгоранию и совместимостью с требованиями к фракционному составу. В демонстраторе граница 360 C - сценарное допущение; нельзя называть её подтверждённой спецификацией конкретного продукта без ГОСТ/паспорта предприятия. + +### Цетановое число CN + +Цетановое число характеризует склонность топлива к самовоспламенению в дизельном двигателе: больше CN - короче задержка воспламенения. Это влияет на фазу тепловыделения, холодный пуск и работу двигателя. [NREL](https://afdc.energy.gov/files/u/publication/mixing_controlled_compression_ignition.pdf?743aa3e783=) + +CN не является прямой функцией одной только плотности или T95. Формульный cetane index можно оценивать из плотности и фракционного состава, но он не заменяет измеренное цетановое число, особенно для нестандартных компонентов и при наличии цетаноповышающей присадки. [NREL](https://www.nrel.gov/docs/fy04osti/36240.pdf) + +В QA 15.09.2026 подтверждены профили дизеля: после гидроочистки плотность +820–845 кг/м³, ЦЧ не нормируется; для летнего товарного ДТ плотность +820–845 кг/м³ и ЦЧ ≥ 51; для зимнего товарного ДТ плотность 800–845 кг/м³ и +ЦЧ ≥ 49. Эти пределы реализованы как `ProductGrade`-профили, но при отсутствии +паспорта плотности/ЦЧ результат остаётся `unavailable`, а не заменяется +синтетической оценкой. + +## Блендинг: где физика, а где приближение + +Для массовой концентрации серы физически корректен материальный баланс при известной массе каждого компонента: + +```text +S_mix = sum(w_i * S_i), sum(w_i) = 1 +``` + +Это верно потому, что масса серы сохраняется при простом смешении без химической реакции. Поэтому лучший практический модуль проекта - проверка масс, запасов и верхней границы серы для партии. + +Линейное усреднение T95 и CN в текущей реализации - не универсальный физический закон. Кривая кипения смеси и особенно фактическое CN могут вести себя нелинейно; эффект присадки зависит от химии топлива, дозы и метода измерения. Поэтому сценарная таблица «доза -> прирост CN» допустима только как калиброванный паспорт конкретной присадки и конкретной базы. Пока такой паспорт не получен, результат должен оставаться `model_demo`. + +Также глубокая гидроочистка может ухудшать смазывающую способность; это отдельное качество, которого нынешние S/T95/CN не покрывают. Например, спецификации ULSD включают отдельное требование к смазывающей способности, а малые доли biodiesel иногда применяются для её улучшения. [NREL/AFDC](https://afdc.energy.gov/fuels/biodiesel-benefits) + +Для экономической части Q&A называет повторную переработку некондиционного +продукта ориентировочно в 50–100 раз дороже обычного запаса качества. Это +объясняет safety-first приоритет и полезно как направление будущего cost-модуля, +но без подтверждённых единиц, объёмов и тарифов не является числом для +оптимизации или ранжирования в production. + +## Как физика улучшает именно этот проект + +### Уже правильные решения + +- Разделение ПАК и ЛИМС защищает от ложного ощущения точности: ПАК - оперативная траектория, ЛИМС - отложенный контрольный факт. +- Прогноз и модель эффекта действия разделены. Историческая корреляция «после роста серы подняли температуру» показывает реакцию оператора, а не эффект температуры. +- Верхняя граница серы применяется раньше ранжирования. Производительность и цена не могут компенсировать нарушение S. +- `abstain` при неподтверждённом теге, единице, запасе или интервале - технологически честнее произвольной рекомендации. + +### Следующие полезные улучшения + +| Приоритет | Что добавить | Физический смысл | Практический эффект | +| --- | --- | --- | --- | +| 1 | Достроить карту от ветви ДТ К-2 до 24-2000 и точек ЛИМС | Внутренняя топология АВТ теперь известна, но межустановочный маршрут, смешение и точки отбора всё ещё не показаны | Убирает риск учить модель на физически несвязанных потоках | +| 2 | Время пребывания и transport lag по участкам | Сигнал должен попасть в будущую пробу после физически возможной задержки | Более осмысленный горизонт, меньше ложных причинных связей | +| 3 | Баланс H2 и индикаторы реактора | H2/сырьё, давление, температура и расход определяют тяжесть HDS | Нужные признаки для прогноза и будущей модели последствий | +| 4 | Деактивация катализатора | Один и тот же режим с течением кампании даёт другое качество | Добавляет time-on-stream/суррогат кампании и снижает drift | +| 5 | Реальный паспорт компонентов и присадки | Нелинейность T95/CN и смазывающая способность нельзя заменить общим коэффициентом | Позволяет перенести блендинг из demo в ограниченный промышленный advisory | +| 6 | Event-based causal study изменений | Сравнить похожие эпизоды до/после разрешённых изменений с учётом трендов и задержек | Единственный путь от прогноза к безопасной рекомендации уставки | + +## Минимальный физический пилот + +1. Технолог вместе с аналитиком рисует потоковую карту: источник, аппарат, точка ПАК, точка ЛИМС, резервуар и товарный выпуск для каждого сигнала. +2. Для каждого перехода фиксируются объём/масса, нормальный расход и диапазон времени прохождения. Это задаёт разрешённые лаги, а не поиск максимальной корреляции по всем сдвигам. +3. На истории сравниваются: persistence последней серы, прогноз только по прошлой сере и прогноз с подтверждёнными технологическими признаками. Побеждает не модель с красивым средним MAE, а модель с меньшим числом пропущенных переходов через 10 мг/кг. +4. Проводится shadow mode: система только предупреждает и журналирует, оператор продолжает работать по регламенту. ЛИМС проверяет, насколько хорошо предупреждения попадали в реальные нарушения. +5. Только после накопления задокументированных эпизодов изменений можно отдельно проверять эффект конкретной уставки. До этого UI должен говорить «наблюдаем риск», а не «подними температуру». + +## Открытые вопросы, которые нельзя закрыть данными проекта в одиночку + +- Точный маршрут между АВТ и 24-2000, точки смешения и резервуарная логистика. +- Единицы, нормальные диапазоны и допустимые скорости изменения `ht:P8`, `ht:T11`, `ht:F19`; управляемость `ht:F26`. +- Тип и возраст катализатора, фактический H2/сырьё, давление водорода, состав сырья и H2S. +- Реальные товарные ограничения сверх серы: T95, CN, плотность, вспышка, низкотемпературные свойства, смазывающая способность. +- Паспорт присадки и результаты стендовых/лабораторных смешений. + +Пока на эти вопросы нет утверждённого ответа, правильное применение системы - раннее предупреждение, воспроизводимая проверка качества и безопасный расчёт сценарных смесей, а не выдача команд установке. + +## Источники + +1. Техническое задание проекта, `materials/ТЗ_нефтекод.docx`; технологические схемы АВТ, `materials/АВТ_схемы.pdf`; словарь точек, `materials/Теги_хакатон.xlsx`. +2. [Weng, X. et al. Ultradeep Hydrodesulfurization of Diesel](https://pubs.acs.org/doi/10.1021/acs.iecr.0c04049), Industrial & Engineering Chemistry Research, 2020. +3. [US EPA Diesel Fuel Standards and Rulemakings](https://www.epa.gov/diesel-fuel-standards/diesel-fuel-standards-and-rulemakings). +4. [NREL Measurement or Prediction of Cetane Number](https://afdc.energy.gov/files/u/publication/mixing_controlled_compression_ignition.pdf?743aa3e783=). +5. [NREL Diesel Fuel Properties and Cetane Index](https://www.nrel.gov/docs/fy04osti/36240.pdf). +6. [NREL Alternative Fuels Data Center Biodiesel Benefits and Considerations](https://afdc.energy.gov/fuels/biodiesel-benefits). diff --git a/PROJECT_GUIDE.md b/PROJECT_GUIDE.md index a886049..cbee95f 100644 --- a/PROJECT_GUIDE.md +++ b/PROJECT_GUIDE.md @@ -10,6 +10,10 @@ оператору безопасное действие. Если безопасного действия нет или данных не хватает, система должна честно отказаться от рекомендации. +Актуальное финальное расширение: `model_demo` проверяет S/T95/CN, перебирает присадку +0–3% и выдаёт `hold`/`recommend`; `abstain` остаётся для неполного паспорта. Все эти +эффекты относятся только к объявленной синтетической модели. + --- ## 1. Суть проекта в одну минуту @@ -583,8 +587,8 @@ blend Например: ```text -sulfur = 8.4 мг/кг -upper = 9.4 мг/кг +sulfur = 8.316 мг/кг +upper = 9.306 мг/кг basis = formula interval_kind = scenario_bound ``` @@ -1141,20 +1145,23 @@ upper <= 10 мг/кг Текущая рецептура: ```text -A = 0.9 -B = 0.1 +A = 0.891 +B = 0.099 +присадка = 0.01 ``` Верхняя оценка серы: ```text -9.4 мг/кг +S upper = 9.306 мг/кг +T95 upper = 354 °C +CN lower = 52.6 ``` Результат: ```text -abstain (T95 и цетановое число не оценены) +hold ``` ### `blend_risk` @@ -1162,23 +1169,23 @@ abstain (T95 и цетановое число не оценены) Текущая рецептура: ```text -A = 0.7 -B = 0.3 +A = 0.693 +B = 0.297 +присадка = 0.01 ``` Верхняя оценка серы: ```text -14.2 мг/кг +14.058 мг/кг ``` -Это выше 10, значит `hold` недопустим. Но операторская рекомендация всё равно -блокируется: T95 и цетановое число не оценены. +Это выше 10, значит `hold` недопустим. Оптимизатор проверяет также T95 и CN. Результат: ```text -abstain (диагностический кандидат A=0.9, B=0.1 не является рекомендацией) +recommend: A=0.891, B=0.099, присадка=0.01 ``` ### `blend_missing` @@ -1667,12 +1674,12 @@ Stage 4 связывает прогноз гидроочистки с модел - прогноз серы гидроочищенного компонента заменяет компонент `A`; - рецептуры пересчитываются по массовому балансу; - верхняя граница серы смешивается как консервативная сценарная граница; -- рецепты с нарушением серы, отсутствующей uncertainty или нехваткой запаса не +- T95/CN считаются по явной линейной модели, присадка — по сценарной кривой; +- рецепты с нарушением S/T95/CN, отсутствующей границей или нехваткой запаса не попадают в ranking; -- `T95`, цетановое число и полный паспорт товарного продукта остаются - `not_assessed`. +- `assessed` означает только полноту синтетического паспорта, не промышленную гарантию. -Газовые теги `ht:F9`, `ht:F22`, `ht:Q21` теперь зафиксированы как gas context. +Газовые теги `ht:F2`, `ht:F22`, `ht:F25` теперь зафиксированы как gas context. Это наблюдаемые технологические сигналы, а не безопасные controls. Их нельзя рекомендовать менять, пока нет подтверждённых единиц, инженерных диапазонов, max step и модели эффекта действия. diff --git a/QA_CLARIFICATIONS.md b/QA_CLARIFICATIONS.md new file mode 100644 index 0000000..373adbb --- /dev/null +++ b/QA_CLARIFICATIONS.md @@ -0,0 +1,135 @@ +# Q&A Нефтекод: уточнения для реализации + +Встреча: **11.09.2026**. Разбор предоставленных файлов: **14.09.2026**. +Дополнительная Q&A-запись: **15.09.2026**. +Документ дополняет [дизайн](DESIGN.md) и [план](IMPLEMENTATION_PLAN.md). +Он фиксирует свидетельства и решения команды, а не завершение реализации. + +## Источники и достоверность + +- **Т** — `Транскрипция_Запись_11_09_Q&A_Сессия_Нефтекод.txt`, заголовок датирует запись 11 сентября 2026 года; ссылки ниже — таймкоды записи. +- **Ч** — `13.09 вопросы из чата.docx`; содержит вопросы участников с временем сообщений, **без письменных ответов экспертов**. Дата имени файла не доказывает отдельную встречу 13 сентября. Ссылки ниже — время сообщения и его тема. +- **С14** — три схемы АВТ, присланные организаторами 14.09.2026; отдельные листы К-1, К-2 и К-10 с рукописными короткими именами тегов. Схемы являются техническим свидетельством по АВТ, но не ответом по 24-2000. +- **В15** — видеозапись `Запись 15.09 Q&A Сессия Нефтекод.mp4`; SHA-256 + `6c3a939f51b604ef135c23803b62d0c124bfc618ac883be75044cfad3af6b2cc`. + Автоматическая расшифровка используется только как рабочее свидетельство: + неразборчивые фразы не считаются письменным подтверждением. + +Исходники переданы отдельно от репозитория. SHA-256 для идентификации версии: + +```text +Т: 551e09d02d3c9fe42350513766adb06add3f4b66a92e8678a9f051c3b6af6d4f +Ч: 7caa8ee4ee16dc8eaa57169178a00b936fc9f678d7bd1d70d7d54d1675ffec57 +В15: 6c3a939f51b604ef135c23803b62d0c124bfc618ac883be75044cfad3af6b2cc +``` + +В транскрипции многие ответы Виталия Пампуры представлены одиночными словами +или отсутствуют. Реплика ведущей «спасибо за ответ» не восстанавливает содержание. +Числа и гипотезы из вопросов не считаются экспертным подтверждением или нашими +проверенными метриками. Прежние ответы от 10.09 сохраняются с оговорками ниже; +неразборчивая запись не заменяет их новой трактовкой. + +## Что подтверждено + +### Письменное дополнение организаторов от 15.09.2026 + +Эти ответы имеют приоритет над прежними спорными трактовками тегов: + +- `Q21` — содержание серы на выходе гидроочистки, ppm; значение 307 — выброс. + В подготовленных данных оно маскируется из ML только явным правилом для + `ht:Q21`, а исходный ряд и provenance сохраняются. +- Для дизельного топлива после гидроочистки плотность 820–845 кг/м³, цетановое + число не нормируется. +- Для летнего товарного ДТ плотность 820–845 кг/м³, цетановое число не менее 51. +- Для зимнего товарного ДТ плотность 800–845 кг/м³, цетановое число не менее 49. +- В скорректированном словаре 24-2000: `P8` — перепад давления Р-202, + `F19` — расход бензина в К-201, `T11` — температура продукта на выходе + Р-202, `F26` — объёмный выход гидроочищенного ДТ. Это не инженерное + разрешение на управление: P8/F19 доступны только историческому исследованию, + все реальные действия остаются выключены. +- Организаторы отдельно исправили размещение `F31` на схеме К-10; старую + позицию нельзя использовать для топологических выводов. На схеме К-2 + размещение тегов признано корректным. ML не включает `avt:F31` в оперативный + прогноз или action-effect модель. + +### Дополнительные ответы из Q&A 15.09.2026 + +Видео частично закрывает вопросы, которые в записи 11.09 остались без ответа: + +- Метка ЛИМС — момент отбора пробы. До публикации результата проходит примерно + 3–4 часа. Поэтому `measured_at` не сдвигается, а в признаки попадает только + результат с `available_at <= as_of`. +- Технологический запас по сере зависит от автоматизации и установки; эксперт + назвал ориентир 1–2 ppm. Это не паспортный предел конкретной установки, поэтому + в коде он остаётся настраиваемым `sulfur_margin_mgkg` в диапазоне 1–2, а не + жёстко приписывается тегу или режиму. +- Между АВТ и гидроочисткой пребывание продукта в резервуарном парке может быть + до суток, обычно — несколько часов. Точная карта потоков, объём и распределение + времени не названы, поэтому межустановочный lag не фиксируется как причинный; + для action-study сохраняются только проверяемые локальные лаги. +- Некондиционный продукт может потребовать повторной переработки и быть + ориентировочно в 50–100 раз дороже обычного запаса качества. Это направление + для будущей cost-модели, но не подтверждённый production-тариф: UI не + оптимизирует по нему без единицы стоимости. +- Организаторы ожидают, что команда сама задаст сценарии и покажет реакцию + симулятора; линейный симулятор допустим как первый шаг, затем сценарии могут + менять эксперты. В проекте это покрывается воспроизводимым сценарным UI и + историческим расчётом эффекта P8/F19 с явной пометкой «не совет»; causal + controller и изменение уставок по-прежнему запрещены. + +Автоматическая расшифровка содержит артефакты распознавания имён и терминов. +Числа выше приняты только там, где они согласуются с письменными ответами или +однозначно произнесены в записи; спорные трактовки остаются открытыми. + +| Уточнение | Основание | Следствие для проекта | +| --- | --- | --- | +| Производственная технологическая сеть не связана с внешним интернетом; решение должно работать локально | Т 00:03:44, Александр Кызродев | Обязательный расчётный путь работает офлайн. Зависимости, данные и модели готовятся до переноса в закрытую среду. | +| LLM и ML не обязательны в каждом компоненте; обоснованные инженерные, физические и химические модели допустимы | Т 00:23:41, Александр Шишкин; общее отсутствие ограничений по подходам — 00:03:44 | Сохраняем численные агенты и детерминированный оркестратор. Формулы допустимы при подтверждённых входах и области применения. | +| Во внутренней сети есть локальные LLM; назван ориентир до примерно 30 млрд параметров | Т 00:04:38, 00:07:09, Александр Шишкин | Это возможная будущая интеграция, не обязательная зависимость. Команде не обещаны конкретные GPU, доступ или скорость. Названия моделей и точный протокол API в записи неразборчивы. | +| Важны архитектурная грамотность и запуск в закрытой среде | Т 00:13:00, Александр Шишкин | На защите нужны поток данных, взаимодействие ролей, ограничения и воспроизводимость наряду с метриками. Веса критериев не сообщены в доступном тексте. | +| Точки ЛИМС АВТ относятся к разным местам/стадиям процесса, а не к разным временным меткам | Т 00:09:02–00:09:33, Александр Шишкин | Сохраняем идентичность точки отбора; не объединяем анализы только по названию продукта. Точная привязка всех номеров к потокам ещё требует схемы/таблицы. | +| Теги 24-2000 не относятся к схеме АВТ | Т 00:11:16, Александр Шишкин | Пространства `avt:*` и `ht:*` остаются раздельными; по схеме АВТ нельзя подтверждать смысл тега гидроочистки. | +| На АВТ и 24-2000 работают системы управления с обратной связью, быстро реагирующие на изменения качества/режима | Т 00:16:15, Александр Шишкин | **Вывод команды:** историческая корреляция может отражать реакцию управления. Обычный прогноз и доказательство эффекта вмешательства остаются разными задачами. Численная скорость реакции не подтверждена. | +| Для гидроочистки описаны точки отбора до и после установки | Т 00:22:34, Александр Шишкин | Различаем сырьё и продукт. Общий ответ не доказывает точное соответствие имени `Pipeline` номеру точки. | +| Короткие `avt:*` локализованы на К-1, К-2 и К-10; на К-2 отдельно показана ветвь ДТ 240-350 °C, мазут уходит через П-3 на К-10 | С14; [постраничный разбор](SCHEME_ANALYSIS_2026_09_14.md) | Можно задавать топологические группы AVT-признаков и проверять внутреннюю согласованность датчиков. Межустановочный маршрут к 24-2000 и номера ЛИМС-точек не установлены. | + +Реплика Т 00:21:14, вероятно, предлагает расширять набор ВАК по лабораторным +и технологическим данным, но термин распознан как «баг». Это направление для +уточнения, а не подтверждение конкретных формул или доступности новых целей. + +## Незакрытые вопросы и границы использования + +| ID | Вопрос и свидетельство | Что требуется; поведение до ответа | +| --- | --- | --- | +| Q1 | АВТ: схема с позициями, точная карта ЛИМС 1/2/2.1/3, соответствия тегов аппаратам и потокам. Ч 16:08, вопросы 1–2; 16:09; С14 | **Частично закрыт:** С14 связывает многие `avt:*` с аппаратами и линиями К-1/К-2/К-10. Остаются единицы, точная карта ЛИМС 1/2/2.1/3, маршрут дизеля до 24-2000 и подтверждение доступных оператору AVT-controls. | +| Q2 | `ht:P8`, `ht:T11`, `ht:F19`: спор с прежним ответом. Ч 16:08, вопрос 3; 16:19 | **Закрыт письменно 15.09:** используется скорректированный словарь 24-2000 с P8=перепадом давления, F19=расходом бензина, T11=температурой продукта. Инженерные пределы и право управления по-прежнему не подтверждены. | +| Q3 | Непроверенные ВАК, контрольный пример; `F25` с заявленной участником медианой около 13091; отсутствующее имя `Pipeline` в формуле T95. Ч 16:08, вопросы 4–5; 16:22 | Нужны проверенные формулы, единицы и пример входов/выхода, точка для `Pipeline`. Не исправлять масштаб `F25` или привязку `Pipeline` догадкой; для `F25` сначала установить пространство имён. Ранее принятые исправления не означают валидацию всего набора ВАК. | +| Q4 | Температура → сера: участники приводят 0.03 мг/кг на 1 °C, сравнивают с 5–10%, предполагают отклик около часа и реакцию оператора за полчаса. Ч 16:11, 16:14; Т 00:13:20–00:16:15 | Нужны данные о динамике и воздействиях с учётом обратной связи. Ни один коэффициент или точная задержка не подтверждены записью; не переносить их в action model. | +| Q5 | Возможный промежуточный резервуар между АВТ и гидроочисткой; отсутствие отклика на лагах 0–12 ч по анализу участника. Ч 16:17; В15 00:08:11–00:08:28 | **Частично закрыт:** эксперт сообщил, что пребывание обычно несколько часов, максимум около суток. Точный маршрут, объём и распределение времени не названы; автоматический межустановочный lag не становится причинным признаком. | +| Q6 | Паспортные пределы оборудования, допустимые диапазоны и шаги уставок. Ч 16:08, вопрос 3; 16:27; Т 00:25:13 | Нужен письменный перечень пределов и единиц. Ответ в транскрипции не сохранился; исторические квантили не становятся паспортными ограничениями. | +| Q7 | Более частые измерения серы сырья и испытания катализатора; участник сообщает 132 пробы и неустойчивую кинетическую оценку. Ч 16:27 | Нужны доступные измерения/испытания и проверка идентифицируемости модели. Количество проб и оценка энергии активации — данные вопроса, не результат нашего анализа. Размножение редких проб не создаёт новые обучающие цели. | +| Q8 | Что происходит с некондиционной партией, цена ошибки, потери выпуска и избыточной очистки. Ч 16:08, вопрос 6; 16:30; В15 00:07:33–00:08:10 | **Частично закрыт:** повторная переработка названа существенно дороже, ориентир — 50–100× против запаса качества. Это не подтверждённый тариф; сохраняем прозрачные прокси и жёсткие ограничения, цель «вести серу вплотную к 10» не вводится. | +| Q9 | Конкретный стандарт качества и обязательность дашборда. Т 00:04:53–00:05:39; Ч 16:30, вопрос об интерфейсе | Содержательные ответы отсутствуют. Сохраняются требования ТЗ, предел серы 10 мг/кг и текущий UI; нельзя объявлять остальные показатели необязательными или подтверждать полный ГОСТ по этой записи. | +| Q10 | Поведение при останове/пуске; участник оценивает простой в 3–4% и падение температуры почти до нуля. Ч 16:33 | Нужны подтверждённые признаки режимов и требования к ним. **Решение команды:** без проверенной применимости — предупреждение/отказ от совета, не «нарушений нет». Эти проценты и температурный порог не становятся правилами детектора. | +| Q11 | Как организаторы проверят воздействия и нужен ли симулятор. В15 00:08:32–00:12:04 | Команда сама задаёт воспроизводимые сценарии; линейный симулятор допустим как baseline, эксперты могут менять сценарии на защите. Это усиливает требования к UI/replay, но не разрешает выдавать causal-рекомендации без action-gate. | + +## Работа backend и ML после встречи + +Приоритеты уточняют существующие этапы; новая инфраструктура ради Q&A не вводится. + +| Очерёдность | ML передаёт | Backend обеспечивает | Условие завершения | +| --- | --- | --- | --- | +| 1. Источники и семантика — Q1–Q3, Q6 | Для каждого спорного входа: источник, проверенное значение/единица, статус противоречия, зависимые признаки/формулы | Сохранение provenance, блокирование неподтверждённых действий, совместимость словаря и артефактов | Оба воспроизводят контрольный пример; неподтверждённые соответствия не получают статус подтверждённых по догадке | +| 2. Прогноз и режимы — Q4, Q5, Q7, Q10 | Отчёт по временным срезам, пуску/останову и отклику; отдельно гипотезы, прогнозная точность и основания для действий | Отображение применимости/причин отказа и воспроизводимый replay тех же эпизодов | Совпадают входы и результаты на общих timestamp; ограничения явно видны, `supports_actions` не включается от улучшения MAE | +| 3. Защита и офлайн-поставка | Метрики, ограничения данных/модели, условность модельного эффекта | Подготовленные зависимости/артефакты, инструкция локального запуска и схема взаимодействий | На заранее подготовленном окружении расчётный путь проходит без внешней сети; фактический результат проверки фиксируется при её выполнении | +| 4. Только после получения подтверждений — Q4–Q9 | Проверенная модель действий, инженерные пределы, спецификация и экономика | Согласованное подключение через существующие контракты и единый фильтр | Есть отдельные свидетельства применимости действий; прогнозная точность сама по себе этап не закрывает | + +Статус открытого вопроса меняется только со ссылкой на содержательный ответ или +воспроизводимую проверку. ML отвечает за смысл, backend — за исполнение правила; +изменения общих файлов передаются по протоколу [DESIGN.md, раздел 14](DESIGN.md#14-работа-двух-исполнителей). + +## Организационное + +- На отборочном этапе допустимо описание решения вместо презентации; для финала презентация обязательна. Т 00:11:41. Точное название формата файла в записи искажено; требований к объёму, шаблону и roadmap здесь нет. +- Проезд и проживание не оплачиваются; при невозможности приехать предусмотрена онлайн-защита. Т 00:16:47. +- Организаторы обещали дополнить ответы в чате. Т 00:10:58, 00:20:01, 00:34:46–00:35:51. В переданных файлах этих письменных ответов нет. diff --git a/README.md b/README.md index 6edbd17..c9e6df2 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,50 @@ - [Актуальные проблемы]() - найденные ошибки, пробелы проверок, ограничения демо и статус исправлений. - [План реализации](IMPLEMENTATION_PLAN.md) — этапы, сроки и разделение работы между backend и ML. - [Технический дизайн](DESIGN.md) — архитектура, структура файлов, типы данных, взаимодействие компонентов и проверки. +- [Уточнения Q&A 11.09 и 15.09](QA_CLARIFICATIONS.md) — подтверждённые ответы, рабочая расшифровка дополнительной записи, открытые вопросы и последствия для backend/ML. +- [Разбор схем АВТ 14.09](SCHEME_ANALYSIS_2026_09_14.md) — привязка `avt:*` к К-1/К-2/К-10, полезные ML-группы и границы доказательств. - [Гайд по проекту](PROJECT_GUIDE.md) — объяснение с нуля: суть ТЗ, роли backend/ML, объекты, этапы и рабочий процесс. - [Stage 1](STAGE1.md) — первый сквозной backend-цикл, demo-сценарии, журнал и ограничения этапа. - [Stage 2](STAGE2.md) — как оригинальные материалы превращаются в prepared dataset и `ProcessState`. - [Stage 3](STAGE3.md) — hard constraints, причины отбраковки кандидатов, materiality и cooldown. - [Stage 4](STAGE4.md) — hybrid chain, model blending и газовые теги как context-only сигналы. - [Stage 5](STAGE5.md) — uncertainty, applicability, robustness и policy guardrails без action model. -- [Stage 6](STAGE6.md) — acceptance pack, reproducible demo checks and final handoff limits. -- [Stage 7](STAGE7.md) — trusted local history artifact serving through CLI. +- [Stage 6](STAGE6.md) — чистый запуск, frozen models, исторические метрики и приёмочная демонстрация. +<<<<<<< HEAD +- [Stage 7](STAGE7.md) — trusted local history artifact serving через CLI/UI в forecast-only режиме. +======= +- [Stage 7](STAGE7.md) — safety-first alarm, joint applicability и усиленный action gate. +- [Stage 8](STAGE8.md) — диагностика drift, режимов, ПАК–ЛИМС и устойчивости признаков. +- [Stage 9](STAGE9.md) — эпизодный multi-horizon shadow-прогноз и отложенный LIMS-контроль. +<<<<<<< HEAD +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) +======= +- [Stage 10](STAGE10.md) — исторический модельный эффект P8/F19 без совета оператору. +>>>>>>> f3eefd7 (Add historical action effects and stage 10 report) - [Code walkthrough](CODE_WALKTHROUGH.md) — папки, файлы и хронология вызовов почти построчно. - [ML system design](DESIGN.md#8-ml-неопределённость-и-модель-последствий) — обучение, метрики, анализ ошибок и жизненный цикл модели; общие контракты и данные описаны в том же документе. - [Материалы задания](materials/README.md) — ТЗ, схемы и исходные данные. ## Текущее состояние проекта -Реализация дошла до Stage 7 и содержит desktop UI. Часть команд и возможностей в -дизайн-документе по-прежнему целевые; актуальный исполняемый контракт описан ниже. +Реализация дошла до Stage 10 и содержит desktop UI, воспроизводимое обучение, +историческую оценку, replay и приёмочную демонстрацию. Промышленная action model не +заявлена. Stage 7 forecast-only history path сохранён как legacy `run-history`; +актуальный исполняемый контракт описан ниже. + +Read-only диагностика ML: + +```bash +python -m source.main diagnose-ml --dataset data/processed/a2fe8577752a +``` + +Stage 9 добавляет schema-1.2 эпизодный multi-horizon прогноз только в shadow-режиме. +Письменное Q&A 15.09 и скорректированный словарь теперь фиксируют физический смысл +`P8/F19/T11/F26` и единицы; P8/F19 исследуются только как исторические эпизоды, +а реальные controls остаются выключенными до инженерного gate. +Схемы АВТ от 14.09 локализуют короткие `avt:*` на К-1/К-2/К-10, но не относятся +к 24-2000 и потому не снимают это ограничение. Они задают компактные группы для +будущей train-only ablation upstream-признаков; текущий feature list не изменён. Сейчас реализованы: @@ -33,53 +61,64 @@ - backend-срез stage 3: более строгий выбор `hold`/`recommend`/`abstain`, единые hard checks, причины отбраковки кандидатов, повторная проверка selected и trace в журнале; -- stage 4: модельная связка гидроочистка -> блендинг, массовый баланс рецептур и - gas context для `ht:F9`, `ht:F22`, `ht:Q21` без включения реального управления газом. +- stage 4: модельная связка гидроочистка -> блендинг, массовый баланс рецептур, + модельные контуры T95/цетанового числа, присадка до 3% и gas context для + `ht:F2`, `ht:F22`, `ht:F25` без включения реального управления газом. - stage 5: empirical upper estimate для прогноза серы, applicability/OOD gate, robustness reporting и materiality/cooldown policy helpers без включения action model. -- stage 6: приемочный контур `accept-stage6`, воспроизводимый прогон трех model-demo - сценариев, проверка journal-файлов и явная фиксация ограничений финальной демонстрации. -- stage 7: CLI `run-history` для подготовленного historical dataset и явно доверенного - локального model artifact с проверкой metadata перед загрузкой `joblib`. +- stage 6: точные версии зависимостей, команды `train`/`evaluate`/`replay`, каталог + демонстрационных эпизодов, проверка frozen models и экспорт полного журнала. Пять + сценариев покрывают `hold`, рекомендации по сере/T95/цетановому числу и отказ при + неполном паспорте компонента. Полноценной промышленной ML-модели и управления реальными уставками пока нет. Доступен +<<<<<<< HEAD локальный desktop UI на Python: он запускает model-demo сценарии, показывает `hold`/`recommend`/`abstain`, проверки и журнал. Исторический forecast с доверенным локальным artifact доступен через CLI и отдельный экран UI, но не выдаёт промышленную рекомендацию изменения уставок. +======= +локальный desktop UI на Python: он запускает model-demo, показывает проверки и журнал, а +также открывает исследовательский сценарий P8/F19 по сохранённому historical artifact. +Этот экран показывает оценку эффекта и её ограничения, но не выдаёт рекомендацию уставки. +>>>>>>> 1527107 (Extend UI and ML analysis materials) Stage 4 показывает связанную цепочку и модельный блендинг. Газовые теги видны как технологический контекст, но не становятся action controls: нет подтверждённых единиц, -диапазонов и модели эффекта. В model-demo `T95` и цетановое число являются сценарными -допущениями для демонстрации, а полный промышленный паспорт продукта остаётся -`not_assessed`. +диапазонов и модели эффекта. В `model_demo` проверяются сера, T95 и цетановое число: +T95 и базовое цетановое число линейно смешиваются как явное допущение, а эффект присадки +берётся из настраиваемой сценарной кривой. Это полный паспорт внутри синтетической модели, +но не подтверждение промышленного соответствия товарному стандарту. Stage 5 добавляет осторожность вокруг ML-прогноза: если artifact поддерживает uncertainty, quality-agent сначала проверяет область применимости признаков, потом использует point и upper sulfur. Missing/OOD/отсутствующий upper не превращаются в pass. Это не action model: backend по-прежнему не рекомендует реальные setpoint-изменения и не управляет газом. -Stage 6 упаковывает финальную приемку: команда `accept-stage6` валидирует конфигурацию, -прогоняет три model-demo сценария, проверяет ожидаемые `hold`/`recommend`/`abstain` -и наличие journal-файлов. +Stage 6 упаковывает финальную приемку: команда `acceptance` прогоняет три +зафиксированных model-demo эпизода, проверяет ожидаемый `abstain`, reason codes, +сохраняет полный ZIP журналов и fingerprint решения. -Stage 7 подключает history artifact serving через CLI: `run-history` загружает -prepared dataset, проверяет metadata доверенного artifact, строит serving features и -сохраняет обычный journal. Desktop UI имеет отдельный forecast-only экран для этого пути. +Stage 7/history path подключает artifact serving через CLI: `replay` и legacy +`run-history` загружают prepared dataset, проверяют metadata доверенного artifact, +строят serving features и сохраняют обычный journal. Desktop UI имеет отдельный +forecast-only экран для этого пути. ## Проверка -Нужны совместимое с проектом Python-окружение, Git LFS и `tar` с поддержкой RAR. -Локальный артефакт Stage 5 был собран в Python 3.12.3 со scikit-learn 1.9.0; перед -воспроизведением или переобучением нужно сверять версии из metadata артефакта. +Нужны Python 3.11 или 3.12, Git LFS и `tar` с поддержкой RAR. Для приёмочного запуска +используется точный набор прямых зависимостей из `requirements.lock.txt`. ```bash +git lfs install +git lfs pull python -m venv .venv -python -m pip install -r requirements.txt +python -m pip install --upgrade pip +python -m pip install -r requirements.lock.txt python -m source.main validate-stage0 -python -m source.main accept-stage6 python -m source.main train --dataset data/processed/ --target-source pak -python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --split test +python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --source pak --split test +python -m source.main acceptance --output reports/final-acceptance python -m source.main run-history --dataset data/processed/ --model artifacts/models/ --trusted-model --as-of 2026-01-15T09:00:00Z python -m pytest python -m pytest global_tests/test_stage7_history_serving.py @@ -90,7 +129,7 @@ python -m ruff format --check . python -m mypy source ``` -Ожидаемый результат `validate-stage0`: пять сценариев, три model-demo fixture и полный +Ожидаемый результат `validate-stage0`: семь сценариев, пять model-demo fixtures и полный словарь известных входных тегов. Неизвестные единицы и управляющие параметры помечены `ambiguous`, все реальные управляющие воздействия отключены. @@ -99,19 +138,17 @@ python -m mypy source ```bash python -m source.main run-model-demo blend_normal python -m source.main run-model-demo blend_risk +python -m source.main run-model-demo blend_t95_risk +python -m source.main run-model-demo blend_cetane_risk python -m source.main run-model-demo blend_missing ``` Ожидаемые статусы: -| Сценарий | Статус | Что показывает | -| --- | --- | --- | -| `blend_normal` | `hold` | текущая модельная рецептура проходит доступные проверки, лишнее действие не создаётся | -| `blend_risk` | `recommend` | текущая рецептура нарушает sulfur limit, выбран безопасный synthetic blend | -| `blend_missing` | `abstain` | при нехватке обязательной серы система отказывается от рискованной рекомендации | - -`recommend` в model-demo относится только к синтетическому блендингу. Реальные -setpoint-рекомендации для history остаются отключены. +- `blend_normal` -> `hold`; +- `blend_risk`, `blend_t95_risk`, `blend_cetane_risk` -> `recommend` с одновременным + прохождением верхних границ серы/T95 и нижней границы цетанового числа; +- `blend_missing` -> `abstain`: неизвестное качество не превращается в `pass`. Stage 3 не меняет публичные demo-команды. Он делает внутренний выбор строже: infeasible-кандидаты не ранжируются, причины отказа сохраняются в журнале, selected @@ -131,18 +168,38 @@ python -m source.ui ```bash python -m source.ui --scenario blend_normal python -m source.ui --scenario blend_risk +python -m source.ui --scenario blend_t95_risk +python -m source.ui --scenario blend_cetane_risk python -m source.ui --scenario blend_missing ``` Интерфейс сохраняет текущие возможности системы: модельный расчёт рецептуры, доступные -ограничения, экспорт результата, журнал запусков, проверку конфигурации, подготовку данных, -сборку historical state и запуск trusted history forecast. Для проверки без дисплея: +ограничения, экспорт результата, журнал запусков, проверку конфигурации, подготовку данных +и сборку historical state. В блоке «Данные» кнопка «Прогноз серы» выполняет replay +доверенного локального forecast-artifact в выбранный момент истории; кнопка «Исторический эффект P8/F19 (не совет)» открывает +исторический сценарий с point/upper серы на 60/120/180 минут; он требует заранее созданный +локальный `action-shadow-*` artifact. UI показывает рассчитанные верхние границы серы/T95, нижнюю +границу цетанового числа и долю присадки; модельный характер расчёта остаётся видимым. +Для проверки без дисплея: ```bash python -m source.ui --smoke --scenario blend_risk python -m source.ui --history-smoke --dataset data/processed/ --model artifacts/models/ --as-of 2026-01-15T09:00:00Z ``` +Стартовую вкладку можно выбрать явно: `overview`, `avt`, `hydrotreating`, `blend`, +`history`, `journal`; legacy `recommendation` остаётся алиасом на `blend`. + +Текущий UI stage-aware: обзор показывает карточки АВТ, гидроочистки и History/ML; +вкладки `АВТ` и `Гидроочистка` строят read-only таблицы сигналов из prepared dataset +с единицами, источником, возрастом, freshness и причиной, почему сигнал не является +управляющей уставкой. Вкладка `Смесь` сохраняет synthetic model-demo и добавляет +hybrid-панель: компонент A заполняется только history forecast гидроочистки, иначе +показывается `forecast unavailable`. Вкладка `История/ML` выполняет `replay` с +forecast artifact и optional verified action artifact, показывает point/upper серы, +reason codes, issues, journal path и явно разделяет `не совет` от actionable +recommendation. + ## Stage 2: подготовка реальных данных Подготовить оригинальные материалы в локальный производный датасет: @@ -164,6 +221,94 @@ python -m source.main build-state --dataset data/processed/ --scenar доступны к `as_of`. Если нужного сигнала нет или он устарел, это фиксируется в issues, а не заменяется придуманным значением. +## Stage 6: обучение, оценка и приёмка + +Production-freeze запускается только из чистого Git worktree; test не участвует в выборе +модели. Для хакатонного исследования можно явно создать воспроизводимо помеченный +`working-tree` shadow-artifact: + +```bash +python -m source.main train --dataset data/processed/ --with-uncertainty + +# Обучить point + upper + calibrated exceedance alarm. +python -m source.main train --dataset data/processed/ --with-uncertainty --with-safety + +# Эпизодный прогноз 10/20/30/60 минут; создаёт только shadow-артефакт schema 1.2. +python -m source.main train-v2-shadow --dataset data/processed/ + +# То же на текущих незакоммиченных изменениях; только shadow, не production-freeze. +python -m source.main train-v2-shadow --dataset data/processed/ \ + --allow-dirty-shadow + +# Проверить один исторический timestamp по schema-1.2 shadow-артефакту. +python -m source.main replay-v2-shadow \ + --dataset data/processed/ \ + --model artifacts/models/sulfur-v2-shadow- \ + --at 2025-06-01T12:00:00+03:00 + +# Оценить исторический модельный эффект P8/F19; рекомендации не включает. +python -m source.main evaluate-action-shadow --dataset data/processed/ + +# Рассчитать один сценарий из сохранённого action-shadow artifact. +python -m source.main action-shadow-estimate --dataset data/processed/ \ + --model artifacts/models/action-shadow--v2 --control ht:P8 --delta 0.001 \ + --at 2025-06-01T12:00:00+03:00 + +# Проверить отдельную отложенную коррекцию ПАК→ЛИМС. +python -m source.main evaluate-lims-correction --dataset data/processed/ \ + --pak-model artifacts/models/ + +# Проверить temporal residualization для P8/F19. +python -m source.main evaluate-action-residualization \ + --dataset data/processed/ +``` + +Обычное обучение требует чистого worktree, чтобы `git_commit` в metadata точно описывал +код. Флаг `--allow-dirty-shadow` не ослабляет этот production-контроль: в metadata +пишется метка `working-tree::`, а `production_status` остаётся +`shadow_only`. `replay-v2-shadow` доступен после обоих вариантов и остаётся read-only. + +Контракт и ограничения ML v2 описаны в [STAGE9.md](STAGE9.md). Test 2026 служит +только audit-набором; production требует нового shadow-периода. + +В UI history replay доступен на вкладке `История/ML`, а диагностические операции +`prepare`, `build-state`, `v2 shadow`, LIMS-контроль и action-shadow остаются в +блоке данных на обзоре. History replay остаётся forecast-only, если поле verified +action artifact пустое. Actionable-рекомендация возможна только для отдельного +artifact `artifact_kind=action_effect`, прошедшего hash/gate-проверки; shadow +artifacts и v2-прогноз не являются советом и не меняют уставки. + +Сравнить frozen point model с persistence baseline на одинаковых timestamp: + +```bash +python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --source pak --split test +python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --source lims --split test +``` + +Воспроизвести историческую точку и отдельно прогнать фиксированные модельные эпизоды: + +```bash +python -m source.main replay --dataset data/processed/ --model artifacts/models/ --scenario history --at 2026-01-15T12:00:00+03:00 +python -m source.main replay --dataset data/processed/ --model artifacts/models/ --action-model artifacts/models/ --scenario history --at 2026-01-15T12:00:00+03:00 +python -m source.main acceptance --output reports/full-quality-acceptance +python -m source.main verify-model-freeze +``` + +`config/model_freeze.json` относится к артефактам, обученным на указанном в нём +`training_git_commit`. Модели не коммитятся; для точного воспроизведения нужно обучить их +на этом commit, затем вернуться в финальную ветку и выполнить проверку хешей. +Для action artifacts `verify-model-freeze` дополнительно проверяет `supports_actions=true`, +enabled controls только для `ht:P8`/`ht:F19`, horizons `60/120/180`, dataset/config/tag/rules +hashes, gate report hashes и все production gates. + +`acceptance` создаёт `summary.json`, каталоги полных запусков и `journals.zip`. Повторный +запуск требует нового output-каталога, поэтому ранее полученное доказательство не +перезаписывается. Один или несколько обычных журналов экспортируются отдельно: + +```bash +python -m source.main export-journal --run --output reports/journal-export.zip +``` + ## Данные Канонический исходник телеметрии — `materials/data.rar` в Git LFS. Распакованные CSV, diff --git a/SCHEME_ANALYSIS_2026_09_14.md b/SCHEME_ANALYSIS_2026_09_14.md new file mode 100644 index 0000000..571f21d --- /dev/null +++ b/SCHEME_ANALYSIS_2026_09_14.md @@ -0,0 +1,158 @@ +# Разбор новых схем АВТ от 14.09.2026 + +## Короткий вывод + +Три новых PDF уточняют размещение коротких тегов `avt:*` на технологической +схеме К-1, К-2 и К-10. Это полезное дополнение к листу `КИП`: таблица объясняет +название сигнала, а схема показывает аппарат, линию, направление потока и соседние +контуры. + +Схемы не относятся к гидроочистке 24-2000. Поэтому они не подтверждают смысл, +единицы или управляемость `ht:P8`, `ht:T11`, `ht:F19` и `ht:F26`, не доказывают +маршрут дизельной фракции от АВТ до Р-202 и не разрешают рекомендации уставок. + +**Поправка организаторов:** размещение `F31` на исходной схеме К-10 было +некорректным и заменено исправленным листом. Прежняя позиция `F31` исключена +из топологических выводов; по К-2 корректировка не требуется. + +## Проверенные источники + +Каждый новый файл - один сканированный лист A3 без текстового слоя. Все страницы +проверены визуально. Исходные байты сохранены без изменения в +`materials/schemes/`. + +| Лист | Файл | SHA-256 | Что изображено | +| --- | --- | --- | --- | +| К-1 | `АВТ_К-1_теги_организаторов_14.09.2026.pdf` | `3665a5d4ecce907be9f7b7fc5c1ce5a0edf7454da873e2396b43be448b918be5` | ЭЛОУ, теплообменники, колонна К-1, печь П-1/1, верхнее орошение и передача потока к печам П-1/2 и П-1/3 | +| К-2 | `АВТ_К-2_теги_организаторов_14.09.2026.pdf` | `abdaedfb3f61c951720ee46bbaf83073d61b810263fec90d09ceaca13fbac385` | Атмосферная колонна К-2, три циркуляционных орошения, боковые отпарные колонны К-6/К-7/К-9 и продуктовые потоки | +| К-10 | `АВТ_К-10_теги_организаторов_14.09.2026.pdf`; исправление `АВТ_К-10_теги_организаторов_15.09.2026_исправлено.pdf` | `169225416aa796d284f25a7cac4d5cef654a3c5b79f01e3f536a8f3c9b2290fd`; `28fcf3d0632972d1ee872d4ce06078dd30b416a9a882266568c7ccfeb373386f` | Вакуумная колонна К-10, печь П-3, вакуумсоздающая система, циркуляционные орошения и тяжёлые продукты | + +Ранее выданный `materials/АВТ_схемы.pdf` содержит те же три технологических +листа с обобщёнными англоязычными подписями признаков. Новые файлы добавляют +рукописные короткие имена из телеметрии. Они дополняют, а не заменяют старый PDF. + +## Что подтверждено + +### Лист К-1 + +Сырая нефть проходит теплообменники и ЭЛОУ, затем подаётся в К-1. Верх К-1 +связан с Е-1 и возвратом орошения; кубовый поток идёт через П-1/1 и далее к +П-1/2 и П-1/3 перед К-2. + +На листе читаются следующие привязки: + +- `avt:P2` - давление верха К-1; +- `avt:T1` - температура верха К-1; +- `avt:F3` - расход бензинового орошения К-1; +- `avt:T6` - температура нижней части К-1; +- `avt:P4` - давление нижней части К-1; +- `avt:F5` - расход пара в К-1; +- `avt:F7`, `avt:F8`, `avt:F9` - три расходных измерения обессоленной нефти + после ЭЛОУ по параллельным теплообменным ходам; +- `avt:D10` - показатель на линии обессоленной нефти; физический смысл плотности + берётся из листа `КИП`, потому что на схеме расшифровка единицы отсутствует. + +### Лист К-2 + +Поток после П-1/2 и П-1/3 поступает в К-2. Схема явно разделяет нафту, +бензиновую фракцию 120-180 °C, керосиновую 150-250 °C, дизельную ветвь +240-350 °C и мазут. Мазут далее подаётся в П-3 и К-10. + +Физически полезные группы тегов: + +| Группа | Теги, видимые на схеме | Смысл для анализа | +| --- | --- | --- | +| Верх К-2 | `avt:P21`, `avt:P22`, `avt:P23`, `avt:P67`, `avt:T20`, `avt:F19`, `avt:F16` | Давление/температура верха, острое орошение и поток нафты; несколько датчиков давления нельзя считать независимыми причинами без проверки дублирования | +| 1-е ЦО | `avt:F14`, `avt:T13`, `avt:T18` | Расход и температуры циркуляционного орошения верхней части колонны | +| 2-е ЦО | `avt:F12`, `avt:T17` | Среднее циркуляционное орошение | +| 3-е ЦО | `avt:F64`, `avt:T11`, `avt:T15` | Нижнее циркуляционное орошение | +| Загрузка и низ | `avt:F65`, `avt:T33`, `avt:F29` | Производительность К-2, температура низа и отпарной пар | +| Бензин/керосин | `avt:T24`, `avt:F26`, `avt:F25`, `avt:F27`, `avt:F34` | Боковые погоны и пар в К-6/К-7 | +| Дизельная ветвь | `avt:T66`, `avt:F28`, `avt:F32`, `avt:T71`, `avt:F30`, `avt:W70` | Отборы вокруг К-9 и два выходных диапазона, из которых формируется обозначенная на листе ветвь ДТ 240-350 °C | + +Схема подтверждает физическую близость последней группы к дизельной отсечке, но +не показывает, какая доля или резервуар затем поступает именно на 24-2000. + +### Лист К-10 + +Мазут от К-2 нагревается в П-3 и поступает в вакуумную К-10. Верх соединён с +Е-15 и барометрическим ящиком. На боковых отборах показаны фракции до 350 °C и +350-560 °C, внизу - гудрон. + +На схеме локализованы: + +- вакуум и давление: `avt:P50`, `avt:P51`, `avt:P52`, `avt:P44`; +- температурный профиль: `avt:T49`, `avt:T39`, `avt:T42`, `avt:T48`; +- печь и вход: `avt:T55`, `avt:F31` (F31 расположен на линии подачи после П-3; исправленная схема заменяет прежнюю позицию); +- верхнее/среднее/нижнее циркуляционное орошение: + `avt:F35`, `avt:F36`, `avt:F41`, `avt:F46`, `avt:F53`, `avt:F54`, + `avt:F62`, `avt:T37`, `avt:T38`, `avt:T40`, `avt:T47`, `avt:T58`, + `avt:T61`; +- продуктовые потоки: `avt:F56`, `avt:F57`, `avt:F59`, `avt:F60`, + `avt:F63`, `avt:F68`, `avt:F69`; +- уровень низа: `avt:L43`. + +Эта часть важна для общего режима АВТ и материального баланса тяжёлых продуктов, +но она находится после отбора атмосферного дизеля. Для краткосрочного прогноза +серы гидроочистки её нельзя автоматически считать причинным upstream-блоком. + +## Что означают обозначения и цвета + +`FI/FC`, `TI/TC`, `PI/PIC`, `LC` и `PDC` описывают тип измерения или локального +контура на P&ID-подобной схеме. Наличие `FC`, `TC` или `PIC` доказывает наличие +контрольного контура на АВТ, но не подтверждает доступность уставки нашему +приложению, инженерный диапазон, `max_step`, ramp-rate или безопасную комбинацию. + +Цветные обводки и легенда старого объединённого PDF отмечают выбранные переменные, +однако новые листы не дают полного машиночитаемого словаря цветов. Поэтому цвет +не переносится в `controllable` или в ограничения модели. + +## Что схемы дают ML + +1. **Топологические группы вместо 98 случайных колонок.** Для следующего + train-only эксперимента можно проверять компактные группы К-2, а не выполнять + массовый feature mining по всей телеметрии. +2. **Причинный порядок внутри АВТ.** Нагрузка/печи предшествуют состоянию К-2, + затем идут циркуляционные орошения и продуктовые отборы. Лаги следует задавать + по этой последовательности, а не выбирать произвольный сдвиг по максимуму + корреляции. +3. **Проверка согласованности датчиков.** `P21/P22/P23/P67`, парные расходы и + температуры одного контура можно использовать для residual/check features, + обнаружения пропусков и смены режима. Их не надо считать четырьмя независимыми + физическими воздействиями. +4. **Выделение режимов.** Нулевая/аномальная загрузка `F65`, разрыв баланса потоков, + пропавшие циркуляционные контуры или необычное сочетание `T20/T33` дают + основания для отдельного OOD/stop-start среза. +5. **Осмысленная ablation-гипотеза.** После PAK-only baseline можно отдельно + проверить: (a) состояние К-2, (b) тепловой баланс ЦО, (c) дизельную ветвь. + Группа принимается только по rolling-origin validation, если улучшает event-FNR + при FPR не выше 20%; audit-2026 для выбора не используется. + +## Что пока запрещено делать + +- смешивать `avt:F19` с одноимённым `ht:F19`; +- выводить смысл `ht:P8/T11/F19/F26` из этих листов; +- назначать единицы по буквам `F/T/P` или по диапазону значений; +- рассчитывать материальный баланс до подтверждения единиц и единой массовой базы; +- привязывать ЛИМС-точки 1/2/2.1/3 к продуктовым стрелкам без отдельной карты; +- задавать межустановочный лаг АВТ -> 24-2000 по максимуму корреляции; +- включать `supports_actions=true` из-за наличия регуляторов на схеме. + +## Следующий проверяемый эксперимент + +На данных 2023-2025, без повторного выбора по audit-2026: + +1. Сохранить PAK-only модель как контроль. +2. Добавлять по одной группе: + `k2_state = {avt:F65,avt:T20,avt:T33,avt:P21,avt:P22,avt:P23,avt:P67}`, + `k2_circulation = {avt:F14,avt:T13,avt:T18,avt:F12,avt:T17,avt:F64,avt:T11,avt:T15}`, + `diesel_cut = {avt:T66,avt:F28,avt:F32,avt:T71,avt:F30,avt:W70}`. +3. Для каждого сигнала использовать только значения, доступные на `as_of`, и + train-only лаги. Межустановочные лаги считать исследовательскими до получения + схемы маршрута/резервуара. +4. Сравнивать event-FNR/FPR, Brier, MAE, худший месяц, missingness и OOD rate. +5. Не продвигать группу, если она улучшает MAE, но ухудшает event-FNR, нестабильна + по месяцам или работает только на уже исследованном 2026 году. + +До такого backtest новые схемы улучшают семантику и план исследования, но не +улучшают численные метрики текущего frozen ML-артефакта сами по себе. diff --git a/STAGE1.md b/STAGE1.md index d135c66..48f8dbd 100644 --- a/STAGE1.md +++ b/STAGE1.md @@ -1,8 +1,8 @@ # Stage 1: первый сквозной backend-цикл -> Актуальный статус: model-demo теперь содержит сценарные `T95` и цетановое число, -> поэтому демонстрирует три исхода: `hold`, `recommend` и `abstain`. Эти значения -> синтетические и не являются промышленным паспортом продукта. +> Актуальный статус: это историческое описание основы цикла. Финальная версия добавила +> модельные T95/CN и присадку: допустимые сценарии дают `hold`/`recommend`, а неполный +> паспорт по-прежнему даёт fail-closed `abstain`. Этот этап нужен, чтобы превратить подготовленные данные и DTO из stage 0 в проверяемый цикл принятия решения. ML-модель ещё не обязательна: backend @@ -16,7 +16,7 @@ - `source/agents/*` содержит логические роли качества, надёжности и оптимизации. Сейчас они считают прозрачные demo-метрики, не обученную модель. - `source/constraints.py` является единственным местом проверки жёстких - ограничений: сера, доступность верхней оценки и запас компонентов. + ограничений: S/T95/CN, необходимые границы, доля присадки и запасы. - `source/journal.py` пишет `metadata.json`, `input.json`, `features.json`, `trace.jsonl`, `candidates.jsonl` и `result.json`. - CLI получил команду `run-model-demo`. @@ -54,6 +54,8 @@ run_cycle -> generate_candidates -> evaluate_candidates -> check_constraints -> python -m source.main validate-stage0 python -m source.main run-model-demo blend_normal python -m source.main run-model-demo blend_risk +python -m source.main run-model-demo blend_t95_risk +python -m source.main run-model-demo blend_cetane_risk python -m source.main run-model-demo blend_missing ``` @@ -72,7 +74,7 @@ python -m pytest Ожидаемые статусы: - `blend_normal` -> `hold`; -- `blend_risk` -> `recommend`; +- `blend_risk`, `blend_t95_risk`, `blend_cetane_risk` -> `recommend`; - `blend_missing` -> `abstain`. ## Ограничения этапа diff --git a/STAGE10.md b/STAGE10.md new file mode 100644 index 0000000..ced0473 --- /dev/null +++ b/STAGE10.md @@ -0,0 +1,70 @@ +# Stage 10 — исторический модельный эффект P8/F19 + +Команда исследования: + +```bash +python -m source.main evaluate-action-shadow \ + --dataset data/processed/a2fe8577752a +``` + +Модуль выделяет изолированные заметные изменения только `ht:P8` и `ht:F19`, +сопоставляет их с ближайшими спокойными состояниями того же календарного года и +строит Ridge/q95-прогноз серы на 60/120/180 минут. `ht:T11` (температура продукта) и `ht:F26` +используются только как контекст состояния и никогда не становятся изменяемыми +кандидатами. + +UI-контракт возвращает обычное изменение серы, point/upper для каждого горизонта, +применимость и причины отказа. Артефакт сохраняется как +`artifacts/models/action-shadow--v2` с checksum и загружается только из +локального доверенного каталога. Заголовок фиксирован как «Модельный эффект по +историческим эпизодам», поле `advisory=false`, `supports_actions=false`. + +Для одной точки истории используется отдельная команда: + +```bash +python -m source.main action-shadow-estimate \ + --dataset data/processed/a2fe8577752a \ + --model artifacts/models/action-shadow-a2fe8577752a-v2 \ + --control ht:P8 --delta 0.001 \ + --at 2025-06-01T12:00:00+03:00 +``` + +Выдача раздельно показывает `within_observed_domain`, `model_validated` и +`safety_passes`. Поэтому точка может находиться в исторической области, но всё +равно иметь `ACTION_EFFECT_VALIDATION_FAILED` или нарушать верхнюю границу серы. +Экран UI «Исторический эффект P8/F19 (не совет)» вызывает этот же путь и не ранжирует действия. + +На dataset `a2fe8577752a` найдено 1 212 matched-пар: 546 для `P8`, 666 для +`F19`. Validation-2025 не прошёл evidence gate: модель хуже hold на всех трёх +горизонтах, хотя q95 coverage равна 98.22% / 98.62% / 98.22% для 60/120/180 +минут. Audit-2026 также хуже hold; q95 coverage равна 92.05% / 94.04% / +93.71% для 60/120/180 минут. + +Поэтому модуль реализован как честный исследовательский экран, но текущая модель +возвращает `ACTION_EFFECT_VALIDATION_FAILED` и не может выдавать совет. Следующий +эксперимент — temporal residualization/cross-fitting и проверка устойчивости знака; +audit-2026 нельзя использовать для выбора варианта. + +В отчёте также сохраняются отдельные gate-флаги: минимум 100 эпизодов на каждый +control, выигрыш MAE не менее 10% относительно hold, coverage upper не ниже 95%, +устойчивость знака в трёх временных fold’ах, coverage на audit не ниже 95%, +подтверждение инженерных границ, shadow replay и согласование технолога. Пока хотя +бы одно условие не подтверждено, `supports_actions=false` сохраняется даже при +улучшении статистических метрик. + +Temporal benchmark запускается отдельно: + +```bash +python -m source.main evaluate-action-residualization \ + --dataset data/processed/a2fe8577752a +``` + +Он сначала оценивает ожидаемое действие и ожидаемую серу по состоянию до изменения, +затем строит модель на временных остатках. На текущем dataset общий знак P8 +отрицательный, но в третьем temporal fold меняется на положительный; для F19 знак +стабилен, однако MAE хуже hold. Результат остаётся `research_only` и не меняет +capability action artifact. + +Схемы АВТ от 14.09 не меняют этот результат: они не содержат Р-202 и не +подтверждают `ht:P8`, `ht:F19` или `ht:F26`. Переобучение action-effect модели +по этим PDF не выполняется; `supports_actions=false` сохраняется. diff --git a/STAGE2.md b/STAGE2.md index 46a044c..ddaa5cd 100644 --- a/STAGE2.md +++ b/STAGE2.md @@ -1,8 +1,8 @@ # Stage 2: Real Data Preparation > Актуальный статус: pipeline `prepare`/`build-state` остаётся рабочей границей -> данных. После Stage 5 он может снабжать history-расчёт признаками, но public CLI -> всё ещё не обучает и не запускает historical ML-forecast. +> данных. После Stage 5 он снабжает history-replay признаками; CLI `train` и +> `replay`, а также UI «Прогноз серы» используют один и тот же подготовленный набор. Stage 2 делает первый практический мост от оригинальных материалов к backend-коду. diff --git a/STAGE3.md b/STAGE3.md index bf62863..45f690f 100644 --- a/STAGE3.md +++ b/STAGE3.md @@ -1,8 +1,7 @@ # Stage 3: Constraints, Controls, Rejection Reasons -> Актуальный статус: guardrails используются и после Stage 5. Model-demo снова -> демонстрирует `hold`/`recommend`/`abstain`, но `recommend` относится только к -> явно синтетическому блендингу; реальные history controls остаются выключены. +> Актуальный статус: guardrails используются в финальной версии. Model-demo теперь +> проверяет S/T95/CN и присадку; `abstain` сохраняется для неполных данных. Stage 3 состоит из двух связанных частей: @@ -105,6 +104,7 @@ Explanation показывает: - текущее значение серы; - выбранное значение серы; +- верхнюю T95 и нижнюю границу цетанового числа; - статус checks; - границу ограничения; - `reason_code`; @@ -177,6 +177,8 @@ Backend guardrails: ```bash python -m source.main run-model-demo blend_normal python -m source.main run-model-demo blend_risk +python -m source.main run-model-demo blend_t95_risk +python -m source.main run-model-demo blend_cetane_risk python -m source.main run-model-demo blend_missing python -m pytest global_tests/test_stage3_guardrails.py python -m pytest @@ -185,7 +187,7 @@ python -m pytest Ожидаемые demo-статусы: - `blend_normal` -> `hold`; -- `blend_risk` -> `recommend`; +- `blend_risk`, `blend_t95_risk`, `blend_cetane_risk` -> `recommend`; - `blend_missing` -> `abstain`. ML/control checks, если соответствующие файлы есть в ветке: diff --git a/STAGE4.md b/STAGE4.md index 2d471dc..dafb246 100644 --- a/STAGE4.md +++ b/STAGE4.md @@ -26,6 +26,10 @@ point/upper sulfur входного прогноза меняет множест входное качество и явный диапазон транспортного лага. - `apply_hydrotreater_forecast()` заменяет только соответствующий компонент смеси. - Сера смеси считается как `sum(w_i * S_i)`. +- T95 и базовое цетановое число считаются линейно по нормированным долям дизельных + компонентов как явное синтетическое допущение. +- Цетановая присадка перебирается от 0 до 3%; её прирост CN задаёт настраиваемая + кусочно-линейная кривая, а стоимость равна 100 стоимостным прокси на тонну. - Верхние оценки серы смешиваются тем же массовым балансом, но помечаются как консервативная сценарная граница без заявления о совместном статистическом покрытии. - Доли рецептуры неотрицательные, содержат все компоненты и суммируются в единицу. @@ -64,9 +68,9 @@ Stage 4 отслеживает следующие газовые сигналы модельным предположением: подтверждённого flow mapping нет. - Реальная история рецептур и запасов не предоставлена, поэтому blending остаётся модельным сценарием. -- Проверяются только сера и запас компонента. -- В model-demo `T95` и цетановое число являются сценарными synthetic values; - полная товарная спецификация остаётся `full_specification_status="not_assessed"`. +- В model-demo проверяются `S_upper`, `T95_upper`, `CN_lower`, запасы и присадка. +- `full_specification_status="assessed"` означает полноту только объявленного + синтетического паспорта, не промышленного товарного стандарта. - Отсутствующая верхняя граница серы даёт `UNKNOWN`, а не `PASS`. - Газовые сигналы не являются безопасными уставками. diff --git a/STAGE5.md b/STAGE5.md index eee7baa..f4b0380 100644 --- a/STAGE5.md +++ b/STAGE5.md @@ -107,8 +107,8 @@ hold candidate + best feasible candidate - Нет разрешенных промышленных setpoint-рекомендаций. - Нет гарантии safety coverage: `0.95` является эмпирической исторической оценкой, а не промышленной гарантией. - Applicability bounds являются marginal q0.001/q0.999 по train-признакам, а не полноценной многомерной OOD-моделью. -- `T95` и цетановое число в model-demo являются сценарными synthetic values; полный - промышленный паспорт товарного дизеля остаётся `not_assessed`. +- T95/CN доступны в отдельном синтетическом model-demo; исторически валидированных + моделей их последствий и промышленного паспорта по-прежнему нет. ## Как проверять @@ -126,11 +126,13 @@ Demo-команды должны сохранить прежние статус ```bash python -m source.main run-model-demo blend_normal python -m source.main run-model-demo blend_risk +python -m source.main run-model-demo blend_t95_risk +python -m source.main run-model-demo blend_cetane_risk python -m source.main run-model-demo blend_missing ``` Ожидаемо: - `blend_normal` -> `hold`; -- `blend_risk` -> `recommend`; +- `blend_risk`, `blend_t95_risk`, `blend_cetane_risk` -> `recommend`; - `blend_missing` -> `abstain`. diff --git a/STAGE6.md b/STAGE6.md index d818521..4e1b6ba 100644 --- a/STAGE6.md +++ b/STAGE6.md @@ -1,73 +1,169 @@ -# Stage 6: acceptance and demonstration +# Stage 6: приёмка и демонстрация -> Current status: Stage 6 is an acceptance pack. It does not add a new ML model, -> action model, or real setpoint control. History artifact serving is available -> separately as forecast-only CLI/UI functionality. +## Что зафиксировано -## Purpose +Финальная версия фиксирует и проверяет весь прототип, включая модельный товарный паспорт: -Stage 6 turns the implemented prototype into a reproducible handoff package. A -second participant should be able to create an environment, run the documented -checks, reproduce the model-demo scenarios, inspect journals, and understand the -remaining limits without reading the whole codebase first. +- точные версии прямых зависимостей в `requirements.lock.txt`; +- явные CLI-команды подготовки, обучения, оценки и historical replay; +- модельные артефакты с `supports_actions=false` и проверяемыми SHA-256; +- пять неизменяемых демонстрационных эпизодов в `config/demo_episodes.json`; +- верхние сценарные границы серы/T95, нижнюю границу цетанового числа и + конфигурируемую кривую цетаноповышающей присадки до 3%; +- атомарный приёмочный отчёт и полный ZIP журналов; +- раздельное представление исторической точности и условного эффекта блендинга. -## What Is Implemented +`run-history` сохранён как legacy-алиас forecast-only replay. Он требует +`--trusted-model`, проверяет metadata локального artifact и не включает action controls. -- `python -m source.main accept-stage6` runs the final acceptance smoke. -- The command validates configs, contracts and fixtures through `validate_stage0()`. -- It runs `blend_normal`, `blend_risk` and `blend_missing` through the existing - deterministic model-demo cycle. -- It verifies the expected status for each scenario: `hold`, `recommend` and - `abstain`. -- It verifies that each run writes the required journal files: - `result.json`, `input.json`, `trace.jsonl` and `candidates.jsonl`. -- It prints a JSON summary with `passed`, `validation`, `scenarios`, - `environment`, `limitations` and `issues`. +## Чистый запуск -By default, acceptance journals are written under `runs/stage6/`. For tests or -clean demos, pass an explicit temporary directory: +Из корня репозитория: ```bash -python -m source.main accept-stage6 --run-dir .test_tmp/stage6-runs +git lfs install +git lfs pull +python -m venv .venv +python -m pip install --upgrade pip +python -m pip install -r requirements.lock.txt +python -m source.main validate-stage0 +python -m pytest +python -m source.main prepare --materials materials --config config/runtime.toml ``` -## Demonstration Flow +На Linux системный пакет Tk может называться `python3-tk`; он нужен только для окна UI, +но не для CLI и тестов ML. -Recommended clean-run sequence: +## Обучение без утечки финального test ```bash -python -m venv .venv -python -m pip install -r requirements.txt -python -m source.main validate-stage0 -python -m source.main accept-stage6 -python -m source.ui --smoke --scenario blend_risk -python -m pytest +python -m source.main train \ + --dataset data/processed/ \ + --target-source pak \ + --with-uncertainty ``` -Desktop UI remains available: +Команда требует чистый worktree и сохраняет точный Git commit в metadata. Point model +выбирается на validation; upper model выбирается и калибруется на двух непересекающихся +половинах validation. Финальный test используется только для однократной оценки. + +Полная проверка зафиксированных локальных артефактов: ```bash -python -m source.ui --scenario blend_risk +python -m source.main verify-model-freeze --manifest config/model_freeze.json ``` -The UI shows the current model-demo capability: model blending diagnostics, -constraint checks, result export, and journal inspection. +Проверяются хеши `model.joblib`, metadata, metrics и финальных агрегированных отчётов, +dataset ID, запрет action capability и флаги неиспользования test при выборе/настройке. + +## Историческая оценка -## Limits +```bash +python -m source.main evaluate \ + --dataset data/processed/ \ + --model artifacts/models/ \ + --source pak --split test + +python -m source.main evaluate \ + --dataset data/processed/ \ + --model artifacts/models/ \ + --source lims --split test +``` -- Stage 6 does not create or retrain a model. -- Stage 6 does not recommend industrial setpoint changes. -- T95 and cetane number in model-demo are synthetic scenario values; the complete - industrial product passport remains not assessed. -- `0.95` uncertainty coverage remains an empirical historical estimate, not an - industrial safety guarantee. +Baseline и модель сравниваются только на общих timestamp. ПАК и ЛИМС не усредняются. +Строковые residuals сохраняются локально для разбора ошибок, агрегированные метрики +перечислены ниже после финального frozen run. -## How To Check +## Воспроизводимые демонстрационные эпизоды ```bash -python -m pytest global_tests/test_stage6_acceptance.py -python -m pytest global_tests/test_ui.py global_tests/test_stage5_uncertainty_policy.py -python -m pytest -python -m ruff check . -python -m mypy source +python -m source.main acceptance --output reports/full-quality-acceptance ``` + +Каталог содержит: + +1. `stable_blend` — `hold`: S upper 9.306 мг/кг, T95 upper 354 °C, + cetane lower 52.6; +2. `sulfur_risk` — `recommend`: текущая S upper 14.058 мг/кг; выбран вариант + A=89.1%, B=9.9%, присадка=1%, S upper 9.306 мг/кг; +3. `t95_risk` — `recommend`: T95 upper снижается с 367 до 359.5 °C; +4. `cetane_risk` — `recommend`: cetane lower повышается с 50.6 до 51.1, дорогая + присадка исключается из выбранного варианта; +5. `missing_component_quality` — `abstain`: отсутствующая оценка серы компонента + блокирует расчёт независимо от экономики. + +Каждый `hold`/`recommend` одновременно проходит все три обязательные проверки. Верхние +оценки используются для серы и T95, нижняя — для цетанового числа. Модель T95/CN и кривая +присадки являются изменяемыми параметрами сценария, а не результатом промышленной +калибровки. + +`summary.json` хранит численные результаты, причины, время цикла и fingerprint решения +без случайного `run_id`. `journals.zip` содержит все шесть файлов каждого запуска и +manifest с SHA-256. + +## Frozen результаты + +Артефакты обучены на неизменённом commit +`ff6085d24ec53deb227f5aa3ab64bd3aab214bd0`, dataset `aacc7c1ab3d9`, Python 3.12.3, +scikit-learn 1.9.0. Полные SHA-256 находятся в `config/model_freeze.json`. + +| Роль | Model ID | Возможности | +| --- | --- | --- | +| Point forecast | `last_value-848324f28111` | forecast=true, uncertainty=false, actions=false | +| Point + upper | `sulfur-upper-e753ac99b59e` | forecast=true, uncertainty=true, actions=false | + +Validation сохранил persistence baseline: сложные модели имели больше пропущенных +превышений, поэтому не прошли защитный критерий. + +| Validation, ПАК | MAE, мг/кг | Доля пропущенных превышений | +| --- | ---: | ---: | +| Last value, выбран | 0.689 | 55.11% | +| Ridge | 0.773 | 83.64% | +| HGB | 0.768 | 85.41% | + +Итоговая историческая оценка на test 2026: + +| Источник цели | N | Baseline MAE | Model MAE | MAE около 8–12 | Пропуски >10 | Ложные тревоги | Coverage | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| ПАК | 31 819 | 0.684 | 0.684 | 0.790 | 28.58% | 4.96% | 100% | +| ЛИМС | 253 | 1.781 | 1.781 | 1.517 | 83.33% | 6.91% | 100% | + +Главный негативный срез — май 2026: MAE 0.889 по ПАК (`n=4464`) и 2.496 по ЛИМС +(`n=43`). Расхождение подтверждает, что хорошая оперативная метрика ПАК не заменяет +контроль по ЛИМС. + +Upper-модель на test ПАК: `n=31819`, coverage 96.34%, средняя ширина 1.944 мг/кг, +доля точек внутри train-only applicability bounds 63.36%, in-domain coverage 95.99%. +При стресс-сдвиге факта `+0.5 мг/кг` coverage падает до 84.33%; на последней четверти +test составляет 97.80%. Это диагностические показатели, а не safety guarantee. + +Historical replay на `2026-01-15T12:00:00+03:00` даёт point 6.223 мг/кг и upper +8.949 мг/кг. Ограничение серы проходит, но итог остаётся `abstain`: action capability +отключена, а подтверждённые факторы индекса надёжности отсутствуют. + +Приёмочный model-demo прогон на Intel64 Family 6 Model 186, 20 логических процессорах и +15.7 GiB RAM занял около 0.02 с на один цикл; проверялись 84 кандидата. Это время +короткого синтетического цикла, а не подготовки данных или обучения. + +Модели и полные residuals остаются локальными производными файлами согласно `.gitignore`. +Для точного восстановления frozen артефакта нужно обучать на указанном training commit; +финальная ветка проверяет полученные файлы командой `verify-model-freeze`. + +## Ограничения и основные ошибки + +- Финальная point model может остаться persistence baseline: более сложная модель не + принимается, если увеличивает число пропущенных превышений на validation. +- Историческая точность ПАК не равна точности относительно контрольного ЛИМС; результаты + источников показываются раздельно. +- Верхняя граница 0.95 — эмпирическое покрытие на истории, не промышленная гарантия. +- Applicability использует одномерные train-квантили признаков и может отклонять много + test-точек при сдвиге режима. +- T95 и цетановое число проверяются только внутри явно объявленной линейной модели + смешения; промышленная валидность этой зависимости не заявляется. +- Кривая `доза присадки -> прирост цетанового числа` синтетическая и настраиваемая. + Ограничение 3% и ценовой коэффициент 100x взяты из уточнения эксперта, но сама кривая + должна быть перекалибрована на конкретном топливе перед производственным применением. +- `P8`, `T11`, `F19` известны как управляющие переменные, но модель причинного эффекта + действий не подтверждена. Реальные setpoint-рекомендации запрещены. +- Условный эффект блендинга доказывается только внутри синтетической модели паспорта; + исторические данные не подтверждают невыполненные воздействия. diff --git a/STAGE7.md b/STAGE7.md index 3e96762..4c71b3c 100644 --- a/STAGE7.md +++ b/STAGE7.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD # Stage 7: trusted history artifact serving > Current status: Stage 7 connects a trusted local forecast artifact to the @@ -61,3 +62,60 @@ python -m pytest python -m ruff check . python -m mypy source ``` +======= +# Stage 7: constraint-aware safety forecast + +## Что реализовано + +- отдельная бинарная цель `S(t+60) > 10 mg/kg` поверх прежнего point forecast; +- Logistic Regression, HistGradientBoostingClassifier и LightGBM с весами превышений + `1, 2, 5, 10, 20` и purged temporal folds; +- для LightGBM зона 8--12 mg/kg получает дополнительный вес `2`; +- Platt calibration на первой половине validation; +- safety-first выбор alarm threshold на второй половине validation; +- gate: `FNR <= 10%` при `FPR <= 20%`; +- composite artifact schema 1.1: point, upper, probability, alarm и applicability; +- joint PCA/Mahalanobis applicability вместо пересечения всех marginal bounds; +- отдельный исследовательский PAK-to-LIMS correction helper; +- action capability требует не менее 100 эпизодов на control, 10% temporal gain, + coverage 95%, устойчивый знак, shadow replay и одобрение технолога. + +## Обучение + +```bash +python -m source.main train \ + --dataset data/processed/ \ + --with-uncertainty \ + --with-safety +``` + +`--with-safety` без `--with-uncertainty` отклоняется. Если validation gate не пройден, +safety artifact не публикуется. Старые schema-1.0 point/upper artifacts остаются +совместимыми fallback-моделями. + +## Граница доказательств + +Safety alarm улучшает обнаружение риска качества, но не доказывает эффект изменения +уставок. `supports_actions` остаётся `false`, пока отсутствуют подтверждённые пределы +`P8/T11/F19`, достаточные change episodes, shadow replay и пилот с технологом. Hard +constraints продолжают проверяться после ML и не заменяются штрафом в loss. + +## Результат на закреплённом dataset `aacc7c1ab3d9` + +Лучший sklearn-кандидат -- HistGradientBoostingClassifier с весом превышения `2`. +На второй половине validation он получил `FNR=11.63%` при `FPR=19.93%`, поэтому +не прошёл обязательный gate. На test: `FNR=6.08%`, `FPR=31.90%`, transition recall +`79.11%`, joint applicability `98.78%`. Test не участвовал в выборе. + +После этого проверен LightGBM. Он оказался хуже sklearn: validation `FNR=13.22%` +при `FPR=19.92%`, test `FNR=7.61%`, `FPR=32.04%`, transition recall `74.57%`. +LightGBM не продвигается. + +Отдельная LIMS-коррекция также не проходит gate: исходный PAK point forecast имеет +test MAE `1.781 mg/kg`, скорректированный -- `2.094 mg/kg`, coverage верхней оценки +`84.98%`. Production LIMS capability не объявляется. + +Итог: schema 1.1 и весь safety pipeline реализованы, но новый artifact намеренно +не публикуется. Runtime продолжает использовать frozen fallback. PyTorch и +decision-focused loss откладываются до появления достоверных action outcomes. +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) diff --git a/STAGE8.md b/STAGE8.md new file mode 100644 index 0000000..350d6a5 --- /dev/null +++ b/STAGE8.md @@ -0,0 +1,79 @@ +# Stage 8: диагностика данных и причин слабого ML + +Диагностика выполняется read-only командой: + +```bash +python -m source.main diagnose-ml --dataset data/processed/aacc7c1ab3d9 +``` + +Команда не обучает и не публикует модель. Она отдельно показывает временной drift, +режимы текущей серы, согласование ПАК--ЛИМС и устойчивость связей признаков. + +## Результаты на `aacc7c1ab3d9` + +### ПАК и ЛИМС + +В LIMS присутствуют шесть значений выше 30 mg/kg: `34`, `35.1`, `45.9`, `107`, +`120`, `2120`. Они сохраняются как факты, потому что без технолога нельзя объявить их +ошибками. На полном наборе ПАК и ЛИМС почти не коррелируют; лучший MAE среди проверенных +сдвигов равен `3.217 mg/kg` при `-60 min`. + +Подтверждённая ML-политика исключает ровно эти шесть `observation_id`, но не удаляет +строки из prepared data. После исключения лучший alignment MAE +равен `1.568 mg/kg` при `-60 min`, а максимальная корреляция `0.367` достигается около +`-90 min`. Другие высокие значения автоматически не исключаются. + +Повторная LIMS-коррекция после исключения выбросов улучшилась с `2.251` до +`2.094 mg/kg`, но всё ещё хуже исходного PAK point MAE `1.781 mg/kg`; upper coverage +осталась `84.98%`. Поэтому LIMS-модель не продвигается. + +`measured_at` LIMS означает момент отбора пробы, а `available_at` устанавливается на +четыре часа позже. Значение используется как отложенная метка прошлого, но не может +попасть в признаки или состояние оператора до `available_at`. Сдвиг `-60...-90 min` +из alignment-анализа не применяется автоматически: четыре часа публикации -- это +задержка знания результата, а не перенос физического момента отбора. + +### Temporal drift + +| Период | Exceedance rate | Переходов `current<=10 -> future>10` | Persistence MAE | Corr current/future | +| --- | ---: | ---: | ---: | ---: | +| train | 13.80% | 8943 | 1.142 | 0.505 | +| validation | 6.88% | 1994 | 0.689 | 0.749 | +| test | 14.79% | 1345 | 0.684 | 0.929 | + +Test существенно отличается от validation: дисперсия цели выросла с `1.51` до +`3.02 mg/kg`, а доля превышений более чем удвоилась. Поэтому один глобальный +калибровочный порог не сохраняет заданный FPR во времени. + +### Режимы + +На test `1147` из `1345` новых превышений начинаются при текущей сере `8--10 mg/kg`. +Это основной режим для раннего предупреждения. При текущей сере выше `12 mg/kg` test +содержит `2400` точек против `350` на validation; это главный наблюдаемый сдвиг режима. + +### Признаки + +Сырые телеметрические значения почти не объясняют изменение серы за 60 минут: +максимальная абсолютная train-корреляция около `0.026`. У `P8`, `T11`, `F19` связи +также малы. Несколько их delta-признаков меняют знак между train/validation/test. + +Наиболее сильный устойчивый сигнал -- собственная история ПАК. Однако знак связи +`pak_delta_30m`, `pak_delta_60m` и коротких delta controls меняется на test. Добавление +всех 98 телеметрических колонок поэтому не обосновано: это увеличит переобучение без +устойчивого сигнала. + +## Проверенная, но отклонённая гипотеза + +Отдельный HGB-классификатор переходов, обученный только при `current_S<=10`, сохранил +validation FNR `11.63%` и снизил test transition recall с `79.11%` до `78.07%`. +Кандидат удалён из production-кода как не дающий выигрыша. + +## Следующие необходимые данные + +1. Нужно подтвердить, совпадает ли timestamp ЛИМС с отбором на той же точке, которую + измеряет ПАК, и допустим ли физический сдвиг 60--90 минут. +2. Для `P8/T11/F19` нужны фактические единицы, рабочие диапазоны и журналы изменений. +3. После подтверждения задержек следует строить lagged response features и temporal + regime model, особенно для диапазона текущей серы 8--10 mg/kg. + +До этих подтверждений frozen fallback и `supports_actions=false` сохраняются. diff --git a/STAGE9.md b/STAGE9.md new file mode 100644 index 0000000..da2415a --- /dev/null +++ b/STAGE9.md @@ -0,0 +1,221 @@ +# Stage 9 — ML v2: эпизодный shadow-прогноз + +После письменного Q&A 15.09.2026 источником истины по 24-2000 является +`materials/теги АВТ_24-2000.xlsx`: P8 — перепад давления, F19 — расход бензина +в К-201, T11 — температура продукта, F26 — объёмный выход. Приведённые ниже +старые audit-метрики относятся к предыдущему словарю и не используются для +продвижения новой версии. + +## Запуск + +```bash +python -m source.main train-v2-shadow \ + --dataset data/processed/a2fe8577752a \ + --output artifacts/models +``` + +Если рабочее дерево содержит незакоммиченные изменения (типичный режим разработки +на хакатоне), fit можно запустить явно как исследовательский shadow: + +```bash +python -m source.main train-v2-shadow \ + --dataset data/processed/a2fe8577752a \ + --output artifacts/models \ + --allow-dirty-shadow +``` + +В этом случае `git_commit` получает метку `working-tree::`. Это +позволяет связать результат с конкретным состоянием файлов, но не превращает его в +production freeze: `production_status=shadow_only`, `supports_actions=false` и +promotion-gate остаются обязательными. + +Команда обучает schema `1.2`. Артефакт всегда получает статус `shadow_only` и +`supports_actions=false`. + +## Что моделируется + +- `delta_60m` и итоговые `point_60m`, `upper_60m`; +- вероятность хотя бы одного перехода через `10 mg/kg` за 10/20/30/60 минут; +- событие считается обнаруженным, если alarm появился за 10–60 минут до начала + непрерывного эпизода превышения; +- строки внутри одного эпизода получают обратный длине эпизода вес, месяцы + балансируются отдельно; +- `current_S > 10` всегда включает тревогу независимо от модели. + +Признаки строятся только из доступного к `as_of` ПАК: динамика и диапазоны за +30/60/180 минут, пересечения 8/9/10, длительность режима, missingness, возраст, +а также `P8/T11/F19`. Остальные телеметрические сигналы не подключаются. + +Схемы АВТ от 14.09 не меняют этот frozen feature list. Они задают следующий +train-only эксперимент после PAK-only baseline: отдельно проверить группы +`k2_state={avt:F65,avt:T20,avt:T33,avt:P21,avt:P22,avt:P23,avt:P67}`, +`k2_circulation={avt:F14,avt:T13,avt:T18,avt:F12,avt:T17,avt:F64,avt:T11,avt:T15}` +и `diesel_cut={avt:T66,avt:F28,avt:F32,avt:T71,avt:F30,avt:W70}`. Группы нельзя +объединять с одноимёнными `ht:*`; audit-2026 не используется для отбора. + +Для ablation запускается одна или несколько явно перечисленных групп, чтобы тяжёлый +rolling-origin расчёт не начинался из UI случайно: + +```bash +python -m source.main ablate-v2-features --dataset data/processed/a2fe8577752a \ + --group pak_only --group ht_context +``` + +Отчёт содержит HGB и LightGBM, их event FNR/FPR, row FPR, Brier, MAE и порог; +для exploratory ablation используется фиксированный малый бюджет +`HGB(max_iter=8, max_leaf_nodes=7)` и `LightGBM(n_estimators=20, num_leaves=7)`; +для ограничения времени fit берётся не более 20 000 train-строк на fold с сохранением +положительных event-строк. Такой отчёт предназначен только для скрининга и всегда +`promotion_eligible=false`. Только после выбора на 2024 выбранную группу +проверяют на 2025 и затем фиксируют отдельный shadow artifact. + +Исторический скрининг на `aacc7c1ab3d9` (2024, только горизонт 60 минут) выполнен +командой `ablate-v2-features` 15.09.2026; audit-2026 в отборе не использовался: + +| Группа | Семейство | Event FNR | Event FPR | MAE, мг/кг | +| --- | --- | ---: | ---: | ---: | +| PAK-only | LightGBM | 41.17% | 19.99% | 1.008 | +| HT context (P8/T11/F19) | LightGBM | 41.82% | 19.95% | 1.006 | +| K-2 state | LightGBM | 41.37% | 19.99% | 1.007 | +| K-2 circulation | LightGBM | 41.67% | 19.95% | 1.009 | +| Diesel cut | LightGBM | 41.52% | 19.96% | 1.010 | + +Ни одна группа не даёт устойчивого выигрыша относительно PAK-only: HT-контекст и +AVT-группы немного меняют MAE, но не улучшают event FNR. Поэтому текущий shadow-fit +сохраняет HT только как именованный контекст, а AVT не подключает. Выбранную группу +нужно отдельно повторить на 2025 до любого изменения production-признаков. + +## Временная проверка + +- 2024: rolling-origin сравнение HGB и LightGBM; +- январь–июнь 2025: калибровка вероятностей; +- июль–декабрь 2025: фиксация alarm threshold; +- 2026: только audit, без выбора признаков, модели или порога. + +Порог обязан одновременно соблюдать row-level и event-opportunity FPR не выше +20%. В отчёте сохраняются event/row FNR/FPR, Brier, PR-AUC, MAE, q95 coverage, +срезы `<8`, `8–10`, `>10`, месяцы, bootstrap CI и предупреждения по каждому +горизонту. Для upper-bound отдельно считаются пропуски правила `upper > 10` и +ложные тревоги этого правила; upper-gate требует coverage не ниже 95% и долю +пропусков лимита не выше 5%. + +## LIMS и продвижение + +LIMS — отдельный отложенный слой. Признак доступен только при +`available_at <= as_of`; шесть подтверждённых выбросов исключаются по +`observation_id`, prepared data не меняется. Коррекция проходит собственный gate: +MAE минимум на 5% лучше ПАК и coverage не ниже 95%. + +Команда `evaluate-lims-correction --dataset --pak-model ` +выполняет эту проверку и печатает отдельный отчёт. Она не меняет оперативный ПАК +прогноз; UI «Контроль ПАК–ЛИМС» показывает тот же отчёт как исследовательский +слой. На новом `a2fe8577752a` Ridge-коррекция также не прошла validation gate: +validation MAE `1.620` против `1.513 mg/kg` у ПАК и upper coverage `88.89%`. +Test-аудит хуже (`2.002` против `1.781 mg/kg`, coverage `84.98%`). Шесть +подтверждённых LIMS-выбросов исключены только по `observation_id`; исходные данные +не менялись. Поэтому статус `research_only`; test не участвует в выборе. + +Audit 2026 не разрешает production. После фиксации нужен новый shadow-период: +100 независимых эпизодов или три полных месяца. Действия `P8/T11/F19` остаются +запрещены до отдельной модели эффектов и инженерного gate. + +Для проверки зафиксированного shadow-артефакта в одной исторической точке используется +отдельный read-only путь: + +```bash +python -m source.main replay-v2-shadow \ + --dataset data/processed/a2fe8577752a \ + --model artifacts/models/sulfur-v2-shadow- \ + --at 2025-06-01T12:00:00+03:00 +``` +Он возвращает вероятности пересечения лимита на 10/20/30/60 минут, point/upper на +60 минут, `applicable`, `reason_codes` и `production_status=shadow_only`; никаких +изменений уставок или рекомендаций этот путь не выполняет. + +## Текущий хакатонный fit + +Предыдущий fit на `aacc7c1ab3d9` относится к устаревшему словарю и оставлен только +как audit-история. Новый dirty-shadow fit на датасете `a2fe8577752a` завершён +артефактом `artifacts/models/sulfur-v2-shadow-61f972d07181`: правило +`ht:Q21=307 -> missing`, признаки только `P8/T11/F19`, selected family +LightGBM. В `metrics.json` дополнительно сохранены месячные срезы для selection +(2024) и threshold-периода (июль–декабрь 2025), худший месяц и bootstrap 95% CI +по независимым эпизодам. На selection-2024 event FNR `3.28%`, event FPR `19.99%`, +row FPR `13.12%`; audit-2026 сохраняется только для проверки и не проходит +production gate (event FPR `40.44%`, upper coverage `94.21%`). В любом случае +schema 1.2 остаётся `shadow_only` и не включает действия. + +## Влияние Q&A 11.09 + +Новые ответы не меняют target и временную схему v2, но усиливают границы: + +- решение и артефакты должны полностью работать офлайн; +- точки АВТ нельзя объединять с гидроочисткой, а физический lag между ними нельзя + назначать без карты потоков; +- смысл и единицы `ht:P8`, `ht:T11`, `ht:F19` ранее оспаривались, теперь подтверждены + новым словарём. Они остаются наблюдаемыми признаками/историческим исследованием, + а не разрешёнными физическими controls; перед promotion всё равно требуется + независимая проверка action-effect и инженерных пределов; +- действующая обратная связь АСУ может создавать корреляцию «состояние → действие». + Поэтому прогнозная важность control-признака не является оценкой эффекта действия; +- численные правила пуска/останова не подтверждены. Такие режимы обрабатываются + applicability/drift предупреждением, а не придуманным порогом. + +Ни один ответ Q&A не разрешает ослабить `FNR/FPR`, coverage или action gates. + +## Влияние Q&A 15.09 и требования к демонстрации + +Дополнительная запись уточняет доступность данных и формат проверки: + +- ЛИМС измеряет момент отбора, а результат становится доступен примерно через + 3–4 часа; v2 и LIMS-контроль используют `available_at <= as_of` без сдвига + `measured_at`. +- Резервуарный парк между АВТ и гидроочисткой даёт обычно несколько часов, + максимум около суток. Это контекст для будущей transport-модели, но не + разрешение на причинный межустановочный lag без карты потоков. +- Экспертный ориентир технологического запаса серы — 1–2 ppm в зависимости от + установки и автоматизации. В проекте он остаётся параметром сценария, не + паспортным ограничением конкретного режима. +- Некондиция может потребовать повторной переработки, ориентировочно в 50–100 + раз дороже запаса качества. До получения тарифа это только мотивация safety-first, + а не число для оптимизатора. +- На защите команда должна задавать сценарии и показывать реакцию продукта; + линейный симулятор допустим как baseline, после чего эксперты могут менять + сценарии. Поэтому UI/replay должен быть воспроизводимым, но исторический + эффект P8/F19 остаётся «не советом», а реальное изменение уставок запрещено. + +## Влияние схем АВТ 14.09 + +Схемы частично закрывают Q1: теперь известны аппараты и внутренние линии для +многих `avt:*`. Они не закрывают Q2/Q5: на листах нет 24-2000, резервуаров между +установками, времени пребывания или карты ЛИМС-точек. Поэтому новые признаки +сначала проходят отдельную rolling-origin ablation; межустановочный lag остаётся +исследовательским и не получает физического статуса по корреляции. + +## Результат прежнего backtest на `aacc7c1ab3d9` + +После исправления определения окна раннего предупреждения (alarm за 10–60 минут +до первого превышения) выбран LightGBM. На 2024 selection event FNR `3.28%`, +event-opportunity FPR `19.99%`, row FPR `13.12%`; threshold `0.029275` зафиксирован +по июлю–декабрю 2025. Test 2026 используется только как audit: + +| Метрика | Результат | +| --- | ---: | +| Независимых эпизодов | 392 | +| Event FNR | 1.53% | +| Event-opportunity FPR | 40.44% | +| Row FNR | 4.47% | +| Row FPR | 26.55% | +| PR-AUC | 0.900 | +| Brier | 0.0557 | +| Point MAE | 0.699 mg/kg | +| q95 coverage | 94.21% | + +По горизонтам event FNR равен `8.16% / 3.57% / 2.55% / 1.53%` для +10/20/30/60 минут, а event FPR — `15.15% / 22.26% / 29.07% / 40.44%`. +Point MAE v2 на audit равен `0.699 mg/kg`; это отдельная от alarm метрика и не +используется для ослабления safety-gate. + +Вывод: эпизодная постановка решила проблему пропуска начала событий, но не прошла +production gate из-за drift ложных тревог и coverage ниже 95%. Артефакт остаётся +`shadow_only`; порог по audit не перенастраивается. diff --git a/actual problems.md b/actual problems.md index 6d5ff8e..7045ff8 100644 --- a/actual problems.md +++ b/actual problems.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD # Актуальные проблемы проекта Последнее обновление: 2026-09-14. @@ -220,3 +221,51 @@ reason codes, model ID и доступный путь к журналу. Про Эти результаты не закрывают AP-001--AP-008. Приоритет следующей работы: AP-001 и AP-002, затем history-экран и его обработка завершения, объяснения и корректная оценка модели. +======= +# Actual Problems + +Last reviewed: 2026-09-20. + +## Active blockers + +- `actual problems.md` was missing from the current branch and must stay part of acceptance evidence. +- Frozen model artifacts referenced by `config/model_freeze.json` are not present locally, so `python -m source.main verify-model-freeze` fails before hash verification can complete. +- Frozen evaluation reports referenced by `config/model_freeze.json` are not present locally. +- The only local prepared dataset, `data/processed/d175aedffaba`, is stale relative to current `config/runtime.toml`, `config/tags.csv`, and telemetry rules. It was created with old hashes and a different LIMS delay assumption. +- `python -m source.main diagnose-ml --dataset data/processed/d175aedffaba` fails on stale data with no valid PAK target rows; this should be reported as a dataset/runtime mismatch before ML diagnostics. +- `write_prepared_dataset()` can hit a transient Windows `PermissionError` while atomically publishing a prepared dataset. Publication needs a bounded retry instead of flaky failure. +- Current P8/F19 historical action artifacts remain shadow-only. `supports_actions=true` is blocked until engineering bounds, step/unit evidence, temporal gates, shadow replay, and technologist approval all pass. + +## Current implementation status + +- Model-demo acceptance is working: `blend_normal=hold`, risk scenarios recommend, and missing quality abstains. +- History forecast is read-only unless a verified action artifact is supplied. +- Tkinter UI is now stage-aware: `avt`, `hydrotreating`, `blend`, `history`, and `journal` have real screens or read-only projections instead of disabled placeholders. +- Observed telemetry quantiles are research context only and must not become engineering limits. +- `ht:P8` and `ht:F19` are the only first production-action candidates; `ht:T11` and `ht:F26` remain context-only. + +## Fixed in current working tree + +- Prepared dataset publication now retries transient Windows `PermissionError` during atomic directory replace. +- CLI commands that consume prepared data now reject stale config/tag/rules hashes before ML diagnostics/training/replay. +- `verify-model-freeze` now understands production action artifacts and checks controls, horizons, fingerprints and gate report hashes. +- `replay` has an optional `--action-model` path. Without it, history remains forecast-only and abstains with `ACTION_MODEL_UNAVAILABLE`. +- Tkinter history replay has a separate optional verified action artifact field and a dedicated `История/ML` screen. +- Tkinter AVT and hydrotreatment buttons now open read-only stage dashboards with value/source/age/freshness instead of placeholders. +- Tkinter blend screen now has a hybrid sulfur-only panel that refuses to invent component A when history forecast is unavailable. + +## Required evidence before full action capability + +- Fresh canonical prepared dataset from current materials/runtime/tags/rules. +- Point, upper, and safety forecast artifacts trained from that canonical dataset. +- PAK and LIMS test reports written under `reports/`. +- Updated `config/model_freeze.json` after successful freeze verification. +- Action-effect gate report for `ht:P8` and `ht:F19`: + - at least 100 change episodes per enabled control; + - validation MAE at least 10% better than hold; + - validation and audit upper coverage at least 95%; + - stable effect sign across 3 temporal folds; + - confirmed engineering units, bounds, step, and max_step; + - shadow replay passed; + - technologist pilot approved. +>>>>>>> e70cafe (fix) diff --git a/config/demo_episodes.json b/config/demo_episodes.json new file mode 100644 index 0000000..bd8603d --- /dev/null +++ b/config/demo_episodes.json @@ -0,0 +1,55 @@ +{ + "schema_version": "1.0", + "episodes": [ + { + "id": "stable_blend", + "scenario": "blend_normal", + "expected_status": "hold", + "expected_reason_codes": ["NO_MATERIAL_IMPROVEMENT"], + "expected_baseline_quality": {"sulfur_upper": 9.306, "t95_upper": 354.0, "cetane_lower": 52.6}, + "expected_selected_quality": {"sulfur_upper": 9.306, "t95_upper": 354.0, "cetane_lower": 52.6}, + "expected_recipe": {"A": 0.891, "B": 0.099}, + "expected_additive_fraction": 0.01 + }, + { + "id": "sulfur_risk", + "scenario": "blend_risk", + "expected_status": "recommend", + "expected_reason_codes": ["QUALITY_LIMIT"], + "expected_baseline_quality": {"sulfur_upper": 14.058, "t95_upper": 358.0, "cetane_lower": 51.8}, + "expected_selected_quality": {"sulfur_upper": 9.306, "t95_upper": 354.0, "cetane_lower": 52.6}, + "expected_recipe": {"A": 0.891, "B": 0.099}, + "expected_additive_fraction": 0.01 + }, + { + "id": "t95_risk", + "scenario": "blend_t95_risk", + "expected_status": "recommend", + "expected_reason_codes": ["QUALITY_LIMIT"], + "expected_baseline_quality": {"sulfur_upper": 7.425, "t95_upper": 367.0, "cetane_lower": 51.0}, + "expected_selected_quality": {"sulfur_upper": 6.9795, "t95_upper": 359.5, "cetane_lower": 51.6}, + "expected_recipe": {"A": 0.6435, "B": 0.3465}, + "expected_additive_fraction": 0.01 + }, + { + "id": "cetane_risk", + "scenario": "blend_cetane_risk", + "expected_status": "recommend", + "expected_reason_codes": ["QUALITY_LIMIT"], + "expected_baseline_quality": {"sulfur_upper": 8.019, "t95_upper": 349.0, "cetane_lower": 50.6}, + "expected_selected_quality": {"sulfur_upper": 6.6, "t95_upper": 344.0, "cetane_lower": 51.1}, + "expected_recipe": {"A": 0.8, "B": 0.2}, + "expected_additive_fraction": 0.0 + }, + { + "id": "missing_component_quality", + "scenario": "blend_missing", + "expected_status": "abstain", + "expected_reason_codes": ["MISSING_REQUIRED_SIGNAL"], + "expected_baseline_quality": {"sulfur_upper": null, "t95_upper": 358.0, "cetane_lower": 51.8}, + "expected_selected_quality": null, + "expected_recipe": null, + "expected_additive_fraction": null + } + ] +} diff --git a/config/model_freeze.json b/config/model_freeze.json new file mode 100644 index 0000000..d65cfaf --- /dev/null +++ b/config/model_freeze.json @@ -0,0 +1,44 @@ +{ + "schema_version": "1.0", + "frozen_on": "2026-09-12", + "training_dataset_id": "aacc7c1ab3d9", + "training_git_commit": "ff6085d24ec53deb227f5aa3ab64bd3aab214bd0", + "python_version": "3.12.3", + "sklearn_version": "1.9.0", + "artifacts": [ + { + "role": "point_forecast", + "model_id": "last_value-848324f28111", + "path": "artifacts/models/last_value-848324f28111", + "model_sha256": "f6a2648569562fbdf452f7e3c12324d5b3da1876a528aad6377b49c2b0ceea37", + "metadata_sha256": "49aeaacf784df33c3dfab62c0db312c53e30fd24477fd0ea06d9b3cbfa7181e6", + "metrics_sha256": "c5a3db197430723a9f125383ada58f359748c885426192e3ca7e34bb3cb70bbf" + }, + { + "role": "point_and_upper_forecast", + "model_id": "sulfur-upper-e753ac99b59e", + "path": "artifacts/models/sulfur-upper-e753ac99b59e", + "model_sha256": "744b4ed2b29419104b11234cc6ec8b976346e655a1504a06c86528e7d8407f2c", + "metadata_sha256": "9b9716ff6035fa9bd0347057dc713b8c5021406aa148abc0aa85d95150586825", + "metrics_sha256": "4a853c515fe5648c05f0f8b2c15ed8d95bae58b5a125d275ccf477a0f408e283" + } + ], + "evaluation_reports": [ + { + "target_source": "pak", + "split": "test", + "path": "reports/final/last_value-848324f28111/pak_test/metrics.json", + "sha256": "ca770a1b7479ebec6c0b46a4ff902a60772828824c544c93c1284f03429743f6" + }, + { + "target_source": "lims", + "split": "test", + "path": "reports/final/last_value-848324f28111/lims_test/metrics.json", + "sha256": "345232219cf907f5dd35f9c6b8586e471fbf9d111ffddf49fb71141b12836123" + } + ], + "evidence_boundary": { + "history": "Forecast accuracy only; no causal action claim.", + "model_demo": "Synthetic blending effect only; no plant guarantee." + } +} diff --git a/config/scenarios/blend_cetane_risk.json b/config/scenarios/blend_cetane_risk.json new file mode 100644 index 0000000..340e182 --- /dev/null +++ b/config/scenarios/blend_cetane_risk.json @@ -0,0 +1,47 @@ +{ + "id": "blend_cetane_risk", + "mode": "model_demo", + "required_signals": [], + "controls": [], + "constraints": [ + {"id": "blend_sulfur", "metric": "sulfur", "stage": "blend", "lower": null, "upper": 10.0, "unit": "mg/kg", "use_upper_estimate": true, "use_lower_estimate": false, "required": true, "basis": "tz", "evidence_ref": "materials/ТЗ_нефтекод.docx"}, + {"id": "blend_t95", "metric": "t95", "stage": "blend", "lower": null, "upper": 360.0, "unit": "degC", "use_upper_estimate": true, "use_lower_estimate": false, "required": true, "basis": "model_assumption", "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario"}, + {"id": "blend_cetane", "metric": "cetane_number", "stage": "blend", "lower": 51.0, "upper": null, "unit": "cetane_number", "use_upper_estimate": false, "use_lower_estimate": true, "required": true, "basis": "model_assumption", "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario"} + ], + "blend_components": [ + { + "id": "A", + "sulfur": {"value": 5.0, "lower": null, "upper": 6.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "t95": {"value": 340.0, "lower": null, "upper": 342.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 53.0, "lower": 52.5, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "available_mass_t": 100.0, + "cost_proxy_per_t": 1.0, + "risk_index": 0.0, + "source_state_id": null + }, + { + "id": "B", + "sulfur": {"value": 8.0, "lower": null, "upper": 9.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "t95": {"value": 350.0, "lower": null, "upper": 352.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 46.0, "lower": 45.5, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "available_mass_t": 80.0, + "cost_proxy_per_t": 0.8, + "risk_index": 0.0, + "source_state_id": null + } + ], + "cetane_additive": { + "id": "cetane_improver", "max_mass_fraction": 0.03, "fraction_step": 0.01, "available_mass_t": 3.0, "cost_proxy_per_t": 100.0, + "response_curve": [{"mass_fraction": 0.0, "cetane_gain": 0.0}, {"mass_fraction": 0.01, "cetane_gain": 3.0}, {"mass_fraction": 0.02, "cetane_gain": 5.0}, {"mass_fraction": 0.03, "cetane_gain": 6.5}], + "evidence_ref": "expert clarification 10.09.2026: dose <=3%, price ratio 100x", + "assumptions": ["The dose-to-cetane curve is synthetic and configurable; it is not plant-calibrated."] + }, + "total_mass_t": 100.0, + "current_blend_mass_fractions": {"A": 0.297, "B": 0.693}, + "current_additive_mass_fraction": 0.01, + "active_criteria": ["risk_index", "throughput", "cost_proxy"], + "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, + "require_upper_bound": true, + "action_cooldown_minutes": 60, + "assumptions": ["All component values and T95/cetane blend relations are synthetic and are not plant guarantees."] +} diff --git a/config/scenarios/blend_missing.json b/config/scenarios/blend_missing.json index e8bc30a..377d978 100644 --- a/config/scenarios/blend_missing.json +++ b/config/scenarios/blend_missing.json @@ -1,49 +1,92 @@ -{ - "id": "blend_missing", - "mode": "model_demo", - "required_signals": [], - "controls": [], - "constraints": [ - { - "id": "blend_sulfur", - "metric": "sulfur", - "stage": "blend", - "lower": null, - "upper": 10.0, - "unit": "mg/kg", - "use_upper_estimate": true, - "required": true, - "basis": "tz", - "evidence_ref": "materials/ТЗ_нефтекод.docx" - } - ], - "blend_components": [ +{ + "id": "blend_missing", + "mode": "model_demo", + "required_signals": [], + "controls": [], + "constraints": [ + { + "id": "blend_sulfur", + "metric": "sulfur", + "stage": "blend", + "lower": null, + "upper": 10.0, + "unit": "mg/kg", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "tz", + "evidence_ref": "materials/ТЗ_нефтекод.docx" + }, + { + "id": "blend_t95", + "metric": "t95", + "stage": "blend", + "lower": null, + "upper": 360.0, + "unit": "degC", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + }, + { + "id": "blend_cetane", + "metric": "cetane_number", + "stage": "blend", + "lower": 51.0, + "upper": null, + "unit": "cetane_number", + "use_upper_estimate": false, + "use_lower_estimate": true, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + } + ], + "blend_components": [ { "id": "A", "sulfur": {"value": null, "lower": null, "upper": null, "unit": "mg/kg", "basis": "formula", "interval_kind": "none", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic missing-data case"]}, - "t95": {"value": 350.0, "lower": null, "upper": 355.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 54.0, "lower": null, "upper": 54.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 350.0, "lower": null, "upper": 352.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 50.5, "lower": 50.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 100.0, "cost_proxy_per_t": 1.0, "risk_index": 0.0, - "source_state_id": null - }, + "source_state_id": null + }, { "id": "B", "sulfur": {"value": 30.0, "lower": null, "upper": 31.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo value"]}, - "t95": {"value": 360.0, "lower": null, "upper": 365.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 48.0, "lower": null, "upper": 48.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 370.0, "lower": null, "upper": 372.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 46.5, "lower": 46.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 30.0, "cost_proxy_per_t": 0.8, "risk_index": 0.0, - "source_state_id": null - } - ], - "total_mass_t": 100.0, - "current_blend_mass_fractions": {"A": 0.7, "B": 0.3}, - "active_criteria": ["risk_index", "throughput", "cost_proxy"], - "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, - "require_upper_bound": true, - "action_cooldown_minutes": 60, - "assumptions": ["All component values, including T95 and cetane number, are synthetic and are not plant guarantees."] + "source_state_id": null + } + ], + "cetane_additive": { + "id": "cetane_improver", + "max_mass_fraction": 0.03, + "fraction_step": 0.01, + "available_mass_t": 3.0, + "cost_proxy_per_t": 100.0, + "response_curve": [ + {"mass_fraction": 0.0, "cetane_gain": 0.0}, + {"mass_fraction": 0.01, "cetane_gain": 3.0}, + {"mass_fraction": 0.02, "cetane_gain": 5.0}, + {"mass_fraction": 0.03, "cetane_gain": 6.5} + ], + "evidence_ref": "expert clarification 10.09.2026: dose <=3%, price ratio 100x", + "assumptions": ["The dose-to-cetane curve is synthetic and configurable; it is not plant-calibrated."] + }, + "total_mass_t": 100.0, + "current_blend_mass_fractions": {"A": 0.693, "B": 0.297}, + "current_additive_mass_fraction": 0.01, + "active_criteria": ["risk_index", "throughput", "cost_proxy"], + "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, + "require_upper_bound": true, + "action_cooldown_minutes": 60, + "assumptions": ["All component values and T95/cetane blend relations are synthetic and are not plant guarantees."] } diff --git a/config/scenarios/blend_normal.json b/config/scenarios/blend_normal.json index 9882092..2c2c33d 100644 --- a/config/scenarios/blend_normal.json +++ b/config/scenarios/blend_normal.json @@ -1,49 +1,92 @@ -{ - "id": "blend_normal", - "mode": "model_demo", - "required_signals": [], - "controls": [], - "constraints": [ - { - "id": "blend_sulfur", - "metric": "sulfur", - "stage": "blend", - "lower": null, - "upper": 10.0, - "unit": "mg/kg", - "use_upper_estimate": true, - "required": true, - "basis": "tz", - "evidence_ref": "materials/ТЗ_нефтекод.docx" - } - ], - "blend_components": [ +{ + "id": "blend_normal", + "mode": "model_demo", + "required_signals": [], + "controls": [], + "constraints": [ + { + "id": "blend_sulfur", + "metric": "sulfur", + "stage": "blend", + "lower": null, + "upper": 10.0, + "unit": "mg/kg", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "tz", + "evidence_ref": "materials/ТЗ_нефтекод.docx" + }, + { + "id": "blend_t95", + "metric": "t95", + "stage": "blend", + "lower": null, + "upper": 360.0, + "unit": "degC", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + }, + { + "id": "blend_cetane", + "metric": "cetane_number", + "stage": "blend", + "lower": 51.0, + "upper": null, + "unit": "cetane_number", + "use_upper_estimate": false, + "use_lower_estimate": true, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + } + ], + "blend_components": [ { "id": "A", "sulfur": {"value": 6.0, "lower": null, "upper": 7.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo value"]}, - "t95": {"value": 350.0, "lower": null, "upper": 355.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 54.0, "lower": null, "upper": 54.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 350.0, "lower": null, "upper": 352.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 50.5, "lower": 50.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 100.0, "cost_proxy_per_t": 1.0, "risk_index": 0.0, - "source_state_id": null - }, + "source_state_id": null + }, { "id": "B", "sulfur": {"value": 30.0, "lower": null, "upper": 31.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo value"]}, - "t95": {"value": 360.0, "lower": null, "upper": 365.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 48.0, "lower": null, "upper": 48.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 370.0, "lower": null, "upper": 372.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 46.5, "lower": 46.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 30.0, "cost_proxy_per_t": 0.8, "risk_index": 0.0, - "source_state_id": null - } - ], - "total_mass_t": 100.0, - "current_blend_mass_fractions": {"A": 0.9, "B": 0.1}, - "active_criteria": ["risk_index", "throughput", "cost_proxy"], - "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, - "require_upper_bound": true, - "action_cooldown_minutes": 60, - "assumptions": ["All component values, including T95 and cetane number, are synthetic and are not plant guarantees."] + "source_state_id": null + } + ], + "cetane_additive": { + "id": "cetane_improver", + "max_mass_fraction": 0.03, + "fraction_step": 0.01, + "available_mass_t": 3.0, + "cost_proxy_per_t": 100.0, + "response_curve": [ + {"mass_fraction": 0.0, "cetane_gain": 0.0}, + {"mass_fraction": 0.01, "cetane_gain": 3.0}, + {"mass_fraction": 0.02, "cetane_gain": 5.0}, + {"mass_fraction": 0.03, "cetane_gain": 6.5} + ], + "evidence_ref": "expert clarification 10.09.2026: dose <=3%, price ratio 100x", + "assumptions": ["The dose-to-cetane curve is synthetic and configurable; it is not plant-calibrated."] + }, + "total_mass_t": 100.0, + "current_blend_mass_fractions": {"A": 0.891, "B": 0.099}, + "current_additive_mass_fraction": 0.01, + "active_criteria": ["risk_index", "throughput", "cost_proxy"], + "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, + "require_upper_bound": true, + "action_cooldown_minutes": 60, + "assumptions": ["All component values and T95/cetane blend relations are synthetic and are not plant guarantees."] } diff --git a/config/scenarios/blend_risk.json b/config/scenarios/blend_risk.json index cdd9e5d..a40c7df 100644 --- a/config/scenarios/blend_risk.json +++ b/config/scenarios/blend_risk.json @@ -1,49 +1,92 @@ -{ - "id": "blend_risk", - "mode": "model_demo", - "required_signals": [], - "controls": [], - "constraints": [ - { - "id": "blend_sulfur", - "metric": "sulfur", - "stage": "blend", - "lower": null, - "upper": 10.0, - "unit": "mg/kg", - "use_upper_estimate": true, - "required": true, - "basis": "tz", - "evidence_ref": "materials/ТЗ_нефтекод.docx" - } - ], - "blend_components": [ +{ + "id": "blend_risk", + "mode": "model_demo", + "required_signals": [], + "controls": [], + "constraints": [ + { + "id": "blend_sulfur", + "metric": "sulfur", + "stage": "blend", + "lower": null, + "upper": 10.0, + "unit": "mg/kg", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "tz", + "evidence_ref": "materials/ТЗ_нефтекод.docx" + }, + { + "id": "blend_t95", + "metric": "t95", + "stage": "blend", + "lower": null, + "upper": 360.0, + "unit": "degC", + "use_upper_estimate": true, + "use_lower_estimate": false, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + }, + { + "id": "blend_cetane", + "metric": "cetane_number", + "stage": "blend", + "lower": 51.0, + "upper": null, + "unit": "cetane_number", + "use_upper_estimate": false, + "use_lower_estimate": true, + "required": true, + "basis": "model_assumption", + "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario" + } + ], + "blend_components": [ { "id": "A", "sulfur": {"value": 6.0, "lower": null, "upper": 7.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo value"]}, - "t95": {"value": 350.0, "lower": null, "upper": 355.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 54.0, "lower": null, "upper": 54.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 350.0, "lower": null, "upper": 352.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 50.5, "lower": 50.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 100.0, "cost_proxy_per_t": 1.0, "risk_index": 0.0, - "source_state_id": null - }, + "source_state_id": null + }, { "id": "B", "sulfur": {"value": 30.0, "lower": null, "upper": 31.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo value"]}, - "t95": {"value": 360.0, "lower": null, "upper": 365.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo T95 value"]}, - "cetane_number": {"value": 48.0, "lower": null, "upper": 48.0, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "DESIGN.md#9", "assumptions": ["Synthetic model-demo cetane value"]}, + "t95": {"value": 370.0, "lower": null, "upper": 372.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 46.5, "lower": 46.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, "available_mass_t": 30.0, "cost_proxy_per_t": 0.8, "risk_index": 0.0, - "source_state_id": null - } - ], - "total_mass_t": 100.0, - "current_blend_mass_fractions": {"A": 0.7, "B": 0.3}, - "active_criteria": ["risk_index", "throughput", "cost_proxy"], - "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, - "require_upper_bound": true, - "action_cooldown_minutes": 60, - "assumptions": ["All component values, including T95 and cetane number, are synthetic and are not plant guarantees."] + "source_state_id": null + } + ], + "cetane_additive": { + "id": "cetane_improver", + "max_mass_fraction": 0.03, + "fraction_step": 0.01, + "available_mass_t": 3.0, + "cost_proxy_per_t": 100.0, + "response_curve": [ + {"mass_fraction": 0.0, "cetane_gain": 0.0}, + {"mass_fraction": 0.01, "cetane_gain": 3.0}, + {"mass_fraction": 0.02, "cetane_gain": 5.0}, + {"mass_fraction": 0.03, "cetane_gain": 6.5} + ], + "evidence_ref": "expert clarification 10.09.2026: dose <=3%, price ratio 100x", + "assumptions": ["The dose-to-cetane curve is synthetic and configurable; it is not plant-calibrated."] + }, + "total_mass_t": 100.0, + "current_blend_mass_fractions": {"A": 0.693, "B": 0.297}, + "current_additive_mass_fraction": 0.01, + "active_criteria": ["risk_index", "throughput", "cost_proxy"], + "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, + "require_upper_bound": true, + "action_cooldown_minutes": 60, + "assumptions": ["All component values and T95/cetane blend relations are synthetic and are not plant guarantees."] } diff --git a/config/scenarios/blend_t95_risk.json b/config/scenarios/blend_t95_risk.json new file mode 100644 index 0000000..5ad1a9a --- /dev/null +++ b/config/scenarios/blend_t95_risk.json @@ -0,0 +1,47 @@ +{ + "id": "blend_t95_risk", + "mode": "model_demo", + "required_signals": [], + "controls": [], + "constraints": [ + {"id": "blend_sulfur", "metric": "sulfur", "stage": "blend", "lower": null, "upper": 10.0, "unit": "mg/kg", "use_upper_estimate": true, "use_lower_estimate": false, "required": true, "basis": "tz", "evidence_ref": "materials/ТЗ_нефтекод.docx"}, + {"id": "blend_t95", "metric": "t95", "stage": "blend", "lower": null, "upper": 360.0, "unit": "degC", "use_upper_estimate": true, "use_lower_estimate": false, "required": true, "basis": "model_assumption", "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario"}, + {"id": "blend_cetane", "metric": "cetane_number", "stage": "blend", "lower": 51.0, "upper": null, "unit": "cetane_number", "use_upper_estimate": false, "use_lower_estimate": true, "required": true, "basis": "model_assumption", "evidence_ref": "expert clarification 10.09.2026; explicit synthetic scenario"} + ], + "blend_components": [ + { + "id": "A", + "sulfur": {"value": 5.0, "lower": null, "upper": 6.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "t95": {"value": 340.0, "lower": null, "upper": 342.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 50.5, "lower": 50.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "available_mass_t": 100.0, + "cost_proxy_per_t": 1.0, + "risk_index": 0.0, + "source_state_id": null + }, + { + "id": "B", + "sulfur": {"value": 8.0, "lower": null, "upper": 9.0, "unit": "mg/kg", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "t95": {"value": 390.0, "lower": null, "upper": 392.0, "unit": "degC", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "cetane_number": {"value": 46.5, "lower": 46.0, "upper": null, "unit": "cetane_number", "basis": "formula", "interval_kind": "scenario_bound", "interval_level": null, "reference": "scenario component passport", "assumptions": ["Synthetic model-demo value"]}, + "available_mass_t": 60.0, + "cost_proxy_per_t": 0.8, + "risk_index": 0.0, + "source_state_id": null + } + ], + "cetane_additive": { + "id": "cetane_improver", "max_mass_fraction": 0.03, "fraction_step": 0.01, "available_mass_t": 3.0, "cost_proxy_per_t": 100.0, + "response_curve": [{"mass_fraction": 0.0, "cetane_gain": 0.0}, {"mass_fraction": 0.01, "cetane_gain": 3.0}, {"mass_fraction": 0.02, "cetane_gain": 5.0}, {"mass_fraction": 0.03, "cetane_gain": 6.5}], + "evidence_ref": "expert clarification 10.09.2026: dose <=3%, price ratio 100x", + "assumptions": ["The dose-to-cetane curve is synthetic and configurable; it is not plant-calibrated."] + }, + "total_mass_t": 100.0, + "current_blend_mass_fractions": {"A": 0.495, "B": 0.495}, + "current_additive_mass_fraction": 0.01, + "active_criteria": ["risk_index", "throughput", "cost_proxy"], + "materiality_thresholds": {"risk_index": 0.02, "throughput": 0.02, "cost_proxy": 0.02}, + "require_upper_bound": true, + "action_cooldown_minutes": 60, + "assumptions": ["All component values and T95/cetane blend relations are synthetic and are not plant guarantees."] +} diff --git a/config/tags.csv b/config/tags.csv index 8d7cdba..85a1c1c 100644 --- a/config/tags.csv +++ b/config/tags.csv @@ -72,32 +72,32 @@ avt:T61,avt:T61,avt,Температура НЦО в К-10,,unknown,none,confirm avt:T66,avt:T66,avt,Переток в К-9,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 avt:T71,avt:T71,avt,Фр.290-350°С из К-2,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 avt:W70,avt:W70,avt,Массовый расход фр.290-350°С с установки,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:F1,ht:F1,ht,Расход бензина c установки,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:F14,ht:F14,ht,"Блок стабилизации. Температура верха колонны К-201 /бензин,УВГ,Н2S",,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:F15,ht:F15,ht,Полисеп. Расход квенча в реактор Р-202,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:F17,ht:F17,ht,Расход гидроочищенного ДТ в цех №8 c установки,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:F19,ht:F19,ht,Полисеп. Реактор Р-202. Давление на входе,,unknown,none,confirmed,true,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:F2,ht:F2,ht,Газовая схема. Расход на линии от ЦК-201,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:F22,ht:F22,ht,Блок стабилизации. Расход газа поддува на входе в К-201Температура в сепараторе С-205,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:F25,ht:F25,ht,"APC. Показания виртуального анализатора ""Температура вспышки ГОДТ""",,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:F26,ht:F26,ht,Расход сырья на установку (объемный),,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:F9,ht:F9,ht,Блок стабилизации. Расход газа поддува на входе в К-201. Массовый расход (АС КУБ),,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:P13,ht:P13,ht,Трубопровод на выходе из реактора Р-202: Качество продукции по S,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:P24,ht:P24,ht,Расход свежего ВСГ с КЦА на установку,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:P3,ht:P3,ht,Полисеп. Сепаратор С-201. Давление на входе,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:P8,ht:P8,ht,Полисеп. Реактор Р-202. Температура гсс на входе,,unknown,none,confirmed,true,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:Q20,ht:Q20,ht,Блок стабилизации. Расход бензина в колонну К-201,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:Q21,ht:Q21,ht,Блок стабилизации. Расход газа поддува на входе в К-201,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:T11,ht:T11,ht,Расход сырья на установку (массовый),,unknown,none,confirmed,true,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:T12,ht:T12,ht,Расход бензина c установки,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:T16,ht:T16,ht,Блок стабилизации. Колонна К-201. Температура стабильного гидрогенизата низ,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:T18,ht:T18,ht,Блок стабилизации. Колонна К-201. Давление на выходе,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:T23,ht:T23,ht,Расход гидроочищенного ДТ в цех №8 c установки,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:T5,ht:T5,ht,Полисеп. Реактор Р-201. Температура ГСС на выходе,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:T6,ht:T6,ht,"Поточный анализатор серы в дизельном топливе между коллекторами нагнетания и приёма Н-202/1,2",,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:W10,ht:W10,ht,Реактор Р-202. Перепад давления.,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:W4,ht:W4,ht,Блок стабилизации. Массовый расход бензина в колонну К-206,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 -ht:W7,ht:W7,ht,Блок стабилизации. Поточный анализатор содержания серы в гидроочищенном ДТ,,unknown,none,confirmed,false,materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026 +ht:F1,ht:F1,ht,Выход бензина с установки,m3/ч,m3/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:F14,ht:F14,ht,Расход квенча в реактор Р-202,т/ч,t/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:F15,ht:F15,ht,Объемный расход сырья на установку,m3/ч,m3/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:F17,ht:F17,ht,Массовый выход гидроочищенного дизельного топлива,т/ч,t/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:F19,ht:F19,ht,Расход бензина в колонну К-201,т/ч,t/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:F2,ht:F2,ht,Расход газа от ЦК-201,Nm3/h,Nm3/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:F22,ht:F22,ht,Объемный расход продувочного газа,Nm3/h,Nm3/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:F25,ht:F25,ht,Расход свежего водородсодержащего газа,Nm3/h,Nm3/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:F26,ht:F26,ht,Объемный выход гидроочищенного дизельного топлива,m3/ч,m3/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:F9,ht:F9,ht,Массовый расход сырья на установку,т/ч,t/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:P13,ht:P13,ht,Давление на входе в реактор Р-202,МПа,MPa,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:P24,ht:P24,ht,Давление на выходе из колонны К-201,МПа,MPa,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:P3,ht:P3,ht,Давление на входе в сепаратор С-201,МПа,MPa,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:P8,ht:P8,ht,Перепад давления в реакторе Р-202,МПа,MPa,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:Q20,ht:Q20,ht,Сера в дизельном топливе между насосами Н-202/1 и Н-202/2,ppm,ppm,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:Q21,ht:Q21,ht,Сера на выходе гидроочистки,ppm,ppm,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000;QA_2026-09-15 +ht:T11,ht:T11,ht,Температура продукта на выходе реактора Р-202,°С,degC,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:T12,ht:T12,ht,Температура верха колонны К-201,°С,degC,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:T16,ht:T16,ht,Температура в сепараторе С-205,°С,degC,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:T18,ht:T18,ht,Виртуальная температура вспышки,°С,degC,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:T23,ht:T23,ht,Температура низа колонны К-201,°С,degC,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:T5,ht:T5,ht,Температура газа на выходе реактора Р-201,°С,degC,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:T6,ht:T6,ht,Температура газа на входе реактора Р-202,°С,degC,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:W10,ht:W10,ht,Перепад давления в реакторе Р-202,МПа,MPa,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:W4,ht:W4,ht,Массовый расход бензина в К-206,т/ч,t/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 +ht:W7,ht:W7,ht,Массовый расход продувочного газа,т/ч,t/h,none,confirmed,false,materials/теги АВТ_24-2000.xlsx#24-2000 avt:1:50%.T,ЛИМС:АВТ.1:50%.T,avt,50%.T,кг/м3,degC,none,confirmed,false,materials/ЛИМСы 01.01.2023 - н.в_ (2).xlsx#columns=5:6;DESIGN.md#16-подтверждения-экспертов-от-10092026 avt:1:90%.T,ЛИМС:АВТ.1:90%.T,avt,90%.T,°С,degC,none,confirmed,false,materials/ЛИМСы 01.01.2023 - н.в_ (2).xlsx#columns=3:4 avt:1:95%.T,ЛИМС:АВТ.1:95%.T,avt,95%.T,°С,degC,none,confirmed,false,materials/ЛИМСы 01.01.2023 - н.в_ (2).xlsx#columns=19:20 diff --git a/config/telemetry_rules.json b/config/telemetry_rules.json new file mode 100644 index 0000000..7191b38 --- /dev/null +++ b/config/telemetry_rules.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "rules": [ + { + "signal_id": "ht:Q21", + "exact_values": [307.0], + "action": "missing", + "issue_code": "CONFIRMED_SENTINEL", + "evidence_ref": "QA_2026-09-15:Q21", + "detail": "Q21=307 ppm is a confirmed analyzer outlier and is not a measurement." + } + ] +} diff --git a/global_tests/fixtures/contracts/recommendation.json b/global_tests/fixtures/contracts/recommendation.json index 8a7af6d..dd1470c 100644 --- a/global_tests/fixtures/contracts/recommendation.json +++ b/global_tests/fixtures/contracts/recommendation.json @@ -1,42 +1,43 @@ -{ - "schema_version": "1.0", - "run_id": "fixture-run-hold", - "state_id": "fixture-state-normal", - "as_of": "2026-01-15T09:00:00Z", - "scenario_id": "blend_normal", - "mode": "model_demo", - "status": "hold", - "baseline": null, - "selected": { - "candidate": { - "id": "hold", - "kind": "hold", - "setpoints": {}, - "blend_mass_fractions": {}, - "horizon_minutes": 60, - "is_model_scenario": true - }, - "assessments": [], - "checks": [ - { - "constraint_id": "blend_sulfur", - "candidate_id": "hold", - "status": "pass", - "actual": 9.4, - "lower": null, - "upper": 10.0, - "unit": "mg/kg", - "basis": "tz", - "evidence_ref": "materials/ТЗ_нефтекод.docx", - "reason_code": "QUALITY_OK" - } - ], - "feasible": true, - "rank_key": [0.0, -100.0, 0.98, 0.0, "hold"] - }, - "alternatives": [], - "reason_codes": ["NO_MATERIAL_IMPROVEMENT"], - "explanation": "Текущая модельная рецептура проходит ограничение по сере.", - "assumptions": ["Все значения сценария синтетические."], - "model_id": null -} +{ + "schema_version": "1.1", + "run_id": "fixture-run-hold", + "state_id": "fixture-state-normal", + "as_of": "2026-01-15T09:00:00Z", + "scenario_id": "blend_normal", + "mode": "model_demo", + "status": "hold", + "baseline": null, + "selected": { + "candidate": { + "id": "hold", + "kind": "hold", + "setpoints": {}, + "blend_mass_fractions": {}, + "additive_mass_fraction": 0.0, + "horizon_minutes": 60, + "is_model_scenario": true + }, + "assessments": [], + "checks": [ + { + "constraint_id": "blend_sulfur", + "candidate_id": "hold", + "status": "pass", + "actual": 9.4, + "lower": null, + "upper": 10.0, + "unit": "mg/kg", + "basis": "tz", + "evidence_ref": "materials/ТЗ_нефтекод.docx", + "reason_code": "QUALITY_OK" + } + ], + "feasible": true, + "rank_key": [0.0, -100.0, 0.98, 0.0, "hold"] + }, + "alternatives": [], + "reason_codes": ["NO_MATERIAL_IMPROVEMENT"], + "explanation": "Текущая модельная рецептура проходит ограничение по сере.", + "assumptions": ["Все значения сценария синтетические."], + "model_id": null +} diff --git a/global_tests/fixtures/model_demo/blend_cetane_risk.json b/global_tests/fixtures/model_demo/blend_cetane_risk.json new file mode 100644 index 0000000..483b7f0 --- /dev/null +++ b/global_tests/fixtures/model_demo/blend_cetane_risk.json @@ -0,0 +1,4 @@ +{ + "state": {"schema_version": "1.0", "state_id": "fixture-state-cetane-risk", "as_of": "2026-01-15T09:00:00Z", "dataset_id": "modeldemo001", "mode": "model_demo", "signals": {}, "issues": []}, + "expected": {"status": "recommend", "selected_blend_mass_fractions": {"A": 0.8, "B": 0.2}, "selected_additive_mass_fraction": 0.0} +} diff --git a/global_tests/fixtures/model_demo/blend_normal.json b/global_tests/fixtures/model_demo/blend_normal.json index cc197a8..f5da624 100644 --- a/global_tests/fixtures/model_demo/blend_normal.json +++ b/global_tests/fixtures/model_demo/blend_normal.json @@ -1,17 +1,20 @@ -{ - "state": { - "schema_version": "1.0", - "state_id": "fixture-state-normal", - "as_of": "2026-01-15T09:00:00Z", - "dataset_id": "modeldemo001", - "mode": "model_demo", - "signals": {}, - "issues": [] - }, - "expected": { - "status": "hold", - "current_sulfur_mg_kg": 8.4, - "current_upper_sulfur_mg_kg": 9.4, - "selected_blend_mass_fractions": {"A": 0.9, "B": 0.1} - } -} +{ + "state": { + "schema_version": "1.0", + "state_id": "fixture-state-normal", + "as_of": "2026-01-15T09:00:00Z", + "dataset_id": "modeldemo001", + "mode": "model_demo", + "signals": {}, + "issues": [] + }, + "expected": { + "status": "hold", + "current_sulfur_mg_kg": 8.316, + "current_upper_sulfur_mg_kg": 9.306, + "current_upper_t95_degC": 354.0, + "current_lower_cetane_number": 52.6, + "selected_blend_mass_fractions": {"A": 0.891, "B": 0.099}, + "selected_additive_mass_fraction": 0.01 + } +} diff --git a/global_tests/fixtures/model_demo/blend_risk.json b/global_tests/fixtures/model_demo/blend_risk.json index 363c10b..4dc8e0e 100644 --- a/global_tests/fixtures/model_demo/blend_risk.json +++ b/global_tests/fixtures/model_demo/blend_risk.json @@ -1,19 +1,24 @@ -{ - "state": { - "schema_version": "1.0", - "state_id": "fixture-state-risk", - "as_of": "2026-01-15T09:00:00Z", - "dataset_id": "modeldemo001", - "mode": "model_demo", - "signals": {}, - "issues": [] - }, - "expected": { - "status": "recommend", - "current_sulfur_mg_kg": 13.2, - "current_upper_sulfur_mg_kg": 14.2, - "selected_sulfur_mg_kg": 8.4, - "selected_upper_sulfur_mg_kg": 9.4, - "selected_blend_mass_fractions": {"A": 0.9, "B": 0.1} - } -} +{ + "state": { + "schema_version": "1.0", + "state_id": "fixture-state-risk", + "as_of": "2026-01-15T09:00:00Z", + "dataset_id": "modeldemo001", + "mode": "model_demo", + "signals": {}, + "issues": [] + }, + "expected": { + "status": "recommend", + "current_sulfur_mg_kg": 13.068, + "current_upper_sulfur_mg_kg": 14.058, + "current_upper_t95_degC": 358.0, + "current_lower_cetane_number": 51.8, + "selected_sulfur_mg_kg": 8.316, + "selected_upper_sulfur_mg_kg": 9.306, + "selected_upper_t95_degC": 354.0, + "selected_lower_cetane_number": 52.6, + "selected_blend_mass_fractions": {"A": 0.891, "B": 0.099}, + "selected_additive_mass_fraction": 0.01 + } +} diff --git a/global_tests/fixtures/model_demo/blend_t95_risk.json b/global_tests/fixtures/model_demo/blend_t95_risk.json new file mode 100644 index 0000000..5e9b8d1 --- /dev/null +++ b/global_tests/fixtures/model_demo/blend_t95_risk.json @@ -0,0 +1,4 @@ +{ + "state": {"schema_version": "1.0", "state_id": "fixture-state-t95-risk", "as_of": "2026-01-15T09:00:00Z", "dataset_id": "modeldemo001", "mode": "model_demo", "signals": {}, "issues": []}, + "expected": {"status": "recommend", "selected_blend_mass_fractions": {"A": 0.6435, "B": 0.3465}, "selected_additive_mass_fraction": 0.01} +} diff --git a/global_tests/test_historical_action_effects.py b/global_tests/test_historical_action_effects.py new file mode 100644 index 0000000..5b2f7c1 --- /dev/null +++ b/global_tests/test_historical_action_effects.py @@ -0,0 +1,212 @@ +"""Tests for shadow-only matched historical action effects.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from source.ml.action_effects import ( + ACTION_HORIZONS, + HistoricalActionDataset, + HistoricalActionEffectModel, + build_historical_action_dataset, + evaluate_temporal_residualization, + load_historical_action_model, + save_historical_action_model, +) +from source.ml.uncertainty import ApplicabilityResult + + +class _Estimator: + def __init__(self, action_coefficient: float = 0.0) -> None: + self.action_coefficient = action_coefficient + + def predict(self, frame: pd.DataFrame) -> np.ndarray: + return frame["baseline_sulfur"].to_numpy(dtype=float) + self.action_coefficient * frame[ + "delta_ht:P8" + ].to_numpy(dtype=float) + + +class _Applicable: + def assess(self, _: pd.DataFrame) -> ApplicabilityResult: + return ApplicabilityResult(True, None, ()) + + +def _prepared_data() -> SimpleNamespace: + timestamp = pd.date_range("2023-01-01", periods=240, freq="10min", tz="UTC") + p8 = np.full(len(timestamp), 0.15) + f19 = np.full(len(timestamp), 200.0) + p8[30:60] = 0.17 + f19[100:130] = 215.0 + sulfur = 8.0 + np.linspace(0.0, 0.5, len(timestamp)) + telemetry = pd.DataFrame( + { + "timestamp": timestamp, + "ht:P8": p8, + "ht:F19": f19, + "ht:T11": np.full(len(timestamp), 100.0), + "ht:F26": np.full(len(timestamp), 350.0), + } + ) + quality = pd.DataFrame( + { + "signal_id": ["ht:2:Mg.Sulfur"] * len(timestamp), + "source": ["pak"] * len(timestamp), + "validity": ["valid"] * len(timestamp), + "unit": ["mg/kg"] * len(timestamp), + "measured_at": timestamp, + "value": sulfur, + } + ) + return SimpleNamespace(telemetry=telemetry, quality=quality) + + +def test_historical_dataset_uses_only_p8_f19_actions_and_process_context() -> None: + dataset = build_historical_action_dataset( + _prepared_data(), # type: ignore[arg-type] + train_end="2024-01-01", + threshold_quantile=0.9, + ) + + assert set(dataset.thresholds) == {"ht:P8", "ht:F19"} + assert "delta_ht:F26" not in dataset.frame + assert dataset.frame["pair_id"].value_counts().eq(2).all() + assert dataset.frame.loc[dataset.frame["is_action_episode"], "delta_ht:P8"].ne(0).any() + assert dataset.frame.loc[dataset.frame["is_action_episode"], "delta_ht:F19"].ne(0).any() + + +def test_historical_estimate_is_ui_ready_but_never_advisory() -> None: + model = HistoricalActionEffectModel( + {horizon: _Estimator(-10.0) for horizon in ACTION_HORIZONS}, + {horizon: _Estimator(-10.0) for horizon in ACTION_HORIZONS}, + {horizon: 0.5 for horizon in ACTION_HORIZONS}, + _Applicable(), # type: ignore[arg-type] + {"ht:P8": (-0.02, 0.02), "ht:F19": (-20.0, 20.0)}, + {}, + ) + state = { + "baseline_sulfur": 9.0, + "sulfur_slope_60m": 0.0, + "ht:P8": 0.15, + "ht:F19": 200.0, + "ht:T11": 100.0, + "ht:F26": 350.0, + } + + estimate = model.estimate(state, "ht:P8", 0.01) + payload = estimate.as_ui_payload() + + assert estimate.effect_by_horizon[60] == pytest.approx(-0.1) + assert payload["advisory"] is False + assert payload["title"] == "Модельный эффект по историческим эпизодам" + assert payload["within_observed_domain"] is True + assert payload["model_validated"] is False + assert payload["safety_passes"] is True + assert model.supports_actions is False + + +def test_historical_action_model_cannot_enable_controls() -> None: + with pytest.raises(ValueError, match="research-only"): + HistoricalActionEffectModel( + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: 0.0 for horizon in ACTION_HORIZONS}, + _Applicable(), # type: ignore[arg-type] + {"ht:P8": (-0.02, 0.02), "ht:F19": (-20.0, 20.0)}, + {}, + True, + ) + + +def test_unseen_delta_or_unsafe_upper_abstains() -> None: + model = HistoricalActionEffectModel( + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: 2.0 for horizon in ACTION_HORIZONS}, + _Applicable(), # type: ignore[arg-type] + {"ht:P8": (-0.02, 0.02), "ht:F19": (-20.0, 20.0)}, + {}, + ) + state = { + "baseline_sulfur": 9.0, + "sulfur_slope_60m": 0.0, + "ht:P8": 0.15, + "ht:F19": 200.0, + "ht:T11": 100.0, + "ht:F26": 350.0, + } + + estimate = model.estimate(state, "ht:P8", 0.5) + + assert estimate.applicable is False + assert "ACTION_DELTA_OUT_OF_OBSERVED_RANGE" in estimate.reason_codes + assert "SULFUR_UPPER_LIMIT_60M" in estimate.reason_codes + assert estimate.within_observed_domain is False + assert estimate.safety_passes is False + + +def test_shadow_action_artifact_round_trip_is_never_advisory(tmp_path: Path) -> None: + model = HistoricalActionEffectModel( + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: _Estimator() for horizon in ACTION_HORIZONS}, + {horizon: 0.0 for horizon in ACTION_HORIZONS}, + _Applicable(), # type: ignore[arg-type] + {"ht:P8": (-0.02, 0.02), "ht:F19": (-20.0, 20.0)}, + {"controls": ["ht:P8", "ht:F19"], "evidence_gate_passed": False}, + ) + + directory = save_historical_action_model( + tmp_path / "action-shadow-fixture", model, training_dataset_id="fixture" + ) + loaded = load_historical_action_model(directory, trusted=True) + + assert loaded.supports_actions is False + assert loaded.report["evidence_gate_passed"] is False + with pytest.raises(ValueError, match="explicitly trusted"): + load_historical_action_model(directory) + with pytest.raises(ValueError, match="different prepared dataset"): + load_historical_action_model(directory, trusted=True, expected_dataset_id="other") + + +def test_temporal_residualization_returns_research_only_report() -> None: + timestamp = pd.date_range("2024-01-01", periods=120, freq="10min", tz="UTC") + index = np.arange(len(timestamp), dtype=float) + frame = pd.DataFrame( + { + "timestamp": timestamp, + "baseline_sulfur": 8.0 + index * 0.001, + "sulfur_slope_60m": np.full(len(index), 0.001), + "sulfur_60m": 8.1 + index * 0.001, + "sulfur_120m": 8.2 + index * 0.001, + "sulfur_180m": 8.3 + index * 0.001, + "ht:P8": 0.15 + np.sin(index / 10.0) * 0.01, + "ht:F19": 200.0 + np.cos(index / 10.0) * 2.0, + "ht:T11": np.full(len(index), 100.0), + "ht:F26": 350.0 + np.sin(index / 7.0), + "delta_ht:P8": np.where(index % 15 == 0, 0.01, 0.0), + "delta_ht:F19": np.where(index % 20 == 0, 5.0, 0.0), + } + ) + dataset = HistoricalActionDataset( + frame, + {}, + {"ht:P8": (-0.01, 0.01), "ht:F19": (-5.0, 5.0)}, + 1.0, + ) + + report = evaluate_temporal_residualization( + dataset, train_end="2024-01-01 08:00", validation_end="2024-01-01 20:00" + ) + + assert report["supports_actions"] is False + assert report["promotion_eligible"] is False + assert set(report["horizons"]) == {"60", "120", "180"} + assert len(report["horizons"]["60"]["effect_sign_by_fold"]) == 3 + assert set(report["horizons"]["60"]["effect_sign_stable"]) == { + "ht:P8", + "ht:F19", + } diff --git a/global_tests/test_ml_diagnostics.py b/global_tests/test_ml_diagnostics.py new file mode 100644 index 0000000..fc56098 --- /dev/null +++ b/global_tests/test_ml_diagnostics.py @@ -0,0 +1,115 @@ +"""Tests for read-only ML data diagnostics.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pandas as pd + +from source.contracts import SourceKind +from source.ml.diagnostics import ( + CONFIRMED_LIMS_OUTLIER_IDS, + engineered_feature_screen, + exclude_confirmed_lims_outliers, + pak_lims_alignment, + regime_diagnostics, + temporal_drift, +) +from source.ml.features import SupervisedDataset + + +def _dataset() -> SupervisedDataset: + times = pd.to_datetime( + [ + "2024-01-01T00:00:00Z", + "2024-06-01T00:00:00Z", + "2025-01-01T00:00:00Z", + "2025-06-01T00:00:00Z", + "2026-01-01T00:00:00Z", + "2026-06-01T00:00:00Z", + ] + ) + frame = pd.DataFrame( + { + "as_of": times, + "target_at": times + pd.Timedelta(minutes=60), + "target_available_at": times + pd.Timedelta(minutes=60), + "y": [9.0, 11.0, 8.0, 12.0, 9.5, 10.5], + "baseline": [8.0, 9.0, 8.0, 9.0, 9.0, 9.0], + "trend": [1.0, 2.0, 0.0, 3.0, 0.5, 1.5], + } + ) + return SupervisedDataset( + frame=frame, + feature_names=("baseline", "trend"), + target_signal_id="ht:2:Mg.Sulfur", + target_unit="mg/kg", + target_source=SourceKind.PAK, + feature_source=SourceKind.PAK, + baseline_feature="baseline", + feature_definition={}, + excluded_counts={}, + ) + + +def test_alignment_keeps_raw_rows_and_labels_sensitivity_exclusions() -> None: + times = pd.to_datetime(["2024-01-01T00:00:00Z", "2024-01-01T01:00:00Z"]) + quality = pd.DataFrame( + { + "signal_id": ["ht:2:Mg.Sulfur"] * 4, + "observation_id": [ + "pak-1", + "pak-2", + "lims-normal", + next(iter(CONFIRMED_LIMS_OUTLIER_IDS)), + ], + "source": ["pak", "pak", "lims", "lims"], + "measured_at": [*times, *times], + "value": [8.0, 9.0, 8.2, 120.0], + } + ) + data = SimpleNamespace(quality=quality) + + report = pak_lims_alignment(data, offsets_minutes=(0,)) # type: ignore[arg-type] + + assert report["raw"][0]["n"] == 2 + assert report["training_policy"]["excluded_values"] == [120.0] + assert "prepared data" in report["training_policy"]["interpretation"] + + +def test_outlier_policy_uses_confirmed_ids_not_a_blanket_value_cap() -> None: + confirmed = next(iter(CONFIRMED_LIMS_OUTLIER_IDS)) + frame = pd.DataFrame({"observation_id": [confirmed, "future-real-high"], "y": [120.0, 120.0]}) + + eligible, excluded = exclude_confirmed_lims_outliers(frame) + + assert excluded == (confirmed,) + assert eligible["observation_id"].tolist() == ["future-real-high"] + + +def test_temporal_drift_reports_transition_counts_per_fixed_period() -> None: + report = temporal_drift(_dataset()) + + assert report["train"]["n"] == 2 + assert report["validation"]["transition_count"] == 1 + assert report["test"]["transition_count"] == 1 + + +def test_engineered_screen_uses_train_association_and_reports_later_signs() -> None: + report = engineered_feature_screen(_dataset(), top_n=2, min_samples=2) + + by_feature = {row["feature"]: row for row in report} + assert "trend" in by_feature + assert set(by_feature["trend"]) >= { + "train_correlation", + "validation_correlation", + "test_correlation", + "stable_sign", + } + + +def test_regime_diagnostics_keeps_near_limit_band_separate() -> None: + report = regime_diagnostics(_dataset()) + + assert report["validation"]["8_to_10"]["transition_count"] == 1 + assert report["test"]["8_to_10"]["transition_count"] == 1 diff --git a/global_tests/test_ml_v2.py b/global_tests/test_ml_v2.py new file mode 100644 index 0000000..09c51a9 --- /dev/null +++ b/global_tests/test_ml_v2.py @@ -0,0 +1,249 @@ +"""Regression tests for episode-aware multi-horizon sulfur forecasting.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from source.contracts import SourceKind +from source.ml.features import SupervisedDataset +from source.ml.v2 import ( + EpisodeSafetyPredictor, + _upper_limit_metrics, + build_episode_dataset, + episode_sample_weights, + event_metrics, + rolling_month_folds, + select_event_threshold, +) + + +def _event_frame() -> pd.DataFrame: + return pd.DataFrame( + { + "as_of": pd.date_range("2025-01-01", periods=7, freq="10min", tz="UTC"), + "target_available_at": pd.date_range( + "2025-01-01 01:00", periods=7, freq="10min", tz="UTC" + ), + "baseline": [9.0] * 7, + "crossing_60m": [True, True, True, False, True, False, False], + "event_id": [1.0, 1.0, 1.0, np.nan, 2.0, np.nan, np.nan], + } + ) + + +def test_long_positive_episode_has_same_total_weight_as_short_episode() -> None: + frame = _event_frame() + weights = episode_sample_weights(frame) + + assert weights[:3].sum() == pytest.approx(weights[4]) + + +def test_event_metrics_count_one_long_episode_once() -> None: + frame = _event_frame() + probability = np.asarray([0.8, 0.2, 0.1, 0.1, 0.2, 0.1, 0.1]) + + metrics = event_metrics(frame, probability, 0.5) + + assert metrics["event_count"] == 2 + assert metrics["detected_events"] == 1 + assert metrics["event_false_negative_rate"] == pytest.approx(0.5) + + +def test_event_metrics_requires_alarm_before_first_crossing() -> None: + frame = pd.DataFrame( + { + "as_of": pd.to_datetime( + ["2025-01-01 00:00", "2025-01-01 00:50", "2025-01-01 01:00"], utc=True + ), + "target_available_at": pd.to_datetime(["2025-01-01 01:00"] * 3, utc=True), + "baseline": [9.0, 9.0, 11.0], + "crossing_60m": [True, True, True], + "event_id": [1.0, 1.0, 1.0], + "event_start_at": pd.to_datetime(["2025-01-01 01:00"] * 3, utc=True), + } + ) + + # Only the post-crossing deterministic alarm is present: it must not count + # as a 10–60 minute early warning. + late_only = event_metrics(frame, np.asarray([0.1, 0.1, 0.1]), 0.5) + assert late_only["detected_events"] == 0 + assert late_only["event_false_negative_rate"] == pytest.approx(1.0) + + early = event_metrics(frame, np.asarray([0.1, 0.9, 0.1]), 0.5) + assert early["detected_events"] == 1 + assert early["event_false_negative_rate"] == pytest.approx(0.0) + + +def test_event_threshold_respects_false_alarm_budget() -> None: + frame = _event_frame() + probability = np.asarray([0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.1]) + + policy, metrics = select_event_threshold(frame, probability, false_alarm_budget=1 / 3) + + assert metrics["row_false_positive_rate"] <= 1 / 3 + assert metrics["event_false_positive_rate"] <= 1 / 3 + assert policy.threshold > 0.4 + + +def test_upper_limit_metrics_measure_missed_limit_crossings() -> None: + metrics = _upper_limit_metrics( + np.asarray([9.0, 11.0, 12.0, 8.0]), np.asarray([10.5, 10.8, 9.9, 11.0]) + ) + + assert metrics["upper_limit_misses"] == 1 + assert metrics["upper_limit_miss_rate"] == pytest.approx(0.5) + assert metrics["upper_limit_false_alarm_rate"] == pytest.approx(1.0) + + +def test_rolling_month_fold_purges_labels_published_after_boundary() -> None: + frame = pd.DataFrame( + { + "as_of": pd.to_datetime(["2024-01-01", "2024-01-31", "2024-02-01"], utc=True), + "target_available_at": pd.to_datetime( + ["2024-01-01 01:00", "2024-02-01 02:00", "2024-02-01 01:00"], utc=True + ), + } + ) + + folds = rolling_month_folds(frame, "2024-02-01", "2024-03-01") + + assert folds[0][0].tolist() == [0] + assert folds[0][1].tolist() == [2] + + +def test_current_violation_always_triggers_event_alarm() -> None: + class Delta: + def predict(self, frame: pd.DataFrame) -> np.ndarray: + return np.zeros(len(frame)) + + class Risk: + def predict_proba(self, frame: pd.DataFrame) -> np.ndarray: + probability = np.full(len(frame), 0.1) + return np.column_stack((1 - probability, probability)) + + class Calibrator: + def predict(self, probability: np.ndarray) -> np.ndarray: + return probability + + predictor = EpisodeSafetyPredictor( + "baseline", + Delta(), + Delta(), + {horizon: Risk() for horizon in (10, 20, 30, 60)}, + {horizon: Calibrator() for horizon in (10, 20, 30, 60)}, # type: ignore[arg-type] + SimpleNamespace(threshold=0.5), + SimpleNamespace(assess=lambda _: SimpleNamespace(available=True, reason_code=None)), + ) + + assert predictor.predict_alarm(pd.DataFrame({"baseline": [9.0, 11.0]})).tolist() == [ + False, + True, + ] + rows = predictor.predict_v2(pd.DataFrame({"baseline": [9.0, 11.0]})) + assert rows[0]["event_alarm"] is False + assert rows[1]["event_alarm"] is True + assert rows[1]["reason_codes"] == ("CURRENT_SULFUR_LIMIT",) + + +def test_episode_dataset_builds_future_labels_and_causal_features() -> None: + times = pd.date_range("2024-01-01", periods=25, freq="10min", tz="UTC") + quality = pd.DataFrame( + { + "observation_id": [f"pak-{index}" for index in range(len(times))], + "signal_id": ["ht:2:Mg.Sulfur"] * len(times), + "source": ["pak"] * len(times), + "validity": ["valid"] * len(times), + "measured_at": times, + "available_at": times, + "unit": ["mg/kg"] * len(times), + "value": [9.0] * 7 + [11.0] * 3 + [9.0] * 15, + } + ) + as_of = times[:19] + base_frame = pd.DataFrame( + { + "as_of": as_of, + "target_at": as_of + pd.Timedelta(minutes=60), + "target_available_at": as_of + pd.Timedelta(minutes=60), + "y": quality["value"].to_numpy()[6:25], + "baseline": quality["value"].to_numpy()[:19], + "ht:2:Mg.Sulfur__pak_last": quality["value"].to_numpy()[:19], + "ht:2:Mg.Sulfur__pak_lag_30m": [np.nan] * 3 + quality["value"].to_list()[:16], + "ht:2:Mg.Sulfur__pak_lag_60m": [np.nan] * 6 + quality["value"].to_list()[:13], + "ht:2:Mg.Sulfur__pak_lag_180m": [np.nan] * 18 + quality["value"].to_list()[:1], + } + ) + names = tuple(base_frame.columns[5:]) + base = SupervisedDataset( + base_frame, + names, + "ht:2:Mg.Sulfur", + "mg/kg", + SourceKind.PAK, + SourceKind.PAK, + "ht:2:Mg.Sulfur__pak_last", + {}, + {}, + ) + data = SimpleNamespace(quality=quality) + + result = build_episode_dataset(data, base) # type: ignore[arg-type] + + assert result.frame.loc[1, "crossing_60m"] + assert result.frame.loc[1, "event_id"] == 1 + assert result.frame.loc[5, "crossing_20m"] + assert not result.frame.loc[5, "crossing_10m"] + assert "pak_range_60m" in result.feature_names + assert "pak_minutes_since_crossing_10" in result.feature_names + + +def test_crossing_labels_include_intermediate_ten_minute_steps() -> None: + times = pd.date_range("2024-01-01", periods=13, freq="10min", tz="UTC") + values = [9.0, 9.0, 9.0, 9.0, 11.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0, 9.0] + quality = pd.DataFrame( + { + "observation_id": [f"pak-{index}" for index in range(len(times))], + "signal_id": ["ht:2:Mg.Sulfur"] * len(times), + "source": ["pak"] * len(times), + "validity": ["valid"] * len(times), + "unit": ["mg/kg"] * len(times), + "measured_at": times, + "available_at": times, + "value": values, + } + ) + base_frame = pd.DataFrame( + { + "observation_id": ["query"], + "as_of": [times[0]], + "target_at": [times[6]], + "target_available_at": [times[6]], + "target_source": ["pak"], + "target_signal": ["ht:2:Mg.Sulfur"], + "target_unit": ["mg/kg"], + "y": [values[6]], + "baseline": [values[0]], + "ht:2:Mg.Sulfur__pak_last": [values[0]], + } + ) + base = SupervisedDataset( + base_frame, + ("ht:2:Mg.Sulfur__pak_last",), + "ht:2:Mg.Sulfur", + "mg/kg", + SourceKind.PAK, + SourceKind.PAK, + "ht:2:Mg.Sulfur__pak_last", + {}, + {}, + ) + + result = build_episode_dataset(SimpleNamespace(quality=quality), base) # type: ignore[arg-type] + + assert result.frame.loc[0, "y_60m"] == pytest.approx(9.0) + assert result.frame.loc[0, "crossing_60m"] + assert result.frame.loc[0, "event_id"] == 1 diff --git a/global_tests/test_safety_forecast.py b/global_tests/test_safety_forecast.py new file mode 100644 index 0000000..536ff5f --- /dev/null +++ b/global_tests/test_safety_forecast.py @@ -0,0 +1,269 @@ +"""Tests for constraint-aware sulfur risk and joint applicability.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from source.agents.quality import predict_quality +from source.config import load_scenario +from source.contracts import ProcessState +from source.ml.artifacts import LastValueRegressor, ModelBundle, ModelMetadata, feature_schema_hash +from source.ml.safety import ( + CompositeSafetyPredictor, + PlattCalibrator, + binary_alarm_metrics, + fit_joint_applicability, + published_lims_features, + select_alarm_threshold, +) + + +def test_threshold_minimizes_misses_inside_false_alarm_budget() -> None: + actual = np.asarray([False, False, False, False, True, True]) + probability = np.asarray([0.05, 0.10, 0.20, 0.80, 0.40, 0.90]) + + policy, metrics = select_alarm_threshold(actual, probability, false_alarm_budget=0.25) + + assert policy.threshold == pytest.approx(0.4) + assert metrics["false_negative_rate"] == 0.0 + assert metrics["false_positive_rate"] == pytest.approx(0.25) + + +def test_alarm_metrics_reject_invalid_probabilities() -> None: + with pytest.raises(ValueError, match="finite"): + binary_alarm_metrics([True], [np.nan], 0.5) + + +def test_joint_applicability_does_not_reject_one_marginally_unusual_feature() -> None: + rng = np.random.default_rng(42) + training = pd.DataFrame( + { + "baseline": rng.normal(9.0, 0.5, 500), + "x": rng.normal(0.0, 1.0, 500), + "correlated": rng.normal(0.0, 1.0, 500), + } + ) + domain = fit_joint_applicability(training, required_features=("baseline",)) + + assert domain.assess( + pd.DataFrame({"baseline": [9.0], "x": [2.0], "correlated": [0.0]}) + ).available + missing = domain.assess(pd.DataFrame({"baseline": [np.nan], "x": [0.0], "correlated": [0.0]})) + assert missing.available is False + assert missing.reason_code == "FEATURES_UNAVAILABLE" + + +def test_composite_predictor_exposes_point_upper_probability_and_alarm() -> None: + features = pd.DataFrame({"baseline": [9.0, 11.0], "x": [0.0, 1.0]}) + point = LastValueRegressor("baseline").fit(features) + + class Upper: + def predict_upper(self, frame: pd.DataFrame) -> np.ndarray: + return frame["baseline"].to_numpy(dtype=float) + 1.0 + + class Risk: + def predict_proba(self, frame: pd.DataFrame) -> np.ndarray: + p = np.asarray([0.2, 0.8]) + return np.column_stack((1.0 - p, p)) + + calibrator_estimator = SimpleNamespace( + predict_proba=lambda values: np.column_stack( + (1.0 - np.asarray([0.2, 0.8]), np.asarray([0.2, 0.8])) + ) + ) + domain = fit_joint_applicability(features, required_features=("baseline",), coverage=0.75) + predictor = CompositeSafetyPredictor( + point, + Upper(), + Risk(), + PlattCalibrator(calibrator_estimator), # type: ignore[arg-type] + SimpleNamespace(threshold=0.5), + domain, + ) + + assert predictor.predict(features).tolist() == [9.0, 11.0] + assert predictor.predict_upper(features).tolist() == [10.0, 12.0] + assert predictor.predict_exceedance_probability(features).tolist() == pytest.approx([0.2, 0.8]) + assert predictor.predict_alarm(features).tolist() == [False, True] + + +def test_schema_12_bundle_validates_capability_and_old_schemas_remain_valid() -> None: + names = ("baseline",) + common = { + "model_id": "safety-test", + "model_sha256": "0" * 64, + "model_type": "test", + "training_dataset_id": "dataset", + "git_commit": "commit", + "python_version": "3.12.0", + "sklearn_version": "1.9.0", + "target_signal": "ht:2:Mg.Sulfur", + "target_source": "pak", + "target_unit": "mg/kg", + "horizon_minutes": 60, + "feature_names": names, + "baseline_feature": "baseline", + "tag_dictionary_sha256": "a" * 64, + "feature_schema_hash": feature_schema_hash(names), + "processing": {"feature_definition": {}}, + "time_boundaries": { + "train_end_local": "2025-01-01", + "validation_end_local": "2026-01-01", + "source_timezone": "UTC", + }, + "seed": 42, + "applicability": {}, + "reports": (), + } + old = ModelMetadata( + **common, + schema_version="1.0", + capabilities={"supports_forecast": True, "supports_actions": False}, + ) + safety = ModelMetadata( + **common, + schema_version="1.1", + capabilities={ + "supports_forecast": True, + "supports_actions": False, + "supports_uncertainty": True, + "supports_exceedance_probability": True, + }, + ) + multi_horizon = ModelMetadata( + **common, + schema_version="1.2", + capabilities={ + "supports_forecast": True, + "supports_actions": False, + "supports_uncertainty": True, + "supports_exceedance_probability": True, + "supports_multi_horizon": True, + }, + ) + + assert old.capabilities.supports_exceedance_probability is False + assert safety.capabilities.supports_exceedance_probability is True + assert multi_horizon.capabilities.supports_multi_horizon is True + + +def test_bundle_refuses_probability_when_capability_is_absent() -> None: + metadata = SimpleNamespace( + feature_names=("baseline",), + capabilities=SimpleNamespace(supports_exceedance_probability=False), + ) + bundle = ModelBundle(LastValueRegressor(), metadata) # type: ignore[arg-type] + + with pytest.raises(ValueError, match="does not declare"): + bundle.predict_exceedance_probability(pd.DataFrame({"baseline": [9.0]})) + + +def test_bundle_returns_complete_safety_contract() -> None: + features = pd.DataFrame({"baseline": [9.0]}) + + class Predictor: + def predict(self, frame: pd.DataFrame) -> np.ndarray: + return np.asarray([9.2]) + + def predict_upper(self, frame: pd.DataFrame) -> np.ndarray: + return np.asarray([10.4]) + + def predict_exceedance_probability(self, frame: pd.DataFrame) -> np.ndarray: + return np.asarray([0.7]) + + def predict_alarm(self, frame: pd.DataFrame) -> np.ndarray: + return np.asarray([True]) + + def check_applicability(self, frame: pd.DataFrame) -> object: + return SimpleNamespace(available=False, reason_code="OUT_OF_DOMAIN") + + metadata = SimpleNamespace( + feature_names=("baseline",), + capabilities=SimpleNamespace( + supports_uncertainty=True, + supports_exceedance_probability=True, + ), + ) + bundle = ModelBundle(Predictor(), metadata) # type: ignore[arg-type] + + assert bundle.predict_safety(features) == [ + { + "point": 9.2, + "upper": 10.4, + "exceedance_probability": 0.7, + "alarm": True, + "applicable": False, + "reason_codes": ("OUT_OF_DOMAIN",), + } + ] + + +def test_history_quality_exposes_risk_and_blocks_on_alarm() -> None: + state = ProcessState.model_validate_json( + Path("global_tests/fixtures/contracts/process_state.json").read_text(encoding="utf-8") + ) + scenario = load_scenario("config/scenarios/history.json") + + class RiskFixture: + metadata = SimpleNamespace( + model_id="risk-fixture", + target_signal="ht:2:Mg.Sulfur", + target_source="pak", + target_unit="mg/kg", + horizon_minutes=60, + capabilities=SimpleNamespace( + supports_forecast=True, + supports_actions=False, + supports_uncertainty=True, + supports_exceedance_probability=True, + ), + ) + + def predict(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray([9.0]) + + def predict_upper(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray([9.8]) + + def predict_exceedance_probability(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray([0.7]) + + def predict_alarm(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray([True]) + + def check_applicability(self, features: pd.DataFrame) -> object: + return SimpleNamespace(available=True, reason_code=None) + + assessment = predict_quality(state, pd.DataFrame({"baseline": [9.0]}), RiskFixture(), scenario) + + assert assessment.status.value == "degraded" + assert assessment.metrics["sulfur_exceedance_probability"].value == pytest.approx(0.7) + assert {issue.code for issue in assessment.issues} == {"SULFUR_EXCEEDANCE_RISK"} + + +def test_published_lims_features_never_see_unpublished_analysis() -> None: + quality = pd.DataFrame( + { + "observation_id": ["old", "hidden"], + "signal_id": ["ht:2:Mg.Sulfur"] * 2, + "source": ["lims"] * 2, + "validity": ["valid"] * 2, + "unit": ["mg/kg"] * 2, + "measured_at": pd.to_datetime(["2025-01-01 00:00Z", "2025-01-01 06:00Z"]), + "available_at": pd.to_datetime(["2025-01-01 04:00Z", "2025-01-01 10:00Z"]), + "value": [8.0, 999.0], + } + ) + data = SimpleNamespace(quality=quality) + + features = published_lims_features( # type: ignore[arg-type] + data, pd.Series(pd.to_datetime(["2025-01-01 09:00Z"])) + ) + + assert features.loc[0, "lims_published_lag_0"] == pytest.approx(8.0) + assert features.loc[0, "lims_age_minutes_0"] == pytest.approx(9 * 60) diff --git a/global_tests/test_stage0.py b/global_tests/test_stage0.py index 541923a..e4698c4 100644 --- a/global_tests/test_stage0.py +++ b/global_tests/test_stage0.py @@ -1,3 +1,361 @@ +<<<<<<< HEAD +"""Acceptance checks for the complete stage-0 contract boundary.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path + +import pandas as pd +import pytest +from pydantic import ValidationError + +from source.config import load_runtime_config, load_scenario, load_tag_dictionary +from source.contracts import ( + CandidateAction, + CandidateKind, + DatasetManifest, + MappingStatus, + Observation, + ProcessState, + Recommendation, + Stage, + TagMeta, + Unit, + Validity, +) +from source.data.ingest import read_lims, read_pak, read_telemetry_csv +from source.data.prepare import PreparedData, known_feature_order +from source.data.state import build_state + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_all_versioned_configs_load() -> None: + """Verify runtime settings and every checked-in scenario load successfully.""" + runtime = load_runtime_config("config/runtime.toml") + assert runtime.source_timezone == "Europe/Moscow" + assert runtime.lims_delay_hours == 4 + assert runtime.horizon_minutes == 60 + assert runtime.max_candidates == 125 + assert {load_scenario(path).id for path in Path("config/scenarios").glob("*.json")} == { + "history", + "blend_normal", + "blend_risk", + "blend_t95_risk", + "blend_cetane_risk", + "blend_missing", + "hybrid_blend", + } + + +def test_tag_dictionary_uses_confirmed_expert_clarifications() -> None: + """Verify expert-confirmed mappings, conversion and controllable signals.""" + tags = load_tag_dictionary("config/tags.csv") + assert len(tags) == 170 + assert {tag.signal_id for tag in tags.values() if tag.controllable} == { + "ht:F19", + "ht:P8", + "ht:T11", + } + pak_sulfur = tags["24-2000:Mg.Sulfur"] + assert pak_sulfur.mapping_status is MappingStatus.CONFIRMED + assert pak_sulfur.signal_id == "ht:2:Mg.Sulfur" + assert pak_sulfur.canonical_unit == Unit.MG_KG.value + assert pak_sulfur.conversion.value == "ppm_mass_to_mg_kg" + assert tags["ЛИМС:Гидроочистка.2:Mg.Sulfur"].mapping_status is MappingStatus.CONFIRMED + + +def test_ml_feature_order_excludes_unconfirmed_mappings() -> None: + """Unknown tags stay inspectable but cannot silently become model inputs.""" + tags = load_tag_dictionary("config/tags.csv") + + feature_order = known_feature_order(tags) + + assert "ht:2:Mg.Sulfur" in feature_order + assert "ht:sulfur" not in feature_order + + +def test_serialized_contract_examples_validate() -> None: + """Verify the representative serialized state and recommendation contracts.""" + ProcessState.model_validate_json( + (FIXTURES / "contracts/process_state.json").read_text(encoding="utf-8") + ) + Recommendation.model_validate_json( + (FIXTURES / "contracts/recommendation.json").read_text(encoding="utf-8") + ) + legacy = json.loads((FIXTURES / "contracts/recommendation.json").read_text(encoding="utf-8")) + legacy["schema_version"] = "1.0" + legacy["selected"]["candidate"].pop("additive_mass_fraction") + Recommendation.model_validate(legacy) + + +@pytest.mark.parametrize( + "name", + [ + "blend_normal", + "blend_risk", + "blend_t95_risk", + "blend_cetane_risk", + "blend_missing", + ], +) +def test_model_demo_state_fixtures_validate(name: str) -> None: + """Verify each model-demo fixture contains a valid process state and status.""" + fixture = json.loads((FIXTURES / f"model_demo/{name}.json").read_text(encoding="utf-8")) + ProcessState.model_validate(fixture["state"]) + assert fixture["expected"]["status"] in {"hold", "recommend", "abstain"} + + +def test_model_demo_numbers_match_design() -> None: + """Verify fixture sulfur values match the numerical examples in DESIGN.md.""" + normal = load_scenario("config/scenarios/blend_normal.json") + risk = load_scenario("config/scenarios/blend_risk.json") + + def sulfur(scenario, field: str) -> float: + """Calculate weighted sulfur for the scenario's current blend.""" + return sum( + scenario.current_blend_mass_fractions[item.id] * getattr(item.sulfur, field) + for item in scenario.blend_components + ) + + assert sulfur(normal, "value") == pytest.approx(8.316) + assert sulfur(normal, "upper") == pytest.approx(9.306) + assert sulfur(risk, "value") == pytest.approx(13.068) + assert sulfur(risk, "upper") == pytest.approx(14.058) + missing = load_scenario("config/scenarios/blend_missing.json") + assert missing.blend_components[0].sulfur.value is None + assert missing.blend_components[0].sulfur.upper is None + + +def test_contracts_reject_extra_fields_naive_time_and_nan() -> None: + """Verify contracts reject unknown fields, naive timestamps and NaN values.""" + payload = json.loads((FIXTURES / "contracts/process_state.json").read_text(encoding="utf-8")) + payload["unexpected"] = True + with pytest.raises(ValidationError): + ProcessState.model_validate(payload) + + observation = { + "id": "invalid", + "signal_id": "ht:sulfur", + "stage": "ht", + "source": "pak", + "measured_at": "2026-01-01T00:00:00", + "available_at": "2026-01-01T00:00:00Z", + "value": 1.0, + "unit": "mg/kg", + "validity": "valid", + "source_ref": "fixture", + } + with pytest.raises(ValidationError): + Observation.model_validate(observation) + observation["measured_at"] = "2026-01-01T00:00:00Z" + observation["value"] = float("nan") + with pytest.raises(ValidationError): + Observation.model_validate(observation) + + +def test_candidate_contract_enforces_kind_and_recipe() -> None: + """Verify candidate payloads cannot contradict their declared action kind.""" + with pytest.raises(ValidationError): + CandidateAction( + id="bad-hold", + kind=CandidateKind.HOLD, + setpoints={"ht:T6": 300.0}, + horizon_minutes=60, + ) + with pytest.raises(ValidationError): + CandidateAction( + id="bad-blend", + kind=CandidateKind.BLEND, + blend_mass_fractions={"A": 0.8, "B": 0.3}, + horizon_minutes=60, + ) + + +def test_telemetry_is_utc_namespaced_and_reports_conflicts(tmp_path: Path) -> None: + """Verify telemetry normalization, namespacing and duplicate conflict reporting.""" + path = tmp_path / "telemetry.csv" + pd.DataFrame( + { + "Unnamed: 0": [0, 1, 2], + "date": ["2026-01-15 12:00:00"] * 2 + ["broken"], + "T1": [130.0, 131.0, 132.0], + } + ).to_csv(path, index=False) + tags = { + "avt:T1": TagMeta( + signal_id="avt:T1", + raw_name="avt:T1", + stage=Stage.AVT, + meaning="temperature fixture", + raw_unit=None, + canonical_unit="unknown", + conversion="none", + mapping_status="ambiguous", + controllable=False, + evidence_ref="fixture", + ) + } + result = read_telemetry_csv(path, "avt", tags) + assert list(result.frame.columns) == ["avt:T1", "timestamp"] or list(result.frame.columns) == [ + "timestamp", + "avt:T1", + ] + assert len(result.frame) == 1 + assert result.frame.loc[0, "timestamp"].tzinfo is not None + assert pd.isna(result.frame.loc[0, "avt:T1"]) + assert {issue.code for issue in result.issues} >= { + "INVALID_TIMESTAMP", + "SOURCE_CONFLICT", + "TAG_UNCONFIRMED", + } + + +def test_pak_and_lims_keep_time_semantics_and_invalid_values(tmp_path: Path) -> None: + """Verify PAK and LIMS preserve timestamps, invalid values and delay semantics.""" + pak_path = tmp_path / "pak.xlsx" + pd.DataFrame( + [ + ["24-2000:D15", None], + ["кг/м3", None], + [datetime(2026, 1, 15, 12), 830.0], + [datetime(2026, 1, 15, 12, 10), "Pt Created"], + ] + ).to_excel(pak_path, header=False, index=False) + lims_path = tmp_path / "lims.xlsx" + section = "Установка 'Гидроочистка'. Точка отбора '2'. Продукт 'ДТ'" + pd.DataFrame( + [ + [section, None], + ["Mg.Sulfur", None], + ["мг/кг", None], + ["Количество значений:", 1], + [datetime(2026, 1, 15, 12), 8.4], + ] + ).to_excel(lims_path, header=False, index=False) + tags = { + "24-2000:D15": TagMeta( + signal_id="ht:density_15c", + raw_name="24-2000:D15", + stage="ht", + meaning="density", + raw_unit="кг/м3", + canonical_unit="kg/m3", + conversion="none", + mapping_status="confirmed", + controllable=False, + evidence_ref="fixture", + ), + "ЛИМС:Гидроочистка.2:Mg.Sulfur": TagMeta( + signal_id="ht:2:Mg.Sulfur", + raw_name="ЛИМС:Гидроочистка.2:Mg.Sulfur", + stage="ht", + meaning="sulfur", + raw_unit="мг/кг", + canonical_unit="mg/kg", + conversion="none", + mapping_status="confirmed", + controllable=False, + evidence_ref="fixture", + ), + } + pak = read_pak(pak_path, tags) + lims = read_lims(lims_path, tags, lims_delay_hours=6) + assert pak.observations[0].measured_at == datetime(2026, 1, 15, 9, tzinfo=UTC) + assert pak.observations[0].available_at == pak.observations[0].measured_at + assert pak.observations[1].validity is Validity.INVALID + assert pak.observations[1].value is None + assert "INVALID_VALUE" in {issue.code for issue in pak.issues} + assert lims.observations[0].available_at - lims.observations[0].measured_at == pd.Timedelta( + hours=6 + ) + + +def test_expert_confirmed_pak_conversion_and_lims_unit_override(tmp_path: Path) -> None: + """Apply only the PAK conversion and bad-header override confirmed by experts.""" + pak_path = tmp_path / "pak_sulfur.xlsx" + pd.DataFrame( + [ + ["24-2000:Mg.Sulfur", None], + ["ppm", None], + [datetime(2026, 1, 15, 12), 7.5], + ] + ).to_excel(pak_path, header=False, index=False) + lims_path = tmp_path / "lims_bad_unit.xlsx" + section = "Установка 'АВТ'. Точка отбора '1'. Продукт 'ДТ'" + pd.DataFrame( + [ + [section, None], + ["50%.T", None], + ["кг/м3", None], + ["Количество значений:", 1], + [datetime(2026, 1, 15, 12), 250.0], + ] + ).to_excel(lims_path, header=False, index=False) + tags = { + "24-2000:Mg.Sulfur": TagMeta( + signal_id="ht:2:Mg.Sulfur", + raw_name="24-2000:Mg.Sulfur", + stage="ht", + meaning="sulfur", + raw_unit="ppm", + canonical_unit="mg/kg", + conversion="ppm_mass_to_mg_kg", + mapping_status="confirmed", + controllable=False, + evidence_ref="DESIGN §16", + ), + "ЛИМС:АВТ.1:50%.T": TagMeta( + signal_id="avt:1:50%.T", + raw_name="ЛИМС:АВТ.1:50%.T", + stage="avt", + meaning="50 percent boiling temperature", + raw_unit="кг/м3", + canonical_unit="degC", + conversion="none", + mapping_status="confirmed", + controllable=False, + evidence_ref="DESIGN §16", + ), + } + + pak = read_pak(pak_path, tags) + lims = read_lims(lims_path, tags, lims_delay_hours=4) + + assert pak.observations[0].signal_id == "ht:2:Mg.Sulfur" + assert pak.observations[0].unit == "mg/kg" + assert pak.observations[0].value == pytest.approx(7.5) + assert pak.observations[0].validity is Validity.VALID + assert lims.observations[0].unit == "degC" + assert lims.observations[0].validity is Validity.VALID + assert {issue.code for issue in lims.issues} == {"UNIT_HEADER_OVERRIDDEN"} + + +def test_build_state_cannot_see_delayed_lims() -> None: + """Verify state selection respects measured and publication availability times.""" + quality = pd.read_csv(FIXTURES / "data/quality.csv") + manifest = DatasetManifest.model_validate_json( + (FIXTURES / "data/manifest.json").read_text(encoding="utf-8") + ) + data = PreparedData( + telemetry=pd.read_csv(FIXTURES / "data/telemetry.csv"), + quality=quality, + issues=pd.read_csv(FIXTURES / "data/issues.csv"), + manifest=manifest, + feature_order=tuple( + json.loads((FIXTURES / "data/feature_order.json").read_text(encoding="utf-8")) + ), + ) + scenario = load_scenario("config/scenarios/history.json") + config = load_runtime_config("config/runtime.toml") + + early = build_state(data, datetime(2026, 1, 15, 8, 10, tzinfo=UTC), scenario, config) + assert early.signals["ht:2:Mg.Sulfur"].selected.source.value == "pak" + later = build_state(data, datetime(2026, 1, 15, 14, 10, tzinfo=UTC), scenario, config) + assert later.signals["ht:2:Mg.Sulfur"].selected.source.value == "lims" +======= """Acceptance checks for the complete stage-0 contract boundary.""" from __future__ import annotations @@ -42,20 +400,20 @@ def test_all_versioned_configs_load() -> None: "history", "blend_normal", "blend_risk", + "blend_t95_risk", + "blend_cetane_risk", "blend_missing", "hybrid_blend", } def test_tag_dictionary_uses_confirmed_expert_clarifications() -> None: - """Verify expert-confirmed mappings, conversion and controllable signals.""" + """Verify expert-confirmed mappings and fail-closed action metadata.""" tags = load_tag_dictionary("config/tags.csv") assert len(tags) == 170 - assert {tag.signal_id for tag in tags.values() if tag.controllable} == { - "ht:F19", - "ht:P8", - "ht:T11", - } + # Historical action-effect research is observational; no tag is yet + # authorized as a production control (supports_actions remains false). + assert {tag.signal_id for tag in tags.values() if tag.controllable} == set() pak_sulfur = tags["24-2000:Mg.Sulfur"] assert pak_sulfur.mapping_status is MappingStatus.CONFIRMED assert pak_sulfur.signal_id == "ht:2:Mg.Sulfur" @@ -82,9 +440,22 @@ def test_serialized_contract_examples_validate() -> None: Recommendation.model_validate_json( (FIXTURES / "contracts/recommendation.json").read_text(encoding="utf-8") ) + legacy = json.loads((FIXTURES / "contracts/recommendation.json").read_text(encoding="utf-8")) + legacy["schema_version"] = "1.0" + legacy["selected"]["candidate"].pop("additive_mass_fraction") + Recommendation.model_validate(legacy) -@pytest.mark.parametrize("name", ["blend_normal", "blend_risk", "blend_missing"]) +@pytest.mark.parametrize( + "name", + [ + "blend_normal", + "blend_risk", + "blend_t95_risk", + "blend_cetane_risk", + "blend_missing", + ], +) def test_model_demo_state_fixtures_validate(name: str) -> None: """Verify each model-demo fixture contains a valid process state and status.""" fixture = json.loads((FIXTURES / f"model_demo/{name}.json").read_text(encoding="utf-8")) @@ -104,10 +475,10 @@ def sulfur(scenario, field: str) -> float: for item in scenario.blend_components ) - assert sulfur(normal, "value") == pytest.approx(8.4) - assert sulfur(normal, "upper") == pytest.approx(9.4) - assert sulfur(risk, "value") == pytest.approx(13.2) - assert sulfur(risk, "upper") == pytest.approx(14.2) + assert sulfur(normal, "value") == pytest.approx(8.316) + assert sulfur(normal, "upper") == pytest.approx(9.306) + assert sulfur(risk, "value") == pytest.approx(13.068) + assert sulfur(risk, "upper") == pytest.approx(14.058) missing = load_scenario("config/scenarios/blend_missing.json") assert missing.blend_components[0].sulfur.value is None assert missing.blend_components[0].sulfur.upper is None @@ -339,3 +710,4 @@ def test_build_state_cannot_see_delayed_lims() -> None: assert early.signals["ht:2:Mg.Sulfur"].selected.source.value == "pak" later = build_state(data, datetime(2026, 1, 15, 14, 10, tzinfo=UTC), scenario, config) assert later.signals["ht:2:Mg.Sulfur"].selected.source.value == "lims" +>>>>>>> d0bbf23 (Update project sources and materials) diff --git a/global_tests/test_stage1_cycle.py b/global_tests/test_stage1_cycle.py index fae17fc..dba414d 100644 --- a/global_tests/test_stage1_cycle.py +++ b/global_tests/test_stage1_cycle.py @@ -9,7 +9,7 @@ import pytest from source.config import load_runtime_config, load_scenario -from source.contracts import DecisionContext, RecommendationStatus +from source.contracts import DecisionContext, RecommendationStatus, ScenarioConfig from source.orchestrator import run_cycle FIXTURES = Path(__file__).parent / "fixtures" @@ -42,6 +42,18 @@ def _run_scenario(runtime_config, scenario, tmp_path: Path): ) +def _run_scenario(runtime_config, scenario: ScenarioConfig, tmp_path: Path): + return run_cycle( + data=None, + as_of=datetime(2026, 1, 15, 9, tzinfo=UTC), + model=None, + scenario=scenario, + config=runtime_config, + context=DecisionContext(), + run_dir=tmp_path, + ) + + def _metric(evaluation, name: str): for assessment in evaluation.assessments: if name in assessment.metrics: @@ -49,63 +61,68 @@ def _metric(evaluation, name: str): raise AssertionError(f"metric {name} not found") -def test_stage1_holds_complete_normal_blend( +def test_complete_product_passport_allows_stable_hold( runtime_config, tmp_path: Path, ) -> None: - """Complete model-demo product properties allow a hold decision.""" + """A stable recipe passes sulfur, T95 and cetane conservative bounds.""" result = _run_demo(runtime_config, "blend_normal", tmp_path) assert result.status is RecommendationStatus.HOLD assert result.selected is not None - assert result.selected.candidate.id == "hold" - assert "NO_MATERIAL_IMPROVEMENT" in result.reason_codes + assert [check.status.value for check in result.selected.checks[:3]] == ["pass"] * 3 -def test_stage1_recommends_model_recipe_for_risk_blend( +def test_complete_product_passport_allows_risk_recommendation( runtime_config, tmp_path: Path, ) -> None: - """A risky model-demo blend can recommend a synthetic safer recipe.""" + """The selected recipe must pass all three product-quality constraints.""" result = _run_demo(runtime_config, "blend_risk", tmp_path) + assert result.schema_version == "1.1" assert result.status is RecommendationStatus.RECOMMEND assert result.baseline is not None assert result.baseline.feasible is False assert result.selected is not None - assert result.selected.candidate.blend_mass_fractions == {"A": 0.9, "B": 0.1} - assert "QUALITY_LIMIT" in result.reason_codes + assert result.selected.candidate.blend_mass_fractions == pytest.approx({"A": 0.891, "B": 0.099}) + assert result.selected.candidate.additive_mass_fraction == pytest.approx(0.01) + assert all(check.status.value == "pass" for check in result.selected.checks) + legacy_payload = result.model_dump(mode="json") + legacy_payload["schema_version"] = "1.0" + with pytest.raises(ValueError, match="requires recommendation schema 1.1"): + type(result).model_validate(legacy_payload) -def test_stage1_abstains_when_model_demo_t95_is_missing( +def test_stage1_abstains_when_required_component_quality_is_missing( runtime_config, tmp_path: Path, ) -> None: - """Missing scenario passport properties still block model-demo recommendations.""" - scenario = load_scenario("config/scenarios/blend_normal.json") - first, second = scenario.blend_components - broken = scenario.model_copy( - update={"blend_components": (first.model_copy(update={"t95": None}), second)} - ) - - result = _run_scenario(runtime_config, broken, tmp_path) + """Missing component quality should remain an explained refusal.""" + result = _run_demo(runtime_config, "blend_missing", tmp_path) assert result.status is RecommendationStatus.ABSTAIN assert result.selected is None - assert "UNASSESSED_REQUIRED_PROPERTY" in result.reason_codes + assert "MISSING_REQUIRED_SIGNAL" in result.reason_codes + assert "Надёжной рекомендации нет" in result.explanation -def test_stage1_abstains_when_required_component_quality_is_missing( +@pytest.mark.parametrize("missing_field", ["t95", "cetane_number"]) +def test_stage1_abstains_when_required_product_property_is_unassessed( runtime_config, tmp_path: Path, + missing_field: str, ) -> None: - """Missing component quality should remain an explained refusal.""" - result = _run_demo(runtime_config, "blend_missing", tmp_path) + """Unknown T95 or cetane data must never be treated as a passed constraint.""" + payload = load_scenario("config/scenarios/blend_normal.json").model_dump(mode="json") + payload["blend_components"][0][missing_field] = None + scenario = ScenarioConfig.model_validate(payload) + + result = _run_scenario(runtime_config, scenario, tmp_path) assert result.status is RecommendationStatus.ABSTAIN assert result.selected is None - assert "MISSING_REQUIRED_SIGNAL" in result.reason_codes - assert "Надёжной рекомендации нет" in result.explanation + assert "UNASSESSED_REQUIRED_PROPERTY" in result.reason_codes def test_stage1_writes_journal_files( diff --git a/global_tests/test_stage1_ml.py b/global_tests/test_stage1_ml.py index 5f753a4..1057856 100644 --- a/global_tests/test_stage1_ml.py +++ b/global_tests/test_stage1_ml.py @@ -84,7 +84,7 @@ def test_history_without_confirmed_severity_factors_is_unavailable_not_zero() -> @pytest.mark.parametrize( ("name", "value", "upper"), - [("blend_normal", 8.4, 9.4), ("blend_risk", 13.2, 14.2)], + [("blend_normal", 8.316, 9.306), ("blend_risk", 13.068, 14.058)], ) def test_model_demo_quality_is_calculated_from_components( name: str, value: float, upper: float @@ -100,6 +100,8 @@ def test_model_demo_quality_is_calculated_from_components( assert assessment.metrics["t95"].value is not None assert assessment.metrics["cetane_number"].value is not None assert assessment.metrics["sulfur"].basis.value == "formula" + assert assessment.metrics["t95"].upper is not None + assert assessment.metrics["cetane_number"].lower is not None def test_missing_component_quality_is_unavailable_not_zero() -> None: @@ -168,12 +170,17 @@ def test_candidates_include_hold_and_deterministic_recipe_grid() -> None: candidates = generate_candidates(state, scenario) - assert len(candidates) == 21 + assert len(candidates) == 84 assert candidates[0].kind is CandidateKind.HOLD assert sum(candidate.kind is CandidateKind.HOLD for candidate in candidates) == 1 - assert any(candidate.blend_mass_fractions == {"A": 0.9, "B": 0.1} for candidate in candidates) + assert any( + candidate.blend_mass_fractions == pytest.approx({"A": 0.891, "B": 0.099}) + and candidate.additive_mass_fraction == pytest.approx(0.01) + for candidate in candidates + ) assert all( - abs(sum(candidate.blend_mass_fractions.values()) - 1.0) <= 1e-9 + abs(sum(candidate.blend_mass_fractions.values()) + candidate.additive_mass_fraction - 1.0) + <= 1e-9 for candidate in candidates if candidate.kind is CandidateKind.BLEND ) @@ -183,9 +190,10 @@ def test_blend_effects_match_design_and_keep_state_immutable() -> None: state, scenario = _demo("blend_risk") before = state.model_dump_json() candidate = CandidateAction( - id="blend:A=0.90,B=0.10", + id="blend:A=0.891,B=0.099,additive=0.010", kind="blend", - blend_mass_fractions={"A": 0.9, "B": 0.1}, + blend_mass_fractions={"A": 0.891, "B": 0.099}, + additive_mass_fraction=0.01, horizon_minutes=60, is_model_scenario=True, ) @@ -193,11 +201,11 @@ def test_blend_effects_match_design_and_keep_state_immutable() -> None: metrics = calculate_blend_metrics(candidate, scenario) assessments = assess_blend_candidate(state, candidate, scenario) - assert metrics["sulfur"].value == pytest.approx(8.4) - assert metrics["sulfur"].upper == pytest.approx(9.4) - assert metrics["t95"].value == pytest.approx(351.0) - assert metrics["cetane_number"].value == pytest.approx(53.4) - assert metrics["cost_proxy"].value == pytest.approx(0.98) - assert metrics["change_size"].value == pytest.approx(0.4) + assert metrics["sulfur"].value == pytest.approx(8.316) + assert metrics["sulfur"].upper == pytest.approx(9.306) + assert metrics["t95"].upper == pytest.approx(354.0) + assert metrics["cetane_number"].lower == pytest.approx(52.6) + assert metrics["cost_proxy"].value == pytest.approx(1.9702) + assert metrics["change_size"].value == pytest.approx(0.396) assert [item.agent.value for item in assessments] == ["quality", "reliability", "optimizer"] assert state.model_dump_json() == before diff --git a/global_tests/test_stage1_ml_gaps.py b/global_tests/test_stage1_ml_gaps.py index 5473c22..2a877ec 100644 --- a/global_tests/test_stage1_ml_gaps.py +++ b/global_tests/test_stage1_ml_gaps.py @@ -27,7 +27,7 @@ def test_candidate_grid_fails_instead_of_silently_truncating() -> None: with pytest.raises( ValueError, - match=r"requires 21 candidates including hold, but max_candidates=20", + match=r"requires 84 candidates including hold, but max_candidates=20", ): optimizer.generate_candidates(state, scenario, config) diff --git a/global_tests/test_stage2_data.py b/global_tests/test_stage2_data.py index 6714176..8463ca7 100644 --- a/global_tests/test_stage2_data.py +++ b/global_tests/test_stage2_data.py @@ -10,6 +10,7 @@ import pandas as pd import pytest +import source.data.prepare as prepare_module from source.contracts import DatasetManifest, ProcessState from source.data.prepare import PreparedData, load_prepared_dataset, write_prepared_dataset from source.main import main @@ -17,6 +18,20 @@ FIXTURES = Path(__file__).parent / "fixtures" +def _fixture_prepared_data() -> PreparedData: + return PreparedData( + telemetry=pd.read_csv(FIXTURES / "data/telemetry.csv"), + quality=pd.read_csv(FIXTURES / "data/quality.csv"), + issues=pd.read_csv(FIXTURES / "data/issues.csv"), + manifest=DatasetManifest.model_validate_json( + (FIXTURES / "data/manifest.json").read_text(encoding="utf-8") + ), + feature_order=tuple( + json.loads((FIXTURES / "data/feature_order.json").read_text(encoding="utf-8")) + ), + ) + + def _write_minimal_materials(root: Path) -> None: raw_data = root / "raw" / "data" raw_data.mkdir(parents=True) @@ -56,17 +71,7 @@ def _write_minimal_materials(root: Path) -> None: def test_prepared_dataset_roundtrip(tmp_path: Path) -> None: """Verify a written prepared dataset can be loaded back without contract drift.""" - data = PreparedData( - telemetry=pd.read_csv(FIXTURES / "data/telemetry.csv"), - quality=pd.read_csv(FIXTURES / "data/quality.csv"), - issues=pd.read_csv(FIXTURES / "data/issues.csv"), - manifest=DatasetManifest.model_validate_json( - (FIXTURES / "data/manifest.json").read_text(encoding="utf-8") - ), - feature_order=tuple( - json.loads((FIXTURES / "data/feature_order.json").read_text(encoding="utf-8")) - ), - ) + data = _fixture_prepared_data() dataset_path = write_prepared_dataset(data, tmp_path) loaded = load_prepared_dataset(dataset_path) @@ -78,6 +83,28 @@ def test_prepared_dataset_roundtrip(tmp_path: Path) -> None: pd.testing.assert_frame_equal(loaded.issues, data.issues, check_dtype=False) +def test_prepared_dataset_publish_retries_transient_permission_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify Windows-style transient locks do not fail prepared dataset publication.""" + calls = 0 + original_replace = prepare_module._replace_path + + def flaky_replace(source: Path, target: Path) -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise PermissionError("temporary Windows directory lock") + original_replace(source, target) + + monkeypatch.setattr(prepare_module, "_replace_path", flaky_replace) + + dataset_path = write_prepared_dataset(_fixture_prepared_data(), tmp_path) + + assert calls == 2 + assert (dataset_path / "manifest.json").is_file() + + def test_prepare_and_build_state_cli_on_small_materials( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -144,3 +171,16 @@ def test_build_state_cli_reports_missing_dataset(capsys: pytest.CaptureFixture[s == 1 ) assert "missing prepared dataset files" in capsys.readouterr().err + + +def test_cli_rejects_stale_prepared_dataset_before_ml_diagnostics( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Verify stale manifest hashes fail before downstream ML diagnostics.""" + dataset_path = write_prepared_dataset(_fixture_prepared_data(), tmp_path) + + assert main(["diagnose-ml", "--dataset", str(dataset_path)]) == 1 + + error = capsys.readouterr().err + assert "prepared dataset is not current" in error + assert "config_sha256" in error diff --git a/global_tests/test_stage3_controls.py b/global_tests/test_stage3_controls.py index ab1e38f..f18a277 100644 --- a/global_tests/test_stage3_controls.py +++ b/global_tests/test_stage3_controls.py @@ -25,6 +25,7 @@ CONTROL_IDS, ActionEffectEvidence, assess_action_capability, + extract_change_episodes, fit_joint_control_domain, generate_setpoint_candidates, summarize_observed_controls, @@ -136,12 +137,69 @@ def test_joint_domain_rejects_individually_typical_but_implausible_combination() def test_action_capability_requires_temporal_gain_on_change_episodes() -> None: controls = _controls() weak = ActionEffectEvidence("2025-01-01", "2026-01-01", 30, 60, 1.0, 1.1) - useful = ActionEffectEvidence("2025-01-01", "2026-01-01", 30, 60, 1.0, 0.8) + useful = ActionEffectEvidence( + "2025-01-01", + "2026-01-01", + 300, + 60, + 1.0, + 0.8, + per_control_episode_counts={signal_id: 100 for signal_id in CONTROL_IDS}, + conservative_coverage=0.96, + sign_stable_folds=3, + shadow_replay_passed=True, + pilot_approved=True, + ) assert not assess_action_capability(controls, weak).supports_actions assert assess_action_capability(controls, useful).supports_actions +def test_action_capability_stays_disabled_without_pilot_even_with_good_metrics() -> None: + controls = _controls() + evidence = ActionEffectEvidence( + "2025-01-01", + "2026-01-01", + 300, + 60, + 1.0, + 0.8, + per_control_episode_counts={signal_id: 100 for signal_id in CONTROL_IDS}, + conservative_coverage=0.96, + sign_stable_folds=3, + shadow_replay_passed=True, + pilot_approved=False, + ) + + report = assess_action_capability(controls, evidence) + + assert report.supports_actions is False + assert "TECHNOLOGIST_PILOT_MISSING" in report.reason_codes + + +def test_change_episode_extraction_keeps_only_isolated_stable_interventions() -> None: + timestamp = pd.date_range("2025-01-01", periods=40, freq="10min", tz="UTC") + frame = pd.DataFrame( + { + "timestamp": timestamp, + "ht:P8": np.r_[np.full(10, 10.0), np.full(30, 11.0)], + "ht:T11": np.full(40, 100.0), + "ht:F19": np.full(40, 30.0), + "ht:2:Mg.Sulfur": np.r_[np.full(10, 10.0), np.linspace(10.0, 8.0, 30)], + } + ) + + episodes = extract_change_episodes( + frame, + {"ht:P8": 0.5, "ht:T11": 1.0, "ht:F19": 0.5}, + horizons_minutes=(30, 60), + ) + + assert len(episodes) == 1 + assert episodes.iloc[0]["control_id"] == "ht:P8" + assert episodes.iloc[0]["control_delta"] == pytest.approx(1.0) + + def test_grid_is_deterministic_bounded_and_jointly_filtered() -> None: frame = _training_frame() domain = fit_joint_control_domain(frame, coverage=0.99) diff --git a/global_tests/test_stage3_guardrails.py b/global_tests/test_stage3_guardrails.py index 77c332a..5d5c891 100644 --- a/global_tests/test_stage3_guardrails.py +++ b/global_tests/test_stage3_guardrails.py @@ -37,31 +37,34 @@ def _baseline_feasible_cost_scenario(cost_threshold: float): scenario = load_scenario("config/scenarios/blend_normal.json") thresholds = dict(scenario.materiality_thresholds) thresholds["cost_proxy"] = cost_threshold + components = list(scenario.blend_components) + components[1] = components[1].model_copy( + update={"sulfur": components[1].sulfur.model_copy(update={"value": 24.0, "upper": 25.0})} + ) return scenario.model_copy( update={ "id": f"blend_materiality_{cost_threshold:g}", - "current_blend_mass_fractions": {"A": 1.0, "B": 0.0}, + "blend_components": tuple(components), "materiality_thresholds": thresholds, } ) def test_stage3_keeps_hold_when_improvement_is_not_material(runtime_config, tmp_path: Path) -> None: - """A feasible baseline should stay hold when the improvement is too small.""" + """A sub-threshold cost improvement keeps the valid current recipe.""" scenario = _baseline_feasible_cost_scenario(cost_threshold=0.05) result = _run(scenario, runtime_config, tmp_path) assert result.status is RecommendationStatus.HOLD assert result.selected is not None - assert result.selected.candidate.id == "hold" assert "NO_MATERIAL_IMPROVEMENT" in result.reason_codes def test_stage3_cooldown_suppresses_repeat_when_baseline_is_feasible( runtime_config, tmp_path: Path ) -> None: - """Cooldown suppresses a repeat recommendation while hold is still feasible.""" + """Cooldown suppresses a repeated material change when hold is feasible.""" scenario = _baseline_feasible_cost_scenario(cost_threshold=0.005) context = DecisionContext(last_recommended_at=AS_OF - timedelta(minutes=30)) diff --git a/global_tests/test_stage4_blending.py b/global_tests/test_stage4_blending.py index 036016d..3ae2ba9 100644 --- a/global_tests/test_stage4_blending.py +++ b/global_tests/test_stage4_blending.py @@ -65,13 +65,14 @@ def test_mass_balance_uses_point_and_upper_sulfur_by_mass() -> None: assert result.sulfur.value == pytest.approx(8.4) assert result.sulfur.upper == pytest.approx(9.4) - assert result.t95.value == pytest.approx(351.0) - assert result.t95.upper == pytest.approx(356.0) - assert result.cetane_number.value == pytest.approx(53.4) + assert result.t95.value == pytest.approx(352.0) + assert result.t95.upper == pytest.approx(354.0) + assert result.cetane_number.value == pytest.approx(50.1) + assert result.cetane_number.lower == pytest.approx(49.6) assert sulfur_constraint_status(result, 10.0) is ConstraintStatus.PASS assert result.checked_properties == ("sulfur", "t95", "cetane_number", "component_stock") - assert result.unassessed_properties == ("full_product_passport",) - assert result.full_specification_status == "not_assessed" + assert result.unassessed_properties == () + assert result.full_specification_status == "assessed" def test_component_quality_changes_recipe_feasibility() -> None: @@ -91,17 +92,39 @@ def test_hybrid_quality_changes_the_set_of_feasible_recipes() -> None: _components(_forecast(upper=7.0)), 100.0, scenario.current_blend_mass_fractions, + additive_mass_fraction=0.01, + additive=scenario.cetane_additive, ) degraded = rank_feasible_blends( recipes, _components(_forecast(upper=9.0)), 100.0, scenario.current_blend_mass_fractions, + additive_mass_fraction=0.01, + additive=scenario.cetane_additive, ) assert good and degraded assert {option.recipe_id for option in good} != {option.recipe_id for option in degraded} - assert all(option.result.full_specification_status == "not_assessed" for option in good) + assert all(option.result.full_specification_status == "assessed" for option in good) + + +def test_additive_curve_enables_cetane_constraint_inside_model_scenario() -> None: + scenario = load_scenario("config/scenarios/blend_risk.json") + recipe = {"A": 0.891, "B": 0.099} + + without_additive = calculate_mass_blend({"A": 0.9, "B": 0.1}, _components(), 100.0) + with_additive = calculate_mass_blend( + recipe, + _components(), + 100.0, + additive_mass_fraction=0.01, + additive=scenario.cetane_additive, + ) + + assert without_additive.cetane_number.lower == pytest.approx(49.6) + assert with_additive.cetane_number.lower == pytest.approx(52.6) + assert with_additive.sulfur.upper == pytest.approx(9.306) def test_missing_upper_is_unknown_not_zero_or_pass() -> None: @@ -134,4 +157,4 @@ def test_gas_tags_are_context_only_not_action_controls() -> None: assert all(not item.action_enabled for item in context) assert all(item.reason == GAS_CONTEXT_REASON for item in context) assert all(tags[item.signal_id].controllable is False for item in context) - assert {item.unit for item in context} == {"unknown"} + assert {item.unit for item in context} == {"Nm3/h"} diff --git a/global_tests/test_stage6_acceptance.py b/global_tests/test_stage6_acceptance.py index 7914ec8..6087056 100644 --- a/global_tests/test_stage6_acceptance.py +++ b/global_tests/test_stage6_acceptance.py @@ -1,74 +1,482 @@ -"""Stage-6 acceptance command tests.""" +"""Stage-6 acceptance, freeze and journal-export tests.""" from __future__ import annotations import json +import zipfile +from datetime import UTC, datetime from pathlib import Path +from types import SimpleNamespace -from source.main import STAGE6_SCENARIOS, accept_stage6, main +import pandas as pd +import pytest -EXPECTED = { - "blend_normal": "hold", - "blend_risk": "recommend", - "blend_missing": "abstain", -} +from source.acceptance import ( + JOURNAL_FILES, + load_episode_specs, + run_acceptance_suite, + sha256_file, + verify_model_freeze, +) +from source.config import load_runtime_config, load_scenario +from source.contracts import ( + ConstraintBasis, + ControlSpec, + DecisionContext, + Observation, + OperationMode, + ProcessState, + SignalSnapshot, + SourceKind, + Stage, + Validity, +) +<<<<<<< HEAD +======= +from source.ml.artifacts import sha256_file +from source.ml.action_effects import VerifiedActionEffectModel, combine_forecast_action_model +from source.ml.controls import ActionEffectEvidence, JointControlDomain +>>>>>>> e70cafe (fix) +from source.orchestrator import run_cycle +PROJECT_ROOT = Path(__file__).resolve().parents[1] -def test_accept_stage6_cli_writes_journals_to_requested_run_dir( - tmp_path: Path, capsys: object + +def test_episode_catalog_is_explicit_and_complete() -> None: + episodes = load_episode_specs(PROJECT_ROOT / "config/demo_episodes.json") + + assert [episode.id for episode in episodes] == [ + "stable_blend", + "sulfur_risk", + "t95_risk", + "cetane_risk", + "missing_component_quality", + ] + assert [episode.expected_status for episode in episodes] == [ + "hold", + "recommend", + "recommend", + "recommend", + "abstain", + ] + + +def test_acceptance_suite_reproduces_decisions_and_exports_full_journals( + tmp_path: Path, ) -> None: - """Verify the public CLI performs the reproducible acceptance pass.""" - run_dir = tmp_path / "stage6-runs" - - exit_code = main(["accept-stage6", "--run-dir", str(run_dir)]) - - captured = capsys.readouterr() - payload = json.loads(captured.out) - assert exit_code == 0 - assert payload["passed"] is True - assert payload["validation"]["model_demo_fixtures"] == 3 - assert {item["scenario_id"] for item in payload["scenarios"]} == set(STAGE6_SCENARIOS) - for scenario in payload["scenarios"]: - assert scenario["status"] == EXPECTED[scenario["scenario_id"]] - assert scenario["passed"] is True - journal_dir = Path(scenario["journal_dir"]) - assert journal_dir.is_relative_to(run_dir) - for path in scenario["journal_files"].values(): - assert Path(path).is_file() - - -def test_accept_stage6_reports_failed_status_without_hiding_journal( + output = tmp_path / "acceptance" + report = run_acceptance_suite( + PROJECT_ROOT, + episodes_path=PROJECT_ROOT / "config/demo_episodes.json", + output_dir=output, + ) + + assert report["episode_count"] == 5 + assert report["max_cycle_seconds"] < 5 + risk = next(item for item in report["episodes"] if item["episode_id"] == "sulfur_risk") + assert risk["baseline_quality"]["sulfur_upper"] == pytest.approx(14.058) + assert risk["selected_quality"] == pytest.approx( + {"sulfur_upper": 9.306, "t95_upper": 354.0, "cetane_lower": 52.6} + ) + assert risk["selected_additive_fraction"] == pytest.approx(0.01) + assert risk["operator_recommendation"] is True + with zipfile.ZipFile(output / "journals.zip") as archive: + names = set(archive.namelist()) + assert "manifest.json" in names + for filename in JOURNAL_FILES: + assert f"sulfur_risk/{filename}" in names + + +def test_acceptance_decision_fingerprints_repeat(tmp_path: Path) -> None: + reports = [ + run_acceptance_suite( + PROJECT_ROOT, + episodes_path=PROJECT_ROOT / "config/demo_episodes.json", + output_dir=tmp_path / f"run-{index}", + ) + for index in range(2) + ] + + assert [item["decision_fingerprint"] for item in reports[0]["episodes"]] == [ + item["decision_fingerprint"] for item in reports[1]["episodes"] + ] + + +def test_model_freeze_verifies_hashes_and_rejects_tampering(tmp_path: Path) -> None: + artifact = tmp_path / "artifacts/models/frozen-test" + artifact.mkdir(parents=True) + model = artifact / "model.joblib" + metadata = artifact / "metadata.json" + metrics = artifact / "metrics.json" + model.write_bytes(b"trusted-local-model") + model_hash = sha256_file(model) + metadata.write_text( + json.dumps( + { + "model_id": "frozen-test", + "model_sha256": model_hash, + "training_dataset_id": "dataset00001", + "git_commit": "a" * 40, + "python_version": "3.12.3", + "sklearn_version": "1.9.0", + "capabilities": {"supports_actions": False}, + } + ), + encoding="utf-8", + ) + metrics.write_text(json.dumps({"test_used_for_selection": False}), encoding="utf-8") + freeze = tmp_path / "freeze.json" + freeze.write_text( + json.dumps( + { + "schema_version": "1.0", + "training_dataset_id": "dataset00001", + "training_git_commit": "a" * 40, + "python_version": "3.12.3", + "sklearn_version": "1.9.0", + "artifacts": [ + { + "model_id": "frozen-test", + "path": "artifacts/models/frozen-test", + "model_sha256": model_hash, + "metadata_sha256": sha256_file(metadata), + "metrics_sha256": sha256_file(metrics), + } + ], + "evaluation_reports": [], + } + ), + encoding="utf-8", + ) + + assert verify_model_freeze(tmp_path, freeze)["verified_models"] == ["frozen-test"] + model.write_bytes(b"tampered") + with pytest.raises(ValueError, match="checksum mismatch"): + verify_model_freeze(tmp_path, freeze) + + +def test_model_freeze_accepts_only_fully_gated_action_artifact(tmp_path: Path) -> None: + artifact = tmp_path / "artifacts/models/action-effect-test" + artifact.mkdir(parents=True) + model = artifact / "model.joblib" + metadata_path = artifact / "metadata.json" + metrics = artifact / "metrics.json" + model.write_bytes(b"trusted-local-action-model") + model_hash = sha256_file(model) + digest = "f" * 64 + gates = { + "minimum_episodes": True, + "per_control_episodes": True, + "mae_gain_10_percent": True, + "validation_upper_coverage": True, + "audit_upper_coverage": True, + "sign_stability": True, + "engineering_bounds": True, + "shadow_replay": True, + "technologist_pilot": True, + } + metadata = { + "model_id": "action-effect-test", + "model_sha256": model_hash, + "training_dataset_id": "dataset00001", + "git_commit": "a" * 40, + "python_version": "3.12.3", + "sklearn_version": "1.9.0", + "artifact_kind": "action_effect", + "supports_actions": True, + "dataset_fingerprints": { + "config_sha256": digest, + "tag_dictionary_sha256": digest, + "telemetry_rules_sha256": digest, + }, + "controls": [ + { + "signal_id": "ht:P8", + "unit": "MPa", + "lower": 0.1, + "upper": 0.2, + "max_step": 0.01, + "step": 0.005, + "evidence_ref": "engineering-bounds.md#P8", + "basis": "confirmed", + "enabled": True, + }, + { + "signal_id": "ht:F19", + "unit": "t/h", + "lower": 180.0, + "upper": 230.0, + "max_step": 5.0, + "step": 2.5, + "evidence_ref": "engineering-bounds.md#F19", + "basis": "confirmed", + "enabled": True, + }, + ], + "horizons_minutes": [60, 120, 180], + "lag_evidence": {"selected_lag_minutes": 60}, + "gate_report": gates, + "gate_report_hashes": {"validation": digest, "audit": digest}, + } + metadata_path.write_text(json.dumps(metadata), encoding="utf-8") + metrics.write_text(json.dumps({"test_used_for_selection": False}), encoding="utf-8") + freeze = tmp_path / "freeze.json" + + def write_freeze() -> None: + freeze.write_text( + json.dumps( + { + "schema_version": "1.0", + "training_dataset_id": "dataset00001", + "training_git_commit": "a" * 40, + "python_version": "3.12.3", + "sklearn_version": "1.9.0", + "config_sha256": digest, + "tag_dictionary_sha256": digest, + "telemetry_rules_sha256": digest, + "artifacts": [ + { + "model_id": "action-effect-test", + "path": "artifacts/models/action-effect-test", + "model_sha256": model_hash, + "metadata_sha256": sha256_file(metadata_path), + "metrics_sha256": sha256_file(metrics), + } + ], + "evaluation_reports": [], + } + ), + encoding="utf-8", + ) + + write_freeze() + assert verify_model_freeze(tmp_path, freeze)["verified_models"] == ["action-effect-test"] + + metadata["gate_report"]["technologist_pilot"] = False + metadata_path.write_text(json.dumps(metadata), encoding="utf-8") + write_freeze() + + with pytest.raises(ValueError, match="action gate failed: technologist_pilot"): + verify_model_freeze(tmp_path, freeze) + + +def test_failed_acceptance_does_not_publish_partial_directory(tmp_path: Path) -> None: + bad_catalog = tmp_path / "episodes.json" + payload = json.loads((PROJECT_ROOT / "config/demo_episodes.json").read_text(encoding="utf-8")) + payload["episodes"][0]["expected_status"] = "abstain" + bad_catalog.write_text(json.dumps(payload), encoding="utf-8") + output = tmp_path / "failed" + + with pytest.raises(ValueError, match="status changed"): + run_acceptance_suite(PROJECT_ROOT, episodes_path=bad_catalog, output_dir=output) + assert not output.exists() + + +def test_history_forecast_without_action_capability_abstains_explicitly( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Verify a status mismatch turns the acceptance result red.""" - expected = dict(EXPECTED) - expected["blend_risk"] = "hold" - - payload = accept_stage6(run_dir=tmp_path, expected_statuses=expected) - - assert payload["passed"] is False - failed = [item for item in payload["scenarios"] if not item["passed"]] - assert [item["scenario_id"] for item in failed] == ["blend_risk"] - assert failed[0]["status"] == "recommend" - assert failed[0]["expected_status"] == "hold" - assert failed[0]["missing_journal_files"] == [] - assert any("blend_risk" in issue for issue in payload["issues"]) - - -def test_accept_stage6_summary_shape(tmp_path: Path) -> None: - """Verify the acceptance JSON exposes the fields used by README and demos.""" - payload = accept_stage6(run_dir=tmp_path) - - assert set(payload) == { - "passed", - "validation", - "scenarios", - "environment", - "limitations", - "issues", + as_of = datetime(2026, 1, 15, 9, tzinfo=UTC) + observation = Observation( + id="pak-sulfur", + signal_id="ht:2:Mg.Sulfur", + stage=Stage.HYDROTREATMENT, + source=SourceKind.PAK, + measured_at=as_of, + available_at=as_of, + value=8.0, + unit="mg/kg", + validity=Validity.VALID, + source_ref="fixture", + ) + state = ProcessState( + state_id="history-state", + as_of=as_of, + dataset_id="dataset00001", + mode=OperationMode.HISTORY, + signals={ + "ht:2:Mg.Sulfur": SignalSnapshot( + selected=observation, + alternatives=(), + age_seconds=0, + fresh=True, + issues=(), + ) + }, + issues=(), + ) + monkeypatch.setattr("source.orchestrator._build_cycle_state", lambda *_: state) + + result = run_cycle( + data=None, + as_of=as_of, + model=None, + scenario=load_scenario(PROJECT_ROOT / "config/scenarios/history.json"), + config=load_runtime_config(PROJECT_ROOT / "config/runtime.toml"), + context=DecisionContext(), + run_dir=tmp_path, + ) + + assert result.status.value == "abstain" + assert result.selected is None + assert "ACTION_MODEL_UNAVAILABLE" in result.reason_codes + + +class _ForecastModel: + def __init__(self) -> None: + self.metadata = SimpleNamespace( + model_id="forecast-fixture", + capabilities=SimpleNamespace( + supports_forecast=True, + supports_actions=False, + supports_uncertainty=True, + supports_exceedance_probability=False, + supports_multi_horizon=False, + ), + target_signal="ht:2:Mg.Sulfur", + target_source="pak", + target_unit="mg/kg", + horizon_minutes=60, + feature_names=("baseline",), + processing={}, + ) + self.feature_names = ("baseline",) + + def predict(self, features: pd.DataFrame) -> list[float]: + _ = features + return [9.8] + + def predict_upper(self, features: pd.DataFrame) -> list[float]: + _ = features + return [11.0] + + def check_applicability(self, features: pd.DataFrame) -> SimpleNamespace: + _ = features + return SimpleNamespace(available=True, reason_code=None) + + +def _history_action_state(as_of: datetime) -> ProcessState: + def observation(signal_id: str, value: float, unit: str) -> Observation: + return Observation( + id=f"obs:{signal_id}", + signal_id=signal_id, + stage=Stage.HYDROTREATMENT, + source=SourceKind.TELEMETRY if signal_id != "ht:2:Mg.Sulfur" else SourceKind.PAK, + measured_at=as_of, + available_at=as_of, + value=value, + unit=unit, + validity=Validity.VALID, + source_ref="fixture", + ) + + observations = { + "ht:2:Mg.Sulfur": observation("ht:2:Mg.Sulfur", 9.8, "mg/kg"), + "ht:P8": observation("ht:P8", 0.15, "MPa"), + "ht:F19": observation("ht:F19", 200.0, "t/h"), } - assert isinstance(payload["validation"], dict) - assert isinstance(payload["scenarios"], list) - assert isinstance(payload["environment"]["python_version"], str) - assert isinstance(payload["environment"]["sklearn_version"], str) - assert payload["limitations"] + return ProcessState( + state_id="history-action-state", + as_of=as_of, + dataset_id="dataset00001", + mode=OperationMode.HISTORY, + signals={ + signal_id: SignalSnapshot( + selected=item, + alternatives=(), + age_seconds=0, + fresh=True, + issues=(), + ) + for signal_id, item in observations.items() + }, + issues=(), + ) + + +def _verified_action_model() -> VerifiedActionEffectModel: + controls = ( + ControlSpec( + signal_id="ht:P8", + unit="MPa", + lower=0.1, + upper=0.2, + max_step=0.01, + step=0.005, + evidence_ref="engineering-bounds.md#P8", + basis=ConstraintBasis.CONFIRMED, + enabled=True, + ), + ControlSpec( + signal_id="ht:F19", + unit="t/h", + lower=180.0, + upper=230.0, + max_step=5.0, + step=2.5, + evidence_ref="engineering-bounds.md#F19", + basis=ConstraintBasis.CONFIRMED, + enabled=True, + ), + ) + evidence = ActionEffectEvidence( + "2025-01-01", + "2026-01-01", + 240, + 60, + 1.0, + 0.8, + per_control_episode_counts={"ht:P8": 120, "ht:F19": 120}, + conservative_coverage=0.96, + sign_stable_folds=3, + shadow_replay_passed=True, + pilot_approved=True, + ) + domain = JointControlDomain( + signal_ids=("ht:P8", "ht:F19"), + center=(0.15, 200.0), + scale=(1.0, 100.0), + inverse_correlation=((1.0, 0.0), (0.0, 1.0)), + max_distance_squared=100.0, + ) + return VerifiedActionEffectModel( + model_id="action-fixture", + controls=controls, + joint_domain=domain, + evidence=evidence, + sulfur_coefficients={"ht:P8": -200.0}, + risk_coefficients={"ht:P8": -10.0}, + throughput_coefficients={}, + cost_coefficients={}, + evidence_ref="reports/action/fixture-gates.json", + ) + + +def test_history_with_verified_action_artifact_can_recommend_setpoints( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + as_of = datetime(2026, 1, 15, 9, tzinfo=UTC) + model = combine_forecast_action_model(_ForecastModel(), _verified_action_model()) + monkeypatch.setattr( + "source.orchestrator._build_cycle_state", + lambda *_: _history_action_state(as_of), + ) + monkeypatch.setattr( + "source.orchestrator.build_features", + lambda *_: pd.DataFrame({"baseline": [9.8]}), + ) + + result = run_cycle( + data=SimpleNamespace(manifest=SimpleNamespace(dataset_id="dataset00001")), + as_of=as_of, + model=model, + scenario=load_scenario(PROJECT_ROOT / "config/scenarios/history.json"), + config=load_runtime_config(PROJECT_ROOT / "config/runtime.toml"), + context=DecisionContext(), + run_dir=tmp_path, + ) + + assert result.status.value == "recommend" + assert result.selected is not None + assert result.selected.candidate.kind.value == "setpoints" + assert "ACTION_MODEL_UNAVAILABLE" not in result.reason_codes diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index 8d56993..0833f4b 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -7,12 +7,172 @@ from datetime import UTC, datetime from pathlib import Path +import pandas as pd import pytest +import source.ui_data as ui_data_module from source.config import load_runtime_config, load_scenario -from source.contracts import DecisionContext, RecommendationStatus +from source.contracts import ( + AgentAssessment, + AssessmentAgent, + AssessmentStatus, + CandidateAction, + CandidateEvaluation, + CandidateKind, + DatasetManifest, + DecisionContext, + EstimateBasis, + IntervalKind, + MetricEstimate, + OperationMode, + Recommendation, + RecommendationStatus, +) +from source.data.prepare import PreparedData, write_prepared_dataset from source.orchestrator import run_cycle +<<<<<<< HEAD from source.ui import history_smoke_snapshot, journal_entries, recommendation_to_view +======= +from source.ui import ( + format_action_shadow_payload, + format_history_replay_view, + format_hybrid_blend_view, + history_replay_to_view, + format_v2_forecast_payload, + journal_entries, + recommendation_to_view, + ui_history_snapshot, + ui_hybrid_snapshot, + ui_stage_snapshot, +) +>>>>>>> 1527107 (Extend UI and ML analysis materials) + +FIXTURES = Path(__file__).parent / "fixtures" +AS_OF = datetime(2026, 1, 15, 9, tzinfo=UTC) + + +def _ui_dataset(tmp_path: Path) -> Path: + manifest = DatasetManifest.model_validate_json( + (FIXTURES / "data/manifest.json").read_text(encoding="utf-8") + ).model_copy(update={"dataset_id": "ui0000000001"}) + telemetry = pd.DataFrame( + { + "timestamp": [AS_OF.isoformat()], + "avt:F65": [101.0], + "avt:T20": [260.0], + "ht:P8": [0.15], + "ht:F19": [205.0], + "ht:T11": [315.0], + "ht:F26": [78.0], + "ht:F2": [1200.0], + "ht:F22": [40.0], + "ht:F25": [80.0], + } + ) + quality = pd.DataFrame( + [ + { + "observation_id": "pak-sulfur-ui", + "signal_id": "ht:2:Mg.Sulfur", + "stage": "ht", + "source": "pak", + "measured_at": AS_OF.isoformat(), + "available_at": AS_OF.isoformat(), + "value": 8.7, + "unit": "mg/kg", + "validity": "valid", + "source_ref": "fixture-pak", + }, + { + "observation_id": "pak-density-ui", + "signal_id": "ht:density_15c", + "stage": "ht", + "source": "pak", + "measured_at": AS_OF.isoformat(), + "available_at": AS_OF.isoformat(), + "value": 833.0, + "unit": "kg/m3", + "validity": "valid", + "source_ref": "fixture-pak", + }, + ] + ) + data = PreparedData( + telemetry=telemetry, + quality=quality, + issues=pd.DataFrame(columns=["code", "severity", "signal_id", "detail", "source_ref"]), + manifest=manifest, + feature_order=tuple(column for column in telemetry.columns if column != "timestamp"), + ) + return write_prepared_dataset(data, tmp_path) + + +def _history_recommendation(*, actionable: bool = False) -> Recommendation: + def evaluation(candidate: CandidateAction, point: float, upper: float) -> CandidateEvaluation: + return CandidateEvaluation( + candidate=candidate, + assessments=( + AgentAssessment( + agent=AssessmentAgent.QUALITY, + state_id="history-ui-state", + candidate_id=candidate.id, + evaluated_for=AS_OF, + status=AssessmentStatus.OK, + metrics={ + "sulfur": MetricEstimate( + value=point, + lower=None, + upper=upper, + unit="mg/kg", + basis=EstimateBasis.FORECAST, + interval_kind=IntervalKind.EMPIRICAL, + interval_level=0.95, + reference="fixture", + ) + }, + ), + ), + checks=(), + feasible=True, + rank_key=(0.0, 0.0, 0.0, 0.0, candidate.id), + ) + + baseline = evaluation( + CandidateAction(id="hold", kind=CandidateKind.HOLD, horizon_minutes=60), + 9.8, + 11.0, + ) + selected = None + status = RecommendationStatus.ABSTAIN + reasons = ("ACTION_MODEL_UNAVAILABLE",) + if actionable: + selected = evaluation( + CandidateAction( + id="setpoints-p8", + kind=CandidateKind.SETPOINTS, + setpoints={"ht:P8": 0.145}, + horizon_minutes=60, + ), + 8.9, + 9.6, + ) + status = RecommendationStatus.RECOMMEND + reasons = () + return Recommendation( + run_id="history-ui-run", + state_id="history-ui-state", + as_of=AS_OF, + scenario_id="history", + mode=OperationMode.HISTORY, + status=status, + baseline=baseline, + selected=selected, + alternatives=(), + reason_codes=reasons, + explanation="fixture history replay", + assumptions=("fixture",), + model_id="forecast-fixture", + ) def _view(scenario_id: str, run_dir: Path): @@ -34,6 +194,8 @@ def _view(scenario_id: str, run_dir: Path): ( ("blend_normal", RecommendationStatus.HOLD), ("blend_risk", RecommendationStatus.RECOMMEND), + ("blend_t95_risk", RecommendationStatus.RECOMMEND), + ("blend_cetane_risk", RecommendationStatus.RECOMMEND), ("blend_missing", RecommendationStatus.ABSTAIN), ), ) @@ -48,17 +210,22 @@ def test_ui_projection_preserves_backend_status( assert view.status == expected_status.value -def test_ui_offers_synthetic_risk_recipe_and_shows_model_properties(tmp_path: Path) -> None: +def test_ui_exposes_complete_risk_recipe_and_quality_bounds(tmp_path: Path) -> None: _, view = _view("blend_risk", tmp_path) - assert view.baseline_upper == pytest.approx(14.2) - assert view.selected_upper == pytest.approx(9.4) - assert view.current_fractions == {"A": 0.7, "B": 0.3} - assert view.proposed_fractions == {"A": 0.9, "B": 0.1} - future = {row.name: row for row in view.constraints if row.name in {"T95", "Цетановое число"}} - assert set(future) == {"T95", "Цетановое число"} - assert all(row.status == "Оценено" for row in future.values()) - assert all(row.actual != "—" for row in future.values()) + assert view.baseline_upper == pytest.approx(14.058) + assert view.selected_upper == pytest.approx(9.306) + assert view.selected_t95_upper == pytest.approx(354.0) + assert view.selected_cetane_lower == pytest.approx(52.6) + assert view.current_fractions == pytest.approx({"A": 0.693, "B": 0.297}) + assert view.proposed_fractions == pytest.approx({"A": 0.891, "B": 0.099}) + assert view.current_additive_fraction == pytest.approx(0.01) + assert view.proposed_additive_fraction == pytest.approx(0.01) + quality = {row.name: row for row in view.constraints if row.name in {"T95", "Цетановое число"}} + assert all(row.status == "В пределах" for row in quality.values()) + additive = next(row for row in view.constraints if row.name == "Доля присадки") + assert additive.actual == "1 %" + assert additive.limit == "≤ 3 %" def test_ui_keeps_missing_sulfur_unavailable(tmp_path: Path) -> None: @@ -68,6 +235,123 @@ def test_ui_keeps_missing_sulfur_unavailable(tmp_path: Path) -> None: assert view.action_title == "Требуется ручная проверка" +def test_stage_snapshots_show_avt_and_hydrotreating_without_controls(tmp_path: Path) -> None: + dataset = _ui_dataset(tmp_path) + + avt = ui_stage_snapshot("avt", dataset, AS_OF) + hydro = ui_stage_snapshot("hydrotreating", dataset, AS_OF) + + assert avt.status == "ready" + avt_rows = {row.signal_id: row for row in avt.rows} + assert avt_rows["avt:F65"].freshness == "fresh" + assert "unit is unknown" in avt_rows["avt:F65"].read_only_reason + assert avt_rows["avt:F12"].freshness == "missing" + hydro_rows = {row.signal_id: row for row in hydro.rows} + assert hydro_rows["ht:2:Mg.Sulfur"].value == pytest.approx(8.7) + assert hydro_rows["ht:P8"].freshness == "fresh" + assert "verified action artifact" in hydro_rows["ht:P8"].read_only_reason + assert "context-only" in hydro_rows["ht:T11"].read_only_reason + + +def test_stage_snapshot_reports_missing_dataset_gracefully(tmp_path: Path) -> None: + snapshot = ui_stage_snapshot("avt", tmp_path / "missing", AS_OF) + + assert snapshot.status == "error" + assert "missing prepared dataset files" in snapshot.message + + +def test_history_snapshot_with_forecast_only_is_not_actionable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + ui_data_module, + "replay_command", + lambda *args, **kwargs: _history_recommendation(), + ) + + view = ui_history_snapshot("dataset", "model", AS_OF) + + assert view.status == "ready" + assert view.action_state == "not_actionable" + assert view.sulfur_upper == pytest.approx(11.0) + assert "ACTION_MODEL_UNAVAILABLE" in view.reason_codes + assert "Raw recommendation JSON" in format_history_replay_view(view) + + +def test_history_projection_marks_verified_setpoint_result_actionable() -> None: + view = history_replay_to_view(_history_recommendation(actionable=True)) + + assert view.action_state == "actionable" + assert view.selected_kind == "setpoints" + assert view.upper_status == "pass" + + +def test_hybrid_snapshot_uses_history_forecast_without_imputing_component_a( + monkeypatch: pytest.MonkeyPatch, +) -> None: + empty = ui_hybrid_snapshot(None, None, AS_OF) + assert empty.status == "empty" + assert "FORECAST_UNAVAILABLE" in empty.reason_codes + + monkeypatch.setattr( + ui_data_module, + "replay_command", + lambda *args, **kwargs: _history_recommendation(), + ) + + view = ui_hybrid_snapshot("dataset", "model", AS_OF) + + assert view.status == "ready" + assert view.component_sulfur_upper == pytest.approx(11.0) + assert view.blend_sulfur_upper == pytest.approx(17.0) + assert view.constraint_status == "fail" + assert "Hybrid sulfur-only blend" in format_hybrid_blend_view(view) + + +def test_action_shadow_view_separates_domain_validation_and_safety() -> None: + text = format_action_shadow_payload( + { + "control_id": "ht:P8", + "proposed_delta": 0.001, + "state": {"baseline_sulfur": 9.5}, + "predicted_sulfur": {"60": 9.4, "120": 9.3, "180": 9.2}, + "sulfur_change": {"60": -0.1, "120": -0.2, "180": -0.3}, + "sulfur_upper": {"60": 10.2, "120": 10.1, "180": 9.9}, + "within_observed_domain": True, + "model_validated": False, + "safety_passes": False, + "reason_codes": ("ACTION_EFFECT_VALIDATION_FAILED",), + } + ) + + assert "Изменение к hold" in text + assert "Историческая область: да" in text + assert "Validation модели: не пройдена" in text + assert "Верхняя граница серы: не проходит" in text + + +def test_v2_shadow_view_shows_horizons_and_never_advises() -> None: + text = format_v2_forecast_payload( + { + "as_of": "2025-06-01T12:00:00+00:00", + "production_status": "shadow_only", + "forecast": { + "horizon_probabilities": {"10": 0.1, "20": 0.2, "30": 0.3, "60": 0.4}, + "event_alarm": True, + "applicable": False, + "point_60m": 9.4, + "upper_60m": 10.4, + "reason_codes": ("OOD",), + }, + } + ) + + assert "Эпизодный прогноз серы" in text + assert "60 мин | 40.0%" in text + assert "не изменяет уставки" in text + assert "shadow_only" in text + + def test_journal_lists_only_complete_results_newest_first(tmp_path: Path) -> None: older = tmp_path / "older" / "result.json" newer = tmp_path / "newer" / "result.json" diff --git a/materials/README.md b/materials/README.md index 448f054..0fb11eb 100644 --- a/materials/README.md +++ b/materials/README.md @@ -2,11 +2,22 @@ Пакет включает техническое задание, схемы, справочник тегов, анализы качества и архив технологической телеметрии. + +[Разбор Q&A 11.09 и 15.09.2026](../QA_CLARIFICATIONS.md) дополняет материалы: содержит +таймкоды ответов, открытые вопросы и идентификаторы двух отдельно переданных +исходников. DOCX с вопросами не является письменными ответами экспертов. | Файл | Содержание | | --- | --- | | `ТЗ_нефтекод.docx` | Техническое задание: процесс АВТ → гидроочистка → блендинг, требования к мультиагентной системе, ограничения качества и правила демонстрации. В частности, для товарного дизельного топлива задан предел серы 10 мг/кг. | -| `АВТ_схемы.pdf` | Три технологические схемы АВТ с тегами, измеряемыми параметрами и отмеченными управляющими переменными. | +| `АВТ_схемы.pdf` | Три технологические схемы АВТ с тегами, измеряемыми параметрами и отмеченными управляющими переменными. | +| `schemes/АВТ_К-1_теги_организаторов_14.09.2026.pdf` | Отдельный лист К-1 с рукописными короткими именами телеметрических тегов. | +| `schemes/АВТ_К-2_теги_организаторов_14.09.2026.pdf` | Отдельный лист К-2 с циркуляционными орошениями, боковыми отпарными колоннами и тегами продуктовых потоков. | +| `schemes/АВТ_К-10_теги_организаторов_14.09.2026.pdf` | Отдельный лист вакуумной К-10 с тегами вакуума, орошений и тяжёлых продуктов. | +| `schemes/АВТ_К-10_теги_организаторов_15.09.2026_исправлено.pdf` | Исправленная схема К-10: `F31` расположен на линии после П-3; прежнюю позицию не использовать. | +| `теги АВТ_24-2000.xlsx` | Скорректированный словарь 24-2000 с подтверждёнными смыслами/единицами `P8/F19/T11/F26/Q21`. | +| `формулы_ВАК.xlsx` | Переданный организаторами набор формул ВАК; применяется с provenance и без автоматической валидации всего набора. | +| Внешний `Запись 15.09 Q&A Сессия Нефтекод.mp4` | Дополнительная Q&A-запись организаторов; исходник остаётся во внешней папке, SHA-256 и проверенные факты зафиксированы в `QA_CLARIFICATIONS.md`. | | `Выгрузка ПАК 01.01.2023 - н.в_.xlsx` | Выгрузка поточных анализаторов: один лист, 189 651 строка и 5 столбцов. Содержит, в частности, значения серы и плотности с временными метками. | | `ЛИМСы 01.01.2023 - н.в_ (2).xlsx` | Лабораторные анализы: один лист, 1 522 строки и 108 столбцов. Охватывает дизельные продукты АВТ и гидроочистки, включая температуры фракционирования, плотность, серу, цетановое число и низкотемпературные свойства. | | `Теги_хакатон.xlsx` | Справочник для работы с данными: листы `ЛА` (лабораторные показатели), `ВАК` (виртуальные анализаторы и формулы), `ПАК` (поточные анализаторы) и `КИП` (описания технологических тегов). | diff --git a/materials/schemes/README.md b/materials/schemes/README.md new file mode 100644 index 0000000..7e78f5a --- /dev/null +++ b/materials/schemes/README.md @@ -0,0 +1,17 @@ +# Дополнительные схемы АВТ от организаторов + +Файлы получены 14.09.2026 как три отдельных листа. Они сохранены без изменения: + +- `АВТ_К-1_теги_организаторов_14.09.2026.pdf` - К-1, ЭЛОУ и печь П-1/1; +- `АВТ_К-2_теги_организаторов_14.09.2026.pdf` - К-2 и продуктовые отборы; +- `АВТ_К-10_теги_организаторов_14.09.2026.pdf` - вакуумная К-10. +- `АВТ_К-10_теги_организаторов_15.09.2026_исправлено.pdf` - исправленная + версия К-10: положение `F31` на подаче после П-3; она имеет приоритет для + топологических выводов. + +В отличие от объединённого `../АВТ_схемы.pdf`, на этих копиях от руки нанесены +короткие имена телеметрических тегов. Подробный разбор, хеши и ограничения +интерпретации находятся в [SCHEME_ANALYSIS_2026_09_14.md](../../SCHEME_ANALYSIS_2026_09_14.md). + +Это схемы АВТ. Они не являются схемой гидроочистки 24-2000 и не служат +доказательством для `ht:*`. diff --git "a/materials/schemes/\320\220\320\222\320\242_\320\232-10_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" "b/materials/schemes/\320\220\320\222\320\242_\320\232-10_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" new file mode 100644 index 0000000..78aa89c Binary files /dev/null and "b/materials/schemes/\320\220\320\222\320\242_\320\232-10_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" differ diff --git "a/materials/schemes/\320\220\320\222\320\242_\320\232-10_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_15.09.2026_\320\270\321\201\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\276.pdf" "b/materials/schemes/\320\220\320\222\320\242_\320\232-10_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_15.09.2026_\320\270\321\201\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\276.pdf" new file mode 100644 index 0000000..fa32fe1 Binary files /dev/null and "b/materials/schemes/\320\220\320\222\320\242_\320\232-10_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_15.09.2026_\320\270\321\201\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\276.pdf" differ diff --git "a/materials/schemes/\320\220\320\222\320\242_\320\232-1_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" "b/materials/schemes/\320\220\320\222\320\242_\320\232-1_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" new file mode 100644 index 0000000..d8f4bca Binary files /dev/null and "b/materials/schemes/\320\220\320\222\320\242_\320\232-1_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" differ diff --git "a/materials/schemes/\320\220\320\222\320\242_\320\232-2_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" "b/materials/schemes/\320\220\320\222\320\242_\320\232-2_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" new file mode 100644 index 0000000..595bd50 Binary files /dev/null and "b/materials/schemes/\320\220\320\222\320\242_\320\232-2_\321\202\320\265\320\263\320\270_\320\276\321\200\320\263\320\260\320\275\320\270\320\267\320\260\321\202\320\276\321\200\320\276\320\262_14.09.2026.pdf" differ diff --git "a/materials/\321\202\320\265\320\263\320\270 \320\220\320\222\320\242_24-2000.xlsx" "b/materials/\321\202\320\265\320\263\320\270 \320\220\320\222\320\242_24-2000.xlsx" new file mode 100644 index 0000000..5a9df42 Binary files /dev/null and "b/materials/\321\202\320\265\320\263\320\270 \320\220\320\222\320\242_24-2000.xlsx" differ diff --git "a/materials/\321\204\320\276\321\200\320\274\321\203\320\273\321\213_\320\222\320\220\320\232.xlsx" "b/materials/\321\204\320\276\321\200\320\274\321\203\320\273\321\213_\320\222\320\220\320\232.xlsx" new file mode 100644 index 0000000..0512f8a Binary files /dev/null and "b/materials/\321\204\320\276\321\200\320\274\321\203\320\273\321\213_\320\222\320\220\320\232.xlsx" differ diff --git a/requirements.lock.txt b/requirements.lock.txt new file mode 100644 index 0000000..a7f23ce --- /dev/null +++ b/requirements.lock.txt @@ -0,0 +1,19 @@ +# Exact direct dependency versions verified for the Stage-6 release. +# CI validates this set on Python 3.11; the local final evaluation used Python 3.12.3. +pandas==2.3.3 +numpy==1.26.4 +pydantic==2.13.5 +openpyxl==3.1.5 +tzdata==2026.3 +scikit-learn==1.9.0 +joblib==1.6.0 +lightgbm==4.7.0 + +# Development and acceptance tools +mypy==1.20.2 +pandas-stubs==2.3.3.260113 +ruff==0.16.6 +pytest==8.4.2 +pre-commit==4.6.2 +types-pytz==2026.3.1.20260727 +types-openpyxl==3.1.5.20260827 diff --git a/requirements.txt b/requirements.txt index 36bcf02..0f062b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,9 @@ numpy>=1.26.0,<2 pydantic>=2.6.0,<3 openpyxl>=3.1.0,<4 tzdata>=2024.1.0 -scikit-learn>=1.5.0,<2 -joblib>=1.4.0,<2 +scikit-learn>=1.5.0,<2 +joblib>=1.4.0,<2 +lightgbm>=4.5.0,<5 # Development tools mypy>=1.13,<2 diff --git a/source/acceptance.py b/source/acceptance.py new file mode 100644 index 0000000..6365dda --- /dev/null +++ b/source/acceptance.py @@ -0,0 +1,451 @@ +"""Stage-6 reproducible demonstrations, model freeze checks and journal export.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import tempfile +import time +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping + +from source.config import load_runtime_config, load_scenario +from source.contracts import ( + CandidateEvaluation, + DecisionContext, + ProcessState, + Recommendation, +) +from source.orchestrator import run_cycle + +JOURNAL_FILES = ( + "metadata.json", + "input.json", + "features.json", + "trace.jsonl", + "candidates.jsonl", + "result.json", +) + + +def sha256_file(path: Path) -> str: + """Calculate a streaming SHA-256 digest without importing ML runtime deps.""" + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +@dataclass(frozen=True) +class EpisodeSpec: + """One checked-in acceptance episode and its stable expectations.""" + + id: str + scenario: str + expected_status: str + expected_reason_codes: tuple[str, ...] + expected_baseline_quality: dict[str, float | None] + expected_selected_quality: dict[str, float | None] | None + expected_recipe: dict[str, float] | None + expected_additive_fraction: float | None + + +def load_episode_specs(path: Path) -> tuple[EpisodeSpec, ...]: + """Read and strictly validate the small Stage-6 episode catalog.""" + payload = json.loads(Path(path).read_text(encoding="utf-8")) + if payload.get("schema_version") != "1.0": + raise ValueError("unsupported episode catalog schema_version") + raw_episodes = payload.get("episodes") + if not isinstance(raw_episodes, list) or not raw_episodes: + raise ValueError("episode catalog must contain a non-empty episodes list") + episodes: list[EpisodeSpec] = [] + for raw in raw_episodes: + if not isinstance(raw, Mapping): + raise ValueError("each episode must be an object") + required = { + "id", + "scenario", + "expected_status", + "expected_reason_codes", + "expected_baseline_quality", + "expected_selected_quality", + "expected_recipe", + "expected_additive_fraction", + } + missing = required.difference(raw) + if missing: + raise ValueError(f"episode is missing fields: {sorted(missing)}") + selected_quality = raw["expected_selected_quality"] + recipe = raw["expected_recipe"] + episodes.append( + EpisodeSpec( + id=str(raw["id"]), + scenario=str(raw["scenario"]), + expected_status=str(raw["expected_status"]), + expected_reason_codes=tuple(str(item) for item in raw["expected_reason_codes"]), + expected_baseline_quality=_quality_expectation(raw["expected_baseline_quality"]), + expected_selected_quality=( + None if selected_quality is None else _quality_expectation(selected_quality) + ), + expected_recipe=( + None + if recipe is None + else {str(key): float(value) for key, value in dict(recipe).items()} + ), + expected_additive_fraction=_optional_float(raw["expected_additive_fraction"]), + ) + ) + if len({episode.id for episode in episodes}) != len(episodes): + raise ValueError("episode ids must be unique") + return tuple(episodes) + + +def recommendation_fingerprint(result: Recommendation) -> str: + """Hash stable decision content while excluding the random run identifier.""" + payload = result.model_dump(mode="json") + payload.pop("run_id", None) + encoded = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def run_acceptance_suite( + root: Path, + *, + episodes_path: Path, + output_dir: Path, +) -> dict[str, Any]: + """Run checked-in episodes and export their complete journals.""" + root = Path(root) + output_dir = Path(output_dir) + if output_dir.exists(): + raise FileExistsError(f"acceptance output already exists: {output_dir}") + output_dir.parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix=f".{output_dir.name}.", dir=output_dir.parent)) + try: + run_root = temporary / "runs" + config = load_runtime_config(root / "config/runtime.toml") + summaries: list[dict[str, Any]] = [] + journal_sources: list[tuple[str, Path]] = [] + started = time.perf_counter() + for episode in load_episode_specs(episodes_path): + scenario = load_scenario(root / f"config/scenarios/{episode.scenario}.json") + fixture = json.loads( + (root / f"global_tests/fixtures/model_demo/{episode.scenario}.json").read_text( + encoding="utf-8" + ) + ) + state = ProcessState.model_validate(fixture["state"]) + cycle_started = time.perf_counter() + result = run_cycle( + data=None, + as_of=state.as_of, + model=None, + scenario=scenario, + config=config, + context=DecisionContext(), + run_dir=run_root, + ) + cycle_seconds = time.perf_counter() - cycle_started + journal_dir = run_root / result.run_id + _check_episode(episode, result, scenario) + recipe, additive_fraction = _effective_selected_recipe(result, scenario) + summaries.append( + { + "episode_id": episode.id, + "scenario": episode.scenario, + "status": result.status.value, + "reason_codes": list(result.reason_codes), + "baseline_quality": _quality_snapshot(result.baseline), + "selected_quality": _quality_snapshot(result.selected), + "selected_recipe": recipe, + "selected_additive_fraction": additive_fraction, + "operator_recommendation": result.status.value in {"hold", "recommend"}, + "decision_fingerprint": recommendation_fingerprint(result), + "cycle_seconds": cycle_seconds, + "run_id": result.run_id, + } + ) + journal_sources.append((episode.id, journal_dir)) + archive = export_journals(journal_sources, temporary / "journals.zip") + report = { + "schema_version": "1.0", + "episodes_file": Path(episodes_path).relative_to(root).as_posix(), + "episode_count": len(summaries), + "elapsed_seconds": time.perf_counter() - started, + "max_cycle_seconds": max(item["cycle_seconds"] for item in summaries), + "episodes": summaries, + "journal_archive": archive.name, + "evidence_boundary": ( + "Historical metrics assess forecast accuracy; model-demo recommendations prove " + "only the declared synthetic sulfur/T95/cetane and additive model." + ), + } + _write_json(temporary / "summary.json", report) + temporary.replace(output_dir) + except Exception: + shutil.rmtree(temporary, ignore_errors=True) + raise + return report + + +def export_journals( + journals: Iterable[tuple[str, Path]], + destination: Path, +) -> Path: + """Export complete journals with checksums and stable archive metadata.""" + destination = Path(destination) + if destination.exists(): + raise FileExistsError(f"journal archive already exists: {destination}") + sources = tuple(journals) + if not sources: + raise ValueError("at least one journal is required") + if len({name for name, _ in sources}) != len(sources): + raise ValueError("journal export names must be unique") + destination.parent.mkdir(parents=True, exist_ok=True) + manifest: dict[str, Any] = {"schema_version": "1.0", "journals": []} + file_descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{destination.name}.", dir=destination.parent + ) + os.close(file_descriptor) + temporary = Path(temporary_name) + try: + with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, directory in sorted(sources): + files = [] + for filename in JOURNAL_FILES: + path = Path(directory) / filename + if not path.is_file(): + raise FileNotFoundError( + f"incomplete journal {directory}: missing {filename}" + ) + archive_name = f"{name}/{filename}" + _write_zip_bytes(archive, archive_name, path.read_bytes()) + files.append({"path": archive_name, "sha256": sha256_file(path)}) + manifest["journals"].append({"name": name, "files": files}) + _write_zip_bytes( + archive, + "manifest.json", + (json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode( + "utf-8" + ), + ) + temporary.replace(destination) + except Exception: + temporary.unlink(missing_ok=True) + raise + return destination + + +def verify_model_freeze(root: Path, manifest_path: Path) -> dict[str, Any]: + """Verify every frozen local artifact and evaluation report by SHA-256.""" + root = Path(root) + payload = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + if payload.get("schema_version") != "1.0": + raise ValueError("unsupported model freeze schema_version") + artifacts = payload.get("artifacts") + if not isinstance(artifacts, list) or not artifacts: + raise ValueError("model freeze must contain artifacts") + verified_ids: list[str] = [] + for frozen in artifacts: + if not isinstance(frozen, Mapping): + raise ValueError("frozen artifact must be an object") + directory = root / str(frozen["path"]) + metadata_path = directory / "metadata.json" + metrics_path = directory / "metrics.json" + model_path = directory / "model.joblib" + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + metrics = json.loads(metrics_path.read_text(encoding="utf-8")) + _require_hash(model_path, str(frozen["model_sha256"])) + _require_hash(metadata_path, str(frozen["metadata_sha256"])) + _require_hash(metrics_path, str(frozen["metrics_sha256"])) + if metadata.get("model_id") != frozen.get("model_id"): + raise ValueError("frozen model_id does not match metadata") + if metadata.get("model_sha256") != frozen.get("model_sha256"): + raise ValueError("frozen model hash does not match metadata") + if metadata.get("training_dataset_id") != payload.get("training_dataset_id"): + raise ValueError("frozen model uses another training dataset") + if metadata.get("git_commit") != payload.get("training_git_commit"): + raise ValueError("frozen model uses another training code revision") + if metadata.get("python_version") != payload.get("python_version"): + raise ValueError("frozen model uses another Python version") + if metadata.get("sklearn_version") != payload.get("sklearn_version"): + raise ValueError("frozen model uses another scikit-learn version") + if metadata.get("artifact_kind") == "action_effect": + from source.ml.controls import validate_action_artifact_metadata + + validate_action_artifact_metadata( + metadata, + expected_dataset_id=str(payload.get("training_dataset_id")), + expected_config_sha256=payload.get("config_sha256"), + expected_tag_dictionary_sha256=payload.get("tag_dictionary_sha256"), + expected_telemetry_rules_sha256=payload.get("telemetry_rules_sha256"), + ) + elif metadata.get("capabilities", {}).get("supports_actions") is not False: + raise ValueError("Stage-6 forecast artifacts must not enable action control") + if str(metadata.get("git_commit", "")).endswith("-dirty"): + raise ValueError("frozen model was trained from a dirty worktree") + if ( + "test_used_for_selection" in metrics + and metrics["test_used_for_selection"] is not False + ): + raise ValueError("point model used final test for selection") + if "test_used_for_tuning" in metrics and metrics["test_used_for_tuning"] is not False: + raise ValueError("upper model used final test for tuning") + verified_ids.append(str(frozen["model_id"])) + for report in payload.get("evaluation_reports", []): + _require_hash(root / str(report["path"]), str(report["sha256"])) + return { + "training_dataset_id": payload.get("training_dataset_id"), + "verified_models": verified_ids, + "evaluation_reports": len(payload.get("evaluation_reports", [])), + } + + +def _check_episode( + episode: EpisodeSpec, + result: Recommendation, + scenario: Any, +) -> None: + if result.status.value != episode.expected_status: + raise ValueError( + f"episode {episode.id} status changed: {result.status.value} != " + f"{episode.expected_status}" + ) + missing_reasons = set(episode.expected_reason_codes).difference(result.reason_codes) + if missing_reasons: + raise ValueError(f"episode {episode.id} lost reasons: {sorted(missing_reasons)}") + _check_quality( + _quality_snapshot(result.baseline), + episode.expected_baseline_quality, + f"episode {episode.id} baseline", + ) + actual_selected = _quality_snapshot(result.selected) + if episode.expected_selected_quality is None: + if actual_selected is not None: + raise ValueError(f"episode {episode.id} unexpectedly selected a result") + else: + _check_quality( + actual_selected, + episode.expected_selected_quality, + f"episode {episode.id} selected", + ) + recipe, additive_fraction = _effective_selected_recipe(result, scenario) + if recipe != episode.expected_recipe: + raise ValueError(f"episode {episode.id} selected recipe changed") + _check_optional_number( + additive_fraction, + episode.expected_additive_fraction, + f"episode {episode.id} additive fraction", + ) + + +def _quality_snapshot(evaluation: CandidateEvaluation | None) -> dict[str, float | None] | None: + if evaluation is None: + return None + estimates = {} + for assessment in evaluation.assessments: + estimates.update(assessment.metrics) + return { + "sulfur_upper": _estimate_value(estimates.get("sulfur"), "upper"), + "t95_upper": _estimate_value(estimates.get("t95"), "upper"), + "cetane_lower": _estimate_value(estimates.get("cetane_number"), "lower"), + } + + +def _estimate_value(estimate: Any, field: str) -> float | None: + value = None if estimate is None else getattr(estimate, field) + return None if value is None else float(value) + + +def _effective_selected_recipe( + result: Recommendation, scenario: Any +) -> tuple[dict[str, float] | None, float | None]: + if result.selected is None: + return None, None + candidate = result.selected.candidate + if candidate.blend_mass_fractions: + return dict(candidate.blend_mass_fractions), candidate.additive_mass_fraction + return ( + dict(scenario.current_blend_mass_fractions), + scenario.current_additive_mass_fraction, + ) + + +def _quality_expectation(value: object) -> dict[str, float | None]: + if not isinstance(value, Mapping): + raise ValueError("quality expectation must be an object") + expected_keys = {"sulfur_upper", "t95_upper", "cetane_lower"} + if set(value) != expected_keys: + raise ValueError(f"quality expectation needs keys: {sorted(expected_keys)}") + return {str(key): _optional_float(item) for key, item in value.items()} + + +def _check_quality( + actual: dict[str, float | None] | None, + expected: dict[str, float | None], + label: str, +) -> None: + if actual is None: + raise ValueError(f"{label} is unavailable") + for key, expected_value in expected.items(): + _check_optional_number(actual[key], expected_value, f"{label} {key}") + + +def _optional_float(value: object) -> float | None: + if value is None: + return None + if not isinstance(value, (int, float)): + raise ValueError("expected a number or null") + return float(value) + + +def _check_optional_number(actual: float | None, expected: float | None, label: str) -> None: + if actual is None or expected is None: + if actual is not expected: + raise ValueError(f"{label} availability changed") + return + if abs(actual - expected) > 1e-9: + raise ValueError(f"{label} changed: {actual} != {expected}") + + +def _require_hash(path: Path, expected: str) -> None: + actual = sha256_file(path) + if actual != expected: + raise ValueError(f"checksum mismatch for {path}: {actual} != {expected}") + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.write_text( + json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False) + "\n", + encoding="utf-8", + newline="\n", + ) + + +def _write_zip_bytes(archive: zipfile.ZipFile, name: str, content: bytes) -> None: + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + archive.writestr(info, content) + + +__all__ = [ + "EpisodeSpec", + "export_journals", + "load_episode_specs", + "recommendation_fingerprint", + "run_acceptance_suite", + "sha256_file", + "verify_model_freeze", +] diff --git a/source/agents/effects.py b/source/agents/effects.py index 7d0f043..d124faa 100644 --- a/source/agents/effects.py +++ b/source/agents/effects.py @@ -11,12 +11,14 @@ AssessmentStatus, CandidateAction, CandidateKind, + CetaneAdditiveSpec, EstimateBasis, IntervalKind, Issue, MetricEstimate, OperationMode, ProcessState, + ProductGrade, ScenarioConfig, Severity, Unit, @@ -25,32 +27,55 @@ FRACTION_TOLERANCE = 1e-9 -def _recipe(candidate: CandidateAction, scenario: ScenarioConfig) -> dict[str, float]: +def _recipe(candidate: CandidateAction, scenario: ScenarioConfig) -> tuple[dict[str, float], float]: component_ids = {component.id for component in scenario.blend_components} if len(component_ids) != 2: raise ValueError("stage-1 blending requires exactly two components") current = scenario.current_blend_mass_fractions - if set(current) != component_ids or abs(sum(current.values()) - 1.0) > FRACTION_TOLERANCE: - raise ValueError("current blend must contain every component and sum to one") + if set(current) != component_ids: + raise ValueError("current blend must contain every component") if candidate.kind is CandidateKind.HOLD: recipe = dict(current) + additive_fraction = scenario.current_additive_mass_fraction elif candidate.kind is CandidateKind.BLEND: recipe = dict(candidate.blend_mass_fractions) + additive_fraction = candidate.additive_mass_fraction else: raise ValueError("stage-1 effects cannot evaluate setpoint actions") if set(recipe) != component_ids: raise ValueError("blend recipe must contain every scenario component exactly once") - if abs(sum(recipe.values()) - 1.0) > FRACTION_TOLERANCE: - raise ValueError("blend mass fractions must sum to one") - return recipe + if abs(sum(recipe.values()) + additive_fraction - 1.0) > FRACTION_TOLERANCE: + raise ValueError("blend and additive mass fractions must sum to one") + if additive_fraction and scenario.cetane_additive is None: + raise ValueError("additive dose needs a configured cetane additive model") + return recipe, additive_fraction -def _weighted(recipe: Mapping[str, float], values: Mapping[str, float | None]) -> float | None: +def _weighted( + recipe: Mapping[str, float], + values: Mapping[str, float | None], + *, + normalize: bool = False, +) -> float | None: if any(values[component_id] is None for component_id, weight in recipe.items() if weight > 0): return None - return sum(weight * (values[component_id] or 0.0) for component_id, weight in recipe.items()) + total = sum(weight * (values[component_id] or 0.0) for component_id, weight in recipe.items()) + return total / sum(recipe.values()) if normalize else total + + +def _additive_gain(spec: CetaneAdditiveSpec | None, dose: float) -> float: + """Interpolate the explicit scenario response curve without extrapolation.""" + if dose == 0: + return 0.0 + if spec is None or dose > spec.max_mass_fraction + FRACTION_TOLERANCE: + raise ValueError("additive dose is outside the configured scenario model") + for left, right in zip(spec.response_curve, spec.response_curve[1:], strict=False): + if left.mass_fraction <= dose <= right.mass_fraction: + share = (dose - left.mass_fraction) / (right.mass_fraction - left.mass_fraction) + return left.cetane_gain + share * (right.cetane_gain - left.cetane_gain) + raise ValueError("additive response curve does not cover the requested dose") def _weighted_metric( @@ -92,17 +117,14 @@ def _weighted_metric( def calculate_blend_metrics( candidate: CandidateAction, scenario: ScenarioConfig ) -> dict[str, MetricEstimate]: - """Calculate product properties and transparent ranking proxies for one recipe.""" + """Calculate the complete synthetic product passport and ranking proxies.""" if scenario.mode is not OperationMode.MODEL_DEMO: raise ValueError("stage-1 blending effects are only valid in model_demo mode") if scenario.total_mass_t is None: raise ValueError("blend scenario needs total_mass_t") - recipe = _recipe(candidate, scenario) + recipe, additive_fraction = _recipe(candidate, scenario) components = {component.id: component for component in scenario.blend_components} - sulfur_units = {component.sulfur.unit for component in components.values()} - if sulfur_units != {Unit.MG_KG.value}: - raise ValueError("all sulfur components must use mg/kg") sulfur_value = _weighted( recipe, {key: component.sulfur.value for key, component in components.items()} @@ -110,26 +132,88 @@ def calculate_blend_metrics( sulfur_upper = _weighted( recipe, {key: component.sulfur.upper for key, component in components.items()} ) - t95_value, t95_upper, t95_unit, t95_basis, t95_interval, t95_ref = _weighted_metric( - recipe, {key: component.t95 for key, component in components.items()}, "t95" + t95_value = _weighted( + recipe, + { + key: None if component.t95 is None else component.t95.value + for key, component in components.items() + }, + normalize=True, ) - cetane_value, cetane_upper, cetane_unit, cetane_basis, cetane_interval, cetane_ref = ( - _weighted_metric( - recipe, - {key: component.cetane_number for key, component in components.items()}, - "cetane_number", - ) + t95_upper = _weighted( + recipe, + { + key: None if component.t95 is None else component.t95.upper + for key, component in components.items() + }, + normalize=True, + ) + cetane_value = _weighted( + recipe, + { + key: None if component.cetane_number is None else component.cetane_number.value + for key, component in components.items() + }, + normalize=True, + ) + cetane_lower = _weighted( + recipe, + { + key: None if component.cetane_number is None else component.cetane_number.lower + for key, component in components.items() + }, + normalize=True, + ) + + def density_bound(field: str) -> float | None: + if additive_fraction: + return None + total_volume = 0.0 + for key, weight in recipe.items(): + if weight <= 0: + continue + estimate = components[key].density + if estimate is None: + return None + value = getattr(estimate, field) + if value is None or value <= 0: + return None + total_volume += weight / value + return 1.0 / total_volume if total_volume > 0 else None + + density_value = density_bound("value") + density_lower = density_bound("upper") + density_upper = density_bound("lower") + cetane_gain = _additive_gain(scenario.cetane_additive, additive_fraction) + if cetane_value is not None: + cetane_value += cetane_gain + if cetane_lower is not None: + cetane_lower += cetane_gain + diesel_fraction = sum(recipe.values()) + risk_index = ( + sum(recipe[key] * component.risk_index for key, component in components.items()) + / diesel_fraction ) - risk_index = sum(recipe[key] * component.risk_index for key, component in components.items()) cost_proxy = sum( recipe[key] * component.cost_proxy_per_t for key, component in components.items() ) + if scenario.cetane_additive is not None: + cost_proxy += additive_fraction * scenario.cetane_additive.cost_proxy_per_t change_size = sum( abs(recipe[key] - scenario.current_blend_mass_fractions.get(key, 0.0)) for key in recipe - ) - assumptions = tuple(scenario.assumptions) + ( - "Sulfur, T95 and cetane number are mixed by mass fraction for model-demo only.", - "Risk and cost are scenario proxies, not plant safety or currency estimates.", + ) + abs(additive_fraction - scenario.current_additive_mass_fraction) + assumptions = ( + tuple(scenario.assumptions) + + ( + "Sulfur and its scenario upper bound are mixed by mass fraction.", + "T95 and base cetane number are linear scenario approximations over diesel components.", + ( + "The additive has zero modeled sulfur/T95 effect; " + "cetane gain follows the configured curve." + ), + "Risk and cost are scenario proxies, not plant safety or currency estimates.", + ) + + (() if scenario.cetane_additive is None else scenario.cetane_additive.assumptions) ) def estimate( @@ -138,12 +222,13 @@ def estimate( basis: EstimateBasis, reference: str, *, + lower: float | None = None, upper: float | None = None, interval_kind: IntervalKind = IntervalKind.NONE, ) -> MetricEstimate: return MetricEstimate( value=value, - lower=None, + lower=lower, upper=upper, unit=unit, basis=basis, @@ -154,6 +239,7 @@ def estimate( ) sulfur_interval = IntervalKind.SCENARIO_BOUND if sulfur_upper is not None else IntervalKind.NONE + operating_target = scenario.operating_sulfur_target return { "sulfur": estimate( sulfur_value, @@ -165,19 +251,48 @@ def estimate( ), "t95": estimate( t95_value, - t95_unit, - t95_basis, - t95_ref, + Unit.CELSIUS.value, + EstimateBasis.FORMULA, + "scenario linear T95 blend assumption", upper=t95_upper, - interval_kind=t95_interval, + interval_kind=( + IntervalKind.SCENARIO_BOUND if t95_upper is not None else IntervalKind.NONE + ), ), "cetane_number": estimate( cetane_value, - cetane_unit, - cetane_basis, - cetane_ref, - upper=cetane_upper, - interval_kind=cetane_interval, + Unit.CETANE.value, + EstimateBasis.FORMULA, + "scenario linear cetane blend and additive response curve", + lower=cetane_lower, + interval_kind=( + IntervalKind.SCENARIO_BOUND if cetane_lower is not None else IntervalKind.NONE + ), + ), + "sulfur_operating_target": estimate( + operating_target, + Unit.MG_KG.value, + EstimateBasis.FORMULA, + "ScenarioConfig.sulfur_margin_mgkg", + ), + "density": estimate( + density_value, + Unit.DENSITY.value, + EstimateBasis.FORMULA, + "mass/volume density blend", + lower=density_lower, + upper=density_upper, + interval_kind=( + IntervalKind.SCENARIO_BOUND + if density_lower is not None or density_upper is not None + else IntervalKind.NONE + ), + ), + "additive_mass_fraction": estimate( + additive_fraction, + Unit.DIMENSIONLESS.value, + EstimateBasis.FORMULA, + "config scenario cetane_additive", ), "risk_index": estimate( risk_index, @@ -216,40 +331,42 @@ def assess_blend_candidate( sulfur = metrics["sulfur"] t95 = metrics["t95"] cetane = metrics["cetane_number"] - quality_status = AssessmentStatus.OK quality_issues: tuple[Issue, ...] = () - if t95.value is None: + quality_status = AssessmentStatus.OK + if sulfur.value is None: quality_issues += ( Issue( - code="UNASSESSED_REQUIRED_PROPERTY", + code="MISSING_REQUIRED_SIGNAL", severity=Severity.BLOCKING, - signal_id="blend:t95", - detail="T95 is required for a blend decision but has no model-demo value.", + signal_id="blend:sulfur", + detail="Sulfur is missing for a component with a positive mass fraction.", source_ref=f"scenario:{scenario.id}", ), ) - if cetane.value is None: + if t95.value is None or t95.upper is None: quality_issues += ( Issue( code="UNASSESSED_REQUIRED_PROPERTY", severity=Severity.BLOCKING, - signal_id="blend:cetane_number", - detail=( - "Cetane number is required for a blend decision but has no model-demo value." - ), + signal_id="blend:t95", + detail="T95 or its required upper scenario bound is unavailable.", source_ref=f"scenario:{scenario.id}", ), ) - if sulfur.value is None: + if scenario.product_grade is not ProductGrade.HDS_DIESEL and ( + cetane.value is None or cetane.lower is None + ): quality_issues += ( Issue( - code="MISSING_REQUIRED_SIGNAL", + code="UNASSESSED_REQUIRED_PROPERTY", severity=Severity.BLOCKING, - signal_id="blend:sulfur", - detail="Sulfur is missing for a component with a positive mass fraction.", + signal_id="blend:cetane_number", + detail="Cetane number or its required lower scenario bound is unavailable.", source_ref=f"scenario:{scenario.id}", ), ) + if any(issue.severity is Severity.BLOCKING for issue in quality_issues): + quality_status = AssessmentStatus.UNAVAILABLE elif scenario.require_upper_bound and sulfur.upper is None: quality_issues += ( Issue( @@ -286,14 +403,14 @@ def assessment( assessment( AssessmentAgent.QUALITY, quality_status, - ("sulfur", "t95", "cetane_number"), + ("sulfur", "sulfur_operating_target", "t95", "cetane_number", "density"), quality_issues, ), assessment(AssessmentAgent.RELIABILITY, AssessmentStatus.OK, ("risk_index",)), assessment( AssessmentAgent.OPTIMIZER, AssessmentStatus.OK, - ("throughput", "cost_proxy", "change_size"), + ("throughput", "cost_proxy", "change_size", "additive_mass_fraction"), ), ) diff --git a/source/agents/optimizer.py b/source/agents/optimizer.py index 4d9c56e..60d75ac 100644 --- a/source/agents/optimizer.py +++ b/source/agents/optimizer.py @@ -33,7 +33,7 @@ def generate_candidates( scenario: ScenarioConfig, config: RuntimeConfig | None = None, ) -> tuple[CandidateAction, ...]: - """Generate the deterministic stage-1 grid and an explicit hold candidate.""" + """Generate a deterministic recipe/additive grid and an explicit hold candidate.""" if state.mode is not scenario.mode: raise ValueError("state and scenario modes must match") horizon_minutes = config.horizon_minutes if config is not None else 60 @@ -51,16 +51,30 @@ def generate_candidates( component_a, component_b = (component.id for component in scenario.blend_components) current = scenario.current_blend_mass_fractions - if set(current) != {component_a, component_b} or abs(sum(current.values()) - 1.0) > 1e-9: - raise ValueError("current blend must contain both components and sum to one") - - recipes: list[dict[str, float]] = [] - for step in range(21): - fraction_b = step / 20 - recipe = {component_a: 1.0 - fraction_b, component_b: fraction_b} - if all(abs(recipe[key] - current[key]) <= 1e-9 for key in recipe): - continue - recipes.append(recipe) + if set(current) != {component_a, component_b}: + raise ValueError("current blend must contain both components") + + additive = scenario.cetane_additive + dose_steps = ( + range(round(additive.max_mass_fraction / additive.fraction_step) + 1) + if additive is not None + else range(1) + ) + recipes: list[tuple[dict[str, float], float]] = [] + for dose_step in dose_steps: + dose = 0.0 if additive is None else dose_step * additive.fraction_step + diesel_fraction = 1.0 - dose + for step in range(21): + share_b = step / 20 + recipe = { + component_a: diesel_fraction * (1.0 - share_b), + component_b: diesel_fraction * share_b, + } + if abs(dose - scenario.current_additive_mass_fraction) <= 1e-9 and all( + abs(recipe[key] - current[key]) <= 1e-9 for key in recipe + ): + continue + recipes.append((recipe, dose)) candidate_count = 1 + len(recipes) if candidate_count > max_candidates: @@ -70,13 +84,16 @@ def generate_candidates( ) candidates = [hold] - for recipe in recipes: - fraction_b = recipe[component_b] + for recipe, dose in recipes: candidates.append( CandidateAction( - id=f"blend:{component_a}={recipe[component_a]:.2f},{component_b}={fraction_b:.2f}", + id=( + f"blend:{component_a}={recipe[component_a]:.3f}," + f"{component_b}={recipe[component_b]:.3f},additive={dose:.3f}" + ), kind=CandidateKind.BLEND, blend_mass_fractions=recipe, + additive_mass_fraction=dose, horizon_minutes=horizon_minutes, is_model_scenario=scenario.mode is OperationMode.MODEL_DEMO, ) diff --git a/source/agents/quality.py b/source/agents/quality.py index f694346..19ee047 100644 --- a/source/agents/quality.py +++ b/source/agents/quality.py @@ -179,6 +179,8 @@ def _forecast_history_quality( ) prediction = float(predict(features)[0]) upper: float | None = None + exceedance_probability: float | None = None + alarm = False interval_kind = IntervalKind.NONE interval_level: float | None = None if getattr(capabilities, "supports_uncertainty", False): @@ -193,46 +195,82 @@ def _forecast_history_quality( upper = float(predict_upper(features)[0]) interval_kind = IntervalKind.EMPIRICAL interval_level = 0.95 - issues: tuple[Issue, ...] = () + issues_list: list[Issue] = [] status = AssessmentStatus.OK if scenario.require_upper_bound and upper is None: status = AssessmentStatus.DEGRADED - issues = ( + issues_list.append( Issue( code="UNCERTAINTY_UNAVAILABLE", severity=Severity.BLOCKING, signal_id=target_signal, detail="Stage-2 point forecasts have no validated upper interval.", source_ref=f"model:{metadata.model_id}", + ) + ) + if getattr(capabilities, "supports_exceedance_probability", False): + predict_probability = getattr(model, "predict_exceedance_probability", None) + predict_alarm = getattr(model, "predict_alarm", None) + if not callable(predict_probability) or not callable(predict_alarm): + return _unavailable( + state, + "RISK_MODEL_UNAVAILABLE", + target_signal, + "The artifact declares exceedance risk but exposes no compatible predictor.", + ) + exceedance_probability = float(predict_probability(features)[0]) + alarm = bool(predict_alarm(features)[0]) + if alarm: + status = AssessmentStatus.DEGRADED + issues_list.append( + Issue( + code="SULFUR_EXCEEDANCE_RISK", + severity=Severity.BLOCKING, + signal_id=target_signal, + detail="The calibrated safety head predicts material risk of S > 10 mg/kg.", + source_ref=f"model:{metadata.model_id}", + ) + ) + metrics = { + "sulfur": MetricEstimate( + value=prediction, + lower=None, + upper=upper, + unit=target_unit, + basis=EstimateBasis.FORECAST, + interval_kind=interval_kind, + interval_level=interval_level, + reference=f"model:{metadata.model_id}", + assumptions=( + f"{horizon_minutes}-minute point forecast", + f"training target source: {metadata.target_source}", + "supports_forecast does not imply supports_actions", + "0.95 empirical coverage is not a safety guarantee" + if upper is not None + else "upper estimate unavailable", ), ) + } + if exceedance_probability is not None: + metrics["sulfur_exceedance_probability"] = MetricEstimate( + value=exceedance_probability, + lower=None, + upper=None, + unit="index_0_1", + basis=EstimateBasis.FORECAST, + interval_kind=IntervalKind.NONE, + interval_level=None, + reference=f"model:{metadata.model_id}", + assumptions=("validation-calibrated probability", "alarm threshold stored in artifact"), + ) return AgentAssessment( agent=AssessmentAgent.QUALITY, state_id=state.state_id, candidate_id="hold", evaluated_for=state.as_of + timedelta(minutes=horizon_minutes), status=status, - metrics={ - "sulfur": MetricEstimate( - value=prediction, - lower=None, - upper=upper, - unit=target_unit, - basis=EstimateBasis.FORECAST, - interval_kind=interval_kind, - interval_level=interval_level, - reference=f"model:{metadata.model_id}", - assumptions=( - f"{horizon_minutes}-minute point forecast", - f"training target source: {metadata.target_source}", - "supports_forecast does not imply supports_actions", - "0.95 empirical coverage is not a safety guarantee" - if upper is not None - else "upper estimate unavailable", - ), - ) - }, - issues=issues, + metrics=metrics, + issues=tuple(issues_list), ) diff --git a/source/config.py b/source/config.py index ee3b672..326751e 100644 --- a/source/config.py +++ b/source/config.py @@ -66,6 +66,31 @@ def load_tag_dictionary(path: str | Path) -> dict[str, TagMeta]: return result +def load_telemetry_rules(path: str | Path) -> dict[str, dict[str, object]]: + """Load explicit telemetry cleaning rules without changing raw source files. + + Rules are keyed by canonical signal id. The deliberately small schema keeps + sentinel handling auditable; at present only exact-value ``missing`` rules + are accepted. + """ + payload = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(payload, dict) or not isinstance(payload.get("rules"), list): + raise ValueError("telemetry rules must contain a rules list") + result: dict[str, dict[str, object]] = {} + for item in payload["rules"]: + if not isinstance(item, dict): + raise ValueError("telemetry rule must be an object") + signal_id = str(item.get("signal_id", "")).strip() + action = str(item.get("action", "")).strip() + values = item.get("exact_values") + if not signal_id or action != "missing" or not isinstance(values, list): + raise ValueError("telemetry rule needs signal_id, action=missing and exact_values") + if signal_id in result: + raise ValueError(f"duplicate telemetry rule: {signal_id}") + result[signal_id] = dict(item) + return result + + def config_fingerprint(config: RuntimeConfig) -> str: """Stable JSON used by the preparation manifest hash.""" return json.dumps( @@ -84,4 +109,5 @@ def config_fingerprint(config: RuntimeConfig) -> str: "load_runtime_config", "load_scenario", "load_tag_dictionary", + "load_telemetry_rules", ] diff --git a/source/constraints.py b/source/constraints.py index 6c2e504..866b371 100644 --- a/source/constraints.py +++ b/source/constraints.py @@ -54,7 +54,12 @@ def _quality_check( evidence_ref=constraint.evidence_ref, reason_code="UNIT_MISMATCH", ) - actual = metric.upper if constraint.use_upper_estimate else metric.value + if constraint.use_upper_estimate: + actual = metric.upper + elif constraint.use_lower_estimate: + actual = metric.lower + else: + actual = metric.value status = ConstraintStatus.PASS reason = "OK" if actual is None: @@ -83,11 +88,24 @@ def _quality_check( def _stock_checks( scenario: ScenarioConfig, candidate: CandidateAction ) -> tuple[ConstraintResult, ...]: - if candidate.kind is not CandidateKind.BLEND or scenario.total_mass_t is None: + if ( + candidate.kind not in {CandidateKind.HOLD, CandidateKind.BLEND} + or scenario.total_mass_t is None + ): return () by_id = {item.id: item for item in scenario.blend_components} + fractions = ( + scenario.current_blend_mass_fractions + if candidate.kind is CandidateKind.HOLD + else candidate.blend_mass_fractions + ) + additive_fraction = ( + scenario.current_additive_mass_fraction + if candidate.kind is CandidateKind.HOLD + else candidate.additive_mass_fraction + ) checks: list[ConstraintResult] = [] - for component_id, fraction in candidate.blend_mass_fractions.items(): + for component_id, fraction in fractions.items(): component = by_id[component_id] requested = fraction * scenario.total_mass_t status = ( @@ -109,6 +127,33 @@ def _stock_checks( reason_code="OK" if status is ConstraintStatus.PASS else "COMPONENT_STOCK", ) ) + additive = scenario.cetane_additive + if additive is not None: + additive_mass = additive_fraction * scenario.total_mass_t + for constraint_id, actual, upper, reason_code in ( + ( + "additive_fraction", + additive_fraction, + additive.max_mass_fraction, + "ADDITIVE_LIMIT", + ), + ("additive_stock", additive_mass, additive.available_mass_t, "ADDITIVE_STOCK"), + ): + status = ConstraintStatus.PASS if actual <= upper + 1e-9 else ConstraintStatus.FAIL + checks.append( + ConstraintResult( + constraint_id=constraint_id, + candidate_id=candidate.id, + status=status, + actual=actual, + lower=None, + upper=upper, + unit="1" if constraint_id == "additive_fraction" else "t", + basis="model_assumption", + evidence_ref=additive.evidence_ref, + reason_code="OK" if status is ConstraintStatus.PASS else reason_code, + ) + ) return tuple(checks) diff --git a/source/contracts.py b/source/contracts.py index 9090526..29dc214 100644 --- a/source/contracts.py +++ b/source/contracts.py @@ -1,3 +1,629 @@ +<<<<<<< HEAD +"""Strict version-1 contracts shared by backend and ML code. + +These models implement the DTOs from DESIGN.md sections 5 and 6. They reject +unknown fields, naive timestamps and non-finite numbers so invalid data cannot +quietly cross a layer boundary. +""" + +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path +from typing import Annotated, Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator + +SCHEMA_VERSION: Literal["1.0"] = "1.0" +RECOMMENDATION_SCHEMA_VERSION: Literal["1.1"] = "1.1" +FiniteFloat = Annotated[float, Field(allow_inf_nan=False)] +NonNegativeFloat = Annotated[FiniteFloat, Field(ge=0)] +PositiveInt = Annotated[int, Field(gt=0)] + + +class ContractModel(BaseModel): + """Common validation and serialization policy for public contracts.""" + + model_config = ConfigDict( + extra="forbid", frozen=True, protected_namespaces=(), validate_default=True + ) + + +class Severity(StrEnum): + WARNING = "warning" + BLOCKING = "blocking" + + +class Stage(StrEnum): + AVT = "avt" + HYDROTREATMENT = "ht" + BLEND = "blend" + + +class SourceKind(StrEnum): + TELEMETRY = "telemetry" + LIMS = "lims" + PAK = "pak" + VAK = "vak" + SCENARIO = "scenario" + + +class Validity(StrEnum): + VALID = "valid" + MISSING = "missing" + INVALID = "invalid" + CONFLICT = "conflict" + + +class Unit(StrEnum): + """Known canonical units; DTO fields remain strings as required by DESIGN.""" + + CELSIUS = "degC" + DENSITY = "kg/m3" + PERCENT_VOLUME = "vol%" + PERCENT_MASS = "mass%" + MG_KG = "mg/kg" + PPM = "ppm" + CETANE = "cetane_number" + MM2_S = "mm2/s" + KPA = "kPa" + MPA = "MPa" + THOUSAND_M3_H = "1000*m3/h" + M3_H = "m3/h" + T_H = "t/h" + PERCENT = "%" + DIMENSIONLESS = "1" + PROXY = "proxy_unit" + RISK_INDEX = "index_0_1" + UNKNOWN = "unknown" + + +class OperationMode(StrEnum): + HISTORY = "history" + MODEL_DEMO = "model_demo" + HYBRID = "hybrid" + + +class CandidateKind(StrEnum): + HOLD = "hold" + SETPOINTS = "setpoints" + BLEND = "blend" + + +class AssessmentAgent(StrEnum): + QUALITY = "quality" + RELIABILITY = "reliability" + OPTIMIZER = "optimizer" + + +class AssessmentStatus(StrEnum): + OK = "ok" + DEGRADED = "degraded" + UNAVAILABLE = "unavailable" + + +class EstimateBasis(StrEnum): + MEASURED = "measured" + FORECAST = "forecast" + FORMULA = "formula" + PROXY = "proxy" + + +class IntervalKind(StrEnum): + NONE = "none" + EMPIRICAL = "empirical" + SCENARIO_BOUND = "scenario_bound" + + +class ConstraintStatus(StrEnum): + PASS = "pass" + FAIL = "fail" + UNKNOWN = "unknown" + + +class ConstraintBasis(StrEnum): + TERMS_OF_REFERENCE = "tz" + CONFIRMED = "confirmed" + MODEL_ASSUMPTION = "model_assumption" + + +class RecommendationStatus(StrEnum): + RECOMMEND = "recommend" + HOLD = "hold" + ABSTAIN = "abstain" + + +class MappingStatus(StrEnum): + CONFIRMED = "confirmed" + AMBIGUOUS = "ambiguous" + EXCLUDED = "excluded" + + +class Conversion(StrEnum): + NONE = "none" + PPM_MASS_TO_MG_KG = "ppm_mass_to_mg_kg" + MASS_PERCENT_TO_MG_KG = "mass_percent_to_mg_kg" + + +class Issue(ContractModel): + code: str + severity: Severity + signal_id: str | None + detail: str + source_ref: str | None + + +class Observation(ContractModel): + id: str + signal_id: str + stage: Stage + source: SourceKind + measured_at: AwareDatetime + available_at: AwareDatetime + value: FiniteFloat | None + unit: str + validity: Validity + source_ref: str + + @model_validator(mode="after") + def validate_times_and_value(self) -> "Observation": + """Ensure publication time, value presence and validity agree.""" + if self.available_at < self.measured_at: + raise ValueError("available_at cannot precede measured_at") + if self.validity is Validity.VALID and self.value is None: + raise ValueError("valid observation must have a value") + if self.validity is Validity.MISSING and self.value is not None: + raise ValueError("missing observation must not have a value") + return self + + +class SignalSnapshot(ContractModel): + selected: Observation | None + alternatives: tuple[Observation, ...] = () + age_seconds: NonNegativeFloat | None + fresh: bool + issues: tuple[Issue, ...] = () + + @model_validator(mode="after") + def validate_freshness(self) -> "SignalSnapshot": + """Require the selected observation and age whenever data is fresh.""" + if self.fresh and (self.selected is None or self.age_seconds is None): + raise ValueError("fresh snapshot needs a selected observation and age") + return self + + +class ProcessState(ContractModel): + schema_version: Literal["1.0"] = SCHEMA_VERSION + state_id: str + as_of: AwareDatetime + dataset_id: str + mode: OperationMode + signals: dict[str, SignalSnapshot] + issues: tuple[Issue, ...] = () + + +class CandidateAction(ContractModel): + id: str + kind: CandidateKind + setpoints: dict[str, FiniteFloat] = Field(default_factory=dict) + blend_mass_fractions: dict[str, NonNegativeFloat] = Field(default_factory=dict) + additive_mass_fraction: Annotated[FiniteFloat, Field(ge=0, le=0.03)] = 0.0 + horizon_minutes: PositiveInt + is_model_scenario: bool = False + + @model_validator(mode="after") + def validate_payload(self) -> "CandidateAction": + """Check that the candidate payload matches its declared action kind.""" + if self.kind is CandidateKind.HOLD and ( + self.setpoints or self.blend_mass_fractions or self.additive_mass_fraction + ): + raise ValueError("hold candidate cannot contain changes") + if self.kind is CandidateKind.SETPOINTS and ( + not self.setpoints or self.blend_mass_fractions or self.additive_mass_fraction + ): + raise ValueError("setpoints candidate needs only setpoints") + if self.kind is CandidateKind.BLEND: + if self.setpoints or not self.blend_mass_fractions: + raise ValueError("blend candidate needs only a complete recipe") + if ( + abs(sum(self.blend_mass_fractions.values()) + self.additive_mass_fraction - 1.0) + > 1e-9 + ): + raise ValueError("blend and additive mass fractions must sum to one") + return self + + +class MetricEstimate(ContractModel): + value: FiniteFloat | None + lower: FiniteFloat | None + upper: FiniteFloat | None + unit: str + basis: EstimateBasis + interval_kind: IntervalKind + interval_level: Annotated[FiniteFloat, Field(gt=0, lt=1)] | None + reference: str + assumptions: tuple[str, ...] = () + + @model_validator(mode="after") + def validate_interval(self) -> "MetricEstimate": + """Validate interval ordering and the metadata describing its confidence.""" + if self.lower is not None and self.upper is not None and self.lower > self.upper: + raise ValueError("lower cannot exceed upper") + if self.interval_kind is IntervalKind.EMPIRICAL and self.interval_level is None: + raise ValueError("empirical interval needs interval_level") + if self.interval_kind is not IntervalKind.EMPIRICAL and self.interval_level is not None: + raise ValueError("interval_level is only valid for empirical intervals") + return self + + +class AgentAssessment(ContractModel): + agent: AssessmentAgent + state_id: str + candidate_id: str + evaluated_for: AwareDatetime + status: AssessmentStatus + metrics: dict[str, MetricEstimate] + issues: tuple[Issue, ...] = () + + +class ConstraintResult(ContractModel): + constraint_id: str + candidate_id: str + status: ConstraintStatus + actual: FiniteFloat | None + lower: FiniteFloat | None + upper: FiniteFloat | None + unit: str | None + basis: ConstraintBasis + evidence_ref: str + reason_code: str + + +RankKey = tuple[FiniteFloat, FiniteFloat, FiniteFloat, FiniteFloat, str] + + +class CandidateEvaluation(ContractModel): + candidate: CandidateAction + assessments: tuple[AgentAssessment, ...] = () + checks: tuple[ConstraintResult, ...] = () + feasible: bool + rank_key: RankKey | None + + @model_validator(mode="after") + def validate_consistency(self) -> "CandidateEvaluation": + """Keep candidate identifiers and feasibility status consistent.""" + candidate_id = self.candidate.id + if any(item.candidate_id != candidate_id for item in self.assessments): + raise ValueError("assessment candidate_id mismatch") + if any(item.candidate_id != candidate_id for item in self.checks): + raise ValueError("constraint candidate_id mismatch") + if self.feasible and any(item.status is not ConstraintStatus.PASS for item in self.checks): + raise ValueError("feasible candidate cannot have failed or unknown checks") + if not self.feasible and self.rank_key is not None: + raise ValueError("infeasible candidate cannot have rank_key") + return self + + +class Recommendation(ContractModel): + schema_version: Literal["1.0", "1.1"] = RECOMMENDATION_SCHEMA_VERSION + run_id: str + state_id: str + as_of: AwareDatetime + scenario_id: str + mode: OperationMode + status: RecommendationStatus + baseline: CandidateEvaluation | None + selected: CandidateEvaluation | None + alternatives: Annotated[tuple[CandidateEvaluation, ...], Field(max_length=3)] = () + reason_codes: tuple[str, ...] + explanation: str + assumptions: tuple[str, ...] = () + model_id: str | None + + @model_validator(mode="after") + def validate_result(self) -> "Recommendation": + """Ensure the selected result matches the recommendation status.""" + evaluations = tuple( + item for item in (self.baseline, self.selected, *self.alternatives) if item is not None + ) + if self.schema_version == "1.0" and any( + item.candidate.additive_mass_fraction for item in evaluations + ): + raise ValueError("non-zero additive dose requires recommendation schema 1.1") + if self.status is RecommendationStatus.ABSTAIN: + if self.selected is not None or not self.reason_codes: + raise ValueError("abstain needs selected=None and at least one reason") + return self + if self.selected is None or not self.selected.feasible: + raise ValueError("hold/recommend needs a feasible selected candidate") + if self.status is RecommendationStatus.HOLD: + if self.selected.candidate.kind is not CandidateKind.HOLD: + raise ValueError("hold must select a hold candidate") + elif self.selected.candidate.kind is CandidateKind.HOLD: + raise ValueError("recommend must select a non-hold candidate") + return self + + +class RunFailure(ContractModel): + run_id: str + occurred_at: AwareDatetime + error_code: str + stage: str + message: str + + +class ControlSpec(ContractModel): + signal_id: str + unit: str + lower: FiniteFloat | None + upper: FiniteFloat | None + max_step: NonNegativeFloat | None + step: Annotated[FiniteFloat, Field(gt=0)] | None + evidence_ref: str + basis: ConstraintBasis + enabled: bool = False + + @model_validator(mode="after") + def validate_enabled(self) -> "ControlSpec": + """Require complete limits and evidence for an enabled control.""" + fields = (self.lower, self.upper, self.max_step, self.step) + if self.enabled and (any(item is None for item in fields) or not self.evidence_ref): + raise ValueError("enabled control needs limits, step and evidence") + if self.lower is not None and self.upper is not None and self.lower > self.upper: + raise ValueError("control lower cannot exceed upper") + return self + + +class ConstraintSpec(ContractModel): + id: str + metric: str + stage: Stage + lower: FiniteFloat | None + upper: FiniteFloat | None + unit: str + use_upper_estimate: bool + use_lower_estimate: bool = False + required: bool + basis: ConstraintBasis + evidence_ref: str + + @model_validator(mode="after") + def validate_bounds(self) -> "ConstraintSpec": + """Require evidence and at least one valid lower or upper bound.""" + if self.use_upper_estimate and self.use_lower_estimate: + raise ValueError("constraint cannot use both lower and upper estimates") + if self.lower is None and self.upper is None: + raise ValueError("constraint needs at least one bound") + if self.lower is not None and self.upper is not None and self.lower > self.upper: + raise ValueError("constraint lower cannot exceed upper") + if not self.evidence_ref: + raise ValueError("constraint needs evidence_ref") + return self + + +class BlendComponent(ContractModel): + id: str + sulfur: MetricEstimate + t95: MetricEstimate | None = None + cetane_number: MetricEstimate | None = None + available_mass_t: NonNegativeFloat + cost_proxy_per_t: NonNegativeFloat + risk_index: Annotated[FiniteFloat, Field(ge=0, le=1)] + source_state_id: str | None + + @model_validator(mode="after") + def validate_quality_units(self) -> "BlendComponent": + """Reject component passports with incompatible physical units.""" + if self.sulfur.unit != Unit.MG_KG.value: + raise ValueError("component sulfur must use mg/kg") + if self.t95 is not None and self.t95.unit != Unit.CELSIUS.value: + raise ValueError("component T95 must use degC") + if self.cetane_number is not None and self.cetane_number.unit != Unit.CETANE.value: + raise ValueError("component cetane number must use cetane_number") + return self + + +class AdditiveResponsePoint(ContractModel): + mass_fraction: Annotated[FiniteFloat, Field(ge=0, le=0.03)] + cetane_gain: NonNegativeFloat + + +class CetaneAdditiveSpec(ContractModel): + id: str + max_mass_fraction: Annotated[FiniteFloat, Field(gt=0, le=0.03)] + fraction_step: Annotated[FiniteFloat, Field(gt=0, le=0.03)] + available_mass_t: NonNegativeFloat + cost_proxy_per_t: NonNegativeFloat + response_curve: Annotated[tuple[AdditiveResponsePoint, ...], Field(min_length=2)] + evidence_ref: str + assumptions: tuple[str, ...] = () + + @model_validator(mode="after") + def validate_curve(self) -> "CetaneAdditiveSpec": + """Require a complete monotone scenario curve over the allowed dosage.""" + doses = [point.mass_fraction for point in self.response_curve] + gains = [point.cetane_gain for point in self.response_curve] + if doses[0] != 0 or gains[0] != 0: + raise ValueError("additive response curve must start at zero") + if any(right <= left for left, right in zip(doses, doses[1:], strict=False)): + raise ValueError("additive response doses must be strictly increasing") + if any(right < left for left, right in zip(gains, gains[1:], strict=False)): + raise ValueError("additive cetane gain must be nondecreasing") + if doses[-1] < self.max_mass_fraction: + raise ValueError("additive response curve must cover max_mass_fraction") + steps = round(self.max_mass_fraction / self.fraction_step) + if abs(steps * self.fraction_step - self.max_mass_fraction) > 1e-9: + raise ValueError("additive fraction_step must divide max_mass_fraction") + if not self.evidence_ref: + raise ValueError("additive model needs evidence_ref") + return self + + +class ScenarioConfig(ContractModel): + id: str + mode: OperationMode + required_signals: tuple[str, ...] = () + controls: tuple[ControlSpec, ...] = () + constraints: tuple[ConstraintSpec, ...] = () + blend_components: tuple[BlendComponent, ...] = () + cetane_additive: CetaneAdditiveSpec | None = None + total_mass_t: NonNegativeFloat | None + current_blend_mass_fractions: dict[str, NonNegativeFloat] = Field(default_factory=dict) + current_additive_mass_fraction: Annotated[FiniteFloat, Field(ge=0, le=0.03)] = 0.0 + active_criteria: tuple[str, ...] = () + materiality_thresholds: dict[str, NonNegativeFloat] = Field(default_factory=dict) + require_upper_bound: bool = True + action_cooldown_minutes: Annotated[int, Field(ge=0)] = 60 + assumptions: tuple[str, ...] = () + + @model_validator(mode="after") + def validate_blend(self) -> "ScenarioConfig": + """Keep the current product recipe and additive model internally consistent.""" + if not self.blend_components: + return self + component_ids = {component.id for component in self.blend_components} + if len(component_ids) != len(self.blend_components): + raise ValueError("blend component ids must be unique") + if set(self.current_blend_mass_fractions) != component_ids: + raise ValueError("current blend must contain every component exactly once") + total = ( + sum(self.current_blend_mass_fractions.values()) + self.current_additive_mass_fraction + ) + if abs(total - 1.0) > 1e-9: + raise ValueError("current blend and additive mass fractions must sum to one") + if self.current_additive_mass_fraction: + if self.cetane_additive is None: + raise ValueError("current additive dose needs a cetane additive model") + if self.current_additive_mass_fraction > self.cetane_additive.max_mass_fraction: + raise ValueError("current additive dose exceeds the configured maximum") + if self.mode is OperationMode.MODEL_DEMO: + required = { + constraint.metric: constraint + for constraint in self.constraints + if constraint.required + } + missing = {"sulfur", "t95", "cetane_number"}.difference(required) + if missing: + raise ValueError(f"model-demo blend lacks required constraints: {sorted(missing)}") + if not required["sulfur"].use_upper_estimate: + raise ValueError("model-demo sulfur constraint must use the upper estimate") + if not required["t95"].use_upper_estimate: + raise ValueError("model-demo T95 constraint must use the upper estimate") + if not required["cetane_number"].use_lower_estimate: + raise ValueError("model-demo cetane constraint must use the lower estimate") + return self + + +class DecisionContext(ContractModel): + last_recommended_at: AwareDatetime | None = None + + +class RuntimeConfig(ContractModel): + source_timezone: str = "Europe/Moscow" + lims_delay_hours: NonNegativeFloat = 4.0 + freshness_minutes: dict[SourceKind, PositiveInt] = Field( + default_factory=lambda: { + SourceKind.TELEMETRY: 20, + SourceKind.PAK: 30, + SourceKind.LIMS: 48 * 60, + } + ) + horizon_minutes: PositiveInt = 60 + seed: int = 42 + max_candidates: PositiveInt = 125 + data_dir: Path = Path("data/processed") + materials_dir: Path = Path("materials") + tag_dictionary_path: Path = Path("config/tags.csv") + models_dir: Path = Path("artifacts/models") + reports_dir: Path = Path("reports") + runs_dir: Path = Path("runs") + + +class TagMeta(ContractModel): + signal_id: str + raw_name: str + stage: Stage + meaning: str + raw_unit: str | None + canonical_unit: str + conversion: Conversion + mapping_status: MappingStatus + controllable: bool + evidence_ref: str + + @model_validator(mode="after") + def validate_mapping(self) -> "TagMeta": + """Prevent unsupported mappings from being treated as controllable.""" + if self.mapping_status is MappingStatus.CONFIRMED and not self.evidence_ref: + raise ValueError("confirmed mapping needs evidence_ref") + if self.controllable and self.mapping_status is not MappingStatus.CONFIRMED: + raise ValueError("controllable signal must have confirmed mapping") + return self + + +class SourceArtifact(ContractModel): + path: str + sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + size_bytes: Annotated[int, Field(ge=0)] + + +class DatasetManifest(ContractModel): + schema_version: Literal["1.0"] = SCHEMA_VERSION + dataset_id: Annotated[str, Field(min_length=12, max_length=12)] + preparation_version: str + created_at: AwareDatetime + source_timezone: str + assumptions: tuple[str, ...] + sources: tuple[SourceArtifact, ...] + config_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + tag_dictionary_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + row_counts: dict[str, Annotated[int, Field(ge=0)]] + time_ranges: dict[str, tuple[AwareDatetime, AwareDatetime] | None] + + +__all__ = [ + "SCHEMA_VERSION", + "AgentAssessment", + "AssessmentAgent", + "AssessmentStatus", + "AdditiveResponsePoint", + "BlendComponent", + "CandidateAction", + "CandidateEvaluation", + "CandidateKind", + "ConstraintBasis", + "ConstraintResult", + "ConstraintSpec", + "ConstraintStatus", + "ControlSpec", + "CetaneAdditiveSpec", + "Conversion", + "DatasetManifest", + "DecisionContext", + "EstimateBasis", + "IntervalKind", + "Issue", + "MappingStatus", + "MetricEstimate", + "Observation", + "OperationMode", + "ProcessState", + "Recommendation", + "RECOMMENDATION_SCHEMA_VERSION", + "RecommendationStatus", + "RunFailure", + "RuntimeConfig", + "ScenarioConfig", + "Severity", + "SignalSnapshot", + "SourceArtifact", + "SourceKind", + "Stage", + "TagMeta", + "Unit", + "Validity", +] +======= """Strict version-1 contracts shared by backend and ML code. These models implement the DTOs from DESIGN.md sections 5 and 6. They reject @@ -14,6 +640,7 @@ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator SCHEMA_VERSION: Literal["1.0"] = "1.0" +RECOMMENDATION_SCHEMA_VERSION: Literal["1.1"] = "1.1" FiniteFloat = Annotated[float, Field(allow_inf_nan=False)] NonNegativeFloat = Annotated[FiniteFloat, Field(ge=0)] PositiveInt = Annotated[int, Field(gt=0)] @@ -69,6 +696,7 @@ class Unit(StrEnum): THOUSAND_M3_H = "1000*m3/h" M3_H = "m3/h" T_H = "t/h" + NM3_H = "Nm3/h" PERCENT = "%" DIMENSIONLESS = "1" PROXY = "proxy_unit" @@ -82,6 +710,24 @@ class OperationMode(StrEnum): HYBRID = "hybrid" +class ProductGrade(StrEnum): + """Confirmed diesel quality profiles from the 2026-09-15 Q&A.""" + + HDS_DIESEL = "hds_diesel" + SUMMER_DIESEL = "summer_diesel" + WINTER_DIESEL = "winter_diesel" + + +def product_grade_limits(grade: ProductGrade | str) -> dict[str, float | None]: + """Return the Q&A-confirmed density/cetane profile for a diesel grade.""" + selected = ProductGrade(grade) + if selected is ProductGrade.HDS_DIESEL: + return {"density_lower": 820.0, "density_upper": 845.0, "cetane_lower": None} + if selected is ProductGrade.SUMMER_DIESEL: + return {"density_lower": 820.0, "density_upper": 845.0, "cetane_lower": 51.0} + return {"density_lower": 800.0, "density_upper": 845.0, "cetane_lower": 49.0} + + class CandidateKind(StrEnum): HOLD = "hold" SETPOINTS = "setpoints" @@ -205,23 +851,29 @@ class CandidateAction(ContractModel): kind: CandidateKind setpoints: dict[str, FiniteFloat] = Field(default_factory=dict) blend_mass_fractions: dict[str, NonNegativeFloat] = Field(default_factory=dict) + additive_mass_fraction: Annotated[FiniteFloat, Field(ge=0, le=0.03)] = 0.0 horizon_minutes: PositiveInt is_model_scenario: bool = False @model_validator(mode="after") def validate_payload(self) -> "CandidateAction": """Check that the candidate payload matches its declared action kind.""" - if self.kind is CandidateKind.HOLD and (self.setpoints or self.blend_mass_fractions): + if self.kind is CandidateKind.HOLD and ( + self.setpoints or self.blend_mass_fractions or self.additive_mass_fraction + ): raise ValueError("hold candidate cannot contain changes") if self.kind is CandidateKind.SETPOINTS and ( - not self.setpoints or self.blend_mass_fractions + not self.setpoints or self.blend_mass_fractions or self.additive_mass_fraction ): raise ValueError("setpoints candidate needs only setpoints") if self.kind is CandidateKind.BLEND: if self.setpoints or not self.blend_mass_fractions: raise ValueError("blend candidate needs only a complete recipe") - if abs(sum(self.blend_mass_fractions.values()) - 1.0) > 1e-9: - raise ValueError("blend mass fractions must sum to one") + if ( + abs(sum(self.blend_mass_fractions.values()) + self.additive_mass_fraction - 1.0) + > 1e-9 + ): + raise ValueError("blend and additive mass fractions must sum to one") return self @@ -297,7 +949,7 @@ def validate_consistency(self) -> "CandidateEvaluation": class Recommendation(ContractModel): - schema_version: Literal["1.0"] = SCHEMA_VERSION + schema_version: Literal["1.0", "1.1"] = RECOMMENDATION_SCHEMA_VERSION run_id: str state_id: str as_of: AwareDatetime @@ -315,6 +967,13 @@ class Recommendation(ContractModel): @model_validator(mode="after") def validate_result(self) -> "Recommendation": """Ensure the selected result matches the recommendation status.""" + evaluations = tuple( + item for item in (self.baseline, self.selected, *self.alternatives) if item is not None + ) + if self.schema_version == "1.0" and any( + item.candidate.additive_mass_fraction for item in evaluations + ): + raise ValueError("non-zero additive dose requires recommendation schema 1.1") if self.status is RecommendationStatus.ABSTAIN: if self.selected is not None or not self.reason_codes: raise ValueError("abstain needs selected=None and at least one reason") @@ -367,6 +1026,7 @@ class ConstraintSpec(ContractModel): upper: FiniteFloat | None unit: str use_upper_estimate: bool + use_lower_estimate: bool = False required: bool basis: ConstraintBasis evidence_ref: str @@ -374,6 +1034,8 @@ class ConstraintSpec(ContractModel): @model_validator(mode="after") def validate_bounds(self) -> "ConstraintSpec": """Require evidence and at least one valid lower or upper bound.""" + if self.use_upper_estimate and self.use_lower_estimate: + raise ValueError("constraint cannot use both lower and upper estimates") if self.lower is None and self.upper is None: raise ValueError("constraint needs at least one bound") if self.lower is not None and self.upper is not None and self.lower > self.upper: @@ -386,6 +1048,7 @@ def validate_bounds(self) -> "ConstraintSpec": class BlendComponent(ContractModel): id: str sulfur: MetricEstimate + density: MetricEstimate | None = None t95: MetricEstimate | None = None cetane_number: MetricEstimate | None = None available_mass_t: NonNegativeFloat @@ -393,22 +1056,120 @@ class BlendComponent(ContractModel): risk_index: Annotated[FiniteFloat, Field(ge=0, le=1)] source_state_id: str | None + @model_validator(mode="after") + def validate_quality_units(self) -> "BlendComponent": + """Reject component passports with incompatible physical units.""" + if self.sulfur.unit != Unit.MG_KG.value: + raise ValueError("component sulfur must use mg/kg") + if self.density is not None and self.density.unit != Unit.DENSITY.value: + raise ValueError("component density must use kg/m3") + if self.t95 is not None and self.t95.unit != Unit.CELSIUS.value: + raise ValueError("component T95 must use degC") + if self.cetane_number is not None and self.cetane_number.unit != Unit.CETANE.value: + raise ValueError("component cetane number must use cetane_number") + return self + + +class AdditiveResponsePoint(ContractModel): + mass_fraction: Annotated[FiniteFloat, Field(ge=0, le=0.03)] + cetane_gain: NonNegativeFloat + + +class CetaneAdditiveSpec(ContractModel): + id: str + max_mass_fraction: Annotated[FiniteFloat, Field(gt=0, le=0.03)] + fraction_step: Annotated[FiniteFloat, Field(gt=0, le=0.03)] + available_mass_t: NonNegativeFloat + cost_proxy_per_t: NonNegativeFloat + response_curve: Annotated[tuple[AdditiveResponsePoint, ...], Field(min_length=2)] + evidence_ref: str + assumptions: tuple[str, ...] = () + + @model_validator(mode="after") + def validate_curve(self) -> "CetaneAdditiveSpec": + """Require a complete monotone scenario curve over the allowed dosage.""" + doses = [point.mass_fraction for point in self.response_curve] + gains = [point.cetane_gain for point in self.response_curve] + if doses[0] != 0 or gains[0] != 0: + raise ValueError("additive response curve must start at zero") + if any(right <= left for left, right in zip(doses, doses[1:], strict=False)): + raise ValueError("additive response doses must be strictly increasing") + if any(right < left for left, right in zip(gains, gains[1:], strict=False)): + raise ValueError("additive cetane gain must be nondecreasing") + if doses[-1] < self.max_mass_fraction: + raise ValueError("additive response curve must cover max_mass_fraction") + steps = round(self.max_mass_fraction / self.fraction_step) + if abs(steps * self.fraction_step - self.max_mass_fraction) > 1e-9: + raise ValueError("additive fraction_step must divide max_mass_fraction") + if not self.evidence_ref: + raise ValueError("additive model needs evidence_ref") + return self + class ScenarioConfig(ContractModel): id: str mode: OperationMode + product_grade: ProductGrade = ProductGrade.SUMMER_DIESEL + sulfur_margin_mgkg: Annotated[FiniteFloat, Field(ge=1.0, le=2.0)] = 2.0 required_signals: tuple[str, ...] = () controls: tuple[ControlSpec, ...] = () constraints: tuple[ConstraintSpec, ...] = () blend_components: tuple[BlendComponent, ...] = () + cetane_additive: CetaneAdditiveSpec | None = None total_mass_t: NonNegativeFloat | None current_blend_mass_fractions: dict[str, NonNegativeFloat] = Field(default_factory=dict) + current_additive_mass_fraction: Annotated[FiniteFloat, Field(ge=0, le=0.03)] = 0.0 active_criteria: tuple[str, ...] = () materiality_thresholds: dict[str, NonNegativeFloat] = Field(default_factory=dict) require_upper_bound: bool = True action_cooldown_minutes: Annotated[int, Field(ge=0)] = 60 assumptions: tuple[str, ...] = () + @property + def operating_sulfur_target(self) -> float: + """Operational target below the hard 10 mg/kg product limit.""" + return 10.0 - float(self.sulfur_margin_mgkg) + + @model_validator(mode="after") + def validate_blend(self) -> "ScenarioConfig": + """Keep the current product recipe and additive model internally consistent.""" + if not self.blend_components: + return self + component_ids = {component.id for component in self.blend_components} + if len(component_ids) != len(self.blend_components): + raise ValueError("blend component ids must be unique") + if set(self.current_blend_mass_fractions) != component_ids: + raise ValueError("current blend must contain every component exactly once") + total = ( + sum(self.current_blend_mass_fractions.values()) + self.current_additive_mass_fraction + ) + if abs(total - 1.0) > 1e-9: + raise ValueError("current blend and additive mass fractions must sum to one") + if self.current_additive_mass_fraction: + if self.cetane_additive is None: + raise ValueError("current additive dose needs a cetane additive model") + if self.current_additive_mass_fraction > self.cetane_additive.max_mass_fraction: + raise ValueError("current additive dose exceeds the configured maximum") + if self.mode is OperationMode.MODEL_DEMO: + required = { + constraint.metric: constraint + for constraint in self.constraints + if constraint.required + } + required_metrics = {"sulfur", "t95"} + if self.product_grade is not ProductGrade.HDS_DIESEL: + required_metrics.add("cetane_number") + missing = required_metrics.difference(required) + if missing: + raise ValueError(f"model-demo blend lacks required constraints: {sorted(missing)}") + if not required["sulfur"].use_upper_estimate: + raise ValueError("model-demo sulfur constraint must use the upper estimate") + if not required["t95"].use_upper_estimate: + raise ValueError("model-demo T95 constraint must use the upper estimate") + if "cetane_number" in required and not required["cetane_number"].use_lower_estimate: + raise ValueError("model-demo cetane constraint must use the lower estimate") + return self + class DecisionContext(ContractModel): last_recommended_at: AwareDatetime | None = None @@ -430,6 +1191,7 @@ class RuntimeConfig(ContractModel): data_dir: Path = Path("data/processed") materials_dir: Path = Path("materials") tag_dictionary_path: Path = Path("config/tags.csv") + telemetry_rules_path: Path = Path("config/telemetry_rules.json") models_dir: Path = Path("artifacts/models") reports_dir: Path = Path("reports") runs_dir: Path = Path("runs") @@ -473,6 +1235,7 @@ class DatasetManifest(ContractModel): sources: tuple[SourceArtifact, ...] config_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] tag_dictionary_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] + telemetry_rules_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] | None = None row_counts: dict[str, Annotated[int, Field(ge=0)]] time_ranges: dict[str, tuple[AwareDatetime, AwareDatetime] | None] @@ -482,6 +1245,7 @@ class DatasetManifest(ContractModel): "AgentAssessment", "AssessmentAgent", "AssessmentStatus", + "AdditiveResponsePoint", "BlendComponent", "CandidateAction", "CandidateEvaluation", @@ -491,6 +1255,7 @@ class DatasetManifest(ContractModel): "ConstraintSpec", "ConstraintStatus", "ControlSpec", + "CetaneAdditiveSpec", "Conversion", "DatasetManifest", "DecisionContext", @@ -501,8 +1266,11 @@ class DatasetManifest(ContractModel): "MetricEstimate", "Observation", "OperationMode", + "ProductGrade", + "product_grade_limits", "ProcessState", "Recommendation", + "RECOMMENDATION_SCHEMA_VERSION", "RecommendationStatus", "RunFailure", "RuntimeConfig", @@ -516,3 +1284,4 @@ class DatasetManifest(ContractModel): "Unit", "Validity", ] +>>>>>>> d0bbf23 (Update project sources and materials) diff --git a/source/data/ingest.py b/source/data/ingest.py index 5591f47..408d980 100644 --- a/source/data/ingest.py +++ b/source/data/ingest.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from collections.abc import Mapping from dataclasses import dataclass from datetime import timedelta from math import isfinite @@ -71,6 +72,7 @@ def read_telemetry_csv( tags: dict[str, TagMeta], source_timezone: str = "Europe/Moscow", nrows: int | None = None, + telemetry_rules: Mapping[str, Mapping[str, object]] | None = None, ) -> TelemetryRead: """Normalize one telemetry CSV to UTC and canonical signal columns.""" path = Path(path) @@ -118,6 +120,31 @@ def read_telemetry_csv( ) valid[column] = numeric.mask(bad_value) + # Apply only explicit, signal-scoped sentinel rules. The raw value stays + # in the source archive; the prepared frame records it as unavailable and + # emits one auditable issue instead of treating an analyzer code as sulfur. + for signal_id, rule in (telemetry_rules or {}).items(): + if signal_id not in valid.columns: + continue + exact_values = rule.get("exact_values", ()) + if not isinstance(exact_values, (list, tuple, set)): + continue + numeric = pd.to_numeric(valid[signal_id], errors="coerce") + mask = numeric.isin([float(value) for value in exact_values]) + if not mask.any(): + continue + valid.loc[mask, signal_id] = pd.NA + issue_code = str(rule.get("issue_code", "CONFIRMED_SENTINEL")) + detail = str(rule.get("detail", "explicit telemetry sentinel was masked")) + issues.append( + _issue( + issue_code, + f"{detail} rows_masked={int(mask.sum())}", + f"{path.as_posix()}#column={signal_id}", + signal_id, + ) + ) + valid = _collapse_telemetry_duplicates(valid, path, issues) valid = valid.sort_values("timestamp", kind="stable").reset_index(drop=True) return TelemetryRead(valid, tuple(issues)) diff --git a/source/data/prepare.py b/source/data/prepare.py index 333de8f..b3223e2 100644 --- a/source/data/prepare.py +++ b/source/data/prepare.py @@ -6,7 +6,9 @@ import json import shutil import subprocess +import tarfile import tempfile +import time from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -30,6 +32,8 @@ PREPARATION_VERSION = "1" _ARCHIVE_MEMBERS = {"data/avt_tags.csv", "data/242000_tags.csv"} +_PUBLISH_ATTEMPTS = 5 +_PUBLISH_RETRY_SECONDS = 0.05 RAW_UNIT_MAP: dict[str, Unit] = { "°С": Unit.CELSIUS, @@ -46,6 +50,9 @@ "тыс.м3/ч": Unit.THOUSAND_M3_H, "м3/ч": Unit.M3_H, "т/ч": Unit.T_H, + "Nm3/h": Unit.NM3_H, + "Нм3/ч": Unit.NM3_H, + "нм3/ч": Unit.NM3_H, "%": Unit.PERCENT, } @@ -144,8 +151,45 @@ def _source_artifact(path: Path, root: Path) -> SourceArtifact: return SourceArtifact(path=artifact_path, sha256=_sha256(path), size_bytes=path.stat().st_size) +def _replace_path(source: Path, target: Path) -> None: + """Wrap platform-specific atomic replace for retry tests.""" + source.replace(target) + + +def _publish_directory(temporary: Path, target: Path) -> None: + """Publish a prepared directory with a bounded retry for transient Windows locks.""" + for attempt in range(_PUBLISH_ATTEMPTS): + try: + _replace_path(temporary, target) + return + except PermissionError: + if attempt == _PUBLISH_ATTEMPTS - 1: + raise + time.sleep(_PUBLISH_RETRY_SECONDS * (2**attempt)) + + def _extract_telemetry(archive: Path, destination: Path) -> Path: """Validate archive members before extraction to a temporary directory.""" + try: + with tarfile.open(archive) as stream: + members = { + member.name.replace("\\", "/").lstrip("./").rstrip("/") + for member in stream.getmembers() + } + if members != _ARCHIVE_MEMBERS | {"data"}: + raise ValueError(f"unexpected archive members: {sorted(members)}") + destination_root = destination.resolve() + for member in stream.getmembers(): + target = (destination / member.name).resolve() + try: + target.relative_to(destination_root) + except ValueError as exc: + raise ValueError(f"unsafe archive member: {member.name}") from exc + stream.extractall(destination) + return destination / "data" + except tarfile.TarError: + pass + listing = subprocess.run( ["tar", "-tf", str(archive)], check=True, capture_output=True, text=True ).stdout.splitlines() @@ -190,7 +234,7 @@ def _deduplicate_quality( def prepare_dataset(materials_dir: Path, config: RuntimeConfig) -> PreparedData: """Build normalized in-memory tables and a reproducibility manifest.""" - from source.config import config_fingerprint, load_tag_dictionary + from source.config import config_fingerprint, load_tag_dictionary, load_telemetry_rules from source.data.ingest import read_lims, read_pak, read_telemetry_csv materials_dir = materials_dir.resolve() @@ -198,19 +242,29 @@ def prepare_dataset(materials_dir: Path, config: RuntimeConfig) -> PreparedData: pak_path = materials_dir / "Выгрузка ПАК 01.01.2023 - н.в_.xlsx" lims_path = materials_dir / "ЛИМСы 01.01.2023 - н.в_ (2).xlsx" tag_path = config.tag_dictionary_path.resolve() - for path in (archive, pak_path, lims_path, tag_path): + rules_path = config.telemetry_rules_path.resolve() + for path in (archive, pak_path, lims_path, tag_path, rules_path): if not path.is_file(): raise FileNotFoundError(path) tags = load_tag_dictionary(tag_path) + telemetry_rules = load_telemetry_rules(rules_path) issues: list[Issue] = [] with tempfile.TemporaryDirectory(prefix="neftekod-stage0-") as temp_dir: telemetry_dir = _extract_telemetry(archive, Path(temp_dir)) avt = read_telemetry_csv( - telemetry_dir / "avt_tags.csv", "avt", tags, config.source_timezone + telemetry_dir / "avt_tags.csv", + "avt", + tags, + config.source_timezone, + telemetry_rules=telemetry_rules, ) ht = read_telemetry_csv( - telemetry_dir / "242000_tags.csv", "ht", tags, config.source_timezone + telemetry_dir / "242000_tags.csv", + "ht", + tags, + config.source_timezone, + telemetry_rules=telemetry_rules, ) issues.extend(avt.issues) issues.extend(ht.issues) @@ -232,13 +286,15 @@ def prepare_dataset(materials_dir: Path, config: RuntimeConfig) -> PreparedData: sources = tuple( _source_artifact(path, materials_dir.parent) - for path in (archive, pak_path, lims_path, tag_path) + for path in (archive, pak_path, lims_path, tag_path, rules_path) ) config_hash = hashlib.sha256(config_fingerprint(config).encode()).hexdigest() tag_hash = _sha256(tag_path) + telemetry_rules_hash = _sha256(rules_path) identity_parts = sorted(item.sha256 for item in sources) + [ config_hash, tag_hash, + telemetry_rules_hash, PREPARATION_VERSION, ] dataset_id = hashlib.sha256("\n".join(identity_parts).encode()).hexdigest()[:12] @@ -266,6 +322,7 @@ def prepare_dataset(materials_dir: Path, config: RuntimeConfig) -> PreparedData: sources=sources, config_sha256=config_hash, tag_dictionary_sha256=tag_hash, + telemetry_rules_sha256=telemetry_rules_hash, row_counts={ "telemetry": len(telemetry), "quality": len(quality), @@ -327,7 +384,7 @@ def write_prepared_dataset(data: PreparedData, output_root: Path) -> Path: ) if any(not path.is_file() for path in required): raise OSError("prepared dataset publication is incomplete") - temporary.replace(target) + _publish_directory(temporary, target) except Exception: shutil.rmtree(temporary, ignore_errors=True) raise diff --git a/source/data/state.py b/source/data/state.py index 47bdc06..b268089 100644 --- a/source/data/state.py +++ b/source/data/state.py @@ -11,14 +11,16 @@ from source.contracts import ( Issue, Observation, - ProcessState, - RuntimeConfig, - ScenarioConfig, - Severity, - SignalSnapshot, - SourceKind, - Validity, -) + ProcessState, + RuntimeConfig, + ScenarioConfig, + Severity, + SignalSnapshot, + SourceKind, + Stage, + Unit, + Validity, +) from source.data.prepare import PreparedData _SOURCE_PRIORITY = { @@ -30,7 +32,7 @@ } -def _observation(row: pd.Series) -> Observation: +def _observation(row: pd.Series) -> Observation: """Convert one prepared quality-table row into a validated observation.""" return Observation( id=str(row["observation_id"]), @@ -43,12 +45,52 @@ def _observation(row: pd.Series) -> Observation: unit=str(row["unit"]), validity=row["validity"], source_ref=str(row["source_ref"]), - ) - - -def build_state( - data: PreparedData, - as_of: datetime, + ) + + +def _stage_from_signal_id(signal_id: str) -> Stage: + if signal_id.startswith("avt:"): + return Stage.AVT + if signal_id.startswith("ht:"): + return Stage.HYDROTREATMENT + return Stage.HYDROTREATMENT + + +def _telemetry_candidates( + frame: pd.DataFrame, + signal_id: str, + as_of: datetime, + unit: str, + dataset_id: str, +) -> list[Observation]: + if signal_id not in frame.columns or "timestamp" not in frame.columns: + return [] + visible = frame[frame["timestamp"] <= pd.Timestamp(as_of)] + values = pd.to_numeric(visible[signal_id], errors="coerce") + valid = visible[values.notna()].copy() + if valid.empty: + return [] + row = valid.iloc[-1] + measured_at = pd.Timestamp(row["timestamp"]).to_pydatetime() + return [ + Observation( + id=f"telemetry:{signal_id}:{measured_at.isoformat()}", + signal_id=signal_id, + stage=_stage_from_signal_id(signal_id), + source=SourceKind.TELEMETRY, + measured_at=measured_at, + available_at=measured_at, + value=float(row[signal_id]), + unit=unit, + validity=Validity.VALID, + source_ref=f"prepared:{dataset_id}:telemetry.csv.gz", + ) + ] + + +def build_state( + data: PreparedData, + as_of: datetime, scenario: ScenarioConfig, config: RuntimeConfig, ) -> ProcessState: @@ -56,17 +98,30 @@ def build_state( if as_of.tzinfo is None or as_of.utcoffset() is None: raise ValueError("as_of must be timezone-aware") as_of = as_of.astimezone(UTC) - frame = data.quality.copy() - frame["measured_at"] = pd.to_datetime(frame["measured_at"], utc=True) - frame["available_at"] = pd.to_datetime(frame["available_at"], utc=True) - visible = frame[(frame["measured_at"] <= as_of) & (frame["available_at"] <= as_of)] - - snapshots: dict[str, SignalSnapshot] = {} - state_issues: list[Issue] = [] - for signal_id in scenario.required_signals: - candidates = [ - _observation(row) for _, row in visible[visible["signal_id"] == signal_id].iterrows() - ] + frame = data.quality.copy() + frame["measured_at"] = pd.to_datetime(frame["measured_at"], utc=True) + frame["available_at"] = pd.to_datetime(frame["available_at"], utc=True) + visible = frame[(frame["measured_at"] <= as_of) & (frame["available_at"] <= as_of)] + telemetry = data.telemetry.copy() + if "timestamp" in telemetry.columns: + telemetry["timestamp"] = pd.to_datetime(telemetry["timestamp"], utc=True) + control_units = {control.signal_id: control.unit for control in scenario.controls} + + snapshots: dict[str, SignalSnapshot] = {} + state_issues: list[Issue] = [] + for signal_id in scenario.required_signals: + candidates = [ + _observation(row) for _, row in visible[visible["signal_id"] == signal_id].iterrows() + ] + candidates.extend( + _telemetry_candidates( + telemetry, + signal_id, + as_of, + control_units.get(signal_id, Unit.UNKNOWN.value), + data.manifest.dataset_id, + ) + ) usable = [ item for item in candidates @@ -128,7 +183,9 @@ def is_fresh(item: Observation) -> bool: "as_of": as_of.isoformat(), "dataset_id": data.manifest.dataset_id, "mode": scenario.mode.value, - "signals": {key: value.model_dump(mode="json") for key, value in sorted(snapshots.items())}, + "signals": { + key: value.model_dump(mode="json") for key, value in sorted(snapshots.items()) + }, "issues": [item.model_dump(mode="json") for item in state_issues], } state_id = hashlib.sha256( diff --git a/source/explain.py b/source/explain.py index 079598a..eb46e8f 100644 --- a/source/explain.py +++ b/source/explain.py @@ -5,18 +5,28 @@ from source.contracts import CandidateEvaluation, RecommendationStatus, ScenarioConfig -def _sulfur_text(evaluation: CandidateEvaluation | None) -> str: +def _quality_text(evaluation: CandidateEvaluation | None) -> str: if evaluation is None: - return "сера не рассчитана" + return "паспорт качества не рассчитан" + metrics = {} for assessment in evaluation.assessments: - sulfur = assessment.metrics.get("sulfur") - if sulfur is not None: - if sulfur.value is None: - return "сера не рассчитана" - if sulfur.upper is None: - return f"сера {sulfur.value:.3g} {sulfur.unit}, верхняя оценка недоступна" - return f"сера {sulfur.value:.3g} {sulfur.unit}, верхняя оценка {sulfur.upper:.3g}" - return "сера не рассчитана" + metrics.update(assessment.metrics) + sulfur = metrics.get("sulfur") + t95 = metrics.get("t95") + cetane = metrics.get("cetane_number") + return ", ".join( + ( + "сера недоступна" + if sulfur is None or sulfur.upper is None + else f"сера upper {sulfur.upper:.3g} {sulfur.unit}", + "T95 недоступен" + if t95 is None or t95.upper is None + else f"T95 upper {t95.upper:.3g} {t95.unit}", + "цетановое число недоступно" + if cetane is None or cetane.lower is None + else f"цетановое lower {cetane.lower:.3g}", + ) + ) def _checks_text(evaluation: CandidateEvaluation | None) -> str: @@ -56,15 +66,15 @@ def build_explanation( if status is RecommendationStatus.HOLD: suffix = f" Причины выбора: {', '.join(reason_codes)}." if reason_codes else "" return ( - f"Рекомендуется сохранить режим: {_sulfur_text(selected)}. " + f"Рекомендуется сохранить режим: {_quality_text(selected)}. " f"Проверки: {_checks_text(selected)}.{suffix}" ) action = selected.candidate.id if selected else "unknown" return ( f"Рекомендуется вариант {action}: текущий режим не проходит ограничения " - f"({_sulfur_text(baseline)}; проверки: {_checks_text(baseline)}), " + f"({_quality_text(baseline)}; проверки: {_checks_text(baseline)}), " f"выбранный вариант проходит " - f"({_sulfur_text(selected)}; проверки: {_checks_text(selected)})." + f"({_quality_text(selected)}; проверки: {_checks_text(selected)})." ) diff --git a/source/main.py b/source/main.py index db2ad5d..b8b5ba7 100644 --- a/source/main.py +++ b/source/main.py @@ -3,50 +3,117 @@ from __future__ import annotations import argparse +import hashlib import json +<<<<<<< HEAD import platform +======= +import os +>>>>>>> 1527107 (Extend UI and ML analysis materials) import subprocess import sys +import tempfile from datetime import datetime from pathlib import Path -from typing import Any, Literal, Mapping, Sequence, cast +from typing import TYPE_CHECKING, Literal, Sequence -from source.config import load_runtime_config, load_scenario, load_tag_dictionary +import pandas as pd + +from source.config import ( + config_fingerprint, + load_runtime_config, + load_scenario, + load_tag_dictionary, +) from source.contracts import ( DecisionContext, ProcessState, Recommendation, - RecommendationStatus, - ScenarioConfig, + RuntimeConfig, SourceKind, ) -from source.data import build_state, load_prepared_dataset, prepare_dataset, write_prepared_dataset +from source.data import ( + PreparedData, + build_state, + load_prepared_dataset, + prepare_dataset, + write_prepared_dataset, +) + +if TYPE_CHECKING: + from source.ml.artifacts import ModelBundle + from source.ml.features import SupervisedDataset + +SplitName = Literal["train", "validation", "test"] +# Curated, dictionary-confirmed 24-2000 context. P8/F19 remain historical +# action candidates; no signal is enabled as a real setpoint control. +TRAINING_TELEMETRY_SIGNALS = ("ht:P8", "ht:F19", "ht:T11") +MODEL_DEMO_SCENARIOS = ( + "blend_normal", + "blend_risk", + "blend_t95_risk", + "blend_cetane_risk", + "blend_missing", +) PROJECT_ROOT = Path(__file__).resolve().parent.parent -STAGE6_SCENARIOS: tuple[str, ...] = ("blend_normal", "blend_risk", "blend_missing") +MODEL_DEMO_SCENARIOS: tuple[str, ...] = ( + "blend_normal", + "blend_risk", + "blend_t95_risk", + "blend_cetane_risk", + "blend_missing", +) +STAGE6_SCENARIOS = MODEL_DEMO_SCENARIOS STAGE6_EXPECTED_STATUSES: dict[str, str] = { - "blend_normal": RecommendationStatus.HOLD.value, - "blend_risk": RecommendationStatus.RECOMMEND.value, - "blend_missing": RecommendationStatus.ABSTAIN.value, + "blend_normal": "hold", + "blend_risk": "recommend", + "blend_t95_risk": "recommend", + "blend_cetane_risk": "recommend", + "blend_missing": "abstain", } STAGE6_JOURNAL_FILES: tuple[str, ...] = ( - "result.json", + "metadata.json", "input.json", + "features.json", "trace.jsonl", "candidates.jsonl", + "result.json", ) STAGE6_LIMITATIONS: tuple[str, ...] = ( - "Stage 6 is an acceptance and demonstration layer, not a new ML or action model.", + "Stage 6 is an acceptance and demonstration layer, not a production action model.", "Real setpoint recommendations remain disabled until a validated action model exists.", - "Model-demo recommendations are synthetic; history artifact serving remains forecast-only.", + "Model-demo counterfactuals are synthetic; history artifact serving remains forecast-only.", ) +def _write_json_report(path: str | Path, payload: dict[str, object], root: Path) -> Path: + """Write a report atomically and refuse accidental overwrite.""" + destination = _resolve_path(path, root) + if destination.exists(): + raise FileExistsError(f"report already exists: {destination}") + destination.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{destination.name}.", dir=destination.parent + ) + os.close(descriptor) + temporary = Path(temporary_name) + try: + temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(destination) + except Exception: + temporary.unlink(missing_ok=True) + raise + return destination + + def validate_stage0(root: Path = PROJECT_ROOT) -> dict[str, int]: """Validate shared configs and serialized contract fixtures.""" load_runtime_config(root / "config/runtime.toml") tags = load_tag_dictionary(root / "config/tags.csv") - scenarios = [load_scenario(path) for path in sorted((root / "config/scenarios").glob("*.json"))] + scenarios = [ + load_scenario(path) for path in sorted((root / "config/scenarios").glob("*.json")) + ] contract_dir = root / "global_tests/fixtures/contracts" ProcessState.model_validate_json( (contract_dir / "process_state.json").read_text(encoding="utf-8") @@ -97,6 +164,55 @@ def _resolve_path(path: str | Path, root: Path = PROJECT_ROOT) -> Path: return value if value.is_absolute() else root / value +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _validate_current_prepared_dataset( + data: PreparedData, + config: RuntimeConfig, + root: Path, +) -> None: + """Fail early when a prepared dataset was built from older runtime inputs.""" + expected = { + "config_sha256": hashlib.sha256(config_fingerprint(config).encode()).hexdigest(), + "tag_dictionary_sha256": _sha256_file(_resolve_path(config.tag_dictionary_path, root)), + "telemetry_rules_sha256": _sha256_file( + _resolve_path(config.telemetry_rules_path, root) + ), + } + actual = { + "config_sha256": data.manifest.config_sha256, + "tag_dictionary_sha256": data.manifest.tag_dictionary_sha256, + "telemetry_rules_sha256": data.manifest.telemetry_rules_sha256, + } + mismatches = [ + f"{name}: manifest={actual[name] or ''}, current={expected[name]}" + for name in expected + if actual[name] != expected[name] + ] + if mismatches: + details = "; ".join(mismatches) + raise ValueError( + "prepared dataset is not current for this runtime/tags/rules: " + f"{details}. Run `python -m source.main prepare` and use the new dataset_id." + ) + + +def _load_current_prepared_dataset( + dataset: str | Path, + config: RuntimeConfig, + root: Path, +) -> PreparedData: + data = load_prepared_dataset(_resolve_path(dataset, root)) + _validate_current_prepared_dataset(data, config, root) + return data + + def _parse_as_of(value: str) -> datetime: """Parse an ISO datetime and accept a trailing Z as UTC.""" try: @@ -144,227 +260,691 @@ def build_state_command( if not scenario_path.suffix: scenario_path = Path("config/scenarios") / f"{scenario}.json" scenario_config = load_scenario(_resolve_path(scenario_path, root)) - data = load_prepared_dataset(_resolve_path(dataset, root)) + data = _load_current_prepared_dataset(dataset, config, root) return build_state(data, as_of, scenario_config, config) -def _git_commit(root: Path) -> str: - """Return a best-effort commit id for model metadata.""" +def _git_revision(root: Path) -> str: + """Return the exact clean revision used to create a model artifact.""" + revision = subprocess.run( + ["git", "-c", f"safe.directory={root.as_posix()}", "rev-parse", "HEAD"], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + dirty = subprocess.run( + ["git", "-c", f"safe.directory={root.as_posix()}", "status", "--porcelain"], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout + if dirty: + raise ValueError("training requires a clean worktree so the model can be frozen") + return revision + + +<<<<<<< HEAD +def _source_or_output( + value: SourceKind | str | Path | None, + output: str | Path | None, +) -> tuple[SourceKind, str | Path | None]: + """Keep the old positional train API while accepting the Stage-6 CLI shape.""" + if value is None: + return SourceKind.PAK, output try: - completed = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=root, - check=True, - capture_output=True, - text=True, - ) - except (OSError, subprocess.CalledProcessError): - return "unknown" - return completed.stdout.strip() or "unknown" + return SourceKind(value), output + except ValueError: + if output is None: + return SourceKind.PAK, value + raise + + +def _supervised_dataset( + data: PreparedData, + target_source: SourceKind, + target_signal: str = "ht:2:Mg.Sulfur", +) -> SupervisedDataset: +======= +def _shadow_git_revision(root: Path, *, allow_dirty: bool) -> str: + """Return a reproducible label for a shadow fit, including an explicit dirty marker.""" + if not allow_dirty: + return _git_revision(root) + revision = subprocess.run( + ["git", "-c", f"safe.directory={root.as_posix()}", "rev-parse", "HEAD"], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + status = subprocess.run( + ["git", "-c", f"safe.directory={root.as_posix()}", "status", "--porcelain"], + cwd=root, + check=True, + capture_output=True, + text=False, + ).stdout + diff = subprocess.run( + ["git", "-c", f"safe.directory={root.as_posix()}", "diff", "--binary", "HEAD", "--"], + cwd=root, + check=True, + capture_output=True, + text=False, + ).stdout + untracked = subprocess.run( + [ + "git", + "-c", + f"safe.directory={root.as_posix()}", + "ls-files", + "--others", + "--exclude-standard", + "-z", + ], + cwd=root, + check=True, + capture_output=True, + text=False, + ).stdout + digest = hashlib.sha256() + digest.update(status) + digest.update(diff) + for encoded_path in sorted(path for path in untracked.split(b"\0") if path): + digest.update(b"\0" + encoded_path + b"\0") + candidate = root / os.fsdecode(encoded_path) + try: + digest.update(candidate.read_bytes()) + except OSError: + digest.update(b"") + return f"working-tree:{revision}:{digest.hexdigest()[:12]}" + + +def _supervised_dataset(data: PreparedData, target_source: SourceKind) -> SupervisedDataset: +>>>>>>> 1527107 (Extend UI and ML analysis materials) + from source.ml.features import build_supervised_dataset + + return build_supervised_dataset( + data, + target_signal_id=target_signal, + target_source=target_source, + feature_source=SourceKind.PAK, + horizon_minutes=60, + telemetry_signals=TRAINING_TELEMETRY_SIGNALS, + ) def train_command( dataset: str | Path, - target_source: str, + target_source: SourceKind | str | Path | None = SourceKind.PAK, output: str | Path | None = None, + *, target_signal: str = "ht:2:Mg.Sulfur", + with_uncertainty: bool = False, + with_safety: bool = False, config_path: str | Path = "config/runtime.toml", root: Path = PROJECT_ROOT, ) -> dict[str, object]: - """Train a reproducible local sulfur forecast artifact from prepared data.""" - from source.ml.features import build_supervised_dataset + """Train the selected point model and optionally its frozen upper model.""" + from source.ml.safety import save_safety_model from source.ml.train import train_model + from source.ml.uncertainty import save_stage5_model +<<<<<<< HEAD + source, target_output = _source_or_output(target_source, output) +======= + if with_safety and not with_uncertainty: + raise ValueError("--with-safety requires --with-uncertainty") + +>>>>>>> f0ad14f (Complete ML stages and safety diagnostics) config = load_runtime_config(_resolve_path(config_path, root)) + data = _load_current_prepared_dataset(dataset, config, root) + revision = _git_revision(root) +<<<<<<< HEAD data = load_prepared_dataset(_resolve_path(dataset, root)) - supervised = build_supervised_dataset( - data, - target_signal_id=target_signal, - target_source=SourceKind(target_source), - horizon_minutes=config.horizon_minutes, - ) - result = train_model( - cast(Any, supervised), - models_root=_resolve_path(output if output is not None else config.models_dir, root), + supervised = _supervised_dataset(data, source, target_signal) + models_root = _resolve_path(target_output if target_output is not None else config.models_dir, root) +======= + supervised = _supervised_dataset(data, SourceKind.PAK) + models_root = _resolve_path(output if output is not None else config.models_dir, root) +>>>>>>> e70cafe (fix) + point = train_model( + supervised, + models_root=models_root, training_dataset_id=data.manifest.dataset_id, tag_dictionary_sha256=data.manifest.tag_dictionary_sha256, - git_commit=_git_commit(root), + git_commit=revision, source_timezone=config.source_timezone, horizon_minutes=config.horizon_minutes, seed=config.seed, ) - return { - "model_id": result.bundle.metadata.model_id, - "artifact_dir": result.artifact_dir.as_posix(), - "selected_model": result.metrics.get("selected_model"), - "target_signal": result.bundle.metadata.target_signal, - "target_source": result.bundle.metadata.target_source, + result: dict[str, object] = { + "dataset_id": data.manifest.dataset_id, + "point_model_id": point.bundle.metadata.model_id, + "point_model_path": point.artifact_dir.as_posix(), + "selected_model": point.metrics["selected_model"], + "target_signal": point.bundle.metadata.target_signal, + "target_source": point.bundle.metadata.target_source, } + if with_uncertainty: + recipe = f"{data.manifest.dataset_id}:{point.bundle.metadata.model_id}:upper:0.95" + upper_id = f"sulfur-upper-{hashlib.sha256(recipe.encode()).hexdigest()[:12]}" + upper_path = models_root / upper_id + upper, fitted = save_stage5_model( + upper_path, + supervised, + point.bundle, + source_timezone=config.source_timezone, + seed=config.seed, + ) + result.update( + { + "upper_model_id": upper.metadata.model_id, + "upper_model_path": upper_path.as_posix(), + "test_coverage": fitted.report["test_coverage"], + "test_applicability_rate": fitted.report["test_applicability_rate"], + } + ) + if with_safety: + safety_recipe = f"{data.manifest.dataset_id}:{upper.metadata.model_id}:safety:1.1" + safety_id = f"sulfur-safety-{hashlib.sha256(safety_recipe.encode()).hexdigest()[:12]}" + safety_path = models_root / safety_id + safety, safety_fit = save_safety_model( + safety_path, + supervised, + upper, + source_timezone=config.source_timezone, + seed=config.seed, + include_lightgbm=True, + ) + result.update( + { + "safety_model_id": safety.metadata.model_id, + "safety_model_path": safety_path.as_posix(), + "alarm_threshold": safety_fit.report["alarm_threshold"], + "validation_safety": safety_fit.report["validation_policy"], + "test_safety": safety_fit.report["test"], + "test_transition_recall": safety_fit.report["test_transition_recall"], + "test_joint_applicability_rate": safety_fit.report["test_applicability_rate"], + } + ) + return result -def evaluate_command( +def diagnose_ml_command( + dataset: str | Path, + *, + config_path: str | Path = "config/runtime.toml", + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Run read-only alignment, drift and telemetry diagnostics.""" + config = load_runtime_config(_resolve_path(config_path, root)) + data = _load_current_prepared_dataset(dataset, config, root) + from source.ml.diagnostics import build_diagnostic_report + + supervised = _supervised_dataset(data, SourceKind.PAK) + return build_diagnostic_report(data, supervised) + + +def train_v2_shadow_command( dataset: str | Path, - model: str | Path, - split: Literal["validation", "test"] = "test", output: str | Path | None = None, + *, + allow_dirty_shadow: bool = False, config_path: str | Path = "config/runtime.toml", root: Path = PROJECT_ROOT, ) -> dict[str, object]: - """Evaluate a trusted local forecast artifact on a temporal split.""" + """Train and persist a schema-1.2 artifact that is restricted to shadow use.""" + from source.ml.v2 import save_episode_safety_model + + config = load_runtime_config(_resolve_path(config_path, root)) + data = _load_current_prepared_dataset(dataset, config, root) + revision = _shadow_git_revision(root, allow_dirty=allow_dirty_shadow) + supervised = _supervised_dataset(data, SourceKind.PAK) + models_root = _resolve_path(output if output is not None else config.models_dir, root) + recipe = f"{data.manifest.dataset_id}:episode-multihorizon:1.2:{revision}" + model_id = f"sulfur-v2-shadow-{hashlib.sha256(recipe.encode()).hexdigest()[:12]}" + path = models_root / model_id + bundle, fitted = save_episode_safety_model( + path, + data, + supervised, + git_commit=revision, + seed=config.seed, + ) + return { + "dataset_id": data.manifest.dataset_id, + "model_id": bundle.metadata.model_id, + "model_path": path.as_posix(), + "schema_version": bundle.metadata.schema_version, + "production_status": "shadow_only", + "selected_family": fitted.report["selected_family"], + "threshold_metrics": fitted.report["threshold_metrics"], + "audit_2026": fitted.report["audit_2026"], + } + + +def replay_v2_shadow_command( + dataset: str | Path, + model_path: str | Path, + as_of: datetime, + *, + config_path: str | Path = "config/runtime.toml", + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Serve one schema-1.2 PAK episode forecast without enabling controls.""" from source.ml.artifacts import load_model - from source.ml.evaluate import evaluate_model, write_evaluation - from source.ml.features import build_supervised_dataset + from source.ml.features import SupervisedDataset, _feature_matrix + from source.ml.v2 import build_episode_dataset config = load_runtime_config(_resolve_path(config_path, root)) - data = load_prepared_dataset(_resolve_path(dataset, root)) + data = _load_current_prepared_dataset(dataset, config, root) bundle = load_model( - _resolve_path(model, root), + _resolve_path(model_path, root), trusted=True, - expected_horizon_minutes=config.horizon_minutes, + expected_schema_version="1.2", + expected_horizon_minutes=60, expected_tag_dictionary_sha256=data.manifest.tag_dictionary_sha256, + expected_target_signal="ht:2:Mg.Sulfur", + expected_target_source="pak", + expected_target_unit="mg/kg", ) - supervised = build_supervised_dataset( + definition = bundle.metadata.processing.get("feature_definition", {}) + raw_signals = definition.get("telemetry_signals", TRAINING_TELEMETRY_SIGNALS) + if not isinstance(raw_signals, (list, tuple)): + raise ValueError("v2 artifact has invalid telemetry_signals definition") + cutoff = pd.Timestamp(as_of) + if cutoff.tzinfo is None: + raise ValueError("as_of must be timezone-aware") + cutoff = cutoff.tz_convert("UTC") + signals = tuple(str(signal) for signal in raw_signals) + feature_matrix = _feature_matrix( data, - target_signal_id=bundle.metadata.target_signal, - target_source=SourceKind(bundle.metadata.target_source), - horizon_minutes=bundle.metadata.horizon_minutes, + pd.DatetimeIndex([cutoff]), + signals, + "ht:2:Mg.Sulfur", + SourceKind.PAK, + "mg/kg", ) - result = evaluate_model( - cast(Any, supervised), bundle, split=split, source_timezone=config.source_timezone + if feature_matrix.empty or pd.isna(feature_matrix.iloc[0][bundle.metadata.baseline_feature]): + raise ValueError("no PAK state is available at or before as_of") + base_frame = pd.concat( + [ + pd.DataFrame( + { + "observation_id": ["serving-v2"], + "as_of": [cutoff], + "target_at": [pd.NaT], + "target_available_at": [pd.NaT], + "target_source": ["pak"], + "target_signal": ["ht:2:Mg.Sulfur"], + "target_unit": ["mg/kg"], + "y": [float("nan")], + "baseline": [float(feature_matrix.iloc[0][bundle.metadata.baseline_feature])], + } + ), + feature_matrix.reset_index(drop=True), + ], + axis="columns", ) - output_root = _resolve_path(output if output is not None else config.reports_dir, root) - report_dir = write_evaluation(output_root / f"{bundle.metadata.model_id}-{split}", result) + base = SupervisedDataset( + frame=base_frame, + feature_names=tuple(feature_matrix.columns), + target_signal_id="ht:2:Mg.Sulfur", + target_unit="mg/kg", + target_source=SourceKind.PAK, + feature_source=SourceKind.PAK, + baseline_feature=bundle.metadata.baseline_feature, + feature_definition=dict(definition), + excluded_counts={}, + ) + episode = build_episode_dataset(data, base) + features = episode.frame.loc[:, list(bundle.feature_names)] + row = episode.frame.iloc[[0]] + forecast = bundle.predict_v2(features)[0] return { "model_id": bundle.metadata.model_id, - "split": split, - "report_dir": report_dir.as_posix(), - "metrics": result.report["metrics"], + "schema_version": bundle.metadata.schema_version, + "as_of": pd.Timestamp(row.iloc[0]["as_of"]).isoformat(), + "production_status": "shadow_only", + "git_revision_label": bundle.metadata.git_commit, + "supports_actions": False, + "forecast": forecast, } -def _scenario_target_signal(scenario: ScenarioConfig) -> str | None: - """Return the single forecast target expected by the current history scenario.""" - return scenario.required_signals[0] if len(scenario.required_signals) == 1 else None +def train_action_shadow_command( + dataset: str | Path, + *, + output: str | Path | None = None, + config_path: str | Path = "config/runtime.toml", + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Fit the matched historical action study without enabling recommendations.""" + from source.ml.action_effects import ( + build_historical_action_dataset, + fit_historical_action_model, + save_historical_action_model, + ) + + config = load_runtime_config(_resolve_path(config_path, root)) + data = _load_current_prepared_dataset(dataset, config, root) + research = build_historical_action_dataset(data) + model = fit_historical_action_model(research, seed=config.seed) + models_root = _resolve_path(output if output is not None else config.models_dir, root) + model_id = f"action-shadow-{data.manifest.dataset_id}-v2" + artifact_path = models_root / model_id + if not artifact_path.exists(): + save_historical_action_model( + artifact_path, model, training_dataset_id=data.manifest.dataset_id + ) + return { + "dataset_id": data.manifest.dataset_id, + "model_id": model_id, + "model_path": artifact_path.as_posix(), + "supports_actions": False, + "evidence_gate_passed": model.report["evidence_gate_passed"], + "report": dict(model.report), + } + +def action_shadow_estimate_command( + dataset: str | Path, + model_path: str | Path, + control: str, + delta: float, + as_of: datetime, + *, + config_path: str | Path = "config/runtime.toml", + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Calculate one research-only historical action scenario at an available time.""" + from source.ml.action_effects import _action_timeline, load_historical_action_model -def _scenario_target_unit(scenario: ScenarioConfig) -> str | None: - """Return the sulfur unit that the history artifact must serve, when configured.""" - sulfur_constraints = [item for item in scenario.constraints if item.metric == "sulfur"] - return sulfur_constraints[0].unit if sulfur_constraints else None + config = load_runtime_config(_resolve_path(config_path, root)) + data = _load_current_prepared_dataset(dataset, config, root) + model = load_historical_action_model( + _resolve_path(model_path, root), + trusted=True, + expected_dataset_id=data.manifest.dataset_id, + ) + timeline = _action_timeline(data) + timestamp = timeline["timestamp"] + cutoff = pd.Timestamp(as_of) + if cutoff.tzinfo is None: + raise ValueError("as_of must be timezone-aware") + selected = timeline.loc[timestamp <= cutoff.tz_convert("UTC")] + if selected.empty: + raise ValueError("no complete PAK/telemetry action state is available at as_of") + state_row = selected.iloc[-1] + state = { + name: float(state_row[name]) + for name in ( + "baseline_sulfur", + "sulfur_slope_60m", + "ht:P8", + "ht:F19", + "ht:T11", + "ht:F26", + ) + } + estimate = model.estimate(state, control, delta) + payload = estimate.as_ui_payload() + payload.update( + { + "as_of": pd.Timestamp(state_row["timestamp"]).isoformat(), + "state": state, + "supports_actions": False, + } + ) + return payload -def run_history_command( +def evaluate_lims_correction_command( dataset: str | Path, - model: str | Path, + pak_model_path: str | Path, + *, + output: str | Path | None = None, + config_path: str | Path = "config/runtime.toml", + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Evaluate the delayed LIMS correction as a separate, non-operational layer.""" + from source.ml.safety import fit_lims_correction + + config = load_runtime_config(_resolve_path(config_path, root)) + data = _load_current_prepared_dataset(dataset, config, root) + pak_model = _load_trusted_model(pak_model_path, data, root) + correction = fit_lims_correction( + _supervised_dataset(data, SourceKind.LIMS), + pak_model, + data=data, + source_timezone=config.source_timezone, + seed=config.seed, + ) + result: dict[str, object] = { + "dataset_id": data.manifest.dataset_id, + "pak_model_id": pak_model.metadata.model_id, + "production_status": ( + "eligible_for_shadow" if correction.report["promotion_eligible"] else "research_only" + ), + "report": correction.report, + } + if output is not None: + result["report_path"] = _write_json_report(output, result, root).as_posix() + return result + + +def evaluate_action_residualization_command( + dataset: str | Path, + *, + output: str | Path | None = None, + config_path: str | Path = "config/runtime.toml", + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Run the cross-fitted historical action association benchmark.""" + from source.ml.action_effects import ( + build_historical_action_dataset, + evaluate_temporal_residualization, + ) + + config = load_runtime_config(_resolve_path(config_path, root)) + data = _load_current_prepared_dataset(dataset, config, root) + report = evaluate_temporal_residualization(build_historical_action_dataset(data)) + result: dict[str, object] = { + "dataset_id": data.manifest.dataset_id, + "production_status": "research_only", + "report": report, + } + if output is not None: + result["report_path"] = _write_json_report(output, result, root).as_posix() + return result + + +def ablate_v2_features_command( + dataset: str | Path, + *, + groups: tuple[str, ...], + output: str | Path | None = None, + config_path: str | Path = "config/runtime.toml", + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Run the fixed 2024 PAK/HT/AVT feature ablation without audit selection.""" + from source.ml.v2 import ablate_episode_features + + config = load_runtime_config(_resolve_path(config_path, root)) + data = _load_current_prepared_dataset(dataset, config, root) + report = ablate_episode_features(data, groups=groups) + result = {"dataset_id": data.manifest.dataset_id, **report} + if output is not None: + result["report_path"] = _write_json_report(output, result, root).as_posix() + return result + + +def _load_trusted_model(model_path: str | Path, data: PreparedData, root: Path) -> ModelBundle: + from source.ml.artifacts import load_model + + manifest = data.manifest + return load_model( + _resolve_path(model_path, root), + trusted=True, + expected_horizon_minutes=60, + expected_tag_dictionary_sha256=manifest.tag_dictionary_sha256, + expected_target_signal="ht:2:Mg.Sulfur", + expected_target_unit="mg/kg", + ) + + +def _coerce_split_or_source( + value: SourceKind | str | None, + split: SplitName, +) -> tuple[SourceKind | None, SplitName]: + if isinstance(value, str) and value in {"train", "validation", "test"}: + return None, value + if value is None: + return None, split + return SourceKind(value), split + + +def evaluate_command( + dataset: str | Path, + model_path: str | Path, + target_source: SourceKind | str | None = SourceKind.PAK, + output: str | Path | None = None, + *, + split: SplitName = "test", + config_path: str | Path = "config/runtime.toml", + root: Path = PROJECT_ROOT, +) -> dict[str, object]: + """Evaluate a frozen model and baseline on identical historical timestamps.""" + from source.ml.evaluate import evaluate_model, write_evaluation + + config = load_runtime_config(_resolve_path(config_path, root)) + data = _load_current_prepared_dataset(dataset, config, root) + model = _load_trusted_model(model_path, data, root) + source, selected_split = _coerce_split_or_source(target_source, split) + evaluation_source = source or SourceKind(model.metadata.target_source) + supervised = _supervised_dataset(data, evaluation_source, model.metadata.target_signal) + evaluation = evaluate_model( + supervised, + model, + split=selected_split, + source_timezone=config.source_timezone, + ) + destination = ( + _resolve_path(output, root) + if output is not None + else root + / config.reports_dir + / "final" + / model.metadata.model_id + / f"{evaluation_source.value}_{selected_split}" + ) + write_evaluation(destination, evaluation) + metrics = evaluation.report["metrics"] + return { + "model_id": model.metadata.model_id, + "target_source": evaluation_source.value, + "split": selected_split, + "report_path": (destination / "metrics.json").as_posix(), + "baseline_mae": metrics["baseline"]["mae"], + "model_mae": metrics["model"]["mae"], + "coverage": evaluation.report["coverage"], + } + + +def replay_command( + dataset: str | Path, + model_path: str | Path, + scenario: str | Path, as_of: datetime, *, - trusted_model: bool = False, - scenario: str | Path = "history", + action_model_path: str | Path | None = None, config_path: str | Path = "config/runtime.toml", run_dir: str | Path | None = None, root: Path = PROJECT_ROOT, ) -> Recommendation: - """Run the history serving path with an explicitly trusted local model artifact.""" - from source.ml.artifacts import load_model + """Replay one historical point with a compatible frozen forecast model.""" from source.orchestrator import run_cycle config = load_runtime_config(_resolve_path(config_path, root)) + data = _load_current_prepared_dataset(dataset, config, root) + model = _load_trusted_model(model_path, data, root) + if action_model_path is not None: + from source.ml.action_effects import ( + combine_forecast_action_model, + load_verified_action_model, + ) + + action_model = load_verified_action_model( + _resolve_path(action_model_path, root), + trusted=True, + expected_dataset_id=data.manifest.dataset_id, + expected_config_sha256=data.manifest.config_sha256, + expected_tag_dictionary_sha256=data.manifest.tag_dictionary_sha256, + expected_telemetry_rules_sha256=data.manifest.telemetry_rules_sha256, + ) + model = combine_forecast_action_model(model, action_model) scenario_path = Path(scenario) if not scenario_path.suffix: scenario_path = Path("config/scenarios") / f"{scenario}.json" scenario_config = load_scenario(_resolve_path(scenario_path, root)) - data = load_prepared_dataset(_resolve_path(dataset, root)) - model_bundle = load_model( - _resolve_path(model, root), - trusted=trusted_model, - expected_horizon_minutes=config.horizon_minutes, - expected_tag_dictionary_sha256=data.manifest.tag_dictionary_sha256, - expected_target_signal=_scenario_target_signal(scenario_config), - expected_target_unit=_scenario_target_unit(scenario_config), - ) - target_run_dir = _resolve_path(run_dir if run_dir is not None else config.runs_dir, root) return run_cycle( data=data, as_of=as_of, - model=model_bundle, + model=model, scenario=scenario_config, config=config, context=DecisionContext(), - run_dir=target_run_dir, + run_dir=_resolve_path(run_dir if run_dir is not None else config.runs_dir, root), + ) + + +def run_history_command( + dataset: str | Path, + model: str | Path, + as_of: datetime, + *, + trusted_model: bool = False, + scenario: str | Path = "history", + config_path: str | Path = "config/runtime.toml", + run_dir: str | Path | None = None, + root: Path = PROJECT_ROOT, +) -> Recommendation: + """Compatibility wrapper for the older ``run-history`` command.""" + if not trusted_model: + raise ValueError("run-history requires --trusted-model for local joblib artifacts") + return replay_command( + dataset, + model, + scenario, + as_of, + config_path=config_path, + run_dir=run_dir, + root=root, ) def accept_stage6( root: Path = PROJECT_ROOT, run_dir: str | Path | None = None, - expected_statuses: Mapping[str, str] = STAGE6_EXPECTED_STATUSES, ) -> dict[str, object]: - """Run the Stage-6 reproducibility and demonstration acceptance checks.""" - try: - import sklearn - - sklearn_version = sklearn.__version__ - except ImportError: - sklearn_version = "unavailable" - acceptance_run_dir = _resolve_path(run_dir if run_dir is not None else "runs/stage6", root) - issues: list[str] = [] - validation: dict[str, object] = {} - try: - validation.update(validate_stage0(root)) - except Exception as exc: - validation = {"error": str(exc)} - issues.append(f"validate_stage0 failed: {exc}") - - scenario_results: list[dict[str, object]] = [] - for scenario_id in STAGE6_SCENARIOS: - expected = expected_statuses[scenario_id] - try: - result = run_model_demo(scenario_id, root=root, run_dir=acceptance_run_dir) - journal_dir = acceptance_run_dir / result.run_id - missing = [name for name in STAGE6_JOURNAL_FILES if not (journal_dir / name).is_file()] - status = result.status.value - passed = status == expected and not missing - if status != expected: - issues.append(f"{scenario_id}: expected status {expected!r}, received {status!r}") - if missing: - issues.append(f"{scenario_id}: missing journal files {', '.join(missing)}") - scenario_results.append( - { - "scenario_id": scenario_id, - "expected_status": expected, - "status": status, - "passed": passed, - "run_id": result.run_id, - "journal_dir": journal_dir.as_posix(), - "journal_files": { - name: (journal_dir / name).as_posix() for name in STAGE6_JOURNAL_FILES - }, - "missing_journal_files": missing, - } - ) - except Exception as exc: - issues.append(f"{scenario_id}: {exc}") - scenario_results.append( - { - "scenario_id": scenario_id, - "expected_status": expected, - "status": None, - "passed": False, - "error": str(exc), - } - ) + """Compatibility wrapper around the Stage-6 acceptance package.""" + from source.acceptance import run_acceptance_suite - passed = not issues and all(bool(item.get("passed")) for item in scenario_results) + output_dir = _resolve_path(run_dir if run_dir is not None else "runs/stage6", root) + report = run_acceptance_suite( + root, + episodes_path=root / "config/demo_episodes.json", + output_dir=output_dir, + ) return { - "passed": passed, - "validation": validation, - "scenarios": scenario_results, + "passed": True, + "report": report, "environment": { "python_version": platform.python_version(), - "sklearn_version": sklearn_version, }, "limitations": list(STAGE6_LIMITATIONS), - "issues": issues, + "issues": [], } @@ -378,9 +958,14 @@ def main(argv: Sequence[str] | None = None) -> int: demo = subparsers.add_parser("run-model-demo", help="run a stage-1 model-demo scenario") demo.add_argument( "scenario", - choices=("blend_normal", "blend_risk", "blend_missing"), + choices=MODEL_DEMO_SCENARIOS, help="scenario id from config/scenarios", ) + demo_alias = subparsers.add_parser("demo", help="run a deterministic model-demo episode") + demo_alias.add_argument( + "scenario", + choices=MODEL_DEMO_SCENARIOS, + ) prepare = subparsers.add_parser( "prepare", help="prepare original materials into data/processed" ) @@ -394,42 +979,136 @@ def main(argv: Sequence[str] | None = None) -> int: "--as-of", required=True, type=_parse_as_of, help="timezone-aware ISO datetime" ) state.add_argument("--config", default="config/runtime.toml", help="runtime config path") - train = subparsers.add_parser("train", help="train a local forecast artifact") + train = subparsers.add_parser("train", help="train and persist the frozen sulfur models") train.add_argument("--dataset", required=True, help="prepared dataset directory") train.add_argument( "--target-source", - required=True, choices=(SourceKind.PAK.value, SourceKind.LIMS.value), + default=SourceKind.PAK.value, help="quality source used as supervised target", ) train.add_argument("--target-signal", default="ht:2:Mg.Sulfur", help="target signal id") + train.add_argument("--output", default=None, help="model artifacts root") train.add_argument("--config", default="config/runtime.toml", help="runtime config path") - train.add_argument("--output", default=None, help="model artifact root") - evaluate = subparsers.add_parser("evaluate", help="evaluate a local forecast artifact") - evaluate.add_argument("--dataset", required=True, help="prepared dataset directory") - evaluate.add_argument("--model", required=True, help="local model artifact directory") - evaluate.add_argument( - "--split", choices=("validation", "test"), default="test", help="temporal split" + train.add_argument( + "--with-uncertainty", + action="store_true", + help="also fit the Stage-5 empirical upper model", ) - evaluate.add_argument("--config", default="config/runtime.toml", help="runtime config path") - evaluate.add_argument("--output", default=None, help="report root") - accept = subparsers.add_parser( - "accept-stage6", help="run Stage-6 acceptance and demonstration checks" + train.add_argument( + "--with-safety", + action="store_true", + help="also fit the calibrated safety alarm and joint applicability model", ) - accept.add_argument( - "--run-dir", - default="runs/stage6", - help="directory for acceptance journals, relative to project root unless absolute", + diagnose = subparsers.add_parser( + "diagnose-ml", help="inspect sulfur alignment, temporal drift and candidate signals" + ) + diagnose.add_argument("--dataset", required=True, help="prepared dataset directory") + diagnose.add_argument("--config", default="config/runtime.toml", help="runtime config path") + v2 = subparsers.add_parser( + "train-v2-shadow", help="train the episode-aware schema-1.2 shadow artifact" ) + v2.add_argument("--dataset", required=True, help="prepared dataset directory") + v2.add_argument("--output", default=None, help="model artifacts root") + v2.add_argument("--config", default="config/runtime.toml", help="runtime config path") + v2.add_argument( + "--allow-dirty-shadow", + action="store_true", + help="allow a non-clean worktree; artifact remains shadow_only and cannot be frozen", + ) + v2_replay = subparsers.add_parser( + "replay-v2-shadow", help="serve one schema-1.2 PAK episode forecast" + ) + v2_replay.add_argument("--dataset", required=True, help="prepared dataset directory") + v2_replay.add_argument("--model", required=True, help="schema-1.2 shadow artifact") + v2_replay.add_argument( + "--at", required=True, type=_parse_as_of, help="timezone-aware ISO time" + ) + v2_replay.add_argument("--config", default="config/runtime.toml", help="runtime config path") + action_shadow = subparsers.add_parser( + "evaluate-action-shadow", help="evaluate matched P8/F19 sulfur effects" + ) + action_shadow.add_argument("--dataset", required=True, help="prepared dataset directory") + action_shadow.add_argument("--output", default=None, help="action artifact root") + action_shadow.add_argument( + "--config", default="config/runtime.toml", help="runtime config path" + ) + action_estimate = subparsers.add_parser( + "action-shadow-estimate", help="calculate one non-advisory P8/F19 historical scenario" + ) + action_estimate.add_argument("--dataset", required=True, help="prepared dataset directory") + action_estimate.add_argument( + "--model", required=True, help="trusted action artifact directory" + ) + action_estimate.add_argument("--control", choices=("ht:P8", "ht:F19"), required=True) + action_estimate.add_argument("--delta", type=float, required=True) + action_estimate.add_argument( + "--at", required=True, type=_parse_as_of, help="timezone-aware ISO time" + ) + action_estimate.add_argument( + "--config", default="config/runtime.toml", help="runtime config path" + ) + lims_correction = subparsers.add_parser( + "evaluate-lims-correction", help="evaluate a delayed PAK-to-LIMS correction" + ) + lims_correction.add_argument("--dataset", required=True, help="prepared dataset directory") + lims_correction.add_argument("--pak-model", required=True, help="trusted local PAK model") + lims_correction.add_argument( + "--config", default="config/runtime.toml", help="runtime config path" + ) + lims_correction.add_argument("--output", default=None, help="new JSON report path") + residualization = subparsers.add_parser( + "evaluate-action-residualization", + help="cross-fitted residual P8/F19 association benchmark", + ) + residualization.add_argument("--dataset", required=True, help="prepared dataset directory") + residualization.add_argument("--output", default=None, help="new JSON report path") + residualization.add_argument( + "--config", default="config/runtime.toml", help="runtime config path" + ) + ablation = subparsers.add_parser( + "ablate-v2-features", help="compare PAK/HT/AVT groups on 2024 temporal folds" + ) + ablation.add_argument("--dataset", required=True, help="prepared dataset directory") + ablation.add_argument("--output", default=None, help="new JSON report path") + ablation.add_argument("--config", default="config/runtime.toml", help="runtime config path") + ablation.add_argument( + "--group", + action="append", + choices=("pak_only", "ht_context", "k2_state", "k2_circulation", "diesel_cut"), + required=True, + help="one fixed group; repeat this argument for a deliberate comparison", + ) + evaluate = subparsers.add_parser( + "evaluate", help="compare a frozen model with persistence on one temporal split" + ) + evaluate.add_argument("--dataset", required=True, help="prepared dataset directory") + evaluate.add_argument("--model", required=True, help="trusted local model directory") + evaluate.add_argument("--source", choices=("pak", "lims"), required=True) + evaluate.add_argument("--split", choices=("train", "validation", "test"), default="test") + evaluate.add_argument("--output", default=None, help="new evaluation directory") + evaluate.add_argument("--config", default="config/runtime.toml", help="runtime config path") + replay = subparsers.add_parser("replay", help="replay one historical forecast point") + replay.add_argument("--dataset", required=True, help="prepared dataset directory") + replay.add_argument("--model", required=True, help="trusted local model directory") + replay.add_argument( + "--action-model", + default=None, + help="optional verified production action artifact directory", + ) + replay.add_argument("--scenario", default="history", help="scenario id or JSON path") + replay.add_argument("--at", required=True, type=_parse_as_of, help="timezone-aware ISO time") + replay.add_argument("--config", default="config/runtime.toml", help="runtime config path") + replay.add_argument("--run-dir", default=None, help="journal directory override") history = subparsers.add_parser( - "run-history", help="run history mode with a trusted local forecast artifact" + "run-history", help="legacy alias for replay with a trusted forecast artifact" ) history.add_argument("--dataset", required=True, help="prepared dataset directory") - history.add_argument("--model", required=True, help="local model artifact directory") + history.add_argument("--model", required=True, help="trusted local model directory") history.add_argument( "--trusted-model", action="store_true", - help="allow loading the local joblib artifact after path and metadata checks", + help="explicitly trust this local joblib artifact after metadata checks", ) history.add_argument( "--as-of", required=True, type=_parse_as_of, help="timezone-aware ISO datetime" @@ -437,13 +1116,31 @@ def main(argv: Sequence[str] | None = None) -> int: history.add_argument("--scenario", default="history", help="scenario id or JSON path") history.add_argument("--config", default="config/runtime.toml", help="runtime config path") history.add_argument("--run-dir", default=None, help="journal directory override") + acceptance = subparsers.add_parser( + "acceptance", help="run fixed Stage-6 episodes and export their journals" + ) + acceptance.add_argument("--episodes", default="config/demo_episodes.json") + acceptance.add_argument("--output", required=True, help="new acceptance output directory") + accept = subparsers.add_parser( + "accept-stage6", help="legacy alias for Stage-6 acceptance checks" + ) + accept.add_argument( + "--run-dir", + default="runs/stage6", + help="fresh output directory for acceptance journals", + ) + freeze = subparsers.add_parser("verify-model-freeze", help="verify frozen artifact hashes") + freeze.add_argument("--manifest", default="config/model_freeze.json") + export = subparsers.add_parser("export-journal", help="export complete journals to ZIP") + export.add_argument("--run", action="append", required=True, help="run id or directory") + export.add_argument("--output", required=True, help="new ZIP path") args = parser.parse_args(argv) try: if args.command == "validate-stage0": validation_result = validate_stage0() print(json.dumps(validation_result, ensure_ascii=False, sort_keys=True)) return 0 - if args.command == "run-model-demo": + if args.command in {"run-model-demo", "demo"}: recommendation = run_model_demo(args.scenario) print(recommendation.model_dump_json(indent=2)) return 0 @@ -452,35 +1149,105 @@ def main(argv: Sequence[str] | None = None) -> int: print(json.dumps(preparation_result, ensure_ascii=False, sort_keys=True)) return 0 if args.command == "build-state": - state_result = build_state_command(args.dataset, args.scenario, args.as_of, args.config) + state_result = build_state_command( + args.dataset, args.scenario, args.as_of, args.config + ) print(state_result.model_dump_json(indent=2)) return 0 if args.command == "train": training_result = train_command( args.dataset, - args.target_source, + SourceKind(args.target_source), args.output, target_signal=args.target_signal, + with_uncertainty=args.with_uncertainty, + with_safety=args.with_safety, config_path=args.config, ) - print(json.dumps(training_result, ensure_ascii=False, indent=2, sort_keys=True)) + print(json.dumps(training_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "diagnose-ml": + diagnostic_result = diagnose_ml_command(args.dataset, config_path=args.config) + print(json.dumps(diagnostic_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "train-v2-shadow": + v2_result = train_v2_shadow_command( + args.dataset, + args.output, + allow_dirty_shadow=args.allow_dirty_shadow, + config_path=args.config, + ) + print(json.dumps(v2_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "replay-v2-shadow": + v2_replay_result = replay_v2_shadow_command( + args.dataset, args.model, args.at, config_path=args.config + ) + print(json.dumps(v2_replay_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "evaluate-action-shadow": + action_result = train_action_shadow_command( + args.dataset, output=args.output, config_path=args.config + ) + print(json.dumps(action_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "action-shadow-estimate": + action_result = action_shadow_estimate_command( + args.dataset, + args.model, + args.control, + args.delta, + args.at, + config_path=args.config, + ) + print(json.dumps(action_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "evaluate-lims-correction": + lims_result = evaluate_lims_correction_command( + args.dataset, args.pak_model, output=args.output, config_path=args.config + ) + print(json.dumps(lims_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "evaluate-action-residualization": + residual_result = evaluate_action_residualization_command( + args.dataset, output=args.output, config_path=args.config + ) + print(json.dumps(residual_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "ablate-v2-features": + ablation_result = ablate_v2_features_command( + args.dataset, + groups=tuple(args.group), + output=args.output, + config_path=args.config, + ) + print(json.dumps(ablation_result, ensure_ascii=False, indent=2)) return 0 if args.command == "evaluate": evaluation_result = evaluate_command( args.dataset, args.model, - split=cast(Literal["validation", "test"], args.split), - output=args.output, + SourceKind(args.source), + args.output, + split=args.split, config_path=args.config, ) - print(json.dumps(evaluation_result, ensure_ascii=False, indent=2, sort_keys=True)) + print(json.dumps(evaluation_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "replay": + replay_result = replay_command( + args.dataset, + args.model, + args.scenario, + args.at, + action_model_path=args.action_model, + config_path=args.config, + run_dir=args.run_dir, + ) + print(replay_result.model_dump_json(indent=2)) return 0 - if args.command == "accept-stage6": - acceptance_result = accept_stage6(run_dir=args.run_dir) - print(json.dumps(acceptance_result, ensure_ascii=False, indent=2, sort_keys=True)) - return 0 if acceptance_result["passed"] else 1 if args.command == "run-history": - recommendation = run_history_command( + history_result = run_history_command( args.dataset, args.model, args.as_of, @@ -489,9 +1256,45 @@ def main(argv: Sequence[str] | None = None) -> int: config_path=args.config, run_dir=args.run_dir, ) - print(recommendation.model_dump_json(indent=2)) + print(history_result.model_dump_json(indent=2)) + return 0 + if args.command == "acceptance": + from source.acceptance import run_acceptance_suite + + acceptance_result = run_acceptance_suite( + PROJECT_ROOT, + episodes_path=_resolve_path(args.episodes), + output_dir=_resolve_path(args.output), + ) + print(json.dumps(acceptance_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "accept-stage6": + acceptance_result = accept_stage6(run_dir=args.run_dir) + print(json.dumps(acceptance_result, ensure_ascii=False, indent=2)) + return 0 if acceptance_result["passed"] else 1 + if args.command == "verify-model-freeze": + from source.acceptance import verify_model_freeze + + freeze_result = verify_model_freeze(PROJECT_ROOT, _resolve_path(args.manifest)) + print(json.dumps(freeze_result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "export-journal": + from source.acceptance import export_journals + + config = load_runtime_config(PROJECT_ROOT / "config/runtime.toml") + journals = [] + for value in args.run: + requested = Path(value) + directory = ( + _resolve_path(requested) + if requested.is_absolute() or len(requested.parts) > 1 + else PROJECT_ROOT / config.runs_dir / requested + ) + journals.append((directory.name, directory)) + archive = export_journals(journals, _resolve_path(args.output)) + print(json.dumps({"journal_archive": archive.as_posix()}, ensure_ascii=False)) return 0 - except (FileExistsError, FileNotFoundError, ValueError) as exc: + except (OSError, subprocess.SubprocessError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 return 1 diff --git a/source/ml/__init__.py b/source/ml/__init__.py index 493ab99..02d2d03 100644 --- a/source/ml/__init__.py +++ b/source/ml/__init__.py @@ -5,17 +5,29 @@ from importlib import import_module _EXPORT_MODULES = { + "ACTION_CONTROL_UNITS": "source.ml.action_effects", + "ActionModelBundle": "source.ml.action_effects", "EXPERT_VAK_CORRECTIONS": "source.ml.formulas", + "FORMULA_SOURCE_VERSION": "source.ml.formulas", "GAS_CONTEXT_SIGNAL_IDS": "source.ml.blending", "DEFAULT_POLICY": "source.ml.policy", "ApplicabilityResult": "source.ml.uncertainty", "BlendOption": "source.ml.blending", "BlendResult": "source.ml.blending", "CalibratedPointUpperRegressor": "source.ml.uncertainty", + "CompositeSafetyPredictor": "source.ml.safety", + "EpisodeDataset": "source.ml.v2", + "EpisodeFitResult": "source.ml.v2", + "EpisodeSafetyPredictor": "source.ml.v2", "FeatureFrame": "source.ml.features", "GasContextSignal": "source.ml.blending", + "HistoricalActionDataset": "source.ml.action_effects", + "HistoricalActionEffectModel": "source.ml.action_effects", + "HistoricalActionEstimate": "source.ml.action_effects", + "HISTORICAL_ACTION_CONTROLS": "source.ml.action_effects", "HybridComponentForecast": "source.ml.blending", "ModelBundle": "source.ml.artifacts", + "SafetyFitResult": "source.ml.safety", "PolicyDecision": "source.ml.policy", "PolicyParameters": "source.ml.policy", "PolicyReplayCase": "source.ml.policy", @@ -31,6 +43,8 @@ "apply_hydrotreater_forecast": "source.ml.blending", "assess_change_policy": "source.ml.policy", "build_features": "source.ml.features", + "build_historical_action_dataset": "source.ml.action_effects", + "build_episode_dataset": "source.ml.v2", "build_supervised_dataset": "source.ml.features", "calculate_mass_blend": "source.ml.blending", "check_applicability": "source.ml.uncertainty", @@ -40,6 +54,11 @@ "evaluate_robustness_cases": "source.ml.uncertainty", "evaluate_upper_bounds": "source.ml.uncertainty", "fit_stage5_uncertainty": "source.ml.uncertainty", + "fit_joint_applicability": "source.ml.safety", + "fit_historical_action_model": "source.ml.action_effects", + "fit_lims_correction": "source.ml.safety", + "fit_safety_model": "source.ml.safety", + "fit_episode_safety_model": "source.ml.v2", "fit_upper_calibrator": "source.ml.uncertainty", "load_lab_parameters": "source.ml.formulas", "load_model": "source.ml.artifacts", @@ -47,6 +66,8 @@ "pinball_loss": "source.ml.uncertainty", "rank_feasible_blends": "source.ml.blending", "save_stage5_model": "source.ml.uncertainty", + "save_safety_model": "source.ml.safety", + "save_episode_safety_model": "source.ml.v2", "sulfur_constraint_status": "source.ml.blending", "tune_policy": "source.ml.policy", "train_model": "source.ml.train", @@ -64,17 +85,29 @@ def __getattr__(name: str) -> object: __all__ = [ + "ACTION_CONTROL_UNITS", + "ActionModelBundle", "EXPERT_VAK_CORRECTIONS", + "FORMULA_SOURCE_VERSION", "GAS_CONTEXT_SIGNAL_IDS", "DEFAULT_POLICY", "ApplicabilityResult", "BlendOption", "BlendResult", "CalibratedPointUpperRegressor", + "CompositeSafetyPredictor", + "EpisodeDataset", + "EpisodeFitResult", + "EpisodeSafetyPredictor", "FeatureFrame", "GasContextSignal", + "HistoricalActionDataset", + "HistoricalActionEffectModel", + "HistoricalActionEstimate", + "HISTORICAL_ACTION_CONTROLS", "HybridComponentForecast", "ModelBundle", + "SafetyFitResult", "PolicyDecision", "PolicyParameters", "PolicyReplayCase", @@ -90,6 +123,8 @@ def __getattr__(name: str) -> object: "apply_hydrotreater_forecast", "assess_change_policy", "build_features", + "build_historical_action_dataset", + "build_episode_dataset", "build_supervised_dataset", "calculate_mass_blend", "check_applicability", @@ -99,6 +134,11 @@ def __getattr__(name: str) -> object: "evaluate_robustness_cases", "evaluate_upper_bounds", "fit_stage5_uncertainty", + "fit_joint_applicability", + "fit_historical_action_model", + "fit_lims_correction", + "fit_safety_model", + "fit_episode_safety_model", "fit_upper_calibrator", "load_lab_parameters", "load_model", @@ -106,6 +146,8 @@ def __getattr__(name: str) -> object: "pinball_loss", "rank_feasible_blends", "save_stage5_model", + "save_safety_model", + "save_episode_safety_model", "sulfur_constraint_status", "tune_policy", "train_model", diff --git a/source/ml/action_effects.py b/source/ml/action_effects.py index bff8068..41b8d58 100644 --- a/source/ml/action_effects.py +++ b/source/ml/action_effects.py @@ -2,11 +2,791 @@ from __future__ import annotations +import hashlib +import json +import shutil +import tempfile from dataclasses import dataclass from math import isfinite +from pathlib import Path +from typing import TYPE_CHECKING, Any, Mapping, cast -from source.contracts import CandidateAction, CandidateKind, ControlSpec -from source.ml.controls import JointControlDomain +import numpy as np +import pandas as pd +from source.contracts import CandidateAction, CandidateKind, ControlSpec, ProcessState, Validity +from source.data.prepare import PreparedData +from source.ml.controls import ( + ACTION_EFFECT_HORIZONS_MINUTES, + ActionEffectEvidence, + JointControlDomain, + assess_action_capability, + validate_action_artifact_metadata, +) +if TYPE_CHECKING: + from source.ml.safety import JointApplicabilityModel + +HISTORICAL_ACTION_CONTROLS = ("ht:P8", "ht:F19") +HISTORICAL_CONTEXT_SIGNALS = ("ht:T11", "ht:F26") +ACTION_CONTROL_UNITS = {"ht:P8": "MPa", "ht:F19": "t/h"} +HISTORICAL_SIGNAL_MEANINGS = { + "ht:P8": "R-202 differential pressure, MPa", + "ht:F19": "gasoline flow to K-201, t/h", + "ht:T11": "R-202 outlet product temperature, degC", + "ht:F26": "hydrotreated diesel volumetric output, m3/h; context only", +} +ACTION_HORIZONS = ACTION_EFFECT_HORIZONS_MINUTES +# Allowed process-to-quality observation lags from the physical review. These +# are metadata for the study, not a licence to search arbitrary offsets. +PHYSICAL_LAG_MINUTES = (0, 60, 120, 180) +LEGACY_ACTION_STATE_FEATURES = ( + "baseline_sulfur", + "sulfur_slope_60m", + "ht:P8", + "ht:F19", + "ht:F26", +) +ACTION_STATE_FEATURES = ( + "baseline_sulfur", + "sulfur_slope_60m", + "ht:P8", + "ht:F19", + "ht:T11", + "ht:F26", +) +ACTION_MODEL_FEATURES = (*ACTION_STATE_FEATURES, "delta_ht:P8", "delta_ht:F19") +LEGACY_ACTION_MODEL_FEATURES = (*LEGACY_ACTION_STATE_FEATURES, "delta_ht:P8", "delta_ht:F19") + + +@dataclass(frozen=True) +class ActionModelBundle: + """Action model kept separate from an ordinary forecast artifact.""" + + predictor: object + control_ids: tuple[str, ...] + outcome_names: tuple[str, ...] + horizons_minutes: tuple[int, ...] + joint_domain: JointControlDomain + evidence: ActionEffectEvidence + supports_actions: bool = False + + def __post_init__(self) -> None: + if not self.control_ids or not self.outcome_names or not self.horizons_minutes: + raise ValueError("action bundle needs controls, outcomes and horizons") + if self.supports_actions and not ( + self.evidence.shadow_replay_passed and self.evidence.pilot_approved + ): + raise ValueError("action capability requires shadow replay and technologist pilot") + + +@dataclass(frozen=True) +class HistoricalActionDataset: + """Matched observational action episodes; never an authorization to act.""" + + frame: pd.DataFrame + thresholds: dict[str, float] + observed_delta_bounds: dict[str, tuple[float, float]] + match_distance_limit: float + + +@dataclass(frozen=True) +class HistoricalActionEstimate: + """UI-ready historical effect estimate explicitly separated from advice.""" + + control_id: str + proposed_delta: float + sulfur_by_horizon: dict[int, float] + sulfur_upper_by_horizon: dict[int, float] + effect_by_horizon: dict[int, float] + applicable: bool + reason_codes: tuple[str, ...] + within_observed_domain: bool = False + model_validated: bool = False + safety_passes: bool = False + + def as_ui_payload(self) -> dict[str, object]: + return { + "title": "Модельный эффект по историческим эпизодам", + "disclaimer": "Оценка не является советом по изменению уставки.", + "control_id": self.control_id, + "control_unit": ACTION_CONTROL_UNITS.get(self.control_id, "unknown"), + "proposed_delta": self.proposed_delta, + "sulfur_change": { + str(horizon): self.effect_by_horizon[horizon] for horizon in ACTION_HORIZONS + }, + "predicted_sulfur": { + str(horizon): self.sulfur_by_horizon[horizon] for horizon in ACTION_HORIZONS + }, + "sulfur_upper": { + str(horizon): self.sulfur_upper_by_horizon[horizon] for horizon in ACTION_HORIZONS + }, + "applicable": self.applicable, + "within_observed_domain": self.within_observed_domain, + "model_validated": self.model_validated, + "safety_passes": self.safety_passes, + "advisory": False, + "reason_codes": self.reason_codes, + } + + +@dataclass(frozen=True) +class HistoricalActionEffectModel: + """Matched-episode sulfur model for shadow display, not control advice.""" + + point_estimators: Mapping[int, Any] + upper_estimators: Mapping[int, Any] + upper_shifts: Mapping[int, float] + applicability: JointApplicabilityModel + observed_delta_bounds: Mapping[str, tuple[float, float]] + report: Mapping[str, Any] + supports_actions: bool = False + + def __post_init__(self) -> None: + if self.supports_actions: + raise ValueError("historical action-effect artifacts are research-only") + + def estimate( + self, + state: Mapping[str, float], + control_id: str, + proposed_delta: float, + *, + sulfur_limit: float = 10.0, + ) -> HistoricalActionEstimate: + if control_id not in HISTORICAL_ACTION_CONTROLS: + raise ValueError("historical action model supports only ht:P8 or ht:F19") + if not np.isfinite(proposed_delta): + raise ValueError("proposed action delta must be finite") + configured_state_features = tuple( + str(name) for name in self.report.get("state_features", LEGACY_ACTION_STATE_FEATURES) + ) + configured_model_features = tuple( + str(name) for name in self.report.get("model_features", LEGACY_ACTION_MODEL_FEATURES) + ) + missing = set(configured_state_features).difference(state) + if missing: + raise ValueError(f"historical action state is missing: {sorted(missing)}") + action = {"delta_ht:P8": 0.0, "delta_ht:F19": 0.0} + action[f"delta_{control_id}"] = float(proposed_delta) + row = pd.DataFrame([{**state, **action}], columns=configured_model_features) + reasons: list[str] = [] + model_validated = bool(self.report.get("evidence_gate_passed", False)) + if not model_validated: + reasons.append("ACTION_EFFECT_VALIDATION_FAILED") + lower, upper = self.observed_delta_bounds[control_id] + delta_in_range = lower <= proposed_delta <= upper + if not delta_in_range: + reasons.append("ACTION_DELTA_OUT_OF_OBSERVED_RANGE") + applicability = self.applicability.assess(row.loc[:, list(configured_state_features)]) + state_in_domain = bool(applicability.available) + if not state_in_domain: + reasons.append(applicability.reason_code or "OUT_OF_DOMAIN") + hold = row.copy() + hold.loc[:, ["delta_ht:P8", "delta_ht:F19"]] = 0.0 + sulfur: dict[int, float] = {} + sulfur_upper: dict[int, float] = {} + effect: dict[int, float] = {} + for horizon in ACTION_HORIZONS: + point = float(self.point_estimators[horizon].predict(row)[0]) + hold_point = float(self.point_estimators[horizon].predict(hold)[0]) + upper_point = float(self.upper_estimators[horizon].predict(row)[0]) + float( + self.upper_shifts[horizon] + ) + sulfur[horizon] = point + sulfur_upper[horizon] = max(point, upper_point) + effect[horizon] = point - hold_point + if sulfur_upper[horizon] > sulfur_limit: + reasons.append(f"SULFUR_UPPER_LIMIT_{horizon}M") + safety_passes = all(value <= sulfur_limit for value in sulfur_upper.values()) + return HistoricalActionEstimate( + control_id, + proposed_delta, + sulfur, + sulfur_upper, + effect, + not reasons, + tuple(dict.fromkeys(reasons)), + delta_in_range and state_in_domain, + model_validated, + safety_passes, + ) + + +def _artifact_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def save_historical_action_model( + directory: Path, model: HistoricalActionEffectModel, *, training_dataset_id: str +) -> Path: + """Persist the shadow model for local research UI; it never enables actions.""" + directory = Path(directory) + if directory.exists(): + raise FileExistsError(f"action model directory already exists: {directory}") + directory.parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix=f".{directory.name}.", dir=directory.parent)) + try: + model_path = temporary / "model.joblib" + _joblib().dump(model, model_path, compress=3) + metadata = { + "schema_version": "1.0", + "artifact_kind": "historical_action_effect_shadow", + "bundle_kind": "ActionModelBundle", + "training_dataset_id": training_dataset_id, + "model_sha256": _artifact_sha256(model_path), + "supports_actions": False, + "controls": list(model.report.get("controls", HISTORICAL_ACTION_CONTROLS)), + "signal_meanings": HISTORICAL_SIGNAL_MEANINGS, + "outcomes": [f"sulfur_{horizon}m" for horizon in ACTION_HORIZONS], + "horizons_minutes": list(ACTION_HORIZONS), + "physical_lag_minutes": list(PHYSICAL_LAG_MINUTES), + "state_features": list( + model.report.get("state_features", LEGACY_ACTION_STATE_FEATURES) + ), + "model_features": list( + model.report.get("model_features", LEGACY_ACTION_MODEL_FEATURES) + ), + "observed_delta_bounds": { + control: list(bounds) for control, bounds in model.observed_delta_bounds.items() + }, + "joint_domain": { + "method": "joint_pca_mahalanobis", + "max_distance_squared": getattr(model.applicability, "max_distance_squared", None), + }, + "validation_evidence": dict(model.report.get("gate", {})), + "engineering_limits": None, + } + (temporary / "metadata.json").write_text( + json.dumps(metadata, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8" + ) + (temporary / "metrics.json").write_text( + json.dumps(dict(model.report), ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + temporary.replace(directory) + except Exception: + shutil.rmtree(temporary, ignore_errors=True) + raise + return directory + + +def load_historical_action_model( + directory: Path, *, trusted: bool = False, expected_dataset_id: str | None = None +) -> HistoricalActionEffectModel: + """Load a checksum-verified local shadow artifact from a trusted directory.""" + if not trusted: + raise ValueError("action artifacts may be loaded only from an explicitly trusted path") + directory = Path(directory) + metadata = json.loads((directory / "metadata.json").read_text(encoding="utf-8")) + model_path = directory / "model.joblib" + if metadata.get("artifact_kind") != "historical_action_effect_shadow": + raise ValueError("artifact is not a historical action-effect model") + if ( + expected_dataset_id is not None + and metadata.get("training_dataset_id") != expected_dataset_id + ): + raise ValueError("action artifact was trained on a different prepared dataset") + if metadata.get("supports_actions") is not False: + raise ValueError("action artifact must not enable actions") + if metadata.get("model_sha256") != _artifact_sha256(model_path): + raise ValueError("action artifact checksum mismatch") + model = _joblib().load(model_path) + if not isinstance(model, HistoricalActionEffectModel) or model.supports_actions: + raise ValueError("action artifact has incompatible capability") + return model + + +def load_verified_action_model( + directory: Path, + *, + trusted: bool = False, + expected_dataset_id: str | None = None, + expected_config_sha256: str | None = None, + expected_tag_dictionary_sha256: str | None = None, + expected_telemetry_rules_sha256: str | None = None, +) -> VerifiedActionEffectModel: + """Load a production action artifact only after full metadata gate validation.""" + if not trusted: + raise ValueError("action artifacts may be loaded only from an explicitly trusted path") + directory = Path(directory) + metadata = json.loads((directory / "metadata.json").read_text(encoding="utf-8")) + if metadata.get("model_id") != directory.name: + raise ValueError("action metadata model_id does not match the artifact directory") + controls = validate_action_artifact_metadata( + metadata, + expected_dataset_id=expected_dataset_id, + expected_config_sha256=expected_config_sha256, + expected_tag_dictionary_sha256=expected_tag_dictionary_sha256, + expected_telemetry_rules_sha256=expected_telemetry_rules_sha256, + ) + model_path = directory / "model.joblib" + if metadata.get("model_sha256") != _artifact_sha256(model_path): + raise ValueError("action artifact checksum mismatch") + model = _joblib().load(model_path) + if not isinstance(model, VerifiedActionEffectModel) or not model.supports_actions: + raise ValueError("action artifact has incompatible capability") + if model.model_id != metadata.get("model_id"): + raise ValueError("action model_id does not match metadata") + loaded_controls = tuple(sorted(control.signal_id for control in model.controls)) + metadata_controls = tuple(sorted(control.signal_id for control in controls)) + if loaded_controls != metadata_controls: + raise ValueError("action model controls do not match metadata") + return model + + +def _joblib() -> Any: + import joblib + + return joblib + + +def _pak_sulfur(data: PreparedData) -> pd.DataFrame: + quality = data.quality + value = pd.to_numeric(quality["value"], errors="coerce") + selected = quality[ + quality["signal_id"].astype(str).eq("ht:2:Mg.Sulfur") + & quality["source"].map(lambda item: str(getattr(item, "value", item))).eq("pak") + & quality["validity"] + .map(lambda item: str(getattr(item, "value", item))) + .eq(Validity.VALID.value) + & quality["unit"].astype(str).eq("mg/kg") + & value.notna() + & np.isfinite(value) + ].copy() + selected["baseline_sulfur"] = value.loc[selected.index].astype(float) + selected["timestamp"] = pd.to_datetime(selected["measured_at"], utc=True) + return cast( + pd.DataFrame, + selected.loc[:, ["timestamp", "baseline_sulfur"]].sort_values("timestamp"), + ) + + +def _action_timeline(data: PreparedData) -> pd.DataFrame: + signals = (*HISTORICAL_ACTION_CONTROLS, *HISTORICAL_CONTEXT_SIGNALS) + missing = set(signals).difference(data.telemetry.columns) + if missing: + raise ValueError(f"telemetry is missing historical action signals: {sorted(missing)}") + telemetry = data.telemetry.loc[:, ["timestamp", *signals]].copy() + telemetry["timestamp"] = pd.to_datetime(telemetry["timestamp"], utc=True) + for signal in signals: + telemetry[signal] = pd.to_numeric(telemetry[signal], errors="coerce") + timeline = pd.merge_asof( + telemetry.sort_values("timestamp"), + _pak_sulfur(data), + on="timestamp", + direction="backward", + tolerance=pd.Timedelta(minutes=10), + ) + timeline["sulfur_slope_60m"] = ( + timeline["baseline_sulfur"] - timeline["baseline_sulfur"].shift(6) + ) / 60.0 + indexed = timeline.set_index("timestamp") + for horizon in ACTION_HORIZONS: + future = indexed["baseline_sulfur"].reindex(indexed.index + pd.Timedelta(minutes=horizon)) + timeline[f"sulfur_{horizon}m"] = future.to_numpy(dtype=float) + return timeline + + +def build_historical_action_dataset( + data: PreparedData, + *, + train_end: str = "2025-01-01", + threshold_quantile: float = 0.95, +) -> HistoricalActionDataset: + """Extract isolated P8/F19 changes and pair them with similar calm states.""" + from sklearn.neighbors import NearestNeighbors + from sklearn.preprocessing import StandardScaler + + if not 0.5 < threshold_quantile < 1.0: + raise ValueError("action threshold quantile must be between 0.5 and 1") + frame = _action_timeline(data) + timestamp = pd.to_datetime(frame["timestamp"], utc=True) + changes = frame.loc[:, list(HISTORICAL_ACTION_CONTROLS)].diff() + train = timestamp < pd.Timestamp(train_end, tz="UTC") + thresholds: dict[str, float] = {} + for signal in HISTORICAL_ACTION_CONTROLS: + positive_steps = changes.loc[train, signal].abs() + positive_steps = positive_steps[positive_steps > 0].dropna() + if positive_steps.empty: + raise ValueError(f"historical action signal {signal} has no observed changes") + thresholds[signal] = float(positive_steps.quantile(threshold_quantile)) + notable = pd.DataFrame( + {signal: changes[signal].abs().ge(threshold) for signal, threshold in thresholds.items()} + ) + stable_before = ( + changes.abs() + .rolling(6, min_periods=6) + .max() + .shift(1) + .lt(pd.Series(thresholds)) + .all(axis=1) + ) + quiet_after = ( + notable.iloc[::-1].rolling(18, min_periods=18).sum().iloc[::-1].shift(-1).fillna(1).eq(0) + ).all(axis=1) + outcomes = [f"sulfur_{horizon}m" for horizon in ACTION_HORIZONS] + complete = frame[[*ACTION_STATE_FEATURES, *outcomes]].notna().all(axis=1) + treated_mask = notable.sum(axis=1).eq(1) & stable_before & quiet_after & complete + calm_mask = notable.rolling(18, min_periods=18).sum().eq(0).all(axis=1) & complete + treated = frame.loc[treated_mask].copy() + calm = frame.loc[calm_mask].copy() + if treated.empty or calm.empty: + raise ValueError("no complete isolated action/control episodes were found") + + scaler = StandardScaler().fit(frame.loc[train & complete, list(ACTION_STATE_FEATURES)]) + treated_parts: list[pd.DataFrame] = [] + matched_parts: list[pd.DataFrame] = [] + distance_parts: list[np.ndarray] = [] + for year in sorted(pd.to_datetime(treated["timestamp"], utc=True).dt.year.unique()): + treated_year = treated.loc[pd.to_datetime(treated["timestamp"], utc=True).dt.year.eq(year)] + calm_year = calm.loc[pd.to_datetime(calm["timestamp"], utc=True).dt.year.eq(year)] + if calm_year.empty: + continue + treated_state = scaler.transform(treated_year.loc[:, list(ACTION_STATE_FEATURES)]) + calm_state = scaler.transform(calm_year.loc[:, list(ACTION_STATE_FEATURES)]) + distances, indices = ( + NearestNeighbors(n_neighbors=1).fit(calm_state).kneighbors(treated_state) + ) + treated_parts.append(treated_year) + matched_parts.append(calm_year.iloc[indices[:, 0]].copy()) + distance_parts.append(distances[:, 0]) + if not treated_parts: + raise ValueError("no same-year matched action/control episodes were found") + treated = pd.concat(treated_parts).copy() + matched = pd.concat(matched_parts).copy() + matched_distances = np.concatenate(distance_parts) + treated["pair_id"] = np.arange(len(treated)) + matched["pair_id"] = np.arange(len(treated)) + treated["is_action_episode"] = True + matched["is_action_episode"] = False + treated["control_id"] = np.where( + notable.loc[treated.index, "ht:P8"].to_numpy(), "ht:P8", "ht:F19" + ) + matched["control_id"] = treated["control_id"].to_numpy() + for signal in HISTORICAL_ACTION_CONTROLS: + treated[f"delta_{signal}"] = np.where( + treated["control_id"].eq(signal), + changes.loc[treated.index, signal].to_numpy(dtype=float), + 0.0, + ) + matched[f"delta_{signal}"] = 0.0 + treated["match_distance"] = matched_distances + matched["match_distance"] = matched_distances + result = pd.concat([treated, matched], ignore_index=True).sort_values( + ["timestamp", "pair_id"], kind="stable" + ) + bounds = { + signal: ( + float(treated.loc[treated[f"delta_{signal}"].ne(0), f"delta_{signal}"].min()), + float(treated.loc[treated[f"delta_{signal}"].ne(0), f"delta_{signal}"].max()), + ) + for signal in HISTORICAL_ACTION_CONTROLS + } + return HistoricalActionDataset( + result.reset_index(drop=True), + thresholds, + bounds, + float(np.quantile(matched_distances, 0.99)), + ) + + +def fit_historical_action_model( + dataset: HistoricalActionDataset, + *, + train_end: str = "2025-01-01", + validation_end: str = "2026-01-01", + seed: int = 42, +) -> HistoricalActionEffectModel: + """Fit shadow-only matched action models and evaluate 2026 without selection.""" + from sklearn.ensemble import GradientBoostingRegressor + from sklearn.impute import SimpleImputer + from sklearn.linear_model import Ridge + from sklearn.metrics import mean_absolute_error + from sklearn.pipeline import Pipeline + from sklearn.preprocessing import StandardScaler + + from source.ml.safety import fit_joint_applicability + + frame = dataset.frame + timestamp = pd.to_datetime(frame["timestamp"], utc=True) + train = timestamp < pd.Timestamp(train_end, tz="UTC") + validation = (timestamp >= pd.Timestamp(train_end, tz="UTC")) & ( + timestamp < pd.Timestamp(validation_end, tz="UTC") + ) + audit = timestamp >= pd.Timestamp(validation_end, tz="UTC") + if min(int(train.sum()), int(validation.sum()), int(audit.sum())) == 0: + raise ValueError("historical action model needs train, validation and audit episodes") + features = list(ACTION_MODEL_FEATURES) + point_estimators: dict[int, Any] = {} + upper_estimators: dict[int, Any] = {} + upper_shifts: dict[int, float] = {} + validation_metrics: dict[str, Any] = {} + audit_metrics: dict[str, Any] = {} + for horizon in ACTION_HORIZONS: + target = f"sulfur_{horizon}m" + point = Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ("scaler", StandardScaler()), + ("model", Ridge(alpha=10.0)), + ) + ).fit(frame.loc[train, features], frame.loc[train, target]) + upper = Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ( + "model", + GradientBoostingRegressor( + loss="quantile", alpha=0.95, max_depth=2, random_state=seed + ), + ), + ) + ).fit(frame.loc[train, features], frame.loc[train, target]) + validation_upper = upper.predict(frame.loc[validation, features]) + shift = max( + 0.0, + float(np.quantile(frame.loc[validation, target] - validation_upper, 0.95)), + ) + point_estimators[horizon] = point + upper_estimators[horizon] = upper + upper_shifts[horizon] = shift + for label, selected, destination in ( + ("validation", validation, validation_metrics), + ("audit", audit, audit_metrics), + ): + predicted = point.predict(frame.loc[selected, features]) + conservative = np.maximum( + predicted, upper.predict(frame.loc[selected, features]) + shift + ) + actual = frame.loc[selected, target].to_numpy(dtype=float) + destination[str(horizon)] = { + "period": label, + "rows": int(selected.sum()), + "mae": float(mean_absolute_error(actual, predicted)), + "hold_mae": float( + mean_absolute_error(actual, frame.loc[selected, "baseline_sulfur"]) + ), + "upper_coverage": float(np.mean(actual <= conservative)), + } + applicability = fit_joint_applicability( + frame.loc[train, list(ACTION_STATE_FEATURES)], + required_features=ACTION_STATE_FEATURES, + ) + per_control_pairs = { + signal: int(frame.loc[frame["is_action_episode"], "control_id"].eq(signal).sum()) + for signal in HISTORICAL_ACTION_CONTROLS + } + statistical_mae_gate = all( + values["mae"] <= 0.90 * values["hold_mae"] for values in validation_metrics.values() + ) + coverage_gate = all(values["upper_coverage"] >= 0.95 for values in validation_metrics.values()) + audit_coverage_gate = all( + values["upper_coverage"] >= 0.95 for values in audit_metrics.values() + ) + episode_count_gate = all(count >= 100 for count in per_control_pairs.values()) + # These are deliberately false until engineering limits and temporal sign stability + # are confirmed outside this observational benchmark. + sign_stability_gate = False + engineering_bounds_gate = False + evidence_gate_passed = ( + episode_count_gate + and statistical_mae_gate + and coverage_gate + and audit_coverage_gate + and sign_stability_gate + and engineering_bounds_gate + ) + gate_reasons: list[str] = [] + if not episode_count_gate: + gate_reasons.append("INSUFFICIENT_PER_CONTROL_EPISODES") + if not statistical_mae_gate: + gate_reasons.append("ACTION_MODEL_GAIN_BELOW_10_PERCENT") + if not coverage_gate: + gate_reasons.append("ACTION_INTERVAL_COVERAGE_INSUFFICIENT") + if not audit_coverage_gate: + gate_reasons.append("ACTION_AUDIT_INTERVAL_COVERAGE_INSUFFICIENT") + gate_reasons.extend( + ( + "ACTION_EFFECT_SIGN_UNSTABLE", + "CONTROL_LIMITS_UNCONFIRMED", + "SHADOW_REPLAY_MISSING", + "TECHNOLOGIST_PILOT_MISSING", + ) + ) + report = { + "basis": "matched_historical_episodes_not_causal_guarantee", + "controls": list(HISTORICAL_ACTION_CONTROLS), + "context_only": list(HISTORICAL_CONTEXT_SIGNALS), + "physical_lag_minutes": list(PHYSICAL_LAG_MINUTES), + "thresholds": dataset.thresholds, + "state_features": list(ACTION_STATE_FEATURES), + "model_features": list(ACTION_MODEL_FEATURES), + "episode_rows": int(len(frame)), + "independent_pairs": int(frame["pair_id"].nunique()), + "per_control_pairs": per_control_pairs, + "match_distance_limit": dataset.match_distance_limit, + "gate": { + "minimum_100_each_control": episode_count_gate, + "mae_10_percent_better_than_hold": statistical_mae_gate, + "upper_coverage_at_least_95_percent": coverage_gate, + "audit_upper_coverage_at_least_95_percent": audit_coverage_gate, + "effect_sign_stable_in_three_folds": sign_stability_gate, + "engineering_bounds_confirmed": engineering_bounds_gate, + "shadow_replay_passed": False, + "technologist_pilot_approved": False, + }, + "gate_failure_reasons": tuple(dict.fromkeys(gate_reasons)), + "validation_2025": validation_metrics, + "audit_2026": audit_metrics, + "evidence_gate_passed": evidence_gate_passed, + "supports_actions": False, + "ui_label": "Модельный эффект по историческим эпизодам", + "disclaimer": "Не является советом по изменению уставки.", + } + return HistoricalActionEffectModel( + point_estimators, + upper_estimators, + upper_shifts, + applicability, + dataset.observed_delta_bounds, + report, + ) + + +def evaluate_temporal_residualization( + dataset: HistoricalActionDataset, + *, + train_end: str = "2025-01-01", + validation_end: str = "2026-01-01", +) -> dict[str, Any]: + """Evaluate an action/outcome residual model with forward-only cross-fitting. + + This is evidence about conditional historical associations. It is deliberately + separate from ``HistoricalActionEffectModel`` because residualization alone does + not establish a causal action effect. + """ + from sklearn.impute import SimpleImputer + from sklearn.linear_model import Ridge + from sklearn.metrics import mean_absolute_error + from sklearn.model_selection import TimeSeriesSplit + from sklearn.pipeline import Pipeline + from sklearn.preprocessing import StandardScaler + + frame = dataset.frame.sort_values("timestamp", kind="stable").reset_index(drop=True) + timestamp = pd.to_datetime(frame["timestamp"], utc=True) + train = frame.loc[timestamp < pd.Timestamp(train_end, tz="UTC")].reset_index(drop=True) + validation = frame.loc[ + (timestamp >= pd.Timestamp(train_end, tz="UTC")) + & (timestamp < pd.Timestamp(validation_end, tz="UTC")) + ].reset_index(drop=True) + if len(train) < 40 or validation.empty: + raise ValueError("residualization needs non-empty temporal train and validation periods") + states = list(ACTION_STATE_FEATURES) + actions = ["delta_ht:P8", "delta_ht:F19"] + + def pipeline() -> Pipeline: + return Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ("scaler", StandardScaler()), + ("model", Ridge(alpha=10.0)), + ) + ) + + splitter = TimeSeriesSplit(n_splits=3) + oof_action = np.full((len(train), len(actions)), np.nan) + oof_outcomes = {horizon: np.full(len(train), np.nan) for horizon in ACTION_HORIZONS} + fold_residuals: dict[int, list[tuple[np.ndarray, np.ndarray]]] = { + horizon: [] for horizon in ACTION_HORIZONS + } + for fold_train, fold_valid in splitter.split(train): + action_model = pipeline().fit( + train.iloc[fold_train][states], train.iloc[fold_train][actions] + ) + oof_action[fold_valid] = action_model.predict(train.iloc[fold_valid][states]) + for horizon in ACTION_HORIZONS: + target = f"sulfur_{horizon}m" + outcome_model = pipeline().fit( + train.iloc[fold_train][states], train.iloc[fold_train][target] + ) + outcome_prediction = outcome_model.predict(train.iloc[fold_valid][states]) + oof_outcomes[horizon][fold_valid] = outcome_prediction + fold_residuals[horizon].append( + ( + train.iloc[fold_valid][actions].to_numpy(dtype=float) - oof_action[fold_valid], + train.iloc[fold_valid][target].to_numpy(dtype=float) - outcome_prediction, + ) + ) + valid_oof = np.isfinite(oof_action).all(axis=1) + action_residual = train.loc[valid_oof, actions].to_numpy(dtype=float) - oof_action[valid_oof] + report: dict[str, Any] = { + "basis": "temporal_cross_fitted_residualization_not_causal_guarantee", + "train_period": f"before {train_end}", + "validation_period": f"{train_end}/{validation_end}", + "cross_fit_folds": 3, + "oof_rows": int(valid_oof.sum()), + "horizons": {}, + "supports_actions": False, + "promotion_eligible": False, + } + final_action_model = pipeline().fit(train[states], train[actions]) + validation_action_residual = validation[actions].to_numpy( + dtype=float + ) - final_action_model.predict(validation[states]) + for horizon in ACTION_HORIZONS: + target = f"sulfur_{horizon}m" + target_oof = train.loc[valid_oof, target].to_numpy(dtype=float) + outcome_oof = oof_outcomes[horizon][valid_oof] + outcome_residual = target_oof - outcome_oof + effect_model = pipeline().fit(action_residual, outcome_residual) + final_outcome_model = pipeline().fit(train[states], train[target]) + outcome_prediction = final_outcome_model.predict(validation[states]) + effect_prediction = effect_model.predict(validation_action_residual) + validation_prediction = outcome_prediction + effect_prediction + actual = validation[target].to_numpy(dtype=float) + hold = validation["baseline_sulfur"].to_numpy(dtype=float) + control_effects: dict[str, float] = {} + signs: dict[str, int] = {} + for position, control in enumerate(HISTORICAL_ACTION_CONTROLS): + scale = float(np.nanquantile(np.abs(action_residual[:, position]), 0.75)) + probe = np.zeros((1, len(actions))) + probe[0, position] = scale + effect = float( + effect_model.predict(probe)[0] - effect_model.predict(np.zeros_like(probe))[0] + ) + control_effects[control] = effect + signs[control] = int(np.sign(effect)) + fold_signs: list[dict[str, int]] = [] + for fold_action_residual, fold_outcome_residual in fold_residuals[horizon]: + fold_model = pipeline().fit(fold_action_residual, fold_outcome_residual) + fold_result: dict[str, int] = {} + for position, control in enumerate(HISTORICAL_ACTION_CONTROLS): + scale = float(np.nanquantile(np.abs(fold_action_residual[:, position]), 0.75)) + probe = np.zeros((1, len(actions))) + probe[0, position] = scale + fold_result[control] = int( + np.sign( + fold_model.predict(probe)[0] - fold_model.predict(np.zeros_like(probe))[0] + ) + ) + fold_signs.append(fold_result) + sign_stable = { + control: len({fold[control] for fold in fold_signs}) == 1 + and fold_signs[0][control] != 0 + for control in HISTORICAL_ACTION_CONTROLS + } + report["horizons"][str(horizon)] = { + "mae": float(mean_absolute_error(actual, validation_prediction)), + "hold_mae": float(mean_absolute_error(actual, hold)), + "effect_for_train_q75_residual_action": control_effects, + "effect_sign": signs, + "effect_sign_by_fold": fold_signs, + "effect_sign_stable": sign_stable, + } + return report @dataclass(frozen=True) @@ -26,6 +806,173 @@ class LinearActionEffectModel: evidence_ref: str +@dataclass(frozen=True) +class VerifiedActionEffectModel: + """Production action-effect model; construction is gated by external evidence.""" + + model_id: str + controls: tuple[ControlSpec, ...] + joint_domain: JointControlDomain + evidence: ActionEffectEvidence + sulfur_coefficients: Mapping[str, float] + risk_coefficients: Mapping[str, float] + throughput_coefficients: Mapping[str, float] + cost_coefficients: Mapping[str, float] + evidence_ref: str + baseline_risk_index: float = 0.5 + baseline_throughput: float = 1.0 + baseline_cost_proxy: float = 1.0 + horizons_minutes: tuple[int, ...] = ACTION_HORIZONS + supports_actions: bool = True + + def __post_init__(self) -> None: + if not self.supports_actions: + raise ValueError("verified action model must declare supports_actions=true") + control_ids = tuple(control.signal_id for control in self.controls) + if set(control_ids) != set(HISTORICAL_ACTION_CONTROLS) or len(control_ids) != len( + set(control_ids) + ): + raise ValueError("verified action model may enable only ht:P8 and ht:F19") + if set(self.joint_domain.signal_ids) != set(control_ids): + raise ValueError("verified action domain must match enabled controls") + if self.horizons_minutes != ACTION_HORIZONS: + raise ValueError("verified action model horizons must be 60/120/180 minutes") + report = assess_action_capability(self.controls, self.evidence) + if not report.supports_actions: + raise ValueError(f"action capability gates failed: {report.reason_codes}") + if not self.evidence_ref: + raise ValueError("verified action model needs evidence_ref") + + def evaluate( + self, + state: ProcessState, + candidate: CandidateAction, + *, + baseline_sulfur: float, + baseline_sulfur_upper: float | None, + sulfur_upper_limit: float = 10.0, + ) -> "ActionOutcome": + current_setpoints = _current_setpoints(state, self.controls) + linear = LinearActionEffectModel( + current_setpoints=current_setpoints, + baseline_sulfur=baseline_sulfur, + baseline_sulfur_upper=baseline_sulfur_upper, + baseline_risk_index=self.baseline_risk_index, + baseline_throughput=self.baseline_throughput, + baseline_cost_proxy=self.baseline_cost_proxy, + sulfur_coefficients=dict(self.sulfur_coefficients), + risk_coefficients=dict(self.risk_coefficients), + throughput_coefficients=dict(self.throughput_coefficients), + cost_coefficients=dict(self.cost_coefficients), + evidence_ref=self.evidence_ref, + ) + return evaluate_linear_action( + candidate, + linear, + self.controls, + self.joint_domain, + sulfur_upper_limit=sulfur_upper_limit, + ) + + +@dataclass(frozen=True) +class CombinedModelCapabilities: + supports_forecast: bool + supports_actions: bool + supports_uncertainty: bool = False + supports_exceedance_probability: bool = False + supports_multi_horizon: bool = False + + +@dataclass(frozen=True) +class ActionEnabledMetadata: + """Forecast metadata view with action capability supplied by a separate artifact.""" + + forecast_metadata: object + action_model_id: str + model_id: str + capabilities: CombinedModelCapabilities + + def __getattr__(self, name: str) -> object: + return getattr(self.forecast_metadata, name) + + +@dataclass(frozen=True) +class ActionEnabledForecastModel: + """Runtime wrapper combining a forecast artifact and a verified action artifact.""" + + forecast_model: object + action_model: VerifiedActionEffectModel + metadata: ActionEnabledMetadata + + @property + def feature_names(self) -> tuple[str, ...]: + return tuple(getattr(self.forecast_model, "feature_names")) + + def predict(self, features: pd.DataFrame) -> np.ndarray: + return self.forecast_model.predict(features) + + def predict_upper(self, features: pd.DataFrame) -> np.ndarray: + return self.forecast_model.predict_upper(features) + + def predict_exceedance_probability(self, features: pd.DataFrame) -> np.ndarray: + return self.forecast_model.predict_exceedance_probability(features) + + def predict_alarm(self, features: pd.DataFrame) -> np.ndarray: + return self.forecast_model.predict_alarm(features) + + def check_applicability(self, features: pd.DataFrame) -> object: + return self.forecast_model.check_applicability(features) + + +def _capability_bool(capabilities: object, name: str) -> bool: + if isinstance(capabilities, Mapping): + return capabilities.get(name) is True + return getattr(capabilities, name, False) is True + + +def combine_forecast_action_model( + forecast_model: object, + action_model: VerifiedActionEffectModel, +) -> ActionEnabledForecastModel: + """Attach a separately verified action artifact to a trusted forecast bundle.""" + forecast_metadata = getattr(forecast_model, "metadata", None) + if forecast_metadata is None: + raise ValueError("forecast model needs metadata before action attachment") + capabilities = getattr(forecast_metadata, "capabilities", {}) + combined = CombinedModelCapabilities( + supports_forecast=_capability_bool(capabilities, "supports_forecast"), + supports_actions=True, + supports_uncertainty=_capability_bool(capabilities, "supports_uncertainty"), + supports_exceedance_probability=_capability_bool( + capabilities, "supports_exceedance_probability" + ), + supports_multi_horizon=_capability_bool(capabilities, "supports_multi_horizon"), + ) + if not combined.supports_forecast: + raise ValueError("action attachment requires a forecast-capable model") + metadata = ActionEnabledMetadata( + forecast_metadata=forecast_metadata, + action_model_id=action_model.model_id, + model_id=f"{getattr(forecast_metadata, 'model_id')}+{action_model.model_id}", + capabilities=combined, + ) + return ActionEnabledForecastModel(forecast_model, action_model, metadata) + + +def _current_setpoints( + state: ProcessState, + controls: tuple[ControlSpec, ...], +) -> dict[str, float]: + values: dict[str, float] = {} + for control in controls: + snapshot = state.signals.get(control.signal_id) + if snapshot is None or snapshot.selected is None or snapshot.selected.value is None: + raise ValueError(f"current setpoint is unavailable for {control.signal_id}") + values[control.signal_id] = float(snapshot.selected.value) + return values + + @dataclass(frozen=True) class ActionOutcome: candidate_id: str @@ -169,8 +1116,30 @@ def rank_linear_actions( __all__ = [ + "ACTION_HORIZONS", + "PHYSICAL_LAG_MINUTES", + "ACTION_MODEL_FEATURES", + "ActionModelBundle", "ActionOutcome", + "ActionEnabledForecastModel", + "ActionEnabledMetadata", + "CombinedModelCapabilities", + "HISTORICAL_ACTION_CONTROLS", + "ACTION_CONTROL_UNITS", + "HISTORICAL_CONTEXT_SIGNALS", + "HISTORICAL_SIGNAL_MEANINGS", + "HistoricalActionDataset", + "HistoricalActionEffectModel", + "HistoricalActionEstimate", "LinearActionEffectModel", + "VerifiedActionEffectModel", + "build_historical_action_dataset", + "combine_forecast_action_model", "evaluate_linear_action", + "evaluate_temporal_residualization", + "fit_historical_action_model", "rank_linear_actions", + "save_historical_action_model", + "load_historical_action_model", + "load_verified_action_model", ] diff --git a/source/ml/artifacts.py b/source/ml/artifacts.py index 6bb5805..3cc7225 100644 --- a/source/ml/artifacts.py +++ b/source/ml/artifacts.py @@ -11,14 +11,23 @@ from pathlib import Path from typing import Any, Literal, Mapping, Self, Sequence -import joblib import numpy as np import pandas as pd -import sklearn from pydantic import BaseModel, ConfigDict, Field, model_validator -from sklearn.base import BaseEstimator, RegressorMixin -MODEL_METADATA_VERSION: Literal["1.0"] = "1.0" +try: + from sklearn.base import BaseEstimator, RegressorMixin +except ModuleNotFoundError: + + class BaseEstimator: # type: ignore[no-redef] + pass + + class RegressorMixin: # type: ignore[no-redef] + pass + + +MODEL_METADATA_VERSION: Literal["1.2"] = "1.2" +SUPPORTED_MODEL_METADATA_VERSIONS = frozenset({"1.0", "1.1", "1.2"}) _SHA256_PATTERN = r"^[0-9a-f]{64}$" @@ -30,6 +39,8 @@ class ModelCapabilities(BaseModel): supports_forecast: bool supports_actions: bool supports_uncertainty: bool = False + supports_exceedance_probability: bool = False + supports_multi_horizon: bool = False class ModelMetadata(BaseModel): @@ -38,7 +49,7 @@ class ModelMetadata(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True, protected_namespaces=()) model_id: str - schema_version: Literal["1.0"] = MODEL_METADATA_VERSION + schema_version: Literal["1.0", "1.1", "1.2"] = "1.0" model_sha256: str = Field(pattern=_SHA256_PATTERN) model_type: str training_dataset_id: str @@ -59,6 +70,11 @@ class ModelMetadata(BaseModel): capabilities: ModelCapabilities applicability: dict[str, Any] reports: tuple[str, ...] + alarm_threshold: float | None = Field(default=None, ge=0.0, le=1.0) + false_alarm_budget: float | None = Field(default=None, ge=0.0, le=1.0) + calibration: dict[str, Any] | None = None + metrics: dict[str, Any] | None = None + ood: dict[str, Any] | None = None @model_validator(mode="after") def validate_features_and_capabilities(self) -> Self: @@ -85,6 +101,8 @@ def validate_features_and_capabilities(self) -> Self: raise ValueError("a stage-2 artifact must support forecasting") if self.capabilities.supports_actions: raise ValueError("a stage-2 forecast cannot claim action support") + if self.capabilities.supports_multi_horizon and self.schema_version != "1.2": + raise ValueError("multi-horizon capability requires schema 1.2") return self @@ -127,7 +145,8 @@ def predict(self, features: pd.DataFrame) -> np.ndarray: actual = tuple(str(name) for name in features.columns) if actual != self.metadata.feature_names: raise ValueError( - f"feature order mismatch: expected {self.metadata.feature_names}, received {actual}" + "feature order mismatch: expected " + f"{self.metadata.feature_names}, received {actual}" ) prediction = np.asarray(self.predictor.predict(features), dtype=float) if prediction.shape != (len(features),): @@ -143,7 +162,8 @@ def predict_upper(self, features: pd.DataFrame) -> np.ndarray: actual = tuple(str(name) for name in features.columns) if actual != self.metadata.feature_names: raise ValueError( - f"feature order mismatch: expected {self.metadata.feature_names}, received {actual}" + "feature order mismatch: expected " + f"{self.metadata.feature_names}, received {actual}" ) predict_upper = getattr(self.predictor, "predict_upper", None) if not callable(predict_upper): @@ -156,10 +176,137 @@ def predict_upper(self, features: pd.DataFrame) -> np.ndarray: raise ValueError("upper prediction cannot be below point prediction") return prediction + def predict_exceedance_probability(self, features: pd.DataFrame) -> np.ndarray: + """Return a calibrated probability only when the artifact declares it.""" + if not self.metadata.capabilities.supports_exceedance_probability: + raise ValueError("model artifact does not declare exceedance probability support") + self._check_feature_order(features) + predict_probability = getattr(self.predictor, "predict_exceedance_probability", None) + if not callable(predict_probability): + raise ValueError( + "safety predictor must expose predict_exceedance_probability(features)" + ) + probability = np.asarray(predict_probability(features), dtype=float) + if probability.shape != (len(features),) or not np.isfinite(probability).all(): + raise ValueError("risk predictor must return one finite value per feature row") + if np.any((probability < 0.0) | (probability > 1.0)): + raise ValueError("exceedance probabilities must be in [0, 1]") + return probability + + def predict_alarm(self, features: pd.DataFrame) -> np.ndarray: + """Apply the validation-selected threshold stored in the predictor.""" + self.predict_exceedance_probability(features) + predict_alarm = getattr(self.predictor, "predict_alarm", None) + if not callable(predict_alarm): + raise ValueError("safety predictor must expose predict_alarm(features)") + alarm = np.asarray(predict_alarm(features), dtype=bool) + if alarm.shape != (len(features),): + raise ValueError("alarm predictor must return one flag per feature row") + return alarm + + def predict_safety(self, features: pd.DataFrame) -> list[dict[str, object]]: + """Return the complete schema-1.1 safety contract for every row.""" + point = self.predict(features) + upper = self.predict_upper(features) + probability = self.predict_exceedance_probability(features) + alarm = self.predict_alarm(features) + rows: list[dict[str, object]] = [] + for position in range(len(features)): + applicability = self.check_applicability(features.iloc[[position]]) + available = bool(getattr(applicability, "available", False)) + reason = getattr(applicability, "reason_code", None) + rows.append( + { + "point": float(point[position]), + "upper": float(upper[position]), + "exceedance_probability": float(probability[position]), + "alarm": bool(alarm[position]), + "applicable": available, + "reason_codes": () if reason is None else (str(reason),), + } + ) + return rows + + def predict_v2(self, features: pd.DataFrame) -> list[dict[str, object]]: + """Return the schema-1.2 multi-horizon contract after capability checks.""" + if not self.metadata.capabilities.supports_multi_horizon: + raise ValueError("model artifact does not declare multi-horizon support") + self._check_feature_order(features) + predict = getattr(self.predictor, "predict_v2", None) + if not callable(predict): + raise ValueError("v2 predictor must expose predict_v2(features)") + rows = predict(features) + if not isinstance(rows, list) or len(rows) != len(features): + raise ValueError("v2 predictor must return one object per feature row") + required = { + "point", + "upper", + "exceedance_probability", + "alarm", + "horizon_probabilities", + "crossing_probability_60m", + "event_alarm", + "predicted_delta", + "point_60m", + "upper_60m", + "applicable", + "reason_codes", + } + if any(not isinstance(row, Mapping) or not required.issubset(row) for row in rows): + raise ValueError("v2 predictor returned an incomplete safety contract") + expected_horizons = {"10", "20", "30", "60"} + for row in rows: + horizons = row["horizon_probabilities"] + normalized_horizons = ( + {str(key): value for key, value in horizons.items()} + if isinstance(horizons, Mapping) + else {} + ) + if set(normalized_horizons) != expected_horizons: + raise ValueError("v2 predictor returned invalid horizon probabilities") + try: + values = np.asarray( + [float(normalized_horizons[key]) for key in expected_horizons], dtype=float + ) + scalar_names = ( + "point", + "upper", + "predicted_delta", + "point_60m", + "upper_60m", + ) + scalar_values = np.asarray( + [float(row[key]) for key in scalar_names], + dtype=float, + ) + except (TypeError, ValueError, KeyError) as exc: + raise ValueError("v2 predictor returned non-numeric safety values") from exc + if ( + not np.isfinite(values).all() + or np.any((values < 0.0) | (values > 1.0)) + or not np.isfinite(scalar_values).all() + or scalar_values[1] < scalar_values[0] + or scalar_values[4] < scalar_values[3] + ): + raise ValueError("v2 predictor returned invalid safety values") + return rows + + def _check_feature_order(self, features: pd.DataFrame) -> None: + actual = tuple(str(name) for name in features.columns) + if actual != self.metadata.feature_names: + raise ValueError( + "feature order mismatch: expected " + f"{self.metadata.feature_names}, received {actual}" + ) + def check_applicability(self, features: pd.DataFrame) -> object: """Check persisted train-only feature bounds before a Stage-5 forecast.""" from source.ml.uncertainty import ApplicabilityResult, check_applicability + joint_check = getattr(self.predictor, "check_applicability", None) + if callable(joint_check): + return joint_check(features) + raw_bounds = self.metadata.applicability.get("feature_bounds") if not isinstance(raw_bounds, Mapping): return ApplicabilityResult(False, "APPLICABILITY_UNAVAILABLE") @@ -220,7 +367,7 @@ def save_model( temporary = Path(tempfile.mkdtemp(prefix=f".{directory.name}.", dir=directory.parent)) try: model_path = temporary / "model.joblib" - joblib.dump(predictor, model_path, compress=3) + _joblib().dump(predictor, model_path, compress=3) payload = dict(metadata) payload["model_sha256"] = sha256_file(model_path) validated = ModelMetadata.model_validate(payload) @@ -239,7 +386,7 @@ def load_model( directory: Path, *, trusted: bool = False, - expected_schema_version: str = MODEL_METADATA_VERSION, + expected_schema_version: str | None = None, expected_horizon_minutes: int | None = None, expected_feature_names: Sequence[str] | None = None, expected_feature_schema_hash: str | None = None, @@ -257,11 +404,13 @@ def load_model( metadata = ModelMetadata.model_validate_json(metadata_path.read_text(encoding="utf-8")) if metadata.model_id != directory.name: raise ValueError("metadata model_id does not match the artifact directory") - if metadata.schema_version != expected_schema_version: + if metadata.schema_version not in SUPPORTED_MODEL_METADATA_VERSIONS: + raise ValueError("model schema version is unsupported") + if expected_schema_version is not None and metadata.schema_version != expected_schema_version: raise ValueError("model schema version is incompatible") if _python_major_minor(metadata.python_version) != platform.python_version_tuple()[:2]: raise ValueError("model Python version is incompatible") - if metadata.sklearn_version != sklearn.__version__: + if metadata.sklearn_version != _sklearn_version(): raise ValueError("model scikit-learn version is incompatible") if sha256_file(model_path) != metadata.model_sha256: raise ValueError("model checksum mismatch") @@ -283,10 +432,22 @@ def load_model( _check_expected("target_signal", metadata.target_signal, expected_target_signal) _check_expected("target_source", metadata.target_source, expected_target_source) _check_expected("target_unit", metadata.target_unit, expected_target_unit) - predictor = joblib.load(model_path) + predictor = _joblib().load(model_path) return ModelBundle(predictor=predictor, metadata=metadata) +def _joblib() -> Any: + import joblib + + return joblib + + +def _sklearn_version() -> str: + import sklearn + + return str(sklearn.__version__) + + def _python_major_minor(version: str) -> tuple[str, str]: parts = version.split(".") if len(parts) < 2: @@ -309,6 +470,7 @@ def _write_json(path: Path, payload: Mapping[str, Any]) -> None: __all__ = [ "MODEL_METADATA_VERSION", + "SUPPORTED_MODEL_METADATA_VERSIONS", "LastValueRegressor", "ModelBundle", "ModelCapabilities", diff --git a/source/ml/blending.py b/source/ml/blending.py index 8f0291d..c7247a5 100644 --- a/source/ml/blending.py +++ b/source/ml/blending.py @@ -8,17 +8,19 @@ from source.contracts import ( BlendComponent, + CetaneAdditiveSpec, ConstraintStatus, EstimateBasis, IntervalKind, MetricEstimate, + ProductGrade, Stage, TagMeta, Unit, ) FRACTION_TOLERANCE = 1e-9 -GAS_CONTEXT_SIGNAL_IDS = ("ht:F9", "ht:F22", "ht:Q21") +GAS_CONTEXT_SIGNAL_IDS = ("ht:F2", "ht:F22", "ht:F25") GAS_CONTEXT_REASON = ( "context_only: gas signals are observed process context, not enabled action controls" ) @@ -52,7 +54,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class BlendResult: - """Partial blend result: core demo properties are assessed, not the full passport.""" + """Synthetic product-passport result with explicit scenario bounds.""" sulfur: MetricEstimate t95: MetricEstimate @@ -60,8 +62,9 @@ class BlendResult: stock_shortfalls_t: tuple[tuple[str, float], ...] checked_properties: tuple[str, ...] unassessed_properties: tuple[str, ...] - full_specification_status: Literal["not_assessed"] + full_specification_status: Literal["assessed", "not_assessed"] assumptions: tuple[str, ...] + density: MetricEstimate | None = None @property def stock_feasible(self) -> bool: @@ -161,8 +164,11 @@ def calculate_mass_blend( mass_fractions: dict[str, float], components: tuple[BlendComponent, ...], total_mass_t: float, + *, + additive_mass_fraction: float = 0.0, + additive: CetaneAdditiveSpec | None = None, ) -> BlendResult: - """Calculate sulfur by mass and preserve missing values instead of imputing zero.""" + """Calculate the declared synthetic blend model without imputing missing quality.""" if total_mass_t <= 0: raise ValueError("total_mass_t must be positive") if not components: @@ -175,15 +181,18 @@ def calculate_mass_blend( raise ValueError("mass fractions must contain every component exactly once") if any(weight < 0 for weight in mass_fractions.values()): raise ValueError("mass fractions must be nonnegative") - if abs(sum(mass_fractions.values()) - 1.0) > FRACTION_TOLERANCE: - raise ValueError("mass fractions must sum to one") - if {component.sulfur.unit for component in components} != {Unit.MG_KG.value}: - raise ValueError("all component sulfur estimates must use mg/kg") + if abs(sum(mass_fractions.values()) + additive_mass_fraction - 1.0) > FRACTION_TOLERANCE: + raise ValueError("mass fractions and additive must sum to one") + if additive_mass_fraction and additive is None: + raise ValueError("additive dose needs an additive model") positive_ids = [key for key, weight in mass_fractions.items() if weight > 0] def weighted( - metric: Literal["sulfur", "t95", "cetane_number"], field: Literal["value", "upper"] + metric: Literal["sulfur", "t95", "cetane_number"], + field: Literal["value", "lower", "upper"], + *, + normalize: bool = False, ) -> float | None: total = 0.0 for key in positive_ids: @@ -192,72 +201,154 @@ def weighted( if value is None: return None total += mass_fractions[key] * value - return total - - def metric_estimate(metric: Literal["sulfur", "t95", "cetane_number"]) -> MetricEstimate: - estimates = [getattr(component_map[key], metric) for key in positive_ids] - present = [item for item in estimates if item is not None] - if len(present) != len(estimates): - return MetricEstimate( - value=None, - lower=None, - upper=None, - unit=Unit.UNKNOWN.value, - basis=EstimateBasis.FORMULA, - interval_kind=IntervalKind.NONE, - interval_level=None, - reference="DESIGN.md#9", - assumptions=assumptions, - ) - units = {item.unit for item in present} - if len(units) != 1: - raise ValueError(f"all component {metric} estimates must use one unit") - upper = weighted(metric, "upper") - return MetricEstimate( - value=weighted(metric, "value"), - lower=None, - upper=upper, - unit=present[0].unit, - basis=EstimateBasis.FORMULA, - interval_kind=IntervalKind.SCENARIO_BOUND if upper is not None else IntervalKind.NONE, - interval_level=None, - reference="DESIGN.md#9", - assumptions=assumptions - + tuple( - assumption - for component in components - for estimate in (getattr(component, metric),) - if estimate is not None - for assumption in estimate.assumptions - ), - ) - + return total / sum(mass_fractions.values()) if normalize else total + + sulfur_value = weighted("sulfur", "value") + sulfur_upper = weighted("sulfur", "upper") + t95_value = weighted("t95", "value", normalize=True) + t95_upper = weighted("t95", "upper", normalize=True) + cetane_value = weighted("cetane_number", "value", normalize=True) + cetane_lower = weighted("cetane_number", "lower", normalize=True) + + def density_bound(field: Literal["value", "lower", "upper"]) -> float | None: + """Mix mass fractions by reciprocal density (mass/volume).""" + if additive_mass_fraction: + # No additive density passport is present, so refusing a density + # number is safer than silently treating it as a base component. + return None + volume_per_mass = 0.0 + for key in positive_ids: + estimate = component_map[key].density + if estimate is None: + return None + value = getattr(estimate, field) + if value is None or value <= 0: + return None + volume_per_mass += mass_fractions[key] / value + return 1.0 / volume_per_mass if volume_per_mass > 0 else None + + density_value = density_bound("value") + # Reciprocal bounds reverse the component extrema. + density_lower = density_bound("upper") + density_upper = density_bound("lower") + cetane_gain = _additive_gain(additive, additive_mass_fraction) + if cetane_value is not None: + cetane_value += cetane_gain + if cetane_lower is not None: + cetane_lower += cetane_gain shortfalls = tuple( (component.id, mass_fractions[component.id] * total_mass_t - component.available_mass_t) for component in components if mass_fractions[component.id] * total_mass_t > component.available_mass_t + FRACTION_TOLERANCE ) + if ( + additive is not None + and additive_mass_fraction * total_mass_t > additive.available_mass_t + FRACTION_TOLERANCE + ): + shortfalls += ( + ( + additive.id, + additive_mass_fraction * total_mass_t - additive.available_mass_t, + ), + ) assumptions = ( - "Sulfur, T95, cetane number and their upper bounds are mixed by mass fraction.", - "The weighted upper bounds are conservative scenario bounds without joint coverage claims.", - "Only the model-demo product properties and component stock are assessed.", + "Sulfur and its upper bound are mixed by mass fraction.", + "The weighted upper bound is conservative and has no joint coverage claim.", + "T95 and base cetane number are explicit linear scenario approximations.", + "Additive has zero modeled sulfur/T95 effect and follows its scenario cetane curve.", + ) + sulfur = MetricEstimate( + value=sulfur_value, + lower=None, + upper=sulfur_upper, + unit=Unit.MG_KG.value, + basis=EstimateBasis.FORMULA, + interval_kind=( + IntervalKind.SCENARIO_BOUND if sulfur_upper is not None else IntervalKind.NONE + ), + interval_level=None, + reference="DESIGN.md#9", + assumptions=assumptions + + tuple( + assumption for component in components for assumption in component.sulfur.assumptions + ), + ) + t95 = MetricEstimate( + value=t95_value, + lower=None, + upper=t95_upper, + unit=Unit.CELSIUS.value, + basis=EstimateBasis.FORMULA, + interval_kind=IntervalKind.SCENARIO_BOUND if t95_upper is not None else IntervalKind.NONE, + interval_level=None, + reference="scenario linear T95 blend assumption", + assumptions=assumptions, + ) + cetane = MetricEstimate( + value=cetane_value, + lower=cetane_lower, + upper=None, + unit=Unit.CETANE.value, + basis=EstimateBasis.FORMULA, + interval_kind=( + IntervalKind.SCENARIO_BOUND if cetane_lower is not None else IntervalKind.NONE + ), + interval_level=None, + reference="scenario linear cetane blend and additive response curve", + assumptions=assumptions, + ) + missing = tuple( + name + for name, value in (("t95", t95.upper), ("cetane_number", cetane.lower)) + if value is None + ) + density = ( + MetricEstimate( + value=density_value, + lower=density_lower, + upper=density_upper, + unit=Unit.DENSITY.value, + basis=EstimateBasis.FORMULA, + interval_kind=( + IntervalKind.SCENARIO_BOUND + if density_lower is not None or density_upper is not None + else IntervalKind.NONE + ), + interval_level=None, + reference="mass/volume density blend", + assumptions=("Density is mixed through reciprocal volumes.",), + ) + if density_value is not None or density_lower is not None or density_upper is not None + else None ) - sulfur = metric_estimate("sulfur") - t95 = metric_estimate("t95") - cetane_number = metric_estimate("cetane_number") + checked_properties = ["sulfur", "t95", "cetane_number", "component_stock"] + if density is not None: + checked_properties.insert(1, "density") return BlendResult( sulfur=sulfur, t95=t95, - cetane_number=cetane_number, + cetane_number=cetane, stock_shortfalls_t=shortfalls, - checked_properties=("sulfur", "t95", "cetane_number", "component_stock"), - unassessed_properties=("full_product_passport",), - full_specification_status="not_assessed", + checked_properties=tuple(checked_properties), + unassessed_properties=missing, + full_specification_status="not_assessed" if missing else "assessed", assumptions=assumptions, ) +def _additive_gain(additive: CetaneAdditiveSpec | None, dose: float) -> float: + if dose == 0: + return 0.0 + if additive is None or dose > additive.max_mass_fraction + FRACTION_TOLERANCE: + raise ValueError("additive dose is outside the configured model") + for left, right in zip(additive.response_curve, additive.response_curve[1:], strict=False): + if left.mass_fraction <= dose <= right.mass_fraction: + share = (dose - left.mass_fraction) / (right.mass_fraction - left.mass_fraction) + return left.cetane_gain + share * (right.cetane_gain - left.cetane_gain) + raise ValueError("additive response curve does not cover dose") + + def sulfur_constraint_status( result: BlendResult, limit_mg_kg: float, *, require_upper: bool = True ) -> ConstraintStatus: @@ -308,30 +399,81 @@ def rank_feasible_blends( current_fractions: dict[str, float], *, sulfur_upper_limit: float = 10.0, + t95_upper_limit: float = 360.0, + cetane_lower_limit: float = 51.0, + additive_mass_fraction: float = 0.0, + additive: CetaneAdditiveSpec | None = None, + product_grade: ProductGrade | str | None = None, + density_lower_limit: float | None = None, + density_upper_limit: float | None = None, ) -> tuple[BlendOption, ...]: - """Filter quality/stock failures and rank the remaining hybrid recipes.""" + """Filter the complete scenario passport and rank remaining recipes.""" component_map = {component.id: component for component in components} if set(current_fractions) != set(component_map): raise ValueError("current fractions must contain every component") + if product_grade is not None: + grade = ProductGrade(product_grade) + if grade is ProductGrade.HDS_DIESEL: + cetane_lower_limit = float("-inf") + density_lower_limit, density_upper_limit = 820.0, 845.0 + elif grade is ProductGrade.SUMMER_DIESEL: + cetane_lower_limit = 51.0 + density_lower_limit, density_upper_limit = 820.0, 845.0 + else: + cetane_lower_limit = 49.0 + density_lower_limit, density_upper_limit = 800.0, 845.0 options: list[BlendOption] = [] for recipe in recipes: - result = calculate_mass_blend(recipe, components, total_mass_t) + final_recipe = { + key: value * (1.0 - additive_mass_fraction) for key, value in recipe.items() + } + result = calculate_mass_blend( + final_recipe, + components, + total_mass_t, + additive_mass_fraction=additive_mass_fraction, + additive=additive, + ) if sulfur_constraint_status(result, sulfur_upper_limit) is not ConstraintStatus.PASS: continue - recipe_id = "blend:" + ",".join(f"{key}={recipe[key]:.2f}" for key in sorted(recipe)) + if ( + result.t95.upper is None + or result.t95.upper > t95_upper_limit + or result.cetane_number.lower is None + or result.cetane_number.lower < cetane_lower_limit + ): + continue + if density_lower_limit is not None or density_upper_limit is not None: + density = result.density + if density is None or density.value is None: + continue + if density_lower_limit is not None and ( + density.lower is None or density.lower < density_lower_limit + ): + continue + if density_upper_limit is not None and ( + density.upper is None or density.upper > density_upper_limit + ): + continue + recipe_id = "blend:" + ",".join( + f"{key}={final_recipe[key]:.3f}" for key in sorted(final_recipe) + ) options.append( BlendOption( recipe_id=recipe_id, - mass_fractions=recipe, + mass_fractions=final_recipe, result=result, risk_index=sum( - recipe[key] * component_map[key].risk_index for key in component_map + final_recipe[key] * component_map[key].risk_index for key in component_map ), throughput=total_mass_t, cost_proxy=sum( - recipe[key] * component_map[key].cost_proxy_per_t for key in component_map + final_recipe[key] * component_map[key].cost_proxy_per_t for key in component_map + ) + + (0.0 if additive is None else additive_mass_fraction * additive.cost_proxy_per_t), + change_size=sum( + abs(final_recipe[key] - current_fractions[key]) for key in component_map ), - change_size=sum(abs(recipe[key] - current_fractions[key]) for key in component_map), ) ) return tuple(sorted(options, key=lambda option: option.rank_key)) diff --git a/source/ml/controls.py b/source/ml/controls.py index 3e99443..7432f09 100644 --- a/source/ml/controls.py +++ b/source/ml/controls.py @@ -8,6 +8,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from itertools import product from typing import Any @@ -25,14 +26,29 @@ ) CONTROL_IDS = ("ht:P8", "ht:T11", "ht:F19") +# The only signals currently studied as historical interventions. CONTROL_IDS +# remains the legacy stage-3 observational vector for artifact compatibility; +# action_effects.py uses this explicit two-signal set. +ACTION_RESEARCH_CONTROLS = ("ht:P8", "ht:F19") +ACTION_EFFECT_HORIZONS_MINUTES = (60, 120, 180) +ACTION_ARTIFACT_GATE_KEYS = ( + "minimum_episodes", + "per_control_episodes", + "mae_gain_10_percent", + "validation_upper_coverage", + "audit_upper_coverage", + "sign_stability", + "engineering_bounds", + "shadow_replay", + "technologist_pilot", +) CONTROL_MEANINGS = { - "ht:P8": "Polisep reactor R-202 inlet gas/feed temperature", - "ht:T11": "Hydrotreatment unit mass feed rate", - "ht:F19": "Polisep reactor R-202 inlet pressure", + "ht:P8": "R-202 reactor differential pressure (MPa)", + "ht:T11": "R-202 outlet product temperature (degC), context only", + "ht:F19": "Gasoline flow to K-201 (t/h)", } -CONTROL_EVIDENCE = ( - "materials/Теги_хакатон.xlsx#КИП;DESIGN.md#16-подтверждения-экспертов-от-10092026" -) +CONTROL_EVIDENCE = "materials/теги АВТ_24-2000.xlsx#24-2000;QA_2026-09-15" +ACTION_HORIZONS_MINUTES = (15, 30, 60, 120, 180) @dataclass(frozen=True) @@ -98,6 +114,11 @@ class ActionEffectEvidence: best_lag_minutes: int baseline_mae: float action_model_mae: float + per_control_episode_counts: dict[str, int] | None = None + conservative_coverage: float | None = None + sign_stable_folds: int | None = None + shadow_replay_passed: bool = False + pilot_approved: bool = False @dataclass(frozen=True) @@ -108,6 +129,88 @@ class ActionCapabilityReport: evidence: ActionEffectEvidence | None +def extract_change_episodes( + telemetry: pd.DataFrame, + change_thresholds: dict[str, float], + *, + target_signal: str = "ht:2:Mg.Sulfur", + horizons_minutes: tuple[int, ...] = ACTION_HORIZONS_MINUTES, + stable_window_minutes: int = 60, +) -> pd.DataFrame: + """Extract isolated natural control changes for offline action research. + + The result is observational evidence only. It cannot enable controls by + itself because operator feedback and unobserved feed changes may confound it. + """ + required = {"timestamp", target_signal, *CONTROL_IDS} + missing = sorted(required.difference(telemetry.columns)) + if missing: + raise ValueError(f"telemetry is missing action-research columns: {missing}") + if set(change_thresholds) != set(CONTROL_IDS) or any( + not np.isfinite(value) or value <= 0 for value in change_thresholds.values() + ): + raise ValueError("positive finite change thresholds are required for every control") + if ( + stable_window_minutes <= 0 + or not horizons_minutes + or any(horizon <= 0 for horizon in horizons_minutes) + ): + raise ValueError("stable window and action horizons must be positive") + + frame = telemetry.loc[:, ["timestamp", *CONTROL_IDS, target_signal]].copy() + frame["timestamp"] = pd.to_datetime(frame["timestamp"], utc=True, errors="raise") + frame = frame.sort_values("timestamp").drop_duplicates("timestamp", keep=False) + frame = frame.set_index("timestamp") + numeric = frame.apply(pd.to_numeric, errors="coerce") + changes = numeric.loc[:, list(CONTROL_IDS)].diff() + rows: list[dict[str, Any]] = [] + for timestamp, deltas in changes.iterrows(): + active = [ + signal_id + for signal_id in CONTROL_IDS + if pd.notna(deltas[signal_id]) + and abs(float(deltas[signal_id])) >= change_thresholds[signal_id] + ] + if len(active) != 1: + continue + signal_id = active[0] + start = timestamp - pd.Timedelta(minutes=stable_window_minutes) + before = numeric.loc[(numeric.index >= start) & (numeric.index < timestamp)] + if len(before) < 3 or before[[*CONTROL_IDS, target_signal]].isna().any().any(): + continue + if any( + before[control].diff().abs().max() >= change_thresholds[control] + for control in CONTROL_IDS + ): + continue + baseline = float(before[target_signal].iloc[-1]) + row: dict[str, Any] = { + "changed_at": timestamp, + "control_id": signal_id, + "control_delta": float(deltas[signal_id]), + "baseline_quality": baseline, + } + complete = True + for horizon in horizons_minutes: + target_at = timestamp + pd.Timedelta(minutes=horizon) + position = numeric.index.searchsorted(target_at) + if position >= len(numeric.index): + complete = False + break + observed_at = numeric.index[position] + if observed_at - target_at > pd.Timedelta(minutes=10): + complete = False + break + value = numeric.iloc[position][target_signal] + if pd.isna(value): + complete = False + break + row[f"delta_quality_{horizon}m"] = float(value) - baseline + if complete: + rows.append(row) + return pd.DataFrame(rows) + + def unconfirmed_real_controls() -> tuple[ControlSpec, ...]: """Return confirmed meanings while keeping unsupported real limits disabled.""" return tuple( @@ -188,7 +291,7 @@ def assess_action_capability( controls: tuple[ControlSpec, ...], evidence: ActionEffectEvidence | None, *, - minimum_episodes: int = 20, + minimum_episodes: int = 100, ) -> ActionCapabilityReport: """Gate action support independently from ordinary forecast performance.""" reasons: list[str] = [] @@ -205,6 +308,11 @@ def assess_action_capability( else: if evidence.change_episode_count < minimum_episodes: reasons.append("INSUFFICIENT_CHANGE_EPISODES") + counts = evidence.per_control_episode_counts + if counts is None or any( + counts.get(control.signal_id, 0) < minimum_episodes for control in controls + ): + reasons.append("INSUFFICIENT_PER_CONTROL_EPISODES") if not np.isfinite(evidence.change_episode_count) or evidence.change_episode_count < 0: reasons.append("ACTION_EPISODE_COUNT_INVALID") if not all( @@ -226,6 +334,16 @@ def assess_action_capability( and evidence.action_model_mae >= evidence.baseline_mae ): reasons.append("ACTION_MODEL_NO_TEMPORAL_GAIN") + elif evidence.action_model_mae > 0.9 * evidence.baseline_mae: + reasons.append("ACTION_MODEL_GAIN_BELOW_10_PERCENT") + if evidence.conservative_coverage is None or evidence.conservative_coverage < 0.95: + reasons.append("ACTION_INTERVAL_COVERAGE_INSUFFICIENT") + if evidence.sign_stable_folds != 3: + reasons.append("ACTION_EFFECT_SIGN_UNSTABLE") + if not evidence.shadow_replay_passed: + reasons.append("SHADOW_REPLAY_MISSING") + if not evidence.pilot_approved: + reasons.append("TECHNOLOGIST_PILOT_MISSING") return ActionCapabilityReport( supports_actions=not reasons, reason_codes=tuple(reasons), @@ -234,6 +352,97 @@ def assess_action_capability( ) +def _fingerprint(metadata: Mapping[str, Any], name: str) -> str | None: + raw = metadata.get(name) + if raw is None: + fingerprints = metadata.get("dataset_fingerprints") + if isinstance(fingerprints, Mapping): + raw = fingerprints.get(name) + return None if raw is None else str(raw) + + +def _check_fingerprint( + metadata: Mapping[str, Any], + name: str, + expected: str | None, +) -> None: + actual = _fingerprint(metadata, name) + if actual is None: + raise ValueError(f"action artifact {name} is missing") + if expected is not None and actual != expected: + raise ValueError(f"action artifact {name} is incompatible") + if len(actual) != 64 or any(ch not in "0123456789abcdef" for ch in actual): + raise ValueError(f"action artifact {name} must be a SHA-256 hex digest") + + +def _action_controls_from_metadata(metadata: Mapping[str, Any]) -> tuple[ControlSpec, ...]: + raw_controls = metadata.get("controls") + if not isinstance(raw_controls, list) or not raw_controls: + raise ValueError("action artifact needs enabled controls") + controls = tuple(ControlSpec.model_validate(item) for item in raw_controls) + control_ids = tuple(control.signal_id for control in controls) + if set(control_ids) != set(ACTION_RESEARCH_CONTROLS) or len(control_ids) != len( + set(control_ids) + ): + raise ValueError("action artifact may enable only ht:P8 and ht:F19") + if any(not control.enabled for control in controls): + raise ValueError("action artifact controls must be enabled") + if any(control.unit == Unit.UNKNOWN.value for control in controls): + raise ValueError("action artifact controls need confirmed units") + if any(control.basis is ConstraintBasis.MODEL_ASSUMPTION for control in controls): + raise ValueError("action artifact controls cannot use model-assumption limits") + return controls + + +def validate_action_artifact_metadata( + metadata: Mapping[str, Any], + *, + expected_dataset_id: str | None = None, + expected_config_sha256: str | None = None, + expected_tag_dictionary_sha256: str | None = None, + expected_telemetry_rules_sha256: str | None = None, +) -> tuple[ControlSpec, ...]: + """Validate the JSON contract before an action artifact can enable controls.""" + if metadata.get("artifact_kind") != "action_effect": + raise ValueError("artifact is not a production action-effect model") + if metadata.get("supports_actions") is not True: + raise ValueError("production action artifact must declare supports_actions=true") + if ( + expected_dataset_id is not None + and metadata.get("training_dataset_id") != expected_dataset_id + ): + raise ValueError("action artifact was trained on a different prepared dataset") + _check_fingerprint(metadata, "config_sha256", expected_config_sha256) + _check_fingerprint(metadata, "tag_dictionary_sha256", expected_tag_dictionary_sha256) + _check_fingerprint(metadata, "telemetry_rules_sha256", expected_telemetry_rules_sha256) + controls = _action_controls_from_metadata(metadata) + + horizons = metadata.get("horizons_minutes") + if tuple(horizons or ()) != ACTION_EFFECT_HORIZONS_MINUTES: + raise ValueError("action artifact horizons must be 60/120/180 minutes") + lag_evidence = metadata.get("lag_evidence") + if not isinstance(lag_evidence, Mapping): + raise ValueError("action artifact needs lag evidence") + selected_lag = lag_evidence.get("selected_lag_minutes") + if not isinstance(selected_lag, int) or not 0 <= selected_lag <= 180: + raise ValueError("action artifact selected lag is invalid") + + gate_report = metadata.get("gate_report") + if not isinstance(gate_report, Mapping): + raise ValueError("action artifact needs a gate report") + for gate in ACTION_ARTIFACT_GATE_KEYS: + if gate_report.get(gate) is not True: + raise ValueError(f"action gate failed: {gate}") + report_hashes = metadata.get("gate_report_hashes") + if not isinstance(report_hashes, Mapping) or not report_hashes: + raise ValueError("action artifact needs gate report hashes") + for name, value in report_hashes.items(): + text = str(value) + if len(text) != 64 or any(ch not in "0123456789abcdef" for ch in text): + raise ValueError(f"action gate report hash is invalid: {name}") + return controls + + def _selected_value(state: ProcessState, signal_id: str) -> float: snapshot = state.signals.get(signal_id) if snapshot is None or snapshot.selected is None or snapshot.selected.value is None: @@ -319,15 +528,21 @@ def generate_setpoint_candidates( __all__ = [ "ActionCapabilityReport", + "ACTION_HORIZONS_MINUTES", + "ACTION_EFFECT_HORIZONS_MINUTES", + "ACTION_ARTIFACT_GATE_KEYS", "ActionEffectEvidence", "CONTROL_EVIDENCE", "CONTROL_IDS", + "ACTION_RESEARCH_CONTROLS", "CONTROL_MEANINGS", "JointControlDomain", "ObservedControlStats", "assess_action_capability", + "extract_change_episodes", "fit_joint_control_domain", "generate_setpoint_candidates", + "validate_action_artifact_metadata", "summarize_observed_controls", "unconfirmed_real_controls", ] diff --git a/source/ml/diagnostics.py b/source/ml/diagnostics.py new file mode 100644 index 0000000..856aad7 --- /dev/null +++ b/source/ml/diagnostics.py @@ -0,0 +1,325 @@ +"""Reproducible diagnostics for sulfur alignment, drift and feature usefulness.""" + +from __future__ import annotations + +from typing import Any, Iterable + +import numpy as np +import pandas as pd + +from source.contracts import SourceKind +from source.data.prepare import PreparedData +from source.ml.evaluate import make_outer_split +from source.ml.features import SupervisedDataset + +SULFUR_SIGNAL = "ht:2:Mg.Sulfur" +SULFUR_LIMIT = 10.0 +CONFIRMED_LIMS_OUTLIER_IDS = frozenset( + { + "c8a61777-9c04-5999-9a7c-ad46bc5e5d60", + "8e60da42-5108-5e92-a1aa-8608455aa850", + "26a758a9-3257-5e19-91da-efc9d04fc57d", + "41e64ee3-d1c7-5553-b630-ed28bfc5f4dc", + "74afa5b5-95e6-592c-80d2-ab41edffb64e", + "4b4cb099-2147-5c9f-b632-c07bd0604cf8", + } +) + + +def _source_value(value: object) -> str: + return str(getattr(value, "value", value)) + + +def _sulfur_rows(data: PreparedData, source: SourceKind) -> pd.DataFrame: + quality = data.quality + rows = quality[ + quality["signal_id"].astype(str).eq(SULFUR_SIGNAL) + & quality["source"].map(_source_value).eq(source.value) + ].loc[:, ["observation_id", "measured_at", "value"]] + rows = rows.copy() + rows["measured_at"] = pd.to_datetime(rows["measured_at"], utc=True) + rows["value"] = pd.to_numeric(rows["value"], errors="coerce") + return rows.dropna().sort_values("measured_at", kind="stable") + + +def exclude_confirmed_lims_outliers(frame: pd.DataFrame) -> tuple[pd.DataFrame, tuple[str, ...]]: + """Exclude only user-confirmed observation IDs while retaining raw provenance.""" + if "observation_id" not in frame.columns: + raise ValueError("LIMS outlier policy requires observation_id") + ids = frame["observation_id"].astype(str) + excluded = tuple(sorted(set(ids).intersection(CONFIRMED_LIMS_OUTLIER_IDS))) + return frame.loc[~ids.isin(CONFIRMED_LIMS_OUTLIER_IDS)].copy(), excluded + + +def pak_lims_alignment( + data: PreparedData, + *, + offsets_minutes: Iterable[int] = range(-180, 181, 30), +) -> dict[str, Any]: + """Compare timestamp shifts before and after confirmed observation exclusions.""" + pak = _sulfur_rows(data, SourceKind.PAK) + lims = _sulfur_rows(data, SourceKind.LIMS) + eligible_lims, excluded_ids = exclude_confirmed_lims_outliers(lims) + + def evaluate(selected: pd.DataFrame) -> list[dict[str, float | int | None]]: + result: list[dict[str, float | int | None]] = [] + for offset in offsets_minutes: + queries = selected.assign( + query=selected["measured_at"] + pd.Timedelta(minutes=int(offset)) + ) + matched = pd.merge_asof( + queries.sort_values("query", kind="stable"), + pak, + left_on="query", + right_on="measured_at", + direction="nearest", + tolerance=pd.Timedelta(minutes=20), + suffixes=("_lims", "_pak"), + ).dropna(subset=["value_lims", "value_pak"]) + correlation = ( + matched["value_lims"].corr(matched["value_pak"]) + if len(matched) >= 2 + and matched["value_lims"].std() > 0.0 + and matched["value_pak"].std() > 0.0 + else np.nan + ) + result.append( + { + "offset_minutes": int(offset), + "n": int(len(matched)), + "mae": float(np.mean(np.abs(matched["value_lims"] - matched["value_pak"]))) + if len(matched) + else None, + "bias_lims_minus_pak": float( + np.mean(matched["value_lims"] - matched["value_pak"]) + ) + if len(matched) + else None, + "correlation": float(correlation) if pd.notna(correlation) else None, + } + ) + return result + + return { + "raw": evaluate(lims), + "training_policy": { + "interpretation": "confirmed outliers stay in prepared data but are excluded from ML", + "excluded_count": len(excluded_ids), + "excluded_observation_ids": excluded_ids, + "excluded_values": sorted( + float(value) + for value in lims.loc[ + lims["observation_id"].astype(str).isin(excluded_ids), "value" + ] + ), + "results": evaluate(eligible_lims), + }, + } + + +def temporal_drift(dataset: SupervisedDataset) -> dict[str, Any]: + """Describe target drift and persistence failure on immutable temporal periods.""" + frame = dataset.frame.reset_index(drop=True) + split = make_outer_split(frame) + periods = {"train": split.train, "validation": split.validation, "test": split.test} + result: dict[str, Any] = {} + for name, indices in periods.items(): + selected = frame.iloc[indices] + actual = pd.to_numeric(selected["y"], errors="coerce").to_numpy(dtype=float) + baseline = pd.to_numeric(selected[dataset.baseline_feature], errors="coerce").to_numpy( + dtype=float + ) + valid = np.isfinite(actual) & np.isfinite(baseline) + actual = actual[valid] + baseline = baseline[valid] + transition = (baseline <= SULFUR_LIMIT) & (actual > SULFUR_LIMIT) + correlation = ( + float(np.corrcoef(baseline, actual)[0, 1]) + if baseline.std() > 0.0 and actual.std() > 0.0 + else None + ) + result[name] = { + "n": int(len(actual)), + "target_mean": float(actual.mean()), + "target_std": float(actual.std()), + "exceedance_rate": float(np.mean(actual > SULFUR_LIMIT)), + "transition_rate": float(transition.mean()), + "transition_count": int(transition.sum()), + "persistence_mae": float(np.mean(np.abs(actual - baseline))), + "current_future_correlation": correlation, + } + return result + + +def regime_diagnostics(dataset: SupervisedDataset) -> dict[str, Any]: + """Locate persistence errors in operationally meaningful current-sulfur bands.""" + frame = dataset.frame.reset_index(drop=True) + split = make_outer_split(frame) + periods = {"train": split.train, "validation": split.validation, "test": split.test} + bins = (-np.inf, 8.0, 10.0, 12.0, np.inf) + labels = ("below_8", "8_to_10", "10_to_12", "above_12") + result: dict[str, Any] = {} + for period, indices in periods.items(): + selected = frame.iloc[indices] + actual = pd.to_numeric(selected["y"], errors="coerce") + baseline = pd.to_numeric(selected[dataset.baseline_feature], errors="coerce") + band = pd.cut(baseline, bins=bins, labels=labels, right=False) + period_rows: dict[str, Any] = {} + for label in labels: + mask = band.eq(label) & actual.notna() & baseline.notna() + y = actual[mask].to_numpy(dtype=float) + current = baseline[mask].to_numpy(dtype=float) + period_rows[label] = { + "n": int(mask.sum()), + "persistence_mae": float(np.mean(np.abs(y - current))) if len(y) else None, + "future_exceedance_rate": float(np.mean(y > SULFUR_LIMIT)) if len(y) else None, + "transition_count": int(((current <= SULFUR_LIMIT) & (y > SULFUR_LIMIT)).sum()), + } + result[period] = period_rows + return result + + +def telemetry_residual_screen( + data: PreparedData, + dataset: SupervisedDataset, + *, + top_n: int = 20, +) -> list[dict[str, Any]]: + """Rank train-only associations and expose whether their sign survives later periods.""" + frame = dataset.frame.reset_index(drop=True) + split = make_outer_split(frame) + queries = frame.loc[:, ["as_of", "y", dataset.baseline_feature]].copy() + queries["as_of"] = pd.to_datetime(queries["as_of"], utc=True) + telemetry = data.telemetry.copy() + telemetry["timestamp"] = pd.to_datetime(telemetry["timestamp"], utc=True) + signal_names = tuple( + name for name in data.feature_order if name in telemetry.columns and name != "timestamp" + ) + matched = pd.merge_asof( + queries.sort_values("as_of", kind="stable"), + telemetry.loc[:, ["timestamp", *signal_names]].sort_values("timestamp", kind="stable"), + left_on="as_of", + right_on="timestamp", + direction="backward", + tolerance=pd.Timedelta(minutes=20), + ).sort_index(kind="stable") + residual = pd.to_numeric(matched["y"], errors="coerce") - pd.to_numeric( + matched[dataset.baseline_feature], errors="coerce" + ) + + def correlation(signal: str, indices: np.ndarray) -> tuple[int, float | None]: + x = pd.to_numeric(matched.iloc[indices][signal], errors="coerce") + y = residual.iloc[indices] + valid = x.notna() & y.notna() + value = ( + x[valid].corr(y[valid]) + if valid.sum() >= 100 and x[valid].std() > 0.0 and y[valid].std() > 0.0 + else np.nan + ) + return int(valid.sum()), float(value) if pd.notna(value) else None + + rows: list[dict[str, Any]] = [] + for signal in signal_names: + train_n, train_corr = correlation(signal, split.train) + if train_corr is None: + continue + validation_n, validation_corr = correlation(signal, split.validation) + test_n, test_corr = correlation(signal, split.test) + rows.append( + { + "signal": signal, + "train_n": train_n, + "train_correlation": train_corr, + "validation_n": validation_n, + "validation_correlation": validation_corr, + "test_n": test_n, + "test_correlation": test_corr, + "stable_sign": bool( + validation_corr is not None + and test_corr is not None + and np.sign(train_corr) == np.sign(validation_corr) == np.sign(test_corr) + ), + } + ) + rows.sort(key=lambda row: abs(float(row["train_correlation"])), reverse=True) + return rows[:top_n] + + +def engineered_feature_screen( + dataset: SupervisedDataset, *, top_n: int = 20, min_samples: int = 100 +) -> list[dict[str, Any]]: + """Check whether existing causal lags explain change beyond persistence.""" + frame = dataset.frame.reset_index(drop=True) + split = make_outer_split(frame) + residual = pd.to_numeric(frame["y"], errors="coerce") - pd.to_numeric( + frame[dataset.baseline_feature], errors="coerce" + ) + + def correlation(feature: str, indices: np.ndarray) -> tuple[int, float | None]: + x = pd.to_numeric(frame.iloc[indices][feature], errors="coerce") + y = residual.iloc[indices] + valid = x.notna() & y.notna() + value = ( + x[valid].corr(y[valid]) + if valid.sum() >= min_samples and x[valid].std() > 0.0 and y[valid].std() > 0.0 + else np.nan + ) + return int(valid.sum()), float(value) if pd.notna(value) else None + + result: list[dict[str, Any]] = [] + for feature in dataset.feature_names: + train_n, train_corr = correlation(feature, split.train) + if train_corr is None: + continue + validation_n, validation_corr = correlation(feature, split.validation) + test_n, test_corr = correlation(feature, split.test) + result.append( + { + "feature": feature, + "train_n": train_n, + "train_correlation": train_corr, + "validation_n": validation_n, + "validation_correlation": validation_corr, + "test_n": test_n, + "test_correlation": test_corr, + "stable_sign": bool( + validation_corr is not None + and test_corr is not None + and np.sign(train_corr) == np.sign(validation_corr) == np.sign(test_corr) + ), + } + ) + result.sort(key=lambda row: abs(float(row["train_correlation"])), reverse=True) + return result[:top_n] + + +def build_diagnostic_report( + data: PreparedData, dataset: SupervisedDataset, *, top_n: int = 20 +) -> dict[str, Any]: + """Build one JSON-serializable research report without changing model capability.""" + return { + "dataset_id": data.manifest.dataset_id, + "target": SULFUR_SIGNAL, + "pak_lims_alignment": pak_lims_alignment(data), + "temporal_drift": temporal_drift(dataset), + "regime_diagnostics": regime_diagnostics(dataset), + "engineered_feature_residual_screen": engineered_feature_screen(dataset, top_n=top_n), + "telemetry_residual_screen": telemetry_residual_screen(data, dataset, top_n=top_n), + "limitations": [ + "confirmed LIMS outliers remain in prepared data and provenance", + "univariate association is not causality and cannot enable actions", + "test correlations are diagnostic only and are not model-selection inputs", + ], + } + + +__all__ = [ + "build_diagnostic_report", + "CONFIRMED_LIMS_OUTLIER_IDS", + "engineered_feature_screen", + "exclude_confirmed_lims_outliers", + "pak_lims_alignment", + "regime_diagnostics", + "telemetry_residual_screen", + "temporal_drift", +] diff --git a/source/ml/evaluate.py b/source/ml/evaluate.py index 233b88f..a9c7041 100644 --- a/source/ml/evaluate.py +++ b/source/ml/evaluate.py @@ -22,8 +22,11 @@ class SupervisedDatasetLike(Protocol): """Small structural contract shared with ``ml.features``.""" - frame: pd.DataFrame - feature_names: tuple[str, ...] + @property + def frame(self) -> pd.DataFrame: ... + + @property + def feature_names(self) -> tuple[str, ...]: ... @dataclass(frozen=True) diff --git a/source/ml/formulas.py b/source/ml/formulas.py index b1e39b5..aa5be5c 100644 --- a/source/ml/formulas.py +++ b/source/ml/formulas.py @@ -16,7 +16,18 @@ SHEET_VAK = "ВАК" SHEET_LA = "ЛА" -# Corrections supplied by the task authors on 2026-09-10; see DESIGN §16. + +def normalize_formula_text(value: object) -> str: + """Convert organiser formula typography to stable ASCII-like text.""" + import re + + text = str(value).strip().replace("−", "-").replace("×", "*") + return re.sub(r"(?<=\d),(?=\d)", ".", text) + + +# Corrections supplied in the organisers' 2026-09-15 VAK workbook. Keep the +# raw inventory untouched and overlay only these exact, auditable expressions. +FORMULA_SOURCE_VERSION = "organizer-vak-2026-09-15" EXPERT_VAK_CORRECTIONS: dict[str, str] = { "24-2000:GODT:T90": ( "162.998+0.12945*T12+59.57*(F15/2000)+0.00036*W7+0.26366*T23-424.72638*F1/F26" @@ -28,7 +39,12 @@ ), "24-2000:GODT:CFPP": ("0.22088*T23-102.375-47.75834*P8+0.03862*F9+43.60207*W7+43.81849*P24"), "24-2000:GODT:T95": ("0.03814*F9-9.201-0.00002*F2+0.50*T6+0.48321*LIMS:24-2000.Pipeline.95%.T"), - "AVT6:240-350:CFPP": ("31.40363-0.06784*T33+17.411*P67-8.11544*P4-0.47309*(F65/F32+F30)"), + "AVT6:240-350:D15": ("791.22872-5.30294*(F65/(F32+F30))+0.52755*T66-0.15629*T33"), + "AVT6:240-350:CFPP": ("31.40363-0.06784*T33+17.411*P67-8.11544*P4-0.47309*F65/(F32+F30)"), + "AVT6:350:T50": ( + "493.6798+1.281193*T42-0.955342*T48-0.018454*F31+0.265904*F57-0.082047*T66-0.545083*T33" + ), + "AVT6:350:I350": ("39.562-1.62865*L43+0.76664*T6-0.22361*T18+0.00031*F64*(T15-T11)"), } @@ -38,18 +54,32 @@ def load_vak_formulas(path: str | Path) -> dict[str, str]: Формулы возвращаются как строки без вычисления: их проверка — отдельная задача (план: «Формулы ВАК требуют проверки»). """ - if SHEET_VAK not in pd.ExcelFile(path).sheet_names: + workbook = pd.ExcelFile(path) + if SHEET_VAK in workbook.sheet_names: + sheets: tuple[str, ...] = (SHEET_VAK,) + else: + sheets = tuple( + str(name) + for name in workbook.sheet_names + if "АВТ" in str(name).upper() or "24-2000" in str(name).upper() + ) + if not sheets: return {} - vak = pd.read_excel(path, sheet_name=SHEET_VAK) formulas: dict[str, str] = {} - columns = list(vak.columns) - for tag_col, formula_col in zip(columns[0::2], columns[1::2], strict=False): - for _, row in vak.iterrows(): - tag_id = row[tag_col] - formula = row[formula_col] - if pd.isna(tag_id) or pd.isna(formula): - continue - formulas[str(tag_id).strip()] = str(formula).strip() + for sheet in sheets: + vak = pd.read_excel(workbook, sheet_name=sheet) + columns = [str(column) for column in vak.columns] + if {"Модель", "Формула"}.issubset({str(column) for column in columns}): + pairs: tuple[tuple[str, str], ...] = (("Модель", "Формула"),) + else: + pairs = tuple(zip(columns[0::2], columns[1::2], strict=False)) + for tag_col, formula_col in pairs: + for _, row in vak.iterrows(): + tag_id = row[tag_col] + formula = row[formula_col] + if pd.isna(tag_id) or pd.isna(formula): + continue + formulas[str(tag_id).strip()] = normalize_formula_text(formula) return formulas @@ -78,9 +108,11 @@ def load_lab_parameters(path: str | Path) -> dict[str, list[str]]: __all__ = [ "EXPERT_VAK_CORRECTIONS", + "FORMULA_SOURCE_VERSION", "SHEET_LA", "SHEET_VAK", "apply_expert_vak_corrections", "load_lab_parameters", "load_vak_formulas", + "normalize_formula_text", ] diff --git a/source/ml/safety.py b/source/ml/safety.py new file mode 100644 index 0000000..b2257b1 --- /dev/null +++ b/source/ml/safety.py @@ -0,0 +1,814 @@ +"""Safety-first sulfur forecasting helpers. + +The point forecast, conservative upper estimate and exceedance alarm are kept +separate deliberately: none of them is allowed to masquerade as an action +effect model. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Sequence, cast + +import numpy as np +import pandas as pd +from joblib import Parallel, delayed +from lightgbm import LGBMClassifier +from sklearn.base import BaseEstimator +from sklearn.decomposition import PCA +from sklearn.ensemble import GradientBoostingRegressor, HistGradientBoostingClassifier +from sklearn.impute import SimpleImputer +from sklearn.linear_model import HuberRegressor, LogisticRegression, Ridge +from sklearn.metrics import average_precision_score, brier_score_loss +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import FunctionTransformer, StandardScaler + +from source.data.prepare import PreparedData +from source.ml.artifacts import ModelBundle, save_model +from source.ml.diagnostics import exclude_confirmed_lims_outliers +from source.ml.evaluate import expanding_purged_folds, make_outer_split +from source.ml.features import SupervisedDataset +from source.ml.uncertainty import ApplicabilityResult + +SULFUR_LIMIT = 10.0 +POSITIVE_WEIGHTS = (1.0, 2.0, 5.0, 10.0, 20.0) +FALSE_ALARM_BUDGET = 0.20 +TARGET_FALSE_NEGATIVE_RATE = 0.10 + + +@dataclass(frozen=True) +class AlarmPolicy: + """Validation-selected operating point for a calibrated risk score.""" + + threshold: float + false_alarm_budget: float + target_false_negative_rate: float + + +@dataclass(frozen=True) +class JointApplicabilityModel: + """Train-only joint feature domain in whitened PCA space.""" + + feature_names: tuple[str, ...] + required_features: tuple[str, ...] + imputer: SimpleImputer + scaler: StandardScaler + pca: PCA + max_distance_squared: float + + def availability_mask(self, features: pd.DataFrame) -> np.ndarray: + """Vectorized applicability result for offline evaluation.""" + if tuple(str(name) for name in features.columns) != self.feature_names: + raise ValueError("applicability feature order mismatch") + required_available = features.loc[:, list(self.required_features)].notna().all(axis=1) + transformed = self.pca.transform(self.scaler.transform(self.imputer.transform(features))) + distances = np.square(transformed).sum(axis=1) + return cast( + np.ndarray, + required_available.to_numpy() + & np.isfinite(distances) + & (distances <= self.max_distance_squared), + ) + + def assess(self, features: pd.DataFrame) -> ApplicabilityResult: + """Reject missing required inputs and flag joint distribution drift.""" + if tuple(str(name) for name in features.columns) != self.feature_names: + raise ValueError("applicability feature order mismatch") + if len(features) != 1: + raise ValueError("applicability check requires exactly one feature row") + missing = tuple(name for name in self.required_features if pd.isna(features.iloc[0][name])) + if missing: + return ApplicabilityResult(False, "FEATURES_UNAVAILABLE", missing) + if not self.availability_mask(features)[0]: + return ApplicabilityResult(False, "OUT_OF_DOMAIN", ("joint_pca_distance",)) + return ApplicabilityResult(True, None) + + +@dataclass(frozen=True) +class PlattCalibrator: + """Small serializable sigmoid calibration layer.""" + + estimator: LogisticRegression + + def predict(self, probabilities: np.ndarray) -> np.ndarray: + clipped = np.clip(np.asarray(probabilities, dtype=float), 1e-6, 1 - 1e-6) + logits = np.log(clipped / (1.0 - clipped)).reshape(-1, 1) + return cast(np.ndarray, self.estimator.predict_proba(logits)[:, 1]) + + +@dataclass(frozen=True) +class CompositeSafetyPredictor: + """One persisted predictor exposing point, upper, risk and applicability.""" + + point_predictor: Any + upper_predictor: Any + risk_predictor: Any + risk_calibrator: PlattCalibrator + alarm_policy: AlarmPolicy + applicability: JointApplicabilityModel + + def predict(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray(self.point_predictor.predict(features), dtype=float) + + def predict_upper(self, features: pd.DataFrame) -> np.ndarray: + point = self.predict(features) + upper = np.asarray(self.upper_predictor.predict_upper(features), dtype=float) + return np.maximum(point, upper) + + def predict_exceedance_probability(self, features: pd.DataFrame) -> np.ndarray: + raw = np.asarray(self.risk_predictor.predict_proba(features)[:, 1], dtype=float) + return self.risk_calibrator.predict(raw) + + def predict_alarm(self, features: pd.DataFrame) -> np.ndarray: + risk_alarm = self.predict_exceedance_probability(features) >= self.alarm_policy.threshold + baseline = ( + pd.to_numeric(features["baseline"], errors="coerce").to_numpy(dtype=float) + if "baseline" in features + else np.full(len(features), np.nan) + ) + return (baseline > SULFUR_LIMIT) | risk_alarm + + def check_applicability(self, features: pd.DataFrame) -> ApplicabilityResult: + return self.applicability.assess(features) + + +@dataclass(frozen=True) +class SafetyFitResult: + predictor: CompositeSafetyPredictor + report: dict[str, Any] + + +@dataclass(frozen=True) +class LimsCorrectionResult: + """Sparse PAK-to-LIMS correction kept separate from the operational target.""" + + model_name: str + point_estimator: Any + upper_estimator: Any + report: dict[str, Any] + + +def published_lims_features( + data: PreparedData, as_of: pd.Series, *, depth: int = 3 +) -> pd.DataFrame: + """Return only LIMS values whose publication time is not later than each decision.""" + if depth < 1: + raise ValueError("LIMS history depth must be positive") + quality = data.quality + values = pd.to_numeric(quality["value"], errors="coerce") + source = quality["source"].map(lambda value: str(getattr(value, "value", value))) + validity = quality["validity"].map(lambda value: str(getattr(value, "value", value))) + rows = quality[ + quality["signal_id"].astype(str).eq("ht:2:Mg.Sulfur") + & source.eq("lims") + & validity.eq("valid") + & quality["unit"].astype(str).str.casefold().eq("mg/kg") + & values.notna() + & np.isfinite(values) + ].copy() + rows["value"] = values.loc[rows.index].astype(float) + rows, _ = exclude_confirmed_lims_outliers(rows) + rows["available_at"] = pd.to_datetime(rows["available_at"], utc=True) + rows["measured_at"] = pd.to_datetime(rows["measured_at"], utc=True) + rows.sort_values(["available_at", "measured_at", "observation_id"], inplace=True) + history = rows.loc[:, ["available_at"]].copy() + for position in range(depth): + history[f"lims_value_{position}"] = rows["value"].shift(position) + history[f"lims_measured_at_{position}"] = rows["measured_at"].shift(position) + queries = pd.DataFrame( + {"position": np.arange(len(as_of)), "as_of": pd.to_datetime(as_of, utc=True)} + ).sort_values("as_of", kind="stable") + merged = pd.merge_asof( + queries, + history, + left_on="as_of", + right_on="available_at", + direction="backward", + allow_exact_matches=True, + ).sort_values("position", kind="stable") + result: dict[str, np.ndarray] = {} + for position in range(depth): + result[f"lims_published_lag_{position}"] = merged[f"lims_value_{position}"].to_numpy( + dtype=float + ) + result[f"lims_age_minutes_{position}"] = ( + (merged["as_of"] - merged[f"lims_measured_at_{position}"]) + .dt.total_seconds() + .div(60) + .to_numpy(dtype=float) + ) + # A publication timestamp is necessary but not sufficient: malformed + # rows with a future sampling time must also be hidden from an as_of + # feature vector. + future = merged[f"lims_measured_at_{position}"] > merged["as_of"] + result[f"lims_published_lag_{position}"][future.to_numpy()] = np.nan + result[f"lims_age_minutes_{position}"][future.to_numpy()] = np.nan + return pd.DataFrame(result) + + +def binary_alarm_metrics( + y_true: Sequence[bool] | np.ndarray, + probabilities: Sequence[float] | np.ndarray, + threshold: float, +) -> dict[str, float | int | None]: + """Return explicit safety and calibration metrics for one alarm threshold.""" + actual = np.asarray(y_true, dtype=bool) + probability = np.asarray(probabilities, dtype=float) + if actual.ndim != 1 or probability.shape != actual.shape or not np.isfinite(probability).all(): + raise ValueError("alarm inputs must be equal-length finite vectors") + if not 0.0 <= threshold <= 1.0: + raise ValueError("alarm threshold must be between zero and one") + alarm = probability >= threshold + positives = int(actual.sum()) + negatives = int((~actual).sum()) + false_negatives = int((actual & ~alarm).sum()) + false_positives = int((~actual & alarm).sum()) + true_positives = int((actual & alarm).sum()) + return { + "n": int(len(actual)), + "positives": positives, + "false_negatives": false_negatives, + "false_negative_rate": false_negatives / positives if positives else None, + "false_positives": false_positives, + "false_positive_rate": false_positives / negatives if negatives else None, + "precision": true_positives / (true_positives + false_positives) + if true_positives + false_positives + else None, + "average_precision": float(average_precision_score(actual, probability)) + if positives and negatives + else None, + "brier": float(brier_score_loss(actual, probability)), + } + + +def select_alarm_threshold( + y_true: Sequence[bool] | np.ndarray, + probabilities: Sequence[float] | np.ndarray, + *, + false_alarm_budget: float = FALSE_ALARM_BUDGET, +) -> tuple[AlarmPolicy, dict[str, float | int | None]]: + """Minimize misses subject to the configured false-alarm budget.""" + if not 0.0 <= false_alarm_budget < 1.0: + raise ValueError("false_alarm_budget must be in [0, 1)") + actual = np.asarray(y_true, dtype=bool) + probability = np.asarray(probabilities, dtype=float) + if actual.ndim != 1 or probability.shape != actual.shape or not np.isfinite(probability).all(): + raise ValueError("alarm inputs must be equal-length finite vectors") + positives = int(actual.sum()) + negatives = int((~actual).sum()) + if not positives or not negatives: + raise ValueError("threshold selection needs both sulfur classes") + order = np.argsort(-probability, kind="stable") + sorted_probability = probability[order] + sorted_actual = actual[order] + cumulative_true = np.cumsum(sorted_actual) + cumulative_false = np.cumsum(~sorted_actual) + group_ends = np.r_[np.flatnonzero(np.diff(sorted_probability) != 0), len(actual) - 1] + brier = float(brier_score_loss(actual, probability)) + average_precision = float(average_precision_score(actual, probability)) + feasible: list[tuple[float, dict[str, float | int | None]]] = [] + for index in group_ends: + true_positives = int(cumulative_true[index]) + false_positives = int(cumulative_false[index]) + false_positive_rate = false_positives / negatives + if false_positive_rate <= false_alarm_budget + 1e-12: + feasible.append( + ( + float(sorted_probability[index]), + { + "n": int(len(actual)), + "positives": positives, + "false_negatives": positives - true_positives, + "false_negative_rate": (positives - true_positives) / positives, + "false_positives": false_positives, + "false_positive_rate": false_positive_rate, + "precision": true_positives / (true_positives + false_positives), + "average_precision": average_precision, + "brier": brier, + }, + ) + ) + if not feasible: + raise ValueError("no alarm threshold satisfies the false-alarm budget") + + def ordering(item: tuple[float, Mapping[str, float | int | None]]) -> tuple[float, float]: + threshold, metrics = item + fnr = metrics["false_negative_rate"] + return (float("inf") if fnr is None else float(fnr), -threshold) + + threshold, metrics = min(feasible, key=ordering) + return AlarmPolicy(threshold, false_alarm_budget, TARGET_FALSE_NEGATIVE_RATE), metrics + + +def fit_joint_applicability( + features: pd.DataFrame, + *, + required_features: Sequence[str], + coverage: float = 0.99, +) -> JointApplicabilityModel: + """Fit a compact joint domain without compounding 54 marginal cutoffs.""" + if not 0.5 < coverage < 1.0: + raise ValueError("coverage must be between 0.5 and 1") + names = tuple(str(name) for name in features.columns) + required = tuple(str(name) for name in required_features) + if not set(required).issubset(names): + raise ValueError("required applicability features are absent") + imputer = SimpleImputer(strategy="median") + scaler = StandardScaler() + imputed = imputer.fit_transform(features) + scaled = scaler.fit_transform(imputed) + pca = PCA(n_components=0.99, whiten=True, svd_solver="full").fit(scaled) + transformed = pca.transform(scaled) + distances = np.square(transformed).sum(axis=1) + return JointApplicabilityModel( + names, + required, + imputer, + scaler, + pca, + float(np.quantile(distances, coverage)), + ) + + +def _risk_estimator(family: str, seed: int) -> BaseEstimator: + if family == "logistic": + return Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ("scaler", StandardScaler()), + ( + "model", + LogisticRegression(max_iter=200, random_state=seed, solver="liblinear"), + ), + ) + ) + if family == "hist_gradient_boosting": + return HistGradientBoostingClassifier( + learning_rate=0.05, + max_iter=100, + max_leaf_nodes=15, + l2_regularization=1.0, + early_stopping=False, + random_state=seed, + ) + if family == "lightgbm": + return Pipeline( + ( + ("to_array", FunctionTransformer(np.asarray, validate=False)), + ( + "model", + LGBMClassifier( + objective="binary", + learning_rate=0.05, + n_estimators=200, + num_leaves=15, + reg_lambda=1.0, + random_state=seed, + n_jobs=1, + verbosity=-1, + ), + ), + ) + ) + raise ValueError(f"unknown risk family: {family}") + + +def _fit_risk( + family: str, + frame: pd.DataFrame, + feature_names: tuple[str, ...], + positive_weight: float, + seed: int, +) -> BaseEstimator: + target = pd.to_numeric(frame["y"], errors="coerce").to_numpy(dtype=float) + valid = np.isfinite(target) & frame.loc[:, list(feature_names)].notna().any(axis=1).to_numpy() + labels = target[valid] > SULFUR_LIMIT + if len(np.unique(labels)) != 2: + raise ValueError("risk training needs both sulfur classes") + weights = np.where(labels, positive_weight, 1.0) + if family == "lightgbm": + weights *= np.where((target[valid] >= 8.0) & (target[valid] <= 12.0), 2.0, 1.0) + estimator = _risk_estimator(family, seed) + estimator.fit( + frame.loc[valid, list(feature_names)], labels, **_sample_weight_argument(family, weights) + ) + return estimator + + +def _sample_weight_argument(family: str, weights: np.ndarray) -> dict[str, np.ndarray]: + if family in {"logistic", "lightgbm"}: + return {"model__sample_weight": weights} + return {"sample_weight": weights} + + +def _fit_calibrator(probabilities: np.ndarray, labels: np.ndarray, seed: int) -> PlattCalibrator: + clipped = np.clip(probabilities, 1e-6, 1 - 1e-6) + logits = np.log(clipped / (1.0 - clipped)).reshape(-1, 1) + estimator = LogisticRegression(random_state=seed).fit(logits, labels) + return PlattCalibrator(estimator) + + +def fit_safety_model( + dataset: SupervisedDataset, + point_upper_model: ModelBundle, + *, + source_timezone: str = "Europe/Moscow", + train_end: str = "2025-01-01", + validation_end: str = "2026-01-01", + false_alarm_budget: float = FALSE_ALARM_BUDGET, + seed: int = 42, + include_lightgbm: bool = False, + candidate_families: Sequence[str] | None = None, +) -> SafetyFitResult: + """Fit risk/OOD heads without reading test labels during selection.""" + frame = dataset.frame.reset_index(drop=True) + feature_names = tuple(dataset.feature_names) + split = make_outer_split( + frame, + source_timezone=source_timezone, + train_end=train_end, + validation_end=validation_end, + ) + train = frame.iloc[split.train].reset_index(drop=True) + validation = frame.iloc[split.validation].reset_index(drop=True) + test = frame.iloc[split.test].reset_index(drop=True) + if min(len(train), len(validation), len(test)) == 0: + raise ValueError("safety fitting needs non-empty train, validation and test") + + folds = expanding_purged_folds(train, n_splits=3) + + def score_candidate(family: str, positive_weight: float) -> dict[str, Any]: + actual_parts: list[np.ndarray] = [] + probability_parts: list[np.ndarray] = [] + for fit_indices, score_indices in folds: + estimator = _fit_risk( + family, + train.iloc[fit_indices], + feature_names, + positive_weight, + seed, + ) + score = train.iloc[score_indices] + actual_parts.append( + pd.to_numeric(score["y"], errors="coerce").to_numpy(dtype=float) > SULFUR_LIMIT + ) + probability_parts.append( + np.asarray(estimator.predict_proba(score.loc[:, list(feature_names)])[:, 1]) + ) + actual = np.concatenate(actual_parts) + probability = np.concatenate(probability_parts) + policy, metrics = select_alarm_threshold( + actual, probability, false_alarm_budget=false_alarm_budget + ) + return { + "family": family, + "positive_weight": positive_weight, + "threshold": policy.threshold, + "metrics": metrics, + } + + families = tuple(candidate_families or ("logistic", "hist_gradient_boosting")) + if include_lightgbm and "lightgbm" not in families: + families += ("lightgbm",) + unknown = set(families) - { + "logistic", + "hist_gradient_boosting", + "lightgbm", + } + if unknown: + raise ValueError(f"unknown risk families: {sorted(unknown)}") + candidates = Parallel(n_jobs=2, prefer="threads")( + delayed(score_candidate)(family, positive_weight) + for family in families + for positive_weight in POSITIVE_WEIGHTS + ) + + complexity = { + "logistic": 0, + "hist_gradient_boosting": 1, + "lightgbm": 2, + } + selected = min( + candidates, + key=lambda item: ( + float(item["metrics"]["false_negative_rate"]), + float(item["metrics"]["brier"]), + complexity[item["family"]], + item["positive_weight"], + ), + ) + risk = _fit_risk( + selected["family"], + train, + feature_names, + selected["positive_weight"], + seed, + ) + middle = len(validation) // 2 + calibration = validation.iloc[:middle] + policy_frame = validation.iloc[middle:] + calibration_labels = ( + pd.to_numeric(calibration["y"], errors="coerce").to_numpy(dtype=float) > SULFUR_LIMIT + ) + raw_calibration = np.asarray( + risk.predict_proba(calibration.loc[:, list(feature_names)])[:, 1], dtype=float + ) + calibrator = _fit_calibrator(raw_calibration, calibration_labels, seed) + policy_labels = ( + pd.to_numeric(policy_frame["y"], errors="coerce").to_numpy(dtype=float) > SULFUR_LIMIT + ) + raw_policy = np.asarray( + risk.predict_proba(policy_frame.loc[:, list(feature_names)])[:, 1], dtype=float + ) + policy, validation_metrics = select_alarm_threshold( + policy_labels, + calibrator.predict(raw_policy), + false_alarm_budget=false_alarm_budget, + ) + required = (str(dataset.baseline_feature),) + applicability = fit_joint_applicability( + train.loc[:, list(feature_names)], required_features=required + ) + predictor = CompositeSafetyPredictor( + point_predictor=point_upper_model.predictor, + upper_predictor=point_upper_model.predictor, + risk_predictor=risk, + risk_calibrator=calibrator, + alarm_policy=policy, + applicability=applicability, + ) + + test_features = test.loc[:, list(feature_names)] + test_labels = pd.to_numeric(test["y"], errors="coerce").to_numpy(dtype=float) > SULFUR_LIMIT + test_probability = predictor.predict_exceedance_probability(test_features) + test_metrics = binary_alarm_metrics(test_labels, test_probability, policy.threshold) + baseline = pd.to_numeric(test[required[0]], errors="coerce").to_numpy(dtype=float) + transition = (baseline <= SULFUR_LIMIT) & test_labels + transition_recall = ( + float(np.mean(predictor.predict_alarm(test_features)[transition])) + if transition.any() + else None + ) + in_domain = applicability.availability_mask(test_features) + promoted = ( + validation_metrics["false_negative_rate"] is not None + and float(validation_metrics["false_negative_rate"]) <= TARGET_FALSE_NEGATIVE_RATE + and validation_metrics["false_positive_rate"] is not None + and float(validation_metrics["false_positive_rate"]) <= false_alarm_budget + ) + report = { + "schema_version": "1.1", + "selected_family": selected["family"], + "positive_weight": selected["positive_weight"], + "near_threshold_weight": 2.0 if selected["family"] == "lightgbm" else 1.0, + "alarm_threshold": policy.threshold, + "false_alarm_budget": false_alarm_budget, + "target_false_negative_rate": TARGET_FALSE_NEGATIVE_RATE, + "validation_policy": validation_metrics, + "test": test_metrics, + "test_transition_recall": transition_recall, + "test_applicability_rate": float(in_domain.mean()), + "promotion_eligible": promoted, + "test_used_for_selection": False, + "candidates": candidates, + } + return SafetyFitResult(predictor, report) + + +def fit_lims_correction( + dataset: SupervisedDataset, + pak_model: ModelBundle, + *, + data: PreparedData | None = None, + source_timezone: str = "Europe/Moscow", + train_end: str = "2025-01-01", + validation_end: str = "2026-01-01", + seed: int = 42, +) -> LimsCorrectionResult: + """Fit a sparse, explicitly LIMS-targeted residual correction model.""" + frame = dataset.frame.reset_index(drop=True).copy() + frame, excluded_outliers = exclude_confirmed_lims_outliers(frame) + feature_names = tuple(dataset.feature_names) + original_features = frame.loc[:, list(feature_names)] + base = np.asarray(pak_model.predict(original_features), dtype=float) + frame["pak_point"] = base + pak_feature_names = ["pak_point"] + if pak_model.metadata.capabilities.supports_uncertainty: + frame["pak_upper"] = pak_model.predict_upper(original_features) + pak_feature_names.append("pak_upper") + if pak_model.metadata.capabilities.supports_exceedance_probability: + frame["pak_exceedance_probability"] = pak_model.predict_exceedance_probability( + original_features + ) + pak_feature_names.append("pak_exceedance_probability") + lims_feature_names: tuple[str, ...] = () + if data is not None: + lims_features = published_lims_features(data, frame["as_of"]) + frame = pd.concat([frame.reset_index(drop=True), lims_features], axis="columns") + lims_feature_names = tuple(lims_features.columns) + frame["residual_target"] = pd.to_numeric(frame["y"], errors="coerce") - base + split = make_outer_split( + frame, + source_timezone=source_timezone, + train_end=train_end, + validation_end=validation_end, + ) + names = ( + *feature_names, + *pak_feature_names, + *lims_feature_names, + ) + train = frame.iloc[split.train] + validation = frame.iloc[split.validation] + test = frame.iloc[split.test] + candidates: dict[str, Any] = { + "ridge": Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ("scaler", StandardScaler()), + ("model", Ridge(alpha=10.0)), + ) + ), + "huber": Pipeline( + ( + ("imputer", SimpleImputer(strategy="median")), + ("scaler", StandardScaler()), + ("model", HuberRegressor(max_iter=2000)), + ) + ), + } + y_train = train["residual_target"].to_numpy(dtype=float) + y_validation = validation["residual_target"].to_numpy(dtype=float) + fitted: dict[str, Any] = {} + validation_mae: dict[str, float] = {} + for name, estimator in candidates.items(): + estimator.fit(train.loc[:, list(names)], y_train) + fitted[name] = estimator + prediction = estimator.predict(validation.loc[:, list(names)]) + validation_mae[name] = float(np.mean(np.abs(y_validation - prediction))) + selected_name = min(validation_mae, key=lambda name: (validation_mae[name], name)) + point = fitted[selected_name] + upper = GradientBoostingRegressor( + loss="quantile", alpha=0.95, n_estimators=100, max_depth=2, random_state=seed + ).fit(train.loc[:, list(names)].fillna(train.loc[:, list(names)].median()), y_train) + median = train.loc[:, list(names)].median() + + validation_features = validation.loc[:, list(names)] + validation_corrected = validation["pak_point"].to_numpy(dtype=float) + point.predict( + validation_features + ) + validation_upper = validation["pak_point"].to_numpy(dtype=float) + upper.predict( + validation_features.fillna(median) + ) + validation_actual = validation["y"].to_numpy(dtype=float) + validation_pak = validation["pak_point"].to_numpy(dtype=float) + + x_test = test.loc[:, list(names)] + corrected = test["pak_point"].to_numpy(dtype=float) + point.predict(x_test) + upper_prediction = test["pak_point"].to_numpy(dtype=float) + upper.predict( + x_test.fillna(median) + ) + actual = pd.to_numeric(test["y"], errors="coerce").to_numpy(dtype=float) + report: dict[str, Any] = { + "schema_version": "1.1", + "target_source": "lims", + "confirmed_outlier_policy": "exclude_by_observation_id", + "excluded_outlier_count": len(excluded_outliers), + "excluded_outlier_ids": excluded_outliers, + "selected_model": selected_name, + "published_lims_feature_count": len(lims_feature_names), + "validation_mae": validation_mae, + "validation_pak_point_mae": float(np.mean(np.abs(validation_actual - validation_pak))), + "validation_corrected_mae": float( + np.mean(np.abs(validation_actual - validation_corrected)) + ), + "validation_upper_coverage": float( + np.mean(validation_actual <= np.maximum(validation_corrected, validation_upper)) + ), + "test_rows": int(len(test)), + "pak_point_mae": float(np.mean(np.abs(actual - test["pak_point"].to_numpy(dtype=float)))), + "corrected_mae": float(np.mean(np.abs(actual - corrected))), + "upper_coverage": float(np.mean(actual <= np.maximum(corrected, upper_prediction))), + "test_used_for_selection": False, + } + report["promotion_eligible"] = ( + report["validation_corrected_mae"] <= 0.95 * report["validation_pak_point_mae"] + and report["validation_upper_coverage"] >= 0.95 + ) + report["promotion_basis"] = "validation_only; test_is_audit" + return LimsCorrectionResult(selected_name, point, upper, report) + + +def save_safety_model( + directory: Path, + dataset: SupervisedDataset, + point_upper_model: ModelBundle, + *, + source_timezone: str = "Europe/Moscow", + train_end: str = "2025-01-01", + validation_end: str = "2026-01-01", + false_alarm_budget: float = FALSE_ALARM_BUDGET, + seed: int = 42, + allow_unpromoted: bool = False, + include_lightgbm: bool = False, +) -> tuple[ModelBundle, SafetyFitResult]: + """Persist schema-1.1 safety heads after the validation promotion gate.""" + if not point_upper_model.metadata.capabilities.supports_uncertainty: + raise ValueError("safety training requires an uncertainty-capable point artifact") + fitted = fit_safety_model( + dataset, + point_upper_model, + source_timezone=source_timezone, + train_end=train_end, + validation_end=validation_end, + false_alarm_budget=false_alarm_budget, + seed=seed, + include_lightgbm=include_lightgbm, + ) + if not fitted.report["promotion_eligible"] and not allow_unpromoted: + raise ValueError("safety model failed the validation promotion gate") + metadata = point_upper_model.metadata.model_dump(mode="python") + metadata.update( + { + "model_id": directory.name, + "schema_version": "1.1", + "model_type": f"{point_upper_model.metadata.model_type}+safety_classifier", + "capabilities": { + "supports_forecast": True, + "supports_actions": False, + "supports_uncertainty": True, + "supports_exceedance_probability": True, + }, + "alarm_threshold": fitted.report["alarm_threshold"], + "false_alarm_budget": fitted.report["false_alarm_budget"], + "calibration": { + "method": "platt", + "period": "first_validation_half", + "threshold_selection": "minimum_fnr_subject_to_fpr_budget", + }, + "metrics": { + "pak": { + "validation": fitted.report["validation_policy"], + "test": fitted.report["test"], + "transition_recall": fitted.report["test_transition_recall"], + }, + "lims": "separate_target_not_promoted", + }, + "processing": { + **point_upper_model.metadata.processing, + "safety_policy": { + "risk_family": fitted.report["selected_family"], + "positive_weight": fitted.report["positive_weight"], + "calibration": "platt_first_validation_half", + "threshold_selection": "minimum_fnr_subject_to_fpr_budget", + "threshold_validation_half": "second", + "alarm_threshold": fitted.report["alarm_threshold"], + "false_alarm_budget": fitted.report["false_alarm_budget"], + "target_false_negative_rate": fitted.report["target_false_negative_rate"], + "pak_metrics": { + "validation": fitted.report["validation_policy"], + "test": fitted.report["test"], + "transition_recall": fitted.report["test_transition_recall"], + }, + "lims_metrics": "separate_target_not_promoted", + }, + }, + "applicability": { + "method": "joint_pca_mahalanobis", + "required_features": list(fitted.predictor.applicability.required_features), + "coverage": 0.99, + "max_distance_squared": fitted.predictor.applicability.max_distance_squared, + "out_of_domain_behavior": "unavailable", + "purpose": "60-minute sulfur point, upper and exceedance-risk forecast", + "target_source": point_upper_model.metadata.target_source, + "action_comparison": "forbidden", + }, + "ood": { + "method": "joint_pca_mahalanobis", + "coverage": 0.99, + "max_distance_squared": fitted.predictor.applicability.max_distance_squared, + "out_of_domain_behavior": "unavailable", + }, + "reports": tuple(point_upper_model.metadata.reports) + ("metrics.json",), + } + ) + bundle = save_model(directory, fitted.predictor, metadata, fitted.report) + return bundle, fitted + + +__all__ = [ + "AlarmPolicy", + "CompositeSafetyPredictor", + "FALSE_ALARM_BUDGET", + "JointApplicabilityModel", + "LimsCorrectionResult", + "SafetyFitResult", + "binary_alarm_metrics", + "fit_joint_applicability", + "fit_lims_correction", + "published_lims_features", + "fit_safety_model", + "save_safety_model", + "select_alarm_threshold", +] diff --git a/source/ml/train.py b/source/ml/train.py index 1f0acce..294faa7 100644 --- a/source/ml/train.py +++ b/source/ml/train.py @@ -53,11 +53,20 @@ class TrainingDatasetLike(Protocol): """Structural contract supplied by ``ml.features.SupervisedDataset``.""" - frame: pd.DataFrame - feature_names: tuple[str, ...] - target_signal_id: str - target_unit: str - target_source: object + @property + def frame(self) -> pd.DataFrame: ... + + @property + def feature_names(self) -> tuple[str, ...]: ... + + @property + def target_signal_id(self) -> str: ... + + @property + def target_unit(self) -> str: ... + + @property + def target_source(self) -> object: ... @dataclass(frozen=True) diff --git a/source/ml/uncertainty.py b/source/ml/uncertainty.py index d4be19f..ff49c13 100644 --- a/source/ml/uncertainty.py +++ b/source/ml/uncertainty.py @@ -64,8 +64,11 @@ class RobustnessCaseResult: class UncertaintyDataset(Protocol): - frame: pd.DataFrame - feature_names: tuple[str, ...] + @property + def frame(self) -> pd.DataFrame: ... + + @property + def feature_names(self) -> tuple[str, ...]: ... @dataclass(frozen=True) diff --git a/source/ml/v2.py b/source/ml/v2.py new file mode 100644 index 0000000..7d1780e --- /dev/null +++ b/source/ml/v2.py @@ -0,0 +1,1215 @@ +"""Episode-aware multi-horizon sulfur forecasting without action claims.""" + +from __future__ import annotations + +import platform +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Mapping, cast + +import numpy as np +import pandas as pd +import sklearn +from lightgbm import LGBMClassifier, LGBMRegressor +from sklearn.ensemble import HistGradientBoostingClassifier, HistGradientBoostingRegressor +from sklearn.metrics import average_precision_score, brier_score_loss +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import FunctionTransformer + +from source.contracts import SourceKind, Validity +from source.data.prepare import PreparedData +from source.ml.artifacts import ModelBundle, feature_schema_hash, save_model +from source.ml.features import SupervisedDataset, build_supervised_dataset +from source.ml.safety import ( + FALSE_ALARM_BUDGET, + SULFUR_LIMIT, + AlarmPolicy, + JointApplicabilityModel, + PlattCalibrator, + _fit_calibrator, + fit_joint_applicability, +) + +HORIZONS = (10, 20, 30, 60) +WINDOWS = (30, 60, 180) +REGIME_LABELS = ("below_8", "8_to_10", "above_10") +ABLATED_TELEMETRY_GROUPS = { + "pak_only": (), + # Corrected 24-2000 dictionary: only the three process signals explicitly + # approved for the episode study are retained. All other HT tags stay + # out of the v2 feature matrix to avoid unstable correlations and leakage. + "ht_context": ("ht:P8", "ht:F19", "ht:T11"), + "k2_state": ("avt:F65", "avt:T20", "avt:T33", "avt:P21", "avt:P22", "avt:P23", "avt:P67"), + "k2_circulation": ( + "avt:F14", + "avt:T13", + "avt:T18", + "avt:F12", + "avt:T17", + "avt:F64", + "avt:T11", + "avt:T15", + ), + "diesel_cut": ("avt:T66", "avt:F28", "avt:F32", "avt:T71", "avt:F30", "avt:W70"), +} + + +@dataclass(frozen=True) +class EpisodeDataset: + """Leakage-safe v2 frame and its exact serving feature order.""" + + frame: pd.DataFrame + feature_names: tuple[str, ...] + baseline_feature: str + + +@dataclass(frozen=True) +class EpisodeSafetyPredictor: + """Serializable point, upper and multi-horizon risk predictor.""" + + baseline_feature: str + delta_estimator: Any + upper_delta_estimator: Any + risk_estimators: Mapping[int, Any] + calibrators: Mapping[int, PlattCalibrator] + alarm_policy: AlarmPolicy + applicability: JointApplicabilityModel + upper_delta_shift: float = 0.0 + + def predict_delta(self, features: pd.DataFrame) -> np.ndarray: + return np.asarray(self.delta_estimator.predict(features), dtype=float) + + def predict(self, features: pd.DataFrame) -> np.ndarray: + current = pd.to_numeric(features[self.baseline_feature], errors="coerce").to_numpy( + dtype=float + ) + return cast(np.ndarray, current + self.predict_delta(features)) + + def predict_upper(self, features: pd.DataFrame) -> np.ndarray: + current = pd.to_numeric(features[self.baseline_feature], errors="coerce").to_numpy( + dtype=float + ) + upper = current + np.asarray(self.upper_delta_estimator.predict(features), dtype=float) + upper += float(self.upper_delta_shift) + return np.maximum(self.predict(features), upper) + + def predict_horizon_probabilities(self, features: pd.DataFrame) -> dict[int, np.ndarray]: + result: dict[int, np.ndarray] = {} + previous = np.zeros(len(features), dtype=float) + for horizon in HORIZONS: + raw = np.asarray( + self.risk_estimators[horizon].predict_proba(features)[:, 1], dtype=float + ) + calibrated = self.calibrators[horizon].predict(raw) + previous = np.maximum(previous, calibrated) + result[horizon] = previous.copy() + return result + + def predict_exceedance_probability(self, features: pd.DataFrame) -> np.ndarray: + return self.predict_horizon_probabilities(features)[60] + + def predict_alarm(self, features: pd.DataFrame) -> np.ndarray: + current = pd.to_numeric(features[self.baseline_feature], errors="coerce").to_numpy( + dtype=float + ) + return (current > SULFUR_LIMIT) | ( + self.predict_exceedance_probability(features) >= self.alarm_policy.threshold + ) + + def predict_v2(self, features: pd.DataFrame) -> list[dict[str, object]]: + point = self.predict(features) + upper = self.predict_upper(features) + delta = self.predict_delta(features) + horizons = self.predict_horizon_probabilities(features) + alarm = self.predict_alarm(features) + current = pd.to_numeric(features[self.baseline_feature], errors="coerce").to_numpy( + dtype=float + ) + rows: list[dict[str, object]] = [] + for position in range(len(features)): + applicable = self.applicability.assess(features.iloc[[position]]) + reasons: list[str] = [] + if current[position] > SULFUR_LIMIT: + reasons.append("CURRENT_SULFUR_LIMIT") + if alarm[position] and current[position] <= SULFUR_LIMIT: + reasons.append("EXCEEDANCE_PROBABILITY_THRESHOLD") + if applicable.reason_code is not None: + reasons.append(applicable.reason_code) + rows.append( + { + "point": float(point[position]), + "upper": float(upper[position]), + "exceedance_probability": float(horizons[60][position]), + "alarm": bool(alarm[position]), + "horizon_probabilities": { + str(horizon): float(values[position]) + for horizon, values in horizons.items() + }, + "crossing_probability_60m": float(horizons[60][position]), + "event_alarm": bool(alarm[position]), + "predicted_delta": float(delta[position]), + "point_60m": float(point[position]), + "upper_60m": float(upper[position]), + "applicable": applicable.available, + "reason_codes": tuple(dict.fromkeys(reasons)), + } + ) + return rows + + def check_applicability(self, features: pd.DataFrame) -> object: + return self.applicability.assess(features) + + +@dataclass(frozen=True) +class EpisodeFitResult: + predictor: EpisodeSafetyPredictor + report: dict[str, Any] + + +def ablate_episode_features( + data: PreparedData, *, groups: tuple[str, ...] | None = None, seed: int = 42 +) -> dict[str, Any]: + """Compare fixed PAK/HT/AVT groups on 2024 folds without consulting audit 2026.""" + selected_groups = tuple(ABLATED_TELEMETRY_GROUPS) if groups is None else groups + unknown = set(selected_groups).difference(ABLATED_TELEMETRY_GROUPS) + if not selected_groups or unknown: + raise ValueError(f"unknown or empty ablation groups: {sorted(unknown)}") + all_signals = tuple( + dict.fromkeys( + signal for group in selected_groups for signal in ABLATED_TELEMETRY_GROUPS[group] + ) + ) + base = build_supervised_dataset( + data, + target_signal_id="ht:2:Mg.Sulfur", + target_source=SourceKind.PAK, + feature_source=SourceKind.PAK, + horizon_minutes=60, + telemetry_signals=all_signals, + ) + full_dataset = build_episode_dataset(data, base) + telemetry_prefixes = tuple(f"{signal}__" for signal in all_signals) + common_features = tuple( + name for name in base.feature_names if not name.startswith(telemetry_prefixes) + ) + candidates: dict[str, dict[str, Any]] = {} + for group in selected_groups: + signals = ABLATED_TELEMETRY_GROUPS[group] + selected_features = tuple( + name + for name in base.feature_names + if name in common_features or any(name.startswith(f"{signal}__") for signal in signals) + ) + frame = full_dataset.frame.copy() + frame["feature_missing_fraction"] = ( + frame.loc[:, list(selected_features)].isna().mean(axis=1) + ) + added_features = tuple( + name for name in full_dataset.feature_names if name.startswith("pak_") + ) + dataset = EpisodeDataset( + frame, + tuple(dict.fromkeys([*selected_features, *added_features])), + full_dataset.baseline_feature, + ) + family_results: dict[str, dict[str, Any]] = {} + for family in ("hgb", "lightgbm"): + frame, raw, delta, _ = _rolling_predictions( + dataset, + family, + "2024-01-01", + "2025-01-01", + seed, + include_upper=False, + estimator_factory=_ablation_estimator, + max_train_rows=20_000, + risk_horizons=(60,), + ) + probabilities = raw[60] + policy, metrics = select_event_threshold(frame, probabilities) + point = frame["baseline"].to_numpy(dtype=float) + delta + family_results[family] = { + "event_fnr": metrics["event_false_negative_rate"], + "event_fpr": metrics["event_false_positive_rate"], + "row_fpr": metrics["row_false_positive_rate"], + "brier": float(brier_score_loss(frame["crossing_60m"], probabilities)), + "mae": float(np.mean(np.abs(frame["y_60m"].to_numpy(dtype=float) - point))), + "threshold": policy.threshold, + } + eligible = [ + family + for family, result in family_results.items() + if result["event_fpr"] is not None + and result["event_fpr"] <= FALSE_ALARM_BUDGET + and result["row_fpr"] is not None + and result["row_fpr"] <= FALSE_ALARM_BUDGET + ] + selected = ( + min( + eligible, + key=lambda family: ( + float(family_results[family]["event_fnr"]), + float(family_results[family]["brier"]), + float(family_results[family]["mae"]), + 0 if family == "hgb" else 1, + ), + ) + if eligible + else None + ) + candidates[group] = { + "telemetry_signals": list(signals), + "feature_count": len(dataset.feature_names), + "families": family_results, + "selected_family": selected, + "selected_metrics": family_results.get(selected) if selected is not None else None, + } + return { + "selection_period": "2024 rolling-origin monthly folds", + "audit_2026_used": False, + "false_alarm_budget": FALSE_ALARM_BUDGET, + "training_budget": { + "max_train_rows_per_fold": 20_000, + "risk_horizons": [60], + "hgb_max_iter": 8, + "hgb_max_leaf_nodes": 7, + "lightgbm_n_estimators": 20, + "lightgbm_num_leaves": 7, + }, + "candidates": candidates, + "promotion_eligible": False, + "next_gate": "repeat the chosen ablation on 2025 before any shadow artifact", + } + + +def _pak_rows(data: PreparedData, signal_id: str) -> pd.DataFrame: + quality = data.quality + values = pd.to_numeric(quality["value"], errors="coerce") + selected = quality[ + quality["signal_id"].astype(str).eq(signal_id) + & quality["source"].map(lambda value: str(getattr(value, "value", value))).eq("pak") + & quality["validity"] + .map(lambda value: str(getattr(value, "value", value))) + .eq(Validity.VALID.value) + & values.notna() + & np.isfinite(values) + ].copy() + selected["value"] = values.loc[selected.index].astype(float) + selected["measured_at"] = pd.to_datetime(selected["measured_at"], utc=True) + return cast( + pd.DataFrame, selected.sort_values("measured_at", kind="stable").reset_index(drop=True) + ) + + +def _exact_future_values( + queries: pd.Series, pak: pd.DataFrame, horizon: int +) -> tuple[np.ndarray, np.ndarray]: + left = pd.DataFrame( + { + "position": np.arange(len(queries)), + "query": pd.to_datetime(queries, utc=True) + pd.Timedelta(minutes=horizon), + } + ).sort_values("query", kind="stable") + right = pak.loc[:, ["measured_at", "value", "episode_id"]] + merged = pd.merge_asof( + left, + right, + left_on="query", + right_on="measured_at", + direction="nearest", + tolerance=pd.Timedelta(seconds=1), + ).sort_values("position", kind="stable") + return merged["value"].to_numpy(dtype=float), merged["episode_id"].to_numpy(dtype=float) + + +def _rolling_features(queries: pd.Series, pak: pd.DataFrame) -> pd.DataFrame: + query_index = pd.DatetimeIndex(pd.to_datetime(queries, utc=True)) + series = pd.Series(pak["value"].to_numpy(dtype=float), index=pak["measured_at"]) + timeline = series.index.union(query_index).sort_values() + expanded = series.reindex(timeline) + columns: dict[str, np.ndarray] = {} + for window in WINDOWS: + rolling = expanded.rolling(f"{window}min", closed="both", min_periods=1) + minimum = rolling.min().reindex(query_index).to_numpy(dtype=float) + maximum = rolling.max().reindex(query_index).to_numpy(dtype=float) + columns[f"pak_min_{window}m"] = minimum + columns[f"pak_max_{window}m"] = maximum + columns[f"pak_range_{window}m"] = maximum - minimum + return pd.DataFrame(columns) + + +def _state_age_features(queries: pd.Series, pak: pd.DataFrame) -> pd.DataFrame: + query = pd.DatetimeIndex(pd.to_datetime(queries, utc=True)) + times = pd.DatetimeIndex(pak["measured_at"]) + values = pak["value"].to_numpy(dtype=float) + result: dict[str, np.ndarray] = {} + for threshold in (8.0, 9.0, 10.0): + state = values >= threshold + crossing = np.r_[True, state[1:] != state[:-1]] + events = pd.DataFrame({"event_at": times[crossing]}).sort_values("event_at") + merged = pd.merge_asof( + pd.DataFrame({"position": np.arange(len(query)), "query": query}).sort_values("query"), + events, + left_on="query", + right_on="event_at", + direction="backward", + ).sort_values("position") + result[f"pak_minutes_since_crossing_{int(threshold)}"] = ( + (merged["query"] - merged["event_at"]).dt.total_seconds().div(60).to_numpy(dtype=float) + ) + bands = pd.cut(values, [-np.inf, 8.0, 10.0, np.inf], labels=False, right=False) + changed = np.r_[True, np.asarray(bands[1:]) != np.asarray(bands[:-1])] + events = pd.DataFrame({"event_at": times[changed]}).sort_values("event_at") + merged = pd.merge_asof( + pd.DataFrame({"position": np.arange(len(query)), "query": query}).sort_values("query"), + events, + left_on="query", + right_on="event_at", + direction="backward", + ).sort_values("position") + result["pak_regime_duration_minutes"] = ( + (merged["query"] - merged["event_at"]).dt.total_seconds().div(60).to_numpy(dtype=float) + ) + return pd.DataFrame(result) + + +def build_episode_dataset(data: PreparedData, base: SupervisedDataset) -> EpisodeDataset: + """Add causal trajectory features and future labels to the 60-minute PAK table.""" + if base.target_source is not SourceKind.PAK or base.feature_source is not SourceKind.PAK: + raise ValueError("episode dataset requires PAK targets and PAK history") + frame = base.frame.reset_index(drop=True).copy() + pak = _pak_rows(data, base.target_signal_id) + exceedance = pak["value"].to_numpy(dtype=float) > SULFUR_LIMIT + starts = exceedance & np.r_[True, ~exceedance[:-1]] + pak["episode_id"] = np.where(exceedance, np.cumsum(starts), np.nan) + + exact_crossings: dict[int, np.ndarray] = {} + future_episodes: dict[int, np.ndarray] = {} + future_values: dict[int, np.ndarray] = {} + future_steps = tuple(range(10, max(HORIZONS) + 1, 10)) + for step in future_steps: + values, episode_ids = _exact_future_values(frame["as_of"], pak, step) + future_values[step] = values + exact_crossings[step] = values > SULFUR_LIMIT + future_episodes[step] = episode_ids + for horizon in HORIZONS: + frame[f"y_{horizon}m"] = future_values[horizon] + frame[f"crossing_{horizon}m"] = np.logical_or.reduce( + [exact_crossings[step] for step in future_steps if step <= horizon] + ) + frame["crossing_60m"] = frame["crossing_60m"].fillna(False) + episode_id = np.full(len(frame), np.nan) + for step in future_steps: + use = np.isnan(episode_id) & exact_crossings[step] + episode_id[use] = future_episodes[step][use] + frame["event_id"] = episode_id + episode_starts = pak.loc[starts, ["episode_id", "measured_at"]].set_index("episode_id") + frame["event_start_at"] = frame["event_id"].map(episode_starts["measured_at"]) + frame["delta_60m"] = pd.to_numeric(frame["y_60m"], errors="coerce") - pd.to_numeric( + frame[base.baseline_feature], errors="coerce" + ) + + derived = _rolling_features(frame["as_of"], pak) + state_ages = _state_age_features(frame["as_of"], pak) + current = pd.to_numeric(frame[base.baseline_feature], errors="coerce") + for window in WINDOWS: + lag_name = f"{base.target_signal_id}__pak_lag_{window}m" + if lag_name in frame: + frame[f"pak_slope_{window}m"] = (current - frame[lag_name]) / float(window) + if "pak_slope_30m" in frame and "pak_slope_60m" in frame: + frame["pak_acceleration_30_60m"] = frame["pak_slope_30m"] - frame["pak_slope_60m"] + frame = pd.concat([frame, derived, state_ages], axis="columns") + original_features = list(base.feature_names) + frame["feature_missing_fraction"] = frame[original_features].isna().mean(axis=1) + added = [ + name + for name in frame.columns + if name.startswith("pak_") or name == "feature_missing_fraction" + ] + feature_names = tuple(dict.fromkeys([*original_features, *added])) + return EpisodeDataset(frame, feature_names, base.baseline_feature) + + +def episode_sample_weights(frame: pd.DataFrame) -> np.ndarray: + """Give every positive episode and calendar month comparable training mass.""" + positive = frame["crossing_60m"].fillna(False).to_numpy(dtype=bool) + weights = np.ones(len(frame), dtype=float) + episode_counts = frame.loc[positive, "event_id"].value_counts() + if not episode_counts.empty: + weights[positive] = frame.loc[positive, "event_id"].map(1.0 / episode_counts).to_numpy() + weights[positive] *= positive.sum() / weights[positive].sum() + months = pd.to_datetime(frame["as_of"], utc=True).dt.strftime("%Y-%m") + month_mass = ( + pd.Series(weights).groupby(months.to_numpy()).transform("sum").to_numpy(dtype=float) + ) + weights /= month_mass + weights *= len(weights) / weights.sum() + return weights + + +def rolling_month_folds( + frame: pd.DataFrame, validation_start: str, validation_end: str +) -> tuple[tuple[np.ndarray, np.ndarray], ...]: + """Return monthly expanding folds purged by target publication time.""" + as_of = pd.to_datetime(frame["as_of"], utc=True) + available = pd.to_datetime(frame["target_available_at"], utc=True) + starts = pd.date_range(validation_start, validation_end, freq="MS", inclusive="left", tz="UTC") + folds: list[tuple[np.ndarray, np.ndarray]] = [] + for start in starts: + end = start + pd.offsets.MonthBegin(1) + train = (as_of < start) & (available < start) + validation = (as_of >= start) & (as_of < end) + if train.any() and validation.any(): + folds.append((np.flatnonzero(train), np.flatnonzero(validation))) + return tuple(folds) + + +def _event_lead_window(frame: pd.DataFrame) -> np.ndarray: + """Return rows 10–60 minutes before the first crossing of their episode.""" + if "event_start_at" not in frame: + # Keep small synthetic fixtures/backward-compatible callers usable. Real v2 + # datasets always carry event_start_at from build_episode_dataset(). + return np.ones(len(frame), dtype=bool) + as_of = pd.to_datetime(frame["as_of"], utc=True) + event_start = pd.to_datetime(frame["event_start_at"], utc=True, errors="coerce") + return ( + event_start.notna() + & (as_of >= event_start - pd.Timedelta(minutes=60)) + & (as_of <= event_start - pd.Timedelta(minutes=10)) + ).to_numpy(dtype=bool) + + +def event_metrics(frame: pd.DataFrame, probability: np.ndarray, threshold: float) -> dict[str, Any]: + """Evaluate unique exceedance episodes and ordinary false-alarm opportunities.""" + actual = frame["crossing_60m"].fillna(False).to_numpy(dtype=bool) + current = pd.to_numeric(frame["baseline"], errors="coerce").to_numpy(dtype=float) + alarm = (probability >= threshold) | (current > SULFUR_LIMIT) + event_alarm = alarm & _event_lead_window(frame) + event_ids = frame.loc[actual, "event_id"].dropna().unique() + detected = sum( + bool(event_alarm[frame["event_id"].eq(event_id).to_numpy()].any()) for event_id in event_ids + ) + eligible_negative = (~actual) & (current <= SULFUR_LIMIT) & np.isfinite(current) + false_positive_rate = ( + float(alarm[eligible_negative].mean()) if eligible_negative.any() else None + ) + opportunity_hour = pd.to_datetime(frame["as_of"], utc=True).dt.floor("60min") + negative_opportunities = pd.DataFrame( + { + "opportunity": opportunity_hour[eligible_negative].to_numpy(), + "alarm": alarm[eligible_negative], + } + ) + opportunity_alarm = ( + negative_opportunities.groupby("opportunity", sort=False)["alarm"].any() + if len(negative_opportunities) + else pd.Series(dtype=bool) + ) + return { + "event_count": int(len(event_ids)), + "detected_events": int(detected), + "event_false_negative_rate": 1.0 - detected / len(event_ids) if len(event_ids) else None, + "event_false_positive_rate": float(opportunity_alarm.mean()) + if len(opportunity_alarm) + else None, + "false_alarm_opportunities": int(opportunity_alarm.sum()), + "negative_opportunities": int(len(opportunity_alarm)), + "row_false_positive_rate": false_positive_rate, + "row_false_negatives": int((actual & ~alarm).sum()), + "row_false_negative_rate": float((actual & ~alarm).sum() / actual.sum()) + if actual.any() + else None, + "brier": float(brier_score_loss(actual, probability)), + "average_precision": float(average_precision_score(actual, probability)) + if actual.any() and (~actual).any() + else None, + } + + +def select_event_threshold( + frame: pd.DataFrame, + probability: np.ndarray, + false_alarm_budget: float = FALSE_ALARM_BUDGET, +) -> tuple[AlarmPolicy, dict[str, Any]]: + """Minimize missed episodes under the row-level false-alarm budget.""" + actual = frame["crossing_60m"].fillna(False).to_numpy(dtype=bool) + current = pd.to_numeric(frame["baseline"], errors="coerce").to_numpy(dtype=float) + negatives = probability[(~actual) & (current <= SULFUR_LIMIT) & np.isfinite(current)] + if not len(negatives): + raise ValueError("threshold selection needs eligible negative rows") + allowed = int(np.floor(false_alarm_budget * len(negatives))) + if allowed >= len(negatives): + threshold = 0.0 + else: + boundary = np.sort(negatives)[len(negatives) - allowed - 1] + threshold = float(np.nextafter(boundary, np.inf)) + if threshold > 1.0: + raise ValueError("no event threshold satisfies false-alarm budget") + metrics = event_metrics(frame, probability, threshold) + if ( + metrics["event_false_positive_rate"] is not None + and metrics["event_false_positive_rate"] > false_alarm_budget + ): + candidates = np.unique(probability[np.isfinite(probability)]) + low = int(np.searchsorted(candidates, threshold, side="left")) + high = len(candidates) + while low < high: + middle = (low + high) // 2 + candidate = float(candidates[middle]) + candidate_metrics = event_metrics(frame, probability, candidate) + event_fpr = candidate_metrics["event_false_positive_rate"] + if event_fpr is not None and event_fpr <= false_alarm_budget: + high = middle + else: + low = middle + 1 + if low == len(candidates): + threshold = 1.0 + else: + threshold = float(candidates[low]) + metrics = event_metrics(frame, probability, threshold) + return AlarmPolicy(threshold, false_alarm_budget, 0.10), metrics + + +def _estimator(family: str, task: str, seed: int) -> Any: + if family == "hgb": + if task == "classifier": + return HistGradientBoostingClassifier( + learning_rate=0.05, + max_iter=100, + max_leaf_nodes=15, + l2_regularization=1.0, + early_stopping=False, + random_state=seed, + ) + loss = "quantile" if task == "upper" else "squared_error" + kwargs = {"quantile": 0.95} if task == "upper" else {} + return HistGradientBoostingRegressor( + loss=loss, max_iter=100, max_leaf_nodes=15, random_state=seed, **kwargs + ) + if family == "lightgbm": + if task == "classifier": + model: Any = LGBMClassifier( + n_estimators=200, learning_rate=0.05, num_leaves=15, random_state=seed, verbosity=-1 + ) + else: + objective = "quantile" if task == "upper" else "regression_l1" + model = LGBMRegressor( + objective=objective, + alpha=0.95 if task == "upper" else 0.9, + n_estimators=200, + learning_rate=0.05, + num_leaves=15, + random_state=seed, + verbosity=-1, + ) + return Pipeline( + (("to_array", FunctionTransformer(np.asarray, validate=False)), ("model", model)) + ) + raise ValueError(f"unknown v2 family: {family}") + + +def _ablation_estimator(family: str, task: str, seed: int) -> Any: + """Use a fixed smaller budget for exploratory feature screening only.""" + estimator = _estimator(family, task, seed) + if family == "hgb": + estimator.set_params(max_iter=8, max_leaf_nodes=7) + else: + estimator.set_params(model__n_estimators=20, model__num_leaves=7) + return estimator + + +def _fit(estimator: Any, x: pd.DataFrame, y: np.ndarray, weights: np.ndarray, family: str) -> Any: + valid = np.isfinite(y) + if not valid.any(): + raise ValueError("v2 fit period has no finite targets") + argument = ( + {"model__sample_weight": weights[valid]} + if family == "lightgbm" + else {"sample_weight": weights[valid]} + ) + return estimator.fit(x.loc[valid], y[valid], **argument) + + +def _rolling_predictions( + dataset: EpisodeDataset, + family: str, + start: str, + end: str, + seed: int, + *, + include_regression: bool = True, + include_upper: bool = True, + estimator_factory: Callable[[str, str, int], Any] = _estimator, + max_train_rows: int | None = None, + risk_horizons: tuple[int, ...] | None = None, +) -> tuple[pd.DataFrame, dict[int, np.ndarray], np.ndarray, np.ndarray]: + frame = dataset.frame + pieces: list[pd.DataFrame] = [] + horizons = HORIZONS if risk_horizons is None else tuple(risk_horizons) + if not horizons or any(horizon not in HORIZONS for horizon in horizons): + raise ValueError(f"risk_horizons must be a non-empty subset of {HORIZONS}") + risk_parts: dict[int, list[np.ndarray]] = {horizon: [] for horizon in horizons} + point_parts: list[np.ndarray] = [] + upper_parts: list[np.ndarray] = [] + for fold_number, (train_indices, validation_indices) in enumerate( + rolling_month_folds(frame, start, end) + ): + if max_train_rows is not None: + train_indices = _cap_training_rows( + frame, train_indices, max_train_rows, seed=seed, fold_number=fold_number + ) + train = frame.iloc[train_indices] + validation = frame.iloc[validation_indices] + features = list(dataset.feature_names) + weights = episode_sample_weights(train) + for horizon in horizons: + estimator = _fit( + estimator_factory(family, "classifier", seed), + train.loc[:, features], + train[f"crossing_{horizon}m"].to_numpy(dtype=bool), + weights, + family, + ) + risk_parts[horizon].append( + np.asarray(estimator.predict_proba(validation.loc[:, features])[:, 1], dtype=float) + ) + if include_regression: + delta = _fit( + estimator_factory(family, "delta", seed), + train.loc[:, features], + train["delta_60m"].to_numpy(dtype=float), + weights, + family, + ) + point_parts.append(np.asarray(delta.predict(validation.loc[:, features]), dtype=float)) + if include_upper: + upper = _fit( + estimator_factory(family, "upper", seed), + train.loc[:, features], + train["delta_60m"].to_numpy(dtype=float), + weights, + family, + ) + upper_parts.append(np.asarray(upper.predict(validation.loc[:, features]), dtype=float)) + pieces.append(validation) + if not pieces: + raise ValueError("rolling backtest produced no folds") + return ( + pd.concat(pieces, ignore_index=True), + {horizon: np.concatenate(parts) for horizon, parts in risk_parts.items()}, + np.concatenate(point_parts) if point_parts else np.asarray([], dtype=float), + np.concatenate(upper_parts) if upper_parts else np.asarray([], dtype=float), + ) + + +def _cap_training_rows( + frame: pd.DataFrame, + train_indices: np.ndarray, + max_rows: int, + *, + seed: int, + fold_number: int, +) -> np.ndarray: + """Bound exploratory fit cost while retaining all positive event rows when possible.""" + if max_rows <= 0 or len(train_indices) <= max_rows: + return train_indices + train = frame.iloc[train_indices] + positive = train.loc[:, [f"crossing_{horizon}m" for horizon in HORIZONS]].any(axis=1) + positive_indices = train_indices[positive.to_numpy()] + negative_indices = train_indices[~positive.to_numpy()] + rng = np.random.default_rng(seed + fold_number) + if len(positive_indices) >= max_rows: + selected = rng.choice(positive_indices, size=max_rows, replace=False) + else: + negative_count = max_rows - len(positive_indices) + sampled_negative = rng.choice( + negative_indices, size=min(negative_count, len(negative_indices)), replace=False + ) + selected = np.concatenate([positive_indices, sampled_negative]) + return np.sort(selected) + + +def _monotone_probabilities(probabilities: Mapping[int, np.ndarray]) -> dict[int, np.ndarray]: + result: dict[int, np.ndarray] = {} + previous = np.zeros_like(next(iter(probabilities.values()))) + for horizon in HORIZONS: + previous = np.maximum(previous, probabilities[horizon]) + result[horizon] = previous.copy() + return result + + +def _regime_report( + frame: pd.DataFrame, probability: np.ndarray, threshold: float +) -> dict[str, Any]: + current = pd.to_numeric(frame["baseline"], errors="coerce") + bands = pd.cut(current, [-np.inf, 8.0, 10.0, np.inf], labels=REGIME_LABELS, right=False) + return { + label: event_metrics( + frame.loc[bands.eq(label)].reset_index(drop=True), + probability[bands.eq(label)], + threshold, + ) + for label in REGIME_LABELS + if bands.eq(label).any() + } + + +def _monthly_report( + frame: pd.DataFrame, + probability: np.ndarray, + threshold: float, + *, + actual: np.ndarray | None = None, + upper: np.ndarray | None = None, +) -> dict[str, Any]: + month = pd.to_datetime(frame["as_of"], utc=True).dt.strftime("%Y-%m") + result: dict[str, Any] = {} + for value in sorted(month.unique()): + selected = month.eq(value).to_numpy() + metrics = event_metrics( + frame.loc[month.eq(value)].reset_index(drop=True), + probability[month.eq(value)], + threshold, + ) + if actual is not None and upper is not None: + finite = np.isfinite(actual[selected]) & np.isfinite(upper[selected]) + metrics["upper_coverage"] = ( + float(np.mean(actual[selected][finite] <= upper[selected][finite])) + if finite.any() + else None + ) + metrics.update(_upper_limit_metrics(actual[selected], upper[selected])) + result[value] = metrics + return result + + +def _upper_limit_metrics(actual: np.ndarray, upper: np.ndarray) -> dict[str, float | int | None]: + """Measure misses and false alarms of the conservative ``upper > 10`` rule.""" + finite = np.isfinite(actual) & np.isfinite(upper) + if not finite.any(): + return { + "upper_limit_misses": 0, + "upper_limit_miss_rate": None, + "upper_limit_false_alarm_rate": None, + } + actual = actual[finite] + upper = upper[finite] + violation = actual > SULFUR_LIMIT + upper_alarm = upper > SULFUR_LIMIT + return { + "upper_limit_misses": int((violation & ~upper_alarm).sum()), + "upper_limit_miss_rate": float((violation & ~upper_alarm).sum() / violation.sum()) + if violation.any() + else None, + "upper_limit_false_alarm_rate": float((~violation & upper_alarm).sum() / (~violation).sum()) + if (~violation).any() + else None, + } + + +def _lead_time_report( + frame: pd.DataFrame, probabilities: Mapping[int, np.ndarray], threshold: float +) -> dict[str, Any]: + result: dict[str, Any] = {} + for horizon in HORIZONS: + view = frame.copy() + view["crossing_60m"] = view[f"crossing_{horizon}m"] + result[f"{horizon}m"] = event_metrics(view, probabilities[horizon], threshold) + return result + + +def _bootstrap_event_fnr( + frame: pd.DataFrame, probability: np.ndarray, threshold: float, seed: int +) -> dict[str, float] | None: + event_ids = frame.loc[frame["crossing_60m"], "event_id"].dropna().unique() + if not len(event_ids): + return None + current = pd.to_numeric(frame["baseline"], errors="coerce").to_numpy(dtype=float) + alarm = (probability >= threshold) | (current > SULFUR_LIMIT) + event_alarm = alarm & _event_lead_window(frame) + detected = np.asarray( + [event_alarm[frame["event_id"].eq(event_id).to_numpy()].any() for event_id in event_ids], + dtype=bool, + ) + rng = np.random.default_rng(seed) + samples = np.asarray( + [1.0 - rng.choice(detected, size=len(detected), replace=True).mean() for _ in range(1000)] + ) + return { + "lower": float(np.quantile(samples, 0.025)), + "upper": float(np.quantile(samples, 0.975)), + } + + +def fit_episode_safety_model(dataset: EpisodeDataset, *, seed: int = 42) -> EpisodeFitResult: + """Select on 2024, calibrate/threshold in 2025, and audit 2026 once fixed.""" + family_reports: dict[str, Any] = {} + for family in ("hgb", "lightgbm"): + frame_2024, raw, delta, _ = _rolling_predictions( + dataset, family, "2024-01-01", "2025-01-01", seed, include_upper=False + ) + probabilities = _monotone_probabilities(raw)[60] + policy, metrics = select_event_threshold(frame_2024, probabilities) + point = pd.to_numeric(frame_2024["baseline"], errors="coerce").to_numpy(dtype=float) + delta + actual = frame_2024["y_60m"].to_numpy(dtype=float) + selection_monthly = _monthly_report(frame_2024, probabilities, policy.threshold) + selection_month_values = [ + values + for values in selection_monthly.values() + if values["event_false_negative_rate"] is not None + ] + family_reports[family] = { + "policy": policy, + "metrics": metrics, + "mae": float(np.mean(np.abs(actual - point))), + "brier": float(brier_score_loss(frame_2024["crossing_60m"], probabilities)), + "monthly": selection_monthly, + "worst_month_event_fnr": max( + float(values["event_false_negative_rate"]) for values in selection_month_values + ) + if selection_month_values + else None, + "event_fnr_bootstrap_95": _bootstrap_event_fnr( + frame_2024, probabilities, policy.threshold, seed + ), + } + eligible_families = tuple( + family + for family, values in family_reports.items() + if values["metrics"]["event_false_positive_rate"] is not None + and values["metrics"]["event_false_positive_rate"] <= FALSE_ALARM_BUDGET + and values["metrics"]["row_false_positive_rate"] is not None + and values["metrics"]["row_false_positive_rate"] <= FALSE_ALARM_BUDGET + ) + selection_pool = eligible_families or tuple(family_reports) + selected_family = min( + selection_pool, + key=lambda family: ( + 0 if family in eligible_families else 1, + float(family_reports[family]["metrics"]["event_false_negative_rate"]), + float(family_reports[family]["brier"]), + float(family_reports[family]["mae"]), + 0 if family == "hgb" else 1, + ), + ) + + calibration_frame, calibration_raw, _, calibration_upper_delta = _rolling_predictions( + dataset, + selected_family, + "2025-01-01", + "2025-07-01", + seed, + include_regression=False, + include_upper=True, + ) + calibrators: dict[int, PlattCalibrator] = {} + for horizon in HORIZONS: + labels = calibration_frame[f"crossing_{horizon}m"].to_numpy(dtype=bool) + calibrators[horizon] = _fit_calibrator(calibration_raw[horizon], labels, seed) + calibration_actual = calibration_frame["y_60m"].to_numpy(dtype=float) + calibration_baseline = calibration_frame["baseline"].to_numpy(dtype=float) + finite_upper = ( + np.isfinite(calibration_actual) + & np.isfinite(calibration_baseline) + & np.isfinite(calibration_upper_delta) + ) + upper_delta_shift = ( + max( + 0.0, + float( + np.quantile( + calibration_actual[finite_upper] + - calibration_baseline[finite_upper] + - calibration_upper_delta[finite_upper], + 0.95, + ) + ), + ) + if finite_upper.any() + else 0.0 + ) + + policy_frame, policy_raw, _, _ = _rolling_predictions( + dataset, + selected_family, + "2025-07-01", + "2026-01-01", + seed, + include_regression=False, + include_upper=False, + ) + calibrated = _monotone_probabilities( + {horizon: calibrators[horizon].predict(policy_raw[horizon]) for horizon in HORIZONS} + ) + policy, policy_metrics = select_event_threshold(policy_frame, calibrated[60]) + threshold_monthly = _monthly_report(policy_frame, calibrated[60], policy.threshold) + threshold_month_values = [ + values + for values in threshold_monthly.values() + if values["event_false_negative_rate"] is not None + ] + + frame = dataset.frame + as_of = pd.to_datetime(frame["as_of"], utc=True) + available = pd.to_datetime(frame["target_available_at"], utc=True) + fit_mask = (as_of < pd.Timestamp("2026-01-01", tz="UTC")) & ( + available < pd.Timestamp("2026-01-01", tz="UTC") + ) + fit_frame = frame.loc[fit_mask] + features = list(dataset.feature_names) + weights = episode_sample_weights(fit_frame) + risk_estimators: dict[int, Any] = {} + for horizon in HORIZONS: + risk_estimators[horizon] = _fit( + _estimator(selected_family, "classifier", seed), + fit_frame.loc[:, features], + fit_frame[f"crossing_{horizon}m"].to_numpy(dtype=bool), + weights, + selected_family, + ) + delta_estimator = _fit( + _estimator(selected_family, "delta", seed), + fit_frame.loc[:, features], + fit_frame["delta_60m"].to_numpy(dtype=float), + weights, + selected_family, + ) + upper_estimator = _fit( + _estimator(selected_family, "upper", seed), + fit_frame.loc[:, features], + fit_frame["delta_60m"].to_numpy(dtype=float), + weights, + selected_family, + ) + applicability = fit_joint_applicability( + fit_frame.loc[:, features], required_features=(dataset.baseline_feature,) + ) + predictor = EpisodeSafetyPredictor( + dataset.baseline_feature, + delta_estimator, + upper_estimator, + risk_estimators, + calibrators, + policy, + applicability, + upper_delta_shift, + ) + + audit = frame.loc[as_of >= pd.Timestamp("2026-01-01", tz="UTC")].reset_index(drop=True) + audit_features = audit.loc[:, features] + audit_probabilities = predictor.predict_horizon_probabilities(audit_features) + audit_probability = audit_probabilities[60] + audit_point = predictor.predict(audit_features) + audit_upper = predictor.predict_upper(audit_features) + actual = audit["y_60m"].to_numpy(dtype=float) + finite_forecast = np.isfinite(actual) & np.isfinite(audit_point) & np.isfinite(audit_upper) + audit_metrics = event_metrics(audit, audit_probability, policy.threshold) + audit_metrics.update( + { + "forecast_rows": int(finite_forecast.sum()), + "mae": float(np.mean(np.abs(actual[finite_forecast] - audit_point[finite_forecast]))) + if finite_forecast.any() + else None, + "upper_coverage": float( + np.mean(actual[finite_forecast] <= audit_upper[finite_forecast]) + ) + if finite_forecast.any() + else None, + "event_fnr_bootstrap_95": _bootstrap_event_fnr( + audit, audit_probability, policy.threshold, seed + ), + } + ) + audit_metrics.update(_upper_limit_metrics(actual, audit_upper)) + monthly = _monthly_report( + audit, + audit_probability, + policy.threshold, + actual=actual, + upper=audit_upper, + ) + valid_months = [ + metrics for metrics in monthly.values() if metrics["event_false_negative_rate"] is not None + ] + report = { + "schema_version": "1.2", + "selected_family": selected_family, + "selection_gate": { + "eligible_families": list(eligible_families), + "false_alarm_budget": FALSE_ALARM_BUDGET, + "passed": bool(eligible_families), + }, + "selection_2024": { + family: {key: value for key, value in values.items() if key != "policy"} + for family, values in family_reports.items() + }, + "calibration_period": "2025-01-01/2025-07-01", + "threshold_period": "2025-07-01/2026-01-01", + "threshold_metrics": policy_metrics, + "threshold_monthly": threshold_monthly, + "threshold_worst_month_event_fnr": max( + float(values["event_false_negative_rate"]) for values in threshold_month_values + ) + if threshold_month_values + else None, + "threshold_event_fnr_bootstrap_95": _bootstrap_event_fnr( + policy_frame, calibrated[60], policy.threshold, seed + ), + "alarm_threshold": policy.threshold, + "upper_delta_shift": upper_delta_shift, + "audit_2026": audit_metrics, + "audit_monthly": monthly, + "audit_worst_month_event_fnr": max( + float(metrics["event_false_negative_rate"]) for metrics in valid_months + ) + if valid_months + else None, + "audit_regimes": _regime_report(audit, audit_probability, policy.threshold), + "audit_lead_times": _lead_time_report(audit, audit_probabilities, policy.threshold), + "statistical_gate_passed": bool( + policy_metrics["event_false_negative_rate"] is not None + and policy_metrics["event_false_negative_rate"] <= 0.10 + and policy_metrics["event_false_positive_rate"] is not None + and policy_metrics["event_false_positive_rate"] <= FALSE_ALARM_BUDGET + and policy_metrics["row_false_positive_rate"] is not None + and policy_metrics["row_false_positive_rate"] <= FALSE_ALARM_BUDGET + ), + "audit_gate_passed": bool( + audit_metrics["event_false_negative_rate"] is not None + and audit_metrics["event_false_negative_rate"] <= 0.10 + and audit_metrics["event_false_positive_rate"] is not None + and audit_metrics["event_false_positive_rate"] <= FALSE_ALARM_BUDGET + and audit_metrics["row_false_positive_rate"] is not None + and audit_metrics["row_false_positive_rate"] <= FALSE_ALARM_BUDGET + and audit_metrics["upper_coverage"] is not None + and audit_metrics["upper_coverage"] >= 0.95 + and audit_metrics["upper_limit_miss_rate"] is not None + and audit_metrics["upper_limit_miss_rate"] <= 0.05 + ), + "test_used_for_selection": False, + "shadow_required": True, + "shadow_minimum": "100 independent episodes or 3 complete months", + "pak_only_ablation_completed": False, + "promotion_blockers": [ + "new shadow period is required before production promotion", + "action effects remain observational and supports_actions=false", + ], + "promotion_eligible": False, + "supports_actions": False, + } + return EpisodeFitResult(predictor, report) + + +def save_episode_safety_model( + directory: Path, + data: PreparedData, + base: SupervisedDataset, + *, + git_commit: str, + seed: int = 42, +) -> tuple[ModelBundle, EpisodeFitResult]: + """Persist a schema-1.2 shadow artifact; production eligibility stays false.""" + dataset = build_episode_dataset(data, base) + fitted = fit_episode_safety_model(dataset, seed=seed) + definition = { + "horizon_minutes": 60, + "horizons_minutes": list(HORIZONS), + "windows_minutes": list(WINDOWS), + "target": "delta_60m and any crossing within horizon", + "episode_weighting": "inverse episode length and equal month mass", + "upper_calibration": "nonnegative 0.95 residual shift on 2025-01/2025-07", + "telemetry_signals": ["ht:P8", "ht:F19", "ht:T11"], + "telemetry_semantics_version": "organizer-qa-2026-09-15", + "lims_availability_rule": "available_at <= as_of; measured_at is never shifted", + } + metadata = { + "model_id": directory.name, + "schema_version": "1.2", + "model_type": f"{fitted.report['selected_family']}+episode_multi_horizon", + "training_dataset_id": data.manifest.dataset_id, + "git_commit": git_commit, + "python_version": platform.python_version(), + "sklearn_version": sklearn.__version__, + "target_signal": base.target_signal_id, + "target_source": "pak", + "target_unit": base.target_unit, + "horizon_minutes": 60, + "feature_names": dataset.feature_names, + "baseline_feature": dataset.baseline_feature, + "tag_dictionary_sha256": data.manifest.tag_dictionary_sha256, + "feature_schema_hash": feature_schema_hash(dataset.feature_names, definition), + "processing": { + "feature_definition": definition, + "selection_period": "2024", + "calibration_period": fitted.report["calibration_period"], + "threshold_period": fitted.report["threshold_period"], + "alarm_threshold": fitted.report["alarm_threshold"], + "upper_delta_shift": fitted.report["upper_delta_shift"], + "production_status": "shadow_only", + "telemetry_semantics_status": "confirmed_by_qa_2026_09_15", + "telemetry_cleaning": "config/telemetry_rules.json:ht:Q21==307->missing", + "telemetry_rules_sha256": getattr(data.manifest, "telemetry_rules_sha256", None), + "pak_only_ablation_required": False, + }, + "time_boundaries": { + "train_end_local": "2026-01-01", + "validation_end_local": "2026-01-01", + "source_timezone": data.manifest.source_timezone, + "calibration_start": "2025-01-01", + "calibration_end": "2025-07-01", + }, + "seed": seed, + "alarm_threshold": fitted.report["alarm_threshold"], + "false_alarm_budget": FALSE_ALARM_BUDGET, + "calibration": { + "method": "platt", + "period": fitted.report["calibration_period"], + "threshold_period": fitted.report["threshold_period"], + }, + "metrics": { + "pak": { + "selection_2024": fitted.report["selection_2024"], + "threshold": fitted.report["threshold_metrics"], + "audit_2026": fitted.report["audit_2026"], + }, + "lims": "separate_delayed_control_layer", + }, + "capabilities": { + "supports_forecast": True, + "supports_actions": False, + "supports_uncertainty": True, + "supports_exceedance_probability": True, + "supports_multi_horizon": True, + }, + "applicability": { + "method": "joint_pca_mahalanobis", + "required_features": [dataset.baseline_feature], + "coverage": 0.99, + "max_distance_squared": fitted.predictor.applicability.max_distance_squared, + "purpose": "shadow-only PAK episode forecast", + "action_comparison": "forbidden", + }, + "ood": { + "method": "joint_pca_mahalanobis", + "coverage": 0.99, + "max_distance_squared": fitted.predictor.applicability.max_distance_squared, + "out_of_domain_behavior": "unavailable", + }, + "reports": ("metrics.json",), + } + return save_model(directory, fitted.predictor, metadata, fitted.report), fitted + + +__all__ = [ + "EpisodeDataset", + "EpisodeFitResult", + "EpisodeSafetyPredictor", + "HORIZONS", + "build_episode_dataset", + "episode_sample_weights", + "event_metrics", + "fit_episode_safety_model", + "rolling_month_folds", + "save_episode_safety_model", + "select_event_threshold", +] diff --git a/source/orchestrator.py b/source/orchestrator.py index fe351f4..b52f5e7 100644 --- a/source/orchestrator.py +++ b/source/orchestrator.py @@ -15,11 +15,18 @@ from source.constraints import check_constraints from source.contracts import ( AgentAssessment, + AssessmentAgent, + AssessmentStatus, + CandidateAction, CandidateEvaluation, CandidateKind, + ConstraintStatus, ConstraintResult, DecisionContext, + EstimateBasis, + IntervalKind, Issue, + MetricEstimate, OperationMode, ProcessState, Recommendation, @@ -28,37 +35,52 @@ ScenarioConfig, Severity, SignalSnapshot, + Unit, ) from source.explain import build_explanation from source.journal import write_run_journal from source.ml.features import build_features from source.ml.policy import PolicyParameters, assess_change_policy +from source.ml.controls import generate_setpoint_candidates def _model_demo_state(as_of: datetime, scenario: ScenarioConfig) -> ProcessState: issues: list[Issue] = [] signals: dict[str, SignalSnapshot] = {} for component in scenario.blend_components: - if component.sulfur.value is not None and ( - not scenario.require_upper_bound or component.sulfur.upper is not None - ): - continue - signal_id = f"blend:{component.id}:sulfur" - issue = Issue( - code="MISSING_REQUIRED_SIGNAL", - severity=Severity.BLOCKING, - signal_id=signal_id, - detail=f"component {component.id} has no complete sulfur estimate", - source_ref=component.sulfur.reference, - ) - issues.append(issue) - signals[signal_id] = SignalSnapshot( - selected=None, - alternatives=(), - age_seconds=None, - fresh=False, - issues=(issue,), + properties = ( + ("sulfur", component.sulfur, "upper", "MISSING_REQUIRED_SIGNAL"), + ("t95", component.t95, "upper", "UNASSESSED_REQUIRED_PROPERTY"), + ( + "cetane_number", + component.cetane_number, + "lower", + "UNASSESSED_REQUIRED_PROPERTY", + ), ) + for name, estimate, bound, reason_code in properties: + if ( + estimate is not None + and estimate.value is not None + and getattr(estimate, bound) is not None + ): + continue + signal_id = f"blend:{component.id}:{name}" + issue = Issue( + code=reason_code, + severity=Severity.BLOCKING, + signal_id=signal_id, + detail=f"component {component.id} has no complete {name} estimate", + source_ref=f"scenario:{scenario.id}", + ) + issues.append(issue) + signals[signal_id] = SignalSnapshot( + selected=None, + alternatives=(), + age_seconds=None, + fresh=False, + issues=(issue,), + ) payload = { "schema_version": "1.0", "as_of": as_of.isoformat(), @@ -119,6 +141,210 @@ def _metric_value( return None +def _rank_key( + assessments: tuple[AgentAssessment, ...], + candidate_id: str, + active_criteria: tuple[str, ...], +) -> tuple[float, float, float, float, str] | None: + values = { + name: _metric_value(assessments, name) + for name in ("risk_index", "throughput", "cost_proxy", "change_size") + } + if any(values.get(name) is None for name in active_criteria): + return None + return ( + values["risk_index"] or 0.0, + -(values["throughput"] or 0.0), + values["cost_proxy"] or 0.0, + values["change_size"] or 0.0, + candidate_id, + ) + + +def _metric_estimate( + value: float, + unit: str, + basis: EstimateBasis, + reference: str, + *, + lower: float | None = None, + upper: float | None = None, + interval_kind: IntervalKind = IntervalKind.NONE, + interval_level: float | None = None, + assumptions: tuple[str, ...] = (), +) -> MetricEstimate: + return MetricEstimate( + value=value, + lower=lower, + upper=upper, + unit=unit, + basis=basis, + interval_kind=interval_kind, + interval_level=interval_level, + reference=reference, + assumptions=assumptions, + ) + + +def _action_model(model: object | None) -> object | None: + if model is None: + return None + return getattr(model, "action_model", None) + + +def _scenario_with_action_controls( + scenario: ScenarioConfig, + model: object | None, +) -> ScenarioConfig: + action_model = _action_model(model) + if scenario.mode is not OperationMode.HISTORY or action_model is None: + return scenario + controls = tuple(getattr(action_model, "controls")) + required = tuple( + dict.fromkeys((*scenario.required_signals, *(item.signal_id for item in controls))) + ) + assumptions = tuple( + dict.fromkeys( + ( + *scenario.assumptions, + "Real setpoint controls are enabled only by a verified action artifact.", + ) + ) + ) + return scenario.model_copy( + update={"controls": controls, "required_signals": required, "assumptions": assumptions} + ) + + +def _sulfur_upper_limit(scenario: ScenarioConfig) -> float: + for constraint in scenario.constraints: + if constraint.metric == "sulfur" and constraint.upper is not None: + return float(constraint.upper) + return 10.0 + + +def _action_assessment( + state: ProcessState, + candidate: CandidateAction, + outcome: object, + model_id: str, +) -> AgentAssessment: + reason_codes = tuple(str(item) for item in getattr(outcome, "reason_codes", ())) + issues = tuple( + Issue( + code=code, + severity=Severity.BLOCKING, + signal_id=None, + detail="Action candidate failed a verified action-model guardrail.", + source_ref=f"action_model:{model_id}", + ) + for code in reason_codes + ) + sulfur_upper = getattr(outcome, "sulfur_upper") + return AgentAssessment( + agent=AssessmentAgent.OPTIMIZER, + state_id=state.state_id, + candidate_id=candidate.id, + evaluated_for=state.as_of, + status=AssessmentStatus.DEGRADED if issues else AssessmentStatus.OK, + metrics={ + "sulfur": _metric_estimate( + float(getattr(outcome, "sulfur")), + Unit.MG_KG.value, + EstimateBasis.FORECAST, + f"action_model:{model_id}", + upper=None if sulfur_upper is None else float(sulfur_upper), + interval_kind=( + IntervalKind.NONE if sulfur_upper is None else IntervalKind.EMPIRICAL + ), + interval_level=None if sulfur_upper is None else 0.95, + assumptions=("verified action-effect model", "upper <= 10 mg/kg is required"), + ), + "risk_index": _metric_estimate( + float(getattr(outcome, "risk_index")), + Unit.RISK_INDEX.value, + EstimateBasis.PROXY, + f"action_model:{model_id}", + ), + "throughput": _metric_estimate( + float(getattr(outcome, "throughput")), + Unit.PROXY.value, + EstimateBasis.PROXY, + f"action_model:{model_id}", + ), + "cost_proxy": _metric_estimate( + float(getattr(outcome, "cost_proxy")), + Unit.PROXY.value, + EstimateBasis.PROXY, + f"action_model:{model_id}", + ), + "change_size": _metric_estimate( + float(getattr(outcome, "change_size")), + Unit.DIMENSIONLESS.value, + EstimateBasis.FORMULA, + f"action_model:{model_id}", + ), + }, + issues=issues, + ) + + +def _history_action_evaluations( + state: ProcessState, + candidates: tuple[CandidateAction, ...], + scenario: ScenarioConfig, + *, + features: pd.DataFrame, + model: object, +) -> tuple[CandidateEvaluation, ...]: + from source.agents.quality import predict_quality + + action_model = _action_model(model) + if action_model is None: + raise ValueError("history action evaluation requires a verified action model") + forecast = predict_quality(state, features, model, scenario) + sulfur = forecast.metrics.get("sulfur") + if sulfur is None or sulfur.value is None: + hold = tuple(item for item in candidates if item.kind is CandidateKind.HOLD) + return evaluate_candidates(state, hold, scenario, features=features, model=model) + + model_id = str(getattr(action_model, "model_id")) + evaluations: list[CandidateEvaluation] = [] + for candidate in candidates: + outcome = action_model.evaluate( + state, + candidate, + baseline_sulfur=float(sulfur.value), + baseline_sulfur_upper=sulfur.upper, + sulfur_upper_limit=_sulfur_upper_limit(scenario), + ) + assessments = (_action_assessment(state, candidate, outcome, model_id),) + checks = check_constraints(state, candidate, assessments, scenario) + rank_key = _rank_key(assessments, candidate.id, scenario.active_criteria) + assessments_available = all( + assessment.status is not AssessmentStatus.UNAVAILABLE + and all(issue.severity is not Severity.BLOCKING for issue in assessment.issues) + for assessment in assessments + ) + feasible = ( + bool(getattr(outcome, "feasible")) + and bool(checks) + and all(item.status is ConstraintStatus.PASS for item in checks) + and assessments_available + and rank_key is not None + ) + evaluations.append( + CandidateEvaluation( + candidate=candidate, + assessments=assessments, + checks=checks, + feasible=feasible, + rank_key=rank_key if feasible else None, + ) + ) + return tuple(evaluations) + + def _select_result( evaluations: tuple[CandidateEvaluation, ...], scenario: ScenarioConfig, @@ -183,6 +409,20 @@ def _required_rank_key(evaluation: CandidateEvaluation) -> tuple[float, float, f return evaluation.rank_key +def _supports_actions(model: object | None) -> bool: + if model is None: + return False + metadata = getattr(model, "metadata", None) + capabilities = ( + metadata.get("capabilities") + if isinstance(metadata, dict) + else getattr(metadata, "capabilities", None) + ) + if isinstance(capabilities, dict): + return capabilities.get("supports_actions") is True + return getattr(capabilities, "supports_actions", False) is True + + def _recheck_selected( state: ProcessState, selected: CandidateEvaluation | None, @@ -211,27 +451,65 @@ def run_cycle( as_of = as_of.astimezone(UTC) if model is not None and scenario.mode is OperationMode.HISTORY and data is None: raise ValueError("prepared data is required when a forecast model is supplied") - state = _build_cycle_state(data, as_of, scenario, config) + if ( + scenario.mode is OperationMode.HISTORY + and _supports_actions(model) + and _action_model(model) is None + ): + raise ValueError("supports_actions=true requires a verified action model") + effective_scenario = _scenario_with_action_controls(scenario, model) + state = _build_cycle_state(data, as_of, effective_scenario, config) features: pd.DataFrame | None = None - if model is not None and scenario.mode is OperationMode.HISTORY: + if model is not None and effective_scenario.mode is OperationMode.HISTORY: features = build_features(data, as_of, state, model) - candidates = generate_candidates(state, scenario, config) - evaluations = evaluate_candidates(state, candidates, scenario, features=features, model=model) + action_model = _action_model(model) + if effective_scenario.mode is OperationMode.HISTORY and action_model is not None: + candidates = generate_setpoint_candidates( + state, + tuple(getattr(action_model, "controls")), + supports_actions=True, + joint_domain=getattr(action_model, "joint_domain"), + horizon_minutes=config.horizon_minutes, + max_action_combinations=config.max_candidates, + ) + if features is None: + raise ValueError("history action evaluation requires forecast features") + evaluations = _history_action_evaluations( + state, + candidates, + effective_scenario, + features=features, + model=model, + ) + else: + candidates = generate_candidates(state, effective_scenario, config) + evaluations = evaluate_candidates( + state, candidates, effective_scenario, features=features, model=model + ) baseline = next(item for item in evaluations if item.candidate.kind is CandidateKind.HOLD) status, selected, reason_codes, selection_reason = _select_result( - evaluations, scenario, context, as_of + evaluations, effective_scenario, context, as_of ) - _recheck_selected(state, selected, scenario) - alternatives = tuple( - item - for item in sorted( - ( - candidate - for candidate in evaluations - if candidate.feasible and candidate != selected - ), - key=_required_rank_key, - )[:3] + if effective_scenario.mode is OperationMode.HISTORY and not _supports_actions(model): + status = RecommendationStatus.ABSTAIN + selected = None + reason_codes = tuple(dict.fromkeys((*reason_codes, "ACTION_MODEL_UNAVAILABLE"))) + selection_reason = "action_model_unavailable" + _recheck_selected(state, selected, effective_scenario) + alternatives = ( + () + if status is RecommendationStatus.ABSTAIN + else tuple( + item + for item in sorted( + ( + candidate + for candidate in evaluations + if candidate.feasible and candidate != selected + ), + key=_required_rank_key, + )[:3] + ) ) result = Recommendation( run_id=str(uuid4()), @@ -244,9 +522,11 @@ def run_cycle( selected=selected, alternatives=alternatives, reason_codes=reason_codes, - explanation=build_explanation(status, baseline, selected, reason_codes, scenario), + explanation=build_explanation( + status, baseline, selected, reason_codes, effective_scenario + ), assumptions=tuple( - dict.fromkeys((*scenario.assumptions, "stage-3 deterministic backend cycle")) + dict.fromkeys((*effective_scenario.assumptions, "stage-3 deterministic backend cycle")) ), model_id=( ( @@ -261,7 +541,7 @@ def run_cycle( write_run_journal( run_dir, state, - scenario, + effective_scenario, context, evaluations, result, diff --git a/source/ui.py b/source/ui.py index 34dffce..c71b03d 100644 --- a/source/ui.py +++ b/source/ui.py @@ -1,7 +1,7 @@ -"""Tkinter desktop interface for the currently implemented recommendation system. +"""Tkinter desktop interface for model-demo, forecasts and gated action artifacts. -The UI deliberately exposes model-demo calculations as model scenarios. It -does not imply that real setpoints, T95 or cetane-number models are available. +Historical effects and schema-1.2 forecasts are shadow-only. History replay can +show an actionable recommendation only when a verified action artifact is supplied. """ from __future__ import annotations @@ -28,12 +28,33 @@ ) from source.main import ( PROJECT_ROOT, + action_shadow_estimate_command, build_state_command, + evaluate_lims_correction_command, prepare_command, +<<<<<<< HEAD +<<<<<<< HEAD run_history_command, +======= + replay_command, +======= +>>>>>>> e70cafe (fix) + replay_v2_shadow_command, +>>>>>>> 1527107 (Extend UI and ML analysis materials) run_model_demo, validate_stage0, ) +from source.ui_data import ( + UiHistoryReplayView, + UiHybridBlendView, + UiStageSnapshot, + default_as_of_for_dataset, + discover_ui_context, + history_replay_to_view, + ui_history_snapshot, + ui_hybrid_snapshot, + ui_stage_snapshot, +) BG = "#F5F7F7" SURFACE = "#FFFFFF" @@ -52,9 +73,22 @@ SCENARIO_LABELS = { "Нормальный режим": "blend_normal", "Повышенная сера": "blend_risk", + "Риск T95": "blend_t95_risk", + "Низкое цетановое число": "blend_cetane_risk", "Недостающие данные": "blend_missing", } +PAGE_ALIASES = {"recommendation": "blend"} +PAGE_CHOICES = ( + "overview", + "avt", + "hydrotreating", + "blend", + "history", + "journal", + "recommendation", +) + @dataclass(frozen=True) class ConstraintRow: @@ -75,24 +109,32 @@ class DashboardView: action_title: str action_detail: str baseline_value: float | None + baseline_t95_upper: float | None + baseline_cetane_lower: float | None baseline_upper: float | None selected_value: float | None + selected_t95_upper: float | None + selected_cetane_lower: float | None selected_upper: float | None current_fractions: dict[str, float] proposed_fractions: dict[str, float] + current_additive_fraction: float + proposed_additive_fraction: float constraints: tuple[ConstraintRow, ...] explanation: str reasons: tuple[str, ...] -def _metric(evaluation: CandidateEvaluation | None, name: str) -> tuple[float | None, float | None]: +def _metric( + evaluation: CandidateEvaluation | None, name: str +) -> tuple[float | None, float | None, float | None]: if evaluation is None: - return None, None + return None, None, None for assessment in evaluation.assessments: estimate = assessment.metrics.get(name) if estimate is not None: - return estimate.value, estimate.upper - return None, None + return estimate.value, estimate.lower, estimate.upper + return None, None, None def _limit_text(lower: float | None, upper: float | None, unit: str | None) -> str: @@ -107,7 +149,14 @@ def _limit_text(lower: float | None, upper: float | None, unit: str | None) -> s def _display_unit(unit: str | None) -> str: - return {"mg/kg": "мг/кг", "t": "т"}.get(unit or "", unit or "") + return { + "mg/kg": "мг/кг", + "degC": "°C", + "cetane_number": "ед.", + "kg/m3": "кг/м³", + "t": "т", + "1": "доля", + }.get(unit or "", unit or "") def _constraint_rows(result: Recommendation) -> tuple[ConstraintRow, ...]: @@ -118,6 +167,14 @@ def _constraint_rows(result: Recommendation) -> tuple[ConstraintRow, ...]: name = "Запас " + check.constraint_id.rsplit(":", 1)[-1] elif check.constraint_id == "blend_sulfur": name = "Сера" + elif check.constraint_id == "blend_t95": + name = "T95" + elif check.constraint_id == "blend_cetane": + name = "Цетановое число" + elif check.constraint_id == "additive_fraction": + name = "Доля присадки" + elif check.constraint_id == "additive_stock": + name = "Запас присадки" else: name = check.constraint_id status = { @@ -130,48 +187,57 @@ def _constraint_rows(result: Recommendation) -> tuple[ConstraintRow, ...]: ConstraintStatus.FAIL: "bad", ConstraintStatus.UNKNOWN: "unknown", }[check.status] - actual = ( - "—" if check.actual is None else f"{check.actual:g} {_display_unit(check.unit)}".strip() - ) + if check.constraint_id == "additive_fraction": + actual = "—" if check.actual is None else f"{check.actual * 100:g} %" + limit = _limit_text( + None if check.lower is None else check.lower * 100, + None if check.upper is None else check.upper * 100, + "%", + ) + else: + actual = ( + "—" + if check.actual is None + else f"{check.actual:g} {_display_unit(check.unit)}".strip() + ) + limit = _limit_text(check.lower, check.upper, check.unit) rows.append( ConstraintRow( name, actual, - _limit_text(check.lower, check.upper, check.unit), + limit, status, tone, ) ) - metric_labels = (("t95", "T95"), ("cetane_number", "Цетановое число")) - for metric_name, label in metric_labels: - value, upper = _metric(evaluation, metric_name) - if value is None: - rows.append(ConstraintRow(label, "—", "Модельное значение", "Не оценено", "unknown")) - continue - unit = "°C" if metric_name == "t95" else "" - actual = f"{value:g} {unit}".strip() - if upper is not None and upper != value: - actual = f"{actual} / upper {upper:g} {unit}".strip() - rows.append(ConstraintRow(label, actual, "Модельное значение", "Оценено", "ok")) return tuple(rows) def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> DashboardView: """Translate strict backend output into honest operator-facing content.""" - baseline_value, baseline_upper = _metric(result.baseline, "sulfur") - selected_value, selected_upper = _metric(result.selected, "sulfur") + baseline_value, _, baseline_upper = _metric(result.baseline, "sulfur") + _, _, baseline_t95_upper = _metric(result.baseline, "t95") + _, baseline_cetane_lower, _ = _metric(result.baseline, "cetane_number") + selected_value, _, selected_upper = _metric(result.selected, "sulfur") + _, _, selected_t95_upper = _metric(result.selected, "t95") + _, selected_cetane_lower, _ = _metric(result.selected, "cetane_number") current = dict(scenario.current_blend_mass_fractions) proposed = dict(current) + current_additive = scenario.current_additive_mass_fraction + proposed_additive = current_additive if result.selected is not None and result.selected.candidate.blend_mass_fractions: proposed = dict(result.selected.candidate.blend_mass_fractions) + proposed_additive = result.selected.candidate.additive_mass_fraction if result.status is RecommendationStatus.RECOMMEND: - changed = [key for key in proposed if proposed.get(key, 0.0) > current.get(key, 0.0) + 1e-9] + changed = [ + key for key in proposed if proposed.get(key, 0.0) > current.get(key, 0.0) + 1e-9 + ] component = changed[0] if changed else "смеси" banner_title = "Доступен модельный вариант" banner_detail = "Расчёт относится только к синтетическому сценарию блендинга." action_title = f"Увеличить долю компонента {component}" - action_detail = "Текущая рецептура нарушает ограничение по сере." + action_detail = "Текущая рецептура нарушает одно или несколько ограничений качества." elif result.status is RecommendationStatus.HOLD: banner_title = "Изменение режима не требуется" banner_detail = "Текущая модельная рецептура проходит доступные проверки." @@ -179,7 +245,7 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> action_detail = "Материального улучшения относительно hold не найдено." else: banner_title = "Рекомендация недоступна" - banner_detail = "Обязательные данные или верхняя оценка качества отсутствуют." + banner_detail = "Обязательные данные или консервативная оценка качества отсутствуют." action_title = "Требуется ручная проверка" action_detail = "Система не подставляет неизвестные значения и не выбирает действие." @@ -190,11 +256,17 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> action_title=action_title, action_detail=action_detail, baseline_value=baseline_value, + baseline_t95_upper=baseline_t95_upper, + baseline_cetane_lower=baseline_cetane_lower, baseline_upper=baseline_upper, selected_value=selected_value, + selected_t95_upper=selected_t95_upper, + selected_cetane_lower=selected_cetane_lower, selected_upper=selected_upper, current_fractions=current, proposed_fractions=proposed, + current_additive_fraction=current_additive, + proposed_additive_fraction=proposed_additive, constraints=_constraint_rows(result), explanation=result.explanation, reasons=result.reason_codes, @@ -216,6 +288,143 @@ def _format_value(value: float | None, unit: str = "мг/кг") -> str: return f"{rendered} {unit}" +def format_action_shadow_payload(payload: dict[str, object]) -> str: + """Render a research action scenario as sulfur changes rather than raw JSON.""" + state = payload.get("state", {}) + baseline = state.get("baseline_sulfur") if isinstance(state, dict) else None + control = str(payload.get("control_id", "—")) + control_unit = str(payload.get("control_unit", "")) + delta = payload.get("proposed_delta") + point = payload.get("predicted_sulfur", {}) + upper = payload.get("sulfur_upper", {}) + effect = payload.get("sulfur_change", {}) + rows = [ + "Модельный эффект по историческим эпизодам", + "Не является советом по изменению уставки.", + "", + f"Текущая сера ПАК: {_format_value(baseline if isinstance(baseline, float) else None)}", + f"Сценарий: {control} на {delta} {control_unit}".rstrip(), + "", + "Горизонт | Сера | Изменение к hold | Верхняя граница", + ] + for horizon in (60, 120, 180): + key = str(horizon) + predicted = point.get(key) if isinstance(point, dict) else None + conservative = upper.get(key) if isinstance(upper, dict) else None + change = effect.get(key) if isinstance(effect, dict) else None + predicted_text = _format_value(predicted if isinstance(predicted, float) else None) + change_text = _format_value(change if isinstance(change, float) else None) + upper_text = _format_value(conservative if isinstance(conservative, float) else None) + rows.append(f"{horizon:>3} мин | {predicted_text} | {change_text} | {upper_text}") + rows.extend( + ( + "", + "Историческая область: " + ("да" if payload.get("within_observed_domain") else "нет"), + "Validation модели: " + + ("пройдена" if payload.get("model_validated") else "не пройдена"), + "Верхняя граница серы: " + + ("проходит" if payload.get("safety_passes") else "не проходит"), + ) + ) + reasons = payload.get("reason_codes") + if isinstance(reasons, (list, tuple)) and reasons: + rows.append("Причины: " + ", ".join(str(reason) for reason in reasons)) + return "\n".join(rows) + + +def format_v2_forecast_payload(payload: dict[str, object]) -> str: + """Render the schema-1.2 shadow forecast in an operator-readable form.""" + forecast = payload.get("forecast") + if not isinstance(forecast, dict): + return json.dumps(payload, ensure_ascii=False, indent=2) + probabilities = forecast.get("horizon_probabilities", {}) + rows = [ + "Эпизодный прогноз серы (PAK, shadow)", + "Не является командой управления и не изменяет уставки.", + f"Состояние на: {payload.get('as_of', '—')}", + f"Тревога: {'да' if forecast.get('event_alarm') else 'нет'}", + f"Применимость: {'да' if forecast.get('applicable') else 'нет'}", + "", + "Горизонт | P(пересечение 10 мг/кг)", + ] + if isinstance(probabilities, dict): + for horizon in (10, 20, 30, 60): + value = probabilities.get(str(horizon)) + rendered = f"{float(value) * 100:.1f}%" if isinstance(value, (int, float)) else "—" + rows.append(f"{horizon:>3} мин | {rendered}") + rows.extend( + ( + "", + "Точка через 60 мин: " + + _format_value( + forecast.get("point_60m") + if isinstance(forecast.get("point_60m"), (int, float)) + else None + ), + "Верхняя граница через 60 мин: " + + _format_value( + forecast.get("upper_60m") + if isinstance(forecast.get("upper_60m"), (int, float)) + else None + ), + "Причины: " + + (", ".join(str(reason) for reason in forecast.get("reason_codes", ())) or "—"), + f"Статус артефакта: {payload.get('production_status', '—')}", + ) + ) + return "\n".join(rows) + + +def format_history_replay_view(view: UiHistoryReplayView) -> str: + """Render a history replay result without hiding the raw journal payload.""" + rows = [ + "Исторический forecast серы", + view.message, + "", + f"Status: {view.recommendation_status or view.status}", + f"Model: {view.model_id or '—'}", + f"As of: {view.as_of or '—'}", + f"Сера point: {_format_value(view.sulfur_point)}", + f"Сера upper: {_format_value(view.sulfur_upper)}", + f"Upper status: {view.upper_status}", + f"Action state: {view.action_state}", + f"Selected kind: {view.selected_kind or '—'}", + f"Reason codes: {', '.join(view.reason_codes) if view.reason_codes else '—'}", + ] + if view.issues: + rows.append("Issues: " + " | ".join(view.issues)) + if view.journal_path: + rows.append(f"Journal: {view.journal_path}") + if view.raw is not None: + rows.extend( + ("", "Raw recommendation JSON:", json.dumps(view.raw, ensure_ascii=False, indent=2)) + ) + return "\n".join(rows) + + +def format_hybrid_blend_view(view: UiHybridBlendView) -> str: + """Render the hybrid sulfur-only blend panel in a compact form.""" + rows = [ + "Hybrid sulfur-only blend", + view.message, + "", + f"Status: {view.status}", + f"Scenario: {view.scenario_id}", + f"Model: {view.model_id or '—'}", + f"State: {view.source_state_id or '—'}", + f"Component A sulfur point: {_format_value(view.component_sulfur_point)}", + f"Component A sulfur upper: {_format_value(view.component_sulfur_upper)}", + f"Blend sulfur point: {_format_value(view.blend_sulfur_point)}", + f"Blend sulfur upper: {_format_value(view.blend_sulfur_upper)}", + f"Constraint: {view.constraint_status}", + ] + if view.reason_codes: + rows.append("Reason codes: " + ", ".join(view.reason_codes)) + if view.assumptions: + rows.append("Assumptions: " + " | ".join(view.assumptions)) + return "\n".join(rows) + + class PetrolCodeApp(tk.Tk): """Resizable operator desktop shell backed by the existing Python API.""" @@ -294,8 +503,15 @@ def _build_shell(self) -> None: tk.Frame(self.header, bg="#65727A", width=1, height=26).pack(side="left", padx=(0, 14)) for key, label in ( ("overview", "Обзор"), +<<<<<<< HEAD ("recommendation", "Рекомендации"), ("history", "История"), +======= + ("avt", "АВТ"), + ("hydrotreating", "Гидроочистка"), + ("blend", "Смесь"), + ("history", "История/ML"), +>>>>>>> e70cafe (fix) ("journal", "Журнал"), ): button = tk.Button( @@ -333,17 +549,28 @@ def _set_nav(self, page: str) -> None: button.configure(fg="white" if key == page else "#C0C8CD") def show_page(self, page: str) -> None: + page = PAGE_ALIASES.get(page, page) self._page = page self._set_nav(page) self._clear_body() if page == "overview": self._render_overview() - elif page == "recommendation": + elif page == "avt": + self._render_stage_page("avt") + elif page == "hydrotreating": + self._render_stage_page("hydrotreating") + elif page == "blend": self._render_recommendation() elif page == "history": self._render_history() +<<<<<<< HEAD else: +======= + elif page == "journal": +>>>>>>> e70cafe (fix) self._render_journal() + else: + self._render_overview() def _page_container(self) -> tk.Frame: canvas = tk.Canvas(self.body, bg=BG, bd=0, highlightthickness=0) @@ -411,6 +638,183 @@ def _secondary_button(parent: tk.Misc, text: str, command: Any) -> tk.Button: cursor="hand2", ) + @staticmethod + def _field( + parent: tk.Misc, label: str, variable: tk.StringVar, width: int | None = None + ) -> None: + tk.Label(parent, text=label, bg=BG, fg=TEXT, font=("Segoe UI", 10, "bold")).pack( + anchor="w" + ) + entry = tk.Entry(parent, textvariable=variable, bg=SURFACE, fg=TEXT, bd=1, width=width) + entry.pack(fill="x", pady=(4, 10), ipady=6) + + @staticmethod + def _stage_tone(status: str) -> str: + return {"fresh": "ok", "stale": "unknown", "missing": "bad"}.get(status, "unknown") + + def _render_stage_cards(self, parent: tk.Misc) -> None: + row = tk.Frame(parent, bg=BG) + row.pack(fill="x", pady=(16, 12)) + context = discover_ui_context() + for page, label in (("avt", "АВТ"), ("hydrotreating", "Гидроочистка")): + snapshot = ui_stage_snapshot(page) + card = self._surface(row) + card.pack(side="left", fill="x", expand=True, padx=(0, 12), ipady=8) + tk.Label(card, text=label, bg=SURFACE, fg=TEXT, font=("Segoe UI", 14, "bold")).pack( + anchor="w", padx=18, pady=(14, 4) + ) + tk.Label( + card, + text=( + f"fresh {snapshot.fresh_count} · stale {snapshot.stale_count} · " + f"missing {snapshot.missing_count}" + ), + bg=SURFACE, + fg=TEXT, + font=("Segoe UI", 11, "bold"), + ).pack(anchor="w", padx=18) + tk.Label( + card, + text=snapshot.message, + bg=SURFACE, + fg=MUTED, + wraplength=360, + justify="left", + ).pack(anchor="w", padx=18, pady=(6, 14)) + self._secondary_button(card, "Открыть", partial(self.show_page, page)).pack( + anchor="w", padx=18, pady=(0, 14) + ) + card = self._surface(row) + card.pack(side="left", fill="x", expand=True, ipady=8) + tk.Label(card, text="История/ML", bg=SURFACE, fg=TEXT, font=("Segoe UI", 14, "bold")).pack( + anchor="w", padx=18, pady=(14, 4) + ) + forecast_count = len(context.forecast_artifacts) + action_count = len(context.action_artifacts) + dataset_text = "dataset найден" if context.latest_dataset else "dataset отсутствует" + tk.Label( + card, + text=f"{dataset_text} · forecast {forecast_count} · action {action_count}", + bg=SURFACE, + fg=TEXT, + font=("Segoe UI", 11, "bold"), + ).pack(anchor="w", padx=18) + tk.Label( + card, + text=( + "Forecast работает read-only; actionable совет включается только " + "verified action artifact." + ), + bg=SURFACE, + fg=MUTED, + wraplength=360, + justify="left", + ).pack(anchor="w", padx=18, pady=(6, 14)) + self._secondary_button(card, "Открыть", partial(self.show_page, "history")).pack( + anchor="w", padx=18, pady=(0, 14) + ) + + def _render_stage_page(self, page_key: str) -> None: + page = self._page_container() + title = "АВТ" if page_key == "avt" else "Гидроочистка" + subtitle = ( + "Read-only контекст установки АВТ: значения, единицы, источник и свежесть." + if page_key == "avt" + else "Read-only контекст гидроочистки: качество, P8/F19 readiness и газовый контур." + ) + tk.Label(page, text=title, bg=BG, fg=TEXT, font=("Segoe UI", 28, "bold")).pack(anchor="w") + tk.Label(page, text=subtitle, bg=BG, fg=MUTED, font=("Segoe UI", 11)).pack( + anchor="w", pady=(2, 18) + ) + + context = discover_ui_context() + dataset_var = tk.StringVar(value=context.latest_dataset or "") + as_of_var = tk.StringVar(value=default_as_of_for_dataset(dataset_var.get() or None)) + controls = self._surface(page) + controls.pack(fill="x", pady=(0, 14), padx=0) + inner = tk.Frame(controls, bg=SURFACE) + inner.pack(fill="x", padx=20, pady=16) + left = tk.Frame(inner, bg=SURFACE) + left.pack(side="left", fill="x", expand=True, padx=(0, 14)) + right = tk.Frame(inner, bg=SURFACE) + right.pack(side="left", fill="x", expand=True) + self._field(left, "Prepared dataset", dataset_var) + self._field(right, "As of (ISO timezone)", as_of_var) + content = tk.Frame(page, bg=BG) + content.pack(fill="both", expand=True) + + def draw() -> None: + for child in content.winfo_children(): + child.destroy() + snapshot = ui_stage_snapshot(page_key, dataset_var.get() or None, as_of_var.get()) + self._render_stage_snapshot(content, snapshot) + + self._primary_button(inner, "Обновить", draw).pack(side="right", padx=(14, 0), pady=20) + draw() + + def _render_stage_snapshot(self, parent: tk.Misc, snapshot: UiStageSnapshot) -> None: + summary = self._surface(parent) + summary.pack(fill="x", pady=(0, 14)) + for label, value in ( + ("Dataset", snapshot.dataset_id or "—"), + ("As of", snapshot.as_of or "—"), + ("Fresh", str(snapshot.fresh_count)), + ("Stale", str(snapshot.stale_count)), + ("Missing", str(snapshot.missing_count)), + ): + cell = tk.Frame(summary, bg=SURFACE) + cell.pack(side="left", fill="x", expand=True, padx=18, pady=16) + tk.Label(cell, text=label, bg=SURFACE, fg=MUTED, font=("Segoe UI", 9, "bold")).pack( + anchor="w" + ) + tk.Label(cell, text=value, bg=SURFACE, fg=TEXT, font=("Segoe UI", 14, "bold")).pack( + anchor="w", pady=(3, 0) + ) + if snapshot.status != "ready": + self._text_content(parent, snapshot.message) + return + table = ttk.Treeview( + parent, + columns=("group", "signal", "label", "value", "source", "age", "status", "reason"), + show="headings", + height=max(8, min(18, len(snapshot.rows))), + style="Petrol.Treeview", + ) + for key, title, width in ( + ("group", "Группа", 150), + ("signal", "Signal", 120), + ("label", "Смысл", 310), + ("value", "Значение", 120), + ("source", "Источник", 95), + ("age", "Возраст", 95), + ("status", "Статус", 95), + ("reason", "Ограничение", 280), + ): + table.heading(key, text=title) + table.column(key, width=width, anchor="w", stretch=True) + table.tag_configure("ok", foreground=GREEN) + table.tag_configure("bad", foreground=RED) + table.tag_configure("unknown", foreground=AMBER) + for row in snapshot.rows: + age = "—" if row.age_minutes is None else f"{row.age_minutes:.0f} мин" + issue = row.issue or row.read_only_reason + table.insert( + "", + "end", + values=( + row.group, + row.signal_id, + row.label, + row.value_text, + row.source, + age, + row.freshness, + issue, + ), + tags=(self._stage_tone(row.freshness),), + ) + table.pack(fill="both", expand=True) + def _render_overview(self) -> None: page = self._page_container() top = tk.Frame(page, bg=BG) @@ -498,22 +902,23 @@ def _render_overview(self) -> None: self._render_kpis(chart_side, view) self._render_chart(chart_side, view) self._render_info(info, view) + self._render_stage_cards(page) stages = tk.Frame(page, bg=SURFACE, highlightbackground=BORDER, highlightthickness=1) stages.pack(fill="x", pady=(16, 12), ipady=4) - for text, enabled in (("АВТ", False), ("Гидроочистка", False), ("Смесь", True)): - command = ( - partial(self.show_page, "recommendation") - if enabled - else self._show_stage_placeholder - ) + for text, target in ( + ("АВТ", "avt"), + ("Гидроочистка", "hydrotreating"), + ("Смесь", "blend"), + ): + command = partial(self.show_page, target) button = tk.Button( stages, text=text, command=command, - bg=TEAL if enabled else SOFT, - fg="white" if enabled else TEXT, - activebackground=TEAL_HOVER if enabled else SOFT, + bg=TEAL, + fg="white", + activebackground=TEAL_HOVER, bd=0, pady=8, font=("Segoe UI", 10, "bold"), @@ -530,9 +935,15 @@ def _render_kpis(self, parent: tk.Frame, view: DashboardView | None) -> None: strip = tk.Frame(parent, bg=SURFACE) strip.pack(fill="x", padx=26, pady=(18, 12)) values = ( - ("Текущая смесь", _format_value(view.baseline_value) if view else "—"), - ("Выбранный вариант +60 мин", _format_value(view.selected_value) if view else "—"), - ("Верхняя оценка", _format_value(view.selected_upper) if view else "—"), + ("Сера · верхняя", _format_value(view.selected_upper) if view else "—"), + ( + "T95 · верхняя", + _format_value(view.selected_t95_upper, "°C") if view else "—", + ), + ( + "Цетановое · нижняя", + _format_value(view.selected_cetane_lower, "ед.") if view else "—", + ), ) for index, (label, value) in enumerate(values): cell = tk.Frame(strip, bg=SURFACE) @@ -684,6 +1095,169 @@ def _info_row(parent: tk.Frame, label: str, value: str) -> None: side="right" ) + def _render_history(self) -> None: + page = self._page_container() + tk.Label(page, text="История и ML", bg=BG, fg=TEXT, font=("Segoe UI", 28, "bold")).pack( + anchor="w" + ) + tk.Label( + page, + text=( + "Исторический forecast read-only; actionable setpoints показываются " + "только с verified action artifact." + ), + bg=BG, + fg=MUTED, + font=("Segoe UI", 11), + ).pack(anchor="w", pady=(2, 18)) + context = discover_ui_context() + dataset_var = tk.StringVar(value=context.latest_dataset or "") + model_var = tk.StringVar( + value=context.forecast_artifacts[-1].path if context.forecast_artifacts else "" + ) + action_var = tk.StringVar( + value=context.action_artifacts[-1].path if context.action_artifacts else "" + ) + as_of_var = tk.StringVar(value=default_as_of_for_dataset(dataset_var.get() or None)) + fields = self._surface(page) + fields.pack(fill="x", pady=(0, 14)) + grid = tk.Frame(fields, bg=SURFACE) + grid.pack(fill="x", padx=20, pady=16) + for column in range(2): + grid.grid_columnconfigure(column, weight=1) + left = tk.Frame(grid, bg=SURFACE) + left.grid(row=0, column=0, sticky="ew", padx=(0, 12)) + right = tk.Frame(grid, bg=SURFACE) + right.grid(row=0, column=1, sticky="ew", padx=(12, 0)) + self._field(left, "Prepared dataset", dataset_var) + self._field(left, "Forecast artifact", model_var) + self._field(right, "As of (ISO timezone)", as_of_var) + self._field(right, "Verified action artifact (optional)", action_var) + output = tk.Text(page, height=24, bg=SURFACE, fg=TEXT, bd=1, wrap="word") + output.pack(fill="both", expand=True) + + def render(view: UiHistoryReplayView) -> None: + output.configure(state="normal") + output.delete("1.0", "end") + output.insert("1.0", format_history_replay_view(view)) + output.configure(state="disabled") + + def calculate() -> None: + view = ui_history_snapshot( + dataset_var.get().strip() or None, + model_var.get().strip() or None, + as_of_var.get(), + action_var.get().strip() or None, + ) + self.after(0, lambda: render(view)) + + actions = tk.Frame(page, bg=BG) + actions.pack(fill="x", pady=(12, 0)) + self._primary_button( + actions, + "Рассчитать history replay", + lambda: threading.Thread(target=calculate, daemon=True).start(), + ).pack(side="left") + self._secondary_button(actions, "Журнал", lambda: self.show_page("journal")).pack( + side="left", padx=12 + ) + render( + UiHistoryReplayView( + status="empty", + message="Выберите dataset/model и запустите расчёт.", + recommendation_status=None, + scenario_id=None, + model_id=None, + as_of=None, + sulfur_point=None, + sulfur_upper=None, + upper_status="unknown", + selected_kind=None, + action_state="unavailable", + reason_codes=(), + issues=(), + journal_path=None, + raw=None, + ) + ) + + def _render_hybrid_panel(self, parent: tk.Misc) -> None: + panel = self._surface(parent) + panel.pack(fill="x", pady=(16, 0)) + tk.Label( + panel, + text="Hybrid: АВТ → гидроочистка → смесь", + bg=SURFACE, + fg=TEXT, + font=("Segoe UI", 15, "bold"), + ).pack(anchor="w", padx=26, pady=(16, 4)) + tk.Label( + panel, + text=( + "Компонент A берётся только из history forecast. Если forecast недоступен, " + "UI не подставляет synthetic production-значение." + ), + bg=SURFACE, + fg=MUTED, + wraplength=1100, + justify="left", + ).pack(anchor="w", padx=26, pady=(0, 12)) + context = discover_ui_context() + dataset_var = tk.StringVar(value=context.latest_dataset or "") + model_var = tk.StringVar( + value=context.forecast_artifacts[-1].path if context.forecast_artifacts else "" + ) + as_of_var = tk.StringVar(value=default_as_of_for_dataset(dataset_var.get() or None)) + form = tk.Frame(panel, bg=SURFACE) + form.pack(fill="x", padx=26) + left = tk.Frame(form, bg=SURFACE) + left.pack(side="left", fill="x", expand=True, padx=(0, 10)) + middle = tk.Frame(form, bg=SURFACE) + middle.pack(side="left", fill="x", expand=True, padx=10) + right = tk.Frame(form, bg=SURFACE) + right.pack(side="left", fill="x", expand=True, padx=(10, 0)) + self._field(left, "Prepared dataset", dataset_var) + self._field(middle, "Forecast artifact", model_var) + self._field(right, "As of (ISO timezone)", as_of_var) + output = tk.Text(panel, height=10, bg="#FAFBFB", fg=TEXT, bd=0, wrap="word") + output.pack(fill="x", padx=26, pady=(4, 16)) + + def render(view: UiHybridBlendView) -> None: + output.configure(state="normal") + output.delete("1.0", "end") + output.insert("1.0", format_hybrid_blend_view(view)) + output.configure(state="disabled") + + def calculate() -> None: + view = ui_hybrid_snapshot( + dataset_var.get().strip() or None, + model_var.get().strip() or None, + as_of_var.get(), + ) + self.after(0, lambda: render(view)) + + self._secondary_button( + panel, + "Рассчитать hybrid", + lambda: threading.Thread(target=calculate, daemon=True).start(), + ).pack(anchor="e", padx=26, pady=(0, 16)) + render( + UiHybridBlendView( + status="empty", + message="Hybrid не рассчитан.", + scenario_id="hybrid_blend", + source_state_id=None, + model_id=None, + component_sulfur_point=None, + component_sulfur_upper=None, + blend_sulfur_point=None, + blend_sulfur_upper=None, + constraint_status="unknown", + assumptions=(), + reason_codes=(), + ) + ) + def _render_recommendation(self) -> None: page = self._page_container() view = self._view() @@ -773,6 +1347,16 @@ def _render_recommendation(self) -> None: tk.Frame(right, bg=BORDER, height=1).pack(fill="x", padx=20, pady=18) self._info_row(right, "Текущая", _format_value(view.baseline_upper if view else None)) self._info_row(right, "Предел сценария", "≤ 10 мг/кг") + self._info_row( + right, + "T95 · верхняя", + _format_value(view.selected_t95_upper if view else None, "°C"), + ) + self._info_row( + right, + "Цетановое · нижняя", + _format_value(view.selected_cetane_lower if view else None, "ед."), + ) checks = self._surface(page) checks.pack(fill="x", pady=(16, 0)) @@ -802,6 +1386,7 @@ def _render_recommendation(self) -> None: "Альтернативы и ход расчёта", self._alternatives_content, ).pack(fill="x") + self._render_hybrid_panel(page) footer = tk.Frame(page, bg=BG) footer.pack(fill="x", pady=(18, 0)) self._primary_button(footer, "Скачать расчёт", self.export_result).pack(side="left") @@ -821,7 +1406,7 @@ def _recipe_table(self, parent: tk.Frame, view: DashboardView | None) -> None: parent, columns=("component", "current", "proposed"), show="headings", - height=max(2, len(view.current_fractions) if view else 2), + height=max(3, len(view.current_fractions) + 1 if view else 3), style="Petrol.Treeview", ) for key, title, width in ( @@ -842,6 +1427,15 @@ def _recipe_table(self, parent: tk.Frame, view: DashboardView | None) -> None: f"{view.proposed_fractions.get(component, 0.0) * 100:.0f} %", ), ) + table.insert( + "", + "end", + values=( + "Цетаноповышающая присадка", + f"{view.current_additive_fraction * 100:.1f} %", + f"{view.proposed_additive_fraction * 100:.1f} %", + ), + ) table.pack(fill="x") @staticmethod @@ -1083,7 +1677,7 @@ def _alternatives_content(self, parent: tk.Frame) -> None: return lines = [] for alternative in self._result.alternatives: - value, upper = _metric(alternative, "sulfur") + value, _, upper = _metric(alternative, "sulfur") lines.append( f"{alternative.candidate.id}: point {_format_value(value)}, " f"upper {_format_value(upper)}" @@ -1113,6 +1707,18 @@ def _data_tools_content(self, parent: tk.Frame) -> None: self._secondary_button(row, "Собрать historical state", self.open_state_dialog).pack( side="left" ) + self._secondary_button(row, "Прогноз серы", self.open_history_forecast_dialog).pack( + side="left", padx=(10, 0) + ) + self._secondary_button(row, "Эпизодный прогноз v2", self.open_v2_forecast_dialog).pack( + side="left", padx=(10, 0) + ) + self._secondary_button(row, "Контроль ПАК–ЛИМС", self.open_lims_correction_dialog).pack( + side="left", padx=(10, 0) + ) + self._secondary_button( + row, "Исторический эффект P8/F19 (не совет)", self.open_action_shadow_dialog + ).pack(side="left", padx=(10, 0)) def _view(self) -> DashboardView | None: if self._result is None or self._scenario is None: @@ -1247,13 +1853,325 @@ def build() -> None: self._primary_button(fields, "Собрать состояние", build).pack(anchor="e", pady=(12, 0)) - def _show_stage_placeholder(self, _: str | None = None) -> None: - messagebox.showinfo( - "Функция следующего этапа", - "Отдельный расчёт для этой стадии пока не подключён. Данные не подменяются заглушкой.", - parent=self, + def open_action_shadow_dialog(self) -> None: + """Show a historical P8/F19 scenario without presenting it as a recommendation.""" + dialog = tk.Toplevel(self) + dialog.title("Модельный эффект по историческим эпизодам") + dialog.geometry("760x620") + dialog.configure(bg=BG) + dialog.transient(self) + fields = tk.Frame(dialog, bg=BG) + fields.pack(fill="both", expand=True, padx=26, pady=22) + datasets = sorted((PROJECT_ROOT / "data/processed").glob("*/manifest.json")) + artifacts = sorted( + (PROJECT_ROOT / "artifacts/models").glob("action-shadow-*/metadata.json"), + key=lambda path: path.stat().st_mtime, + ) + dataset_var = tk.StringVar(value=str(datasets[-1].parent) if datasets else "") + model_var = tk.StringVar(value=str(artifacts[-1].parent) if artifacts else "") + control_var = tk.StringVar(value="ht:P8") + delta_var = tk.StringVar(value="0.001") + at_var = tk.StringVar(value="2025-06-01T12:00:00+03:00") + for label, variable in ( + ("Prepared dataset", dataset_var), + ("Action shadow artifact", model_var), + ("Время состояния (ISO с timezone)", at_var), + ("Изменение тега в его исходной единице", delta_var), + ): + tk.Label(fields, text=label, bg=BG, fg=TEXT).pack(anchor="w") + tk.Entry(fields, textvariable=variable, bg=SURFACE, fg=TEXT, bd=1).pack( + fill="x", pady=(4, 10), ipady=6 + ) + tk.Label(fields, text="Тег", bg=BG, fg=TEXT).pack(anchor="w") + ttk.Combobox( + fields, + textvariable=control_var, + values=("ht:P8", "ht:F19"), + state="readonly", + style="Petrol.TCombobox", + ).pack(fill="x", pady=(4, 10)) + tk.Label( + fields, + text=( + "Расчёт показывает модельный эффект по наблюдавшимся эпизодам. P8 — " + "перепад давления реактора Р-202 (МПа), F19 — расход бензина в К-201 (т/ч). " + "Это не совет по изменению уставки и не команда оборудованию." + ), + bg=BG, + fg=MUTED, + wraplength=700, + justify="left", + ).pack(anchor="w", pady=(0, 10)) + output = tk.Text(fields, height=14, bg=SURFACE, fg=TEXT, bd=1, wrap="word") + output.pack(fill="both", expand=True) + + def render(text: str) -> None: + output.delete("1.0", "end") + output.insert("1.0", text) + + def calculate() -> None: + try: + at = datetime.fromisoformat(at_var.get().replace("Z", "+00:00")) + if at.tzinfo is None: + raise ValueError("время должно содержать timezone") + result = action_shadow_estimate_command( + dataset_var.get(), + model_var.get(), + control_var.get(), + float(delta_var.get()), + at, + ) + text = format_action_shadow_payload(result) + except Exception as exc: + text = f"Ошибка: {exc}" + self.after(0, lambda: render(text)) + + self._primary_button( + fields, + "Рассчитать исследовательский сценарий", + lambda: threading.Thread(target=calculate, daemon=True).start(), + ).pack(anchor="e", pady=(12, 0)) + + def open_history_forecast_dialog(self) -> None: + """Replay a trusted local sulfur artifact at one historical timestamp.""" + dialog = tk.Toplevel(self) + dialog.title("Исторический прогноз серы") + dialog.geometry("760x640") + dialog.configure(bg=BG) + dialog.transient(self) + fields = tk.Frame(dialog, bg=BG) + fields.pack(fill="both", expand=True, padx=26, pady=22) + datasets = sorted((PROJECT_ROOT / "data/processed").glob("*/manifest.json")) + artifacts = sorted((PROJECT_ROOT / "artifacts/models").glob("*/metadata.json")) + forecast_artifacts: list[Path] = [] + action_artifacts: list[Path] = [] + for path in artifacts: + if path.parent.name.startswith("action-shadow-"): + continue + try: + metadata = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if metadata.get("artifact_kind") == "action_effect" and metadata.get( + "supports_actions" + ): + action_artifacts.append(path) + continue + capabilities = metadata.get("capabilities") + # LIMS correction consumes the ordinary PAK feature schema. A v2 + # episode artifact has derived features and is deliberately not a + # drop-in replacement for this delayed control layer. + if ( + metadata.get("schema_version") == "1.0" + and isinstance(capabilities, dict) + and not capabilities.get("supports_multi_horizon", False) + ): + forecast_artifacts.append(path) + forecast_artifacts.sort(key=lambda path: path.stat().st_mtime) + action_artifacts.sort(key=lambda path: path.stat().st_mtime) + dataset_var = tk.StringVar(value=str(datasets[-1].parent) if datasets else "") + model_var = tk.StringVar( + value=str(forecast_artifacts[-1].parent) if forecast_artifacts else "" + ) + action_model_var = tk.StringVar(value="") + at_var = tk.StringVar(value="2025-06-01T12:00:00+03:00") + for label, variable in ( + ("Prepared dataset", dataset_var), + ("Forecast artifact", model_var), + ( + "Verified action artifact (optional)", + action_model_var, + ), + ("Время состояния (ISO с timezone)", at_var), + ): + tk.Label(fields, text=label, bg=BG, fg=TEXT).pack(anchor="w") + tk.Entry(fields, textvariable=variable, bg=SURFACE, fg=TEXT, bd=1).pack( + fill="x", pady=(4, 12), ipady=6 + ) + tk.Label( + fields, + text=( + "Прогноз использует только доступные к выбранному времени данные. " + "Без verified action artifact это предупреждение, а не совет по уставкам." + ), + bg=BG, + fg=MUTED, + wraplength=700, + justify="left", + ).pack(anchor="w", pady=(0, 10)) + output = tk.Text(fields, height=13, bg=SURFACE, fg=TEXT, bd=1, wrap="word") + output.pack(fill="both", expand=True) + + def render(text: str) -> None: + output.delete("1.0", "end") + output.insert("1.0", text) + + def calculate() -> None: + try: + at = datetime.fromisoformat(at_var.get().replace("Z", "+00:00")) + if at.tzinfo is None: + raise ValueError("время должно содержать timezone") + action_model = action_model_var.get().strip() or None + view = ui_history_snapshot( + dataset_var.get(), + model_var.get(), + at, + action_model, + ) + text = format_history_replay_view(view) + except Exception as exc: + text = f"Ошибка: {exc}" + self.after(0, lambda: render(text)) + + self._primary_button( + fields, + "Рассчитать прогноз", + lambda: threading.Thread(target=calculate, daemon=True).start(), + ).pack(anchor="e", pady=(12, 0)) + + def open_v2_forecast_dialog(self) -> None: + """Serve the multi-horizon schema-1.2 artifact as a shadow forecast.""" + dialog = tk.Toplevel(self) + dialog.title("Эпизодный прогноз серы v2") + dialog.geometry("760x570") + dialog.configure(bg=BG) + dialog.transient(self) + fields = tk.Frame(dialog, bg=BG) + fields.pack(fill="both", expand=True, padx=26, pady=22) + datasets = sorted((PROJECT_ROOT / "data/processed").glob("*/manifest.json")) + v2_artifacts: list[Path] = [] + for metadata_path in sorted((PROJECT_ROOT / "artifacts/models").glob("*/metadata.json")): + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + capabilities = metadata.get("capabilities") + if ( + metadata.get("schema_version") == "1.2" + and isinstance(capabilities, dict) + and capabilities.get("supports_multi_horizon") + ): + v2_artifacts.append(metadata_path) + dataset_var = tk.StringVar(value=str(datasets[-1].parent) if datasets else "") + v2_artifacts.sort(key=lambda path: path.stat().st_mtime) + model_var = tk.StringVar(value=str(v2_artifacts[-1].parent) if v2_artifacts else "") + at_var = tk.StringVar(value="2025-06-01T12:00:00+03:00") + for label, variable in ( + ("Prepared dataset", dataset_var), + ("Schema-1.2 shadow artifact", model_var), + ("Время состояния (ISO с timezone)", at_var), + ): + tk.Label(fields, text=label, bg=BG, fg=TEXT).pack(anchor="w") + tk.Entry(fields, textvariable=variable, bg=SURFACE, fg=TEXT, bd=1).pack( + fill="x", pady=(4, 12), ipady=6 + ) + tk.Label( + fields, + text=( + "Показывает вероятность начала эпизода и верхнюю границу серы на нескольких " + "горизонтах. Артефакт работает только в shadow-режиме: это предупреждение, " + "а не совет и не изменение уставок." + ), + bg=BG, + fg=MUTED, + wraplength=700, + justify="left", + ).pack(anchor="w", pady=(0, 10)) + output = tk.Text(fields, height=13, bg=SURFACE, fg=TEXT, bd=1, wrap="word") + output.pack(fill="both", expand=True) + + def render(text: str) -> None: + output.delete("1.0", "end") + output.insert("1.0", text) + + def calculate() -> None: + try: + at = datetime.fromisoformat(at_var.get().replace("Z", "+00:00")) + if at.tzinfo is None: + raise ValueError("время должно содержать timezone") + result = replay_v2_shadow_command(dataset_var.get(), model_var.get(), at) + text = format_v2_forecast_payload(result) + except Exception as exc: + text = f"Ошибка: {exc}" + self.after(0, lambda: render(text)) + + self._primary_button( + fields, + "Рассчитать episode forecast", + lambda: threading.Thread(target=calculate, daemon=True).start(), + ).pack(anchor="e", pady=(12, 0)) + + def open_lims_correction_dialog(self) -> None: + """Evaluate the separately labelled delayed LIMS correction evidence.""" + dialog = tk.Toplevel(self) + dialog.title("Контроль ПАК–ЛИМС") + dialog.geometry("760x500") + dialog.configure(bg=BG) + dialog.transient(self) + fields = tk.Frame(dialog, bg=BG) + fields.pack(fill="both", expand=True, padx=26, pady=22) + datasets = sorted((PROJECT_ROOT / "data/processed").glob("*/manifest.json")) + artifacts = sorted((PROJECT_ROOT / "artifacts/models").glob("*/metadata.json")) + forecast_artifacts: list[Path] = [] + for path in artifacts: + if path.parent.name.startswith("action-shadow-"): + continue + try: + metadata = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + capabilities = metadata.get("capabilities") + if ( + metadata.get("schema_version") == "1.0" + and isinstance(capabilities, dict) + and not capabilities.get("supports_multi_horizon", False) + ): + forecast_artifacts.append(path) + forecast_artifacts.sort(key=lambda path: path.stat().st_mtime) + dataset_var = tk.StringVar(value=str(datasets[-1].parent) if datasets else "") + model_var = tk.StringVar( + value=str(forecast_artifacts[-1].parent) if forecast_artifacts else "" ) + for label, variable in ( + ("Prepared dataset", dataset_var), + ("PAK forecast artifact", model_var), + ): + tk.Label(fields, text=label, bg=BG, fg=TEXT).pack(anchor="w") + tk.Entry(fields, textvariable=variable, bg=SURFACE, fg=TEXT, bd=1).pack( + fill="x", pady=(4, 12), ipady=6 + ) + tk.Label( + fields, + text=( + "ЛИМС публикуется с задержкой и используется отдельно от оперативного ПАК. " + "Результат показывает качество коррекции; только прошедшая gate-коррекция " + "может стать кандидатом для shadow-периода." + ), + bg=BG, + fg=MUTED, + wraplength=700, + justify="left", + ).pack(anchor="w", pady=(0, 10)) + output = tk.Text(fields, height=14, bg=SURFACE, fg=TEXT, bd=1, wrap="word") + output.pack(fill="both", expand=True) + + def render(text: str) -> None: + output.delete("1.0", "end") + output.insert("1.0", text) + def calculate() -> None: + try: + result = evaluate_lims_correction_command(dataset_var.get(), model_var.get()) + text = json.dumps(result, ensure_ascii=False, indent=2) + except Exception as exc: + text = f"Ошибка: {exc}" + self.after(0, lambda: render(text)) + + self._primary_button( + fields, + "Оценить LIMS-коррекцию", + lambda: threading.Thread(target=calculate, daemon=True).start(), + ).pack(anchor="e", pady=(12, 0)) def smoke_snapshot(scenario_id: str) -> dict[str, Any]: """Headless smoke path used by CI and machines without a display.""" @@ -1298,10 +2216,12 @@ def history_smoke_snapshot( def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(description="НЕФТЕКОД desktop interface") - parser.add_argument("--scenario", choices=tuple(SCENARIO_LABELS.values()), default="blend_risk") + parser.add_argument( + "--scenario", choices=tuple(SCENARIO_LABELS.values()), default="blend_risk" + ) parser.add_argument( "--page", - choices=("overview", "recommendation", "journal"), + choices=PAGE_CHOICES, default="overview", help="initial screen", ) @@ -1343,9 +2263,23 @@ def main(argv: Sequence[str] | None = None) -> int: "ConstraintRow", "DashboardView", "PetrolCodeApp", +<<<<<<< HEAD "history_smoke_snapshot", +======= + "format_action_shadow_payload", + "format_history_replay_view", + "format_hybrid_blend_view", + "format_v2_forecast_payload", +<<<<<<< HEAD +>>>>>>> 1527107 (Extend UI and ML analysis materials) +======= + "history_replay_to_view", +>>>>>>> e70cafe (fix) "journal_entries", "main", "recommendation_to_view", "smoke_snapshot", + "ui_history_snapshot", + "ui_hybrid_snapshot", + "ui_stage_snapshot", ] diff --git a/source/ui_data.py b/source/ui_data.py new file mode 100644 index 0000000..3ea1ee4 --- /dev/null +++ b/source/ui_data.py @@ -0,0 +1,765 @@ +"""Headless UI projections for stage dashboards and history replay. + +The Tkinter shell imports this module for display-ready data. Keeping the +queries here makes AVT/hydrotreatment/history screens testable without opening +Tk and avoids importing optional ML stacks during ordinary UI startup. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +import pandas as pd + +from source.config import PROJECT_ROOT, load_runtime_config, load_scenario, load_tag_dictionary +from source.contracts import ( + CandidateEvaluation, + ConstraintStatus, + EstimateBasis, + IntervalKind, + MetricEstimate, + Recommendation, + RecommendationStatus, + SourceKind, + TagMeta, + Unit, +) +from source.data.prepare import PreparedData, load_prepared_dataset +from source.main import replay_command +from source.ml.blending import ( + HybridComponentForecast, + apply_hydrotreater_forecast, + calculate_mass_blend, + sulfur_constraint_status, +) + +AVT_SIGNAL_GROUPS: dict[str, tuple[str, ...]] = { + "K-2 state": ("avt:F65", "avt:T20", "avt:T33", "avt:P21", "avt:P22", "avt:P23", "avt:P67"), + "K-2 circulation": ( + "avt:F14", + "avt:T13", + "avt:T18", + "avt:F12", + "avt:T17", + "avt:F64", + "avt:T11", + "avt:T15", + ), + "Diesel cut": ("avt:T66", "avt:F28", "avt:F32", "avt:T71", "avt:F30", "avt:W70"), +} + +HYDROTREATING_SIGNAL_GROUPS: dict[str, tuple[str, ...]] = { + "Quality": ("ht:2:Mg.Sulfur", "ht:density_15c"), + "Action readiness": ("ht:P8", "ht:F19"), + "Process context": ("ht:T11", "ht:F26", "ht:F14", "ht:F15", "ht:F17"), + "Gas context": ("ht:F2", "ht:F22", "ht:F25"), +} + +ACTION_CONTROL_IDS = {"ht:P8", "ht:F19"} +CONTEXT_ONLY_IDS = {"ht:T11", "ht:F26", "ht:F2", "ht:F22", "ht:F25"} +HISTORY_SULFUR_SIGNAL_ID = "ht:2:Mg.Sulfur" +SULFUR_LIMIT_MG_KG = 10.0 + +_SOURCE_PRIORITY = { + "lims": 0, + "pak": 1, + "vak": 2, + "telemetry": 3, + "scenario": 4, +} + + +@dataclass(frozen=True) +class UiArtifactOption: + path: str + model_id: str + schema_version: str + artifact_kind: str + supports_actions: bool + supports_multi_horizon: bool + + +@dataclass(frozen=True) +class UiSignalRow: + group: str + signal_id: str + label: str + value: float | None + value_text: str + unit: str + source: str + measured_at: str | None + available_at: str | None + age_minutes: float | None + freshness: Literal["fresh", "stale", "missing"] + issue: str + evidence_ref: str + read_only_reason: str + + +@dataclass(frozen=True) +class UiStageSnapshot: + page: str + title: str + dataset_path: str | None + dataset_id: str | None + as_of: str | None + status: Literal["ready", "empty", "error"] + message: str + fresh_count: int + stale_count: int + missing_count: int + rows: tuple[UiSignalRow, ...] + + +@dataclass(frozen=True) +class UiHistoryReplayView: + status: Literal["ready", "empty", "error"] + message: str + recommendation_status: str | None + scenario_id: str | None + model_id: str | None + as_of: str | None + sulfur_point: float | None + sulfur_upper: float | None + upper_status: Literal["pass", "fail", "unknown"] + selected_kind: str | None + action_state: Literal["not_actionable", "actionable", "unavailable"] + reason_codes: tuple[str, ...] + issues: tuple[str, ...] + journal_path: str | None + raw: dict[str, Any] | None + + +@dataclass(frozen=True) +class UiHybridBlendView: + status: Literal["ready", "empty", "error"] + message: str + scenario_id: str + source_state_id: str | None + model_id: str | None + component_sulfur_point: float | None + component_sulfur_upper: float | None + blend_sulfur_point: float | None + blend_sulfur_upper: float | None + constraint_status: str + assumptions: tuple[str, ...] + reason_codes: tuple[str, ...] + + +@dataclass(frozen=True) +class UiContext: + latest_dataset: str | None + forecast_artifacts: tuple[UiArtifactOption, ...] + action_artifacts: tuple[UiArtifactOption, ...] + v2_artifacts: tuple[UiArtifactOption, ...] + + +def list_prepared_datasets(root: Path = PROJECT_ROOT) -> tuple[Path, ...]: + """Return prepared dataset directories sorted by modification time.""" + config = load_runtime_config(root / "config/runtime.toml") + data_root = _resolve(config.data_dir, root) + manifests = [path for path in data_root.glob("*/manifest.json") if path.is_file()] + return tuple(path.parent for path in sorted(manifests, key=lambda item: item.stat().st_mtime)) + + +def latest_prepared_dataset(root: Path = PROJECT_ROOT) -> Path | None: + datasets = list_prepared_datasets(root) + return datasets[-1] if datasets else None + + +def list_model_artifacts(root: Path = PROJECT_ROOT) -> tuple[UiArtifactOption, ...]: + """Return model metadata summaries without loading executable artifacts.""" + config = load_runtime_config(root / "config/runtime.toml") + models_root = _resolve(config.models_dir, root) + result: list[UiArtifactOption] = [] + for metadata_path in sorted(models_root.glob("*/metadata.json")): + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + capabilities = metadata.get("capabilities") + if not isinstance(capabilities, dict): + capabilities = {} + artifact_kind = str(metadata.get("artifact_kind", "forecast")) + model_id = str(metadata.get("model_id") or metadata_path.parent.name) + result.append( + UiArtifactOption( + path=str(metadata_path.parent), + model_id=model_id, + schema_version=str(metadata.get("schema_version", "")), + artifact_kind=artifact_kind, + supports_actions=bool( + metadata.get("supports_actions") is True + or capabilities.get("supports_actions") is True + ), + supports_multi_horizon=bool(capabilities.get("supports_multi_horizon")), + ) + ) + return tuple(result) + + +def discover_ui_context(root: Path = PROJECT_ROOT) -> UiContext: + artifacts = list_model_artifacts(root) + forecast = tuple( + item + for item in artifacts + if item.schema_version == "1.0" + and not item.supports_multi_horizon + and item.artifact_kind != "action_effect" + ) + action = tuple( + item + for item in artifacts + if item.artifact_kind == "action_effect" and item.supports_actions + ) + v2 = tuple( + item + for item in artifacts + if item.schema_version == "1.2" and item.supports_multi_horizon + ) + latest = latest_prepared_dataset(root) + return UiContext( + latest_dataset=str(latest) if latest is not None else None, + forecast_artifacts=forecast, + action_artifacts=action, + v2_artifacts=v2, + ) + + +def default_as_of_for_dataset(dataset: str | Path | None, root: Path = PROJECT_ROOT) -> str: + """Return an ISO timestamp inside the available prepared data when possible.""" + data_path = _dataset_path(dataset, root) + if data_path is None: + return datetime.now(UTC).isoformat() + try: + data = load_prepared_dataset(data_path) + return _default_as_of(data).isoformat() + except Exception: + return datetime.now(UTC).isoformat() + + +def ui_stage_snapshot( + page: str, + dataset: str | Path | None = None, + as_of: datetime | str | None = None, + *, + root: Path = PROJECT_ROOT, +) -> UiStageSnapshot: + """Build a read-only AVT or hydrotreating dashboard snapshot.""" + canonical_page = _canonical_stage_page(page) + title = "АВТ" if canonical_page == "avt" else "Гидроочистка" + data_path = _dataset_path(dataset, root) + if data_path is None: + return UiStageSnapshot( + page=canonical_page, + title=title, + dataset_path=None, + dataset_id=None, + as_of=None, + status="empty", + message=( + "Prepared dataset is not available. Run: python -m source.main prepare " + "--materials materials --config config/runtime.toml" + ), + fresh_count=0, + stale_count=0, + missing_count=0, + rows=(), + ) + try: + config = load_runtime_config(root / "config/runtime.toml") + data = load_prepared_dataset(data_path) + timestamp = _parse_as_of(as_of) if as_of is not None else _default_as_of(data) + tags = _tags_by_signal_id(root) + groups = AVT_SIGNAL_GROUPS if canonical_page == "avt" else HYDROTREATING_SIGNAL_GROUPS + rows = tuple( + _signal_row(data, timestamp, config, tags, group, signal_id) + for group, signal_ids in groups.items() + for signal_id in signal_ids + ) + except Exception as exc: + return UiStageSnapshot( + page=canonical_page, + title=title, + dataset_path=str(data_path), + dataset_id=None, + as_of=None, + status="error", + message=str(exc), + fresh_count=0, + stale_count=0, + missing_count=0, + rows=(), + ) + fresh = sum(row.freshness == "fresh" for row in rows) + stale = sum(row.freshness == "stale" for row in rows) + missing = sum(row.freshness == "missing" for row in rows) + return UiStageSnapshot( + page=canonical_page, + title=title, + dataset_path=str(data_path), + dataset_id=data.manifest.dataset_id, + as_of=timestamp.isoformat(), + status="ready", + message=( + "Read-only process context. Real setpoint changes require a verified " + "action artifact." + ), + fresh_count=fresh, + stale_count=stale, + missing_count=missing, + rows=rows, + ) + + +def ui_history_snapshot( + dataset: str | Path | None, + model: str | Path | None, + as_of: datetime | str, + action_model: str | Path | None = None, + *, + root: Path = PROJECT_ROOT, +) -> UiHistoryReplayView: + """Replay history and convert the recommendation into UI-facing fields.""" + if not dataset or not model: + return UiHistoryReplayView( + status="empty", + message="Prepared dataset and forecast artifact are required for history replay.", + recommendation_status=None, + scenario_id=None, + model_id=None, + as_of=None, + sulfur_point=None, + sulfur_upper=None, + upper_status="unknown", + selected_kind=None, + action_state="unavailable", + reason_codes=(), + issues=(), + journal_path=None, + raw=None, + ) + try: + timestamp = _parse_as_of(as_of) + result = replay_command( + dataset, + model, + "history", + timestamp, + action_model_path=str(action_model) if action_model else None, + root=root, + ) + config = load_runtime_config(root / "config/runtime.toml") + journal = root / config.runs_dir / result.run_id / "result.json" + return history_replay_to_view(result, journal if journal.is_file() else None) + except Exception as exc: + return UiHistoryReplayView( + status="error", + message=str(exc), + recommendation_status=None, + scenario_id=None, + model_id=None, + as_of=_parse_as_of(as_of).isoformat() if as_of else None, + sulfur_point=None, + sulfur_upper=None, + upper_status="unknown", + selected_kind=None, + action_state="unavailable", + reason_codes=(), + issues=(str(exc),), + journal_path=None, + raw=None, + ) + + +def history_replay_to_view( + result: Recommendation, journal_path: str | Path | None = None +) -> UiHistoryReplayView: + """Project a strict Recommendation into a compact history replay view.""" + carrier = result.selected or result.baseline + sulfur_point, _, sulfur_upper = _metric(carrier, "sulfur") + issues = tuple(_evaluation_issues(carrier)) + if sulfur_upper is None: + upper_status: Literal["pass", "fail", "unknown"] = "unknown" + else: + upper_status = "pass" if sulfur_upper <= SULFUR_LIMIT_MG_KG else "fail" + selected_kind = result.selected.candidate.kind.value if result.selected else None + action_state: Literal["not_actionable", "actionable", "unavailable"] + if result.status is RecommendationStatus.RECOMMEND and selected_kind == "setpoints": + action_state = "actionable" + elif "ACTION_MODEL_UNAVAILABLE" in result.reason_codes: + action_state = "not_actionable" + else: + action_state = ( + "unavailable" if result.status is RecommendationStatus.ABSTAIN else "not_actionable" + ) + return UiHistoryReplayView( + status="ready", + message=result.explanation, + recommendation_status=result.status.value, + scenario_id=result.scenario_id, + model_id=result.model_id, + as_of=result.as_of.isoformat(), + sulfur_point=sulfur_point, + sulfur_upper=sulfur_upper, + upper_status=upper_status, + selected_kind=selected_kind, + action_state=action_state, + reason_codes=tuple(result.reason_codes), + issues=issues, + journal_path=str(journal_path) if journal_path is not None else None, + raw=result.model_dump(mode="json"), + ) + + +def ui_hybrid_snapshot( + dataset: str | Path | None, + model: str | Path | None, + as_of: datetime | str, + *, + root: Path = PROJECT_ROOT, +) -> UiHybridBlendView: + """Use a history sulfur forecast as component A in the hybrid blend scenario.""" + if not dataset or not model: + return UiHybridBlendView( + status="empty", + message="Hybrid needs a prepared dataset and a trusted sulfur forecast artifact.", + scenario_id="hybrid_blend", + source_state_id=None, + model_id=None, + component_sulfur_point=None, + component_sulfur_upper=None, + blend_sulfur_point=None, + blend_sulfur_upper=None, + constraint_status="unknown", + assumptions=(), + reason_codes=("FORECAST_UNAVAILABLE",), + ) + try: + timestamp = _parse_as_of(as_of) + result = replay_command(dataset, model, "history", timestamp, root=root) + carrier = result.baseline + sulfur_point, _, sulfur_upper = _metric(carrier, "sulfur") + if sulfur_point is None and sulfur_upper is None: + return UiHybridBlendView( + status="empty", + message="History replay did not produce a sulfur forecast for component A.", + scenario_id="hybrid_blend", + source_state_id=result.state_id, + model_id=result.model_id, + component_sulfur_point=None, + component_sulfur_upper=None, + blend_sulfur_point=None, + blend_sulfur_upper=None, + constraint_status="unknown", + assumptions=tuple(result.assumptions), + reason_codes=tuple(result.reason_codes) + ("FORECAST_UNAVAILABLE",), + ) + scenario = load_scenario(root / "config/scenarios/hybrid_blend.json") + estimate = MetricEstimate( + value=sulfur_point, + lower=None, + upper=sulfur_upper, + unit=Unit.MG_KG.value, + basis=EstimateBasis.FORECAST, + interval_kind=( + IntervalKind.EMPIRICAL if sulfur_upper is not None else IntervalKind.NONE + ), + interval_level=0.95 if sulfur_upper is not None else None, + reference=f"history replay {result.run_id}", + assumptions=("Component A is populated from a trusted history sulfur forecast.",), + ) + forecast = HybridComponentForecast( + component_id="A", + sulfur=estimate, + source_state_id=result.state_id, + upstream_quality_reference=f"history:{result.state_id}", + lag_min_minutes=0, + lag_max_minutes=180, + link_confirmed=False, + evidence_ref="config/scenarios/hybrid_blend.json", + ) + components = apply_hydrotreater_forecast(scenario.blend_components, forecast) + blend = calculate_mass_blend( + dict(scenario.current_blend_mass_fractions), + components, + scenario.total_mass_t, + additive_mass_fraction=scenario.current_additive_mass_fraction, + additive=scenario.cetane_additive, + ) + status = sulfur_constraint_status(blend, SULFUR_LIMIT_MG_KG).value + return UiHybridBlendView( + status="ready", + message=( + "Hybrid is sulfur-only and keeps AVT-to-hydrotreatment linkage as an " + "explicit assumption." + ), + scenario_id=scenario.id, + source_state_id=result.state_id, + model_id=result.model_id, + component_sulfur_point=sulfur_point, + component_sulfur_upper=sulfur_upper, + blend_sulfur_point=blend.sulfur.value, + blend_sulfur_upper=blend.sulfur.upper, + constraint_status=status, + assumptions=tuple(dict.fromkeys((*scenario.assumptions, *blend.assumptions))), + reason_codes=tuple(result.reason_codes), + ) + except Exception as exc: + return UiHybridBlendView( + status="error", + message=str(exc), + scenario_id="hybrid_blend", + source_state_id=None, + model_id=None, + component_sulfur_point=None, + component_sulfur_upper=None, + blend_sulfur_point=None, + blend_sulfur_upper=None, + constraint_status="unknown", + assumptions=(), + reason_codes=(type(exc).__name__,), + ) + + +def _canonical_stage_page(page: str) -> Literal["avt", "hydrotreating"]: + normalized = page.strip().lower() + if normalized in {"avt", "авт"}: + return "avt" + if normalized in {"hydrotreating", "hydro", "ht", "гидроочистка"}: + return "hydrotreating" + raise ValueError(f"unsupported UI stage page: {page}") + + +def _resolve(path: str | Path, root: Path) -> Path: + candidate = Path(path) + return candidate if candidate.is_absolute() else root / candidate + + +def _dataset_path(dataset: str | Path | None, root: Path) -> Path | None: + if dataset: + return _resolve(dataset, root) + return latest_prepared_dataset(root) + + +def _tags_by_signal_id(root: Path) -> dict[str, TagMeta]: + config = load_runtime_config(root / "config/runtime.toml") + tags = load_tag_dictionary(_resolve(config.tag_dictionary_path, root)) + result: dict[str, TagMeta] = {} + for tag in tags.values(): + result.setdefault(tag.signal_id, tag) + return result + + +def _parse_as_of(value: datetime | str) -> datetime: + if isinstance(value, datetime): + timestamp = value + else: + timestamp = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if timestamp.tzinfo is None or timestamp.utcoffset() is None: + raise ValueError("as_of must include timezone") + return timestamp.astimezone(UTC) + + +def _default_as_of(data: PreparedData) -> datetime: + candidates: list[pd.Timestamp] = [] + if "timestamp" in data.telemetry.columns: + timestamps = pd.to_datetime(data.telemetry["timestamp"], utc=True, errors="coerce") + if timestamps.notna().any(): + candidates.append(timestamps.max()) + if "available_at" in data.quality.columns: + available = pd.to_datetime(data.quality["available_at"], utc=True, errors="coerce") + if available.notna().any(): + candidates.append(available.max()) + if not candidates: + return datetime.now(UTC) + return max(candidates).to_pydatetime() + + +def _signal_row( + data: PreparedData, + as_of: datetime, + config: Any, + tags: dict[str, TagMeta], + group: str, + signal_id: str, +) -> UiSignalRow: + tag = tags.get(signal_id) + unit = tag.canonical_unit if tag is not None else Unit.UNKNOWN.value + selected = _select_observation(data, signal_id, as_of, unit) + label = tag.meaning if tag is not None else signal_id + evidence = tag.evidence_ref if tag is not None else "" + read_only_reason = _read_only_reason(signal_id, unit) + if selected is None: + return UiSignalRow( + group=group, + signal_id=signal_id, + label=label, + value=None, + value_text="—", + unit=unit, + source="—", + measured_at=None, + available_at=None, + age_minutes=None, + freshness="missing", + issue="no valid observation at as_of", + evidence_ref=evidence, + read_only_reason=read_only_reason, + ) + measured_at = selected["measured_at"] + available_at = selected["available_at"] + source = str(selected["source"]) + value = float(selected["value"]) + age_minutes = max(0.0, (as_of - measured_at).total_seconds() / 60.0) + limit = _freshness_limit_minutes(config, source) + fresh = age_minutes <= limit + return UiSignalRow( + group=group, + signal_id=signal_id, + label=label, + value=value, + value_text=_format_value(value, unit), + unit=unit, + source=source, + measured_at=measured_at.isoformat(), + available_at=available_at.isoformat(), + age_minutes=age_minutes, + freshness="fresh" if fresh else "stale", + issue="" if fresh else f"age {age_minutes:.0f} min exceeds {limit:.0f} min", + evidence_ref=evidence, + read_only_reason=read_only_reason, + ) + + +def _select_observation( + data: PreparedData, signal_id: str, as_of: datetime, unit: str +) -> dict[str, Any] | None: + candidates: list[dict[str, Any]] = [] + if signal_id in data.quality.get("signal_id", pd.Series(dtype=str)).astype(str).values: + frame = data.quality.copy() + frame["measured_at"] = pd.to_datetime(frame["measured_at"], utc=True, errors="coerce") + frame["available_at"] = pd.to_datetime(frame["available_at"], utc=True, errors="coerce") + visible = frame[ + (frame["signal_id"].astype(str) == signal_id) + & (frame["measured_at"] <= pd.Timestamp(as_of)) + & (frame["available_at"] <= pd.Timestamp(as_of)) + & (frame["validity"].astype(str) == "valid") + & frame["value"].notna() + ] + for _, row in visible.iterrows(): + candidates.append( + { + "source": str(row["source"]), + "value": float(row["value"]), + "unit": str(row["unit"]), + "measured_at": pd.Timestamp(row["measured_at"]).to_pydatetime(), + "available_at": pd.Timestamp(row["available_at"]).to_pydatetime(), + } + ) + if signal_id in data.telemetry.columns and "timestamp" in data.telemetry.columns: + frame = data.telemetry.copy() + frame["timestamp"] = pd.to_datetime(frame["timestamp"], utc=True, errors="coerce") + visible = frame[frame["timestamp"] <= pd.Timestamp(as_of)] + values = pd.to_numeric(visible[signal_id], errors="coerce") + valid = visible[values.notna()] + if not valid.empty: + row = valid.iloc[-1] + measured = pd.Timestamp(row["timestamp"]).to_pydatetime() + candidates.append( + { + "source": SourceKind.TELEMETRY.value, + "value": float(row[signal_id]), + "unit": unit, + "measured_at": measured, + "available_at": measured, + } + ) + if not candidates: + return None + return min( + candidates, + key=lambda item: ( + _SOURCE_PRIORITY.get(str(item["source"]), 99), + -item["measured_at"].timestamp(), + ), + ) + + +def _freshness_limit_minutes(config: Any, source: str) -> float: + try: + key = SourceKind(source) + except ValueError: + return 0.0 + return float(config.freshness_minutes.get(key, 0)) + + +def _read_only_reason(signal_id: str, unit: str) -> str: + if signal_id in ACTION_CONTROL_IDS: + return ( + "candidate control; disabled until a verified action artifact supplies bounds " + "and gates" + ) + if signal_id in CONTEXT_ONLY_IDS: + return "context-only signal; not an enabled action control" + if unit == Unit.UNKNOWN.value: + return "read-only context; unit is unknown and cannot define a control" + return "read-only process context" + + +def _format_value(value: float | None, unit: str) -> str: + if value is None: + return "—" + rendered = f"{value:.3f}".rstrip("0").rstrip(".") + return f"{rendered} {unit}".strip() + + +def _metric( + evaluation: CandidateEvaluation | None, name: str +) -> tuple[float | None, float | None, float | None]: + if evaluation is None: + return None, None, None + for assessment in evaluation.assessments: + estimate = assessment.metrics.get(name) + if estimate is not None: + return estimate.value, estimate.lower, estimate.upper + return None, None, None + + +def _evaluation_issues(evaluation: CandidateEvaluation | None) -> tuple[str, ...]: + if evaluation is None: + return () + result: list[str] = [] + for assessment in evaluation.assessments: + result.extend(f"{issue.code}: {issue.detail}" for issue in assessment.issues) + for check in evaluation.checks: + if check.status is not ConstraintStatus.PASS: + result.append(f"{check.reason_code}: {check.constraint_id}") + return tuple(result) + + +__all__ = [ + "ACTION_CONTROL_IDS", + "AVT_SIGNAL_GROUPS", + "HYDROTREATING_SIGNAL_GROUPS", + "UiArtifactOption", + "UiContext", + "UiHistoryReplayView", + "UiHybridBlendView", + "UiSignalRow", + "UiStageSnapshot", + "default_as_of_for_dataset", + "discover_ui_context", + "history_replay_to_view", + "latest_prepared_dataset", + "list_model_artifacts", + "list_prepared_datasets", + "ui_history_snapshot", + "ui_hybrid_snapshot", + "ui_stage_snapshot", +]