Skip to main content

pvlib/
iotools.rs

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