Skip to content

Changelog

All notable changes to this project are documented here. The format follows Keep a Changelog, and the project uses semantic versioning. Before 1.0, minor versions may include breaking changes; they are listed under Changed.

0.1.1 - 2026-10-16

Fixes from testing the first release as a user would: following the documentation, and on edge cases of real data.

Fixed

  • apply could hang after writing its outputs, with pyarrow 26, when its output was captured (from a script, a notebook or CI): parquet datasets still cached at exit deadlocked Arrow's thread pool. The cache is now released before the process exits.
  • Commands did not find each other's default outputs. Without outputs.artifact, calibrate and apply stopped with "Missing required option 'artifact'", and with no task_name, screen looked in trustlayer_outputs/unknown/ while compute and calibrate wrote to trustlayer_outputs/powerflow/. Every command now uses one rule: trustlayer_outputs/<task_name>/<cp_type>/ (unknown when task_name is not set), and the artifact <calibration predictions>_<variables>.trustlayer there.
  • screen crashed with KeyError: 'branch' (or 'bus') when asked for a variable that was not calibrated, e.g. after dropping line_loading from variables but not from commands.screen.variables. It now says which variable is missing.
  • A line_loading lower limit in screen_bounds was used by apply but ignored by screen, so the two gave different statuses for the same scenarios. Both ignore it now, as documented: a loading is screened against its rating only.
  • Missing (NaN) predictions: apply left line values with a missing prediction unscreened while screen marked them uncertain; both mark them uncertain now. The terminal line, the summary tables and the crosstab show uncertain point-prediction statuses when there are any, instead of leaving those scenarios out of the counts.
  • Coverage of voltages counted values with no true value as misses, while line loading left them out. Both leave them out now, and so does screen, which counted them as values within their limits; a value whose prediction is missing still counts as not covered.
  • calibrate's line-loading bounds depended on the true values: a missing true voltage made them zero-width. Bounds now depend on the predictions only, and equal apply's.
  • One NaN in the embeddings switched the kernel off silently (every scenario fell back to the stratum quantile). Such embeddings are refused, naming the scenarios, by the CLI and by KernelIntervalCalibrator.
  • compute overwrote any file at the artifact path, an input included. It now refuses a path that is an input, a directory, or a file that is not a trustlayer artifact.
  • Switching a run between one and two target families with the same outputs.calibrated_predictions (or applied_predictions) failed: the two datasets were written inside the previous one, or the previous folder was refused. The previous output is now replaced.
  • A rerun with fewer variables left the previous reports behind, e.g. the line-loading tables and figures next to a new voltage-only summary. Report folders are cleared of trustlayer's own files before each report. The example configs now each write their coverage report to their own folder.
  • calibrate accepted missing set-points (NaN Vm_target at PV buses) and gave NaN bounds; it now refuses them, as apply does.
  • apply --output <file> in a run with both families gave a NotADirectoryError traceback; it now asks for a directory.
  • outputs.output and commands.calibrate.output were ignored when calibrating both families.
  • mean_width in coverage_by_stratum.csv and coverage_by_group.csv dropped every element with any infinite bound, so with sparse_strata: infinite it could be NaN overall. It is now the mean over values with finite bounds, as documented.
  • *_kernel_tuning_details.csv repeated the overall mean width on every stratum row.
  • --variables Vm,line_loading was rejected; comma-separated and space-separated lists both work.
  • The sparse-strata message counted bus-stratum pairs as buses (e.g. "3948 buses" on a 14-bus grid); it now says pairs and lists at most five strata.
  • Python API: ChannelSet(valid_masks=None) crashed in fit; channels or columns of the wrong shape were broadcast silently (now a ValueError); non-integer stratum codes were cast to integers at fit but not at prediction (now rejected); scenario_status failed on zero values; KernelIntervalCalibrator.fit required embedding_pooling, which it only records (now "mean" by default).
  • Config builder: build_config dropped unknown variables silently, and validate_config accepted alpha outside (0, 1).
  • Documentation: the Python API example "from GridFM files" fitted the power-flow set-points as predictions, giving bounds that were too narrow at generator buses; it now sets them aside, as the CLI does. The README Quick start runs apply but had no inputs.apply. Also: the extra named for line_loading (datakit), an outdated report file name, the bounds of sparse strata, the troubleshooting messages, and classify_status / scenario_status in the API reference.

Changed

  • calibrate and apply report how many values use pooled bounds (sparse_strata: pooled, no guarantee within their stratum) and, for cp_type: kernel, the share of scenarios in which some value uses the stratum quantile; apply's status summary lists the same. Before, only the n_pooled column and the kcp_* columns showed them.
  • calibrate and apply print a note when the config differs from the artifact (alpha, cp_type, embedding_pooling, variables): the artifact is used as fitted, and the note says when compute should run again.
  • compute prints a note when task_name is neither PowerFlow nor OptimalPowerFlow, since predictions are then read as they are, with no set-points set aside. Spaces in the name no longer matter (Power Flow).
  • Clearer errors: a missing input names its config key (Missing inputs.apply.predictions in the config (or --predictions)), a missing artifact says to run compute first, a file that is not an artifact says so, swapped pf_node and pf_edge files are pointed out instead of printing Arrow's schema, and duplicate or missing rows name the scenarios. alpha must lie in (0, 1), version must be 1, and screen_bounds entries take only lower and upper, as numbers, with lower below upper. A missing optional extra (datakit for line loading) is a one-line error, like the others.
  • With one target family, calibrate and apply print the path of the dataset rather than its folder.

0.1.0 - 2026-10-15

First public release.

Added

  • Two conformal methods for continuous grid-state predictions:
  • stratified split-conformal prediction (cp_type: element);
  • kernel-weighted conformal prediction (cp_type: kernel), localized in a scenario-embedding space, with n_eff (default, target 600) or knn bandwidth selection and euclidean or cosine distance.
  • Stratification by n_outages, outage_type, system_load_bin and pred_bins.
  • Direct targets (Vm, Va and other continuous task outputs) and the derived line_loading.
  • The config-first CLI, gridfm_trustlayer compute | calibrate | screen | apply, with ten example configs in examples/config/.
  • A top-level Python API: from gridfm_trustlayer import NaiveIntervalCalibrator, KernelIntervalCalibrator, ChannelSet, .... A worked example is in the README, under "Python API".
  • CITATION.cff and a contributing guide, CONTRIBUTING.md.
  • Statuses per value and per operating scenario, the same in screen and apply. A value is within_limits when its whole interval lies inside the limits, a violation when it lies entirely beyond a limit, and uncertain when it straddles one. An operating scenario takes the worst status of its values: secure, uncertain or insecure, overall and per variable. The point prediction is classified the same way, as a baseline, and reported next to the interval.
  • screen checks both against the true solution: scenarios.csv (one row per scenario), status_by_stratum.csv (counts per status, stratum and method, with how many were truly violated), new sections in summary.md, and pred, status and point_status in the screening detail. The existing alarm tables are unchanged.
  • apply gives the statuses of new scenarios when screen_bounds is set: <variable>_status and <variable>_point_status in its data, and a report in outputs.status_dir (default status/ next to the data) with the same tables without the truth, plus scenarios by interval status against point status. On labeled data it gives exactly the statuses of screen (tested).
  • The documentation now presents the workflow as fit (compute, always), validate (calibrate and screen, on scenarios with a known solution) and operate (apply, on new scenarios).
  • gridfm_trustlayer apply: bounds for new scenarios without a known AC solution (inputs.apply). Same bounds as calibrate (tested), without the coverage analysis. It reads only what the layer needs: the predictions (with the power-flow set-points, Vm_target at PV buses and Va_target at the reference bus), the topology, loads and branch parameters of each scenario when the layer is stratified by outages or load or calibrates line_loading, and the embeddings for cp_type: kernel. Outputs are named *_bounds.parquet, apart from calibrate's, and hold no *_target columns, since for new scenarios those are placeholders except at the set-points. The config builder offers the new block. load_ground_truth(..., solved=False) reads scenario descriptions without solved values.
  • gridfm_trustlayer --version, and python -m gridfm_trustlayer as an alternative to the command.
  • Each command ends with a short summary (coverage and width per variable and the weakest stratum after calibrate; caught, missed and false alarms after screen), and long runs print their progress block by block.
  • Pull request and issue templates, and Dependabot updates for the GitHub Actions.
  • Readable reports. calibrate and screen each write a summary.md (method, data, tables by stratum, figures and files) next to their CSVs, which renders on GitHub or in any Markdown viewer. Coverage is given with an approximate 95% interval and the coverage of the worst bus or line, and screen reports caught, missed and false alarms per stratum (screening_by_stratum.csv), not only overall and per element. The reports of calibrate go to outputs.coverage_dir, by default coverage/ next to the calibrated predictions. screen takes --no-figures and save_figures: false, like calibrate.
  • A demo notebook, examples/demo/demo.ipynb: the whole workflow on IEEE 14-bus N-1 to N-5 contingencies, with predictions from the released GENCO checkpoint gridfm/genco-pf-datakit (case14_ieee/small/seed0) on freshly generated datakit scenarios: the model's error by number of outages, coverage, why the bounds are stratified by number of outages (against one set of bounds), screening against the point prediction (with every overload the point prediction misses, and the misses by number of outages), new scenarios without their solution, how kernel-weighted bounds adapt to each scenario and screen with them, the trade-off alpha sets between AC power flows and missed overloads, and how coverage weakens for lines loaded near their rating (with element, strat: pred_bins and the kernel). The data (32 MB: 9,723 calibration, 4,848 test and 294 new scenarios) is included. It runs in under a minute, is saved with its outputs, and CI runs it and checks the results the docs quote.
  • Parallel processes, n_jobs (config) or --n-jobs (every command). The cross-validated bandwidth choice (n_eff_target: auto) runs its fold × candidate fits in 4 processes by default, with the same result: on 20,000 IEEE-118 calibration scenarios, 37 s instead of 75 s, at 5.0 GB of peak memory instead of 2.4 GB. When n_jobs is set, every command also processes its blocks of scenarios in parallel; this is off by default because on IEEE-118 test sets of 5,000 and 20,000 scenarios it was slower and took more memory, as reading parquet dominates and Arrow already uses several threads. Outputs are identical either way (tested).
  • load_interval_artifacts in the Python API: reads any artifact, including the mixed bus-and-line artifacts of the CLI, and returns its calibrators by family ("volt", "line").
  • classify_status and scenario_status in the Python API: the screening rule of screen and apply, for any bounds.
  • A release workflow: publishing a GitHub release builds the sdist and wheel, checks that the tag matches the package version, and attaches both to the release.
  • sparse_strata: a bus or line with fewer than ceil(2 / alpha) - 1 valid calibration samples in a stratum (19 at alpha = 0.1), or in a test stratum absent from the calibration data, gets its quantile over all calibration scenarios there (pooled, the default), or infinite bounds (infinite, which keeps the per-stratum guarantee). Pooled bounds lose the guarantee within the stratum, so they are never silent: compute lists the strata concerned, a <variable>_pooled column marks the rows, and the coverage report counts them in n_pooled. The common case is the voltage magnitude of generator buses, which is predicted only when the generator is out: in the N-1 stratum, only when it is the single outage. Also compute --sparse-strata, the config builder and the Python API (fit(..., sparse_strata=...), pooled_masks).
  • Known values are reported apart. In a power flow, Vm at PV buses and Va at the reference bus are set-points given to the model, always inside their (zero-width) bounds. They are now left out of every coverage, width and screening number, counted in n_known columns, and named in the reports and terminal summaries (e.g. 261,580 known values not counted). The calibrated and applied voltage predictions mark them in Vm_known and Va_known.
  • Cross-validated choice of the kernel bandwidth: n_eff_target: auto (with cp_type: kernel, bandwidth: n_eff) makes compute pick the target, and optionally the distance (distances: [euclidean, cosine]), by K-fold cross-validation on the calibration set only, over a grid or a random sample with a fixed budget, grouped by load profile when datakit's load_scenario_idx is available. The rule is coverage first, then the narrowest intervals, with uniform weights as the fallback. Off by default; options under tuning:. The choice is recorded in the artifact and reported in <artifact>_kernel_tuning*.csv. Also available from Python as gridfm_trustlayer.conformal.tuning.tune_n_eff_target.
  • A config builder: gridfm_trustlayer.interactive.config_builder(), a Jupyter form (optional extra interactive; notebook in examples/notebooks/config_builder.ipynb). It shows only the options that apply and validates the result with the CLI's own checks. The underlying build_config() works without Jupyter.
  • A documentation site, https://gridfm.github.io/gridfm-trustlayer/, built with MkDocs from docs/ and published to GitHub Pages on every push to main (.github/workflows/docs.yml): getting-started guides, a user guide, references for the CLI, the configuration keys, the data schemas and the Python API, and the methodology.

Changed (relative to the internal 0.0.1 snapshot)

  • Breaking: calibration and test data must be separate datasets. The config now takes inputs.calibration (read by compute) and inputs.test (read by calibrate and screen), each with its own predictions, pf_node, pf_edge and, for cp_type: kernel, embeddings.
  • The in-file split is removed: calibration_fraction, random_seed, --calibration-fraction and --random-seed are no longer accepted.
  • So is the in-sample mode, in which calibrate evaluated on the scenarios compute was fitted on.
  • A random split of one file hides near-duplicate scenarios on both sides, which inflates coverage and kernel gains. The in-sample mode measured coverage on the fitting data.
  • The config is rejected if both blocks point at the same file, and calibrate refuses test predictions identical to the calibration predictions, using a fingerprint stored in the artifact.
  • Migration: move the old flat inputs keys under inputs.calibration and add an inputs.test block for an independently generated dataset. A flat inputs block now fails with a message pointing here.
  • analysis_pf_node / analysis_pf_edge and per-command commands.<verb>.inputs overrides are removed, since each command reads its own dataset block.
  • Calibration quantiles now use the split-conformal finite-sample correction. Each tail at level alpha / 2 takes the ceil((n + 1)(1 - alpha / 2))-th smallest residual for the upper bound and the floor((n + 1) alpha / 2)-th for the lower bound. Version 0.0.1 used the interpolated empirical quantile, which does not carry the finite-sample coverage guarantee. Bounds are therefore slightly wider (by at most one order statistic per tail). This applies to cp_type: element and to the stratum fallback quantiles of cp_type: kernel.
  • No more zero-width bounds from too few calibration samples. 0.0.1 returned a zero-width interval where an element had no valid residual. A finite bound at level alpha / 2 needs at least ceil(2 / alpha) - 1 valid residuals, e.g. 19 at alpha = 0.1; a stratum with fewer now uses the element's quantile over all strata (see sparse_strata), and an element with fewer over all strata gets infinite bounds. Figures plot the finite widths; the CSV outputs count the infinite ones.
  • Kernel defaults: bandwidth: n_eff with n_eff_target: 600 (0.0.1: bandwidth: knn, k_nn: 20). Artifacts record their bandwidth, so existing artifacts are read unchanged.
  • The package version is defined once, in gridfm_trustlayer/_version.py, and read by the build.
  • Large outputs are partitioned parquet datasets, in the layout of gridfm-datakit: the calibrated predictions (*_with_intervals.parquet) and the screening detail are directories with one scenario_partition=k/ folder per 200 scenarios, zstd-compressed. pd.read_parquet(path) reads them as one table. The screening detail is now parquet (screening_detail_*.parquet/) instead of CSV, with one row per scenario, element and variable holding the true value, the bounds and four violation flags; the per-row limits, duplicated value columns and 0/1 count columns are gone, since the counts follow from the flags. On a 20,000-scenario IEEE-118 test set, screen writes 58 MB instead of 1 GB. Inputs may be single files or partitioned directories.
  • calibrate and screen process the test set block by block (about 500,000 rows at a time): each block is read with only the needed columns and scenarios, calibrated, screened and written, and the reports are built from per-entity sums, so memory no longer grows in proportion to the test set. Reports and rows are identical whatever the block size (tested). On a 20,000-scenario IEEE-118 test set, peak memory fell from 6.3 to 2.8 GB for calibrate and from 3.1 to 1.3 GB for screen, which also runs about seven times faster. The loaders read only the columns they need and no longer re-sort the data for every column, which also lowers the memory of compute.
  • The calibrated voltage predictions include Vm_target / Va_target from the ground truth when the predictions file has none, so screen never needs to reload it.
  • cp_type: kernel is several times faster and uses less memory. The weighted quantile now takes each element's total weight from one matrix product and scans only the tails of the sorted residuals, in two levels (blocks, then positions), instead of accumulating the weights over all of them; scenarios whose total weight cannot reach the level skip it entirely; distances, bandwidth and weights are computed once per scenario and shared by all variables; and the test embeddings are read once when they fit in memory. The quantiles are identical to the full computation (tested). On synthetic data at IEEE-118 scale, predict_intervals is 6-12x faster with a third of the peak memory, and the cross-validated bandwidth choice (n_eff_target: auto) about 5x faster.
  • The bandwidth solve of bandwidth: n_eff reaches the target effective sample size to a relative 1e-6 instead of 1e-3, using safeguarded Newton steps (5-16 iterations, against 15 bisection steps for the old tolerance) and stopping each test scenario as soon as it converges. Previously all scenarios of a batch iterated until the last one converged, so a scenario's bandwidth depended on which others were processed with it, and a weighted quantile could move to a neighbouring residual depending on where the solve stopped. With the tighter tolerance, two different solvers give the same bounds for 99.996% of the entries (IEEE-118, 10,000 test scenarios). Compared with the old tolerance, about 0.5% of individual bounds change; coverage and width summaries are unchanged to four decimals.
  • Breaking: new names and layout for the reports, with fewer, clearer files and rounded numbers:
  • calibrate: coverage/summary.md, coverage_by_stratum.csv (was trust_reliability_*.csv), coverage_by_group.csv (was trust_summary_*.csv), coverage_by_element_{voltage,line_loading}.csv (was trust_analysis_*.csv), and three figures per variable in coverage/figures/: coverage by stratum with its interval, coverage per bus or line, width by stratum. Voltage and line-loading results share one table per level instead of one file per family.
  • screen: summary.md, screening_by_stratum.csv (new), screening_by_group.csv (was trust_screen_summary_*.csv), screening_by_element_{voltage,line_loading}.csv (was trust_screen_entity_*.csv), screening_detail_{voltage,line_loading}.parquet/ (was trust_screen_detail_*.parquet/), and figures/ (was screen_figures/) with a confusion matrix and, with strat, recall and precision per stratum. The tables have one row per screened side (lower, upper, any) and name the counts caught, missed, false_alarms and cleared instead of tp, fn, fp and tn.
  • Terminal summaries give widths with their unit (Va in degrees) and counts with thousands separators.
  • compute reads the calibration set block by block, like the other commands, and keeps only the arrays the calibrators need. The artifact is identical (tested, and checked on IEEE-118); peak memory on 20,000 IEEE-118 calibration scenarios fell from 3.0 to 2.0 GB, and no longer grows with the number of calibration scenarios.
  • The two screening figures were laid out twice; they now take half the time.
  • The command-line code is split into one module per command (gridfm_trustlayer/cli/compute.py, calibrate.py, screen.py, apply.py, with shared helpers in common.py); CONTRIBUTING.md maps the code. The CI caches its pip downloads.
  • Outages and angle units are judged as in the calibration set. The artifact stores which branches and generators are in service in the calibration set and whether its angles are in degrees; calibrate, screen and apply count outages and convert angles against it, so a scenario gets the same stratum whatever other scenarios it comes with. Artifacts without it fall back to a scan of the data at hand, as before.
  • Installation: trustlayer is published on PyPI (pip install gridfm-trustlayer). A new datakit extra installs what line_loading needs (gridfm-datakit 1.1.0 or later); graphkit adds gridfm-graphkit, which needs PyTorch. The juliapkg<0.1.26 cap is gone, since datakit 1.1.0 supports both juliapkg APIs. Minimum versions: pyarrow 16 and matplotlib 3.8.4, the first that work with numpy 2.
  • CLI errors (a bad config, a missing file) are one line on stderr and exit code 2 instead of a traceback; GRIDFM_TRUSTLAYER_DEBUG=1 shows the traceback.
  • An output path is only replaced if it holds a previous trustlayer output (a parquet file or dataset with interval bounds), and never if it is an input of the command.
  • A missing (NaN) bound now makes a value uncertain instead of within_limits.
  • outputs.screened_predictions, accepted but never used, is removed.
  • scripts/inspect_parquet.py is removed; read outputs with pd.read_parquet or pyarrow (see "Reading the results" in the docs).

Fixed

  • screen failed to find the calibrated data of a single-family run (e.g. line_loading only) when outputs.calibrated_predictions was a folder-like path: calibrate writes the partitioned dataset at that very path, and screen looked for files inside it.
  • Config validation took a missing bandwidth as knn, while the kernel's default is n_eff: n_eff_target: auto was rejected unless bandwidth: n_eff was written out, and the check that n_eff_target is not below n_eff_min was skipped for configs relying on the default.
  • The bandwidth solve of bandwidth: n_eff could print an overflow RuntimeWarning when the effective sample size is flat in the bandwidth; the step was already discarded in favour of bisection, so results are unchanged.
  • The width box plots used an argument of matplotlib that is deprecated from 3.11 and removed in 3.13, which would have broken the figures with a future matplotlib.
  • cp_type: kernel gave zero-width bounds to test scenarios in a stratum absent from the calibration data, e.g. N-6 when calibration stops at N-5. They now get the pooled quantile (or infinite bounds with sparse_strata: infinite), and kcp_fallback marks them. The cross-validated bandwidth choice was affected in the same way when a fold held a stratum that the other folds lacked.
  • With cp_type: element, a test stratum absent from the calibration data used the unstratified quantile silently; it now follows sparse_strata like any sparse stratum, and is marked and counted.
  • Set-points no longer shrink the bounds of buses that are PV in only some scenarios. Their zero residuals (the prediction is replaced by the set-point) entered the calibration and pulled the quantiles towards zero, so the Vm bounds of such buses were far too narrow when the bus was PQ. Known values are now excluded from the fit. On a 5,000-scenario IEEE-118 test set, Vm coverage at these buses rose from 0.001 to 0.919. Buses of a single type were not affected.
  • Coverage and screening no longer count the set-points, which were always covered and inflated the reported Vm and Va coverage.
  • The screening confusion matrix swapped false alarms and misses in its labels; it is now drawn with true violations in rows and alarms in columns, and each cell named.
  • screen now finds the calibrated parquets inside a directory. Before, it failed when outputs.calibrated_predictions was a directory (a mixed voltage and line-loading run), reading the whole directory as one parquet. It also failed with the default output paths whenever fewer variables were screened than calibrated, e.g. screening Vm after calibrating Vm and Va.
  • In a run with one target family, calibrate now writes to outputs.calibrated_predictions_volt / _line when set, which is where screen reads from.
  • The kernel_fallback diagnostic of KernelIntervalCalibrator no longer flags every test scenario when some element has no valid calibration residual in its stratum, e.g. a branch that is out of service in every scenario. Such an element still uses the fallback bound, but it no longer counts as a kernel fallback. The bounds themselves were not affected.
  • Box plots in the analysis figures no longer produce NaN percentiles (and a RuntimeWarning) when some widths are infinite.
  • A scenario alone, or in a small batch, could get the wrong stratum. An outage only counted for an element in service somewhere in the same dataset, so apply on a single N-1 scenario counted N-0 and gave it the narrower N-0 bounds; and angle units were guessed per dataset, so a test set with small angles could stay in degrees. Both now follow the calibration set (see Changed).
  • Missing true or predicted values (NaN or infinite) are left out of the fit. Before, element returned a NaN quantile for that element in every scenario, and kernel counted the value as a perfect prediction, narrowing its bounds.
  • --output pointing at an input deleted it before the run failed, including datakit's raw data, which has the same partitioned layout as trustlayer outputs.
  • scenario_status raised KeyError on missing statuses (None, as apply writes for known values and out-of-service lines), even where they were masked out.
  • kcp_fallback flagged nearly every PowerFlow scenario: a generator bus has a valid Vm residual in few calibration scenarios, and counted even where its Vm is a known set-point. Only the elements with an interval in the test scenario count now; the bounds were not affected. The bandwidth tuning's fallback_share had the same distortion.
  • kcp_n_eff could be 0 or NaN for embeddings with many dimensions, where the squared kernel weights underflow in float32; it is now taken from the bandwidth solve.
  • With strat: pred_bins, the known values (which have no prediction bin) formed a second "all" row per variable in coverage_by_stratum.csv and screening_by_stratum.csv.
  • commands.compute.n_eff_target: auto with a top-level tuning block made the other commands fail; tuning is now checked by compute only.
  • tuning.n_eff_range without a whole number in it (e.g. [1.5, 1.7]) gave a candidate outside the range; it is now rejected.
  • pred_bins errors named system_load_bin.