diff --git a/CHANGE_LOG.md b/CHANGE_LOG.md index c5fb159..f94fff1 100644 --- a/CHANGE_LOG.md +++ b/CHANGE_LOG.md @@ -4,6 +4,10 @@ This file records completed project work in chronological order. ## 2026-06-22 +- Activated Phase 30 on `feature/p30-notebook-dataframe-interface`, created GitHub parent issue #174 and child issues #175, #178, #176, #177, #179, and #180, and scoped the next `0.1.0a5` alpha around an optional pandas-backed notebook/DataFrame layer over `modelwright.wrappers` for live-kernel inspection, scenario mutation, table rendering, and baseline-vs-scenario comparison workflows. +- Completed P30.1 through P30.5 by adding `modelwright.notebooks` with lazy pandas-backed DataFrame helpers for declared inputs, outputs, scenarios, tables, reports, and baseline-vs-scenario comparisons; added a `notebook` optional extra with `pandas>=2` while keeping pandas out of core dependencies; documented the notebook workflow in Sphinx; added always-on synthetic notebook tests plus real generated synthetic model coverage; and extended the opt-in FABLE wrapper benchmark so `MODELWRIGHT_RUN_FABLE_BENCHMARKS=1 .venv/bin/python -m pytest -vv tests/test_fable_wrapper_benchmark.py` passed in 149.67 seconds against ignored local 2020 FABLE artifacts while preserving the existing `281,741` matches and `0` mismatches evidence boundary. +- Added P30.6 release examples scope with a tracked `examples/` directory, a tiny synthetic notebook-interface example, a production-size 2020 FABLE notebook-interface example backed by compressed generated Python output rather than the original workbook, Sphinx Examples Gallery pages linked from the docs index, and lightweight tests that verify example integrity without running the expensive FABLE generated-model calculation in default pytest. +- Prepared the `0.1.0a5` release candidate by bumping package/import metadata and release docs, passing repo-local bootstrap, Ruff, default `pytest -vv` with `163` passed and `1` benchmark skip, Sphinx warning-as-error docs, Read the Docs theme verification, and `scripts/check_release_artifacts.sh`; the artifact checker built a roughly `56K` wheel and `2.2M` sdist, included the compressed FABLE generated-model example in the sdist, installed the wheel into a clean ignored environment, imported `modelwright 0.1.0a5`, and smoke-tested the CLI. - Activated Phase 29 on `feature/p29-model-wrapper-templates`, created GitHub parent issue #167 and child issues #171, #170, #169, #168, and #172, and scoped initial wrapper-template facades for generated Python models with `0.1.0a4` publication as the final phase closeout task. - Closed P29.1 by expanding `planning/model-wrapper-template-facades.md` into a concrete initial wrapper API contract covering generated-model boundaries, facade records, declaration helpers, table semantics, scenario mutation, report payloads, provenance preservation, error handling, and alpha API limits. - Completed P29.2 by adding the initial `modelwright.wrappers` module with `ModelFacade`, `Scenario`, cell/table/report declarations, table and report view payloads, wrapper declaration/execution errors, public package exports, and focused tests for scenario overrides, rectangular table reporting, duplicate declarations, report references, and generated-model error wrapping. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..01dcf21 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include examples *.md *.py *.xz diff --git a/README.md b/README.md index a5bc5d1..de0b5a5 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths: scripts/bootstrap_dev_env.sh --benchmarks ``` -`modelwright` is pre-release. The current alpha line is `0.1.0a4`; alpha releases must not be described as full-workbook conversion guarantees. +`modelwright` is pre-release. The current alpha line is `0.1.0a5`; alpha releases must not be described as full-workbook conversion guarantees. Check release artifacts locally: diff --git a/ROADMAP.md b/ROADMAP.md index d5af3d9..9761033 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -721,9 +721,9 @@ Release claim boundary: GitHub parent issue: #167 -Active branch: `feature/p29-model-wrapper-templates` +Completed branch: `feature/p29-model-wrapper-templates` -Status: active. +Status: complete. Goal: explore and implement an initial `modelwright` module that helps users build custom wrapper facades around generated Python models, bridging the gap between raw generated cell-address APIs and @@ -794,5 +794,121 @@ Release evidence: ## Current Next Steps -1. Keep Phase 29 closed unless a release defect is discovered. -2. Plan the next roadmap phase before opening a new feature branch. +## Phase 30: Notebook Interface And DataFrame Display Layer + +GitHub parent issue: #174 + +Active branch: `feature/p30-notebook-dataframe-interface` + +Status: active. + +Goal: add an optional notebook-facing layer on top of `modelwright.wrappers` that exposes wrapped +generated models as Jupyter-friendly, pandas-backed analyst workflows. This phase should support a +live-kernel loop where analysts can inspect declared inputs, outputs, and tables; mutate scenarios; +recalculate; render declared tables as DataFrames; and compare baseline-vs-scenario results without +using raw generated source or `Sheet!A1` dictionaries as the common path. + +Planning note: `planning/notebook-dataframe-interface.md`. + +Release target: `modelwright==0.1.0a5`. + +- [x] P30.1 Define notebook adapter contract. Child issue: #175. + - Status: complete. + - [x] Decide the module boundary for notebook-facing helpers. + - [x] Define the relationship to `ModelFacade` without making pandas a core wrapper dependency. + - [x] Decide the optional dependency policy for `pandas`. + - [x] Define helper API sketches for inputs, outputs, tables, reports, scenarios, and comparisons. + - [x] Define non-goals: no full spreadsheet UI, no dashboard server, no automatic workbook semantic + naming, no widget framework unless explicitly scoped, and no stable public API guarantee yet. +- [x] P30.2 Implement DataFrame view helpers. Child issue: #178. + - Status: complete. + - [x] Add the selected notebook/DataFrame module. + - [x] Add a lazy pandas import and clear missing-dependency error. + - [x] Convert declared inputs and outputs to tidy DataFrames. + - [x] Convert declared table views to DataFrames preserving row labels, column labels, values, + cell refs, and provenance where practical. + - [x] Convert report bundles to DataFrame payloads without changing `ModelFacade` calculation behavior. + - [x] Add focused synthetic tests. +- [x] P30.3 Implement scenario comparison helpers. Child issue: #176. + - Status: complete. + - [x] Define scenario comparison inputs and output columns. + - [x] Include declared name, label, cell ref, baseline value, scenario value, absolute change, + percent change where numeric, unit, role, and provenance/drilldown metadata where practical. + - [x] Preserve generated-model execution errors as wrapper/notebook-layer errors. + - [x] Add synthetic tests for numeric, text, missing, and zero-baseline comparison behavior. + - [x] Keep raw cell refs available without making them the primary notebook-facing interaction. +- [x] P30.4 Add notebook-oriented docs. Child issue: #177. + - Status: complete. + - [x] Add `docs/guides/notebook-interface.rst`. + - [x] Show loading a generated model and wrapping it with `ModelFacade`. + - [x] Show declared inputs, outputs, tables, and reports. + - [x] Show DataFrame rendering helpers. + - [x] Show scenario mutation and scenario comparison. + - [x] Document alpha limits and non-goals clearly. + - [x] Add the guide to the docs index. +- [x] P30.5 Validate synthetic and FABLE notebook workflows. Child issue: #179. + - Status: complete. + - [x] Add always-on synthetic tests for DataFrame conversion and scenario comparison. + - [x] Confirm the notebook layer does not change generated-model calculation behavior. + - [x] Add or extend an opt-in FABLE benchmark-gated test with `MODELWRIGHT_RUN_FABLE_BENCHMARKS=1` + if local artifacts support it. + - [x] Keep generated FABLE models and raw reports under ignored `tmp/`. + - [x] Run full local verification and record evidence in roadmap, changelog, and issue comments. +- [x] P30.6 Add examples gallery. Child issue: #181. + - Status: complete. + - [x] Add an `examples/` directory with a tiny synthetic notebook-interface example. + - [x] Add a production-size 2020 FABLE generated-model example without tracking the original workbook. + - [x] Keep the original FABLE workbook and raw local validation reports out of tracked files. + - [x] Avoid ordinary Git blobs larger than GitHub/PyPI practical limits. + - [x] Add Sphinx Examples Gallery pages linked from the docs index. + - [x] Add lightweight tests for example integrity without running expensive production-size FABLE + calculation in default pytest. +- [ ] P30.7 Publish `modelwright==0.1.0a5`. Child issue: #180. + - Status: active. + - [x] Confirm P30 notebook/DataFrame scope and evidence are complete. + - [x] Bump package/import version and release docs to `0.1.0a5`. + - [x] Run local release checks, including Ruff, pytest, Sphinx docs, docs theme verification, and + release artifact checks. + - [ ] Open and merge the P30 PR to `main`. + - [ ] Create annotated tag `v0.1.0a5`. + - [ ] Publish through the gated release workflow after maintainer approval. + - [ ] Verify PyPI JSON, clean PyPI install, import version, CLI help, GitHub release, and docs deployment. + +Acceptance boundary: + +- May claim initial Jupyter/DataFrame-facing helpers for wrapped generated models. +- May claim optional pandas-backed display helpers. +- May claim scenario mutation and comparison as notebook-native workflows. +- May claim synthetic and opt-in FABLE evidence that notebook helpers do not change calculation behavior. +- Must not claim a full spreadsheet UI, dashboard application, widget framework, automatic workbook + semantic recovery, stable public API compatibility, or compact runtime IR production readiness. + +Implementation evidence: + +- Added `modelwright.notebooks` with lazy pandas-backed helpers: + `inputs_frame`, `outputs_frame`, `scenario_frame`, `table_frame`, `report_frames`, and + `compare_scenarios_frame`. +- Added the `notebook` optional extra with `pandas>=2`, while keeping pandas out of core dependencies. +- Added always-on synthetic notebook tests, including real generated synthetic model coverage and + missing-pandas dependency behavior. +- Extended the opt-in FABLE wrapper benchmark to validate notebook DataFrame helpers over the ignored + generated 2020 FABLE model. +- Added `examples/` with a tracked synthetic notebook-interface example and a production-size FABLE + notebook-interface example. The FABLE generated Python output is tracked as a compressed + `generated_fable_2020_model.py.xz` artifact because the uncompressed module is larger than ordinary + GitHub per-file limits. + +Verification evidence: + +- `scripts/bootstrap_dev_env.sh` passed and installed the updated `dev` extra. +- `.venv/bin/python -m ruff check .` passed. +- `.venv/bin/python -m pytest -vv` passed with `161` passed and `1` skipped benchmark. +- `.venv/bin/sphinx-build -b html docs _build/html -W` passed. +- `.venv/bin/python scripts/verify_docs_theme.py _build/html` passed. +- `MODELWRIGHT_RUN_FABLE_BENCHMARKS=1 .venv/bin/python -m pytest -vv tests/test_fable_wrapper_benchmark.py` + passed in `149.67` seconds, using ignored local FABLE artifacts under `tmp/p26-fable-full-validation/`. +- `scripts/check_release_artifacts.sh` passed for `0.1.0a5`; the clean wheel install imported + `modelwright 0.1.0a5` and the artifact inspection found no forbidden private workbook, ignored + `tmp/`, or source workbook content. +- Release artifacts from the local check were about `56K` for the wheel and `2.2M` for the sdist. The + sdist includes the compressed FABLE generated-model example; the wheel remains package-code only. diff --git a/docs/examples.rst b/docs/examples.rst new file mode 100644 index 0000000..4b54294 --- /dev/null +++ b/docs/examples.rst @@ -0,0 +1,11 @@ +Examples Gallery +================ + +These examples show the path from generated Python models to analyst-facing wrapper facades and +notebook-friendly DataFrame displays. + +.. toctree:: + :maxdepth: 1 + + examples/synthetic-notebook-interface + examples/fable-2020-notebook-interface diff --git a/docs/examples/fable-2020-notebook-interface.rst b/docs/examples/fable-2020-notebook-interface.rst new file mode 100644 index 0000000..8b5f1f9 --- /dev/null +++ b/docs/examples/fable-2020-notebook-interface.rst @@ -0,0 +1,23 @@ +2020 FABLE Notebook Interface +============================= + +This production-size example uses Modelwright's generated Python output from the public 2020 FABLE +Calculator benchmark workbook. The original workbook is not tracked in this repository. The generated +Python model is tracked as ``examples/fable_2020/generated_fable_2020_model.py.xz`` and decompressed +into ignored ``tmp/`` working space before import. + +The example wraps three validated ``SCENARIOS selection`` outputs, renders them as DataFrames, and +keeps the validation boundary explicit: the source Phase 26 full-validation report recorded 281,741 +comparable cached outputs, 281,741 matches, and 0 mismatches. + +Run it from the repository root: + +.. code-block:: bash + + python examples/fable_2020/notebook_interface.py + +Source +------ + +.. literalinclude:: ../../examples/fable_2020/notebook_interface.py + :language: python diff --git a/docs/examples/synthetic-notebook-interface.rst b/docs/examples/synthetic-notebook-interface.rst new file mode 100644 index 0000000..58900fa --- /dev/null +++ b/docs/examples/synthetic-notebook-interface.rst @@ -0,0 +1,18 @@ +Synthetic Notebook Interface +============================ + +This tiny example uses a small generated-model-shaped ``calculate(inputs=None)`` function, wraps it +with ``ModelFacade``, and renders outputs, a declared table, and a baseline-vs-scenario comparison as +DataFrames. + +Run it from the repository root: + +.. code-block:: bash + + python examples/synthetic/notebook_interface.py + +Source +------ + +.. literalinclude:: ../../examples/synthetic/notebook_interface.py + :language: python diff --git a/docs/guides/notebook-interface.rst b/docs/guides/notebook-interface.rst new file mode 100644 index 0000000..9c0b5d9 --- /dev/null +++ b/docs/guides/notebook-interface.rst @@ -0,0 +1,159 @@ +Notebook Interface +================== + +The wrapper facade API gives generated models analyst-facing names, tables, reports, and scenarios. +The ``modelwright.notebooks`` module adds the next layer: pandas-backed helpers that display those +facade views naturally in a live Jupyter kernel. + +Install the optional notebook dependency when using these helpers: + +.. code-block:: bash + + python -m pip install 'modelwright[notebook]' + +The core package and ``modelwright.wrappers`` do not require pandas. + +Boundary +-------- + +Notebook helpers do not edit generated source code and do not recreate a spreadsheet UI. They convert +declared wrapper views into DataFrames so an analyst can inspect model structure, change scenario +inputs, recalculate, and compare results without manually reading raw ``Sheet!A1`` dictionaries. + +Minimal Example +--------------- + +Assume a generated model module exposes ``calculate(inputs=None)``: + +.. code-block:: python + + def calculate(inputs=None): + inputs = inputs or {} + base = inputs.get("Inputs!B2", 100) + growth = inputs.get("Inputs!B3", 0.1) + return { + "Summary!B2": base * (1 + growth), + "Summary!C2": base * 2, + "Summary!B3": "ok", + "Summary!C3": base + 5, + } + +Declare a facade around the generated model: + +.. code-block:: python + + from modelwright.wrappers import ModelFacade, cell, report, table + + facade = ModelFacade( + generated_model, + cells=[ + cell("Inputs!B2", name="base", label="Base volume", role="input", unit="t"), + cell("Inputs!B3", name="growth", label="Growth rate", role="input", unit="fraction"), + cell("Summary!B2", name="projected", label="Projected volume", role="output", unit="t"), + cell("Summary!B3", name="status", label="Status", role="output"), + ], + tables=[ + table( + "summary_grid", + sheet="Summary", + range_ref="B2:C3", + row_labels=["volume", "status"], + column_labels=["primary", "secondary"], + ) + ], + reports=[ + report("summary", cells=["base", "projected", "status"], tables=["summary_grid"]), + ], + ) + +Render declared inputs and outputs: + +.. code-block:: python + + from modelwright.notebooks import inputs_frame, outputs_frame + + scenario = facade.scenario(name="shock", inputs={"Inputs!B2": 50}).with_input("Inputs!B3", 0.2) + + inputs_frame(facade, scenario) + outputs_frame(facade, scenario) + +In Jupyter, those calls display tidy DataFrames with names, labels, cell references, roles, units, +values, and value-presence flags. + +Render Tables +------------- + +Declared rectangular tables become DataFrames whose visible row and column labels come from the +wrapper declaration: + +.. code-block:: python + + from modelwright.notebooks import table_frame + + summary = table_frame(facade, "summary_grid", scenario) + summary + +Workbook provenance stays attached to the DataFrame: + +.. code-block:: python + + summary.attrs["sheet"] + summary.attrs["range_ref"] + summary.attrs["cell_refs"] + +Render Reports +-------------- + +Reports return a small mapping with a cell DataFrame and named table DataFrames: + +.. code-block:: python + + from modelwright.notebooks import report_frames + + frames = report_frames(facade, "summary", scenario) + frames["cells"] + frames["tables"]["summary_grid"] + +Compare Scenarios +----------------- + +Scenario comparison is the core notebook loop: + +.. code-block:: python + + from modelwright.notebooks import compare_scenarios_frame + + baseline = facade.scenario(name="baseline", inputs={"Inputs!B2": 100, "Inputs!B3": 0.1}) + shock = baseline.with_input("Inputs!B2", 120) + + compare_scenarios_frame(facade, baseline, shock) + +The comparison DataFrame includes declared name, label, cell reference, baseline value, scenario +value, absolute change, percent change where numeric and meaningful, unit, role, and description. +Text comparisons and zero-baseline percent changes remain explicit rather than raising. + +Scenario Inputs +--------------- + +Use ``scenario_frame`` to inspect the exact overrides being sent to the generated model: + +.. code-block:: python + + from modelwright.notebooks import scenario_frame + + scenario_frame(shock) + +Alpha Limits +------------ + +The notebook helpers are provisional. They are intended to make wrapped generated models usable in a +live Python kernel, not to promise a complete end-user application. + +Current non-goals: + +- full spreadsheet UI recreation; +- dashboard server or widget framework; +- automatic recovery of workbook semantic names or table meanings; +- visual formatting or workbook editing; +- Excel-backed recalculation equivalence; +- stable public API compatibility before real notebook workflows harden the design. diff --git a/docs/guides/release-deployment.rst b/docs/guides/release-deployment.rst index e29373d..5950817 100644 --- a/docs/guides/release-deployment.rst +++ b/docs/guides/release-deployment.rst @@ -8,15 +8,17 @@ all been checked. Current Alpha Target -------------------- -The current alpha target is ``0.1.0a4`` with Git tag ``v0.1.0a4``. +The current alpha target is ``0.1.0a5`` with Git tag ``v0.1.0a5``. The alpha may claim full comparable-output validation for the 2020 FABLE Calculator benchmark: 281,741 comparable cached workbook outputs, 281,741 matches, and zero mismatches. It may also claim the measured Phase 27 generated-runtime and generated-source-size improvements recorded in ``planning/phase-27-performance-memory-hardening.md`` and the initial ``modelwright.wrappers`` -facade helpers for building analyst-facing wrappers around generated models. It must not claim -full-workbook conversion, a full spreadsheet UI, Excel-backed recalculation equivalence, compact -runtime IR production readiness, or stable public API compatibility. +facade helpers for building analyst-facing wrappers around generated models. The ``0.1.0a5`` alpha +may additionally claim optional pandas-backed notebook helpers and a tracked Examples Gallery with +synthetic and compressed generated 2020 FABLE model examples. It must not claim full-workbook +conversion, a full spreadsheet UI, Excel-backed recalculation equivalence, compact runtime IR +production readiness, or stable public API compatibility. Local Release Checks -------------------- @@ -73,7 +75,7 @@ After TestPyPI publication, install the package from TestPyPI in a clean environ .. code-block:: bash - python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ modelwright==0.1.0a4 + python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ modelwright==0.1.0a5 python -c "import modelwright; print(modelwright.__version__)" modelwright --help @@ -87,7 +89,7 @@ Expected sequence: 1. Confirm ``CHANGE_LOG.md`` and release notes describe the actual alpha boundary. 2. Confirm local and CI release artifact checks pass. 3. Confirm TestPyPI rehearsal passes or document the exact blocker. -4. Create the annotated tag, for example ``v0.1.0a4``. +4. Create the annotated tag, for example ``v0.1.0a5``. 5. Run the ``Release`` workflow or push the tag, then approve the protected PyPI environment. 6. Verify the package page, wheel install, import, CLI help, docs deployment, and GitHub release notes. @@ -100,7 +102,7 @@ Use one of these responses: - yank the broken release on PyPI if installation should be discouraged but historical availability is still useful; -- publish a new alpha such as ``0.1.0a5`` after fixing the issue; +- publish a new alpha such as ``0.1.0a6`` after fixing the issue; - update release notes and roadmap entries with the failure mode and mitigation. Private Data Rules diff --git a/docs/index.rst b/docs/index.rst index b3b6ca4..cf1d442 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -18,6 +18,8 @@ assemble validation reports. It is not yet a general workbook converter. guides/private-workbooks guides/validation guides/wrapping-generated-models + guides/notebook-interface + examples guides/release-deployment guides/limitations diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 4abdae5..a45e334 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -58,6 +58,12 @@ Wrapper Facades .. automodule:: modelwright.wrappers :members: +Notebook Helpers +---------------- + +.. automodule:: modelwright.notebooks + :members: + Validation ---------- diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..4bc6a6e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,12 @@ +# Modelwright Examples + +These examples show how generated Modelwright Python models can be wrapped with analyst-facing +facades and notebook-friendly DataFrame helpers. + +- `synthetic/`: a tiny generated-model example based on the tracked synthetic fixture shape. +- `fable_2020/`: a production-size example using a compressed generated Python model converted from + the public 2020 FABLE Calculator benchmark workbook. + +The original FABLE workbook is not tracked here. The tracked FABLE example contains Modelwright's +generated Python output, compressed as `generated_fable_2020_model.py.xz` because the uncompressed +module is larger than ordinary GitHub per-file limits. diff --git a/examples/fable_2020/README.md b/examples/fable_2020/README.md new file mode 100644 index 0000000..17106e0 --- /dev/null +++ b/examples/fable_2020/README.md @@ -0,0 +1,21 @@ +# 2020 FABLE Generated Model Example + +This directory contains a compressed generated Python model produced by Modelwright from the public +2020 FABLE Calculator benchmark workbook. + +The original workbook is not tracked in this repository. The generated model is tracked as +`generated_fable_2020_model.py.xz` because the uncompressed Python module is about 117 MiB, which is +larger than ordinary GitHub per-file limits. The example script decompresses it into ignored `tmp/` +working space before importing it. + +The generated model preserves the P26 full-validation evidence boundary: + +- comparable cached outputs: 281,741; +- matches: 281,741; +- mismatches: 0. + +Run from the repository root: + +```bash +python examples/fable_2020/notebook_interface.py +``` diff --git a/examples/fable_2020/generated_fable_2020_model.py.xz b/examples/fable_2020/generated_fable_2020_model.py.xz new file mode 100644 index 0000000..1887607 Binary files /dev/null and b/examples/fable_2020/generated_fable_2020_model.py.xz differ diff --git a/examples/fable_2020/notebook_interface.py b/examples/fable_2020/notebook_interface.py new file mode 100644 index 0000000..85a7952 --- /dev/null +++ b/examples/fable_2020/notebook_interface.py @@ -0,0 +1,95 @@ +"""Notebook-interface example for the generated 2020 FABLE benchmark model.""" + +from __future__ import annotations + +import importlib.util +import lzma +import shutil +import sys +from pathlib import Path +from types import ModuleType + +from modelwright.notebooks import outputs_frame, report_frames, table_frame +from modelwright.wrappers import ModelFacade, cell, report, table + + +EXAMPLE_DIR = Path(__file__).resolve().parent +ARCHIVE_PATH = EXAMPLE_DIR / "generated_fable_2020_model.py.xz" +WORK_DIR = Path("tmp/examples/fable_2020") +MODEL_PATH = WORK_DIR / "generated_fable_2020_model.py" + +FABLE_SCENARIO_OUTPUTS = { + "SCENARIOS selection!D20": 2.146115426018433, + "SCENARIOS selection!D21": 1.8982220554032356, + "SCENARIOS selection!D22": 1.462761288724012, +} + + +def materialize_generated_model() -> Path: + """Decompress the tracked generated model into ignored local working space.""" + + WORK_DIR.mkdir(parents=True, exist_ok=True) + if not MODEL_PATH.exists(): + with lzma.open(ARCHIVE_PATH, "rb") as source: + with MODEL_PATH.open("wb") as target: + shutil.copyfileobj(source, target) + return MODEL_PATH + + +def load_generated_model(path: Path | None = None) -> ModuleType: + model_path = path or materialize_generated_model() + spec = importlib.util.spec_from_file_location("modelwright_example_fable_2020", model_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load generated model from {model_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def build_facade(module: ModuleType | None = None) -> ModelFacade: + generated_model = module or load_generated_model() + return ModelFacade( + generated_model, + cells=[ + cell("SCENARIOS selection!D20", name="scenario_metric_1", role="output"), + cell("SCENARIOS selection!D21", name="scenario_metric_2", role="output"), + cell("SCENARIOS selection!D22", name="scenario_metric_3", role="output"), + ], + tables=[ + table( + "scenario_selection_slice", + sheet="SCENARIOS selection", + range_ref="D20:D22", + row_labels=["d20", "d21", "d22"], + column_labels=["value"], + ) + ], + reports=[ + report( + "scenario_selection", + cells=["scenario_metric_1", "scenario_metric_2", "scenario_metric_3"], + tables=["scenario_selection_slice"], + ) + ], + ) + + +def run_example(): + facade = build_facade() + values = facade.calculate() + for cell_ref, expected in FABLE_SCENARIO_OUTPUTS.items(): + observed = values[cell_ref] + if observed != expected: + raise RuntimeError(f"{cell_ref} expected {expected!r}, observed {observed!r}") + return { + "outputs": outputs_frame(facade), + "scenario_selection_slice": table_frame(facade, "scenario_selection_slice"), + "report": report_frames(facade, "scenario_selection"), + } + + +if __name__ == "__main__": + frames = run_example() + print(frames["outputs"]) + print(frames["scenario_selection_slice"]) diff --git a/examples/synthetic/notebook_interface.py b/examples/synthetic/notebook_interface.py new file mode 100644 index 0000000..d39411e --- /dev/null +++ b/examples/synthetic/notebook_interface.py @@ -0,0 +1,62 @@ +"""Tiny notebook-interface example for a generated Modelwright model.""" + +from __future__ import annotations + +from modelwright.notebooks import compare_scenarios_frame, outputs_frame, table_frame +from modelwright.wrappers import ModelFacade, cell, report, table + + +def calculate(inputs=None): + """Small stand-in for generated Modelwright Python output.""" + + inputs = inputs or {} + base = inputs.get("Inputs!B2", 100) + growth = inputs.get("Inputs!B3", 0.1) + return { + "Summary!B2": base * (1 + growth), + "Summary!C2": base * 2, + "Summary!B3": "ok", + "Summary!C3": base + 5, + } + + +def build_facade() -> ModelFacade: + return ModelFacade( + calculate, + cells=[ + cell("Inputs!B2", name="base", label="Base volume", role="input", unit="t"), + cell("Inputs!B3", name="growth", label="Growth rate", role="input", unit="fraction"), + cell("Summary!B2", name="projected", label="Projected volume", role="output", unit="t"), + cell("Summary!B3", name="status", label="Status", role="output"), + ], + tables=[ + table( + "summary_grid", + sheet="Summary", + range_ref="B2:C3", + row_labels=["volume", "status"], + column_labels=["primary", "secondary"], + ) + ], + reports=[ + report("summary", cells=["base", "projected", "status"], tables=["summary_grid"]), + ], + ) + + +def run_example(): + facade = build_facade() + baseline = facade.scenario(name="baseline", inputs={"Inputs!B2": 100, "Inputs!B3": 0.1}) + shock = baseline.with_input("Inputs!B2", 120) + return { + "outputs": outputs_frame(facade, shock), + "summary_grid": table_frame(facade, "summary_grid", shock), + "comparison": compare_scenarios_frame(facade, baseline, shock), + } + + +if __name__ == "__main__": + frames = run_example() + for name, frame in frames.items(): + print(f"\n{name}") + print(frame) diff --git a/planning/notebook-dataframe-interface.md b/planning/notebook-dataframe-interface.md new file mode 100644 index 0000000..d0ad4a9 --- /dev/null +++ b/planning/notebook-dataframe-interface.md @@ -0,0 +1,171 @@ +# Notebook DataFrame Interface + +Phase 30 adds a notebook-facing interaction layer on top of `modelwright.wrappers`. + +The product boundary is deliberately narrow: analysts should be able to use a live Python kernel to +inspect declared model structure, change scenario inputs, recalculate, render declared tables as +`pandas.DataFrame` objects, and compare baseline-vs-scenario outputs without treating raw +`Sheet!A1` dictionaries or generated source as the normal interface. + +## Layering Decision + +The intended stack is: + +```text +generated model -> ModelFacade -> notebook/DataFrame helpers +``` + +`ModelFacade` remains the lower-level semantic adapter around generated `calculate(inputs=None) -> +dict[str, object]` models. Phase 30 should not move pandas into the core wrapper path. The notebook +layer should consume public wrapper declarations and views: + +- `ModelFacade.inputs()` +- `ModelFacade.outputs()` +- `ModelFacade.scenario(...)` +- `ModelFacade.calculate(...)` +- `ModelFacade.inspect(...)` +- `ModelFacade.table(...)` +- `ModelFacade.report(...)` + +## Module Boundary + +The preferred initial module is `modelwright.notebooks`. + +Rationale: + +- The release claim is notebook-facing workflow, not only generic tabular conversion. +- The module can host DataFrame helpers now and still leave room for later notebook-specific display + affordances if evidence justifies them. +- The lower-level `modelwright.wrappers` API can stay independent of pandas and Jupyter assumptions. + +The initial implementation should provide pure helper functions rather than adding methods directly +to `ModelFacade`. That keeps optional dependency handling local to the notebook adapter and avoids +turning wrapper facades into a broad display surface too early. + +## Optional Dependency Policy + +Add a `notebook` optional extra that installs pandas: + +```toml +[project.optional-dependencies] +notebook = [ + "pandas>=2" +] +``` + +The `modelwright.notebooks` module should import pandas lazily from helper calls, not at package +import time. If pandas is missing, raise a clear notebook-layer exception that tells users to install +`modelwright[notebook]`. + +Pandas should not become a core package dependency during this phase. + +## Initial Helper Sketch + +Candidate module-level helpers: + +```python +from modelwright.notebooks import ( + compare_scenarios_frame, + inputs_frame, + outputs_frame, + report_frames, + scenario_frame, + table_frame, +) +``` + +Expected behavior: + +- `inputs_frame(facade, scenario=None)` returns one row per declared input. +- `outputs_frame(facade, scenario=None)` returns one row per declared output. +- `scenario_frame(scenario)` returns one row per scenario input override. +- `table_frame(facade, "name", scenario=None)` returns a DataFrame whose visible index and columns + use declared row and column labels while cell references remain available as metadata columns or + attrs. +- `report_frames(facade, "name", scenario=None)` returns a mapping that contains cell and table + DataFrames for a declared report bundle. +- `compare_scenarios_frame(facade, baseline, scenario, cells=None)` returns a tidy comparison + DataFrame for declared outputs by default, or for an explicit cell-name/ref selection. + +## Comparison Columns + +Scenario comparison should include these fields where practical: + +- `name` +- `label` +- `cell_ref` +- `baseline_value` +- `scenario_value` +- `absolute_change` +- `percent_change` +- `unit` +- `role` +- `description` + +Percent change should only be populated for numeric values with a non-zero numeric baseline. +Textual, missing, or zero-baseline comparisons should remain explicit rather than raising. + +## Non-Goals + +Phase 30 must not claim: + +- full spreadsheet UI; +- dashboard server; +- widget framework; +- automatic recovery of workbook semantic names or table meanings; +- stable public API compatibility; +- compact runtime IR production readiness; +- Excel-backed recalculation equivalence. + +## Verification Plan + +Always-on tests should cover the tracked synthetic generated-model workflow and prove that DataFrame +helpers do not change generated calculation behavior. + +Opt-in benchmark coverage may extend the existing FABLE wrapper test pattern behind +`MODELWRIGHT_RUN_FABLE_BENCHMARKS=1`. Generated FABLE models, raw validation outputs, and source +workbooks must remain under ignored `tmp/`. + +## Implementation Result + +The first implementation uses `modelwright.notebooks` and exposes these helpers: + +- `inputs_frame(facade, scenario=None)` +- `outputs_frame(facade, scenario=None)` +- `scenario_frame(scenario)` +- `table_frame(facade, name, scenario=None)` +- `report_frames(facade, name, scenario=None)` +- `compare_scenarios_frame(facade, baseline, scenario, cells=None)` + +Tables display with declared row and column labels. Workbook provenance stays attached through +`DataFrame.attrs`, including sheet, range, and cell references. + +The implementation delegates value resolution through the facade cache path where available. This is +important for large generated models: notebook display helpers should not accidentally trigger +multiple full generated-model recalculations when they are formatting already-calculated results. + +Verification completed: + +- `scripts/bootstrap_dev_env.sh` +- `.venv/bin/python -m ruff check .` +- `.venv/bin/python -m pytest -vv` +- `.venv/bin/sphinx-build -b html docs _build/html -W` +- `.venv/bin/python scripts/verify_docs_theme.py _build/html` +- `MODELWRIGHT_RUN_FABLE_BENCHMARKS=1 .venv/bin/python -m pytest -vv tests/test_fable_wrapper_benchmark.py` + +The opt-in FABLE notebook wrapper benchmark passed in 149.67 seconds using ignored local artifacts +under `tmp/p26-fable-full-validation/`. + +## Examples Gallery + +The P30 release adds a tracked `examples/` directory and Sphinx Examples Gallery: + +- `examples/synthetic/notebook_interface.py` is a tiny runnable example using a generated-model-shaped + `calculate(inputs=None)` function, `ModelFacade`, and notebook DataFrame helpers. +- `examples/fable_2020/notebook_interface.py` is a production-size wrapper example over the generated + 2020 FABLE benchmark model. +- `examples/fable_2020/generated_fable_2020_model.py.xz` tracks the generated Python output in + compressed form. The uncompressed module is about 117 MiB, which is above ordinary GitHub per-file + limits, so the example script decompresses it into ignored `tmp/examples/fable_2020/` before import. + +The original FABLE workbook remains untracked. diff --git a/pyproject.toml b/pyproject.toml index 9a24fef..84c0080 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "modelwright" -version = "0.1.0a4" +version = "0.1.0a5" description = "Tools for converting spreadsheet workbooks into transparent Python models." readme = "README.md" requires-python = ">=3.10" @@ -56,12 +56,16 @@ docs = [ dev = [ "build>=1.2", "formulas", + "pandas>=2", "pytest>=8", "ruff>=0.8", "sphinx>=7", "sphinx-rtd-theme>=2", "twine>=5" ] +notebook = [ + "pandas>=2" +] oracle = [ "formulas" ] @@ -74,6 +78,7 @@ release = [ ] test = [ "formulas", + "pandas>=2", "pytest>=8" ] diff --git a/scripts/check_release_artifacts.sh b/scripts/check_release_artifacts.sh index 53db71d..715fc7d 100755 --- a/scripts/check_release_artifacts.sh +++ b/scripts/check_release_artifacts.sh @@ -98,7 +98,7 @@ from __future__ import annotations import modelwright import importlib.util -assert modelwright.__version__ == "0.1.0a4", modelwright.__version__ +assert modelwright.__version__ == "0.1.0a5", modelwright.__version__ assert importlib.util.find_spec("sheetforge") is None print(f"[release-check] imported modelwright {modelwright.__version__}") PY diff --git a/src/modelwright/__init__.py b/src/modelwright/__init__.py index b4b109c..1f6134b 100644 --- a/src/modelwright/__init__.py +++ b/src/modelwright/__init__.py @@ -54,6 +54,15 @@ DependencyGraph, build_dependency_graph, ) +from modelwright.notebooks import ( + NotebookDependencyError, + compare_scenarios_frame, + inputs_frame, + outputs_frame, + report_frames, + scenario_frame, + table_frame, +) from modelwright.oracles import ( OracleDiagnostic, OracleRequest, @@ -95,7 +104,7 @@ table, ) -__version__ = "0.1.0a4" +__version__ = "0.1.0a5" __all__ = [ "CellRecord", @@ -127,6 +136,7 @@ "MISSING_VALUE", "ModelFacade", "NamedRangeRecord", + "NotebookDependencyError", "OracleConfig", "OracleDiagnostic", "OracleRequest", @@ -160,15 +170,21 @@ "build_validation_report", "cell", "compare_scalar_output", + "compare_scenarios_frame", "execute_generated_model", "evaluate_generated_model", "extract_workbook", "generate_python_module", "infer_generated_module_contract", + "inputs_frame", "load_validation_scenario", "normalize_cell_reference", "normalize_reference", + "outputs_frame", "report", + "report_frames", + "scenario_frame", "table", + "table_frame", "translate_formula_cell", ] diff --git a/src/modelwright/notebooks.py b/src/modelwright/notebooks.py new file mode 100644 index 0000000..c9a9ba8 --- /dev/null +++ b/src/modelwright/notebooks.py @@ -0,0 +1,246 @@ +"""Notebook-friendly DataFrame helpers for wrapped generated models.""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from numbers import Real +from typing import TYPE_CHECKING, Any + +from modelwright.references import normalize_cell_reference +from modelwright.wrappers import CellRef, ModelFacade, Scenario, WrapperDeclarationError + +if TYPE_CHECKING: + import pandas as pd + + +class NotebookDependencyError(RuntimeError): + """Raised when notebook helpers need an optional dependency that is not installed.""" + + +def inputs_frame(facade: ModelFacade, scenario: Scenario | None = None) -> "pd.DataFrame": + """Return declared facade inputs as a tidy pandas DataFrame.""" + + values = _values_for(facade, scenario) + return _cell_frame(facade.inputs().values(), values) + + +def outputs_frame(facade: ModelFacade, scenario: Scenario | None = None) -> "pd.DataFrame": + """Return declared facade outputs as a tidy pandas DataFrame.""" + + values = _values_for(facade, scenario) + return _cell_frame(facade.outputs().values(), values) + + +def scenario_frame(scenario: Scenario) -> "pd.DataFrame": + """Return scenario input overrides as a tidy pandas DataFrame.""" + + pd = _load_pandas() + return pd.DataFrame( + [ + { + "scenario": scenario.name, + "cell_ref": cell_ref, + "value": value, + } + for cell_ref, value in scenario.inputs.items() + ], + columns=["scenario", "cell_ref", "value"], + ) + + +def table_frame(facade: ModelFacade, name: str, scenario: Scenario | None = None) -> "pd.DataFrame": + """Return a declared facade table as a pandas DataFrame.""" + + pd = _load_pandas() + table = facade.table(name, scenario=scenario) + frame = pd.DataFrame(table.values, index=list(table.rows), columns=list(table.columns)) + frame.index.name = "row" + frame.attrs.update( + { + "name": table.name, + "label": table.label, + "description": table.description, + "sheet": table.sheet, + "range_ref": table.range_ref, + "cell_refs": [list(row) for row in table.cell_refs], + } + ) + return frame + + +def report_frames(facade: ModelFacade, name: str, scenario: Scenario | None = None) -> dict[str, Any]: + """Return DataFrame payloads for a declared facade report.""" + + report = facade.reports.get(name) + if report is None: + raise WrapperDeclarationError(f"unknown report declaration {name!r}") + + values = _values_for(facade, scenario) + cells = [facade.cells[cell_name] for cell_name in report.cells] + return { + "name": report.name, + "label": report.label, + "description": report.description, + "cells": _cell_frame(cells, values), + "tables": { + table_name: table_frame(facade, table_name, scenario=scenario) + for table_name in report.tables + }, + } + + +def compare_scenarios_frame( + facade: ModelFacade, + baseline: Scenario, + scenario: Scenario, + *, + cells: Iterable[str] | None = None, +) -> "pd.DataFrame": + """Compare declared output cells between two scenarios as a tidy DataFrame.""" + + baseline_values = _values_for(facade, baseline) + scenario_values = _values_for(facade, scenario) + declarations = _comparison_cells(facade, cells) + + rows = [] + for declaration in declarations: + baseline_value = baseline_values.get(declaration.cell_ref) + scenario_value = scenario_values.get(declaration.cell_ref) + absolute_change = _absolute_change(baseline_value, scenario_value) + rows.append( + { + "name": declaration.name, + "label": declaration.label, + "cell_ref": declaration.cell_ref, + "baseline_value": baseline_value, + "scenario_value": scenario_value, + "absolute_change": absolute_change, + "percent_change": _percent_change(baseline_value, absolute_change), + "unit": declaration.unit, + "role": declaration.role, + "description": declaration.description, + } + ) + + pd = _load_pandas() + return pd.DataFrame( + rows, + columns=[ + "name", + "label", + "cell_ref", + "baseline_value", + "scenario_value", + "absolute_change", + "percent_change", + "unit", + "role", + "description", + ], + dtype=object, + ) + + +def _cell_frame(cells: Iterable[CellRef], values: Mapping[str, object]) -> "pd.DataFrame": + pd = _load_pandas() + return pd.DataFrame( + [ + { + "name": declaration.name, + "label": declaration.label, + "cell_ref": declaration.cell_ref, + "role": declaration.role, + "unit": declaration.unit, + "description": declaration.description, + "value": values.get(declaration.cell_ref), + "has_value": declaration.cell_ref in values, + } + for declaration in cells + ], + columns=[ + "name", + "label", + "cell_ref", + "role", + "unit", + "description", + "value", + "has_value", + ], + ) + + +def _comparison_cells(facade: ModelFacade, cells: Iterable[str] | None) -> list[CellRef]: + declarations_by_name = facade.cells + if cells is None: + return list(facade.outputs().values()) + + declarations = [] + for selector in cells: + declaration = declarations_by_name.get(selector) + if declaration is not None: + declarations.append(declaration) + continue + + normalized = _normalize_cell_ref(selector) + for candidate in declarations_by_name.values(): + if candidate.cell_ref == normalized: + declarations.append(candidate) + break + else: + declarations.append(CellRef(cell_ref=normalized, name=normalized)) + return declarations + + +def _values_for(facade: ModelFacade, scenario: Scenario | None) -> dict[str, object]: + facade_values_for = getattr(facade, "_values_for", None) + if callable(facade_values_for): + return dict(facade_values_for(scenario)) + + active_scenario = scenario or facade.scenario() + values = facade.calculate(active_scenario) + return {**active_scenario.inputs, **values} + + +def _normalize_cell_ref(cell_ref: str) -> str: + normalized = normalize_cell_reference(cell_ref) + if normalized.kind != "cell" or normalized.sheet is None: + raise WrapperDeclarationError(f"expected a full cell reference like 'Sheet!A1', got {cell_ref!r}") + return normalized.normalized + + +def _absolute_change(baseline_value: object, scenario_value: object) -> float | int | None: + if _is_number(baseline_value) and _is_number(scenario_value): + return scenario_value - baseline_value + return None + + +def _percent_change(baseline_value: object, absolute_change: object) -> float | None: + if _is_number(baseline_value) and baseline_value != 0 and _is_number(absolute_change): + return absolute_change / baseline_value + return None + + +def _is_number(value: object) -> bool: + return isinstance(value, Real) and not isinstance(value, bool) + + +def _load_pandas() -> Any: + try: + import pandas as pd + except ImportError as error: + raise NotebookDependencyError( + "Install modelwright[notebook] to use pandas-backed notebook helpers." + ) from error + return pd + + +__all__ = [ + "NotebookDependencyError", + "compare_scenarios_frame", + "inputs_frame", + "outputs_frame", + "report_frames", + "scenario_frame", + "table_frame", +] diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..1d357fc --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import importlib.util +import lzma +from pathlib import Path +from types import ModuleType + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_example(path: Path, module_name: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(module_name, path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_synthetic_notebook_example_runs() -> None: + module = load_example(ROOT / "examples/synthetic/notebook_interface.py", "synthetic_notebook_example") + + frames = module.run_example() + + assert frames["outputs"].set_index("name").loc["projected", "value"] == 132.0 + assert frames["summary_grid"].loc["volume", "primary"] == 132.0 + assert frames["comparison"].set_index("name").loc["projected", "absolute_change"] == pytest.approx(22.0) + + +def test_fable_generated_model_archive_is_tracked_and_readable() -> None: + archive_path = ROOT / "examples/fable_2020/generated_fable_2020_model.py.xz" + + assert archive_path.exists() + assert archive_path.stat().st_size < 10_000_000 + with lzma.open(archive_path, "rb") as archive: + prefix = archive.read(128) + + assert b"Generated Modelwright model" in prefix diff --git a/tests/test_fable_wrapper_benchmark.py b/tests/test_fable_wrapper_benchmark.py index 19ebe03..ee84a97 100644 --- a/tests/test_fable_wrapper_benchmark.py +++ b/tests/test_fable_wrapper_benchmark.py @@ -9,6 +9,7 @@ import pytest +from modelwright.notebooks import outputs_frame, report_frames, table_frame from modelwright.wrappers import ModelFacade, cell, report, table @@ -87,3 +88,24 @@ def test_model_facade_wraps_2020_fable_benchmark_model_outputs() -> None: assert report_payload["tables"]["scenario_selection_slice"]["values"][2][0] == pytest.approx( FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D22"] ) + + output_rows = outputs_frame(facade).set_index("cell_ref") + assert output_rows.loc["SCENARIOS selection!D20", "value"] == pytest.approx( + FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D20"] + ) + + table_rows = table_frame(facade, "scenario_selection_slice") + assert table_rows.loc["d21", "value"] == pytest.approx(FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D21"]) + assert table_rows.attrs["cell_refs"] == [ + ["SCENARIOS selection!D20"], + ["SCENARIOS selection!D21"], + ["SCENARIOS selection!D22"], + ] + + frames = report_frames(facade, "scenario_selection") + assert frames["cells"].set_index("name").loc["scenario_metric_3", "value"] == pytest.approx( + FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D22"] + ) + assert frames["tables"]["scenario_selection_slice"].loc["d20", "value"] == pytest.approx( + FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D20"] + ) diff --git a/tests/test_import.py b/tests/test_import.py index 4f345ce..de10ad5 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -2,4 +2,4 @@ def test_package_imports() -> None: - assert modelwright.__version__ == "0.1.0a4" + assert modelwright.__version__ == "0.1.0a5" diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py new file mode 100644 index 0000000..3d349a1 --- /dev/null +++ b/tests/test_notebooks.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import builtins +import importlib +from pathlib import Path + +import pytest + +from modelwright.notebooks import ( + NotebookDependencyError, + compare_scenarios_frame, + inputs_frame, + outputs_frame, + report_frames, + scenario_frame, + table_frame, +) +from modelwright.wrappers import ModelFacade, cell, report, table +from tests.test_wrappers import build_generated_synthetic_model + + +def generated_model(inputs=None): + inputs = inputs or {} + base = inputs.get("Inputs!B2", 100) + growth = inputs.get("Inputs!B3", 0.1) + return { + "Summary!B2": base * (1 + growth), + "Summary!C2": base * 2, + "Summary!B3": "ok", + "Summary!C3": base + 5, + } + + +def notebook_facade(generated=generated_model) -> ModelFacade: + return ModelFacade( + generated, + cells=[ + cell("Inputs!B2", name="base", label="Base volume", role="input", unit="t"), + cell("Inputs!B3", name="growth", label="Growth rate", role="input", unit="fraction"), + cell("Summary!B2", name="projected", label="Projected volume", role="output", unit="t"), + cell("Summary!B3", name="status", label="Status", role="output"), + ], + tables=[ + table( + "summary_grid", + sheet="Summary", + range_ref="B2:C3", + row_labels=["volume", "status"], + column_labels=["primary", "secondary"], + ) + ], + reports=[report("summary", cells=["base", "projected", "status"], tables=["summary_grid"])], + ) + + +def test_notebook_frames_expose_declared_inputs_outputs_tables_and_reports() -> None: + facade = notebook_facade() + scenario = facade.scenario(name="shock", inputs={"Inputs!B2": 50}).with_input("Inputs!B3", 0.2) + + input_rows = inputs_frame(facade, scenario) + assert input_rows.to_dict("records") == [ + { + "name": "base", + "label": "Base volume", + "cell_ref": "Inputs!B2", + "role": "input", + "unit": "t", + "description": None, + "value": 50, + "has_value": True, + }, + { + "name": "growth", + "label": "Growth rate", + "cell_ref": "Inputs!B3", + "role": "input", + "unit": "fraction", + "description": None, + "value": 0.2, + "has_value": True, + }, + ] + + output_rows = outputs_frame(facade, scenario) + assert output_rows[["name", "cell_ref", "value", "has_value"]].to_dict("records") == [ + {"name": "projected", "cell_ref": "Summary!B2", "value": 60.0, "has_value": True}, + {"name": "status", "cell_ref": "Summary!B3", "value": "ok", "has_value": True}, + ] + + scenario_rows = scenario_frame(scenario) + assert scenario_rows.to_dict("records") == [ + {"scenario": "shock", "cell_ref": "Inputs!B2", "value": 50}, + {"scenario": "shock", "cell_ref": "Inputs!B3", "value": 0.2}, + ] + + summary_grid = table_frame(facade, "summary_grid", scenario) + assert list(summary_grid.index) == ["volume", "status"] + assert list(summary_grid.columns) == ["primary", "secondary"] + assert summary_grid.loc["volume", "primary"] == 60.0 + assert summary_grid.loc["status", "secondary"] == 55 + assert summary_grid.attrs["sheet"] == "Summary" + assert summary_grid.attrs["range_ref"] == "B2:C3" + assert summary_grid.attrs["cell_refs"] == [["Summary!B2", "Summary!C2"], ["Summary!B3", "Summary!C3"]] + + frames = report_frames(facade, "summary", scenario) + assert frames["name"] == "summary" + assert frames["cells"][["name", "value"]].to_dict("records") == [ + {"name": "base", "value": 50}, + {"name": "projected", "value": 60.0}, + {"name": "status", "value": "ok"}, + ] + assert frames["tables"]["summary_grid"].loc["volume", "secondary"] == 100 + + +def test_compare_scenarios_frame_handles_numeric_text_and_zero_baseline() -> None: + def comparison_model(inputs=None): + inputs = inputs or {} + base = inputs.get("Inputs!B2", 100) + multiplier = inputs.get("Inputs!B3", 1) + return { + "Summary!B2": base * multiplier, + "Summary!B3": "ok" if multiplier == 1 else "changed", + "Summary!B4": base - 100, + } + + facade = ModelFacade( + comparison_model, + cells=[ + cell("Inputs!B2", name="base", role="input"), + cell("Inputs!B3", name="multiplier", role="input"), + cell("Summary!B2", name="projected", label="Projected volume", role="output", unit="t"), + cell("Summary!B3", name="status", label="Status", role="output"), + cell("Summary!B4", name="delta", label="Delta", role="output"), + ], + ) + + baseline = facade.scenario(name="baseline", inputs={"Inputs!B2": 100, "Inputs!B3": 1}) + shock = baseline.with_input("Inputs!B3", 1.2) + comparison = compare_scenarios_frame(facade, baseline, shock) + + projected = comparison.set_index("name").loc["projected"] + assert projected["baseline_value"] == 100 + assert projected["scenario_value"] == 120 + assert projected["absolute_change"] == 20 + assert projected["percent_change"] == pytest.approx(0.2) + + status = comparison.set_index("name").loc["status"] + assert status["baseline_value"] == "ok" + assert status["scenario_value"] == "changed" + assert status["absolute_change"] is None + assert status["percent_change"] is None + + delta = comparison.set_index("name").loc["delta"] + assert delta["baseline_value"] == 0 + assert delta["scenario_value"] == 0 + assert delta["absolute_change"] == 0 + assert delta["percent_change"] is None + + +def test_notebook_helpers_preserve_real_generated_synthetic_model_semantics(tmp_path: Path) -> None: + module = build_generated_synthetic_model(tmp_path) + facade = ModelFacade( + module, + cells=[ + cell("Inputs!B2", name="base", label="Base volume", role="input"), + cell("Summary!B2", name="harvest", label="Rounded harvest", role="output"), + cell("Summary!B3", name="status", label="Status", role="output"), + ], + tables=[ + table( + "summary", + sheet="Summary", + range_ref="B2:B3", + row_labels=["harvest", "status"], + column_labels=["value"], + ) + ], + ) + scenario = facade.scenario(name="low-volume", inputs={"Inputs!B2": 10}) + + assert facade.calculate(scenario) == module.calculate(scenario.inputs) + assert outputs_frame(facade, scenario).set_index("name").loc["harvest", "value"] == 7.02 + assert table_frame(facade, "summary", scenario).loc["status", "value"] == "low" + + +def test_modelwright_import_does_not_require_pandas(monkeypatch: pytest.MonkeyPatch) -> None: + real_import = builtins.__import__ + + def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "pandas": + raise ModuleNotFoundError("No module named 'pandas'") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + module = importlib.reload(importlib.import_module("modelwright")) + + assert "inputs_frame" in module.__all__ + + +def test_notebook_helpers_report_missing_pandas(monkeypatch: pytest.MonkeyPatch) -> None: + real_import = builtins.__import__ + + def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "pandas": + raise ModuleNotFoundError("No module named 'pandas'") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", guarded_import) + + with pytest.raises(NotebookDependencyError, match=r"modelwright\[notebook\]"): + scenario_frame(notebook_facade().scenario()) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index fe403dc..ab0c968 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -21,6 +21,10 @@ def test_root_facade_exports_primary_entrypoints() -> None: assert "cell" in modelwright.__all__ assert "table" in modelwright.__all__ assert "report" in modelwright.__all__ + assert "inputs_frame" in modelwright.__all__ + assert "outputs_frame" in modelwright.__all__ + assert "table_frame" in modelwright.__all__ + assert "compare_scenarios_frame" in modelwright.__all__ def test_root_facade_does_not_export_internal_helpers() -> None: