Skip to main content

pvlib/
modelchain.rs

1use chrono::DateTime;
2use chrono_tz::Tz;
3use crate::location::Location;
4use crate::pvsystem::PVSystem;
5use crate::solarposition::get_solarposition;
6use crate::irradiance::{
7    aoi, get_total_irradiance, get_extra_radiation, poa_direct, erbs,
8    DiffuseModel, PoaComponents,
9};
10use crate::atmosphere::{get_relative_airmass, get_absolute_airmass, alt2pres};
11use crate::iam;
12use crate::temperature;
13use crate::inverter;
14
15// ---------------------------------------------------------------------------
16// Model selection enums
17// ---------------------------------------------------------------------------
18
19/// DC power model selection.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum DCModel {
23    /// PVWatts simple linear model (nameplate * POA/1000 * temp correction)
24    PVWatts,
25}
26
27/// AC power model selection.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum ACModel {
31    /// PVWatts simple inverter model
32    PVWatts,
33    /// Sandia inverter model. Requires inverter parameters that are not yet
34    /// plumbed through `ModelChain`; selecting this variant currently panics.
35    /// See `ROADMAP.md`.
36    Sandia,
37    /// ADR inverter model. Requires inverter parameters that are not yet
38    /// plumbed through `ModelChain`; selecting this variant currently panics.
39    /// See `ROADMAP.md`.
40    ADR,
41}
42
43/// Angle-of-incidence (IAM) model selection.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum AOIModel {
47    /// Fresnel/Snell physical model
48    Physical,
49    /// ASHRAE model
50    ASHRAE,
51    /// SAPM polynomial model
52    SAPM,
53    /// Martin-Ruiz model
54    MartinRuiz,
55    /// No IAM losses
56    NoLoss,
57}
58
59/// Spectral modifier model selection.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum SpectralModel {
63    /// No spectral losses
64    NoLoss,
65}
66
67/// Cell temperature model selection.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69#[allow(non_camel_case_types)]
70#[non_exhaustive]
71pub enum TemperatureModel {
72    /// SAPM cell temperature model
73    SAPM,
74    /// PVsyst cell temperature model
75    PVSyst,
76    /// Faiman cell temperature model
77    Faiman,
78    /// Fuentes cell temperature model
79    Fuentes,
80    /// NOCT SAM cell temperature model
81    NOCT_SAM,
82    /// Simple PVWatts-style (T_air + POA*(NOCT-20)/800)
83    PVWatts,
84}
85
86/// Transposition model selection.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum TranspositionModel {
90    /// Isotropic sky model
91    Isotropic,
92    /// Hay-Davies model
93    HayDavies,
94    /// Perez 1990 model
95    Perez,
96    /// Klucher model
97    Klucher,
98    /// Reindl model
99    Reindl,
100}
101
102/// Losses model selection.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104#[non_exhaustive]
105pub enum LossesModel {
106    /// PVWatts default losses
107    PVWatts,
108    /// No additional losses
109    NoLoss,
110}
111
112// ---------------------------------------------------------------------------
113// Configuration
114// ---------------------------------------------------------------------------
115
116/// Configuration for all model selections in a ModelChain run.
117#[derive(Debug, Clone)]
118pub struct ModelChainConfig {
119    pub dc_model: DCModel,
120    pub ac_model: ACModel,
121    pub aoi_model: AOIModel,
122    pub spectral_model: SpectralModel,
123    pub temperature_model: TemperatureModel,
124    pub transposition_model: TranspositionModel,
125    pub losses_model: LossesModel,
126}
127
128// ---------------------------------------------------------------------------
129// Weather input
130// ---------------------------------------------------------------------------
131
132/// Weather data for a single timestep.
133#[derive(Debug, Clone)]
134pub struct WeatherInput {
135    /// Timestamp with timezone
136    pub time: DateTime<Tz>,
137    /// Global horizontal irradiance [W/m2] (optional if DNI+DHI provided)
138    pub ghi: Option<f64>,
139    /// Direct normal irradiance [W/m2]
140    pub dni: Option<f64>,
141    /// Diffuse horizontal irradiance [W/m2]
142    pub dhi: Option<f64>,
143    /// Ambient air temperature [C]
144    pub temp_air: f64,
145    /// Wind speed [m/s]
146    pub wind_speed: f64,
147    /// Surface albedo (optional, default 0.25)
148    pub albedo: Option<f64>,
149}
150
151/// POA (plane of array) input data for run_model_from_poa.
152#[derive(Debug, Clone)]
153pub struct POAInput {
154    /// Timestamp with timezone
155    pub time: DateTime<Tz>,
156    /// Direct POA irradiance [W/m2]
157    pub poa_direct: f64,
158    /// Diffuse POA irradiance [W/m2]
159    pub poa_diffuse: f64,
160    /// Global POA irradiance [W/m2]
161    pub poa_global: f64,
162    /// Ambient air temperature [C]
163    pub temp_air: f64,
164    /// Wind speed [m/s]
165    pub wind_speed: f64,
166    /// Angle of incidence [degrees]
167    pub aoi: f64,
168}
169
170/// Effective irradiance input data for run_model_from_effective_irradiance.
171#[derive(Debug, Clone)]
172pub struct EffectiveIrradianceInput {
173    /// Timestamp with timezone
174    pub time: DateTime<Tz>,
175    /// Effective irradiance reaching cells [W/m2]
176    pub effective_irradiance: f64,
177    /// Global POA irradiance [W/m2] (for cell temperature calculation)
178    pub poa_global: f64,
179    /// Ambient air temperature [C]
180    pub temp_air: f64,
181    /// Wind speed [m/s]
182    pub wind_speed: f64,
183}
184
185// ---------------------------------------------------------------------------
186// Results
187// ---------------------------------------------------------------------------
188
189/// Full simulation result from ModelChain.
190#[derive(Debug, Clone, PartialEq)]
191pub struct ModelChainResult {
192    /// Solar zenith angle [degrees]
193    pub solar_zenith: f64,
194    /// Solar azimuth angle [degrees]
195    pub solar_azimuth: f64,
196    /// Absolute airmass
197    pub airmass: f64,
198    /// Angle of incidence [degrees]
199    pub aoi: f64,
200    /// POA irradiance components
201    pub poa_global: f64,
202    pub poa_direct: f64,
203    pub poa_diffuse: f64,
204    /// AOI modifier (IAM)
205    pub aoi_modifier: f64,
206    /// Spectral modifier
207    pub spectral_modifier: f64,
208    /// Effective irradiance reaching cells [W/m2]
209    pub effective_irradiance: f64,
210    /// Cell temperature [C]
211    pub cell_temperature: f64,
212    /// DC power output [W]
213    pub dc_power: f64,
214    /// AC power output [W]
215    pub ac_power: f64,
216}
217
218/// Legacy simulation result for backward compatibility.
219#[derive(Debug, Clone, PartialEq)]
220pub struct SimulationResult {
221    pub poa_global: f64,
222    pub temp_cell: f64,
223    pub dc_power: f64,
224    pub ac_power: f64,
225}
226
227// ---------------------------------------------------------------------------
228// ModelChain
229// ---------------------------------------------------------------------------
230
231/// Orchestrates the full PV system performance simulation pipeline.
232pub struct ModelChain {
233    pub system: PVSystem,
234    pub location: Location,
235    pub surface_tilt: f64,
236    pub surface_azimuth: f64,
237    pub inverter_pac0: f64,
238    pub inverter_eta: f64,
239    pub config: ModelChainConfig,
240}
241
242impl ModelChain {
243    /// Create a new ModelChain with explicit parameters (legacy constructor).
244    pub fn new(
245        system: PVSystem,
246        location: Location,
247        surface_tilt: f64,
248        surface_azimuth: f64,
249        inverter_pac0: f64,
250        inverter_eta: f64,
251    ) -> Self {
252        Self {
253            system,
254            location,
255            surface_tilt,
256            surface_azimuth,
257            inverter_pac0,
258            inverter_eta,
259            config: ModelChainConfig {
260                dc_model: DCModel::PVWatts,
261                ac_model: ACModel::PVWatts,
262                aoi_model: AOIModel::ASHRAE,
263                spectral_model: SpectralModel::NoLoss,
264                temperature_model: TemperatureModel::PVWatts,
265                transposition_model: TranspositionModel::Isotropic,
266                losses_model: LossesModel::NoLoss,
267            },
268        }
269    }
270
271    /// Create a ModelChain with a custom configuration.
272    pub fn with_config(
273        system: PVSystem,
274        location: Location,
275        surface_tilt: f64,
276        surface_azimuth: f64,
277        inverter_pac0: f64,
278        inverter_eta: f64,
279        config: ModelChainConfig,
280    ) -> Self {
281        Self {
282            system,
283            location,
284            surface_tilt,
285            surface_azimuth,
286            inverter_pac0,
287            inverter_eta,
288            config,
289        }
290    }
291
292    /// Factory: PVWatts-style configuration.
293    ///
294    /// Uses PVWatts DC/AC, Physical AOI, PVWatts temperature, Perez transposition.
295    pub fn with_pvwatts(
296        system: PVSystem,
297        location: Location,
298        surface_tilt: f64,
299        surface_azimuth: f64,
300        inverter_pac0: f64,
301        inverter_eta: f64,
302    ) -> Self {
303        Self {
304            system,
305            location,
306            surface_tilt,
307            surface_azimuth,
308            inverter_pac0,
309            inverter_eta,
310            config: ModelChainConfig {
311                dc_model: DCModel::PVWatts,
312                ac_model: ACModel::PVWatts,
313                aoi_model: AOIModel::Physical,
314                spectral_model: SpectralModel::NoLoss,
315                temperature_model: TemperatureModel::PVWatts,
316                transposition_model: TranspositionModel::Perez,
317                losses_model: LossesModel::PVWatts,
318            },
319        }
320    }
321
322    /// Factory: SAPM-style configuration.
323    ///
324    /// Uses PVWatts DC, PVWatts AC, ASHRAE AOI, SAPM temperature, HayDavies transposition.
325    pub fn with_sapm(
326        system: PVSystem,
327        location: Location,
328        surface_tilt: f64,
329        surface_azimuth: f64,
330        inverter_pac0: f64,
331        inverter_eta: f64,
332    ) -> Self {
333        Self {
334            system,
335            location,
336            surface_tilt,
337            surface_azimuth,
338            inverter_pac0,
339            inverter_eta,
340            config: ModelChainConfig {
341                dc_model: DCModel::PVWatts,
342                ac_model: ACModel::PVWatts,
343                aoi_model: AOIModel::ASHRAE,
344                spectral_model: SpectralModel::NoLoss,
345                temperature_model: TemperatureModel::SAPM,
346                transposition_model: TranspositionModel::HayDavies,
347                losses_model: LossesModel::NoLoss,
348            },
349        }
350    }
351
352    // -----------------------------------------------------------------------
353    // Legacy run_model (backward compatible)
354    // -----------------------------------------------------------------------
355
356    /// Run the model for a single timestep with given weather data (legacy API).
357    pub fn run_model(
358        &self,
359        time: DateTime<Tz>,
360        _ghi: f64,
361        dni: f64,
362        dhi: f64,
363        temp_air: f64,
364        _wind_speed: f64,
365    ) -> Result<SimulationResult, spa::SpaError> {
366        let solpos = get_solarposition(&self.location, time)?;
367        let incidence = aoi(self.surface_tilt, self.surface_azimuth, solpos.zenith, solpos.azimuth);
368        let iam_mult = iam::ashrae(incidence, 0.05);
369        let poa_diffuse_val = crate::irradiance::isotropic(self.surface_tilt, dhi);
370        let poa_dir = poa_direct(incidence, dni);
371        let poa_global = poa_dir * iam_mult + poa_diffuse_val;
372        let temp_cell = temp_air + poa_global * (45.0 - 20.0) / 800.0;
373        let pdc = self.system.get_dc_power_total(poa_global, temp_cell);
374        let pdc0 = self.system.get_nameplate_dc_total();
375        let eta_inv_nom = self.inverter_eta;
376        let eta_inv_ref = 0.9637;
377        let pac = inverter::pvwatts_ac(pdc, pdc0, eta_inv_nom, eta_inv_ref);
378
379        Ok(SimulationResult {
380            poa_global,
381            temp_cell,
382            dc_power: pdc,
383            ac_power: pac,
384        })
385    }
386
387    // -----------------------------------------------------------------------
388    // Enhanced run methods
389    // -----------------------------------------------------------------------
390
391    /// Run the full simulation pipeline from weather data.
392    ///
393    /// Steps: solar position -> airmass -> AOI -> transposition -> AOI modifier
394    /// -> spectral modifier -> effective irradiance -> cell temperature
395    /// -> DC power -> AC power.
396    pub fn run_model_from_weather(
397        &self,
398        weather: &WeatherInput,
399    ) -> Result<ModelChainResult, spa::SpaError> {
400        // Complete irradiance if needed
401        let (ghi, dni, dhi) = self.resolve_irradiance(weather)?;
402        let albedo = weather.albedo.unwrap_or(0.25);
403
404        // 1. Solar position
405        let solpos = get_solarposition(&self.location, weather.time)?;
406
407        // 2. Airmass
408        let am_rel = get_relative_airmass(solpos.zenith);
409        let pressure = alt2pres(self.location.altitude);
410        let am_abs = if am_rel.is_nan() {
411            0.0
412        } else {
413            get_absolute_airmass(am_rel, pressure)
414        };
415
416        // 3. AOI
417        let aoi_val = aoi(self.surface_tilt, self.surface_azimuth, solpos.zenith, solpos.azimuth);
418
419        // 4. Day of year for extraterrestrial irradiance
420        let day_of_year = {
421            use chrono::Datelike;
422            weather.time.ordinal() as i32
423        };
424        let dni_extra = get_extra_radiation(day_of_year);
425
426        // 5. Transposition -> POA components
427        let diffuse_model = match self.config.transposition_model {
428            TranspositionModel::Isotropic => DiffuseModel::Isotropic,
429            TranspositionModel::HayDavies => DiffuseModel::HayDavies,
430            TranspositionModel::Perez => DiffuseModel::Perez,
431            TranspositionModel::Klucher => DiffuseModel::Klucher,
432            TranspositionModel::Reindl => DiffuseModel::Reindl,
433        };
434
435        let poa = get_total_irradiance(
436            self.surface_tilt,
437            self.surface_azimuth,
438            solpos.zenith,
439            solpos.azimuth,
440            dni,
441            ghi,
442            dhi,
443            albedo,
444            diffuse_model,
445            Some(dni_extra),
446            if am_rel.is_nan() { None } else { Some(am_rel) },
447        );
448
449        // Continue from POA
450        self.compute_from_poa(
451            solpos.zenith,
452            solpos.azimuth,
453            am_abs,
454            aoi_val,
455            &poa,
456            weather.temp_air,
457            weather.wind_speed,
458        )
459    }
460
461    /// Run the simulation starting from plane-of-array irradiance data.
462    ///
463    /// Skips transposition step but still computes solar position for airmass.
464    pub fn run_model_from_poa(
465        &self,
466        input: &POAInput,
467    ) -> Result<ModelChainResult, spa::SpaError> {
468        let solpos = get_solarposition(&self.location, input.time)?;
469        let am_rel = get_relative_airmass(solpos.zenith);
470        let pressure = alt2pres(self.location.altitude);
471        let am_abs = if am_rel.is_nan() { 0.0 } else { get_absolute_airmass(am_rel, pressure) };
472
473        let poa = PoaComponents {
474            poa_global: input.poa_global,
475            poa_direct: input.poa_direct,
476            poa_diffuse: input.poa_diffuse,
477            poa_sky_diffuse: input.poa_diffuse,
478            poa_ground_diffuse: 0.0,
479        };
480
481        self.compute_from_poa(
482            solpos.zenith,
483            solpos.azimuth,
484            am_abs,
485            input.aoi,
486            &poa,
487            input.temp_air,
488            input.wind_speed,
489        )
490    }
491
492    /// Run the simulation starting from effective irradiance.
493    ///
494    /// Skips solar position, transposition, AOI, and spectral steps.
495    pub fn run_model_from_effective_irradiance(
496        &self,
497        input: &EffectiveIrradianceInput,
498    ) -> Result<ModelChainResult, spa::SpaError> {
499        let solpos = get_solarposition(&self.location, input.time)?;
500        let am_rel = get_relative_airmass(solpos.zenith);
501        let pressure = alt2pres(self.location.altitude);
502        let am_abs = if am_rel.is_nan() { 0.0 } else { get_absolute_airmass(am_rel, pressure) };
503
504        let temp_cell = self.calc_cell_temperature(
505            input.poa_global,
506            input.temp_air,
507            input.wind_speed,
508        );
509        let pdc = self.calc_dc_power(input.effective_irradiance, temp_cell);
510        let pac = self.calc_ac_power(pdc);
511
512        Ok(ModelChainResult {
513            solar_zenith: solpos.zenith,
514            solar_azimuth: solpos.azimuth,
515            airmass: am_abs,
516            aoi: 0.0,
517            poa_global: input.poa_global,
518            poa_direct: 0.0,
519            poa_diffuse: 0.0,
520            aoi_modifier: 1.0,
521            spectral_modifier: 1.0,
522            effective_irradiance: input.effective_irradiance,
523            cell_temperature: temp_cell,
524            dc_power: pdc,
525            ac_power: pac,
526        })
527    }
528
529    /// Fill missing GHI, DNI, or DHI using the Erbs decomposition model.
530    ///
531    /// If all three are provided, returns them as-is. If GHI is provided but
532    /// DNI or DHI are missing, uses Erbs to decompose. If GHI is missing but
533    /// DNI and DHI are provided, computes GHI = DNI * cos(zenith) + DHI.
534    pub fn complete_irradiance(
535        &self,
536        weather: &WeatherInput,
537    ) -> Result<(f64, f64, f64), spa::SpaError> {
538        self.resolve_irradiance(weather)
539    }
540
541    // -----------------------------------------------------------------------
542    // Internal pipeline steps
543    // -----------------------------------------------------------------------
544
545    fn resolve_irradiance(
546        &self,
547        weather: &WeatherInput,
548    ) -> Result<(f64, f64, f64), spa::SpaError> {
549        match (weather.ghi, weather.dni, weather.dhi) {
550            (Some(ghi), Some(dni), Some(dhi)) => Ok((ghi, dni, dhi)),
551            (Some(ghi), _, _) => {
552                // Decompose GHI -> DNI + DHI using Erbs
553                let solpos = get_solarposition(&self.location, weather.time)?;
554                let day_of_year = {
555            use chrono::Datelike;
556            weather.time.ordinal() as i32
557        };
558                let dni_extra = get_extra_radiation(day_of_year);
559                let (dni, dhi) = erbs(ghi, solpos.zenith, day_of_year as u32, dni_extra);
560                Ok((ghi, dni, dhi))
561            }
562            (None, Some(dni), Some(dhi)) => {
563                // Compute GHI from DNI + DHI
564                let solpos = get_solarposition(&self.location, weather.time)?;
565                let cos_z = solpos.zenith.to_radians().cos().max(0.0);
566                let ghi = dni * cos_z + dhi;
567                Ok((ghi, dni, dhi))
568            }
569            _ => {
570                // Not enough data, return zeros
571                Ok((0.0, 0.0, 0.0))
572            }
573        }
574    }
575
576    #[allow(clippy::too_many_arguments)]
577    fn compute_from_poa(
578        &self,
579        solar_zenith: f64,
580        solar_azimuth: f64,
581        airmass: f64,
582        aoi_val: f64,
583        poa: &PoaComponents,
584        temp_air: f64,
585        wind_speed: f64,
586    ) -> Result<ModelChainResult, spa::SpaError> {
587        // AOI modifier (IAM)
588        let aoi_modifier = self.calc_aoi_modifier(aoi_val);
589
590        // Spectral modifier
591        let spectral_modifier = self.calc_spectral_modifier();
592
593        // Effective irradiance
594        let effective_irradiance = (poa.poa_direct * aoi_modifier + poa.poa_diffuse)
595            * spectral_modifier;
596
597        // Cell temperature
598        let temp_cell = self.calc_cell_temperature(poa.poa_global, temp_air, wind_speed);
599
600        // DC power
601        let pdc = self.calc_dc_power(effective_irradiance, temp_cell);
602
603        // AC power
604        let pac = self.calc_ac_power(pdc);
605
606        Ok(ModelChainResult {
607            solar_zenith,
608            solar_azimuth,
609            airmass,
610            aoi: aoi_val,
611            poa_global: poa.poa_global,
612            poa_direct: poa.poa_direct,
613            poa_diffuse: poa.poa_diffuse,
614            aoi_modifier,
615            spectral_modifier,
616            effective_irradiance,
617            cell_temperature: temp_cell,
618            dc_power: pdc,
619            ac_power: pac,
620        })
621    }
622
623    fn calc_aoi_modifier(&self, aoi_val: f64) -> f64 {
624        match self.config.aoi_model {
625            AOIModel::Physical => iam::physical(aoi_val, 1.526, 4.0, 0.002),
626            AOIModel::ASHRAE => iam::ashrae(aoi_val, 0.05),
627            AOIModel::MartinRuiz => iam::martin_ruiz(aoi_val, 0.16),
628            AOIModel::SAPM => {
629                iam::sapm(aoi_val, 1.0, -0.002438, 3.103e-4, -1.246e-5, 2.112e-7, -1.359e-9)
630            }
631            AOIModel::NoLoss => 1.0,
632        }
633    }
634
635    fn calc_spectral_modifier(&self) -> f64 {
636        match self.config.spectral_model {
637            SpectralModel::NoLoss => 1.0,
638        }
639    }
640
641    fn calc_cell_temperature(&self, poa_global: f64, temp_air: f64, wind_speed: f64) -> f64 {
642        match self.config.temperature_model {
643            TemperatureModel::SAPM => {
644                let (temp_cell, _) = temperature::sapm_cell_temperature(
645                    poa_global, temp_air, wind_speed, -3.56, -0.075, 3.0, 1000.0,
646                );
647                temp_cell
648            }
649            TemperatureModel::PVSyst => {
650                temperature::pvsyst_cell_temperature(
651                    poa_global, temp_air, wind_speed, 29.0, 0.0, 0.15, 0.9,
652                )
653            }
654            TemperatureModel::Faiman => {
655                temperature::faiman(poa_global, temp_air, wind_speed, 25.0, 6.84)
656            }
657            TemperatureModel::Fuentes => {
658                temperature::fuentes(poa_global, temp_air, wind_speed, 45.0)
659            }
660            TemperatureModel::NOCT_SAM => {
661                temperature::noct_sam_default(poa_global, temp_air, wind_speed, 45.0, 0.15)
662            }
663            TemperatureModel::PVWatts => {
664                temp_air + poa_global * (45.0 - 20.0) / 800.0
665            }
666        }
667    }
668
669    fn calc_dc_power(&self, effective_irradiance: f64, temp_cell: f64) -> f64 {
670        match self.config.dc_model {
671            DCModel::PVWatts => self.system.get_dc_power_total(effective_irradiance, temp_cell),
672        }
673    }
674
675    fn calc_ac_power(&self, pdc: f64) -> f64 {
676        let pdc0 = self.system.get_nameplate_dc_total();
677        let eta_inv_nom = self.inverter_eta;
678        let eta_inv_ref = 0.9637;
679        match self.config.ac_model {
680            ACModel::PVWatts => inverter::pvwatts_ac(pdc, pdc0, eta_inv_nom, eta_inv_ref),
681            ACModel::Sandia | ACModel::ADR => {
682                // The Sandia and ADR inverter models require a full inverter
683                // parameter set (see `inverter::sandia` / `inverter::adr`)
684                // that is not yet plumbed through `ModelChain`. Fail loudly
685                // rather than silently producing PVWatts output under a
686                // different name. Tracked in ROADMAP.md.
687                panic!(
688                    "ACModel::{:?} is selected on ModelChain but is not wired up in \
689                     this release — construct the inverter parameters and call \
690                     `inverter::sandia` or `inverter::adr` directly, or use \
691                     `ACModel::PVWatts`.",
692                    self.config.ac_model
693                );
694            }
695        }
696    }
697}