modelwright 0.1.0a3__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.0a3/src/modelwright.egg-info → modelwright-0.1.0a5}/PKG-INFO +6 -2
  3. {modelwright-0.1.0a3 → 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.0a3 → modelwright-0.1.0a5}/pyproject.toml +9 -1
  10. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/__init__.py +43 -1
  11. modelwright-0.1.0a5/src/modelwright/notebooks.py +246 -0
  12. modelwright-0.1.0a5/src/modelwright/wrappers.py +426 -0
  13. {modelwright-0.1.0a3 → modelwright-0.1.0a5/src/modelwright.egg-info}/PKG-INFO +6 -2
  14. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright.egg-info/SOURCES.txt +13 -1
  15. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright.egg-info/requires.txt +5 -0
  16. modelwright-0.1.0a5/tests/test_examples.py +41 -0
  17. modelwright-0.1.0a5/tests/test_fable_wrapper_benchmark.py +111 -0
  18. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_import.py +1 -1
  19. modelwright-0.1.0a5/tests/test_notebooks.py +211 -0
  20. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_public_api.py +9 -0
  21. modelwright-0.1.0a5/tests/test_wrappers.py +212 -0
  22. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/LICENSE +0 -0
  23. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/setup.cfg +0 -0
  24. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/cli.py +0 -0
  25. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/conversion.py +0 -0
  26. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/evaluation.py +0 -0
  27. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/execution.py +0 -0
  28. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/extraction.py +0 -0
  29. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/formulas.py +0 -0
  30. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/formulas_oracle.py +0 -0
  31. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/generation.py +0 -0
  32. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/graph.py +0 -0
  33. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/oracle_validation.py +0 -0
  34. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/oracles.py +0 -0
  35. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/references.py +0 -0
  36. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright/validation.py +0 -0
  37. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright.egg-info/dependency_links.txt +0 -0
  38. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright.egg-info/entry_points.txt +0 -0
  39. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/src/modelwright.egg-info/top_level.txt +0 -0
  40. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_cli.py +0 -0
  41. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_conversion_plan.py +0 -0
  42. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_dependency_graph.py +0 -0
  43. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_evaluation_orchestration.py +0 -0
  44. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_extraction_records.py +0 -0
  45. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_formula_expressions.py +0 -0
  46. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_formula_translation.py +0 -0
  47. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_formulas_oracle.py +0 -0
  48. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_generated_execution.py +0 -0
  49. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_generation_contract.py +0 -0
  50. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_materialize_fable_benchmarks.py +0 -0
  51. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_openpyxl_extraction.py +0 -0
  52. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_oracle_backed_validation.py +0 -0
  53. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_oracle_interface.py +0 -0
  54. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_python_generation.py +0 -0
  55. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_references.py +0 -0
  56. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_scalar_comparison.py +0 -0
  57. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_supported_semantics_fixture.py +0 -0
  58. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_synthetic_fixture.py +0 -0
  59. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_validation.py +0 -0
  60. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_validation_regression.py +0 -0
  61. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_validation_report_builder.py +0 -0
  62. {modelwright-0.1.0a3 → modelwright-0.1.0a5}/tests/test_validation_scenario.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.0a3
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.0a3`; 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.0a3`; 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.0a3"
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
 
@@ -82,6 +87,9 @@ where = ["src"]
82
87
 
83
88
  [tool.pytest.ini_options]
84
89
  testpaths = ["tests"]
90
+ markers = [
91
+ "benchmark: tests that require local benchmark artifacts and are skipped unless explicitly enabled"
92
+ ]
85
93
 
86
94
  [tool.ruff]
87
95
  line-length = 120
@@ -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,
@@ -80,11 +89,27 @@ from modelwright.validation import (
80
89
  compare_scalar_output,
81
90
  load_validation_scenario,
82
91
  )
92
+ from modelwright.wrappers import (
93
+ CellRef,
94
+ CellView,
95
+ ModelFacade,
96
+ ReportRef,
97
+ Scenario,
98
+ TableRef,
99
+ TableView,
100
+ WrapperDeclarationError,
101
+ WrapperExecutionError,
102
+ cell,
103
+ report,
104
+ table,
105
+ )
83
106
 
84
- __version__ = "0.1.0a3"
107
+ __version__ = "0.1.0a5"
85
108
 
86
109
  __all__ = [
87
110
  "CellRecord",
111
+ "CellRef",
112
+ "CellView",
88
113
  "ComparisonResult",
89
114
  "ComparisonRules",
90
115
  "ConversionPlan",
@@ -109,7 +134,9 @@ __all__ = [
109
134
  "GenerationDiagnostic",
110
135
  "GenerationResult",
111
136
  "MISSING_VALUE",
137
+ "ModelFacade",
112
138
  "NamedRangeRecord",
139
+ "NotebookDependencyError",
113
140
  "OracleConfig",
114
141
  "OracleDiagnostic",
115
142
  "OracleRequest",
@@ -117,10 +144,14 @@ __all__ = [
117
144
  "PlanRecommendation",
118
145
  "PrivacyReview",
119
146
  "ResidualBlocker",
147
+ "ReportRef",
148
+ "Scenario",
120
149
  "ScenarioInput",
121
150
  "ScenarioOutput",
122
151
  "SheetRecord",
152
+ "TableRef",
123
153
  "TableRecord",
154
+ "TableView",
124
155
  "ValidationReport",
125
156
  "ValidationEvaluationResult",
126
157
  "ValidationScenario",
@@ -129,20 +160,31 @@ __all__ = [
129
160
  "WorkbookReference",
130
161
  "WorkbookRecord",
131
162
  "WorkflowStatus",
163
+ "WrapperDeclarationError",
164
+ "WrapperExecutionError",
132
165
  "__version__",
133
166
  "build_dependency_graph",
134
167
  "build_conversion_plan",
135
168
  "build_formula_reference_index",
136
169
  "build_oracle_validation_report",
137
170
  "build_validation_report",
171
+ "cell",
138
172
  "compare_scalar_output",
173
+ "compare_scenarios_frame",
139
174
  "execute_generated_model",
140
175
  "evaluate_generated_model",
141
176
  "extract_workbook",
142
177
  "generate_python_module",
143
178
  "infer_generated_module_contract",
179
+ "inputs_frame",
144
180
  "load_validation_scenario",
145
181
  "normalize_cell_reference",
146
182
  "normalize_reference",
183
+ "outputs_frame",
184
+ "report",
185
+ "report_frames",
186
+ "scenario_frame",
187
+ "table",
188
+ "table_frame",
147
189
  "translate_formula_cell",
148
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
+ ]