LMP Decomposition
What you'll learn: What Locational Marginal Pricing (LMP) is, how it decomposes into energy, congestion, and loss components, how MangoGridStudio computes it, and how to use it to interpret solver results.
Prerequisites: Understanding of Unit Commitment — specifically the load balance constraint and its shadow price. Basic familiarity with optimization duality is helpful for Section 4 but not required.
Related docs: Unit Commitment | API Reference
1. What is LMP?
Locational Marginal Price (LMP) is the cost of serving one additional megawatt-hour of load at a specific bus (node) in the power network, given the current system conditions.
LMP has two properties that distinguish it from a single "system price":
-
It varies by location: if a transmission line is congested, buses on opposite sides of the bottleneck have different LMPs. The supply-side bus may have a low LMP; the demand-side bus, which cannot import cheap power through the congested line, must run local expensive generation and therefore has a high LMP.
-
It is derived from optimization: LMP is the shadow price (dual variable) of the power balance constraint at each bus in the optimal dispatch. It is not negotiated or announced before the solve — it falls out of the solution automatically.
Why location matters: an intuitive example
Two cities connected by a single 100 MW transmission line. The western city has cheap hydro (MC = $10/MWh). The eastern city has only expensive gas peakers (MC = $80/MWh).
- When total western-to-eastern demand is below 100 MW, the eastern city is served cheaply from the west. LMP everywhere ≈ $10/MWh.
- When demand pushes the line to its 100 MW limit, no more cheap power reaches the east. The eastern city must run its own peakers. LMP[east] ≈ $80/MWh, LMP[west] ≈ $10/MWh.
The $70/MWh difference between east and west is the congestion component — entirely due to the binding transmission limit. Without that line constraint, both cities would have LMP = $10/MWh.
Why LMP matters for MangoGridStudio users
- Grid operators use LMP patterns to identify which transmission constraints are binding and where generation additions or demand response would have the most value.
- FTR traders in MangoGridStudio's FTR module base their congestion revenue streams on the long-run LMP spread between injection and withdrawal points.
- AI Analyst uses LMP decomposition to explain solver results: "Bus 107 is $5.41/MWh above system price because Line L34 is congested."
- Developers use the LMP identity as the primary numeric assertion in solver tests — a solver that produces wrong LMPs is computing wrong shadow prices.
2. The Decomposition Formula
Source: PJM Manual 11, Section 2.2 "Definition of Locational Marginal Price" (Rev. 136, effective 2025-10-01)
LMP[bus] = Energy_Component[bus]
+ Congestion_Component[bus]
+ Loss_Component[bus]
Each term is a $/MWh value. The decomposition holds at every bus in the network and at every time interval. MangoGridStudio computes this decomposition after every solve and exposes it through the LMP decomposition API.
3. Energy Component
The energy component is the system-wide marginal energy price. In PJM's production system (M11 §2.2), it is the load-weighted average nodal LMP computed at a virtual distributed load reference bus — not tied to any single physical bus (DA: weighted by fixed-demand bids; RT: weighted by state-estimated load). In MangoGridStudio's DC approximation, it equals the shadow price of the aggregate load-balance constraint, which is equivalent for lossless single-period solves.
Energy_Component = dual( Σ_g p[g, h] = load[h] )
This is the same value at every bus in the network for a given hour. It represents the cost of serving one more MW of load system-wide, ignoring location.
How it maps to the marginal unit: In a simple economic dispatch (no network constraints), the energy component equals the marginal cost of the most expensive generator being dispatched.
Using the PJM Manual 11 §3 example:
- G1 (Coal, MC = $22/MWh) dispatched at 300 MW — inframarginal
- G2 (Gas CT, MC = $45/MWh) dispatched at 50 MW (Pmin) — marginal unit
- Energy Component = $45/MWh (G2's MC sets the system price)
Julia assertion pattern:
# Energy component = dual of load balance = MC of marginal unit (PJM M11 §3)
@test abs(energy_comp - 45.0) < 0.01
4. Congestion Component
The congestion component is location-specific — it captures the price premium or discount at a given bus due to binding transmission constraints.
Reference bus
In PJM's production system, the reference is a distributed load reference bus — a virtual load-weighted bus, not a single physical node (M11 §2.2). In MangoGridStudio's DC approximation, a single physical bus (topology.slack_bus) is used. In both cases, by construction:
Congestion_Component[reference] = 0
The reference has no congestion component — it is the point against which all other bus prices are measured. The energy component equals the LMP at this reference (when losses are zero).
PTDF-based congestion formula
The congestion component at any bus equals the sum of Power Transfer Distribution Factor (PTDF) weights multiplied by the shadow prices of binding line flow constraints:
Congestion_Component[bus] = Σ_l PTDF[l, bus] × dual( flow_constraint[l] )
Where:
PTDF[l, bus]= fraction of an injection atbusthat flows on linel(dimensionless, range [-1, 1])dual(flow_constraint[l])= shadow price of the thermal limit on linel(zero if not binding, non-zero if binding)
Physical interpretation: A positive congestion component at a bus means injecting power there relieves congestion elsewhere — the bus is on the demand side of a bottleneck. A negative congestion component means injection at that bus worsens congestion (the bus is on the supply side of a bottleneck).
Voltage angle representation: In DC power flow, PTDFs are computed from the network admittance matrix and bus-branch incidence matrix. MangoGridStudio caches PTDFs and reuses them across hours within a solve.
5. Loss Component
PJM production system: The loss component is computed by the EMS as per-Pnode loss penalty factors (LPFs), typically 1–3% of the energy component, derived from AC state estimation. LPFs vary by bus and operating condition. This is PJM's production settlement mechanism per M11 §2.2.
In MangoGridStudio's DC power flow approximation (the default for all UC/ED solves):
Loss_Component[bus] = 0 (all buses, all hours)
DC approximation treats the network as lossless — it ignores resistive power losses in transmission lines. This is the standard approach for large-scale UC/ED problems and introduces less than 2% error under typical operating conditions.
MangoGridStudio sets loss_component = 0.0 for all DC-formulation solves and logs an @info message once per solve confirming this. The loss field is present in every LMP decomposition response for forward compatibility with future AC OPF support.
In an AC OPF formulation (not yet enabled in MangoGridStudio):
Loss_Component[bus] ≈ (marginal transmission loss factor at bus) × Energy_Component
AC loss components are typically 1–3% of the energy component and vary with bus voltage angle (aligned with PJM EMS LPF magnitudes).
6. Full Worked Example
Setup: 2-bus network with congestion
Network: 2 buses connected by a single transmission line with a thermal limit of 200 MW.
Generators:
- Bus 1 (cheap, supply side): Coal, MC = $30/MWh, Pmax = 400 MW
- Bus 2 (expensive, demand side): Gas CT, MC = $60/MWh, Pmax = 150 MW
Loads:
- Bus 1: 150 MW
- Bus 2: 300 MW
- Total: 450 MW
Unconstrained optimum: Serve all load from Bus 1 coal (450 MW). Bus 1 coal exports 300 MW to Bus 2. But the line limit is 200 MW — this export is infeasible.
Constrained dispatch (line at 200 MW binding limit):
- Bus 1 coal: 150 (local) + 200 (export via line) = 350 MW
- Bus 2 gas CT: 300 - 200 = 100 MW (serves remaining local load)
- Line flow: 200 MW (binding, shadow price > 0)
LMP decomposition results:
| Bus | Energy | Congestion | Loss | LMP |
|---|---|---|---|---|
| Bus 1 (reference) | $30.00/MWh | $0.00 | $0.00 | $30.00/MWh |
| Bus 2 | $30.00/MWh | +$30.00 | $0.00 | $60.00/MWh |
Interpretation:
- Energy component = $30/MWh everywhere (MC of the marginal coal unit at Bus 1)
- Congestion at Bus 2 = +$30/MWh: to serve the next MW at Bus 2, you cannot import more from Bus 1 (line is full), so you must run local gas CT at $60/MWh — $30 above the system energy price
The PJM M11 §2.2 canonical LMP decomposition example (with loss component)
| Bus | Energy | Congestion | Loss | LMP |
|---|---|---|---|---|
| Reference bus | $30.00 | $0.00 | $0.00 | $30.00/MWh |
| Congested bus | $30.00 | +$5.00 | +$0.50 | $35.50/MWh |
Python assertion (required in all solver tests per CLAUDE.md):
energy_comp = result["lmp_decomposition"]["bus_ref"]["energy"] # 30.00
congestion = result["lmp_decomposition"]["bus2"]["congestion"] # 5.00
loss = result["lmp_decomposition"]["bus2"]["loss"] # 0.50 (AC) or 0.00 (DC)
lmp_bus2 = result["lmps"]["bus2"] # 35.50
# PJM M11 §2.2 LMP decomposition identity
assert abs(lmp_bus2 - (energy_comp + congestion + loss)) < 0.01
7. RTS-GMLC Reference Values
These values come from the GridMod/RTS-GMLC dataset (73 buses, 120 branches, NREL/DOE). Use them for LMP and marginal cost assertions in all solver tests.
Generator reference values (from gen.csv):
| Generator | Type | MC ($/MWh) | Ramp (MW/h) | MinUp (h) | Pmax (MW) | Source |
|---|---|---|---|---|---|---|
107_CC_1 | Gas CC | $28.09 | 248.4 | 4 | 355 | PJM M28 §4 |
113_CT_1 | Gas CT | — | 222.0 | 2.2 | 55 | gen.csv |
101_STEAM_3 | Coal | — | 120.0 | 8 | 76 | gen.csv |
121_NUCLEAR_1 | Nuclear | — | fixed | 24 | 400 | gen.csv |
MC calculation for 107_CC_1 (PJM Manual 28 §4):
MC = heat_rate_avg [BTU/kWh] × fuel_price [$/MMBTU] ÷ 1,000
= 7,222 × $3.887 ÷ 1,000
= $28.09/MWh
Startup cost for 107_CC_1 (cold start):
SU_cost = Start_Heat_Cold [MMBTU] × fuel_price [$/MMBTU]
= 7,215 × $3.887
= $28,044
121_NUCLEAR_1 is always must-run: PMin = 396 MW, PMax = 400 MW (essentially constant output), MinUp = 24h, MinDown = 48h, cold-start heat = 78,978 MMBTU. Fix is_on[nuclear, h] = 1.0 for all hours.
Ramp rate loading note: gen.csv stores ramp in MW/min. Multiply by 60 for MW/h:
107_CC_1: 4.14 MW/min × 60 = 248.4 MW/h113_CT_1: 3.7 MW/min × 60 = 222.0 MW/h101_STEAM_3: 2.0 MW/min × 60 = 120.0 MW/h
8. MangoGridStudio API
Python: access LMP from solver result
The solve result dictionary exposes LMPs per bus per hour:
# result is returned by the UC/ED solve API endpoint
# System LMP per hour (single bus or reference bus)
system_lmps = result["lmps"]["system"] # list of 24 floats ($/MWh)
# Bus-level LMP per hour (requires network model with PTDFs)
bus_107_lmps = result["lmps"]["bus_107"] # list of 24 floats ($/MWh)
# LMP at a specific bus, hour 12:
lmp_h12 = result["lmps"]["bus_107"][11] # index 11 = hour 12 (0-indexed)
Python: extract LMP decomposition
The LMP decomposition is returned in the solver result dictionary when compute_lmp_decomposition is enabled:
# result["lmp_decomposition"] is populated when enhanced_uc.compute_lmp_decomposition=true
decomp = result["lmp_decomposition"]
# Access decomposition for a specific bus
bus2_decomp = decomp["bus_107"]
energy = bus2_decomp["energy"] # $/MWh — same at all buses
congestion = bus2_decomp["congestion"] # $/MWh — location-specific
loss = bus2_decomp["loss"] # $/MWh — 0.0 for DC formulation
# Verify identity (PJM M11 §2.2)
lmp = result["lmps"]["bus_107"][0] # first hour
assert abs(lmp - (energy + congestion + loss)) < 0.01
API endpoint
LMP values are returned from the UC/ED solve endpoints:
POST /api/v1/uc/solve → response["lmp"][h] (system LMP per hour, DC)
POST /api/v1/ed/solve → response["lmps"][bus][h] (bus-level LMP per hour)
For bus-level LMP with full decomposition, use the enhanced UC solve endpoint with:
{
"config": {
"enhanced_uc": {
"compute_lmp_decomposition": true
}
}
}
Response includes lmp_decomposition:
{
"lmps": {
"bus_1": [28.09, 28.09, "..."],
"bus_107": [33.50, 34.10, "..."]
},
"lmp_decomposition": {
"bus_1": {"energy": 28.09, "congestion": 0.00, "loss": 0.0},
"bus_107": {"energy": 28.09, "congestion": 5.41, "loss": 0.0},
"reference_bus": "bus_1"
}
}
9. Use Cases
Identifying which transmission constraints are creating price divergence
When two buses have different LMPs, the congestion component reveals which lines are responsible:
# Find buses with high congestion components
congested_buses = [
bus for bus, decomp in result["lmp_decomposition"].items()
if abs(decomp["congestion"]) > 5.0 # more than $5/MWh congestion
]
# For each congested bus, the PTDFs × line duals produce the congestion value
# A line's dual is non-zero only when that line is at its thermal limit
In MangoGridStudio's AI Analyst, this appears as: "Bus 107's LMP is $5.41/MWh above the system price because Line L34 (230 kV, Bus 1 — Bus 107) is binding at its 200 MW thermal limit."
Detecting market power
If a specific generator has a marginal cost well below the system LMP, it is collecting economic rent. The rent equals (LMP - MC) × dispatch_MW:
for gen_id, dispatch in result["dispatch"].items():
gen = network["generators"][gen_id]
lmp_at_bus = result["lmps"][gen["bus"]][hour]
rent = (lmp_at_bus - gen["marginal_cost"]) * dispatch
if rent > 10000: # more than $10,000/h rent
print(f"{gen_id}: ${rent:,.0f}/h economic rent at hour {hour}")
Validating solver output
The LMP decomposition identity is the strongest single check on solver correctness. A solver that computes correct dispatch quantities but wrong shadow prices will fail this test:
for bus_id, decomp in result["lmp_decomposition"].items():
lmp = result["lmps"][bus_id][hour]
identity_lhs = decomp["energy"] + decomp["congestion"] + decomp["loss"]
assert abs(lmp - identity_lhs) < 0.01, (
f"LMP decomposition identity failed at bus {bus_id}, hour {hour}: "
f"LMP={lmp:.4f}, sum={identity_lhs:.4f}"
)
FTR congestion revenue calculation
A Financial Transmission Right (FTR) from Bus A to Bus B entitles the holder to revenue equal to:
FTR_revenue = MW_awarded × (LMP[B] - LMP[A]) × hours
In MangoGridStudio's FTR module, this settlement is computed using the DA-UC LMP values returned from the solver. The congestion component at each bus is what the FTR was purchased to hedge against.
10. Required Test Assertions
Per CLAUDE.md testing standards, every test that validates DA-UC dispatch, LMP, or shadow prices must assert at least one numeric result grounded in PJM M11 or M28.
Minimum required assertion (Python):
# PJM M11 §2.2 LMP decomposition — required in every solver test
assert abs(lmp["bus2"] - (energy_comp + congestion_comp + loss_comp)) < 0.01
Full recommended assertion set:
# System LMP = MC of marginal unit (PJM M11 §3)
assert abs(system_lmp - 45.0) < 0.01 # G2 Gas CT at Pmin
# LMP decomposition identity (PJM M11 §2.2)
assert abs(lmp["bus2"] - (energy_comp + congestion_comp + loss_comp)) < 0.01
# Reference bus has zero congestion by definition
assert abs(decomp["bus_ref"]["congestion"]) < 1e-6
# DC approximation: loss = 0
assert decomp["bus2"]["loss"] == 0.0
# MC formula (PJM M28 §4)
assert abs(mc["107_CC_1"] - (7222.0 * 3.88722 / 1000.0)) < 0.10 # $28.09/MWh
# Shadow price = dual of load balance
assert abs(shadow_price - dual_load_balance) < 1e-4
Julia assertion pattern:
# PJM M11 §2.2 — decomposition identity
@test abs(lmp[bus_congested] - (energy_comp + congestion_comp + loss_comp)) < 0.01
# PJM M28 §4 — MC formula
@test abs(mc["107_CC_1"] - (7222.0 * 3.88722 / 1000.0)) < 0.10
# Shadow price equals load balance dual
@test abs(shadow_price - load_balance_dual) < 1e-4
Why the decomposition identity is the best test: The decomposition is not just a restatement of the LMP value — it forces the solver to compute correctly aligned shadow prices for both the load balance constraint and all binding line flow constraints. A solver that produces a correct total LMP but incorrect decomposition (e.g., attributing congestion to the energy component) will fail this check.
11. Troubleshooting
LMP values are all equal across buses
Expected when no transmission constraints are binding. All congestion components are zero, and every bus sees the same energy price. If you expect congestion but see flat LMPs, check that line thermal limits (rate_a) are set correctly — if all limits are very large (9,999 MW), no lines bind.
LMP is negative
Legitimate negative LMP occurs when:
- A must-run generator (nuclear, minimum-energy contract) produces more than local load can absorb, and line limits prevent export
- Curtailment is cheaper than congestion relief
Check that Pmin constraints are not forcing excess generation beyond load.
Energy component does not match any generator's MC
In a MILP (with startup costs), the energy component includes the amortized startup cost contribution. The LP relaxation LMP equals the MC of the marginal unit exactly; the MILP LMP may differ slightly due to commitment decisions. This is correct behavior — the decomposition identity still holds.
compute_lmp_decomposition returns error about missing dual values
Dual values are only available for LP solves or LP re-solves. After a MILP solve, MangoGridStudio automatically re-solves the LP with all binary variables fixed to their optimal values to obtain duals. If this step is being skipped (e.g., timeout before re-solve), set time_limit_s high enough to allow both the MILP solve and the LP re-solve.
LMP decomposition returns zero congestion despite binding lines
Check that compute_lmp_decomposition: true is set in the enhanced_uc config and that the network topology (buses, lines, PTDFs) is being passed to the Julia solver. A UC solve without a network model (single-bus approximation) has no PTDFs and therefore cannot compute congestion components.