1use std::collections::HashMap;
2use std::error::Error;
3use std::fs::File;
4use std::io::{BufRead, BufReader};
5use serde_json::Value;
6
7pub 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 records.push(map);
50 }
51
52 Ok(records)
53}
54
55#[derive(Debug, Clone)]
61pub struct WeatherRecord {
62 pub time: String,
64 pub ghi: f64,
66 pub dni: f64,
68 pub dhi: f64,
70 pub temp_air: f64,
72 pub wind_speed: f64,
74 pub pressure: f64,
76 pub relative_humidity: f64,
78 pub infrared: Option<f64>,
80 pub wind_direction: Option<f64>,
82 pub temp_dew: Option<f64>,
84 pub albedo: Option<f64>,
86 pub precipitable_water: Option<f64>,
88 pub year: Option<i32>,
90 pub month: Option<u32>,
92 pub day: Option<u32>,
94 pub hour: Option<u32>,
96}
97
98#[derive(Debug, Clone)]
100pub struct WeatherMetadata {
101 pub latitude: f64,
102 pub longitude: f64,
103 pub elevation: Option<f64>,
104 pub tz_offset: Option<f64>,
106 pub name: Option<String>,
108 pub city: Option<String>,
110 pub state: Option<String>,
112 pub source: Option<String>,
114 pub months_selected: Option<Vec<MonthYear>>,
116 pub extra: HashMap<String, String>,
118}
119
120#[derive(Debug, Clone)]
122pub struct MonthYear {
123 pub month: i32,
124 pub year: i32,
125}
126
127#[derive(Debug, Clone)]
129pub struct WeatherData {
130 pub records: Vec<WeatherRecord>,
131 pub metadata: WeatherMetadata,
132}
133
134const PVGIS_BASE_URL: &str = "https://re.jrc.ec.europa.eu/api/v5_3/";
139
140pub 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#[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 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
217pub 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
234pub 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 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 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 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
305pub 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
358pub 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 let az_pvlib = az + 180.0;
374 (az_pvlib, el)
375 })
376 .collect();
377
378 result.retain(|&(az, _)| az < 360.0);
380
381 Ok(result)
382}
383
384pub 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 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 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 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 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
492pub 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 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 for _ in 0..7 {
524 lines.next().ok_or("EPW file has fewer than 8 header lines")??;
525 }
526
527 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 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 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}