use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PvSubstring {
pub n_cells: usize,
pub irradiance: f64,
pub temperature_c: f64,
}
pub fn substring_current(
sub: &PvSubstring,
v_cell: f64,
i_sc_ref: f64,
i_o_ref: f64,
a_factor: f64,
r_s: f64,
vt_ref: f64,
) -> f64 {
let t_ratio = (sub.temperature_c + 273.15) / 298.15;
let i_sc = i_sc_ref * (sub.irradiance / 1000.0) * (1.0 + 0.0005 * (sub.temperature_c - 25.0));
let vt = vt_ref * t_ratio;
let i_o = i_o_ref
* t_ratio.powi(3)
* ((-11600.0 / a_factor) * (1.0 / (sub.temperature_c + 273.15) - 1.0 / 298.15)).exp();
let v_total = v_cell * sub.n_cells as f64;
let v_bypass = -0.7; if v_total < v_bypass {
return i_sc; }
let vt_substr = vt * sub.n_cells as f64; let arg = (v_total + i_sc * r_s) / (a_factor * vt_substr);
let arg_clamped = arg.min(50.0); let i_diode = i_o * (arg_clamped.exp() - 1.0);
(i_sc - i_diode).max(-i_sc)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CellParams {
pub i_sc_ref: f64,
pub i_o_ref: f64,
pub a_factor: f64,
pub r_s: f64,
pub vt_ref: f64,
pub v_oc_cell: f64,
}
impl CellParams {
pub fn mono_si() -> Self {
Self {
i_sc_ref: 9.0,
i_o_ref: 1e-10,
a_factor: 1.3,
r_s: 0.005,
vt_ref: 0.02585,
v_oc_cell: 0.623,
}
}
pub fn poly_si() -> Self {
Self {
i_sc_ref: 8.5,
i_o_ref: 2e-10,
a_factor: 1.4,
r_s: 0.006,
vt_ref: 0.02585,
v_oc_cell: 0.610,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PvArray {
pub substrings: Vec<PvSubstring>,
pub cells_per_substring: usize,
pub cell_params: CellParams,
pub n_parallel: usize,
}
impl PvArray {
pub fn uniform(
irradiance: f64,
temperature_c: f64,
n_substrings: usize,
n_parallel: usize,
) -> Self {
let cells = 20; Self {
substrings: (0..n_substrings)
.map(|_| PvSubstring {
n_cells: cells,
irradiance,
temperature_c,
})
.collect(),
cells_per_substring: cells,
cell_params: CellParams::mono_si(),
n_parallel,
}
}
pub fn partially_shaded(
full_irr: f64,
shaded_irr: f64,
temperature_c: f64,
n_substrings: usize,
shaded_index: usize,
n_parallel: usize,
) -> Self {
let cells = 20;
let substrings = (0..n_substrings)
.map(|i| PvSubstring {
n_cells: cells,
irradiance: if i == shaded_index {
shaded_irr
} else {
full_irr
},
temperature_c,
})
.collect();
Self {
substrings,
cells_per_substring: cells,
cell_params: CellParams::mono_si(),
n_parallel,
}
}
pub fn array_current(&self, v_array: f64) -> f64 {
let p = &self.cell_params;
let n_sub = self.substrings.len();
if n_sub == 0 {
return 0.0;
}
let v_per_sub = v_array / n_sub as f64;
let v_per_cell = v_per_sub / self.cells_per_substring as f64;
let i_per_string = self
.substrings
.iter()
.map(|sub| {
substring_current(
sub, v_per_cell, p.i_sc_ref, p.i_o_ref, p.a_factor, p.r_s, p.vt_ref,
)
})
.fold(f64::INFINITY, f64::min)
.max(0.0);
i_per_string * self.n_parallel as f64
}
pub fn power(&self, v: f64) -> f64 {
v * self.array_current(v)
}
pub fn v_oc_array(&self) -> f64 {
self.cell_params.v_oc_cell * self.cells_per_substring as f64 * self.substrings.len() as f64
}
}
pub fn pv_curve(array: &PvArray, n_points: usize) -> Vec<(f64, f64)> {
let v_oc = array.v_oc_array();
(0..n_points)
.map(|i| {
let v = v_oc * i as f64 / (n_points - 1).max(1) as f64;
let p = array.power(v);
(v, p)
})
.collect()
}
pub fn local_maxima(curve: &[(f64, f64)]) -> Vec<(f64, f64)> {
let mut maxima = Vec::new();
for i in 1..curve.len().saturating_sub(1) {
let (_, p_prev) = curve[i - 1];
let (v_i, p_i) = curve[i];
let (_, p_next) = curve[i + 1];
if p_i > p_prev && p_i >= p_next {
maxima.push((v_i, p_i));
}
}
maxima
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PsoMpptConfig {
pub n_particles: usize,
pub max_iterations: usize,
pub inertia: f64,
pub c1: f64,
pub c2: f64,
pub v_max_fraction: f64,
pub tol_w: f64,
}
impl Default for PsoMpptConfig {
fn default() -> Self {
Self {
n_particles: 10,
max_iterations: 50,
inertia: 0.729,
c1: 1.494,
c2: 1.494,
v_max_fraction: 0.20,
tol_w: 0.01,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PsoMpptResult {
pub v_gmpp: f64,
pub p_gmpp: f64,
pub iterations: usize,
pub converged: bool,
pub particle_best: Vec<(f64, f64)>,
}
pub fn pso_global_mppt(array: &PvArray, config: &PsoMpptConfig) -> PsoMpptResult {
let v_max = array.v_oc_array() * 0.95;
let v_min = v_max * 0.01;
let v_range = v_max - v_min;
let v_clamp = config.v_max_fraction * v_range;
let mut rng = LcgRng::new(42);
let mut positions: Vec<f64> = (0..config.n_particles)
.map(|i| v_min + v_range * i as f64 / config.n_particles as f64)
.collect();
let mut velocities: Vec<f64> = vec![0.0; config.n_particles];
let mut p_best: Vec<f64> = positions.clone();
let mut p_best_power: Vec<f64> = positions.iter().map(|&v| array.power(v).max(0.0)).collect();
let mut g_best = p_best[0];
let mut g_best_power = p_best_power[0];
for (v, p) in p_best.iter().zip(p_best_power.iter()) {
if *p > g_best_power {
g_best_power = *p;
g_best = *v;
}
}
let mut iterations = 0;
let mut prev_best = 0.0_f64;
for iter in 0..config.max_iterations {
iterations = iter + 1;
for i in 0..config.n_particles {
let r1 = rng.next_f64();
let r2 = rng.next_f64();
velocities[i] = config.inertia * velocities[i]
+ config.c1 * r1 * (p_best[i] - positions[i])
+ config.c2 * r2 * (g_best - positions[i]);
velocities[i] = velocities[i].clamp(-v_clamp, v_clamp);
positions[i] = (positions[i] + velocities[i]).clamp(v_min, v_max);
let power = array.power(positions[i]).max(0.0);
if power > p_best_power[i] {
p_best_power[i] = power;
p_best[i] = positions[i];
}
if power > g_best_power {
g_best_power = power;
g_best = positions[i];
}
}
if (g_best_power - prev_best).abs() < config.tol_w && iter > 5 {
break;
}
prev_best = g_best_power;
}
PsoMpptResult {
v_gmpp: g_best,
p_gmpp: g_best_power,
iterations,
converged: (g_best_power - prev_best).abs() < config.tol_w,
particle_best: p_best
.iter()
.zip(p_best_power.iter())
.map(|(&v, &p)| (v, p))
.collect(),
}
}
struct LcgRng {
state: u64,
}
impl LcgRng {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self
.state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
self.state
}
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShadingLossResult {
pub p_unshaded: f64,
pub p_shaded: f64,
pub loss_w: f64,
pub loss_fraction: f64,
pub mismatch_loss_fraction: f64,
pub bypass_active: bool,
}
pub fn shading_loss_analysis(shaded_array: &PvArray, full_irradiance: f64) -> ShadingLossResult {
let n_sub = shaded_array.substrings.len();
let temp = shaded_array
.substrings
.first()
.map(|s| s.temperature_c)
.unwrap_or(25.0);
let unshaded = PvArray::uniform(full_irradiance, temp, n_sub, shaded_array.n_parallel);
let config = PsoMpptConfig::default();
let unshaded_result = pso_global_mppt(&unshaded, &config);
let shaded_result = pso_global_mppt(shaded_array, &config);
let p_unshaded = unshaded_result.p_gmpp;
let p_shaded = shaded_result.p_gmpp;
let loss_w = (p_unshaded - p_shaded).max(0.0);
let loss_fraction = if p_unshaded > 1e-6 {
loss_w / p_unshaded
} else {
0.0
};
let irr_fraction = shaded_array
.substrings
.iter()
.map(|s| s.irradiance)
.sum::<f64>()
/ (n_sub as f64 * full_irradiance).max(1e-6);
let expected_power = p_unshaded * irr_fraction;
let mismatch_loss = (expected_power - p_shaded).max(0.0) / p_unshaded.max(1e-6);
let bypass_active = shaded_array
.substrings
.iter()
.any(|s| s.irradiance < full_irradiance * 0.5);
ShadingLossResult {
p_unshaded,
p_shaded,
loss_w,
loss_fraction,
mismatch_loss_fraction: mismatch_loss.clamp(0.0, 1.0),
bypass_active,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_uniform_array_current_positive() {
let array = PvArray::uniform(1000.0, 25.0, 3, 1);
let i = array.array_current(array.v_oc_array() * 0.8);
assert!(i >= 0.0, "Current should be non-negative: {:.4}", i);
}
#[test]
fn test_uniform_array_power_positive() {
let array = PvArray::uniform(1000.0, 25.0, 3, 1);
let v_test = array.v_oc_array() * 0.75;
let p = array.power(v_test);
assert!(p > 0.0, "Power at MPP region should be positive: {:.2}", p);
}
#[test]
fn test_pv_curve_length() {
let array = PvArray::uniform(1000.0, 25.0, 3, 1);
let curve = pv_curve(&array, 100);
assert_eq!(curve.len(), 100);
}
#[test]
fn test_pv_curve_starts_at_zero() {
let array = PvArray::uniform(1000.0, 25.0, 3, 1);
let curve = pv_curve(&array, 50);
assert!((curve[0].0).abs() < 1e-9, "Curve should start at V=0");
}
#[test]
fn test_pso_finds_power_positive() {
let array = PvArray::uniform(800.0, 30.0, 3, 2);
let config = PsoMpptConfig::default();
let result = pso_global_mppt(&array, &config);
assert!(
result.p_gmpp > 0.0,
"PSO should find positive power: {:.2}",
result.p_gmpp
);
assert!(
result.v_gmpp > 0.0,
"GMPP voltage should be positive: {:.2}",
result.v_gmpp
);
}
#[test]
fn test_pso_uniform_vs_shaded() {
let uniform = PvArray::uniform(1000.0, 25.0, 3, 1);
let shaded = PvArray::partially_shaded(1000.0, 200.0, 25.0, 3, 1, 1);
let config = PsoMpptConfig::default();
let p_uniform = pso_global_mppt(&uniform, &config).p_gmpp;
let p_shaded = pso_global_mppt(&shaded, &config).p_gmpp;
assert!(
p_uniform > p_shaded,
"Shaded array should produce less power"
);
}
#[test]
fn test_pso_iterations_bounded() {
let array = PvArray::uniform(1000.0, 25.0, 3, 1);
let config = PsoMpptConfig {
max_iterations: 20,
..Default::default()
};
let result = pso_global_mppt(&array, &config);
assert!(result.iterations <= 20);
}
#[test]
fn test_shading_loss_positive_for_partial_shade() {
let shaded = PvArray::partially_shaded(1000.0, 100.0, 25.0, 3, 0, 1);
let loss = shading_loss_analysis(&shaded, 1000.0);
assert!(
loss.loss_fraction > 0.0,
"Should have shading loss: {:.4}",
loss.loss_fraction
);
assert!(
loss.bypass_active,
"Bypass diode should be active under heavy shading"
);
}
#[test]
fn test_shading_loss_zero_for_uniform() {
let uniform = PvArray::uniform(1000.0, 25.0, 3, 1);
let loss = shading_loss_analysis(&uniform, 1000.0);
assert!(
loss.loss_fraction < 0.05,
"Uniform array should have near-zero shading loss: {:.4}",
loss.loss_fraction
);
assert!(!loss.bypass_active);
}
#[test]
fn test_local_maxima_detection() {
let curve: Vec<(f64, f64)> = vec![
(0.0, 0.0),
(5.0, 10.0),
(10.0, 20.0),
(15.0, 40.0),
(20.0, 50.0),
(25.0, 48.0),
(30.0, 30.0),
(35.0, 10.0),
(40.0, 0.0),
];
let maxima = local_maxima(&curve);
assert!(
!maxima.is_empty(),
"Constructed curve has one maximum at (20, 50)"
);
assert!(
(maxima[0].0 - 20.0).abs() < 1e-9,
"Maximum should be at V=20: {:.2}",
maxima[0].0
);
}
#[test]
fn test_substring_current_bypass_at_negative_v() {
let sub = PvSubstring {
n_cells: 20,
irradiance: 1000.0,
temperature_c: 25.0,
};
let p = CellParams::mono_si();
let i_bypass = substring_current(
&sub, -0.5, p.i_sc_ref, p.i_o_ref, p.a_factor, p.r_s, p.vt_ref,
);
assert!(i_bypass >= 0.0);
}
#[test]
fn test_cell_params_presets() {
let mono = CellParams::mono_si();
let poly = CellParams::poly_si();
assert!(
mono.v_oc_cell > poly.v_oc_cell,
"Mono-Si should have higher Voc"
);
assert!(mono.i_sc_ref > poly.i_sc_ref);
}
#[test]
fn test_pso_particle_best_count() {
let array = PvArray::uniform(1000.0, 25.0, 3, 1);
let config = PsoMpptConfig {
n_particles: 8,
..Default::default()
};
let result = pso_global_mppt(&array, &config);
assert_eq!(result.particle_best.len(), 8);
}
#[test]
fn test_v_oc_array_scales_with_substrings() {
let a3 = PvArray::uniform(1000.0, 25.0, 3, 1);
let a6 = PvArray::uniform(1000.0, 25.0, 6, 1);
assert!((a6.v_oc_array() / a3.v_oc_array() - 2.0).abs() < 1e-9);
}
}