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.
- modelwright-0.1.0a5/MANIFEST.in +1 -0
- {modelwright-0.1.0a4/src/modelwright.egg-info → modelwright-0.1.0a5}/PKG-INFO +6 -2
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/README.md +1 -1
- modelwright-0.1.0a5/examples/README.md +12 -0
- modelwright-0.1.0a5/examples/fable_2020/README.md +21 -0
- modelwright-0.1.0a5/examples/fable_2020/generated_fable_2020_model.py.xz +0 -0
- modelwright-0.1.0a5/examples/fable_2020/notebook_interface.py +95 -0
- modelwright-0.1.0a5/examples/synthetic/notebook_interface.py +62 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/pyproject.toml +6 -1
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/__init__.py +17 -1
- modelwright-0.1.0a5/src/modelwright/notebooks.py +246 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5/src/modelwright.egg-info}/PKG-INFO +6 -2
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright.egg-info/SOURCES.txt +9 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright.egg-info/requires.txt +5 -0
- modelwright-0.1.0a5/tests/test_examples.py +41 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_fable_wrapper_benchmark.py +22 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_import.py +1 -1
- modelwright-0.1.0a5/tests/test_notebooks.py +211 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_public_api.py +4 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/LICENSE +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/setup.cfg +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/cli.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/conversion.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/evaluation.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/execution.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/extraction.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/formulas.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/formulas_oracle.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/generation.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/graph.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/oracle_validation.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/oracles.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/references.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/validation.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright/wrappers.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright.egg-info/dependency_links.txt +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright.egg-info/entry_points.txt +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/src/modelwright.egg-info/top_level.txt +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_cli.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_conversion_plan.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_dependency_graph.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_evaluation_orchestration.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_extraction_records.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_formula_expressions.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_formula_translation.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_formulas_oracle.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_generated_execution.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_generation_contract.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_materialize_fable_benchmarks.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_openpyxl_extraction.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_oracle_backed_validation.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_oracle_interface.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_python_generation.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_references.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_scalar_comparison.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_supported_semantics_fixture.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_synthetic_fixture.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_validation.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_validation_regression.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_validation_report_builder.py +0 -0
- {modelwright-0.1.0a4 → modelwright-0.1.0a5}/tests/test_validation_scenario.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
+
```
|
|
Binary file
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|