modelwright 0.1.0a3__tar.gz → 0.1.0a4__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.0a3/src/modelwright.egg-info → modelwright-0.1.0a4}/PKG-INFO +2 -2
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/README.md +1 -1
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/pyproject.toml +4 -1
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/__init__.py +27 -1
- modelwright-0.1.0a4/src/modelwright/wrappers.py +426 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4/src/modelwright.egg-info}/PKG-INFO +2 -2
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright.egg-info/SOURCES.txt +4 -1
- modelwright-0.1.0a4/tests/test_fable_wrapper_benchmark.py +89 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_import.py +1 -1
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_public_api.py +5 -0
- modelwright-0.1.0a4/tests/test_wrappers.py +212 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/LICENSE +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/setup.cfg +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/cli.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/conversion.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/evaluation.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/execution.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/extraction.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/formulas.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/formulas_oracle.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/generation.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/graph.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/oracle_validation.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/oracles.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/references.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright/validation.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright.egg-info/dependency_links.txt +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright.egg-info/entry_points.txt +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright.egg-info/requires.txt +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/src/modelwright.egg-info/top_level.txt +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_cli.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_conversion_plan.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_dependency_graph.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_evaluation_orchestration.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_extraction_records.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_formula_expressions.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_formula_translation.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_formulas_oracle.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_generated_execution.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_generation_contract.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_materialize_fable_benchmarks.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_openpyxl_extraction.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_oracle_backed_validation.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_oracle_interface.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_python_generation.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_references.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_scalar_comparison.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_supported_semantics_fixture.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_synthetic_fixture.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_validation.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_validation_regression.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_validation_report_builder.py +0 -0
- {modelwright-0.1.0a3 → modelwright-0.1.0a4}/tests/test_validation_scenario.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: modelwright
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0a4
|
|
4
4
|
Summary: Tools for converting spreadsheet workbooks into transparent Python models.
|
|
5
5
|
Author: UBC FRESH Lab
|
|
6
6
|
License-Expression: MIT
|
|
@@ -135,7 +135,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths:
|
|
|
135
135
|
scripts/bootstrap_dev_env.sh --benchmarks
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
`modelwright` is pre-release. The current alpha line is `0.1.
|
|
138
|
+
`modelwright` is pre-release. The current alpha line is `0.1.0a4`; alpha releases must not be described as full-workbook conversion guarantees.
|
|
139
139
|
|
|
140
140
|
Check release artifacts locally:
|
|
141
141
|
|
|
@@ -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.0a4`; alpha releases must not be described as full-workbook conversion guarantees.
|
|
88
88
|
|
|
89
89
|
Check release artifacts locally:
|
|
90
90
|
|
|
@@ -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.0a4"
|
|
8
8
|
description = "Tools for converting spreadsheet workbooks into transparent Python models."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -82,6 +82,9 @@ where = ["src"]
|
|
|
82
82
|
|
|
83
83
|
[tool.pytest.ini_options]
|
|
84
84
|
testpaths = ["tests"]
|
|
85
|
+
markers = [
|
|
86
|
+
"benchmark: tests that require local benchmark artifacts and are skipped unless explicitly enabled"
|
|
87
|
+
]
|
|
85
88
|
|
|
86
89
|
[tool.ruff]
|
|
87
90
|
line-length = 120
|
|
@@ -80,11 +80,27 @@ from modelwright.validation import (
|
|
|
80
80
|
compare_scalar_output,
|
|
81
81
|
load_validation_scenario,
|
|
82
82
|
)
|
|
83
|
+
from modelwright.wrappers import (
|
|
84
|
+
CellRef,
|
|
85
|
+
CellView,
|
|
86
|
+
ModelFacade,
|
|
87
|
+
ReportRef,
|
|
88
|
+
Scenario,
|
|
89
|
+
TableRef,
|
|
90
|
+
TableView,
|
|
91
|
+
WrapperDeclarationError,
|
|
92
|
+
WrapperExecutionError,
|
|
93
|
+
cell,
|
|
94
|
+
report,
|
|
95
|
+
table,
|
|
96
|
+
)
|
|
83
97
|
|
|
84
|
-
__version__ = "0.1.
|
|
98
|
+
__version__ = "0.1.0a4"
|
|
85
99
|
|
|
86
100
|
__all__ = [
|
|
87
101
|
"CellRecord",
|
|
102
|
+
"CellRef",
|
|
103
|
+
"CellView",
|
|
88
104
|
"ComparisonResult",
|
|
89
105
|
"ComparisonRules",
|
|
90
106
|
"ConversionPlan",
|
|
@@ -109,6 +125,7 @@ __all__ = [
|
|
|
109
125
|
"GenerationDiagnostic",
|
|
110
126
|
"GenerationResult",
|
|
111
127
|
"MISSING_VALUE",
|
|
128
|
+
"ModelFacade",
|
|
112
129
|
"NamedRangeRecord",
|
|
113
130
|
"OracleConfig",
|
|
114
131
|
"OracleDiagnostic",
|
|
@@ -117,10 +134,14 @@ __all__ = [
|
|
|
117
134
|
"PlanRecommendation",
|
|
118
135
|
"PrivacyReview",
|
|
119
136
|
"ResidualBlocker",
|
|
137
|
+
"ReportRef",
|
|
138
|
+
"Scenario",
|
|
120
139
|
"ScenarioInput",
|
|
121
140
|
"ScenarioOutput",
|
|
122
141
|
"SheetRecord",
|
|
142
|
+
"TableRef",
|
|
123
143
|
"TableRecord",
|
|
144
|
+
"TableView",
|
|
124
145
|
"ValidationReport",
|
|
125
146
|
"ValidationEvaluationResult",
|
|
126
147
|
"ValidationScenario",
|
|
@@ -129,12 +150,15 @@ __all__ = [
|
|
|
129
150
|
"WorkbookReference",
|
|
130
151
|
"WorkbookRecord",
|
|
131
152
|
"WorkflowStatus",
|
|
153
|
+
"WrapperDeclarationError",
|
|
154
|
+
"WrapperExecutionError",
|
|
132
155
|
"__version__",
|
|
133
156
|
"build_dependency_graph",
|
|
134
157
|
"build_conversion_plan",
|
|
135
158
|
"build_formula_reference_index",
|
|
136
159
|
"build_oracle_validation_report",
|
|
137
160
|
"build_validation_report",
|
|
161
|
+
"cell",
|
|
138
162
|
"compare_scalar_output",
|
|
139
163
|
"execute_generated_model",
|
|
140
164
|
"evaluate_generated_model",
|
|
@@ -144,5 +168,7 @@ __all__ = [
|
|
|
144
168
|
"load_validation_scenario",
|
|
145
169
|
"normalize_cell_reference",
|
|
146
170
|
"normalize_reference",
|
|
171
|
+
"report",
|
|
172
|
+
"table",
|
|
147
173
|
"translate_formula_cell",
|
|
148
174
|
]
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Lightweight facades for generated Modelwright models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from openpyxl.utils import get_column_letter
|
|
11
|
+
from openpyxl.utils.cell import column_index_from_string, range_boundaries
|
|
12
|
+
|
|
13
|
+
from modelwright.references import normalize_cell_reference, normalize_reference
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
CellRole = Literal["input", "output", "intermediate", "metadata"]
|
|
17
|
+
GeneratedCalculate = Callable[[dict[str, object] | None], dict[str, object]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WrapperDeclarationError(ValueError):
|
|
21
|
+
"""Raised when wrapper declarations are malformed or inconsistent."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WrapperExecutionError(RuntimeError):
|
|
25
|
+
"""Raised when a generated model cannot be executed through a facade."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CellRef:
|
|
30
|
+
"""Declared facade metadata for one generated-model cell reference."""
|
|
31
|
+
|
|
32
|
+
cell_ref: str
|
|
33
|
+
name: str
|
|
34
|
+
label: str | None = None
|
|
35
|
+
role: CellRole = "metadata"
|
|
36
|
+
unit: str | None = None
|
|
37
|
+
description: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class TableRef:
|
|
42
|
+
"""Declared rectangular workbook-like table facade."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
sheet: str
|
|
46
|
+
range_ref: str
|
|
47
|
+
cell_refs: tuple[tuple[str, ...], ...]
|
|
48
|
+
row_labels: tuple[str, ...]
|
|
49
|
+
column_labels: tuple[str, ...]
|
|
50
|
+
role: CellRole = "output"
|
|
51
|
+
label: str | None = None
|
|
52
|
+
description: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ReportRef:
|
|
57
|
+
"""Declared named output bundle."""
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
cells: tuple[str, ...] = ()
|
|
61
|
+
tables: tuple[str, ...] = ()
|
|
62
|
+
label: str | None = None
|
|
63
|
+
description: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class CellView:
|
|
68
|
+
"""Inspection payload for one cell in a facade scenario."""
|
|
69
|
+
|
|
70
|
+
cell_ref: str
|
|
71
|
+
name: str | None = None
|
|
72
|
+
label: str | None = None
|
|
73
|
+
role: CellRole | None = None
|
|
74
|
+
unit: str | None = None
|
|
75
|
+
description: str | None = None
|
|
76
|
+
value: object = None
|
|
77
|
+
has_value: bool = False
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, object]:
|
|
80
|
+
return {
|
|
81
|
+
"cell_ref": self.cell_ref,
|
|
82
|
+
"name": self.name,
|
|
83
|
+
"label": self.label,
|
|
84
|
+
"role": self.role,
|
|
85
|
+
"unit": self.unit,
|
|
86
|
+
"description": self.description,
|
|
87
|
+
"value": self.value,
|
|
88
|
+
"has_value": self.has_value,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class TableView:
|
|
94
|
+
"""Calculated rectangular table payload."""
|
|
95
|
+
|
|
96
|
+
name: str
|
|
97
|
+
sheet: str
|
|
98
|
+
range_ref: str
|
|
99
|
+
rows: tuple[str, ...]
|
|
100
|
+
columns: tuple[str, ...]
|
|
101
|
+
cell_refs: tuple[tuple[str, ...], ...]
|
|
102
|
+
values: tuple[tuple[object, ...], ...]
|
|
103
|
+
label: str | None = None
|
|
104
|
+
description: str | None = None
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> dict[str, object]:
|
|
107
|
+
return {
|
|
108
|
+
"name": self.name,
|
|
109
|
+
"sheet": self.sheet,
|
|
110
|
+
"range_ref": self.range_ref,
|
|
111
|
+
"rows": list(self.rows),
|
|
112
|
+
"columns": list(self.columns),
|
|
113
|
+
"cell_refs": [list(row) for row in self.cell_refs],
|
|
114
|
+
"values": [list(row) for row in self.values],
|
|
115
|
+
"label": self.label,
|
|
116
|
+
"description": self.description,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Scenario:
|
|
122
|
+
"""Immutable input override set for a facade calculation."""
|
|
123
|
+
|
|
124
|
+
name: str = "default"
|
|
125
|
+
_inputs: tuple[tuple[str, object], ...] = field(default_factory=tuple)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_inputs(cls, name: str = "default", inputs: Mapping[str, object] | None = None) -> "Scenario":
|
|
129
|
+
if inputs is None:
|
|
130
|
+
return cls(name=name)
|
|
131
|
+
normalized = {normalize_full_cell_ref(cell_ref): value for cell_ref, value in inputs.items()}
|
|
132
|
+
return cls(name=name, _inputs=tuple(normalized.items()))
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def inputs(self) -> dict[str, object]:
|
|
136
|
+
return dict(self._inputs)
|
|
137
|
+
|
|
138
|
+
def with_input(self, cell_ref: str, value: object) -> "Scenario":
|
|
139
|
+
inputs = self.inputs
|
|
140
|
+
inputs[normalize_full_cell_ref(cell_ref)] = value
|
|
141
|
+
return Scenario.from_inputs(name=self.name, inputs=inputs)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cell(
|
|
145
|
+
cell_ref: str,
|
|
146
|
+
*,
|
|
147
|
+
name: str | None = None,
|
|
148
|
+
label: str | None = None,
|
|
149
|
+
role: CellRole = "metadata",
|
|
150
|
+
unit: str | None = None,
|
|
151
|
+
description: str | None = None,
|
|
152
|
+
) -> CellRef:
|
|
153
|
+
"""Declare one facade cell."""
|
|
154
|
+
|
|
155
|
+
normalized = normalize_full_cell_ref(cell_ref)
|
|
156
|
+
return CellRef(
|
|
157
|
+
cell_ref=normalized,
|
|
158
|
+
name=name or normalized,
|
|
159
|
+
label=label,
|
|
160
|
+
role=role,
|
|
161
|
+
unit=unit,
|
|
162
|
+
description=description,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def table(
|
|
167
|
+
name: str,
|
|
168
|
+
*,
|
|
169
|
+
sheet: str | None = None,
|
|
170
|
+
range_ref: str,
|
|
171
|
+
row_labels: tuple[str, ...] | list[str] | None = None,
|
|
172
|
+
column_labels: tuple[str, ...] | list[str] | None = None,
|
|
173
|
+
role: CellRole = "output",
|
|
174
|
+
label: str | None = None,
|
|
175
|
+
description: str | None = None,
|
|
176
|
+
) -> TableRef:
|
|
177
|
+
"""Declare one rectangular facade table."""
|
|
178
|
+
|
|
179
|
+
table_sheet, coordinate = normalize_table_range(sheet=sheet, range_ref=range_ref)
|
|
180
|
+
min_col, min_row, max_col, max_row = range_boundaries(coordinate)
|
|
181
|
+
row_count = max_row - min_row + 1
|
|
182
|
+
column_count = max_col - min_col + 1
|
|
183
|
+
|
|
184
|
+
rows = tuple(row_labels) if row_labels is not None else tuple(str(row) for row in range(min_row, max_row + 1))
|
|
185
|
+
columns = (
|
|
186
|
+
tuple(column_labels)
|
|
187
|
+
if column_labels is not None
|
|
188
|
+
else tuple(get_column_letter(column) for column in range(min_col, max_col + 1))
|
|
189
|
+
)
|
|
190
|
+
if len(rows) != row_count:
|
|
191
|
+
raise WrapperDeclarationError(
|
|
192
|
+
f"table {name!r} declares {len(rows)} row labels for {row_count} table rows"
|
|
193
|
+
)
|
|
194
|
+
if len(columns) != column_count:
|
|
195
|
+
raise WrapperDeclarationError(
|
|
196
|
+
f"table {name!r} declares {len(columns)} column labels for {column_count} table columns"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
cell_refs = tuple(
|
|
200
|
+
tuple(f"{table_sheet}!{get_column_letter(column)}{row}" for column in range(min_col, max_col + 1))
|
|
201
|
+
for row in range(min_row, max_row + 1)
|
|
202
|
+
)
|
|
203
|
+
normalized_range = f"{get_column_letter(min_col)}{min_row}:{get_column_letter(max_col)}{max_row}"
|
|
204
|
+
return TableRef(
|
|
205
|
+
name=name,
|
|
206
|
+
sheet=table_sheet,
|
|
207
|
+
range_ref=normalized_range,
|
|
208
|
+
cell_refs=cell_refs,
|
|
209
|
+
row_labels=rows,
|
|
210
|
+
column_labels=columns,
|
|
211
|
+
role=role,
|
|
212
|
+
label=label,
|
|
213
|
+
description=description,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def report(
|
|
218
|
+
name: str,
|
|
219
|
+
*,
|
|
220
|
+
cells: tuple[str, ...] | list[str] = (),
|
|
221
|
+
tables: tuple[str, ...] | list[str] = (),
|
|
222
|
+
label: str | None = None,
|
|
223
|
+
description: str | None = None,
|
|
224
|
+
) -> ReportRef:
|
|
225
|
+
"""Declare one structured report selection."""
|
|
226
|
+
|
|
227
|
+
return ReportRef(
|
|
228
|
+
name=name,
|
|
229
|
+
cells=tuple(cells),
|
|
230
|
+
tables=tuple(tables),
|
|
231
|
+
label=label,
|
|
232
|
+
description=description,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class ModelFacade:
|
|
237
|
+
"""Analyst-facing facade around a generated Modelwright model."""
|
|
238
|
+
|
|
239
|
+
def __init__(
|
|
240
|
+
self,
|
|
241
|
+
generated_model: ModuleType | GeneratedCalculate | object,
|
|
242
|
+
*,
|
|
243
|
+
cells: tuple[CellRef, ...] | list[CellRef] = (),
|
|
244
|
+
tables: tuple[TableRef, ...] | list[TableRef] = (),
|
|
245
|
+
reports: tuple[ReportRef, ...] | list[ReportRef] = (),
|
|
246
|
+
) -> None:
|
|
247
|
+
self._calculate = generated_calculate(generated_model)
|
|
248
|
+
self._cells = keyed_declarations(cells, kind="cell")
|
|
249
|
+
self._cells_by_ref = {declaration.cell_ref: declaration for declaration in self._cells.values()}
|
|
250
|
+
self._tables = keyed_declarations(tables, kind="table")
|
|
251
|
+
self._reports = keyed_declarations(reports, kind="report")
|
|
252
|
+
self._last_scenario: Scenario | None = None
|
|
253
|
+
self._last_inputs: dict[str, object] = {}
|
|
254
|
+
self._last_values: dict[str, object] | None = None
|
|
255
|
+
self._validate_reports()
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def cells(self) -> dict[str, CellRef]:
|
|
259
|
+
return dict(self._cells)
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def tables(self) -> dict[str, TableRef]:
|
|
263
|
+
return dict(self._tables)
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def reports(self) -> dict[str, ReportRef]:
|
|
267
|
+
return dict(self._reports)
|
|
268
|
+
|
|
269
|
+
def inputs(self) -> dict[str, CellRef]:
|
|
270
|
+
return {
|
|
271
|
+
name: declaration
|
|
272
|
+
for name, declaration in self._cells.items()
|
|
273
|
+
if declaration.role == "input"
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
def outputs(self) -> dict[str, CellRef]:
|
|
277
|
+
return {
|
|
278
|
+
name: declaration
|
|
279
|
+
for name, declaration in self._cells.items()
|
|
280
|
+
if declaration.role == "output"
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
def scenario(self, name: str = "default", inputs: Mapping[str, object] | None = None) -> Scenario:
|
|
284
|
+
return Scenario.from_inputs(name=name, inputs=inputs)
|
|
285
|
+
|
|
286
|
+
def calculate(self, scenario: Scenario | None = None) -> dict[str, object]:
|
|
287
|
+
scenario = scenario or Scenario()
|
|
288
|
+
try:
|
|
289
|
+
values = self._calculate(scenario.inputs)
|
|
290
|
+
except Exception as error: # noqa: BLE001
|
|
291
|
+
raise WrapperExecutionError(f"generated model calculation failed: {error!r}") from error
|
|
292
|
+
if not isinstance(values, dict):
|
|
293
|
+
raise WrapperExecutionError("generated model calculate() did not return a dictionary")
|
|
294
|
+
self._last_scenario = scenario
|
|
295
|
+
self._last_inputs = scenario.inputs
|
|
296
|
+
self._last_values = dict(values)
|
|
297
|
+
return dict(values)
|
|
298
|
+
|
|
299
|
+
def inspect(self, cell_ref: str, scenario: Scenario | None = None) -> CellView:
|
|
300
|
+
normalized = normalize_full_cell_ref(cell_ref)
|
|
301
|
+
values = self._values_for(scenario)
|
|
302
|
+
declaration = self._cells_by_ref.get(normalized)
|
|
303
|
+
has_value = normalized in values
|
|
304
|
+
return CellView(
|
|
305
|
+
cell_ref=normalized,
|
|
306
|
+
name=declaration.name if declaration else None,
|
|
307
|
+
label=declaration.label if declaration else None,
|
|
308
|
+
role=declaration.role if declaration else None,
|
|
309
|
+
unit=declaration.unit if declaration else None,
|
|
310
|
+
description=declaration.description if declaration else None,
|
|
311
|
+
value=values.get(normalized),
|
|
312
|
+
has_value=has_value,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def table(self, name: str, scenario: Scenario | None = None) -> TableView:
|
|
316
|
+
declaration = self._tables.get(name)
|
|
317
|
+
if declaration is None:
|
|
318
|
+
raise WrapperDeclarationError(f"unknown table declaration {name!r}")
|
|
319
|
+
values = self._values_for(scenario)
|
|
320
|
+
table_values = tuple(
|
|
321
|
+
tuple(values.get(cell_ref) for cell_ref in row)
|
|
322
|
+
for row in declaration.cell_refs
|
|
323
|
+
)
|
|
324
|
+
return TableView(
|
|
325
|
+
name=declaration.name,
|
|
326
|
+
sheet=declaration.sheet,
|
|
327
|
+
range_ref=declaration.range_ref,
|
|
328
|
+
rows=declaration.row_labels,
|
|
329
|
+
columns=declaration.column_labels,
|
|
330
|
+
cell_refs=declaration.cell_refs,
|
|
331
|
+
values=table_values,
|
|
332
|
+
label=declaration.label,
|
|
333
|
+
description=declaration.description,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def report(self, name: str, scenario: Scenario | None = None) -> dict[str, object]:
|
|
337
|
+
declaration = self._reports.get(name)
|
|
338
|
+
if declaration is None:
|
|
339
|
+
raise WrapperDeclarationError(f"unknown report declaration {name!r}")
|
|
340
|
+
cell_payloads = {}
|
|
341
|
+
for cell_name in declaration.cells:
|
|
342
|
+
cell_declaration = self._cells.get(cell_name)
|
|
343
|
+
if cell_declaration is None:
|
|
344
|
+
raise WrapperDeclarationError(f"report {name!r} references unknown cell {cell_name!r}")
|
|
345
|
+
cell_payloads[cell_name] = self.inspect(cell_declaration.cell_ref, scenario=scenario).to_dict()
|
|
346
|
+
table_payloads = {
|
|
347
|
+
table_name: self.table(table_name, scenario=scenario).to_dict()
|
|
348
|
+
for table_name in declaration.tables
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
"name": declaration.name,
|
|
352
|
+
"label": declaration.label,
|
|
353
|
+
"description": declaration.description,
|
|
354
|
+
"cells": cell_payloads,
|
|
355
|
+
"tables": table_payloads,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
def _values_for(self, scenario: Scenario | None) -> dict[str, object]:
|
|
359
|
+
if scenario is None and self._last_values is not None:
|
|
360
|
+
return {**self._last_inputs, **self._last_values}
|
|
361
|
+
active_scenario = scenario or Scenario()
|
|
362
|
+
values = self.calculate(active_scenario)
|
|
363
|
+
return {**active_scenario.inputs, **values}
|
|
364
|
+
|
|
365
|
+
def _validate_reports(self) -> None:
|
|
366
|
+
for report_declaration in self._reports.values():
|
|
367
|
+
for cell_name in report_declaration.cells:
|
|
368
|
+
if cell_name not in self._cells:
|
|
369
|
+
raise WrapperDeclarationError(
|
|
370
|
+
f"report {report_declaration.name!r} references unknown cell {cell_name!r}"
|
|
371
|
+
)
|
|
372
|
+
for table_name in report_declaration.tables:
|
|
373
|
+
if table_name not in self._tables:
|
|
374
|
+
raise WrapperDeclarationError(
|
|
375
|
+
f"report {report_declaration.name!r} references unknown table {table_name!r}"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def generated_calculate(generated_model: ModuleType | GeneratedCalculate | object) -> GeneratedCalculate:
|
|
380
|
+
if callable(generated_model):
|
|
381
|
+
return generated_model
|
|
382
|
+
calculate = getattr(generated_model, "calculate", None)
|
|
383
|
+
if callable(calculate):
|
|
384
|
+
return calculate
|
|
385
|
+
raise WrapperDeclarationError("generated model must be callable or expose a callable calculate()")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def keyed_declarations(declarations: tuple[Any, ...] | list[Any], *, kind: str) -> dict[str, Any]:
|
|
389
|
+
keyed: dict[str, Any] = {}
|
|
390
|
+
for declaration in declarations:
|
|
391
|
+
name = getattr(declaration, "name", None)
|
|
392
|
+
if not isinstance(name, str) or not name:
|
|
393
|
+
raise WrapperDeclarationError(f"{kind} declaration is missing a non-empty name")
|
|
394
|
+
if name in keyed:
|
|
395
|
+
raise WrapperDeclarationError(f"duplicate {kind} declaration {name!r}")
|
|
396
|
+
keyed[name] = declaration
|
|
397
|
+
return keyed
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def normalize_full_cell_ref(cell_ref: str) -> str:
|
|
401
|
+
normalized = normalize_cell_reference(cell_ref)
|
|
402
|
+
if normalized.kind != "cell" or normalized.sheet is None:
|
|
403
|
+
raise WrapperDeclarationError(f"expected a full cell reference like 'Sheet!A1', got {cell_ref!r}")
|
|
404
|
+
return normalized.normalized
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def normalize_table_range(*, sheet: str | None, range_ref: str) -> tuple[str, str]:
|
|
408
|
+
if sheet is not None:
|
|
409
|
+
reference = normalize_reference(f"{sheet}!{range_ref}")
|
|
410
|
+
else:
|
|
411
|
+
reference = normalize_reference(range_ref)
|
|
412
|
+
if reference.kind != "range" or reference.sheet is None or reference.start_cell is None or reference.end_cell is None:
|
|
413
|
+
raise WrapperDeclarationError(
|
|
414
|
+
f"expected a rectangular range with a sheet, got sheet={sheet!r} range_ref={range_ref!r}"
|
|
415
|
+
)
|
|
416
|
+
start_col, start_row = split_cell(reference.start_cell)
|
|
417
|
+
end_col, end_row = split_cell(reference.end_cell)
|
|
418
|
+
return reference.sheet, f"{start_col}{start_row}:{end_col}{end_row}"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def split_cell(cell_ref: str) -> tuple[str, int]:
|
|
422
|
+
column = "".join(character for character in cell_ref if character.isalpha())
|
|
423
|
+
row = "".join(character for character in cell_ref if character.isdigit())
|
|
424
|
+
if not column or not row:
|
|
425
|
+
raise WrapperDeclarationError(f"invalid cell coordinate {cell_ref!r}")
|
|
426
|
+
return get_column_letter(column_index_from_string(column)), int(row)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: modelwright
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0a4
|
|
4
4
|
Summary: Tools for converting spreadsheet workbooks into transparent Python models.
|
|
5
5
|
Author: UBC FRESH Lab
|
|
6
6
|
License-Expression: MIT
|
|
@@ -135,7 +135,7 @@ Restore the public external FABLE benchmark workbooks into ignored local paths:
|
|
|
135
135
|
scripts/bootstrap_dev_env.sh --benchmarks
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
`modelwright` is pre-release. The current alpha line is `0.1.
|
|
138
|
+
`modelwright` is pre-release. The current alpha line is `0.1.0a4`; alpha releases must not be described as full-workbook conversion guarantees.
|
|
139
139
|
|
|
140
140
|
Check release artifacts locally:
|
|
141
141
|
|
|
@@ -15,6 +15,7 @@ src/modelwright/oracle_validation.py
|
|
|
15
15
|
src/modelwright/oracles.py
|
|
16
16
|
src/modelwright/references.py
|
|
17
17
|
src/modelwright/validation.py
|
|
18
|
+
src/modelwright/wrappers.py
|
|
18
19
|
src/modelwright.egg-info/PKG-INFO
|
|
19
20
|
src/modelwright.egg-info/SOURCES.txt
|
|
20
21
|
src/modelwright.egg-info/dependency_links.txt
|
|
@@ -26,6 +27,7 @@ tests/test_conversion_plan.py
|
|
|
26
27
|
tests/test_dependency_graph.py
|
|
27
28
|
tests/test_evaluation_orchestration.py
|
|
28
29
|
tests/test_extraction_records.py
|
|
30
|
+
tests/test_fable_wrapper_benchmark.py
|
|
29
31
|
tests/test_formula_expressions.py
|
|
30
32
|
tests/test_formula_translation.py
|
|
31
33
|
tests/test_formulas_oracle.py
|
|
@@ -45,4 +47,5 @@ tests/test_synthetic_fixture.py
|
|
|
45
47
|
tests/test_validation.py
|
|
46
48
|
tests/test_validation_regression.py
|
|
47
49
|
tests/test_validation_report_builder.py
|
|
48
|
-
tests/test_validation_scenario.py
|
|
50
|
+
tests/test_validation_scenario.py
|
|
51
|
+
tests/test_wrappers.py
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from modelwright.wrappers import ModelFacade, cell, report, table
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
FABLE_ARTIFACT_DIR = Path("tmp/p26-fable-full-validation")
|
|
16
|
+
FABLE_MODEL_PATH = FABLE_ARTIFACT_DIR / "generated_fable_2020_model.py"
|
|
17
|
+
FABLE_SUMMARY_PATH = FABLE_ARTIFACT_DIR / "summary.json"
|
|
18
|
+
FABLE_SCENARIO_OUTPUTS = {
|
|
19
|
+
"SCENARIOS selection!D20": 2.146115426018433,
|
|
20
|
+
"SCENARIOS selection!D21": 1.8982220554032356,
|
|
21
|
+
"SCENARIOS selection!D22": 1.462761288724012,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_module(path: Path) -> ModuleType:
|
|
26
|
+
spec = importlib.util.spec_from_file_location("generated_fable_2020_model_for_wrapper_test", path)
|
|
27
|
+
assert spec is not None
|
|
28
|
+
assert spec.loader is not None
|
|
29
|
+
module = importlib.util.module_from_spec(spec)
|
|
30
|
+
sys.modules[spec.name] = module
|
|
31
|
+
spec.loader.exec_module(module)
|
|
32
|
+
return module
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.benchmark
|
|
36
|
+
def test_model_facade_wraps_2020_fable_benchmark_model_outputs() -> None:
|
|
37
|
+
if os.environ.get("MODELWRIGHT_RUN_FABLE_BENCHMARKS") != "1":
|
|
38
|
+
pytest.skip("set MODELWRIGHT_RUN_FABLE_BENCHMARKS=1 to run local FABLE benchmark facade tests")
|
|
39
|
+
if not FABLE_MODEL_PATH.exists() or not FABLE_SUMMARY_PATH.exists():
|
|
40
|
+
pytest.skip("materialize local P26 FABLE validation artifacts before running this benchmark test")
|
|
41
|
+
|
|
42
|
+
summary = json.loads(FABLE_SUMMARY_PATH.read_text())
|
|
43
|
+
assert summary["status"] == "pass"
|
|
44
|
+
assert summary["validated_output_universe"]["matches"] == 281741
|
|
45
|
+
assert summary["validated_output_universe"]["mismatches"] == 0
|
|
46
|
+
|
|
47
|
+
module = load_module(FABLE_MODEL_PATH)
|
|
48
|
+
facade = ModelFacade(
|
|
49
|
+
module,
|
|
50
|
+
cells=[
|
|
51
|
+
cell("SCENARIOS selection!D20", name="scenario_metric_1", role="output"),
|
|
52
|
+
cell("SCENARIOS selection!D21", name="scenario_metric_2", role="output"),
|
|
53
|
+
cell("SCENARIOS selection!D22", name="scenario_metric_3", role="output"),
|
|
54
|
+
],
|
|
55
|
+
tables=[
|
|
56
|
+
table(
|
|
57
|
+
"scenario_selection_slice",
|
|
58
|
+
sheet="SCENARIOS selection",
|
|
59
|
+
range_ref="D20:D22",
|
|
60
|
+
row_labels=["d20", "d21", "d22"],
|
|
61
|
+
column_labels=["value"],
|
|
62
|
+
)
|
|
63
|
+
],
|
|
64
|
+
reports=[
|
|
65
|
+
report(
|
|
66
|
+
"scenario_selection",
|
|
67
|
+
cells=["scenario_metric_1", "scenario_metric_2", "scenario_metric_3"],
|
|
68
|
+
tables=["scenario_selection_slice"],
|
|
69
|
+
)
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
values = facade.calculate()
|
|
74
|
+
|
|
75
|
+
for cell_ref, expected in FABLE_SCENARIO_OUTPUTS.items():
|
|
76
|
+
assert values[cell_ref] == pytest.approx(expected)
|
|
77
|
+
assert facade.inspect(cell_ref).value == pytest.approx(expected)
|
|
78
|
+
table_values = facade.table("scenario_selection_slice").to_dict()["values"]
|
|
79
|
+
assert table_values[0][0] == pytest.approx(FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D20"])
|
|
80
|
+
assert table_values[1][0] == pytest.approx(FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D21"])
|
|
81
|
+
assert table_values[2][0] == pytest.approx(FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D22"])
|
|
82
|
+
|
|
83
|
+
report_payload = facade.report("scenario_selection")
|
|
84
|
+
assert report_payload["cells"]["scenario_metric_1"]["value"] == pytest.approx(
|
|
85
|
+
FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D20"]
|
|
86
|
+
)
|
|
87
|
+
assert report_payload["tables"]["scenario_selection_slice"]["values"][2][0] == pytest.approx(
|
|
88
|
+
FABLE_SCENARIO_OUTPUTS["SCENARIOS selection!D22"]
|
|
89
|
+
)
|
|
@@ -16,6 +16,11 @@ def test_root_facade_exports_primary_entrypoints() -> None:
|
|
|
16
16
|
assert "ValidationEvaluationResult" in modelwright.__all__
|
|
17
17
|
assert "infer_generated_module_contract" in modelwright.__all__
|
|
18
18
|
assert "GeneratedContractInferenceResult" in modelwright.__all__
|
|
19
|
+
assert "ModelFacade" in modelwright.__all__
|
|
20
|
+
assert "Scenario" in modelwright.__all__
|
|
21
|
+
assert "cell" in modelwright.__all__
|
|
22
|
+
assert "table" in modelwright.__all__
|
|
23
|
+
assert "report" in modelwright.__all__
|
|
19
24
|
|
|
20
25
|
|
|
21
26
|
def test_root_facade_does_not_export_internal_helpers() -> None:
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from modelwright.extraction import extract_workbook
|
|
9
|
+
from modelwright.formulas import translate_formula_cell
|
|
10
|
+
from modelwright.generation import generate_python_module, infer_generated_module_contract
|
|
11
|
+
from modelwright.graph import build_dependency_graph
|
|
12
|
+
from modelwright.wrappers import (
|
|
13
|
+
ModelFacade,
|
|
14
|
+
Scenario,
|
|
15
|
+
WrapperDeclarationError,
|
|
16
|
+
WrapperExecutionError,
|
|
17
|
+
cell,
|
|
18
|
+
report,
|
|
19
|
+
table,
|
|
20
|
+
)
|
|
21
|
+
from tests.fixtures.synthetic_model.build_workbook import build_workbook
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generated_model(inputs=None):
|
|
25
|
+
inputs = inputs or {}
|
|
26
|
+
base = inputs.get("Inputs!B2", 100)
|
|
27
|
+
growth = inputs.get("Inputs!B3", 0.1)
|
|
28
|
+
return {
|
|
29
|
+
"Summary!B2": base * (1 + growth),
|
|
30
|
+
"Summary!C2": base * 2,
|
|
31
|
+
"Summary!B3": "ok",
|
|
32
|
+
"Summary!C3": base + 5,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_module(path: Path) -> ModuleType:
|
|
37
|
+
spec = importlib.util.spec_from_file_location("wrapped_generated_synthetic_model", path)
|
|
38
|
+
assert spec is not None
|
|
39
|
+
assert spec.loader is not None
|
|
40
|
+
module = importlib.util.module_from_spec(spec)
|
|
41
|
+
sys.modules[spec.name] = module
|
|
42
|
+
spec.loader.exec_module(module)
|
|
43
|
+
return module
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_generated_synthetic_model(tmp_path: Path) -> ModuleType:
|
|
47
|
+
workbook = extract_workbook(build_workbook(tmp_path / "synthetic_model.xlsx"))
|
|
48
|
+
graph = build_dependency_graph(workbook)
|
|
49
|
+
formula_cells = {cell.cell_ref: cell for cell in workbook.cells if cell.formula is not None}
|
|
50
|
+
expressions = {
|
|
51
|
+
cell_ref: translate_formula_cell(cell, graph)
|
|
52
|
+
for cell_ref, cell in formula_cells.items()
|
|
53
|
+
}
|
|
54
|
+
inference = infer_generated_module_contract(
|
|
55
|
+
workbook=workbook,
|
|
56
|
+
graph=graph,
|
|
57
|
+
expressions=expressions,
|
|
58
|
+
output_refs=("Summary!B2", "Summary!B3"),
|
|
59
|
+
module_name="synthetic_model",
|
|
60
|
+
)
|
|
61
|
+
output_path = tmp_path / "generated_synthetic_model.py"
|
|
62
|
+
|
|
63
|
+
generation = generate_python_module(
|
|
64
|
+
contract=inference.contract,
|
|
65
|
+
expressions=inference.expressions,
|
|
66
|
+
constants=inference.constants,
|
|
67
|
+
output_path=output_path,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
assert generation.generated is True
|
|
71
|
+
return load_module(output_path)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_model_facade_wraps_generated_model_with_scenario_tables_and_reports() -> None:
|
|
75
|
+
facade = ModelFacade(
|
|
76
|
+
generated_model,
|
|
77
|
+
cells=[
|
|
78
|
+
cell("Inputs!B2", name="base", label="Base volume", role="input", unit="t"),
|
|
79
|
+
cell("Inputs!B3", name="growth", label="Growth rate", role="input", unit="fraction"),
|
|
80
|
+
cell("Summary!B2", name="projected", label="Projected volume", role="output", unit="t"),
|
|
81
|
+
],
|
|
82
|
+
tables=[
|
|
83
|
+
table(
|
|
84
|
+
"summary_grid",
|
|
85
|
+
sheet="Summary",
|
|
86
|
+
range_ref="B2:C3",
|
|
87
|
+
row_labels=["volume", "status"],
|
|
88
|
+
column_labels=["primary", "secondary"],
|
|
89
|
+
)
|
|
90
|
+
],
|
|
91
|
+
reports=[report("summary", cells=["base", "projected"], tables=["summary_grid"])],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
scenario = facade.scenario(inputs={"Inputs!B2": 50}).with_input("Inputs!B3", 0.2)
|
|
95
|
+
|
|
96
|
+
assert facade.inputs()["base"].cell_ref == "Inputs!B2"
|
|
97
|
+
assert facade.outputs()["projected"].cell_ref == "Summary!B2"
|
|
98
|
+
assert facade.calculate(scenario) == {
|
|
99
|
+
"Summary!B2": 60.0,
|
|
100
|
+
"Summary!C2": 100,
|
|
101
|
+
"Summary!B3": "ok",
|
|
102
|
+
"Summary!C3": 55,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
input_view = facade.inspect("Inputs!B2", scenario)
|
|
106
|
+
assert input_view.to_dict() == {
|
|
107
|
+
"cell_ref": "Inputs!B2",
|
|
108
|
+
"name": "base",
|
|
109
|
+
"label": "Base volume",
|
|
110
|
+
"role": "input",
|
|
111
|
+
"unit": "t",
|
|
112
|
+
"description": None,
|
|
113
|
+
"value": 50,
|
|
114
|
+
"has_value": True,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
table_view = facade.table("summary_grid", scenario)
|
|
118
|
+
assert table_view.to_dict() == {
|
|
119
|
+
"name": "summary_grid",
|
|
120
|
+
"sheet": "Summary",
|
|
121
|
+
"range_ref": "B2:C3",
|
|
122
|
+
"rows": ["volume", "status"],
|
|
123
|
+
"columns": ["primary", "secondary"],
|
|
124
|
+
"cell_refs": [["Summary!B2", "Summary!C2"], ["Summary!B3", "Summary!C3"]],
|
|
125
|
+
"values": [[60.0, 100], ["ok", 55]],
|
|
126
|
+
"label": None,
|
|
127
|
+
"description": None,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
report_payload = facade.report("summary", scenario)
|
|
131
|
+
assert report_payload["cells"]["base"]["value"] == 50
|
|
132
|
+
assert report_payload["cells"]["projected"]["value"] == 60.0
|
|
133
|
+
assert report_payload["tables"]["summary_grid"]["values"] == [[60.0, 100], ["ok", 55]]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_model_facade_preserves_real_generated_synthetic_model_semantics(tmp_path: Path) -> None:
|
|
137
|
+
module = build_generated_synthetic_model(tmp_path)
|
|
138
|
+
facade = ModelFacade(
|
|
139
|
+
module,
|
|
140
|
+
cells=[
|
|
141
|
+
cell("Inputs!B2", name="base", label="Base volume", role="input"),
|
|
142
|
+
cell("Inputs!B3", name="growth", label="Growth rate", role="input"),
|
|
143
|
+
cell("Inputs!B4", name="harvest_share", label="Harvest share", role="input"),
|
|
144
|
+
cell("Summary!B2", name="harvest", label="Rounded harvest", role="output"),
|
|
145
|
+
cell("Summary!B3", name="status", label="Status", role="output"),
|
|
146
|
+
],
|
|
147
|
+
tables=[
|
|
148
|
+
table(
|
|
149
|
+
"summary",
|
|
150
|
+
sheet="Summary",
|
|
151
|
+
range_ref="B2:B3",
|
|
152
|
+
row_labels=["harvest", "status"],
|
|
153
|
+
column_labels=["value"],
|
|
154
|
+
)
|
|
155
|
+
],
|
|
156
|
+
reports=[report("default", cells=["base", "harvest", "status"], tables=["summary"])],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
assert facade.calculate() == module.calculate()
|
|
160
|
+
|
|
161
|
+
scenario = facade.scenario(name="low-volume", inputs={"Inputs!B2": 10})
|
|
162
|
+
generated_values = module.calculate(scenario.inputs)
|
|
163
|
+
|
|
164
|
+
assert facade.calculate(scenario) == generated_values
|
|
165
|
+
assert generated_values == {"Summary!B2": 7.02, "Summary!B3": "low"}
|
|
166
|
+
assert facade.inspect("Summary!B2", scenario).value == generated_values["Summary!B2"]
|
|
167
|
+
assert facade.table("summary", scenario).to_dict()["values"] == [[7.02], ["low"]]
|
|
168
|
+
|
|
169
|
+
report_payload = facade.report("default", scenario)
|
|
170
|
+
assert report_payload["cells"]["base"]["value"] == 10
|
|
171
|
+
assert report_payload["cells"]["harvest"]["value"] == generated_values["Summary!B2"]
|
|
172
|
+
assert report_payload["tables"]["summary"]["values"] == [[7.02], ["low"]]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_scenario_is_copy_on_write_and_normalizes_input_refs() -> None:
|
|
176
|
+
original = Scenario.from_inputs(inputs={"Inputs!B2": 10})
|
|
177
|
+
updated = original.with_input("Inputs!B3", 0.25)
|
|
178
|
+
|
|
179
|
+
assert original.inputs == {"Inputs!B2": 10}
|
|
180
|
+
assert updated.inputs == {"Inputs!B2": 10, "Inputs!B3": 0.25}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_table_declaration_validates_label_shape() -> None:
|
|
184
|
+
with pytest.raises(WrapperDeclarationError, match="row labels"):
|
|
185
|
+
table("bad_rows", sheet="Summary", range_ref="A1:A2", row_labels=["only one"])
|
|
186
|
+
|
|
187
|
+
with pytest.raises(WrapperDeclarationError, match="column labels"):
|
|
188
|
+
table("bad_columns", sheet="Summary", range_ref="A1:B1", column_labels=["only one"])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_facade_validates_duplicate_names_and_report_references() -> None:
|
|
192
|
+
with pytest.raises(WrapperDeclarationError, match="duplicate cell declaration"):
|
|
193
|
+
ModelFacade(
|
|
194
|
+
generated_model,
|
|
195
|
+
cells=[
|
|
196
|
+
cell("Inputs!B2", name="base"),
|
|
197
|
+
cell("Inputs!B3", name="base"),
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
with pytest.raises(WrapperDeclarationError, match="unknown table"):
|
|
202
|
+
ModelFacade(generated_model, reports=[report("bad", tables=["missing"])])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_facade_wraps_generated_model_execution_errors() -> None:
|
|
206
|
+
def broken_model(inputs=None):
|
|
207
|
+
raise RuntimeError("boom")
|
|
208
|
+
|
|
209
|
+
facade = ModelFacade(broken_model)
|
|
210
|
+
|
|
211
|
+
with pytest.raises(WrapperExecutionError, match="calculation failed"):
|
|
212
|
+
facade.calculate()
|
|
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
|