Skip to main content

pvlib/
ivtools.rs

1/// IV curve tools: utilities and parameter fitting for the single diode model.
2///
3/// Ported from pvlib-python `pvlib.ivtools`.
4
5/// Result of fitting the single diode model to an IV curve or module specs.
6#[derive(Debug, Clone)]
7pub struct SdmFitResult {
8    /// Light-generated (photo) current [A]
9    pub photocurrent: f64,
10    /// Diode saturation (dark) current [A]
11    pub saturation_current: f64,
12    /// Series resistance [ohm]
13    pub resistance_series: f64,
14    /// Shunt (parallel) resistance [ohm]
15    pub resistance_shunt: f64,
16    /// Product of diode ideality factor, cells in series, and thermal voltage [V]
17    pub n_ns_vth: f64,
18}
19
20// ---------------------------------------------------------------------------
21// rectify_iv_curve
22// ---------------------------------------------------------------------------
23
24/// Sort an IV curve by voltage, remove NaN/negative values, and merge duplicate voltages.
25///
26/// The returned vectors are sorted by voltage, contain only non-negative values,
27/// and have no duplicate voltage entries (duplicates are averaged).
28pub fn rectify_iv_curve(voltage: &[f64], current: &[f64]) -> (Vec<f64>, Vec<f64>) {
29    // Pair up, filter NaN and negatives
30    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    // Sort by voltage, then descending current
38    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    // Merge duplicate voltages by averaging current
49    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
74// ---------------------------------------------------------------------------
75// fit_sandia_simple
76// ---------------------------------------------------------------------------
77
78/// Fit the single diode equation to an IV curve using the Sandia simplified method.
79///
80/// The IV curve must be sorted by increasing voltage from 0 to `v_oc`, with
81/// current decreasing from `i_sc` to 0.
82///
83/// `vlim` defines the fraction of `v_oc` below which the exponential term is
84/// neglected (linear region). `ilim` defines the fraction of `i_sc` used to
85/// identify the exponential region.
86///
87/// # Errors
88/// Returns `Err` if parameter extraction fails (e.g. insufficient data,
89/// negative slopes not found, or saturation current cannot be determined).
90pub 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    // Step 1-3: linear fit of low-voltage region → beta0, beta1
121    let (beta0, beta1) = sandia_beta0_beta1(voltage, current, vlim, v_oc)?;
122
123    // Step 4-5: exponential fit → beta3, beta4
124    let (beta3, beta4) = sandia_beta3_beta4(voltage, current, beta0, beta1, ilim, i_sc)?;
125
126    // Step 6: calculate parameters
127    sandia_simple_params(beta0, beta1, beta3, beta4, v_mp, i_mp, v_oc)
128}
129
130/// Linear fit on the low-voltage region of the IV curve.
131fn 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    // Find first index where v >= threshold
139    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
150/// Exponential fit on the high-current-deficit region.
151fn 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    // y = beta0 - beta1*V - I; select points where y > ilim * i_sc
160    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    // Least-squares: yv = xv * [beta2, beta3, beta4]^T
177    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
190/// Calculate SDM parameters from regression coefficients.
191fn 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
237// ---------------------------------------------------------------------------
238// fit_desoto
239// ---------------------------------------------------------------------------
240
241/// Fit the De Soto single diode model from module datasheet specifications.
242///
243/// Solves a system of 5 nonlinear equations using a hybrid Newton method
244/// to determine the five SDM parameters at reference conditions.
245///
246/// # Arguments
247/// * `v_mp` - Voltage at maximum power point [V]
248/// * `i_mp` - Current at maximum power point [A]
249/// * `v_oc` - Open-circuit voltage [V]
250/// * `i_sc` - Short-circuit current [A]
251/// * `alpha_sc` - Temperature coefficient of Isc [A/K]
252/// * `beta_voc` - Temperature coefficient of Voc [V/K]
253/// * `cells_in_series` - Number of cells in series
254/// * `eg_ref` - Bandgap energy at reference [eV], default 1.121 for silicon
255/// * `d_eg_dt` - Temperature dependence of bandgap [1/K], default -0.0002677
256///
257/// # Returns
258/// `SdmFitResult` with parameters at reference conditions, where `n_ns_vth`
259/// corresponds to `a_ref` (modified ideality factor).
260///
261/// # Errors
262/// Returns `Err` if the Newton solver does not converge.
263pub 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    // Boltzmann constant in eV/K
275    const K_EV: f64 = 8.617333262e-5;
276    let t_ref = 25.0 + 273.15; // K
277
278    // Initial guesses (Duffie & Beckman, p753)
279    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    // Newton-Raphson iteration with damping
307    for _ in 0..500 {
308        let f = desoto_equations(&params, &specs);
309        let j = desoto_jacobian(&params, &specs);
310
311        let delta = match solve_5x5(&j, &f) {
312            Some(d) => d,
313            None => {
314                // Perturb parameters slightly and retry
315                for p in params.iter_mut() {
316                    *p *= 1.0 + 1e-6;
317                }
318                continue;
319            }
320        };
321
322        // Damped step: limit each parameter change to avoid overshooting
323        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        // Ensure I0 stays positive
342        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
373/// Evaluate the 5 De Soto equations. params = [IL, Io, Rs, Rsh, a].
374fn 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    // eq1: short circuit
378    let y0 = s.i_sc - il + io * ((s.i_sc * rs / a).exp() - 1.0) + s.i_sc * rs / rsh;
379
380    // eq2: open circuit at Tref
381    let y1 = -il + io * ((s.v_oc / a).exp() - 1.0) + s.v_oc / rsh;
382
383    // eq3: max power point
384    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    // eq4: dP/dV = 0 at MPP
388    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    // eq5: open circuit at T2
394    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
405/// Numerical Jacobian via finite differences for the De Soto system.
406fn 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
422// ---------------------------------------------------------------------------
423// Linear algebra helpers (no external dep needed)
424// ---------------------------------------------------------------------------
425
426/// Simple 1st-degree polynomial fit (y = slope*x + intercept) via least squares.
427fn 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
443/// Least-squares solve for 3 unknowns: A*x = b  where A is Mx3.
444fn lstsq_3(a: &[[f64; 3]], b: &[f64]) -> Result<[f64; 3], String> {
445    // Normal equations: A^T A x = A^T b
446    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 via Cramer or Gauss elimination
460    solve_3x3(&ata, &atb).ok_or_else(|| "Singular matrix in least-squares".to_string())
461}
462
463/// Solve 3x3 linear system via Gaussian elimination with partial pivoting.
464fn 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        // partial pivot
475        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    // Back substitution
498    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
509/// Solve 5x5 linear system via Gaussian elimination with partial pivoting.
510fn 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    // Compute a scale factor for relative pivot check
520    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// ---------------------------------------------------------------------------
562// Tests
563// ---------------------------------------------------------------------------
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    /// Generate a synthetic IV curve from known SDM parameters using the
570    /// single diode equation: I = IL - I0*(exp((V+I*Rs)/a) - 1) - (V+I*Rs)/Rsh
571    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        // Estimate Voc
580        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            // Newton-Raphson to solve for I
587            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        // Should remove NaN and negative voltage entries
611        assert_eq!(rv.len(), ri.len());
612        // Sorted by voltage
613        for w in rv.windows(2) {
614            assert!(w[0] <= w[1]);
615        }
616        // No negative values
617        for &v in &rv {
618            assert!(v >= 0.0);
619        }
620        for &i in &ri {
621            assert!(i >= 0.0);
622        }
623        // Duplicate voltage=1.0 should be merged (average of 2.0 and 1.5 = 1.75)
624        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        // Known parameters
638        let il = 9.0;
639        let i0 = 1e-10;
640        let rs = 0.3;
641        let rsh = 500.0;
642        let a = 1.6; // nNsVth
643
644        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, &current, 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        // Check recovered parameters are within reasonable tolerance
653        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        // Typical 60-cell silicon module specs (similar to CS5P-220M)
667        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; // A/K
672        let beta_voc = -0.125; // V/K
673        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        // Sanity checks on fitted parameters
692        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}