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_m1();
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.
263#[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    // Boltzmann constant in eV/K
276    const K_EV: f64 = 8.617333262e-5;
277    let t_ref = 25.0 + 273.15; // K
278
279    // Initial guesses (Duffie & Beckman, p753)
280    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    // Newton-Raphson iteration with damping
308    for _ in 0..500 {
309        let f = desoto_equations(&params, &specs);
310        let j = desoto_jacobian(&params, &specs);
311
312        let delta = match solve_5x5(&j, &f) {
313            Some(d) => d,
314            None => {
315                // Perturb parameters slightly and retry
316                for p in params.iter_mut() {
317                    *p *= 1.0 + 1e-6;
318                }
319                continue;
320            }
321        };
322
323        // Damped step: limit each parameter change to avoid overshooting
324        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        // Ensure I0 stays positive
343        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
374/// Evaluate the 5 De Soto equations. params = [IL, Io, Rs, Rsh, a].
375fn 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    // eq1: short circuit
379    let y0 = s.i_sc - il + io * ((s.i_sc * rs / a).exp() - 1.0) + s.i_sc * rs / rsh;
380
381    // eq2: open circuit at Tref
382    let y1 = -il + io * ((s.v_oc / a).exp() - 1.0) + s.v_oc / rsh;
383
384    // eq3: max power point
385    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    // eq4: dP/dV = 0 at MPP
389    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    // eq5: open circuit at T2
395    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
406/// Numerical Jacobian via finite differences for the De Soto system.
407fn 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
423// ---------------------------------------------------------------------------
424// Linear algebra helpers (no external dep needed)
425// ---------------------------------------------------------------------------
426
427/// Simple 1st-degree polynomial fit (y = slope*x + intercept) via least squares.
428fn 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
444/// Least-squares solve for 3 unknowns: A*x = b  where A is Mx3.
445fn lstsq_3(a: &[[f64; 3]], b: &[f64]) -> Result<[f64; 3], String> {
446    // Normal equations: A^T A x = A^T b
447    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 via Cramer or Gauss elimination
461    solve_3x3(&ata, &atb).ok_or_else(|| "Singular matrix in least-squares".to_string())
462}
463
464/// Solve 3x3 linear system via Gaussian elimination with partial pivoting.
465#[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        // partial pivot
477        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    // Back substitution
500    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/// Solve 5x5 linear system via Gaussian elimination with partial pivoting.
512#[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    // Compute a scale factor for relative pivot check
523    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// ---------------------------------------------------------------------------
565// Tests
566// ---------------------------------------------------------------------------
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    /// Generate a synthetic IV curve from known SDM parameters using the
573    /// single diode equation: I = IL - I0*(exp((V+I*Rs)/a) - 1) - (V+I*Rs)/Rsh
574    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        // Estimate Voc
583        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            // Newton-Raphson to solve for I
590            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        // Should remove NaN and negative voltage entries
614        assert_eq!(rv.len(), ri.len());
615        // Sorted by voltage
616        for w in rv.windows(2) {
617            assert!(w[0] <= w[1]);
618        }
619        // No negative values
620        for &v in &rv {
621            assert!(v >= 0.0);
622        }
623        for &i in &ri {
624            assert!(i >= 0.0);
625        }
626        // Duplicate voltage=1.0 should be merged (average of 2.0 and 1.5 = 1.75)
627        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        // Known parameters
641        let il = 9.0;
642        let i0 = 1e-10;
643        let rs = 0.3;
644        let rsh = 500.0;
645        let a = 1.6; // nNsVth
646
647        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, &current, 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        // Check recovered parameters are within reasonable tolerance
656        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        // Typical 60-cell silicon module specs (similar to CS5P-220M)
670        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; // A/K
675        let beta_voc = -0.125; // V/K
676        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        // Sanity checks on fitted parameters
695        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}