use serde::{Deserialize, Serialize};
use std::f64::consts::PI;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct SolarPosition {
pub zenith: f64,
pub elevation: f64,
pub azimuth: f64,
pub air_mass: f64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct PlaneOfArrayIrradiance {
pub total: f64,
pub direct: f64,
pub diffuse: f64,
pub ground: f64,
pub ghi: f64,
}
impl SolarPosition {
pub fn compute(latitude_deg: f64, day_of_year: u32, hour: f64) -> Self {
let lat = latitude_deg.to_radians();
let n = day_of_year as f64;
let b = 2.0 * PI * (n - 1.0) / 365.0;
let delta = (0.006918 - 0.399912 * b.cos() + 0.070257 * b.sin()
- 0.006758 * (2.0 * b).cos()
+ 0.000907 * (2.0 * b).sin()
- 0.002697 * (3.0 * b).cos()
+ 0.00148 * (3.0 * b).sin())
.asin()
.clamp(-0.4093, 0.4093);
let omega = (hour - 12.0) * 15.0_f64.to_radians();
let sin_elev =
(lat.sin() * delta.sin() + lat.cos() * delta.cos() * omega.cos()).clamp(-1.0, 1.0);
let elevation = sin_elev.asin();
let zenith = PI / 2.0 - elevation;
let cos_az = if zenith.cos().abs() > 1e-6 {
(sin_elev * lat.sin() - delta.sin()) / (zenith.sin() * lat.cos())
} else {
0.0
};
let az_abs = cos_az.clamp(-1.0, 1.0).acos();
let azimuth = if omega > 0.0 { az_abs } else { -az_abs };
let air_mass = if elevation > 0.0 {
1.0 / (elevation.sin() + 0.50572 * (elevation.to_degrees() + 6.07995_f64).powf(-1.6364))
} else {
f64::INFINITY
};
Self {
zenith,
elevation,
azimuth,
air_mass,
}
}
pub fn is_daytime(&self) -> bool {
self.elevation > 0.0
}
}
pub fn extraterrestrial_irradiance(day_of_year: u32) -> f64 {
let gsc = 1361.0; let b = 2.0 * PI * day_of_year as f64 / 365.0;
let et_factor = 1.000110
+ 0.034221 * b.cos()
+ 0.001280 * b.sin()
+ 0.000719 * (2.0 * b).cos()
+ 0.000077 * (2.0 * b).sin();
gsc * et_factor
}
pub fn erbs_decomposition(ghi: f64, clearness_index: f64) -> (f64, f64) {
let kt = clearness_index.clamp(0.0, 1.0);
let df = if kt <= 0.22 {
1.0 - 0.09 * kt
} else if kt <= 0.80 {
0.9511 - 0.1604 * kt + 4.388 * kt * kt - 16.638 * kt.powi(3) + 12.336 * kt.powi(4)
} else {
0.165
};
let dhi = (df * ghi).max(0.0);
let dni_horiz = (ghi - dhi).max(0.0);
(dhi, dni_horiz)
}
pub fn poa_isotropic(
ghi: f64,
pos: &SolarPosition,
tilt_deg: f64,
panel_az_deg: f64,
albedo: f64,
day_of_year: u32,
) -> PlaneOfArrayIrradiance {
if !pos.is_daytime() || ghi <= 0.0 {
return PlaneOfArrayIrradiance {
total: 0.0,
direct: 0.0,
diffuse: 0.0,
ground: 0.0,
ghi,
};
}
let i0 = extraterrestrial_irradiance(day_of_year);
let cos_z = pos.zenith.cos().max(1e-6);
let kt = (ghi / (i0 * cos_z)).min(1.0);
let (dhi, dni_horiz) = erbs_decomposition(ghi, kt);
let dni = if cos_z > 1e-3 { dni_horiz / cos_z } else { 0.0 };
let tilt = tilt_deg.to_radians();
let panel_az = panel_az_deg.to_radians();
let cos_aoi = (pos.elevation.sin() * tilt.cos()
+ pos.elevation.cos() * (pos.azimuth - panel_az).cos() * tilt.sin())
.max(0.0);
let direct = dni * cos_aoi;
let diffuse = dhi * (1.0 + tilt.cos()) / 2.0; let ground = ghi * albedo * (1.0 - tilt.cos()) / 2.0;
let total = direct + diffuse + ground;
PlaneOfArrayIrradiance {
total,
direct,
diffuse,
ground,
ghi,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_solar_position_noon() {
let pos = SolarPosition::compute(35.0, 172, 12.0); assert!(pos.is_daytime());
assert!(
pos.elevation.to_degrees() > 50.0,
"elevation={}",
pos.elevation.to_degrees()
);
}
#[test]
fn test_solar_below_horizon_night() {
let pos = SolarPosition::compute(35.0, 1, 2.0); assert!(!pos.is_daytime());
}
#[test]
fn test_extraterrestrial_near_solar_constant() {
let i0 = extraterrestrial_irradiance(1);
assert!(i0 > 1300.0 && i0 < 1420.0, "I0={}", i0);
}
#[test]
fn test_poa_tilted_vs_horizontal() {
let pos = SolarPosition::compute(35.0, 355, 12.0); let poa_h = poa_isotropic(700.0, &pos, 0.0, 0.0, 0.2, 355); let poa_t = poa_isotropic(700.0, &pos, 35.0, 0.0, 0.2, 355); assert!(
poa_t.total >= poa_h.total,
"tilted={:.1} horizontal={:.1}",
poa_t.total,
poa_h.total
);
}
#[test]
fn test_erbs_decomposition_overcast() {
let (dhi, _dni_h) = erbs_decomposition(100.0, 0.1);
assert!(dhi > 80.0, "dhi={}", dhi);
}
#[test]
fn test_equatorial_equinox_elevation() {
let pos = SolarPosition::compute(0.0, 80, 12.0);
assert!(
pos.elevation.to_degrees() > 80.0,
"elevation={}",
pos.elevation.to_degrees()
);
}
#[test]
fn test_high_latitude_winter_below_horizon() {
let pos = SolarPosition::compute(70.0, 5, 12.0);
assert!(
!pos.is_daytime() || pos.elevation.to_degrees() <= 0.0,
"elevation={}",
pos.elevation.to_degrees()
);
}
#[test]
fn test_air_mass_at_zenith() {
let pos = SolarPosition::compute(0.0, 80, 12.0);
assert!(
pos.air_mass >= 0.9 && pos.air_mass <= 1.5,
"air_mass={}",
pos.air_mass
);
}
#[test]
fn test_air_mass_below_horizon() {
let pos = SolarPosition::compute(35.0, 1, 2.0); assert!(
pos.air_mass > 1000.0 || pos.air_mass == f64::INFINITY,
"air_mass={}",
pos.air_mass
);
}
#[test]
fn test_erbs_high_clearness_low_diffuse() {
let (dhi, _dni_h) = erbs_decomposition(800.0, 0.85);
let diffuse_fraction = dhi / 800.0;
assert!(
diffuse_fraction < 0.25,
"diffuse_fraction={}",
diffuse_fraction
);
}
#[test]
fn test_erbs_moderate_clearness() {
let (dhi, _dni_h) = erbs_decomposition(500.0, 0.5);
let diffuse_fraction = dhi / 500.0;
assert!(
(0.25..=0.70).contains(&diffuse_fraction),
"diffuse_fraction={}",
diffuse_fraction
);
}
#[test]
fn test_poa_zero_ghi_zero_output() {
let pos = SolarPosition::compute(35.0, 172, 12.0);
let poa = poa_isotropic(0.0, &pos, 30.0, 180.0, 0.2, 172);
assert!(
poa.total == 0.0 && poa.direct == 0.0 && poa.diffuse == 0.0 && poa.ground == 0.0,
"expected all zeros but got total={} direct={} diffuse={} ground={}",
poa.total,
poa.direct,
poa.diffuse,
poa.ground
);
}
}