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_m1();
231 if denom.abs() < 1e-30 {
232 return f64::NAN;
233 }
234 (iph - current - gsh * (voltage + rs * current)) / denom
235}
236
237#[allow(clippy::too_many_arguments)]
264pub fn fit_desoto(
265 v_mp: f64,
266 i_mp: f64,
267 v_oc: f64,
268 i_sc: f64,
269 alpha_sc: f64,
270 beta_voc: f64,
271 cells_in_series: i32,
272 eg_ref: f64,
273 d_eg_dt: f64,
274) -> Result<SdmFitResult, String> {
275 const K_EV: f64 = 8.617333262e-5;
277 let t_ref = 25.0 + 273.15; let a_0 = 1.5 * K_EV * t_ref * cells_in_series as f64;
281 let il_0 = i_sc;
282 let io_0 = i_sc * (-v_oc / a_0).exp();
283 let rs_0 = {
284 let ratio = (il_0 - i_mp) / io_0;
285 if ratio > 0.0 {
286 (a_0 * (1.0 + ratio).ln() - v_mp) / i_mp
287 } else {
288 0.1
289 }
290 };
291 let rsh_0 = 100.0;
292
293 let mut params = [il_0, io_0, rs_0, rsh_0, a_0];
294 let specs = DesotoSpecs {
295 i_sc,
296 v_oc,
297 i_mp,
298 v_mp,
299 beta_voc,
300 alpha_sc,
301 eg_ref,
302 d_eg_dt,
303 t_ref,
304 k: K_EV,
305 };
306
307 for _ in 0..500 {
309 let f = desoto_equations(¶ms, &specs);
310 let j = desoto_jacobian(¶ms, &specs);
311
312 let delta = match solve_5x5(&j, &f) {
313 Some(d) => d,
314 None => {
315 for p in params.iter_mut() {
317 *p *= 1.0 + 1e-6;
318 }
319 continue;
320 }
321 };
322
323 let mut alpha = 1.0_f64;
325 for i in 0..5 {
326 if params[i].abs() > 1e-30 {
327 let rel = (delta[i] / params[i]).abs();
328 if rel > 0.5 {
329 alpha = alpha.min(0.5 / rel);
330 }
331 }
332 }
333
334 let mut max_step = 0.0_f64;
335 for i in 0..5 {
336 let step = alpha * delta[i];
337 params[i] -= step;
338 let scale = params[i].abs().max(1e-30);
339 max_step = max_step.max((step / scale).abs());
340 }
341
342 if params[1] <= 0.0 {
344 params[1] = 1e-15;
345 }
346
347 if max_step < 1e-10 {
348 return Ok(SdmFitResult {
349 photocurrent: params[0],
350 saturation_current: params[1],
351 resistance_series: params[2],
352 resistance_shunt: params[3],
353 n_ns_vth: params[4],
354 });
355 }
356 }
357
358 Err("De Soto parameter estimation did not converge".into())
359}
360
361struct DesotoSpecs {
362 i_sc: f64,
363 v_oc: f64,
364 i_mp: f64,
365 v_mp: f64,
366 beta_voc: f64,
367 alpha_sc: f64,
368 eg_ref: f64,
369 d_eg_dt: f64,
370 t_ref: f64,
371 k: f64,
372}
373
374fn desoto_equations(params: &[f64; 5], s: &DesotoSpecs) -> [f64; 5] {
376 let (il, io, rs, rsh, a) = (params[0], params[1], params[2], params[3], params[4]);
377
378 let y0 = s.i_sc - il + io * ((s.i_sc * rs / a).exp() - 1.0) + s.i_sc * rs / rsh;
380
381 let y1 = -il + io * ((s.v_oc / a).exp() - 1.0) + s.v_oc / rsh;
383
384 let vrs_mp = s.v_mp + s.i_mp * rs;
386 let y2 = s.i_mp - il + io * ((vrs_mp / a).exp() - 1.0) + vrs_mp / rsh;
387
388 let exp_mp = (vrs_mp / a).exp();
390 let num = s.i_mp - s.v_mp * (io / a * exp_mp + 1.0 / rsh);
391 let den = 1.0 + io * rs / a * exp_mp + rs / rsh;
392 let y3 = num / den;
393
394 let t2 = s.t_ref + 2.0;
396 let voc2 = (t2 - s.t_ref) * s.beta_voc + s.v_oc;
397 let a2 = a * t2 / s.t_ref;
398 let il2 = il + s.alpha_sc * (t2 - s.t_ref);
399 let eg2 = s.eg_ref * (1.0 + s.d_eg_dt * (t2 - s.t_ref));
400 let io2 = io * (t2 / s.t_ref).powi(3) * ((1.0 / s.k) * (s.eg_ref / s.t_ref - eg2 / t2)).exp();
401 let y4 = -il2 + io2 * ((voc2 / a2).exp() - 1.0) + voc2 / rsh;
402
403 [y0, y1, y2, y3, y4]
404}
405
406fn desoto_jacobian(params: &[f64; 5], s: &DesotoSpecs) -> [[f64; 5]; 5] {
408 let f0 = desoto_equations(params, s);
409 let mut jac = [[0.0; 5]; 5];
410
411 for j in 0..5 {
412 let mut p = *params;
413 let h = (params[j].abs() * 1e-8).max(1e-14);
414 p[j] += h;
415 let f1 = desoto_equations(&p, s);
416 for i in 0..5 {
417 jac[i][j] = (f1[i] - f0[i]) / h;
418 }
419 }
420 jac
421}
422
423fn polyfit1(x: &[f64], y: &[f64]) -> (f64, f64) {
429 let n = x.len() as f64;
430 let sx: f64 = x.iter().sum();
431 let sy: f64 = y.iter().sum();
432 let sxy: f64 = x.iter().zip(y.iter()).map(|(a, b)| a * b).sum();
433 let sx2: f64 = x.iter().map(|a| a * a).sum();
434
435 let denom = n * sx2 - sx * sx;
436 if denom.abs() < 1e-30 {
437 return (0.0, sy / n);
438 }
439 let slope = (n * sxy - sx * sy) / denom;
440 let intercept = (sy - slope * sx) / n;
441 (slope, intercept)
442}
443
444fn lstsq_3(a: &[[f64; 3]], b: &[f64]) -> Result<[f64; 3], String> {
446 let m = a.len();
448 let mut ata = [[0.0; 3]; 3];
449 let mut atb = [0.0; 3];
450
451 for k in 0..m {
452 for i in 0..3 {
453 atb[i] += a[k][i] * b[k];
454 for j in 0..3 {
455 ata[i][j] += a[k][i] * a[k][j];
456 }
457 }
458 }
459
460 solve_3x3(&ata, &atb).ok_or_else(|| "Singular matrix in least-squares".to_string())
462}
463
464#[allow(clippy::needless_range_loop)]
466fn solve_3x3(a: &[[f64; 3]; 3], b: &[f64; 3]) -> Option<[f64; 3]> {
467 let mut aug = [[0.0; 4]; 3];
468 for i in 0..3 {
469 for j in 0..3 {
470 aug[i][j] = a[i][j];
471 }
472 aug[i][3] = b[i];
473 }
474
475 for col in 0..3 {
476 let mut max_row = col;
478 let mut max_val = aug[col][col].abs();
479 for row in (col + 1)..3 {
480 if aug[row][col].abs() > max_val {
481 max_val = aug[row][col].abs();
482 max_row = row;
483 }
484 }
485 if max_val < 1e-30 {
486 return None;
487 }
488 aug.swap(col, max_row);
489
490 let pivot = aug[col][col];
491 for row in (col + 1)..3 {
492 let factor = aug[row][col] / pivot;
493 for j in col..4 {
494 aug[row][j] -= factor * aug[col][j];
495 }
496 }
497 }
498
499 let mut x = [0.0; 3];
501 for i in (0..3).rev() {
502 x[i] = aug[i][3];
503 for j in (i + 1)..3 {
504 x[i] -= aug[i][j] * x[j];
505 }
506 x[i] /= aug[i][i];
507 }
508 Some(x)
509}
510
511#[allow(clippy::needless_range_loop)]
513fn solve_5x5(a: &[[f64; 5]; 5], b: &[f64; 5]) -> Option<[f64; 5]> {
514 let mut aug = [[0.0; 6]; 5];
515 for i in 0..5 {
516 for j in 0..5 {
517 aug[i][j] = a[i][j];
518 }
519 aug[i][5] = b[i];
520 }
521
522 let max_abs = aug
524 .iter()
525 .flat_map(|row| row.iter())
526 .map(|x| x.abs())
527 .fold(0.0_f64, f64::max)
528 .max(1e-300);
529
530 for col in 0..5 {
531 let mut max_row = col;
532 let mut max_val = aug[col][col].abs();
533 for row in (col + 1)..5 {
534 if aug[row][col].abs() > max_val {
535 max_val = aug[row][col].abs();
536 max_row = row;
537 }
538 }
539 if max_val < max_abs * 1e-15 {
540 return None;
541 }
542 aug.swap(col, max_row);
543
544 let pivot = aug[col][col];
545 for row in (col + 1)..5 {
546 let factor = aug[row][col] / pivot;
547 for j in col..6 {
548 aug[row][j] -= factor * aug[col][j];
549 }
550 }
551 }
552
553 let mut x = [0.0; 5];
554 for i in (0..5).rev() {
555 x[i] = aug[i][5];
556 for j in (i + 1)..5 {
557 x[i] -= aug[i][j] * x[j];
558 }
559 x[i] /= aug[i][i];
560 }
561 Some(x)
562}
563
564#[cfg(test)]
569mod tests {
570 use super::*;
571
572 fn generate_iv_curve(
575 il: f64,
576 i0: f64,
577 rs: f64,
578 rsh: f64,
579 a: f64,
580 n_points: usize,
581 ) -> (Vec<f64>, Vec<f64>) {
582 let v_oc_est = a * (il / i0).ln();
584 let mut voltages = Vec::with_capacity(n_points);
585 let mut currents = Vec::with_capacity(n_points);
586
587 for k in 0..n_points {
588 let v = v_oc_est * (k as f64) / (n_points as f64 - 1.0);
589 let mut i = il - v / rsh;
591 for _ in 0..200 {
592 let exp_term = ((v + i * rs) / a).exp();
593 let f = il - i0 * (exp_term - 1.0) - (v + i * rs) / rsh - i;
594 let df = -i0 * rs / a * exp_term - rs / rsh - 1.0;
595 let step = f / df;
596 i -= step;
597 if step.abs() < 1e-12 {
598 break;
599 }
600 }
601 voltages.push(v);
602 currents.push(i.max(0.0));
603 }
604 (voltages, currents)
605 }
606
607 #[test]
608 fn test_rectify_iv_curve_basic() {
609 let v = vec![3.0, 1.0, 2.0, 1.0, -0.5, f64::NAN];
610 let i = vec![0.5, 2.0, 1.0, 1.5, 3.0, 1.0];
611 let (rv, ri) = rectify_iv_curve(&v, &i);
612
613 assert_eq!(rv.len(), ri.len());
615 for w in rv.windows(2) {
617 assert!(w[0] <= w[1]);
618 }
619 for &v in &rv {
621 assert!(v >= 0.0);
622 }
623 for &i in &ri {
624 assert!(i >= 0.0);
625 }
626 let idx = rv.iter().position(|&x| (x - 1.0).abs() < 1e-10).unwrap();
628 assert!((ri[idx] - 1.75).abs() < 1e-10);
629 }
630
631 #[test]
632 fn test_rectify_iv_curve_empty() {
633 let (v, i) = rectify_iv_curve(&[], &[]);
634 assert!(v.is_empty());
635 assert!(i.is_empty());
636 }
637
638 #[test]
639 fn test_fit_sandia_simple_roundtrip() {
640 let il = 9.0;
642 let i0 = 1e-10;
643 let rs = 0.3;
644 let rsh = 500.0;
645 let a = 1.6; let (voltage, current) = generate_iv_curve(il, i0, rs, rsh, a, 200);
648 let v_oc = *voltage.last().unwrap();
649 let i_sc = current[0];
650
651 let result = fit_sandia_simple(&voltage, ¤t, Some(v_oc), Some(i_sc), None, 0.2, 0.1);
652 assert!(result.is_ok(), "fit_sandia_simple failed: {:?}", result.err());
653 let r = result.unwrap();
654
655 let il_err = (r.photocurrent - il).abs() / il;
657 let rsh_err = (r.resistance_shunt - rsh).abs() / rsh;
658 let a_err = (r.n_ns_vth - a).abs() / a;
659
660 assert!(il_err < 0.05, "IL error too large: {:.4}", il_err);
661 assert!(rsh_err < 0.5, "Rsh error too large: {:.4}", rsh_err);
662 assert!(a_err < 0.15, "nNsVth error too large: {:.4}", a_err);
663 assert!(r.saturation_current > 0.0, "I0 should be positive");
664 assert!(r.resistance_series >= 0.0, "Rs should be non-negative");
665 }
666
667 #[test]
668 fn test_fit_desoto_typical_module() {
669 let v_mp = 29.0;
671 let i_mp = 7.6;
672 let v_oc = 36.3;
673 let i_sc = 8.1;
674 let alpha_sc = 0.003; let beta_voc = -0.125; let cells_in_series = 60;
677 let eg_ref = 1.121;
678 let d_eg_dt = -0.0002677;
679
680 let result = fit_desoto(
681 v_mp,
682 i_mp,
683 v_oc,
684 i_sc,
685 alpha_sc,
686 beta_voc,
687 cells_in_series,
688 eg_ref,
689 d_eg_dt,
690 );
691 assert!(result.is_ok(), "fit_desoto failed: {:?}", result.err());
692 let r = result.unwrap();
693
694 assert!(r.photocurrent > 0.0, "IL should be positive: {}", r.photocurrent);
696 assert!(
697 (r.photocurrent - i_sc).abs() < 1.0,
698 "IL should be close to Isc: {}",
699 r.photocurrent
700 );
701 assert!(r.saturation_current > 0.0, "I0 should be positive: {}", r.saturation_current);
702 assert!(
703 r.saturation_current < 1e-5,
704 "I0 should be very small: {}",
705 r.saturation_current
706 );
707 assert!(r.resistance_series > 0.0, "Rs should be positive: {}", r.resistance_series);
708 assert!(r.resistance_series < 5.0, "Rs should be reasonable: {}", r.resistance_series);
709 assert!(r.resistance_shunt > 10.0, "Rsh should be large: {}", r.resistance_shunt);
710 assert!(r.n_ns_vth > 0.0, "a_ref should be positive: {}", r.n_ns_vth);
711 }
712
713 #[test]
714 fn test_fit_sandia_simple_too_few_points() {
715 let v = vec![0.0, 1.0, 2.0];
716 let i = vec![5.0, 4.0, 0.0];
717 let result = fit_sandia_simple(&v, &i, None, None, None, 0.2, 0.1);
718 assert!(result.is_err());
719 }
720}