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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum DCModel {
23 PVWatts,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum ACModel {
31 PVWatts,
33 Sandia,
37 ADR,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum AOIModel {
47 Physical,
49 ASHRAE,
51 SAPM,
53 MartinRuiz,
55 NoLoss,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum SpectralModel {
63 NoLoss,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69#[allow(non_camel_case_types)]
70#[non_exhaustive]
71pub enum TemperatureModel {
72 SAPM,
74 PVSyst,
76 Faiman,
78 Fuentes,
80 NOCT_SAM,
82 PVWatts,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum TranspositionModel {
90 Isotropic,
92 HayDavies,
94 Perez,
96 Klucher,
98 Reindl,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104#[non_exhaustive]
105pub enum LossesModel {
106 PVWatts,
108 NoLoss,
110}
111
112#[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#[derive(Debug, Clone)]
134pub struct WeatherInput {
135 pub time: DateTime<Tz>,
137 pub ghi: Option<f64>,
139 pub dni: Option<f64>,
141 pub dhi: Option<f64>,
143 pub temp_air: f64,
145 pub wind_speed: f64,
147 pub albedo: Option<f64>,
149}
150
151#[derive(Debug, Clone)]
153pub struct POAInput {
154 pub time: DateTime<Tz>,
156 pub poa_direct: f64,
158 pub poa_diffuse: f64,
160 pub poa_global: f64,
162 pub temp_air: f64,
164 pub wind_speed: f64,
166 pub aoi: f64,
168}
169
170#[derive(Debug, Clone)]
172pub struct EffectiveIrradianceInput {
173 pub time: DateTime<Tz>,
175 pub effective_irradiance: f64,
177 pub poa_global: f64,
179 pub temp_air: f64,
181 pub wind_speed: f64,
183}
184
185#[derive(Debug, Clone, PartialEq)]
191pub struct ModelChainResult {
192 pub solar_zenith: f64,
194 pub solar_azimuth: f64,
196 pub airmass: f64,
198 pub aoi: f64,
200 pub poa_global: f64,
202 pub poa_direct: f64,
203 pub poa_diffuse: f64,
204 pub aoi_modifier: f64,
206 pub spectral_modifier: f64,
208 pub effective_irradiance: f64,
210 pub cell_temperature: f64,
212 pub dc_power: f64,
214 pub ac_power: f64,
216}
217
218#[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
227pub 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 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 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 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 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 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 pub fn run_model_from_weather(
397 &self,
398 weather: &WeatherInput,
399 ) -> Result<ModelChainResult, spa::SpaError> {
400 let (ghi, dni, dhi) = self.resolve_irradiance(weather)?;
402 let albedo = weather.albedo.unwrap_or(0.25);
403
404 let solpos = get_solarposition(&self.location, weather.time)?;
406
407 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 let aoi_val = aoi(self.surface_tilt, self.surface_azimuth, solpos.zenith, solpos.azimuth);
418
419 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 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 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 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 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 pub fn complete_irradiance(
535 &self,
536 weather: &WeatherInput,
537 ) -> Result<(f64, f64, f64), spa::SpaError> {
538 self.resolve_irradiance(weather)
539 }
540
541 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 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 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 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 let aoi_modifier = self.calc_aoi_modifier(aoi_val);
589
590 let spectral_modifier = self.calc_spectral_modifier();
592
593 let effective_irradiance = (poa.poa_direct * aoi_modifier + poa.poa_diffuse)
595 * spectral_modifier;
596
597 let temp_cell = self.calc_cell_temperature(poa.poa_global, temp_air, wind_speed);
599
600 let pdc = self.calc_dc_power(effective_irradiance, temp_cell);
602
603 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 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}