1use 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
20pub const VIRIDIS_PALETTE: &str = "viridis";
22
23pub const BLUE_RED_PALETTE: &str = "blue-red";
25
26pub const CYCLIC_PHASE_PALETTE: &str = "cyclic-phase";
28
29pub const MAX_HEATMAP_CELLS: usize = 1024 * 1024;
31
32pub const MAX_HEATMAP_BYTES: u64 = 32 * 1024 * 1024;
34
35const DEFAULT_CELLS: usize = 64 * 1024;
36const DEFAULT_BYTES: u64 = 1024 * 1024;
37
38#[derive(Clone, Copy, Debug)]
45pub struct HeatmapData<'a> {
46 pub rows: usize,
48 pub cols: usize,
50 pub values: &'a [f64],
52 pub valid: &'a [bool],
54 pub range: (f64, f64),
56 pub palette: &'a str,
58 pub label: &'a str,
60 pub detector: &'a str,
62 pub advisory: Option<&'a str>,
64}
65
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub struct HeatmapBudget {
69 max_cells: usize,
70 max_bytes: u64,
71}
72
73impl HeatmapBudget {
74 pub const fn max_cells(self) -> usize {
76 self.max_cells
77 }
78
79 pub const fn max_bytes(self) -> u64 {
81 self.max_bytes
82 }
83}
84
85pub 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
124pub fn heatmap_cell_budget(caps: &SurfaceCaps) -> Result<usize> {
126 heatmap_budget(caps).map(HeatmapBudget::max_cells)
127}
128
129pub fn heatmap_byte_budget(caps: &SurfaceCaps) -> Result<u64> {
131 heatmap_budget(caps).map(HeatmapBudget::max_bytes)
132}
133
134pub 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}