Skip to main content

pfx_core/
daylight.rs

1/// Noon. A caller passes it to [`Daylight::light`] and [`Daylight::authored`] for a neutral normaliser.
2pub const REFERENCE_HOUR: f64 = 12.0;
3const AXIAL_TILT: f64 = 23.44;
4
5const FIELDS: [(&str, f64, f64, Option<f64>); 4] = [
6    ("hour", 0.0, 24.0, None),
7    ("day", 1.0, 365.0, Some(Daylight::DAY)),
8    ("latitude", -90.0, 90.0, Some(Daylight::LATITUDE)),
9    ("heading", 0.0, 360.0, Some(Daylight::HEADING)),
10];
11
12#[derive(Clone, Debug, PartialEq)]
13pub enum Reading {
14    Number(f64),
15    Other(String),
16}
17
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct Daylight {
20    pub hour: f64,
21    pub day: f64,
22    pub latitude: f64,
23    pub heading: f64,
24}
25
26impl Daylight {
27    pub const DAY: f64 = 172.0;
28    pub const LATITUDE: f64 = 45.0;
29    pub const HEADING: f64 = 180.0;
30
31    pub fn parse<I, K>(fields: I) -> Result<Self, String>
32    where
33        I: IntoIterator<Item = (K, Reading)>,
34        K: AsRef<str>,
35    {
36        let fields: Vec<(String, Reading)> = fields
37            .into_iter()
38            .map(|(key, value)| (key.as_ref().to_string(), value))
39            .collect();
40        for (key, _) in &fields {
41            if !FIELDS.iter().any(|field| field.0 == key) {
42                return Err(format!(
43                    "daylight knows no '{key}'; it takes hour, day, latitude and heading"
44                ));
45            }
46        }
47        let mut found = [None, None, None, None];
48        for (key, value) in &fields {
49            if let Some(index) = FIELDS.iter().position(|field| field.0 == key) {
50                found[index] = Some(value);
51            }
52        }
53        let mut values = [0.0; 4];
54        for (index, &(key, low, high, fallback)) in FIELDS.iter().enumerate() {
55            let value = match found[index] {
56                Some(Reading::Number(value)) => *value,
57                Some(Reading::Other(text)) => {
58                    return Err(format!("daylight.{key} is {text}, not a number"));
59                }
60                None => fallback.ok_or("daylight needs an hour, 0 to 24")?,
61            };
62            if !(low..=high).contains(&value) {
63                return Err(format!(
64                    "daylight.{key} = {value} is outside {low} to {high}"
65                ));
66            }
67            values[index] = value;
68        }
69        Ok(Self {
70            hour: values[0],
71            day: values[1],
72            latitude: values[2],
73            heading: values[3],
74        })
75    }
76
77    pub fn sun(&self) -> Sun {
78        let declination =
79            (AXIAL_TILT * (std::f64::consts::TAU * (284.0 + self.day) / 365.0).sin()).to_radians();
80        let hour_angle = (15.0 * (self.hour - 12.0)).to_radians();
81        let latitude = self.latitude.to_radians();
82        let elevation = (latitude.sin() * declination.sin()
83            + latitude.cos() * declination.cos() * hour_angle.cos())
84        .clamp(-1.0, 1.0)
85        .asin();
86        let bearing = (-declination.cos() * hour_angle.sin()).atan2(
87            declination.sin() * latitude.cos()
88                - declination.cos() * hour_angle.cos() * latitude.sin(),
89        );
90        let flat = elevation.cos();
91        let front = self.heading.to_radians();
92        let right = front - std::f64::consts::FRAC_PI_2;
93        let z_up = [
94            flat * (bearing - right).cos(),
95            -flat * (bearing - front).cos(),
96            elevation.sin(),
97        ];
98        Sun {
99            elevation: elevation.to_degrees(),
100            azimuth: bearing.to_degrees().rem_euclid(360.0),
101            y_up: [z_up[0], z_up[2], -z_up[1]],
102            z_up,
103        }
104    }
105
106    pub fn light(&self, reference_hour: f64) -> Light {
107        let at = self.sun();
108        let reference = reference_place(reference_hour).sun();
109        let colour = kelvin(temperature(at.elevation));
110        let intensity = if at.elevation <= 0.0 {
111            0.0
112        } else {
113            at.elevation.to_radians().sin().max(0.0).powf(0.6)
114                / reference.elevation.to_radians().sin().powf(0.6)
115                * smooth(0.0, 3.0, at.elevation)
116        };
117        let ambient = ambient_raw(at.elevation) / ambient_raw(reference.elevation);
118        let (lamp, energy, sky) = lamp(at.elevation);
119        Light {
120            colour,
121            intensity,
122            ambient,
123            lamp,
124            energy,
125            sky,
126        }
127    }
128
129    pub fn authored(&self, authored: &Authored, reference_hour: f64) -> AuthoredLight {
130        let at = self.sun();
131        let reference = reference_place(reference_hour).sun();
132        let authored_toward = unit(authored.toward);
133        let authored_elevation = authored_toward[1].clamp(-1.0, 1.0).asin();
134        let horizontal = (-authored_toward[0]).atan2(authored_toward[2]);
135        let turn = (at.azimuth - reference.azimuth).to_radians()
136            - (self.heading - Self::HEADING).to_radians();
137        let lifted = (authored_elevation * at.elevation.to_radians()
138            / reference.elevation.to_radians().max(1e-3))
139        .max(0.5_f64.to_radians());
140        let across = horizontal + turn;
141        let toward = [
142            -across.sin() * lifted.cos(),
143            lifted.sin(),
144            across.cos() * lifted.cos(),
145        ];
146        let tint = kelvin(temperature(at.elevation));
147        let base = kelvin(temperature(reference.elevation));
148        let mut colour = [0, 1, 2]
149            .map(|channel| authored.colour[channel] * tint[channel] / base[channel].max(1e-3));
150        let peak = colour.into_iter().fold(0.0_f64, f64::max).max(1e-6);
151        colour = colour.map(|channel| channel / peak);
152        let light = self.light(reference_hour);
153        let turned = (toward[0] * authored_toward[0]
154            + toward[1] * authored_toward[1]
155            + toward[2] * authored_toward[2])
156            .clamp(-1.0, 1.0)
157            .acos()
158            .to_degrees();
159        let cool = smooth(30.0, 55.0, turned) * smooth(0.0, 20.0, at.elevation);
160        AuthoredLight {
161            toward,
162            colour,
163            intensity: light.intensity,
164            ambient: light.ambient * (1.0 + authored.sky_fill * cool),
165            cool,
166        }
167    }
168}
169
170#[derive(Clone, Copy, Debug, PartialEq)]
171pub struct Sun {
172    pub elevation: f64,
173    pub azimuth: f64,
174    pub y_up: [f64; 3],
175    pub z_up: [f64; 3],
176}
177
178#[derive(Clone, Copy, Debug, PartialEq)]
179pub struct Light {
180    pub colour: [f64; 3],
181    pub intensity: f64,
182    pub ambient: f64,
183    pub lamp: [f64; 3],
184    pub energy: f64,
185    pub sky: f64,
186}
187
188#[derive(Clone, Copy, Debug, PartialEq)]
189pub struct Authored {
190    pub toward: [f64; 3],
191    pub colour: [f64; 3],
192    pub sky_fill: f64,
193}
194
195#[derive(Clone, Copy, Debug, PartialEq)]
196pub struct AuthoredLight {
197    pub toward: [f64; 3],
198    pub colour: [f64; 3],
199    pub intensity: f64,
200    pub ambient: f64,
201    pub cool: f64,
202}
203
204#[derive(Clone, Copy, Debug, PartialEq)]
205pub struct Probe {
206    pub hour: f64,
207    pub index: usize,
208}
209
210pub fn probe_weights(hour: f64, probes: &[Probe], count: usize) -> Vec<f64> {
211    let mut weights = vec![0.0; count];
212    if probes.is_empty() || count == 0 {
213        return weights;
214    }
215    let mut keys: Vec<(f64, usize)> = probes
216        .iter()
217        .map(|probe| (probe.hour, probe.index))
218        .collect();
219    keys.sort_by(|a, b| a.0.total_cmp(&b.0));
220    let hour = hour.rem_euclid(24.0);
221    let next = keys.iter().position(|(key, _)| *key > hour).unwrap_or(0);
222    let prev = (next + keys.len() - 1) % keys.len();
223    let (from_hour, from) = keys[prev];
224    let (to_hour, to) = keys[next];
225    let span = (to_hour - from_hour).rem_euclid(24.0).max(1e-3);
226    let t = ((hour - from_hour).rem_euclid(24.0) / span).clamp(0.0, 1.0);
227    let t = t * t * (3.0 - 2.0 * t);
228    if from == to {
229        if from < count {
230            weights[from] = 1.0;
231        }
232    } else {
233        if from < count {
234            weights[from] = 1.0 - t;
235        }
236        if to < count {
237            weights[to] = t;
238        }
239    }
240    weights
241}
242
243#[derive(Clone, Copy, Debug, PartialEq)]
244pub struct SunArc {
245    pub start: f64,
246    pub span: f64,
247    pub elevation: f64,
248    pub azimuth: f64,
249    pub sweep: f64,
250    pub reference: f64,
251}
252
253#[derive(Clone, Copy, Debug, PartialEq)]
254pub struct DampedClock {
255    pub sun: f64,
256    pub tint: f64,
257    pub elevation: f64,
258    pub azimuth: f64,
259    pub skywarm: f64,
260    pub lamp: f64,
261    pub exposure: f64,
262    pub warmth: f64,
263    pub turn: f64,
264    pub arc: SunArc,
265}
266
267#[derive(Clone, Copy, Debug, PartialEq)]
268pub struct DampedSun {
269    pub toward: [f64; 3],
270    pub colour: [f64; 3],
271    pub shade: [f64; 3],
272    pub ambient: [f64; 3],
273    pub lamp: f64,
274    pub exposure: f64,
275    pub warmth: f64,
276    pub turn: f64,
277}
278
279impl DampedClock {
280    #[allow(clippy::too_many_arguments)]
281    pub fn new(
282        sun: f64,
283        tint: f64,
284        elevation: f64,
285        azimuth: f64,
286        skywarm: f64,
287        lamp: f64,
288        exposure: f64,
289        warmth: f64,
290        turn: f64,
291        arc: SunArc,
292    ) -> Self {
293        Self {
294            sun,
295            tint,
296            elevation,
297            azimuth,
298            skywarm,
299            lamp,
300            exposure,
301            warmth,
302            turn,
303            arc,
304        }
305    }
306
307    pub fn at(&self, hour: f64, base: [f64; 3]) -> DampedSun {
308        let base = unit(base);
309        let (elevation, azimuth) = sun_arc(&self.arc, hour.rem_euclid(24.0));
310        let (origin_elevation, origin_azimuth) = sun_arc(&self.arc, self.arc.reference);
311        let d_elevation = (0.35 * (elevation.max(-10.0) - origin_elevation) + 0.5 * self.elevation)
312            .clamp(-24.0, 20.0)
313            .to_radians();
314        let d_azimuth = (0.3 * (azimuth - origin_azimuth) + 0.5 * self.azimuth).to_radians();
315        let el = base[1].clamp(-1.0, 1.0).asin() + d_elevation;
316        let az = base[2].atan2(base[0]) + d_azimuth;
317        let toward = [el.cos() * az.cos(), el.sin(), el.cos() * az.sin()];
318        let sun = smooth(-2.0, 10.0, elevation);
319        let low = 1.0 - smooth(4.0, 30.0, elevation);
320        let tint = self.tint + 1.4 * low;
321        let warm = [1.0, 1.0 - 0.10 * tint, (1.0 - 0.22 * tint).max(0.2)];
322        let night = 1.0 - smooth(-8.0, 5.0, elevation);
323        let moon = [0.55 * 0.12, 0.65 * 0.12, 0.12];
324        let colour = [0, 1, 2]
325            .map(|channel| warm[channel] * self.sun * sun + moon[channel] * self.sun * night);
326        let room = 0.14 + 0.86 * smooth(-8.0, 15.0, elevation);
327        let dusk = low * (1.0 - night);
328        let sky = [
329            1.0,
330            1.0 - 0.07 * self.skywarm,
331            (1.0 - 0.16 * self.skywarm).max(0.2),
332        ];
333        let day = [1.0, 0.86, 0.72];
334        let blue = [0.55, 0.65, 1.0];
335        let shade = [0, 1, 2].map(|channel| {
336            sky[channel]
337                * room
338                * (day[channel] * dusk + blue[channel] * night + (1.0 - dusk - night).max(0.0))
339        });
340        let sun_tint = [1.0, 1.0 - 0.10 * self.tint, 1.0 - 0.22 * self.tint];
341        let ambient = [0, 1, 2].map(|channel| {
342            0.55 * shade[channel]
343                + 0.45 * colour[channel] / (self.sun * sun_tint[channel]).max(1e-3)
344        });
345        DampedSun {
346            toward,
347            colour,
348            shade,
349            ambient,
350            lamp: self.lamp * (1.0 - smooth(-6.0, 6.0, elevation)),
351            exposure: self.exposure * (1.0 + 0.9 * night),
352            warmth: self.warmth * (1.0 - night),
353            turn: self.turn,
354        }
355    }
356}
357
358fn reference_place(hour: f64) -> Daylight {
359    Daylight {
360        hour,
361        day: Daylight::DAY,
362        latitude: Daylight::LATITUDE,
363        heading: Daylight::HEADING,
364    }
365}
366
367fn ambient_raw(elevation: f64) -> f64 {
368    0.06 + 0.19 * smooth(-12.0, 0.0, elevation) + 0.75 * smooth(0.0, 20.0, elevation)
369}
370
371fn lamp(elevation: f64) -> ([f64; 3], f64, f64) {
372    let rise = elevation.to_radians().sin().max(0.0);
373    let warm = (elevation / 30.0).clamp(0.0, 1.0);
374    let low = [1.0, 0.42, 0.18];
375    let high = [1.0, 0.97, 0.92];
376    let colour = [0, 1, 2].map(|channel| low[channel] + (high[channel] - low[channel]) * warm);
377    let energy = 2.0 * rise.sqrt();
378    let sky = 0.15 + 0.85 * (rise * 2.0).min(1.0);
379    (colour, energy, sky)
380}
381
382fn temperature(elevation: f64) -> f64 {
383    2000.0 + 3600.0 * smooth(0.0, 40.0, elevation).powf(0.7)
384}
385
386fn kelvin(k: f64) -> [f64; 3] {
387    let t = k / 100.0;
388    let r = if t <= 66.0 {
389        255.0
390    } else {
391        329.698_73 * (t - 60.0).powf(-0.133_204_76)
392    };
393    let g = if t <= 66.0 {
394        99.470_8 * t.ln() - 161.119_57
395    } else {
396        288.122_16 * (t - 60.0).powf(-0.075_514_85)
397    };
398    let b = if t >= 66.0 {
399        255.0
400    } else if t <= 19.0 {
401        0.0
402    } else {
403        138.517_73 * (t - 10.0).ln() - 305.044_8
404    };
405    let rgb = [r, g, b].map(|channel| (channel / 255.0).clamp(0.0, 1.0));
406    let peak = rgb.into_iter().fold(0.0_f64, f64::max).max(1e-6);
407    rgb.map(|channel| channel / peak)
408}
409
410fn smooth(low: f64, high: f64, x: f64) -> f64 {
411    let t = ((x - low) / (high - low)).clamp(0.0, 1.0);
412    t * t * (3.0 - 2.0 * t)
413}
414
415fn unit(v: [f64; 3]) -> [f64; 3] {
416    let length = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
417    [v[0] / length, v[1] / length, v[2] / length]
418}
419
420fn sun_arc(arc: &SunArc, hour: f64) -> (f64, f64) {
421    let f = (hour - arc.start) / arc.span;
422    (
423        arc.elevation * (std::f64::consts::PI * f).sin(),
424        arc.azimuth + arc.sweep * f,
425    )
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    fn near(value: f64, expect: f64) {
433        assert!((value - expect).abs() < 1e-4, "{value} is not {expect}");
434    }
435
436    fn at(hour: f64, day: f64, latitude: f64, heading: f64) -> Daylight {
437        Daylight {
438            hour,
439            day,
440            latitude,
441            heading,
442        }
443    }
444
445    fn num(key: &str, value: f64) -> (String, Reading) {
446        (key.to_string(), Reading::Number(value))
447    }
448
449    fn fixture() -> Authored {
450        Authored {
451            toward: [0.2, 0.8, 0.4],
452            colour: [1.0, 0.7, 0.4],
453            sky_fill: 1.0,
454        }
455    }
456
457    fn arc() -> SunArc {
458        SunArc {
459            start: 6.0,
460            span: 12.0,
461            elevation: 50.0,
462            azimuth: -90.0,
463            sweep: 180.0,
464            reference: 12.0,
465        }
466    }
467
468    fn clock() -> DampedClock {
469        DampedClock::new(2.0, 0.5, 0.0, 0.0, 0.0, 3.0, 1.0, 0.4, 0.2, arc())
470    }
471
472    fn probes() -> Vec<Probe> {
473        vec![
474            Probe {
475                hour: 23.0,
476                index: 1,
477            },
478            Probe {
479                hour: 5.0,
480                index: 1,
481            },
482            Probe {
483                hour: 6.5,
484                index: 2,
485            },
486            Probe {
487                hour: 9.0,
488                index: 3,
489            },
490            Probe {
491                hour: 13.0,
492                index: 0,
493            },
494            Probe {
495                hour: 17.5,
496                index: 4,
497            },
498            Probe {
499                hour: 19.5,
500                index: 5,
501            },
502        ]
503    }
504
505    #[test]
506    fn parse_fills_the_defaults_and_refuses_what_the_harness_refuses() {
507        let lit = Daylight::parse([num("hour", 16.5)]).unwrap();
508        assert_eq!(
509            (lit.hour, lit.day, lit.latitude, lit.heading),
510            (16.5, 172.0, 45.0, 180.0)
511        );
512        let ordered = Daylight::parse([
513            num("heading", 90.0),
514            num("hour", 8.0),
515            num("latitude", -33.9),
516            num("day", 100.0),
517        ])
518        .unwrap();
519        assert_eq!(
520            (ordered.hour, ordered.day, ordered.latitude, ordered.heading),
521            (8.0, 100.0, -33.9, 90.0)
522        );
523        for (hour, day, latitude, heading) in [(0.0, 1.0, -90.0, 0.0), (24.0, 365.0, 90.0, 360.0)] {
524            Daylight::parse([
525                num("hour", hour),
526                num("day", day),
527                num("latitude", latitude),
528                num("heading", heading),
529            ])
530            .unwrap();
531        }
532        assert_eq!(
533            Daylight::parse([num("hour", 25.0)]).unwrap_err(),
534            "daylight.hour = 25 is outside 0 to 24"
535        );
536        assert_eq!(
537            Daylight::parse([num("hour", 12.0), num("day", 0.0)]).unwrap_err(),
538            "daylight.day = 0 is outside 1 to 365"
539        );
540        assert_eq!(
541            Daylight::parse([num("hour", 12.0), num("day", 366.0)]).unwrap_err(),
542            "daylight.day = 366 is outside 1 to 365"
543        );
544        assert_eq!(
545            Daylight::parse([num("moon", 1.0)]).unwrap_err(),
546            "daylight knows no 'moon'; it takes hour, day, latitude and heading"
547        );
548        assert_eq!(
549            Daylight::parse([num("hour", 25.0), num("moon", 1.0)]).unwrap_err(),
550            "daylight knows no 'moon'; it takes hour, day, latitude and heading"
551        );
552        assert_eq!(
553            Daylight::parse([num("day", 100.0)]).unwrap_err(),
554            "daylight needs an hour, 0 to 24"
555        );
556        assert_eq!(
557            Daylight::parse(Vec::<(String, Reading)>::new()).unwrap_err(),
558            "daylight needs an hour, 0 to 24"
559        );
560        assert_eq!(
561            Daylight::parse([("hour", Reading::Other("\"noon\"".into()))]).unwrap_err(),
562            "daylight.hour is \"noon\", not a number"
563        );
564        assert_eq!(
565            Daylight::parse([
566                ("hour", Reading::Other("true".into())),
567                ("latitude", Reading::Number(100.0)),
568            ])
569            .unwrap_err(),
570            "daylight.hour is true, not a number"
571        );
572        assert_eq!(
573            Daylight::parse([("latitude", Reading::Other("true".into()))]).unwrap_err(),
574            "daylight needs an hour, 0 to 24"
575        );
576        assert_eq!(
577            Daylight::parse([num("hour", 12.0), num("latitude", 90.5)]).unwrap_err(),
578            "daylight.latitude = 90.5 is outside -90 to 90"
579        );
580        assert_eq!(
581            Daylight::parse([num("hour", 12.0), num("heading", 361.0)]).unwrap_err(),
582            "daylight.heading = 361 is outside 0 to 360"
583        );
584    }
585
586    #[test]
587    fn the_sun_follows_the_axial_tilt() {
588        let samples = [
589            (12.0, 172.0, 45.0, 68.439_783, 180.0),
590            (12.0, 80.0, 0.0, 89.596_52, 180.0),
591            (6.0, 80.0, 45.0, -0.285_30, 90.285_31),
592            (9.0, 172.0, 45.0, 47.732_76, 105.298_64),
593            (15.0, 172.0, 45.0, 47.732_76, 254.701_36),
594            (16.0, 172.0, 45.0, 37.275_93, 266.895_01),
595            (8.0, 172.0, 45.0, 37.275_93, 93.104_99),
596            (0.0, 172.0, 45.0, -21.560_22, 0.0),
597            (12.0, 355.0, 45.0, 21.560_22, 180.0),
598            (12.0, 172.0, -33.9, 32.660_22, 0.0),
599            (18.0, 172.0, 60.0, 20.150_79, 282.231_41),
600            (16.5, 172.0, 45.0, 31.974_68, 272.230_07),
601        ];
602        for (hour, day, latitude, elevation, azimuth) in samples {
603            let sun = at(hour, day, latitude, 180.0).sun();
604            near(sun.elevation, elevation);
605            near(sun.azimuth, azimuth);
606            let len =
607                (sun.z_up[0] * sun.z_up[0] + sun.z_up[1] * sun.z_up[1] + sun.z_up[2] * sun.z_up[2])
608                    .sqrt();
609            near(len, 1.0);
610            near(sun.y_up[0], sun.z_up[0]);
611            near(sun.y_up[1], sun.z_up[2]);
612            near(sun.y_up[2], -sun.z_up[1]);
613            near(sun.y_up[1], sun.elevation.to_radians().sin());
614        }
615
616        let noon = at(12.0, 172.0, 45.0, 180.0).sun();
617        assert!((noon.elevation - (90.0 - 45.0 + 23.44)).abs() < 0.2);
618        assert!(noon.z_up[0].abs() < 1e-9);
619        assert!(noon.z_up[1] < 0.0 && noon.z_up[2] > 0.9);
620        assert!(noon.y_up[1] > 0.9 && noon.y_up[2] > 0.0);
621
622        let morning = at(8.0, 172.0, 45.0, 180.0).sun();
623        assert!(morning.z_up[0] > 0.3);
624        assert!(morning.elevation > 0.0 && morning.elevation < 45.0);
625        assert!(morning.y_up[0] > 0.3);
626
627        assert!(at(0.0, 172.0, 45.0, 180.0).sun().elevation < 0.0);
628        let winter = at(12.0, 355.0, 45.0, 180.0).sun();
629        assert!((winter.elevation - (90.0 - 45.0 - 23.44)).abs() < 0.3);
630        assert!(at(12.0, 172.0, 45.0, 0.0).sun().z_up[1] > 0.0);
631
632        let june = at(12.0, 172.0, 45.0, 180.0).sun();
633        assert!((june.elevation - 68.4).abs() < 1.0);
634        assert!(at(12.0, 80.0, 0.0, 180.0).sun().elevation > 88.0);
635        assert!(at(6.0, 80.0, 45.0, 180.0).sun().elevation.abs() < 2.0);
636        assert!(at(9.0, 172.0, 45.0, 180.0).sun().azimuth < 180.0);
637        assert!(at(15.0, 172.0, 45.0, 180.0).sun().azimuth > 180.0);
638    }
639
640    #[test]
641    fn a_noon_sun_is_higher_in_summer_than_in_winter() {
642        let summer = at(12.0, 172.0, 45.0, 180.0).sun();
643        let winter = at(12.0, 355.0, 45.0, 180.0).sun();
644        assert!(summer.elevation > winter.elevation + 40.0);
645        assert!(summer.y_up[1] > winter.y_up[1]);
646        let january = at(12.0, 1.0, 45.0, 180.0).sun();
647        assert!(summer.elevation > january.elevation);
648    }
649
650    #[test]
651    fn light_is_one_at_its_reference_hour_and_authored_light_follows_the_sun() {
652        let noon = at(
653            REFERENCE_HOUR,
654            Daylight::DAY,
655            Daylight::LATITUDE,
656            Daylight::HEADING,
657        );
658        let light = noon.light(REFERENCE_HOUR);
659        near(light.intensity, 1.0);
660        near(light.ambient, 1.0);
661        for (got, expect) in light.lamp.into_iter().zip([1.0, 0.97, 0.92]) {
662            near(got, expect);
663        }
664        assert!(light.energy > 1.0);
665        near(light.sky, 1.0);
666
667        let sky = noon.authored(&fixture(), REFERENCE_HOUR);
668        let toward = unit(fixture().toward);
669        for (got, expect) in sky.toward.into_iter().zip(toward) {
670            near(got, expect);
671        }
672        for (got, expect) in sky.colour.into_iter().zip(fixture().colour) {
673            near(got, expect);
674        }
675        near(sky.intensity, 1.0);
676        near(sky.ambient, 1.0);
677        assert_eq!(sky.cool, 0.0);
678
679        let morning = at(8.0, Daylight::DAY, Daylight::LATITUDE, Daylight::HEADING);
680        let moved = morning.authored(&fixture(), REFERENCE_HOUR);
681        assert!((moved.toward[0] - sky.toward[0]).abs() > 0.2);
682        assert!(moved.intensity < sky.intensity);
683
684        let mut filled = fixture();
685        filled.sky_fill = 0.0;
686        let mut wide = fixture();
687        wide.sky_fill = 2.0;
688        let plain = morning.authored(&filled, REFERENCE_HOUR);
689        let boosted = morning.authored(&wide, REFERENCE_HOUR);
690        assert!(boosted.cool > 0.0);
691        near(
692            boosted.ambient - plain.ambient,
693            plain.ambient * boosted.cool * 2.0,
694        );
695        assert!(boosted.ambient > plain.ambient);
696
697        let dawn = at(6.0, 80.0, 45.0, 180.0);
698        assert!(dawn.sun().elevation.abs() < 2.0);
699        let around = dawn.light(REFERENCE_HOUR);
700        assert_eq!(around.intensity, 0.0);
701        assert_eq!(around.energy, 0.0);
702        assert!(around.lamp[2] < 0.3);
703        near(around.sky, 0.15);
704
705        for hour in [0.0, 23.0] {
706            let place = at(hour, Daylight::DAY, Daylight::LATITUDE, Daylight::HEADING);
707            let lit = place.light(REFERENCE_HOUR);
708            assert!(place.sun().elevation < 0.0);
709            assert_eq!(lit.intensity, 0.0);
710            assert_eq!(lit.energy, 0.0);
711            assert_eq!(lit.lamp, [1.0, 0.42, 0.18]);
712            near(lit.sky, 0.15);
713            let sky = place.authored(&fixture(), REFERENCE_HOUR);
714            assert_eq!(sky.intensity, 0.0);
715            assert!(sky.ambient < 0.2);
716        }
717    }
718
719    #[test]
720    fn probe_weights_sum_to_one_and_wrap_at_midnight() {
721        let probes = probes();
722        for hour in [
723            0.0, 4.0, 5.0, 7.25, 11.0, 13.0, 18.5, 22.0, 23.0, 24.0, -1.0,
724        ] {
725            let weights = probe_weights(hour, &probes, 6);
726            assert_eq!(weights.len(), 6);
727            near(weights.iter().sum::<f64>(), 1.0);
728            assert!(weights.iter().filter(|weight| **weight > 0.0).count() <= 2);
729        }
730        let noon = probe_weights(13.0, &probes, 6);
731        assert_eq!(noon[0], 1.0);
732        let between = probe_weights(11.0, &probes, 6);
733        near(between[3], 0.5);
734        near(between[0], 0.5);
735        assert_eq!(between[1], 0.0);
736        assert_eq!(between[2], 0.0);
737        assert_eq!(between[4], 0.0);
738        assert_eq!(between[5], 0.0);
739
740        let midnight = probe_weights(0.0, &probes, 6);
741        assert_eq!(midnight[1], 1.0);
742        assert_eq!(probe_weights(24.0, &probes, 6), midnight);
743        assert_eq!(probe_weights(4.0, &probes, 6)[1], 1.0);
744
745        let blend = probe_weights(22.0, &probes, 6);
746        near(blend[5], 1.0 - 275.0 / 343.0);
747        near(blend[1], 275.0 / 343.0);
748
749        let wrap = [
750            Probe {
751                hour: 22.0,
752                index: 0,
753            },
754            Probe {
755                hour: 2.0,
756                index: 1,
757            },
758        ];
759        let across = probe_weights(0.0, &wrap, 2);
760        near(across[0], 0.5);
761        near(across[1], 0.5);
762        let before = probe_weights(23.0, &wrap, 2);
763        near(before[0], 0.843_75);
764        near(before[1], 0.156_25);
765        assert_eq!(probe_weights(22.0, &wrap, 2)[0], 1.0);
766        assert_eq!(probe_weights(2.0, &wrap, 2)[1], 1.0);
767        assert!(
768            probe_weights(0.0, &[], 6)
769                .iter()
770                .all(|weight| *weight == 0.0)
771        );
772    }
773
774    #[test]
775    fn a_damped_clock_holds_its_base_at_the_reference_and_wraps_the_day() {
776        let base = [0.3, 0.7, 0.4];
777        let clock = clock();
778        let noon = clock.at(arc().reference, base);
779        let norm = unit(base);
780        for (got, expect) in noon.toward.into_iter().zip(norm) {
781            near(got, expect);
782        }
783        assert_eq!(noon.turn, 0.2);
784        near(noon.exposure, 1.0);
785        near(noon.warmth, 0.4);
786        assert_eq!(noon.lamp, 0.0);
787        assert!(noon.colour[0] > noon.colour[2]);
788
789        let later = clock.at(15.0, base);
790        let rise = later.toward[1].asin().to_degrees() - noon.toward[1].asin().to_degrees();
791        let full = {
792            let height = |hour: f64| {
793                let f = (hour - arc().start) / arc().span;
794                arc().elevation * (std::f64::consts::PI * f).sin()
795            };
796            height(15.0) - height(arc().reference)
797        };
798        assert!(rise.abs() < full.abs() * 0.5);
799        assert!(rise * full > 0.0);
800
801        let midnight = clock.at(0.0, base);
802        let same = clock.at(24.0, base);
803        for (got, expect) in midnight.toward.into_iter().zip(same.toward) {
804            near(got, expect);
805        }
806        for (got, expect) in midnight.colour.into_iter().zip(same.colour) {
807            near(got, expect);
808        }
809        assert!(midnight.toward[1] < noon.toward[1]);
810        assert!(midnight.exposure > noon.exposure);
811        assert_eq!(midnight.warmth, 0.0);
812        assert!(midnight.lamp > 0.0);
813    }
814}