use crate::error::OxiGridError;
use serde::{Deserialize, Serialize};
struct Lcg64 {
state: u64,
}
impl Lcg64 {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_f64(&mut self) -> f64 {
self.state = self
.state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(self.state >> 11) as f64 / (1u64 << 53) as f64
}
fn next_normal(&mut self) -> f64 {
let u1 = self.next_f64().max(1e-10);
let u2 = self.next_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * core::f64::consts::PI * u2).cos()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SdpConfig {
pub n_soc_levels: usize,
pub n_actions: usize,
pub soc_min: f64,
pub soc_max: f64,
pub p_max_mw: f64,
pub e_max_mwh: f64,
pub eta_charge: f64,
pub eta_discharge: f64,
pub dt_hours: f64,
pub n_horizons: usize,
pub c_terminal: f64,
pub n_scenarios: usize,
pub discount_factor: f64,
}
impl Default for SdpConfig {
fn default() -> Self {
Self {
n_soc_levels: 100,
n_actions: 50,
soc_min: 0.1,
soc_max: 0.9,
p_max_mw: 1.0,
e_max_mwh: 4.0,
eta_charge: 0.95,
eta_discharge: 0.95,
dt_hours: 1.0,
n_horizons: 24,
c_terminal: 50.0,
n_scenarios: 200,
discount_factor: 1.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Scenario {
pub price: Vec<f64>,
pub renewable: Vec<f64>,
pub probability: f64,
}
#[derive(Debug, Clone)]
pub struct SdpResult {
pub value_function: Vec<Vec<f64>>,
pub policy: Vec<Vec<f64>>,
pub expected_revenue: f64,
pub expected_cycles: f64,
pub soc_distribution: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct ForwardSimResult {
pub time: Vec<f64>,
pub soc: Vec<f64>,
pub power: Vec<f64>,
pub revenue: Vec<f64>,
pub total_revenue: f64,
pub total_cycles: f64,
pub average_soc: f64,
}
pub struct StochasticDpSolver {
pub config: SdpConfig,
}
impl StochasticDpSolver {
pub fn new(config: SdpConfig) -> Self {
Self { config }
}
fn soc_from_index(&self, idx: usize) -> f64 {
let n = self.config.n_soc_levels;
let soc_min = self.config.soc_min;
let soc_max = self.config.soc_max;
if n <= 1 {
return (soc_min + soc_max) / 2.0;
}
soc_min + (soc_max - soc_min) * idx as f64 / (n - 1) as f64
}
fn action_from_index(&self, idx: usize) -> f64 {
let n = self.config.n_actions;
let p_max = self.config.p_max_mw;
if n <= 1 {
return 0.0;
}
-p_max + 2.0 * p_max * idx as f64 / (n - 1) as f64
}
fn transition(&self, soc: f64, p_bat_mw: f64) -> Option<f64> {
let cfg = &self.config;
let delta = if p_bat_mw >= 0.0 {
cfg.eta_charge * p_bat_mw * cfg.dt_hours / cfg.e_max_mwh
} else {
p_bat_mw * cfg.dt_hours / (cfg.eta_discharge * cfg.e_max_mwh)
};
let soc_next = soc + delta;
if soc_next < cfg.soc_min - 1e-9 || soc_next > cfg.soc_max + 1e-9 {
None
} else {
Some(soc_next.clamp(cfg.soc_min, cfg.soc_max))
}
}
fn interpolate_value(&self, vf_t: &[f64], soc: f64) -> f64 {
let n = self.config.n_soc_levels;
let soc_min = self.config.soc_min;
let soc_max = self.config.soc_max;
let t = if (soc_max - soc_min).abs() < 1e-12 {
0.0
} else {
((soc - soc_min) / (soc_max - soc_min)) * (n - 1) as f64
};
let lo = (t.floor() as usize).min(n.saturating_sub(2));
let hi = lo + 1;
let frac = t - lo as f64;
let v_lo = vf_t.get(lo).copied().unwrap_or(f64::INFINITY);
let v_hi = vf_t.get(hi).copied().unwrap_or(f64::INFINITY);
v_lo + frac * (v_hi - v_lo)
}
fn soc_to_index(&self, soc: f64) -> usize {
let n = self.config.n_soc_levels;
let soc_min = self.config.soc_min;
let soc_max = self.config.soc_max;
if n <= 1 {
return 0;
}
let t = ((soc - soc_min) / (soc_max - soc_min)) * (n - 1) as f64;
(t.round() as usize).min(n - 1)
}
fn validate_scenarios(&self, scenarios: &[Scenario]) -> Result<(), OxiGridError> {
if scenarios.is_empty() {
return Err(OxiGridError::InvalidParameter(
"scenario set must be non-empty".to_owned(),
));
}
let t = self.config.n_horizons;
for (i, sc) in scenarios.iter().enumerate() {
if sc.price.len() != t {
return Err(OxiGridError::InvalidParameter(format!(
"scenario {i}: price length {} != n_horizons {t}",
sc.price.len()
)));
}
if sc.renewable.len() != t {
return Err(OxiGridError::InvalidParameter(format!(
"scenario {i}: renewable length {} != n_horizons {t}",
sc.renewable.len()
)));
}
}
Ok(())
}
pub fn solve_backward(&self, scenarios: &[Scenario]) -> Result<SdpResult, OxiGridError> {
self.validate_scenarios(scenarios)?;
let cfg = &self.config;
let n_soc = cfg.n_soc_levels;
let n_act = cfg.n_actions;
let t_max = cfg.n_horizons;
let gamma = cfg.discount_factor;
let prob_sum: f64 = scenarios.iter().map(|s| s.probability).sum();
let prob_sum = if prob_sum < 1e-12 { 1.0 } else { prob_sum };
let mut value_function: Vec<Vec<f64>> = vec![vec![0.0; n_soc]; t_max + 1];
let mut policy: Vec<Vec<f64>> = vec![vec![0.0; n_soc]; t_max];
#[allow(clippy::needless_range_loop)]
for s in 0..n_soc {
let soc = self.soc_from_index(s);
value_function[t_max][s] = -cfg.c_terminal * soc * cfg.e_max_mwh;
}
for t in (0..t_max).rev() {
let vf_next = &value_function[t + 1].clone();
for s in 0..n_soc {
let soc = self.soc_from_index(s);
let mut best_q = f64::INFINITY;
let mut best_action = 0.0_f64;
for a in 0..n_act {
let p_bat = self.action_from_index(a);
let soc_next = match self.transition(soc, p_bat) {
Some(s_next) => s_next,
None => continue, };
let v_next = self.interpolate_value(vf_next, soc_next);
let mut q_expected = 0.0_f64;
for sc in scenarios {
let price_t = sc.price[t];
let immediate_cost = price_t * p_bat * cfg.dt_hours;
let q = immediate_cost + gamma * v_next;
q_expected += sc.probability / prob_sum * q;
}
if q_expected < best_q {
best_q = q_expected;
best_action = p_bat;
}
}
value_function[t][s] = if best_q.is_finite() {
best_q
} else {
self.interpolate_value(vf_next, soc)
};
policy[t][s] = best_action;
}
}
let mut total_revenue_acc = 0.0_f64;
let mut total_cycles_acc = 0.0_f64;
let mut soc_dist = vec![0.0_f64; n_soc];
let n_sc = scenarios.len() as f64;
for sc in scenarios {
let weight = sc.probability / prob_sum;
let initial_soc = (cfg.soc_min + cfg.soc_max) / 2.0;
match self.simulate_forward(&policy, sc, initial_soc) {
Ok(fwd) => {
total_revenue_acc += weight * fwd.total_revenue;
total_cycles_acc += weight * fwd.total_cycles;
for &s in &fwd.soc {
let idx = self.soc_to_index(s);
soc_dist[idx] += weight / (t_max as f64 * n_sc);
}
}
Err(_) => { }
}
}
let dist_sum: f64 = soc_dist.iter().sum();
if dist_sum > 1e-12 {
for v in &mut soc_dist {
*v /= dist_sum;
}
}
Ok(SdpResult {
value_function,
policy,
expected_revenue: total_revenue_acc,
expected_cycles: total_cycles_acc,
soc_distribution: soc_dist,
})
}
pub fn simulate_forward(
&self,
policy: &[Vec<f64>],
scenario: &Scenario,
initial_soc: f64,
) -> Result<ForwardSimResult, OxiGridError> {
let cfg = &self.config;
let t_max = cfg.n_horizons;
if policy.len() < t_max {
return Err(OxiGridError::InvalidParameter(format!(
"policy length {} < n_horizons {}",
policy.len(),
t_max
)));
}
if scenario.price.len() < t_max {
return Err(OxiGridError::InvalidParameter(format!(
"scenario price length {} < n_horizons {}",
scenario.price.len(),
t_max
)));
}
let mut time = Vec::with_capacity(t_max + 1);
let mut soc_trace = Vec::with_capacity(t_max + 1);
let mut power_trace = Vec::with_capacity(t_max);
let mut revenue_trace = Vec::with_capacity(t_max);
let mut soc = initial_soc.clamp(cfg.soc_min, cfg.soc_max);
time.push(0.0);
soc_trace.push(soc);
let mut total_revenue = 0.0_f64;
let mut energy_discharged = 0.0_f64;
#[allow(clippy::needless_range_loop)]
for t in 0..t_max {
let soc_idx = self.soc_to_index(soc);
let p_bat = policy[t].get(soc_idx).copied().unwrap_or(0.0);
let p_bat = p_bat.clamp(-cfg.p_max_mw, cfg.p_max_mw);
let soc_next = match self.transition(soc, p_bat) {
Some(s_next) => s_next,
None => {
soc
}
};
let price_t = scenario.price[t];
let rev = -price_t * p_bat * cfg.dt_hours;
total_revenue += rev;
revenue_trace.push(rev);
power_trace.push(p_bat);
if p_bat < 0.0 {
energy_discharged += p_bat.abs() * cfg.dt_hours;
}
soc = soc_next;
time.push((t + 1) as f64 * cfg.dt_hours);
soc_trace.push(soc);
}
let avg_soc = if soc_trace.is_empty() {
0.0
} else {
soc_trace.iter().sum::<f64>() / soc_trace.len() as f64
};
let total_cycles = energy_discharged / cfg.e_max_mwh;
Ok(ForwardSimResult {
time,
soc: soc_trace,
power: power_trace,
revenue: revenue_trace,
total_revenue,
total_cycles,
average_soc: avg_soc,
})
}
pub fn greedy_policy(&self, soc: f64, price: f64, _renewable: f64, _load: f64) -> f64 {
let cfg = &self.config;
let n_act = cfg.n_actions;
let mut best_rev = f64::NEG_INFINITY;
let mut best_p = 0.0_f64;
for a in 0..n_act {
let p_bat = self.action_from_index(a);
if self.transition(soc, p_bat).is_none() {
continue;
}
let rev = -price * p_bat * cfg.dt_hours;
if rev > best_rev {
best_rev = rev;
best_p = p_bat;
}
}
best_p
}
}
pub fn generate_price_scenarios(
n_scenarios: usize,
n_steps: usize,
initial_price: f64,
drift: f64,
volatility: f64,
dt: f64,
seed: u64,
) -> Vec<Scenario> {
let mut rng = Lcg64::new(seed);
let dt_year = dt / 8760.0; let mu_dt = (drift - 0.5 * volatility * volatility) * dt_year;
let sigma_sqrt_dt = volatility * dt_year.sqrt();
let mut scenarios = Vec::with_capacity(n_scenarios);
let prob = 1.0 / n_scenarios as f64;
for _ in 0..n_scenarios {
let mut prices = Vec::with_capacity(n_steps);
let mut price = initial_price;
for _ in 0..n_steps {
let z = rng.next_normal();
price *= (mu_dt + sigma_sqrt_dt * z).exp();
price = price.clamp(0.01, initial_price * 20.0);
prices.push(price);
}
scenarios.push(Scenario {
price: prices,
renewable: vec![0.0; n_steps],
probability: prob,
});
}
scenarios
}
pub fn generate_joint_scenarios(
n_scenarios: usize,
n_steps: usize,
price_params: (f64, f64, f64),
renewable_params: (f64, f64),
correlation: f64,
dt: f64,
seed: u64,
) -> Vec<Scenario> {
let (initial_price, drift, volatility) = price_params;
let (capacity_mw, shape) = renewable_params;
let rho = correlation.clamp(-0.9999, 0.9999);
let l21 = rho;
let l22 = (1.0 - rho * rho).max(0.0).sqrt();
let dt_year = dt / 8760.0;
let mu_dt = (drift - 0.5 * volatility * volatility) * dt_year;
let sigma_sqrt_dt = volatility * dt_year.sqrt();
let mut rng = Lcg64::new(seed);
let prob = 1.0 / n_scenarios as f64;
let mut scenarios = Vec::with_capacity(n_scenarios);
for _ in 0..n_scenarios {
let mut prices = Vec::with_capacity(n_steps);
let mut renewables = Vec::with_capacity(n_steps);
let mut price = initial_price;
for _ in 0..n_steps {
let z1 = rng.next_normal();
let z2 = rng.next_normal();
let w1 = z1; let w2 = l21 * z1 + l22 * z2;
price *= (mu_dt + sigma_sqrt_dt * w1).exp();
price = price.clamp(0.01, initial_price * 20.0);
prices.push(price);
let u_ren = normal_cdf(w2);
let ren = beta_quantile(u_ren, shape, capacity_mw);
renewables.push(ren.clamp(0.0, capacity_mw));
}
scenarios.push(Scenario {
price: prices,
renewable: renewables,
probability: prob,
});
}
scenarios
}
fn normal_cdf(x: f64) -> f64 {
let t = 1.0 / (1.0 + 0.2316419 * x.abs());
let poly = t
* (0.319_381_530
+ t * (-0.356_563_782
+ t * (1.781_477_937 + t * (-1.821_255_978 + t * 1.330_274_429))));
let pdf = (-0.5 * x * x).exp() / (2.0 * core::f64::consts::PI).sqrt();
let cdf_pos = 1.0 - pdf * poly;
if x >= 0.0 {
cdf_pos
} else {
1.0 - cdf_pos
}
}
fn beta_quantile(u: f64, shape: f64, capacity_mw: f64) -> f64 {
let u = u.clamp(1e-9, 1.0 - 1e-9);
let x_init = if shape >= 1.0 {
let logit = (u / (1.0 - u)).ln();
let std_guess = logit / (2.0 * (2.0 * shape - 1.0)).sqrt();
0.5 + std_guess * 0.5
} else {
u.powf(1.0 / shape)
};
let x = x_init.clamp(1e-6, 1.0 - 1e-6);
let x_refined = bisect_beta_quantile(u, shape, x, 12);
(x_refined * capacity_mw).clamp(0.0, capacity_mw)
}
fn bisect_beta_quantile(target_u: f64, shape: f64, _x_init: f64, iters: usize) -> f64 {
let mut lo = 0.0_f64;
let mut hi = 1.0_f64;
for _ in 0..iters {
let mid = (lo + hi) / 2.0;
if regularised_incomplete_beta(mid, shape, shape) < target_u {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) / 2.0
}
fn regularised_incomplete_beta(x: f64, a: f64, _b: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
let n_steps = 64_usize;
let step = x / n_steps as f64;
let mut integral = 0.0_f64;
let a_m1 = a - 1.0;
let f = |t: f64| {
if t <= 0.0 || t >= 1.0 {
0.0
} else {
(t.ln() * a_m1 + ((1.0 - t).ln() * a_m1)).exp()
}
};
integral += f(0.0);
let mut i = 1_usize;
while i < n_steps {
let t = i as f64 * step;
let coeff = if i % 2 == 0 { 2.0 } else { 4.0 };
integral += coeff * f(t);
i += 1;
}
integral += f(x);
integral *= step / 3.0;
let full_step = 1.0 / n_steps as f64;
let mut full_integral = 0.0_f64;
full_integral += f(0.0);
let mut j = 1_usize;
while j < n_steps {
let t = j as f64 * full_step;
let coeff = if j % 2 == 0 { 2.0 } else { 4.0 };
full_integral += coeff * f(t);
j += 1;
}
full_integral += f(1.0);
full_integral *= full_step / 3.0;
if full_integral < 1e-300 {
return 0.5; }
(integral / full_integral).clamp(0.0, 1.0)
}
pub struct AdpSolver {
pub config: SdpConfig,
pub basis_degree: usize,
pub n_training_scenarios: usize,
weights: Vec<Vec<f64>>,
}
impl AdpSolver {
pub fn new(config: SdpConfig) -> Self {
let basis_degree = 3;
let n_training_scenarios = 1000;
let n_t = config.n_horizons + 1;
let n_basis = basis_degree + 1; let weights = vec![vec![0.0; n_basis]; n_t];
Self {
config,
basis_degree,
n_training_scenarios,
weights,
}
}
fn n_basis(&self) -> usize {
self.basis_degree + 1
}
fn basis(&self, soc: f64) -> Vec<f64> {
let soc_min = self.config.soc_min;
let soc_max = self.config.soc_max;
let x = if (soc_max - soc_min).abs() < 1e-12 {
0.0
} else {
2.0 * (soc - soc_min) / (soc_max - soc_min) - 1.0
};
let mut phi = Vec::with_capacity(self.n_basis());
let mut xp = 1.0_f64;
for _ in 0..self.n_basis() {
phi.push(xp);
xp *= x;
}
phi
}
fn dot(phi: &[f64], w: &[f64]) -> f64 {
phi.iter().zip(w.iter()).map(|(a, b)| a * b).sum()
}
fn ols(phi_mat: &[Vec<f64>], y: &[f64], n_basis: usize) -> Vec<f64> {
let n = phi_mat.len();
let mut a = vec![vec![0.0_f64; n_basis]; n_basis];
let mut b = vec![0.0_f64; n_basis];
let ridge = 1e-4;
for (row, y_i) in phi_mat.iter().zip(y.iter()) {
for i in 0..n_basis {
b[i] += row[i] * y_i;
for j in 0..n_basis {
a[i][j] += row[i] * row[j];
}
}
}
#[allow(clippy::needless_range_loop)]
for i in 0..n_basis {
a[i][i] += ridge * n as f64;
}
let mut l = vec![vec![0.0_f64; n_basis]; n_basis];
#[allow(clippy::needless_range_loop)]
for i in 0..n_basis {
for j in 0..=i {
let mut s = a[i][j];
#[allow(clippy::needless_range_loop)]
for k in 0..j {
s -= l[i][k] * l[j][k];
}
if i == j {
l[i][j] = s.max(0.0).sqrt();
} else if l[j][j].abs() > 1e-15 {
l[i][j] = s / l[j][j];
}
}
}
let mut z = vec![0.0_f64; n_basis];
for i in 0..n_basis {
let mut s = b[i];
for k in 0..i {
s -= l[i][k] * z[k];
}
if l[i][i].abs() > 1e-15 {
z[i] = s / l[i][i];
}
}
let mut w = vec![0.0_f64; n_basis];
for i in (0..n_basis).rev() {
let mut s = z[i];
for k in (i + 1)..n_basis {
s -= l[k][i] * w[k];
}
if l[i][i].abs() > 1e-15 {
w[i] = s / l[i][i];
}
}
w
}
pub fn fit(&mut self, scenarios: &[Scenario]) -> Result<Vec<f64>, OxiGridError> {
if scenarios.is_empty() {
return Err(OxiGridError::InvalidParameter(
"ADP fit requires at least one scenario".to_owned(),
));
}
let cfg = &self.config;
let t_max = cfg.n_horizons;
let n_basis = self.n_basis();
let gamma = cfg.discount_factor;
let prob_sum: f64 = scenarios.iter().map(|s| s.probability).sum();
let prob_sum = if prob_sum < 1e-12 { 1.0 } else { prob_sum };
let n_soc_pts = 20_usize;
{
let mut phi_mat = Vec::with_capacity(n_soc_pts);
let mut y_vals = Vec::with_capacity(n_soc_pts);
for k in 0..n_soc_pts {
let soc =
cfg.soc_min + (cfg.soc_max - cfg.soc_min) * k as f64 / (n_soc_pts - 1) as f64;
phi_mat.push(self.basis(soc));
y_vals.push(-cfg.c_terminal * soc * cfg.e_max_mwh);
}
self.weights[t_max] = Self::ols(&phi_mat, &y_vals, n_basis);
}
for t in (0..t_max).rev() {
let mut phi_mat: Vec<Vec<f64>> = Vec::new();
let mut y_vals: Vec<f64> = Vec::new();
let n_soc_pts = 50_usize.max(self.n_training_scenarios / t_max.max(1));
let w_next = self.weights[t + 1].clone();
for sc in scenarios {
let sc_weight = sc.probability / prob_sum;
let price_t = sc.price.get(t).copied().unwrap_or(cfg.c_terminal);
for k in 0..n_soc_pts {
let soc = cfg.soc_min
+ (cfg.soc_max - cfg.soc_min) * k as f64 / n_soc_pts.max(1) as f64;
let mut best_q = f64::INFINITY;
let n_act = cfg.n_actions;
let p_max = cfg.p_max_mw;
for a in 0..n_act {
let p_bat = if n_act <= 1 {
0.0
} else {
-p_max + 2.0 * p_max * a as f64 / (n_act - 1) as f64
};
let delta = if p_bat >= 0.0 {
cfg.eta_charge * p_bat * cfg.dt_hours / cfg.e_max_mwh
} else {
p_bat * cfg.dt_hours / (cfg.eta_discharge * cfg.e_max_mwh)
};
let soc_next = soc + delta;
if soc_next < cfg.soc_min - 1e-9 || soc_next > cfg.soc_max + 1e-9 {
continue;
}
let soc_next = soc_next.clamp(cfg.soc_min, cfg.soc_max);
let phi_next = self.basis(soc_next);
let v_next = Self::dot(&phi_next, &w_next);
let immediate = price_t * p_bat * cfg.dt_hours;
let q = immediate + gamma * v_next;
if q < best_q {
best_q = q;
}
}
if best_q.is_finite() {
phi_mat.push(self.basis(soc));
y_vals.push(sc_weight * best_q);
}
}
}
if !phi_mat.is_empty() {
self.weights[t] = Self::ols(&phi_mat, &y_vals, n_basis);
} else {
self.weights[t] = vec![0.0; n_basis];
}
}
let flat: Vec<f64> = self.weights.iter().flatten().copied().collect();
Ok(flat)
}
pub fn evaluate(&self, weights: &[f64], soc: f64, t: usize) -> f64 {
let n_basis = self.n_basis();
let t_clamped = t.min(self.config.n_horizons);
let offset = t_clamped * n_basis;
if offset + n_basis > weights.len() {
return -self.config.c_terminal * soc * self.config.e_max_mwh;
}
let w_slice = &weights[offset..offset + n_basis];
let phi = self.basis(soc);
Self::dot(&phi, w_slice)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config_small() -> SdpConfig {
SdpConfig {
n_soc_levels: 20,
n_actions: 10,
n_horizons: 24,
n_scenarios: 5,
..Default::default()
}
}
fn flat_price_scenario(price: f64) -> Scenario {
Scenario {
price: vec![price; 24],
renewable: vec![0.0; 24],
probability: 1.0,
}
}
#[test]
fn test_sdp_value_function_shape() {
let config = default_config_small();
let scenarios = vec![flat_price_scenario(100.0)];
let solver = StochasticDpSolver::new(config.clone());
let result = solver.solve_backward(&scenarios).expect("SDP failed");
assert_eq!(result.value_function.len(), config.n_horizons + 1);
assert_eq!(result.value_function[0].len(), config.n_soc_levels);
let v0_low = result.value_function[0][0];
let v0_high = result.value_function[0][config.n_soc_levels - 1];
assert!(
v0_high <= v0_low,
"Higher SoC should have lower (or equal) cost-to-go at high price: v_high={v0_high:.2}, v_low={v0_low:.2}"
);
}
#[test]
fn test_sdp_policy_discharge_at_high_price() {
let n_h = 24;
let config = SdpConfig {
n_soc_levels: 20,
n_actions: 11, n_horizons: n_h,
n_scenarios: 1,
..Default::default()
};
let scenarios = vec![flat_price_scenario(200.0)]; let solver = StochasticDpSolver::new(config.clone());
let result = solver.solve_backward(&scenarios).expect("SDP failed");
let high_soc_idx = config.n_soc_levels - 1;
let p = result.policy[0][high_soc_idx];
assert!(
p <= 0.0,
"At high SoC and high price, policy should discharge: p={p:.3}"
);
}
#[test]
fn test_forward_sim_soc_bounds() {
let config = default_config_small();
let scenarios = vec![flat_price_scenario(50.0)];
let solver = StochasticDpSolver::new(config.clone());
let result = solver.solve_backward(&scenarios).expect("SDP failed");
let sc = flat_price_scenario(50.0);
let initial_soc = (config.soc_min + config.soc_max) / 2.0;
let fwd = solver
.simulate_forward(&result.policy, &sc, initial_soc)
.expect("Forward sim failed");
for &s in &fwd.soc {
assert!(
(config.soc_min - 1e-6..=config.soc_max + 1e-6).contains(&s),
"SoC out of bounds: {s:.4}"
);
}
assert_eq!(fwd.time.len(), config.n_horizons + 1);
assert_eq!(fwd.power.len(), config.n_horizons);
}
#[test]
fn test_scenario_generation() {
let n_sc = 10;
let scenarios = generate_price_scenarios(n_sc, 24, 50.0, 0.0, 0.2, 1.0, 42);
assert_eq!(
scenarios.len(),
n_sc,
"Should generate exactly n_sc scenarios"
);
for sc in &scenarios {
assert_eq!(sc.price.len(), 24);
for &p in &sc.price {
assert!(p > 0.0, "All prices should be positive, got {p}");
}
}
let prob_sum: f64 = scenarios.iter().map(|s| s.probability).sum();
assert!(
(prob_sum - 1.0).abs() < 1e-9,
"Probabilities should sum to 1.0, got {prob_sum}"
);
}
#[test]
fn test_sdp_vs_greedy_revenue() {
let config = SdpConfig {
n_soc_levels: 20,
n_actions: 11,
n_horizons: 24,
n_scenarios: 1,
c_terminal: 0.0, ..Default::default()
};
let mut prices = vec![20.0_f64; 12];
prices.extend(vec![80.0_f64; 12]);
let sc = Scenario {
price: prices,
renewable: vec![0.0; 24],
probability: 1.0,
};
let solver = StochasticDpSolver::new(config.clone());
let sdp_result = solver
.solve_backward(std::slice::from_ref(&sc))
.expect("SDP failed");
let sdp_fwd = solver
.simulate_forward(&sdp_result.policy, &sc, 0.5)
.expect("SDP forward sim failed");
let mut soc = 0.5_f64;
let mut greedy_revenue = 0.0_f64;
for t in 0..config.n_horizons {
let price = sc.price[t];
let p_bat = solver.greedy_policy(soc, price, 0.0, 0.0);
let delta = if p_bat >= 0.0 {
config.eta_charge * p_bat * config.dt_hours / config.e_max_mwh
} else {
p_bat * config.dt_hours / (config.eta_discharge * config.e_max_mwh)
};
let soc_next = (soc + delta).clamp(config.soc_min, config.soc_max);
greedy_revenue += -price * p_bat * config.dt_hours;
soc = soc_next;
}
assert!(
sdp_fwd.total_revenue >= greedy_revenue - 1e-3,
"SDP revenue {:.4} should be >= greedy revenue {:.4}",
sdp_fwd.total_revenue,
greedy_revenue
);
}
#[test]
fn test_adp_fit_predict() {
let config = SdpConfig {
n_soc_levels: 10,
n_actions: 5,
n_horizons: 6,
n_scenarios: 3,
..Default::default()
};
let scenarios: Vec<Scenario> = (0..3)
.map(|i| Scenario {
price: vec![30.0 + 10.0 * i as f64; 6],
renewable: vec![0.0; 6],
probability: 1.0 / 3.0,
})
.collect();
let mut adp = AdpSolver::new(config.clone());
let weights = adp.fit(&scenarios).expect("ADP fit failed");
assert!(!weights.is_empty(), "Weights should be non-empty");
let soc_mid = (config.soc_min + config.soc_max) / 2.0;
let v_mid = adp.evaluate(&weights, soc_mid, 0);
assert!(v_mid.is_finite(), "ADP value should be finite at mid SoC");
let v_lo = adp.evaluate(&weights, config.soc_min + 0.01, 0);
let v_hi = adp.evaluate(&weights, config.soc_max - 0.01, 0);
assert!(
v_hi <= v_lo + (v_lo.abs() * 0.5 + 1.0),
"ADP: v_hi={v_hi:.3} should not greatly exceed v_lo={v_lo:.3}"
);
}
#[test]
fn test_lcg_reproducibility() {
let s1 = generate_price_scenarios(10, 24, 50.0, 0.0, 0.2, 1.0, 42);
let s2 = generate_price_scenarios(10, 24, 50.0, 0.0, 0.2, 1.0, 42);
assert_eq!(
s1.len(),
s2.len(),
"Both runs should produce same number of scenarios"
);
for (i, (a, b)) in s1.iter().zip(s2.iter()).enumerate() {
assert_eq!(
a.price[0], b.price[0],
"Scenario {i} price[0] should match: {} vs {}",
a.price[0], b.price[0]
);
}
}
#[test]
fn test_joint_scenario_generation() {
let scenarios =
generate_joint_scenarios(20, 24, (50.0, 0.0, 0.3), (10.0, 2.0), -0.6, 1.0, 123);
assert_eq!(scenarios.len(), 20);
let prob_sum: f64 = scenarios.iter().map(|s| s.probability).sum();
assert!((prob_sum - 1.0).abs() < 1e-9, "Probs sum to 1: {prob_sum}");
for sc in &scenarios {
assert_eq!(sc.price.len(), 24);
assert_eq!(sc.renewable.len(), 24);
for &r in &sc.renewable {
assert!(
(0.0..=10.0).contains(&r),
"Renewable out of [0, capacity]: {r}"
);
}
}
}
#[test]
fn test_terminal_value_decreasing_cost() {
let config = default_config_small();
let scenarios = vec![flat_price_scenario(0.0)]; let solver = StochasticDpSolver::new(config.clone());
let result = solver.solve_backward(&scenarios).expect("SDP failed");
let t_end = config.n_horizons;
let v_low = result.value_function[t_end][0];
let v_high = result.value_function[t_end][config.n_soc_levels - 1];
assert!(
v_high < v_low,
"Terminal: higher SoC should give lower (more negative) value: v_high={v_high:.3}, v_low={v_low:.3}"
);
}
#[test]
fn test_greedy_policy_is_valid() {
let config = default_config_small();
let solver = StochasticDpSolver::new(config.clone());
let soc_mid = (config.soc_min + config.soc_max) / 2.0;
let p = solver.greedy_policy(soc_mid, 100.0, 5.0, 10.0);
assert!(
p <= 0.0,
"At high price and mid-SoC, greedy should discharge or hold: p={p:.3}"
);
assert!(
p.abs() <= config.p_max_mw + 1e-9,
"Greedy power should not exceed p_max: {p:.3}"
);
}
}