1pub 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}