use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SizingResult {
pub capacity_kwh: f64,
pub power_kw: f64,
pub c_rate: f64,
pub annual_savings: Option<f64>,
}
impl SizingResult {
fn new(capacity_kwh: f64, power_kw: f64) -> Self {
let c_rate = if capacity_kwh > 1e-6 {
power_kw / capacity_kwh
} else {
0.0
};
Self {
capacity_kwh,
power_kw,
c_rate,
annual_savings: None,
}
}
}
pub fn size_for_peak_shaving(
load_kw: &[f64],
grid_limit_kw: f64,
dt_h: f64,
efficiency_rt: f64,
) -> SizingResult {
let peak_load = load_kw.iter().cloned().fold(0.0_f64, f64::max);
let p_bess_kw = (peak_load - grid_limit_kw).max(0.0);
let mut e_needed = 0.0_f64;
let mut e_acc = 0.0_f64;
let mut e_max = 0.0_f64;
for &p in load_kw {
let excess = (p - grid_limit_kw).max(0.0);
let shortfall = (grid_limit_kw - p).max(0.0);
e_acc -= excess * dt_h; e_acc += shortfall * dt_h * efficiency_rt.sqrt(); e_acc = e_acc.max(0.0); if -e_acc > e_needed {
e_needed = -e_acc;
}
if e_acc > e_max {
e_max = e_acc;
}
}
let capacity_kwh = e_needed / 0.80; SizingResult::new(capacity_kwh, p_bess_kw)
}
pub fn size_for_solar_shifting(
pv_kw: &[f64],
load_kw: &[f64],
dt_h: f64,
efficiency_rt: f64,
) -> SizingResult {
assert_eq!(
pv_kw.len(),
load_kw.len(),
"PV and load series must be same length"
);
let eta_c = efficiency_rt.sqrt();
let mut soc_trace = 0.0_f64;
let mut peak_charge = 0.0_f64;
let mut peak_discharge = 0.0_f64;
let mut max_soc = 0.0_f64;
let mut min_soc = 0.0_f64;
for (&p, &l) in pv_kw.iter().zip(load_kw.iter()) {
let net = p - l;
if net > 0.0 {
soc_trace += net * dt_h * eta_c;
if net > peak_charge {
peak_charge = net;
}
} else {
soc_trace += net * dt_h / eta_c;
if net.abs() > peak_discharge {
peak_discharge = net.abs();
}
}
if soc_trace > max_soc {
max_soc = soc_trace;
}
if soc_trace < min_soc {
min_soc = soc_trace;
}
}
let swing = max_soc - min_soc;
let capacity_kwh = swing / 0.80; let power_kw = peak_charge.max(peak_discharge);
SizingResult::new(capacity_kwh, power_kw)
}
pub fn size_for_backup(
load_kw: f64,
duration_h: f64,
dod: f64,
efficiency_rt: f64,
) -> SizingResult {
let eta_d = efficiency_rt.sqrt();
let e_required = load_kw * duration_h / eta_d; let capacity_kwh = e_required / dod;
SizingResult::new(capacity_kwh, load_kw)
}
pub fn size_for_self_consumption(
pv_kw: &[f64],
load_kw: &[f64],
dt_h: f64,
target_sc_ratio: f64,
efficiency_rt: f64,
) -> SizingResult {
let mut lo = 0.0_f64;
let mut hi = pv_kw.iter().cloned().fold(0.0_f64, f64::max) * 24.0 * dt_h;
let power_kw = pv_kw.iter().cloned().fold(0.0_f64, f64::max);
for _ in 0..40 {
let mid = (lo + hi) / 2.0;
let sc = simulate_self_consumption(pv_kw, load_kw, mid, power_kw, dt_h, efficiency_rt);
if sc < target_sc_ratio {
lo = mid;
} else {
hi = mid;
}
}
SizingResult::new(hi, power_kw)
}
fn simulate_self_consumption(
pv_kw: &[f64],
load_kw: &[f64],
capacity_kwh: f64,
power_kw: f64,
dt_h: f64,
efficiency_rt: f64,
) -> f64 {
if capacity_kwh < 1e-9 {
let total_pv: f64 = pv_kw.iter().sum::<f64>() * dt_h;
if total_pv < 1e-9 {
return 1.0;
}
let direct_use: f64 = pv_kw
.iter()
.zip(load_kw.iter())
.map(|(&p, &l)| p.min(l))
.sum::<f64>()
* dt_h;
return direct_use / total_pv;
}
let eta_c = efficiency_rt.sqrt();
let eta_d = efficiency_rt.sqrt();
let mut soc = 0.0_f64; let mut total_pv = 0.0_f64;
let mut used = 0.0_f64;
for (&p, &l) in pv_kw.iter().zip(load_kw.iter()) {
total_pv += p * dt_h;
let direct = p.min(l);
used += direct * dt_h;
let excess = (p - l).max(0.0);
let shortfall = (l - p).max(0.0);
let charge = excess
.min(power_kw)
.min((capacity_kwh - soc) / (dt_h * eta_c));
soc += charge * dt_h * eta_c;
let discharge = shortfall.min(power_kw).min(soc * eta_d / dt_h);
let e_out = discharge * dt_h;
soc -= e_out / eta_d;
used += e_out;
}
if total_pv < 1e-9 {
1.0
} else {
(used / total_pv).min(1.0)
}
}
pub fn self_consumption_curve(
pv_kw: &[f64],
load_kw: &[f64],
dt_h: f64,
efficiency_rt: f64,
n_points: usize,
) -> Vec<(f64, f64)> {
let max_pv = pv_kw.iter().cloned().fold(0.0_f64, f64::max);
let power_kw = max_pv;
let max_cap = max_pv * 8.0;
(0..=n_points)
.map(|i| {
let cap = max_cap * i as f64 / n_points as f64;
let sc = simulate_self_consumption(pv_kw, load_kw, cap, power_kw, dt_h, efficiency_rt);
(cap, sc)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn solar_load_profiles() -> (Vec<f64>, Vec<f64>) {
let pv: Vec<f64> = (0..24)
.map(|h| {
if (6..=18).contains(&h) {
50.0 * ((h as f64 - 12.0) / 6.0 * std::f64::consts::PI / 2.0)
.cos()
.powi(2)
} else {
0.0
}
})
.collect();
let load: Vec<f64> = (0..24)
.map(|h| {
if !(7..=20).contains(&h) {
20.0
} else if h < 10 {
40.0
} else if h < 16 {
25.0
} else {
45.0
}
})
.collect();
(pv, load)
}
#[test]
fn test_peak_shaving_capacity_positive() {
let load = vec![50.0, 60.0, 80.0, 70.0, 40.0, 30.0];
let result = size_for_peak_shaving(&load, 60.0, 1.0, 0.90);
assert!(result.capacity_kwh >= 0.0);
assert!(result.power_kw >= 0.0);
}
#[test]
fn test_peak_shaving_no_need_if_below_limit() {
let load = vec![20.0, 30.0, 25.0];
let result = size_for_peak_shaving(&load, 100.0, 1.0, 0.90);
assert_eq!(result.power_kw, 0.0, "No battery needed if load < limit");
}
#[test]
fn test_solar_shifting_positive_capacity() {
let (pv, load) = solar_load_profiles();
let result = size_for_solar_shifting(&pv, &load, 1.0, 0.90);
assert!(result.capacity_kwh > 0.0);
assert!(result.power_kw > 0.0);
}
#[test]
fn test_backup_sizing() {
let result = size_for_backup(10.0, 4.0, 0.80, 0.90);
assert!(
result.capacity_kwh > 40.0 && result.capacity_kwh < 80.0,
"Backup capacity={:.1} kWh",
result.capacity_kwh
);
assert_eq!(result.power_kw, 10.0);
}
#[test]
fn test_self_consumption_increases_with_capacity() {
let (pv, load) = solar_load_profiles();
let sc_no_bat = simulate_self_consumption(&pv, &load, 0.0, 50.0, 1.0, 0.90);
let sc_with_bat = simulate_self_consumption(&pv, &load, 100.0, 50.0, 1.0, 0.90);
assert!(
sc_with_bat >= sc_no_bat,
"SC should increase with battery: {:.3} → {:.3}",
sc_no_bat,
sc_with_bat
);
}
#[test]
fn test_sc_curve_monotone() {
let (pv, load) = solar_load_profiles();
let curve = self_consumption_curve(&pv, &load, 1.0, 0.90, 10);
for w in curve.windows(2) {
assert!(
w[1].1 >= w[0].1 - 0.001,
"SC curve should be non-decreasing: {:.3} → {:.3}",
w[0].1,
w[1].1
);
}
}
#[test]
fn test_c_rate_computed() {
let r = SizingResult::new(100.0, 50.0);
assert!((r.c_rate - 0.5).abs() < 1e-9);
}
}