Skip to main content

pvlib/
batch.rs

1use rayon::prelude::*;
2use crate::{solarposition, atmosphere, clearsky, irradiance, temperature, iam, inverter};
3
4// ---------------------------------------------------------------------------
5// Solar Position Batch
6// ---------------------------------------------------------------------------
7
8/// Batch solar position calculation for multiple timestamps.
9/// Returns (zenith_vec, azimuth_vec, elevation_vec).
10pub fn solar_position_batch(
11    location: &crate::location::Location,
12    times: &[chrono::DateTime<chrono_tz::Tz>],
13) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>), spa::SpaError> {
14    let results: Result<Vec<_>, _> = times.par_iter()
15        .map(|t| solarposition::get_solarposition(location, *t))
16        .collect();
17    let results = results?;
18    let zenith = results.iter().map(|r| r.zenith).collect();
19    let azimuth = results.iter().map(|r| r.azimuth).collect();
20    let elevation = results.iter().map(|r| r.elevation).collect();
21    Ok((zenith, azimuth, elevation))
22}
23
24// ---------------------------------------------------------------------------
25// Atmosphere Batch
26// ---------------------------------------------------------------------------
27
28/// Batch relative airmass for an array of zenith angles.
29pub fn airmass_relative_batch(zenith: &[f64]) -> Vec<f64> {
30    zenith.par_iter()
31        .map(|z| atmosphere::get_relative_airmass(*z))
32        .collect()
33}
34
35/// Batch absolute airmass.
36pub fn airmass_absolute_batch(airmass_relative: &[f64], pressure: f64) -> Vec<f64> {
37    airmass_relative.par_iter()
38        .map(|am| atmosphere::get_absolute_airmass(*am, pressure))
39        .collect()
40}
41
42// ---------------------------------------------------------------------------
43// Clear Sky Batch
44// ---------------------------------------------------------------------------
45
46/// Batch Ineichen clear sky model.
47/// Returns (ghi_vec, dni_vec, dhi_vec).
48pub fn ineichen_batch(
49    zenith: &[f64],
50    airmass_absolute: &[f64],
51    linke_turbidity: f64,
52    altitude: f64,
53) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
54    assert_eq!(zenith.len(), airmass_absolute.len(), "zenith and airmass_absolute must have the same length");
55    let results: Vec<_> = zenith.par_iter()
56        .zip(airmass_absolute.par_iter())
57        .map(|(z, am)| clearsky::ineichen(*z, *am, linke_turbidity, altitude, 1364.0))
58        .collect();
59    let ghi = results.iter().map(|r| r.ghi).collect();
60    let dni = results.iter().map(|r| r.dni).collect();
61    let dhi = results.iter().map(|r| r.dhi).collect();
62    (ghi, dni, dhi)
63}
64
65/// Batch Bird clear sky model.
66pub fn bird_batch(
67    zenith: &[f64],
68    airmass_relative: &[f64],
69    aod380: f64,
70    aod500: f64,
71    precipitable_water: f64,
72) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
73    assert_eq!(zenith.len(), airmass_relative.len(), "zenith and airmass_relative must have the same length");
74    let results: Vec<_> = zenith.par_iter()
75        .zip(airmass_relative.par_iter())
76        .map(|(z, am)| clearsky::bird_default(*z, *am, aod380, aod500, precipitable_water))
77        .collect();
78    let ghi = results.iter().map(|r| r.ghi).collect();
79    let dni = results.iter().map(|r| r.dni).collect();
80    let dhi = results.iter().map(|r| r.dhi).collect();
81    (ghi, dni, dhi)
82}
83
84// ---------------------------------------------------------------------------
85// Irradiance Batch
86// ---------------------------------------------------------------------------
87
88/// Batch AOI calculation.
89pub fn aoi_batch(
90    surface_tilt: f64,
91    surface_azimuth: f64,
92    solar_zenith: &[f64],
93    solar_azimuth: &[f64],
94) -> Vec<f64> {
95    assert_eq!(solar_zenith.len(), solar_azimuth.len(), "solar_zenith and solar_azimuth must have the same length");
96    solar_zenith.par_iter()
97        .zip(solar_azimuth.par_iter())
98        .map(|(z, a)| irradiance::aoi(surface_tilt, surface_azimuth, *z, *a))
99        .collect()
100}
101
102/// Batch extraterrestrial radiation.
103pub fn extra_radiation_batch(day_of_year: &[i32]) -> Vec<f64> {
104    day_of_year.par_iter()
105        .map(|d| irradiance::get_extra_radiation(*d))
106        .collect()
107}
108
109/// Batch Erbs decomposition. Returns (dni_vec, dhi_vec).
110pub fn erbs_batch(
111    ghi: &[f64],
112    zenith: &[f64],
113    day_of_year: &[u32],
114    dni_extra: &[f64],
115) -> (Vec<f64>, Vec<f64>) {
116    let n = ghi.len();
117    assert_eq!(zenith.len(), n, "zenith len mismatch");
118    assert_eq!(day_of_year.len(), n, "day_of_year len mismatch");
119    assert_eq!(dni_extra.len(), n, "dni_extra len mismatch");
120    let results: Vec<_> = ghi.par_iter()
121        .zip(zenith.par_iter())
122        .zip(day_of_year.par_iter())
123        .zip(dni_extra.par_iter())
124        .map(|(((g, z), d), e)| irradiance::erbs(*g, *z, *d, *e))
125        .collect();
126    let dni = results.iter().map(|r| r.0).collect();
127    let dhi = results.iter().map(|r| r.1).collect();
128    (dni, dhi)
129}
130
131/// Batch DISC decomposition. Returns (dni_vec, kt_vec, airmass_vec).
132pub fn disc_batch(
133    ghi: &[f64],
134    solar_zenith: &[f64],
135    day_of_year: &[i32],
136    pressure: Option<f64>,
137) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
138    let n = ghi.len();
139    assert_eq!(solar_zenith.len(), n, "solar_zenith len mismatch");
140    assert_eq!(day_of_year.len(), n, "day_of_year len mismatch");
141    let results: Vec<_> = ghi.par_iter()
142        .zip(solar_zenith.par_iter())
143        .zip(day_of_year.par_iter())
144        .map(|((g, z), d)| irradiance::disc(*g, *z, *d, pressure))
145        .collect();
146    let dni = results.iter().map(|r| r.dni).collect();
147    let kt = results.iter().map(|r| r.kt).collect();
148    let am = results.iter().map(|r| r.airmass).collect();
149    (dni, kt, am)
150}
151
152/// Batch Perez transposition model.
153#[allow(clippy::too_many_arguments)]
154pub fn perez_batch(
155    surface_tilt: f64,
156    surface_azimuth: f64,
157    dhi: &[f64],
158    dni: &[f64],
159    dni_extra: &[f64],
160    solar_zenith: &[f64],
161    solar_azimuth: &[f64],
162    airmass: &[f64],
163    aoi_vals: &[f64],
164) -> Vec<f64> {
165    let n = dhi.len();
166    assert_eq!(dni.len(), n, "dni len mismatch");
167    assert_eq!(dni_extra.len(), n, "dni_extra len mismatch");
168    assert_eq!(solar_zenith.len(), n, "solar_zenith len mismatch");
169    assert_eq!(solar_azimuth.len(), n, "solar_azimuth len mismatch");
170    assert_eq!(airmass.len(), n, "airmass len mismatch");
171    assert_eq!(aoi_vals.len(), n, "aoi_vals len mismatch");
172    (0..n).into_par_iter()
173        .map(|i| irradiance::perez(
174            surface_tilt, surface_azimuth,
175            dhi[i], dni[i], dni_extra[i],
176            solar_zenith[i], solar_azimuth[i],
177            airmass[i], aoi_vals[i],
178        ))
179        .collect()
180}
181
182/// Batch get_total_irradiance. Returns PoaComponents for each timestep.
183#[allow(clippy::too_many_arguments)]
184pub fn total_irradiance_batch(
185    surface_tilt: f64,
186    surface_azimuth: f64,
187    solar_zenith: &[f64],
188    solar_azimuth: &[f64],
189    dni: &[f64],
190    ghi: &[f64],
191    dhi: &[f64],
192    albedo: f64,
193    model: irradiance::DiffuseModel,
194    dni_extra: &[f64],
195    airmass: &[f64],
196) -> Vec<irradiance::PoaComponents> {
197    let n = solar_zenith.len();
198    assert_eq!(solar_azimuth.len(), n, "solar_azimuth len mismatch");
199    assert_eq!(dni.len(), n, "dni len mismatch");
200    assert_eq!(ghi.len(), n, "ghi len mismatch");
201    assert_eq!(dhi.len(), n, "dhi len mismatch");
202    assert_eq!(dni_extra.len(), n, "dni_extra len mismatch");
203    assert_eq!(airmass.len(), n, "airmass len mismatch");
204    (0..n).into_par_iter()
205        .map(|i| irradiance::get_total_irradiance(
206            surface_tilt, surface_azimuth,
207            solar_zenith[i], solar_azimuth[i],
208            dni[i], ghi[i], dhi[i],
209            albedo, model,
210            Some(dni_extra[i]),
211            Some(airmass[i]),
212        ))
213        .collect()
214}
215
216// ---------------------------------------------------------------------------
217// Temperature Batch
218// ---------------------------------------------------------------------------
219
220/// Batch SAPM cell temperature. Returns (cell_temp_vec, module_temp_vec).
221#[allow(clippy::too_many_arguments)]
222pub fn sapm_cell_temperature_batch(
223    poa_global: &[f64],
224    temp_air: &[f64],
225    wind_speed: &[f64],
226    a: f64, b: f64, delta_t: f64, irrad_ref: f64,
227) -> (Vec<f64>, Vec<f64>) {
228    let n = poa_global.len();
229    assert_eq!(temp_air.len(), n, "temp_air len mismatch");
230    assert_eq!(wind_speed.len(), n, "wind_speed len mismatch");
231    let results: Vec<_> = (0..n).into_par_iter()
232        .map(|i| temperature::sapm_cell_temperature(
233            poa_global[i], temp_air[i], wind_speed[i], a, b, delta_t, irrad_ref,
234        ))
235        .collect();
236    let cell = results.iter().map(|r| r.0).collect();
237    let module = results.iter().map(|r| r.1).collect();
238    (cell, module)
239}
240
241/// Batch Faiman cell temperature.
242pub fn faiman_batch(
243    poa_global: &[f64],
244    temp_air: &[f64],
245    wind_speed: &[f64],
246    u0: f64,
247    u1: f64,
248) -> Vec<f64> {
249    let n = poa_global.len();
250    assert_eq!(temp_air.len(), n, "temp_air len mismatch");
251    assert_eq!(wind_speed.len(), n, "wind_speed len mismatch");
252    (0..n).into_par_iter()
253        .map(|i| temperature::faiman(poa_global[i], temp_air[i], wind_speed[i], u0, u1))
254        .collect()
255}
256
257// ---------------------------------------------------------------------------
258// IAM Batch
259// ---------------------------------------------------------------------------
260
261/// Batch Physical IAM.
262pub fn iam_physical_batch(aoi: &[f64], n: f64, k: f64, l: f64) -> Vec<f64> {
263    aoi.par_iter()
264        .map(|a| iam::physical(*a, n, k, l))
265        .collect()
266}
267
268/// Batch ASHRAE IAM.
269pub fn iam_ashrae_batch(aoi: &[f64], b0: f64) -> Vec<f64> {
270    aoi.par_iter()
271        .map(|a| iam::ashrae(*a, b0))
272        .collect()
273}
274
275// ---------------------------------------------------------------------------
276// Inverter Batch
277// ---------------------------------------------------------------------------
278
279/// Batch PVWatts AC power.
280pub fn pvwatts_ac_batch(
281    pdc: &[f64],
282    pdc0: f64,
283    eta_inv_nom: f64,
284    eta_inv_ref: f64,
285) -> Vec<f64> {
286    pdc.par_iter()
287        .map(|p| inverter::pvwatts_ac(*p, pdc0, eta_inv_nom, eta_inv_ref))
288        .collect()
289}
290
291// ---------------------------------------------------------------------------
292// Full Pipeline Batch
293// ---------------------------------------------------------------------------
294
295/// Input data for batch simulation -- one value per timestep.
296#[derive(Debug, Clone)]
297pub struct WeatherSeries {
298    pub times: Vec<chrono::DateTime<chrono_tz::Tz>>,
299    pub ghi: Vec<f64>,
300    pub dni: Vec<f64>,
301    pub dhi: Vec<f64>,
302    pub temp_air: Vec<f64>,
303    pub wind_speed: Vec<f64>,
304    pub albedo: Option<Vec<f64>>,
305}
306
307/// Output from batch simulation -- one value per timestep.
308#[derive(Debug, Clone)]
309pub struct SimulationSeries {
310    pub solar_zenith: Vec<f64>,
311    pub solar_azimuth: Vec<f64>,
312    pub airmass: Vec<f64>,
313    pub aoi: Vec<f64>,
314    pub poa_global: Vec<f64>,
315    pub poa_direct: Vec<f64>,
316    pub poa_diffuse: Vec<f64>,
317    pub cell_temperature: Vec<f64>,
318    pub effective_irradiance: Vec<f64>,
319    pub dc_power: Vec<f64>,
320    pub ac_power: Vec<f64>,
321}
322
323impl SimulationSeries {
324    /// Total energy produced in Wh (assuming 1-hour timesteps).
325    pub fn total_energy_wh(&self) -> f64 {
326        self.ac_power.iter().filter(|p| **p > 0.0).sum()
327    }
328
329    /// Peak AC power in W.
330    pub fn peak_power(&self) -> f64 {
331        self.ac_power.iter().cloned().fold(0.0_f64, f64::max)
332    }
333
334    /// Capacity factor (ratio of actual energy to theoretical maximum).
335    pub fn capacity_factor(&self, system_capacity_w: f64) -> f64 {
336        let hours = self.ac_power.len() as f64;
337        if hours == 0.0 || system_capacity_w == 0.0 { return 0.0; }
338        self.total_energy_wh() / (system_capacity_w * hours)
339    }
340}
341
342/// Batch ModelChain -- runs the full PV simulation pipeline on a time series
343/// using rayon for parallel processing.
344///
345/// This is the main entry point for production batch simulations.
346/// A typical TMY year (8760 hourly timesteps) completes in milliseconds.
347pub struct BatchModelChain {
348    pub location: crate::location::Location,
349    pub surface_tilt: f64,
350    pub surface_azimuth: f64,
351    pub system_capacity_dc: f64,
352    /// Temperature coefficient, e.g. -0.004
353    pub gamma_pdc: f64,
354    pub inverter_capacity: f64,
355    pub inverter_efficiency: f64,
356    pub albedo: f64,
357    pub transposition_model: irradiance::DiffuseModel,
358}
359
360impl BatchModelChain {
361    /// Create a new BatchModelChain with PVWatts-style defaults.
362    pub fn pvwatts(
363        location: crate::location::Location,
364        surface_tilt: f64,
365        surface_azimuth: f64,
366        system_capacity_dc: f64,
367    ) -> Self {
368        Self {
369            location,
370            surface_tilt,
371            surface_azimuth,
372            system_capacity_dc,
373            gamma_pdc: -0.004,
374            inverter_capacity: system_capacity_dc,
375            inverter_efficiency: 0.96,
376            albedo: 0.2,
377            transposition_model: irradiance::DiffuseModel::Perez,
378        }
379    }
380
381    /// Builder: set temperature coefficient.
382    pub fn with_gamma_pdc(mut self, gamma_pdc: f64) -> Self {
383        self.gamma_pdc = gamma_pdc;
384        self
385    }
386
387    /// Builder: set inverter parameters.
388    pub fn with_inverter(mut self, capacity: f64, efficiency: f64) -> Self {
389        self.inverter_capacity = capacity;
390        self.inverter_efficiency = efficiency;
391        self
392    }
393
394    /// Builder: set ground albedo.
395    pub fn with_albedo(mut self, albedo: f64) -> Self {
396        self.albedo = albedo;
397        self
398    }
399
400    /// Builder: set transposition model.
401    pub fn with_transposition(mut self, model: irradiance::DiffuseModel) -> Self {
402        self.transposition_model = model;
403        self
404    }
405
406    /// Run the full simulation on a weather time series.
407    ///
408    /// Uses rayon for automatic parallelization across CPU cores.
409    pub fn run(&self, weather: &WeatherSeries) -> Result<SimulationSeries, spa::SpaError> {
410        let n = weather.times.len();
411        assert_eq!(weather.ghi.len(), n);
412        assert_eq!(weather.dni.len(), n, "dni len mismatch");
413        assert_eq!(weather.dhi.len(), n, "dhi len mismatch");
414        assert_eq!(weather.temp_air.len(), n, "temp_air len mismatch");
415        assert_eq!(weather.wind_speed.len(), n, "wind_speed len mismatch");
416        if let Some(albedos) = &weather.albedo {
417            assert_eq!(albedos.len(), n, "albedo len mismatch");
418        }
419
420        let pressure = atmosphere::alt2pres(self.location.altitude);
421        let albedo_default = self.albedo;
422
423        // Run entire pipeline in parallel for each timestep
424        let results: Result<Vec<_>, _> = (0..n).into_par_iter().map(|i| {
425            // 1. Solar position
426            let solpos = solarposition::get_solarposition(&self.location, weather.times[i])?;
427
428            // 2. Airmass
429            let am_rel = atmosphere::get_relative_airmass(solpos.zenith);
430            let am_abs = if am_rel.is_nan() || am_rel <= 0.0 {
431                0.0
432            } else {
433                atmosphere::get_absolute_airmass(am_rel, pressure)
434            };
435
436            // 3. AOI
437            let aoi_val = irradiance::aoi(
438                self.surface_tilt, self.surface_azimuth,
439                solpos.zenith, solpos.azimuth,
440            );
441
442            // 4. Extraterrestrial irradiance
443            let doy = weather.times[i].format("%j").to_string().parse::<i32>().unwrap_or(1);
444            let dni_extra = irradiance::get_extra_radiation(doy);
445
446            // 5. Transposition
447            let poa = irradiance::get_total_irradiance(
448                self.surface_tilt, self.surface_azimuth,
449                solpos.zenith, solpos.azimuth,
450                weather.dni[i], weather.ghi[i], weather.dhi[i],
451                weather.albedo.as_ref().map_or(albedo_default, |a| a[i]),
452                self.transposition_model,
453                Some(dni_extra),
454                if am_rel.is_nan() { None } else { Some(am_rel) },
455            );
456
457            // 6. IAM
458            // Note: Uses Physical IAM model (Fresnel/Snell's law) matching the PVWatts configuration.
459            let iam_val = iam::physical(aoi_val, 1.526, 4.0, 0.002);
460
461            // 7. Effective irradiance (spectral modifier = 1.0 for NoLoss)
462            let spectral_modifier = 1.0;
463            let eff_irrad = ((poa.poa_direct * iam_val + poa.poa_diffuse) * spectral_modifier).max(0.0);
464
465            // 8. Cell temperature
466            // Note: Uses PVWatts temperature model (T_cell = T_air + POA*(NOCT-20)/800).
467            let t_cell = weather.temp_air[i] + poa.poa_global * (45.0 - 20.0) / 800.0;
468
469            // 9. DC power
470            let pdc = self.system_capacity_dc * (eff_irrad / 1000.0)
471                * (1.0 + self.gamma_pdc * (t_cell - 25.0));
472            let pdc = pdc.max(0.0);
473
474            // 10. AC power
475            let pac = inverter::pvwatts_ac(
476                pdc, self.system_capacity_dc,
477                self.inverter_efficiency, 0.9637,
478            );
479
480            Ok((solpos.zenith, solpos.azimuth, am_abs, aoi_val,
481                poa.poa_global, poa.poa_direct, poa.poa_diffuse,
482                t_cell, eff_irrad, pdc, pac))
483        }).collect();
484
485        let results = results?;
486
487        Ok(SimulationSeries {
488            solar_zenith: results.iter().map(|r| r.0).collect(),
489            solar_azimuth: results.iter().map(|r| r.1).collect(),
490            airmass: results.iter().map(|r| r.2).collect(),
491            aoi: results.iter().map(|r| r.3).collect(),
492            poa_global: results.iter().map(|r| r.4).collect(),
493            poa_direct: results.iter().map(|r| r.5).collect(),
494            poa_diffuse: results.iter().map(|r| r.6).collect(),
495            cell_temperature: results.iter().map(|r| r.7).collect(),
496            effective_irradiance: results.iter().map(|r| r.8).collect(),
497            dc_power: results.iter().map(|r| r.9).collect(),
498            ac_power: results.iter().map(|r| r.10).collect(),
499        })
500    }
501}