use std::f64::consts::PI;
pub const SOLAR_CONSTANT: f64 = 1361.0;
pub const STEFAN_BOLTZMANN: f64 = 5.670_374_419e-8;
pub const EARTH_ALBEDO: f64 = 0.30;
pub const CLEAR_SKY_TRANSMISSIVITY: f64 = 0.75;
pub const ATMOSPHERIC_EMISSIVITY: f64 = 0.75;
#[must_use]
#[inline]
pub fn solar_zenith_angle(latitude_rad: f64, declination_rad: f64, hour_angle_rad: f64) -> f64 {
let cos_z = latitude_rad.sin() * declination_rad.sin()
+ latitude_rad.cos() * declination_rad.cos() * hour_angle_rad.cos();
cos_z.clamp(-1.0, 1.0).acos()
}
#[must_use]
#[inline]
pub fn solar_declination(day_of_year: u16) -> f64 {
let angle = 2.0 * PI / 365.0 * (day_of_year as f64 + 10.0);
-23.44_f64.to_radians() * angle.cos()
}
#[must_use]
#[inline]
pub fn hour_angle(solar_hour: f64) -> f64 {
(solar_hour - 12.0) * 15.0_f64.to_radians()
}
#[must_use]
pub fn day_length(latitude_rad: f64, declination_rad: f64) -> f64 {
let cos_h0 = -(latitude_rad.tan() * declination_rad.tan());
if cos_h0 <= -1.0 {
return 24.0; }
if cos_h0 >= 1.0 {
return 0.0; }
let h0 = cos_h0.acos(); 2.0 * h0.to_degrees() / 15.0 }
#[must_use]
pub fn sunrise_sunset(latitude_rad: f64, declination_rad: f64) -> (f64, f64) {
let cos_h0 = -(latitude_rad.tan() * declination_rad.tan());
if cos_h0 <= -1.0 {
return (0.0, 24.0); }
if cos_h0 >= 1.0 {
return (12.0, 12.0); }
let h0_hours = cos_h0.acos().to_degrees() / 15.0;
(12.0 - h0_hours, 12.0 + h0_hours)
}
#[must_use]
#[inline]
pub fn clear_sky_irradiance(zenith_rad: f64, transmissivity: f64) -> f64 {
let cos_z = zenith_rad.cos();
if cos_z <= 0.0 {
return 0.0;
}
SOLAR_CONSTANT * transmissivity * cos_z
}
#[must_use]
#[inline]
pub fn cloud_attenuated_irradiance(clear_irradiance: f64, cloud_fraction: f64) -> f64 {
let cf = cloud_fraction.clamp(0.0, 1.0);
clear_irradiance * (1.0 - 0.75 * cf.powf(3.4))
}
#[must_use]
#[inline]
pub fn longwave_emission(emissivity: f64, surface_temp_k: f64) -> f64 {
if surface_temp_k <= 0.0 {
return 0.0;
}
emissivity * STEFAN_BOLTZMANN * surface_temp_k.powi(4)
}
#[must_use]
#[inline]
pub fn atmospheric_longwave(atmospheric_emissivity: f64, air_temp_k: f64) -> f64 {
if air_temp_k <= 0.0 {
return 0.0;
}
atmospheric_emissivity * STEFAN_BOLTZMANN * air_temp_k.powi(4)
}
#[must_use]
#[inline]
pub fn net_radiation(shortwave_in: f64, albedo: f64, longwave_down: f64, longwave_up: f64) -> f64 {
(1.0 - albedo) * shortwave_in + longwave_down - longwave_up
}
#[must_use]
pub fn diurnal_temperature_range(net_radiation_peak: f64, cloud_fraction: f64) -> f64 {
if net_radiation_peak <= 0.0 {
return 0.0;
}
let thermal_mass = 1.225 * 1005.0 * 1000.0;
let heating_seconds = 6.0 * 3600.0; let dt = net_radiation_peak * heating_seconds / thermal_mass;
let cf = cloud_fraction.clamp(0.0, 1.0);
dt * (1.0 - 0.5 * cf)
}
#[must_use]
#[inline]
pub fn equilibrium_temperature(solar_irradiance: f64, albedo: f64) -> f64 {
let absorbed = solar_irradiance * (1.0 - albedo);
if absorbed <= 0.0 {
return 0.0;
}
(absorbed / (4.0 * STEFAN_BOLTZMANN)).powf(0.25)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn declination_summer_solstice() {
let d = solar_declination(172);
assert!(
(d - 23.44_f64.to_radians()).abs() < 2.0_f64.to_radians(),
"summer solstice declination should be ~+23.4°, got {:.1}°",
d.to_degrees()
);
}
#[test]
fn declination_winter_solstice() {
let d = solar_declination(355);
assert!(
(d - (-23.44_f64.to_radians())).abs() < 2.0_f64.to_radians(),
"winter solstice declination should be ~-23.4°, got {:.1}°",
d.to_degrees()
);
}
#[test]
fn declination_equinox() {
let d = solar_declination(80);
assert!(
d.abs() < 2.0_f64.to_radians(),
"equinox declination should be ~0°, got {:.1}°",
d.to_degrees()
);
}
#[test]
fn hour_angle_noon() {
assert!(hour_angle(12.0).abs() < f64::EPSILON);
}
#[test]
fn hour_angle_morning() {
assert!(
hour_angle(6.0) < 0.0,
"morning hour angle should be negative"
);
}
#[test]
fn hour_angle_afternoon() {
assert!(
hour_angle(18.0) > 0.0,
"afternoon hour angle should be positive"
);
}
#[test]
fn zenith_at_noon_equator_equinox() {
let z = solar_zenith_angle(0.0, 0.0, 0.0);
assert!(
z.abs() < 0.01,
"sun should be overhead at equator noon on equinox, got {:.1}°",
z.to_degrees()
);
}
#[test]
fn zenith_below_horizon() {
let z = solar_zenith_angle(0.0, 0.0, PI);
assert!(z > PI / 2.0, "sun should be below horizon at midnight");
}
#[test]
fn zenith_high_latitude_summer() {
let lat = 65.0_f64.to_radians();
let dec = 23.44_f64.to_radians();
let z = solar_zenith_angle(lat, dec, 0.0);
assert!(
z.to_degrees() < 50.0,
"high-latitude summer noon should have moderate zenith, got {:.1}°",
z.to_degrees()
);
}
#[test]
fn clear_sky_irradiance_noon() {
let irr = clear_sky_irradiance(0.0, CLEAR_SKY_TRANSMISSIVITY);
assert!(
(irr - SOLAR_CONSTANT * CLEAR_SKY_TRANSMISSIVITY).abs() < 1.0,
"overhead sun should give S×τ, got {irr}"
);
}
#[test]
fn clear_sky_irradiance_below_horizon() {
let irr = clear_sky_irradiance(2.0, CLEAR_SKY_TRANSMISSIVITY); assert_eq!(irr, 0.0);
}
#[test]
fn clear_sky_irradiance_at_angle() {
let z = 60.0_f64.to_radians();
let irr = clear_sky_irradiance(z, CLEAR_SKY_TRANSMISSIVITY);
let expected = SOLAR_CONSTANT * CLEAR_SKY_TRANSMISSIVITY * 0.5; assert!(
(irr - expected).abs() < 1.0,
"60° zenith should halve irradiance, got {irr}"
);
}
#[test]
fn cloud_attenuation_clear() {
let att = cloud_attenuated_irradiance(1000.0, 0.0);
assert!(
(att - 1000.0).abs() < f64::EPSILON,
"no clouds → no attenuation"
);
}
#[test]
fn cloud_attenuation_overcast() {
let att = cloud_attenuated_irradiance(1000.0, 1.0);
assert!(
att > 200.0 && att < 300.0,
"full overcast should reduce to ~25%, got {att}"
);
}
#[test]
fn cloud_attenuation_partial() {
let att = cloud_attenuated_irradiance(1000.0, 0.5);
assert!(
att > 800.0 && att < 1000.0,
"50% cloud should reduce modestly, got {att}"
);
}
#[test]
fn longwave_emission_earth_surface() {
let lw = longwave_emission(0.95, 288.15);
assert!(
lw > 350.0 && lw < 400.0,
"Earth surface LW should be ~371 W/m², got {lw}"
);
}
#[test]
fn longwave_emission_zero_temp() {
assert_eq!(longwave_emission(1.0, 0.0), 0.0);
}
#[test]
fn atmospheric_longwave_typical() {
let lw = atmospheric_longwave(ATMOSPHERIC_EMISSIVITY, 288.15);
assert!(
lw > 250.0 && lw < 350.0,
"atmospheric LW should be ~293 W/m², got {lw}"
);
}
#[test]
fn atmospheric_longwave_zero_temp() {
assert_eq!(atmospheric_longwave(0.75, 0.0), 0.0);
}
#[test]
fn net_radiation_daytime_positive() {
let rn = net_radiation(800.0, 0.2, 300.0, 390.0);
assert!(
(rn - 550.0).abs() < 1.0,
"midday net radiation should be ~550 W/m², got {rn}"
);
}
#[test]
fn net_radiation_nighttime_negative() {
let rn = net_radiation(0.0, 0.3, 280.0, 350.0);
assert!(
rn < 0.0,
"nighttime should have negative net radiation, got {rn}"
);
}
#[test]
fn diurnal_range_clear_sky() {
let dtr = diurnal_temperature_range(500.0, 0.0);
assert!(
dtr > 5.0 && dtr < 15.0,
"clear sky DTR should be ~8-9°C, got {dtr}"
);
}
#[test]
fn diurnal_range_clouds_damp() {
let clear = diurnal_temperature_range(500.0, 0.0);
let cloudy = diurnal_temperature_range(500.0, 0.8);
assert!(
cloudy < clear,
"clouds should reduce diurnal range: clear={clear}, cloudy={cloudy}"
);
}
#[test]
fn diurnal_range_negative_radiation() {
assert_eq!(diurnal_temperature_range(-100.0, 0.0), 0.0);
}
#[test]
fn equilibrium_temperature_earth() {
let t_eq = equilibrium_temperature(SOLAR_CONSTANT, EARTH_ALBEDO);
assert!(
(t_eq - 255.0).abs() < 3.0,
"Earth T_eq should be ~255 K, got {t_eq}"
);
}
#[test]
fn equilibrium_temperature_no_albedo() {
let t_eq = equilibrium_temperature(SOLAR_CONSTANT, 0.0);
assert!(
t_eq > 255.0,
"zero albedo should give higher T_eq than Earth"
);
}
#[test]
fn equilibrium_temperature_full_albedo() {
let t_eq = equilibrium_temperature(SOLAR_CONSTANT, 1.0);
assert_eq!(t_eq, 0.0, "full albedo → no absorbed energy → 0 K");
}
#[test]
fn day_length_equinox() {
let dl = day_length(0.0, 0.0);
assert!(
(dl - 12.0).abs() < 0.1,
"equinox at equator should be ~12h, got {dl}"
);
}
#[test]
fn day_length_summer_high_latitude() {
let dl = day_length(70.0_f64.to_radians(), 23.44_f64.to_radians());
assert!(
dl > 20.0,
"high-latitude summer should have very long days, got {dl}"
);
}
#[test]
fn day_length_midnight_sun() {
let dl = day_length(80.0_f64.to_radians(), 23.44_f64.to_radians());
assert!((dl - 24.0).abs() < f64::EPSILON, "should be midnight sun");
}
#[test]
fn day_length_polar_night() {
let dl = day_length(80.0_f64.to_radians(), (-23.44_f64).to_radians());
assert!(dl.abs() < f64::EPSILON, "should be polar night");
}
#[test]
fn sunrise_sunset_equinox() {
let (sr, ss) = sunrise_sunset(45.0_f64.to_radians(), 0.0);
assert!(
(sr - 6.0).abs() < 0.1,
"equinox sunrise should be ~6:00, got {sr}"
);
assert!(
(ss - 18.0).abs() < 0.1,
"equinox sunset should be ~18:00, got {ss}"
);
}
#[test]
fn sunrise_sunset_symmetry() {
let dec = 10.0_f64.to_radians();
let lat = 40.0_f64.to_radians();
let (sr, ss) = sunrise_sunset(lat, dec);
assert!(
(sr + ss - 24.0).abs() < 0.01,
"sunrise + sunset should sum to 24h"
);
}
}