Mapping transmission loss factors to settlement nodes

A topology update renames GEN_101_A to GEN-101-A overnight, the join to the ISO loss-factor file silently drops the node, and the interval settles on no adjustment at all until the T+30 statement fails to tie out — this page implements the deterministic mapping that closes that gap. It is the node-binding worked example under Loss Factor Mapping Strategies, resolving identifier drift, regulatory-bound violations, and temporal misalignment before any factor rescales a metered volume.

The diagram below maps the deterministic reconciliation pipeline this page implements: node IDs are canonicalized, factors validated against regulatory bounds, telemetry temporally aligned, then joined to produce settlement-cleared records.

Deterministic node-to-factor reconciliation pipeline Raw telemetry and the ISO factor file enter the pipeline. The node ID is canonicalized against the alias registry, then the loss factor is tested against the regulatory band 0.85 to 1.15. An out-of-bounds factor drops to the exception lane, where the record is locked, the prior-day factor is pulled, and the variance is flagged for manual review. An in-bounds factor continues to interval alignment, resampling 5-minute telemetry to hourly. A gap over 15 minutes is also flagged for manual review; a short gap is forward-filled and the factor is joined to the record, cleared for settlement. Raw telemetry+ ISO factor file Canonicalize IDalias registry In bounds?0.85–1.15 Align intervals5-min → hourly Gap >15 min? Forward-fillshort gaps Join to recordcleared for settlement Lock recordpull prior-day factor Flag variancemanual review yes no no yes

The canonical operation the whole page serves is the multiplicative adjustment of metered energy by a per-node, per-interval delivery factor:

$$V^{\text{adj}}{n,t} = V^{\text{meter}}{n,t} \times \delta_{n,t}, \qquad \delta_{n,t} = 1 - L_{n,t}$$

where \(V^{\text{meter}}{n,t}\) is the metered volume at node \(n\) in interval \(t\), \(L{n,t}\) is the fractional loss published by the transmission operator, and \(\delta_{n,t}\) is the delivery factor. Every failure mode below is a way for the wrong \(\delta_{n,t}\) — or none at all — to reach that multiplication.

Diagnosing node-to-factor misalignment

Production reconciliation failures rarely stem from algorithmic complexity; they originate from data ingestion drift, identifier normalization gaps, and unhandled temporal offsets. Settlement analysts routinely encounter three deterministic error signatures that require immediate resolution prior to clearing cycles.

Identifier canonicalization and topology drift

The KeyError: SettlementNodeID not in LossFactorIndex exception surfaces when topology updates retire legacy nodes or when naming conventions diverge between SCADA telemetry, EMS models, and settlement files. For example, GEN_101_A in telemetry may map to GEN-101-A in the ISO settlement file. Resolution requires a deterministic alias registry that maps all historical and current node variants to a single master identifier. The field contract that guarantees those identifier columns even arrive in a known shape is enforced upstream by the Schema Validation Frameworks, while the raw parse of each ISO drop belongs to the ISO/RTO Data Format Standards layer. Production systems must strip whitespace, enforce uppercase normalization, and apply compiled regex patterns that collapse delimiters into a unified schema. Raw string equality should never be relied upon for node matching in regulated environments.

Regulatory boundary enforcement and unit validation

A ValueError: Loss factor exceeds regulatory bounds (>1.15 or <0.85) typically indicates decimal misplacement during CSV parsing, unit conversion errors (MW vs MWh), or stale file drops from the transmission operator. Traders and utility operations must enforce hard boundary validation immediately after ingestion. Any factor falling outside the jurisdictional tolerance band must trigger an automated exception route rather than propagating downstream. The fallback protocol should lock the out-of-range record, pull the prior-day published factor from an immutable archive, and flag the variance for the same alerting fabric described in Threshold Tuning & Alerts. This aligns with FERC tariff requirements for verifiable settlement adjustments.

Temporal alignment and interval aggregation

MergeError conditions arise from temporal misalignment between 5-minute SCADA telemetry and hourly settlement intervals. Interval aggregation mismatches occur when timezone localization is omitted or when daylight saving transitions shift interval boundaries. Settlement engines expect strictly aligned, contiguous timestamps. Resolution requires explicit localization to the market timezone, resampling telemetry using deterministic grouping logic, and applying forward-fill strategies only for gaps under 15 minutes. Gaps exceeding this threshold must be flagged for interpolation review rather than silently imputed. The same DST-boundary discipline governs the interval keys produced by Settlement Cycle Mapping, and correct handling here is what keeps the aligned frame safe for downstream Settlement Calculation & Validation Engines ingestion.

The error-signature reference below is what the pipeline classifies each ingestion failure against before it is allowed to clear.

Error signature Root cause Deterministic resolution Routed to
KeyError on node ID Topology rename / delimiter drift Alias-registry canonicalization Auto-resolve
ValueError out of bounds Decimal misplacement / stale drop Lock record, pull prior-day factor Manual review
MergeError on join 5-min vs hourly interval offset Localize + resample to hourly grid Auto-resolve
Gap > 15 min Telemetry outage / DST shift Flag for interpolation review Manual review

Prerequisites

  • Python packages: pandas>=2.0, plus the standard-library decimal, re, zoneinfo, and logging modules. No third-party timezone library is required — zoneinfo (PEP 615) ships with Python 3.9+.
  • Data dependencies: a raw telemetry frame exposing node_id, timestamp, and mw_value, plus an ISO/RTO loss-factor frame exposing node_id and loss_factor. Delivery factors are read as strings and cast to Decimal — never float — so the volume rescaling survives summation across thousands of intervals without binary rounding drift.
  • Permissions: read access to the SCADA/AMI telemetry feed and the ISO member data feed (or the archived factor drop), and write access to the append-only reconciliation log. The prior-day fallback archive must be a WORM-compliant or Object-Lock store so a substituted factor is cryptographically verifiable.

Implementation

The pattern below is a deterministic, type-hinted pipeline that integrates canonicalization, boundary validation, temporal alignment, and Decimal-exact delivery-factor application with explicit audit logging. Alignment arithmetic (averaging MW telemetry) stays in pandas; the financial step — rescaling metered energy by the delivery factor — is handled entirely with the decimal module.

import pandas as pd
import re
import logging
from decimal import Decimal, ROUND_HALF_EVEN
from zoneinfo import ZoneInfo
from typing import Dict

# Configure structured audit logging for regulatory traceability
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(module)s | %(message)s",
    handlers=[logging.FileHandler("loss_factor_reconciliation.log")],
)

MWH = Decimal("0.001")  # settle delivered energy to the nearest kWh


class LossFactorMapper:
    def __init__(
        self,
        tolerance_min: str = "0.85",
        tolerance_max: str = "1.15",
        market_tz: str = "US/Eastern",
    ):
        # Bounds held as Decimal so comparisons never inherit float representation error.
        self.tolerance_min = Decimal(tolerance_min)
        self.tolerance_max = Decimal(tolerance_max)
        self.market_tz = ZoneInfo(market_tz)
        self.alias_registry = self._build_alias_registry()
        self.delimiter_pattern = re.compile(r"[_\-\.]+")

    def _build_alias_registry(self) -> Dict[str, str]:
        """Deterministic mapping of historical/current variants to master node IDs."""
        return {
            "GEN-101-A": "GEN-101-A", "GEN101A": "GEN-101-A",
            "LOAD-202-B": "LOAD-202-B", "LOAD202B": "LOAD-202-B",
        }

    def canonicalize_node_id(self, raw_id: str) -> str:
        """Normalize a node identifier to the master schema."""
        cleaned = raw_id.strip().upper()
        canonical = self.delimiter_pattern.sub("-", cleaned)
        return self.alias_registry.get(canonical, canonical)

    def validate_delivery_factor(self, factor: str, node_id: str) -> Decimal:
        """Enforce regulatory bounds; raise for out-of-range so the caller can route fallback."""
        delta = Decimal(str(factor))
        if not (self.tolerance_min <= delta <= self.tolerance_max):
            logging.warning(
                "REGULATORY VIOLATION: node %s factor %s outside bounds [%s, %s]; "
                "applying prior-day fallback.",
                node_id, delta, self.tolerance_min, self.tolerance_max,
            )
            raise ValueError(f"Loss factor {delta} for {node_id} exceeds regulatory bounds.")
        return delta

    def align_temporal_intervals(self, telemetry_df: pd.DataFrame) -> pd.DataFrame:
        """Resample per-node telemetry to hourly settlement intervals.

        Expects a `master_node` column (set by ``reconcile``). Returns one row per
        (master_node, hour) with a tz-aware ``timestamp`` and the hour's mean MW.
        """
        df = telemetry_df.copy()
        df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True).dt.tz_convert(self.market_tz)
        df = df.set_index("timestamp").sort_index()

        aggregated = []
        for node, node_df in df.groupby("master_node"):
            # Snap to the native 5-minute grid and forward-fill only short gaps
            # (<= 15 minutes == 3 five-minute intervals) BEFORE hourly aggregation,
            # so the gap limit is measured in 5-minute steps rather than in hours.
            node_df = node_df.resample("5min").asfreq()
            node_df["mw_value"] = node_df["mw_value"].ffill(limit=3)

            # Aggregate to hourly settlement windows: mean power (MW) over the hour.
            hourly = node_df.groupby(pd.Grouper(freq="1h")).agg({"mw_value": "mean"})
            hourly = hourly.dropna(subset=["mw_value"])
            hourly["master_node"] = node
            aggregated.append(hourly)

        if not aggregated:
            return pd.DataFrame(columns=["timestamp", "mw_value", "master_node"])

        return pd.concat(aggregated).reset_index().rename(columns={"index": "timestamp"})

    def apply_delivery_factor(self, mw_value: float, delta: Decimal) -> Decimal:
        """Financial step: metered MWh (mean MW over a 1-hour interval) x delivery factor.

        Cast through str so no float representation leaks into the settled volume.
        """
        metered_mwh = Decimal(str(mw_value))  # 1-hour window => MW mean == MWh
        return (metered_mwh * delta).quantize(MWH, rounding=ROUND_HALF_EVEN)

    def reconcile(
        self, telemetry_df: pd.DataFrame, factor_df: pd.DataFrame
    ) -> pd.DataFrame:
        """Execute the deterministic node-to-factor mapping pipeline."""
        telemetry_df = telemetry_df.copy()
        factor_df = factor_df.copy()
        telemetry_df["master_node"] = telemetry_df["node_id"].apply(self.canonicalize_node_id)
        factor_df["master_node"] = factor_df["node_id"].apply(self.canonicalize_node_id)

        # Validate factors before the merge to prevent downstream contamination.
        factor_df["delivery_factor"] = factor_df.apply(
            lambda row: self.validate_delivery_factor(row["loss_factor"], row["master_node"]),
            axis=1,
        )

        aligned = self.align_temporal_intervals(telemetry_df)
        # Left join keeps every metered interval; an unmatched node yields a null factor
        # that must halt the run rather than settle on no adjustment.
        merged = aligned.merge(
            factor_df[["master_node", "delivery_factor"]], on="master_node", how="left"
        )
        unmatched = merged["delivery_factor"].isna().sum()
        if unmatched:
            logging.error("%d interval(s) had no factor after canonicalization; halting.", unmatched)
            raise KeyError(f"{unmatched} settlement intervals unmatched to a loss factor.")

        # Financial application under Decimal, one row at a time.
        merged["delivered_mwh"] = merged.apply(
            lambda row: self.apply_delivery_factor(row["mw_value"], row["delivery_factor"]),
            axis=1,
        )
        logging.info("Reconciliation complete. %d records cleared for settlement.", len(merged))
        return merged

Verification steps

Confirm the mapping before the delivered volumes reach the calculation core:

  • DataFrame shape. For telemetry spanning H node-hours that all match a factor, reconcile returns exactly H rows and the columns {"timestamp", "mw_value", "master_node", "delivery_factor", "delivered_mwh"}. A row count below H means intervals were dropped in resampling — assert the count so a silent gap never passes.
  • Unmatched-node guard. Feed a telemetry node whose canonical ID has no factor row and confirm reconcile raises KeyError rather than emitting a NaN delivery factor. No interval may clear on a missing multiplier.
  • Bound enforcement. Inject a loss_factor of 1.30 and confirm validate_delivery_factor raises ValueError and writes a REGULATORY VIOLATION line to loss_factor_reconciliation.log; the record must route to the prior-day fallback, not the merge.
  • Decimal exactness. Re-running reconcile on identical inputs must reproduce byte-identical delivered_mwh values. Diff two runs with pd.testing.assert_frame_equal(run_a, run_b) — any residual signals a float leak, because all financial arithmetic is Decimal quantized with ROUND_HALF_EVEN.
  • DST reconciliation. Run a spring-forward operating day and confirm the aligned frame has 23 hourly rows, and a fall-back day 25 — proof the tz_convert localization, not a naive clock, governs interval boundaries.

Compliance note

Regulatory frameworks mandate that every settlement adjustment be traceable, immutable, and reproducible. When mapping transmission loss factors, utility operations must maintain a strict chain of custody for each node-to-factor assignment:

  • Immutable version control. Store daily ISO/RTO loss-factor publications in append-only storage (S3 Object Lock or a WORM-compliant database). Version drift must be cryptographically verifiable, satisfying the auditable-lineage requirement that every mapped interval trace back to its source drop.
  • Deterministic fallback logic. Never allow silent defaults. Out-of-range or missing factors trigger a documented exception workflow with explicit operator acknowledgment, using market-aware timestamp resolution per Python’s datetime handling standards.
  • Timestamp integrity. Market timezone localization is applied at ingestion, not during downstream aggregation. Relying on system-local clocks introduces DST-related settlement drift that violates tariff compliance and FERC settlement guidelines.
  • Audit logging. Every canonicalization, validation, and merge operation emits structured logs containing record hashes, operator IDs, and execution timestamps, ensuring readiness for regulatory audit and dispute resolution. Material variances escalate through the same tiers used by downstream Imbalance Allocation Algorithms so no adjustment settles unreviewed.

Frequently asked questions

Why does the join drop nodes after a topology update?

Because raw string equality treats GEN_101_A and GEN-101-A as different keys. A topology rename or a delimiter convention change breaks the merge silently, leaving the interval with no factor. The alias registry collapses every historical and current variant to one master ID before the join, so a rename never orphans a node.

Should the loss factor use Decimal or float?

Decimal. The delivery factor multiplies metered energy that later reconciles against an ISO statement to the cent. Binary floating point cannot represent most decimal fractions exactly, so scaling thousands of intervals accumulates drift that eventually flips a rounding boundary and breaks reconciliation. Casting the factor and the volume through Decimal(str(value)) and quantizing with ROUND_HALF_EVEN keeps the settled volume bit-exact and reproducible.

What happens to a factor outside the 0.85–1.15 band?

It never reaches the merge. validate_delivery_factor raises immediately, the record is locked, and the prior-day published factor is pulled from the immutable archive with the substitution recorded in lineage. An unbounded or decimal-misplaced factor rescaling a node’s entire volume is exactly the silent error the bound enforcement exists to stop.