surge_network/market/dispatchable_load.rs
1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! Dispatchable load (demand-response) types and cost models for OPF.
3//!
4//! # Background
5//!
6//! Dispatchable loads are the demand-side counterpart to generators. In modern
7//! ISO/RTO markets (ERCOT, PJM, MISO):
8//!
9//! - **Interruptible loads** (ILs): industrial customers with curtailment contracts,
10//! dispatched by the ISO when the LMP exceeds their curtailment threshold.
11//! - **Demand Response Resources** (DRRs): aggregated behind-the-meter loads that
12//! reduce consumption for a price signal.
13//! - **Emergency Response Service** (ERS in ERCOT): loads that curtail on emergency
14//! signals at a fixed interruptible price.
15//! - **EV charging stations**: flexible load that can shift time-of-use.
16//! - **Virtual Power Plants**: aggregated small loads + DER acting as a single
17//! dispatchable resource.
18//!
19//! # Formulation
20//!
21//! Standard OPF minimizes generation cost given fixed load:
22//! ```text
23//! min Σ C_gen(Pg)
24//! s.t. power balance, thermal limits, voltage limits
25//! ```
26//!
27//! With dispatchable loads the problem becomes **social welfare maximization**:
28//! ```text
29//! min Σ C_gen(Pg) - Σ U_load(P_served)
30//! s.t. power balance: Σ Pg - Σ P_load_fixed - Σ (p_sched - P_served) - P_calc(V) = 0
31//! P_served[i] ∈ [p_min[i], p_max[i]]
32//! ```
33//!
34//! The net load at bus k becomes:
35//! ```text
36//! P_load_effective[k] = P_load_fixed[k] + Σ (p_sched[i] - P_served[i])
37//! i at bus k
38//! ```
39//! So when `P_served = p_sched` (fully served), the load is normal.
40//! When `P_served < p_sched` (curtailed), net load is reduced by the curtailment.
41//!
42//! # Sign Convention
43//!
44//! All power quantities use the generator convention (positive = injection):
45//! - Loads consume power: they appear as negative injection.
46//! - `p_sched_pu` > 0 means the load consumes `p_sched * base_mva` MW.
47//! - The OPF variable `P_served` ∈ [p_min, p_max] (both positive).
48//! - Curtailment = `p_sched - P_served` (positive when load is curtailed).
49//! - Power balance contribution: `P_served - p_sched` (negative when fully served,
50//! less negative when curtailed — i.e., reduced load = increased net injection).
51
52use serde::{Deserialize, Serialize};
53
54use crate::market::EnergyOffer;
55
56/// Per-period parameter override for a dispatchable load.
57///
58/// Analogous to `OfferSchedule` for generators, this lets
59/// the DA/RT market engine supply per-period demand curves that the
60/// SCUC/SCED LP uses for cost coefficients and capacity bounds.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct DlPeriodParams {
63 /// Scheduled / baseline real power consumption this period (per-unit).
64 pub p_sched_pu: f64,
65 /// Maximum real power served this period (per-unit).
66 pub p_max_pu: f64,
67 /// Optional scheduled reactive power this period (per-unit).
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub q_sched_pu: Option<f64>,
70 /// Optional minimum reactive power served this period (per-unit).
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub q_min_pu: Option<f64>,
73 /// Optional maximum reactive power served this period (per-unit).
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub q_max_pu: Option<f64>,
76 /// Optional per-period GO-style `q = q0 + beta * p` equality link.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub pq_linear_equality: Option<crate::network::generator::PqLinearLink>,
79 /// Optional per-period GO-style upper p-q capability line.
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub pq_linear_upper: Option<crate::network::generator::PqLinearLink>,
82 /// Optional per-period GO-style lower p-q capability line.
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub pq_linear_lower: Option<crate::network::generator::PqLinearLink>,
85 /// Cost / benefit model for this period's objective.
86 pub cost_model: LoadCostModel,
87}
88
89/// Time-varying schedule for a dispatchable load, per period.
90///
91/// Index into `periods` = period (hour for DA, interval for RT).
92/// `None` = use the base [`DispatchableLoad`] fields for that period.
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94pub struct DlOfferSchedule {
95 /// Per-period overrides. `periods[t] = None` means fall back to base.
96 pub periods: Vec<Option<DlPeriodParams>>,
97}
98
99/// Demand-response load archetype, governing the OPF variable structure.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101pub enum LoadArchetype {
102 /// P curtailable in `[p_min, p_sched]`, Q follows a fixed power factor.
103 ///
104 /// Most common archetype for industrial interruptible contracts. The OPF
105 /// chooses how much real power to serve; reactive power follows automatically
106 /// via `Q_served = Q_sched * (P_served / p_sched)`.
107 Curtailable,
108
109 /// Elastic / price-responsive demand: OPF chooses P in `[p_min, p_max]`.
110 ///
111 /// Used for loads with a true demand curve (EV charging aggregators,
112 /// virtual power plants, demand-side bidding in day-ahead markets).
113 /// Typically paired with [`LoadCostModel::QuadraticUtility`] or
114 /// [`LoadCostModel::PiecewiseLinear`].
115 Elastic,
116
117 /// Interruptible: P ∈ [0, p_sched], continuous relaxation of {0, p_sched}.
118 ///
119 /// Models loads that are either fully on or fully off (interruptible service
120 /// contracts). The LP/NLP relaxation allows fractional values; round
121 /// post-solve if binary decisions are needed.
122 Interruptible,
123
124 /// Independent P and Q control.
125 ///
126 /// Used for STATCOM-like loads, EV chargers with reactive capability, or
127 /// any resource that can independently set real and reactive power.
128 IndependentPQ,
129}
130
131/// Cost / benefit model governing the OPF objective contribution.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub enum LoadCostModel {
134 /// Linear curtailment cost: `c * (P_sched - P_served)` [$/MWh].
135 ///
136 /// Represents the Value of Lost Load (VOLL) or interruptible contract price.
137 /// OPF will curtail when `LMP > c` (curtailment is cheaper than serving the load).
138 /// Objective contribution to minimise: `c * (p_sched - P_served)`.
139 /// Gradient d_obj/d_P_served = `-c` (serving more load reduces curtailment cost).
140 LinearCurtailment {
141 /// Curtailment cost in $/MWh (Value of Lost Load or contract price).
142 cost_per_mw: f64,
143 },
144
145 /// Quadratic utility / welfare: `U(P) = a*P - b*P²/2`.
146 ///
147 /// The marginal utility is `MU(P) = a - b*P` (linear demand curve).
148 /// OPF minimises `-U(P)` (equivalently, maximises utility minus cost).
149 /// At equilibrium: `MU(P_served) = LMP` (price equals marginal value).
150 ///
151 /// OPF objective contribution (to minimise): `-(a*P - b*P²/2)`.
152 /// Gradient d_obj/d_P = `-(a - b*P) = b*P - a`.
153 /// Second derivative (for Hessian): `b` (positive = convex minimisation).
154 QuadraticUtility {
155 /// Choke price in $/MWh (marginal utility at P = 0).
156 a: f64,
157 /// Slope of marginal utility curve in $/MW²h (demand curve steepness).
158 b: f64,
159 },
160
161 /// Piecewise-linear utility: list of `(P_breakpoint_MW, marginal_utility_$/MWh)` pairs.
162 ///
163 /// Breakpoints must be sorted by P in ascending order. The utility between
164 /// adjacent breakpoints is the integral of the linear interpolated MU.
165 /// Compatible with LP formulation (no quadratic terms).
166 PiecewiseLinear {
167 /// Breakpoints: `(P_MW, marginal_utility_$/MWh)`. Must be sorted by P.
168 points: Vec<(f64, f64)>,
169 },
170
171 /// Fixed penalty for any curtailment (interruptible contract).
172 ///
173 /// Similar to `LinearCurtailment` but semantically represents a per-event
174 /// interrupt payment rather than a value-of-lost-load.
175 /// Objective: `c * (P_sched - P_served)`.
176 InterruptPenalty {
177 /// Payment to load owner per MW curtailed per hour [$/MWh].
178 cost_per_mw: f64,
179 },
180}
181
182impl LoadCostModel {
183 /// Evaluate cost contribution to OPF objective (to **minimise**).
184 ///
185 /// For utility models, returns the *negative* utility (minimising negative
186 /// utility maximises social welfare). For cost models, returns the positive
187 /// curtailment cost.
188 ///
189 /// `p_served` and `p_sched` are both in per-unit (positive = consuming).
190 /// Returns $/hr (consistent with generator cost units in the OPF).
191 pub fn objective_contribution(&self, p_served_pu: f64, p_sched_pu: f64, base_mva: f64) -> f64 {
192 match self {
193 LoadCostModel::LinearCurtailment { cost_per_mw }
194 | LoadCostModel::InterruptPenalty { cost_per_mw } => {
195 // c * (P_sched - P_served) in $/hr
196 cost_per_mw * (p_sched_pu - p_served_pu) * base_mva
197 }
198 LoadCostModel::QuadraticUtility { a, b } => {
199 // Minimise -(a*P - b*P²/2). P in MW = p_served_pu * base_mva.
200 let p_mw = p_served_pu * base_mva;
201 -(a * p_mw - b * p_mw * p_mw / 2.0)
202 }
203 LoadCostModel::PiecewiseLinear { points } => {
204 if points.len() < 2 {
205 return 0.0;
206 }
207 let p_mw = p_served_pu * base_mva;
208 // Integrate MU(P) from 0 to p_mw = utility value.
209 // Objective = -utility (we minimise -U).
210 let utility = integrate_pwl_utility(points, p_mw);
211 -utility
212 }
213 }
214 }
215
216 /// Gradient of objective contribution w.r.t. `p_served_pu`.
217 ///
218 /// Used in gradient evaluation (AC-OPF objective gradient).
219 /// Units: $/hr / (pu) = $/hr * base_mva / MW.
220 pub fn d_obj_d_p(&self, p_served_pu: f64, base_mva: f64) -> f64 {
221 match self {
222 LoadCostModel::LinearCurtailment { cost_per_mw }
223 | LoadCostModel::InterruptPenalty { cost_per_mw } => {
224 // d/dP_pu [c * (p_sched - P_served) * base] = -c * base
225 -cost_per_mw * base_mva
226 }
227 LoadCostModel::QuadraticUtility { a, b } => {
228 // d/dP_pu [-(a*P_mw - b*P_mw²/2)] where P_mw = P_pu * base
229 // = -(a - b*P_mw) * base = (b*P_mw - a) * base
230 let p_mw = p_served_pu * base_mva;
231 (b * p_mw - a) * base_mva
232 }
233 LoadCostModel::PiecewiseLinear { points } => {
234 if points.len() < 2 {
235 return 0.0;
236 }
237 let p_mw = p_served_pu * base_mva;
238 // d(-utility)/dP_pu = -MU(P) * base
239 let mu = interpolate_marginal_utility(points, p_mw);
240 -mu * base_mva
241 }
242 }
243 }
244
245 /// Second derivative of objective w.r.t. `p_served_pu` (for Hessian).
246 ///
247 /// Non-zero only for [`LoadCostModel::QuadraticUtility`]: `b * base_mva²`.
248 /// All other models have zero second derivative (linear in P_served).
249 pub fn d2_obj_d_p2(&self, base_mva: f64) -> f64 {
250 match self {
251 LoadCostModel::QuadraticUtility { b, .. } => b * base_mva * base_mva,
252 _ => 0.0,
253 }
254 }
255
256 /// Whether this cost model contributes a nonzero quadratic (Hessian) term.
257 pub fn has_quadratic_term(&self) -> bool {
258 matches!(self, LoadCostModel::QuadraticUtility { .. })
259 }
260
261 /// Linear objective coefficient for DC-OPF (LP/QP).
262 ///
263 /// For LP-compatible models (LinearCurtailment, InterruptPenalty):
264 /// returns `-cost_per_mw * base_mva` (coefficient on P_served_pu in objective).
265 ///
266 /// For QP models (QuadraticUtility): returns the linear coefficient `-a * base_mva`.
267 /// The quadratic term must be added to the Hessian separately.
268 ///
269 /// For PiecewiseLinear: returns 0.0 (pwl epiograph constraints handle it).
270 pub fn dc_linear_obj_coeff(&self, base_mva: f64) -> f64 {
271 match self {
272 LoadCostModel::LinearCurtailment { cost_per_mw }
273 | LoadCostModel::InterruptPenalty { cost_per_mw } => {
274 // Objective: c*(p_sched - P_served)*base → linear coeff on P_served = -c*base
275 -cost_per_mw * base_mva
276 }
277 LoadCostModel::QuadraticUtility { a, .. } => {
278 // Objective: -(a*P_mw - b*P_mw²/2) → linear coeff = -a*base
279 -a * base_mva
280 }
281 LoadCostModel::PiecewiseLinear { .. } => 0.0,
282 }
283 }
284
285 /// Quadratic diagonal coefficient for DC-OPF (QP Hessian).
286 ///
287 /// For [`LoadCostModel::QuadraticUtility`]: `b * base_mva²` (full quadratic coefficient,
288 /// HiGHS applies 0.5× internally for the symmetric ½ x'Hx convention).
289 pub fn dc_quadratic_obj_coeff(&self, base_mva: f64) -> f64 {
290 match self {
291 LoadCostModel::QuadraticUtility { b, .. } => b * base_mva * base_mva,
292 _ => 0.0,
293 }
294 }
295}
296
297/// Integrate piecewise-linear marginal utility from P=0 to P=p_mw.
298fn integrate_pwl_utility(points: &[(f64, f64)], p_mw: f64) -> f64 {
299 let mut utility = 0.0;
300 let mut prev_p = 0.0;
301 let mut prev_mu = if !points.is_empty() { points[0].1 } else { 0.0 };
302
303 // Extend from 0 to first breakpoint using first MU value
304 if !points.is_empty() && points[0].0 > 0.0 {
305 let end = p_mw.min(points[0].0);
306 utility += prev_mu * end;
307 if p_mw <= points[0].0 {
308 return utility;
309 }
310 prev_p = points[0].0;
311 }
312
313 for &(bp, mu) in points {
314 if bp <= prev_p {
315 prev_mu = mu;
316 continue;
317 }
318 let end = p_mw.min(bp);
319 // Trapezoid integration over [prev_p, end]
320 let mid_mu = prev_mu + (mu - prev_mu) * (end - prev_p) / (bp - prev_p);
321 utility += (prev_mu + mid_mu) / 2.0 * (end - prev_p);
322 if p_mw <= bp {
323 return utility;
324 }
325 prev_p = bp;
326 prev_mu = mu;
327 }
328 // Beyond last breakpoint: assume MU = last value (flat)
329 if p_mw > prev_p {
330 utility += prev_mu * (p_mw - prev_p);
331 }
332 utility
333}
334
335/// Interpolate marginal utility at a given P_mw from PWL breakpoints.
336fn interpolate_marginal_utility(points: &[(f64, f64)], p_mw: f64) -> f64 {
337 if points.is_empty() {
338 return 0.0;
339 }
340 if p_mw <= points[0].0 {
341 return points[0].1;
342 }
343 for i in 1..points.len() {
344 let (p0, mu0) = points[i - 1];
345 let (p1, mu1) = points[i];
346 if p_mw <= p1 {
347 let t = (p_mw - p0) / (p1 - p0);
348 return mu0 + t * (mu1 - mu0);
349 }
350 }
351 // Beyond last breakpoint: use last MU value
352 points.last().map(|&(_, mu)| mu).unwrap_or(0.0)
353}
354
355/// A dispatchable load resource at a specific bus.
356///
357/// Represents any demand-side resource that can vary its consumption in response
358/// to price signals or ISO dispatch instructions. Compatible archetypes include
359/// interruptible loads, demand response resources, EV charging aggregators, and
360/// virtual power plants.
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct DispatchableLoad {
363 /// External bus number this resource is attached to.
364 pub bus: u32,
365
366 /// Scheduled / baseline real power consumption (per-unit, positive = consuming).
367 pub p_sched_pu: f64,
368
369 /// Scheduled / baseline reactive power consumption (per-unit, positive = consuming).
370 pub q_sched_pu: f64,
371
372 /// Minimum real power served (per-unit).
373 ///
374 /// - Curtailable: `p_min < p_sched` (partial curtailment allowed).
375 /// - Interruptible: 0.0 (full curtailment allowed).
376 /// - Elastic: may be 0.0 or greater.
377 pub p_min_pu: f64,
378
379 /// Maximum real power served (per-unit).
380 ///
381 /// - Curtailable / Interruptible: typically equals `p_sched_pu`.
382 /// - Elastic: may exceed `p_sched_pu` (load can increase above baseline).
383 pub p_max_pu: f64,
384
385 /// Minimum reactive power served (per-unit).
386 ///
387 /// For fixed-power-factor loads: `q_sched * p_min / p_sched`.
388 /// For IndependentPQ: independently bounded.
389 pub q_min_pu: f64,
390
391 /// Maximum reactive power served (per-unit).
392 ///
393 /// For fixed-power-factor loads: `q_sched_pu`.
394 /// For IndependentPQ: independently bounded.
395 pub q_max_pu: f64,
396
397 /// Demand-response archetype — governs variable structure and constraints.
398 pub archetype: LoadArchetype,
399
400 /// Cost / benefit model for objective function.
401 pub cost_model: LoadCostModel,
402
403 /// Fixed power factor flag.
404 ///
405 /// When `true`, reactive power tracks real power proportionally:
406 /// `Q_served = q_sched_pu / p_sched_pu * P_served`
407 ///
408 /// Implemented as an equality constraint in AC-OPF, or by substituting
409 /// `Q_served` as a function of `P_served` (eliminating one variable).
410 ///
411 /// Automatically `false` for `IndependentPQ` archetype.
412 pub fixed_power_factor: bool,
413
414 /// Whether this resource is active in the OPF.
415 ///
416 /// When `false`, the resource is treated as a fixed load at `p_sched_pu`.
417 pub in_service: bool,
418
419 /// Stable user-facing identifier for this demand-response resource.
420 ///
421 /// When empty, callers may canonicalize it from network context.
422 #[serde(default)]
423 pub resource_id: String,
424
425 // -----------------------------------------------------------------------
426 // Market product metadata (informational — does not affect OPF math)
427 // -----------------------------------------------------------------------
428 /// ISO/RTO market product type, e.g., "ECRSS", "NSRS", "RRS", "ERCOT_DR".
429 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub product_type: Option<String>,
431
432 /// Dispatch notification lead time in minutes.
433 ///
434 /// Distinguishes real-time (< 10 min) from day-ahead (> 60 min) resources.
435 /// Informational — affects market product eligibility, not OPF math.
436 #[serde(default)]
437 pub dispatch_notification_minutes: f64,
438
439 /// Minimum dispatch duration in hours (e.g., 1.0 for ERS in ERCOT).
440 ///
441 /// Informational — affects market product eligibility, not OPF math.
442 #[serde(default)]
443 pub min_duration_hours: f64,
444 /// Customer baseline load (MW). When present, curtailment is measured as
445 /// `baseline_mw - p_served_mw` instead of `p_sched - p_served`.
446 /// Used for settlement. Dispatch optimization still uses `p_sched`.
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub baseline_mw: Option<f64>,
449 /// Fraction of curtailed energy that rebounds as additional load after
450 /// curtailment ends (0.0–1.0). Zero means no rebound.
451 #[serde(default)]
452 pub rebound_fraction: f64,
453 /// Number of periods over which rebound energy is spread.
454 #[serde(default)]
455 pub rebound_periods: usize,
456
457 // --- intertemporal ramp limits ---
458 /// Maximum upward ramp rate of served real power (per-unit per hour).
459 ///
460 /// `None` means the load has no ramp constraint (legacy behaviour).
461 /// When set, the SCUC/SCED LP enforces
462 /// `p_served[t] - p_served[t-1] <= ramp_up_pu_per_hr * dt`
463 /// and the initial-period constraint
464 /// `p_served[0] <= initial_p_pu + ramp_up_pu_per_hr * dt[0]`
465 /// (via the optional `initial_p_pu` below).
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub ramp_up_pu_per_hr: Option<f64>,
468
469 /// Maximum downward ramp rate of served real power (per-unit per hour).
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub ramp_down_pu_per_hr: Option<f64>,
472
473 /// Prior-horizon served real power (per-unit). Used as the anchor for the
474 /// first-period ramp constraint when `ramp_up_pu_per_hr` /
475 /// `ramp_down_pu_per_hr` is set. `None` disables the first-period anchor.
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub initial_p_pu: Option<f64>,
478
479 /// Linear EQUALITY linking `q = q_at_p_zero_pu + beta * p` for
480 /// consumers whose reactive output is rigidly tied to their real
481 /// power schedule. Q-reserves on these devices are forced to zero.
482 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub pq_linear_equality: Option<crate::network::generator::PqLinearLink>,
484 /// Linear UPPER bound `q + q^qrd ≤ q_at_p_zero_pu + beta * p` for
485 /// consumers that cap their reactive output relative to real power.
486 /// For consumers `q^qrd` is on the LHS of the upper bound — the
487 /// formulation is symmetric to producers but with `qrd` and `qru`
488 /// swapped on the role of "tightening the box".
489 #[serde(default, skip_serializing_if = "Option::is_none")]
490 pub pq_linear_upper: Option<crate::network::generator::PqLinearLink>,
491 /// Linear LOWER bound `q − q^qru ≥ q_at_p_zero_pu + beta * p` for
492 /// consumers that floor their reactive output relative to real
493 /// power.
494 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub pq_linear_lower: Option<crate::network::generator::PqLinearLink>,
496
497 /// Identifier for a multi-block ramp aggregate.
498 ///
499 /// When multiple `DispatchableLoad` entries share the same `ramp_group`,
500 /// the SCUC/SCED LP constrains `Σ p_served` across the group (rather
501 /// than each block individually) against the shared `ramp_up_pu_per_hr`,
502 /// `ramp_down_pu_per_hr`, and `initial_p_pu`. Use this when a single
503 /// physical consumer is split into price-block DL entries that must
504 /// still respect the consumer-level ramp rate as an aggregate.
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub ramp_group: Option<String>,
507
508 // --- market ---
509 /// Energy offer for market clearing.
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub energy_offer: Option<EnergyOffer>,
512 /// Reserve offers keyed by product ID (generic reserve model).
513 #[serde(default, skip_serializing_if = "Vec::is_empty")]
514 pub reserve_offers: Vec<crate::market::reserve::ReserveOffer>,
515 /// Identifier for a physical reserve-capability aggregate.
516 ///
517 /// When multiple dispatchable-load blocks share this value, the reserve LP
518 /// constrains the sum of their awards by the group's capability instead of
519 /// treating each block as an independent provider. This is useful when one
520 /// physical consumer is split into multiple price blocks.
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub reserve_group: Option<String>,
523 /// Custom qualification flags for reserve products.
524 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
525 pub qualifications: crate::market::reserve::QualificationMap,
526}
527
528impl DispatchableLoad {
529 /// Create a curtailable load with linear curtailment cost.
530 ///
531 /// # Arguments
532 /// - `bus`: external bus number
533 /// - `p_sched_mw`, `q_sched_mvar`: scheduled consumption (MW / MVAr)
534 /// - `p_min_mw`: minimum served real power (MW)
535 /// - `cost_per_mwh`: Value of Lost Load or interruptible contract price ($/MWh)
536 /// - `base_mva`: system MVA base for per-unit conversion
537 pub fn curtailable(
538 bus: u32,
539 p_sched_mw: f64,
540 q_sched_mvar: f64,
541 p_min_mw: f64,
542 cost_per_mwh: f64,
543 base_mva: f64,
544 ) -> Self {
545 let pf_ratio = if p_sched_mw.abs() > 1e-10 {
546 q_sched_mvar / p_sched_mw
547 } else {
548 0.0
549 };
550 let p_sched_pu = p_sched_mw / base_mva;
551 let q_sched_pu = q_sched_mvar / base_mva;
552 let p_min_pu = p_min_mw / base_mva;
553 let q_min_pu = p_min_pu * pf_ratio;
554 Self {
555 bus,
556 p_sched_pu,
557 q_sched_pu,
558 p_min_pu,
559 p_max_pu: p_sched_pu,
560 q_min_pu,
561 q_max_pu: q_sched_pu,
562 archetype: LoadArchetype::Curtailable,
563 cost_model: LoadCostModel::LinearCurtailment {
564 cost_per_mw: cost_per_mwh,
565 },
566 fixed_power_factor: true,
567 in_service: true,
568 resource_id: String::new(),
569 product_type: None,
570 dispatch_notification_minutes: 0.0,
571 min_duration_hours: 0.0,
572 baseline_mw: None,
573 rebound_fraction: 0.0,
574 rebound_periods: 0,
575 energy_offer: None,
576 reserve_offers: Vec::new(),
577 reserve_group: None,
578 qualifications: std::collections::HashMap::new(),
579 ramp_up_pu_per_hr: None,
580 ramp_down_pu_per_hr: None,
581 initial_p_pu: None,
582 ramp_group: None,
583 pq_linear_equality: None,
584 pq_linear_upper: None,
585 pq_linear_lower: None,
586 }
587 }
588
589 /// Create an elastic load with quadratic utility function.
590 ///
591 /// Demand curve: `MU(P) = a - b*P` where P is in MW.
592 /// At equilibrium: `MU(P_served) = LMP`.
593 pub fn elastic(
594 bus: u32,
595 p_min_mw: f64,
596 p_max_mw: f64,
597 a_choke_price: f64,
598 b_slope: f64,
599 base_mva: f64,
600 ) -> Self {
601 let p_min_pu = p_min_mw / base_mva;
602 let p_max_pu = p_max_mw / base_mva;
603 // Reactive bounds: assume unity power factor (Q = 0)
604 Self {
605 bus,
606 p_sched_pu: p_max_pu,
607 q_sched_pu: 0.0,
608 p_min_pu,
609 p_max_pu,
610 q_min_pu: 0.0,
611 q_max_pu: 0.0,
612 archetype: LoadArchetype::Elastic,
613 cost_model: LoadCostModel::QuadraticUtility {
614 a: a_choke_price,
615 b: b_slope,
616 },
617 fixed_power_factor: false,
618 in_service: true,
619 resource_id: String::new(),
620 product_type: None,
621 dispatch_notification_minutes: 0.0,
622 min_duration_hours: 0.0,
623 baseline_mw: None,
624 rebound_fraction: 0.0,
625 rebound_periods: 0,
626 energy_offer: None,
627 reserve_offers: Vec::new(),
628 reserve_group: None,
629 qualifications: std::collections::HashMap::new(),
630 ramp_up_pu_per_hr: None,
631 ramp_down_pu_per_hr: None,
632 initial_p_pu: None,
633 ramp_group: None,
634 pq_linear_equality: None,
635 pq_linear_upper: None,
636 pq_linear_lower: None,
637 }
638 }
639
640 /// Create an interruptible load with interrupt penalty.
641 pub fn interruptible(
642 bus: u32,
643 p_sched_mw: f64,
644 q_sched_mvar: f64,
645 cost_per_mwh: f64,
646 base_mva: f64,
647 ) -> Self {
648 let p_sched_pu = p_sched_mw / base_mva;
649 let q_sched_pu = q_sched_mvar / base_mva;
650 Self {
651 bus,
652 p_sched_pu,
653 q_sched_pu,
654 p_min_pu: 0.0,
655 p_max_pu: p_sched_pu,
656 q_min_pu: 0.0,
657 q_max_pu: q_sched_pu,
658 archetype: LoadArchetype::Interruptible,
659 cost_model: LoadCostModel::InterruptPenalty {
660 cost_per_mw: cost_per_mwh,
661 },
662 fixed_power_factor: true,
663 in_service: true,
664 resource_id: String::new(),
665 product_type: None,
666 dispatch_notification_minutes: 0.0,
667 min_duration_hours: 0.0,
668 baseline_mw: None,
669 rebound_fraction: 0.0,
670 rebound_periods: 0,
671 energy_offer: None,
672 reserve_offers: Vec::new(),
673 reserve_group: None,
674 qualifications: std::collections::HashMap::new(),
675 ramp_up_pu_per_hr: None,
676 ramp_down_pu_per_hr: None,
677 initial_p_pu: None,
678 ramp_group: None,
679 pq_linear_equality: None,
680 pq_linear_upper: None,
681 pq_linear_lower: None,
682 }
683 }
684
685 /// Get the reserve offer for a specific product, if any.
686 pub fn reserve_offer(&self, product_id: &str) -> Option<&crate::market::reserve::ReserveOffer> {
687 self.reserve_offers
688 .iter()
689 .find(|o| o.product_id == product_id)
690 }
691
692 /// Net injection contribution at the bus at scheduled level (negative = load consuming).
693 #[inline]
694 pub fn p_injection_sched(&self) -> f64 {
695 -self.p_sched_pu
696 }
697
698 /// Net reactive injection at the bus at scheduled level.
699 #[inline]
700 pub fn q_injection_sched(&self) -> f64 {
701 -self.q_sched_pu
702 }
703
704 /// Fixed-power-factor ratio Q/P (0 if p_sched = 0).
705 #[inline]
706 pub fn pf_ratio(&self) -> f64 {
707 if self.p_sched_pu.abs() > 1e-10 {
708 self.q_sched_pu / self.p_sched_pu
709 } else {
710 0.0
711 }
712 }
713}
714
715/// Post-solve dispatch result for a single dispatchable load.
716#[derive(Debug, Clone, Serialize, Deserialize)]
717pub struct LoadDispatchResult {
718 /// External bus number this dispatch result applies to.
719 pub bus: u32,
720
721 /// Real power served at optimal dispatch (per-unit, positive = consuming).
722 pub p_served_pu: f64,
723
724 /// Reactive power served at optimal dispatch (per-unit, positive = consuming).
725 pub q_served_pu: f64,
726
727 /// Real power curtailed: `p_sched - p_served` (per-unit, ≥ 0).
728 pub p_curtailed_pu: f64,
729
730 /// Curtailment percentage: `(p_curtailed / p_sched) * 100`.
731 pub curtailment_pct: f64,
732
733 /// Objective cost contribution ($/hr), consistent with generator costs.
734 pub cost_contribution: f64,
735
736 /// LMP at this bus from the OPF solution ($/MWh).
737 pub lmp_at_bus: f64,
738
739 /// Net economic benefit of curtailment: `(LMP - curtailment_cost) * MW_curtailed`.
740 ///
741 /// Positive when curtailment is economically beneficial (LMP > curtailment cost).
742 pub net_curtailment_benefit: f64,
743}
744
745impl LoadDispatchResult {
746 /// Build a dispatch result from OPF solution values.
747 ///
748 /// # Arguments
749 /// - `dl`: the dispatchable load resource
750 /// - `p_served_pu`: optimal P_served from OPF (per-unit)
751 /// - `q_served_pu`: optimal Q_served from OPF (per-unit)
752 /// - `lmp`: LMP at the load's bus ($/MWh)
753 /// - `base_mva`: system MVA base
754 pub fn from_solution(
755 dl: &DispatchableLoad,
756 p_served_pu: f64,
757 q_served_pu: f64,
758 lmp: f64,
759 base_mva: f64,
760 ) -> Self {
761 let baseline_pu = dl
762 .baseline_mw
763 .map(|mw| mw / base_mva)
764 .unwrap_or(dl.p_sched_pu);
765 let p_curtailed_pu = (baseline_pu - p_served_pu).max(0.0);
766 let curtailment_pct = if baseline_pu.abs() > 1e-12 {
767 p_curtailed_pu / baseline_pu * 100.0
768 } else {
769 0.0
770 };
771 let cost_contribution =
772 dl.cost_model
773 .objective_contribution(p_served_pu, dl.p_sched_pu, base_mva);
774
775 // Net benefit of curtailment: (LMP - curtailment_cost) * MW_curtailed
776 // When LMP > curtailment_cost, it is cheaper to curtail and buy less energy.
777 let curtailment_cost = match &dl.cost_model {
778 LoadCostModel::LinearCurtailment { cost_per_mw }
779 | LoadCostModel::InterruptPenalty { cost_per_mw } => *cost_per_mw,
780 LoadCostModel::QuadraticUtility { a, b } => {
781 // Effective curtailment cost ≈ average marginal utility in [p_served, p_sched]
782 let p_served_mw = p_served_pu * base_mva;
783 let p_sched_mw = dl.p_sched_pu * base_mva;
784 let mu_served = a - b * p_served_mw;
785 let mu_sched = a - b * p_sched_mw;
786 (mu_served + mu_sched) / 2.0
787 }
788 LoadCostModel::PiecewiseLinear { .. } => lmp, // use LMP as proxy
789 };
790 let p_curtailed_mw = p_curtailed_pu * base_mva;
791 let net_curtailment_benefit = (lmp - curtailment_cost) * p_curtailed_mw;
792
793 Self {
794 bus: dl.bus,
795 p_served_pu,
796 q_served_pu,
797 p_curtailed_pu,
798 curtailment_pct,
799 cost_contribution,
800 lmp_at_bus: lmp,
801 net_curtailment_benefit,
802 }
803 }
804}
805
806/// Full demand-response dispatch results from an OPF solve.
807#[derive(Debug, Clone, Default, Serialize, Deserialize)]
808pub struct DemandResponseResults {
809 /// Per-load dispatch outcomes.
810 pub loads: Vec<LoadDispatchResult>,
811
812 /// Total real power served across all dispatchable loads (MW).
813 pub total_served_mw: f64,
814
815 /// Total real power curtailed across all dispatchable loads (MW).
816 pub total_curtailed_mw: f64,
817
818 /// Total cost contribution to OPF objective ($/hr).
819 pub total_cost: f64,
820
821 /// Consumer surplus: value received by load owners minus payments ($/hr).
822 pub consumer_surplus: f64,
823}
824
825impl DemandResponseResults {
826 /// Build aggregate results from per-load dispatch.
827 pub fn from_load_results(loads: Vec<LoadDispatchResult>, base_mva: f64) -> Self {
828 let total_served_mw = loads.iter().map(|r| r.p_served_pu * base_mva).sum();
829 let total_curtailed_mw = loads.iter().map(|r| r.p_curtailed_pu * base_mva).sum();
830 let total_cost = loads.iter().map(|r| r.cost_contribution).sum();
831 let consumer_surplus = loads
832 .iter()
833 .map(|r| {
834 // CS = utility received - cost paid = (U(P_served) + curtailment_value) - LMP * P_served
835 // Simplified: surplus = net_curtailment_benefit (economic gain from curtailment)
836 r.net_curtailment_benefit.max(0.0)
837 })
838 .sum();
839 Self {
840 loads,
841 total_served_mw,
842 total_curtailed_mw,
843 total_cost,
844 consumer_surplus,
845 }
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852
853 #[test]
854 fn test_linear_curtailment_objective() {
855 let model = LoadCostModel::LinearCurtailment { cost_per_mw: 50.0 };
856 // p_sched = 0.15 pu, p_served = 0.10 pu, base = 100 MVA
857 // curtailment = 0.05 pu * 100 = 5 MW → cost = 50 * 5 = 250 $/hr
858 let obj = model.objective_contribution(0.10, 0.15, 100.0);
859 assert!((obj - 250.0).abs() < 1e-10, "got {obj}");
860 }
861
862 #[test]
863 fn test_quadratic_utility_objective() {
864 let _model = LoadCostModel::QuadraticUtility { a: 60.0, b: 100.0 };
865 // U(P) = a*P - b*P²/2, P_mw = 0.10*100 = 10 MW
866 // U(10) = 60*10 - 100*100/2 = 600 - 5000 = ... wait, b in $/MW²h
867 // U(10) = 60*10 - 100*10*10/2 = 600 - 5000 ... that's negative
868 // Actually for a = 60, b = 100, MU(0) = 60, MU(0.6) = 0 → equilibrium at 0.6 MW
869 // So p_max should be ~0.006 pu. Let's test with a=60, b=1 (more reasonable scale)
870 let model2 = LoadCostModel::QuadraticUtility { a: 60.0, b: 1.0 };
871 let p = 0.10; // pu → 10 MW
872 let base = 100.0;
873 let p_mw = p * base;
874 // U(10) = 60*10 - 1*100/2 = 600 - 50 = 550 $/hr
875 let expected = -(60.0 * p_mw - 1.0 * p_mw * p_mw / 2.0);
876 let obj = model2.objective_contribution(p, p, base);
877 assert!(
878 (obj - expected).abs() < 1e-6,
879 "expected {expected}, got {obj}"
880 );
881 }
882
883 #[test]
884 fn test_quadratic_utility_gradient() {
885 let model = LoadCostModel::QuadraticUtility { a: 50.0, b: 100.0 };
886 // d(-U)/dP_pu = (b*P_mw - a) * base
887 let p = 0.002; // pu → 0.2 MW
888 let base = 100.0;
889 let p_mw = p * base;
890 let expected = (100.0 * p_mw - 50.0) * base;
891 let grad = model.d_obj_d_p(p, base);
892 assert!(
893 (grad - expected).abs() < 1e-6,
894 "expected {expected}, got {grad}"
895 );
896 }
897
898 #[test]
899 fn test_quadratic_hessian() {
900 let model = LoadCostModel::QuadraticUtility { a: 50.0, b: 100.0 };
901 let base = 100.0;
902 // d²(-U)/dP_pu² = b * base² = 100 * 10000 = 1e6
903 let h = model.d2_obj_d_p2(base);
904 assert!((h - 100.0 * 100.0 * 100.0).abs() < 1e-6, "got {h}");
905 }
906
907 #[test]
908 fn test_linear_curtailment_gradient() {
909 let model = LoadCostModel::LinearCurtailment { cost_per_mw: 50.0 };
910 let grad = model.d_obj_d_p(0.15, 100.0);
911 // d/dP [c * (p_sched - P) * base] = -c * base = -50 * 100 = -5000
912 assert!((grad - (-5000.0)).abs() < 1e-10, "got {grad}");
913 }
914
915 #[test]
916 fn test_curtailable_constructor() {
917 let dl = DispatchableLoad::curtailable(3, 150.0, 30.0, 50.0, 50.0, 100.0);
918 assert_eq!(dl.bus, 3);
919 assert!((dl.p_sched_pu - 1.5).abs() < 1e-10);
920 assert!((dl.p_min_pu - 0.5).abs() < 1e-10);
921 assert!((dl.q_sched_pu - 0.3).abs() < 1e-10);
922 assert!(dl.fixed_power_factor);
923 assert!(matches!(dl.archetype, LoadArchetype::Curtailable));
924 }
925
926 #[test]
927 fn test_pf_ratio() {
928 let dl = DispatchableLoad::curtailable(0, 100.0, 50.0, 0.0, 50.0, 100.0);
929 assert!((dl.pf_ratio() - 0.5).abs() < 1e-10);
930 }
931
932 #[test]
933 fn test_dispatch_result_net_benefit() {
934 let dl = DispatchableLoad::curtailable(5, 150.0, 0.0, 50.0, 40.0, 100.0);
935 // LMP = 60 $/MWh, curtailment cost = 40 $/MWh → beneficial to curtail
936 // p_served = 0.5 pu (50 MW), p_sched = 1.5 pu (150 MW)
937 // curtailment = 100 MW → net benefit = (60-40)*100 = 2000 $/hr
938 let result = LoadDispatchResult::from_solution(&dl, 0.5, 0.0, 60.0, 100.0);
939 assert!((result.p_curtailed_pu - 1.0).abs() < 1e-10);
940 assert!((result.curtailment_pct - (100.0 / 150.0 * 100.0)).abs() < 1e-6);
941 assert!(result.net_curtailment_benefit > 0.0);
942 }
943}