From be4063bd07c79e591dacc5e3e2f2bd9bd8d286bb Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Fri, 25 Sep 2026 22:55:18 +0300 Subject: [PATCH 01/11] feat: add assisted blend editor and harden release serving --- DESIGN.md | 6 +- README.md | 6 +- config/release_manifest.json | 10 +- docs/DEMO_SCRIPT.md | 12 +- global_tests/test_blend_assist.py | 115 ++++++ global_tests/test_stage2_data.py | 65 +++- global_tests/test_stage2_features.py | 60 +++ global_tests/test_stage6_acceptance.py | 10 + global_tests/test_stage7_history_serving.py | 108 +++++- global_tests/test_ui.py | 16 +- global_tests/test_ui_interactive.py | 40 +- scripts/build_release.ps1 | 8 +- source/acceptance.py | 6 + source/agents/blend_assist.py | 192 ++++++++++ source/agents/optimizer.py | 25 ++ source/agents/quality.py | 15 +- source/config.py | 20 +- source/contracts.py | 13 +- source/data/prepare.py | 146 +++++-- source/data/state.py | 35 +- source/desktop.py | 10 +- source/journal.py | 30 +- source/main.py | 40 +- source/ml/features.py | 182 ++++++++- source/orchestrator.py | 72 +++- source/ui.py | 184 ++++++--- source/ui_charts.py | 22 +- source/ui_data.py | 26 +- source/ui_what_if.py | 405 ++++++++++++++++++-- 29 files changed, 1694 insertions(+), 185 deletions(-) create mode 100644 global_tests/test_blend_assist.py create mode 100644 source/agents/blend_assist.py diff --git a/DESIGN.md b/DESIGN.md index 7cc234c..4a9b79a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -99,7 +99,6 @@ flowchart TD ```text HackathonPetrolCode/ ├── README.md # запуск и ограничения готовой версии -├── IMPLEMENTATION_PLAN.md # сроки, этапы и критерии готовности ├── DESIGN.md # архитектура и контракты ├── pyproject.toml # Ruff и pytest; заменяет config.toml ├── requirements.txt # проверенные зависимости Python 3.11 @@ -568,12 +567,15 @@ point/upper и reason codes. В отдельных экранах доступн action-shadow и контроль ПАК--ЛИМС; ни один из этих результатов не разрешает изменение реальных уставок. -В v1.1.0 редактор `source/ui_what_if.py` создаёт проверенную копию +Редактор `source/ui_what_if.py` создаёт проверенную копию `ScenarioConfig` только для `model_demo` без реальных controls. Свойства A/B, запасы, доли, масса партии и синтетическая кривая присадки передаются через `run_model_demo(scenario_override=...)` существующему циклу решения. Preset на диске не изменяется; изменённые входы попадают в журнал и JSON-экспорт. Сброс восстанавливает поля preset и требует явного пересчёта. +С v1.2.0 он подбирает допустимую рецептуру двух компонентов при закреплённых +значениях долей или присадки. Предложения проходят тот же фильтр ограничений, +что и обычный расчёт; исходное и показанное значения записываются в журнал. `source/ui_charts.py` строит график исторического point/upper и лимита серы. Ось времени соответствует цели прогноза (as_of + 60 минут); пропуски сохраняются, diff --git a/README.md b/README.md index d41eb9b..8d9dd64 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## Запуск за три шага -В v1.1.0 добавлены **синтетический what-if** (свойства, запасы, присадка, пересчёт и сброс) и **график исторического прогноза**. Технические пути на экране истории скрыты в дополнительных настройках. Инструкция и материалы для жюри обновлены. +В v1.2.0 синтетический what-if подбирает допустимую смесь при изменении доли или присадки: можно закрепить введённое значение, сравнить варианты и отменить автоподбор. Исторический прогноз показывает причины отказа и сохраняет статус запуска в журнале. 1. Скачайте **Neftekod-Windows-x64.zip** по ссылке выше. 2. Распакуйте **весь архив** в доступную для записи папку, например `D:\Нефтекод`. @@ -22,7 +22,7 @@ Python, установка библиотек, обучение моделей | Раздел | Что доступно | | --- | --- | -| Обзор и Смесь | Шесть сценариев и редактор «Синтетический what-if»: свойства компонентов, запасы и присадка | +| Обзор и Смесь | Шесть сценариев и редактор «Синтетический what-if»: автоподбор рецептуры, свойства компонентов, запасы и присадка | | АВТ и Гидроочистка | Исторические сигналы, единицы, источники и свежесть | | История/ML | График прогноза и верхней оценки, выбор периода, прогресс, отмена и экспорт | | Hybrid и v2 | Условное смешение по сере и исследовательские предупреждения | @@ -55,6 +55,8 @@ Python, установка библиотек, обучение моделей ## Для разработчика +Редактор синтетической смеси предлагает допустимые доли после изменения поля, сохраняет закреплённые значения и показывает варианты «ближе», «дешевле» и «меньше присадки». Для проверки откройте «Обзор» → «Смесь» → «Синтетический what-if», измените долю компонента и нажмите Enter или «Подобрать смесь». «Отменить автоподбор» возвращает введённые значения; переключатель «Автоподбор» оставляет ручной режим. + В `main` находится сдаваемая версия без дневников разработки. Планы этапов, старые ревью и журнал проблем сохранены в [ветке dev](https://github.com/bug00n/HackathonPetrolCode/tree/dev); конкретная поставка закреплена тегом релиза. В Git хранятся исходники, конфигурации, тесты и технические материалы. EXE, презентация, Word-документ, подготовленные данные и модели поставляются через Releases. Уведомления сторонних компонентов сохранены в [third_party/licenses](third_party/licenses). [Запуск исходников, тесты и сборка](docs/DEVELOPMENT.md) · [Архитектура и контракты](DESIGN.md) · [Уточнения организаторов](docs/technical/QA_CLARIFICATIONS.md) diff --git a/config/release_manifest.json b/config/release_manifest.json index 97a23e8..f90024a 100644 --- a/config/release_manifest.json +++ b/config/release_manifest.json @@ -1,7 +1,15 @@ { "schema_version": "1.0", - "release_id": "neftekod-v1.1.0", + "release_id": "neftekod-v1.2.0", "prepared_dataset": "data/processed/66bdfcbb23b4", + "legacy_config_sha256": "00a45650caa0f415e78037d56bc49de7161f55f9eecd24675e8874d64efb63e2", + "preparation_config_sha256": "7f8dab1f4befa7ed4d14108146d344eeeb4341711880144ad0195d163a55c4dd", + "prepared_sha256": { + "telemetry.csv.gz": "20b434e7fd01e49ad45ec82635ea14940677267f58d0d3f70f63812d8c17c84d", + "quality.csv.gz": "fbef330af12854052c7622f10c561460af3d1a708d59b87892ad49d82f0be9e4", + "issues.csv.gz": "bffe17ad1fcfdfaa4496405ce1b7eb2d0f318c67103bfbaf63a0e45d17ee9fb9", + "feature_order.json": "fb7b26c4991e029f246baa8efc99f971f07fc765bbb5e59d521794a3ed33932e" + }, "forecast_artifact": "artifacts/models/sulfur-upper-33efba3c3141", "v2_artifact": "artifacts/models/sulfur-v2-shadow-61f972d07181", "action_artifact": null, diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index 83eee67..ade2db0 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -97,19 +97,23 @@ Hybrid проверяет только серу. Он не подтвержда Нажмите «Синтетический what-if» на «Обзоре» или «Смеси». За основу берётся выбранный готовый сценарий. При повторном открытии после успешного пересчёта редактор показывает последние изменённые входы этого сценария. Это эксперимент с модельными допущениями, а не правка истории предприятия. -На вкладке «Компоненты и текущая рецептура» для A и B задаются сера и её верхняя оценка (мг/кг), T95 и его верхняя оценка (°C), цетановое число и его нижняя оценка, запас (т), стоимость (условный индекс/т), риск от 0 до 1 и текущая массовая доля (%). Пустое поле качества означает неизвестное значение и может привести к отказу; запас и другие обязательные числа оставлять пустыми нельзя. +На первой вкладке «Рецептура» задайте доли A, B, присадки и массу партии. После Enter или выхода из поля редактор подбирает допустимые значения остальных долей, оставляя последнее изменённое значение неизменным. Галочка «Зафиксировать» удерживает долю и при следующих правках. В списке вариантов можно выбрать смесь ближе к введённой, с меньшей стоимостью или меньшим количеством присадки. Под списком показаны изменения и проверки серы, T95 и цетанового числа. Если смесь невозможна при закреплённых полях или запасах, редактор объясняет причину. -На вкладке «Партия и присадка» задаются положительная масса партии (т), текущая доля присадки (%), максимум присадки (1, 2 или 3%), запас (т), стоимость (условный индекс/т) и прирост цетанового числа при 1, 2 и 3%. Кривая эффекта остаётся синтетической; прирост не должен уменьшаться с дозой. +На вкладке «Свойства и запасы» для A и B задаются сера и её верхняя оценка (мг/кг), T95 и его верхняя оценка (°C), цетановое число и его нижняя оценка, запас (т), стоимость (условный индекс/т) и риск от 0 до 1. Пустое поле качества означает неизвестное значение и может привести к отказу; запас и другие обязательные числа оставлять пустыми нельзя. + +На вкладке «Параметры присадки» задаются максимум присадки (1, 2 или 3%), запас (т), стоимость (условный индекс/т) и прирост цетанового числа при 1, 2 и 3%. Кривая эффекта остаётся синтетической; прирост не должен уменьшаться с дозой. | Кнопка | Действие | | --- | --- | -| Пересчитать what-if | Проверяет поля, закрывает редактор и запускает существующий расчёт; результат появляется на «Смеси» с отметкой what-if | +| Подобрать смесь | Пересчитывает допустимые варианты с учётом закреплённых значений | +| Отменить автоподбор | Возвращает введённые перед подбором значения; ручной режим также доступен через переключатель «Автоподбор» | +| Пересчитать what-if | Проверяет поля, закрывает редактор и оценивает именно показанную смесь; результат появляется на «Смеси» с отметкой what-if | | Сбросить к сценарию | Восстанавливает исходные поля; затем нажмите «Пересчитать what-if», чтобы обновить результат | | Закрыть без изменений | Закрывает редактор без применения введённых значений; крестик действует так же | Сумма текущих долей A, B и присадки должна равняться 100%. Верхняя оценка не может быть ниже точки, нижняя — выше. Числа должны быть конечными и неотрицательными; допустимы точка и запятая. Ошибка ввода остаётся в окне. Нехватка запасов, пропущенное качество или отсутствие допустимой смеси дают объяснённый отказ, а не вымышленную рекомендацию. -Оптимизатор сохраняет прежний дискретный перебор: соотношение A/B с шагом 5% без присадки, присадка с шагом 1%. Редактор не обещает непрерывный математический оптимум. Изменённый сценарий сохраняется в журнале `input.json` и в экспорте; исходный preset на диске остаётся прежним. +Обычный расчёт сохраняет прежний дискретный перебор и добавляет допустимые граничные смеси, если сетка пропускает узкий допустимый диапазон. Редактор ищет смесь в пределах заданных свойств, запасов и закреплённых полей; он не обещает непрерывный математический оптимум. Изменённый сценарий и введённые значения сохраняются в журнале `input.json` и в экспорте; исходный preset на диске остаётся прежним. Для дополнительной минуты показа выберите «Повышенная сера», откройте редактор и измените серу A с 6 до 1, верхнюю оценку с 7 до 2 мг/кг. Нажмите пересчёт и сравните качество исходной смеси. Затем снова откройте редактор, сбросьте поля и пересчитайте: исходный пример вернётся. diff --git a/global_tests/test_blend_assist.py b/global_tests/test_blend_assist.py new file mode 100644 index 0000000..a1f4a7c --- /dev/null +++ b/global_tests/test_blend_assist.py @@ -0,0 +1,115 @@ +"""The synthetic blend helper must agree with the decision engine.""" + +import json +from pathlib import Path + +import pytest + +from source.agents.blend_assist import feasible_blends +from source.agents.optimizer import evaluate_candidates, generate_candidates +from source.config import load_runtime_config, load_scenario +from source.contracts import ScenarioConfig +from source.main import run_model_demo +from source.orchestrator import _model_demo_state +from source.ui_what_if import assist_editor_values, editor_values, scenario_from_editor + + +def test_editor_keeps_changed_share_and_only_repairs_recipe() -> None: + scenario = load_scenario("config/scenarios/blend_risk.json") + values = editor_values(scenario) + values["B.fraction"] = "10" + options = assist_editor_values(scenario, values, frozenset({"B.fraction"})) + assert options + for option, updated in options: + assert updated["B.fraction"] == "10" + assert updated["A.sulfur.value"] == values["A.sulfur.value"] + assert updated["total_mass_t"] == values["total_mass_t"] + assert scenario_from_editor(scenario, updated) + assert option.evaluation.feasible + + +def test_editor_explains_impossible_fixed_stock() -> None: + scenario = load_scenario("config/scenarios/blend_risk.json") + values = editor_values(scenario) + values["B.fraction"] = "40" + with pytest.raises(ValueError, match="доступно 30"): + assist_editor_values(scenario, values, frozenset({"B.fraction"})) + + +def test_lowering_additive_cap_repairs_the_current_dose() -> None: + scenario = load_scenario("config/scenarios/blend_risk.json") + values = editor_values(scenario) | {"dose": "3", "max_dose": "1"} + options = assist_editor_values(scenario, values, frozenset()) + assert options + assert all(float(updated["dose"]) <= 1 for _, updated in options) + + +def test_continuous_search_finds_narrow_feasible_window() -> None: + base = load_scenario("config/scenarios/blend_risk.json") + payload = base.model_dump(mode="json") + payload["blend_components"][0]["available_mass_t"] = 100 + payload["blend_components"][1]["available_mass_t"] = 100 + payload["blend_components"][0]["sulfur"].update(value=0, upper=0) + payload["blend_components"][1]["sulfur"].update(value=100, upper=100) + payload["blend_components"][0]["cetane_number"].update(value=0, lower=0) + payload["blend_components"][1]["cetane_number"].update(value=100, lower=100) + payload["constraints"] = [ + payload["constraints"][0] | {"upper": 48.9}, + payload["constraints"][1] | {"upper": 400}, + payload["constraints"][2] | {"lower": 48.1}, + ] + payload["cetane_additive"]["available_mass_t"] = 0 + scenario = ScenarioConfig.model_validate(payload) + from datetime import UTC, datetime + + state = _model_demo_state(datetime.now(UTC), scenario) + found = feasible_blends(scenario, dict(scenario.current_blend_mass_fractions), 0) + assert found + assert all(0.481 <= item.fractions["B"] <= 0.489 for item in found) + assert any( + item.feasible + for item in evaluate_candidates(state, generate_candidates(state, scenario), scenario) + ) + bounded = load_runtime_config(Path("config/runtime.toml")).model_copy( + update={"max_candidates": 85} + ) + candidates = generate_candidates(state, scenario, bounded) + assert len(candidates) == 85 + assert any(item.feasible for item in evaluate_candidates(state, candidates, scenario)) + + +def test_fixed_recipe_and_editor_context_are_journaled(tmp_path: Path) -> None: + preset = load_scenario("config/scenarios/blend_risk.json") + values = editor_values(preset) | {"B.fraction": "10"} + _, updated = assist_editor_values(preset, values, frozenset({"B.fraction"}))[0] + scenario = scenario_from_editor(preset, updated) + context = {"original_values": values, "displayed_values": updated, "assisted": True} + result = run_model_demo( + preset.id, + run_dir=tmp_path, + scenario_override=scenario, + recipe_mode="evaluate", + editor_context=context, + ) + assert result.selected is not None + assert result.selected.candidate.kind.value == "hold" + recorded = json.loads((tmp_path / result.run_id / "input.json").read_text()) + assert recorded["editor_context"] == context + assert ( + recorded["scenario"]["current_blend_mass_fractions"] + == scenario.current_blend_mass_fractions + ) + status = json.loads((tmp_path / result.run_id / "status.json").read_text()) + assert status["status"] == "completed" + + +def test_failed_run_has_durable_status(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def fail(*_args, **_kwargs): + raise RuntimeError("deliberate failure") + + monkeypatch.setattr("source.orchestrator._run_cycle", fail) + with pytest.raises(RuntimeError, match="deliberate failure"): + run_model_demo("blend_risk", run_dir=tmp_path) + statuses = [json.loads(path.read_text()) for path in tmp_path.glob("*/status.json")] + assert len(statuses) == 1 + assert statuses[0]["status"] == "failed" diff --git a/global_tests/test_stage2_data.py b/global_tests/test_stage2_data.py index 8463ca7..8452b6f 100644 --- a/global_tests/test_stage2_data.py +++ b/global_tests/test_stage2_data.py @@ -2,6 +2,7 @@ from __future__ import annotations +import io import json import tarfile from datetime import datetime @@ -69,6 +70,39 @@ def _write_minimal_materials(root: Path) -> None: ).to_excel(root / "ЛИМСы 01.01.2023 - н.в_ (2).xlsx", header=False, index=False) +@pytest.mark.parametrize("external_tar", [False, True]) +def test_archive_extraction_rejects_symlinked_data_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, external_tar: bool +) -> None: + """Archive metadata must not redirect CSV extraction outside the temporary directory.""" + archive_path = tmp_path / "malicious.tar" + with tarfile.open(archive_path, "w") as archive: + link = tarfile.TarInfo("data") + link.type = tarfile.SYMTYPE + link.linkname = "../outside" + archive.addfile(link) + for name in ("avt_tags.csv", "242000_tags.csv"): + payload = b"date,value\n2026-01-15,1\n" + member = tarfile.TarInfo(f"data/{name}") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + outside = tmp_path / "outside" + outside.mkdir() + destination = tmp_path / "extracted" + destination.mkdir() + if external_tar: + + def reject_tar_archive(path: Path) -> None: + raise tarfile.ReadError(path) + + monkeypatch.setattr(prepare_module.tarfile, "open", reject_tar_archive) + + with pytest.raises(ValueError, match="unsafe archive member"): + prepare_module._extract_telemetry(archive_path, destination) + assert not list(outside.iterdir()) + + def test_prepared_dataset_roundtrip(tmp_path: Path) -> None: """Verify a written prepared dataset can be loaded back without contract drift.""" data = _fixture_prepared_data() @@ -76,13 +110,42 @@ def test_prepared_dataset_roundtrip(tmp_path: Path) -> None: dataset_path = write_prepared_dataset(data, tmp_path) loaded = load_prepared_dataset(dataset_path) - assert loaded.manifest == data.manifest + assert loaded.manifest.model_dump(exclude={"prepared_sha256"}) == data.manifest.model_dump( + exclude={"prepared_sha256"} + ) + assert loaded.manifest.prepared_sha256 is not None assert loaded.feature_order == data.feature_order pd.testing.assert_frame_equal(loaded.telemetry, data.telemetry, check_dtype=False) pd.testing.assert_frame_equal(loaded.quality, data.quality, check_dtype=False) pd.testing.assert_frame_equal(loaded.issues, data.issues, check_dtype=False) +def test_prepared_dataset_memory_cache_isolated_and_invalidated( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + dataset_path = write_prepared_dataset(_fixture_prepared_data(), tmp_path) + read_csv = pd.read_csv + reads: list[str] = [] + + def counted_read_csv(path: Path, *args: object, **kwargs: object) -> pd.DataFrame: + reads.append(Path(path).name) + return read_csv(path, *args, **kwargs) + + monkeypatch.setattr(prepare_module.pd, "read_csv", counted_read_csv) + first = load_prepared_dataset(dataset_path) + original = first.quality.loc[0, "value"] + first.quality.loc[0, "value"] = 999 + second = load_prepared_dataset(dataset_path) + assert second.quality.loc[0, "value"] == original + assert len(reads) == 3 + + second.quality.loc[0, "value"] = 998 + second.quality.to_csv(dataset_path / "quality.csv.gz", index=False, compression="gzip") + with pytest.raises(ValueError, match="checksum mismatch"): + load_prepared_dataset(dataset_path) + assert len(reads) == 6 + + def test_prepared_dataset_publish_retries_transient_permission_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/global_tests/test_stage2_features.py b/global_tests/test_stage2_features.py index 23033e2..cceb381 100644 --- a/global_tests/test_stage2_features.py +++ b/global_tests/test_stage2_features.py @@ -16,6 +16,7 @@ baseline_feature_name, build_features, build_supervised_dataset, + prepare_feature_batch, ) FIXTURES = Path(__file__).parent / "fixtures" @@ -249,6 +250,65 @@ def test_training_and_serving_use_identical_features_and_model_order() -> None: pd.testing.assert_frame_equal(served, expected) +def test_batched_history_features_match_single_points_with_delayed_lims() -> None: + signal = "ht:2:Mg.Sulfur" + data = _prepared( + pd.DataFrame( + { + "timestamp": pd.date_range("2026-01-01", periods=8, freq="1h", tz="UTC"), + "ht:F1": np.arange(8, dtype=float), + } + ), + [ + _observation( + "old", + "2026-01-01T00:00:00Z", + 5.0, + source="lims", + signal_id=signal, + available_at="2026-01-01T02:00:00Z", + ), + _observation( + "new", + "2026-01-01T04:00:00Z", + 7.0, + source="lims", + signal_id=signal, + available_at="2026-01-01T06:00:00Z", + ), + ], + ("ht:F1",), + ) + model = SimpleNamespace( + metadata=SimpleNamespace( + feature_names=( + "ht:F1__lag_0m", + baseline_feature_name(signal, "lims"), + f"{signal}__lims_mean_60m", + ), + target_signal=signal, + target_source="lims", + target_unit="mg/kg", + ) + ) + queries = pd.date_range("2026-01-01T01:00:00Z", periods=4, freq="2h") + batch = prepare_feature_batch(data, queries, model) + for query in queries: + state = ProcessState( + state_id="fixture", + as_of=query.to_pydatetime(), + dataset_id=data.manifest.dataset_id, + mode=OperationMode.HISTORY, + signals={}, + ) + single = build_features(data, query.to_pydatetime(), state, model) + batched = build_features(data, query.to_pydatetime(), state, model, batch=batch) + pd.testing.assert_frame_equal(single, batched) + assert np.isnan(batch.frame.iloc[0][baseline_feature_name(signal, "lims")]) + assert batch.frame.iloc[1][baseline_feature_name(signal, "lims")] == 5.0 + assert batch.frame.iloc[-1][baseline_feature_name(signal, "lims")] == 7.0 + + def test_build_features_rejects_naive_time_and_state_mismatch() -> None: data = _prepared( pd.DataFrame({"timestamp": [pd.Timestamp("2026-01-01T00:00:00Z")]}), diff --git a/global_tests/test_stage6_acceptance.py b/global_tests/test_stage6_acceptance.py index 05320af..425d0a4 100644 --- a/global_tests/test_stage6_acceptance.py +++ b/global_tests/test_stage6_acceptance.py @@ -13,6 +13,7 @@ from source.acceptance import ( JOURNAL_FILES, + export_journals, load_episode_specs, run_acceptance_suite, sha256_file, @@ -83,6 +84,15 @@ def test_acceptance_suite_reproduces_decisions_and_exports_full_journals( assert f"sulfur_risk/{filename}" in names +@pytest.mark.parametrize("name", ("../outside", r"..\outside")) +def test_journal_export_rejects_path_components(name: str, tmp_path: Path) -> None: + """A catalog id must not create a ZIP entry outside its own journal directory.""" + destination = tmp_path / "journals.zip" + with pytest.raises(ValueError, match="single path components"): + export_journals([(name, tmp_path / "source")], destination) + assert not destination.exists() + + def test_acceptance_decision_fingerprints_repeat(tmp_path: Path) -> None: reports = [ run_acceptance_suite( diff --git a/global_tests/test_stage7_history_serving.py b/global_tests/test_stage7_history_serving.py index 8076617..3c424e7 100644 --- a/global_tests/test_stage7_history_serving.py +++ b/global_tests/test_stage7_history_serving.py @@ -13,7 +13,7 @@ from source.config import config_fingerprint, load_runtime_config from source.contracts import DatasetManifest, Recommendation -from source.data.prepare import PreparedData, write_prepared_dataset +from source.data.prepare import PreparedData, load_prepared_dataset, write_prepared_dataset from source.main import main, run_history_command from source.ml.artifacts import LastValueRegressor, feature_schema_hash, save_model @@ -114,6 +114,112 @@ def _write_point_model(directory: Path, tag_dictionary_sha256: str) -> Path: return artifact_dir +def test_reused_feature_sources_match_uncached_batches(tmp_path: Path) -> None: + from source.ml.artifacts import load_model + from source.ml.features import prepare_feature_batch, prepare_feature_sources + + data = _prepared_data() + model = load_model( + _write_point_model(tmp_path / "models", data.manifest.tag_dictionary_sha256), + trusted=True, + ) + queries = pd.date_range("2026-01-15T08:40:00Z", periods=4, freq="10min") + sources = prepare_feature_sources(data, model) + for chunk in (queries[:2], queries[2:]): + expected = prepare_feature_batch(data, chunk, model).frame + actual = prepare_feature_batch(data, chunk, model, sources=sources).frame + pd.testing.assert_frame_equal(expected, actual) + + +def test_feature_recipe_mismatch_fails_before_forecast(tmp_path: Path) -> None: + from source.ml.artifacts import load_model + from source.ml.features import prepare_feature_sources + + data = _prepared_data() + model = load_model( + _write_point_model(tmp_path / "models", data.manifest.tag_dictionary_sha256), + trusted=True, + ) + metadata = model.metadata.model_dump(mode="python") + metadata["processing"]["feature_definition"]["lags_minutes"] = [0, 15] + from types import SimpleNamespace + + with pytest.raises(ValueError, match="feature recipe"): + prepare_feature_sources(data, SimpleNamespace(metadata=metadata)) + + +def test_feature_recipe_accepts_telemetry_order_from_model() -> None: + from dataclasses import replace + from types import SimpleNamespace + + from source.ml.features import prepare_feature_sources + + data = _prepared_data() + telemetry = data.telemetry.copy() + telemetry["ht:F2"] = telemetry["ht:F1"] + 1 + data = replace(data, telemetry=telemetry, feature_order=("ht:F2", "ht:F1", TARGET_SIGNAL)) + metadata = { + "feature_names": ("ht:F1__lag_0m", "ht:F2__lag_0m", FEATURE_NAME), + "target_signal": TARGET_SIGNAL, + "target_source": "pak", + "target_unit": TARGET_UNIT, + "horizon_minutes": 60, + "processing": {"feature_definition": {"telemetry_signals": ["ht:F1", "ht:F2"]}}, + } + sources = prepare_feature_sources(data, SimpleNamespace(metadata=metadata)) + assert sources.feature_spec[1] == ("ht:F2", "ht:F1") + + +def test_runtime_output_path_does_not_invalidate_new_prepared_data(tmp_path: Path) -> None: + from dataclasses import replace + + from source.main import PROJECT_ROOT, _validate_current_prepared_dataset + + config = load_runtime_config(Path("config/runtime.toml")) + data = _prepared_data() + manifest = data.manifest.model_copy( + update={ + "preparation_version": "2", + "config_sha256": hashlib.sha256( + config_fingerprint(config, preparation_only=True).encode() + ).hexdigest(), + } + ) + data = replace(data, manifest=manifest) + path = write_prepared_dataset(data, tmp_path / "processed") + data = load_prepared_dataset(path) + changed_output = config.model_copy(update={"runs_dir": Path("other-runs")}) + _validate_current_prepared_dataset(data, changed_output, PROJECT_ROOT, path) + changed_preparation = config.model_copy(update={"source_timezone": "UTC"}) + with pytest.raises(ValueError, match="config_sha256"): + _validate_current_prepared_dataset(data, changed_preparation, PROJECT_ROOT, path) + + +def test_pinned_legacy_dataset_tolerates_output_path_change(tmp_path: Path) -> None: + from source.main import _validate_current_prepared_dataset + + config = load_runtime_config(Path("config/runtime.toml")) + for relative in ("config/tags.csv", "config/telemetry_rules.json"): + destination = tmp_path / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(Path(relative).read_bytes()) + path = write_prepared_dataset(_prepared_data(), tmp_path / "data/processed") + data = load_prepared_dataset(path) + release = { + "prepared_dataset": f"data/processed/{path.name}", + "legacy_config_sha256": data.manifest.config_sha256, + "preparation_config_sha256": hashlib.sha256( + config_fingerprint(config, preparation_only=True).encode() + ).hexdigest(), + } + (tmp_path / "config/release_manifest.json").write_text(json.dumps(release), encoding="utf-8") + changed_output = config.model_copy(update={"runs_dir": Path("other-runs")}) + _validate_current_prepared_dataset(data, changed_output, tmp_path, path) + changed_preparation = config.model_copy(update={"source_timezone": "UTC"}) + with pytest.raises(ValueError, match="config_sha256"): + _validate_current_prepared_dataset(data, changed_preparation, tmp_path, path) + + def test_run_history_cli_uses_trusted_artifact_and_writes_journal( tmp_path: Path, capsys: pytest.CaptureFixture[str], diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index 25d59d7..835ee99 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -294,7 +294,7 @@ def release_context_root(tmp_path: Path) -> Path: def test_release_manifest_pins_one_live_context(release_context_root: Path) -> None: context = ui_data_module.discover_ui_context(release_context_root) - assert context.release_id == "neftekod-v1.1.0" + assert context.release_id == "neftekod-v1.2.0" assert context.latest_dataset is not None assert context.latest_dataset.endswith("66bdfcbb23b4") assert tuple(item.model_id for item in context.forecast_artifacts) == ( @@ -306,6 +306,13 @@ def test_release_manifest_pins_one_live_context(release_context_root: Path) -> N assert context.action_artifacts == () +def test_corrupt_release_manifest_cannot_fall_back_to_latest(release_context_root: Path) -> None: + release_file = release_context_root / "config/release_manifest.json" + release_file.write_text("{broken", encoding="utf-8") + with pytest.raises(ValueError, match="invalid release manifest"): + ui_data_module.discover_ui_context(release_context_root) + + def test_missing_release_pins_do_not_select_other_available_inputs( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -376,7 +383,12 @@ def test_history_interval_ui_retains_and_exports_completed_result( monkeypatch.setattr(ui_module.PetrolCodeApp, "calculate", lambda self: None) monkeypatch.setattr(ui_module, "history_interval_command", lambda *args, **kwargs: payload) monkeypatch.setattr( - ui_module.threading, "Thread", lambda target, **kwargs: SimpleNamespace(start=target) + ui_module, + "ThreadPoolExecutor", + lambda **kwargs: SimpleNamespace( + submit=lambda target, *args: target(*args), + shutdown=lambda **kwargs: None, + ), ) monkeypatch.setattr( ui_module.filedialog, "asksaveasfilename", lambda **kwargs: str(destination) diff --git a/global_tests/test_ui_interactive.py b/global_tests/test_ui_interactive.py index 94ce74b..4ef29f8 100644 --- a/global_tests/test_ui_interactive.py +++ b/global_tests/test_ui_interactive.py @@ -146,7 +146,7 @@ def test_editor_reset_validation_and_chart_on_real_tk() -> None: buttons["Пересчитать what-if"].invoke() assert not received and dialog.winfo_exists() buttons["Сбросить к сценарию"].invoke() - assert first.get() == "6" + assert first.get() == editor_values(preset)["A.fraction"] buttons["Пересчитать what-if"].invoke() assert len(received) == 1 and received[0].id.endswith("_what_if") chart = HistoryChart(root) @@ -159,3 +159,41 @@ def test_editor_reset_validation_and_chart_on_real_tk() -> None: assert not chart.find_withtag("point") finally: root.destroy() + + +def test_editor_assist_and_undo_on_real_tk() -> None: + try: + root = tk.Tk() + except tk.TclError as exc: + pytest.skip(f"Tk display unavailable: {exc}") + root.withdraw() + try: + preset = load_scenario("config/scenarios/blend_risk.json") + dialog = open_editor(root, preset, preset, lambda _scenario: None) + dialog.withdraw() + entries = [widget for widget in descendants(dialog) if isinstance(widget, ttk.Entry)] + b_share = entries[1] + b_share.delete(0, "end") + b_share.insert(0, "10") + locks = [ + widget + for widget in descendants(dialog) + if isinstance(widget, ttk.Checkbutton) and widget.cget("text") == "Зафиксировать" + ] + locks[1].invoke() + buttons = { + widget.cget("text"): widget + for widget in descendants(dialog) + if isinstance(widget, ttk.Button) + } + buttons["Подобрать смесь"].invoke() + assert b_share.get() == "10" + assert float(entries[0].get()) + float(b_share.get()) + float( + entries[2].get() + ) == pytest.approx(100) + buttons["Отменить автоподбор"].invoke() + assert b_share.get() == "10" + assert entries[0].get() == editor_values(preset)["A.fraction"] + dialog.destroy() + finally: + root.destroy() diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1 index a971c99..5bb64a6 100644 --- a/scripts/build_release.ps1 +++ b/scripts/build_release.ps1 @@ -15,13 +15,10 @@ if (Test-Path -LiteralPath $destination) { $manifestPath = Join-Path $root "config\release_manifest.json" $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json $required = @( - "README.md", "requirements.txt", "requirements.lock.txt", + "README.md", "DESIGN.md", "requirements.txt", "requirements.lock.txt", "pyproject.toml", "source", "config", "global_tests", "materials", "scripts", "docs", "third_party" ) -$required += @(Get-ChildItem -LiteralPath $root -Filter "*.md" -File | - Where-Object { $_.Name -notin @("README.md", "AGENTS.md") } | - ForEach-Object { $_.Name }) $selected = @( [string]$manifest.prepared_dataset, [string]$manifest.forecast_artifact, @@ -112,7 +109,8 @@ if ($BinaryDirectory) { if ($process.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $smokeReport)) { throw "Portable EXE verification failed" } $smoke = Get-Content -Raw -LiteralPath $smokeReport | ConvertFrom-Json if ($smoke.error -or $smoke.tk -ne "ok" -or $smoke.history.points -ne 2 -or - -not $smoke.history_chart.rendered -or $smoke.what_if.editor -ne "ok") { + -not $smoke.history_chart.rendered -or $smoke.what_if.editor -ne "ok" -or + $smoke.what_if.assist -ne "ok") { throw "Portable smoke report is incomplete" } } finally { diff --git a/source/acceptance.py b/source/acceptance.py index 6a34893..dcd1893 100644 --- a/source/acceptance.py +++ b/source/acceptance.py @@ -212,6 +212,12 @@ def export_journals( 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") + # ZIP member names must stay inside one episode directory on extraction. + if any( + not name or name in {".", ".."} or "/" in name or "\\" in name or "\0" in name + for name, _ in sources + ): + raise ValueError("journal export names must be single path components") destination.parent.mkdir(parents=True, exist_ok=True) manifest: dict[str, Any] = {"schema_version": "1.0", "journals": []} file_descriptor, temporary_name = tempfile.mkstemp( diff --git a/source/agents/blend_assist.py b/source/agents/blend_assist.py new file mode 100644 index 0000000..682ba06 --- /dev/null +++ b/source/agents/blend_assist.py @@ -0,0 +1,192 @@ +"""Find feasible synthetic two-component recipes without a coarse share grid.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime + +from source.agents.optimizer import evaluate_candidates +from source.contracts import CandidateAction, CandidateEvaluation, CandidateKind, ScenarioConfig +from source.orchestrator import _model_demo_state + + +@dataclass(frozen=True) +class BlendOption: + label: str + fractions: dict[str, float] + dose: float + evaluation: CandidateEvaluation + + +def _bound( + interval: tuple[float, float], slope: float, intercept: float, limit: float, *, upper: bool +) -> tuple[float, float]: + """Intersect lo <= share_B <= hi with one linear quality inequality.""" + lo, hi = interval + if abs(slope) < 1e-12: + valid = intercept <= limit + 1e-10 if upper else intercept >= limit - 1e-10 + return (lo, hi) if valid else (1.0, 0.0) + edge = (limit - intercept) / slope + if (slope > 0) == upper: + return lo, min(hi, edge) + return max(lo, edge), hi + + +def _gain(scenario: ScenarioConfig, dose: float) -> float: + additive = scenario.cetane_additive + if additive is None or dose == 0: + return 0.0 + points = additive.response_curve + for left, right in zip(points, points[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("Доза вне заданной кривой присадки") + + +def _shares(scenario: ScenarioConfig, dose: float, fixed: dict[str, float]) -> tuple[float, float]: + a, b = scenario.blend_components + mass = scenario.total_mass_t + assert mass is not None and mass > 0 + diesel = 1.0 - dose + lo = max(0.0, diesel - a.available_mass_t / mass) + hi = min(diesel, b.available_mass_t / mass) + if a.id in fixed: + lo = max(lo, diesel - fixed[a.id]) + hi = min(hi, diesel - fixed[a.id]) + if b.id in fixed: + lo = max(lo, fixed[b.id]) + hi = min(hi, fixed[b.id]) + for check in scenario.constraints: + if check.metric not in {"sulfur", "t95", "cetane_number"}: + raise ValueError(f"Автоподбор не поддерживает ограничение {check.metric}") + left_metric = getattr(a, check.metric) + right_metric = getattr(b, check.metric) + if left_metric is None or right_metric is None: + return 1.0, 0.0 + field = ( + "upper" + if check.use_upper_estimate + else "lower" + if check.use_lower_estimate + else "value" + ) + left = getattr(left_metric, field) + right = getattr(right_metric, field) + if left is None or right is None: + return 1.0, 0.0 + if check.metric == "sulfur": + intercept, slope, correction = diesel * left, right - left, 0.0 + else: + intercept, slope = left, (right - left) / diesel + correction = _gain(scenario, dose) if check.metric == "cetane_number" else 0.0 + if check.upper is not None: + lo, hi = _bound((lo, hi), slope, intercept + correction, check.upper, upper=True) + if check.lower is not None: + lo, hi = _bound((lo, hi), slope, intercept + correction, check.lower, upper=False) + return lo, hi + + +def assist_blend( + scenario: ScenarioConfig, + desired: dict[str, float], + desired_dose: float, + locked: frozenset[str] = frozenset(), +) -> tuple[BlendOption, ...]: + """Return three distinct useful recipes; desired and locked fractions use 0..1 units.""" + found = feasible_blends(scenario, desired, desired_dose, locked) + if not found: + return () + a, b = scenario.blend_components + + def distance(item: BlendOption) -> float: + return sum(abs(item.fractions[key] - desired[key]) for key in desired) + abs( + item.dose - desired_dose + ) + + def cost(item: BlendOption) -> float: + for assessment in item.evaluation.assessments: + metric = assessment.metrics.get("cost_proxy") + if metric and metric.value is not None: + return metric.value + return float("inf") + + rankings = ( + ("Ближе к введённому", lambda item: (distance(item), cost(item))), + ("Ниже стоимость", lambda item: (cost(item), distance(item))), + ("Меньше присадки", lambda item: (item.dose, distance(item), cost(item))), + ) + chosen: list[BlendOption] = [] + for label, key in rankings: + best = min(found, key=lambda item: (*key(item), item.dose, item.fractions[b.id])) + if not any( + abs(best.dose - other.dose) < 1e-10 + and abs(best.fractions[b.id] - other.fractions[b.id]) < 1e-10 + for other in chosen + ): + chosen.append(BlendOption(label, best.fractions, best.dose, best.evaluation)) + return tuple(chosen) + + +def feasible_blends( + scenario: ScenarioConfig, + desired: dict[str, float], + desired_dose: float, + locked: frozenset[str] = frozenset(), +) -> tuple[BlendOption, ...]: + """Evaluate interval boundaries and nearest points through the shared hard checks.""" + if scenario.mode.value != "model_demo" or len(scenario.blend_components) != 2: + raise ValueError("Автоподбор доступен только для модельной смеси из двух компонентов") + a, b = scenario.blend_components + if set(desired) != {a.id, b.id}: + raise ValueError("Укажите доли обоих компонентов") + if any(value < 0 or value > 1 for value in (*desired.values(), desired_dose)): + raise ValueError("Доли должны быть в пределах 0–100%") + additive = scenario.cetane_additive + doses: tuple[float, ...] + if additive is None: + doses = (0.0,) + elif "dose" in locked: + doses = (desired_dose,) + else: + steps = round(additive.max_mass_fraction / additive.fraction_step) + doses = tuple( + sorted({desired_dose, *(i * additive.fraction_step for i in range(steps + 1))}) + ) + state = _model_demo_state(datetime.now(UTC), scenario) + assert scenario.total_mass_t is not None + found: list[BlendOption] = [] + fixed = {name: desired[name] for name in (a.id, b.id) if name in locked} + for dose in doses: + if dose < 0 or dose > (additive.max_mass_fraction if additive else 0.0) + 1e-9: + continue + if additive and dose * scenario.total_mass_t > additive.available_mass_t + 1e-9: + continue + lo, hi = _shares(scenario, dose, fixed) + if lo > hi + 1e-9: + continue + # Endpoints, closest shares, and the former grid's useful interior points. + points = { + max(lo, min(hi, value)) + for value in ( + lo, + hi, + desired[b.id], + 1 - dose - desired[a.id], + (lo + hi) / 2, + ) + } + for share_b in sorted(points): + fractions = {a.id: 1 - dose - share_b, b.id: share_b} + candidate = CandidateAction( + id=f"assisted:{a.id}={fractions[a.id]:.12g},{b.id}={share_b:.12g},dose={dose:.12g}", + kind=CandidateKind.BLEND, + blend_mass_fractions=fractions, + additive_mass_fraction=dose, + horizon_minutes=60, + is_model_scenario=True, + ) + evaluation = evaluate_candidates(state, (candidate,), scenario)[0] + if evaluation.feasible: + found.append(BlendOption("", fractions, dose, evaluation)) + return tuple(found) diff --git a/source/agents/optimizer.py b/source/agents/optimizer.py index 60d75ac..8d093fd 100644 --- a/source/agents/optimizer.py +++ b/source/agents/optimizer.py @@ -98,6 +98,31 @@ def generate_candidates( is_model_scenario=scenario.mode is OperationMode.MODEL_DEMO, ) ) + # Preserve established scenario choices; search continuously if the grid + # falsely reports that no admissible recipe exists. + if any(item.feasible for item in evaluate_candidates(state, tuple(candidates), scenario)): + return tuple(candidates) + from source.agents.blend_assist import feasible_blends + + seen = { + ( + round(item.blend_mass_fractions.get(component_b, -1), 9), + round(item.additive_mass_fraction, 9), + ) + for item in candidates + } + for option in feasible_blends( + scenario, + dict(scenario.current_blend_mass_fractions), + scenario.current_additive_mass_fraction, + ): + key = (round(option.fractions[component_b], 9), round(option.dose, 9)) + if key not in seen: + if len(candidates) >= max_candidates: + candidates[-1] = option.evaluation.candidate + break + candidates.append(option.evaluation.candidate) + seen.add(key) return tuple(candidates) diff --git a/source/agents/quality.py b/source/agents/quality.py index 19ee047..b563f2d 100644 --- a/source/agents/quality.py +++ b/source/agents/quality.py @@ -171,11 +171,24 @@ def _forecast_history_quality( applicability = check_applicability(features) if not getattr(applicability, "available", False): code = str(getattr(applicability, "reason_code", "OUT_OF_DOMAIN")) + detail = "Forecast features are missing or outside the validated training domain." + if code == "OUT_OF_DOMAIN": + age_features = [ + name + for name in getattr(applicability, "violations", ()) + if str(name).endswith("_age_minutes") + ] + if age_features: + detail = ( + "The saved model has not been validated for this measurement age. " + "Choose a historical measurement time or use a model " + "validated for delayed data." + ) return _unavailable( state, code, target_signal, - "Forecast features are missing or outside the validated training domain.", + detail, ) prediction = float(predict(features)[0]) upper: float | None = None diff --git a/source/config.py b/source/config.py index 5f13924..c6600f9 100644 --- a/source/config.py +++ b/source/config.py @@ -96,11 +96,21 @@ def load_telemetry_rules(path: str | Path) -> dict[str, dict[str, object]]: return result -def config_fingerprint(config: RuntimeConfig) -> str: - """Stable JSON used by the preparation manifest hash.""" - return json.dumps( - config.model_dump(mode="json"), ensure_ascii=False, sort_keys=True, separators=(",", ":") - ) +def config_fingerprint(config: RuntimeConfig, *, preparation_only: bool = False) -> str: + """Stable preparation identity; preserve the v1.0 full-config fingerprint.""" + payload = config.model_dump(mode="json") + if preparation_only: + payload = { + name: payload[name] + for name in ( + "source_timezone", + "lims_delay_hours", + "tag_dictionary_path", + "telemetry_rules_path", + "materials_dir", + ) + } + return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) __all__ = [ diff --git a/source/contracts.py b/source/contracts.py index e61ad78..efb4a71 100644 --- a/source/contracts.py +++ b/source/contracts.py @@ -600,7 +600,7 @@ class SourceArtifact(ContractModel): class DatasetManifest(ContractModel): - schema_version: Literal["1.0"] = SCHEMA_VERSION + schema_version: Literal["1.0", "1.1"] = SCHEMA_VERSION dataset_id: Annotated[str, Field(min_length=12, max_length=12)] preparation_version: str created_at: AwareDatetime @@ -612,6 +612,17 @@ class DatasetManifest(ContractModel): 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] + prepared_sha256: dict[str, Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]] | None = None + + @model_validator(mode="after") + def validate_prepared_hashes(self) -> "DatasetManifest": + if self.schema_version == "1.1" and ( + self.prepared_sha256 is None + or set(self.prepared_sha256) + != {"telemetry.csv.gz", "quality.csv.gz", "issues.csv.gz", "feature_order.json"} + ): + raise ValueError("schema 1.1 requires hashes of every prepared data file") + return self __all__ = [ diff --git a/source/data/prepare.py b/source/data/prepare.py index b3223e2..001dbcd 100644 --- a/source/data/prepare.py +++ b/source/data/prepare.py @@ -8,9 +8,11 @@ import subprocess import tarfile import tempfile +import threading import time from dataclasses import dataclass from datetime import UTC, datetime +from functools import lru_cache from pathlib import Path from typing import Any from zoneinfo import ZoneInfo @@ -30,10 +32,18 @@ Validity, ) -PREPARATION_VERSION = "1" +PREPARATION_VERSION = "2" _ARCHIVE_MEMBERS = {"data/avt_tags.csv", "data/242000_tags.csv"} _PUBLISH_ATTEMPTS = 5 _PUBLISH_RETRY_SECONDS = 0.05 +_PREPARED_CACHE_LOCK = threading.Lock() +_PREPARED_FILES = ( + "manifest.json", + "telemetry.csv.gz", + "quality.csv.gz", + "issues.csv.gz", + "feature_order.json", +) RAW_UNIT_MAP: dict[str, Unit] = { "°С": Unit.CELSIUS, @@ -168,24 +178,44 @@ def _publish_directory(temporary: Path, target: Path) -> None: time.sleep(_PUBLISH_RETRY_SECONDS * (2**attempt)) +def _validate_archive_entries(entries: list[tuple[str, bool, bool]]) -> None: + """Accept only the two CSV files and their real parent directory.""" + expected = _ARCHIVE_MEMBERS | {"data"} + seen: set[str] = set() + for name, is_directory, is_file in entries: + # A single leading ./ is harmless; stripping arbitrary dots would hide ../. + normalized = name.removeprefix("./") + if normalized == "data/": + normalized = "data" + if ( + "\\" in name + or normalized not in expected + or normalized in seen + or (normalized == "data" and not is_directory) + or (normalized != "data" and not is_file) + ): + raise ValueError(f"unsafe archive member: {name}") + seen.add(normalized) + if seen != expected: + raise ValueError(f"unexpected archive members: {sorted(seen)}") + + 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)}") + members = stream.getmembers() + _validate_archive_entries( + [(member.name, member.isdir(), member.isfile()) for member in members] + ) destination_root = destination.resolve() - for member in stream.getmembers(): + for member in members: 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) + stream.extractall(destination, members=members) return destination / "data" except tarfile.TarError: pass @@ -193,9 +223,17 @@ def _extract_telemetry(archive: Path, destination: Path) -> Path: listing = subprocess.run( ["tar", "-tf", str(archive)], check=True, capture_output=True, text=True ).stdout.splitlines() - members = {name.replace("\\", "/").lstrip("./").rstrip("/") for name in listing} - if members != _ARCHIVE_MEMBERS | {"data"}: - raise ValueError(f"unexpected archive members: {sorted(members)}") + details = subprocess.run( + ["tar", "-tvf", str(archive)], check=True, capture_output=True, text=True + ).stdout.splitlines() + if len(listing) != len(details): + raise ValueError("archive member listings disagree") + _validate_archive_entries( + [ + (name, detail.startswith("d"), detail.startswith("-")) + for name, detail in zip(listing, details, strict=True) + ] + ) subprocess.run(["tar", "-xf", str(archive), "-C", str(destination)], check=True) return destination / "data" @@ -288,7 +326,9 @@ def prepare_dataset(materials_dir: Path, config: RuntimeConfig) -> PreparedData: _source_artifact(path, materials_dir.parent) for path in (archive, pak_path, lims_path, tag_path, rules_path) ) - config_hash = hashlib.sha256(config_fingerprint(config).encode()).hexdigest() + config_hash = hashlib.sha256( + config_fingerprint(config, preparation_only=True).encode() + ).hexdigest() tag_hash = _sha256(tag_path) telemetry_rules_hash = _sha256(rules_path) identity_parts = sorted(item.sha256 for item in sources) + [ @@ -352,9 +392,9 @@ def write_prepared_dataset(data: PreparedData, output_root: Path) -> Path: } 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"} - ): + if existing.model_dump( + exclude={"created_at", "schema_version", "prepared_sha256"} + ) != data.manifest.model_dump(exclude={"created_at", "schema_version", "prepared_sha256"}): raise FileExistsError(f"dataset_id collision at {target}") missing = [ path.name @@ -363,20 +403,28 @@ def write_prepared_dataset(data: PreparedData, output_root: Path) -> Path: ] if missing: raise FileExistsError(f"incomplete prepared dataset at {target}: missing {missing}") + _verify_prepared_files(target, existing) return target 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", ) + hashes = {name: _sha256(temporary / name) for name in _PREPARED_FILES[1:]} + published_manifest = data.manifest.model_copy( + update={ + "schema_version": "1.1" if data.manifest.preparation_version == "2" else "1.0", + "prepared_sha256": hashes, + } + ) + (temporary / "manifest.json").write_text( + published_manifest.model_dump_json(indent=2), encoding="utf-8", newline="\n" + ) required = ( temporary / "manifest.json", temporary / "feature_order.json", @@ -391,24 +439,60 @@ def write_prepared_dataset(data: PreparedData, output_root: Path) -> Path: return target +def _verify_prepared_files(dataset_path: Path, manifest: DatasetManifest) -> None: + expected = manifest.prepared_sha256 + if expected is None: + release_root = dataset_path.parent.parent.parent + release_file = release_root / "config/release_manifest.json" + if release_file.is_file(): + release = json.loads(release_file.read_text(encoding="utf-8")) + if (release_root / release.get("prepared_dataset", "")).resolve() == dataset_path: + expected = release.get("prepared_sha256") + if not isinstance(expected, dict): + raise ValueError("pinned legacy dataset has no trusted prepared hashes") + if expected is None: + return # Unpinned legacy development fixtures cannot be retroactively authenticated. + if set(expected) != set(_PREPARED_FILES[1:]): + raise ValueError("prepared file hash manifest is incomplete") + for name, digest in expected.items(): + if not isinstance(digest, str) or _sha256(dataset_path / name) != digest: + raise ValueError(f"prepared dataset checksum mismatch: {name}") + + +@lru_cache(maxsize=1) +def _read_prepared_dataset(dataset_path: Path, file_hashes: tuple[str, ...]) -> PreparedData: + """Keep one verified, unmodified parsed dataset in process memory.""" + required = tuple(dataset_path / name for name in _PREPARED_FILES) + manifest_path, telemetry_path, quality_path, issues_path, feature_order_path = required + data = PreparedData( + telemetry=pd.read_csv(telemetry_path), + quality=pd.read_csv(quality_path), + issues=pd.read_csv(issues_path), + manifest=DatasetManifest.model_validate_json(manifest_path.read_text(encoding="utf-8")), + feature_order=tuple(json.loads(feature_order_path.read_text(encoding="utf-8"))), + ) + if tuple(_sha256(path) for path in required) != file_hashes: + raise OSError(f"prepared dataset changed while loading: {dataset_path}") + return data + + def load_prepared_dataset(dataset_path: Path) -> PreparedData: - """Load a prepared dataset written by :func:`write_prepared_dataset`.""" + """Load prepared data, reusing a content-checked in-memory parse when possible.""" dataset_path = dataset_path.resolve() - manifest_path = dataset_path / "manifest.json" - telemetry_path = dataset_path / "telemetry.csv.gz" - quality_path = dataset_path / "quality.csv.gz" - issues_path = dataset_path / "issues.csv.gz" - feature_order_path = dataset_path / "feature_order.json" - required = (manifest_path, telemetry_path, quality_path, issues_path, feature_order_path) + required = tuple(dataset_path / name for name in _PREPARED_FILES) missing = [path.name for path in required if not path.is_file()] if missing: raise FileNotFoundError(f"{dataset_path}: missing prepared dataset files: {missing}") + file_hashes = tuple(_sha256(path) for path in required) + with _PREPARED_CACHE_LOCK: + cached = _read_prepared_dataset(dataset_path, file_hashes) + _verify_prepared_files(dataset_path, cached.manifest) return PreparedData( - telemetry=pd.read_csv(telemetry_path), - quality=pd.read_csv(quality_path), - issues=pd.read_csv(issues_path), - manifest=DatasetManifest.model_validate_json(manifest_path.read_text(encoding="utf-8")), - feature_order=tuple(json.loads(feature_order_path.read_text(encoding="utf-8"))), + telemetry=cached.telemetry.copy(), + quality=cached.quality.copy(), + issues=cached.issues.copy(), + manifest=cached.manifest.model_copy(deep=True), + feature_order=cached.feature_order, ) diff --git a/source/data/state.py b/source/data/state.py index 64d7516..c519469 100644 --- a/source/data/state.py +++ b/source/data/state.py @@ -4,6 +4,7 @@ import hashlib import json +from dataclasses import replace from datetime import UTC, datetime import pandas as pd @@ -56,6 +57,31 @@ def _stage_from_signal_id(signal_id: str) -> Stage: return Stage.HYDROTREATMENT +def _utc_frame(frame: pd.DataFrame, columns: tuple[str, ...]) -> pd.DataFrame: + """Normalize timestamp columns once; reuse frames already normalized to UTC.""" + for column in columns: + dtype = frame[column].dtype + if not isinstance(dtype, pd.DatetimeTZDtype) or dtype.tz != UTC: + break + else: + return frame + normalized = frame.copy() + for column in columns: + normalized[column] = pd.to_datetime(normalized[column], utc=True) + return normalized + + +def prepare_state_data(data: PreparedData) -> PreparedData: + """Return a per-request dataset with UTC timestamps for repeated state builds.""" + quality = _utc_frame(data.quality, ("measured_at", "available_at")) + telemetry = ( + _utc_frame(data.telemetry, ("timestamp",)) + if "timestamp" in data.telemetry.columns + else data.telemetry + ) + return replace(data, quality=quality, telemetry=telemetry) + + def _telemetry_candidates( frame: pd.DataFrame, signal_id: str, @@ -98,13 +124,10 @@ 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) + prepared = prepare_state_data(data) + frame = prepared.quality 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) + telemetry = prepared.telemetry control_units = {control.signal_id: control.unit for control in scenario.controls} snapshots: dict[str, SignalSnapshot] = {} diff --git a/source/desktop.py b/source/desktop.py index 87c9807..be15e3f 100644 --- a/source/desktop.py +++ b/source/desktop.py @@ -25,7 +25,7 @@ def main() -> None: from source.ui import PetrolCodeApp from source.ui_charts import HistoryChart from source.ui_data import discover_ui_context, ui_hybrid_snapshot, ui_stage_snapshot - from source.ui_what_if import editor_values, scenario_from_editor + from source.ui_what_if import assist_editor_values, editor_values, scenario_from_editor if len(sys.argv) == 3 and sys.argv[1] == "--smoke-report": destination = Path(sys.argv[2]) @@ -86,10 +86,18 @@ def descendants(widget: tk.Misc) -> Iterator[tk.Misc]: edited = run_model_demo(preset.id, scenario_override=scenario) if edited.scenario_id != "blend_risk_what_if" or edited.selected is None: raise RuntimeError("Packaged what-if calculation failed") + options = assist_editor_values( + preset, + editor_values(preset) | {"B.fraction": "10"}, + frozenset({"B.fraction"}), + ) + if not options or options[0][1]["B.fraction"] != "10": + raise RuntimeError("Packaged blend assistance failed") report["what_if"] = { "scenario_id": edited.scenario_id, "status": edited.status.value, "editor": "ok", + "assist": "ok", } hybrid = ui_hybrid_snapshot(dataset, model, at) report["hybrid"] = asdict(hybrid) diff --git a/source/journal.py b/source/journal.py index 834ac45..4546da0 100644 --- a/source/journal.py +++ b/source/journal.py @@ -35,6 +35,32 @@ def _json_line(payload: object) -> str: return json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n" +def write_run_status(run_dir: Path, run_id: str, status: str, detail: str = "") -> None: + """Publish a durable lifecycle marker for success, failure, or cancellation.""" + if status not in {"started", "completed", "failed", "cancelled", "interrupted"}: + raise ValueError("invalid run lifecycle status") + _atomic_write( + run_dir / run_id / "status.json", + json.dumps({"run_id": run_id, "status": status, "detail": detail}, ensure_ascii=False), + ) + + +def recover_interrupted_runs(run_dir: Path) -> int: + """Mark abandoned started runs after restarting the local application.""" + count = 0 + for path in run_dir.glob("*/status.json"): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if payload.get("status") == "started": + write_run_status( + run_dir, path.parent.name, "interrupted", "process stopped before completion" + ) + count += 1 + return count + + def write_run_journal( run_dir: Path, state: ProcessState, @@ -46,6 +72,7 @@ def write_run_journal( selection_reason: str = "unknown", rejection_summary: dict[str, int] | None = None, features: pd.DataFrame | None = None, + editor_context: dict[str, object] | None = None, ) -> Path: """Write a compact reproducible record of one successful run.""" rejection_summary = rejection_summary or {} @@ -75,6 +102,7 @@ def write_run_journal( "state": state.model_dump(mode="json"), "scenario": scenario.model_dump(mode="json"), "context": context.model_dump(mode="json"), + "editor_context": editor_context, }, ensure_ascii=False, indent=2, @@ -104,4 +132,4 @@ def write_run_journal( return target -__all__ = ["write_run_journal"] +__all__ = ["write_run_journal", "write_run_status", "recover_interrupted_runs"] diff --git a/source/main.py b/source/main.py index 1ef277e..da5d0aa 100644 --- a/source/main.py +++ b/source/main.py @@ -38,6 +38,7 @@ prepare_dataset, write_prepared_dataset, ) +from source.data.state import prepare_state_data if TYPE_CHECKING: from source.ml.action_effects import ActionEnabledForecastModel @@ -130,6 +131,8 @@ def run_model_demo( run_dir: str | Path | None = None, *, scenario_override: ScenarioConfig | None = None, + recipe_mode: str = "optimize", + editor_context: dict[str, object] | None = None, ) -> Recommendation: """Run one deterministic stage-1 model-demo scenario on fixture data.""" from source.orchestrator import run_cycle @@ -152,6 +155,8 @@ def run_model_demo( config=config, context=DecisionContext(), run_dir=_resolve_path(run_dir if run_dir is not None else config.runs_dir, root), + recipe_mode=recipe_mode, + editor_context=editor_context, ) @@ -173,10 +178,30 @@ def _validate_current_prepared_dataset( data: PreparedData, config: RuntimeConfig, root: Path, + dataset_path: Path, ) -> None: """Fail early when a prepared dataset was built from older runtime inputs.""" + preparation_hash = hashlib.sha256( + config_fingerprint(config, preparation_only=True).encode() + ).hexdigest() + config_hash = hashlib.sha256( + config_fingerprint(config, preparation_only=False).encode() + ).hexdigest() + if data.manifest.schema_version == "1.1": + config_hash = preparation_hash + else: + release_path = root / "config/release_manifest.json" + if release_path.is_file(): + release = json.loads(release_path.read_text(encoding="utf-8")) + pinned = _resolve_path(release.get("prepared_dataset", ""), root).resolve() + if ( + pinned == dataset_path.resolve() + and release.get("legacy_config_sha256") == data.manifest.config_sha256 + ): + if release.get("preparation_config_sha256") == preparation_hash: + config_hash = data.manifest.config_sha256 expected = { - "config_sha256": hashlib.sha256(config_fingerprint(config).encode()).hexdigest(), + "config_sha256": config_hash, "tag_dictionary_sha256": _sha256_file(_resolve_path(config.tag_dictionary_path, root)), "telemetry_rules_sha256": _sha256_file(_resolve_path(config.telemetry_rules_path, root)), } @@ -204,7 +229,7 @@ def _load_current_prepared_dataset( root: Path, ) -> PreparedData: data = load_prepared_dataset(_resolve_path(dataset, root)) - _validate_current_prepared_dataset(data, config, root) + _validate_current_prepared_dataset(data, config, root, _resolve_path(dataset, root)) return data @@ -927,6 +952,7 @@ def history_interval_command( cancelled: Callable[[], bool] | None = None, ) -> dict[str, object]: """Replay a user-selected historical interval without fitting or tuning.""" + from source.ml.features import prepare_feature_batch, prepare_feature_sources from source.orchestrator import run_cycle if start.tzinfo is None or end.tzinfo is None: @@ -943,6 +969,7 @@ def history_interval_command( config = load_runtime_config(_resolve_path(config_path, root)) data = _load_current_prepared_dataset(dataset, config, root) + data = prepare_state_data(data) forecast_model = _load_trusted_model(model, data, root) scenario = load_scenario(root / "config/scenarios/history.json") output_dir = _resolve_path(run_dir if run_dir is not None else config.runs_dir, root) @@ -957,9 +984,15 @@ def history_interval_command( counts: dict[str, int] = {} reason_counts: dict[str, int] = {} available_forecasts = 0 - for timestamp in timestamps: + feature_batch = None + feature_sources = prepare_feature_sources(data, forecast_model) + for index, timestamp in enumerate(timestamps): if cancelled is not None and cancelled(): break + if index % 24 == 0: + feature_batch = prepare_feature_batch( + data, timestamps[index : index + 24], forecast_model, sources=feature_sources + ) result = run_cycle( data=data, model=forecast_model, @@ -968,6 +1001,7 @@ def history_interval_command( config=config, context=DecisionContext(), run_dir=output_dir, + feature_batch=feature_batch, ) carrier = result.selected or result.baseline sulfur = None diff --git a/source/ml/features.py b/source/ml/features.py index 533f399..81122d5 100644 --- a/source/ml/features.py +++ b/source/ml/features.py @@ -47,6 +47,28 @@ class SupervisedDataset: excluded_counts: dict[str, int] +@dataclass(frozen=True, eq=False) +class FeatureBatch: + """Features for one bounded history interval chunk and its exact inputs.""" + + data: PreparedData + model: object + queries: pd.DatetimeIndex + frame: FeatureFrame + + +@dataclass(frozen=True, eq=False) +class FeatureSources: + """Validated and sorted source tables shared by history replay chunks.""" + + data: PreparedData + model: object + telemetry: pd.DataFrame + target_history: pd.DataFrame + published_history: pd.DataFrame + feature_spec: tuple[tuple[str, ...], tuple[str, ...], str, SourceKind, str] + + def _enum_value(value: object) -> str: """Return a stable string for plain strings and string enums.""" raw = getattr(value, "value", value) @@ -250,7 +272,10 @@ def _window_values( def _last_available_quality( - observations: pd.DataFrame, queries: pd.DatetimeIndex + observations: pd.DataFrame, + queries: pd.DatetimeIndex, + *, + events_sorted: bool = False, ) -> tuple[np.ndarray[Any, np.dtype[np.float64]], np.ndarray[Any, np.dtype[np.float64]]]: """Select the latest measured observation among those published by each as_of.""" values = np.full(len(queries), np.nan, dtype=float) @@ -258,9 +283,13 @@ def _last_available_quality( if observations.empty or queries.empty: return values, ages - events = observations.sort_values( - ["available_at", "measured_at", "observation_id"], kind="stable" - ).reset_index(drop=True) + events = ( + observations + if events_sorted + else observations.sort_values( + ["available_at", "measured_at", "observation_id"], kind="stable" + ).reset_index(drop=True) + ) available = pd.DatetimeIndex(events["available_at"]).as_unit("ns").astype("int64").to_numpy() measured = pd.DatetimeIndex(events["measured_at"]).as_unit("ns").astype("int64").to_numpy() query_times = queries.as_unit("ns").astype("int64").to_numpy() @@ -323,15 +352,30 @@ def _feature_matrix( target_signal_id: str, target_source: SourceKind, target_unit: str, + *, + sources: FeatureSources | None = None, ) -> pd.DataFrame: """Build the common training/serving matrix for already-normalized UTC times.""" - telemetry = _telemetry(data, telemetry_signals) + telemetry = sources.telemetry if sources is not None else _telemetry(data, telemetry_signals) lag_values = _lag_values(telemetry, queries, telemetry_signals) window_values = _window_values(telemetry, queries, telemetry_signals) - target_history = _quality_rows(data, target_signal_id, target_source, target_unit) - last_values, ages = _last_available_quality(target_history, queries) + target_history = ( + sources.target_history + if sources is not None + else _quality_rows(data, target_signal_id, target_source, target_unit) + ) + published_history = ( + sources.published_history + if sources is not None + else target_history.sort_values( + ["available_at", "measured_at", "observation_id"], kind="stable" + ).reset_index(drop=True) + ) + last_values, ages = _last_available_quality(published_history, queries, events_sorted=True) quality_lags = { - lag: _last_available_quality(target_history, queries - pd.Timedelta(minutes=lag))[0] + lag: _last_available_quality( + published_history, queries - pd.Timedelta(minutes=lag), events_sorted=True + )[0] for lag in LAG_MINUTES } quality_windows = _quality_window_values(target_history, queries) @@ -442,19 +486,10 @@ def _metadata_value(metadata: object, *names: str) -> object: raise ValueError(f"model metadata is missing {names[0]}") -def build_features( - data: PreparedData, - as_of: datetime, - state: ProcessState, - model: object, -) -> FeatureFrame: - """Build one serving row in the exact feature order stored by the model.""" - query = _as_utc(as_of, "as_of") - if _as_utc(state.as_of, "state.as_of") != query: - raise ValueError("state.as_of must match feature as_of") - if state.dataset_id != data.manifest.dataset_id: - raise ValueError("state and prepared data dataset_id mismatch") - +def _model_feature_spec( + data: PreparedData, model: object +) -> tuple[tuple[str, ...], tuple[str, ...], str, SourceKind, str]: + """Resolve the frozen model's feature contract once per feature build.""" metadata = getattr(model, "metadata", None) if metadata is None: raise ValueError("model needs metadata") @@ -483,6 +518,107 @@ def build_features( if signal in data.telemetry.columns and any(name.startswith(f"{signal}__") for name in feature_names) ) + if isinstance(processing, dict) and isinstance(processing.get("feature_definition"), dict): + definition = processing["feature_definition"] + expected_recipe = { + "horizon_minutes": _metadata_value(metadata, "horizon_minutes"), + "lags_minutes": list(LAG_MINUTES), + "rolling_windows_minutes": list(WINDOW_MINUTES), + "telemetry_max_age_minutes": TELEMETRY_MAX_AGE_MINUTES, + "telemetry_signals": list(telemetry_signals), + "quality_history_source": target_source.value, + "autoregressive_target_signal": target_signal_id, + } + mismatched = [ + key + for key, value in expected_recipe.items() + if key in definition + and ( + sorted(definition[key]) != sorted(telemetry_signals) + if key == "telemetry_signals" and isinstance(definition[key], list) + else definition[key] != value + ) + ] + if mismatched: + raise ValueError("model feature recipe is incompatible with serving code") + return feature_names, telemetry_signals, target_signal_id, target_source, target_unit + + +def prepare_feature_sources(data: PreparedData, model: object) -> FeatureSources: + """Parse the large source tables once for a historical interval.""" + spec = _model_feature_spec(data, model) + _, telemetry_signals, target_signal_id, target_source, target_unit = spec + target_history = _quality_rows(data, target_signal_id, target_source, target_unit) + published_history = target_history.sort_values( + ["available_at", "measured_at", "observation_id"], kind="stable" + ).reset_index(drop=True) + return FeatureSources( + data, + model, + _telemetry(data, telemetry_signals), + target_history, + published_history, + spec, + ) + + +def prepare_feature_batch( + data: PreparedData, + queries: pd.DatetimeIndex, + model: object, + *, + sources: FeatureSources | None = None, +) -> FeatureBatch: + """Build feature rows together for a bounded, UTC-aware replay chunk.""" + if queries.empty or queries.tz is None: + raise ValueError("feature batch requires timezone-aware, nonempty queries") + utc_queries = queries.tz_convert("UTC") + if sources is not None and (sources.data is not data or sources.model is not model): + raise ValueError("feature sources inputs do not match this replay") + feature_names, telemetry_signals, target_signal_id, target_source, target_unit = ( + sources.feature_spec if sources is not None else _model_feature_spec(data, model) + ) + generated = _feature_matrix( + data, + utc_queries, + telemetry_signals, + target_signal_id, + target_source, + target_unit, + sources=sources, + ) + missing = set(feature_names).difference(generated.columns) + if missing: + raise ValueError(f"model requests unknown features: {sorted(missing)}") + return FeatureBatch(data, model, utc_queries, generated.loc[:, list(feature_names)]) + + +def build_features( + data: PreparedData, + as_of: datetime, + state: ProcessState, + model: object, + *, + batch: FeatureBatch | None = None, +) -> FeatureFrame: + """Build one serving row in the exact feature order stored by the model.""" + query = _as_utc(as_of, "as_of") + if _as_utc(state.as_of, "state.as_of") != query: + raise ValueError("state.as_of must match feature as_of") + if state.dataset_id != data.manifest.dataset_id: + raise ValueError("state and prepared data dataset_id mismatch") + feature_names, telemetry_signals, target_signal_id, target_source, target_unit = ( + _model_feature_spec(data, model) + ) + if batch is not None: + if batch.data is not data or batch.model is not model: + raise ValueError("feature batch inputs do not match this replay") + if tuple(batch.frame.columns) != feature_names: + raise ValueError("feature batch columns do not match model") + position = batch.queries.get_indexer(pd.DatetimeIndex([query]))[0] + if position < 0: + raise ValueError("feature batch does not contain as_of") + return batch.frame.iloc[[position]].reset_index(drop=True) generated = _feature_matrix( data, pd.DatetimeIndex([query]), @@ -498,12 +634,16 @@ def build_features( __all__ = [ + "FeatureBatch", "FeatureFrame", + "FeatureSources", "LAG_MINUTES", "SUPERVISED_COLUMNS", "SupervisedDataset", "WINDOW_MINUTES", "baseline_feature_name", "build_features", + "prepare_feature_batch", + "prepare_feature_sources", "build_supervised_dataset", ] diff --git a/source/orchestrator.py b/source/orchestrator.py index 32b87ba..9ea4790 100644 --- a/source/orchestrator.py +++ b/source/orchestrator.py @@ -38,10 +38,10 @@ Unit, ) from source.explain import build_explanation -from source.journal import write_run_journal +from source.journal import write_run_journal, write_run_status from source.ml.action_effects import ActionOutcome, VerifiedActionEffectModel from source.ml.controls import generate_setpoint_candidates -from source.ml.features import build_features +from source.ml.features import FeatureBatch, build_features from source.ml.policy import PolicyParameters, assess_change_policy @@ -438,7 +438,7 @@ def _recheck_selected( return rechecked -def run_cycle( +def _run_cycle( data: Any, as_of: datetime, model: Any, @@ -446,6 +446,10 @@ def run_cycle( config: RuntimeConfig, context: DecisionContext, run_dir: Path, + feature_batch: FeatureBatch | None = None, + recipe_mode: str = "optimize", + editor_context: dict[str, object] | None = None, + run_id: str | None = None, ) -> Recommendation: """Run one backend recommendation cycle and persist its journal.""" if as_of.tzinfo is None or as_of.utcoffset() is None: @@ -463,7 +467,11 @@ def run_cycle( state = _build_cycle_state(data, as_of, effective_scenario, config) features: pd.DataFrame | None = None if model is not None and effective_scenario.mode is OperationMode.HISTORY: - features = build_features(data, as_of, state, model) + features = ( + build_features(data, as_of, state, model) + if feature_batch is None + else build_features(data, as_of, state, model, batch=feature_batch) + ) action_model = _action_model(model) if effective_scenario.mode is OperationMode.HISTORY and action_model is not None: candidates = generate_setpoint_candidates( @@ -484,7 +492,22 @@ def run_cycle( model=model, ) else: - candidates = generate_candidates(state, effective_scenario, config) + if recipe_mode not in {"optimize", "evaluate"}: + raise ValueError("recipe_mode must be optimize or evaluate") + if recipe_mode == "evaluate" and effective_scenario.mode is not OperationMode.MODEL_DEMO: + raise ValueError("fixed recipe evaluation is available only in model_demo") + candidates = ( + ( + CandidateAction( + id="hold", + kind=CandidateKind.HOLD, + horizon_minutes=config.horizon_minutes, + is_model_scenario=True, + ), + ) + if recipe_mode == "evaluate" + else generate_candidates(state, effective_scenario, config) + ) evaluations = evaluate_candidates( state, candidates, effective_scenario, features=features, model=model ) @@ -514,7 +537,7 @@ def run_cycle( ) ) result = Recommendation( - run_id=str(uuid4()), + run_id=run_id or str(uuid4()), state_id=state.state_id, as_of=state.as_of, scenario_id=scenario.id, @@ -548,8 +571,45 @@ def run_cycle( selection_reason=selection_reason, rejection_summary=_rejection_summary(evaluations), features=features, + editor_context=editor_context, ) return result +def run_cycle( + data: Any, + as_of: datetime, + model: Any, + scenario: ScenarioConfig, + config: RuntimeConfig, + context: DecisionContext, + run_dir: Path, + feature_batch: FeatureBatch | None = None, + recipe_mode: str = "optimize", + editor_context: dict[str, object] | None = None, +) -> Recommendation: + """Run and record a complete decision lifecycle, including failed attempts.""" + run_id = str(uuid4()) + write_run_status(run_dir, run_id, "started") + try: + result = _run_cycle( + data, + as_of, + model, + scenario, + config, + context, + run_dir, + feature_batch, + recipe_mode, + editor_context, + run_id, + ) + write_run_status(run_dir, run_id, "completed") + return result + except Exception as exc: + write_run_status(run_dir, run_id, "failed", f"{type(exc).__name__}: {exc}") + raise + + __all__ = ["run_cycle"] diff --git a/source/ui.py b/source/ui.py index 6ab5983..e086eea 100644 --- a/source/ui.py +++ b/source/ui.py @@ -12,6 +12,7 @@ import sys import threading import tkinter as tk +from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict, dataclass from datetime import datetime from functools import partial @@ -19,7 +20,7 @@ from tkinter import filedialog, messagebox, ttk from typing import Any, Callable, Sequence -from source.config import load_scenario +from source.config import load_runtime_config, load_scenario from source.contracts import ( CandidateEvaluation, ConstraintStatus, @@ -27,6 +28,7 @@ RecommendationStatus, ScenarioConfig, ) +from source.journal import recover_interrupted_runs from source.main import ( PROJECT_ROOT, action_shadow_estimate_command, @@ -294,7 +296,18 @@ 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()] + files = [] + for path in run_dir.glob("*/result.json"): + if not path.is_file(): + continue + status_path = path.parent / "status.json" + if status_path.is_file(): + try: + if json.loads(status_path.read_text(encoding="utf-8")).get("status") != "completed": + continue + except (OSError, json.JSONDecodeError): + continue + files.append(path) return tuple(sorted(files, key=lambda path: path.stat().st_mtime, reverse=True)) @@ -468,7 +481,11 @@ def __init__( self.status_var = tk.StringVar(value="Готово к расчёту") self._ui_events: queue.SimpleQueue[Callable[[], None]] = queue.SimpleQueue() self._closing = False + recover_interrupted_runs( + PROJECT_ROOT / load_runtime_config(PROJECT_ROOT / "config/runtime.toml").runs_dir + ) self._interval_cancel = threading.Event() + self._history_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="history") self._shared_dataset = tk.StringVar(value=discover_ui_context().latest_dataset or "") self._shared_as_of = tk.StringVar(value=RELEASE_DEMO_AS_OF) self._result: Recommendation | None = None @@ -507,6 +524,7 @@ def _drain_ui_events(self) -> None: def destroy(self) -> None: self._closing = True self._interval_cancel.set() + self._history_executor.shutdown(wait=False, cancel_futures=True) if hasattr(self, "_poll_id"): self.after_cancel(self._poll_id) super().destroy() @@ -601,7 +619,10 @@ def _clear_body(self) -> None: def _set_nav(self, page: str) -> None: for key, button in self._nav_buttons.items(): - button.configure(fg="white" if key == page else "#C0C8CD") + button.configure( + fg="white" if key == page else "#C0C8CD", + bg="#31454B" if key == page else HEADER, + ) def show_page(self, page: str) -> None: page = PAGE_ALIASES.get(page, page) @@ -698,11 +719,26 @@ def _secondary_button(parent: tk.Misc, text: str, command: Any) -> tk.Button: 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") + tk.Label( + parent, + text=label, + bg=parent.cget("bg"), + fg=MUTED, + font=("Segoe UI", 10, "bold"), + ).pack(anchor="w") entry = tk.Entry( - parent, textvariable=variable, bg=SURFACE, fg=TEXT, bd=1, width=width or 20 + parent, + textvariable=variable, + bg=SURFACE, + fg=TEXT, + relief="flat", + bd=0, + highlightthickness=1, + highlightbackground=BORDER, + highlightcolor=TEAL, + width=width or 20, ) - entry.pack(fill="x", pady=(4, 10), ipady=6) + entry.pack(fill="x", pady=(6, 8), ipady=8) @staticmethod def _stage_tone(status: str) -> str: @@ -1306,13 +1342,57 @@ def settings(parent: tk.Misc) -> None: summary = tk.StringVar( value="Укажите время и запустите расчёт. Реальные действия отключены." ) + result_panel = self._surface(page) + result_panel.pack(fill="x", pady=(0, 14)) tk.Label( - page, textvariable=summary, bg=BG, fg=TEXT, anchor="w", justify="left", wraplength=1100 - ).pack(fill="x", pady=8) - chart = HistoryChart(page) - chart.pack(fill="x", pady=(0, 10)) - output = tk.Text(page, height=7, bg=SURFACE, fg=TEXT, bd=1, wrap="word") - output.pack(fill="both", expand=True) + result_panel, + text="ПОСЛЕДНИЙ РЕЗУЛЬТАТ", + bg=SURFACE, + fg=MUTED, + font=("Segoe UI", 9, "bold"), + ).pack(anchor="w", padx=20, pady=(14, 4)) + tk.Label( + result_panel, + textvariable=summary, + bg=SURFACE, + fg=TEXT, + anchor="w", + justify="left", + wraplength=1100, + font=("Segoe UI", 12, "bold"), + ).pack(fill="x", padx=20, pady=(0, 14)) + chart_panel = self._surface(page) + chart_panel.pack(fill="x", pady=(0, 14)) + tk.Label( + chart_panel, + text="Динамика серы", + bg=SURFACE, + fg=TEXT, + font=("Segoe UI", 14, "bold"), + ).pack(anchor="w", padx=20, pady=(16, 0)) + chart = HistoryChart(chart_panel) + chart.pack(fill="x", padx=12, pady=(0, 8)) + details = self._surface(page) + details.pack(fill="x") + tk.Label( + details, + text="Детали расчёта", + bg=SURFACE, + fg=TEXT, + font=("Segoe UI", 14, "bold"), + ).pack(anchor="w", padx=20, pady=(16, 10)) + output = tk.Text( + details, + height=8, + bg="#F8FAFA", + fg=TEXT, + bd=0, + wrap="word", + font=("Consolas", 10), + padx=14, + pady=10, + ) + output.pack(fill="x", padx=20, pady=(0, 18)) def render(view: UiHistoryReplayView) -> None: output.configure(state="normal") @@ -1335,6 +1415,8 @@ def calculate( as_of: str, action_model: str | None, ) -> None: + if request_id != self._history_request_id: + return view = ui_history_snapshot(dataset, model, as_of, action_model) def finish() -> None: @@ -1364,17 +1446,14 @@ def start_calculation() -> None: output.delete("1.0", "end") output.insert("1.0", "Расчёт исторического прогноза…") output.configure(state="disabled") - threading.Thread( - target=calculate, - args=( - self._history_request_id, - dataset_var.get().strip() or None, - model_var.get().strip() or None, - as_of_var.get(), - action_var.get().strip() or None, - ), - daemon=True, - ).start() + self._history_executor.submit( + calculate, + self._history_request_id, + dataset_var.get().strip() or None, + model_var.get().strip() or None, + as_of_var.get(), + action_var.get().strip() or None, + ) def calculate_interval() -> None: self._history_request_id += 1 @@ -1425,6 +1504,8 @@ def show_progress() -> None: progress(0, 0) def work() -> None: + if request_id != self._history_request_id or cancel.is_set(): + return payload = None try: payload = history_interval_command( @@ -1466,32 +1547,27 @@ def finish() -> None: except tk.TclError: return - threading.Thread(target=work, daemon=True).start() + self._history_executor.submit(work) actions = tk.Frame(page, bg=BG) - actions.pack(fill="x", pady=(0, 12), before=chart) - self._primary_button( - actions, - "Рассчитать на выбранный момент", - start_calculation, - ).pack(side="left") - self._secondary_button(actions, "Журнал", lambda: self.show_page("journal")).pack( - side="left", padx=12 + actions.pack(fill="x", pady=(0, 14), before=chart_panel) + for column in range(3): + actions.grid_columnconfigure(column, weight=1) + self._primary_button(actions, "Рассчитать на выбранный момент", start_calculation).grid( + row=0, column=0, sticky="ew", padx=(0, 8), pady=(0, 8) ) - self._secondary_button(actions, "Рассчитать интервал", calculate_interval).pack(side="left") + interval_button = self._secondary_button(actions, "Рассчитать интервал", calculate_interval) + interval_button.configure(fg=TEAL, font=("Segoe UI", 11, "bold")) + interval_button.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=(0, 8)) self._secondary_button( actions, "Отменить интервал", lambda: self._interval_cancel.set() - ).pack(side="left") + ).grid(row=0, column=2, sticky="ew", pady=(0, 8)) self._secondary_button( actions, "Экспортировать этот результат", self.export_history_result - ).pack(side="left", padx=12) - buttons = [child for child in actions.winfo_children() if isinstance(child, tk.Button)] - for button in buttons: - button.pack_forget() - for index, button in enumerate(buttons): - button.grid(row=index // 3, column=index % 3, sticky="ew", padx=4, pady=4) - for column in range(3): - actions.grid_columnconfigure(column, weight=1) + ).grid(row=1, column=0, sticky="ew", padx=(0, 8)) + self._secondary_button(actions, "Журнал", lambda: self.show_page("journal")).grid( + row=1, column=1, sticky="ew", padx=(0, 8) + ) render( self._history_view or UiHistoryReplayView( @@ -2067,11 +2143,22 @@ def open_what_if_dialog(self) -> None: ) def recalculate(scenario: ScenarioConfig) -> None: - self.calculate(scenario) + self.calculate(scenario, recipe_mode="evaluate") + + def recalculate_with_context(scenario: ScenarioConfig, context: dict[str, object]) -> None: + self.calculate(scenario, recipe_mode="evaluate", editor_context=context) - open_editor(self, preset, current, recalculate) + open_editor( + self, preset, current, recalculate, calculate_with_context=recalculate_with_context + ) - def calculate(self, scenario_override: ScenarioConfig | None = None) -> None: + def calculate( + self, + scenario_override: ScenarioConfig | None = None, + *, + recipe_mode: str = "optimize", + editor_context: dict[str, object] | None = None, + ) -> None: self._calculation_request_id += 1 request_id = self._calculation_request_id scenario_id = SCENARIO_LABELS[self.scenario_var.get()] @@ -2085,7 +2172,12 @@ def work() -> None: scenario = scenario_override or load_scenario( PROJECT_ROOT / f"config/scenarios/{scenario_id}.json" ) - result = run_model_demo(scenario_id, scenario_override=scenario_override) + result = run_model_demo( + scenario_id, + scenario_override=scenario_override, + recipe_mode=recipe_mode, + editor_context=editor_context, + ) except Exception as exc: # UI boundary: render backend failure without crashing Tk. self._post_ui(partial(self._calculation_failed, request_id, exc)) return diff --git a/source/ui_charts.py b/source/ui_charts.py index b3b47b4..5b0639a 100644 --- a/source/ui_charts.py +++ b/source/ui_charts.py @@ -80,13 +80,21 @@ def draw(self) -> None: self.delete("all") width, height = max(self.winfo_width(), 600), max(self.winfo_height(), 290) left, right, top, bottom = 62.0, float(width - 24), 52.0, float(height - 64) - self.create_text( - left, - 16, - anchor="w", - fill="#007D78", - text="● Прогноз серы ● Верхняя оценка (синяя) — Предел 10 мг/кг", - ) + for legend_x, color, label, dashed in ( + (left, "#007D78", "Прогноз серы", False), + (left + 164, "#3366BB", "Верхняя оценка", False), + (left + 354, "#D88400", "Предел 10 мг/кг", True), + ): + self.create_line( + legend_x, + 18, + legend_x + 22, + 18, + fill=color, + width=3, + dash=(6, 4) if dashed else (), + ) + self.create_text(legend_x + 30, 18, anchor="w", fill="#38485A", text=label) self.create_text( right, height - 10, diff --git a/source/ui_data.py b/source/ui_data.py index 74fa2b5..fb7260f 100644 --- a/source/ui_data.py +++ b/source/ui_data.py @@ -170,13 +170,22 @@ def list_prepared_datasets(root: Path = PROJECT_ROOT) -> tuple[Path, ...]: def _release_manifest(root: Path) -> dict[str, Any]: - """Read the optional release pin without making it a runtime dependency.""" + """Allow discovery only when no release manifest exists at all.""" path = root / "config/release_manifest.json" + if not path.exists(): + return {} try: payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {} - return payload if isinstance(payload, dict) else {} + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"invalid release manifest: {path}") from exc + if not isinstance(payload, dict) or payload.get("schema_version") != "1.0": + raise ValueError("unsupported release manifest schema") + for key in ("release_id", "prepared_dataset", "forecast_artifact", "v2_artifact"): + if not isinstance(payload.get(key), str) or not payload[key]: + raise ValueError(f"release manifest needs {key}") + if "action_artifact" not in payload: + raise ValueError("release manifest needs action_artifact, null if disabled") + return payload def _manifest_dataset_id(path: Path) -> str | None: @@ -292,11 +301,7 @@ def configured_artifact( forecast_artifacts=forecast, action_artifacts=action, v2_artifacts=v2, - release_id=( - str(_release_manifest(root).get("release_id")) - if _release_manifest(root).get("release_id") - else None - ), + release_id=str(manifest["release_id"]) if manifest.get("release_id") else None, ) @@ -343,6 +348,9 @@ def ui_stage_snapshot( try: config = load_runtime_config(root / "config/runtime.toml") data = load_prepared_dataset(data_path) + data.telemetry["timestamp"] = pd.to_datetime( + data.telemetry["timestamp"], utc=True, errors="coerce" + ) 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 diff --git a/source/ui_what_if.py b/source/ui_what_if.py index f593d79..61ac97d 100644 --- a/source/ui_what_if.py +++ b/source/ui_what_if.py @@ -5,8 +5,10 @@ import math import tkinter as tk from collections.abc import Callable +from functools import partial from tkinter import ttk +from source.agents.blend_assist import BlendOption, assist_blend from source.contracts import ScenarioConfig COMPONENT_FIELDS = ( @@ -128,50 +130,375 @@ def number(key: str) -> float: return ScenarioConfig.model_validate(data) +def assist_editor_values( + base: ScenarioConfig, values: dict[str, str], locked: frozenset[str] +) -> tuple[tuple[BlendOption, dict[str, str]], ...]: + """Parse physical inputs, then repair only the freely adjustable recipe fields.""" + if len(base.blend_components) != 2: + raise ValueError("Автоподбор поддерживает два компонента") + component_ids = tuple(item.id for item in base.blend_components) + + def fraction(key: str) -> float: + try: + result = float(values[key].strip().replace(",", ".")) / 100 + except (KeyError, ValueError) as exc: + raise ValueError(f"{key}: введите число от 0 до 100") from exc + if not math.isfinite(result) or result < 0 or result > 1: + raise ValueError(f"{key}: требуется число от 0 до 100") + return result + + desired = {key: fraction(f"{key}.fraction") for key in component_ids} + dose = fraction("dose") + temporary = dict(values) + # Only the recipe is temporarily balanced so scenario_from_editor can validate + # unchanged component properties before the actual constrained search. + try: + max_dose = float(values["max_dose"].strip().replace(",", ".")) / 100 + except (KeyError, ValueError) as exc: + raise ValueError("max_dose: введите максимум присадки 1, 2 или 3%") from exc + temporary_dose = min(dose, max_dose) if math.isfinite(max_dose) else dose + temporary["dose"] = f"{temporary_dose * 100:.12g}" + temporary[f"{component_ids[0]}.fraction"] = f"{(1 - temporary_dose) * 50:.12g}" + temporary[f"{component_ids[1]}.fraction"] = f"{(1 - temporary_dose) * 50:.12g}" + scenario = scenario_from_editor(base, temporary) + fixed = frozenset( + key.removesuffix(".fraction") for key in locked if key.endswith(".fraction") + ) | (frozenset({"dose"}) if "dose" in locked else frozenset()) + options = assist_blend(scenario, desired, dose, fixed) + if not options: + mass = scenario.total_mass_t or 0 + for component in scenario.blend_components: + if component.id in fixed and desired[component.id] * mass > component.available_mass_t: + needed = desired[component.id] * mass + raise ValueError( + f"Компонента {component.id} нужно {needed:.3g} т, " + f"доступно {component.available_mass_t:.3g} т. " + "Снимите фиксацию или измените долю." + ) + raise ValueError( + "Подходящей смеси нет при заданных свойствах, запасах и закреплённых долях. " + "Снимите фиксацию или измените указанное свойство или массу партии." + ) + result = [] + for option in options: + updated = dict(values) + for key, share in option.fractions.items(): + updated[f"{key}.fraction"] = f"{share * 100:.12g}" + updated["dose"] = f"{option.dose * 100:.12g}" + scenario_from_editor(base, updated) + result.append((option, updated)) + return tuple(result) + + def open_editor( parent: tk.Tk, preset: ScenarioConfig, current: ScenarioConfig, calculate: Callable[[ScenarioConfig], None], + *, + calculate_with_context: Callable[[ScenarioConfig, dict[str, object]], None] | None = None, ) -> tk.Toplevel: dialog = tk.Toplevel(parent) dialog.title("Синтетический what-if — не реальные данные") - dialog.geometry("940x680") - dialog.minsize(800, 620) + dialog.geometry("940x700") + dialog.minsize(800, 700) dialog.transient(parent) + dialog.configure(bg="#F5F7F7") + style = ttk.Style(dialog) + style.configure("WhatIf.TNotebook", background="#F5F7F7", borderwidth=0) + style.configure( + "WhatIf.TNotebook.Tab", + background="#EDF1F3", + foreground="#38485A", + padding=(16, 7), + font=("Segoe UI", 10, "bold"), + ) + style.map( + "WhatIf.TNotebook.Tab", + background=[("selected", "#FFFFFF")], + foreground=[("selected", "#007D78")], + ) + style.configure("WhatIf.TFrame", background="#FFFFFF") + style.configure("WhatIf.Footer.TFrame", background="#F5F7F7") + style.configure("WhatIf.TLabelframe", background="#FFFFFF", bordercolor="#D8E0E4") + style.configure( + "WhatIf.TLabelframe.Label", + background="#FFFFFF", + foreground="#101B28", + font=("Segoe UI", 11, "bold"), + ) + style.configure( + "WhatIf.TLabel", background="#FFFFFF", foreground="#38485A", font=("Segoe UI", 10) + ) + style.configure("WhatIf.TEntry", fieldbackground="#FFFFFF", foreground="#101B28", padding=3) + style.configure( + "WhatIf.TButton", + background="#FFFFFF", + foreground="#101B28", + bordercolor="#D8E0E4", + padding=(16, 9), + font=("Segoe UI", 10), + ) + style.map("WhatIf.TButton", background=[("active", "#EDF1F3")]) + style.configure( + "WhatIf.Primary.TButton", + background="#007D78", + foreground="#FFFFFF", + bordercolor="#007D78", + padding=(18, 9), + font=("Segoe UI", 10, "bold"), + ) + style.map("WhatIf.Primary.TButton", background=[("active", "#006965")]) + heading = tk.Frame(dialog, bg="#F5F7F7") + heading.pack(fill="x", padx=24, pady=(10, 6)) tk.Label( - dialog, - text="Синтетические свойства и запасы. Реальное управление отключено.", - fg="#A55E00", - font=("Segoe UI", 12, "bold"), - ).pack(pady=(14, 6)) + heading, + text="Синтетический what-if", + bg="#F5F7F7", + fg="#101B28", + font=("Segoe UI", 18, "bold"), + ).pack(anchor="w") tk.Label( - dialog, text="Пустое свойство означает неизвестное, а не ноль. Доли с присадкой = 100%." - ).pack() - notebook = ttk.Notebook(dialog) - notebook.pack(fill="both", expand=True, padx=16, pady=10) + heading, + text="Реальное управление отключено. Доли компонентов и присадки должны составлять 100 %.", + bg="#F5F7F7", + fg="#617082", + font=("Segoe UI", 10), + ).pack(anchor="w", pady=(2, 0)) + warning = tk.Label( + dialog, + text="Пустое свойство означает неизвестное значение, а не ноль.", + bg="#FFF8E8", + fg="#8C5400", + anchor="w", + font=("Segoe UI", 10, "bold"), + padx=16, + pady=6, + highlightbackground="#E9B850", + highlightthickness=1, + ) + warning.pack(fill="x", padx=24, pady=(0, 8)) + notebook = ttk.Notebook(dialog, style="WhatIf.TNotebook") + notebook.pack(fill="both", expand=True, padx=24, pady=(0, 8)) variables = { key: tk.StringVar(dialog, value=value) for key, value in editor_values(current).items() } - components = ttk.Frame(notebook, padding=12) - notebook.add(components, text="Компоненты и текущая рецептура") + recipe = ttk.Frame(notebook, padding=20, style="WhatIf.TFrame") + notebook.add(recipe, text="Рецептура") + ttk.Label( + recipe, + text="Введите значение. Свободные доли подберутся после Enter или выхода из поля.", + style="WhatIf.TLabel", + ).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 14)) + locks: dict[str, tk.BooleanVar] = {} + entries: dict[str, ttk.Entry] = {} + recipe_keys = [f"{item.id}.fraction" for item in preset.blend_components] + ["dose"] + for row, key in enumerate(recipe_keys, 1): + label = "Присадка, %" if key == "dose" else f"Компонент {key.split('.')[0]}, %" + ttk.Label(recipe, text=label, style="WhatIf.TLabel").grid( + row=row, column=0, sticky="w", pady=7 + ) + entry = ttk.Entry(recipe, textvariable=variables[key], width=18, style="WhatIf.TEntry") + entry.grid(row=row, column=1, padx=12, sticky="w") + entries[key] = entry + locks[key] = tk.BooleanVar(dialog, value=False) + ttk.Checkbutton(recipe, text="Зафиксировать", variable=locks[key]).grid( + row=row, column=2, sticky="w" + ) + ttk.Label(recipe, text="Масса партии, т", style="WhatIf.TLabel").grid( + row=4, column=0, sticky="w", pady=7 + ) + entries["total_mass_t"] = ttk.Entry( + recipe, textvariable=variables["total_mass_t"], width=18, style="WhatIf.TEntry" + ) + entries["total_mass_t"].grid(row=4, column=1, padx=12, sticky="w") + preview = tk.StringVar( + dialog, value="Исходный сценарий. Измените поле или нажмите «Подобрать смесь»." + ) + ttk.Label( + recipe, textvariable=preview, style="WhatIf.TLabel", wraplength=790, justify="left" + ).grid(row=5, column=0, columnspan=3, sticky="w", pady=(18, 6)) + variants = ttk.Combobox(recipe, state="readonly", width=38) + variants.grid(row=6, column=0, columnspan=2, sticky="w", pady=5) + variants.grid_remove() + components = ttk.Frame(notebook, padding=10, style="WhatIf.TFrame") + notebook.add(components, text="Свойства и запасы") for col, component in enumerate(preset.blend_components): - frame = ttk.LabelFrame(components, text=f"Компонент {component.id}", padding=10) + frame = ttk.LabelFrame( + components, + text=f"Компонент {component.id}", + padding=8, + style="WhatIf.TLabelframe", + ) frame.grid(row=0, column=col, sticky="nsew", padx=8) components.columnconfigure(col, weight=1) for row, (field, label) in enumerate(COMPONENT_FIELDS): - ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=6) - ttk.Entry(frame, textvariable=variables[f"{component.id}.{field}"], width=14).grid( - row=row, column=1, padx=10 + if field == "fraction": + continue + ttk.Label(frame, text=label, style="WhatIf.TLabel").grid( + row=row, column=0, sticky="w", pady=2 + ) + entry = ttk.Entry( + frame, + textvariable=variables[f"{component.id}.{field}"], + width=14, + style="WhatIf.TEntry", ) - additive = ttk.Frame(notebook, padding=20) - notebook.add(additive, text="Партия и присадка") + entry.grid(row=row, column=1, padx=10) + entries[f"{component.id}.{field}"] = entry + additive = ttk.Frame(notebook, padding=20, style="WhatIf.TFrame") + notebook.add(additive, text="Параметры присадки") for row, (key, label) in enumerate(ADDITIVE_FIELDS): - ttk.Label(additive, text=label).grid(row=row, column=0, sticky="w", pady=7) - ttk.Entry(additive, textvariable=variables[key], width=20).grid(row=row, column=1, padx=16) + if key in {"total_mass_t", "dose"}: + continue + ttk.Label(additive, text=label, style="WhatIf.TLabel").grid( + row=row, column=0, sticky="w", pady=7 + ) + entry = ttk.Entry(additive, textvariable=variables[key], width=20, style="WhatIf.TEntry") + entry.grid(row=row, column=1, padx=16) + entries[key] = entry error = tk.StringVar(dialog) - tk.Label(dialog, textvariable=error, fg="#B3473C", wraplength=880, justify="left").pack( - fill="x", padx=20 + tk.Label( + dialog, + textvariable=error, + bg="#F5F7F7", + fg="#B3473C", + wraplength=880, + justify="left", + ).pack(fill="x", padx=24) + + style.configure("WhatIf.Changed.TEntry", fieldbackground="#E2F6EF", padding=3) + assistant_on = tk.BooleanVar(dialog, value=True) + before_assist: dict[str, str] | None = None + options: tuple[tuple[BlendOption, dict[str, str]], ...] = () + pending: str | None = None + updating = False + + def values_now() -> dict[str, str]: + return {key: var.get() for key, var in variables.items()} + + def show_option(position: int) -> None: + nonlocal updating + option, updated = options[position] + old = values_now() + updating = True + try: + for key in recipe_keys: + variables[key].set(updated[key]) + entries[key].configure( + style=("WhatIf.Changed.TEntry" if updated[key] != old[key] else "WhatIf.TEntry") + ) + finally: + updating = False + names = { + **{f"{item.id}.fraction": f"Компонент {item.id}" for item in preset.blend_components}, + "dose": "Присадка", + } + changes = [ + f"{names[key]}: {old[key]}% → {updated[key]}%" + for key in recipe_keys + if old[key] != updated[key] + ] + quality = next( + (item.metrics for item in option.evaluation.assessments if "sulfur" in item.metrics), + {}, + ) + checks = [] + for constraint in preset.constraints: + metric = quality.get(constraint.metric) + if metric is None: + continue + bound = ( + "upper" + if constraint.use_upper_estimate + else "lower" + if constraint.use_lower_estimate + else "value" + ) + value = getattr(metric, bound) + limit = constraint.upper if constraint.upper is not None else constraint.lower + if value is not None and limit is not None: + sign = "≤" if constraint.upper is not None else "≥" + label = {"sulfur": "Сера", "t95": "T95", "cetane_number": "ЦЧ"}.get( + constraint.metric, constraint.metric + ) + checks.append(f"{label}: {value:.2f} {sign} {limit:g}") + preview.set( + f"{option.label}. " + + ("; ".join(changes) if changes else "Смесь уже подходит.") + + ("\nПроверка: " + "; ".join(checks) if checks else "") + ) + error.set("") + + def pick(_: tk.Event[tk.Misc] | None = None) -> None: + if variants.current() >= 0: + show_option(variants.current()) + + variants.bind("<>", pick) + + def suggest(changed: str | None = None) -> None: + nonlocal options, before_assist, pending + pending = None + if updating or not assistant_on.get(): + return + raw = values_now() + fixed = frozenset(key for key, locked in locks.items() if locked.get()) + if changed in locks: + fixed |= {changed} + try: + options = assist_editor_values(preset, raw, fixed) + except ValueError as exc: + options = () + variants.grid_remove() + error.set(str(exc)) + preview.set("Автоподбор не смог составить допустимую смесь.") + return + before_assist = raw + variants["values"] = tuple(item.label for item, _ in options) + variants.current(0) + variants.grid() + show_option(0) + + def schedule(changed: str) -> None: + nonlocal pending + if pending is not None: + dialog.after_cancel(pending) + pending = dialog.after(400, lambda: suggest(changed)) + + def on_return(_event: tk.Event[tk.Misc], *, name: str) -> None: + suggest(name) + + def on_focus_out(_event: tk.Event[tk.Misc], *, name: str) -> None: + schedule(name) + + for key, entry in entries.items(): + entry.bind("", partial(on_return, name=key)) + entry.bind("", partial(on_focus_out, name=key)) + + def undo_assist() -> None: + nonlocal updating, before_assist + if before_assist is None: + return + updating = True + try: + for key in recipe_keys: + if key not in locks or not locks[key].get(): + variables[key].set(before_assist[key]) + entries[key].configure(style="WhatIf.TEntry") + finally: + updating = False + before_assist = None + preview.set("Автоподбор отменён. Введённое значение сохранено.") + variants.grid_remove() + + ttk.Checkbutton(recipe, text="Автоподбор", variable=assistant_on).grid( + row=7, column=0, sticky="w", pady=(12, 0) + ) + ttk.Button(recipe, text="Подобрать смесь", command=suggest).grid( + row=7, column=1, sticky="w", pady=(12, 0) + ) + ttk.Button(recipe, text="Отменить автоподбор", command=undo_assist).grid( + row=7, column=2, sticky="w", pady=(12, 0) ) def apply() -> None: @@ -182,20 +509,42 @@ def apply() -> None: except ValueError as exc: error.set(str(exc)) return - calculate(scenario) + if calculate_with_context is None: + calculate(scenario) + else: + calculate_with_context( + scenario, + { + "original_values": before_assist, + "displayed_values": values_now(), + "locked_fields": [key for key, value in locks.items() if value.get()], + "assisted": before_assist is not None, + }, + ) dialog.destroy() def reset() -> None: + nonlocal before_assist for key, value in editor_values(preset).items(): variables[key].set(value) + for lock_var in locks.values(): + lock_var.set(False) + before_assist = None + variants.grid_remove() error.set( "Восстановлены исходные поля сценария. " "Нажмите «Пересчитать what-if» для нового результата." ) - buttons = ttk.Frame(dialog, padding=16) + buttons = ttk.Frame(dialog, padding=(24, 8), style="WhatIf.Footer.TFrame") buttons.pack(fill="x") - ttk.Button(buttons, text="Пересчитать what-if", command=apply).pack(side="left") - ttk.Button(buttons, text="Сбросить к сценарию", command=reset).pack(side="left", padx=10) - ttk.Button(buttons, text="Закрыть без изменений", command=dialog.destroy).pack(side="right") + ttk.Button( + buttons, text="Пересчитать what-if", command=apply, style="WhatIf.Primary.TButton" + ).pack(side="left") + ttk.Button(buttons, text="Сбросить к сценарию", command=reset, style="WhatIf.TButton").pack( + side="left", padx=10 + ) + ttk.Button( + buttons, text="Закрыть без изменений", command=dialog.destroy, style="WhatIf.TButton" + ).pack(side="right") return dialog From 059dd147d0a7197de85f7cc6d6109634003d34c8 Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Fri, 25 Sep 2026 23:10:36 +0300 Subject: [PATCH 02/11] ci: provide required aggregate lint-and-test check --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 059c42d..06a815b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,9 @@ on: pull_request: branches: [ main, dev ] -jobs: - lint-and-test: +jobs: + test-matrix: + name: lint-and-test (${{ matrix.os }}, ${{ matrix.python }}) strategy: fail-fast: false matrix: @@ -53,3 +54,12 @@ jobs: run: | python -m source.main validate-stage0 python -m source.main acceptance --output reports/ci-acceptance + + lint-and-test: + name: lint-and-test + runs-on: ubuntu-latest + needs: test-matrix + if: always() + steps: + - name: Require both platform checks + run: test '${{ needs.test-matrix.result }}' = 'success' From cdb2602567b3b3e50fa3eaee672aef78860a5451 Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Fri, 25 Sep 2026 23:49:44 +0300 Subject: [PATCH 03/11] feat(ui): clarify blend workflow and use native controls --- README.md | 4 +- docs/DEMO_SCRIPT.md | 31 ++-- global_tests/test_ui.py | 5 + global_tests/test_ui_interactive.py | 27 +++- source/ui.py | 222 +++++++++++++++------------- source/ui_what_if.py | 203 ++++++++++++++++--------- 6 files changed, 299 insertions(+), 193 deletions(-) diff --git a/README.md b/README.md index 8d9dd64..f3bdab2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Python, установка библиотек, обучение моделей | Раздел | Что доступно | | --- | --- | -| Обзор и Смесь | Шесть сценариев и редактор «Синтетический what-if»: автоподбор рецептуры, свойства компонентов, запасы и присадка | +| Обзор и Смесь | Шесть сценариев и кнопка «Настроить смесь»: автоподбор рецептуры, свойства компонентов, запасы и присадка | | АВТ и Гидроочистка | Исторические сигналы, единицы, источники и свежесть | | История/ML | График прогноза и верхней оценки, выбор периода, прогресс, отмена и экспорт | | Hybrid и v2 | Условное смешение по сере и исследовательские предупреждения | @@ -55,7 +55,7 @@ Python, установка библиотек, обучение моделей ## Для разработчика -Редактор синтетической смеси предлагает допустимые доли после изменения поля, сохраняет закреплённые значения и показывает варианты «ближе», «дешевле» и «меньше присадки». Для проверки откройте «Обзор» → «Смесь» → «Синтетический what-if», измените долю компонента и нажмите Enter или «Подобрать смесь». «Отменить автоподбор» возвращает введённые значения; переключатель «Автоподбор» оставляет ручной режим. +Редактор синтетической смеси предлагает допустимые доли после изменения поля, сохраняет поля «Не менять» и показывает варианты «ближе», «дешевле» и «меньше присадки». Для проверки откройте «Обзор» → «Настроить смесь», измените долю компонента и нажмите Enter или «Подобрать смесь». «Отменить подбор» возвращает введённые значения; переключатель «Подбирать после изменения» включает и выключает автоматический подбор. Кнопка «Подобрать смесь» работает и при выключенном переключателе. В `main` находится сдаваемая версия без дневников разработки. Планы этапов, старые ревью и журнал проблем сохранены в [ветке dev](https://github.com/bug00n/HackathonPetrolCode/tree/dev); конкретная поставка закреплена тегом релиза. В Git хранятся исходники, конфигурации, тесты и технические материалы. EXE, презентация, Word-документ, подготовленные данные и модели поставляются через Releases. Уведомления сторонних компонентов сохранены в [third_party/licenses](third_party/licenses). diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index ade2db0..c8e7be9 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -35,7 +35,7 @@ ### Какие данные можно менять -Готовые модельные сценарии можно рассчитать без изменений или открыть «Синтетический what-if» на «Обзоре» либо «Смеси». Редактор позволяет менять синтетические свойства, запасы, текущие доли и модель присадки. Файлы готовых сценариев и реальные исторические измерения при этом не меняются. +Готовые модельные сценарии можно рассчитать без изменений или открыть «Настроить смесь» на «Обзоре» либо «Смеси». Редактор позволяет менять синтетические свойства, запасы, текущие доли и модель присадки. Файлы готовых сценариев и реальные исторические измерения при этом не меняются. В исторических формах можно менять путь к подготовленным данным и момент времени. «Исторические данные» означает папку подготовленного набора, а не исходный CSV или архив. «Прогноз серы» означает путь к сохранённой модели, а не численное значение серы. Для штатного показа оставьте автоматически заполненные пути; используйте только доверенные совместимые модели. @@ -74,7 +74,7 @@ ### Последний модельный расчёт -Откройте «Смесь» после расчёта на «Обзоре». Экран показывает рекомендацию, текущие и выбранные массовые доли и массы компонентов с присадкой, ожидаемое качество и проверку ограничений. Сумма долей вместе с присадкой равна 100%. Таблица результата не редактируется. Для своих входных значений нажмите «Синтетический what-if». Чтобы вернуться к готовому примеру, выберите его на «Обзоре» и нажмите «Рассчитать». +Откройте «Смесь» после расчёта на «Обзоре». Экран показывает модельный вариант, текущие и выбранные массовые доли и массы компонентов с присадкой, ожидаемое качество и проверку ограничений. Сумма долей вместе с присадкой равна 100%. Таблица результата не редактируется. Для своих входных значений нажмите «Настроить смесь». Чтобы вернуться к готовому примеру, выберите его на «Обзоре» и нажмите «Рассчитать». В «Повышенной сере» выбранная рецептура: A 89,1%, B 9,9%, присадка 1%. Для партии 100 т это 89,1 т, 9,9 т и 1 т. Проверьте серу сверху 9,306 мг/кг, T95 сверху 354 °C и цетановое число снизу 52,6. @@ -95,9 +95,9 @@ Hybrid проверяет только серу. Он не подтвержда ## Синтетический редактор what-if -Нажмите «Синтетический what-if» на «Обзоре» или «Смеси». За основу берётся выбранный готовый сценарий. При повторном открытии после успешного пересчёта редактор показывает последние изменённые входы этого сценария. Это эксперимент с модельными допущениями, а не правка истории предприятия. +Нажмите «Настроить смесь» на «Обзоре» или «Смеси». За основу берётся выбранный готовый сценарий. При повторном открытии после успешного пересчёта редактор показывает последние изменённые входы этого сценария. Это эксперимент с модельными допущениями, а не правка истории предприятия. -На первой вкладке «Рецептура» задайте доли A, B, присадки и массу партии. После Enter или выхода из поля редактор подбирает допустимые значения остальных долей, оставляя последнее изменённое значение неизменным. Галочка «Зафиксировать» удерживает долю и при следующих правках. В списке вариантов можно выбрать смесь ближе к введённой, с меньшей стоимостью или меньшим количеством присадки. Под списком показаны изменения и проверки серы, T95 и цетанового числа. Если смесь невозможна при закреплённых полях или запасах, редактор объясняет причину. +На первой вкладке «Рецептура» задайте доли A, B, присадки и массу партии. После Enter или выхода из поля редактор подбирает допустимые значения остальных долей, оставляя последнее изменённое значение неизменным. Галочка «Не менять» удерживает долю и при следующих правках. Справа показаны изменения, проверки серы, T95 и цетанового числа, а в списке можно выбрать смесь ближе к введённой, с меньшей стоимостью или меньшим количеством присадки. Если смесь невозможна при закреплённых полях или запасах, редактор объясняет причину. На вкладке «Свойства и запасы» для A и B задаются сера и её верхняя оценка (мг/кг), T95 и его верхняя оценка (°C), цетановое число и его нижняя оценка, запас (т), стоимость (условный индекс/т) и риск от 0 до 1. Пустое поле качества означает неизвестное значение и может привести к отказу; запас и другие обязательные числа оставлять пустыми нельзя. @@ -106,10 +106,10 @@ Hybrid проверяет только серу. Он не подтвержда | Кнопка | Действие | | --- | --- | | Подобрать смесь | Пересчитывает допустимые варианты с учётом закреплённых значений | -| Отменить автоподбор | Возвращает введённые перед подбором значения; ручной режим также доступен через переключатель «Автоподбор» | -| Пересчитать what-if | Проверяет поля, закрывает редактор и оценивает именно показанную смесь; результат появляется на «Смеси» с отметкой what-if | -| Сбросить к сценарию | Восстанавливает исходные поля; затем нажмите «Пересчитать what-if», чтобы обновить результат | -| Закрыть без изменений | Закрывает редактор без применения введённых значений; крестик действует так же | +| Отменить подбор | Возвращает введённые перед подбором значения; ручной режим также доступен через переключатель «Подбирать после изменения» | +| Рассчитать смесь | Проверяет поля, закрывает редактор и оценивает именно показанную смесь; результат появляется на «Смеси» с отметкой синтетического сценария | +| Сбросить | Восстанавливает исходные поля; затем нажмите «Рассчитать смесь», чтобы обновить результат | +| Закрыть | Закрывает редактор без применения введённых значений; крестик действует так же | Сумма текущих долей A, B и присадки должна равняться 100%. Верхняя оценка не может быть ниже точки, нижняя — выше. Числа должны быть конечными и неотрицательными; допустимы точка и запятая. Ошибка ввода остаётся в окне. Нехватка запасов, пропущенное качество или отсутствие допустимой смеси дают объяснённый отказ, а не вымышленную рекомендацию. @@ -119,9 +119,9 @@ Hybrid проверяет только серу. Он не подтвержда ## История и экспорт -Откройте «История/ML». В готовой поставке данные и основной прогноз уже выбраны. «Момент данных (ISO с timezone)» задаёт время одиночного расчёта или начало интервала. «Конец исторического интервала (ISO с timezone)» нужен только для интервала. Технические пути скрыты в свёрнутом блоке «Дополнительные настройки данных и модели». Нажмите заголовок, только если нужно сменить подготовленные данные или доверенную модель. Поле «Артефакт действий (не используется)» оставьте пустым: штатная поставка не включает модель реальных воздействий. +Откройте «История/ML». В готовой поставке данные и основной прогноз уже выбраны. «Момент прогноза / начало интервала» задаёт время одиночного расчёта или начало интервала. «Конец интервала» нужен только для интервала; под полями показан формат с обязательным часовым поясом. Технические пути скрыты в свёрнутом блоке «Дополнительные настройки данных и модели». Нажмите заголовок, только если нужно сменить подготовленные данные или доверенную модель. Поле «Артефакт действий (не используется)» оставьте пустым: штатная поставка не включает модель реальных воздействий. -Для одной точки задайте `2025-06-01T12:00:00+03:00` и нажмите «Рассчитать на выбранный момент». Поле конца при этом не используется. Основной прогноз оценивает серу через 60 минут по данным, доступным на выбранный момент. +Для одной точки задайте `2025-06-01T12:00:00+03:00` и нажмите «Рассчитать момент». Поле конца при этом не используется. Основной прогноз оценивает серу через 60 минут по данным, доступным на выбранный момент. Для интервала оставьте это начало, задайте конец `2025-06-01T13:00:00+03:00` и нажмите «Рассчитать интервал». Получатся две исходные точки, 12:00 и 13:00, каждая со своим прогнозом на 60 минут вперёд. Конец не должен быть раньше начала. Интервал ограничен 10 000 точками. @@ -129,19 +129,20 @@ Hybrid проверяет только серу. Он не подтвержда | Кнопка | Что происходит | | --- | --- | -| Рассчитать на выбранный момент | Запускается одиночный исторический расчёт | +| Рассчитать момент | Запускается одиночный исторический расчёт | | Рассчитать интервал | Запускаются последовательные точки с отображением прогресса | | Отменить интервал | Отмена проверяется после текущей точки; завершённые точки остаются в явно обозначенном частичном результате | -| Экспортировать этот результат | Сохраняется результат точки или интервала, в том числе частичного, в JSON | -| Журнал | Открываются сохранённые запуски; переход с истории также запрашивает отмену текущего интервала | +| Экспорт результата | Сохраняется результат точки или интервала, в том числе частичного, в JSON | + +«Журнал» доступен в верхней навигации; переход с истории также запрашивает отмену текущего интервала. Во время расчёта оставайтесь на странице истории и следите за прогрессом. Первичная загрузка занимает время. Отмена не прерывает уже вычисляемую точку мгновенно. После запуска нового расчёта экспорт истории недоступен до получения нового результата. Не закрывайте приложение до завершения расчёта, если нужен результат. -Контрольный пример на 12:00: прогноз 10,15656 мг/кг, верхняя оценка 10,38809 мг/кг. На 13:00: 9,00645 и 10,27399 мг/кг. Отсутствие реального управляющего действия ожидаемо в обеих точках. График показывает точечный прогноз серы бирюзовым, верхнюю оценку синим и предел 10 мг/кг пунктиром. По горизонтали указано время, на которое сделан прогноз: для исходных 12:00 и 13:00 это 13:00 и 14:00. Наведите мышь для точных значений. Пропуски не соединяются линией; это прогноз, а не измеренный факт. Ниже остаются подробности JSON, включая время, модель, причины ограничений и журнал. +Контрольный пример на 12:00: прогноз 10,15656 мг/кг, верхняя оценка 10,38809 мг/кг. На 13:00: 9,00645 и 10,27399 мг/кг. Отсутствие реального управляющего действия ожидаемо в обеих точках. График показывает точечный прогноз серы бирюзовым, верхнюю оценку синим и предел 10 мг/кг пунктиром. По горизонтали указано время, на которое сделан прогноз: для исходных 12:00 и 13:00 это 13:00 и 14:00. Наведите мышь для точных значений. Пропуски не соединяются линией; это прогноз, а не измеренный факт. Ниже показаны число рассчитанных точек, покрытие и статусы; затем остаются подробности JSON, включая время, модель и причины ограничений. ### Три отдельных экспорта -«Скачать расчёт» на «Смеси» сохраняет модельную рекомендацию; для what-if JSON также содержит изменённый сценарий в `synthetic_scenario`. «Экспортировать hybrid» сохраняет условную смесь по историческому прогнозу. «Экспортировать этот результат» на «Истории/ML» сохраняет историческую точку или интервал. Каждая кнопка открывает выбор файла JSON; отмена диалога ничего не сохраняет. PDF, Excel и общий архив всех запусков эти кнопки не создают. +«Скачать расчёт» на «Смеси» сохраняет модельную рекомендацию; для what-if JSON также содержит изменённый сценарий в `synthetic_scenario`. «Экспортировать hybrid» сохраняет условную смесь по историческому прогнозу. «Экспорт результата» на «Истории/ML» сохраняет историческую точку или интервал. Каждая кнопка открывает выбор файла JSON; отмена диалога ничего не сохраняет. PDF, Excel и общий архив всех запусков эти кнопки не создают. ## Дополнительные инструменты diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index 835ee99..a14f0c2 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -418,6 +418,11 @@ def descendants(widget): assert app._history_export_payload == payload app.show_page("journal") app.show_page("history") + assert any( + "Рассчитано точек: 2 из 2" in widget.get("1.0", "end") + for widget in descendants(app) + if isinstance(widget, tk.Text) + ) assert any( '"points": 2' in widget.get("1.0", "end") for widget in descendants(app) diff --git a/global_tests/test_ui_interactive.py b/global_tests/test_ui_interactive.py index 4ef29f8..5c3c97a 100644 --- a/global_tests/test_ui_interactive.py +++ b/global_tests/test_ui_interactive.py @@ -142,12 +142,14 @@ def test_editor_reset_validation_and_chart_on_real_tk() -> None: first = entries[0] first.delete(0, "end") first.insert(0, "bad input") - buttons = {w.cget("text"): w for w in descendants(dialog) if isinstance(w, ttk.Button)} - buttons["Пересчитать what-if"].invoke() + buttons = { + w.cget("text"): w for w in descendants(dialog) if isinstance(w, (tk.Button, ttk.Button)) + } + buttons["Рассчитать смесь"].invoke() assert not received and dialog.winfo_exists() - buttons["Сбросить к сценарию"].invoke() + buttons["Сбросить"].invoke() assert first.get() == editor_values(preset)["A.fraction"] - buttons["Пересчитать what-if"].invoke() + buttons["Рассчитать смесь"].invoke() assert len(received) == 1 and received[0].id.endswith("_what_if") chart = HistoryChart(root) chart.set_payload(interval_payload()) @@ -178,22 +180,33 @@ def test_editor_assist_and_undo_on_real_tk() -> None: locks = [ widget for widget in descendants(dialog) - if isinstance(widget, ttk.Checkbutton) and widget.cget("text") == "Зафиксировать" + if isinstance(widget, ttk.Checkbutton) and widget.cget("text") == "Не менять" ] locks[1].invoke() buttons = { widget.cget("text"): widget for widget in descendants(dialog) - if isinstance(widget, ttk.Button) + if isinstance(widget, (tk.Button, ttk.Button)) } buttons["Подобрать смесь"].invoke() assert b_share.get() == "10" assert float(entries[0].get()) + float(b_share.get()) + float( entries[2].get() ) == pytest.approx(100) - buttons["Отменить автоподбор"].invoke() + buttons["Отменить подбор"].invoke() assert b_share.get() == "10" assert entries[0].get() == editor_values(preset)["A.fraction"] + auto = next( + widget + for widget in descendants(dialog) + if isinstance(widget, ttk.Checkbutton) + and widget.cget("text") == "Подбирать после изменения" + ) + auto.invoke() + buttons["Подобрать смесь"].invoke() + assert float(entries[0].get()) + float(b_share.get()) + float( + entries[2].get() + ) == pytest.approx(100) dialog.destroy() finally: root.destroy() diff --git a/source/ui.py b/source/ui.py index e086eea..5332d0a 100644 --- a/source/ui.py +++ b/source/ui.py @@ -59,7 +59,7 @@ SURFACE = "#FFFFFF" TEXT = "#101B28" MUTED = "#617082" -HEADER = "#202B31" +HEADER = "#FFFFFF" TEAL = "#007D78" TEAL_HOVER = "#006965" AMBER = "#D88400" @@ -250,7 +250,7 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> component = changed[0] if changed else "смеси" banner_title = "Доступен модельный вариант" banner_detail = "Расчёт относится только к синтетическому сценарию блендинга." - action_title = f"Увеличить долю компонента {component}" + action_title = f"Вариант: увеличить долю компонента {component}" action_detail = ( "Текущая рецептура проходит проверки; вариант улучшает модельные критерии." if result.baseline is not None and result.baseline.feasible @@ -434,6 +434,38 @@ def format_history_replay_view(view: UiHistoryReplayView) -> str: return "\n".join(rows) +def format_history_interval_payload(payload: dict[str, object]) -> str: + """Put the useful interval result before its technical export data.""" + points = payload.get("points", 0) + requested = payload.get("requested_points", points) + rows = [ + "Частичный исторический интервал" if payload.get("cancelled") else "Исторический интервал", + f"Рассчитано точек: {points} из {requested}", + ] + if payload.get("start") and payload.get("end"): + rows.append(f"Период: {payload['start']} — {payload['end']}") + coverage = payload.get("forecast_coverage") + if isinstance(coverage, (int, float)): + rows.append(f"Прогноз серы доступен в {coverage:.0%} точек") + counts = payload.get("status_counts") + if isinstance(counts, dict) and counts: + rows.append( + "Статусы: " + + ", ".join( + f"{STATUS_RU.get(str(key), key)} — {value}" for key, value in counts.items() + ) + ) + rows.extend( + ( + "Реальные управляющие действия отключены.", + "", + "Технические данные расчёта (JSON):", + json.dumps(payload, 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 = [ @@ -538,7 +570,9 @@ def _scenario_label(scenario_id: str) -> str: def _build_styles(self) -> None: style = ttk.Style(self) - style.theme_use("clam") + style.theme_use( + "vista" if sys.platform == "win32" and "vista" in style.theme_names() else "clam" + ) style.configure( "Petrol.Treeview", background=SURFACE, @@ -568,17 +602,17 @@ def _build_styles(self) -> None: ) def _build_shell(self) -> None: - self.header = tk.Frame(self, bg=HEADER, height=60) + self.header = tk.Frame(self, bg=HEADER, height=58) 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)) + fg=TEXT, + font=("Segoe UI", 17, "bold"), + ).pack(side="left", padx=(24, 18)) + tk.Frame(self.header, bg=BORDER, width=1, height=24).pack(side="left", padx=(0, 10)) for key, label in ( ("overview", "Обзор"), ("avt", "АВТ"), @@ -592,23 +626,24 @@ def _build_shell(self) -> None: text=label, command=partial(self.show_page, key), bg=HEADER, - fg="#C0C8CD", - activebackground=HEADER, - activeforeground="white", + fg=MUTED, + activebackground=SOFT, + activeforeground=TEAL, bd=0, - padx=18, - font=("Segoe UI", 11, "bold"), + padx=12, + font=("Segoe UI", 10, "bold"), cursor="hand2", ) button.pack(side="left", fill="y") self._nav_buttons[key] = button tk.Label( self.header, - text="Модельный контур • реальные уставки отключены", + text="ДЕМО · управление отключено", bg=HEADER, - fg="#B8C3C9", - font=("Segoe UI", 10), - ).pack(side="right", padx=32) + fg=MUTED, + font=("Segoe UI", 9), + ).pack(side="right", padx=24) + tk.Frame(self, bg=BORDER, height=1).pack(fill="x") self.body = tk.Frame(self, bg=BG) self.body.pack(fill="both", expand=True) self.show_page(self._page) @@ -620,8 +655,8 @@ def _clear_body(self) -> None: def _set_nav(self, page: str) -> None: for key, button in self._nav_buttons.items(): button.configure( - fg="white" if key == page else "#C0C8CD", - bg="#31454B" if key == page else HEADER, + fg=TEAL if key == page else MUTED, + bg=SOFT if key == page else HEADER, ) def show_page(self, page: str) -> None: @@ -1018,20 +1053,23 @@ def _render_overview(self) -> None: title.pack(side="left") tk.Label( title, - text="Состояние установки", + text="Модельный расчёт смеси", bg=BG, fg=TEXT, font=("Segoe UI", 28, "bold"), ).pack(anchor="w") tk.Label( title, - text="Текущий модельный сценарий, расчёт и ограничения", + text="Выберите синтетический сценарий или настройте свою смесь", bg=BG, fg=MUTED, font=("Segoe UI", 12), ).pack(anchor="w") - controls = tk.Frame(top, bg=BG) - controls.pack(side="right", pady=8) + controls = self._surface(page) + controls.pack(fill="x", pady=(20, 0), ipady=12) + tk.Label( + controls, text="СЦЕНАРИЙ", bg=SURFACE, fg=MUTED, font=("Segoe UI", 9, "bold") + ).pack(side="left", padx=(18, 10)) scenario = ttk.Combobox( controls, values=tuple(SCENARIO_LABELS), @@ -1040,51 +1078,52 @@ def _render_overview(self) -> None: 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) + scenario.pack(side="left", padx=(0, 18)) + tk.Label(controls, text="Горизонт: 60 мин", bg=SURFACE, fg=MUTED).pack(side="left") button = self._primary_button(controls, "Рассчитать", self.calculate) - button.pack(side="left", padx=(8, 0)) - self._secondary_button(page, "Синтетический what-if", self.open_what_if_dialog).pack( - anchor="w", pady=(10, 0) + button.pack(side="right", padx=(8, 18)) + self._secondary_button(controls, "Настроить смесь", self.open_what_if_dialog).pack( + side="right", padx=(8, 0) ) view = self._view() + banner_colors = { + "hold": ("#EDF7F3", "#B7DACC", GREEN, "РЕЖИМ БЕЗ ИЗМЕНЕНИЙ"), + "recommend": ("#FFF8E8", "#E9B850", AMBER, "МОДЕЛЬНЫЙ ВАРИАНТ"), + "abstain": ("#FFF4F1", "#E5B5AD", RED, "НУЖНА ПРОВЕРКА"), + } + banner_bg, banner_border, banner_accent, banner_label = banner_colors.get( + view.status if view else "", (SOFT, BORDER, MUTED, "ОЖИДАНИЕ РАСЧЁТА") + ) banner = tk.Frame( page, - bg=AMBER_BG, - highlightbackground="#E9B850", + bg=banner_bg, + highlightbackground=banner_border, highlightthickness=1, ) - banner.pack(fill="x", pady=(22, 16), ipady=12) + banner.pack(fill="x", pady=(16, 16)) + tk.Frame(banner, bg=banner_accent, width=4).pack(side="left", fill="y") + banner_text = tk.Frame(banner, bg=banner_bg) + banner_text.pack(side="left", fill="x", expand=True, padx=18, pady=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") + banner_text, + text=banner_label, + bg=banner_bg, + fg=banner_accent, + font=("Segoe UI", 9, "bold"), + ).pack(anchor="w") 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") + bg=banner_bg, + fg=TEXT, + font=("Segoe UI", 12, "bold"), + ).pack(anchor="w", pady=(2, 0)) tk.Label( banner_text, text=view.banner_detail if view else "Расчёт ещё не выполнен.", - bg=AMBER_BG, - fg="#536171", + bg=banner_bg, + fg=MUTED, font=("Segoe UI", 10), ).pack(anchor="w") @@ -1102,27 +1141,6 @@ def _render_overview(self) -> None: 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, target in ( - ("АВТ", "avt"), - ("Гидроочистка", "hydrotreating"), - ("Смесь", "blend"), - ): - command = partial(self.show_page, target) - button = tk.Button( - stages, - text=text, - command=command, - bg=TEAL, - fg="white", - activebackground=TEAL_HOVER, - bd=0, - pady=8, - font=("Segoe UI", 10, "bold"), - cursor="hand2", - ) - button.pack(side="left", fill="x", expand=True, padx=3) self._accordion( page, "Источники, проверка конфигурации и подготовка данных", @@ -1258,7 +1276,15 @@ def _render_info(self, parent: tk.Frame, view: DashboardView | None) -> None: anchor="w", padx=22, pady=(20, 12) ) self._info_row(parent, "Режим", "Модельный") - self._info_row(parent, "Сценарий", self._scenario.id if self._scenario else "—") + self._info_row( + parent, + "Сценарий", + "Изменённая смесь" + if self._scenario and self._scenario.id.endswith("_what_if") + else self._scenario_label(self._scenario.id) + if self._scenario + else "—", + ) 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) @@ -1328,8 +1354,15 @@ def _render_history(self) -> None: 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, "Момент данных (ISO с timezone)", as_of_var) - self._field(right, "Конец исторического интервала (ISO с timezone)", interval_to_var) + self._field(left, "Момент прогноза / начало интервала", as_of_var) + self._field(right, "Конец интервала", interval_to_var) + tk.Label( + fields, + text="Формат времени: 2025-06-01T12:00:00+03:00 · часовой пояс обязателен", + bg=SURFACE, + fg=MUTED, + font=("Segoe UI", 9), + ).pack(anchor="w", padx=20, pady=(0, 12)) def settings(parent: tk.Misc) -> None: self._field(parent, "Исторические данные", dataset_var) @@ -1511,12 +1544,7 @@ def work() -> None: payload = history_interval_command( dataset, model, start, end, progress=progress, cancelled=cancel.is_set ) - title = ( - "Интервал отменён. Частичный результат" - if payload.get("cancelled") - else "Исторический интервал" - ) - text = title + "\n\n" + json.dumps(payload, ensure_ascii=False, indent=2) + text = format_history_interval_payload(payload) except Exception as exc: text = f"Ошибка интервала: {exc}" @@ -1551,22 +1579,16 @@ def finish() -> None: actions = tk.Frame(page, bg=BG) actions.pack(fill="x", pady=(0, 14), before=chart_panel) - for column in range(3): - actions.grid_columnconfigure(column, weight=1) - self._primary_button(actions, "Рассчитать на выбранный момент", start_calculation).grid( - row=0, column=0, sticky="ew", padx=(0, 8), pady=(0, 8) + self._primary_button(actions, "Рассчитать момент", start_calculation).pack( + side="left", padx=(0, 8) ) interval_button = self._secondary_button(actions, "Рассчитать интервал", calculate_interval) - interval_button.configure(fg=TEAL, font=("Segoe UI", 11, "bold")) - interval_button.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=(0, 8)) + interval_button.pack(side="left", padx=(0, 8)) self._secondary_button( actions, "Отменить интервал", lambda: self._interval_cancel.set() - ).grid(row=0, column=2, sticky="ew", pady=(0, 8)) - self._secondary_button( - actions, "Экспортировать этот результат", self.export_history_result - ).grid(row=1, column=0, sticky="ew", padx=(0, 8)) - self._secondary_button(actions, "Журнал", lambda: self.show_page("journal")).grid( - row=1, column=1, sticky="ew", padx=(0, 8) + ).pack(side="left") + self._secondary_button(actions, "Экспорт результата", self.export_history_result).pack( + side="right" ) render( self._history_view @@ -1709,16 +1731,16 @@ def _render_recommendation(self) -> None: heading.pack(fill="x") tk.Label( heading, - text="Рекомендация по смешению", + text="Результат подбора смеси", bg=BG, fg=TEXT, font=("Segoe UI", 28, "bold"), ).pack(side="left") badge = tk.Label( heading, - text="⚠ Синтетический what-if" + text="Синтетическая смесь" if self._scenario and self._scenario.id.endswith("_what_if") - else "⚠ Модельный режим", + else "Модельный сценарий", bg=AMBER_BG, fg="#A55E00", font=("Segoe UI", 10, "bold"), @@ -1836,7 +1858,7 @@ def _render_recommendation(self) -> None: 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, "Синтетический what-if", self.open_what_if_dialog).pack( + self._secondary_button(footer, "Настроить смесь", self.open_what_if_dialog).pack( side="left", padx=10 ) self._secondary_button(footer, "Открыть журнал", lambda: self.show_page("journal")).pack( @@ -1844,7 +1866,7 @@ def _render_recommendation(self) -> None: ) tk.Label( footer, - text="НЕФТЕКОД v1.1 | prototype", + text="НЕФТЕКОД · модельный прототип", bg=BG, fg=MUTED, font=("Segoe UI", 9), diff --git a/source/ui_what_if.py b/source/ui_what_if.py index 61ac97d..0e1fd0f 100644 --- a/source/ui_what_if.py +++ b/source/ui_what_if.py @@ -13,14 +13,14 @@ COMPONENT_FIELDS = ( ("sulfur.value", "Сера, мг/кг"), - ("sulfur.upper", "Сера сверху, мг/кг"), + ("sulfur.upper", "Сера · верхняя оценка, мг/кг"), ("t95.value", "T95, °C"), - ("t95.upper", "T95 сверху, °C"), + ("t95.upper", "T95 · верхняя оценка, °C"), ("cetane_number.value", "Цетановое число"), - ("cetane_number.lower", "Цетановое снизу"), + ("cetane_number.lower", "Цетановое · нижняя оценка"), ("available_mass_t", "Запас, т"), - ("cost_proxy_per_t", "Стоимость, индекс/т"), - ("risk_index", "Риск, от 0 до 1"), + ("cost_proxy_per_t", "Стоимость, усл. ед./т"), + ("risk_index", "Риск, 0–1"), ("fraction", "Текущая доля, %"), ) ADDITIVE_FIELDS = ( @@ -28,7 +28,7 @@ ("dose", "Текущая присадка, %"), ("max_dose", "Максимум присадки, % (1, 2 или 3)"), ("additive_stock", "Запас присадки, т"), - ("additive_cost", "Стоимость присадки, индекс/т"), + ("additive_cost", "Стоимость присадки, усл. ед./т"), ("gain_1", "Прирост цетанового числа при 1%"), ("gain_2", "Прирост цетанового числа при 2%"), ("gain_3", "Прирост цетанового числа при 3%"), @@ -199,9 +199,9 @@ def open_editor( calculate_with_context: Callable[[ScenarioConfig, dict[str, object]], None] | None = None, ) -> tk.Toplevel: dialog = tk.Toplevel(parent) - dialog.title("Синтетический what-if — не реальные данные") + dialog.title("Подбор смеси — синтетический сценарий") dialog.geometry("940x700") - dialog.minsize(800, 700) + dialog.minsize(800, 660) dialog.transient(parent) dialog.configure(bg="#F5F7F7") style = ttk.Style(dialog) @@ -240,34 +240,27 @@ def open_editor( font=("Segoe UI", 10), ) style.map("WhatIf.TButton", background=[("active", "#EDF1F3")]) - style.configure( - "WhatIf.Primary.TButton", - background="#007D78", - foreground="#FFFFFF", - bordercolor="#007D78", - padding=(18, 9), - font=("Segoe UI", 10, "bold"), - ) - style.map("WhatIf.Primary.TButton", background=[("active", "#006965")]) heading = tk.Frame(dialog, bg="#F5F7F7") heading.pack(fill="x", padx=24, pady=(10, 6)) tk.Label( heading, - text="Синтетический what-if", + text="Подбор смеси", bg="#F5F7F7", fg="#101B28", font=("Segoe UI", 18, "bold"), ).pack(anchor="w") tk.Label( heading, - text="Реальное управление отключено. Доли компонентов и присадки должны составлять 100 %.", + text="Синтетический сценарий · управление установкой отключено", bg="#F5F7F7", fg="#617082", font=("Segoe UI", 10), ).pack(anchor="w", pady=(2, 0)) warning = tk.Label( dialog, - text="Пустое свойство означает неизвестное значение, а не ноль.", + text=( + "Пустое поле свойства означает «нет данных». Доли смеси и присадки в сумме дают 100 %." + ), bg="#FFF8E8", fg="#8C5400", anchor="w", @@ -283,44 +276,79 @@ def open_editor( variables = { key: tk.StringVar(dialog, value=value) for key, value in editor_values(current).items() } - recipe = ttk.Frame(notebook, padding=20, style="WhatIf.TFrame") + recipe = ttk.Frame(notebook, padding=18, style="WhatIf.TFrame") notebook.add(recipe, text="Рецептура") + recipe.columnconfigure(0, weight=1) + recipe.columnconfigure(1, weight=1) + form = ttk.Frame(recipe, style="WhatIf.TFrame") + form.grid(row=0, column=0, sticky="nw", padx=(0, 20)) + result = tk.Frame(recipe, bg="#F6F8F8", highlightbackground="#D8E0E4", highlightthickness=1) + result.grid(row=0, column=1, sticky="new") ttk.Label( - recipe, - text="Введите значение. Свободные доли подберутся после Enter или выхода из поля.", + form, + text="1. Задайте доли", style="WhatIf.TLabel", - ).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 14)) + font=("Segoe UI", 12, "bold"), + ).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 6)) + ttk.Label( + form, + text="Введите долю. Остальные поля можно оставить свободными.", + style="WhatIf.TLabel", + wraplength=400, + ).grid(row=1, column=0, columnspan=3, sticky="w", pady=(0, 14)) locks: dict[str, tk.BooleanVar] = {} entries: dict[str, ttk.Entry] = {} recipe_keys = [f"{item.id}.fraction" for item in preset.blend_components] + ["dose"] - for row, key in enumerate(recipe_keys, 1): + for row, key in enumerate(recipe_keys, 2): label = "Присадка, %" if key == "dose" else f"Компонент {key.split('.')[0]}, %" - ttk.Label(recipe, text=label, style="WhatIf.TLabel").grid( + ttk.Label(form, text=label, style="WhatIf.TLabel").grid( row=row, column=0, sticky="w", pady=7 ) - entry = ttk.Entry(recipe, textvariable=variables[key], width=18, style="WhatIf.TEntry") + entry = ttk.Entry(form, textvariable=variables[key], width=12, style="WhatIf.TEntry") entry.grid(row=row, column=1, padx=12, sticky="w") entries[key] = entry locks[key] = tk.BooleanVar(dialog, value=False) - ttk.Checkbutton(recipe, text="Зафиксировать", variable=locks[key]).grid( + ttk.Checkbutton(form, text="Не менять", variable=locks[key]).grid( row=row, column=2, sticky="w" ) - ttk.Label(recipe, text="Масса партии, т", style="WhatIf.TLabel").grid( - row=4, column=0, sticky="w", pady=7 + ttk.Label(form, text="Масса партии, т", style="WhatIf.TLabel").grid( + row=5, column=0, sticky="w", pady=7 ) entries["total_mass_t"] = ttk.Entry( - recipe, textvariable=variables["total_mass_t"], width=18, style="WhatIf.TEntry" + form, textvariable=variables["total_mass_t"], width=12, style="WhatIf.TEntry" ) - entries["total_mass_t"].grid(row=4, column=1, padx=12, sticky="w") + entries["total_mass_t"].grid(row=5, column=1, padx=12, sticky="w") + tk.Label( + result, + text="2. Выберите вариант", + bg="#F6F8F8", + fg="#101B28", + font=("Segoe UI", 12, "bold"), + ).pack(anchor="w", padx=18, pady=(17, 3)) + tk.Label( + result, + text="Подбор учитывает качество, запасы и поля «Не менять».", + bg="#F6F8F8", + fg="#617082", + wraplength=330, + justify="left", + ).pack(anchor="w", padx=18) preview = tk.StringVar( - dialog, value="Исходный сценарий. Измените поле или нажмите «Подобрать смесь»." + dialog, value="Исходная рецептура. Измените долю или нажмите «Подобрать смесь»." ) - ttk.Label( - recipe, textvariable=preview, style="WhatIf.TLabel", wraplength=790, justify="left" - ).grid(row=5, column=0, columnspan=3, sticky="w", pady=(18, 6)) - variants = ttk.Combobox(recipe, state="readonly", width=38) - variants.grid(row=6, column=0, columnspan=2, sticky="w", pady=5) - variants.grid_remove() + tk.Label( + result, + textvariable=preview, + bg="#F6F8F8", + fg="#38485A", + wraplength=330, + justify="left", + anchor="nw", + font=("Segoe UI", 10), + ).pack(fill="x", padx=18, pady=(16, 8)) + variants = ttk.Combobox(result, state="readonly", width=35) + variants.pack(fill="x", padx=18, pady=(2, 18)) + variants.pack_forget() components = ttk.Frame(notebook, padding=10, style="WhatIf.TFrame") notebook.add(components, text="Свойства и запасы") for col, component in enumerate(preset.blend_components): @@ -374,6 +402,12 @@ def open_editor( pending: str | None = None updating = False + def cancel_pending() -> None: + nonlocal pending + if pending is not None: + dialog.after_cancel(pending) + pending = None + def values_now() -> dict[str, str]: return {key: var.get() for key, var in variables.items()} @@ -381,12 +415,17 @@ def show_option(position: int) -> None: nonlocal updating option, updated = options[position] old = values_now() + original = before_assist or old updating = True try: for key in recipe_keys: variables[key].set(updated[key]) entries[key].configure( - style=("WhatIf.Changed.TEntry" if updated[key] != old[key] else "WhatIf.TEntry") + style=( + "WhatIf.Changed.TEntry" + if updated[key] != original[key] + else "WhatIf.TEntry" + ) ) finally: updating = False @@ -395,9 +434,9 @@ def show_option(position: int) -> None: "dose": "Присадка", } changes = [ - f"{names[key]}: {old[key]}% → {updated[key]}%" + f"{names[key]}: {original[key]}% → {updated[key]}%" for key in recipe_keys - if old[key] != updated[key] + if original[key] != updated[key] ] quality = next( (item.metrics for item in option.evaluation.assessments if "sulfur" in item.metrics), @@ -422,12 +461,14 @@ def show_option(position: int) -> None: label = {"sulfur": "Сера", "t95": "T95", "cetane_number": "ЦЧ"}.get( constraint.metric, constraint.metric ) - checks.append(f"{label}: {value:.2f} {sign} {limit:g}") - preview.set( - f"{option.label}. " - + ("; ".join(changes) if changes else "Смесь уже подходит.") - + ("\nПроверка: " + "; ".join(checks) if checks else "") - ) + unit = {"sulfur": "мг/кг", "t95": "°C", "cetane_number": "ед."}.get( + constraint.metric, "" + ) + checks.append(f"{label}: {value:.2f} {unit} {sign} {limit:g} {unit}") + summary = [option.label, "", "Изменения:", *(changes or ["Смесь уже подходит."])] + if checks: + summary.extend(("", "Проверка ограничений:", *checks)) + preview.set("\n".join(summary)) error.set("") def pick(_: tk.Event[tk.Misc] | None = None) -> None: @@ -436,10 +477,10 @@ def pick(_: tk.Event[tk.Misc] | None = None) -> None: variants.bind("<>", pick) - def suggest(changed: str | None = None) -> None: - nonlocal options, before_assist, pending - pending = None - if updating or not assistant_on.get(): + def suggest(changed: str | None = None, *, force: bool = False) -> None: + nonlocal options, before_assist + cancel_pending() + if updating or (not assistant_on.get() and not force): return raw = values_now() fixed = frozenset(key for key, locked in locks.items() if locked.get()) @@ -449,14 +490,14 @@ def suggest(changed: str | None = None) -> None: options = assist_editor_values(preset, raw, fixed) except ValueError as exc: options = () - variants.grid_remove() + variants.pack_forget() error.set(str(exc)) preview.set("Автоподбор не смог составить допустимую смесь.") return before_assist = raw variants["values"] = tuple(item.label for item, _ in options) variants.current(0) - variants.grid() + variants.pack(fill="x", padx=18, pady=(2, 18)) show_option(0) def schedule(changed: str) -> None: @@ -477,6 +518,7 @@ def on_focus_out(_event: tk.Event[tk.Misc], *, name: str) -> None: def undo_assist() -> None: nonlocal updating, before_assist + cancel_pending() if before_assist is None: return updating = True @@ -489,19 +531,20 @@ def undo_assist() -> None: updating = False before_assist = None preview.set("Автоподбор отменён. Введённое значение сохранено.") - variants.grid_remove() + variants.pack_forget() - ttk.Checkbutton(recipe, text="Автоподбор", variable=assistant_on).grid( - row=7, column=0, sticky="w", pady=(12, 0) + ttk.Checkbutton(form, text="Подбирать после изменения", variable=assistant_on).grid( + row=6, column=0, columnspan=3, sticky="w", pady=(18, 6) ) - ttk.Button(recipe, text="Подобрать смесь", command=suggest).grid( - row=7, column=1, sticky="w", pady=(12, 0) + ttk.Button(form, text="Подобрать смесь", command=lambda: suggest(force=True)).grid( + row=7, column=0, columnspan=2, sticky="w", pady=(6, 0) ) - ttk.Button(recipe, text="Отменить автоподбор", command=undo_assist).grid( - row=7, column=2, sticky="w", pady=(12, 0) + ttk.Button(form, text="Отменить подбор", command=undo_assist).grid( + row=8, column=0, columnspan=2, sticky="w", pady=(8, 0) ) def apply() -> None: + cancel_pending() try: scenario = scenario_from_editor( preset, {key: var.get() for key, var in variables.items()} @@ -525,26 +568,48 @@ def apply() -> None: def reset() -> None: nonlocal before_assist + cancel_pending() for key, value in editor_values(preset).items(): variables[key].set(value) for lock_var in locks.values(): lock_var.set(False) before_assist = None - variants.grid_remove() + variants.pack_forget() + preview.set("Исходная рецептура восстановлена. Измените долю или рассчитайте результат.") + for key in recipe_keys: + entries[key].configure(style="WhatIf.TEntry") error.set( "Восстановлены исходные поля сценария. " - "Нажмите «Пересчитать what-if» для нового результата." + "Нажмите «Рассчитать смесь» для нового результата." ) buttons = ttk.Frame(dialog, padding=(24, 8), style="WhatIf.Footer.TFrame") buttons.pack(fill="x") - ttk.Button( - buttons, text="Пересчитать what-if", command=apply, style="WhatIf.Primary.TButton" + tk.Button( + buttons, + text="Рассчитать смесь", + command=apply, + bg="#007D78", + fg="#FFFFFF", + activebackground="#006965", + activeforeground="#FFFFFF", + bd=0, + padx=20, + pady=10, + font=("Segoe UI", 10, "bold"), + cursor="hand2", ).pack(side="left") - ttk.Button(buttons, text="Сбросить к сценарию", command=reset, style="WhatIf.TButton").pack( + ttk.Button(buttons, text="Сбросить", command=reset, style="WhatIf.TButton").pack( side="left", padx=10 ) - ttk.Button( - buttons, text="Закрыть без изменений", command=dialog.destroy, style="WhatIf.TButton" - ).pack(side="right") + + def close_dialog() -> None: + cancel_pending() + dialog.destroy() + + ttk.Button(buttons, text="Закрыть", command=close_dialog, style="WhatIf.TButton").pack( + side="right" + ) + dialog.protocol("WM_DELETE_WINDOW", close_dialog) + dialog.bind("", lambda _event: close_dialog()) return dialog From 1145a2f418b8c68b96487b1a87f6d73aecb798dd Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sat, 26 Sep 2026 00:29:48 +0300 Subject: [PATCH 04/11] fix(ui): clarify scenario timing and validation feedback --- global_tests/test_ui_interactive.py | 6 ++++++ source/ui.py | 24 ++++++++++++++++++------ source/ui_what_if.py | 25 +++++++++++++++++++------ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/global_tests/test_ui_interactive.py b/global_tests/test_ui_interactive.py index 5c3c97a..dc85f95 100644 --- a/global_tests/test_ui_interactive.py +++ b/global_tests/test_ui_interactive.py @@ -67,6 +67,12 @@ def test_what_if_rejects_invalid_inputs(updates: dict[str, str]) -> None: scenario_from_editor(preset, editor_values(preset) | updates) +def test_what_if_validation_names_visible_field() -> None: + preset = load_scenario("config/scenarios/blend_risk.json") + with pytest.raises(ValueError, match="Компонент A · Текущая доля"): + scenario_from_editor(preset, editor_values(preset) | {"A.fraction": "abc"}) + + @pytest.mark.parametrize( "updates", [ diff --git a/source/ui.py b/source/ui.py index 5332d0a..cbc12d2 100644 --- a/source/ui.py +++ b/source/ui.py @@ -1286,7 +1286,11 @@ def _render_info(self, parent: tk.Frame, view: DashboardView | None) -> None: else "—", ) self._info_row(parent, "Горизонт", self.horizon_var.get()) - self._info_row(parent, "Последний расчёт", datetime.now().strftime("%H:%M")) + self._info_row( + parent, + "Момент сценария", + self._result.as_of.strftime("%d.%m.%Y %H:%M") if self._result else "—", + ) tk.Frame(parent, bg=BORDER, height=1).pack(fill="x", padx=20, pady=18) tk.Label( parent, @@ -1616,11 +1620,19 @@ def finish() -> None: output.insert("1.0", self._history_interval_text) output.configure(state="disabled") chart.set_payload(self._history_export_payload) - summary.set( - "Частичный интервал" - if self._history_export_payload and self._history_export_payload.get("cancelled") - else "Результат последнего интервала" - ) + if self._history_export_payload: + status = ( + "Частичный интервал" + if self._history_export_payload.get("cancelled") + else "Интервал рассчитан" + ) + summary.set( + f"{status}. Рассчитано точек: " + f"{self._history_export_payload.get('points', 0)}. " + "Реальные действия отключены." + ) + else: + summary.set("Результат интервала недоступен") def _render_hybrid_panel(self, parent: tk.Misc) -> None: panel = self._surface(parent) diff --git a/source/ui_what_if.py b/source/ui_what_if.py index 0e1fd0f..bce4028 100644 --- a/source/ui_what_if.py +++ b/source/ui_what_if.py @@ -35,6 +35,14 @@ ) +def field_label(key: str) -> str: + """Name editor inputs as a person sees them, not by contract keys.""" + if "." in key: + component, field = key.split(".", 1) + return f"Компонент {component} · {dict(COMPONENT_FIELDS).get(field, field)}" + return dict(ADDITIVE_FIELDS).get(key, key) + + def editor_values(scenario: ScenarioConfig) -> dict[str, str]: """Represent null quality explicitly as an empty field, never as zero.""" values: dict[str, str] = {} @@ -76,9 +84,9 @@ def number(key: str) -> float: try: value = float(values[key].strip().replace(",", ".")) except (KeyError, ValueError) as exc: - raise ValueError(f"{key}: введите число") from exc + raise ValueError(f"{field_label(key)}: введите число") from exc if not math.isfinite(value) or value < 0: - raise ValueError(f"{key}: требуется конечное неотрицательное число") + raise ValueError(f"{field_label(key)}: требуется конечное неотрицательное число") return value data = base.model_dump(mode="json") @@ -105,7 +113,12 @@ def number(key: str) -> float: point, edge = estimate["value"], estimate[bound] if point is not None and edge is not None: if (bound == "upper" and edge < point) or (bound == "lower" and edge > point): - raise ValueError(f"{component['id']}: граница {metric} противоречит значению") + label = {"sulfur": "серы", "t95": "T95", "cetane_number": "цетанового числа"}[ + metric + ] + raise ValueError( + f"Компонент {component['id']}: оценка {label} противоречит значению" + ) if component["risk_index"] > 1: raise ValueError(f"{component['id']}: риск должен быть от 0 до 1") data["current_additive_mass_fraction"] = number("dose") / 100 @@ -142,9 +155,9 @@ def fraction(key: str) -> float: try: result = float(values[key].strip().replace(",", ".")) / 100 except (KeyError, ValueError) as exc: - raise ValueError(f"{key}: введите число от 0 до 100") from exc + raise ValueError(f"{field_label(key)}: введите число от 0 до 100") from exc if not math.isfinite(result) or result < 0 or result > 1: - raise ValueError(f"{key}: требуется число от 0 до 100") + raise ValueError(f"{field_label(key)}: требуется число от 0 до 100") return result desired = {key: fraction(f"{key}.fraction") for key in component_ids} @@ -155,7 +168,7 @@ def fraction(key: str) -> float: try: max_dose = float(values["max_dose"].strip().replace(",", ".")) / 100 except (KeyError, ValueError) as exc: - raise ValueError("max_dose: введите максимум присадки 1, 2 или 3%") from exc + raise ValueError("Максимум присадки: введите 1, 2 или 3%") from exc temporary_dose = min(dose, max_dose) if math.isfinite(max_dose) else dose temporary["dose"] = f"{temporary_dose * 100:.12g}" temporary[f"{component_ids[0]}.fraction"] = f"{(1 - temporary_dose) * 50:.12g}" From e072bd2178144a04b065a3579003a7edd1850e34 Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sat, 26 Sep 2026 11:49:29 +0300 Subject: [PATCH 05/11] docs(jury): simplify demo around the desktop app --- README.md | 20 +++++++++----------- config/release_manifest.json | 2 +- docs/DEMO_SCRIPT.md | 20 ++++++++++---------- global_tests/test_ui.py | 2 +- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f3bdab2..ab80f76 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,17 @@ ## Запуск за три шага -В v1.2.0 синтетический what-if подбирает допустимую смесь при изменении доли или присадки: можно закрепить введённое значение, сравнить варианты и отменить автоподбор. Исторический прогноз показывает причины отказа и сохраняет статус запуска в журнале. +1. Скачайте **Neftekod-Windows-x64.zip** по ссылке выше и распакуйте **весь архив**. +2. Откройте **Neftekod.exe** (Windows x64). Дождитесь готового результата на экране «Обзор». +3. Нажмите **«Смесь»**, чтобы увидеть рецепт и проверки. Кнопка **«Настроить смесь»** позволяет изменить входы и пересчитать вариант. -1. Скачайте **Neftekod-Windows-x64.zip** по ссылке выше. -2. Распакуйте **весь архив** в доступную для записи папку, например `D:\Нефтекод`. -3. Откройте **Neftekod.exe**. - -Python, установка библиотек, обучение моделей и интернет после скачивания не нужны. Рядом с EXE должны оставаться `_internal`, `config`, `data`, `artifacts` и остальные файлы поставки. Не запускайте приложение прямо внутри ZIP. +Приложение работает локально: Python, Docker, обучение моделей и интернет после скачивания не нужны. Не запускайте EXE прямо внутри ZIP и не переносите его отдельно от `_internal`, `config`, `data` и `artifacts`. **Code → Download ZIP скачивает исходники, а не готовое приложение.** Для запуска без настройки используйте Releases. Закрытый репозиторий и его релизы доступны только пользователям с выданным доступом; для показа на другом компьютере можно передать скачанный полный архив. ## Что показать первым -На «Обзоре» выберите **«Повышенная сера»**, нажмите «Рассчитать» и откройте **«Смесь»**. Вы увидите состав, проверку ограничений и объяснение результата. +В приложении на «Обзоре» уже рассчитан сценарий **«Повышенная сера»**. Откройте **«Смесь»**: верхняя модельная оценка серы меняется с 14,058 до 9,306 мг/кг при пределе 10 мг/кг. Нажмите **«Настроить смесь»**, чтобы изменить долю, сохранить важное поле отметкой «Не менять» и сравнить допустимые варианты. | Раздел | Что доступно | | --- | --- | @@ -28,15 +26,15 @@ Python, установка библиотек, обучение моделей | Hybrid и v2 | Условное смешение по сере и исследовательские предупреждения | | Журнал | Результаты запусков и подробный след расчёта | -Для истории уже закреплены совместимые данные и модели. Начните с интервала **01.06.2025, 12:00–13:00 +03:00**. Он занимает время; состояние расчёта показывается на экране. +Исторический график показан в презентации. Для самостоятельного пересчёта в приложении выбран совместимый набор данных и модель; контрольный интервал — **01.06.2025, 12:00–13:00 +03:00**. Интервал считается отдельно и может занять время. ## Материалы для жюри -- [Презентация — 14 слайдов](https://github.com/bug00n/HackathonPetrolCode/releases/latest/download/Neftekod_Presentation.pptx). +- [Презентация](https://github.com/bug00n/HackathonPetrolCode/releases/latest/download/Neftekod_Presentation.pptx). - [Технический документ для жюри](https://github.com/bug00n/HackathonPetrolCode/releases/latest/download/Neftekod_Jury.docx). -- [Пятиминутный сценарий показа, полная инструкция по UI и ответы на вопросы](docs/DEMO_SCRIPT.md). +- [Короткий сценарий показа, полная инструкция по UI и ответы на вопросы](docs/DEMO_SCRIPT.md). -Документы также находятся внутри готового ZIP. PPTX и DOCX можно скачать отдельно со страницы релиза. +В готовом ZIP документы лежат в `docs/jury`. PPTX и DOCX можно скачать отдельно со страницы релиза. ## Границы результата diff --git a/config/release_manifest.json b/config/release_manifest.json index f90024a..f8487f9 100644 --- a/config/release_manifest.json +++ b/config/release_manifest.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "release_id": "neftekod-v1.2.0", + "release_id": "neftekod-v1.3.0", "prepared_dataset": "data/processed/66bdfcbb23b4", "legacy_config_sha256": "00a45650caa0f415e78037d56bc49de7161f55f9eecd24675e8874d64efb63e2", "preparation_config_sha256": "7f8dab1f4befa7ed4d14108146d344eeeb4341711880144ad0195d163a55c4dd", diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index c8e7be9..947e3ba 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -1,31 +1,31 @@ # Сценарий защиты Нефтекода -Сначала приведён короткий маршрут выступления, затем полное руководство по интерфейсу: [запуск и общий контекст](#запуск-и-общий-контекст), [обзор](#обзор-и-сценарии), [смесь и Hybrid](#смесь-и-hybrid), [редактор what-if](#синтетический-редактор-what-if), [история и экспорт](#история-и-экспорт), [дополнительные окна](#дополнительные-инструменты), [журнал и статусы](#журнал-статусы-и-ожидаемые-отказы). +Начните с готового результата в приложении. Ниже — короткий маршрут выступления; подробная инструкция по интерфейсу остаётся после него: [запуск](#запуск-и-общий-контекст), [смесь](#смесь-и-hybrid), [настройка](#синтетический-редактор-what-if), [история](#история-и-экспорт), [журнал](#журнал-статусы-и-ожидаемые-отказы). ## До выступления -Полностью распаковать ZIP. Открыть `Neftekod.exe`. Не запускать прямо внутри архива и не переносить exe отдельно от `_internal`, config, data и artifacts. Открыть презентацию, скачанную из GitHub Releases или взятую из полного ZIP. Проверить, что экран обзора показывает рассчитанный модельный сценарий. При показе с проектора уменьшать окно можно до 1000×680; нижние блоки доступны прокруткой. +Полностью распаковать ZIP. Открыть `Neftekod.exe` и убедиться, что на «Обзоре» рассчитан сценарий «Повышенная сера». Открыть короткую презентацию из `docs/jury` как наглядное сопровождение. Не запускать EXE внутри архива и не переносить его отдельно от `_internal`, `config`, `data` и `artifacts`. -## Показ за пять минут +## Показ за три–четыре минуты -1. **Назначение, 30 секунд.** «Это локальная система поддержки решения. Реальные измерения и модельные сценарии разделены. Промышленное управление отключено». -2. **Повышенная сера, 60 секунд.** На обзоре выбрать сценарий и нажать «Рассчитать». Открыть «Смесь». Показать 14,058 и 9,306 мг/кг, состав 89,1/9,9/1%, успешные модельные проверки T95/CN. Объяснить, что дешёвый недопустимый вариант отфильтрован. -3. **Отказ и конфликт, 60 секунд.** Выбрать недостающие данные: результата управления нет. Затем «Риск и стоимость»: индекс риска 0,80 → 0,73 при росте стоимости 1,99 → 2,0395. Это модельные прокси, не производственная экономия. -4. **История, 90 секунд.** Время начала 2025-06-01T12:00:00+03:00, конец 2025-06-01T13:00:00+03:00. Нажать «Рассчитать интервал». Пока он считается, объяснить время доступности ЛИМС. Показать прогресс, график двух прогнозов, верхнюю границу и отсутствие управляющих действий. Экспортировать результат. Длинный интервал можно отменить; частичный результат подписан. -5. **Проверяемость, 60 секунд.** Открыть журнал. Показать выбранный сценарий и полный след. Завершить фактической ML-оценкой на 31 819 точках: MAE 0,6842 мг/кг, равен baseline. Основной результат проекта — согласованный безопасный цикл, а не заявленное превосходство этого baseline. +1. **Задача, 30 секунд.** На «Обзоре»: «Нефтекод показывает модельную рецептуру и исторический прогноз серы. Команды оборудованию отключены». +2. **Результат смеси, 60 секунд.** На экране «Смесь» или слайде 2 показать верхнюю модельную оценку серы **14,058 → 9,306 мг/кг** при пределе **10 мг/кг**. Коротко назвать другие проверки: T95, цетановое число, запасы и присадка. +3. **Что можно менять, 45 секунд.** Нажать «Настроить смесь» или показать слайд 3. В приложении можно изменить долю, отметить «Не менять», сравнить варианты и рассчитать выбранную рецептуру. Исторические измерения при этом не редактируются. +4. **История и принцип расчёта, 45 секунд.** Показать готовый график и слайд «Как получается ответ». Будущий лабораторный анализ не используется до публикации. Прогноз на 60 минут не означает доказанного эффекта действий. Не ждать расчёта исторического интервала во время короткой защиты. +5. **Проверка и границы, 30 секунд.** На отложенном тесте 2026 года — **31 819** целей, MAE **0,6842 мг/кг**, столько же у простой модели «последнее значение». Промышленная экономия и реальное управление не заявляются. Журнал и технический документ доступны для подробной проверки. ## Дополнительные вопросы - **Почему без LLM?** Численные агенты и явные контракты решают обязательный цикл локально и воспроизводимо. - **Что делает Hybrid?** Подставляет прогноз серы A в условную смесь. Остальные свойства здесь не подтверждены. - **Почему abstain при наличии числа?** Прогноз качества доступен, но проверенной модели последствий реального действия нет. -- **Можно ли изменять период в UI?** Да: «История/ML» → «Момент данных» (начало) и «Конец исторического интервала» → «Рассчитать интервал». Даты вводятся с часовым поясом, например `2025-06-01T12:00:00+03:00`. В UI шаг фиксирован: 60 минут. Другой шаг задаётся только в CLI через `--step-minutes`. +- **Можно ли изменять период в UI?** Да: «История/ML» → «Момент прогноза / начало интервала» и «Конец интервала» → «Рассчитать интервал». Даты вводятся с часовым поясом, например `2025-06-01T12:00:00+03:00`. В UI шаг фиксирован: 60 минут. Другой шаг задаётся только в CLI через `--step-minutes`. - **Откуда уверенность?** Верхние оценки эмпирические или сценарные согласно режиму. Промышленную гарантию они не дают. - **Что ещё исследуется?** Причинные эффекты P8/F19, надёжность, межустановочные задержки и лабораторная коррекция. В штатной поставке реальные действия выключены. ## Если запуск не удался -Не удалять `_internal`. Повторно распаковать весь архив в пользовательскую папку. Сверить SHA256SUMS. При программной ошибке сохранить `startup-error.log`. При ограничениях запуска со стороны Windows следовать политике компьютера, не отключать антивирус. Для технического проверяющего сохранён исходный маршрут Python в README. +Если EXE не открылся, покажите экраны и результаты в презентации. Для последующей диагностики не удаляйте `_internal`, повторно распакуйте архив, сверьте `SHA256SUMS.txt` и сохраните `startup-error.log`. При ограничениях Windows следуйте политике компьютера, не отключайте защиту. ## Запуск и общий контекст diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index a14f0c2..8485deb 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -294,7 +294,7 @@ def release_context_root(tmp_path: Path) -> Path: def test_release_manifest_pins_one_live_context(release_context_root: Path) -> None: context = ui_data_module.discover_ui_context(release_context_root) - assert context.release_id == "neftekod-v1.2.0" + assert context.release_id == "neftekod-v1.3.0" assert context.latest_dataset is not None assert context.latest_dataset.endswith("66bdfcbb23b4") assert tuple(item.model_id for item in context.forecast_artifacts) == ( From aaa7905f4cb51d8fe4fd892e695b4ad5d14ed498 Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sat, 26 Sep 2026 21:39:05 +0300 Subject: [PATCH 06/11] fix(ui): explain result details in clear Russian --- config/release_manifest.json | 2 +- docs/DEMO_SCRIPT.md | 12 +- global_tests/test_stage1_cycle.py | 2 + global_tests/test_stage3_guardrails.py | 3 +- global_tests/test_ui.py | 113 ++++- source/explain.py | 232 +++++++---- source/ui.py | 547 ++++++++++++++++++------- source/ui_data.py | 77 ++-- 8 files changed, 722 insertions(+), 266 deletions(-) diff --git a/config/release_manifest.json b/config/release_manifest.json index f8487f9..68289d3 100644 --- a/config/release_manifest.json +++ b/config/release_manifest.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "release_id": "neftekod-v1.3.0", + "release_id": "neftekod-v1.3.1", "prepared_dataset": "data/processed/66bdfcbb23b4", "legacy_config_sha256": "00a45650caa0f415e78037d56bc49de7161f55f9eecd24675e8874d64efb63e2", "preparation_config_sha256": "7f8dab1f4befa7ed4d14108146d344eeeb4341711880144ad0195d163a55c4dd", diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index 947e3ba..5108b50 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -78,7 +78,7 @@ В «Повышенной сере» выбранная рецептура: A 89,1%, B 9,9%, присадка 1%. Для партии 100 т это 89,1 т, 9,9 т и 1 т. Проверьте серу сверху 9,306 мг/кг, T95 сверху 354 °C и цетановое число снизу 52,6. -«Почему выбран этот вариант» раскрывает объяснение решения. «Альтернативы и ход расчёта» раскрывает доступные альтернативы и их оценки серы; это не пошаговый отладчик оптимизатора. Повторное нажатие сворачивает блок. «Скачать расчёт» открывает диалог сохранения последнего модельного результата в JSON. «Открыть журнал» переводит к сохранённым запускам. +«Почему выбран этот вариант» объясняет решение обычным языком. «Другие допустимые варианты» показывает состав и оценки качества альтернатив; это не пошаговый отладчик оптимизатора. Повторное нажатие сворачивает блок. «Скачать расчёт» сохраняет полный результат в JSON. «Открыть журнал» переводит к сохранённым запускам. ### Условная смесь по историческому прогнозу @@ -138,7 +138,7 @@ Hybrid проверяет только серу. Он не подтвержда Во время расчёта оставайтесь на странице истории и следите за прогрессом. Первичная загрузка занимает время. Отмена не прерывает уже вычисляемую точку мгновенно. После запуска нового расчёта экспорт истории недоступен до получения нового результата. Не закрывайте приложение до завершения расчёта, если нужен результат. -Контрольный пример на 12:00: прогноз 10,15656 мг/кг, верхняя оценка 10,38809 мг/кг. На 13:00: 9,00645 и 10,27399 мг/кг. Отсутствие реального управляющего действия ожидаемо в обеих точках. График показывает точечный прогноз серы бирюзовым, верхнюю оценку синим и предел 10 мг/кг пунктиром. По горизонтали указано время, на которое сделан прогноз: для исходных 12:00 и 13:00 это 13:00 и 14:00. Наведите мышь для точных значений. Пропуски не соединяются линией; это прогноз, а не измеренный факт. Ниже показаны число рассчитанных точек, покрытие и статусы; затем остаются подробности JSON, включая время, модель и причины ограничений. +Контрольный пример на 12:00: прогноз 10,15656 мг/кг, верхняя оценка 10,38809 мг/кг. На 13:00: 9,00645 и 10,27399 мг/кг. Отсутствие реального управляющего действия ожидаемо в обеих точках. График показывает точечный прогноз серы бирюзовым, верхнюю оценку синим и предел 10 мг/кг пунктиром. По горизонтали указано время, на которое сделан прогноз: для исходных 12:00 и 13:00 это 13:00 и 14:00. Наведите мышь для точных значений. Пропуски не соединяются линией; это прогноз, а не измеренный факт. Ниже показаны число рассчитанных точек, покрытие и причины ограничений понятным языком. Полные поля доступны в JSON-экспорте. ### Три отдельных экспорта @@ -154,17 +154,17 @@ Hybrid проверяет только серу. Он не подтвержда «Подготовить данные» запрашивает подтверждение чтения исходных материалов из `materials` и создаёт локальный подготовленный набор. Подтвердите только при наличии исходного архива и необходимости подготовки; отмена подтверждения не запускает операцию. Подготовка может занять несколько минут. Перед штатным показом готового ZIP она не требуется; после неё проверьте путь выбранного набора в исторических формах. -«Собрать historical state» открывает поля данных и времени. Нажмите «Собрать состояние», чтобы получить JSON состояния, доступного к этому моменту. Прогноз эта кнопка не рассчитывает. +«Собрать историческое состояние» открывает поля данных и времени. Нажмите «Собрать состояние», чтобы увидеть число доступных, устаревших и отсутствующих сигналов на выбранный момент. Кнопка «Показать технический JSON» открывает все поля. Прогноз эта форма не рассчитывает. ### Одиночный прогноз и исследование превышений «Прогноз серы» открывает ещё одну форму одиночного исторического расчёта. Оставьте штатные данные и модель, укажите время с часовым поясом и нажмите «Рассчитать прогноз». Поле «Проверенный action-артефакт (необязательно)» оставьте пустым. Для обычного просмотра удобнее основная вкладка «История/ML», где есть экспорт. -«Эпизодный прогноз v2» открывает поля данных, «Shadow-артефакт v2» и времени. Используйте выбранную модель v2 и нажмите «Рассчитать episode forecast». Результат показывает вероятности начала эпизода превышения на 10, 20, 30 и 60 минутах, прогнозные оценки и применимость. Это исследовательское предупреждение в shadow-режиме, без изменения уставок; тревога не означает доказанное будущее нарушение. +«Эпизодный прогноз v2» открывает поля данных, исследовательской модели и времени. Нажмите «Рассчитать прогноз превышения». Результат показывает вероятности начала эпизода превышения на 10, 20, 30 и 60 минутах, прогнозные оценки и применимость. Это исследовательское предупреждение без изменения уставок; тревога не означает доказанное будущее нарушение. ### Лабораторная коррекция и эффекты тегов -«Контроль ПАК–ЛИМС» принимает данные и модель «Прогноз ПАК». Нажмите «Оценить LIMS-коррекцию»: выполняется отдельная оценка по доступным лабораторным наблюдениям с учётом задержки. Общий выбранный момент времени эту оценку не ограничивает: в форме нет поля времени. Результат `research_only` означает, что критерии допуска не пройдены; `eligible_for_shadow` допускает лишь дальнейшую теневую проверку. Основная модель приложения автоматически не заменяется. +«Контроль ПАК–ЛИМС» принимает данные и модель «Прогноз ПАК». Нажмите «Оценить коррекцию по ЛИМС»: выполняется отдельная оценка по доступным лабораторным наблюдениям с учётом задержки. Общий выбранный момент времени эту оценку не ограничивает: в форме нет поля времени. Экран показывает ошибки и допуск к дальнейшей теневой проверке по валидации; полный отчёт доступен кнопкой «Показать технический JSON». Основная модель приложения автоматически не заменяется. «Исторический эффект P8/F19 (не совет)» требует отдельного совместимого исследовательского action-артефакта; в штатной поставке поле пустое. Для подготовленного исследования задайте данные, артефакт, время, тег `ht:P8` или `ht:F19` и изменение в исходной единице, затем нажмите «Рассчитать исследовательский сценарий». P8 — перепад давления Р-202, МПа; F19 — расход бензина в К-201, т/ч. Без артефакта успешный расчёт не ожидается. Результат по историческим эпизодам не доказывает причинный эффект и не является советом оператору. @@ -172,7 +172,7 @@ Hybrid проверяет только серу. Он не подтвержда ### Просмотр сохранённых запусков -Откройте «Журнал» верхней кнопкой либо переходом с расчётного экрана. Слева выберите запись по моменту данных, сценарию, статусу и короткому идентификатору. Справа появятся объяснение, модель, путь к папке доказательств, полный `result.json` и имеющиеся `metadata.json`. При пустом журнале выполните модельный расчёт на «Обзоре» и вернитесь сюда. +Откройте «Журнал» верхней кнопкой либо переходом с расчётного экрана. Слева выберите запись по моменту данных, сценарию, статусу и короткому идентификатору. Справа по умолчанию показаны результат, рецепт, качество и причина решения; это работает и для старых записей. Кнопка «Показать технический JSON» открывает полный `result.json` и имеющиеся `metadata.json`. При пустом журнале выполните модельный расчёт на «Обзоре» и вернитесь сюда. Записи нельзя редактировать или удалять из интерфейса. Отдельного экспорта всего журнала нет; используйте нужный экспорт на расчётном экране. Журнал не является общей корзиной всех вспомогательных отчётов: результат исследовательского окна не следует считать сохранённым только потому, что он показан на экране. diff --git a/global_tests/test_stage1_cycle.py b/global_tests/test_stage1_cycle.py index 21c17a3..0d85a6b 100644 --- a/global_tests/test_stage1_cycle.py +++ b/global_tests/test_stage1_cycle.py @@ -101,6 +101,8 @@ def test_stage1_abstains_when_required_component_quality_is_missing( assert result.selected is None assert "MISSING_REQUIRED_SIGNAL" in result.reason_codes assert "Надёжной рекомендации нет" in result.explanation + assert "MISSING_REQUIRED_SIGNAL" not in result.explanation + assert "reason=" not in result.explanation @pytest.mark.parametrize("missing_field", ["t95", "cetane_number"]) diff --git a/global_tests/test_stage3_guardrails.py b/global_tests/test_stage3_guardrails.py index 911b8eb..86ebc00 100644 --- a/global_tests/test_stage3_guardrails.py +++ b/global_tests/test_stage3_guardrails.py @@ -87,7 +87,8 @@ def test_material_improvement_does_not_claim_baseline_quality_failure( assert result.baseline is not None and result.baseline.feasible assert "MATERIAL_IMPROVEMENT" in result.reason_codes assert "текущий режим проходит ограничения" in result.explanation - assert "cost_proxy" in result.explanation + assert "стоимост" in result.explanation + assert "reason=" not in result.explanation assert "проходит проверки" in view.action_detail diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index 8485deb..8b8f8cb 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -25,6 +25,7 @@ IntervalKind, MetricEstimate, OperationMode, + ProcessState, Recommendation, RecommendationStatus, ) @@ -32,8 +33,13 @@ from source.orchestrator import run_cycle from source.ui import ( format_action_shadow_payload, + format_alternatives, + format_history_interval_payload, format_history_replay_view, format_hybrid_blend_view, + format_journal_payload, + format_lims_correction_payload, + format_state_summary, format_v2_forecast_payload, history_replay_to_view, history_smoke_snapshot, @@ -241,20 +247,20 @@ def test_stage_snapshots_show_avt_and_hydrotreating_without_controls(tmp_path: P 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 "единица измерения не подтверждена" 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 + assert "управление отключено" in hydro_rows["ht:P8"].read_only_reason + assert "сигнал для контекста" 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 + assert "не найдены" in snapshot.message @pytest.fixture @@ -294,7 +300,7 @@ def release_context_root(tmp_path: Path) -> Path: def test_release_manifest_pins_one_live_context(release_context_root: Path) -> None: context = ui_data_module.discover_ui_context(release_context_root) - assert context.release_id == "neftekod-v1.3.0" + assert context.release_id == "neftekod-v1.3.1" assert context.latest_dataset is not None assert context.latest_dataset.endswith("66bdfcbb23b4") assert tuple(item.model_id for item in context.forecast_artifacts) == ( @@ -423,8 +429,8 @@ def descendants(widget): for widget in descendants(app) if isinstance(widget, tk.Text) ) - assert any( - '"points": 2' in widget.get("1.0", "end") + assert all( + '"points": 2' not in widget.get("1.0", "end") for widget in descendants(app) if isinstance(widget, tk.Text) ) @@ -449,7 +455,10 @@ def test_history_snapshot_with_forecast_only_is_not_actionable( 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) + text = format_history_replay_view(view) + assert "Raw recommendation JSON" not in text + assert "ACTION_MODEL_UNAVAILABLE" not in text + assert "реального действия" in text def test_history_snapshot_reports_invalid_datetime_without_reparsing_failure() -> None: @@ -522,7 +531,10 @@ def test_hybrid_snapshot_uses_history_forecast_without_imputing_component_a( 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) + text = format_hybrid_blend_view(view) + assert "Условное смешение" in text + assert "Hybrid sulfur-only blend" not in text + assert "ACTION_MODEL_UNAVAILABLE" not in text def test_action_shadow_view_separates_domain_validation_and_safety() -> None: @@ -541,10 +553,11 @@ def test_action_shadow_view_separates_domain_validation_and_safety() -> None: } ) - assert "Изменение к hold" in text + assert "Изменение к исходному режиму" in text assert "Историческая область: да" in text - assert "Validation модели: не пройдена" in text + assert "Проверка модели: не пройдена" in text assert "Верхняя граница серы: не проходит" in text + assert "ACTION_EFFECT_VALIDATION_FAILED" not in text def test_v2_shadow_view_shows_horizons_and_never_advises() -> None: @@ -564,9 +577,83 @@ def test_v2_shadow_view_shows_horizons_and_never_advises() -> None: ) assert "Эпизодный прогноз серы" in text - assert "60 мин | 40.0%" in text + assert "60 мин: 40,0 %" in text assert "не изменяет уставки" in text - assert "shadow_only" in text + assert "shadow_only" not in text + + +def test_detail_formatters_keep_technical_json_out_of_default_view() -> None: + payload = _history_recommendation().model_dump(mode="json") + journal = format_journal_payload(payload) + assert "Отказ от управляющего действия" in journal + assert "невозможно оценить эффект" in journal + assert '"reason_codes"' not in journal + assert "ACTION_MODEL_UNAVAILABLE" not in journal + + interval = format_history_interval_payload( + { + "points": 2, + "requested_points": 2, + "forecast_coverage": 1.0, + "status_counts": {"abstain": 2}, + "reason_counts": {"ACTION_MODEL_UNAVAILABLE": 2}, + } + ) + assert "Прогноз серы доступен в 100 % точек" in interval + assert '"points"' not in interval + assert "ACTION_MODEL_UNAVAILABLE" not in interval + + +def test_alternatives_show_recipe_and_quality_without_internal_ids(tmp_path: Path) -> None: + scenario = load_scenario("config/scenarios/blend_risk.json") + result = run_cycle( + data=None, + as_of=AS_OF, + model=None, + scenario=scenario, + config=load_runtime_config("config/runtime.toml"), + context=DecisionContext(), + run_dir=tmp_path, + ) + + text = format_alternatives(result) + + assert result.alternatives + assert "Вариант 1" in text + assert "компонент A" in text + assert "Верхняя оценка серы" in text + assert "point" not in text + assert "blend:A=" not in text + + +def test_research_details_show_plain_language_gate_and_signal_counts() -> None: + state = ProcessState( + state_id="state-1", + as_of=AS_OF, + dataset_id="dataset-1", + mode=OperationMode.HISTORY, + signals={}, + ) + assert "Сигналов: 0" in format_state_summary(state) + + report = format_lims_correction_payload( + { + "report": { + "promotion_eligible": False, + "validation_pak_point_mae": 0.8, + "validation_corrected_mae": 0.9, + "validation_upper_coverage": 0.94, + "test_rows": 100, + "pak_point_mae": 0.7, + "corrected_mae": 0.75, + "test_used_for_selection": False, + } + } + ) + assert "Допуск к теневой проверке: нет" in report + assert "Ошибка с коррекцией на валидации: 0,9 мг/кг" in report + assert "Отложенный тест: 100 наблюдений" in report + assert "promotion_eligible" not in report def test_journal_lists_only_complete_results_newest_first(tmp_path: Path) -> None: diff --git a/source/explain.py b/source/explain.py index 5f59d7f..62bfd85 100644 --- a/source/explain.py +++ b/source/explain.py @@ -1,73 +1,150 @@ -"""Human-readable Russian explanations for recommendation decisions.""" +"""Operator-facing explanations; technical codes remain in the saved result.""" from __future__ import annotations -from source.contracts import CandidateEvaluation, RecommendationStatus, ScenarioConfig +from source.contracts import ( + CandidateEvaluation, + ConstraintStatus, + RecommendationStatus, + ScenarioConfig, +) + +REASON_TEXT = { + "QUALITY_LIMIT": "ограничение качества не выполнено", + "MISSING_REQUIRED_SIGNAL": "нет обязательного показателя качества", + "UNASSESSED_REQUIRED_PROPERTY": "обязательное свойство смеси не оценено", + "UNCERTAINTY_UNAVAILABLE": "нет консервативной оценки качества", + "COMPONENT_STOCK": "недостаточный запас компонента", + "ADDITIVE_LIMIT": "превышена допустимая доля присадки", + "ADDITIVE_STOCK": "недостаточный запас присадки", + "NO_FEASIBLE_CANDIDATE": "нет смеси, проходящей все проверки", + "BASELINE_INFEASIBLE": "текущая рецептура не проходит ограничения", + "NO_MATERIAL_IMPROVEMENT": "существенного улучшения не найдено", + "MATERIAL_IMPROVEMENT": "найдено существенное модельное улучшение", + "ACTION_COOLDOWN": "повторное изменение временно ограничено", + "ACTION_MODEL_UNAVAILABLE": "невозможно оценить эффект реального действия", + "ACTION_EFFECT_VALIDATION_FAILED": "модель эффекта действия не прошла проверку", + "RELIABILITY_UNAVAILABLE": "надёжность оценки не подтверждена", + "FORECAST_UNAVAILABLE": "нет применимого прогноза серы", + "OUT_OF_DOMAIN": "условия вне проверенной области модели", + "OOD": "условия вне проверенной области модели", + "UNIT_MISMATCH": "единицы измерения не совпадают", + "STALE_REQUIRED_SIGNAL": "обязательные данные устарели", + "APPLICABILITY_UNAVAILABLE": "применимость модели не подтверждена", + "RISK_MODEL_UNAVAILABLE": "оценка риска недоступна", + "CRITERION_UNAVAILABLE": "критерий выбора не рассчитан", + "QUALITY_LIMIT_VIOLATION": "превышен предел качества", + "CURRENT_SULFUR_LIMIT": "текущая сера превышает предел", + "EXCEEDANCE_PROBABILITY_THRESHOLD": "вероятность превышения выше порога", + "ACTION_DELTA_OUT_OF_OBSERVED_RANGE": "изменение вне наблюдавшегося диапазона", + "SULFUR_UPPER_LIMIT_60M": "верхняя оценка серы через час превышает предел", +} + +METRIC_TEXT = { + "sulfur": "сера", + "t95": "T95", + "cetane_number": "цетановое число", + "risk_index": "индекс риска", + "cost_proxy": "стоимостной индекс", + "throughput": "производительность", +} + + +def reason_text(code: str) -> str: + """Translate a backend reason without exposing an untranslated code in the UI.""" + return REASON_TEXT.get(code, "требуется дополнительная техническая проверка") + + +def reasons_text(codes: tuple[str, ...] | list[str]) -> str: + """Collapse repeated reasons while keeping their order.""" + return "; ".join(dict.fromkeys(reason_text(code) for code in codes)) or "не указана" + + +def _number(value: float) -> str: + return f"{value:.4f}".rstrip("0").rstrip(".").replace(".", ",") + + +def _metric(evaluation: CandidateEvaluation | None, name: str) -> float | None: + if evaluation is not None: + for assessment in evaluation.assessments: + estimate = assessment.metrics.get(name) + if estimate is not None: + return estimate.value + return None def _quality_text(evaluation: CandidateEvaluation | None) -> str: if evaluation is None: - return "паспорт качества не рассчитан" - metrics = {} - for assessment in evaluation.assessments: - 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}", - ) - ) + return "оценки качества недоступны" + metrics = { + name: estimate for item in evaluation.assessments for name, estimate in item.metrics.items() + } + values = [] + for name, bound, unit in ( + ("sulfur", "upper", "мг/кг"), + ("t95", "upper", "°C"), + ("cetane_number", "lower", "единиц"), + ): + estimate = metrics.get(name) + value = getattr(estimate, bound, None) + if value is not None: + edge = "верхняя" if bound == "upper" else "нижняя" + values.append(f"{METRIC_TEXT[name]} ({edge} оценка) — {_number(value)} {unit}") + return "; ".join(values) if values else "оценки качества недоступны" + + +def _constraint_name(constraint_id: str) -> str: + if constraint_id.startswith("component_stock:"): + return f"запас компонента {constraint_id.rsplit(':', 1)[-1]}" + return { + "blend_sulfur": "сера", + "blend_t95": "T95", + "blend_cetane": "цетановое число", + "additive_fraction": "доля присадки", + "additive_stock": "запас присадки", + }.get(constraint_id, "обязательное ограничение") -def _checks_text(evaluation: CandidateEvaluation | None) -> str: - if evaluation is None or not evaluation.checks: - return "проверки недоступны" - parts = [] +def _failed_checks_text(evaluation: CandidateEvaluation | None) -> str: + if evaluation is None: + return "обязательные проверки недоступны" + failed = [] for check in evaluation.checks: - bound = "" - if check.lower is not None and check.upper is not None: - bound = f" [{check.lower:g}; {check.upper:g}]" - elif check.upper is not None: - bound = f" <= {check.upper:g}" - elif check.lower is not None: - bound = f" >= {check.lower:g}" - unit = f" {check.unit}" if check.unit else "" - parts.append( - f"{check.constraint_id}: {check.status.value}{bound}{unit}, " - f"reason={check.reason_code}, source={check.evidence_ref}" + if check.status is ConstraintStatus.PASS: + continue + name = _constraint_name(check.constraint_id) + if check.status is ConstraintStatus.UNKNOWN or check.actual is None: + failed.append(f"{name}: нет надёжной оценки") + continue + unit = { + "mg/kg": "мг/кг", + "degC": "°C", + "cetane_number": "ед.", + "t": "т", + "1": "доля", + }.get(check.unit or "", check.unit or "") + limit = ( + f"предел ≤ {_number(check.upper)} {unit}" + if check.upper is not None + else f"предел ≥ {_number(check.lower)} {unit}" + if check.lower is not None + else "предел недоступен" ) - return "; ".join(parts) + failed.append(f"{name}: {_number(check.actual)} {unit}, {limit}".replace(" ", " ")) + return "; ".join(failed) if failed else "нарушений среди рассчитанных проверок нет" -def _improvement_text( +def _comparison_text( baseline: CandidateEvaluation, selected: CandidateEvaluation, scenario: ScenarioConfig ) -> str: - def value(evaluation: CandidateEvaluation, name: str) -> float | None: - for assessment in evaluation.assessments: - metric = assessment.metrics.get(name) - if metric is not None: - return metric.value - return None - - improved = [] + changes = [] for name in scenario.active_criteria: - before, after = value(baseline, name), value(selected, name) - if before is None or after is None: + if name not in METRIC_TEXT: continue - better = after > before if name == "throughput" else after < before - if better: - improved.append(f"{name}: {before:.3g} → {after:.3g}") - return ", ".join(improved) or "модельные критерии выбора" + before, after = _metric(baseline, name), _metric(selected, name) + if before is not None and after is not None and abs(before - after) > 1e-9: + changes.append(f"{METRIC_TEXT[name]} {_number(before)} → {_number(after)}") + return "; ".join(changes) def build_explanation( @@ -77,33 +154,44 @@ def build_explanation( reason_codes: tuple[str, ...], scenario: ScenarioConfig, ) -> str: - """Build a short operator-facing explanation from saved calculations.""" + """Explain the decision in Russian; keep machine IDs in the journal fields.""" if status is RecommendationStatus.ABSTAIN: - reasons = ", ".join(reason_codes) + reasons = "\n".join( + f"• {text}" for text in dict.fromkeys(reason_text(code) for code in reason_codes) + ) return ( - f"Надёжной рекомендации нет: {reasons}. Проверки baseline: " - f"{_checks_text(baseline)}. Сценарий {scenario.id} требует ручной проверки." + "Надёжной рекомендации нет.\n\n" + f"При проверке вариантов обнаружено:\n{reasons}\n" + f"Проверка текущего варианта: {_failed_checks_text(baseline)}.\n" + "Рецептура не выбирается автоматически." ) if status is RecommendationStatus.HOLD: - suffix = f" Причины выбора: {', '.join(reason_codes)}." if reason_codes else "" - return ( - f"Рекомендуется сохранить режим: {_quality_text(selected)}. " - f"Проверки: {_checks_text(selected)}.{suffix}" + why = ( + reasons_text(reason_codes) + if reason_codes + else "текущая рецептура проходит проверки, а менять её не требуется" ) - action = selected.candidate.id if selected else "unknown" - if baseline is not None and baseline.feasible and selected is not None: return ( - f"Рекомендуется вариант {action}: текущий режим проходит ограничения, " - f"но выбранный существенно улучшает {_improvement_text(baseline, selected, scenario)}. " - f"Качество выбранного варианта: {_quality_text(selected)}; " - f"проверки: {_checks_text(selected)}." + "Текущую рецептуру менять не нужно.\n\n" + f"Качество: {_quality_text(selected)}.\n" + f"Почему: {why}." ) + comparison = ( + _comparison_text(baseline, selected, scenario) + if baseline is not None and selected is not None + else "" + ) + if baseline is not None and baseline.feasible: + start = "Выбран допустимый модельный вариант: текущий режим проходит ограничения." + why = "Сравнение с текущим режимом: " + (comparison or "найдено существенное улучшение") + else: + start = "Текущая рецептура нарушает ограничения; найден допустимый модельный вариант." + why = f"Что не прошло: {_failed_checks_text(baseline)}" return ( - f"Рекомендуется вариант {action}: текущий режим не проходит ограничения " - f"({_quality_text(baseline)}; проверки: {_checks_text(baseline)}), " - f"выбранный вариант проходит " - f"({_quality_text(selected)}; проверки: {_checks_text(selected)})." + f"{start}\n\n{why}.\n" + f"Качество выбранной смеси: {_quality_text(selected)}.\n" + "Выбранный вариант прошёл обязательные модельные проверки." ) -__all__ = ["build_explanation"] +__all__ = ["build_explanation", "reason_text", "reasons_text"] diff --git a/source/ui.py b/source/ui.py index cbc12d2..369a59c 100644 --- a/source/ui.py +++ b/source/ui.py @@ -24,10 +24,12 @@ from source.contracts import ( CandidateEvaluation, ConstraintStatus, + ProcessState, Recommendation, RecommendationStatus, ScenarioConfig, ) +from source.explain import reason_text, reasons_text from source.journal import recover_interrupted_runs from source.main import ( PROJECT_ROOT, @@ -83,6 +85,14 @@ "actionable": "разрешены моделью", "unavailable": "недоступны", } +FRESHNESS_RU = {"fresh": "свежий", "stale": "устарел", "missing": "нет данных"} +SOURCE_RU = { + "telemetry": "телеметрия", + "pak": "ПАК", + "lims": "ЛИМС", + "vak": "ВАК", + "scenario": "сценарий", +} SCENARIO_LABELS = { "Риск и стоимость": "blend_tradeoff", @@ -172,6 +182,8 @@ def _display_unit(unit: str | None) -> str: "kg/m3": "кг/м³", "t": "т", "1": "доля", + "MPa": "МПа", + "t/h": "т/ч", }.get(unit or "", unit or "") @@ -260,7 +272,7 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> banner_title = "Изменение режима не требуется" banner_detail = "Текущая модельная рецептура проходит доступные проверки." action_title = "Сохранить текущую рецептуру" - action_detail = "Материального улучшения относительно hold не найдено." + action_detail = "Существенного улучшения относительно текущей смеси не найдено." else: banner_title = "Рекомендация недоступна" banner_detail = "Обязательные данные или консервативная оценка качества отсутствуют." @@ -314,8 +326,8 @@ def journal_entries(run_dir: Path) -> tuple[Path, ...]: 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}" + rendered = f"{value:.4f}".rstrip("0").rstrip(".").replace(".", ",") + return f"{rendered} {unit}".strip() def format_action_shadow_payload(payload: dict[str, object]) -> str: @@ -332,10 +344,11 @@ def format_action_shadow_payload(payload: dict[str, object]) -> str: "Модельный эффект по историческим эпизодам", "Не является советом по изменению уставки.", "", - f"Текущая сера ПАК: {_format_value(baseline if isinstance(baseline, float) else None)}", - f"Сценарий: {control} на {delta} {control_unit}".rstrip(), + "Текущая сера ПАК: " + + _format_value(baseline if isinstance(baseline, (int, float)) else None), + f"Сценарий: изменение {control} на {delta} {control_unit}".rstrip(), "", - "Горизонт | Сера | Изменение к hold | Верхняя граница", + "Изменение к исходному режиму по горизонтам:", ] for horizon in (60, 120, 180): key = str(horizon) @@ -345,20 +358,22 @@ def format_action_shadow_payload(payload: dict[str, object]) -> str: 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.append( + f"{horizon} мин: сера {predicted_text}; изменение {change_text}; " + f"верхняя оценка {upper_text}" + ) rows.extend( ( "", "Историческая область: " + ("да" if payload.get("within_observed_domain") else "нет"), - "Validation модели: " - + ("пройдена" if payload.get("model_validated") else "не пройдена"), + "Проверка модели: " + ("пройдена" 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)) + rows.append("Причины: " + reasons_text([str(reason) for reason in reasons])) return "\n".join(rows) @@ -366,22 +381,27 @@ 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) + return "Прогноз недоступен. Проверьте выбранные данные и модель." 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 мг/кг)", + "Вероятность начала превышения 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}") + rendered = ( + f"{float(value) * 100:.1f}".replace(".", ",") + " %" + if isinstance(value, (int, float)) + else "—" + ) + rows.append(f"{horizon} мин: {rendered}") rows.extend( ( "", @@ -398,44 +418,46 @@ def format_v2_forecast_payload(payload: dict[str, object]) -> str: else None ), "Причины: " - + (", ".join(str(reason) for reason in forecast.get("reason_codes", ())) or "—"), - f"Статус артефакта: {payload.get('production_status', '—')}", + + reasons_text([str(reason) for reason in forecast.get("reason_codes", ())]), + "Результат служит предупреждением, а не основанием менять уставки.", ) ) return "\n".join(rows) def format_history_replay_view(view: UiHistoryReplayView) -> str: - """Render a history replay result without hiding the raw journal payload.""" + """Show the forecast and its limits; the raw payload remains exportable.""" + if view.status != "ready": + return ( + "Исторический прогноз серы\n\n" + "Расчёт пока недоступен. Укажите время, данные и модель, затем запустите прогноз." + if view.status == "empty" + else ( + "Исторический прогноз серы\n\n" + "Не удалось выполнить расчёт. Проверьте время, данные и модель." + ) + ) rows = [ "Исторический прогноз серы", - "Прогноз рассчитан. Реальные уставки не изменяются." - if view.status == "ready" - else view.message, + "Прогноз рассчитан. Реальные уставки не изменяются.", "", f"Статус: {STATUS_RU.get(view.recommendation_status or view.status, view.status)}", - f"Модель: {view.model_id or '—'}", f"Момент данных: {view.as_of or '—'}", f"Сера, точечный прогноз: {_format_value(view.sulfur_point)}", f"Сера, верхняя граница: {_format_value(view.sulfur_upper)}", f"Проверка верхней границы: {STATUS_RU.get(view.upper_status, view.upper_status)}", - f"Действия: {STATUS_RU.get(view.action_state, view.action_state)}", - f"Выбранный тип: {view.selected_kind or '—'}", - f"Коды причин: {', '.join(view.reason_codes) if view.reason_codes else '—'}", + f"Реальные действия: {STATUS_RU.get(view.action_state, 'недоступны')}", ] - if view.issues: - rows.append("Проблемы: " + " | ".join(view.issues)) - if view.journal_path: - rows.append(f"Журнал: {view.journal_path}") - if view.raw is not None: - rows.extend( - ("", "Raw recommendation JSON:", json.dumps(view.raw, ensure_ascii=False, indent=2)) - ) + if view.reason_codes: + rows.append("Почему: " + reasons_text(list(view.reason_codes))) + if view.issues and not view.reason_codes: + rows.append("Некоторые проверки недоступны; технические детали есть в экспорте.") + rows.append("Технические данные можно сохранить кнопкой «Экспорт результата».") return "\n".join(rows) def format_history_interval_payload(payload: dict[str, object]) -> str: - """Put the useful interval result before its technical export data.""" + """Summarize the interval without showing machine JSON in the operator view.""" points = payload.get("points", 0) requested = payload.get("requested_points", points) rows = [ @@ -446,7 +468,7 @@ def format_history_interval_payload(payload: dict[str, object]) -> str: rows.append(f"Период: {payload['start']} — {payload['end']}") coverage = payload.get("forecast_coverage") if isinstance(coverage, (int, float)): - rows.append(f"Прогноз серы доступен в {coverage:.0%} точек") + rows.append(f"Прогноз серы доступен в {coverage * 100:.0f} % точек") counts = payload.get("status_counts") if isinstance(counts, dict) and counts: rows.append( @@ -455,43 +477,213 @@ def format_history_interval_payload(payload: dict[str, object]) -> str: f"{STATUS_RU.get(str(key), key)} — {value}" for key, value in counts.items() ) ) + reasons = payload.get("reason_counts") + if isinstance(reasons, dict) and reasons: + rows.append( + "Почему система отказалась от действий: " + + "; ".join(f"{reason_text(str(code))} — {count}" for code, count in reasons.items()) + ) rows.extend( ( "Реальные управляющие действия отключены.", - "", - "Технические данные расчёта (JSON):", - json.dumps(payload, 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.""" + """Explain the sulfur-only calculation without raw assumptions and codes.""" + if view.status != "ready": + return ( + "Условное смешение по прогнозу серы\n\n" + "Для расчёта выберите исторические данные и модель прогноза." + if view.status == "empty" + else ( + "Условное смешение по прогнозу серы\n\n" + "Расчёт не выполнен. Проверьте данные и модель." + ) + ) rows = [ - "Условное смешение по прогнозу серы (Hybrid sulfur-only blend)", - "Рассчитана только сера. Остальные свойства смеси здесь не подтверждаются." - if view.status == "ready" - else view.message, + "Условное смешение по прогнозу серы", + "Рассчитана только сера. Остальные свойства смеси здесь не подтверждаются.", + "Связь потока между установками остаётся модельным допущением.", "", - f"Статус расчёта: {STATUS_RU.get(view.status, view.status)}", - f"Сценарий: {view.scenario_id}", - f"Модель: {view.model_id or '—'}", - f"Состояние: {view.source_state_id or '—'}", - f"Компонент A, сера point: {_format_value(view.component_sulfur_point)}", - f"Компонент A, сера upper: {_format_value(view.component_sulfur_upper)}", - f"Смесь, сера point: {_format_value(view.blend_sulfur_point)}", - f"Смесь, сера upper: {_format_value(view.blend_sulfur_upper)}", + f"Компонент A, прогноз серы: {_format_value(view.component_sulfur_point)}", + f"Компонент A, верхняя оценка: {_format_value(view.component_sulfur_upper)}", + f"Смесь, расчётная сера: {_format_value(view.blend_sulfur_point)}", + f"Смесь, верхняя оценка: {_format_value(view.blend_sulfur_upper)}", "Проверка ограничения серы: " + STATUS_RU.get(view.constraint_status, view.constraint_status), ] if view.reason_codes: - rows.append("Коды причин: " + ", ".join(view.reason_codes)) - if view.assumptions: - rows.append("Допущения: " + " | ".join(view.assumptions)) + rows.append("Ограничения вывода: " + reasons_text(list(view.reason_codes))) + rows.append("Полные допущения сохраняются в экспорте результата.") + return "\n".join(rows) + + +def format_journal_payload(payload: dict[str, object]) -> str: + """Give old and new journal entries the same readable default view.""" + try: + result = Recommendation.model_validate(payload) + except (TypeError, ValueError): + return "Не удалось прочитать результат запуска. Проверьте технический файл result.json." + scenario_name = next( + ( + label + for label, scenario_id in SCENARIO_LABELS.items() + if scenario_id == result.scenario_id + ), + "Изменённая модельная смесь" + if result.scenario_id.endswith("_what_if") + else "Исторический расчёт", + ) + status = ( + { + RecommendationStatus.RECOMMEND: "выбран модельный вариант", + RecommendationStatus.HOLD: "сохранена текущая смесь", + RecommendationStatus.ABSTAIN: "вариант не выбран", + }[result.status] + if result.mode.value == "model_demo" + else STATUS_RU.get(result.status.value, "требует проверки") + ) + rows = [ + f"Результат: {status[0].upper() + status[1:]}", + "", + f"Сценарий: {scenario_name}", + f"Момент данных: {result.as_of.isoformat()}", + ] + baseline = result.baseline + selected = result.selected + _, _, baseline_upper = _metric(baseline, "sulfur") + _, _, selected_upper = _metric(selected, "sulfur") + if baseline_upper is not None: + rows.append(f"Текущая сера, верхняя оценка: {_format_value(baseline_upper)}") + if selected_upper is not None: + rows.append(f"Выбранная смесь, верхняя оценка: {_format_value(selected_upper)}") + if selected is not None and selected.candidate.blend_mass_fractions: + recipe = [ + f"{component}: {_format_value(fraction * 100, '%')}" + for component, fraction in sorted(selected.candidate.blend_mass_fractions.items()) + ] + recipe.append( + f"присадка: {_format_value(selected.candidate.additive_mass_fraction * 100, '%')}" + ) + rows.append("Рецептура: " + "; ".join(recipe)) + if result.reason_codes: + rows.append("Причины при проверке вариантов:") + rows.extend( + f"• {text}" for text in dict.fromkeys(reason_text(code) for code in result.reason_codes) + ) + elif result.status is RecommendationStatus.HOLD: + rows.append("Почему: текущая рецептура проходит проверки; изменение не требуется.") + rows.extend( + ( + "", + "Модельный результат не меняет уставки оборудования.", + "Исходные расчёты и коды сохранены в технических файлах запуска.", + ) + ) + return "\n".join(rows) + + +def format_alternatives(result: Recommendation | None) -> str: + """Describe feasible recipes by their fractions and checked quality.""" + if result is None or not result.alternatives: + return "Других допустимых вариантов нет." + rows = [ + "Другие рецептуры, прошедшие модельные проверки:", + "Выбор учитывает риск, производительность и стоимость; " + "минимум серы не всегда лучший вариант.", + ] + for index, alternative in enumerate(result.alternatives, start=1): + shares = [ + f"компонент {name} — {_format_value(share * 100, '%')}" + for name, share in sorted(alternative.candidate.blend_mass_fractions.items()) + ] + shares.append( + f"присадка — {_format_value(alternative.candidate.additive_mass_fraction * 100, '%')}" + ) + _, _, sulfur_upper = _metric(alternative, "sulfur") + _, _, t95_upper = _metric(alternative, "t95") + _, cetane_lower, _ = _metric(alternative, "cetane_number") + risk, _, _ = _metric(alternative, "risk_index") + cost, _, _ = _metric(alternative, "cost_proxy") + rows.extend( + ( + "", + f"Вариант {index}: " + "; ".join(shares), + "Верхняя оценка серы: " + _format_value(sulfur_upper), + "T95 сверху: " + + _format_value(t95_upper, "°C") + + "; цетановое снизу: " + + _format_value(cetane_lower, "ед."), + "Модельный риск: " + + _format_value(risk, "") + + "; стоимостной индекс: " + + _format_value(cost, ""), + ) + ) return "\n".join(rows) +def format_state_summary(state: ProcessState) -> str: + """Summarize available historical inputs without dumping every signal.""" + signals = tuple(state.signals.values()) + fresh = sum(signal.fresh for signal in signals) + missing = sum(signal.selected is None for signal in signals) + stale = len(signals) - fresh - missing + return "\n".join( + ( + "Историческое состояние собрано", + "", + f"Момент данных: {state.as_of.isoformat()}", + f"Сигналов: {len(signals)} · свежих: {fresh} · " + f"устаревших: {stale} · отсутствуют: {missing}", + f"Замечаний к данным: {len(state.issues)}", + "Состояние доступно только для чтения. Прогноз и действия здесь не рассчитываются.", + ) + ) + + +def format_lims_correction_payload(payload: dict[str, object]) -> str: + """Show the validation gate and held-out audit of delayed LIMS correction.""" + report = payload.get("report") + if not isinstance(report, dict): + return "Отчёт коррекции недоступен. Проверьте выбранные данные и модель." + + def metric(key: str) -> str: + value = report.get(key) + return _format_value(value if isinstance(value, (int, float)) else None) + + coverage = report.get("validation_upper_coverage") + coverage_text = ( + f"{coverage * 100:.1f}".replace(".", ",") + " %" + if isinstance(coverage, (int, float)) + else "—" + ) + eligible = report.get("promotion_eligible") is True + return "\n".join( + ( + "Проверка лабораторной коррекции", + "Коррекция исследовательская; основная модель приложения не меняется.", + "", + "Допуск к теневой проверке: " + + ("да, по валидации" if eligible else "нет, критерий не пройден"), + f"Ошибка исходного прогноза на валидации: {metric('validation_pak_point_mae')}", + f"Ошибка с коррекцией на валидации: {metric('validation_corrected_mae')}", + f"Покрытие верхней оценки на валидации: {coverage_text}", + "", + f"Отложенный тест: {report.get('test_rows', '—')} наблюдений.", + f"Ошибка исходного прогноза: {metric('pak_point_mae')}", + f"Ошибка с коррекцией: {metric('corrected_mae')}", + "Тест не использовался для выбора модели." + if report.get("test_used_for_selection") is False + else "Проверьте порядок выбора модели по техническому отчёту.", + ) + ) + + class PetrolCodeApp(tk.Tk): """Resizable operator desktop shell backed by the existing Python API.""" @@ -863,10 +1055,13 @@ def _render_stage_cards(self, parent: tk.Misc) -> None: ) forecast_count = len(context.forecast_artifacts) action_count = len(context.action_artifacts) - dataset_text = "dataset найден" if context.latest_dataset else "dataset отсутствует" + dataset_text = "данные выбраны" if context.latest_dataset else "данные не найдены" tk.Label( card, - text=f"{dataset_text} · forecast {forecast_count} · action {action_count}", + text=( + f"{dataset_text} · моделей прогноза: {forecast_count} · " + f"моделей эффекта действий: {action_count}" + ), bg=SURFACE, fg=TEXT, font=("Segoe UI", 11, "bold"), @@ -874,8 +1069,8 @@ def _render_stage_cards(self, parent: tk.Misc) -> None: tk.Label( card, text=( - "Forecast работает read-only; actionable совет включается только " - "verified action artifact." + "Исторический прогноз доступен только для просмотра. Советы по реальным " + "уставкам отключены без отдельно проверенной модели их эффекта." ), bg=SURFACE, fg=MUTED, @@ -944,7 +1139,7 @@ def _render_stage_page(self, page_key: str) -> None: right = tk.Frame(inner, bg=SURFACE) right.pack(side="left", fill="x", expand=True) self._field(left, "Исторические данные", dataset_var) - self._field(right, "Момент данных (ISO с timezone)", as_of_var) + self._field(right, "Момент данных (ISO, с часовым поясом)", as_of_var) content = tk.Frame(page, bg=BG) content.pack(fill="both", expand=True) @@ -1012,7 +1207,7 @@ def _render_stage_snapshot(self, parent: tk.Misc, snapshot: UiStageSnapshot) -> ) for key, title, width in ( ("group", "Группа", 150), - ("signal", "Signal", 120), + ("signal", "Тег", 120), ("label", "Смысл", 310), ("value", "Значение", 120), ("source", "Источник", 95), @@ -1036,9 +1231,9 @@ def _render_stage_snapshot(self, parent: tk.Misc, snapshot: UiStageSnapshot) -> row.signal_id, row.label, row.value_text, - row.source, + SOURCE_RU.get(row.source, row.source), age, - row.freshness, + FRESHNESS_RU[row.freshness], issue, ), tags=(self._stage_tone(row.freshness),), @@ -1371,7 +1566,7 @@ def _render_history(self) -> None: def settings(parent: tk.Misc) -> None: self._field(parent, "Исторические данные", dataset_var) self._field(parent, "Прогноз серы", model_var) - self._field(parent, "Артефакт действий (не используется)", action_var) + self._field(parent, "Модель эффекта действий (не используется)", action_var) self._accordion(page, "Дополнительные настройки данных и модели", settings).pack( fill="x", pady=(0, 10) @@ -1425,7 +1620,7 @@ def settings(parent: tk.Misc) -> None: fg=TEXT, bd=0, wrap="word", - font=("Consolas", 10), + font=("Segoe UI", 11), padx=14, pady=10, ) @@ -1508,7 +1703,7 @@ def calculate_interval() -> None: start = datetime.fromisoformat(as_of_var.get().replace("Z", "+00:00")) end = datetime.fromisoformat(interval_to_var.get().replace("Z", "+00:00")) if start.tzinfo is None or end.tzinfo is None: - raise ValueError("обе границы интервала должны содержать timezone") + raise ValueError("введите обе границы с часовым поясом") except (TypeError, ValueError) as exc: self._history_interval_text = f"Ошибка ввода интервала: {exc}" summary.set(self._history_interval_text) @@ -1639,7 +1834,7 @@ def _render_hybrid_panel(self, parent: tk.Misc) -> None: panel.pack(fill="x", pady=(16, 0)) tk.Label( panel, - text="Hybrid: АВТ → гидроочистка → смесь", + text="Условная смесь: АВТ → гидроочистка → смесь", bg=SURFACE, fg=TEXT, font=("Segoe UI", 15, "bold"), @@ -1647,8 +1842,8 @@ def _render_hybrid_panel(self, parent: tk.Misc) -> None: tk.Label( panel, text=( - "Компонент A берётся только из history forecast. Если forecast недоступен, " - "UI не подставляет synthetic production-значение." + "Сера компонента A берётся из исторического прогноза. Если прогноз " + "недоступен, система не подставляет модельное значение." ), bg=SURFACE, fg=MUTED, @@ -1671,7 +1866,7 @@ def _render_hybrid_panel(self, parent: tk.Misc) -> None: right.pack(side="left", fill="x", expand=True, padx=(10, 0)) self._field(left, "Исторические данные", dataset_var) self._field(middle, "Прогноз серы", model_var) - self._field(right, "Момент данных (ISO с timezone)", as_of_var) + self._field(right, "Момент данных (ISO, с часовым поясом)", 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)) @@ -1712,17 +1907,17 @@ def start_calculation() -> None: self._secondary_button( panel, - "Рассчитать hybrid", + "Рассчитать условную смесь", start_calculation, ).pack(anchor="e", padx=26, pady=(0, 16)) - self._secondary_button(panel, "Экспортировать hybrid", self.export_hybrid_result).pack( - anchor="e", padx=26, pady=(0, 16) - ) + self._secondary_button( + panel, "Экспортировать условную смесь", self.export_hybrid_result + ).pack(anchor="e", padx=26, pady=(0, 16)) render( self._hybrid_view or UiHybridBlendView( status="empty", - message="Hybrid не рассчитан.", + message="Условная смесь не рассчитана.", scenario_id="hybrid_blend", source_state_id=None, model_id=None, @@ -1863,7 +2058,7 @@ def _render_recommendation(self) -> None: ).pack(fill="x", pady=(16, 8)) self._accordion( page, - "Альтернативы и ход расчёта", + "Другие допустимые варианты", self._alternatives_content, ).pack(fill="x") self._render_hybrid_panel(page) @@ -1995,7 +2190,7 @@ def _render_journal(self) -> None: bd=0, highlightthickness=0, width=38, - font=("Consolas", 10), + font=("Segoe UI", 11), ) listbox.grid(row=0, column=0, sticky="nsew", padx=(18, 0), pady=18) detail = tk.Text( @@ -2014,8 +2209,12 @@ def _render_journal(self) -> None: try: payload = json.loads(path.read_text(encoding="utf-8")) as_of = str(payload.get("as_of", "—"))[:19] - scenario = str(payload.get("scenario_id", "расчёт")) - status = str(payload.get("status", "—")) + scenario_id = str(payload.get("scenario_id", "расчёт")) + scenario = next( + (label for label, code in SCENARIO_LABELS.items() if code == scenario_id), + "Изменённая смесь" if scenario_id.endswith("_what_if") else "История", + ) + status = STATUS_RU.get(str(payload.get("status", "")), "требует проверки") run_id = str(payload.get("run_id", path.parent.name))[:8] label = f"{as_of} · {scenario} · {status} · {run_id}" except (OSError, json.JSONDecodeError): @@ -2023,6 +2222,8 @@ def _render_journal(self) -> None: labels.append(label) listbox.insert("end", label) + technical = tk.BooleanVar(value=False) + def select(_: tk.Event[Any] | None = None) -> None: selection = listbox.curselection() if not selection: @@ -2036,22 +2237,14 @@ def select(_: tk.Event[Any] | None = None) -> None: if metadata_path.is_file() else {} ) - summary = ( - f"Запуск: {payload.get('run_id', path.parent.name)}\n" - f"Сценарий: {payload.get('scenario_id', '—')}\n" - f"Момент данных: {payload.get('as_of', '—')}\n" - f"Статус: {payload.get('status', '—')}\n" - f"Модель: {payload.get('model_id', '—')}\n" - f"Папка доказательств: {path.parent}\n\n" - "Краткий результат:\n" - f"{payload.get('explanation', '—')}\n\n" - "Полный result.json:\n" - ) - rendered = summary + json.dumps(payload, ensure_ascii=False, indent=2) - if metadata: - rendered += "\n\nmetadata.json:\n" + json.dumps( - metadata, ensure_ascii=False, indent=2 - ) + if technical.get(): + rendered = "result.json:\n" + json.dumps(payload, ensure_ascii=False, indent=2) + if metadata: + rendered += "\n\nmetadata.json:\n" + json.dumps( + metadata, ensure_ascii=False, indent=2 + ) + else: + rendered = format_journal_payload(payload) + f"\n\nПапка запуска: {path.parent}" except (OSError, json.JSONDecodeError) as exc: rendered = f"Не удалось открыть текущий запуск: {exc}" detail.configure(state="normal") @@ -2059,6 +2252,20 @@ def select(_: tk.Event[Any] | None = None) -> None: detail.insert("1.0", rendered) detail.configure(state="disabled") + def toggle_technical() -> None: + technical.set(not technical.get()) + technical_button.configure( + text="Показать понятный результат" + if technical.get() + else "Показать технический JSON" + ) + select() + + technical_button = self._secondary_button( + content, "Показать технический JSON", toggle_technical + ) + technical_button.grid(row=1, column=1, sticky="e", padx=18, pady=(0, 14)) + listbox.bind("<>", select) if paths: listbox.selection_set(0) @@ -2108,28 +2315,20 @@ def _text_content(parent: tk.Misc, text: str) -> None: text=text, bg=SURFACE, fg=MUTED, - wraplength=1250, + wraplength=900, justify="left", + font=("Segoe UI", 11), ).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)) + self._text_content(parent, format_alternatives(self._result)) def _data_tools_content(self, parent: tk.Frame) -> None: tk.Label( parent, text=( - "Доступны те же безопасные операции, что и в CLI. Подготовка полного архива " + "Доступны те же безопасные операции, что и в командной строке. " + "Подготовка полного архива " "может занять несколько минут." ), bg=SURFACE, @@ -2145,7 +2344,7 @@ def _data_tools_content(self, parent: tk.Frame) -> None: self._secondary_button(row, "Подготовить данные", self.prepare_data).pack( side="left", padx=10 ) - self._secondary_button(row, "Собрать historical state", self.open_state_dialog).pack( + self._secondary_button(row, "Собрать историческое состояние", self.open_state_dialog).pack( side="left" ) self._secondary_button(row, "Прогноз серы", self.open_history_forecast_dialog).pack( @@ -2266,14 +2465,14 @@ def export_history_result(self) -> None: def export_hybrid_result(self) -> None: if self._hybrid_view is None or self._hybrid_view.status == "empty": messagebox.showinfo( - "Нет hybrid-результата", + "Нет результата условной смеси", "Сначала выполните условное смешение по прогнозу серы.", parent=self, ) return destination = filedialog.asksaveasfilename( parent=self, - title="Сохранить hybrid-результат", + title="Сохранить результат условной смеси", defaultextension=".json", filetypes=(("JSON", "*.json"),), initialfile="neftekod-hybrid-result.json", @@ -2321,14 +2520,14 @@ def validate_config(self) -> None: messagebox.showinfo( "Конфигурация корректна", f"Теги: {result['tags']}\nСценарии: {result['scenarios']}\n" - f"Demo fixtures: {result['model_demo_fixtures']}", + f"Примеров для проверки: {result['model_demo_fixtures']}", parent=self, ) def prepare_data(self) -> None: if not messagebox.askokcancel( "Подготовить данные", - "Прочитать полный архив materials и создать локальный prepared dataset?", + "Прочитать исходные материалы и создать локальный подготовленный набор данных?", parent=self, ): return @@ -2348,7 +2547,10 @@ def _preparation_done(self, result: dict[str, Any]) -> None: self.status_var.set("Данные подготовлены") messagebox.showinfo( "Данные подготовлены", - json.dumps(result, ensure_ascii=False, indent=2), + "Набор данных создан.\n" + f"Идентификатор: {result.get('dataset_id', '—')}\n" + f"Замечаний к исходным данным: {result.get('issues_count', '—')}\n" + f"Папка: {result.get('dataset_path', '—')}", parent=self, ) @@ -2358,8 +2560,8 @@ def _preparation_failed(self, error: Exception) -> None: def open_state_dialog(self) -> None: dialog = tk.Toplevel(self) - dialog.title("Собрать historical state") - dialog.geometry("680x330") + dialog.title("Историческое состояние") + dialog.geometry("680x390") dialog.configure(bg=BG) dialog.transient(self) fields = tk.Frame(dialog, bg=BG) @@ -2369,25 +2571,59 @@ def open_state_dialog(self) -> None: tk.Entry(fields, textvariable=dataset_var, bg=SURFACE, fg=TEXT, bd=1).pack( fill="x", pady=(4, 14), ipady=7 ) - tk.Label(fields, text="Момент данных (ISO с timezone)", bg=BG, fg=TEXT).pack(anchor="w") + tk.Label(fields, text="Момент данных (ISO, с часовым поясом)", bg=BG, fg=TEXT).pack( + anchor="w" + ) as_of_var = self._shared_as_of 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) + current_state: ProcessState | None = None + technical = tk.BooleanVar(value=False) + + def render(text: str) -> None: + output.delete("1.0", "end") + output.insert("1.0", text) def build() -> None: + nonlocal current_state 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) + current_state = build_state_command(dataset_var.get(), "history", timestamp) + text = ( + current_state.model_dump_json(indent=2) + if technical.get() + else format_state_summary(current_state) + ) except Exception as exc: + current_state = None text = f"Ошибка: {exc}" - output.delete("1.0", "end") - output.insert("1.0", text) + render(text) - self._primary_button(fields, "Собрать состояние", build).pack(anchor="e", pady=(12, 0)) + def toggle_technical() -> None: + if current_state is None: + return + technical.set(not technical.get()) + technical_button.configure( + text="Показать понятный результат" + if technical.get() + else "Показать технический JSON" + ) + render( + current_state.model_dump_json(indent=2) + if technical.get() + else format_state_summary(current_state) + ) + + buttons = tk.Frame(fields, bg=BG) + buttons.pack(fill="x", pady=(12, 0)) + technical_button = self._secondary_button( + buttons, "Показать технический JSON", toggle_technical + ) + technical_button.pack(side="left") + self._primary_button(buttons, "Собрать состояние", build).pack(side="right") def open_action_shadow_dialog(self) -> None: """Show a historical P8/F19 scenario without presenting it as a recommendation.""" @@ -2406,8 +2642,8 @@ def open_action_shadow_dialog(self) -> None: at_var = self._shared_as_of for label, variable in ( ("Исторические данные", dataset_var), - ("Исследовательский action-артефакт", model_var), - ("Время состояния (ISO с timezone)", at_var), + ("Исследовательская модель эффекта действий", model_var), + ("Время состояния (ISO, с часовым поясом)", at_var), ("Изменение тега в его исходной единице", delta_var), ): tk.Label(fields, text=label, bg=BG, fg=TEXT).pack(anchor="w") @@ -2445,7 +2681,7 @@ def calculate(values: dict[str, str]) -> None: try: at = datetime.fromisoformat(values["at_var"].replace("Z", "+00:00")) if at.tzinfo is None: - raise ValueError("время должно содержать timezone") + raise ValueError("введите время с часовым поясом") result = action_shadow_estimate_command( values["dataset_var"], values["model_var"], @@ -2498,10 +2734,10 @@ def open_history_forecast_dialog(self) -> None: ("Исторические данные", dataset_var), ("Прогноз серы", model_var), ( - "Проверенный action-артефакт (необязательно)", + "Проверенная модель эффекта действий (необязательно)", action_model_var, ), - ("Время состояния (ISO с timezone)", at_var), + ("Время состояния (ISO, с часовым поясом)", 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( @@ -2511,7 +2747,8 @@ def open_history_forecast_dialog(self) -> None: fields, text=( "Прогноз использует только доступные к выбранному времени данные. " - "Без verified action artifact это предупреждение, а не совет по уставкам." + "Без отдельно проверенной модели эффекта действий это предупреждение, " + "а не совет по уставкам." ), bg=BG, fg=MUTED, @@ -2529,7 +2766,7 @@ def calculate(values: dict[str, str]) -> None: try: at = datetime.fromisoformat(values["at_var"].replace("Z", "+00:00")) if at.tzinfo is None: - raise ValueError("время должно содержать timezone") + raise ValueError("введите время с часовым поясом") action_model = values["action_model_var"].strip() or None view = ui_history_snapshot( values["dataset_var"], @@ -2574,8 +2811,8 @@ def open_v2_forecast_dialog(self) -> None: at_var = self._shared_as_of for label, variable in ( ("Исторические данные", dataset_var), - ("Shadow-артефакт v2", model_var), - ("Время состояния (ISO с timezone)", at_var), + ("Исследовательская модель v2", model_var), + ("Время состояния (ISO, с часовым поясом)", 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( @@ -2585,7 +2822,8 @@ def open_v2_forecast_dialog(self) -> None: fields, text=( "Показывает вероятность начала эпизода и верхнюю границу серы на нескольких " - "горизонтах. Артефакт работает только в shadow-режиме: это предупреждение, " + "горизонтах. Модель работает только в исследовательском режиме: " + "это предупреждение, " "а не совет и не изменение уставок." ), bg=BG, @@ -2604,7 +2842,7 @@ def calculate(values: dict[str, str]) -> None: try: at = datetime.fromisoformat(values["at_var"].replace("Z", "+00:00")) if at.tzinfo is None: - raise ValueError("время должно содержать timezone") + raise ValueError("введите время с часовым поясом") result = replay_v2_shadow_command(values["dataset_var"], values["model_var"], at) text = format_v2_forecast_payload(result) except Exception as exc: @@ -2613,7 +2851,7 @@ def calculate(values: dict[str, str]) -> None: self._primary_button( fields, - "Рассчитать episode forecast", + "Рассчитать прогноз превышения", lambda: threading.Thread( target=calculate, args=( @@ -2653,8 +2891,8 @@ def open_lims_correction_dialog(self) -> None: fields, text=( "ЛИМС публикуется с задержкой и используется отдельно от оперативного ПАК. " - "Результат показывает качество коррекции; только прошедшая gate-коррекция " - "может стать кандидатом для shadow-периода." + "Результат показывает качество коррекции. Только прошедшую проверку " + "коррекцию можно рассматривать для теневого испытания." ), bg=BG, fg=MUTED, @@ -2663,30 +2901,57 @@ def open_lims_correction_dialog(self) -> None: ).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) + current_result: dict[str, object] | None = None + technical = tk.BooleanVar(value=False) def render(text: str) -> None: output.delete("1.0", "end") output.insert("1.0", text) + def render_result(result: dict[str, object]) -> None: + nonlocal current_result + current_result = result + render( + json.dumps(result, ensure_ascii=False, indent=2) + if technical.get() + else format_lims_correction_payload(result) + ) + + def toggle_technical() -> None: + if current_result is None: + return + technical.set(not technical.get()) + technical_button.configure( + text="Показать понятный результат" + if technical.get() + else "Показать технический JSON" + ) + render_result(current_result) + def calculate(values: dict[str, str]) -> None: try: result = evaluate_lims_correction_command( values["dataset_var"], values["model_var"] ) - text = json.dumps(result, ensure_ascii=False, indent=2) + self._schedule_dialog_render(dialog, lambda _: render_result(result), "") except Exception as exc: - text = f"Ошибка: {exc}" - self._schedule_dialog_render(dialog, render, text) + self._schedule_dialog_render(dialog, render, f"Ошибка: {exc}") + buttons = tk.Frame(fields, bg=BG) + buttons.pack(fill="x", pady=(12, 0)) + technical_button = self._secondary_button( + buttons, "Показать технический JSON", toggle_technical + ) + technical_button.pack(side="left") self._primary_button( - fields, - "Оценить LIMS-коррекцию", + buttons, + "Оценить коррекцию по ЛИМС", lambda: threading.Thread( target=calculate, args=({"dataset_var": dataset_var.get(), "model_var": model_var.get()},), daemon=True, ).start(), - ).pack(anchor="e", pady=(12, 0)) + ).pack(side="right") def smoke_snapshot(scenario_id: str) -> dict[str, Any]: diff --git a/source/ui_data.py b/source/ui_data.py index fb7260f..824d12d 100644 --- a/source/ui_data.py +++ b/source/ui_data.py @@ -38,8 +38,8 @@ ) 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": ( + "Состояние К-2": ("avt:F65", "avt:T20", "avt:T33", "avt:P21", "avt:P22", "avt:P23", "avt:P67"), + "Циркуляция К-2": ( "avt:F14", "avt:T13", "avt:T18", @@ -49,14 +49,14 @@ "avt:T11", "avt:T15", ), - "Diesel cut": ("avt:T66", "avt:F28", "avt:F32", "avt:T71", "avt:F30", "avt:W70"), + "Дизельная фракция": ("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"), + "Качество": ("ht:2:Mg.Sulfur", "ht:density_15c"), + "Теги исследования действий": ("ht:P8", "ht:F19"), + "Технологические сигналы": ("ht:T11", "ht:F26", "ht:F14", "ht:F15", "ht:F17"), + "Газовый контур": ("ht:F2", "ht:F22", "ht:F25"), } ACTION_CONTROL_IDS = {"ht:P8", "ht:F19"} @@ -336,10 +336,7 @@ def ui_stage_snapshot( 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" - ), + message="Подготовленный набор данных не найден. Проверьте папку данных в настройках.", fresh_count=0, stale_count=0, missing_count=0, @@ -367,7 +364,14 @@ def ui_stage_snapshot( dataset_id=None, as_of=None, status="error", - message=str(exc), + message=( + "Файлы подготовленного набора данных не найдены. Проверьте выбранную папку." + if isinstance(exc, FileNotFoundError) + else ( + "Не удалось открыть подготовленные данные. " + "Проверьте путь и совместимость набора." + ) + ), fresh_count=0, stale_count=0, missing_count=0, @@ -383,9 +387,7 @@ def ui_stage_snapshot( 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." - ), + message="Сигналы доступны только для просмотра. Изменение уставок отключено.", fresh_count=fresh, stale_count=stale, missing_count=missing, @@ -405,7 +407,7 @@ def ui_history_snapshot( if not dataset or not model: return UiHistoryReplayView( status="empty", - message="Prepared dataset and forecast artifact are required for history replay.", + message="Для прогноза выберите подготовленные данные и модель.", recommendation_status=None, scenario_id=None, model_id=None, @@ -436,7 +438,7 @@ def ui_history_snapshot( except Exception as exc: return UiHistoryReplayView( status="error", - message=str(exc), + message="Не удалось рассчитать прогноз. Проверьте время, данные и модель.", recommendation_status=None, scenario_id=None, model_id=None, @@ -504,7 +506,7 @@ def ui_hybrid_snapshot( if not dataset or not model: return UiHybridBlendView( status="empty", - message="Hybrid needs a prepared dataset and a trusted sulfur forecast artifact.", + message="Для условной смеси выберите подготовленные данные и модель прогноза серы.", scenario_id="hybrid_blend", source_state_id=None, model_id=None, @@ -524,7 +526,7 @@ def ui_hybrid_snapshot( 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.", + message="Прогноз серы для компонента A недоступен на выбранный момент.", scenario_id="hybrid_blend", source_state_id=result.state_id, model_id=result.model_id, @@ -548,7 +550,7 @@ def ui_hybrid_snapshot( ), 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.",), + assumptions=("Сера компонента A взята из проверенного исторического прогноза.",), ) forecast = HybridComponentForecast( component_id="A", @@ -574,8 +576,8 @@ def ui_hybrid_snapshot( 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, @@ -704,7 +706,7 @@ def _signal_row( available_at=None, age_minutes=None, freshness="missing", - issue="no valid observation at as_of", + issue="на выбранный момент нет доступного измерения", evidence_ref=evidence, read_only_reason=read_only_reason, ) @@ -727,7 +729,9 @@ def _signal_row( 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", + issue="" + if fresh + else f"данные старше нормы: {age_minutes:.0f} мин при пределе {limit:.0f} мин", evidence_ref=evidence, read_only_reason=read_only_reason, ) @@ -802,21 +806,30 @@ def _freshness_limit_minutes(config: Any, source: str) -> float: 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" - ) + return "управление отключено до проверки модели эффекта и пределов изменения" if signal_id in CONTEXT_ONLY_IDS: - return "context-only signal; not an enabled action control" + return "сигнал для контекста; управление по нему отключено" if unit == Unit.UNKNOWN.value: - return "read-only context; unit is unknown and cannot define a control" - return "read-only process context" + return "единица измерения не подтверждена; управление отключено" + return "технологический сигнал только для просмотра" 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() + rendered = f"{value:.3f}".rstrip("0").rstrip(".").replace(".", ",") + label = { + "unknown": "единица не указана", + "mg/kg": "мг/кг", + "kg/m3": "кг/м³", + "m3/h": "м³/ч", + "Nm3/h": "нм³/ч", + "t/h": "т/ч", + "MPa": "МПа", + "degC": "°C", + "vol%": "об. %", + }.get(unit, unit) + return f"{rendered} {label}".strip() def _metric( From 1497ab2cf1025c1398d56fac726e6f4cb7a374ff Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sat, 26 Sep 2026 21:59:55 +0300 Subject: [PATCH 07/11] fix(ui): keep blend quality estimates in sync --- config/release_manifest.json | 2 +- docs/DEMO_SCRIPT.md | 4 +- global_tests/test_ui.py | 2 +- global_tests/test_ui_interactive.py | 71 +++++++++++- source/ui_what_if.py | 174 +++++++++++++++++++++++++++- 5 files changed, 245 insertions(+), 8 deletions(-) diff --git a/config/release_manifest.json b/config/release_manifest.json index 68289d3..e086ad4 100644 --- a/config/release_manifest.json +++ b/config/release_manifest.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "release_id": "neftekod-v1.3.1", + "release_id": "neftekod-v1.3.2", "prepared_dataset": "data/processed/66bdfcbb23b4", "legacy_config_sha256": "00a45650caa0f415e78037d56bc49de7161f55f9eecd24675e8874d64efb63e2", "preparation_config_sha256": "7f8dab1f4befa7ed4d14108146d344eeeb4341711880144ad0195d163a55c4dd", diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index 5108b50..baf7f33 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -99,7 +99,7 @@ Hybrid проверяет только серу. Он не подтвержда На первой вкладке «Рецептура» задайте доли A, B, присадки и массу партии. После Enter или выхода из поля редактор подбирает допустимые значения остальных долей, оставляя последнее изменённое значение неизменным. Галочка «Не менять» удерживает долю и при следующих правках. Справа показаны изменения, проверки серы, T95 и цетанового числа, а в списке можно выбрать смесь ближе к введённой, с меньшей стоимостью или меньшим количеством присадки. Если смесь невозможна при закреплённых полях или запасах, редактор объясняет причину. -На вкладке «Свойства и запасы» для A и B задаются сера и её верхняя оценка (мг/кг), T95 и его верхняя оценка (°C), цетановое число и его нижняя оценка, запас (т), стоимость (условный индекс/т) и риск от 0 до 1. Пустое поле качества означает неизвестное значение и может привести к отказу; запас и другие обязательные числа оставлять пустыми нельзя. +На вкладке «Свойства и запасы» для A и B задаются сера и её верхняя оценка (мг/кг), T95 и его верхняя оценка (°C), цетановое число и его нижняя оценка, запас (т), стоимость (условный индекс/т) и риск от 0 до 1. Подсказка над полями показывает правила для связанных оценок и пределы готовой смеси. При изменении значения редактор сдвигает оценку с прежней разницей: сера A «6 → 1» даёт верхнюю оценку «7 → 2». Если изменить саму оценку так, что она противоречит значению, редактор сохраняет ввод и подстраивает значение: верхняя оценка «7 → 1» даёт серу «6 → 1». Обе автоправки видны в полях и пояснены под ними. Пустое поле качества означает неизвестное значение и может привести к отказу; запас и другие обязательные числа оставлять пустыми нельзя. На вкладке «Параметры присадки» задаются максимум присадки (1, 2 или 3%), запас (т), стоимость (условный индекс/т) и прирост цетанового числа при 1, 2 и 3%. Кривая эффекта остаётся синтетической; прирост не должен уменьшаться с дозой. @@ -111,7 +111,7 @@ Hybrid проверяет только серу. Он не подтвержда | Сбросить | Восстанавливает исходные поля; затем нажмите «Рассчитать смесь», чтобы обновить результат | | Закрыть | Закрывает редактор без применения введённых значений; крестик действует так же | -Сумма текущих долей A, B и присадки должна равняться 100%. Верхняя оценка не может быть ниже точки, нижняя — выше. Числа должны быть конечными и неотрицательными; допустимы точка и запятая. Ошибка ввода остаётся в окне. Нехватка запасов, пропущенное качество или отсутствие допустимой смеси дают объяснённый отказ, а не вымышленную рекомендацию. +Сумма текущих долей A, B и присадки должна равняться 100%. Верхняя оценка не может быть ниже точки, нижняя — выше. Связанная пара автоматически согласуется после выхода из поля, но прочие проверки сохраняются. Числа должны быть конечными и неотрицательными; допустимы точка и запятая. Ошибка ввода остаётся в окне. Нехватка запасов, пропущенное качество или отсутствие допустимой смеси дают объяснённый отказ, а не вымышленную рекомендацию. Обычный расчёт сохраняет прежний дискретный перебор и добавляет допустимые граничные смеси, если сетка пропускает узкий допустимый диапазон. Редактор ищет смесь в пределах заданных свойств, запасов и закреплённых полей; он не обещает непрерывный математический оптимум. Изменённый сценарий и введённые значения сохраняются в журнале `input.json` и в экспорте; исходный preset на диске остаётся прежним. diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index 8b8f8cb..5fe7709 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -300,7 +300,7 @@ def release_context_root(tmp_path: Path) -> Path: def test_release_manifest_pins_one_live_context(release_context_root: Path) -> None: context = ui_data_module.discover_ui_context(release_context_root) - assert context.release_id == "neftekod-v1.3.1" + assert context.release_id == "neftekod-v1.3.2" assert context.latest_dataset is not None assert context.latest_dataset.endswith("66bdfcbb23b4") assert tuple(item.model_id for item in context.forecast_artifacts) == ( diff --git a/global_tests/test_ui_interactive.py b/global_tests/test_ui_interactive.py index dc85f95..0038f5e 100644 --- a/global_tests/test_ui_interactive.py +++ b/global_tests/test_ui_interactive.py @@ -11,7 +11,12 @@ from source.config import load_scenario from source.main import run_model_demo from source.ui_charts import HistoryChart, history_points -from source.ui_what_if import editor_values, open_editor, scenario_from_editor +from source.ui_what_if import ( + editor_values, + linked_quality_adjustment, + open_editor, + scenario_from_editor, +) def test_what_if_changes_forecast_and_journals_inputs(tmp_path: Path) -> None: @@ -73,6 +78,35 @@ def test_what_if_validation_names_visible_field() -> None: scenario_from_editor(preset, editor_values(preset) | {"A.fraction": "abc"}) +def test_linked_quality_adjustment_keeps_the_field_the_user_changed() -> None: + preset = load_scenario("config/scenarios/blend_risk.json") + original = editor_values(preset) + values = original | {"A.sulfur.upper": "1"} + assert linked_quality_adjustment(values, "A.sulfur.upper")[:2] == ( + "A.sulfur.value", + "1", + ) + assert values["A.sulfur.value"] == "6" + assert linked_quality_adjustment(values, "A.sulfur.value")[:2] == ( + "A.sulfur.upper", + "6", + ) + assert linked_quality_adjustment( + original | {"A.sulfur.value": "1"}, "A.sulfur.value", original + )[:2] == ("A.sulfur.upper", "2") + assert linked_quality_adjustment( + editor_values(preset) | {"A.cetane_number.lower": "55"}, + "A.cetane_number.lower", + )[:2] == ("A.cetane_number.value", "55") + assert ( + linked_quality_adjustment( + editor_values(preset) | {"A.sulfur.upper": "не число"}, + "A.sulfur.upper", + ) + is None + ) + + @pytest.mark.parametrize( "updates", [ @@ -216,3 +250,38 @@ def test_editor_assist_and_undo_on_real_tk() -> None: dialog.destroy() finally: root.destroy() + + +def test_editor_repairs_conflicting_quality_before_calculation() -> None: + try: + root = tk.Tk() + except tk.TclError as exc: + pytest.skip(f"Tk display unavailable: {exc}") + root.withdraw() + try: + preset = load_scenario("config/scenarios/blend_risk.json") + received = [] + dialog = open_editor(root, preset, preset, received.append) + dialog.withdraw() + entries = [widget for widget in descendants(dialog) if isinstance(widget, ttk.Entry)] + point, upper = entries[5:7] + point.delete(0, "end") + point.insert(0, "1") + point.event_generate("") + assert upper.get() == "2" + buttons = { + widget.cget("text"): widget + for widget in descendants(dialog) + if isinstance(widget, (tk.Button, ttk.Button)) + } + buttons["Сбросить"].invoke() + upper.delete(0, "end") + upper.insert(0, "1") + upper.event_generate("") + assert point.get() == "1" + buttons["Рассчитать смесь"].invoke() + assert len(received) == 1 + assert received[0].blend_components[0].sulfur.value == 1 + assert received[0].blend_components[0].sulfur.upper == 1 + finally: + root.destroy() diff --git a/source/ui_what_if.py b/source/ui_what_if.py index bce4028..691369e 100644 --- a/source/ui_what_if.py +++ b/source/ui_what_if.py @@ -75,6 +75,73 @@ def editor_values(scenario: ScenarioConfig) -> dict[str, str]: return values +def linked_quality_adjustment( + values: dict[str, str], changed: str, previous: dict[str, str] | None = None +) -> tuple[str, str, str] | None: + """Keep an edited bound, or move its paired bound with an edited point.""" + parts = changed.split(".") + if len(parts) != 3: + return None + component, metric, field = parts + bounds = { + "sulfur": ("upper", "серы", "мг/кг"), + "t95": ("upper", "T95", "°C"), + "cetane_number": ("lower", "цетанового числа", ""), + } + if metric not in bounds: + return None + bound, label, unit = bounds[metric] + if field not in ("value", bound): + return None + point_key = f"{component}.{metric}.value" + bound_key = f"{component}.{metric}.{bound}" + try: + point = float(values[point_key].strip().replace(",", ".")) + edge = float(values[bound_key].strip().replace(",", ".")) + except (KeyError, ValueError): + return None + if not all(math.isfinite(value) and value >= 0 for value in (point, edge)): + return None + if field == "value" and previous is not None: + try: + old_point = float(previous[point_key].strip().replace(",", ".")) + old_edge = float(previous[bound_key].strip().replace(",", ".")) + except (KeyError, ValueError): + pass + else: + if ( + all(math.isfinite(value) and value >= 0 for value in (old_point, old_edge)) + and abs(edge - old_edge) < 1e-9 + and abs(point - old_point) >= 1e-9 + ): + gap = max(0.0, old_edge - old_point if bound == "upper" else old_point - old_edge) + corrected = point + gap if bound == "upper" else max(0.0, point - gap) + if abs(corrected - edge) >= 1e-9: + displayed = f"{corrected:g}".replace(".", ",") + bound_label = "верхняя" if bound == "upper" else "нижняя" + return ( + bound_key, + f"{corrected:.12g}", + f"Компонент {component}: {bound_label} оценка {label} подстроена " + f"до {displayed}{' ' + unit if unit else ''}; " + "прежняя разница с основным значением сохранена.", + ) + if (bound == "upper" and edge >= point) or (bound == "lower" and edge <= point): + return None + target = bound_key if field == "value" else point_key + corrected = point if field == "value" else edge + displayed = f"{corrected:g}".replace(".", ",") + changed_label = "верхняя оценка" if bound == "upper" else "нижняя оценка" + repaired_label = changed_label if field == "value" else "значение" + note = ( + f"Компонент {component}: {repaired_label} {label} автоматически установлена на " + f"{displayed}{' ' + unit if unit else ''}, чтобы оценка не противоречила значению." + ) + if field != "value": + note = note.replace("установлена", "установлено") + return target, f"{corrected:.12g}", note + + def scenario_from_editor(base: ScenarioConfig, values: dict[str, str]) -> ScenarioConfig: """Validate a detached synthetic scenario without touching preset files.""" if base.mode.value != "model_demo" or base.controls: @@ -116,8 +183,13 @@ def number(key: str) -> float: label = {"sulfur": "серы", "t95": "T95", "cetane_number": "цетанового числа"}[ metric ] + direction = "не меньше" if bound == "upper" else "не больше" + unit = {"sulfur": " мг/кг", "t95": " °C"}.get(metric, "") + estimate_name = "верхняя" if bound == "upper" else "нижняя" raise ValueError( - f"Компонент {component['id']}: оценка {label} противоречит значению" + f"Компонент {component['id']}: {estimate_name} оценка {label} " + f"({edge:g}{unit}) должна быть {direction} значения " + f"({point:g}{unit}). Измените одно из двух полей." ) if component["risk_index"] > 1: raise ValueError(f"{component['id']}: риск должен быть от 0 до 1") @@ -364,6 +436,33 @@ def open_editor( variants.pack_forget() components = ttk.Frame(notebook, padding=10, style="WhatIf.TFrame") notebook.add(components, text="Свойства и запасы") + limits = [] + for constraint in preset.constraints: + label, unit = { + "sulfur": ("сера", "мг/кг"), + "t95": ("T95", "°C"), + "cetane_number": ("цетановое число", ""), + }.get(constraint.metric, ("", "")) + if not label: + continue + if constraint.upper is not None: + limits.append(f"{label} ≤ {constraint.upper:g} {unit}".strip()) + elif constraint.lower is not None: + limits.append(f"{label} ≥ {constraint.lower:g} {unit}".strip()) + quality_hint = ( + "Для компонента: верхняя оценка ≥ значения, нижняя ≤ значения. " + "При правке значения оценка сохраняет прежнюю разницу; " + "при правке оценки противоречие устраняется автоматически.\n" + "Для готовой смеси: " + "; ".join(limits) + ". Эти пределы проверяются после подбора." + ) + quality_note = tk.StringVar(dialog, value=quality_hint) + ttk.Label( + components, + textvariable=quality_note, + style="WhatIf.TLabel", + wraplength=840, + justify="left", + ).grid(row=0, column=0, columnspan=2, sticky="w", padx=8, pady=(0, 12)) for col, component in enumerate(preset.blend_components): frame = ttk.LabelFrame( components, @@ -371,7 +470,7 @@ def open_editor( padding=8, style="WhatIf.TLabelframe", ) - frame.grid(row=0, column=col, sticky="nsew", padx=8) + frame.grid(row=1, column=col, sticky="nsew", padx=8) components.columnconfigure(col, weight=1) for row, (field, label) in enumerate(COMPONENT_FIELDS): if field == "fraction": @@ -414,6 +513,8 @@ def open_editor( options: tuple[tuple[BlendOption, dict[str, str]], ...] = () pending: str | None = None updating = False + last_quality_edit: str | None = None + quality_previous = {key: var.get() for key, var in variables.items()} def cancel_pending() -> None: nonlocal pending @@ -424,6 +525,51 @@ def cancel_pending() -> None: def values_now() -> dict[str, str]: return {key: var.get() for key, var in variables.items()} + def reconcile_quality(preferred: str | None) -> None: + nonlocal updating, quality_previous + quality_keys = [ + f"{component.id}.{metric}.{bound}" + for component in preset.blend_components + for metric, bound in ( + ("sulfur", "upper"), + ("t95", "upper"), + ("cetane_number", "lower"), + ) + ] + for key in ([preferred] if preferred else []) + quality_keys: + if key is None: + continue + adjustment = linked_quality_adjustment(values_now(), key, quality_previous) + if adjustment is None: + continue + target, value, note = adjustment + updating = True + try: + variables[target].set(value) + entries[target].configure(style="WhatIf.Changed.TEntry") + finally: + updating = False + quality_note.set(note) + error.set("") + current_values = values_now() + for bound_key in quality_keys: + point_key = bound_key.rsplit(".", 1)[0] + ".value" + try: + point = float(current_values[point_key].strip().replace(",", ".")) + edge = float(current_values[bound_key].strip().replace(",", ".")) + except ValueError: + continue + valid = ( + math.isfinite(point) + and math.isfinite(edge) + and point >= 0 + and edge >= 0 + and (edge >= point if bound_key.endswith(".upper") else edge <= point) + ) + if valid: + quality_previous[point_key] = current_values[point_key] + quality_previous[bound_key] = current_values[bound_key] + def show_option(position: int) -> None: nonlocal updating option, updated = options[position] @@ -493,6 +639,7 @@ def pick(_: tk.Event[tk.Misc] | None = None) -> None: def suggest(changed: str | None = None, *, force: bool = False) -> None: nonlocal options, before_assist cancel_pending() + reconcile_quality(changed or last_quality_edit) if updating or (not assistant_on.get() and not force): return raw = values_now() @@ -523,9 +670,26 @@ def on_return(_event: tk.Event[tk.Misc], *, name: str) -> None: suggest(name) def on_focus_out(_event: tk.Event[tk.Misc], *, name: str) -> None: + reconcile_quality(name) schedule(name) + def on_focus_in(_event: tk.Event[tk.Misc], *, name: str) -> None: + nonlocal last_quality_edit + if name.endswith( + ( + ".sulfur.value", + ".sulfur.upper", + ".t95.value", + ".t95.upper", + ".cetane_number.value", + ".cetane_number.lower", + ) + ): + last_quality_edit = name + quality_note.set(quality_hint) + for key, entry in entries.items(): + entry.bind("", partial(on_focus_in, name=key)) entry.bind("", partial(on_return, name=key)) entry.bind("", partial(on_focus_out, name=key)) @@ -558,6 +722,7 @@ def undo_assist() -> None: def apply() -> None: cancel_pending() + reconcile_quality(last_quality_edit) try: scenario = scenario_from_editor( preset, {key: var.get() for key, var in variables.items()} @@ -580,13 +745,16 @@ def apply() -> None: dialog.destroy() def reset() -> None: - nonlocal before_assist + nonlocal before_assist, last_quality_edit, quality_previous cancel_pending() for key, value in editor_values(preset).items(): variables[key].set(value) for lock_var in locks.values(): lock_var.set(False) before_assist = None + last_quality_edit = None + quality_previous = values_now() + quality_note.set(quality_hint) variants.pack_forget() preview.set("Исходная рецептура восстановлена. Измените долю или рассчитайте результат.") for key in recipe_keys: From 3939d0ca88075f8c10be3d695c2042c430c7e562 Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sat, 26 Sep 2026 22:20:14 +0300 Subject: [PATCH 08/11] fix(ui): preserve entered blend share during assistance --- DESIGN.md | 8 +- config/release_manifest.json | 2 +- docs/DEMO_SCRIPT.md | 9 +- global_tests/test_blend_assist.py | 19 ++++ global_tests/test_ui.py | 2 +- global_tests/test_ui_interactive.py | 82 ++++++++++++++++ source/constraints.py | 14 ++- source/ui_what_if.py | 140 +++++++++++++++++++++++----- 8 files changed, 240 insertions(+), 36 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 4a9b79a..12a6263 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -574,8 +574,12 @@ action-shadow и контроль ПАК--ЛИМС; ни один из этих Preset на диске не изменяется; изменённые входы попадают в журнал и JSON-экспорт. Сброс восстанавливает поля preset и требует явного пересчёта. С v1.2.0 он подбирает допустимую рецептуру двух компонентов при закреплённых -значениях долей или присадки. Предложения проходят тот же фильтр ограничений, -что и обычный расчёт; исходное и показанное значения записываются в журнал. +значениях долей или присадки. Последняя введённая доля сохраняется и при ручном +запуске подбора; пустая соседняя доля заполняется из остатка. Изменение введённой +доли требует отдельной команды поиска ближайшего допустимого варианта. Предложения +проходят тот же фильтр ограничений, что и обычный расчёт; исходное и показанное +значения записываются в журнал. В синтетическом режиме сравнение с пределом +качества допускает только погрешность округления 1e-10 в единицах показателя. `source/ui_charts.py` строит график исторического point/upper и лимита серы. Ось времени соответствует цели прогноза (as_of + 60 минут); пропуски сохраняются, diff --git a/config/release_manifest.json b/config/release_manifest.json index e086ad4..89b20d3 100644 --- a/config/release_manifest.json +++ b/config/release_manifest.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "release_id": "neftekod-v1.3.2", + "release_id": "neftekod-v1.3.3", "prepared_dataset": "data/processed/66bdfcbb23b4", "legacy_config_sha256": "00a45650caa0f415e78037d56bc49de7161f55f9eecd24675e8874d64efb63e2", "preparation_config_sha256": "7f8dab1f4befa7ed4d14108146d344eeeb4341711880144ad0195d163a55c4dd", diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index baf7f33..2e04f5a 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -97,7 +97,7 @@ Hybrid проверяет только серу. Он не подтвержда Нажмите «Настроить смесь» на «Обзоре» или «Смеси». За основу берётся выбранный готовый сценарий. При повторном открытии после успешного пересчёта редактор показывает последние изменённые входы этого сценария. Это эксперимент с модельными допущениями, а не правка истории предприятия. -На первой вкладке «Рецептура» задайте доли A, B, присадки и массу партии. После Enter или выхода из поля редактор подбирает допустимые значения остальных долей, оставляя последнее изменённое значение неизменным. Галочка «Не менять» удерживает долю и при следующих правках. Справа показаны изменения, проверки серы, T95 и цетанового числа, а в списке можно выбрать смесь ближе к введённой, с меньшей стоимостью или меньшим количеством присадки. Если смесь невозможна при закреплённых полях или запасах, редактор объясняет причину. +На первой вкладке «Рецептура» задайте доли A, B, присадки и массу партии. Долю второго компонента можно оставить пустой: она заполнится остатком до 100%. После Enter, выхода из поля или нажатия «Подобрать смесь» редактор подбирает допустимые значения остальных долей, сохраняя последнюю введённую долю. Галочка «Не менять» удерживает долю и при следующих правках. Справа показаны изменения, проверки серы, T95 и цетанового числа, а в списке можно выбрать смесь ближе к введённой, с меньшей стоимостью или меньшим количеством присадки. Если при заданной доле допустимой смеси нет, можно изменить вход или явно нажать «Найти ближайшую допустимую»: этот вариант может изменить и введённую долю. Отмеченные «Не менять» поля он не меняет. На вкладке «Свойства и запасы» для A и B задаются сера и её верхняя оценка (мг/кг), T95 и его верхняя оценка (°C), цетановое число и его нижняя оценка, запас (т), стоимость (условный индекс/т) и риск от 0 до 1. Подсказка над полями показывает правила для связанных оценок и пределы готовой смеси. При изменении значения редактор сдвигает оценку с прежней разницей: сера A «6 → 1» даёт верхнюю оценку «7 → 2». Если изменить саму оценку так, что она противоречит значению, редактор сохраняет ввод и подстраивает значение: верхняя оценка «7 → 1» даёт серу «6 → 1». Обе автоправки видны в полях и пояснены под ними. Пустое поле качества означает неизвестное значение и может привести к отказу; запас и другие обязательные числа оставлять пустыми нельзя. @@ -105,13 +105,14 @@ Hybrid проверяет только серу. Он не подтвержда | Кнопка | Действие | | --- | --- | -| Подобрать смесь | Пересчитывает допустимые варианты с учётом закреплённых значений | +| Подобрать смесь | Сохраняет последнюю введённую долю и остальные отмеченные «Не менять» поля; заполняет свободные доли и показывает допустимые варианты | +| Найти ближайшую допустимую | Ищет вариант, который может изменить последнюю введённую долю; поля «Не менять» остаются закреплёнными | | Отменить подбор | Возвращает введённые перед подбором значения; ручной режим также доступен через переключатель «Подбирать после изменения» | -| Рассчитать смесь | Проверяет поля, закрывает редактор и оценивает именно показанную смесь; результат появляется на «Смеси» с отметкой синтетического сценария | +| Рассчитать смесь | Заполняет пустую долю из остатка, проверяет поля, закрывает редактор и оценивает показанную смесь; результат появляется на «Смеси» с отметкой синтетического сценария | | Сбросить | Восстанавливает исходные поля; затем нажмите «Рассчитать смесь», чтобы обновить результат | | Закрыть | Закрывает редактор без применения введённых значений; крестик действует так же | -Сумма текущих долей A, B и присадки должна равняться 100%. Верхняя оценка не может быть ниже точки, нижняя — выше. Связанная пара автоматически согласуется после выхода из поля, но прочие проверки сохраняются. Числа должны быть конечными и неотрицательными; допустимы точка и запятая. Ошибка ввода остаётся в окне. Нехватка запасов, пропущенное качество или отсутствие допустимой смеси дают объяснённый отказ, а не вымышленную рекомендацию. +После заполнения свободных полей сумма долей A, B и присадки должна равняться 100%. Верхняя оценка не может быть ниже точки, нижняя — выше. Связанная пара автоматически согласуется после выхода из поля, но прочие проверки сохраняются. Числа должны быть конечными и неотрицательными; допустимы точка и запятая. Ошибка ввода остаётся в окне. Нехватка запасов, пропущенное качество или отсутствие допустимой смеси дают объяснённый отказ, а не вымышленную рекомендацию. Обычный расчёт сохраняет прежний дискретный перебор и добавляет допустимые граничные смеси, если сетка пропускает узкий допустимый диапазон. Редактор ищет смесь в пределах заданных свойств, запасов и закреплённых полей; он не обещает непрерывный математический оптимум. Изменённый сценарий и введённые значения сохраняются в журнале `input.json` и в экспорте; исходный preset на диске остаётся прежним. diff --git a/global_tests/test_blend_assist.py b/global_tests/test_blend_assist.py index a1f4a7c..3777042 100644 --- a/global_tests/test_blend_assist.py +++ b/global_tests/test_blend_assist.py @@ -28,6 +28,25 @@ def test_editor_keeps_changed_share_and_only_repairs_recipe() -> None: assert option.evaluation.feasible +def test_blank_neighbor_uses_the_entered_share_at_a_rounded_quality_boundary() -> None: + scenario = load_scenario("config/scenarios/blend_risk.json") + values = editor_values(scenario) | {"A.fraction": "86.2083333333", "B.fraction": ""} + options = assist_editor_values(scenario, values, frozenset({"A.fraction"})) + assert options + _, updated = options[0] + assert updated["A.fraction"] == values["A.fraction"] + assert float(updated["B.fraction"]) == pytest.approx(12.7916666667) + assert updated["dose"] == "1" + assert scenario_from_editor(scenario, updated) + + +def test_rounding_tolerance_does_not_allow_a_material_quality_violation() -> None: + scenario = load_scenario("config/scenarios/blend_risk.json") + values = editor_values(scenario) | {"A.fraction": "86.2", "B.fraction": ""} + with pytest.raises(ValueError, match="Подходящей смеси нет"): + assist_editor_values(scenario, values, frozenset({"A.fraction", "dose"})) + + def test_editor_explains_impossible_fixed_stock() -> None: scenario = load_scenario("config/scenarios/blend_risk.json") values = editor_values(scenario) diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index 5fe7709..8798cbd 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -300,7 +300,7 @@ def release_context_root(tmp_path: Path) -> Path: def test_release_manifest_pins_one_live_context(release_context_root: Path) -> None: context = ui_data_module.discover_ui_context(release_context_root) - assert context.release_id == "neftekod-v1.3.2" + assert context.release_id == "neftekod-v1.3.3" assert context.latest_dataset is not None assert context.latest_dataset.endswith("66bdfcbb23b4") assert tuple(item.model_id for item in context.forecast_artifacts) == ( diff --git a/global_tests/test_ui_interactive.py b/global_tests/test_ui_interactive.py index 0038f5e..225e7e8 100644 --- a/global_tests/test_ui_interactive.py +++ b/global_tests/test_ui_interactive.py @@ -285,3 +285,85 @@ def test_editor_repairs_conflicting_quality_before_calculation() -> None: assert received[0].blend_components[0].sulfur.upper == 1 finally: root.destroy() + + +def test_manual_blend_assist_keeps_the_latest_entered_share_and_fills_blank() -> None: + try: + root = tk.Tk() + except tk.TclError as exc: + pytest.skip(f"Tk display unavailable: {exc}") + root.withdraw() + try: + preset = load_scenario("config/scenarios/blend_risk.json") + received = [] + dialog = open_editor(root, preset, preset, received.append) + dialog.withdraw() + entries = [widget for widget in descendants(dialog) if isinstance(widget, ttk.Entry)] + share_a, share_b, dose = entries[:3] + share_a.delete(0, "end") + share_a.insert(0, "86.2083333333") + share_b.delete(0, "end") + button = next( + widget + for widget in descendants(dialog) + if isinstance(widget, ttk.Button) and widget.cget("text") == "Подобрать смесь" + ) + button.invoke() + assert share_a.get() == "86.2083333333" + assert float(share_b.get()) == pytest.approx(12.7916666667) + assert dose.get() == "1" + reset = next( + widget + for widget in descendants(dialog) + if isinstance(widget, ttk.Button) and widget.cget("text") == "Сбросить" + ) + reset.invoke() + share_a.delete(0, "end") + share_a.insert(0, "86.2083333333") + share_b.delete(0, "end") + calculate = next( + widget + for widget in descendants(dialog) + if isinstance(widget, tk.Button) and widget.cget("text") == "Рассчитать смесь" + ) + calculate.invoke() + assert len(received) == 1 + assert received[0].current_blend_mass_fractions["A"] == pytest.approx(0.862083333333) + assert received[0].current_blend_mass_fractions["B"] == pytest.approx(0.127916666667) + finally: + root.destroy() + + +def test_nearest_blend_option_changes_input_only_when_explicitly_requested() -> None: + try: + root = tk.Tk() + except tk.TclError as exc: + pytest.skip(f"Tk display unavailable: {exc}") + root.withdraw() + try: + preset = load_scenario("config/scenarios/blend_risk.json") + dialog = open_editor(root, preset, preset, lambda _scenario: None) + dialog.withdraw() + entries = [widget for widget in descendants(dialog) if isinstance(widget, ttk.Entry)] + share_a, share_b = entries[:2] + share_a.delete(0, "end") + share_a.insert(0, "86.2") + share_b.delete(0, "end") + locks = [ + widget + for widget in descendants(dialog) + if isinstance(widget, ttk.Checkbutton) and widget.cget("text") == "Не менять" + ] + locks[2].invoke() + buttons = { + widget.cget("text"): widget + for widget in descendants(dialog) + if isinstance(widget, ttk.Button) + } + buttons["Подобрать смесь"].invoke() + assert share_a.get() == "86.2" + buttons["Найти ближайшую допустимую"].invoke() + assert float(share_a.get()) > 86.2 + assert float(share_a.get()) + float(share_b.get()) == pytest.approx(99.0) + finally: + root.destroy() diff --git a/source/constraints.py b/source/constraints.py index 866b371..ba167d5 100644 --- a/source/constraints.py +++ b/source/constraints.py @@ -10,6 +10,7 @@ ConstraintSpec, ConstraintStatus, MetricEstimate, + OperationMode, ProcessState, ScenarioConfig, ) @@ -26,6 +27,8 @@ def _quality_check( constraint: ConstraintSpec, candidate: CandidateAction, assessments: tuple[AgentAssessment, ...], + *, + rounding_tolerance: float = 0.0, ) -> ConstraintResult: metric = _metric(assessments, constraint.metric) if metric is None: @@ -65,10 +68,10 @@ def _quality_check( if actual is None: status = ConstraintStatus.UNKNOWN reason = "UNCERTAINTY_UNAVAILABLE" - elif constraint.lower is not None and actual < constraint.lower: + elif constraint.lower is not None and actual < constraint.lower - rounding_tolerance: status = ConstraintStatus.FAIL reason = "QUALITY_LIMIT" - elif constraint.upper is not None and actual > constraint.upper: + elif constraint.upper is not None and actual > constraint.upper + rounding_tolerance: status = ConstraintStatus.FAIL reason = "QUALITY_LIMIT" return ConstraintResult( @@ -165,7 +168,12 @@ def check_constraints( ) -> tuple[ConstraintResult, ...]: """Check all hard constraints for one evaluated candidate.""" _ = state - checks = [_quality_check(item, candidate, assessments) for item in scenario.constraints] + # Synthetic recipes are displayed with rounded decimals; ignore only float noise. + tolerance = 1e-10 if scenario.mode is OperationMode.MODEL_DEMO else 0.0 + checks = [ + _quality_check(item, candidate, assessments, rounding_tolerance=tolerance) + for item in scenario.constraints + ] checks.extend(_stock_checks(scenario, candidate)) return tuple(checks) diff --git a/source/ui_what_if.py b/source/ui_what_if.py index 691369e..4393d3a 100644 --- a/source/ui_what_if.py +++ b/source/ui_what_if.py @@ -215,25 +215,57 @@ def number(key: str) -> float: return ScenarioConfig.model_validate(data) -def assist_editor_values( - base: ScenarioConfig, values: dict[str, str], locked: frozenset[str] -) -> tuple[tuple[BlendOption, dict[str, str]], ...]: - """Parse physical inputs, then repair only the freely adjustable recipe fields.""" - if len(base.blend_components) != 2: - raise ValueError("Автоподбор поддерживает два компонента") - component_ids = tuple(item.id for item in base.blend_components) - - def fraction(key: str) -> float: +def recipe_inputs( + values: dict[str, str], component_ids: tuple[str, str] +) -> tuple[dict[str, float], float, dict[str, str]]: + """Interpret blank recipe fields as free shares and return their completion.""" + + def fraction(key: str) -> float | None: + raw = values.get(key, "").strip() + if not raw: + return None try: - result = float(values[key].strip().replace(",", ".")) / 100 - except (KeyError, ValueError) as exc: + result = float(raw.replace(",", ".")) / 100 + except ValueError as exc: raise ValueError(f"{field_label(key)}: введите число от 0 до 100") from exc if not math.isfinite(result) or result < 0 or result > 1: raise ValueError(f"{field_label(key)}: требуется число от 0 до 100") return result - desired = {key: fraction(f"{key}.fraction") for key in component_ids} + first, second = (fraction(f"{key}.fraction") for key in component_ids) dose = fraction("dose") + completed: dict[str, str] = {} + if dose is None: + dose = max(0.0, 1 - first - second) if first is not None and second is not None else 0.0 + completed["dose"] = f"{dose * 100:.12g}" + if first is None and second is None: + first = second = (1 - dose) / 2 + completed[f"{component_ids[0]}.fraction"] = f"{first * 100:.12g}" + completed[f"{component_ids[1]}.fraction"] = f"{second * 100:.12g}" + elif first is None: + assert second is not None + if second + dose > 1 + 1e-9: + raise ValueError("Доля компонента B вместе с присадкой превышает 100 %") + first = max(0.0, 1 - dose - second) + completed[f"{component_ids[0]}.fraction"] = f"{first * 100:.12g}" + elif second is None: + if first + dose > 1 + 1e-9: + raise ValueError("Доля компонента A вместе с присадкой превышает 100 %") + second = max(0.0, 1 - dose - first) + completed[f"{component_ids[1]}.fraction"] = f"{second * 100:.12g}" + assert first is not None and second is not None + desired: dict[str, float] = dict(zip(component_ids, (first, second), strict=True)) + return desired, dose, completed + + +def assist_editor_values( + base: ScenarioConfig, values: dict[str, str], locked: frozenset[str] +) -> tuple[tuple[BlendOption, dict[str, str]], ...]: + """Parse physical inputs, then repair only the freely adjustable recipe fields.""" + if len(base.blend_components) != 2: + raise ValueError("Автоподбор поддерживает два компонента") + component_ids = (base.blend_components[0].id, base.blend_components[1].id) + desired, dose, _ = recipe_inputs(values, component_ids) temporary = dict(values) # Only the recipe is temporarily balanced so scenario_from_editor can validate # unchanged component properties before the actual constrained search. @@ -247,8 +279,12 @@ def fraction(key: str) -> float: temporary[f"{component_ids[1]}.fraction"] = f"{(1 - temporary_dose) * 50:.12g}" scenario = scenario_from_editor(base, temporary) fixed = frozenset( - key.removesuffix(".fraction") for key in locked if key.endswith(".fraction") - ) | (frozenset({"dose"}) if "dose" in locked else frozenset()) + key.removesuffix(".fraction") + for key in locked + if key.endswith(".fraction") and values.get(key, "").strip() + ) | ( + frozenset({"dose"}) if "dose" in locked and values.get("dose", "").strip() else frozenset() + ) options = assist_blend(scenario, desired, dose, fixed) if not options: mass = scenario.total_mass_t or 0 @@ -344,7 +380,8 @@ def open_editor( warning = tk.Label( dialog, text=( - "Пустое поле свойства означает «нет данных». Доли смеси и присадки в сумме дают 100 %." + "Пустое поле свойства означает «нет данных». " + "Пустая доля заполняется из остатка до 100 %." ), bg="#FFF8E8", fg="#8C5400", @@ -389,7 +426,7 @@ def open_editor( ttk.Label(form, text=label, style="WhatIf.TLabel").grid( row=row, column=0, sticky="w", pady=7 ) - entry = ttk.Entry(form, textvariable=variables[key], width=12, style="WhatIf.TEntry") + entry = ttk.Entry(form, textvariable=variables[key], width=16, style="WhatIf.TEntry") entry.grid(row=row, column=1, padx=12, sticky="w") entries[key] = entry locks[key] = tk.BooleanVar(dialog, value=False) @@ -412,7 +449,10 @@ def open_editor( ).pack(anchor="w", padx=18, pady=(17, 3)) tk.Label( result, - text="Подбор учитывает качество, запасы и поля «Не менять».", + text=( + "«Подобрать смесь» сохраняет последнюю введённую долю. «Найти ближайшую» " + "может изменить её. Для других полей используйте «Не менять»." + ), bg="#F6F8F8", fg="#617082", wraplength=330, @@ -514,6 +554,7 @@ def open_editor( pending: str | None = None updating = False last_quality_edit: str | None = None + preferred_recipe_edit: str | None = None quality_previous = {key: var.get() for key, var in variables.items()} def cancel_pending() -> None: @@ -525,6 +566,18 @@ def cancel_pending() -> None: def values_now() -> dict[str, str]: return {key: var.get() for key, var in variables.items()} + def remember_recipe_edit(*_args: str, key: str) -> None: + nonlocal preferred_recipe_edit, before_assist + if updating: + return + before_assist = None + error.set("") + if variables[key].get().strip(): + preferred_recipe_edit = key + + for key in recipe_keys: + variables[key].trace_add("write", partial(remember_recipe_edit, key=key)) + def reconcile_quality(preferred: str | None) -> None: nonlocal updating, quality_previous quality_keys = [ @@ -636,7 +689,9 @@ def pick(_: tk.Event[tk.Misc] | None = None) -> None: variants.bind("<>", pick) - def suggest(changed: str | None = None, *, force: bool = False) -> None: + def suggest( + changed: str | None = None, *, force: bool = False, allow_changed_share: bool = False + ) -> None: nonlocal options, before_assist cancel_pending() reconcile_quality(changed or last_quality_edit) @@ -644,15 +699,28 @@ def suggest(changed: str | None = None, *, force: bool = False) -> None: return raw = values_now() fixed = frozenset(key for key, locked in locks.items() if locked.get()) - if changed in locks: - fixed |= {changed} + preferred = None + if not allow_changed_share: + preferred = ( + changed + if changed is not None and changed in locks and raw[changed].strip() + else preferred_recipe_edit + ) + if preferred is not None and preferred in locks and raw[preferred].strip(): + fixed |= {preferred} try: options = assist_editor_values(preset, raw, fixed) except ValueError as exc: options = () variants.pack_forget() error.set(str(exc)) - preview.set("Автоподбор не смог составить допустимую смесь.") + preview.set( + f"Сохраняем {field_label(preferred)} = {raw[preferred]} %. " + "Допустимой смеси с этой долей нет. Можно изменить вход или " + "выбрать ближайший вариант." + if preferred is not None and preferred.endswith(".fraction") + else "Автоподбор не смог составить допустимую смесь." + ) return before_assist = raw variants["values"] = tuple(item.label for item, _ in options) @@ -716,14 +784,31 @@ def undo_assist() -> None: ttk.Button(form, text="Подобрать смесь", command=lambda: suggest(force=True)).grid( row=7, column=0, columnspan=2, sticky="w", pady=(6, 0) ) + ttk.Button( + form, + text="Найти ближайшую допустимую", + command=lambda: suggest(force=True, allow_changed_share=True), + ).grid(row=8, column=0, columnspan=2, sticky="w", pady=(8, 0)) ttk.Button(form, text="Отменить подбор", command=undo_assist).grid( - row=8, column=0, columnspan=2, sticky="w", pady=(8, 0) + row=9, column=0, columnspan=2, sticky="w", pady=(8, 0) ) def apply() -> None: + nonlocal updating cancel_pending() reconcile_quality(last_quality_edit) try: + _, _, completed = recipe_inputs( + values_now(), (preset.blend_components[0].id, preset.blend_components[1].id) + ) + if completed: + updating = True + try: + for key, value in completed.items(): + variables[key].set(value) + entries[key].configure(style="WhatIf.Changed.TEntry") + finally: + updating = False scenario = scenario_from_editor( preset, {key: var.get() for key, var in variables.items()} ) @@ -745,14 +830,19 @@ def apply() -> None: dialog.destroy() def reset() -> None: - nonlocal before_assist, last_quality_edit, quality_previous + nonlocal before_assist, last_quality_edit, preferred_recipe_edit, quality_previous, updating cancel_pending() - for key, value in editor_values(preset).items(): - variables[key].set(value) + updating = True + try: + for key, value in editor_values(preset).items(): + variables[key].set(value) + finally: + updating = False for lock_var in locks.values(): lock_var.set(False) before_assist = None last_quality_edit = None + preferred_recipe_edit = None quality_previous = values_now() quality_note.set(quality_hint) variants.pack_forget() From 3db7696bc0e3b25da00deaec9387ece0f5e63243 Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sat, 26 Sep 2026 22:41:54 +0300 Subject: [PATCH 09/11] feat(ui): compare model scenarios with current recipe --- DESIGN.md | 5 + config/release_manifest.json | 2 +- docs/DEMO_SCRIPT.md | 6 +- global_tests/test_ui.py | 33 +++- source/ui.py | 355 ++++++++++++++++++++++------------- 5 files changed, 269 insertions(+), 132 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 12a6263..eb245ea 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -587,6 +587,11 @@ Preset на диске не изменяется; изменённые вход истории находятся в свёрнутых дополнительных настройках. Ни редактор, ни график не меняют `supports_actions=false`. +«Обзор» модельного сценария показывает исходную и выбранную рецептуры рядом: +доли компонентов, проверяемые оценки качества, модельный риск и стоимость с +разницей и пределами. Это сравнение в одном сценарии, не временной прогноз; +при отказе выбранные значения отсутствуют. + Целевой экран должен поддерживать: 1. Выбор режима, сценария, времени истории и совместимой модели; кнопка «Рассчитать». diff --git a/config/release_manifest.json b/config/release_manifest.json index 89b20d3..4abbf58 100644 --- a/config/release_manifest.json +++ b/config/release_manifest.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "release_id": "neftekod-v1.3.3", + "release_id": "neftekod-v1.3.4", "prepared_dataset": "data/processed/66bdfcbb23b4", "legacy_config_sha256": "00a45650caa0f415e78037d56bc49de7161f55f9eecd24675e8874d64efb63e2", "preparation_config_sha256": "7f8dab1f4befa7ed4d14108146d344eeeb4341711880144ad0195d163a55c4dd", diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index 2e04f5a..c9638cc 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -49,7 +49,7 @@ ## Обзор и сценарии -На «Обзоре» выберите сценарий в списке и нажмите «Рассчитать». Сам выбор в списке не запускает расчёт. Поле «60 мин» содержит единственный фиксированный вариант; через него нельзя менять горизонт или шаг исторического интервала. После завершения откройте «Смесь» для подробностей. +На «Обзоре» выберите сценарий в списке и нажмите «Рассчитать». Сам выбор в списке не запускает расчёт; под списком кратко объяснено, какую проблему проверяет сценарий. После завершения сравните доли и качество до/после, затем откройте «Смесь» для подробностей. | Сценарий | Что проверять после расчёта | | --- | --- | @@ -60,9 +60,9 @@ | Недостающие данные | `abstain`: неизвестное обязательное качество не позволяет рекомендовать изменение | | Риск и стоимость | `recommend`: модельный риск 0,80 → 0,73 при росте стоимостного индекса 1,99 → 2,0395 | -Карточки «Сера · верхняя», «T95 · верхняя», «Цетановое · нижняя» относятся к выбранному модельному результату. T95 — температура выкипания 95% топлива. Для серы и T95 проверяется верхняя оценка, для цетанового числа — нижняя. Это оценки в рамках сценария, не промышленная гарантия. +На «Обзоре» сначала показано, какие доли компонентов были в исходной смеси и какие предлагает модель; столбец «Разница» измеряется в процентных пунктах. Ниже показатели «Сейчас» и «Вариант» стоят рядом с разницей и модельным пределом. Для серы и T95 проверяется верхняя оценка, для цетанового числа — нижняя. T95 — температура выкипания 95% топлива. В сценарии «Риск и стоимость» дополнительно показаны условные индексы риска и стоимости, без промышленной калибровки. -График сравнивает текущую рецептуру и выбранный вариант по верхней оценке серы. Пунктирный предел равен 10 мг/кг. Соединение двух точек не является историческим трендом или прогнозом траектории процесса. Если оценка недоступна, число не подставляется. +Это сравнение двух рецептур в одном синтетическом сценарии, а не прогноз изменения качества во времени. Для «Риск и стоимость» соответствующие показатели стоят первыми. Если обязательная оценка отсутствует, таблица показывает известные исходные значения и «Не выбран» вместо вымышленного варианта. Нижние карточки АВТ и гидроочистки показывают уже исторический контекст: число свежих, устаревших и отсутствующих показаний. Карточка «История/ML» сообщает о наличии подготовленных данных и моделей. Эти карточки не являются входными паспортами синтетической смеси. diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index 8798cbd..d56f726 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -44,6 +44,8 @@ history_replay_to_view, history_smoke_snapshot, journal_entries, + quality_comparison_rows, + recipe_comparison_rows, recommendation_to_view, ui_history_snapshot, ui_hybrid_snapshot, @@ -230,12 +232,41 @@ def test_ui_exposes_complete_risk_recipe_and_quality_bounds(tmp_path: Path) -> N assert additive.actual == "1 %" assert additive.limit == "≤ 3 %" + recipe = {row[0]: row for row in recipe_comparison_rows(view)} + assert recipe["Компонент A"] == ("Компонент A", "69,3 %", "89,1 %", "+19,8 п.п.") + assert recipe["Компонент B"] == ("Компонент B", "29,7 %", "9,9 %", "-19,8 п.п.") + quality = {row[0]: row for row in quality_comparison_rows(view)} + assert len(quality) == 3 + assert quality["Сера · верхняя, мг/кг"] == ( + "Сера · верхняя, мг/кг", + "14,058", + "9,306", + "-4,752", + "≤ 10 мг/кг", + ) + 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 == "Требуется ручная проверка" + assert all(row[2] == "Не выбран" for row in quality_comparison_rows(view)) + assert all(row[3] == "—" for row in recipe_comparison_rows(view)) + + +def test_ui_shows_model_risk_and_cost_tradeoff_side_by_side(tmp_path: Path) -> None: + _, view = _view("blend_tradeoff", tmp_path) + comparison = quality_comparison_rows(view) + assert [row[0] for row in comparison[:2]] == [ + "Модельный риск, 0–1", + "Стоимость, усл. ед./т", + ] + rows = {row[0]: row for row in comparison} + assert rows["Модельный риск, 0–1"][1] == "0,8" + assert rows["Модельный риск, 0–1"][2] == "0,73" + assert rows["Стоимость, усл. ед./т"][1] == "1,99" + assert rows["Стоимость, усл. ед./т"][2] == "2,0395" def test_stage_snapshots_show_avt_and_hydrotreating_without_controls(tmp_path: Path) -> None: @@ -300,7 +331,7 @@ def release_context_root(tmp_path: Path) -> Path: def test_release_manifest_pins_one_live_context(release_context_root: Path) -> None: context = ui_data_module.discover_ui_context(release_context_root) - assert context.release_id == "neftekod-v1.3.3" + assert context.release_id == "neftekod-v1.3.4" assert context.latest_dataset is not None assert context.latest_dataset.endswith("66bdfcbb23b4") assert tuple(item.model_id for item in context.forecast_artifacts) == ( diff --git a/source/ui.py b/source/ui.py index 369a59c..dbac300 100644 --- a/source/ui.py +++ b/source/ui.py @@ -102,6 +102,18 @@ "Низкое цетановое число": "blend_cetane_risk", "Недостающие данные": "blend_missing", } +SCENARIO_HELP = { + "Риск и стоимость": "Сравните снижение модельного риска с изменением стоимости смеси.", + "Нормальный режим": "Исходная смесь проходит проверки. Посмотрите, нужно ли её менять.", + "Повышенная сера": ( + "Исходная смесь превышает предел серы. Сравните доли и качество до и после подбора." + ), + "Риск T95": "Исходная смесь превышает предел T95. Сравните доли и качество до и после подбора.", + "Низкое цетановое число": "Исходная смесь не достигает минимума цетанового числа.", + "Недостающие данные": ( + "Нет обязательной оценки качества, поэтому модель не может выбрать смесь." + ), +} PAGE_ALIASES = {"recommendation": "blend"} PAGE_CHOICES = ( @@ -129,6 +141,7 @@ class DashboardView: """Small UI projection derived exclusively from recommendation contracts.""" status: str + scenario_id: str banner_title: str banner_detail: str action_title: str @@ -141,6 +154,10 @@ class DashboardView: selected_t95_upper: float | None selected_cetane_lower: float | None selected_upper: float | None + baseline_risk: float | None + selected_risk: float | None + baseline_cost: float | None + selected_cost: float | None current_fractions: dict[str, float] proposed_fractions: dict[str, float] current_additive_fraction: float @@ -246,9 +263,13 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> baseline_value, _, baseline_upper = _metric(result.baseline, "sulfur") _, _, baseline_t95_upper = _metric(result.baseline, "t95") _, baseline_cetane_lower, _ = _metric(result.baseline, "cetane_number") + baseline_risk, _, _ = _metric(result.baseline, "risk_index") + baseline_cost, _, _ = _metric(result.baseline, "cost_proxy") selected_value, _, selected_upper = _metric(result.selected, "sulfur") _, _, selected_t95_upper = _metric(result.selected, "t95") _, selected_cetane_lower, _ = _metric(result.selected, "cetane_number") + selected_risk, _, _ = _metric(result.selected, "risk_index") + selected_cost, _, _ = _metric(result.selected, "cost_proxy") current = dict(scenario.current_blend_mass_fractions) proposed = dict(current) current_additive = scenario.current_additive_mass_fraction @@ -260,8 +281,8 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> 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 = "Расчёт относится только к синтетическому сценарию блендинга." + banner_title = "Предложена другая рецептура" + banner_detail = "Ниже показано, какие доли и показатели меняются в модельном расчёте." action_title = f"Вариант: увеличить долю компонента {component}" action_detail = ( "Текущая рецептура проходит проверки; вариант улучшает модельные критерии." @@ -270,17 +291,20 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> ) elif result.status is RecommendationStatus.HOLD: banner_title = "Изменение режима не требуется" - banner_detail = "Текущая модельная рецептура проходит доступные проверки." + banner_detail = "Текущая модельная рецептура проходит проверки; ниже видно сравнение." action_title = "Сохранить текущую рецептуру" action_detail = "Существенного улучшения относительно текущей смеси не найдено." else: banner_title = "Рекомендация недоступна" - banner_detail = "Обязательные данные или консервативная оценка качества отсутствуют." + banner_detail = ( + "Обязательных данных не хватает: сравнение покажет исходные значения без варианта." + ) action_title = "Требуется ручная проверка" action_detail = "Система не подставляет неизвестные значения и не выбирает действие." return DashboardView( status=result.status.value, + scenario_id=scenario.id, banner_title=banner_title, banner_detail=banner_detail, action_title=action_title, @@ -293,6 +317,10 @@ def recommendation_to_view(result: Recommendation, scenario: ScenarioConfig) -> selected_t95_upper=selected_t95_upper, selected_cetane_lower=selected_cetane_lower, selected_upper=selected_upper, + baseline_risk=baseline_risk, + selected_risk=selected_risk, + baseline_cost=baseline_cost, + selected_cost=selected_cost, current_fractions=current, proposed_fractions=proposed, current_additive_fraction=current_additive, @@ -330,6 +358,82 @@ def _format_value(value: float | None, unit: str = "мг/кг") -> str: return f"{rendered} {unit}".strip() +def _format_delta(before: float | None, after: float | None, unit: str = "") -> str: + if before is None or after is None: + return "—" + difference = after - before + if abs(difference) < 0.00005: + difference = 0.0 + return ("+" if difference > 0 else "") + _format_value(difference, unit) + + +def recipe_comparison_rows(view: DashboardView) -> tuple[tuple[str, str, str, str], ...]: + """Show the input recipe beside the selected recipe, without implying a time series.""" + rows = [] + for component in sorted(view.current_fractions): + before = view.current_fractions[component] * 100 + after = view.proposed_fractions.get(component, 0.0) * 100 + rows.append( + ( + f"Компонент {component}", + _format_value(before, "%"), + _format_value(after, "%") if view.status != "abstain" else "Не выбран", + _format_delta(before, after, "п.п.") if view.status != "abstain" else "—", + ) + ) + before = view.current_additive_fraction * 100 + after = view.proposed_additive_fraction * 100 + rows.append( + ( + "Присадка", + _format_value(before, "%"), + _format_value(after, "%") if view.status != "abstain" else "Не выбрана", + _format_delta(before, after, "п.п.") if view.status != "abstain" else "—", + ) + ) + return tuple(rows) + + +def quality_comparison_rows(view: DashboardView) -> tuple[tuple[str, str, str, str, str], ...]: + """Compare the same conservative estimates used by scenario quality checks.""" + limits = {row.name: row.limit for row in view.constraints} + quality_values: tuple[tuple[str, str, float | None, float | None], ...] = ( + ("Сера · верхняя, мг/кг", "Сера", view.baseline_upper, view.selected_upper), + ("T95 · верхняя, °C", "T95", view.baseline_t95_upper, view.selected_t95_upper), + ( + "Цетановое · нижняя, ед.", + "Цетановое число", + view.baseline_cetane_lower, + view.selected_cetane_lower, + ), + ) + if view.scenario_id.startswith("blend_tradeoff"): + values = ( + ("Модельный риск, 0–1", "", view.baseline_risk, view.selected_risk), + ("Стоимость, усл. ед./т", "", view.baseline_cost, view.selected_cost), + *quality_values, + ) + else: + values = quality_values + focus = { + "blend_t95_risk": ("T95 · верхняя, °C",), + "blend_cetane_risk": ("Цетановое · нижняя, ед.",), + }.get(view.scenario_id, ()) + values = tuple( + sorted(values, key=lambda row: focus.index(row[0]) if row[0] in focus else len(focus)) + ) + return tuple( + ( + label, + _format_value(before, ""), + _format_value(after, "") if view.status != "abstain" else "Не выбран", + _format_delta(before, after) if view.status != "abstain" else "—", + limits.get(key, "—"), + ) + for label, key, before, after in values + ) + + 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", {}) @@ -1255,7 +1359,7 @@ def _render_overview(self) -> None: ).pack(anchor="w") tk.Label( title, - text="Выберите синтетический сценарий или настройте свою смесь", + text="Сравните текущую смесь с модельным вариантом: доли, качество и разницу", bg=BG, fg=MUTED, font=("Segoe UI", 12), @@ -1274,12 +1378,29 @@ def _render_overview(self) -> None: style="Petrol.TCombobox", ) scenario.pack(side="left", padx=(0, 18)) - tk.Label(controls, text="Горизонт: 60 мин", bg=SURFACE, fg=MUTED).pack(side="left") + tk.Label(controls, text="Сравнение рецептур", bg=SURFACE, fg=MUTED).pack(side="left") button = self._primary_button(controls, "Рассчитать", self.calculate) button.pack(side="right", padx=(8, 18)) self._secondary_button(controls, "Настроить смесь", self.open_what_if_dialog).pack( side="right", padx=(8, 0) ) + scenario_help = tk.StringVar(value=SCENARIO_HELP[self.scenario_var.get()]) + scenario.bind( + "<>", + lambda _event: scenario_help.set( + SCENARIO_HELP[self.scenario_var.get()] + + " Нажмите «Рассчитать», чтобы обновить сравнение." + ), + ) + tk.Label( + page, + textvariable=scenario_help, + bg=BG, + fg=MUTED, + font=("Segoe UI", 10), + wraplength=1000, + justify="left", + ).pack(anchor="w", pady=(8, 0)) view = self._view() banner_colors = { @@ -1327,12 +1448,12 @@ def _render_overview(self) -> None: 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") + comparison = tk.Frame(main, bg=SURFACE) + comparison.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_recipe_comparison(comparison, view) + self._render_quality_comparison(comparison, view) self._render_info(info, view) self._render_stage_cards(page) @@ -1342,129 +1463,109 @@ def _render_overview(self) -> None: 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.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 "—", - ), + @staticmethod + def _comparison_table( + parent: tk.Frame, headings: tuple[str, ...], rows: tuple[tuple[str, ...], ...] + ) -> None: + columns = tuple(str(index) for index in range(len(headings))) + table = ttk.Treeview( + parent, + columns=columns, + show="headings", + height=len(rows), + selectmode="none", + style="Petrol.Treeview", ) - 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" + for index, heading in enumerate(headings): + key = columns[index] + table.heading(key, text=heading) + table.column( + key, + width=220 if index == 0 else 130, + minwidth=150 if index == 0 else 90, + anchor="w" if index == 0 else "e", + stretch=True, ) - 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)) + for row in rows: + table.insert("", "end", values=row) + table.pack(fill="x", padx=24) - def _render_chart(self, parent: tk.Frame, view: DashboardView | None) -> None: - tk.Frame(parent, bg=BORDER, height=1).pack(fill="x", padx=24) + def _render_recipe_comparison(self, parent: tk.Frame, view: DashboardView | None) -> None: + heading = ( + "Исходная рецептура" + if view is not None and view.status == "abstain" + else "Рецептура без изменений" + if view is not None and view.status == "hold" + else "Что меняется в рецептуре" + ) tk.Label( parent, - text="Сера в смеси, мг/кг", + text=heading, 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), + font=("Segoe UI", 14, "bold"), + ).pack(anchor="w", padx=24, pady=(18, 4)) + tk.Label( + parent, + text=( + "Доли компонентов одной партии. Изменение указано в процентных пунктах." + if view is None or view.status == "recommend" + else "Доли компонентов одной модельной партии." + ), + bg=SURFACE, + fg=MUTED, + ).pack(anchor="w", padx=24, pady=(0, 10)) + if view is None: + tk.Label(parent, text="Сначала выполните расчёт.", bg=SURFACE, fg=MUTED).pack( + anchor="w", padx=24, pady=(0, 14) ) - 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), + return + self._comparison_table( + parent, + ("Доля", "Сейчас", "Вариант", "Разница"), + recipe_comparison_rows(view), ) + if view.status == "abstain": + tk.Label( + parent, + text="Вариант не выбран: обязательных данных для подбора недостаточно.", + bg=SURFACE, + fg=RED, + ).pack(anchor="w", padx=24, pady=(7, 0)) + + def _render_quality_comparison(self, parent: tk.Frame, view: DashboardView | None) -> None: + heading = ( + "Доступные оценки текущей смеси" + if view is not None and view.status == "abstain" + else "Проверки текущей смеси" + if view is not None and view.status == "hold" + else "Что меняется в результате" + ) + tk.Frame(parent, bg=BORDER, height=1).pack(fill="x", padx=24, pady=(18, 0)) + tk.Label( + parent, + text=heading, + bg=SURFACE, + fg=TEXT, + font=("Segoe UI", 14, "bold"), + ).pack(anchor="w", padx=24, pady=(16, 10)) + if view is not None: + self._comparison_table( + parent, + ("Показатель", "Сейчас", "Вариант", "Разница", "Предел"), + quality_comparison_rows(view), + ) + tk.Label( + parent, + text=( + "Сера и T95 — верхние оценки, цетановое число — нижняя. " + "Это сравнение двух модельных рецептур, не прогноз по времени." + ), + bg=SURFACE, + fg=MUTED, + wraplength=800, + justify="left", + ).pack(anchor="w", padx=24, pady=(10, 18)) 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( @@ -1480,10 +1581,10 @@ def _render_info(self, parent: tk.Frame, view: DashboardView | None) -> None: if self._scenario else "—", ) - self._info_row(parent, "Горизонт", self.horizon_var.get()) + self._info_row(parent, "Тип результата", "Сравнение рецептур") self._info_row( parent, - "Момент сценария", + "Момент расчёта", self._result.as_of.strftime("%d.%m.%Y %H:%M") if self._result else "—", ) tk.Frame(parent, bg=BORDER, height=1).pack(fill="x", padx=20, pady=18) @@ -1499,7 +1600,7 @@ def _render_info(self, parent: tk.Frame, view: DashboardView | None) -> None: tk.Label( parent, text=( - "Action model не подтверждён. Реальные управляющие воздействия отключены." + "Модель реальных действий не подтверждена. Управление установкой отключено." if view else "Выполните расчёт." ), From 95373d75c329d131d4a66723e76c7f4ba870a6cd Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sat, 26 Sep 2026 23:00:29 +0300 Subject: [PATCH 10/11] fix(desktop): render Windows UI at native DPI --- config/release_manifest.json | 2 +- global_tests/test_ui.py | 2 +- scripts/build_release.ps1 | 2 +- scripts/build_windows.ps1 | 2 +- scripts/windows-manifest.xml | 31 +++++++++++++++++++++++++++++++ source/desktop.py | 7 +++++++ source/ui.py | 5 +++++ 7 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 scripts/windows-manifest.xml diff --git a/config/release_manifest.json b/config/release_manifest.json index 4abbf58..d40ed3a 100644 --- a/config/release_manifest.json +++ b/config/release_manifest.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "release_id": "neftekod-v1.3.4", + "release_id": "neftekod-v1.3.5", "prepared_dataset": "data/processed/66bdfcbb23b4", "legacy_config_sha256": "00a45650caa0f415e78037d56bc49de7161f55f9eecd24675e8874d64efb63e2", "preparation_config_sha256": "7f8dab1f4befa7ed4d14108146d344eeeb4341711880144ad0195d163a55c4dd", diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index d56f726..2c84c4c 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -331,7 +331,7 @@ def release_context_root(tmp_path: Path) -> Path: def test_release_manifest_pins_one_live_context(release_context_root: Path) -> None: context = ui_data_module.discover_ui_context(release_context_root) - assert context.release_id == "neftekod-v1.3.4" + assert context.release_id == "neftekod-v1.3.5" assert context.latest_dataset is not None assert context.latest_dataset.endswith("66bdfcbb23b4") assert tuple(item.model_id for item in context.forecast_artifacts) == ( diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1 index 5bb64a6..e1e5a74 100644 --- a/scripts/build_release.ps1 +++ b/scripts/build_release.ps1 @@ -110,7 +110,7 @@ if ($BinaryDirectory) { $smoke = Get-Content -Raw -LiteralPath $smokeReport | ConvertFrom-Json if ($smoke.error -or $smoke.tk -ne "ok" -or $smoke.history.points -ne 2 -or -not $smoke.history_chart.rendered -or $smoke.what_if.editor -ne "ok" -or - $smoke.what_if.assist -ne "ok") { + $smoke.what_if.assist -ne "ok" -or $smoke.dpi_awareness -ne 1) { throw "Portable smoke report is incomplete" } } finally { diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 index bf7492b..f9d2bcd 100644 --- a/scripts/build_windows.ps1 +++ b/scripts/build_windows.ps1 @@ -12,7 +12,7 @@ if ($LASTEXITCODE -ne 0) { throw "Build dependency installation failed" } $savedPythonPath = $env:PYTHONPATH try { $env:PYTHONPATH = $buildTools - & $python -m PyInstaller --noconfirm --onedir --windowed --name Neftekod --distpath (Join-Path $build "dist") --workpath (Join-Path $build "work") --specpath $build --paths $root --collect-all lightgbm --hidden-import sklearn.ensemble._hist_gradient_boosting.predictor --hidden-import sklearn.linear_model._ridge (Join-Path $root "source\desktop.py") + & $python -m PyInstaller --noconfirm --onedir --windowed --name Neftekod --manifest (Join-Path $root "scripts\windows-manifest.xml") --distpath (Join-Path $build "dist") --workpath (Join-Path $build "work") --specpath $build --paths $root --collect-all lightgbm --hidden-import sklearn.ensemble._hist_gradient_boosting.predictor --hidden-import sklearn.linear_model._ridge (Join-Path $root "source\desktop.py") if ($LASTEXITCODE -ne 0) { throw "Freezing failed" } } finally { $env:PYTHONPATH = $savedPythonPath } Write-Output "Pass -BinaryDirectory '$build\dist\Neftekod' to scripts/build_release.ps1." diff --git a/scripts/windows-manifest.xml b/scripts/windows-manifest.xml new file mode 100644 index 0000000..b88d230 --- /dev/null +++ b/scripts/windows-manifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + true + system + true + + + + + + + + diff --git a/source/desktop.py b/source/desktop.py index be15e3f..bb9e587 100644 --- a/source/desktop.py +++ b/source/desktop.py @@ -56,6 +56,13 @@ def main() -> None: report["history"] = history app = PetrolCodeApp(initial_page="journal") app.withdraw() + if sys.platform == "win32": + from ctypes import byref, c_int, windll + + awareness = c_int() + if windll.shcore.GetProcessDpiAwareness(None, byref(awareness)) != 0: + raise RuntimeError("Cannot read Windows DPI awareness") + report["dpi_awareness"] = awareness.value app._history_export_payload = history app._history_interval_text = "Portable history chart check" app.show_page("history") diff --git a/source/ui.py b/source/ui.py index dbac300..30bfeff 100644 --- a/source/ui.py +++ b/source/ui.py @@ -796,6 +796,11 @@ def __init__( initial_scenario: str = "blend_risk", initial_page: str = "overview", ) -> None: + if sys.platform == "win32": + from ctypes import windll + + # Keep direct Python launches crisp; the packaged EXE uses the manifest. + windll.user32.SetProcessDPIAware() super().__init__() self.title("НЕФТЕКОД — поддержка технологических решений") width = min(1536, max(1000, self.winfo_screenwidth() - 80)) From 0257908851f1713c377cdfe06e9fd40b518afdf6 Mon Sep 17 00:00:00 2001 From: MatveyVarfolomeev Date: Sun, 27 Sep 2026 00:07:27 +0300 Subject: [PATCH 11/11] docs(jury): refresh release presentation and links --- README.md | 8 ++++---- config/release_manifest.json | 2 +- docs/DEVELOPMENT.md | 2 +- global_tests/test_ui.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ab80f76..c30c60d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Локальное приложение поддержки технологических решений: модельные рецептуры дизельного топлива, исторический прогноз серы и объяснимые проверки ограничений. -[Скачать Windows-версию](https://github.com/bug00n/HackathonPetrolCode/releases/latest/download/Neftekod-Windows-x64.zip) · [Все файлы релиза](https://github.com/bug00n/HackathonPetrolCode/releases/latest) · [Сценарий защиты](docs/DEMO_SCRIPT.md) +[Скачать Windows-версию](https://github.com/bug00n/HackathonPetrolCode/releases/download/v1.3.6/Neftekod-Windows-x64.zip) · [Все файлы релиза](https://github.com/bug00n/HackathonPetrolCode/releases/tag/v1.3.6) · [Сценарий защиты](docs/DEMO_SCRIPT.md) ## Запуск за три шага @@ -16,7 +16,7 @@ ## Что показать первым -В приложении на «Обзоре» уже рассчитан сценарий **«Повышенная сера»**. Откройте **«Смесь»**: верхняя модельная оценка серы меняется с 14,058 до 9,306 мг/кг при пределе 10 мг/кг. Нажмите **«Настроить смесь»**, чтобы изменить долю, сохранить важное поле отметкой «Не менять» и сравнить допустимые варианты. +В приложении на «Обзоре» уже рассчитан сценарий **«Повышенная сера»**. Откройте **«Смесь»**: верхняя модельная оценка серы меняется с 14,058 до 9,306 мг/кг при пределе 10 мг/кг. Нажмите **«Настроить смесь»**, чтобы изменить долю, сохранить важное поле отметкой «Не менять» и сравнить допустимые варианты. Если с введённой долей подходящей смеси нет, кнопка «Найти ближайшую допустимую» предложит изменить её. | Раздел | Что доступно | | --- | --- | @@ -30,8 +30,8 @@ ## Материалы для жюри -- [Презентация](https://github.com/bug00n/HackathonPetrolCode/releases/latest/download/Neftekod_Presentation.pptx). -- [Технический документ для жюри](https://github.com/bug00n/HackathonPetrolCode/releases/latest/download/Neftekod_Jury.docx). +- [Презентация](https://github.com/bug00n/HackathonPetrolCode/releases/download/v1.3.6/Neftekod_Presentation.pptx). +- [Технический документ для жюри](https://github.com/bug00n/HackathonPetrolCode/releases/download/v1.3.6/Neftekod_Jury.docx). - [Короткий сценарий показа, полная инструкция по UI и ответы на вопросы](docs/DEMO_SCRIPT.md). В готовом ZIP документы лежат в `docs/jury`. PPTX и DOCX можно скачать отдельно со страницы релиза. diff --git a/config/release_manifest.json b/config/release_manifest.json index d40ed3a..c5a0aa9 100644 --- a/config/release_manifest.json +++ b/config/release_manifest.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "release_id": "neftekod-v1.3.5", + "release_id": "neftekod-v1.3.6", "prepared_dataset": "data/processed/66bdfcbb23b4", "legacy_config_sha256": "00a45650caa0f415e78037d56bc49de7161f55f9eecd24675e8874d64efb63e2", "preparation_config_sha256": "7f8dab1f4befa7ed4d14108146d344eeeb4341711880144ad0195d163a55c4dd", diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 748a440..0dd1e24 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Разработка и воспроизведение -Для показа без настройки скачайте [готовую Windows-сборку](https://github.com/bug00n/HackathonPetrolCode/releases/latest). Этот документ нужен только при работе с исходниками. Команды PowerShell выполняются из корня репозитория. +Для показа без настройки скачайте [готовую Windows-сборку](https://github.com/bug00n/HackathonPetrolCode/releases/tag/v1.3.6). Этот документ нужен только при работе с исходниками. Команды PowerShell выполняются из корня репозитория. ## 1. Исходники и Python diff --git a/global_tests/test_ui.py b/global_tests/test_ui.py index 2c84c4c..1050029 100644 --- a/global_tests/test_ui.py +++ b/global_tests/test_ui.py @@ -331,7 +331,7 @@ def release_context_root(tmp_path: Path) -> Path: def test_release_manifest_pins_one_live_context(release_context_root: Path) -> None: context = ui_data_module.discover_ui_context(release_context_root) - assert context.release_id == "neftekod-v1.3.5" + assert context.release_id == "neftekod-v1.3.6" assert context.latest_dataset is not None assert context.latest_dataset.endswith("66bdfcbb23b4") assert tuple(item.model_id for item in context.forecast_artifacts) == (