modelwright 0.1.0a4__tar.gz → 0.1.0a5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. modelwright-0.1.0a5/MANIFEST.in +1 -0
  2. {modelwright-0.1.0a4/src/modelwright.egg-info → modelwright-0.1.0a5}/PKG-INFO +6 -2
  3. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/README.md +1 -1
  4. modelwright-0.1.0a5/examples/README.md +12 -0
  5. modelwright-0.1.0a5/examples/fable_2020/README.md +21 -0
  6. modelwright-0.1.0a5/examples/fable_2020/generated_fable_2020_model.py.xz +0 -0
  7. modelwright-0.1.0a5/examples/fable_2020/notebook_interface.py +95 -0
  8. modelwright-0.1.0a5/examples/synthetic/notebook_interface.py +62 -0
  9. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/pyproject.toml +6 -1
  10. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/__init__.py +17 -1
  11. modelwright-0.1.0a5/src/modelwright/notebooks.py +246 -0
  12. {modelwright-0.1.0a4 → modelwright-0.1.0a5/src/modelwright.egg-info}/PKG-INFO +6 -2
  13. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright.egg-info/SOURCES.txt +9 -0
  14. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright.egg-info/requires.txt +5 -0
  15. modelwright-0.1.0a5/tests/test_examples.py +41 -0
  16. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_fable_wrapper_benchmark.py +22 -0
  17. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_import.py +1 -1
  18. modelwright-0.1.0a5/tests/test_notebooks.py +211 -0
  19. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_public_api.py +4 -0
  20. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/LICENSE +0 -0
  21. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/setup.cfg +0 -0
  22. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/cli.py +0 -0
  23. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/conversion.py +0 -0
  24. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/evaluation.py +0 -0
  25. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/execution.py +0 -0
  26. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/extraction.py +0 -0
  27. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/formulas.py +0 -0
  28. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/formulas_oracle.py +0 -0
  29. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/generation.py +0 -0
  30. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/graph.py +0 -0
  31. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/oracle_validation.py +0 -0
  32. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/oracles.py +0 -0
  33. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/references.py +0 -0
  34. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/validation.py +0 -0
  35. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/wrappers.py +0 -0
  36. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright.egg-info/dependency_links.txt +0 -0
  37. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright.egg-info/entry_points.txt +0 -0
  38. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright.egg-info/top_level.txt +0 -0
  39. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_cli.py +0 -0
  40. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_conversion_plan.py +0 -0
  41. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_dependency_graph.py +0 -0
  42. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_evaluation_orchestration.py +0 -0
  43. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_extraction_records.py +0 -0
  44. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_formula_expressions.py +0 -0
  45. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_formula_translation.py +0 -0
  46. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_formulas_oracle.py +0 -0
  47. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_generated_execution.py +0 -0
  48. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_generation_contract.py +0 -0
  49. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_materialize_fable_benchmarks.py +0 -0
  50. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_openpyxl_extraction.py +0 -0
  51. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_oracle_backed_validation.py +0 -0
  52. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_oracle_interface.py +0 -0
  53. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_python_generation.py +0 -0
  54. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_references.py +0 -0
  55. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_scalar_comparison.py +0 -0
  56. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_supported_semantics_fixture.py +0 -0
  57. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_synthetic_fixture.py +0 -0
  58. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_validation.py +0 -0
  59. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_validation_regression.py +0 -0
  60. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_validation_report_builder.py +0 -0
  61. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_validation_scenario.py +0 -0
  62. {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_wrappers.py +0 -0
@@ -0,0 +1 @@
1
+ recursive-include examples *.md *.py *.xz
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modelwright
3
- Version: 0.1.0a4
3
+ Version: 0.1.0a5
4
4
  Summary: Tools for converting spreadsheet workbooks into transparent Python models.
5
5
  Author: UBC FRESH Lab
6
6
  License-Expression: MIT
@@ -32,11 +32,14 @@ Requires-Dist: sphinx-rtd-theme>=2; extra == "docs"
32
32
  Provides-Extra: dev
33
33
  Requires-Dist: build>=1.2; extra == "dev"
34
34
  Requires-Dist: formulas; extra == "dev"
35
+ Requires-Dist: pandas>=2; extra == "dev"
35
36
  Requires-Dist: pytest>=8; extra == "dev"
36
37
  Requires-Dist: ruff>=0.8; extra == "dev"
37
38
  Requires-Dist: sphinx>=7; extra == "dev"
38
39
  Requires-Dist: sphinx-rtd-theme>=2; extra == "dev"
39
40
  Requires-Dist: twine>=5; extra == "dev"
41
+ Provides-Extra: notebook
42
+ Requires-Dist: pandas>=2; extra == "notebook"
40
43
  Provides-Extra: oracle
41
44
  Requires-Dist: formulas; extra == "oracle"
42
45
  Provides-Extra: quality
@@ -46,6 +49,7 @@ Requires-Dist: build>=1.2; extra == "release"
46
49
  Requires-Dist: twine>=5; extra == "release"
47
50
  Provides-Extra: test
48
51
  Requires-Dist: formulas; extra == "test"
52
+ Requires-Dist: pandas>=2; extra == "test"
49
53
  Requires-Dist: pytest>=8; extra == "test"
50
54
  Dynamic: license-file
51
55
 
@@ -135,7 +139,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths:
135
139
  scripts/bootstrap_dev_env.sh --benchmarks
136
140
  ```
137
141
 
138
- `modelwright` is pre-release. The current alpha line is `0.1.0a4`; alpha releases must not be described as full-workbook conversion guarantees.
142
+ `modelwright` is pre-release. The current alpha line is `0.1.0a5`; alpha releases must not be described as full-workbook conversion guarantees.
139
143
 
140
144
  Check release artifacts locally:
141
145
 
@@ -84,7 +84,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths:
84
84
  scripts/bootstrap_dev_env.sh --benchmarks
85
85
  ```
86
86
 
87
- `modelwright` is pre-release. The current alpha line is `0.1.0a4`; alpha releases must not be described as full-workbook conversion guarantees.
87
+ `modelwright` is pre-release. The current alpha line is `0.1.0a5`; alpha releases must not be described as full-workbook conversion guarantees.
88
88
 
89
89
  Check release artifacts locally:
90
90
 
@@ -0,0 +1,12 @@
1
+ # Modelwright Examples
2
+
3
+ These examples show how generated Modelwright Python models can be wrapped with analyst-facing
4
+ facades and notebook-friendly DataFrame helpers.
5
+
6
+ - `synthetic/`: a tiny generated-model example based on the tracked synthetic fixture shape.
7
+ - `fable_2020/`: a production-size example using a compressed generated Python model converted from
8
+ the public 2020 FABLE Calculator benchmark workbook.
9
+
10
+ The original FABLE workbook is not tracked here. The tracked FABLE example contains Modelwright's
11
+ generated Python output, compressed as `generated_fable_2020_model.py.xz` because the uncompressed
12
+ module is larger than ordinary GitHub per-file limits.
@@ -0,0 +1,21 @@
1
+ # 2020 FABLE Generated Model Example
2
+
3
+ This directory contains a compressed generated Python model produced by Modelwright from the public
4
+ 2020 FABLE Calculator benchmark workbook.
5
+
6
+ The original workbook is not tracked in this repository. The generated model is tracked as
7
+ `generated_fable_2020_model.py.xz` because the uncompressed Python module is about 117 MiB, which is
8
+ larger than ordinary GitHub per-file limits. The example script decompresses it into ignored `tmp/`
9
+ working space before importing it.
10
+
11
+ The generated model preserves the P26 full-validation evidence boundary:
12
+
13
+ - comparable cached outputs: 281,741;
14
+ - matches: 281,741;
15
+ - mismatches: 0.
16
+
17
+ Run from the repository root:
18
+
19
+ ```bash
20
+ python examples/fable_2020/notebook_interface.py
21
+ ```
@@ -0,0 +1,95 @@
1
+ """Notebook-interface example for the generated 2020 FABLE benchmark model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import lzma
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+ from types import ModuleType
11
+
12
+ from modelwright.notebooks import outputs_frame, report_frames, table_frame
13
+ from modelwright.wrappers import ModelFacade, cell, report, table
14
+
15
+
16
+ EXAMPLE_DIR = Path(__file__).resolve().parent
17
+ ARCHIVE_PATH = EXAMPLE_DIR / "generated_fable_2020_model.py.xz"
18
+ WORK_DIR = Path("tmp/examples/fable_2020")
19
+ MODEL_PATH = WORK_DIR / "generated_fable_2020_model.py"
20
+
21
+ FABLE_SCENARIO_OUTPUTS = {
22
+ "SCENARIOS selection!D20": 2.146115426018433,
23
+ "SCENARIOS selection!D21": 1.8982220554032356,
24
+ "SCENARIOS selection!D22": 1.462761288724012,
25
+ }
26
+
27
+
28
+ def materialize_generated_model() -> Path:
29
+ """Decompress the tracked generated model into ignored local working space."""
30
+
31
+ WORK_DIR.mkdir(parents=True, exist_ok=True)
32
+ if not MODEL_PATH.exists():
33
+ with lzma.open(ARCHIVE_PATH, "rb") as source:
34
+ with MODEL_PATH.open("wb") as target:
35
+ shutil.copyfileobj(source, target)
36
+ return MODEL_PATH
37
+
38
+
39
+ def load_generated_model(path: Path | None = None) -> ModuleType:
40
+ model_path = path or materialize_generated_model()
41
+ spec = importlib.util.spec_from_file_location("modelwright_example_fable_2020", model_path)
42
+ if spec is None or spec.loader is None:
43
+ raise RuntimeError(f"could not load generated model from {model_path}")
44
+ module = importlib.util.module_from_spec(spec)
45
+ sys.modules[spec.name] = module
46
+ spec.loader.exec_module(module)
47
+ return module
48
+
49
+
50
+ def build_facade(module: ModuleType | None = None) -> ModelFacade:
51
+ generated_model = module or load_generated_model()
52
+ return ModelFacade(
53
+ generated_model,
54
+ cells=[
55
+ cell("SCENARIOS selection!D20", name="scenario_metric_1", role="output"),
56
+ cell("SCENARIOS selection!D21", name="scenario_metric_2", role="output"),
57
+ cell("SCENARIOS selection!D22", name="scenario_metric_3", role="output"),
58
+ ],
59
+ tables=[
60
+ table(
61
+ "scenario_selection_slice",
62
+ sheet="SCENARIOS selection",
63
+ range_ref="D20:D22",
64
+ row_labels=["d20", "d21", "d22"],
65
+ column_labels=["value"],
66
+ )
67
+ ],
68
+ reports=[
69
+ report(
70
+ "scenario_selection",
71
+ cells=["scenario_metric_1", "scenario_metric_2", "scenario_metric_3"],
72
+ tables=["scenario_selection_slice"],
73
+ )
74
+ ],
75
+ )
76
+
77
+
78
+ def run_example():
79
+ facade = build_facade()
80
+ values = facade.calculate()
81
+ for cell_ref, expected in FABLE_SCENARIO_OUTPUTS.items():
82
+ observed = values[cell_ref]
83
+ if observed != expected:
84
+ raise RuntimeError(f"{cell_ref} expected {expected!r}, observed {observed!r}")
85
+ return {
86
+ "outputs": outputs_frame(facade),
87
+ "scenario_selection_slice": table_frame(facade, "scenario_selection_slice"),
88
+ "report": report_frames(facade, "scenario_selection"),
89
+ }
90
+
91
+
92
+ if __name__ == "__main__":
93
+ frames = run_example()
94
+ print(frames["outputs"])
95
+ print(frames["scenario_selection_slice"])
@@ -0,0 +1,62 @@
1
+ """Tiny notebook-interface example for a generated Modelwright model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from modelwright.notebooks import compare_scenarios_frame, outputs_frame, table_frame
6
+ from modelwright.wrappers import ModelFacade, cell, report, table
7
+
8
+
9
+ def calculate(inputs=None):
10
+ """Small stand-in for generated Modelwright Python output."""
11
+
12
+ inputs = inputs or {}
13
+ base = inputs.get("Inputs!B2", 100)
14
+ growth = inputs.get("Inputs!B3", 0.1)
15
+ return {
16
+ "Summary!B2": base * (1 + growth),
17
+ "Summary!C2": base * 2,
18
+ "Summary!B3": "ok",
19
+ "Summary!C3": base + 5,
20
+ }
21
+
22
+
23
+ def build_facade() -> ModelFacade:
24
+ return ModelFacade(
25
+ calculate,
26
+ cells=[
27
+ cell("Inputs!B2", name="base", label="Base volume", role="input", unit="t"),
28
+ cell("Inputs!B3", name="growth", label="Growth rate", role="input", unit="fraction"),
29
+ cell("Summary!B2", name="projected", label="Projected volume", role="output", unit="t"),
30
+ cell("Summary!B3", name="status", label="Status", role="output"),
31
+ ],
32
+ tables=[
33
+ table(
34
+ "summary_grid",
35
+ sheet="Summary",
36
+ range_ref="B2:C3",
37
+ row_labels=["volume", "status"],
38
+ column_labels=["primary", "secondary"],
39
+ )
40
+ ],
41
+ reports=[
42
+ report("summary", cells=["base", "projected", "status"], tables=["summary_grid"]),
43
+ ],
44
+ )
45
+
46
+
47
+ def run_example():
48
+ facade = build_facade()
49
+ baseline = facade.scenario(name="baseline", inputs={"Inputs!B2": 100, "Inputs!B3": 0.1})
50
+ shock = baseline.with_input("Inputs!B2", 120)
51
+ return {
52
+ "outputs": outputs_frame(facade, shock),
53
+ "summary_grid": table_frame(facade, "summary_grid", shock),
54
+ "comparison": compare_scenarios_frame(facade, baseline, shock),
55
+ }
56
+
57
+
58
+ if __name__ == "__main__":
59
+ frames = run_example()
60
+ for name, frame in frames.items():
61
+ print(f"\n{name}")
62
+ print(frame)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "modelwright"
7
- version = "0.1.0a4"
7
+ version = "0.1.0a5"
8
8
  description = "Tools for converting spreadsheet workbooks into transparent Python models."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -56,12 +56,16 @@ docs = [
56
56
  dev = [
57
57
  "build>=1.2",
58
58
  "formulas",
59
+ "pandas>=2",
59
60
  "pytest>=8",
60
61
  "ruff>=0.8",
61
62
  "sphinx>=7",
62
63
  "sphinx-rtd-theme>=2",
63
64
  "twine>=5"
64
65
  ]
66
+ notebook = [
67
+ "pandas>=2"
68
+ ]
65
69
  oracle = [
66
70
  "formulas"
67
71
  ]
@@ -74,6 +78,7 @@ release = [
74
78
  ]
75
79
  test = [
76
80
  "formulas",
81
+ "pandas>=2",
77
82
  "pytest>=8"
78
83
  ]
79
84
 
@@ -54,6 +54,15 @@ from modelwright.graph import (
54
54
  DependencyGraph,
55
55
  build_dependency_graph,
56
56
  )
57
+ from modelwright.notebooks import (
58
+ NotebookDependencyError,
59
+ compare_scenarios_frame,
60
+ inputs_frame,
61
+ outputs_frame,
62
+ report_frames,
63
+ scenario_frame,
64
+ table_frame,
65
+ )
57
66
  from modelwright.oracles import (
58
67
  OracleDiagnostic,
59
68
  OracleRequest,
@@ -95,7 +104,7 @@ from modelwright.wrappers import (
95
104
  table,
96
105
  )
97
106
 
98
- __version__ = "0.1.0a4"
107
+ __version__ = "0.1.0a5"
99
108
 
100
109
  __all__ = [
101
110
  "CellRecord",
@@ -127,6 +136,7 @@ __all__ = [
127
136
  "MISSING_VALUE",
128
137
  "ModelFacade",
129
138
  "NamedRangeRecord",
139
+ "NotebookDependencyError",
130
140
  "OracleConfig",
131
141
  "OracleDiagnostic",
132
142
  "OracleRequest",
@@ -160,15 +170,21 @@ __all__ = [
160
170
  "build_validation_report",
161
171
  "cell",
162
172
  "compare_scalar_output",
173
+ "compare_scenarios_frame",
163
174
  "execute_generated_model",
164
175
  "evaluate_generated_model",
165
176
  "extract_workbook",
166
177
  "generate_python_module",
167
178
  "infer_generated_module_contract",
179
+ "inputs_frame",
168
180
  "load_validation_scenario",
169
181
  "normalize_cell_reference",
170
182
  "normalize_reference",
183
+ "outputs_frame",
171
184
  "report",
185
+ "report_frames",
186
+ "scenario_frame",
172
187
  "table",
188
+ "table_frame",
173
189
  "translate_formula_cell",
174
190
  ]
@@ -0,0 +1,246 @@
1
+ """Notebook-friendly DataFrame helpers for wrapped generated models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from numbers import Real
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from modelwright.references import normalize_cell_reference
10
+ from modelwright.wrappers import CellRef, ModelFacade, Scenario, WrapperDeclarationError
11
+
12
+ if TYPE_CHECKING:
13
+ import pandas as pd
14
+
15
+
16
+ class NotebookDependencyError(RuntimeError):
17
+ """Raised when notebook helpers need an optional dependency that is not installed."""
18
+
19
+
20
+ def inputs_frame(facade: ModelFacade, scenario: Scenario | None = None) -> "pd.DataFrame":
21
+ """Return declared facade inputs as a tidy pandas DataFrame."""
22
+
23
+ values = _values_for(facade, scenario)
24
+ return _cell_frame(facade.inputs().values(), values)
25
+
26
+
27
+ def outputs_frame(facade: ModelFacade, scenario: Scenario | None = None) -> "pd.DataFrame":
28
+ """Return declared facade outputs as a tidy pandas DataFrame."""
29
+
30
+ values = _values_for(facade, scenario)
31
+ return _cell_frame(facade.outputs().values(), values)
32
+
33
+
34
+ def scenario_frame(scenario: Scenario) -> "pd.DataFrame":
35
+ """Return scenario input overrides as a tidy pandas DataFrame."""
36
+
37
+ pd = _load_pandas()
38
+ return pd.DataFrame(
39
+ [
40
+ {
41
+ "scenario": scenario.name,
42
+ "cell_ref": cell_ref,
43
+ "value": value,
44
+ }
45
+ for cell_ref, value in scenario.inputs.items()
46
+ ],
47
+ columns=["scenario", "cell_ref", "value"],
48
+ )
49
+
50
+
51
+ def table_frame(facade: ModelFacade, name: str, scenario: Scenario | None = None) -> "pd.DataFrame":
52
+ """Return a declared facade table as a pandas DataFrame."""
53
+
54
+ pd = _load_pandas()
55
+ table = facade.table(name, scenario=scenario)
56
+ frame = pd.DataFrame(table.values, index=list(table.rows), columns=list(table.columns))
57
+ frame.index.name = "row"
58
+ frame.attrs.update(
59
+ {
60
+ "name": table.name,
61
+ "label": table.label,
62
+ "description": table.description,
63
+ "sheet": table.sheet,
64
+ "range_ref": table.range_ref,
65
+ "cell_refs": [list(row) for row in table.cell_refs],
66
+ }
67
+ )
68
+ return frame
69
+
70
+
71
+ def report_frames(facade: ModelFacade, name: str, scenario: Scenario | None = None) -> dict[str, Any]:
72
+ """Return DataFrame payloads for a declared facade report."""
73
+
74
+ report = facade.reports.get(name)
75
+ if report is None:
76
+ raise WrapperDeclarationError(f"unknown report declaration {name!r}")
77
+
78
+ values = _values_for(facade, scenario)
79
+ cells = [facade.cells[cell_name] for cell_name in report.cells]
80
+ return {
81
+ "name": report.name,
82
+ "label": report.label,
83
+ "description": report.description,
84
+ "cells": _cell_frame(cells, values),
85
+ "tables": {
86
+ table_name: table_frame(facade, table_name, scenario=scenario)
87
+ for table_name in report.tables
88
+ },
89
+ }
90
+
91
+
92
+ def compare_scenarios_frame(
93
+ facade: ModelFacade,
94
+ baseline: Scenario,
95
+ scenario: Scenario,
96
+ *,
97
+ cells: Iterable[str] | None = None,
98
+ ) -> "pd.DataFrame":
99
+ """Compare declared output cells between two scenarios as a tidy DataFrame."""
100
+
101
+ baseline_values = _values_for(facade, baseline)
102
+ scenario_values = _values_for(facade, scenario)
103
+ declarations = _comparison_cells(facade, cells)
104
+
105
+ rows = []
106
+ for declaration in declarations:
107
+ baseline_value = baseline_values.get(declaration.cell_ref)
108
+ scenario_value = scenario_values.get(declaration.cell_ref)
109
+ absolute_change = _absolute_change(baseline_value, scenario_value)
110
+ rows.append(
111
+ {
112
+ "name": declaration.name,
113
+ "label": declaration.label,
114
+ "cell_ref": declaration.cell_ref,
115
+ "baseline_value": baseline_value,
116
+ "scenario_value": scenario_value,
117
+ "absolute_change": absolute_change,
118
+ "percent_change": _percent_change(baseline_value, absolute_change),
119
+ "unit": declaration.unit,
120
+ "role": declaration.role,
121
+ "description": declaration.description,
122
+ }
123
+ )
124
+
125
+ pd = _load_pandas()
126
+ return pd.DataFrame(
127
+ rows,
128
+ columns=[
129
+ "name",
130
+ "label",
131
+ "cell_ref",
132
+ "baseline_value",
133
+ "scenario_value",
134
+ "absolute_change",
135
+ "percent_change",
136
+ "unit",
137
+ "role",
138
+ "description",
139
+ ],
140
+ dtype=object,
141
+ )
142
+
143
+
144
+ def _cell_frame(cells: Iterable[CellRef], values: Mapping[str, object]) -> "pd.DataFrame":
145
+ pd = _load_pandas()
146
+ return pd.DataFrame(
147
+ [
148
+ {
149
+ "name": declaration.name,
150
+ "label": declaration.label,
151
+ "cell_ref": declaration.cell_ref,
152
+ "role": declaration.role,
153
+ "unit": declaration.unit,
154
+ "description": declaration.description,
155
+ "value": values.get(declaration.cell_ref),
156
+ "has_value": declaration.cell_ref in values,
157
+ }
158
+ for declaration in cells
159
+ ],
160
+ columns=[
161
+ "name",
162
+ "label",
163
+ "cell_ref",
164
+ "role",
165
+ "unit",
166
+ "description",
167
+ "value",
168
+ "has_value",
169
+ ],
170
+ )
171
+
172
+
173
+ def _comparison_cells(facade: ModelFacade, cells: Iterable[str] | None) -> list[CellRef]:
174
+ declarations_by_name = facade.cells
175
+ if cells is None:
176
+ return list(facade.outputs().values())
177
+
178
+ declarations = []
179
+ for selector in cells:
180
+ declaration = declarations_by_name.get(selector)
181
+ if declaration is not None:
182
+ declarations.append(declaration)
183
+ continue
184
+
185
+ normalized = _normalize_cell_ref(selector)
186
+ for candidate in declarations_by_name.values():
187
+ if candidate.cell_ref == normalized:
188
+ declarations.append(candidate)
189
+ break
190
+ else:
191
+ declarations.append(CellRef(cell_ref=normalized, name=normalized))
192
+ return declarations
193
+
194
+
195
+ def _values_for(facade: ModelFacade, scenario: Scenario | None) -> dict[str, object]:
196
+ facade_values_for = getattr(facade, "_values_for", None)
197
+ if callable(facade_values_for):
198
+ return dict(facade_values_for(scenario))
199
+
200
+ active_scenario = scenario or facade.scenario()
201
+ values = facade.calculate(active_scenario)
202
+ return {**active_scenario.inputs, **values}
203
+
204
+
205
+ def _normalize_cell_ref(cell_ref: str) -> str:
206
+ normalized = normalize_cell_reference(cell_ref)
207
+ if normalized.kind != "cell" or normalized.sheet is None:
208
+ raise WrapperDeclarationError(f"expected a full cell reference like 'Sheet!A1', got {cell_ref!r}")
209
+ return normalized.normalized
210
+
211
+
212
+ def _absolute_change(baseline_value: object, scenario_value: object) -> float | int | None:
213
+ if _is_number(baseline_value) and _is_number(scenario_value):
214
+ return scenario_value - baseline_value
215
+ return None
216
+
217
+
218
+ def _percent_change(baseline_value: object, absolute_change: object) -> float | None:
219
+ if _is_number(baseline_value) and baseline_value != 0 and _is_number(absolute_change):
220
+ return absolute_change / baseline_value
221
+ return None
222
+
223
+
224
+ def _is_number(value: object) -> bool:
225
+ return isinstance(value, Real) and not isinstance(value, bool)
226
+
227
+
228
+ def _load_pandas() -> Any:
229
+ try:
230
+ import pandas as pd
231
+ except ImportError as error:
232
+ raise NotebookDependencyError(
233
+ "Install modelwright[notebook] to use pandas-backed notebook helpers."
234
+ ) from error
235
+ return pd
236
+
237
+
238
+ __all__ = [
239
+ "NotebookDependencyError",
240
+ "compare_scenarios_frame",
241
+ "inputs_frame",
242
+ "outputs_frame",
243
+ "report_frames",
244
+ "scenario_frame",
245
+ "table_frame",
246
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modelwright
3
- Version: 0.1.0a4
3
+ Version: 0.1.0a5
4
4
  Summary: Tools for converting spreadsheet workbooks into transparent Python models.
5
5
  Author: UBC FRESH Lab
6
6
  License-Expression: MIT
@@ -32,11 +32,14 @@ Requires-Dist: sphinx-rtd-theme>=2; extra == "docs"
32
32
  Provides-Extra: dev
33
33
  Requires-Dist: build>=1.2; extra == "dev"
34
34
  Requires-Dist: formulas; extra == "dev"
35
+ Requires-Dist: pandas>=2; extra == "dev"
35
36
  Requires-Dist: pytest>=8; extra == "dev"
36
37
  Requires-Dist: ruff>=0.8; extra == "dev"
37
38
  Requires-Dist: sphinx>=7; extra == "dev"
38
39
  Requires-Dist: sphinx-rtd-theme>=2; extra == "dev"
39
40
  Requires-Dist: twine>=5; extra == "dev"
41
+ Provides-Extra: notebook
42
+ Requires-Dist: pandas>=2; extra == "notebook"
40
43
  Provides-Extra: oracle
41
44
  Requires-Dist: formulas; extra == "oracle"
42
45
  Provides-Extra: quality
@@ -46,6 +49,7 @@ Requires-Dist: build>=1.2; extra == "release"
46
49
  Requires-Dist: twine>=5; extra == "release"
47
50
  Provides-Extra: test
48
51
  Requires-Dist: formulas; extra == "test"
52
+ Requires-Dist: pandas>=2; extra == "test"
49
53
  Requires-Dist: pytest>=8; extra == "test"
50
54
  Dynamic: license-file
51
55
 
@@ -135,7 +139,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths:
135
139
  scripts/bootstrap_dev_env.sh --benchmarks
136
140
  ```
137
141
 
138
- `modelwright` is pre-release. The current alpha line is `0.1.0a4`; alpha releases must not be described as full-workbook conversion guarantees.
142
+ `modelwright` is pre-release. The current alpha line is `0.1.0a5`; alpha releases must not be described as full-workbook conversion guarantees.
139
143
 
140
144
  Check release artifacts locally:
141
145
 
@@ -1,6 +1,12 @@
1
1
  LICENSE
2
+ MANIFEST.in
2
3
  README.md
3
4
  pyproject.toml
5
+ examples/README.md
6
+ examples/fable_2020/README.md
7
+ examples/fable_2020/generated_fable_2020_model.py.xz
8
+ examples/fable_2020/notebook_interface.py
9
+ examples/synthetic/notebook_interface.py
4
10
  src/modelwright/__init__.py
5
11
  src/modelwright/cli.py
6
12
  src/modelwright/conversion.py
@@ -11,6 +17,7 @@ src/modelwright/formulas.py
11
17
  src/modelwright/formulas_oracle.py
12
18
  src/modelwright/generation.py
13
19
  src/modelwright/graph.py
20
+ src/modelwright/notebooks.py
14
21
  src/modelwright/oracle_validation.py
15
22
  src/modelwright/oracles.py
16
23
  src/modelwright/references.py
@@ -26,6 +33,7 @@ tests/test_cli.py
26
33
  tests/test_conversion_plan.py
27
34
  tests/test_dependency_graph.py
28
35
  tests/test_evaluation_orchestration.py
36
+ tests/test_examples.py
29
37
  tests/test_extraction_records.py
30
38
  tests/test_fable_wrapper_benchmark.py
31
39
  tests/test_formula_expressions.py
@@ -35,6 +43,7 @@ tests/test_generated_execution.py
35
43
  tests/test_generation_contract.py
36
44
  tests/test_import.py
37
45
  tests/test_materialize_fable_benchmarks.py
46
+ tests/test_notebooks.py
38
47
  tests/test_openpyxl_extraction.py
39
48
  tests/test_oracle_backed_validation.py
40
49
  tests/test_oracle_interface.py
@@ -5,6 +5,7 @@ typer>=0.9
5
5
  [dev]
6
6
  build>=1.2
7
7
  formulas
8
+ pandas>=2
8
9
  pytest>=8
9
10
  ruff>=0.8
10
11
  sphinx>=7
@@ -15,6 +16,9 @@ twine>=5
15
16
  sphinx>=7
16
17
  sphinx-rtd-theme>=2
17
18
 
19
+ [notebook]
20
+ pandas>=2
21
+
18
22
  [oracle]
19
23
  formulas
20
24
 
@@ -27,4 +31,5 @@ twine>=5
27
31
 
28
32
  [test]
29
33
  formulas
34
+ pandas>=2
30
35
  pytest>=8
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import lzma
5
+ from pathlib import Path
6
+ from types import ModuleType
7
+
8
+ import pytest
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+
13
+
14
+ def load_example(path: Path, module_name: str) -> ModuleType:
15
+ spec = importlib.util.spec_from_file_location(module_name, path)
16
+ assert spec is not None
17
+ assert spec.loader is not None
18
+ module = importlib.util.module_from_spec(spec)
19
+ spec.loader.exec_module(module)
20
+ return module
21
+
22
+
23
+ def test_synthetic_notebook_example_runs() -> None:
24
+ module = load_example(ROOT / "examples/synthetic/notebook_interface.py", "synthetic_notebook_example")
25
+
26
+ frames = module.run_example()
27
+
28
+ assert frames["outputs"].set_index("name").loc["projected", "value"] == 132.0
29
+ assert frames["summary_grid"].loc["volume", "primary"] == 132.0
30
+ assert frames["comparison"].set_index("name").loc["projected", "absolute_change"] == pytest.approx(22.0)
31
+
32
+
33
+ def test_fable_generated_model_archive_is_tracked_and_readable() -> None:
34
+ archive_path = ROOT / "examples/fable_2020/generated_fable_2020_model.py.xz"
35
+
36
+ assert archive_path.exists()
37
+ assert archive_path.stat().st_size < 10_000_000
38
+ with lzma.open(archive_path, "rb") as archive:
39
+ prefix = archive.read(128)
40
+
41
+ assert b"Generated Modelwright model" in prefix
@@ -9,6 +9,7 @@ from types import ModuleType
9
9
 
10
10
  import pytest
11
11
 
12
+ from modelwright.notebooks import outputs_frame, report_frames, table_frame
12
13
  from modelwright.wrappers import ModelFacade, cell, report, table
13
14
 
14
15
 
@@ -87,3 +88,24 @@ def test_model_facade_wraps_2020_fable_benchmark_model_outputs() -> None:
87
88
  assert report_payload["tables"]["scenario_selection_slice"]["values"][2][0] == pytest.approx(
88
89
  FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D22"]
89
90
  )
91
+
92
+ output_rows = outputs_frame(facade).set_index("cell_ref")
93
+ assert output_rows.loc["SCENARIOS selection!D20", "value"] == pytest.approx(
94
+ FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D20"]
95
+ )
96
+
97
+ table_rows = table_frame(facade, "scenario_selection_slice")
98
+ assert table_rows.loc["d21", "value"] == pytest.approx(FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D21"])
99
+ assert table_rows.attrs["cell_refs"] == [
100
+ ["SCENARIOS selection!D20"],
101
+ ["SCENARIOS selection!D21"],
102
+ ["SCENARIOS selection!D22"],
103
+ ]
104
+
105
+ frames = report_frames(facade, "scenario_selection")
106
+ assert frames["cells"].set_index("name").loc["scenario_metric_3", "value"] == pytest.approx(
107
+ FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D22"]
108
+ )
109
+ assert frames["tables"]["scenario_selection_slice"].loc["d20", "value"] == pytest.approx(
110
+ FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D20"]
111
+ )
@@ -2,4 +2,4 @@ import modelwright
2
2
 
3
3
 
4
4
  def test_package_imports() -> None:
5
- assert modelwright.__version__ == "0.1.0a4"
5
+ assert modelwright.__version__ == "0.1.0a5"
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+ import importlib
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from modelwright.notebooks import (
10
+ NotebookDependencyError,
11
+ compare_scenarios_frame,
12
+ inputs_frame,
13
+ outputs_frame,
14
+ report_frames,
15
+ scenario_frame,
16
+ table_frame,
17
+ )
18
+ from modelwright.wrappers import ModelFacade, cell, report, table
19
+ from tests.test_wrappers import build_generated_synthetic_model
20
+
21
+
22
+ def generated_model(inputs=None):
23
+ inputs = inputs or {}
24
+ base = inputs.get("Inputs!B2", 100)
25
+ growth = inputs.get("Inputs!B3", 0.1)
26
+ return {
27
+ "Summary!B2": base * (1 + growth),
28
+ "Summary!C2": base * 2,
29
+ "Summary!B3": "ok",
30
+ "Summary!C3": base + 5,
31
+ }
32
+
33
+
34
+ def notebook_facade(generated=generated_model) -> ModelFacade:
35
+ return ModelFacade(
36
+ generated,
37
+ cells=[
38
+ cell("Inputs!B2", name="base", label="Base volume", role="input", unit="t"),
39
+ cell("Inputs!B3", name="growth", label="Growth rate", role="input", unit="fraction"),
40
+ cell("Summary!B2", name="projected", label="Projected volume", role="output", unit="t"),
41
+ cell("Summary!B3", name="status", label="Status", role="output"),
42
+ ],
43
+ tables=[
44
+ table(
45
+ "summary_grid",
46
+ sheet="Summary",
47
+ range_ref="B2:C3",
48
+ row_labels=["volume", "status"],
49
+ column_labels=["primary", "secondary"],
50
+ )
51
+ ],
52
+ reports=[report("summary", cells=["base", "projected", "status"], tables=["summary_grid"])],
53
+ )
54
+
55
+
56
+ def test_notebook_frames_expose_declared_inputs_outputs_tables_and_reports() -> None:
57
+ facade = notebook_facade()
58
+ scenario = facade.scenario(name="shock", inputs={"Inputs!B2": 50}).with_input("Inputs!B3", 0.2)
59
+
60
+ input_rows = inputs_frame(facade, scenario)
61
+ assert input_rows.to_dict("records") == [
62
+ {
63
+ "name": "base",
64
+ "label": "Base volume",
65
+ "cell_ref": "Inputs!B2",
66
+ "role": "input",
67
+ "unit": "t",
68
+ "description": None,
69
+ "value": 50,
70
+ "has_value": True,
71
+ },
72
+ {
73
+ "name": "growth",
74
+ "label": "Growth rate",
75
+ "cell_ref": "Inputs!B3",
76
+ "role": "input",
77
+ "unit": "fraction",
78
+ "description": None,
79
+ "value": 0.2,
80
+ "has_value": True,
81
+ },
82
+ ]
83
+
84
+ output_rows = outputs_frame(facade, scenario)
85
+ assert output_rows[["name", "cell_ref", "value", "has_value"]].to_dict("records") == [
86
+ {"name": "projected", "cell_ref": "Summary!B2", "value": 60.0, "has_value": True},
87
+ {"name": "status", "cell_ref": "Summary!B3", "value": "ok", "has_value": True},
88
+ ]
89
+
90
+ scenario_rows = scenario_frame(scenario)
91
+ assert scenario_rows.to_dict("records") == [
92
+ {"scenario": "shock", "cell_ref": "Inputs!B2", "value": 50},
93
+ {"scenario": "shock", "cell_ref": "Inputs!B3", "value": 0.2},
94
+ ]
95
+
96
+ summary_grid = table_frame(facade, "summary_grid", scenario)
97
+ assert list(summary_grid.index) == ["volume", "status"]
98
+ assert list(summary_grid.columns) == ["primary", "secondary"]
99
+ assert summary_grid.loc["volume", "primary"] == 60.0
100
+ assert summary_grid.loc["status", "secondary"] == 55
101
+ assert summary_grid.attrs["sheet"] == "Summary"
102
+ assert summary_grid.attrs["range_ref"] == "B2:C3"
103
+ assert summary_grid.attrs["cell_refs"] == [["Summary!B2", "Summary!C2"], ["Summary!B3", "Summary!C3"]]
104
+
105
+ frames = report_frames(facade, "summary", scenario)
106
+ assert frames["name"] == "summary"
107
+ assert frames["cells"][["name", "value"]].to_dict("records") == [
108
+ {"name": "base", "value": 50},
109
+ {"name": "projected", "value": 60.0},
110
+ {"name": "status", "value": "ok"},
111
+ ]
112
+ assert frames["tables"]["summary_grid"].loc["volume", "secondary"] == 100
113
+
114
+
115
+ def test_compare_scenarios_frame_handles_numeric_text_and_zero_baseline() -> None:
116
+ def comparison_model(inputs=None):
117
+ inputs = inputs or {}
118
+ base = inputs.get("Inputs!B2", 100)
119
+ multiplier = inputs.get("Inputs!B3", 1)
120
+ return {
121
+ "Summary!B2": base * multiplier,
122
+ "Summary!B3": "ok" if multiplier == 1 else "changed",
123
+ "Summary!B4": base - 100,
124
+ }
125
+
126
+ facade = ModelFacade(
127
+ comparison_model,
128
+ cells=[
129
+ cell("Inputs!B2", name="base", role="input"),
130
+ cell("Inputs!B3", name="multiplier", role="input"),
131
+ cell("Summary!B2", name="projected", label="Projected volume", role="output", unit="t"),
132
+ cell("Summary!B3", name="status", label="Status", role="output"),
133
+ cell("Summary!B4", name="delta", label="Delta", role="output"),
134
+ ],
135
+ )
136
+
137
+ baseline = facade.scenario(name="baseline", inputs={"Inputs!B2": 100, "Inputs!B3": 1})
138
+ shock = baseline.with_input("Inputs!B3", 1.2)
139
+ comparison = compare_scenarios_frame(facade, baseline, shock)
140
+
141
+ projected = comparison.set_index("name").loc["projected"]
142
+ assert projected["baseline_value"] == 100
143
+ assert projected["scenario_value"] == 120
144
+ assert projected["absolute_change"] == 20
145
+ assert projected["percent_change"] == pytest.approx(0.2)
146
+
147
+ status = comparison.set_index("name").loc["status"]
148
+ assert status["baseline_value"] == "ok"
149
+ assert status["scenario_value"] == "changed"
150
+ assert status["absolute_change"] is None
151
+ assert status["percent_change"] is None
152
+
153
+ delta = comparison.set_index("name").loc["delta"]
154
+ assert delta["baseline_value"] == 0
155
+ assert delta["scenario_value"] == 0
156
+ assert delta["absolute_change"] == 0
157
+ assert delta["percent_change"] is None
158
+
159
+
160
+ def test_notebook_helpers_preserve_real_generated_synthetic_model_semantics(tmp_path: Path) -> None:
161
+ module = build_generated_synthetic_model(tmp_path)
162
+ facade = ModelFacade(
163
+ module,
164
+ cells=[
165
+ cell("Inputs!B2", name="base", label="Base volume", role="input"),
166
+ cell("Summary!B2", name="harvest", label="Rounded harvest", role="output"),
167
+ cell("Summary!B3", name="status", label="Status", role="output"),
168
+ ],
169
+ tables=[
170
+ table(
171
+ "summary",
172
+ sheet="Summary",
173
+ range_ref="B2:B3",
174
+ row_labels=["harvest", "status"],
175
+ column_labels=["value"],
176
+ )
177
+ ],
178
+ )
179
+ scenario = facade.scenario(name="low-volume", inputs={"Inputs!B2": 10})
180
+
181
+ assert facade.calculate(scenario) == module.calculate(scenario.inputs)
182
+ assert outputs_frame(facade, scenario).set_index("name").loc["harvest", "value"] == 7.02
183
+ assert table_frame(facade, "summary", scenario).loc["status", "value"] == "low"
184
+
185
+
186
+ def test_modelwright_import_does_not_require_pandas(monkeypatch: pytest.MonkeyPatch) -> None:
187
+ real_import = builtins.__import__
188
+
189
+ def guarded_import(name, globals=None, locals=None, fromlist=(), level=0):
190
+ if name == "pandas":
191
+ raise ModuleNotFoundError("No module named 'pandas'")
192
+ return real_import(name, globals, locals, fromlist, level)
193
+
194
+ monkeypatch.setattr(builtins, "__import__", guarded_import)
195
+ module = importlib.reload(importlib.import_module("modelwright"))
196
+
197
+ assert "inputs_frame" in module.__all__
198
+
199
+
200
+ def test_notebook_helpers_report_missing_pandas(monkeypatch: pytest.MonkeyPatch) -> None:
201
+ real_import = builtins.__import__
202
+
203
+ def guarded_import(name, globals=None, locals=None, fromlist=(), level=0):
204
+ if name == "pandas":
205
+ raise ModuleNotFoundError("No module named 'pandas'")
206
+ return real_import(name, globals, locals, fromlist, level)
207
+
208
+ monkeypatch.setattr(builtins, "__import__", guarded_import)
209
+
210
+ with pytest.raises(NotebookDependencyError, match=r"modelwright\[notebook\]"):
211
+ scenario_frame(notebook_facade().scenario())
@@ -21,6 +21,10 @@ def test_root_facade_exports_primary_entrypoints() -> None:
21
21
  assert "cell" in modelwright.__all__
22
22
  assert "table" in modelwright.__all__
23
23
  assert "report" in modelwright.__all__
24
+ assert "inputs_frame" in modelwright.__all__
25
+ assert "outputs_frame" in modelwright.__all__
26
+ assert "table_frame" in modelwright.__all__
27
+ assert "compare_scenarios_frame" in modelwright.__all__
24
28
 
25
29
 
26
30
  def test_root_facade_does_not_export_internal_helpers() -> None:
File without changes
File without changes