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#[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#[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 records.push(map);
83 }
84
85 Ok(records)
86}
87
88#[derive(Debug, Clone)]
94pub struct WeatherRecord {
95 pub time: String,
97 pub ghi: f64,
99 pub dni: f64,
101 pub dhi: f64,
103 pub temp_air: f64,
105 pub wind_speed: f64,
107 pub pressure: f64,
109 pub relative_humidity: f64,
111 pub infrared: Option<f64>,
113 pub wind_direction: Option<f64>,
115 pub temp_dew: Option<f64>,
117 pub albedo: Option<f64>,
119 pub precipitable_water: Option<f64>,
121 pub year: Option<i32>,
123 pub month: Option<u32>,
125 pub day: Option<u32>,
127 pub hour: Option<u32>,
129}
130
131#[derive(Debug, Clone)]
133pub struct WeatherMetadata {
134 pub latitude: f64,
135 pub longitude: f64,
136 pub elevation: Option<f64>,
137 pub tz_offset: Option<f64>,
139 pub name: Option<String>,
141 pub city: Option<String>,
143 pub state: Option<String>,
145 pub source: Option<String>,
147 pub months_selected: Option<Vec<MonthYear>>,
149 pub extra: HashMap<String, String>,
151}
152
153#[derive(Debug, Clone)]
155pub struct MonthYear {
156 pub month: i32,
157 pub year: i32,
158}
159
160#[derive(Debug, Clone)]
162pub struct WeatherData {
163 pub records: Vec<WeatherRecord>,
164 pub metadata: WeatherMetadata,
165}
166
167#[cfg(feature = "pvgis")]
172const PVGIS_BASE_URL: &str = "https://re.jrc.ec.europa.eu/api/v5_3/";
173
174#[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#[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 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#[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
271pub 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 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 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 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
342pub 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
395pub 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 let az_pvlib = az + 180.0;
411 (az_pvlib, el)
412 })
413 .collect();
414
415 result.retain(|&(az, _)| az < 360.0);
417
418 Ok(result)
419}
420
421pub 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 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 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 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 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
540pub 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 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 for _ in 0..7 {
572 lines.next().ok_or("EPW file has fewer than 8 header lines")??;
573 }
574
575 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 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 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}