1#[derive(Debug, Clone)]
7pub struct SdmFitResult {
8 pub photocurrent: f64,
10 pub saturation_current: f64,
12 pub resistance_series: f64,
14 pub resistance_shunt: f64,
16 pub n_ns_vth: f64,
18}
19
20pub fn rectify_iv_curve(voltage: &[f64], current: &[f64]) -> (Vec<f64>, Vec<f64>) {
29 let mut pairs: Vec<(f64, f64)> = voltage
31 .iter()
32 .zip(current.iter())
33 .filter(|(v, i)| v.is_finite() && i.is_finite() && **v >= 0.0 && **i >= 0.0)
34 .map(|(v, i)| (*v, *i))
35 .collect();
36
37 pairs.sort_by(|a, b| {
39 a.0.partial_cmp(&b.0)
40 .unwrap_or(std::cmp::Ordering::Equal)
41 .then(b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
42 });
43
44 if pairs.is_empty() {
45 return (vec![], vec![]);
46 }
47
48 let mut out_v: Vec<f64> = Vec::with_capacity(pairs.len());
50 let mut out_i: Vec<f64> = Vec::with_capacity(pairs.len());
51
52 let mut cur_v = pairs[0].0;
53 let mut cur_sum = pairs[0].1;
54 let mut cur_count = 1usize;
55
56 for &(v, i) in &pairs[1..] {
57 if (v - cur_v).abs() < f64::EPSILON * cur_v.abs().max(1.0) {
58 cur_sum += i;
59 cur_count += 1;
60 } else {
61 out_v.push(cur_v);
62 out_i.push(cur_sum / cur_count as f64);
63 cur_v = v;
64 cur_sum = i;
65 cur_count = 1;
66 }
67 }
68 out_v.push(cur_v);
69 out_i.push(cur_sum / cur_count as f64);
70
71 (out_v, out_i)
72}
73
74pub fn fit_sandia_simple(
91 voltage: &[f64],
92 current: &[f64],
93 v_oc: Option<f64>,
94 i_sc: Option<f64>,
95 v_mp_i_mp: Option<(f64, f64)>,
96 vlim: f64,
97 ilim: f64,
98) -> Result<SdmFitResult, String> {
99 let n = voltage.len();
100 if n < 6 || current.len() != n {
101 return Err("Need at least 6 matching voltage/current points".into());
102 }
103
104 let v_oc = v_oc.unwrap_or(voltage[n - 1]);
105 let i_sc = i_sc.unwrap_or(current[0]);
106
107 let (v_mp, i_mp) = v_mp_i_mp.unwrap_or_else(|| {
108 let mut best_idx = 0;
109 let mut best_p = voltage[0] * current[0];
110 for k in 1..n {
111 let p = voltage[k] * current[k];
112 if p > best_p {
113 best_p = p;
114 best_idx = k;
115 }
116 }
117 (voltage[best_idx], current[best_idx])
118 });
119
120 let (beta0, beta1) = sandia_beta0_beta1(voltage, current, vlim, v_oc)?;
122
123 let (beta3, beta4) = sandia_beta3_beta4(voltage, current, beta0, beta1, ilim, i_sc)?;
125
126 sandia_simple_params(beta0, beta1, beta3, beta4, v_mp, i_mp, v_oc)
128}
129
130fn sandia_beta0_beta1(
132 v: &[f64],
133 i: &[f64],
134 vlim: f64,
135 v_oc: f64,
136) -> Result<(f64, f64), String> {
137 let threshold = vlim * v_oc;
138 let first_idx = v.iter().position(|&x| x >= threshold).unwrap_or(3).max(3);
140
141 for idx in first_idx..=v.len() {
142 let (slope, intercept) = polyfit1(&v[..idx], &i[..idx]);
143 if slope < 0.0 {
144 return Ok((intercept, -slope));
145 }
146 }
147 Err("Parameter extraction failed: could not determine beta0, beta1 from linear region".into())
148}
149
150fn sandia_beta3_beta4(
152 voltage: &[f64],
153 current: &[f64],
154 beta0: f64,
155 beta1: f64,
156 ilim: f64,
157 i_sc: f64,
158) -> Result<(f64, f64), String> {
159 let n = voltage.len();
161 let mut xv: Vec<[f64; 3]> = Vec::new();
162 let mut yv: Vec<f64> = Vec::new();
163
164 for k in 0..n {
165 let y = beta0 - beta1 * voltage[k] - current[k];
166 if y > ilim * i_sc {
167 xv.push([1.0, voltage[k], current[k]]);
168 yv.push(y.ln());
169 }
170 }
171
172 if xv.len() < 3 {
173 return Err("Parameter extraction failed: insufficient points in exponential region".into());
174 }
175
176 let coef = lstsq_3(&xv, &yv)?;
178 let beta3 = coef[1];
179 let beta4 = coef[2];
180
181 if beta3.is_nan() || beta4.is_nan() {
182 return Err(format!(
183 "Parameter extraction failed: beta3={}, beta4={}",
184 beta3, beta4
185 ));
186 }
187 Ok((beta3, beta4))
188}
189
190fn sandia_simple_params(
192 beta0: f64,
193 beta1: f64,
194 beta3: f64,
195 beta4: f64,
196 v_mp: f64,
197 i_mp: f64,
198 v_oc: f64,
199) -> Result<SdmFitResult, String> {
200 let n_ns_vth = 1.0 / beta3;
201 let rs = beta4 / beta3;
202 let gsh = beta1 / (1.0 - rs * beta1);
203 let rsh = 1.0 / gsh;
204 let iph = (1.0 + gsh * rs) * beta0;
205
206 let io_vmp = calc_i0(v_mp, i_mp, iph, gsh, rs, n_ns_vth);
207 let io_voc = calc_i0(v_oc, 0.0, iph, gsh, rs, n_ns_vth);
208
209 let io = if io_vmp > 0.0 && io_voc > 0.0 {
210 0.5 * (io_vmp + io_voc)
211 } else if io_vmp > 0.0 {
212 io_vmp
213 } else if io_voc > 0.0 {
214 io_voc
215 } else {
216 return Err("Parameter extraction failed: I0 is undetermined".into());
217 };
218
219 Ok(SdmFitResult {
220 photocurrent: iph,
221 saturation_current: io,
222 resistance_series: rs,
223 resistance_shunt: rsh,
224 n_ns_vth,
225 })
226}
227
228fn calc_i0(voltage: f64, current: f64, iph: f64, gsh: f64, rs: f64, n_ns_vth: f64) -> f64 {
229 let x = (voltage + rs * current) / n_ns_vth;
230 let denom = x.exp() - 1.0;
231 if denom.abs() < 1e-30 {
232 return f64::NAN;
233 }
234 (iph - current - gsh * (voltage + rs * current)) / denom
235}
236
237pub fn fit_desoto(
264 v_mp: f64,
265 i_mp: f64,
266 v_oc: f64,
267 i_sc: f64,
268 alpha_sc: f64,
269 beta_voc: f64,
270 cells_in_series: i32,
271 eg_ref: f64,
272 d_eg_dt: f64,
273) -> Result<SdmFitResult, String> {
274 const K_EV: f64 = 8.617333262e-5;
276 let t_ref = 25.0 + 273.15; let a_0 = 1.5 * K_EV * t_ref * cells_in_series as f64;
280 let il_0 = i_sc;
281 let io_0 = i_sc * (-v_oc / a_0).exp();
282 let rs_0 = {
283 let ratio = (il_0 - i_mp) / io_0;
284 if ratio > 0.0 {
285 (a_0 * (1.0 + ratio).ln() - v_mp) / i_mp
286 } else {
287 0.1
288 }
289 };
290 let rsh_0 = 100.0;
291
292 let mut params = [il_0, io_0, rs_0, rsh_0, a_0];
293 let specs = DesotoSpecs {
294 i_sc,
295 v_oc,
296 i_mp,
297 v_mp,
298 beta_voc,
299 alpha_sc,
300 eg_ref,
301 d_eg_dt,
302 t_ref,
303 k: K_EV,
304 };
305
306 for _ in 0..500 {
308 let f = desoto_equations(¶ms, &specs);
309 let j = desoto_jacobian(¶ms, &specs);
310
311 let delta = match solve_5x5(&j, &f) {
312 Some(d) => d,
313 None => {
314 for p in params.iter_mut() {
316 *p *= 1.0 + 1e-6;
317 }
318 continue;
319 }
320 };
321
322 let mut alpha = 1.0_f64;
324 for i in 0..5 {
325 if params[i].abs() > 1e-30 {
326 let rel = (delta[i] / params[i]).abs();
327 if rel > 0.5 {
328 alpha = alpha.min(0.5 / rel);
329 }
330 }
331 }
332
333 let mut max_step = 0.0_f64;
334 for i in 0..5 {
335 let step = alpha * delta[i];
336 params[i] -= step;
337 let scale = params[i].abs().max(1e-30);
338 max_step = max_step.max((step / scale).abs());
339 }
340
341 if params[1] <= 0.0 {
343 params[1] = 1e-15;
344 }
345
346 if max_step < 1e-10 {
347 return Ok(SdmFitResult {
348 photocurrent: params[0],
349 saturation_current: params[1],
350 resistance_series: params[2],
351 resistance_shunt: params[3],
352 n_ns_vth: params[4],
353 });
354 }
355 }
356
357 Err("De Soto parameter estimation did not converge".into())
358}
359
360struct DesotoSpecs {
361 i_sc: f64,
362 v_oc: f64,
363 i_mp: f64,
364 v_mp: f64,
365 beta_voc: f64,
366 alpha_sc: f64,
367 eg_ref: f64,
368 d_eg_dt: f64,
369 t_ref: f64,
370 k: f64,
371}
372
373fn desoto_equations(params: &[f64; 5], s: &DesotoSpecs) -> [f64; 5] {
375 let (il, io, rs, rsh, a) = (params[0], params[1], params[2], params[3], params[4]);
376
377 let y0 = s.i_sc - il + io * ((s.i_sc * rs / a).exp() - 1.0) + s.i_sc * rs / rsh;
379
380 let y1 = -il + io * ((s.v_oc / a).exp() - 1.0) + s.v_oc / rsh;
382
383 let vrs_mp = s.v_mp + s.i_mp * rs;
385 let y2 = s.i_mp - il + io * ((vrs_mp / a).exp() - 1.0) + vrs_mp / rsh;
386
387 let exp_mp = (vrs_mp / a).exp();
389 let num = s.i_mp - s.v_mp * (io / a * exp_mp + 1.0 / rsh);
390 let den = 1.0 + io * rs / a * exp_mp + rs / rsh;
391 let y3 = num / den;
392
393 let t2 = s.t_ref + 2.0;
395 let voc2 = (t2 - s.t_ref) * s.beta_voc + s.v_oc;
396 let a2 = a * t2 / s.t_ref;
397 let il2 = il + s.alpha_sc * (t2 - s.t_ref);
398 let eg2 = s.eg_ref * (1.0 + s.d_eg_dt * (t2 - s.t_ref));
399 let io2 = io * (t2 / s.t_ref).powi(3) * ((1.0 / s.k) * (s.eg_ref / s.t_ref - eg2 / t2)).exp();
400 let y4 = -il2 + io2 * ((voc2 / a2).exp() - 1.0) + voc2 / rsh;
401
402 [y0, y1, y2, y3, y4]
403}
404
405fn desoto_jacobian(params: &[f64; 5], s: &DesotoSpecs) -> [[f64; 5]; 5] {
407 let f0 = desoto_equations(params, s);
408 let mut jac = [[0.0; 5]; 5];
409
410 for j in 0..5 {
411 let mut p = *params;
412 let h = (params[j].abs() * 1e-8).max(1e-14);
413 p[j] += h;
414 let f1 = desoto_equations(&p, s);
415 for i in 0..5 {
416 jac[i][j] = (f1[i] - f0[i]) / h;
417 }
418 }
419 jac
420}
421
422fn polyfit1(x: &[f64], y: &[f64]) -> (f64, f64) {
428 let n = x.len() as f64;
429 let sx: f64 = x.iter().sum();
430 let sy: f64 = y.iter().sum();
431 let sxy: f64 = x.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
432 let sx2: f64 = x.iter().map(|a| a * a).sum();
433
434 let denom = n * sx2 - sx * sx;
435 if denom.abs() < 1e-30 {
436 return (0.0, sy / n);
437 }
438 let slope = (n * sxy - sx * sy) / denom;
439 let intercept = (sy - slope * sx) / n;
440 (slope, intercept)
441}
442
443fn lstsq_3(a: &[[f64; 3]], b: &[f64]) -> Result<[f64; 3], String> {
445 let m = a.len();
447 let mut ata = [[0.0; 3]; 3];
448 let mut atb = [0.0; 3];
449
450 for k in 0..m {
451 for i in 0..3 {
452 atb[i] += a[k][i] * b[k];
453 for j in 0..3 {
454 ata[i][j] += a[k][i] * a[k][j];
455 }
456 }
457 }
458
459 solve_3x3(&ata, &atb).ok_or_else(|| "Singular matrix in least-squares".to_string())
461}
462
463fn solve_3x3(a: &[[f64; 3]; 3], b: &[f64; 3]) -> Option<[f64; 3]> {
465 let mut aug = [[0.0; 4]; 3];
466 for i in 0..3 {
467 for j in 0..3 {
468 aug[i][j] = a[i][j];
469 }
470 aug[i][3] = b[i];
471 }
472
473 for col in 0..3 {
474 let mut max_row = col;
476 let mut max_val = aug[col][col].abs();
477 for row in (col + 1)..3 {
478 if aug[row][col].abs() > max_val {
479 max_val = aug[row][col].abs();
480 max_row = row;
481 }
482 }
483 if max_val < 1e-30 {
484 return None;
485 }
486 aug.swap(col, max_row);
487
488 let pivot = aug[col][col];
489 for row in (col + 1)..3 {
490 let factor = aug[row][col] / pivot;
491 for j in col..4 {
492 aug[row][j] -= factor * aug[col][j];
493 }
494 }
495 }
496
497 let mut x = [0.0; 3];
499 for i in (0..3).rev() {
500 x[i] = aug[i][3];
501 for j in (i + 1)..3 {
502 x[i] -= aug[i][j] * x[j];
503 }
504 x[i] /= aug[i][i];
505 }
506 Some(x)
507}
508
509fn solve_5x5(a: &[[f64; 5]; 5], b: &[f64; 5]) -> Option<[f64; 5]> {
511 let mut aug = [[0.0; 6]; 5];
512 for i in 0..5 {
513 for j in 0..5 {
514 aug[i][j] = a[i][j];
515 }
516 aug[i][5] = b[i];
517 }
518
519 let max_abs = aug
521 .iter()
522 .flat_map(|row| row.iter())
523 .map(|x| x.abs())
524 .fold(0.0_f64, f64::max)
525 .max(1e-300);
526
527 for col in 0..5 {
528 let mut max_row = col;
529 let mut max_val = aug[col][col].abs();
530 for row in (col + 1)..5 {
531 if aug[row][col].abs() > max_val {
532 max_val = aug[row][col].abs();
533 max_row = row;
534 }
535 }
536 if max_val < max_abs * 1e-15 {
537 return None;
538 }
539 aug.swap(col, max_row);
540
541 let pivot = aug[col][col];
542 for row in (col + 1)..5 {
543 let factor = aug[row][col] / pivot;
544 for j in col..6 {
545 aug[row][j] -= factor * aug[col][j];
546 }
547 }
548 }
549
550 let mut x = [0.0; 5];
551 for i in (0..5).rev() {
552 x[i] = aug[i][5];
553 for j in (i + 1)..5 {
554 x[i] -= aug[i][j] * x[j];
555 }
556 x[i] /= aug[i][i];
557 }
558 Some(x)
559}
560
561#[cfg(test)]
566mod tests {
567 use super::*;
568
569 fn generate_iv_curve(
572 il: f64,
573 i0: f64,
574 rs: f64,
575 rsh: f64,
576 a: f64,
577 n_points: usize,
578 ) -> (Vec<f64>, Vec<f64>) {
579 let v_oc_est = a * (il / i0).ln();
581 let mut voltages = Vec::with_capacity(n_points);
582 let mut currents = Vec::with_capacity(n_points);
583
584 for k in 0..n_points {
585 let v = v_oc_est * (k as f64) / (n_points as f64 - 1.0);
586 let mut i = il - v / rsh;
588 for _ in 0..200 {
589 let exp_term = ((v + i * rs) / a).exp();
590 let f = il - i0 * (exp_term - 1.0) - (v + i * rs) / rsh - i;
591 let df = -i0 * rs / a * exp_term - rs / rsh - 1.0;
592 let step = f / df;
593 i -= step;
594 if step.abs() < 1e-12 {
595 break;
596 }
597 }
598 voltages.push(v);
599 currents.push(i.max(0.0));
600 }
601 (voltages, currents)
602 }
603
604 #[test]
605 fn test_rectify_iv_curve_basic() {
606 let v = vec![3.0, 1.0, 2.0, 1.0, -0.5, f64::NAN];
607 let i = vec![0.5, 2.0, 1.0, 1.5, 3.0, 1.0];
608 let (rv, ri) = rectify_iv_curve(&v, &i);
609
610 assert_eq!(rv.len(), ri.len());
612 for w in rv.windows(2) {
614 assert!(w[0] <= w[1]);
615 }
616 for &v in &rv {
618 assert!(v >= 0.0);
619 }
620 for &i in &ri {
621 assert!(i >= 0.0);
622 }
623 let idx = rv.iter().position(|&x| (x - 1.0).abs() < 1e-10).unwrap();
625 assert!((ri[idx] - 1.75).abs() < 1e-10);
626 }
627
628 #[test]
629 fn test_rectify_iv_curve_empty() {
630 let (v, i) = rectify_iv_curve(&[], &[]);
631 assert!(v.is_empty());
632 assert!(i.is_empty());
633 }
634
635 #[test]
636 fn test_fit_sandia_simple_roundtrip() {
637 let il = 9.0;
639 let i0 = 1e-10;
640 let rs = 0.3;
641 let rsh = 500.0;
642 let a = 1.6; let (voltage, current) = generate_iv_curve(il, i0, rs, rsh, a, 200);
645 let v_oc = *voltage.last().unwrap();
646 let i_sc = current[0];
647
648 let result = fit_sandia_simple(&voltage, ¤t, Some(v_oc), Some(i_sc), None, 0.2, 0.1);
649 assert!(result.is_ok(), "fit_sandia_simple failed: {:?}", result.err());
650 let r = result.unwrap();
651
652 let il_err = (r.photocurrent - il).abs() / il;
654 let rsh_err = (r.resistance_shunt - rsh).abs() / rsh;
655 let a_err = (r.n_ns_vth - a).abs() / a;
656
657 assert!(il_err < 0.05, "IL error too large: {:.4}", il_err);
658 assert!(rsh_err < 0.5, "Rsh error too large: {:.4}", rsh_err);
659 assert!(a_err < 0.15, "nNsVth error too large: {:.4}", a_err);
660 assert!(r.saturation_current > 0.0, "I0 should be positive");
661 assert!(r.resistance_series >= 0.0, "Rs should be non-negative");
662 }
663
664 #[test]
665 fn test_fit_desoto_typical_module() {
666 let v_mp = 29.0;
668 let i_mp = 7.6;
669 let v_oc = 36.3;
670 let i_sc = 8.1;
671 let alpha_sc = 0.003; let beta_voc = -0.125; let cells_in_series = 60;
674 let eg_ref = 1.121;
675 let d_eg_dt = -0.0002677;
676
677 let result = fit_desoto(
678 v_mp,
679 i_mp,
680 v_oc,
681 i_sc,
682 alpha_sc,
683 beta_voc,
684 cells_in_series,
685 eg_ref,
686 d_eg_dt,
687 );
688 assert!(result.is_ok(), "fit_desoto failed: {:?}", result.err());
689 let r = result.unwrap();
690
691 assert!(r.photocurrent > 0.0, "IL should be positive: {}", r.photocurrent);
693 assert!(
694 (r.photocurrent - i_sc).abs() < 1.0,
695 "IL should be close to Isc: {}",
696 r.photocurrent
697 );
698 assert!(r.saturation_current > 0.0, "I0 should be positive: {}", r.saturation_current);
699 assert!(
700 r.saturation_current < 1e-5,
701 "I0 should be very small: {}",
702 r.saturation_current
703 );
704 assert!(r.resistance_series > 0.0, "Rs should be positive: {}", r.resistance_series);
705 assert!(r.resistance_series < 5.0, "Rs should be reasonable: {}", r.resistance_series);
706 assert!(r.resistance_shunt > 10.0, "Rsh should be large: {}", r.resistance_shunt);
707 assert!(r.n_ns_vth > 0.0, "a_ref should be positive: {}", r.n_ns_vth);
708 }
709
710 #[test]
711 fn test_fit_sandia_simple_too_few_points() {
712 let v = vec![0.0, 1.0, 2.0];
713 let i = vec![5.0, 4.0, 0.0];
714 let result = fit_sandia_simple(&v, &i, None, None, None, 0.2, 0.1);
715 assert!(result.is_err());
716 }
717}