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.
189pub fn get_pvgis_hourly(
190    latitude: f64,
191    longitude: f64,
192    start: i32,
193    end: i32,
194    pvcalculation: bool,
195    peakpower: Option<f64>,
196    surface_tilt: Option<f64>,
197    surface_azimuth: Option<f64>,
198) -> Result<WeatherData, Box<dyn Error>> {
199    let tilt = surface_tilt.unwrap_or(0.0);
200    // PVGIS uses south=0 convention; pvlib uses south=180, so subtract 180.
201    let aspect = surface_azimuth.unwrap_or(180.0) - 180.0;
202    let pvcalc_int = if pvcalculation { 1 } else { 0 };
203
204    let mut url = format!(
205        "{}seriescalc?lat={}&lon={}&startyear={}&endyear={}&pvcalculation={}&angle={}&aspect={}&outputformat=json",
206        PVGIS_BASE_URL, latitude, longitude, start, end, pvcalc_int, tilt, aspect,
207    );
208    if let Some(pp) = peakpower {
209        url.push_str(&format!("&peakpower={}", pp));
210    }
211
212    let response = reqwest::blocking::get(&url)?.text()?;
213    parse_pvgis_hourly_json(&response)
214}
215
216/// Retrieve horizon profile data from PVGIS.
217///
218/// Returns a vector of (azimuth, elevation) pairs where azimuth follows
219/// the pvlib convention (north=0, clockwise, south=180).
220pub fn get_pvgis_horizon(
221    latitude: f64,
222    longitude: f64,
223) -> Result<Vec<(f64, f64)>, Box<dyn Error>> {
224    let url = format!(
225        "{}printhorizon?lat={}&lon={}&outputformat=json",
226        PVGIS_BASE_URL, latitude, longitude,
227    );
228
229    let response = reqwest::blocking::get(&url)?.text()?;
230    parse_pvgis_horizon_json(&response)
231}
232
233// ---------------------------------------------------------------------------
234// JSON parsing helpers (public for testing)
235// ---------------------------------------------------------------------------
236
237/// Parse PVGIS TMY JSON response into `WeatherData`.
238pub fn parse_pvgis_tmy_json(json_str: &str) -> Result<WeatherData, Box<dyn Error>> {
239    let root: Value = serde_json::from_str(json_str)?;
240
241    // Location metadata
242    let inputs = &root["inputs"]["location"];
243    let latitude = inputs["latitude"].as_f64().unwrap_or(0.0);
244    let longitude = inputs["longitude"].as_f64().unwrap_or(0.0);
245    let elevation = inputs["elevation"].as_f64();
246
247    // Months selected
248    let months_selected = root["outputs"]["months_selected"]
249        .as_array()
250        .map(|arr| {
251            arr.iter()
252                .map(|m| MonthYear {
253                    month: m["month"].as_i64().unwrap_or(0) as i32,
254                    year: m["year"].as_i64().unwrap_or(0) as i32,
255                })
256                .collect()
257        });
258
259    // Hourly records
260    let hourly = root["outputs"]["tmy_hourly"]
261        .as_array()
262        .ok_or("Missing outputs.tmy_hourly in PVGIS TMY response")?;
263
264    let records: Vec<WeatherRecord> = hourly
265        .iter()
266        .map(|h| WeatherRecord {
267            time: h["time(UTC)"].as_str().unwrap_or("").to_string(),
268            ghi: h["G(h)"].as_f64().unwrap_or(0.0),
269            dni: h["Gb(n)"].as_f64().unwrap_or(0.0),
270            dhi: h["Gd(h)"].as_f64().unwrap_or(0.0),
271            temp_air: h["T2m"].as_f64().unwrap_or(0.0),
272            wind_speed: h["WS10m"].as_f64().unwrap_or(0.0),
273            pressure: h["SP"].as_f64().unwrap_or(0.0),
274            relative_humidity: h["RH"].as_f64().unwrap_or(0.0),
275            infrared: h["IR(h)"].as_f64(),
276            wind_direction: h["WD10m"].as_f64(),
277            temp_dew: None,
278            albedo: None,
279            precipitable_water: None,
280            year: None,
281            month: None,
282            day: None,
283            hour: None,
284        })
285        .collect();
286
287    Ok(WeatherData {
288        records,
289        metadata: WeatherMetadata {
290            latitude,
291            longitude,
292            elevation,
293            tz_offset: None,
294            name: None,
295            city: None,
296            state: None,
297            source: None,
298            months_selected,
299            extra: HashMap::new(),
300        },
301    })
302}
303
304/// Parse PVGIS hourly radiation JSON response into `WeatherData`.
305pub fn parse_pvgis_hourly_json(json_str: &str) -> Result<WeatherData, Box<dyn Error>> {
306    let root: Value = serde_json::from_str(json_str)?;
307
308    let inputs = &root["inputs"]["location"];
309    let latitude = inputs["latitude"].as_f64().unwrap_or(0.0);
310    let longitude = inputs["longitude"].as_f64().unwrap_or(0.0);
311    let elevation = inputs["elevation"].as_f64();
312
313    let hourly = root["outputs"]["hourly"]
314        .as_array()
315        .ok_or("Missing outputs.hourly in PVGIS hourly response")?;
316
317    let records: Vec<WeatherRecord> = hourly
318        .iter()
319        .map(|h| WeatherRecord {
320            time: h["time"].as_str().unwrap_or("").to_string(),
321            ghi: h["G(h)"].as_f64().unwrap_or(0.0),
322            dni: h["Gb(n)"].as_f64().unwrap_or(0.0),
323            dhi: h["Gd(h)"].as_f64().unwrap_or(0.0),
324            temp_air: h["T2m"].as_f64().unwrap_or(0.0),
325            wind_speed: h["WS10m"].as_f64().unwrap_or(0.0),
326            pressure: h["SP"].as_f64().unwrap_or(0.0),
327            relative_humidity: h["RH"].as_f64().unwrap_or(0.0),
328            infrared: h["IR(h)"].as_f64(),
329            wind_direction: h["WD10m"].as_f64(),
330            temp_dew: None,
331            albedo: None,
332            precipitable_water: None,
333            year: None,
334            month: None,
335            day: None,
336            hour: None,
337        })
338        .collect();
339
340    Ok(WeatherData {
341        records,
342        metadata: WeatherMetadata {
343            latitude,
344            longitude,
345            elevation,
346            tz_offset: None,
347            name: None,
348            city: None,
349            state: None,
350            source: None,
351            months_selected: None,
352            extra: HashMap::new(),
353        },
354    })
355}
356
357/// Parse PVGIS horizon JSON response into a vector of (azimuth, elevation) pairs.
358/// Azimuths are converted to pvlib convention (north=0, clockwise).
359pub fn parse_pvgis_horizon_json(json_str: &str) -> Result<Vec<(f64, f64)>, Box<dyn Error>> {
360    let root: Value = serde_json::from_str(json_str)?;
361
362    let profile = root["outputs"]["horizon_profile"]
363        .as_array()
364        .ok_or("Missing outputs.horizon_profile in PVGIS horizon response")?;
365
366    let mut result: Vec<(f64, f64)> = profile
367        .iter()
368        .map(|p| {
369            let az = p["A"].as_f64().unwrap_or(0.0);
370            let el = p["H_hor"].as_f64().unwrap_or(0.0);
371            // PVGIS uses south=0; convert to pvlib north=0 by adding 180.
372            let az_pvlib = az + 180.0;
373            (az_pvlib, el)
374        })
375        .collect();
376
377    // Remove the duplicate north point (360 == 0).
378    result.retain(|&(az, _)| az < 360.0);
379
380    Ok(result)
381}
382
383// ---------------------------------------------------------------------------
384// TMY3 / EPW file readers
385// ---------------------------------------------------------------------------
386
387/// Read a TMY3 CSV file.
388///
389/// TMY3 files have two header lines: the first contains site metadata
390/// (USAF, Name, State, TZ, latitude, longitude, altitude), the second
391/// contains column names. Data rows follow.
392///
393/// Modeled after `pvlib.iotools.read_tmy3`.
394pub fn read_tmy3(filepath: &str) -> Result<WeatherData, Box<dyn Error>> {
395    let file = File::open(filepath)?;
396    let reader = BufReader::new(file);
397    let mut lines = reader.lines();
398
399    // First line: metadata
400    let meta_line = lines.next().ok_or("TMY3 file is empty")??;
401    let meta_fields: Vec<&str> = meta_line.split(',').collect();
402    if meta_fields.len() < 7 {
403        return Err("TMY3 metadata line has fewer than 7 fields".into());
404    }
405    let metadata = WeatherMetadata {
406        latitude: meta_fields[4].trim().parse()?,
407        longitude: meta_fields[5].trim().parse()?,
408        elevation: Some(meta_fields[6].trim().parse()?),
409        tz_offset: Some(meta_fields[3].trim().parse()?),
410        name: Some(meta_fields[1].trim().to_string()),
411        city: Some(meta_fields[1].trim().to_string()),
412        state: Some(meta_fields[2].trim().to_string()),
413        source: Some(format!("USAF {}", meta_fields[0].trim())),
414        months_selected: None,
415        extra: HashMap::new(),
416    };
417
418    // Second line: column headers
419    let header_line = lines.next().ok_or("TMY3 file missing column header line")??;
420    let headers: Vec<String> = header_line.split(',').map(|s| s.trim().to_string()).collect();
421
422    let idx = |name: &str| -> Result<usize, Box<dyn Error>> {
423        headers.iter().position(|h| h == name)
424            .ok_or_else(|| format!("TMY3 column '{}' not found", name).into())
425    };
426    let i_date = idx("Date (MM/DD/YYYY)")?;
427    let i_time = idx("Time (HH:MM)")?;
428    let i_ghi = idx("GHI (W/m^2)")?;
429    let i_dni = idx("DNI (W/m^2)")?;
430    let i_dhi = idx("DHI (W/m^2)")?;
431    let i_temp = idx("Dry-bulb (C)")?;
432    let i_dew = idx("Dew-point (C)")?;
433    let i_rh = idx("RHum (%)")?;
434    let i_pres = idx("Pressure (mbar)")?;
435    let i_wdir = idx("Wdir (degrees)")?;
436    let i_wspd = idx("Wspd (m/s)")?;
437    let i_alb = idx("Alb (unitless)")?;
438    let i_pwat = idx("Pwat (cm)")?;
439
440    let mut records = Vec::new();
441    for line_result in lines {
442        let line = line_result?;
443        if line.trim().is_empty() {
444            continue;
445        }
446        let fields: Vec<&str> = line.split(',').collect();
447
448        // Parse date MM/DD/YYYY
449        let date_parts: Vec<&str> = fields[i_date].split('/').collect();
450        let month: u32 = date_parts[0].parse()?;
451        let day: u32 = date_parts[1].parse()?;
452        let year: i32 = date_parts[2].parse()?;
453
454        // Parse time HH:MM — TMY3 uses 1-24, 24:00 means midnight next day
455        let time_parts: Vec<&str> = fields[i_time].split(':').collect();
456        let raw_hour: u32 = time_parts[0].parse()?;
457        let hour = raw_hour % 24;
458
459        let parse_f64 = |i: usize| -> Result<f64, Box<dyn Error>> {
460            fields.get(i).ok_or_else(|| format!("missing field {}", i))?
461                .trim().parse::<f64>().map_err(|e| e.into())
462        };
463
464        let time_str = format!("{:04}{:02}{:02}:{:02}{:02}",
465            year, month, day, hour, 0);
466
467        records.push(WeatherRecord {
468            time: time_str,
469            ghi: parse_f64(i_ghi)?,
470            dni: parse_f64(i_dni)?,
471            dhi: parse_f64(i_dhi)?,
472            temp_air: parse_f64(i_temp)?,
473            wind_speed: parse_f64(i_wspd)?,
474            pressure: parse_f64(i_pres)?,
475            relative_humidity: parse_f64(i_rh)?,
476            infrared: None,
477            wind_direction: Some(parse_f64(i_wdir)?),
478            temp_dew: Some(parse_f64(i_dew)?),
479            albedo: Some(parse_f64(i_alb)?),
480            precipitable_water: Some(parse_f64(i_pwat)?),
481            year: Some(year),
482            month: Some(month),
483            day: Some(day),
484            hour: Some(hour),
485        });
486    }
487
488    Ok(WeatherData { metadata, records })
489}
490
491/// Read an EPW (EnergyPlus Weather) file.
492///
493/// EPW files have 8 header lines (LOCATION, DESIGN CONDITIONS, etc.)
494/// followed by hourly data rows. The LOCATION line provides site metadata.
495///
496/// Modeled after `pvlib.iotools.read_epw`.
497pub fn read_epw(filepath: &str) -> Result<WeatherData, Box<dyn Error>> {
498    let file = File::open(filepath)?;
499    let reader = BufReader::new(file);
500    let mut lines = reader.lines();
501
502    // First line: LOCATION,city,state,country,data_type,WMO_code,lat,lon,tz,elev
503    let loc_line = lines.next().ok_or("EPW file is empty")??;
504    let loc_fields: Vec<&str> = loc_line.split(',').collect();
505    if loc_fields.len() < 10 {
506        return Err("EPW LOCATION line has fewer than 10 fields".into());
507    }
508    let metadata = WeatherMetadata {
509        latitude: loc_fields[6].trim().parse()?,
510        longitude: loc_fields[7].trim().parse()?,
511        elevation: Some(loc_fields[9].trim().parse()?),
512        tz_offset: Some(loc_fields[8].trim().parse()?),
513        name: Some(loc_fields[1].trim().to_string()),
514        city: Some(loc_fields[1].trim().to_string()),
515        state: Some(loc_fields[2].trim().to_string()),
516        source: Some(loc_fields[4].trim().to_string()),
517        months_selected: None,
518        extra: HashMap::new(),
519    };
520
521    // Skip remaining 7 header lines
522    for _ in 0..7 {
523        lines.next().ok_or("EPW file has fewer than 8 header lines")??;
524    }
525
526    // Data columns (0-indexed):
527    //  0=year, 1=month, 2=day, 3=hour, 4=minute, 5=data_source,
528    //  6=temp_air, 7=temp_dew, 8=rh, 9=pressure,
529    //  10=etr, 11=etrn, 12=ghi_infrared, 13=ghi, 14=dni, 15=dhi,
530    //  ...20=wind_dir, 21=wind_speed, ...28=precipitable_water,
531    //  ...32=albedo
532    let mut records = Vec::new();
533    for line_result in lines {
534        let line = line_result?;
535        if line.trim().is_empty() {
536            continue;
537        }
538        let fields: Vec<&str> = line.split(',').collect();
539        if fields.len() < 29 {
540            continue;
541        }
542
543        let parse_f64 = |i: usize| -> Result<f64, Box<dyn Error>> {
544            fields[i].trim().parse::<f64>().map_err(|e| e.into())
545        };
546
547        let try_parse_f64 = |i: usize| -> Option<f64> {
548            fields.get(i).and_then(|s| s.trim().parse::<f64>().ok())
549        };
550
551        // EPW hour is 1-24; convert to 0-23
552        let raw_hour: u32 = fields[3].trim().parse()?;
553        let hour = if raw_hour == 0 { 0 } else { raw_hour - 1 };
554        let year: i32 = fields[0].trim().parse()?;
555        let month: u32 = fields[1].trim().parse()?;
556        let day: u32 = fields[2].trim().parse()?;
557
558        let time_str = format!("{:04}{:02}{:02}:{:02}{:02}",
559            year, month, day, hour, 0);
560
561        // EPW standard columns (0-indexed):
562        //  0-5: year, month, day, hour, minute, data_source
563        //  6: temp_air, 7: temp_dew, 8: rh, 9: pressure
564        //  10: etr, 11: etrn, 12: ghi_infrared, 13: ghi, 14: dni, 15: dhi
565        //  16-19: illuminance fields, 20: wind_dir, 21: wind_speed
566        //  22-27: sky cover, visibility, ceiling, weather obs/codes
567        //  28: precipitable_water, 29: aod, 30: snow_depth, 31: days_since_snow
568        //  32: albedo, 33: liquid_precip_depth, 34: liquid_precip_qty
569        records.push(WeatherRecord {
570            time: time_str,
571            temp_air: parse_f64(6)?,
572            wind_speed: parse_f64(21)?,
573            pressure: parse_f64(9)?,
574            relative_humidity: parse_f64(8)?,
575            ghi: parse_f64(13)?,
576            dni: parse_f64(14)?,
577            dhi: parse_f64(15)?,
578            infrared: Some(parse_f64(12)?),
579            wind_direction: Some(parse_f64(20)?),
580            temp_dew: Some(parse_f64(7)?),
581            precipitable_water: try_parse_f64(28),
582            albedo: try_parse_f64(32),
583            year: Some(year),
584            month: Some(month),
585            day: Some(day),
586            hour: Some(hour),
587        });
588    }
589
590    Ok(WeatherData { metadata, records })
591}