NGED Data API
NGED JSON Data
This package reads NGED's telemetry JSON files from S3 and parses them into the PowerTimeSeries
and TimeSeriesMetadata schemas (see contracts). The metadata table and the downloaded-files
list are the only files this package owns and writes; the parsed power observations are handed back
to the caller, which appends them to the power_time_series Delta table (see Usage
below). nged_data.storage's module docstring, below on this page, says which functions read that
Delta table and which write the metadata table.
Public surface
Only upsert_metadata is re-exported from the package root (from nged_data import
upsert_metadata); the other functions live in nged_data.storage and nged_data.cleaning (from
nged_data.storage import list_timeseries_json_files, etc.).
nged_data.storage.list_timeseries_json_files(store)— lists the timeseries JSON files on NGED's S3 bucket, parsingtime_series_id,start_time, andend_timeout of each file's path.nged_data.storage.read_downloaded_files(...),write_downloaded_files(...)— read and replace the downloaded-files list, which records the bucket listing (path andLastModified) that the ingest last processed in full.nged_data.storage.select_files_not_yet_downloaded(file_listing, downloaded_files)— keeps the listed files that the downloaded-files list lacks, so each file is downloaded once.nged_data.storage.download_and_parse_files(store, paths_df)— downloads the listed files concurrently, in chunks, and parses them inend_timeorder, returning aDownloadAndParseResultofmetadata(TimeSeriesMetadata),power_time_series(PowerTimeSeries), andn_implausible_power_rows_dropped. When every file'sdatafield was null, the power frame is empty.nged_data.storage.select_new_rows(time_series, delta_path, storage_options=None)— filtersPowerTimeSeriesrows down to those missing from thepower_time_seriesDelta table atdelta_path, by an anti-join on(time_series_id, time), so a reading is kept regardless of arrival order.nged_data.storage.time_series_coverage(delta_path, storage_options=None)— the earliest and latest observationtimeon disk for eachtime_series_idin thepower_time_seriesDelta table.nged_data.storage.coverage_from_power(power)— the same earliest and latesttimepertime_series_id, from any lazyPowerTimeSeriesframe.nged_data.storage.scan_cleaned_power(delta_path, storage_options=None)— scans thecleaned_power_time_seriesDelta table, keeping only the rows no cleaning rule flagged. Every reader of observed power except the ingest and its freshness check usesscan_cleaned_power.nged_data.cleaning.flag_nged_power(power, metadata)— where cleaning rules go.flag_nged_powerreturns every raw power row plus adrop_reasoncolumn, and theclean_nged_power_dataDagster asset writes the result to the cleaned table. The function's docstring says how to add a rule.nged_data.upsert_metadata(new_metadata, metadata_path, storage_options=None)— merges aTimeSeriesMetadatasnapshot into the stored metadata Parquet file, keeping the newest values pertime_series_idand rewriting the file only if the incoming metadata differs from what is stored.
nged_data.read_nged_json parses one downloaded JSON file into the two schemas. All three of its
functions are private, and the two that parse a whole file are called only by
download_and_parse_files, so the module appears on the API page below carrying just its
ExtractedPowerTimeSeries result type.
Data quality
download_and_parse_files drops rows whose time is malformed — outside the plausible datetime
range, null, or not aligned to the top or bottom of the hour — via
PowerTimeSeries.drop_implausible_rows, and reports how many as n_implausible_power_rows_dropped.
Degrading rather than raising on a malformed reading follows inherent
stability:
a malformed time originates upstream of our pipeline, at the meter or in the telemetry export, not
in our own code. Ingestion therefore keeps the rest of the batch rather than aborting it. No other
cleaning happens during ingestion. Cleaning rules run afterwards, over the whole stored table, in
nged_data.cleaning.
Usage
This package is used by the power_time_series_and_metadata Dagster asset in
src/nged_substation_forecast/defs/assets.py.
nged_data.read_nged_json
Extracts metadata and time series from NGED JSON data.
The parser expects two properties of each JSON file. Metadata fields sit at the top level. A data
field holds an array of time series data points.
Attributes
log = logging.getLogger(__name__)
module-attribute
Classes
NoReadingsInFile
Bases: Exception
Raised by _extract_power_time_series when a file's data field holds no readings.
NGED's meter reported nothing for the period the file covers.
Source code in packages/nged_data/src/nged_data/read_nged_json.py
28 29 30 31 32 | |
ExtractedPowerTimeSeries
Bases: NamedTuple
Result of parsing PowerTimeSeries rows out of one NGED JSON file.
n_dropped counts rows dropped by PowerTimeSeries.drop_implausible_rows for a
malformed time — see that method's docstring for why ingestion degrades rather than
raising.
Source code in packages/nged_data/src/nged_data/read_nged_json.py
35 36 37 38 39 40 41 42 43 44 | |
Attributes
dataframe
instance-attribute
n_dropped
instance-attribute
nged_data.storage
Listing, downloading, and parsing NGED's telemetry JSON from S3.
Each file yields TimeSeriesMetadata describing one series and PowerTimeSeries power
observations from it. The metadata is upserted here, into a Parquet metadata table (see
upsert_metadata). The power observations are not. This module never writes PowerTimeSeries
rows to disk. It returns them to the caller, and the caller appends them to the
power_time_series Delta table. select_new_rows reads that same Delta table, to drop rows
already on disk, but does not write to it.
The module downloads each NGED file once. The downloaded-files list (read_downloaded_files,
write_downloaded_files) records the bucket listing that the ingest last processed in full, and
select_files_not_yet_downloaded keeps only the listed files that the downloaded-files list lacks.
Attributes
log = logging.getLogger(__name__)
module-attribute
Classes
NgedFileParseError
Bases: ValueError
Raised when an NGED file is malformed or breaks the TimeSeriesMetadata contract.
Parsing is deterministic, so retrying the download cannot fix the file.
Source code in packages/nged_data/src/nged_data/storage.py
165 166 167 168 169 | |
DownloadedFilesError
Bases: Exception
Raised when the stored downloaded-files list cannot be read or fails validation.
The ingest must stop on this error. Reading a damaged list as an empty one would download every file in NGED's bucket.
Source code in packages/nged_data/src/nged_data/storage.py
172 173 174 175 176 177 | |
DownloadAndParseResult
Bases: NamedTuple
Result of download_and_parse_files.
n_implausible_power_rows_dropped sums ExtractedPowerTimeSeries.n_dropped across every
file in the batch — see PowerTimeSeries.drop_implausible_rows for what gets dropped and
why.
Source code in packages/nged_data/src/nged_data/storage.py
299 300 301 302 303 304 305 306 307 308 309 | |
Attributes
metadata
instance-attribute
power_time_series
instance-attribute
n_implausible_power_rows_dropped
instance-attribute
TimeSeriesCoverage
Bases: Model
Per-series observation-time span of the power_time_series Delta table.
first_time/last_time are the earliest/latest observation time for each
time_series_id. This frame is a transient intermediate, never persisted. Two callers
read the frame: the freshness asset check reads last_time to detect staleness, and
cross-validation (CV) fold-eligibility (eligible_time_series_ids) reads both
first_time and last_time. A CV fold is one train/test split of the history, and a
series is eligible for a fold only if its data covers that split.
The freshness check reads this on-disk recency rather than the asset's materialisation timestamp. A materialisation-freshness policy would miss the failure the check exists to catch. When NGED's telemetry stalls, the ingest asset keeps materialising successfully on schedule, and writes nothing. The materialisation looks fresh; the newest observation on disk does not. Full reasoning: https://openclimatefix.github.io/nged-substation-forecast/architecture/production-deployment/#warn-on-stale-power-data-with-a-dagster-asset-check.
Source code in packages/nged_data/src/nged_data/storage.py
443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 | |
Attributes
time_series_id = _get_time_series_id_dtype(unique=True)
class-attribute
instance-attribute
first_time = pt.Field(dtype=PowerTimeSeries.dtypes['time'])
class-attribute
instance-attribute
last_time = pt.Field(dtype=PowerTimeSeries.dtypes['time'])
class-attribute
instance-attribute
UpsertMetadataStats
Bases: TypedDict
What the TimeSeriesMetadata upsert did, published as Dagster output metadata.
Source code in packages/nged_data/src/nged_data/storage.py
641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 | |
Attributes
metadata_n_new_TimeSeriesIDs
instance-attribute
metadata_n_updated_TimeSeriesIDs
instance-attribute
metadata_updated_TimeSeriesIDs
instance-attribute
metadata_upsert_failed
instance-attribute
Set by the asset when the whole upsert raised, so the power write went ahead without it.
Read this field's presence as "the metadata table is stale until each affected series publishes its next file", not as a power-ingest failure — the power write is unaffected. See Degraded input data, under "Reading a failed metadata table upsert", for the operational read of this field and what a stale metadata table costs while the failure persists.
Functions:
list_timeseries_json_files(store)
List all the timeseries JSON files in NGED's S3 bucket.
Each path encodes start_time, end_time, and time_series_id, and is assumed to be of the
form:
timeseries/1774512000000_1774533600000/TimeSeries_23_20260326T080000Z_20260326T140000Z.json
_process_file_listing parses those three fields back out, and its aligned comment traces the
regex against this same key.
A key that does not match yields null captures and fails _ProcessedFileListing.validate. That
validation failure aborts the listing for the whole bucket rather than skipping the one object.
Aborting the whole listing is deliberately stricter than the null-data handling in
download_and_parse_files, which logs the offending file and carries on. The difference is
where the fault lies. A malformed reading originates upstream of our pipeline, at the meter or
in the telemetry export. A key we cannot parse means NGED's naming convention has changed. Every
start_time, end_time, and time_series_id this function returns is then suspect — including
the values parsed from the keys that still match.
Source code in packages/nged_data/src/nged_data/storage.py
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 | |
read_downloaded_files(list_of_downloaded_files_path, power_table_path, metadata_path, storage_options=None)
Read the bucket listing that the ingest last processed in full.
The list describes files loaded into the power_time_series Delta table and the metadata
parquet. If an operator moves the power table or the metadata parquet aside to rebuild that
table, the list would record files as loaded whose readings the rebuilt table lacks. The list
therefore counts as empty, and the next run downloads every file, whenever the list file, the
power table, or the metadata parquet does not exist.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
list_of_downloaded_files_path
|
str
|
Local path or remote URI of the list's parquet file. |
required |
power_table_path
|
str
|
Local path or remote URI of the |
required |
metadata_path
|
str
|
Local path or remote URI of the metadata parquet file. |
required |
storage_options
|
ObjectStoreOptions | None
|
Object-store credentials/endpoint for remote paths; |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame[_DownloadedFiles]
|
The stored |
Raises:
| Type | Description |
|---|---|
DownloadedFilesError
|
if the list file exists but is damaged or fails validation. A transient object-store error is not wrapped, so the caller's retry guard can retry it. |
Source code in packages/nged_data/src/nged_data/storage.py
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | |
write_downloaded_files(list_of_downloaded_files_path, listing, storage_options=None)
Replace the downloaded-files list with listing's path and last_modified columns.
A local write goes through a temporary file and Path.replace, so a killed process cannot leave
a torn list. A torn list would stop the ingest, whereas a torn metadata parquet does not. An
object-store write replaces the object in one request.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
list_of_downloaded_files_path
|
str
|
Local path or remote URI of the list's parquet file. |
required |
listing
|
DataFrame[_ProcessedFileListing]
|
The whole bucket listing that the run processed. |
required |
storage_options
|
ObjectStoreOptions | None
|
Object-store credentials/endpoint for a remote path; |
None
|
Source code in packages/nged_data/src/nged_data/storage.py
244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 | |
select_files_not_yet_downloaded(file_listing, downloaded_files)
Keep the listed files whose (path, last_modified) the downloaded-files list lacks.
A file whose path is known but whose last_modified changed was rewritten by NGED, so it is
selected again. A late file is selected too: a file absent from an earlier listing is absent
from the list, so a later listing selects it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
file_listing
|
DataFrame[_ProcessedFileListing]
|
The whole bucket listing. |
required |
downloaded_files
|
DataFrame[_DownloadedFiles]
|
The listing that the ingest last processed in full. |
required |
Returns:
| Type | Description |
|---|---|
DataFrame[_ProcessedFileListing]
|
The selected files, in no particular order. |
Source code in packages/nged_data/src/nged_data/storage.py
276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | |
download_and_parse_files(store, paths_df)
Download and parse each listed file, in ascending end_time order.
Two files can cover overlapping periods for the same time_series_id. The function sorts its
input by end_time itself. Processing in that order means the more recent file's readings
overwrite the older file's duplicate rows, in the unique(..., keep="last") dedupes below.
The function works through the sorted input in chunks of _DOWNLOAD_CHUNK_FILES. Per chunk it
fetches all the files concurrently, at most _MAX_REQUESTS_IN_FLIGHT at a time, through
asyncio.run, then parses them sequentially in input order. The function is synchronous and
needs a thread with no running event loop, which a synchronous Dagster asset has.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
S3Store
|
The NGED S3 bucket to download each file from. |
required |
paths_df
|
DataFrame[_ProcessedFileListing]
|
The files to process, typically the output of |
required |
Returns:
| Type | Description |
|---|---|
DownloadAndParseResult
|
A |
DownloadAndParseResult
|
|
DownloadAndParseResult
|
docstring for what each field holds. When every file was data-less ( |
DownloadAndParseResult
|
the metadata is still returned and the power frame is empty. |
Raises:
| Type | Description |
|---|---|
NgedFileParseError
|
if a file is malformed or breaks the |
Source code in packages/nged_data/src/nged_data/storage.py
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 | |
time_series_coverage(delta_path, storage_options=None)
Return the earliest/latest observation time on disk per time_series_id.
Returns an empty (but correctly typed) frame if the Delta table does not exist yet.
min/max grouped by time_series_id are value aggregations, so they are safe from
the Polars 32-bit row-count wraparound even on a very large table (see
https://openclimatefix.github.io/nged-substation-forecast/architecture/code-style/#data-handling).
Cost: a full two-column scan-and-aggregate, O(rows in the table). Projection pushdown drops
the power column. A group-wise min/max cannot be answered from Parquet row-group
statistics, because no engine on our stack does aggregate-from-statistics, so every
time/time_series_id value is read. Computing both bounds instead of one takes ~20%
more wall-clock time and no extra memory, because the shared scan dominates.
The collect uses the streaming engine to keep peak memory bounded, because this scan runs
hourly on a small control-plane VM, in the power_data_is_fresh asset check. The ingest does
not run time_series_coverage: select_new_rows uses _existing_power_time_series_keys
instead, a scan restricted to the reporting series' own history. The measurement used a
synthetic V2 table: 2,500 series, half-hourly, partitioned by time_series_id, holding a
year of history (43.8M rows). The streaming engine took ~0.21 s at ~190 MB peak. The in-memory
engine peaked at ~1.3 GB for the same result, so streaming uses ~7x less memory.
Cost scales linearly with accumulated history. If the scan ever becomes a problem, both
bounds can instead be read from the Delta add-action min.time/max.time file
statistics. Delta's transaction log records one add-action per data file, carrying that
file's per-column minimum and maximum. That read is metadata-only, O(files): ~0.02 s and <100
MB at the same scale. The statistics read is the same Delta-log-metadata trick used to count
whole-table rows without scanning.
delta_path is a local path or remote URI for the power_time_series Delta table;
storage_options carries the object-store credentials/endpoint for a remote delta_path.
Source code in packages/nged_data/src/nged_data/storage.py
466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 | |
coverage_from_power(power)
Return the earliest/latest observation time per time_series_id of a power frame.
The aggregation behind time_series_coverage, split out so a caller can compute coverage of
any scan of power, for example the unflagged rows of the cleaned table (scan_cleaned_power).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
power
|
LazyFrame[PowerTimeSeries]
|
Lazy power observations; only |
required |
Returns:
| Type | Description |
|---|---|
DataFrame[TimeSeriesCoverage]
|
One row per |
Source code in packages/nged_data/src/nged_data/storage.py
515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 | |
scan_cleaned_power(delta_path, storage_options=None)
Scan the cleaned power table, keeping only the rows that no cleaning rule flagged.
scan_cleaned_power is the one read path for every consumer of observed power except the ingest
and its freshness check, so no consumer can forget the drop_reason filter. The raw table stays
the record of what NGED delivered.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
delta_path
|
str
|
Path or URI of the |
required |
storage_options
|
ObjectStoreOptions | None
|
delta-rs object-store options for a remote |
None
|
Returns:
| Type | Description |
|---|---|
LazyFrame[PowerTimeSeries]
|
A lazy frame with the three |
Source code in packages/nged_data/src/nged_data/storage.py
543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 | |
select_new_rows(time_series, delta_path, storage_options=None)
Return rows in time_series genuinely missing from the Delta table.
The filter is an existence check: an anti-join on (time_series_id, time) against
_existing_power_time_series_keys. A late file is therefore ingested even when a later reading
for the same series is already on disk. So is a file that fills a gap earlier in a series'
history. The existence check also makes a re-download safe, because a rewritten file or a
crash between the append and the downloaded-files list write can offer readings the table
already holds. See
_existing_power_time_series_keys's docstring for the cost the existence check trades in
return.
When the Delta table does not exist yet, a call returns its input unchanged and scans nothing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
time_series
|
DataFrame[PowerTimeSeries]
|
The parsed rows to check. |
required |
delta_path
|
str
|
Local path or remote URI for the |
required |
storage_options
|
ObjectStoreOptions | None
|
Object-store credentials/endpoint for a remote |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame[PowerTimeSeries]
|
The rows of |
Source code in packages/nged_data/src/nged_data/storage.py
600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 | |
upsert_metadata(new_metadata, metadata_path, storage_options=None)
Upserts metadata to a Parquet file, keeping the newest version of each time series.
If the Parquet file does not exist, it saves the new_metadata. If it exists, it merges the
new_metadata into it and rewrites the file only if the incoming metadata differs from what is
stored. new_metadata is a snapshot of the metadata for the series that reported this run.
The snapshot need not carry the same columns, or the same column order, as the stored metadata
table. Rows are matched on time_series_id. A series that new_metadata covers is replaced
wholesale, so a field the snapshot has stopped carrying is cleared for that series. A series
that new_metadata omits keeps its last stored values indefinitely. The metadata table
therefore holds every time series we have ever seen, not only the series in the latest snapshot.
This function is not safe under concurrent callers: it assumes it is called by one thread at a time, and takes no lock.
The rewrite is not atomic either. write_parquet overwrites the metadata table in place, with
no write-to-temporary-file-and-rename. The metadata table therefore does not get the
all-or-nothing commit that Delta gives the tables around it. See principle 10, every write is
atomic and idempotent.
A crash or an out-of-memory kill part-way through a local write leaves a partial file.
pl.read_parquet below is what rejects that partial file on the next run, before
TimeSeriesMetadata.validate ever sees it. The error reads ComputeError: parquet: File out of
specification: The file must end with PAR1. validate is the guard for the other case: a
metadata table that reads back cleanly but is off-contract, from an older writer or a hand-edit.
Either way the asset records metadata_upsert_failed, and the metadata table stays broken until
an operator acts. A corrupt file is not a missing file, so the create branch below never runs
again by itself.
Deleting the metadata parquet makes the next run rebuild the metadata table.
read_downloaded_files counts the downloaded-files list as empty
when the metadata table does not exist, so the next run downloads every file in NGED's bucket
and the metadata table gets every series NGED publishes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
new_metadata
|
DataFrame[TimeSeriesMetadata]
|
The new metadata DataFrame. |
required |
metadata_path
|
str
|
Local path or remote URI of the Parquet file where we store our version of the metadata. |
required |
storage_options
|
ObjectStoreOptions | None
|
Object-store credentials/endpoint for a remote |
None
|
Returns:
| Type | Description |
|---|---|
UpsertMetadataStats
|
An |
UpsertMetadataStats
|
|
UpsertMetadataStats
|
the |
UpsertMetadataStats
|
|
UpsertMetadataStats
|
counts are zero and the id list is omitted when the parquet file was up to date already. The |
UpsertMetadataStats
|
id list is also omitted on a first-ever write, when every id in |
UpsertMetadataStats
|
rather than updated. |
Source code in packages/nged_data/src/nged_data/storage.py
658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 | |
nged_data.cleaning
Flagging NGED's power telemetry that should not be trained or forecast on.
flag_nged_power is where cleaning rules live. The clean_nged_power_data Dagster asset calls it
with the whole raw power_time_series table and writes the result to the
cleaned_power_time_series Delta table.
Attributes
CLEANING_CODE_HASH = hashlib.sha256(Path(__file__).read_bytes() + Path(contracts.power_schemas.__file__).read_bytes()).hexdigest()
module-attribute
The SHA-256 of this module's source file and of contracts/power_schemas.py, computed at import.
power_schemas.py is included because the substation_zero rule reads
TimeSeriesMetadata.SUBSTATION_TYPES from it.
clean_nged_power_data skips a rebuild only when this hash matches the one recorded in the
cleaned table's newest write commit, so any edit to a cleaning rule forces a rebuild on the next
run, whether or not the edit is committed. Every file a cleaning rule reads its code or constants
from must be in this hash.
Classes
Functions:
flag_nged_power(power, metadata)
Flag the rows of NGED's power telemetry that cleaning rejects, by adding drop_reason.
Where this sits. clean_nged_power_data calls this function with the whole raw power
table and the TimeSeriesMetadata table, about four times a day, and writes the result to the
cleaned table. Every reader of power then sees only the rows whose drop_reason is null.
How to add a rule. Write a boolean Polars expression that is true for a row to flag. Add
the expression as one more .when(...).then(pl.lit("<reason>")) branch of the chain below, and
add the reason to contracts.power_schemas.DROP_REASONS. The first matching branch wins. A
rule needing another TimeSeriesMetadata column adds that column to the select below.
The contract a rule keeps. Return every input row exactly once, flagged rather than
deleted. Never change time_series_id or time. Do not call collect inside this function.
Logging. clean_nged_power_data reports to Dagster, per reason, the rows and series
flagged and the range of flagged power and time, so a rule needs no logging code. Python's
logging output reaches only the step's captured stderr.
The one rule today, substation_zero, flags a reading of exactly 0 from a Primary, BSP,
or GSP series. A substation almost never truly reads zero, so a zero is almost always a
telemetry fault. A series missing from the TimeSeriesMetadata table has no substation_type
and is not flagged.
Example. The output holds every input power row, with a drop_reason column added. These
seven rows are real readings from a Primary substation, MARSH LANE 33 11kV S STN, after
cleaning:
time_series_id time (UTC) power (MVA) drop_reason
Int32 Datetime Float32 String
26 2026-03-05 12:30 0.043 null
26 2026-03-05 13:00 0.094 null
26 2026-03-05 13:30 0.045 null
26 2026-03-05 14:00 0.0 substation_zero
26 2026-03-05 14:30 0.392 null
26 2026-03-05 15:00 1.492 null
26 2026-03-05 15:30 2.585 null
The 14:00 reading of exactly 0 MVA is flagged. The flagged row stays in the output. Every reader
of power skips the flagged row, so the zero never reaches training, cross-validation, scoring,
or live forecasts. A zero from an EHV Customer or HV Customer series would get a null
drop_reason and stay in, because a customer site, such as a solar farm at night, can truly
read zero. CleanedPowerTimeSeries in contracts.power_schemas defines these four columns and
their dtypes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
power
|
LazyFrame[PowerTimeSeries]
|
The whole raw power table. |
required |
metadata
|
DataFrame[TimeSeriesMetadata]
|
The |
required |
Returns:
| Type | Description |
|---|---|
LazyFrame[CleanedPowerTimeSeries]
|
Every row of |
LazyFrame[CleanedPowerTimeSeries]
|
that passed, otherwise the name of the rule that flagged it. |
Source code in packages/nged_data/src/nged_data/cleaning.py
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | |