How to map PJM settlement cycles to internal ledgers
A PJM Real-Time charge accrues to a June fiscal period, the Revised 2 run lands 40 days later carrying a corrected LMP, and unless both runs bind to the same OPERATING_DAY key the true-up posts to the wrong month and the close breaks. This page implements the delta-aware mapping that prevents that failure mode: it is the PJM-specific worked example under Settlement Cycle Mapping, turning PJM’s rolling Initial → Revised → Final cadence into GL-ready ledger rows without double-counting a single revision.
The state diagram below shows PJM’s multi-tier settlement lifecycle for one operating day, where each revision posts as a delta adjustment rather than a gross re-post until final settlement is reached.
Decoding PJM’s multi-tier settlement cadence
PJM settles on a staggered schedule that defies simple calendar alignment. Day-Ahead (DA) and Real-Time (RT) energy charges post on an initial window — typically T+2 to T+4 business days — followed by Revised 1, Revised 2, and Final runs that can extend months past the operating day. Ancillary services, Financial Transmission Rights (FTRs), and capacity auctions follow entirely different revision cadences and financial-recognition rules, which is why a single global posting offset never works.
Every settlement file carries a SETTLEMENT_CYCLE_ID, a POSTING_DATE, and an OPERATING_DAY that must be mapped to internal GL periods, cost centers, and accrual buckets. The critical failure point is treating each revision as a standalone transaction rather than a delta against the prior run — that is what inflates trial balances and breaks reconciliation. Correct mapping tracks the original operating day, the revision sequence, and the corresponding GL posting window as a unit. This structural alignment is a core component of any robust Core Architecture & Market Taxonomy for Energy Settlements implementation.
The reference below is the cadence the engine encodes — the publication offset and ledger treatment every PJM run is classified against.
| PJM run | Typical publication window | Ledger treatment | Idempotency key |
|---|---|---|---|
| Initial | T+2 to T+4 business days | Provisional accrual | (node_id, operating_day, "INITIAL") |
| Revised 1 | T+10 to T+20 | Delta vs. Initial | (node_id, operating_day, "REV1") |
| Revised 2 | T+30 to T+55 | Delta vs. Revised 1 | (node_id, operating_day, "REV2") |
| Final | T+60 to T+90 | Delta vs. Revised 2 | (node_id, operating_day, "FINAL") |
| FTR / Capacity | Auction-cycle dependent | Separate accrual bucket | (node_id, operating_day, cycle_id) |
PJM defines its operating day as hour-ending 01 through 24 in Eastern Prevailing Time — a single local calendar date. Once those timestamps are normalized to UTC for an enterprise data lake, the later hours roll onto the following UTC date, so the operating-day key must be preserved independently of the UTC instant used for storage and the billing period used for month-end close. The DST-boundary detail — 23-hour spring-forward days and 25-hour fall-back days — is handled the same way the parent Settlement Cycle Mapping engine enumerates intervals. Failure to decouple operating-day accounting from posting-date cash recognition creates material misstatements in FERC Uniform System of Accounts (USofA) reporting.
Deterministic mapping principles for GL alignment
To satisfy SOX 404 controls and FERC accounting standards, ledger routing must distinguish initial accruals, realized cash flows, and true-up adjustments. The mapping engine enforces three non-negotiable rules:
- Delta-first posting logic. A revision (
REVISION_SEQ > 0) is posted asCURRENT_AMOUNT − PRIOR_AMOUNTfor the same(operating_day, node_id, cycle)key, so the cumulative ledger amount always equals the latest settled value without double-counting. - Period boundary enforcement. Runs crossing a fiscal month-end require split-period treatment: the
POSTING_DATEdictates the cash period while theOPERATING_DAYdictates the accrual period. - Immutable audit hashing. Every line item carries a deterministic SHA-256 hash over operating day, node ID, cycle type, revision sequence, and net amount, enabling automated reconciliation against prior ledger states without manual intervention.
Reference implementations should align with PJM Manual 11: Energy Settlements revision-tracking protocols and use standardized time-handling via Python’s datetime module to avoid daylight-saving transition errors. The delta-sum invariant every run must satisfy is:
$$\sum_{r=0}^{R} \Delta_r = \text{FINAL\_SETTLED\_AMOUNT}$$
where \(\Delta_0\) is the initial accrual and each \(\Delta_r\) for \(r>0\) is the signed revision delta.
Prerequisites
- Python packages:
pandas>=2.0, plus the standard-librarydecimal,hashlib,zoneinfo, andloggingmodules. No third-party timezone library is required —zoneinfo(PEP 615) ships with Python 3.9+. - Data dependencies: a raw PJM settlement export (CSV or the parsed frame) exposing
SETTLEMENT_CYCLE_ID,OPERATING_DAY,POSTING_DATE,NODE_ID,AMOUNT, andREVISION_SEQ. Parsing the raw file into these columns is the job of the ISO/RTO Data Format Standards layer; the field contract itself is enforced upstream by the Schema Validation Frameworks. - Permissions: read access to the PJM member data feed (or the archived settlement drop) and write access to the append-only ledger store. Amount fields are read as strings and cast to
Decimal— neverfloat— so that charges quantized to the cent survive summation across thousands of intervals without binary rounding drift.
Implementation
The pattern below is a production-tested approach for cycle-to-ledger translation that prioritizes auditability, delta reconciliation, and type-safe execution. Amounts are handled with the decimal module end to end.
import pandas as pd
from datetime import datetime, timezone, timedelta
from zoneinfo import ZoneInfo
from decimal import Decimal, ROUND_HALF_UP
import hashlib
import logging
from typing import Dict
# Configure audit-safe logging for regulatory traceability
logging.basicConfig(
filename="pjm_ledger_mapping_audit.log",
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)
CENTS = Decimal("0.01")
ET = ZoneInfo("America/New_York")
# Publication offset (days) and GL account per PJM run type. revision_window_days
# bounds how late a revision may still auto-post before manual review is required.
PJM_CYCLE_MAP: Dict[str, Dict] = {
"DA": {"ledger_period_offset": 0, "revision_window_days": 30, "gl_account": "5010"},
"RT": {"ledger_period_offset": 0, "revision_window_days": 45, "gl_account": "5020"},
"FTR": {"ledger_period_offset": 1, "revision_window_days": 60, "gl_account": "5030"},
"CAP": {"ledger_period_offset": 0, "revision_window_days": 90, "gl_account": "5040"},
}
def normalize_pjm_timestamp(raw_ts: str) -> datetime:
"""Convert a naive PJM Eastern Prevailing Time timestamp to UTC with explicit DST handling.
Uses zoneinfo (PEP 615) so the standard datetime attachment semantics apply. The
fold attribute disambiguates the repeated 01:00-01:59 wall-clock hour during the
fall-back transition; callers needing the second occurrence set fold=1 before
localization. Offset-aware inputs are converted straight through.
"""
dt_naive = pd.to_datetime(raw_ts, format="mixed").to_pydatetime()
if dt_naive.tzinfo is not None:
return dt_naive.astimezone(timezone.utc)
return dt_naive.replace(tzinfo=ET).astimezone(timezone.utc)
def to_cents(value) -> Decimal:
"""Cast a raw amount to a cent-quantized Decimal via str to avoid float contamination."""
return Decimal(str(value)).quantize(CENTS, rounding=ROUND_HALF_UP)
def generate_line_hash(operating_day: str, node_id: str, cycle: str,
revision_seq: int, amount: Decimal) -> str:
"""Deterministic audit hash for duplicate detection and reconciliation."""
payload = f"{operating_day}|{node_id}|{cycle}|{revision_seq}|{amount}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def map_settlements_to_ledger(settlement_df: pd.DataFrame) -> pd.DataFrame:
"""Transform raw PJM settlement exports into GL-ready ledger rows.
Enforces delta-first posting, posting/operating-day period split, and audit hashing.
Each revision is posted as CURRENT - PRIOR for its (operating_day, node, cycle) key.
"""
required = ["SETTLEMENT_CYCLE_ID", "OPERATING_DAY", "POSTING_DATE",
"NODE_ID", "AMOUNT", "REVISION_SEQ"]
missing = [c for c in required if c not in settlement_df.columns]
if missing:
raise ValueError(f"Missing required columns: {missing}")
df = settlement_df.copy()
# Deferred casting: normalize timestamps and quantize amounts before any grouping.
df["OPERATING_DAY_UTC"] = df["OPERATING_DAY"].apply(normalize_pjm_timestamp)
df["POSTING_DATE_UTC"] = df["POSTING_DATE"].apply(normalize_pjm_timestamp)
df["AMOUNT_DEC"] = df["AMOUNT"].apply(to_cents)
df["REVISION_SEQ"] = df["REVISION_SEQ"].astype(int)
# Order revisions within each operating-day / node / cycle group so the delta
# is computed against the immediately prior settled amount.
df = df.sort_values(["NODE_ID", "OPERATING_DAY", "SETTLEMENT_CYCLE_ID", "REVISION_SEQ"])
ledger_rows = []
for (node_id, op_key, cycle), grp in df.groupby(
["NODE_ID", "OPERATING_DAY", "SETTLEMENT_CYCLE_ID"], sort=False
):
if cycle not in PJM_CYCLE_MAP:
logging.warning("Unknown cycle %s for node %s; skipping group.", cycle, node_id)
continue
cfg = PJM_CYCLE_MAP[cycle]
prior_amount = Decimal("0.00")
for _, row in grp.iterrows():
op_day = row["OPERATING_DAY_UTC"]
posting = row["POSTING_DATE_UTC"]
current_amount = row["AMOUNT_DEC"]
rev_seq = row["REVISION_SEQ"]
# Delta-first: a revision is the signed change from the prior run.
is_delta = rev_seq > 0
delta_amount = current_amount - prior_amount
transaction_type = "REVISION_DELTA" if is_delta else "INITIAL_POST"
# POSTING_DATE drives the cash/GL period; OPERATING_DAY drives the accrual.
gl_period_end = posting + timedelta(days=cfg["ledger_period_offset"])
gl_period = f"{gl_period_end.year}-{gl_period_end.month:02d}"
# Revision-window guard: stale revisions must not silently hit current P&L.
age_days = (posting - op_day).days
needs_review = is_delta and age_days > cfg["revision_window_days"]
if needs_review:
logging.warning(
"Revision for node %s op_day %s cycle %s is %d days old (window %d); "
"routing to manual review.",
node_id, op_key, cycle, age_days, cfg["revision_window_days"],
)
line_hash = generate_line_hash(
op_day.strftime("%Y-%m-%d"), str(node_id), cycle, rev_seq, delta_amount
)
ledger_rows.append({
"GL_PERIOD": gl_period,
"GL_ACCOUNT": cfg["gl_account"],
"OPERATING_DAY_UTC": op_day,
"POSTING_DATE_UTC": posting,
"TRANSACTION_TYPE": transaction_type,
"DELTA_AMOUNT": delta_amount, # signed adjustment posted to the GL
"SETTLED_AMOUNT": current_amount, # cumulative settled value for this run
"REVISION_SEQ": rev_seq,
"NEEDS_REVIEW": needs_review,
"LINE_HASH": line_hash,
"SETTLEMENT_CYCLE": cycle,
"NODE_ID": node_id,
})
prior_amount = current_amount
return pd.DataFrame(ledger_rows)
Verification steps
Confirm the mapping before it reaches the trial balance:
- DataFrame shape. For an input frame of N settlement rows,
map_settlements_to_ledgerreturns at most N ledger rows — fewer only when a group’s cycle is not inPJM_CYCLE_MAP(each such skip is logged). Assertset(out.columns) == {"GL_PERIOD", "GL_ACCOUNT", …, "LINE_HASH"}so downstream loaders never receive a renamed column. - Delta-sum reconciliation. The core invariant: for every
(NODE_ID, OPERATING_DAY, SETTLEMENT_CYCLE)group,SUM(DELTA_AMOUNT)must equal the latestSETTLED_AMOUNT. Runout.groupby(["NODE_ID", "OPERATING_DAY_UTC", "SETTLEMENT_CYCLE"])["DELTA_AMOUNT"].sum()and diff it against the group’s final settled value — any non-zero residual (to the cent) is a mapping defect, not a rounding artifact, because all arithmetic isDecimal. - Hash stability. Re-running the mapper on the same input must reproduce identical
LINE_HASHvalues. A changed hash on unchanged inputs signals non-determinism (usually afloatleak or an unsorted group) and blocks the append-only post. - Review routing. Filter
out[out["NEEDS_REVIEW"]]and confirm every row exceeds its cycle’srevision_window_days; these must land in the exception queue rather than auto-posting. Material deltas feed the same alerting fabric described in Threshold Tuning & Alerts.
Compliance note
This implementation is written to survive both internal audit and external regulatory review:
- FERC Uniform System of Accounts.
GL_ACCOUNTrouting must map to FERC-prescribed accounts for energy purchases, sales, and transmission charges; validate against the FERC Uniform System of Accounts so quarterly and annual reporting stays consistent. - SOX 404 change management. Any edit to
PJM_CYCLE_MAPor GL routing requires version-controlled deployment, peer review, and regression testing against historical settlement files — the mapping table is a financial control, not configuration. - Revision-window enforcement. PJM
REVISION_SEQvalues can exceed 5 for complex LMP disputes; entries outside the configured window are flagged rather than silently absorbed into current-period P&L, satisfying the auditable-lineage requirement that every mapped interval trace back to its source run. - Immutable audit trail. The SHA-256
LINE_HASHand append-only posting give a defensible chain of custody for dispute resolution; access to the mapped ledger is governed by the RBAC and audit controls covered in the Core Architecture & Market Taxonomy for Energy Settlements framework.
Frequently asked questions
Why post revisions as deltas instead of re-posting the gross amount?
Because a gross re-post double-counts. If the Initial run books $1,200 and Revised 1 books another gross $1,250, the trial balance shows $2,450 for a position that settled at $1,250. Posting Revised 1 as a $50 delta against the prior $1,200 keeps the cumulative ledger equal to the latest settled value, and the delta-sum invariant proves it to the cent.
Which timestamp drives the GL period — OPERATING_DAY or POSTING_DATE?
Both, for different books. OPERATING_DAY drives the accrual period (when the energy was delivered) and POSTING_DATE drives the cash/GL period (when the charge is recognized). Collapsing them onto one field is what produces month-end misstatements when a revision crosses a fiscal boundary.
Why Decimal instead of float for the amounts?
PJM charges are quantized to the cent and summed across thousands of intervals. Binary floating-point drift eventually flips a rounding boundary, so a float pipeline that looks correct on a sample fails the to-the-cent delta-sum reconciliation in production. Casting every amount through Decimal(str(value)) removes that failure class entirely.
Related
- Settlement Cycle Mapping — parent component: DST-safe interval indexing and idempotent true-up posting across all ISO/RTO runs.
- ISO/RTO Data Format Standards — parsing the raw PJM settlement drop into the field contract this page consumes.
- Calculating locational marginal pricing in Python — decomposing the nodal price that feeds each settled amount.
- Mapping transmission loss factors to settlement nodes — the loss adjustment applied before a final PJM run reconciles.