Skip to main content

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}