views-frames 1.7.0__tar.gz → 1.8.0__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 (45) hide show
  1. {views_frames-1.7.0 → views_frames-1.8.0}/PKG-INFO +14 -2
  2. {views_frames-1.7.0 → views_frames-1.8.0}/README.md +7 -1
  3. {views_frames-1.7.0 → views_frames-1.8.0}/pyproject.toml +27 -8
  4. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_reconcile/__init__.py +14 -1
  5. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_reconcile/conformance.py +4 -1
  6. views_frames-1.8.0/src/views_frames_reconcile/module.py +120 -0
  7. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_reconcile/proportional.py +6 -3
  8. views_frames-1.8.0/src/views_frames_reconcile/result.py +55 -0
  9. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_reconcile/validation.py +6 -4
  10. views_frames-1.7.0/src/views_frames_reconcile/module.py +0 -68
  11. {views_frames-1.7.0 → views_frames-1.8.0}/.gitignore +0 -0
  12. {views_frames-1.7.0 → views_frames-1.8.0}/LICENSE +0 -0
  13. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/__init__.py +0 -0
  14. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/_typing.py +0 -0
  15. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/_validation.py +0 -0
  16. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/conformance/__init__.py +0 -0
  17. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/feature_frame.py +0 -0
  18. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/index.py +0 -0
  19. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/io/__init__.py +0 -0
  20. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/io/arrow.py +0 -0
  21. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/io/npz.py +0 -0
  22. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/metadata.py +0 -0
  23. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/prediction_frame.py +0 -0
  24. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/protocols.py +0 -0
  25. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/py.typed +0 -0
  26. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/spatial_level.py +0 -0
  27. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames/target_frame.py +0 -0
  28. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_reconcile/frames.py +0 -0
  29. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_reconcile/grouping.py +0 -0
  30. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_reconcile/py.typed +0 -0
  31. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/__init__.py +0 -0
  32. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/_common.py +0 -0
  33. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/aggregate.py +0 -0
  34. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/bimodality.py +0 -0
  35. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/collapse.py +0 -0
  36. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/config.py +0 -0
  37. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/conformance.py +0 -0
  38. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/exceedance.py +0 -0
  39. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/expected_shortfall.py +0 -0
  40. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/interval.py +0 -0
  41. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/point.py +0 -0
  42. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/py.typed +0 -0
  43. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/summarize_tower.py +0 -0
  44. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/tower.py +0 -0
  45. {views_frames-1.7.0 → views_frames-1.8.0}/src/views_frames_summarize/tower_point.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: views-frames
3
- Version: 1.7.0
3
+ Version: 1.8.0
4
4
  Summary: The VIEWS platform data-contract layer: immutable array+identifier frames (numpy only, root of the dependency DAG).
5
5
  Project-URL: Homepage, https://github.com/views-platform/views-frames
6
6
  Project-URL: Repository, https://github.com/views-platform/views-frames
@@ -22,6 +22,12 @@ Requires-Python: >=3.10
22
22
  Requires-Dist: numpy<3,>=1.26
23
23
  Provides-Extra: arrow
24
24
  Requires-Dist: pyarrow<20,>=14; extra == 'arrow'
25
+ Provides-Extra: docs
26
+ Requires-Dist: jupyterlab<5,>=4; extra == 'docs'
27
+ Requires-Dist: matplotlib<4,>=3.8; extra == 'docs'
28
+ Requires-Dist: nbmake<2,>=1.5; extra == 'docs'
29
+ Requires-Dist: pyarrow<20,>=14; extra == 'docs'
30
+ Requires-Dist: scipy<2,>=1.11; extra == 'docs'
25
31
  Description-Content-Type: text/markdown
26
32
 
27
33
  # views-frames
@@ -249,7 +255,13 @@ makes it safe to depend on from everywhere (SDP).
249
255
  3. **Immutable value objects.** A frame is validated at construction and then
250
256
  treated as read-only. Operations (`collapse`, `select`, `with_metadata`)
251
257
  **return new frames**; they never mutate in place. (Directly forbids the
252
- C-184 cross-repo-mutation anti-pattern.) **Copy-vs-view:** structural and
258
+ C-184 cross-repo-mutation anti-pattern.) **Enforced for the index, by
259
+ convention for the value buffer:** the identifier arrays (`time`/`unit`) are
260
+ write-protected (`setflags(write=False)`); the `values` buffer is left
261
+ **writeable on purpose** — so structural ops can share it zero-copy — and is
262
+ immutable *by convention*: mutating `.values` in place is unsupported and may
263
+ silently corrupt buffer-sharing frames (ADR-025 / register C-63; enforcing it
264
+ is a deferred MAJOR-rider). **Copy-vs-view:** structural and
253
265
  metadata-only operations (`with_metadata`, contiguous `select`) return frames
254
266
  that **share** the underlying `values` buffer (numpy view / zero-copy), and a
255
267
  `mmap`-backed frame stays `mmap`-backed — a new frame must never copy a
@@ -223,7 +223,13 @@ makes it safe to depend on from everywhere (SDP).
223
223
  3. **Immutable value objects.** A frame is validated at construction and then
224
224
  treated as read-only. Operations (`collapse`, `select`, `with_metadata`)
225
225
  **return new frames**; they never mutate in place. (Directly forbids the
226
- C-184 cross-repo-mutation anti-pattern.) **Copy-vs-view:** structural and
226
+ C-184 cross-repo-mutation anti-pattern.) **Enforced for the index, by
227
+ convention for the value buffer:** the identifier arrays (`time`/`unit`) are
228
+ write-protected (`setflags(write=False)`); the `values` buffer is left
229
+ **writeable on purpose** — so structural ops can share it zero-copy — and is
230
+ immutable *by convention*: mutating `.values` in place is unsupported and may
231
+ silently corrupt buffer-sharing frames (ADR-025 / register C-63; enforcing it
232
+ is a deferred MAJOR-rider). **Copy-vs-view:** structural and
227
233
  metadata-only operations (`with_metadata`, contiguous `select`) return frames
228
234
  that **share** the underlying `values` buffer (numpy view / zero-copy), and a
229
235
  `mmap`-backed frame stays `mmap`-backed — a new frame must never copy a
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "views-frames"
7
- version = "1.7.0"
7
+ version = "1.8.0"
8
8
  description = "The VIEWS platform data-contract layer: immutable array+identifier frames (numpy only, root of the dependency DAG)."
9
9
  authors = [
10
10
  { name = "Simon Polichinel von der Maase", email = "simmaa@prio.org" },
@@ -39,6 +39,22 @@ Changelog = "https://github.com/views-platform/views-frames/blob/main/CHANGELOG.
39
39
  # Optional serialization extra — pyarrow is allowed ONLY behind io/ (README §3.1).
40
40
  [project.optional-dependencies]
41
41
  arrow = ["pyarrow>=14,<20"]
42
+ # Optional docs/notebooks extra — plotting + jupyter for the showcase notebooks
43
+ # (notebooks/, an un-gated dev artifact outside src/). NEVER runtime dependencies;
44
+ # self-references [arrow] so `uv sync --extra docs` also enables the parquet round-trip
45
+ # demo in notebooks/01_frames.ipynb. matplotlib/scipy are notebook-only (the core is
46
+ # numpy-only); these are imported by notebooks, never by the package.
47
+ docs = [
48
+ "views-frames[arrow]",
49
+ "matplotlib>=3.8,<4",
50
+ "scipy>=1.11,<2",
51
+ "jupyterlab>=4,<5",
52
+ # nbmake drives the non-blocking notebook-execution CI check (issue #151): it just
53
+ # runs the notebooks end-to-end to catch frozen-API drift. A test tool, but it rides
54
+ # in [docs] so the single `uv sync --extra docs` provisions everything the notebooks
55
+ # job needs (kernel + plotting + the checker).
56
+ "nbmake>=1.5,<2",
57
+ ]
42
58
 
43
59
  [dependency-groups]
44
60
  dev = [
@@ -76,9 +92,10 @@ show_missing = true
76
92
  [tool.ruff]
77
93
  line-length = 88
78
94
  target-version = "py310"
79
- # research/ is the volatile estimator lab (outside the frozen, gated src/); it is
80
- # not held to the leaf's lint/format bar.
81
- extend-exclude = ["research"]
95
+ # research/ is the volatile estimator lab and notebooks/ holds the un-gated showcase
96
+ # notebooks + their helper (notebooks/_synthetic.py) — both sit outside the frozen,
97
+ # gated src/ and are not held to the leaf's lint/format bar.
98
+ extend-exclude = ["research", "notebooks"]
82
99
 
83
100
  [tool.ruff.lint]
84
101
  select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "SIM"]
@@ -87,11 +104,13 @@ select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "SIM"]
87
104
  # The falsification stubs assert against the README via long, literal regexes
88
105
  # whose lines cannot wrap without changing their semantics.
89
106
  "tests/test_falsification_*.py" = ["E501"]
90
- # The reconciliation parity tests + their oracle-regeneration scripts are
91
- # faithfully relocated from views-postprocessing (Epic 11 / ADR-023); their dense
92
- # numpy/dict-comprehension assertions are kept verbatim rather than re-wrapped.
107
+ # The reconciliation parity tests are faithfully relocated from views-postprocessing
108
+ # (Epic 11 / ADR-023); their dense numpy/dict-comprehension assertions are kept verbatim
109
+ # rather than re-wrapped.
93
110
  "tests/test_reconciliation_*.py" = ["E501"]
94
- "scripts/gen_reconciliation_*.py" = ["E501"]
111
+ # The dev/ops scripts (oracle regeneration; the reconciliation parity verifier) are not
112
+ # CI-run and carry dense numpy one-liners + report f-strings; not held to the wrap bar.
113
+ "scripts/*.py" = ["E501"]
95
114
 
96
115
  [tool.mypy]
97
116
  python_version = "3.10"
@@ -7,5 +7,18 @@ should be added as sibling modules, not by modifying ``proportional``.
7
7
 
8
8
  from views_frames_reconcile.module import ReconciliationModule
9
9
  from views_frames_reconcile.proportional import reconcile_proportional
10
+ from views_frames_reconcile.result import (
11
+ ALIGNED_DRAWS,
12
+ METHOD_PROPORTIONAL,
13
+ POINT_BROADCAST,
14
+ ReconciliationResult,
15
+ )
10
16
 
11
- __all__ = ["ReconciliationModule", "reconcile_proportional"]
17
+ __all__ = [
18
+ "ALIGNED_DRAWS",
19
+ "METHOD_PROPORTIONAL",
20
+ "POINT_BROADCAST",
21
+ "ReconciliationModule",
22
+ "ReconciliationResult",
23
+ "reconcile_proportional",
24
+ ]
@@ -66,7 +66,10 @@ def assert_reconcile_contract(
66
66
  rows = (pg_time == t) & (cm_units == c)
67
67
  in_sum = pgm_frame.values[rows].sum(axis=0) # (S,)
68
68
  out_sum = out.values[rows].sum(axis=0) # (S,)
69
- total = cm_frame.values[cm_pos[(int(t), int(c))]] # (S,)
69
+ # cm may be a point (sample_count == 1, broadcast inside reconcile) or aligned
70
+ # draws — broadcast its per-(time, country) total to the draw axis either way.
71
+ cm_total = cm_frame.values[cm_pos[(int(t), int(c))]]
72
+ total = np.broadcast_to(cm_total, out_sum.shape)
70
73
  active = in_sum != 0
71
74
  np.testing.assert_allclose(
72
75
  out_sum[active], total[active], rtol=1e-4, atol=1e-3
@@ -0,0 +1,120 @@
1
+ """Frames-native reconciliation orchestration (epic #31, story #36).
2
+
3
+ `ReconciliationModule` holds the injected `(time, priogrid_gid) -> country_id`
4
+ mapping (geography is injected, never embedded — views-frames ADR-014) and
5
+ applies it: `reconcile(cm_frame, pgm_frame)` validates the inputs and scales the
6
+ grid forecasts to the country totals, returning a **new** pgm frame (de-mutated,
7
+ C-184).
8
+
9
+ **SRP:** orchestration only — the scaling math is the leaf (`proportional`), the
10
+ grouping is `grouping`, the guards are `validation`, the I/O is `frames`. No
11
+ torch, no pandas, no viewser, no wandb: the original's `ProcessPoolExecutor` and
12
+ WandB alerting are dropped (numpy is fast; there is no GPU). If scale ever needs
13
+ parallelism, add it behind this same interface (OCP). Multi-target inputs are
14
+ reconciled by calling `reconcile` once per target.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ import numpy as np
22
+ from numpy.typing import NDArray
23
+
24
+ from views_frames import PredictionFrame
25
+ from views_frames_reconcile.grouping import reconcile_pgm_to_cm
26
+ from views_frames_reconcile.result import (
27
+ ALIGNED_DRAWS,
28
+ METHOD_PROPORTIONAL,
29
+ POINT_BROADCAST,
30
+ ReconciliationResult,
31
+ )
32
+ from views_frames_reconcile.validation import validate_reconciliation_inputs
33
+
34
+
35
+ def _broadcast_point_country(
36
+ cm_frame: PredictionFrame, n_samples: int
37
+ ) -> PredictionFrame:
38
+ """Tile a point country forecast (``sample_count == 1``) across ``n_samples`` draws.
39
+
40
+ Bit-identical to the WET broadcast pipeline-core's ``align_country_to_grid`` does
41
+ (views-frames#143): the single column is repeated, so every draw is rescaled to the
42
+ same total. It lives in the orchestrator so the leaf ``proportional`` and the
43
+ parity-frozen ``grouping`` hot loop stay **untouched** — the equal-count path is
44
+ byte-for-byte identical and only ``sample_count == 1`` takes this new path.
45
+ """
46
+ tiled = np.tile(np.asarray(cm_frame.values, dtype=np.float32), (1, n_samples))
47
+ return PredictionFrame(tiled, cm_frame.index, cm_frame.metadata)
48
+
49
+
50
+ class ReconciliationModule:
51
+ """Reconcile pgm forecasts to cm country totals (one target per call)."""
52
+
53
+ def __init__(
54
+ self,
55
+ map_keys: NDArray[np.integer[Any]] | object,
56
+ map_vals: NDArray[np.integer[Any]] | object,
57
+ ) -> None:
58
+ """Inject the `(time, priogrid_gid) -> country_id` mapping.
59
+
60
+ Args:
61
+ map_keys: ``(M, 2)`` int array of ``(time, priogrid_gid)`` pairs.
62
+ map_vals: ``(M,)`` int ``country_id`` for each key.
63
+
64
+ Raises:
65
+ ValueError: ``map_keys`` is not ``(M, 2)`` or ``map_vals`` is not
66
+ length ``M``.
67
+ """
68
+ keys = np.asarray(map_keys)
69
+ vals = np.asarray(map_vals)
70
+ if keys.ndim != 2 or keys.shape[1] != 2:
71
+ raise ValueError("map_keys must be an (M, 2) array of (time, priogrid_gid)")
72
+ if vals.shape != (keys.shape[0],):
73
+ raise ValueError("map_vals must be a length-M array aligned to map_keys")
74
+ self._map_keys = keys
75
+ self._map_vals = vals
76
+
77
+ def reconcile_result(
78
+ self, cm_frame: PredictionFrame, pgm_frame: PredictionFrame
79
+ ) -> ReconciliationResult:
80
+ """Reconcile, also reporting the mode — a ``ReconciliationResult``.
81
+
82
+ A **point** country (``cm.sample_count == 1``) is broadcast across the grid's
83
+ draws before reconciling (mode ``point-broadcast``); an **aligned** country
84
+ (``cm.sample_count == S``) scales draw-for-draw (mode ``aligned-draws``, the
85
+ documented per-draw approximation). Any other count fails loud.
86
+
87
+ The mode is **returned**, never stamped on the leaf's generic ``FrameMetadata``
88
+ (ADR-020 / register C-47 — the leaf carries no reconciliation vocabulary; see
89
+ ``result.py``).
90
+
91
+ Raises:
92
+ ValueError: the inputs fail validation (level / sample-count — cm must be 1
93
+ or pgm's ``S`` / time coverage / missing country forecast).
94
+ """
95
+ validate_reconciliation_inputs(
96
+ cm_frame, pgm_frame, self._map_keys, self._map_vals
97
+ )
98
+ if cm_frame.sample_count != pgm_frame.sample_count:
99
+ cm = _broadcast_point_country(cm_frame, pgm_frame.sample_count)
100
+ mode = POINT_BROADCAST
101
+ else:
102
+ cm = cm_frame
103
+ mode = ALIGNED_DRAWS
104
+ frame = reconcile_pgm_to_cm(pgm_frame, cm, self._map_keys, self._map_vals)
105
+ return ReconciliationResult(frame=frame, mode=mode, method=METHOD_PROPORTIONAL)
106
+
107
+ def reconcile(
108
+ self, cm_frame: PredictionFrame, pgm_frame: PredictionFrame
109
+ ) -> PredictionFrame:
110
+ """Validate the inputs, then return a new pgm frame reconciled to cm totals.
111
+
112
+ Point/aligned handling and fail-loud are as :meth:`reconcile_result`; this
113
+ returns only the reconciled frame. Call :meth:`reconcile_result` when you also
114
+ need the reconciliation *mode* (point-broadcast vs aligned-draws).
115
+
116
+ Raises:
117
+ ValueError: the inputs fail validation (level / sample-count — cm must be 1
118
+ or pgm's ``S`` / time coverage / missing country forecast).
119
+ """
120
+ return self.reconcile_result(cm_frame, pgm_frame).frame
@@ -10,9 +10,12 @@ This is a *faithful, numpy-only* port of views-reporting's
10
10
  ``ForecastReconciler.reconcile_forecast`` (torch), migrated here because the
11
11
  algorithm belongs in post-processing, not reporting (views-reporting issue #72).
12
12
  It is intentionally the **same** method — a pragmatic per-draw approximation, not
13
- principled joint probabilistic reconciliation. The upgrade to the latter is
14
- tracked as **C-37** and is deliberately deferred until this port's parity with
15
- the original is proven and the move is wired.
13
+ principled joint probabilistic reconciliation. The upgrade to the latter is designed
14
+ in **ADR-024** (register **C-62**; the cross-repo lineage is views-postprocessing
15
+ C-37) and is deliberately **deferred**: per-draw pairing of independently-trained
16
+ grid and country draws has no shared draw identity, so the principled method waits on
17
+ a defined draw-identity/coupling contract and a consumer that needs calibrated joint
18
+ tails.
16
19
 
17
20
  No torch, no pandas — numpy only.
18
21
  """
@@ -0,0 +1,55 @@
1
+ """The reconciliation result — the reconciled frame plus *how* it was produced (#144).
2
+
3
+ The reconciliation **mode** (`point-broadcast` vs `aligned-draws`) and **method** are
4
+ reported here, on a reconcile-package result object — deliberately **not** stamped onto
5
+ the leaf's generic ``FrameMetadata``. That header is governed *generic-only* (ADR-020 /
6
+ register C-47): the numpy leaf carries provenance meaningful for *any* frame (`run_id`,
7
+ `data_version`) and must never carry domain/operation vocabulary it cannot know — as
8
+ evaluation provenance lives in views-evaluation's ``MetricFrame``, not the leaf.
9
+ Reconciliation is a sibling operation, so its provenance is reported by the sibling.
10
+
11
+ A caller that needs the mode reads it off :class:`ReconciliationResult`
12
+ (``ReconciliationModule.reconcile_result``); a caller that only needs the frame uses
13
+ ``ReconciliationModule.reconcile``, which returns the frame directly.
14
+
15
+ The mode reflects **what this reconcile call did** (broadcast a point, or scaled aligned
16
+ draws) — not upstream provenance: a caller that pre-tiles a point country to ``S`` draws
17
+ before calling reconciles in ``aligned-draws`` mode. The string values match
18
+ pipeline-core's ``reconcile_frames`` constants verbatim, so the *vocabulary* is shared
19
+ across the repos that produce and consume the mode.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+
26
+ from views_frames import PredictionFrame
27
+
28
+ #: Mode — the call **broadcast a point**: the country arrived as a point
29
+ #: (``cm.sample_count == 1``) against a multi-draw grid and was tiled across the grid's
30
+ #: ``S`` draws (every draw rescaled to the same total).
31
+ POINT_BROADCAST = "point-broadcast"
32
+ #: Mode — the call **scaled aligned draws**: the country already carried the grid's draw
33
+ #: count (``cm.sample_count == S``) and was scaled draw-for-draw — the per-draw
34
+ #: approximation (the principled joint upgrade is a separate design, #145). The mode
35
+ #: describes *what this call did*: a point against a single-draw grid, or a point the
36
+ #: caller pre-tiled to ``S``, also reads as aligned-draws (nothing was broadcast).
37
+ ALIGNED_DRAWS = "aligned-draws"
38
+ #: The reconciliation algorithm — top-down proportional, per draw.
39
+ METHOD_PROPORTIONAL = "proportional"
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ReconciliationResult:
44
+ """A reconciled grid frame plus how it was produced.
45
+
46
+ Attributes:
47
+ frame: the reconciled pgm ``PredictionFrame`` (identical to what ``reconcile``
48
+ returns).
49
+ mode: :data:`POINT_BROADCAST` or :data:`ALIGNED_DRAWS`.
50
+ method: the reconciliation method, e.g. :data:`METHOD_PROPORTIONAL`.
51
+ """
52
+
53
+ frame: PredictionFrame
54
+ mode: str
55
+ method: str
@@ -6,7 +6,8 @@ buried in the orchestrator. The original's intents map as:
6
6
 
7
7
  - dataset type checks -> spatial-level guard (cm@CM, pgm@PGM)
8
8
  - same time steps + exact overlap -> identical set of time values
9
- - (per-draw scaling needs it) -> equal `sample_count`
9
+ - (per-draw scaling needs it) -> equal `sample_count`, or a point cm
10
+ (`sample_count == 1`) broadcast across the draws
10
11
  - valid countries -> every (time, country) the grid maps to has
11
12
  a country forecast in the cm frame
12
13
 
@@ -44,10 +45,11 @@ def validate_reconciliation_inputs(
44
45
  raise ValueError(
45
46
  f"grid frame must be at SpatialLevel.PGM, got {pgm_frame.index.level}"
46
47
  )
47
- if cm_frame.sample_count != pgm_frame.sample_count:
48
+ if cm_frame.sample_count not in (1, pgm_frame.sample_count):
48
49
  raise ValueError(
49
- f"sample-count mismatch: cm has {cm_frame.sample_count}, "
50
- f"pgm has {pgm_frame.sample_count}"
50
+ f"sample-count mismatch: cm has {cm_frame.sample_count}, pgm has "
51
+ f"{pgm_frame.sample_count}; cm must be 1 (point, broadcast across draws) "
52
+ f"or {pgm_frame.sample_count} (aligned draws)"
51
53
  )
52
54
 
53
55
  cm_times = {int(t) for t in np.unique(cm_frame.index.time)}
@@ -1,68 +0,0 @@
1
- """Frames-native reconciliation orchestration (epic #31, story #36).
2
-
3
- `ReconciliationModule` holds the injected `(time, priogrid_gid) -> country_id`
4
- mapping (geography is injected, never embedded — views-frames ADR-014) and
5
- applies it: `reconcile(cm_frame, pgm_frame)` validates the inputs and scales the
6
- grid forecasts to the country totals, returning a **new** pgm frame (de-mutated,
7
- C-184).
8
-
9
- **SRP:** orchestration only — the scaling math is the leaf (`proportional`), the
10
- grouping is `grouping`, the guards are `validation`, the I/O is `frames`. No
11
- torch, no pandas, no viewser, no wandb: the original's `ProcessPoolExecutor` and
12
- WandB alerting are dropped (numpy is fast; there is no GPU). If scale ever needs
13
- parallelism, add it behind this same interface (OCP). Multi-target inputs are
14
- reconciled by calling `reconcile` once per target.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- from typing import Any
20
-
21
- import numpy as np
22
- from numpy.typing import NDArray
23
-
24
- from views_frames import PredictionFrame
25
- from views_frames_reconcile.grouping import reconcile_pgm_to_cm
26
- from views_frames_reconcile.validation import validate_reconciliation_inputs
27
-
28
-
29
- class ReconciliationModule:
30
- """Reconcile pgm forecasts to cm country totals (one target per call)."""
31
-
32
- def __init__(
33
- self,
34
- map_keys: NDArray[np.integer[Any]] | object,
35
- map_vals: NDArray[np.integer[Any]] | object,
36
- ) -> None:
37
- """Inject the `(time, priogrid_gid) -> country_id` mapping.
38
-
39
- Args:
40
- map_keys: ``(M, 2)`` int array of ``(time, priogrid_gid)`` pairs.
41
- map_vals: ``(M,)`` int ``country_id`` for each key.
42
-
43
- Raises:
44
- ValueError: ``map_keys`` is not ``(M, 2)`` or ``map_vals`` is not
45
- length ``M``.
46
- """
47
- keys = np.asarray(map_keys)
48
- vals = np.asarray(map_vals)
49
- if keys.ndim != 2 or keys.shape[1] != 2:
50
- raise ValueError("map_keys must be an (M, 2) array of (time, priogrid_gid)")
51
- if vals.shape != (keys.shape[0],):
52
- raise ValueError("map_vals must be a length-M array aligned to map_keys")
53
- self._map_keys = keys
54
- self._map_vals = vals
55
-
56
- def reconcile(
57
- self, cm_frame: PredictionFrame, pgm_frame: PredictionFrame
58
- ) -> PredictionFrame:
59
- """Validate the inputs, then return a new pgm frame reconciled to cm totals.
60
-
61
- Raises:
62
- ValueError: the inputs fail validation (level / sample-count / time
63
- coverage / missing country forecast).
64
- """
65
- validate_reconciliation_inputs(
66
- cm_frame, pgm_frame, self._map_keys, self._map_vals
67
- )
68
- return reconcile_pgm_to_cm(pgm_frame, cm_frame, self._map_keys, self._map_vals)
File without changes
File without changes