gridfm-trustlayer 0.1.0__py3-none-any.whl

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 (35) hide show
  1. gridfm_trustlayer/__init__.py +58 -0
  2. gridfm_trustlayer/__main__.py +9 -0
  3. gridfm_trustlayer/_version.py +3 -0
  4. gridfm_trustlayer/cli/__init__.py +5 -0
  5. gridfm_trustlayer/cli/apply.py +540 -0
  6. gridfm_trustlayer/cli/calibrate.py +787 -0
  7. gridfm_trustlayer/cli/common.py +1190 -0
  8. gridfm_trustlayer/cli/compute.py +591 -0
  9. gridfm_trustlayer/cli/config.py +597 -0
  10. gridfm_trustlayer/cli/main.py +354 -0
  11. gridfm_trustlayer/cli/reports.py +844 -0
  12. gridfm_trustlayer/cli/screen.py +980 -0
  13. gridfm_trustlayer/cli/status.py +513 -0
  14. gridfm_trustlayer/conformal/__init__.py +6 -0
  15. gridfm_trustlayer/conformal/calibrator.py +847 -0
  16. gridfm_trustlayer/conformal/kernel.py +1213 -0
  17. gridfm_trustlayer/conformal/tuning.py +631 -0
  18. gridfm_trustlayer/interactive.py +882 -0
  19. gridfm_trustlayer/io/__init__.py +18 -0
  20. gridfm_trustlayer/io/artifact.py +235 -0
  21. gridfm_trustlayer/io/embeddings.py +191 -0
  22. gridfm_trustlayer/io/ground_truth.py +670 -0
  23. gridfm_trustlayer/io/loading.py +121 -0
  24. gridfm_trustlayer/io/parquet.py +283 -0
  25. gridfm_trustlayer/io/predictions.py +444 -0
  26. gridfm_trustlayer/io/task_adapters.py +194 -0
  27. gridfm_trustlayer/parallel.py +195 -0
  28. gridfm_trustlayer/py.typed +0 -0
  29. gridfm_trustlayer/screening.py +116 -0
  30. gridfm_trustlayer-0.1.0.dist-info/METADATA +248 -0
  31. gridfm_trustlayer-0.1.0.dist-info/RECORD +35 -0
  32. gridfm_trustlayer-0.1.0.dist-info/WHEEL +5 -0
  33. gridfm_trustlayer-0.1.0.dist-info/entry_points.txt +2 -0
  34. gridfm_trustlayer-0.1.0.dist-info/licenses/LICENSE +201 -0
  35. gridfm_trustlayer-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,58 @@
1
+ """Conformal trustworthiness layer for GridFM foundation models.
2
+
3
+ The public Python API is re-exported here; import from the top-level package
4
+ rather than from submodules, whose layout may change.
5
+
6
+ Calibrators:
7
+ NaiveIntervalCalibrator stratified split-conformal intervals (``cp_type: element``)
8
+ KernelIntervalCalibrator kernel-weighted conformal intervals (``cp_type: kernel``)
9
+ ChannelSet aligned (scenario x entity) arrays to calibrate
10
+
11
+ Data loading:
12
+ load_predictions, load_ground_truth, load_scenario_embeddings, compute_line_loading
13
+
14
+ Artifacts:
15
+ save_interval_artifact, load_interval_artifact, load_interval_artifacts
16
+
17
+ Screening:
18
+ classify_status, scenario_status status of values and operating scenarios
19
+ """
20
+
21
+ from gridfm_trustlayer._version import __version__
22
+ from gridfm_trustlayer.conformal import (
23
+ ChannelSet,
24
+ KernelIntervalCalibrator,
25
+ NaiveIntervalCalibrator,
26
+ )
27
+ from gridfm_trustlayer.io import (
28
+ GroundTruthSet,
29
+ PredictionSet,
30
+ compute_line_loading,
31
+ load_ground_truth,
32
+ load_predictions,
33
+ )
34
+ from gridfm_trustlayer.io.artifact import (
35
+ load_interval_artifact,
36
+ load_interval_artifacts,
37
+ save_interval_artifact,
38
+ )
39
+ from gridfm_trustlayer.io.embeddings import load_scenario_embeddings
40
+ from gridfm_trustlayer.screening import classify_status, scenario_status
41
+
42
+ __all__ = [
43
+ "ChannelSet",
44
+ "GroundTruthSet",
45
+ "KernelIntervalCalibrator",
46
+ "NaiveIntervalCalibrator",
47
+ "PredictionSet",
48
+ "__version__",
49
+ "classify_status",
50
+ "compute_line_loading",
51
+ "load_ground_truth",
52
+ "load_interval_artifact",
53
+ "load_interval_artifacts",
54
+ "load_predictions",
55
+ "load_scenario_embeddings",
56
+ "save_interval_artifact",
57
+ "scenario_status",
58
+ ]
@@ -0,0 +1,9 @@
1
+ """`python -m gridfm_trustlayer`, the same as the `gridfm_trustlayer` command."""
2
+
3
+ import sys
4
+
5
+ from gridfm_trustlayer.cli import main
6
+
7
+ # Guarded: worker processes are spawned and import the main module again.
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -0,0 +1,3 @@
1
+ """Package version information."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Command-line interface for gridfm_trustlayer."""
2
+
3
+ from gridfm_trustlayer.cli.main import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,540 @@
1
+ """`apply`: bounds and statuses for new scenarios, without their AC solution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import math
7
+ import os
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass, field
10
+ from functools import partial
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ import pandas as pd # type: ignore[import-untyped]
16
+
17
+ from gridfm_trustlayer.cli import common, reports, status
18
+ from gridfm_trustlayer.cli.common import (
19
+ _SCENARIOS_FILE_TEXT,
20
+ _SET_POINTS,
21
+ _STATUS_FILE_TEXT,
22
+ SCREEN_BOUND_TOLERANCE,
23
+ _add_known_columns,
24
+ _analysis_scenario_metadata_columns_from_frame,
25
+ _block_jobs,
26
+ _build_bus_channel_set,
27
+ _build_entity_output_frame,
28
+ _build_interval_frame,
29
+ _build_pred_bin_frame,
30
+ _build_scenario_columns,
31
+ _common_parent,
32
+ _default_output_dir,
33
+ _embedding_spec,
34
+ _EmbeddingSource,
35
+ _ground_truth_reference,
36
+ _known_mask,
37
+ _merge_kernel_diagnostics,
38
+ _merge_pooled_columns,
39
+ _ordered_analysis_strat_columns,
40
+ _parse_screen_bounds,
41
+ _report_progress,
42
+ _scenario_strata,
43
+ _share_threads,
44
+ _stratification_applies_to_variable,
45
+ _stratum_label,
46
+ _write_block,
47
+ _write_status_tables,
48
+ )
49
+ from gridfm_trustlayer.cli.config import (
50
+ coalesce_option,
51
+ load_cli_config,
52
+ resolve_command_config,
53
+ validate_command_config,
54
+ )
55
+ from gridfm_trustlayer.conformal import ChannelSet, NaiveIntervalCalibrator
56
+ from gridfm_trustlayer.conformal.kernel import KernelIntervalCalibrator
57
+ from gridfm_trustlayer.io import (
58
+ GroundTruthSet,
59
+ PredictionSet,
60
+ compute_line_loading,
61
+ load_ground_truth,
62
+ load_predictions,
63
+ )
64
+ from gridfm_trustlayer.io.artifact import load_interval_artifacts
65
+ from gridfm_trustlayer.io.ground_truth import (
66
+ GroundTruthReference,
67
+ )
68
+ from gridfm_trustlayer.io.parquet import (
69
+ clear_partitioned_output,
70
+ column_names,
71
+ read_frame,
72
+ read_scenario_ids,
73
+ rows_per_scenario,
74
+ scenario_blocks,
75
+ )
76
+ from gridfm_trustlayer.parallel import block_rows, map_blocks
77
+
78
+ # Stratifications computed from the description of each scenario: its outages
79
+ # (branch and generator status) or its total load.
80
+ _DESCRIPTION_STRATA = {"n_outages", "outage_type", "system_load_bin"}
81
+
82
+
83
+ def _run_apply(args: argparse.Namespace) -> None:
84
+ """Bounds for new scenarios, which need no known AC solution.
85
+
86
+ Same computation as `calibrate`, without the coverage analysis, and with
87
+ `screen_bounds` the statuses of `screen`, without the truth: only the
88
+ predictions are needed, plus the description of each scenario (topology,
89
+ branch parameters, loads) when the layer is stratified by outages or load
90
+ or calibrates line loading, and the embeddings for `cp_type: kernel`.
91
+ """
92
+
93
+ config = resolve_command_config(load_cli_config(args.config), "apply")
94
+ validate_command_config("apply", config)
95
+ predictions_path = str(
96
+ coalesce_option(args.predictions, config, "predictions", required=True)
97
+ )
98
+ artifact_path = coalesce_option(args.artifact, config, "artifact", required=True)
99
+ calibrators = load_interval_artifacts(artifact_path)
100
+ task_name = coalesce_option(args.task_name, config, "task_name")
101
+ generator_path = coalesce_option(
102
+ args.generator_predictions, config, "generator_predictions"
103
+ )
104
+ scenario_ids = read_scenario_ids(predictions_path)
105
+ if scenario_ids.size == 0:
106
+ raise ValueError(f"{predictions_path} contains no scenarios.")
107
+ task = load_predictions(
108
+ predictions_path,
109
+ task_name=task_name,
110
+ generator_parquet_path=generator_path,
111
+ scenario_ids=scenario_ids[:1],
112
+ ).task_name
113
+ _require_set_point_columns(predictions_path, task)
114
+
115
+ reasons = sorted(
116
+ {
117
+ f"strat: {calibrator.strat}"
118
+ for calibrator in calibrators.values()
119
+ if calibrator.strat in _DESCRIPTION_STRATA
120
+ }
121
+ | {
122
+ "line_loading"
123
+ for calibrator in calibrators.values()
124
+ if calibrator.entity_name == "branch"
125
+ }
126
+ )
127
+ description_paths = None
128
+ reference = None
129
+ if reasons:
130
+ pf_node = coalesce_option(args.pf_node, config, "pf_node")
131
+ pf_edge = coalesce_option(args.pf_edge, config, "pf_edge")
132
+ if pf_node is None or pf_edge is None:
133
+ raise ValueError(
134
+ f"This layer ({', '.join(reasons)}) needs the description of the new "
135
+ "scenarios: set inputs.apply.pf_node and inputs.apply.pf_edge (or "
136
+ "--pf-node / --pf-edge) to their bus and branch files. Their topology, "
137
+ "branch parameters and loads are read; no AC solution is needed.",
138
+ )
139
+ description_paths = (str(pf_node), str(pf_edge))
140
+ reference = _ground_truth_reference(calibrators.values(), description_paths)
141
+ has_bus_targets = any(
142
+ calibrator.entity_name == "bus" for calibrator in calibrators.values()
143
+ )
144
+ output_paths = {
145
+ target_kind: _resolve_apply_output_path(
146
+ args,
147
+ config,
148
+ predictions_path,
149
+ calibrator,
150
+ target_kind=target_kind,
151
+ multiple_targets=len(calibrators) > 1,
152
+ )
153
+ for target_kind, calibrator in calibrators.items()
154
+ }
155
+ embeddings = _embedding_spec(calibrators, args, config)
156
+ inputs = (
157
+ predictions_path,
158
+ *(description_paths or ()),
159
+ artifact_path,
160
+ generator_path,
161
+ None if embeddings is None else embeddings[0],
162
+ )
163
+ for path in output_paths.values():
164
+ clear_partitioned_output(path, inputs=inputs)
165
+ # With screen_bounds, every value and scenario also gets its status.
166
+ variables = sorted(
167
+ (name for c in calibrators.values() for name in c.channel_names),
168
+ key=reports.variable_rank,
169
+ )
170
+ screen_bounds = {
171
+ variable: bounds
172
+ for variable, bounds in _parse_screen_bounds(
173
+ config.get("screen_bounds")
174
+ ).items()
175
+ if variable in variables
176
+ }
177
+ n_jobs = _block_jobs(args, config)
178
+ rows = rows_per_scenario(predictions_path, len(scenario_ids))
179
+ blocks = scenario_blocks(
180
+ scenario_ids,
181
+ rows,
182
+ block_rows(common._BLOCK_ROWS, rows * len(scenario_ids), n_jobs),
183
+ )
184
+ job = _ApplyJob(
185
+ predictions_path=predictions_path,
186
+ task_name=task_name,
187
+ generator_path=generator_path,
188
+ description_paths=description_paths,
189
+ reference=reference,
190
+ artifact_path=str(artifact_path),
191
+ output_paths={kind: str(path) for kind, path in output_paths.items()},
192
+ has_bus_targets=has_bus_targets,
193
+ embeddings=embeddings,
194
+ scenario_ids=scenario_ids,
195
+ screen_bounds=screen_bounds,
196
+ calibrators=calibrators,
197
+ )
198
+ results = map_blocks(
199
+ job,
200
+ blocks,
201
+ n_jobs=n_jobs,
202
+ progress=partial(_report_progress, "apply", blocks=blocks),
203
+ )
204
+ status_counts = [part for result in results for part in result[0]]
205
+ scenario_parts = [part for result in results for part in result[1]]
206
+ data_dir = _common_parent(output_paths.values())
207
+ print(
208
+ f"apply: bounds for {', '.join(variables)} in {len(scenario_ids):,} scenarios; "
209
+ f"outputs in {data_dir}",
210
+ flush=True,
211
+ )
212
+ if scenario_parts:
213
+ status_dir = Path(str(config.get("status_dir") or Path(data_dir) / "status"))
214
+ status_dir.mkdir(parents=True, exist_ok=True)
215
+ table, counts = _write_status_tables(
216
+ scenario_parts, status_counts, status_dir, truth=False
217
+ )
218
+ limits = _limits_text(screen_bounds)
219
+ status.write_apply_summary(
220
+ status_dir / "summary.md",
221
+ counts,
222
+ table,
223
+ files=[
224
+ ("scenarios.csv", _SCENARIOS_FILE_TEXT),
225
+ ("status_by_stratum.csv", _STATUS_FILE_TEXT),
226
+ *(
227
+ (
228
+ os.path.relpath(path, status_dir),
229
+ _APPLIED_DATA_TEXT,
230
+ )
231
+ for path in output_paths.values()
232
+ ),
233
+ ],
234
+ context=[
235
+ f"- Limits: {limits}.",
236
+ f"- New scenarios: `{predictions_path}` ({len(scenario_ids):,} scenarios).",
237
+ f"- Artifact: `{artifact_path}`.",
238
+ "- Known values (set-points) and out-of-service lines are not screened.",
239
+ ],
240
+ )
241
+ print(status.terminal_line(counts, command="apply", truth=False), flush=True)
242
+ print(f"apply: status report in {status_dir}", flush=True)
243
+
244
+
245
+ @dataclass
246
+ class _ApplyJob:
247
+ """`apply` on one block of new scenarios (see `parallel.BlockJob`).
248
+
249
+ Writes the block's bounds (and statuses) and returns the block's status
250
+ counts and per-scenario statuses.
251
+ """
252
+
253
+ predictions_path: str
254
+ task_name: str | None
255
+ generator_path: str | None
256
+ description_paths: tuple[str, str] | None
257
+ reference: GroundTruthReference | None
258
+ artifact_path: str
259
+ output_paths: dict[str, str]
260
+ has_bus_targets: bool
261
+ embeddings: tuple[str, str] | None
262
+ scenario_ids: np.ndarray
263
+ screen_bounds: dict[str, dict[str, float]]
264
+ calibrators: (
265
+ dict[str, NaiveIntervalCalibrator | KernelIntervalCalibrator] | None
266
+ ) = None
267
+ schemas: dict[str, Any] = field(default_factory=dict)
268
+ _embeddings: _EmbeddingSource | None = None
269
+
270
+ def __getstate__(self) -> dict[str, Any]:
271
+ return {**self.__dict__, "calibrators": None, "_embeddings": None}
272
+
273
+ def setup(self, threads: int | None) -> None:
274
+ if self.calibrators is None:
275
+ self.calibrators = load_interval_artifacts(self.artifact_path)
276
+ _share_threads(self.calibrators, threads)
277
+ if self.embeddings is not None:
278
+ path, pooling = self.embeddings
279
+ self._embeddings = _EmbeddingSource(
280
+ path, pooling, self.scenario_ids, preload=threads is None
281
+ )
282
+
283
+ def run(
284
+ self, index: int, block: np.ndarray
285
+ ) -> tuple[list[pd.DataFrame], list[pd.DataFrame]]:
286
+ assert self.calibrators is not None
287
+ predictions = load_predictions(
288
+ self.predictions_path,
289
+ task_name=self.task_name,
290
+ generator_parquet_path=self.generator_path,
291
+ scenario_ids=block,
292
+ )
293
+ _require_set_points(predictions)
294
+ if self._embeddings is not None:
295
+ self._embeddings.attach(predictions)
296
+ description = None
297
+ if self.description_paths is not None:
298
+ description = load_ground_truth(
299
+ *self.description_paths,
300
+ scenario_ids=predictions.scenario_ids,
301
+ reference=self.reference,
302
+ solved=False,
303
+ )
304
+ original_frame = (
305
+ read_frame(self.predictions_path, scenario_ids=block)
306
+ if self.has_bus_targets
307
+ else None
308
+ )
309
+ status_counts: list[pd.DataFrame] = []
310
+ scenario_parts: list[pd.DataFrame] = []
311
+ for kind, calibrator in self.calibrators.items():
312
+ frame = _bounds_block_frame(
313
+ calibrator, predictions, description, original_frame
314
+ )
315
+ screened = [v for v in calibrator.channel_names if v in self.screen_bounds]
316
+ if screened:
317
+ frame, values = _add_status_columns(frame, screened, self.screen_bounds)
318
+ status_counts.append(
319
+ status.element_counts(
320
+ values,
321
+ _ordered_analysis_strat_columns(values.columns),
322
+ label=_stratum_label,
323
+ truth=False,
324
+ applies=_stratification_applies_to_variable,
325
+ )
326
+ )
327
+ scenario_parts.append(
328
+ status.scenario_rows(
329
+ values, _scenario_strata(values.columns), truth=False
330
+ )
331
+ )
332
+ _write_block(self.output_paths[kind], frame, index, self.schemas, kind)
333
+ return status_counts, scenario_parts
334
+
335
+
336
+ def _limits_text(bounds_by_variable: Mapping[str, Mapping[str, float]]) -> str:
337
+ return "; ".join(
338
+ f"`{variable}` ≤ {bounds['upper']:g}"
339
+ if math.isnan(bounds["lower"])
340
+ else f"`{variable}` in [{bounds['lower']:g}, {bounds['upper']:g}]"
341
+ for variable, bounds in bounds_by_variable.items()
342
+ )
343
+
344
+
345
+ def _add_status_columns(
346
+ frame: pd.DataFrame,
347
+ variables: Sequence[str],
348
+ bounds_by_variable: Mapping[str, Mapping[str, float]],
349
+ ) -> tuple[pd.DataFrame, pd.DataFrame]:
350
+ """`<variable>_status` and `<variable>_point_status` of new scenarios.
351
+
352
+ Known values (set-points) and out-of-service lines are not screened: their
353
+ statuses are empty. Returns the frame and the screened values, for counting.
354
+ """
355
+
356
+ values: list[pd.DataFrame] = []
357
+ for variable in variables:
358
+ bounds = bounds_by_variable[variable]
359
+ pred = frame[f"{variable}_pred"].to_numpy(dtype=float)
360
+ screened = ~_known_mask(frame, variable)
361
+ if variable == "line_loading":
362
+ screened &= pred >= 0.0
363
+ lower_limit = float(bounds["lower"]) - SCREEN_BOUND_TOLERANCE
364
+ interval, point = status.classify(
365
+ pred[screened],
366
+ frame[f"{variable}_pi_lower"].to_numpy(dtype=float)[screened],
367
+ frame[f"{variable}_pi_upper"].to_numpy(dtype=float)[screened],
368
+ float("nan") if math.isnan(lower_limit) else lower_limit,
369
+ float(bounds["upper"]) + SCREEN_BOUND_TOLERANCE,
370
+ )
371
+ for column, statuses in (("status", interval), ("point_status", point)):
372
+ codes = np.full(len(frame), -1, dtype=np.int8)
373
+ codes[screened] = statuses.codes
374
+ frame[f"{variable}_{column}"] = pd.Categorical.from_codes(
375
+ codes, status.ELEMENT_STATUSES
376
+ )
377
+ strata = _analysis_scenario_metadata_columns_from_frame(
378
+ frame, variable=variable
379
+ )
380
+ rows = frame.loc[screened, ["scenario", *strata]].copy()
381
+ rows["variable"] = variable
382
+ rows["status"] = interval
383
+ rows["point_status"] = point
384
+ values.append(rows)
385
+ return frame, pd.concat(values, ignore_index=True)
386
+
387
+
388
+ def _require_set_point_columns(predictions_path: str, task: str) -> None:
389
+ columns = {name.lower() for name in column_names(predictions_path)}
390
+ missing = [
391
+ f"{variable}_target"
392
+ for variable, _ in _SET_POINTS.get(task, ())
393
+ if f"{variable.lower()}_target" not in columns
394
+ ]
395
+ if missing:
396
+ raise ValueError(
397
+ f"{task} predictions need the known set-points of each scenario in "
398
+ f"{', '.join(missing)}: "
399
+ + ", ".join(f"{v} at {bus} buses" for v, bus in _SET_POINTS[task])
400
+ + ". The model's predictions there are replaced by these inputs, as when "
401
+ "the layer was fitted. Values at other buses may be missing.",
402
+ )
403
+
404
+
405
+ def _require_set_points(predictions: PredictionSet) -> None:
406
+ for variable, bus_type in _SET_POINTS.get(predictions.task_name, ()):
407
+ mask = predictions.bus_type_masks.get(bus_type)
408
+ values = predictions.Vm if variable == "Vm" else predictions.Va
409
+ if mask is not None and not np.isfinite(values[mask]).all():
410
+ raise ValueError(
411
+ f"{variable}_target is missing at some {bus_type} buses: these "
412
+ f"set-points are inputs of each {predictions.task_name} scenario and "
413
+ "are needed to apply the layer.",
414
+ )
415
+
416
+
417
+ def _resolve_apply_output_path(
418
+ args: argparse.Namespace,
419
+ config: dict[str, object],
420
+ predictions_path: str,
421
+ calibrator: NaiveIntervalCalibrator | KernelIntervalCalibrator,
422
+ *,
423
+ target_kind: str,
424
+ multiple_targets: bool,
425
+ ) -> str:
426
+ """Where `apply` writes one target family.
427
+
428
+ The default name ends in `_bounds.parquet`, not `calibrate`'s
429
+ `_with_intervals.parquet`: new and test predictions often share a file
430
+ name, and the two runs must not overwrite each other.
431
+ """
432
+
433
+ stem = Path(predictions_path).stem
434
+ variables = "_".join(name.lower() for name in calibrator.channel_names)
435
+ default_name = f"{stem}_{variables}_bounds.parquet"
436
+ if args.output is not None:
437
+ output = Path(str(args.output))
438
+ return str(output / default_name if multiple_targets else output)
439
+ explicit = config.get(f"applied_predictions_{target_kind}")
440
+ if explicit is not None:
441
+ return str(explicit)
442
+ shared = config.get("applied_predictions")
443
+ if shared is not None:
444
+ if not multiple_targets:
445
+ return str(shared)
446
+ if Path(str(shared)).suffix.lower() == ".parquet":
447
+ raise ValueError(
448
+ "Bounds for voltages and line loading are written separately. Set "
449
+ "outputs.applied_predictions to a directory, or set "
450
+ "outputs.applied_predictions_volt and outputs.applied_predictions_line.",
451
+ )
452
+ return str(Path(str(shared)) / default_name)
453
+ task_name = str(coalesce_option(args.task_name, config, "task_name") or "unknown")
454
+ cp_type = (
455
+ "kernel" if isinstance(calibrator, KernelIntervalCalibrator) else "element"
456
+ )
457
+ return str(_default_output_dir(task_name, cp_type) / default_name)
458
+
459
+
460
+ def _bounds_block_frame(
461
+ calibrator: NaiveIntervalCalibrator | KernelIntervalCalibrator,
462
+ predictions: PredictionSet,
463
+ description: GroundTruthSet | None,
464
+ original_frame: pd.DataFrame | None,
465
+ ) -> pd.DataFrame:
466
+ """Rows of one block of new scenarios with their bounds, for one target family."""
467
+
468
+ scenario_columns: dict[str, np.ndarray] = {}
469
+ if description is not None:
470
+ scenario_columns = _build_scenario_columns(
471
+ _build_bus_channel_set(predictions, ("Vm",), scenario_columns=None),
472
+ description,
473
+ calibrator.strat,
474
+ system_load_n_bins=calibrator.system_load_n_bins,
475
+ system_load_bin_edges=calibrator.system_load_bin_edges,
476
+ pred_n_bins=calibrator.pred_n_bins,
477
+ pred_bin_edges=calibrator.pred_bin_edges,
478
+ )
479
+ application: ChannelSet
480
+ if calibrator.entity_name == "branch":
481
+ assert description is not None
482
+ loading = compute_line_loading(predictions.Vm, predictions.Va, description)
483
+ application = ChannelSet(
484
+ scenario_ids=predictions.scenario_ids.copy(),
485
+ entity_ids=description.branch_ids.copy(),
486
+ entity_name="branch",
487
+ channels={"line_loading": loading},
488
+ # Out-of-service or unrated branches carry -1 and get no interval.
489
+ valid_masks={"line_loading": loading >= 0.0},
490
+ entity_columns={
491
+ "from_bus": description.f_bus.astype(np.int64, copy=True),
492
+ "to_bus": description.t_bus.astype(np.int64, copy=True),
493
+ "rate_a": description.rate_a.astype(np.float32, copy=True),
494
+ },
495
+ scenario_columns=scenario_columns,
496
+ scenario_embeddings=predictions.scenario_embeddings,
497
+ )
498
+ intervals = calibrator.predict_intervals(application)
499
+ output_frame = _build_entity_output_frame(application, None, intervals)
500
+ else:
501
+ assert original_frame is not None
502
+ application = _build_bus_channel_set(
503
+ predictions, calibrator.channel_names, scenario_columns=scenario_columns
504
+ )
505
+ intervals = calibrator.predict_intervals(application)
506
+ # For new scenarios the `*_target` columns hold real values only at the
507
+ # set-points (Vm at PV buses, Va at the reference bus) and placeholders
508
+ # elsewhere, which would read as true values; they are not written.
509
+ targets = [c for c in original_frame.columns if c.lower().endswith("_target")]
510
+ output_frame = original_frame.drop(columns=targets).merge(
511
+ _build_interval_frame(application, intervals),
512
+ on=["scenario", "bus"],
513
+ how="left",
514
+ validate="one_to_one",
515
+ )
516
+ if calibrator.strat == "pred_bins":
517
+ entity = application.entity_name
518
+ output_frame = output_frame.merge(
519
+ _build_pred_bin_frame(application, calibrator.pred_bin_edges),
520
+ on=["scenario", entity],
521
+ how="left",
522
+ validate="one_to_one",
523
+ )
524
+ return _merge_kernel_diagnostics(
525
+ _merge_pooled_columns(
526
+ _add_known_columns(
527
+ output_frame, calibrator.channel_names, predictions.task_name
528
+ ),
529
+ calibrator,
530
+ application,
531
+ ),
532
+ calibrator,
533
+ application.scenario_ids,
534
+ )
535
+
536
+
537
+ _APPLIED_DATA_TEXT = (
538
+ "the bounds, with `<variable>_status` and `<variable>_point_status` for every "
539
+ "screened value"
540
+ )