Skip to main content

lumen/
timing.rs

1//! The per-layer settings pipeline ([`spec/14-layer-timing.md`] section 8).
2//!
3//! Base values come from `META`, a `META.sectors` entry overrides them per
4//! sector, the bottom and transition ranges blend bottom-prefixed values into
5//! normal ones, and the `(layer, sector)`'s own `LROV` payload - at most one, so
6//! there is nothing to fold in order - replaces what it carries.
7
8use crate::check::Check;
9use crate::error::{Error, Result};
10use crate::json::{CureCurve, Meta, Sector, Timing, REQUIRED_META_FIELDS};
11
12/// Light PWM when META carries none (`spec/05-meta.md` section 4.2).
13const DEFAULT_LIGHT_PWM: u32 = 255;
14
15/// The concrete timing of one `(layer, sector)` pair.
16#[derive(Debug, Clone, PartialEq)]
17pub struct Resolved {
18    /// Layer thickness in micrometers.
19    pub layer_height_um: u32,
20    /// Exposure time for this layer, in milliseconds.
21    pub exposure_ms: u32,
22    /// Lift, slow segment distance.
23    pub lift_slow_distance_um: u32,
24    /// Lift, slow segment speed.
25    pub lift_slow_speed_um_min: u32,
26    /// Lift, fast segment distance.
27    pub lift_fast_distance_um: u32,
28    /// Lift, fast segment speed.
29    pub lift_fast_speed_um_min: u32,
30    /// Retract, fast segment distance.
31    pub retract_fast_distance_um: u32,
32    /// Retract, fast segment speed.
33    pub retract_fast_speed_um_min: u32,
34    /// Retract, slow segment distance.
35    pub retract_slow_distance_um: u32,
36    /// Retract, slow segment speed.
37    pub retract_slow_speed_um_min: u32,
38    /// Pause before curing, in milliseconds.
39    pub wait_time_before_cure_ms: u32,
40    /// Pause after curing, in milliseconds.
41    pub wait_time_after_cure_ms: u32,
42    /// Pause after the lift, in milliseconds.
43    pub wait_time_after_lift_ms: u32,
44    /// Light PWM for this layer.
45    pub light_pwm: u32,
46    /// Chamber target temperature, if any.
47    pub chamber_temperature_c: Option<f64>,
48    /// Vat target temperature, if any.
49    pub vat_temperature_c: Option<f64>,
50    /// The resin working curve, if any.
51    pub cure_curve: Option<CureCurve>,
52    /// Whether this layer is in the bottom range.
53    pub is_bottom: bool,
54    /// Whether this layer is in the interpolated transition range.
55    pub is_transition: bool,
56}
57
58/// [`field_pair`] for a field named in both its normal and its `bottom_` form.
59macro_rules! pair {
60    ($meta:expr, $sector:expr, $normal:ident, $bottom:ident, $default:expr) => {
61        field_pair($meta, $sector, |t| t.$normal, |t| t.$bottom, $default)
62    };
63}
64
65/// Resolve one layer's timing.
66///
67/// `sector` is the `META.sectors` entry for `sector_id`, when the print defines
68/// one; sector 0 has none by construction. `overrides` is that `(layer,
69/// sector)`'s `LROV` payload, when it has one.
70pub fn resolve(
71    meta: &Meta,
72    sector: Option<&Sector>,
73    overrides: Option<&Timing>,
74    layer: u32,
75    sector_id: u32,
76) -> Result<Resolved> {
77    // The pipeline needs a concrete value for every required META field except
78    // the schema version, which `validate` owns and a resolved layer never
79    // depends on. Presence can only be read from the parsed `Option`s here.
80    let missing: Vec<&str> = REQUIRED_META_FIELDS
81        .iter()
82        .copied()
83        .filter(|name| *name != "meta_version")
84        .filter(|name| !meta_carries(meta, name))
85        .collect();
86    if !missing.is_empty() {
87        return Err(Error::new(
88            Check::MetaRequiredFields,
89            format!("META is missing {}", missing.join(", ")),
90        ));
91    }
92
93    let timing = &meta.timing;
94    // A `META.sectors` entry describes a non-zero sector, so it only speaks
95    // for one; sector 0 has none by construction.
96    let sect = match sector_id {
97        0 => None,
98        _ => sector.map(|definition| &definition.timing),
99    };
100
101    // Base values: META, with every field the sector definition carries
102    // replacing the META value for that sector.
103    let layer_height_um = merged(timing, sect, |t| t.layer_height_um).unwrap_or_default();
104    let bottom_layer_count = merged(timing, sect, |t| t.bottom_layer_count).unwrap_or_default();
105    let transition_layer_count =
106        merged(timing, sect, |t| t.transition_layer_count).unwrap_or_default();
107    let light_pwm = merged(timing, sect, |t| t.light_pwm).unwrap_or(DEFAULT_LIGHT_PWM);
108    let bottom_light_pwm = merged(timing, sect, |t| t.bottom_light_pwm).unwrap_or(light_pwm);
109
110    // The values the bottom and transition ranges blend, each as a (normal,
111    // bottom) pair. An absent optional field resolves to `0`, which the spec
112    // renders as a segment that is not performed, or no pause at all.
113    let exposure = pair!(timing, sect, normal_exposure_ms, bottom_exposure_ms, 0);
114    let lift_slow_distance = pair!(
115        timing,
116        sect,
117        lift_slow_distance_um,
118        bottom_lift_slow_distance_um,
119        0
120    );
121    let lift_slow_speed = pair!(
122        timing,
123        sect,
124        lift_slow_speed_um_min,
125        bottom_lift_slow_speed_um_min,
126        0
127    );
128    let lift_fast_distance = pair!(
129        timing,
130        sect,
131        lift_fast_distance_um,
132        bottom_lift_fast_distance_um,
133        0
134    );
135    let lift_fast_speed = pair!(
136        timing,
137        sect,
138        lift_fast_speed_um_min,
139        bottom_lift_fast_speed_um_min,
140        0
141    );
142    let retract_fast_distance = pair!(
143        timing,
144        sect,
145        retract_fast_distance_um,
146        bottom_retract_fast_distance_um,
147        0
148    );
149    let retract_fast_speed = pair!(
150        timing,
151        sect,
152        retract_fast_speed_um_min,
153        bottom_retract_fast_speed_um_min,
154        0
155    );
156    let retract_slow_distance = pair!(
157        timing,
158        sect,
159        retract_slow_distance_um,
160        bottom_retract_slow_distance_um,
161        0
162    );
163    let retract_slow_speed = pair!(
164        timing,
165        sect,
166        retract_slow_speed_um_min,
167        bottom_retract_slow_speed_um_min,
168        0
169    );
170    let wait_before_cure = pair!(
171        timing,
172        sect,
173        wait_time_before_cure_ms,
174        bottom_wait_time_before_cure_ms,
175        0
176    );
177    let wait_after_cure = pair!(
178        timing,
179        sect,
180        wait_time_after_cure_ms,
181        bottom_wait_time_after_cure_ms,
182        0
183    );
184    let wait_after_lift = pair!(
185        timing,
186        sect,
187        wait_time_after_lift_ms,
188        bottom_wait_time_after_lift_ms,
189        0
190    );
191
192    // `u64` keeps `bottom + transition` from wrapping on a malformed META.
193    let bottom = u64::from(bottom_layer_count);
194    let transition = u64::from(transition_layer_count);
195    let index = u64::from(layer);
196    let stage = if index < bottom {
197        Stage::Bottom
198    } else if index < bottom + transition {
199        // One step into the range is the first blend step, so `k` runs
200        // `1..=transition_layer_count` and the first fully-normal layer,
201        // `bottom + transition`, is the `k == N` case the formula collapses to
202        // the normal value. Both counts are `u32`s widened before the `+ 1`, so
203        // even `transition = u32::MAX` cannot overflow.
204        Stage::Transition {
205            k: index - bottom + 1,
206            n: transition + 1,
207        }
208    } else {
209        Stage::Normal
210    };
211
212    let mut resolved = Resolved {
213        layer_height_um,
214        exposure_ms: blend_u32(exposure, stage),
215        lift_slow_distance_um: blend_u32(lift_slow_distance, stage),
216        lift_slow_speed_um_min: blend_u32(lift_slow_speed, stage),
217        lift_fast_distance_um: blend_u32(lift_fast_distance, stage),
218        lift_fast_speed_um_min: blend_u32(lift_fast_speed, stage),
219        retract_fast_distance_um: blend_u32(retract_fast_distance, stage),
220        retract_fast_speed_um_min: blend_u32(retract_fast_speed, stage),
221        retract_slow_distance_um: blend_u32(retract_slow_distance, stage),
222        retract_slow_speed_um_min: blend_u32(retract_slow_speed, stage),
223        wait_time_before_cure_ms: blend_u32(wait_before_cure, stage),
224        wait_time_after_cure_ms: blend_u32(wait_after_cure, stage),
225        wait_time_after_lift_ms: blend_u32(wait_after_lift, stage),
226        // PWM is categorical: it switches at the first non-bottom layer instead
227        // of interpolating.
228        light_pwm: match stage {
229            Stage::Bottom => bottom_light_pwm,
230            _ => light_pwm,
231        },
232        chamber_temperature_c: merged(timing, sect, |t| t.chamber_temperature_c),
233        vat_temperature_c: merged(timing, sect, |t| t.vat_temperature_c),
234        cure_curve: merged(timing, sect, |t| t.cure_curve),
235        is_bottom: matches!(stage, Stage::Bottom),
236        is_transition: matches!(stage, Stage::Transition { .. }),
237    };
238
239    // A (layer, sector) has one override set or none, so this is a single
240    // replacement rather than a fold: a field the payload leaves out keeps what
241    // the pipeline resolved for it.
242    if let Some(overrides) = overrides {
243        fold_entry(&mut resolved, overrides);
244    }
245
246    Ok(resolved)
247}
248
249/// Which range a layer falls in.
250#[derive(Clone, Copy)]
251enum Stage {
252    /// Below `bottom_layer_count`: bottom-prefixed values, verbatim.
253    Bottom,
254    /// At or beyond `bottom_layer_count + transition_layer_count`: normal values.
255    Normal,
256    /// Inside the transition range: the `k`th of `n` blend steps.
257    Transition { k: u64, n: u64 },
258}
259
260/// One field's two forms, with an absent bottom form already folded onto the
261/// normal one, which is what makes its interpolation a no-op.
262#[derive(Clone, Copy)]
263struct Blend<T> {
264    /// The normal-layer value.
265    normal: T,
266    /// The bottom-layer value.
267    bottom: T,
268}
269
270impl<T: Copy> Blend<T> {
271    /// Pair `normal` with `bottom`, defaulting an absent `normal` to `default`
272    /// and an absent `bottom` to the resolved normal value.
273    fn new(normal: Option<T>, bottom: Option<T>, default: T) -> Self {
274        let normal = normal.unwrap_or(default);
275        Blend {
276            normal,
277            bottom: bottom.unwrap_or(normal),
278        }
279    }
280}
281
282/// A META field with the sector override applied.
283///
284/// `sector` is the definition's timing namespace, or `None` for sector 0.
285fn merged<T: Copy>(
286    meta: &Timing,
287    sector: Option<&Timing>,
288    get: impl Fn(&Timing) -> Option<T>,
289) -> Option<T> {
290    sector.and_then(&get).or_else(|| get(meta))
291}
292
293/// One field's `(normal, bottom)` pair, each with the sector override applied.
294fn field_pair<T: Copy>(
295    meta: &Timing,
296    sector: Option<&Timing>,
297    normal: impl Fn(&Timing) -> Option<T>,
298    bottom: impl Fn(&Timing) -> Option<T>,
299    default: T,
300) -> Blend<T> {
301    Blend::new(
302        merged(meta, sector, normal),
303        merged(meta, sector, bottom),
304        default,
305    )
306}
307
308/// Whether META carries a value for `name`, one of the fields the pipeline
309/// needs ([`REQUIRED_META_FIELDS`] without `meta_version`).
310///
311/// [`resolve`] only receives the parsed struct, so unlike `validate`, which
312/// reads the raw JSON object, it has nothing but the `Option`s to go on.
313fn meta_carries(meta: &Meta, name: &str) -> bool {
314    let timing = &meta.timing;
315    match name {
316        "normal_exposure_ms" => timing.normal_exposure_ms.is_some(),
317        "bottom_exposure_ms" => timing.bottom_exposure_ms.is_some(),
318        "bottom_layer_count" => timing.bottom_layer_count.is_some(),
319        "transition_layer_count" => timing.transition_layer_count.is_some(),
320        "layer_height_um" => timing.layer_height_um.is_some(),
321        "lift_slow_distance_um" => timing.lift_slow_distance_um.is_some(),
322        "lift_slow_speed_um_min" => timing.lift_slow_speed_um_min.is_some(),
323        "retract_fast_distance_um" => timing.retract_fast_distance_um.is_some(),
324        "retract_fast_speed_um_min" => timing.retract_fast_speed_um_min.is_some(),
325        _ => false,
326    }
327}
328
329/// An integer field at `stage`: verbatim at either end, blended in between.
330///
331/// The blend is the specification's exact integer form (ยง8), with `N = n` and
332/// `k` the step:
333///
334/// ```text
335/// value = round((bottom * (N - k) + normal * k) / N)
336/// ```
337///
338/// `round` is half away from zero, which for the non-negative values here is
339/// half **up**: a remainder of exactly half a unit rounds to the larger value.
340/// The numerator is computed in `u128` and the doubling the tie needs is folded
341/// into it, so no pair of `u32` values and no layer count a malformed META can
342/// carry can overflow the intermediate or truncate the result: the value is a
343/// convex combination of the two ends, so it lies between them and fits the
344/// `u32` it came from.
345fn blend_u32(field: Blend<u32>, stage: Stage) -> u32 {
346    match stage {
347        Stage::Bottom => field.bottom,
348        Stage::Normal => field.normal,
349        Stage::Transition { k, n } => {
350            let bottom = u128::from(field.bottom);
351            let normal = u128::from(field.normal);
352            let numerator = bottom * (n - k) as u128 + normal * k as u128;
353            // `floor(numerator / n + 1/2)`, written so that the half-unit is
354            // exact: `(2 * numerator + n) / (2 * n)`. `n` is
355            // `transition_layer_count + 1`, so it is never zero, and `k < n`
356            // inside the range.
357            ((2 * numerator + n as u128) / (2 * n as u128)) as u32
358        }
359    }
360}
361
362/// Replace the fields one `(layer, sector)`'s `LROV` payload carries.
363///
364/// The payload replaces only the fields it names, so an absent field keeps the
365/// value the pipeline resolved for it. When it names a field in both its normal
366/// and its `bottom_` form, the normal one wins: an `LROV` payload overrides the
367/// layer's single resolved value, not the two ends of a blend.
368fn fold_entry(resolved: &mut Resolved, over: &Timing) {
369    if let Some(value) = over.layer_height_um {
370        resolved.layer_height_um = value;
371    }
372    if let Some(value) = over.normal_exposure_ms.or(over.bottom_exposure_ms) {
373        resolved.exposure_ms = value;
374    }
375    if let Some(value) = over
376        .lift_slow_distance_um
377        .or(over.bottom_lift_slow_distance_um)
378    {
379        resolved.lift_slow_distance_um = value;
380    }
381    if let Some(value) = over
382        .lift_slow_speed_um_min
383        .or(over.bottom_lift_slow_speed_um_min)
384    {
385        resolved.lift_slow_speed_um_min = value;
386    }
387    if let Some(value) = over
388        .lift_fast_distance_um
389        .or(over.bottom_lift_fast_distance_um)
390    {
391        resolved.lift_fast_distance_um = value;
392    }
393    if let Some(value) = over
394        .lift_fast_speed_um_min
395        .or(over.bottom_lift_fast_speed_um_min)
396    {
397        resolved.lift_fast_speed_um_min = value;
398    }
399    if let Some(value) = over
400        .retract_fast_distance_um
401        .or(over.bottom_retract_fast_distance_um)
402    {
403        resolved.retract_fast_distance_um = value;
404    }
405    if let Some(value) = over
406        .retract_fast_speed_um_min
407        .or(over.bottom_retract_fast_speed_um_min)
408    {
409        resolved.retract_fast_speed_um_min = value;
410    }
411    if let Some(value) = over
412        .retract_slow_distance_um
413        .or(over.bottom_retract_slow_distance_um)
414    {
415        resolved.retract_slow_distance_um = value;
416    }
417    if let Some(value) = over
418        .retract_slow_speed_um_min
419        .or(over.bottom_retract_slow_speed_um_min)
420    {
421        resolved.retract_slow_speed_um_min = value;
422    }
423    if let Some(value) = over
424        .wait_time_before_cure_ms
425        .or(over.bottom_wait_time_before_cure_ms)
426    {
427        resolved.wait_time_before_cure_ms = value;
428    }
429    if let Some(value) = over
430        .wait_time_after_cure_ms
431        .or(over.bottom_wait_time_after_cure_ms)
432    {
433        resolved.wait_time_after_cure_ms = value;
434    }
435    if let Some(value) = over
436        .wait_time_after_lift_ms
437        .or(over.bottom_wait_time_after_lift_ms)
438    {
439        resolved.wait_time_after_lift_ms = value;
440    }
441    if let Some(value) = over.light_pwm.or(over.bottom_light_pwm) {
442        resolved.light_pwm = value;
443    }
444    if let Some(value) = over.chamber_temperature_c {
445        resolved.chamber_temperature_c = Some(value);
446    }
447    if let Some(value) = over.vat_temperature_c {
448        resolved.vat_temperature_c = Some(value);
449    }
450    if let Some(value) = over.cure_curve {
451        resolved.cure_curve = Some(value);
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    /// META with every required field, the optional four-segment motion, and the
460    /// bottom counts the acceptance case names.
461    fn meta() -> Meta {
462        Meta {
463            meta_version: Some(1),
464            timing: Timing {
465                layer_height_um: Some(50),
466                normal_exposure_ms: Some(2500),
467                bottom_exposure_ms: Some(30000),
468                bottom_layer_count: Some(4),
469                transition_layer_count: Some(8),
470                lift_slow_distance_um: Some(5000),
471                lift_slow_speed_um_min: Some(65000),
472                lift_fast_distance_um: Some(3000),
473                lift_fast_speed_um_min: Some(180000),
474                retract_fast_distance_um: Some(5000),
475                retract_fast_speed_um_min: Some(150000),
476                retract_slow_distance_um: Some(3000),
477                retract_slow_speed_um_min: Some(180000),
478                wait_time_before_cure_ms: Some(1000),
479                wait_time_after_cure_ms: Some(0),
480                wait_time_after_lift_ms: Some(500),
481                light_pwm: Some(255),
482                ..Timing::default()
483            },
484            ..Meta::default()
485        }
486    }
487
488    /// Sector 0's resolution, which is what most cases look at.
489    fn at(meta: &Meta, layer: u32) -> Resolved {
490        resolve(meta, None, None, layer, 0).unwrap()
491    }
492
493    #[test]
494    fn exposure_blends_bottom_transition_and_normal() {
495        let meta = meta();
496
497        assert_eq!(at(&meta, 0).exposure_ms, 30000);
498        assert_eq!(at(&meta, 3).exposure_ms, 30000);
499        // Layer 4 is `k = 1` of `N = 9`:
500        // (30000 * 8 + 2500 * 1) / 9 = 26944.44.. -> 26944.
501        assert_eq!(at(&meta, 4).exposure_ms, 26944);
502        // Layer 11 is `k = 8`: (30000 * 1 + 2500 * 8) / 9 = 5555.55.. -> 5556.
503        assert_eq!(at(&meta, 11).exposure_ms, 5556);
504        assert_eq!(at(&meta, 12).exposure_ms, 2500);
505
506        assert_eq!(
507            (at(&meta, 0).is_bottom, at(&meta, 0).is_transition),
508            (true, false)
509        );
510        assert_eq!(
511            (at(&meta, 3).is_bottom, at(&meta, 3).is_transition),
512            (true, false)
513        );
514        assert_eq!(
515            (at(&meta, 4).is_bottom, at(&meta, 4).is_transition),
516            (false, true)
517        );
518        assert_eq!(
519            (at(&meta, 11).is_bottom, at(&meta, 11).is_transition),
520            (false, true)
521        );
522        assert_eq!(
523            (at(&meta, 12).is_bottom, at(&meta, 12).is_transition),
524            (false, false)
525        );
526        assert_eq!(
527            (at(&meta, 400).is_bottom, at(&meta, 400).is_transition),
528            (false, false)
529        );
530    }
531
532    #[test]
533    fn wait_times_interpolate_between_their_two_forms() {
534        let mut meta = meta();
535        meta.timing.bottom_wait_time_before_cure_ms = Some(10000);
536
537        assert_eq!(at(&meta, 0).wait_time_before_cure_ms, 10000);
538        // `k = 1` of `N = 9`: (10000 * 8 + 1000 * 1) / 9 = 9000 exactly.
539        assert_eq!(at(&meta, 4).wait_time_before_cure_ms, 9000);
540        assert_eq!(at(&meta, 12).wait_time_before_cure_ms, 1000);
541        // A wait with no bottom form holds its normal value throughout.
542        assert_eq!(at(&meta, 0).wait_time_after_lift_ms, 500);
543        assert_eq!(at(&meta, 5).wait_time_after_lift_ms, 500);
544    }
545
546    #[test]
547    fn absent_bottom_form_inherits_its_normal_value() {
548        let meta = meta();
549        // No `bottom_lift_fast_distance_um` in META, so a bottom layer lifts the
550        // same as a normal one.
551        assert_eq!(at(&meta, 0).lift_fast_distance_um, 3000);
552        assert_eq!(at(&meta, 6).lift_fast_distance_um, 3000);
553        assert_eq!(at(&meta, 40).lift_fast_distance_um, 3000);
554
555        // An optional segment that is absent altogether is not performed.
556        let mut without = meta.clone();
557        without.timing.lift_fast_distance_um = None;
558        assert_eq!(at(&without, 40).lift_fast_distance_um, 0);
559    }
560
561    #[test]
562    fn interpolation_rounds_half_away_from_zero() {
563        let mut meta = meta();
564        meta.timing.bottom_lift_slow_distance_um = Some(6000);
565
566        assert_eq!(at(&meta, 0).lift_slow_distance_um, 6000);
567        // `k = 1` of `N = 9`: (6000 * 8 + 5000 * 1) / 9 = 5888.88.. -> 5889.
568        assert_eq!(at(&meta, 4).lift_slow_distance_um, 5889);
569        assert_eq!(at(&meta, 12).lift_slow_distance_um, 5000);
570
571        // A one-layer transition makes `N = 2`, so an odd sum lands exactly on
572        // the half: `(bottom + normal) / 2`. Truncation would take the smaller
573        // value every time; the rule takes the larger, half away from zero.
574        let mut half = meta.clone();
575        half.timing.bottom_layer_count = Some(1);
576        half.timing.transition_layer_count = Some(1);
577        half.timing.lift_slow_distance_um = Some(5000);
578        half.timing.bottom_lift_slow_distance_um = Some(5001);
579        half.timing.normal_exposure_ms = Some(2500);
580        half.timing.bottom_exposure_ms = Some(2501);
581        half.timing.wait_time_after_lift_ms = Some(100);
582        half.timing.bottom_wait_time_after_lift_ms = Some(101);
583
584        assert_eq!(at(&half, 1).lift_slow_distance_um, 5001);
585        assert_eq!(at(&half, 1).exposure_ms, 2501);
586        assert_eq!(at(&half, 1).wait_time_after_lift_ms, 101);
587        // Either end is verbatim: layer 0 is a bottom layer, layer 2 normal.
588        assert_eq!(at(&half, 0).exposure_ms, 2501);
589        assert_eq!(at(&half, 2).exposure_ms, 2500);
590
591        // The widest layer counts a malformed META can carry: `N` is 2^32 and
592        // the weighted sum reaches close to 2^64. That is the case the
593        // intermediate width exists for, so it is pinned: an implementation
594        // computing this in `u32` would wrap `bottom + transition` to zero and
595        // one computing the sum too narrowly would truncate. The value is a
596        // convex combination rounded to nearest, `u32::MAX / 2^32`.
597        let mut extreme = meta.clone();
598        extreme.timing.bottom_layer_count = Some(0);
599        extreme.timing.transition_layer_count = Some(u32::MAX);
600        extreme.timing.normal_exposure_ms = Some(0);
601        extreme.timing.bottom_exposure_ms = Some(u32::MAX);
602
603        assert_eq!(at(&extreme, u32::MAX - 1).exposure_ms, 1);
604        assert!(at(&extreme, u32::MAX - 1).is_transition);
605        assert_eq!(at(&extreme, u32::MAX).exposure_ms, 0);
606    }
607
608    #[test]
609    fn light_pwm_switches_at_the_bottom_boundary_without_interpolating() {
610        let mut meta = meta();
611        meta.timing.bottom_light_pwm = Some(200);
612
613        assert_eq!(at(&meta, 3).light_pwm, 200);
614        // The first non-bottom layer, even a transition one, takes the normal
615        // value: the boundary is `bottom_layer_count`, not the end of blending.
616        assert_eq!(at(&meta, 4).light_pwm, 255);
617
618        // An absent bottom PWM falls back to the normal one.
619        let mut no_bottom_pwm = meta.clone();
620        no_bottom_pwm.timing.bottom_light_pwm = None;
621        no_bottom_pwm.timing.light_pwm = Some(180);
622        assert_eq!(at(&no_bottom_pwm, 0).light_pwm, 180);
623        assert_eq!(at(&no_bottom_pwm, 4).light_pwm, 180);
624
625        // An absent normal PWM is the spec's default of 255.
626        let mut no_pwm = meta.clone();
627        no_pwm.timing.light_pwm = None;
628        no_pwm.timing.bottom_light_pwm = None;
629        assert_eq!(at(&no_pwm, 0).light_pwm, 255);
630        assert_eq!(at(&no_pwm, 40).light_pwm, 255);
631    }
632
633    #[test]
634    fn a_sector_entry_overrides_its_own_sector_only() {
635        let meta = meta();
636        let sector = Sector {
637            sector_id: 1,
638            timing: Timing {
639                normal_exposure_ms: Some(3000),
640                bottom_exposure_ms: Some(35000),
641                ..Timing::default()
642            },
643            ..Sector::default()
644        };
645
646        // Sector 0 keeps META even when an entry is offered.
647        assert_eq!(
648            resolve(&meta, Some(&sector), None, 0, 0)
649                .unwrap()
650                .exposure_ms,
651            30000
652        );
653        assert_eq!(
654            resolve(&meta, Some(&sector), None, 12, 0)
655                .unwrap()
656                .exposure_ms,
657            2500
658        );
659
660        // Sector 1 blends the entry's own pair.
661        assert_eq!(
662            resolve(&meta, Some(&sector), None, 0, 1)
663                .unwrap()
664                .exposure_ms,
665            35000
666        );
667        assert_eq!(
668            resolve(&meta, Some(&sector), None, 12, 1)
669                .unwrap()
670                .exposure_ms,
671            3000
672        );
673
674        // A field the entry leaves out still comes from META...
675        assert_eq!(
676            resolve(&meta, Some(&sector), None, 12, 1)
677                .unwrap()
678                .lift_slow_distance_um,
679            5000
680        );
681        // ...for a sector with no entry at all, as well.
682        assert_eq!(resolve(&meta, None, None, 12, 1).unwrap().exposure_ms, 2500);
683
684        // The entry's own layer counts move the ranges for its sector.
685        let sector = Sector {
686            sector_id: 1,
687            timing: Timing {
688                bottom_layer_count: Some(6),
689                transition_layer_count: Some(0),
690                ..Timing::default()
691            },
692            ..Sector::default()
693        };
694        assert!(resolve(&meta, Some(&sector), None, 5, 1).unwrap().is_bottom);
695        assert!(
696            resolve(&meta, Some(&sector), None, 5, 0)
697                .unwrap()
698                .is_transition
699        );
700    }
701
702    #[test]
703    fn temperatures_and_cure_curve_come_from_meta_or_a_sector_entry_unblended() {
704        let mut meta = meta();
705        meta.timing.chamber_temperature_c = Some(30.0);
706        meta.timing.cure_curve = Some(CureCurve {
707            dp_um: 120.0,
708            ec_mj_cm2: 7.5,
709            e0_mj_cm2: 3.0,
710        });
711
712        let resolved = at(&meta, 0);
713        assert_eq!(resolved.chamber_temperature_c, Some(30.0));
714        assert_eq!(resolved.vat_temperature_c, None);
715        assert_eq!(
716            resolved.cure_curve,
717            Some(CureCurve {
718                dp_um: 120.0,
719                ec_mj_cm2: 7.5,
720                e0_mj_cm2: 3.0,
721            })
722        );
723
724        let sector = Sector {
725            sector_id: 1,
726            timing: Timing {
727                chamber_temperature_c: Some(40.0),
728                ..Timing::default()
729            },
730            ..Sector::default()
731        };
732        let for_sector_1 = resolve(&meta, Some(&sector), None, 0, 1).unwrap();
733        assert_eq!(for_sector_1.chamber_temperature_c, Some(40.0));
734        assert_eq!(for_sector_1.vat_temperature_c, None);
735        assert_eq!(for_sector_1.cure_curve.unwrap().dp_um, 120.0);
736        assert_eq!(at(&meta, 0).chamber_temperature_c, Some(30.0));
737    }
738
739    #[test]
740    fn an_lrov_payload_replaces_only_the_fields_it_carries() {
741        let meta = meta();
742        let overrides = Timing {
743            normal_exposure_ms: Some(9000),
744            lift_slow_distance_um: Some(7000),
745            ..Timing::default()
746        };
747
748        let resolved = resolve(&meta, None, Some(&overrides), 12, 0).unwrap();
749        assert_eq!(resolved.exposure_ms, 9000);
750        assert_eq!(resolved.lift_slow_distance_um, 7000);
751        // A field the payload leaves out keeps the blended value.
752        assert_eq!(resolved.lift_slow_speed_um_min, 65_000);
753
754        // The payload is applied to the (layer, sector) the caller passes it
755        // for; which one that is comes from the table entry that points at it,
756        // not from anything inside the payload, so a point with no payload
757        // resolves from the pipeline alone.
758        assert_eq!(resolve(&meta, None, None, 16, 0).unwrap().exposure_ms, 2500);
759        assert_eq!(resolve(&meta, None, None, 12, 1).unwrap().exposure_ms, 2500);
760    }
761
762    #[test]
763    fn an_lrov_payload_overrides_pwm_and_optional_motion() {
764        let meta = meta();
765        let overrides = Timing {
766            normal_exposure_ms: Some(9000),
767            light_pwm: Some(120),
768            bottom_light_pwm: Some(130),
769            lift_fast_distance_um: Some(2500),
770            chamber_temperature_c: Some(35.0),
771            ..Timing::default()
772        };
773
774        let resolved = resolve(&meta, None, Some(&overrides), 1, 0).unwrap();
775        assert_eq!(resolved.light_pwm, 120);
776        assert_eq!(resolved.lift_fast_distance_um, 2500);
777        assert_eq!(resolved.chamber_temperature_c, Some(35.0));
778        // The payload names the resolved value, not the blend's two ends.
779        assert_eq!(
780            resolve(&meta, None, Some(&overrides), 0, 0)
781                .unwrap()
782                .light_pwm,
783            120
784        );
785        assert_eq!(resolve(&meta, None, None, 0, 0).unwrap().light_pwm, 255);
786    }
787
788    #[test]
789    fn missing_required_meta_field_is_reported() {
790        let mut meta = meta();
791        meta.timing.bottom_exposure_ms = None;
792        meta.timing.lift_slow_speed_um_min = None;
793
794        let err = resolve(&meta, None, None, 0, 0).unwrap_err();
795        assert_eq!(err.check(), Check::MetaRequiredFields);
796        let detail = err.detail();
797        assert!(detail.contains("bottom_exposure_ms"), "{detail}");
798        assert!(detail.contains("lift_slow_speed_um_min"), "{detail}");
799        assert!(!detail.contains("normal_exposure_ms"), "{detail}");
800
801        // The schema version is `validate`'s business, not the pipeline's.
802        let mut no_version = meta.clone();
803        no_version.meta_version = None;
804        no_version.timing.bottom_exposure_ms = Some(30000);
805        no_version.timing.lift_slow_speed_um_min = Some(65000);
806        assert!(resolve(&no_version, None, None, 0, 0).is_ok());
807    }
808}