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