Skip to main content

pvlib/
iotools.rs

1use std::collections::HashMap;
2use std::error::Error;
3use std::fs::File;
4use std::io::{BufRead, BufReader};
5use serde_json::Value;
6
7/// Retrieve a database from the SAM (System Advisor Model) library.
8/// Modeled after `pvlib.pvsystem.retrieve_sam`.
9/// 
10/// Supported databases:
11///  - "CEC Inverters"
12///  - "CEC Modules"
13/// 
14/// Note: These files are downloaded from the NREL SAM GitHub repository.
15/// 
16/// # Arguments
17/// * `name` - The name of the database to retrieve.
18/// 
19/// # Returns
20/// A Vector of HashMaps, where each map corresponds to a row (usually an inverter or module)
21/// keyed by the column headers.
22pub fn retrieve_sam(name: &str) -> Result<Vec<HashMap<String, String>>, Box<dyn Error>> {
23    let url = match name {
24        "CEC Inverters" | "cecinverter" => "https://raw.githubusercontent.com/NREL/SAM/patch/deploy/libraries/CEC%20Inverters.csv",
25        "CEC Modules" | "cecmod" => "https://raw.githubusercontent.com/NREL/SAM/patch/deploy/libraries/CEC%20Modules.csv",
26        _ => return Err(format!("Unknown SAM DB string. Please use 'CEC Inverters' or 'CEC Modules'. You provided: {}", name).into()),
27    };
28
29    let response = reqwest::blocking::get(url)?.text()?;
30    
31    let mut reader = csv::ReaderBuilder::new()
32        .flexible(true)
33        .from_reader(response.as_bytes());
34        
35    let headers = reader.headers()?.clone();
36    
37    let mut records = Vec::new();
38    for result in reader.records() {
39        let record = result?;
40        let mut map = HashMap::new();
41        for (i, field) in record.iter().enumerate() {
42            if let Some(header) = headers.get(i) {
43                map.insert(header.to_string(), field.to_string());
44            }
45        }
46        // SAM CSVs can have an initial row with units which we might want to skip.
47        // If the 'Name' or similar field is blank or "Units", we can skip it.
48        // For simplicity, we just return all rows. The user can filter.
49        records.push(map);
50    }
51    
52    Ok(records)
53}
54
55// ---------------------------------------------------------------------------
56// Weather data types
57// ---------------------------------------------------------------------------
58
59/// A single hourly weather observation.
60#[derive(Debug, Clone)]
61pub struct WeatherRecord {
62    /// Timestamp string as returned by PVGIS (e.g. "20050101:0010").
63    pub time: String,
64    /// Global horizontal irradiance (W/m²).
65    pub ghi: f64,
66    /// Direct normal irradiance (W/m²).
67    pub dni: f64,
68    /// Diffuse horizontal irradiance (W/m²).
69    pub dhi: f64,
70    /// Air temperature at 2 m (°C).
71    pub temp_air: f64,
72    /// Wind speed at 10 m (m/s).
73    pub wind_speed: f64,
74    /// Surface pressure (Pa or mbar depending on source).
75    pub pressure: f64,
76    /// Relative humidity (%).
77    pub relative_humidity: f64,
78    /// Infrared radiation downwards (W/m²), if available.
79    pub infrared: Option<f64>,
80    /// Wind direction at 10 m (°), if available.
81    pub wind_direction: Option<f64>,
82    /// Dew-point temperature (°C), if available.
83    pub temp_dew: Option<f64>,
84    /// Albedo (unitless), if available.
85    pub albedo: Option<f64>,
86    /// Precipitable water (cm), if available.
87    pub precipitable_water: Option<f64>,
88    /// Year from the data file.
89    pub year: Option<i32>,
90    /// Month from the data file.
91    pub month: Option<u32>,
92    /// Day from the data file.
93    pub day: Option<u32>,
94    /// Hour from the data file (0-23).
95    pub hour: Option<u32>,
96}
97
98/// Metadata about the weather data source and location.
99#[derive(Debug, Clone)]
100pub struct WeatherMetadata {
101    pub latitude: f64,
102    pub longitude: f64,
103    pub elevation: Option<f64>,
104    /// Timezone offset from UTC (hours).
105    pub tz_offset: Option<f64>,
106    /// Site / station name.
107    pub name: Option<String>,
108    /// City name.
109    pub city: Option<String>,
110    /// State or province.
111    pub state: Option<String>,
112    /// Data source identifier.
113    pub source: Option<String>,
114    /// Months selected for TMY, if applicable.
115    pub months_selected: Option<Vec<MonthYear>>,
116    /// Additional key-value metadata from the API response.
117    pub extra: HashMap<String, String>,
118}
119
120/// Month-year pair indicating which year was selected for a given month in TMY.
121#[derive(Debug, Clone)]
122pub struct MonthYear {
123    pub month: i32,
124    pub year: i32,
125}
126
127/// Container for weather time-series data plus metadata.
128#[derive(Debug, Clone)]
129pub struct WeatherData {
130    pub records: Vec<WeatherRecord>,
131    pub metadata: WeatherMetadata,
132}
133
134// ---------------------------------------------------------------------------
135// PVGIS constants
136// ---------------------------------------------------------------------------
137
138const PVGIS_BASE_URL: &str = "https://re.jrc.ec.europa.eu/api/v5_3/";
139
140// ---------------------------------------------------------------------------
141// PVGIS API functions
142// ---------------------------------------------------------------------------
143
144/// Retrieve Typical Meteorological Year (TMY) data from PVGIS.
145///
146/// # Arguments
147/// * `latitude` – Latitude in decimal degrees (north positive).
148/// * `longitude` – Longitude in decimal degrees (east positive).
149/// * `outputformat` – Response format: `"json"`, `"csv"`, or `"epw"`.
150/// * `startyear` – Optional first year for TMY calculation.
151/// * `endyear` – Optional last year for TMY calculation.
152pub fn get_pvgis_tmy(
153    latitude: f64,
154    longitude: f64,
155    outputformat: &str,
156    startyear: Option<i32>,
157    endyear: Option<i32>,
158) -> Result<WeatherData, Box<dyn Error>> {
159    let mut url = format!(
160        "{}tmy?lat={}&lon={}&outputformat={}",
161        PVGIS_BASE_URL, latitude, longitude, outputformat,
162    );
163    if let Some(sy) = startyear {
164        url.push_str(&format!("&startyear={}", sy));
165    }
166    if let Some(ey) = endyear {
167        url.push_str(&format!("&endyear={}", ey));
168    }
169
170    let response = reqwest::blocking::get(&url)?.text()?;
171
172    match outputformat {
173        "json" => parse_pvgis_tmy_json(&response),
174        _ => Err(format!("Unsupported PVGIS TMY outputformat: '{}'. Use 'json'.", outputformat).into()),
175    }
176}
177
178/// Retrieve hourly solar radiation (and optionally PV power) data from PVGIS.
179///
180/// # Arguments
181/// * `latitude` – Latitude in decimal degrees.
182/// * `longitude` – Longitude in decimal degrees.
183/// * `start` – First year of the time series.
184/// * `end` – Last year of the time series.
185/// * `pvcalculation` – If true, include estimated PV power output.
186/// * `peakpower` – Nominal PV system power in kW (required if `pvcalculation` is true).
187/// * `surface_tilt` – Tilt angle from horizontal (degrees).
188/// * `surface_azimuth` – Orientation clockwise from north (degrees). Converted to PVGIS convention internally.
189#[allow(clippy::too_many_arguments)]
190pub fn get_pvgis_hourly(
191    latitude: f64,
192    longitude: f64,
193    start: i32,
194    end: i32,
195    pvcalculation: bool,
196    peakpower: Option<f64>,
197    surface_tilt: Option<f64>,
198    surface_azimuth: Option<f64>,
199) -> Result<WeatherData, Box<dyn Error>> {
200    let tilt = surface_tilt.unwrap_or(0.0);
201    // PVGIS uses south=0 convention; pvlib uses south=180, so subtract 180.
202    let aspect = surface_azimuth.unwrap_or(180.0) - 180.0;
203    let pvcalc_int = if pvcalculation { 1 } else { 0 };
204
205    let mut url = format!(
206        "{}seriescalc?lat={}&lon={}&startyear={}&endyear={}&pvcalculation={}&angle={}&aspect={}&outputformat=json",
207        PVGIS_BASE_URL, latitude, longitude, start, end, pvcalc_int, tilt, aspect,
208    );
209    if let Some(pp) = peakpower {
210        url.push_str(&format!("&peakpower={}", pp));
211    }
212
213    let response = reqwest::blocking::get(&url)?.text()?;
214    parse_pvgis_hourly_json(&response)
215}
216
217/// Retrieve horizon profile data from PVGIS.
218///
219/// Returns a vector of (azimuth, elevation) pairs where azimuth follows
220/// the pvlib convention (north=0, clockwise, south=180).
221pub fn get_pvgis_horizon(
222    latitude: f64,
223    longitude: f64,
224) -> Result<Vec<(f64, f64)>, Box<dyn Error>> {
225    let url = format!(
226        "{}printhorizon?lat={}&lon={}&outputformat=json",
227        PVGIS_BASE_URL, latitude, longitude,
228    );
229
230    let response = reqwest::blocking::get(&url)?.text()?;
231    parse_pvgis_horizon_json(&response)
232}
233
234// ---------------------------------------------------------------------------
235// JSON parsing helpers (public for testing)
236// ---------------------------------------------------------------------------
237
238/// Parse PVGIS TMY JSON response into `WeatherData`.
239pub fn parse_pvgis_tmy_json(json_str: &str) -> Result<WeatherData, Box<dyn Error>> {
240    let root: Value = serde_json::from_str(json_str)?;
241
242    // Location metadata
243    let inputs = &root["inputs"]["location"];
244    let latitude = inputs["latitude"].as_f64().unwrap_or(0.0);
245    let longitude = inputs["longitude"].as_f64().unwrap_or(0.0);
246    let elevation = inputs["elevation"].as_f64();
247
248    // Months selected
249    let months_selected = root["outputs"]["months_selected"]
250        .as_array()
251        .map(|arr| {
252            arr.iter()
253                .map(|m| MonthYear {
254                    month: m["month"].as_i64().unwrap_or(0) as i32,
255                    year: m["year"].as_i64().unwrap_or(0) as i32,
256                })
257                .collect()
258        });
259
260    // Hourly records
261    let hourly = root["outputs"]["tmy_hourly"]
262        .as_array()
263        .ok_or("Missing outputs.tmy_hourly in PVGIS TMY response")?;
264
265    let records: Vec<WeatherRecord> = hourly
266        .iter()
267        .map(|h| WeatherRecord {
268            time: h["time(UTC)"].as_str().unwrap_or("").to_string(),
269            ghi: h["G(h)"].as_f64().unwrap_or(0.0),
270            dni: h["Gb(n)"].as_f64().unwrap_or(0.0),
271            dhi: h["Gd(h)"].as_f64().unwrap_or(0.0),
272            temp_air: h["T2m"].as_f64().unwrap_or(0.0),
273            wind_speed: h["WS10m"].as_f64().unwrap_or(0.0),
274            pressure: h["SP"].as_f64().unwrap_or(0.0),
275            relative_humidity: h["RH"].as_f64().unwrap_or(0.0),
276            infrared: h["IR(h)"].as_f64(),
277            wind_direction: h["WD10m"].as_f64(),
278            temp_dew: None,
279            albedo: None,
280            precipitable_water: None,
281            year: None,
282            month: None,
283            day: None,
284            hour: None,
285        })
286        .collect();
287
288    Ok(WeatherData {
289        records,
290        metadata: WeatherMetadata {
291            latitude,
292            longitude,
293            elevation,
294            tz_offset: None,
295            name: None,
296            city: None,
297            state: None,
298            source: None,
299            months_selected,
300            extra: HashMap::new(),
301        },
302    })
303}
304
305/// Parse PVGIS hourly radiation JSON response into `WeatherData`.
306pub fn parse_pvgis_hourly_json(json_str: &str) -> Result<WeatherData, Box<dyn Error>> {
307    let root: Value = serde_json::from_str(json_str)?;
308
309    let inputs = &root["inputs"]["location"];
310    let latitude = inputs["latitude"].as_f64().unwrap_or(0.0);
311    let longitude = inputs["longitude"].as_f64().unwrap_or(0.0);
312    let elevation = inputs["elevation"].as_f64();
313
314    let hourly = root["outputs"]["hourly"]
315        .as_array()
316        .ok_or("Missing outputs.hourly in PVGIS hourly response")?;
317
318    let records: Vec<WeatherRecord> = hourly
319        .iter()
320        .map(|h| WeatherRecord {
321            time: h["time"].as_str().unwrap_or("").to_string(),
322            ghi: h["G(h)"].as_f64().unwrap_or(0.0),
323            dni: h["Gb(n)"].as_f64().unwrap_or(0.0),
324            dhi: h["Gd(h)"].as_f64().unwrap_or(0.0),
325            temp_air: h["T2m"].as_f64().unwrap_or(0.0),
326            wind_speed: h["WS10m"].as_f64().unwrap_or(0.0),
327            pressure: h["SP"].as_f64().unwrap_or(0.0),
328            relative_humidity: h["RH"].as_f64().unwrap_or(0.0),
329            infrared: h["IR(h)"].as_f64(),
330            wind_direction: h["WD10m"].as_f64(),
331            temp_dew: None,
332            albedo: None,
333            precipitable_water: None,
334            year: None,
335            month: None,
336            day: None,
337            hour: None,
338        })
339        .collect();
340
341    Ok(WeatherData {
342        records,
343        metadata: WeatherMetadata {
344            latitude,
345            longitude,
346            elevation,
347            tz_offset: None,
348            name: None,
349            city: None,
350            state: None,
351            source: None,
352            months_selected: None,
353            extra: HashMap::new(),
354        },
355    })
356}
357
358/// Parse PVGIS horizon JSON response into a vector of (azimuth, elevation) pairs.
359/// Azimuths are converted to pvlib convention (north=0, clockwise).
360pub fn parse_pvgis_horizon_json(json_str: &str) -> Result<Vec<(f64, f64)>, Box<dyn Error>> {
361    let root: Value = serde_json::from_str(json_str)?;
362
363    let profile = root["outputs"]["horizon_profile"]
364        .as_array()
365        .ok_or("Missing outputs.horizon_profile in PVGIS horizon response")?;
366
367    let mut result: Vec<(f64, f64)> = profile
368        .iter()
369        .map(|p| {
370            let az = p["A"].as_f64().unwrap_or(0.0);
371            let el = p["H_hor"].as_f64().unwrap_or(0.0);
372            // PVGIS uses south=0; convert to pvlib north=0 by adding 180.
373            let az_pvlib = az + 180.0;
374            (az_pvlib, el)
375        })
376        .collect();
377
378    // Remove the duplicate north point (360 == 0).
379    result.retain(|&(az, _)| az < 360.0);
380
381    Ok(result)
382}
383
384// ---------------------------------------------------------------------------
385// TMY3 / EPW file readers
386// ---------------------------------------------------------------------------
387
388/// Read a TMY3 CSV file.
389///
390/// TMY3 files have two header lines: the first contains site metadata
391/// (USAF, Name, State, TZ, latitude, longitude, altitude), the second
392/// contains column names. Data rows follow.
393///
394/// Modeled after `pvlib.iotools.read_tmy3`.
395pub fn read_tmy3(filepath: &str) -> Result<WeatherData, Box<dyn Error>> {
396    let file = File::open(filepath)?;
397    let reader = BufReader::new(file);
398    let mut lines = reader.lines();
399
400    // First line: metadata
401    let meta_line = lines.next().ok_or("TMY3 file is empty")??;
402    let meta_fields: Vec<&str> = meta_line.split(',').collect();
403    if meta_fields.len() < 7 {
404        return Err("TMY3 metadata line has fewer than 7 fields".into());
405    }
406    let metadata = WeatherMetadata {
407        latitude: meta_fields[4].trim().parse()?,
408        longitude: meta_fields[5].trim().parse()?,
409        elevation: Some(meta_fields[6].trim().parse()?),
410        tz_offset: Some(meta_fields[3].trim().parse()?),
411        name: Some(meta_fields[1].trim().to_string()),
412        city: Some(meta_fields[1].trim().to_string()),
413        state: Some(meta_fields[2].trim().to_string()),
414        source: Some(format!("USAF {}", meta_fields[0].trim())),
415        months_selected: None,
416        extra: HashMap::new(),
417    };
418
419    // Second line: column headers
420    let header_line = lines.next().ok_or("TMY3 file missing column header line")??;
421    let headers: Vec<String> = header_line.split(',').map(|s| s.trim().to_string()).collect();
422
423    let idx = |name: &str| -> Result<usize, Box<dyn Error>> {
424        headers.iter().position(|h| h == name)
425            .ok_or_else(|| format!("TMY3 column '{}' not found", name).into())
426    };
427    let i_date = idx("Date (MM/DD/YYYY)")?;
428    let i_time = idx("Time (HH:MM)")?;
429    let i_ghi = idx("GHI (W/m^2)")?;
430    let i_dni = idx("DNI (W/m^2)")?;
431    let i_dhi = idx("DHI (W/m^2)")?;
432    let i_temp = idx("Dry-bulb (C)")?;
433    let i_dew = idx("Dew-point (C)")?;
434    let i_rh = idx("RHum (%)")?;
435    let i_pres = idx("Pressure (mbar)")?;
436    let i_wdir = idx("Wdir (degrees)")?;
437    let i_wspd = idx("Wspd (m/s)")?;
438    let i_alb = idx("Alb (unitless)")?;
439    let i_pwat = idx("Pwat (cm)")?;
440
441    let mut records = Vec::new();
442    for line_result in lines {
443        let line = line_result?;
444        if line.trim().is_empty() {
445            continue;
446        }
447        let fields: Vec<&str> = line.split(',').collect();
448
449        // Parse date MM/DD/YYYY
450        let date_parts: Vec<&str> = fields[i_date].split('/').collect();
451        let month: u32 = date_parts[0].parse()?;
452        let day: u32 = date_parts[1].parse()?;
453        let year: i32 = date_parts[2].parse()?;
454
455        // Parse time HH:MM — TMY3 uses 1-24, 24:00 means midnight next day
456        let time_parts: Vec<&str> = fields[i_time].split(':').collect();
457        let raw_hour: u32 = time_parts[0].parse()?;
458        let hour = raw_hour % 24;
459
460        let parse_f64 = |i: usize| -> Result<f64, Box<dyn Error>> {
461            fields.get(i).ok_or_else(|| format!("missing field {}", i))?
462                .trim().parse::<f64>().map_err(|e| e.into())
463        };
464
465        let time_str = format!("{:04}{:02}{:02}:{:02}{:02}",
466            year, month, day, hour, 0);
467
468        records.push(WeatherRecord {
469            time: time_str,
470            ghi: parse_f64(i_ghi)?,
471            dni: parse_f64(i_dni)?,
472            dhi: parse_f64(i_dhi)?,
473            temp_air: parse_f64(i_temp)?,
474            wind_speed: parse_f64(i_wspd)?,
475            pressure: parse_f64(i_pres)?,
476            relative_humidity: parse_f64(i_rh)?,
477            infrared: None,
478            wind_direction: Some(parse_f64(i_wdir)?),
479            temp_dew: Some(parse_f64(i_dew)?),
480            albedo: Some(parse_f64(i_alb)?),
481            precipitable_water: Some(parse_f64(i_pwat)?),
482            year: Some(year),
483            month: Some(month),
484            day: Some(day),
485            hour: Some(hour),
486        });
487    }
488
489    Ok(WeatherData { metadata, records })
490}
491
492/// Read an EPW (EnergyPlus Weather) file.
493///
494/// EPW files have 8 header lines (LOCATION, DESIGN CONDITIONS, etc.)
495/// followed by hourly data rows. The LOCATION line provides site metadata.
496///
497/// Modeled after `pvlib.iotools.read_epw`.
498pub fn read_epw(filepath: &str) -> Result<WeatherData, Box<dyn Error>> {
499    let file = File::open(filepath)?;
500    let reader = BufReader::new(file);
501    let mut lines = reader.lines();
502
503    // First line: LOCATION,city,state,country,data_type,WMO_code,lat,lon,tz,elev
504    let loc_line = lines.next().ok_or("EPW file is empty")??;
505    let loc_fields: Vec<&str> = loc_line.split(',').collect();
506    if loc_fields.len() < 10 {
507        return Err("EPW LOCATION line has fewer than 10 fields".into());
508    }
509    let metadata = WeatherMetadata {
510        latitude: loc_fields[6].trim().parse()?,
511        longitude: loc_fields[7].trim().parse()?,
512        elevation: Some(loc_fields[9].trim().parse()?),
513        tz_offset: Some(loc_fields[8].trim().parse()?),
514        name: Some(loc_fields[1].trim().to_string()),
515        city: Some(loc_fields[1].trim().to_string()),
516        state: Some(loc_fields[2].trim().to_string()),
517        source: Some(loc_fields[4].trim().to_string()),
518        months_selected: None,
519        extra: HashMap::new(),
520    };
521
522    // Skip remaining 7 header lines
523    for _ in 0..7 {
524        lines.next().ok_or("EPW file has fewer than 8 header lines")??;
525    }
526
527    // Data columns (0-indexed):
528    //  0=year, 1=month, 2=day, 3=hour, 4=minute, 5=data_source,
529    //  6=temp_air, 7=temp_dew, 8=rh, 9=pressure,
530    //  10=etr, 11=etrn, 12=ghi_infrared, 13=ghi, 14=dni, 15=dhi,
531    //  ...20=wind_dir, 21=wind_speed, ...28=precipitable_water,
532    //  ...32=albedo
533    let mut records = Vec::new();
534    for line_result in lines {
535        let line = line_result?;
536        if line.trim().is_empty() {
537            continue;
538        }
539        let fields: Vec<&str> = line.split(',').collect();
540        if fields.len() < 29 {
541            continue;
542        }
543
544        let parse_f64 = |i: usize| -> Result<f64, Box<dyn Error>> {
545            fields[i].trim().parse::<f64>().map_err(|e| e.into())
546        };
547
548        let try_parse_f64 = |i: usize| -> Option<f64> {
549            fields.get(i).and_then(|s| s.trim().parse::<f64>().ok())
550        };
551
552        // EPW hour is 1-24; convert to 0-23
553        let raw_hour: u32 = fields[3].trim().parse()?;
554        let hour = if raw_hour == 0 { 0 } else { raw_hour - 1 };
555        let year: i32 = fields[0].trim().parse()?;
556        let month: u32 = fields[1].trim().parse()?;
557        let day: u32 = fields[2].trim().parse()?;
558
559        let time_str = format!("{:04}{:02}{:02}:{:02}{:02}",
560            year, month, day, hour, 0);
561
562        // EPW standard columns (0-indexed):
563        //  0-5: year, month, day, hour, minute, data_source
564        //  6: temp_air, 7: temp_dew, 8: rh, 9: pressure
565        //  10: etr, 11: etrn, 12: ghi_infrared, 13: ghi, 14: dni, 15: dhi
566        //  16-19: illuminance fields, 20: wind_dir, 21: wind_speed
567        //  22-27: sky cover, visibility, ceiling, weather obs/codes
568        //  28: precipitable_water, 29: aod, 30: snow_depth, 31: days_since_snow
569        //  32: albedo, 33: liquid_precip_depth, 34: liquid_precip_qty
570        records.push(WeatherRecord {
571            time: time_str,
572            temp_air: parse_f64(6)?,
573            wind_speed: parse_f64(21)?,
574            pressure: parse_f64(9)?,
575            relative_humidity: parse_f64(8)?,
576            ghi: parse_f64(13)?,
577            dni: parse_f64(14)?,
578            dhi: parse_f64(15)?,
579            infrared: Some(parse_f64(12)?),
580            wind_direction: Some(parse_f64(20)?),
581            temp_dew: Some(parse_f64(7)?),
582            precipitable_water: try_parse_f64(28),
583            albedo: try_parse_f64(32),
584            year: Some(year),
585            month: Some(month),
586            day: Some(day),
587            hour: Some(hour),
588        });
589    }
590
591    Ok(WeatherData { metadata, records })
592}