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.
- gridfm_trustlayer/__init__.py +58 -0
- gridfm_trustlayer/__main__.py +9 -0
- gridfm_trustlayer/_version.py +3 -0
- gridfm_trustlayer/cli/__init__.py +5 -0
- gridfm_trustlayer/cli/apply.py +540 -0
- gridfm_trustlayer/cli/calibrate.py +787 -0
- gridfm_trustlayer/cli/common.py +1190 -0
- gridfm_trustlayer/cli/compute.py +591 -0
- gridfm_trustlayer/cli/config.py +597 -0
- gridfm_trustlayer/cli/main.py +354 -0
- gridfm_trustlayer/cli/reports.py +844 -0
- gridfm_trustlayer/cli/screen.py +980 -0
- gridfm_trustlayer/cli/status.py +513 -0
- gridfm_trustlayer/conformal/__init__.py +6 -0
- gridfm_trustlayer/conformal/calibrator.py +847 -0
- gridfm_trustlayer/conformal/kernel.py +1213 -0
- gridfm_trustlayer/conformal/tuning.py +631 -0
- gridfm_trustlayer/interactive.py +882 -0
- gridfm_trustlayer/io/__init__.py +18 -0
- gridfm_trustlayer/io/artifact.py +235 -0
- gridfm_trustlayer/io/embeddings.py +191 -0
- gridfm_trustlayer/io/ground_truth.py +670 -0
- gridfm_trustlayer/io/loading.py +121 -0
- gridfm_trustlayer/io/parquet.py +283 -0
- gridfm_trustlayer/io/predictions.py +444 -0
- gridfm_trustlayer/io/task_adapters.py +194 -0
- gridfm_trustlayer/parallel.py +195 -0
- gridfm_trustlayer/py.typed +0 -0
- gridfm_trustlayer/screening.py +116 -0
- gridfm_trustlayer-0.1.0.dist-info/METADATA +248 -0
- gridfm_trustlayer-0.1.0.dist-info/RECORD +35 -0
- gridfm_trustlayer-0.1.0.dist-info/WHEEL +5 -0
- gridfm_trustlayer-0.1.0.dist-info/entry_points.txt +2 -0
- gridfm_trustlayer-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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,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
|
+
)
|