Skip to main content

sim_lib_view_math/
heatmap.rs

1//! Bounded scalar-grid projection as a domain-neutral `scene/heatmap`.
2//!
3//! The caller owns detector semantics and prepares the exact values and mask
4//! that may be displayed. This module derives a display budget from open
5//! [`SurfaceCaps`] metadata, refuses data that exceeds it, and never
6//! downsamples or otherwise changes the caller's grid. A domain's `detail`
7//! projection preserves source cells and must refuse an undersized target;
8//! detector integration is a separate upstream operation that integrates every
9//! covered source cell before this module sees the result. Detector labels,
10//! advisories, and evidence remain caller-owned metadata rather than claims
11//! manufactured by the generic view.
12
13use sim_kernel::{Error, Expr, Result};
14use sim_lib_scene::{HEATMAP_PALETTES, data_map, heatmap_payload_bytes, node, sym, validate_scene};
15use sim_lib_view::SurfaceCaps;
16use sim_value::{access, build};
17
18use crate::num::number;
19
20/// Named sequential palette for monotonically ordered scalar data.
21pub const VIRIDIS_PALETTE: &str = "viridis";
22
23/// Named diverging palette for signed or reference-centred scalar data.
24pub const BLUE_RED_PALETTE: &str = "blue-red";
25
26/// Named cyclic palette for phase-like scalar data whose endpoints meet.
27pub const CYCLIC_PHASE_PALETTE: &str = "cyclic-phase";
28
29/// Absolute cell ceiling for one heatmap Scene, independent of surface claims.
30pub const MAX_HEATMAP_CELLS: usize = 1024 * 1024;
31
32/// Absolute scalar-payload ceiling for one heatmap Scene.
33pub const MAX_HEATMAP_BYTES: u64 = 32 * 1024 * 1024;
34
35const DEFAULT_CELLS: usize = 64 * 1024;
36const DEFAULT_BYTES: u64 = 1024 * 1024;
37
38/// Caller-prepared scalar grid projected by [`heatmap_view`].
39///
40/// `values` and `valid` are row-major and must each contain exactly
41/// `rows * cols` entries. The helper validates them but never resamples them:
42/// only the caller's domain knows whether a smaller grid requires point
43/// sampling, area integration, complex averaging, or another detector rule.
44#[derive(Clone, Copy, Debug)]
45pub struct HeatmapData<'a> {
46    /// Number of non-zero grid rows.
47    pub rows: usize,
48    /// Number of non-zero grid columns.
49    pub cols: usize,
50    /// Finite row-major scalar values.
51    pub values: &'a [f64],
52    /// Row-major validity mask, parallel to `values`.
53    pub valid: &'a [bool],
54    /// Finite inclusive display range `(min, max)`.
55    pub range: (f64, f64),
56    /// One of [`sim_lib_scene::HEATMAP_PALETTES`].
57    pub palette: &'a str,
58    /// Non-empty accessible label for the represented quantity.
59    pub label: &'a str,
60    /// Non-empty caller-supplied detector or sampling description.
61    pub detector: &'a str,
62    /// Optional non-empty warning or qualification rendered with the grid.
63    pub advisory: Option<&'a str>,
64}
65
66/// Checked cell and scalar-payload ceilings derived from display metadata.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub struct HeatmapBudget {
69    max_cells: usize,
70    max_bytes: u64,
71}
72
73impl HeatmapBudget {
74    /// Maximum caller-prepared grid cells.
75    pub const fn max_cells(self) -> usize {
76        self.max_cells
77    }
78
79    /// Maximum scalar-and-metadata payload bytes.
80    pub const fn max_bytes(self) -> u64 {
81        self.max_bytes
82    }
83}
84
85/// Derive the checked heatmap budget for a surface.
86///
87/// Density provides a conservative baseline; physical pixel dimensions cap
88/// the useful cell count when present. Open display metadata may advertise
89/// lower or higher `heatmap-max-cells` and `heatmap-max-bytes` values, bounded
90/// by [`MAX_HEATMAP_CELLS`] and [`MAX_HEATMAP_BYTES`]. Malformed or overflowing
91/// advertised values fail closed.
92pub fn heatmap_budget(caps: &SurfaceCaps) -> Result<HeatmapBudget> {
93    let (density_cells, density_bytes) = density_budget(caps);
94    let advertised_cells =
95        display_limit(&caps.display, "heatmap-max-cells")?.unwrap_or(density_cells as u64);
96    let advertised_bytes =
97        display_limit(&caps.display, "heatmap-max-bytes")?.unwrap_or(density_bytes);
98    if advertised_cells == 0 || advertised_cells > MAX_HEATMAP_CELLS as u64 {
99        return Err(Error::Eval(format!(
100            "surface heatmap cell budget {advertised_cells} is outside 1..={MAX_HEATMAP_CELLS}"
101        )));
102    }
103    if advertised_bytes == 0 || advertised_bytes > MAX_HEATMAP_BYTES {
104        return Err(Error::Eval(format!(
105            "surface heatmap byte budget {advertised_bytes} is outside 1..={MAX_HEATMAP_BYTES}"
106        )));
107    }
108    let pixel_cells = display_pixel_cells(&caps.display)?;
109    let max_cells = usize::try_from(
110        pixel_cells.map_or(advertised_cells, |pixels| advertised_cells.min(pixels)),
111    )
112    .map_err(|_| Error::Eval("surface heatmap cell budget does not fit usize".to_owned()))?;
113    if max_cells == 0 {
114        return Err(Error::Eval(
115            "surface heatmap pixel dimensions admit no cells".to_owned(),
116        ));
117    }
118    Ok(HeatmapBudget {
119        max_cells,
120        max_bytes: advertised_bytes,
121    })
122}
123
124/// Return only the checked cell ceiling from [`heatmap_budget`].
125pub fn heatmap_cell_budget(caps: &SurfaceCaps) -> Result<usize> {
126    heatmap_budget(caps).map(HeatmapBudget::max_cells)
127}
128
129/// Return only the checked scalar-payload byte ceiling from [`heatmap_budget`].
130pub fn heatmap_byte_budget(caps: &SurfaceCaps) -> Result<u64> {
131    heatmap_budget(caps).map(HeatmapBudget::max_bytes)
132}
133
134/// Validate and project caller-prepared data to one `scene/heatmap`.
135///
136/// Oversized input is refused with a diagnostic telling the caller to apply
137/// its domain-specific detector rule first. This function never downsamples,
138/// drops, reorders, clamps, or otherwise changes a grid cell.
139pub fn heatmap_view(data: HeatmapData<'_>, caps: &SurfaceCaps) -> Result<Expr> {
140    let cells = data
141        .rows
142        .checked_mul(data.cols)
143        .ok_or_else(|| Error::Eval("heatmap rows * cols overflowed".to_owned()))?;
144    if data.rows == 0 || data.cols == 0 {
145        return Err(Error::Eval(
146            "heatmap rows and cols must be non-zero".to_owned(),
147        ));
148    }
149    if data.values.len() != cells {
150        return Err(Error::Eval(format!(
151            "heatmap rows * cols is {cells}, but values has {} entries",
152            data.values.len()
153        )));
154    }
155    if data.valid.len() != cells {
156        return Err(Error::Eval(format!(
157            "heatmap rows * cols is {cells}, but valid has {} entries",
158            data.valid.len()
159        )));
160    }
161    if let Some(index) = data.values.iter().position(|value| !value.is_finite()) {
162        return Err(Error::Eval(format!(
163            "heatmap values[{index}] must be finite"
164        )));
165    }
166    if !data.range.0.is_finite() || !data.range.1.is_finite() || data.range.0 > data.range.1 {
167        return Err(Error::Eval(
168            "heatmap range must be finite with min <= max".to_owned(),
169        ));
170    }
171    if !HEATMAP_PALETTES.contains(&data.palette) {
172        return Err(Error::Eval(format!(
173            "heatmap palette '{}' is not recognized",
174            data.palette
175        )));
176    }
177    require_metadata("label", data.label)?;
178    require_metadata("detector", data.detector)?;
179    if let Some(advisory) = data.advisory {
180        require_metadata("advisory", advisory)?;
181    }
182    let cells_u64 = u64::try_from(cells)
183        .map_err(|_| Error::Eval("heatmap cell count does not fit u64".to_owned()))?;
184    let bytes = heatmap_payload_bytes(cells_u64, data.label, data.detector, data.advisory)
185        .ok_or_else(|| Error::Eval("heatmap byte footprint overflowed".to_owned()))?;
186    let budget = heatmap_budget(caps)?;
187    if cells > budget.max_cells {
188        return Err(Error::Eval(format!(
189            "heatmap has {cells} cells, surface budget is {}; apply the domain detector rule first",
190            budget.max_cells
191        )));
192    }
193    if bytes > budget.max_bytes {
194        return Err(Error::Eval(format!(
195            "heatmap has a {bytes}-byte payload, surface budget is {} bytes; apply the domain detector rule first",
196            budget.max_bytes
197        )));
198    }
199
200    let mut entries = vec![
201        ("rows", build::uint(data.rows as u64)),
202        ("cols", build::uint(data.cols as u64)),
203        (
204            "values",
205            Expr::List(data.values.iter().copied().map(number).collect()),
206        ),
207        (
208            "valid",
209            Expr::List(data.valid.iter().copied().map(Expr::Bool).collect()),
210        ),
211        ("min", number(data.range.0)),
212        ("max", number(data.range.1)),
213        ("palette", sym(data.palette)),
214        ("label", Expr::String(data.label.to_owned())),
215        ("detector", Expr::String(data.detector.to_owned())),
216        (
217            "footprint",
218            data_map(vec![
219                ("cells", build::uint(cells_u64)),
220                ("bytes", build::uint(bytes)),
221            ]),
222        ),
223    ];
224    if let Some(advisory) = data.advisory {
225        entries.push(("advisory", Expr::String(advisory.to_owned())));
226    }
227    let scene = node("heatmap", entries);
228    validate_scene(&scene)
229        .map_err(|error| Error::Eval(format!("invalid scene/heatmap: {error}")))?;
230    Ok(scene)
231}
232
233fn require_metadata(name: &str, value: &str) -> Result<()> {
234    if value.trim().is_empty() {
235        Err(Error::Eval(format!("heatmap {name} must not be empty")))
236    } else {
237        Ok(())
238    }
239}
240
241fn density_budget(caps: &SurfaceCaps) -> (usize, u64) {
242    let density = caps.display_density();
243    match density.as_ref().map(|symbol| symbol.name.as_ref()) {
244        Some("glance") => (4 * 1024, 64 * 1024),
245        Some("compact") => (64 * 1024, 1024 * 1024),
246        Some("regular") => (256 * 1024, 4 * 1024 * 1024),
247        Some("dense") => (MAX_HEATMAP_CELLS, 16 * 1024 * 1024),
248        _ => (DEFAULT_CELLS, DEFAULT_BYTES),
249    }
250}
251
252fn display_limit(display: &Expr, name: &str) -> Result<Option<u64>> {
253    access::field(display, name)
254        .map(|value| {
255            integer(value).ok_or_else(|| {
256                Error::Eval(format!(
257                    "surface display {name} must be a non-negative integer"
258                ))
259            })
260        })
261        .transpose()
262}
263
264fn display_pixel_cells(display: &Expr) -> Result<Option<u64>> {
265    for name in ["px", "mono-px", "per-eye-px"] {
266        let Some(value) = access::field(display, name) else {
267            continue;
268        };
269        let Expr::List(dimensions) = value else {
270            return Err(Error::Eval(format!(
271                "surface display {name} must be [width, height]"
272            )));
273        };
274        let [width, height] = dimensions.as_slice() else {
275            return Err(Error::Eval(format!(
276                "surface display {name} must contain exactly two dimensions"
277            )));
278        };
279        let width = integer(width)
280            .filter(|value| *value > 0)
281            .ok_or_else(|| Error::Eval(format!("surface display {name} width must be positive")))?;
282        let height = integer(height).filter(|value| *value > 0).ok_or_else(|| {
283            Error::Eval(format!("surface display {name} height must be positive"))
284        })?;
285        return width
286            .checked_mul(height)
287            .map(Some)
288            .ok_or_else(|| Error::Eval(format!("surface display {name} area overflowed")));
289    }
290    Ok(None)
291}
292
293fn integer(value: &Expr) -> Option<u64> {
294    match value {
295        Expr::Number(number)
296            if matches!(number.domain.name.as_ref(), "i64" | "u64")
297                && number.domain.namespace.is_none() =>
298        {
299            number.canonical.parse().ok()
300        }
301        _ => None,
302    }
303}