diff --git a/CODE_WALKTHROUGH.md b/CODE_WALKTHROUGH.md index 4f5fe6d..ebd10db 100644 --- a/CODE_WALKTHROUGH.md +++ b/CODE_WALKTHROUGH.md @@ -11,17 +11,18 @@ 3. `STAGE2.md` - что именно добавил stage 2. 4. `STAGE3.md` - как backend выбирает результат и отбраковывает кандидатов. 5. Этот файл - как код устроен по папкам и строкам. -6. `DESIGN.md` - строгая целевая архитектура. +6. `DESIGN.md` - целевая архитектура и зафиксированные границы. ## 1. Главная хронология программы -Сейчас есть три основные CLI-команды: +Сейчас есть четыре основные CLI-команды и desktop UI: ```bash python -m source.main validate-stage0 python -m source.main run-model-demo blend_normal python -m source.main prepare --materials materials --config config/runtime.toml python -m source.main build-state --dataset data/processed/ --scenario history --as-of 2025-01-15T10:00:00+03:00 +python -m source.ui --smoke --scenario blend_risk ``` В хронологическом виде backend делает так: @@ -89,8 +90,9 @@ README не должен быть огромным учебником. Его з ### `DESIGN.md` -Самый строгий документ. Если `README.md`, `PROJECT_GUIDE.md` или этот файл -расходятся с `DESIGN.md`, правильным считать `DESIGN.md`. +Самый строгий документ о контрактах и целевой архитектуре. Для списка реально +доступных команд приоритет у `README.md`: дизайн также содержит ещё не реализованные +цели. ### `IMPLEMENTATION_PLAN.md` @@ -191,11 +193,11 @@ source_timezone = "Europe/Moscow" время. ```toml -lims_delay_hours = 6.0 +lims_delay_hours = 4.0 ``` Лабораторный анализ ЛИМС не доступен мгновенно. Если он измерен в 12:00, backend -считает его доступным только в 18:00 локального времени. +считает его доступным только в 16:00 локального времени. ```toml horizon_minutes = 60 diff --git a/DESIGN.md b/DESIGN.md index d34ce0c..db5d2a6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,8 +1,9 @@ # Технический дизайн системы рекомендаций Нефтекода -Версия контракта: **1.0**. Статус: **спецификация для реализации**. +Версия контракта: **1.0**. Статус: **частично реализованная спецификация**. -Дерево файлов, команды и тесты описывают целевую реализацию. +Документ содержит целевую архитектуру. Актуальный runtime: Stages 0–5 и Tkinter UI +для `model_demo`; исторический ML-артефакт ещё не подключён к UI. Основания: [техническое задание](materials/ТЗ_нефтекод.docx), [материалы](materials/README.md), [поэтапный план](IMPLEMENTATION_PLAN.md). ТЗ определяет обязательные требования, этот документ — технические контракты, план — порядок реализации. При изменении контракта документ и тестовые примеры обновляются в том же PR. @@ -11,10 +12,10 @@ | Вопрос | Решение для версии 1 | | --- | --- | | Команда и ресурсы | Два исполнителя (человека или агента): backend и ML; 1–2 недели; CPU; без обязательных платных API | -| Развёртывание | Одно локальное Python-приложение, Python 3.11 | +| Развёртывание | Одно локальное Python-приложение; toolchain нацелен на Python 3.11, локальный Stage-5 artifact собран в Python 3.12.3 | | Мультиагентность | Агент качества, агент надёжности, агент оптимизации и оркестратор с явными входами и выходами | | Взаимодействие | Синхронные вызовы Python-функций в одном процессе; структурированные результаты | -| UI | Streamlit; CLI для подготовки, обучения, оценки и воспроизведения | +| UI | Tkinter desktop UI для model-demo; CLI для `prepare`, `build-state` и demo | | Хранение | Исходные файлы; подготовленные CSV.gz; артефакты модели; JSON/JSONL с результатами | | Модели | Сохранение последнего значения → Ridge → HistGradientBoostingRegressor; улучшение допускается по результатам временной валидации | | Оптимизация | Детерминированный ограниченный перебор; жёсткий фильтр перед ранжированием | @@ -45,7 +46,7 @@ flowchart TD Store --> State[Состояние на момент t] Store --> Train[Офлайн обучение и оценка] Train --> Model[Модель и metadata] - UI[Streamlit или CLI] --> Orch[Оркестратор] + UI[Tkinter UI или CLI] --> Orch[Оркестратор] State --> Orch Model --> Quality[Агент качества] Orch --> Quality @@ -69,7 +70,7 @@ flowchart TD - `config.py` зависит только от контрактов и стандартного чтения TOML/JSON. - `data/*` и `ml/features.py` не импортируют UI, оркестратор или оптимизатор. - `ml/train.py` и `ml/evaluate.py` используются только офлайн; при открытии UI они не импортируются. -- `agents/*` используют контракты, признаки и численные модели, но не Streamlit и не журнал. +- `agents/*` используют контракты, признаки и численные модели, но не UI и не журнал. - `constraints.py` — единственная реализация жёстких проверок; его вызывают оптимизатор и оркестратор. - `orchestrator.py` координирует расчёт, не содержит формул обучения и смешения. - `main.py` и `ui.py` собирают зависимости явно и вызывают общий `run_cycle`; DI-контейнер не нужен. @@ -98,7 +99,7 @@ HackathonPetrolCode/ ├── source/ │ ├── __init__.py │ ├── main.py # CLI и сборка зависимостей -│ ├── ui.py # Streamlit; представление результатов +│ ├── ui.py # Tkinter; model-demo и диагностические команды │ ├── contracts.py # Pydantic-типы обмена │ ├── config.py # чтение и валидация настроек │ ├── constraints.py # единый фильтр допустимости @@ -430,9 +431,10 @@ sequenceDiagram Партия — 100 т; горизонт — 60 минут. Выпуск модели — масса партии за горизонт, одинаковая для всех рецептур. Критерий риска — взвешенный индекс компонентов. Генерируются доли B от 0 до 1 с шагом 0.05; доля A равна `1-B`. Проверки доступности и качества отбрасывают неподходящие варианты. Добавляется отдельный `hold`, дубликаты рецептуры схлопываются в пользу `hold`. -- `blend_normal`: исходная рецептура A=0.9, B=0.1. Сера 8.4; верхняя граница 9.4 мг/кг. Результат — `hold`. -- `blend_risk`: A=0.7, B=0.3. Сера 13.2; верхняя граница 14.2 мг/кг. Результат — A=0.9, B=0.1: это наиболее дешёвая допустимая рецептура на данной сетке. -- `blend_missing`: исходная рецептура как у риска, но сера/граница A отсутствуют. Все потенциально пригодные рецептуры требуют A; результат — `abstain` с объяснением отсутствующих данных. +- Все demo-рецептуры рассчитывают серу и запас компонентов, но не имеют оценки T95 и + цетанового числа. Поэтому центральный цикл фиксирует кандидатов и верхнюю оценку серы, + но возвращает `abstain` без операторской рекомендации до появления обеих обязательных + оценок. Для уставок после их подтверждения — до трёх параметров, до пяти значений каждого вокруг текущего режима, не больше 125 комбинаций плюс `hold`. При превышении бюджета запуск останавливается с понятной ошибкой, не обрезает пространство скрыто. Реальные шаги и границы фиксируются после анализа источников, без выдуманных температур и давлений в этом документе. @@ -446,7 +448,9 @@ sequenceDiagram | --- | --- | | `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` и сводка причин отбраковки | @@ -489,7 +493,14 @@ Metadata обязательно содержит: `model_id`, `schema_version`, ## 12. Интерфейс и команды запуска -### Экран Streamlit +### Текущий desktop UI и целевой экран + +Сейчас `source/ui.py` — локальное Tkinter-приложение. Оно запускает только +`model_demo`, отображает checks и журнал, а также вызывает `prepare` и `build-state` +как диагностические операции. Оно не загружает `ModelBundle` и не выполняет historical +forecast. + +Целевой экран должен поддерживать: 1. Выбор режима, сценария, времени истории и совместимой модели; кнопка «Рассчитать». 2. Состояние: измерения, единицы, источник и возраст; отдельные блоки АВТ, гидроочистки и смеси. @@ -497,13 +508,17 @@ Metadata обязательно содержит: `model_id`, `schema_version`, 4. Итог: статус, текущее → рекомендуемое значение, ожидаемый эффект, проверенные ограничения. 5. Альтернативы и полный след взаимодействия агентов; возможность скачать результат. -Streamlit rerun сам по себе не запускает новый цикл и не создаёт новые журналы. Расчёт вызывается кнопкой; последний результат хранится в session state. Сохранённые модели и подготовленные данные кэшируются по идентификаторам/хешам. UI не меняет значения, выбранные оптимизатором, и не округляет числа перед проверками. +Расчёт запускается только явным действием пользователя; UI не меняет значения, +выбранные оптимизатором, и не округляет числа перед проверками. При будущей загрузке +моделей prepared data и artifacts кэшируются только по идентификаторам и хешам. Модельный режим и неполнота проверенной спецификации всегда видны рядом с рекомендацией. Экран ошибки выполнения отличается от технологического отказа. -### Целевые команды после реализации +### Реализованные и целевые команды -Команды выполняются из корня репозитория после активации Python 3.11 окружения. Они ещё не реализованы на момент принятия дизайна. +Команды выполняются из корня репозитория после активации совместимого окружения. +Сейчас реализованы `validate-stage0`, `run-model-demo`, `prepare`, `build-state` и +`python -m source.ui`; `train`, `evaluate` и `replay` ниже остаются целевыми CLI. ```bash python -m pip install -r requirements.txt @@ -513,7 +528,7 @@ python -m source.main train --dataset data/processed/ --config confi python -m source.main evaluate --dataset data/processed/ --model artifacts/models/ --split test 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 --scenario config/scenarios/blend_risk.json -python -m streamlit run source/ui.py +python -m source.ui python -m pytest python -m ruff check . python -m ruff format --check . diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 7be3361..63a91fe 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -6,7 +6,10 @@ Основа — [ТЗ из репозитория](materials/ТЗ_нефтекод.docx), [технологические схемы](materials/АВТ_схемы.pdf) и [исходные данные](materials/README.md). Архитектура, контракты и порядок совместной работы зафиксированы в [техническом дизайне](DESIGN.md). Блендинг включаем как **явно обозначенный модельный сценарий**, поскольку отдельных исторических рецептур в пакете не найдено. -При составлении плана реализация практически отсутствует: точка входа содержит комментарий, единственный тест — заглушка. Есть зависимости, CI и исходные материалы. +Этот раздел фиксирует исходную точку плана. Актуальный статус: реализованы Stages 0–5, +включая preparation/state pipeline, safety guardrails, Stage-5 uncertainty layer и +локальный Tkinter UI для model-demo. Интеграция исторического артефакта в UI и action +model остаются следующими задачами. В данных: @@ -29,7 +32,7 @@ | Ограничения | Исполнение единой проверки, запрет недопустимого результата | Обоснование границ, формулы риска, критерии допустимости | | Оптимизация | Подключение к циклу решения, обработка ошибок | Генерация кандидатов, прогноз последствий, ранжирование | | Мультиагентность | Оркестратор, последовательность вызовов, журнал | Агент качества, агент надёжности, агент оптимизации | -| Демонстрация | Streamlit, воспроизведение истории, отображение объяснений | Выбор эпизодов, метрики, проверка содержательных выводов | +| Демонстрация | Desktop UI на Tkinter, воспроизведение model-demo, отображение объяснений | Выбор эпизодов, метрики, проверка содержательных выводов | | Приёмка | Интеграционные тесты и воспроизводимый запуск | Проверка утечек, качества прогнозов и корректности экспериментов | Каждый отвечает за тесты своей части. **ML задаёт формулы и ограничения; backend реализует их единое применение**, для одинаковой логики оптимизатора и интерфейса. @@ -50,7 +53,7 @@ → проверка ограничений → выбор → объяснение и журнал ``` -Используем имеющиеся pandas, NumPy, scikit-learn, Pydantic, Streamlit и pytest. Добавляем `openpyxl` для чтения Excel и `tzdata` для единообразной работы часовых поясов. HTTP API, отдельные сервисы и база данных для первой версии не нужны. +Используем имеющиеся pandas, NumPy, scikit-learn, Pydantic, Tkinter и pytest. Добавляем `openpyxl` для чтения Excel и `tzdata` для единообразной работы часовых поясов. HTTP API, отдельные сервисы и база данных для первой версии не нужны. Объяснение формируется шаблоном из результатов расчёта. LLM в обязательную часть не входит. @@ -95,7 +98,7 @@ ML предоставляет `predict_quality`, `assess_reliability` и `evalua - Реализовать воспроизведение состояния на выбранный момент истории. - Подключить четыре логические роли: качество, надёжность, оптимизация, оркестратор. -- Добавить минимальный Streamlit: состояние, свежесть данных, результаты агентов, итог. +- Добавить минимальный desktop UI: состояние, свежесть данных, результаты агентов, итог. - Реализовать статусы рекомендации, сохранения режима и отказа. **ML:** diff --git a/PROJECT_GUIDE.md b/PROJECT_GUIDE.md index 5ee9591..a886049 100644 --- a/PROJECT_GUIDE.md +++ b/PROJECT_GUIDE.md @@ -111,10 +111,10 @@ ЛИМС считается контрольным фактом, если он расходится с ПАК. Важный момент: лабораторный анализ не становится доступным мгновенно. В текущей -конфигурации мы считаем, что ЛИМС доступен через 6 часов после отбора: +конфигурации мы считаем, что ЛИМС доступен через 4 часа после отбора: ```toml -lims_delay_hours = 6.0 +lims_delay_hours = 4.0 ``` ### `materials/Выгрузка ПАК ...xlsx` @@ -180,7 +180,7 @@ ML - признаки, модели, формулы, численная о - единое применение ограничений; - журналирование; - CLI; -- будущий Streamlit UI; +- desktop UI на Tkinter для model-demo; - интеграцию ML-артефактов; - тесты своей части. @@ -215,8 +215,9 @@ production-загрузчик данных в ноутбуке. ## 6. Что уже реализовано сейчас -Проект прошел stage 0, имеет backend-каркас stage 1, backend-срез stage 2 для -подготовки оригинальных данных и guardrails stage 3 для выбора безопасного результата. +Проект прошел Stages 0–5: есть preparation/state pipeline, model-demo цикл, +guardrails, model blending, Stage-5 uncertainty/applicability layer и desktop UI. +UI пока запускает только model-demo и не загружает исторический ML-артефакт. ### Stage 0: база данных и контрактов @@ -873,8 +874,8 @@ ML: Статус сейчас: ```text -backend-каркас реализован для model_demo -Streamlit UI еще нет +backend-цикл реализован для model_demo +есть desktop UI на Tkinter; он не заменяет интеграцию historical ML artifact ``` ### Этап 2 - прогноз серы @@ -1153,7 +1154,7 @@ B = 0.1 Результат: ```text -hold +abstain (T95 и цетановое число не оценены) ``` ### `blend_risk` @@ -1171,12 +1172,13 @@ B = 0.3 14.2 мг/кг ``` -Это выше 10, значит `hold` недопустим. +Это выше 10, значит `hold` недопустим. Но операторская рекомендация всё равно +блокируется: T95 и цетановое число не оценены. Результат: ```text -recommend A=0.9, B=0.1 +abstain (диагностический кандидат A=0.9, B=0.1 не является рекомендацией) ``` ### `blend_missing` @@ -1279,9 +1281,13 @@ abstain ## 23. Ближайшие backend-задачи после текущего состояния -### Минимальный Streamlit UI +### Интеграция UI с historical ML + +Текущий Tkinter UI уже показывает model-demo, checks, журнал, подготовку данных и +сбор historical state. Следующая задача — не новый экран, а безопасно подключить к нему +совместимый `ModelBundle` и historical `run_cycle`, сохранив fail-closed поведение. -Нужно сделать экран, где видны: +В этом режиме должны быть видны: - выбор demo-сценария; - кнопка запуска; diff --git a/README.md b/README.md index 71c4bea..79b25c3 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ ## Текущее состояние проекта -Реализация находится на начальном backend-этапе. Структура и команды в дизайн-документе -описывают целевую систему. +Реализация дошла до Stage 5 и содержит desktop UI. Часть команд и возможностей в +дизайн-документе по-прежнему целевые; актуальный исполняемый контракт описан ниже. Сейчас реализованы: @@ -35,9 +35,9 @@ - stage 5: empirical upper estimate для прогноза серы, applicability/OOD gate, robustness reporting и materiality/cooldown policy helpers без включения action model. -Полноценной ML-модели, промышленного управления реальными уставками и Streamlit UI пока -нет. Текущий backend уже умеет готовить данные и запускать безопасный demo-каркас с -guardrails, но не выдаёт промышленную рекомендацию на реальной истории. +Полноценной промышленной ML-модели и управления реальными уставками пока нет. Доступен +локальный desktop UI на Python: он запускает существующие model-demo сценарии, показывает +проверки и журнал, но не выдаёт промышленную рекомендацию на реальной истории. Stage 4 показывает связанную цепочку и модельный блендинг. Газовые теги видны как технологический контекст, но не становятся action controls: нет подтверждённых единиц, @@ -51,7 +51,9 @@ backend по-прежнему не рекомендует реальные setpo ## Проверка -Нужны Python 3.11, Git LFS и `tar` с поддержкой RAR. +Нужны совместимое с проектом Python-окружение, Git LFS и `tar` с поддержкой RAR. +Локальный артефакт Stage 5 был собран в Python 3.12.3 со scikit-learn 1.9.0; перед +воспроизведением или переобучением нужно сверять версии из metadata артефакта. ```bash python -m venv .venv @@ -64,7 +66,7 @@ python -m ruff format --check . python -m mypy source ``` -Ожидаемый результат `validate-stage0`: четыре сценария, три model-demo fixture и полный +Ожидаемый результат `validate-stage0`: пять сценариев, три model-demo fixture и полный словарь известных входных тегов. Неизвестные единицы и управляющие параметры помечены `ambiguous`, все реальные управляющие воздействия отключены. @@ -78,15 +80,40 @@ python -m source.main run-model-demo blend_missing Ожидаемые статусы: -- `blend_normal` -> `hold`; -- `blend_risk` -> `recommend`; -- `blend_missing` -> `abstain`. +- все три demo-сценария -> `abstain`: T95 и цетановое число обязательны для + операторского решения, но пока не имеют измерительной или validated-model оценки; + серная часть и кандидаты сохраняются в журнале как диагностический расчёт. Stage 3 не меняет публичные demo-команды. Он делает внутренний выбор строже: infeasible-кандидаты не ранжируются, причины отказа сохраняются в журнале, selected повторно проверяется через `check_constraints()`, а cooldown не скрывает нарушение качества. +## Desktop UI + +Запустить приложение: + +```bash +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_missing +``` + +Интерфейс сохраняет текущие возможности системы: модельный расчёт рецептуры, доступные +ограничения, экспорт результата, журнал запусков, проверку конфигурации, подготовку данных +и сборку historical state. T95 и цетановое число показываются как неподтверждённые свойства, +поэтому UI не выдаёт операторское действие по неполному паспорту. Для проверки без дисплея: + +```bash +python -m source.ui --smoke --scenario blend_risk +``` + ## Stage 2: подготовка реальных данных Подготовить оригинальные материалы в локальный производный датасет: @@ -113,7 +140,7 @@ python -m source.main build-state --dataset data/processed/ --scenar Канонический исходник телеметрии — `materials/data.rar` в Git LFS. Распакованные CSV, подготовленные наборы, модели, отчёты и журналы в Git не добавляются. Внутри приложения время хранится в UTC; наивные даты источников пока интерпретируются как `Europe/Moscow`, -а ЛИМС считается доступным через 6 часов. Оба значения — проверяемые допущения из +а ЛИМС считается доступным через 4 часа. Оба значения — проверяемые допущения из `config/runtime.toml`, не свойства установки. Полный контракт и границы утверждений описаны в [DESIGN.md](DESIGN.md). diff --git a/STAGE1.md b/STAGE1.md index f0fc6fd..75e7763 100644 --- a/STAGE1.md +++ b/STAGE1.md @@ -1,5 +1,9 @@ # Stage 1: первый сквозной backend-цикл +> Актуальный статус: это историческое описание основы цикла. После Stages 4–5 все +> model-demo сценарии возвращают `abstain`, потому что обязательные `T95` и цетановое +> число остаются `not_assessed`; это fail-closed поведение, а не поломка цикла. + Этот этап нужен, чтобы превратить подготовленные данные и DTO из stage 0 в проверяемый цикл принятия решения. ML-модель ещё не обязательна: backend использует только явные значения из model-demo сценариев и сохраняет одинаковый @@ -67,8 +71,8 @@ python -m pytest Ожидаемые статусы: -- `blend_normal` -> `hold`; -- `blend_risk` -> `recommend`; +- `blend_normal` -> `abstain`; +- `blend_risk` -> `abstain`; - `blend_missing` -> `abstain`. ## Ограничения этапа diff --git a/STAGE2.md b/STAGE2.md index 3f835cf..46a044c 100644 --- a/STAGE2.md +++ b/STAGE2.md @@ -1,5 +1,9 @@ # Stage 2: Real Data Preparation +> Актуальный статус: pipeline `prepare`/`build-state` остаётся рабочей границей +> данных. После Stage 5 он может снабжать history-расчёт признаками, но public CLI +> всё ещё не обучает и не запускает historical ML-forecast. + Stage 2 делает первый практический мост от оригинальных материалов к backend-коду. До этого проект в основном доказывал, что контракты, fixtures и базовые загрузчики @@ -71,7 +75,7 @@ Stage 2 не обучает ML-модель и не делает прогноз - промышленной рекомендации оператору; - подбора управляющих воздействий; -- UI; +- historical ML-forecast в UI; - оценки экономического эффекта; - автоматического удаления или изменения оригинальных `materials/`. diff --git a/STAGE3.md b/STAGE3.md index 13d15c6..dc00164 100644 --- a/STAGE3.md +++ b/STAGE3.md @@ -1,5 +1,9 @@ # Stage 3: Constraints, Controls, Rejection Reasons +> Актуальный статус: guardrails используются и после Stage 5. Исторические ожидаемые +> `hold`/`recommend` ниже заменены текущим fail-closed результатом `abstain`, потому +> что model-demo не подтверждает полный товарный паспорт (`T95`, цетановое число). + Stage 3 состоит из двух связанных частей: 1. backend guardrails для выбора `hold`, `recommend` или `abstain`; @@ -180,8 +184,8 @@ python -m pytest Ожидаемые demo-статусы: -- `blend_normal` -> `hold`; -- `blend_risk` -> `recommend`; +- `blend_normal` -> `abstain`; +- `blend_risk` -> `abstain`; - `blend_missing` -> `abstain`. ML/control checks, если соответствующие файлы есть в ветке: diff --git a/STAGE4.md b/STAGE4.md index 03c7794..ef0c4fe 100644 --- a/STAGE4.md +++ b/STAGE4.md @@ -1,5 +1,9 @@ # Stage 4: Hybrid Chain, Blending, Gas Context +> Актуальный статус: blending и hybrid chain остаются модельным контуром. В +> пользовательском UI доступен model-demo; historical ML forecast и реальные actions +> в UI пока не подключены. + ## Результат Stage 4 демонстрирует связанную цепочку: diff --git a/STAGE5.md b/STAGE5.md index 0d5b31d..0e690b7 100644 --- a/STAGE5.md +++ b/STAGE5.md @@ -1,5 +1,9 @@ # Stage 5: uncertainty, applicability, policy guardrails +> Актуальный статус: Stage-5 helpers и artifact contract реализованы и покрыты +> тестами. Локальный Tkinter UI пока вызывает только `run_model_demo`, поэтому не +> загружает Stage-5 artifact и не демонстрирует historical forecast. + ## Зачем нужен этап До Stage 5 backend мог работать с точечным прогнозом серы: модель говорит "ожидаю 9.2 mg/kg", а дальше constraints сравнивают это с лимитом. Проблема в том, что точечный прогноз сам по себе слишком смелый: он не говорит, насколько прогноз надежен, похож ли текущий режим на обучающие данные и можно ли вообще использовать модель сейчас. @@ -11,7 +15,8 @@ Stage 5 добавляет честный слой осторожности: - robustness-отчет по стрессовым случаям; - materiality/cooldown policy для решения, стоит ли менять рекомендацию. -Это всё еще backend-only. Этап не включает action model, не разрешает управление газом и не делает промышленную оптимизацию уставок. +Это ML/backend safety layer. Этап не включает action model, не разрешает управление +газом и не делает промышленную оптимизацию уставок. ## Что реализовано @@ -125,6 +130,6 @@ python -m source.main run-model-demo blend_missing Ожидаемо: -- `blend_normal` -> `hold`; -- `blend_risk` -> `recommend`; +- `blend_normal` -> `abstain`; +- `blend_risk` -> `abstain`; - `blend_missing` -> `abstain`. diff --git a/global_tests/test_stage1_cycle.py b/global_tests/test_stage1_cycle.py index f91faac..d8b5896 100644 --- a/global_tests/test_stage1_cycle.py +++ b/global_tests/test_stage1_cycle.py @@ -45,34 +45,30 @@ def _metric(evaluation, name: str): raise AssertionError(f"metric {name} not found") -def test_stage1_holds_when_current_blend_is_feasible( +def test_stage1_refuses_incomplete_blend_even_when_sulfur_is_feasible( runtime_config, tmp_path: Path, ) -> None: - """Normal model-demo scenario should preserve the current recipe.""" + """Missing T95/cetane prevents an operator-facing hold decision.""" 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 result.selected.candidate.blend_mass_fractions == {} - assert _metric(result.selected, "sulfur").upper == pytest.approx(9.4) + assert result.status is RecommendationStatus.ABSTAIN + assert result.selected is None + assert "UNASSESSED_REQUIRED_PROPERTY" in result.reason_codes -def test_stage1_recommends_feasible_blend_when_current_violates_sulfur( +def test_stage1_refuses_risk_blend_without_full_product_spec( runtime_config, tmp_path: Path, ) -> None: - """Risk model-demo scenario should select the cheapest feasible recipe on the grid.""" + """Sulfur remediation is diagnostic only until T95 and cetane are assessed.""" result = _run_demo(runtime_config, "blend_risk", tmp_path) - assert result.status is RecommendationStatus.RECOMMEND + assert result.status is RecommendationStatus.ABSTAIN 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 _metric(result.selected, "sulfur").upper == pytest.approx(9.4) - assert "QUALITY_LIMIT" in result.reason_codes + assert result.selected is None + assert "UNASSESSED_REQUIRED_PROPERTY" in result.reason_codes def test_stage1_abstains_when_required_component_quality_is_missing( @@ -102,4 +98,4 @@ def test_stage1_writes_journal_files( assert (run_path / "result.json").is_file() saved = json.loads((run_path / "result.json").read_text(encoding="utf-8")) assert saved["run_id"] == result.run_id - assert saved["status"] == "recommend" + assert saved["status"] == "abstain" diff --git a/global_tests/test_stage1_ml.py b/global_tests/test_stage1_ml.py index 8b1ba32..08a1413 100644 --- a/global_tests/test_stage1_ml.py +++ b/global_tests/test_stage1_ml.py @@ -93,7 +93,8 @@ def test_model_demo_quality_is_calculated_from_components( assessment = predict_quality(state, pd.DataFrame(), None, scenario) - assert assessment.status.value == "ok" + assert assessment.status.value == "unavailable" + assert "UNASSESSED_REQUIRED_PROPERTY" in {issue.code for issue in assessment.issues} assert assessment.metrics["sulfur"].value == pytest.approx(value) assert assessment.metrics["sulfur"].upper == pytest.approx(upper) assert assessment.metrics["sulfur"].basis.value == "formula" @@ -107,7 +108,9 @@ def test_missing_component_quality_is_unavailable_not_zero() -> None: assert assessment.status.value == "unavailable" assert assessment.metrics["sulfur"].value is None assert assessment.metrics["sulfur"].upper is None - assert {issue.code for issue in assessment.issues} == {"MISSING_REQUIRED_SIGNAL"} + assert {"MISSING_REQUIRED_SIGNAL", "UNASSESSED_REQUIRED_PROPERTY"}.issubset( + {issue.code for issue in assessment.issues} + ) def test_stage1_refuses_to_emulate_hybrid_mode() -> None: diff --git a/global_tests/test_stage3_guardrails.py b/global_tests/test_stage3_guardrails.py index e7fb0d9..eb67573 100644 --- a/global_tests/test_stage3_guardrails.py +++ b/global_tests/test_stage3_guardrails.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 AssessmentStatus, DecisionContext, RecommendationStatus from source.orchestrator import run_cycle AS_OF = datetime(2026, 1, 15, 9, tzinfo=UTC) @@ -47,28 +47,27 @@ def _baseline_feasible_cost_scenario(cost_threshold: float): def test_stage3_keeps_hold_when_improvement_is_not_material(runtime_config, tmp_path: Path) -> None: - """A feasible baseline should not change for a tiny economic proxy improvement.""" + """An incomplete blend must abstain before materiality can create an action.""" 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 result.reason_codes == ("NO_MATERIAL_IMPROVEMENT",) + assert result.status is RecommendationStatus.ABSTAIN + assert result.selected is None + assert "UNASSESSED_REQUIRED_PROPERTY" in result.reason_codes def test_stage3_cooldown_suppresses_repeat_when_baseline_is_feasible( runtime_config, tmp_path: Path ) -> None: - """Cooldown suppresses repeat actions only while holding is safe.""" + """Cooldown is not evaluated when the product specification is incomplete.""" scenario = _baseline_feasible_cost_scenario(cost_threshold=0.005) context = DecisionContext(last_recommended_at=AS_OF - timedelta(minutes=30)) result = _run(scenario, runtime_config, tmp_path, context) - assert result.status is RecommendationStatus.HOLD - assert result.reason_codes == ("ACTION_COOLDOWN",) + assert result.status is RecommendationStatus.ABSTAIN + assert "UNASSESSED_REQUIRED_PROPERTY" in result.reason_codes def test_stage3_cooldown_does_not_hide_quality_violation(runtime_config, tmp_path: Path) -> None: @@ -78,10 +77,9 @@ def test_stage3_cooldown_does_not_hide_quality_violation(runtime_config, tmp_pat result = _run(scenario, runtime_config, tmp_path, context) - assert result.status is RecommendationStatus.RECOMMEND - assert result.selected is not None - assert result.selected.candidate.kind.value == "blend" - assert "QUALITY_LIMIT" in result.reason_codes + assert result.status is RecommendationStatus.ABSTAIN + assert result.selected is None + assert "UNASSESSED_REQUIRED_PROPERTY" in result.reason_codes def test_stage3_journal_records_rejection_summary(runtime_config, tmp_path: Path) -> None: @@ -93,8 +91,8 @@ def test_stage3_journal_records_rejection_summary(runtime_config, tmp_path: Path metadata = json.loads((run_path / "metadata.json").read_text(encoding="utf-8")) trace = json.loads((run_path / "trace.jsonl").read_text(encoding="utf-8")) - assert metadata["selection_reason"] == "baseline_infeasible_recommend" - assert metadata["rejection_summary"]["QUALITY_LIMIT"] >= 1 + assert metadata["selection_reason"] == "no_feasible_candidate" + assert metadata["rejection_summary"]["UNASSESSED_REQUIRED_PROPERTY"] >= 1 assert trace["selection_reason"] == metadata["selection_reason"] assert trace["rejection_summary"] == metadata["rejection_summary"] @@ -104,6 +102,19 @@ def test_stage3_rechecks_selected_candidate_with_same_constraints( ) -> None: """A selected candidate must fail closed if its repeated hard checks drift.""" scenario = load_scenario("config/scenarios/blend_normal.json") + + original = __import__( + "source.agents.optimizer", fromlist=["assess_blend_candidate"] + ).assess_blend_candidate + + def complete_quality(*args): + assessments = original(*args) + return tuple( + item.model_copy(update={"status": AssessmentStatus.OK, "issues": ()}) + for item in assessments + ) + + monkeypatch.setattr("source.agents.optimizer.assess_blend_candidate", complete_quality) monkeypatch.setattr("source.orchestrator.check_constraints", lambda *args: ()) with pytest.raises(ValueError, match="selected constraint recheck mismatch"): diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py new file mode 100644 index 0000000..e844f67 --- /dev/null +++ b/global_tests/test_ui.py @@ -0,0 +1,83 @@ +"""Headless checks for the desktop UI projection and journal helpers.""" + +from __future__ import annotations + +import json +import os +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from source.config import load_runtime_config, load_scenario +from source.contracts import DecisionContext, RecommendationStatus +from source.orchestrator import run_cycle +from source.ui import journal_entries, recommendation_to_view + + +def _view(scenario_id: str, run_dir: Path): + scenario = load_scenario(f"config/scenarios/{scenario_id}.json") + result = run_cycle( + data=None, + as_of=datetime(2026, 1, 15, 9, tzinfo=UTC), + model=None, + scenario=scenario, + config=load_runtime_config("config/runtime.toml"), + context=DecisionContext(), + run_dir=run_dir, + ) + return result, recommendation_to_view(result, scenario) + + +@pytest.mark.parametrize( + ("scenario_id", "expected_status"), + ( + ("blend_normal", RecommendationStatus.ABSTAIN), + ("blend_risk", RecommendationStatus.ABSTAIN), + ("blend_missing", RecommendationStatus.ABSTAIN), + ), +) +def test_ui_projection_preserves_backend_status( + scenario_id: str, + expected_status: RecommendationStatus, + tmp_path: Path, +) -> None: + result, view = _view(scenario_id, tmp_path) + + assert result.status is expected_status + assert view.status == expected_status.value + + +def test_ui_does_not_offer_risk_recipe_or_invent_future_metrics(tmp_path: Path) -> None: + _, view = _view("blend_risk", tmp_path) + + assert view.baseline_upper == pytest.approx(14.2) + assert view.selected_upper is None + assert view.current_fractions == {"A": 0.7, "B": 0.3} + assert view.proposed_fractions == {"A": 0.7, "B": 0.3} + 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()) + + +def test_ui_keeps_missing_sulfur_unavailable(tmp_path: Path) -> None: + _, view = _view("blend_missing", tmp_path) + + assert view.selected_upper is None + assert view.action_title == "Требуется ручная проверка" + + +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" + incomplete = tmp_path / "incomplete" + older.parent.mkdir() + newer.parent.mkdir() + incomplete.mkdir() + older.write_text(json.dumps({"run_id": "older"}), encoding="utf-8") + newer.write_text(json.dumps({"run_id": "newer"}), encoding="utf-8") + os.utime(older, (1_700_000_000, 1_700_000_000)) + os.utime(newer, (1_800_000_000, 1_800_000_000)) + + assert journal_entries(tmp_path) == (newer, older) diff --git a/source/agents/effects.py b/source/agents/effects.py index b0a0876..8774009 100644 --- a/source/agents/effects.py +++ b/source/agents/effects.py @@ -154,9 +154,27 @@ def assess_blend_candidate( sulfur = metrics["sulfur"] quality_issues: tuple[Issue, ...] = () quality_status = AssessmentStatus.OK + quality_issues = ( + Issue( + code="UNASSESSED_REQUIRED_PROPERTY", + severity=Severity.BLOCKING, + signal_id="blend:t95", + detail="T95 is required for a blend decision but has no model or measurement.", + source_ref=f"scenario:{scenario.id}", + ), + 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 or measurement." + ), + source_ref=f"scenario:{scenario.id}", + ), + ) + quality_status = AssessmentStatus.UNAVAILABLE if sulfur.value is None: - quality_status = AssessmentStatus.UNAVAILABLE - quality_issues = ( + quality_issues += ( Issue( code="MISSING_REQUIRED_SIGNAL", severity=Severity.BLOCKING, @@ -166,8 +184,7 @@ def assess_blend_candidate( ), ) elif scenario.require_upper_bound and sulfur.upper is None: - quality_status = AssessmentStatus.DEGRADED - quality_issues = ( + quality_issues += ( Issue( code="UNCERTAINTY_UNAVAILABLE", severity=Severity.BLOCKING, diff --git a/source/constraints.py b/source/constraints.py index 86aecd1..6c2e504 100644 --- a/source/constraints.py +++ b/source/constraints.py @@ -41,6 +41,19 @@ def _quality_check( evidence_ref=constraint.evidence_ref, reason_code="UNCERTAINTY_UNAVAILABLE", ) + if metric.unit != constraint.unit: + return ConstraintResult( + constraint_id=constraint.id, + candidate_id=candidate.id, + status=ConstraintStatus.UNKNOWN, + actual=None, + lower=constraint.lower, + upper=constraint.upper, + unit=metric.unit, + basis=constraint.basis, + evidence_ref=constraint.evidence_ref, + reason_code="UNIT_MISMATCH", + ) actual = metric.upper if constraint.use_upper_estimate else metric.value status = ConstraintStatus.PASS reason = "OK" diff --git a/source/data/ingest.py b/source/data/ingest.py index ef3d63a..5591f47 100644 --- a/source/data/ingest.py +++ b/source/data/ingest.py @@ -110,13 +110,13 @@ def read_telemetry_csv( for column in [name for name in valid.columns if name != "timestamp"]: original = valid[column] numeric = pd.to_numeric(original, errors="coerce") - bad_value = original.notna() & numeric.isna() + bad_value = original.notna() & ~numeric.map(isfinite) for row_index in valid.index[bad_value]: source_ref = f"{path.as_posix()}#row={row_index + 2};column={column}" issues.append( _issue("INVALID_VALUE", "telemetry value is not finite numeric data", source_ref) ) - valid[column] = numeric + valid[column] = numeric.mask(bad_value) valid = _collapse_telemetry_duplicates(valid, path, issues) valid = valid.sort_values("timestamp", kind="stable").reset_index(drop=True) diff --git a/source/data/prepare.py b/source/data/prepare.py index 1abf32f..333de8f 100644 --- a/source/data/prepare.py +++ b/source/data/prepare.py @@ -4,6 +4,7 @@ import hashlib import json +import shutil import subprocess import tempfile from dataclasses import dataclass @@ -283,44 +284,53 @@ def prepare_dataset(materials_dir: Path, config: RuntimeConfig) -> PreparedData: def write_prepared_dataset(data: PreparedData, output_root: Path) -> Path: """Atomically publish the stage-2 prepared files under ``dataset_id``.""" + output_root.mkdir(parents=True, exist_ok=True) target = output_root / data.manifest.dataset_id - target.mkdir(parents=True, exist_ok=True) manifest_path = target / "manifest.json" feature_order_path = target / "feature_order.json" + files = { + "telemetry.csv.gz": data.telemetry, + "quality.csv.gz": data.quality, + "issues.csv.gz": data.issues, + } if manifest_path.exists(): existing = DatasetManifest.model_validate_json(manifest_path.read_text(encoding="utf-8")) if existing.model_dump(exclude={"created_at"}) != data.manifest.model_dump( exclude={"created_at"} ): raise FileExistsError(f"dataset_id collision at {target}") - if not feature_order_path.exists(): - feature_order_path.write_text( - json.dumps(list(data.feature_order), ensure_ascii=False, indent=2), - encoding="utf-8", - newline="\n", - ) + missing = [ + path.name + for path in (feature_order_path, *(target / name for name in files)) + if not path.is_file() + ] + if missing: + raise FileExistsError(f"incomplete prepared dataset at {target}: missing {missing}") return target - files = { - "telemetry.csv.gz": data.telemetry, - "quality.csv.gz": data.quality, - "issues.csv.gz": data.issues, - } - for name, frame in files.items(): - temporary = target / f".{name}.tmp" - frame.to_csv(temporary, index=False, compression="gzip") - temporary.replace(target / name) - temporary_manifest = target / ".manifest.json.tmp" - temporary_manifest.write_text( - data.manifest.model_dump_json(indent=2), encoding="utf-8", newline="\n" - ) - temporary_manifest.replace(manifest_path) - temporary_feature_order = target / ".feature_order.json.tmp" - temporary_feature_order.write_text( - json.dumps(list(data.feature_order), ensure_ascii=False, indent=2), - encoding="utf-8", - newline="\n", - ) - temporary_feature_order.replace(feature_order_path) + + temporary = Path(tempfile.mkdtemp(prefix=f".{target.name}.", dir=output_root)) + try: + for name, frame in files.items(): + frame.to_csv(temporary / name, index=False, compression="gzip") + (temporary / "manifest.json").write_text( + data.manifest.model_dump_json(indent=2), encoding="utf-8", newline="\n" + ) + (temporary / "feature_order.json").write_text( + json.dumps(list(data.feature_order), ensure_ascii=False, indent=2), + encoding="utf-8", + newline="\n", + ) + required = ( + temporary / "manifest.json", + temporary / "feature_order.json", + *(temporary / name for name in files), + ) + if any(not path.is_file() for path in required): + raise OSError("prepared dataset publication is incomplete") + temporary.replace(target) + except Exception: + shutil.rmtree(temporary, ignore_errors=True) + raise return target diff --git a/source/data/state.py b/source/data/state.py index 8738abd..47bdc06 100644 --- a/source/data/state.py +++ b/source/data/state.py @@ -67,15 +67,26 @@ def build_state( candidates = [ _observation(row) for _, row in visible[visible["signal_id"] == signal_id].iterrows() ] - candidates.sort( - key=lambda item: (item.measured_at, -_SOURCE_PRIORITY[item.source]), reverse=True - ) usable = [ item for item in candidates if item.validity is Validity.VALID and item.value is not None ] - selected = usable[0] if usable else None + + def is_fresh(item: Observation) -> bool: + age_seconds = (as_of - item.measured_at).total_seconds() + return age_seconds <= config.freshness_minutes.get(item.source, 0) * 60 + + fresh_candidates = [item for item in usable if is_fresh(item)] + pool = fresh_candidates or usable + selected = ( + min( + pool, + key=lambda item: (_SOURCE_PRIORITY[item.source], -item.measured_at.timestamp()), + ) + if pool + else None + ) issues: list[Issue] = [] if selected is None: issues.append( @@ -88,12 +99,12 @@ def build_state( ) ) age_seconds = None - fresh = False + selected_is_fresh = False else: age_seconds = (as_of - selected.measured_at).total_seconds() limit_seconds = config.freshness_minutes.get(selected.source, 0) * 60 - fresh = age_seconds <= limit_seconds - if not fresh: + selected_is_fresh = age_seconds <= limit_seconds + if not selected_is_fresh: issues.append( Issue( code="STALE_REQUIRED_SIGNAL", @@ -107,7 +118,7 @@ def build_state( selected=selected, alternatives=tuple(item for item in candidates if item != selected), age_seconds=age_seconds, - fresh=fresh, + fresh=selected_is_fresh, issues=tuple(issues), ) state_issues.extend(issues) diff --git a/source/journal.py b/source/journal.py index ebc0e9d..834ac45 100644 --- a/source/journal.py +++ b/source/journal.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Iterable +import pandas as pd from pydantic import BaseModel from source.contracts import ( @@ -44,6 +45,7 @@ def write_run_journal( *, selection_reason: str = "unknown", rejection_summary: dict[str, int] | None = None, + features: pd.DataFrame | None = None, ) -> Path: """Write a compact reproducible record of one successful run.""" rejection_summary = rejection_summary or {} @@ -79,7 +81,13 @@ def write_run_journal( sort_keys=True, ), ) - _atomic_write(target / "features.json", json.dumps({"features": []}, indent=2)) + feature_records: list[dict[str, object]] = [] + if features is not None: + feature_records = json.loads(features.to_json(orient="records", date_format="iso")) + _atomic_write( + target / "features.json", + json.dumps({"features": feature_records}, ensure_ascii=False, indent=2, allow_nan=False), + ) _atomic_write( target / "trace.jsonl", _json_line( diff --git a/source/ml/action_effects.py b/source/ml/action_effects.py index 7709e42..bff8068 100644 --- a/source/ml/action_effects.py +++ b/source/ml/action_effects.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from math import isfinite from source.contracts import CandidateAction, CandidateKind, ControlSpec from source.ml.controls import JointControlDomain @@ -123,6 +124,10 @@ def evaluate_linear_action( reasons.append("CONTROL_LIMITS_UNCONFIRMED") else: change_size += abs(delta) / step + if not all(isfinite(value) for value in (sulfur, risk, throughput, cost, change_size)) or ( + sulfur_upper is not None and not isfinite(sulfur_upper) + ): + reasons.append("NON_FINITE_ACTION_FORECAST") if sulfur_upper is None: reasons.append("UNCERTAINTY_UNAVAILABLE") elif sulfur_upper > sulfur_upper_limit: diff --git a/source/ml/controls.py b/source/ml/controls.py index d57ed1c..3e99443 100644 --- a/source/ml/controls.py +++ b/source/ml/controls.py @@ -205,9 +205,26 @@ def assess_action_capability( else: if evidence.change_episode_count < minimum_episodes: reasons.append("INSUFFICIENT_CHANGE_EPISODES") + if not np.isfinite(evidence.change_episode_count) or evidence.change_episode_count < 0: + reasons.append("ACTION_EPISODE_COUNT_INVALID") + if not all( + np.isfinite(value) + for value in ( + evidence.best_lag_minutes, + evidence.baseline_mae, + evidence.action_model_mae, + ) + ): + reasons.append("ACTION_EFFECT_METRICS_NON_FINITE") + if evidence.baseline_mae < 0 or evidence.action_model_mae < 0: + reasons.append("ACTION_EFFECT_METRICS_INVALID") if not 0 <= evidence.best_lag_minutes <= 180: reasons.append("ACTION_LAG_OUT_OF_RANGE") - if evidence.action_model_mae >= evidence.baseline_mae: + if ( + np.isfinite(evidence.baseline_mae) + and np.isfinite(evidence.action_model_mae) + and evidence.action_model_mae >= evidence.baseline_mae + ): reasons.append("ACTION_MODEL_NO_TEMPORAL_GAIN") return ActionCapabilityReport( supports_actions=not reasons, diff --git a/source/orchestrator.py b/source/orchestrator.py index a4c590a..fe351f4 100644 --- a/source/orchestrator.py +++ b/source/orchestrator.py @@ -9,6 +9,8 @@ from typing import Any from uuid import uuid4 +import pandas as pd + from source.agents.optimizer import evaluate_candidates, generate_candidates from source.constraints import check_constraints from source.contracts import ( @@ -29,6 +31,8 @@ ) 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 def _model_demo_state(as_of: datetime, scenario: ScenarioConfig) -> ProcessState: @@ -115,51 +119,6 @@ def _metric_value( return None -def _criterion_delta( - criterion: str, - baseline: CandidateEvaluation, - candidate: CandidateEvaluation, -) -> float | None: - baseline_value = _metric_value(baseline.assessments, criterion) - candidate_value = _metric_value(candidate.assessments, criterion) - if baseline_value is None or candidate_value is None: - return None - if criterion == "throughput": - return candidate_value - baseline_value - if criterion in {"risk_index", "cost_proxy"}: - return baseline_value - candidate_value - return None - - -def _has_material_improvement( - baseline: CandidateEvaluation, - candidate: CandidateEvaluation, - scenario: ScenarioConfig, -) -> bool: - for criterion in scenario.active_criteria: - delta = _criterion_delta(criterion, baseline, candidate) - if delta is None: - continue - threshold = scenario.materiality_thresholds.get(criterion, 0.0) - if delta > threshold: - return True - if delta < -threshold: - return False - return False - - -def _cooldown_active( - as_of: datetime, - scenario: ScenarioConfig, - context: DecisionContext, -) -> bool: - if context.last_recommended_at is None or scenario.action_cooldown_minutes <= 0: - return False - last = context.last_recommended_at.astimezone(UTC) - elapsed_seconds = (as_of - last).total_seconds() - return elapsed_seconds >= 0 and elapsed_seconds < scenario.action_cooldown_minutes * 60 - - def _select_result( evaluations: tuple[CandidateEvaluation, ...], scenario: ScenarioConfig, @@ -176,23 +135,44 @@ def _select_result( if not feasible: return RecommendationStatus.HOLD, baseline, (), "hold" best = min(feasible, key=_required_rank_key) - if not _has_material_improvement(baseline, best, scenario): + hold_values = { + criterion: _metric_value(baseline.assessments, criterion) + for criterion in scenario.active_criteria + } + candidate_values = { + criterion: _metric_value(best.assessments, criterion) + for criterion in scenario.active_criteria + } + policy = assess_change_policy( + hold_values, + candidate_values, + scenario.active_criteria, + hold_feasible=True, + now=as_of, + last_recommended_at=context.last_recommended_at, + parameters=PolicyParameters( + risk=scenario.materiality_thresholds.get("risk_index", 0.0), + throughput=scenario.materiality_thresholds.get("throughput", 0.0), + cost_proxy=scenario.materiality_thresholds.get("cost_proxy", 0.0), + cooldown_minutes=scenario.action_cooldown_minutes, + ), + ) + if not policy.allow_change: return ( RecommendationStatus.HOLD, baseline, - ("NO_MATERIAL_IMPROVEMENT",), - "no_material_improvement", + (policy.reason_code,), + policy.reason_code.lower(), ) - if _cooldown_active(as_of, scenario, context): - return RecommendationStatus.HOLD, baseline, ("ACTION_COOLDOWN",), "action_cooldown" return RecommendationStatus.RECOMMEND, best, ("MATERIAL_IMPROVEMENT",), "recommend" if not feasible: reasons = _blocking_reason_codes(evaluations) or ("NO_FEASIBLE_CANDIDATE",) return RecommendationStatus.ABSTAIN, None, reasons, "no_feasible_candidate" + reasons = _blocking_reason_codes((baseline,)) or ("BASELINE_INFEASIBLE",) return ( RecommendationStatus.RECOMMEND, min(feasible, key=_required_rank_key), - ("QUALITY_LIMIT",), + reasons, "baseline_infeasible_recommend", ) @@ -226,13 +206,17 @@ def run_cycle( run_dir: Path, ) -> Recommendation: """Run one backend recommendation cycle and persist its journal.""" - _ = model 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) + 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) + features: pd.DataFrame | None = None + if model is not None and 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) + evaluations = evaluate_candidates(state, candidates, 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 @@ -264,7 +248,15 @@ def run_cycle( assumptions=tuple( dict.fromkeys((*scenario.assumptions, "stage-3 deterministic backend cycle")) ), - model_id=None, + model_id=( + ( + getattr(model.metadata, "model_id", None) + if not isinstance(getattr(model, "metadata", None), dict) + else model.metadata.get("model_id") + ) + if model is not None + else None + ), ) write_run_journal( run_dir, @@ -275,6 +267,7 @@ def run_cycle( result, selection_reason=selection_reason, rejection_summary=_rejection_summary(evaluations), + features=features, ) return result diff --git a/source/ui.py b/source/ui.py new file mode 100644 index 0000000..318feaf --- /dev/null +++ b/source/ui.py @@ -0,0 +1,1201 @@ +"""Tkinter desktop interface for the currently implemented recommendation system. + +The UI deliberately exposes model-demo calculations as model scenarios. It +does not imply that real setpoints, T95 or cetane-number models are available. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import threading +import tkinter as tk +from dataclasses import dataclass +from datetime import datetime +from functools import partial +from pathlib import Path +from tkinter import filedialog, messagebox, ttk +from typing import Any, Sequence + +from source.config import load_scenario +from source.contracts import ( + CandidateEvaluation, + ConstraintStatus, + Recommendation, + RecommendationStatus, + ScenarioConfig, +) +from source.main import ( + PROJECT_ROOT, + build_state_command, + prepare_command, + run_model_demo, + validate_stage0, +) + +BG = "#F5F7F7" +SURFACE = "#FFFFFF" +TEXT = "#101B28" +MUTED = "#617082" +HEADER = "#202B31" +TEAL = "#007D78" +TEAL_HOVER = "#006965" +AMBER = "#D88400" +AMBER_BG = "#FFF8E8" +BORDER = "#D8E0E4" +SOFT = "#EDF1F3" +GREEN = "#149B68" +RED = "#B3473C" + +SCENARIO_LABELS = { + "Нормальный режим": "blend_normal", + "Повышенная сера": "blend_risk", + "Недостающие данные": "blend_missing", +} + + +@dataclass(frozen=True) +class ConstraintRow: + name: str + actual: str + limit: str + status: str + tone: str + + +@dataclass(frozen=True) +class DashboardView: + """Small UI projection derived exclusively from recommendation contracts.""" + + status: str + banner_title: str + banner_detail: str + action_title: str + action_detail: str + baseline_value: float | None + baseline_upper: float | None + selected_value: float | None + selected_upper: float | None + current_fractions: dict[str, float] + proposed_fractions: dict[str, float] + constraints: tuple[ConstraintRow, ...] + explanation: str + reasons: tuple[str, ...] + + +def _metric(evaluation: CandidateEvaluation | None, name: str) -> tuple[float | None, float | None]: + if evaluation is None: + return 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 + + +def _limit_text(lower: float | None, upper: float | None, unit: str | None) -> str: + suffix = f" {_display_unit(unit)}" if unit else "" + if lower is not None and upper is not None: + return f"{lower:g}–{upper:g}{suffix}" + if upper is not None: + return f"≤ {upper:g}{suffix}" + if lower is not None: + return f"≥ {lower:g}{suffix}" + return "—" + + +def _display_unit(unit: str | None) -> str: + return {"mg/kg": "мг/кг", "t": "т"}.get(unit or "", unit or "") + + +def _constraint_rows(result: Recommendation) -> tuple[ConstraintRow, ...]: + evaluation = result.selected or result.baseline + rows: list[ConstraintRow] = [] + for check in evaluation.checks if evaluation is not None else (): + if check.constraint_id.startswith("component_stock"): + name = "Запас " + check.constraint_id.rsplit(":", 1)[-1] + elif check.constraint_id == "blend_sulfur": + name = "Сера" + else: + name = check.constraint_id + status = { + ConstraintStatus.PASS: "В пределах", + ConstraintStatus.FAIL: "Нарушено", + ConstraintStatus.UNKNOWN: "Нет оценки", + }[check.status] + tone = { + ConstraintStatus.PASS: "ok", + ConstraintStatus.FAIL: "bad", + ConstraintStatus.UNKNOWN: "unknown", + }[check.status] + actual = ( + "—" if check.actual is None else f"{check.actual:g} {_display_unit(check.unit)}".strip() + ) + rows.append( + ConstraintRow( + name, + actual, + _limit_text(check.lower, check.upper, check.unit), + status, + tone, + ) + ) + rows.extend( + ( + ConstraintRow("T95", "—", "Требуется модель", "Не оценено", "unknown"), + ConstraintRow("Цетановое число", "—", "Требуется модель", "Не оценено", "unknown"), + ) + ) + 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") + current = dict(scenario.current_blend_mass_fractions) + proposed = dict(current) + if result.selected is not None and result.selected.candidate.blend_mass_fractions: + proposed = dict(result.selected.candidate.blend_mass_fractions) + + 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] + component = changed[0] if changed else "смеси" + banner_title = "Доступен модельный вариант" + banner_detail = "Расчёт относится только к синтетическому сценарию блендинга." + action_title = f"Увеличить долю компонента {component}" + action_detail = "Текущая рецептура нарушает ограничение по сере." + elif result.status is RecommendationStatus.HOLD: + banner_title = "Изменение режима не требуется" + banner_detail = "Текущая модельная рецептура проходит доступные проверки." + action_title = "Сохранить текущую рецептуру" + action_detail = "Материального улучшения относительно hold не найдено." + else: + banner_title = "Рекомендация недоступна" + banner_detail = "Обязательные данные или верхняя оценка качества отсутствуют." + action_title = "Требуется ручная проверка" + action_detail = "Система не подставляет неизвестные значения и не выбирает действие." + + return DashboardView( + status=result.status.value, + banner_title=banner_title, + banner_detail=banner_detail, + action_title=action_title, + action_detail=action_detail, + baseline_value=baseline_value, + baseline_upper=baseline_upper, + selected_value=selected_value, + selected_upper=selected_upper, + current_fractions=current, + proposed_fractions=proposed, + constraints=_constraint_rows(result), + explanation=result.explanation, + reasons=result.reason_codes, + ) + + +def journal_entries(run_dir: Path) -> tuple[Path, ...]: + """Return newest journal result files first without trusting partial directories.""" + if not run_dir.exists(): + return () + files = [path for path in run_dir.glob("*/result.json") if path.is_file()] + return tuple(sorted(files, key=lambda path: path.stat().st_mtime, reverse=True)) + + +def _format_value(value: float | None, unit: str = "мг/кг") -> str: + if value is None: + return "—" + rendered = f"{value:.2f}".rstrip("0").rstrip(".").replace(".", ",") + return f"{rendered} {unit}" + + +class PetrolCodeApp(tk.Tk): + """Resizable operator desktop shell backed by the existing Python API.""" + + def __init__( + self, + initial_scenario: str = "blend_risk", + initial_page: str = "overview", + ) -> None: + super().__init__() + self.title("НЕФТЕКОД — поддержка технологических решений") + self.geometry("1536x1024") + self.minsize(1120, 760) + self.configure(bg=BG) + self.option_add("*Font", "{Segoe UI} 11") + self.scenario_var = tk.StringVar(value=self._scenario_label(initial_scenario)) + self.horizon_var = tk.StringVar(value="60 мин") + self.status_var = tk.StringVar(value="Готово к расчёту") + self._result: Recommendation | None = None + self._scenario: ScenarioConfig | None = None + self._calculation_request_id = 0 + self._page = initial_page + self._nav_buttons: dict[str, tk.Button] = {} + self._build_styles() + self._build_shell() + self.calculate() + + @staticmethod + def _scenario_label(scenario_id: str) -> str: + return next( + (label for label, value in SCENARIO_LABELS.items() if value == scenario_id), + "Повышенная сера", + ) + + def _build_styles(self) -> None: + style = ttk.Style(self) + style.theme_use("clam") + style.configure( + "Petrol.Treeview", + background=SURFACE, + fieldbackground=SURFACE, + foreground=TEXT, + rowheight=38, + borderwidth=0, + font=("Segoe UI", 11), + ) + style.configure( + "Petrol.Treeview.Heading", + background=SOFT, + foreground="#38485A", + relief="flat", + font=("Segoe UI", 10, "bold"), + ) + style.map("Petrol.Treeview", background=[("selected", "#D8EFED")]) + style.configure( + "Petrol.TCombobox", + fieldbackground=SURFACE, + background=SURFACE, + foreground=TEXT, + bordercolor=BORDER, + lightcolor=BORDER, + darkcolor=BORDER, + padding=9, + ) + + def _build_shell(self) -> None: + self.header = tk.Frame(self, bg=HEADER, height=60) + self.header.pack(fill="x") + self.header.pack_propagate(False) + tk.Label( + self.header, + text="НЕФТЕКОД", + bg=HEADER, + fg="white", + font=("Segoe UI", 20, "bold"), + ).pack(side="left", padx=(32, 28)) + tk.Frame(self.header, bg="#65727A", width=1, height=26).pack(side="left", padx=(0, 14)) + for key, label in ( + ("overview", "Обзор"), + ("recommendation", "Рекомендации"), + ("journal", "Журнал"), + ): + button = tk.Button( + self.header, + text=label, + command=partial(self.show_page, key), + bg=HEADER, + fg="#C0C8CD", + activebackground=HEADER, + activeforeground="white", + bd=0, + padx=18, + font=("Segoe UI", 11, "bold"), + cursor="hand2", + ) + button.pack(side="left", fill="y") + self._nav_buttons[key] = button + tk.Label( + self.header, + text="Модельный контур • реальные уставки отключены", + bg=HEADER, + fg="#B8C3C9", + font=("Segoe UI", 10), + ).pack(side="right", padx=32) + self.body = tk.Frame(self, bg=BG) + self.body.pack(fill="both", expand=True) + self.show_page(self._page) + + def _clear_body(self) -> None: + for child in self.body.winfo_children(): + child.destroy() + + def _set_nav(self, page: str) -> None: + for key, button in self._nav_buttons.items(): + button.configure(fg="white" if key == page else "#C0C8CD") + + def show_page(self, page: str) -> None: + self._page = page + self._set_nav(page) + self._clear_body() + if page == "overview": + self._render_overview() + elif page == "recommendation": + self._render_recommendation() + else: + self._render_journal() + + def _page_container(self) -> tk.Frame: + canvas = tk.Canvas(self.body, bg=BG, bd=0, highlightthickness=0) + scrollbar = ttk.Scrollbar(self.body, orient="vertical", command=canvas.yview) + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + frame = tk.Frame(canvas, bg=BG) + window_id = canvas.create_window(0, 0, anchor="nw", window=frame) + + def resize_page(event: tk.Event[tk.Misc]) -> None: + canvas.itemconfigure(window_id, width=event.width) + + def update_scroll_region(_: tk.Event[tk.Misc]) -> None: + canvas.configure(scrollregion=canvas.bbox("all")) + + def scroll_page(event: tk.Event[tk.Misc]) -> None: + canvas.yview_scroll(int(-event.delta / 120), "units") + + canvas.bind("", resize_page) + self.bind_all("", scroll_page) + frame.bind("", update_scroll_region) + frame.configure(padx=32, pady=24) + return frame + + @staticmethod + def _surface(parent: tk.Misc, **grid: Any) -> tk.Frame: + frame = tk.Frame(parent, bg=SURFACE, highlightbackground=BORDER, highlightthickness=1) + if grid: + frame.grid(**grid) + return frame + + @staticmethod + def _primary_button(parent: tk.Misc, text: str, command: Any) -> tk.Button: + return tk.Button( + parent, + text=text, + command=command, + bg=TEAL, + fg="white", + activebackground=TEAL_HOVER, + activeforeground="white", + bd=0, + padx=24, + pady=11, + font=("Segoe UI", 11, "bold"), + cursor="hand2", + ) + + @staticmethod + def _secondary_button(parent: tk.Misc, text: str, command: Any) -> tk.Button: + return tk.Button( + parent, + text=text, + command=command, + bg=SURFACE, + fg=TEXT, + activebackground=SOFT, + activeforeground=TEXT, + highlightbackground=BORDER, + highlightthickness=1, + bd=0, + padx=20, + pady=10, + cursor="hand2", + ) + + def _render_overview(self) -> None: + page = self._page_container() + top = tk.Frame(page, bg=BG) + top.pack(fill="x") + title = tk.Frame(top, bg=BG) + title.pack(side="left") + tk.Label( + title, + text="Состояние установки", + bg=BG, + fg=TEXT, + font=("Segoe UI", 28, "bold"), + ).pack(anchor="w") + tk.Label( + title, + text="Текущий модельный сценарий, расчёт и ограничения", + bg=BG, + fg=MUTED, + font=("Segoe UI", 12), + ).pack(anchor="w") + controls = tk.Frame(top, bg=BG) + controls.pack(side="right", pady=8) + scenario = ttk.Combobox( + controls, + values=tuple(SCENARIO_LABELS), + textvariable=self.scenario_var, + state="readonly", + width=23, + style="Petrol.TCombobox", + ) + scenario.pack(side="left", padx=6) + horizon = ttk.Combobox( + controls, + values=("60 мин",), + textvariable=self.horizon_var, + state="readonly", + width=12, + style="Petrol.TCombobox", + ) + horizon.pack(side="left", padx=6) + button = self._primary_button(controls, "Рассчитать", self.calculate) + button.pack(side="left", padx=(8, 0)) + + view = self._view() + banner = tk.Frame( + page, + bg=AMBER_BG, + highlightbackground="#E9B850", + highlightthickness=1, + ) + banner.pack(fill="x", pady=(22, 16), ipady=12) + tk.Label( + banner, + text="⚠", + bg=AMBER_BG, + fg=AMBER, + font=("Segoe UI Symbol", 23, "bold"), + ).pack(side="left", padx=(20, 14)) + banner_text = tk.Frame(banner, bg=AMBER_BG) + banner_text.pack(side="left") + tk.Label( + banner_text, + text=view.banner_title if view else self.status_var.get(), + bg=AMBER_BG, + fg="#583714", + font=("Segoe UI", 11, "bold"), + ).pack(anchor="w") + tk.Label( + banner_text, + text=view.banner_detail if view else "Расчёт ещё не выполнен.", + bg=AMBER_BG, + fg="#536171", + font=("Segoe UI", 10), + ).pack(anchor="w") + + main = self._surface(page) + main.pack(fill="both", expand=True) + main.grid_columnconfigure(0, weight=4) + main.grid_columnconfigure(1, weight=1, minsize=260) + main.grid_rowconfigure(0, weight=1) + chart_side = tk.Frame(main, bg=SURFACE) + chart_side.grid(row=0, column=0, sticky="nsew") + info = tk.Frame(main, bg=SURFACE, highlightbackground=BORDER, highlightthickness=1) + info.grid(row=0, column=1, sticky="nsew") + self._render_kpis(chart_side, view) + self._render_chart(chart_side, view) + self._render_info(info, view) + + 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 + ) + 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, + bd=0, + pady=8, + font=("Segoe UI", 10, "bold"), + cursor="hand2", + ) + button.pack(side="left", fill="x", expand=True, padx=3) + self._accordion( + page, + "Источники, проверка конфигурации и подготовка данных", + self._data_tools_content, + ).pack(fill="x") + + 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 "—"), + ) + for index, (label, value) in enumerate(values): + cell = tk.Frame(strip, bg=SURFACE) + cell.pack(side="left", fill="x", expand=True, padx=(0, 18)) + tk.Label(cell, text=label, bg=SURFACE, fg=TEXT, font=("Segoe UI", 10, "bold")).pack( + anchor="w" + ) + tk.Label(cell, text=value, bg=SURFACE, fg=TEXT, font=("Segoe UI", 25, "bold")).pack( + anchor="w", pady=(3, 0) + ) + tk.Label( + cell, text="Модельный расчёт", bg=SURFACE, fg=MUTED, font=("Segoe UI", 9) + ).pack(anchor="w") + if index < 2: + tk.Frame(strip, bg=BORDER, width=1, height=72).pack(side="left", padx=(0, 18)) + + def _render_chart(self, parent: tk.Frame, view: DashboardView | None) -> None: + tk.Frame(parent, bg=BORDER, height=1).pack(fill="x", padx=24) + tk.Label( + parent, + text="Сера в смеси, мг/кг", + bg=SURFACE, + fg=TEXT, + font=("Segoe UI", 12, "bold"), + ).pack(anchor="w", padx=24, pady=(16, 2)) + canvas = tk.Canvas(parent, bg=SURFACE, highlightthickness=0, height=345) + canvas.pack(fill="both", expand=True, padx=24, pady=(0, 14)) + + def redraw(_: tk.Event[Any] | None = None) -> None: + self._draw_sulfur_chart(canvas, view) + + canvas.bind("", redraw) + redraw() + + @staticmethod + def _draw_sulfur_chart(canvas: tk.Canvas, view: DashboardView | None) -> None: + canvas.delete("all") + width = max(canvas.winfo_width(), 680) + height = max(canvas.winfo_height(), 300) + left, right, top, bottom = 52, width - 26, 34, height - 48 + maximum = 16.0 + for value in range(0, 17, 2): + y = bottom - (value / maximum) * (bottom - top) + canvas.create_line(left, y, right, y, fill="#E3E8EA") + canvas.create_text(left - 12, y, text=str(value), fill=MUTED, anchor="e") + canvas.create_line(left, bottom, right, bottom, fill="#AEBAC1") + limit_y = bottom - (10.0 / maximum) * (bottom - top) + canvas.create_line(left, limit_y, right, limit_y, fill=AMBER, dash=(7, 5), width=2) + canvas.create_text( + right - 6, + limit_y - 12, + text="Предел сценария · 10", + fill=AMBER, + anchor="e", + font=("Segoe UI", 9, "bold"), + ) + x_current = left + (right - left) * 0.28 + x_selected = left + (right - left) * 0.72 + baseline = view.baseline_upper if view and view.baseline_upper is not None else None + selected = view.selected_upper if view and view.selected_upper is not None else None + if baseline is not None: + y_current = bottom - min(baseline, maximum) / maximum * (bottom - top) + canvas.create_oval( + x_current - 6, y_current - 6, x_current + 6, y_current + 6, fill=TEAL, outline="" + ) + canvas.create_text( + x_current, + y_current - 18, + text=_format_value(baseline), + fill=TEXT, + font=("Segoe UI", 10, "bold"), + ) + if selected is not None: + y_selected = bottom - min(selected, maximum) / maximum * (bottom - top) + canvas.create_line( + x_current, y_current, x_selected, y_selected, fill=TEAL, dash=(7, 5), width=3 + ) + canvas.create_oval( + x_selected - 6, + y_selected - 6, + x_selected + 6, + y_selected + 6, + fill=TEAL, + outline="", + ) + canvas.create_text( + x_selected, + y_selected - 18, + text=_format_value(selected), + fill=TEXT, + font=("Segoe UI", 10, "bold"), + ) + else: + canvas.create_text( + (left + right) / 2, + (top + bottom) / 2, + text="Расчёт серы недоступен", + fill=MUTED, + font=("Segoe UI", 13), + ) + canvas.create_text(x_current, bottom + 24, text="Текущая рецептура", fill=MUTED) + canvas.create_text(x_selected, bottom + 24, text="Выбранный вариант", fill=MUTED) + canvas.create_text( + right, + top - 14, + text="Показана верхняя сценарная оценка, не исторический тренд", + fill=MUTED, + anchor="e", + font=("Segoe UI", 9), + ) + + def _render_info(self, parent: tk.Frame, view: DashboardView | None) -> None: + tk.Label(parent, text="Данные", bg=SURFACE, fg=TEXT, font=("Segoe UI", 15, "bold")).pack( + anchor="w", padx=22, pady=(20, 12) + ) + self._info_row(parent, "Режим", "Модельный") + self._info_row(parent, "Сценарий", SCENARIO_LABELS.get(self.scenario_var.get(), "—")) + self._info_row(parent, "Горизонт", self.horizon_var.get()) + self._info_row(parent, "Последний расчёт", datetime.now().strftime("%H:%M")) + tk.Frame(parent, bg=BORDER, height=1).pack(fill="x", padx=20, pady=18) + tk.Label( + parent, + text="Надёжность: нет промышленной оценки", + bg=SURFACE, + fg=TEXT, + font=("Segoe UI", 10, "bold"), + wraplength=230, + justify="left", + ).pack(anchor="w", padx=22) + tk.Label( + parent, + text=( + "Action model не подтверждён. Реальные управляющие воздействия отключены." + if view + else "Выполните расчёт." + ), + bg=SURFACE, + fg=MUTED, + wraplength=230, + justify="left", + ).pack(anchor="w", padx=22, pady=(8, 0)) + + @staticmethod + def _info_row(parent: tk.Frame, label: str, value: str) -> None: + row = tk.Frame(parent, bg=SURFACE) + row.pack(fill="x", padx=22, pady=6) + tk.Label(row, text=label, bg=SURFACE, fg=MUTED).pack(side="left") + tk.Label(row, text=value, bg=SURFACE, fg=TEXT, font=("Segoe UI", 10, "bold")).pack( + side="right" + ) + + def _render_recommendation(self) -> None: + page = self._page_container() + view = self._view() + heading = tk.Frame(page, bg=BG) + heading.pack(fill="x") + tk.Label( + heading, + text="Рекомендация по смешению", + bg=BG, + fg=TEXT, + font=("Segoe UI", 28, "bold"), + ).pack(side="left") + badge = tk.Label( + heading, + text="⚠ Модельный режим", + bg=AMBER_BG, + fg="#A55E00", + font=("Segoe UI", 10, "bold"), + padx=14, + pady=8, + highlightbackground=AMBER, + highlightthickness=1, + ) + badge.pack(side="left", padx=22) + tk.Label( + page, + text="Результат применим только к модельному сценарию; реальные уставки не меняются", + bg=BG, + fg=MUTED, + font=("Segoe UI", 11), + ).pack(anchor="w", pady=(2, 18)) + + main = self._surface(page) + main.pack(fill="x") + main.grid_columnconfigure(0, weight=2) + main.grid_columnconfigure(1, weight=1) + left = tk.Frame(main, bg=SURFACE) + left.grid(row=0, column=0, sticky="nsew", padx=26, pady=22) + right = tk.Frame(main, bg=SURFACE, highlightbackground=BORDER, highlightthickness=1) + right.grid(row=0, column=1, sticky="nsew", padx=(0, 20), pady=14) + tk.Label( + left, + text="РЕКОМЕНДАЦИЯ", + bg=SURFACE, + fg=MUTED, + font=("Segoe UI", 9, "bold"), + ).pack(anchor="w") + tk.Label( + left, + text=view.action_title if view else "Расчёт не выполнен", + bg=SURFACE, + fg=TEXT, + font=("Segoe UI", 20, "bold"), + ).pack(anchor="w", pady=(8, 2)) + tk.Label( + left, + text=view.action_detail if view else "Вернитесь на экран обзора.", + bg=SURFACE, + fg=MUTED, + font=("Segoe UI", 12), + ).pack(anchor="w", pady=(0, 14)) + self._recipe_table(left, view) + tk.Label( + left, + text="Массовые доли · сумма 100 % · значения синтетические", + bg=SURFACE, + fg=MUTED, + font=("Segoe UI", 9), + ).pack(anchor="w", pady=(8, 0)) + tk.Label( + right, + text="ОЖИДАЕМЫЙ РЕЗУЛЬТАТ", + bg=SURFACE, + fg=MUTED, + font=("Segoe UI", 9, "bold"), + ).pack(anchor="w", padx=20, pady=(18, 0)) + tk.Label( + right, + text=_format_value(view.selected_upper if view else None), + bg=SURFACE, + fg=TEAL if view and view.selected_upper is not None else MUTED, + font=("Segoe UI", 30, "bold"), + ).pack(anchor="w", padx=20, pady=(8, 0)) + tk.Label(right, text="Верхняя оценка серы смеси", bg=SURFACE, fg=MUTED).pack( + anchor="w", padx=20 + ) + 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 мг/кг") + + checks = self._surface(page) + checks.pack(fill="x", pady=(16, 0)) + tk.Label( + checks, + text="Проверка ограничений", + bg=SURFACE, + fg=TEXT, + font=("Segoe UI", 15, "bold"), + ).pack(anchor="w", padx=26, pady=(14, 8)) + self._constraint_table(checks, view.constraints if view else ()) + tk.Label( + checks, + text="Система подтверждает только строки с рассчитанным значением и источником.", + bg=SURFACE, + fg=MUTED, + font=("Segoe UI", 9), + ).pack(anchor="w", padx=26, pady=(7, 12)) + + self._accordion( + page, + "Почему выбран этот вариант", + lambda parent: self._text_content(parent, view.explanation if view else "Нет расчёта"), + ).pack(fill="x", pady=(16, 8)) + self._accordion( + page, + "Альтернативы и ход расчёта", + self._alternatives_content, + ).pack(fill="x") + footer = tk.Frame(page, bg=BG) + footer.pack(fill="x", pady=(18, 0)) + self._primary_button(footer, "Скачать расчёт", self.export_result).pack(side="left") + self._secondary_button(footer, "Открыть журнал", lambda: self.show_page("journal")).pack( + side="left", padx=14 + ) + tk.Label( + footer, + text="НЕФТЕКОД v1.0 | prototype", + bg=BG, + fg=MUTED, + font=("Segoe UI", 9), + ).pack(side="right", pady=12) + + def _recipe_table(self, parent: tk.Frame, view: DashboardView | None) -> None: + table = ttk.Treeview( + parent, + columns=("component", "current", "proposed"), + show="headings", + height=max(2, len(view.current_fractions) if view else 2), + style="Petrol.Treeview", + ) + for key, title, width in ( + ("component", "Компонент", 220), + ("current", "Сейчас", 150), + ("proposed", "Предлагается", 170), + ): + table.heading(key, text=title) + table.column(key, width=width, anchor="w", stretch=True) + if view: + for component in sorted(view.current_fractions): + table.insert( + "", + "end", + values=( + f"Компонент {component}", + f"{view.current_fractions[component] * 100:.0f} %", + f"{view.proposed_fractions.get(component, 0.0) * 100:.0f} %", + ), + ) + table.pack(fill="x") + + @staticmethod + def _constraint_table(parent: tk.Frame, rows: tuple[ConstraintRow, ...]) -> None: + table = ttk.Treeview( + parent, + columns=("name", "actual", "limit", "status"), + show="headings", + height=max(4, len(rows)), + style="Petrol.Treeview", + ) + for key, title, width in ( + ("name", "Показатель", 240), + ("actual", "Расчёт", 210), + ("limit", "Предел сценария", 250), + ("status", "Статус", 210), + ): + 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 rows: + table.insert( + "", "end", values=(row.name, row.actual, row.limit, row.status), tags=(row.tone,) + ) + table.pack(fill="x", padx=26) + + def _render_journal(self) -> None: + page = self._page_container() + tk.Label( + page, + text="Журнал расчётов", + bg=BG, + fg=TEXT, + font=("Segoe UI", 28, "bold"), + ).pack(anchor="w") + tk.Label( + page, + text="Сохранённые backend-результаты; записи не редактируются из интерфейса", + bg=BG, + fg=MUTED, + font=("Segoe UI", 11), + ).pack(anchor="w", pady=(2, 18)) + content = self._surface(page) + content.pack(fill="both", expand=True) + content.grid_columnconfigure(1, weight=1) + content.grid_rowconfigure(0, weight=1) + paths = journal_entries(PROJECT_ROOT / "runs") + listbox = tk.Listbox( + content, + bg=SURFACE, + fg=TEXT, + selectbackground="#D8EFED", + selectforeground=TEXT, + bd=0, + highlightthickness=0, + width=38, + font=("Consolas", 10), + ) + listbox.grid(row=0, column=0, sticky="nsew", padx=(18, 0), pady=18) + detail = tk.Text( + content, + bg="#FAFBFB", + fg=TEXT, + bd=0, + padx=18, + pady=14, + wrap="word", + font=("Consolas", 10), + ) + detail.grid(row=0, column=1, sticky="nsew", padx=18, pady=18) + for path in paths: + listbox.insert("end", path.parent.name) + + def select(_: tk.Event[Any] | None = None) -> None: + selection = listbox.curselection() + if not selection: + return + payload = json.loads(paths[selection[0]].read_text(encoding="utf-8")) + detail.configure(state="normal") + detail.delete("1.0", "end") + detail.insert("1.0", json.dumps(payload, ensure_ascii=False, indent=2)) + detail.configure(state="disabled") + + listbox.bind("<>", select) + if paths: + listbox.selection_set(0) + select() + else: + detail.insert("1.0", "Журнал пуст. Выполните модельный расчёт на экране «Обзор».") + detail.configure(state="disabled") + + def _accordion(self, parent: tk.Misc, title: str, builder: Any) -> tk.Frame: + shell = tk.Frame(parent, bg=SURFACE, highlightbackground=BORDER, highlightthickness=1) + content = tk.Frame(shell, bg=SURFACE) + opened = tk.BooleanVar(value=False) + + def toggle() -> None: + if opened.get(): + content.pack_forget() + opened.set(False) + button.configure(text=f"› {title}") + else: + if not content.winfo_children(): + builder(content) + content.pack(fill="x", padx=24, pady=(0, 16)) + opened.set(True) + button.configure(text=f"⌄ {title}") + + button = tk.Button( + shell, + text=f"› {title}", + command=toggle, + anchor="w", + bg=SURFACE, + fg=TEXT, + activebackground=SOFT, + bd=0, + padx=18, + pady=12, + font=("Segoe UI", 11, "bold"), + cursor="hand2", + ) + button.pack(fill="x") + return shell + + @staticmethod + def _text_content(parent: tk.Frame, text: str) -> None: + tk.Label( + parent, + text=text, + bg=SURFACE, + fg=MUTED, + wraplength=1250, + justify="left", + ).pack(anchor="w") + + def _alternatives_content(self, parent: tk.Frame) -> None: + if self._result is None or not self._result.alternatives: + self._text_content(parent, "Допустимых альтернатив нет.") + return + lines = [] + for alternative in self._result.alternatives: + value, upper = _metric(alternative, "sulfur") + lines.append( + f"{alternative.candidate.id}: point {_format_value(value)}, " + f"upper {_format_value(upper)}" + ) + self._text_content(parent, "\n".join(lines)) + + def _data_tools_content(self, parent: tk.Frame) -> None: + tk.Label( + parent, + text=( + "Доступны те же безопасные операции, что и в CLI. Подготовка полного архива " + "может занять несколько минут." + ), + bg=SURFACE, + fg=MUTED, + wraplength=1100, + justify="left", + ).pack(anchor="w", pady=(0, 10)) + row = tk.Frame(parent, bg=SURFACE) + row.pack(fill="x") + self._secondary_button(row, "Проверить конфигурацию", self.validate_config).pack( + side="left" + ) + self._secondary_button(row, "Подготовить данные", self.prepare_data).pack( + side="left", padx=10 + ) + self._secondary_button(row, "Собрать historical state", self.open_state_dialog).pack( + side="left" + ) + + def _view(self) -> DashboardView | None: + if self._result is None or self._scenario is None: + return None + return recommendation_to_view(self._result, self._scenario) + + def calculate(self) -> None: + self._calculation_request_id += 1 + request_id = self._calculation_request_id + scenario_id = SCENARIO_LABELS[self.scenario_var.get()] + self.status_var.set("Выполняется расчёт…") + + def work() -> None: + try: + scenario = load_scenario(PROJECT_ROOT / f"config/scenarios/{scenario_id}.json") + result = run_model_demo(scenario_id) + except Exception as exc: # UI boundary: render backend failure without crashing Tk. + self.after(0, partial(self._calculation_failed, request_id, exc)) + return + self.after(0, lambda: self._calculation_done(request_id, result, scenario)) + + threading.Thread(target=work, daemon=True).start() + + def _calculation_done( + self, request_id: int, result: Recommendation, scenario: ScenarioConfig + ) -> None: + if request_id != self._calculation_request_id: + return + self._result = result + self._scenario = scenario + self.status_var.set("Расчёт завершён") + self.show_page(self._page) + + def _calculation_failed(self, request_id: int, error: Exception) -> None: + if request_id != self._calculation_request_id: + return + self.status_var.set("Ошибка расчёта") + messagebox.showerror("Ошибка расчёта", str(error), parent=self) + self.show_page(self._page) + + def export_result(self) -> None: + if self._result is None: + messagebox.showinfo("Нет расчёта", "Сначала выполните расчёт.", parent=self) + return + destination = filedialog.asksaveasfilename( + parent=self, + title="Сохранить расчёт", + defaultextension=".json", + filetypes=(("JSON", "*.json"),), + initialfile=f"neftekod-{self._result.scenario_id}-{self._result.run_id[:8]}.json", + ) + if destination: + Path(destination).write_text( + self._result.model_dump_json(indent=2), encoding="utf-8", newline="\n" + ) + + def validate_config(self) -> None: + try: + result = validate_stage0() + except Exception as exc: + messagebox.showerror("Проверка не пройдена", str(exc), parent=self) + return + messagebox.showinfo( + "Конфигурация корректна", + f"Теги: {result['tags']}\nСценарии: {result['scenarios']}\n" + f"Demo fixtures: {result['model_demo_fixtures']}", + parent=self, + ) + + def prepare_data(self) -> None: + if not messagebox.askokcancel( + "Подготовить данные", + "Прочитать полный архив materials и создать локальный prepared dataset?", + parent=self, + ): + return + self.status_var.set("Подготовка данных…") + + def work() -> None: + try: + result = prepare_command("materials", "config/runtime.toml") + except Exception as exc: + self.after(0, partial(self._preparation_failed, exc)) + return + self.after(0, partial(self._preparation_done, result)) + + threading.Thread(target=work, daemon=True).start() + + def _preparation_done(self, result: dict[str, Any]) -> None: + self.status_var.set("Данные подготовлены") + messagebox.showinfo( + "Данные подготовлены", + json.dumps(result, ensure_ascii=False, indent=2), + parent=self, + ) + + def _preparation_failed(self, error: Exception) -> None: + self.status_var.set("Ошибка подготовки данных") + messagebox.showerror("Ошибка подготовки", str(error), parent=self) + + def open_state_dialog(self) -> None: + dialog = tk.Toplevel(self) + dialog.title("Собрать historical state") + dialog.geometry("680x330") + dialog.configure(bg=BG) + dialog.transient(self) + fields = tk.Frame(dialog, bg=BG) + fields.pack(fill="both", expand=True, padx=26, pady=22) + tk.Label(fields, text="Prepared dataset", bg=BG, fg=TEXT).pack(anchor="w") + datasets = sorted((PROJECT_ROOT / "data/processed").glob("*/manifest.json")) + dataset_var = tk.StringVar(value=str(datasets[-1].parent) if datasets else "") + tk.Entry(fields, textvariable=dataset_var, bg=SURFACE, fg=TEXT, bd=1).pack( + fill="x", pady=(4, 14), ipady=7 + ) + tk.Label(fields, text="As of (ISO с timezone)", bg=BG, fg=TEXT).pack(anchor="w") + as_of_var = tk.StringVar(value="2025-01-15T10:00:00+03:00") + tk.Entry(fields, textvariable=as_of_var, bg=SURFACE, fg=TEXT, bd=1).pack( + fill="x", pady=(4, 14), ipady=7 + ) + output = tk.Text(fields, height=5, bg=SURFACE, fg=TEXT, bd=1, wrap="word") + output.pack(fill="both", expand=True) + + def build() -> None: + try: + timestamp = datetime.fromisoformat(as_of_var.get().replace("Z", "+00:00")) + state = build_state_command(dataset_var.get(), "history", timestamp) + text = state.model_dump_json(indent=2) + except Exception as exc: + text = f"Ошибка: {exc}" + output.delete("1.0", "end") + output.insert("1.0", text) + + 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 smoke_snapshot(scenario_id: str) -> dict[str, Any]: + """Headless smoke path used by CI and machines without a display.""" + scenario = load_scenario(PROJECT_ROOT / f"config/scenarios/{scenario_id}.json") + result = run_model_demo(scenario_id) + view = recommendation_to_view(result, scenario) + return { + "scenario_id": scenario_id, + "status": view.status, + "action_title": view.action_title, + "baseline_upper": view.baseline_upper, + "selected_upper": view.selected_upper, + "constraints": [row.__dict__ for row in view.constraints], + "run_id": result.run_id, + } + + +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( + "--page", + choices=("overview", "recommendation", "journal"), + default="overview", + help="initial screen", + ) + parser.add_argument("--smoke", action="store_true", help="run calculation without opening Tk") + args = parser.parse_args(argv) + if args.smoke: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + print(json.dumps(smoke_snapshot(args.scenario), ensure_ascii=False, indent=2)) + return 0 + app = PetrolCodeApp(args.scenario, args.page) + app.mainloop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + +__all__ = [ + "ConstraintRow", + "DashboardView", + "PetrolCodeApp", + "journal_entries", + "main", + "recommendation_to_view", + "smoke_snapshot", +]