ph_surfaces/surface.rs
1//! The validated static representation of a rectilinear bilinear surface.
2
3use crate::axis::{AxisLookup, BinaryAxis, BucketedAxis, LinearAxis, UniformAxis};
4use crate::boundary::BoundaryPolicy;
5
6/// A static rectilinear `u16 × u16 → i32` surface.
7///
8/// The handle references static tables and never owns or copies them. `NX` is
9/// the number of X knots and `NY` the number of Y knots.
10///
11/// # Orientation
12///
13/// The value grid is row-major with Y selecting the row and X selecting the
14/// column, so a value is addressed as `values[y][x]`. Because the grid type is
15/// `&'static [[i32; NX]; NY]`, a transposed grid is a type error when `NX != NY`,
16/// and there is no reachable dimension-mismatch outcome. For a square grid,
17/// the transposed shape has the same type, so preserving orientation remains
18/// the caller's responsibility.
19///
20/// # Lookup strategies
21///
22/// The last two type parameters select how each axis locates a coordinate, and
23/// they default to [`BinaryAxis`] — the general-purpose choice, and the only one
24/// [`BilinearSurface::new`] can produce. `BilinearSurface<NX, NY>` is therefore
25/// the binary-knotted surface.
26///
27/// A firmware that wants a different trade names it in the type and builds the
28/// surface with [`BilinearSurface::from_axes`]: [`LinearAxis`](crate::LinearAxis)
29/// for a tiny axis, [`UniformAxis`](crate::UniformAxis) for evenly spaced knots
30/// that then need not be stored at all, or
31/// [`BucketedAxis`](crate::BucketedAxis) to buy a smaller search bound on a long
32/// irregular axis with a few static index bytes. The two axes choose
33/// independently, and the choice is a type rather than a value: there is no
34/// runtime discriminant and no branch among strategies.
35///
36/// Choose [`LinearAxis`](crate::LinearAxis) for a tiny axis when the minimum
37/// auxiliary structure is what matters; [`BinaryAxis`] as the general default;
38/// [`UniformAxis`](crate::UniformAxis) when knots are evenly spaced, so the
39/// knot arrays can be dropped and location is constant work;
40/// [`BucketedAxis`](crate::BucketedAxis) for a long irregular axis when
41/// `2*B` extra index bytes buy a smaller local bound.
42///
43/// Whichever strategies a surface names, it locates the same cell, evaluates
44/// the same value, and reports the same errors. Only the stored bytes and the
45/// search work differ.
46///
47/// # Validation
48///
49/// [`BilinearSurface::new`] is a `const fn` that rejects fewer than two knots
50/// on either axis and any axis that is not strictly increasing. A definition
51/// that violates those invariants fails to compile. Every axis strategy
52/// validates itself the same way in its own `const fn` constructor, so a surface
53/// built with [`BilinearSurface::from_axes`] is validated before it exists.
54///
55/// # Storage
56///
57/// The referenced table element payload is exactly
58/// [`BilinearSurface::PAYLOAD_BYTES`]: the two axes'
59/// [`AxisLookup::KNOT_BYTES`](crate::AxisLookup::KNOT_BYTES) and
60/// [`AxisLookup::INDEX_BYTES`](crate::AxisLookup::INDEX_BYTES) plus
61/// [`BilinearSurface::VALUE_BYTES`] (`4*NX*NY`). For the default binary
62/// surface that equals `2*NX + 2*NY + 4*NX*NY` bytes. That figure excludes
63/// this handle, alignment, linker effects, code, stack, and binary or flash
64/// placement. It is not a total memory cost.
65///
66/// The handle always contains the value-grid reference and the four-byte
67/// boundary policy. Its remaining fields depend on the axis strategies:
68/// [`UniformAxis`] stores no reference, [`LinearAxis`] and [`BinaryAxis`] each
69/// store one knot-array reference, and [`BucketedAxis`] stores a knot-array and
70/// an index-array reference. The default binary/binary handle is therefore
71/// three thin references plus the policy and alignment padding. Its size is
72/// [`BilinearSurface::HANDLE_BYTES`], which is target-dependent.
73///
74/// # Equality
75///
76/// The derived [`PartialEq`] and [`Hash`] implementations compare and hash the
77/// referenced tables, not their addresses. Two handles over distinct but equal
78/// tables therefore compare equal, at a cost proportional to `NX * NY`.
79///
80/// # Examples
81///
82/// ```
83/// use ph_surfaces::{BilinearSurface, Boundary, BoundaryPolicy};
84///
85/// static X: [u16; 2] = [0, 100];
86/// static Y: [u16; 3] = [0, 100, 200];
87/// static VALUES: [[i32; 2]; 3] = [[0, 1], [2, 3], [4, 5]];
88///
89/// static SURFACE: BilinearSurface<2, 3> = BilinearSurface::new(&X, &Y, &VALUES)
90/// .with_policy(BoundaryPolicy::new().with_y_above(Boundary::Clamp));
91///
92/// assert_eq!(SURFACE.nx(), 2);
93/// assert_eq!(SURFACE.ny(), 3);
94/// assert_eq!(SURFACE.values()[2][1], 5);
95/// assert_eq!(SURFACE.policy().y_above(), Boundary::Clamp);
96/// assert_eq!(SURFACE.policy().y_below(), Boundary::Error);
97/// ```
98///
99/// A grid whose dimensions are swapped does not compile. Here the axes call
100/// for `[[i32; 2]; 3]` but the grid is `[[i32; 3]; 2]`:
101///
102/// ```compile_fail
103/// use ph_surfaces::BilinearSurface;
104///
105/// static X: [u16; 2] = [0, 100];
106/// static Y: [u16; 3] = [0, 100, 200];
107/// static VALUES: [[i32; 3]; 2] = [[0, 1, 2], [3, 4, 5]];
108///
109/// static SURFACE: BilinearSurface<2, 3> = BilinearSurface::new(&X, &Y, &VALUES);
110/// ```
111///
112/// The same declaration with the correct `[[i32; NX]; NY]` shape compiles:
113///
114/// ```
115/// use ph_surfaces::BilinearSurface;
116///
117/// static X: [u16; 2] = [0, 100];
118/// static Y: [u16; 3] = [0, 100, 200];
119/// static VALUES: [[i32; 2]; 3] = [[0, 1], [2, 3], [4, 5]];
120///
121/// static SURFACE: BilinearSurface<2, 3> = BilinearSurface::new(&X, &Y, &VALUES);
122/// assert_eq!(SURFACE.values()[2][1], 5);
123/// ```
124///
125/// An axis whose knot count disagrees with the value grid does not compile
126/// either, because the axis type carries that count:
127///
128/// ```compile_fail
129/// use ph_surfaces::{BilinearSurface, BinaryAxis};
130///
131/// static X: [u16; 3] = [0, 50, 100];
132/// static Y: [u16; 2] = [0, 100];
133/// static VALUES: [[i32; 2]; 2] = [[0, 1], [2, 3]];
134///
135/// static SURFACE: BilinearSurface<2, 2, BinaryAxis<3>, BinaryAxis<2>> =
136/// BilinearSurface::from_axes(BinaryAxis::new(&X), BinaryAxis::new(&Y), &VALUES);
137/// ```
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
139pub struct BilinearSurface<
140 const NX: usize,
141 const NY: usize,
142 X: AxisLookup<NX> = BinaryAxis<NX>,
143 Y: AxisLookup<NY> = BinaryAxis<NY>,
144> {
145 x: X,
146 y: Y,
147 values: &'static [[i32; NX]; NY],
148 policy: BoundaryPolicy,
149}
150
151impl<const NX: usize, const NY: usize> BilinearSurface<NX, NY, BinaryAxis<NX>, BinaryAxis<NY>> {
152 /// Declares a surface over static axes and a static row-major value grid,
153 /// with both axes located by binary search.
154 ///
155 /// This is the general-purpose constructor and the one to reach for unless
156 /// an axis has a reason to choose otherwise; see
157 /// [`BilinearSurface::from_axes`] for the surfaces that do.
158 ///
159 /// Every domain side defaults to [`Boundary::Error`](crate::Boundary::Error);
160 /// use [`BilinearSurface::with_policy`] to select clamping on any side.
161 ///
162 /// # Panics
163 ///
164 /// Panics unless both axes declare at least two knots and both axes are
165 /// strictly increasing. In a constant or static definition that panic is a
166 /// compile error, so an invalid surface cannot be defined.
167 ///
168 /// A single X knot is rejected:
169 ///
170 /// ```compile_fail
171 /// use ph_surfaces::BilinearSurface;
172 ///
173 /// static X: [u16; 1] = [0];
174 /// static Y: [u16; 2] = [0, 10];
175 /// static VALUES: [[i32; 1]; 2] = [[0], [1]];
176 ///
177 /// static SURFACE: BilinearSurface<1, 2> = BilinearSurface::new(&X, &Y, &VALUES);
178 /// ```
179 ///
180 /// An empty Y axis is rejected:
181 ///
182 /// ```compile_fail
183 /// use ph_surfaces::BilinearSurface;
184 ///
185 /// static X: [u16; 2] = [0, 10];
186 /// static Y: [u16; 0] = [];
187 /// static VALUES: [[i32; 2]; 0] = [];
188 ///
189 /// static SURFACE: BilinearSurface<2, 0> = BilinearSurface::new(&X, &Y, &VALUES);
190 /// ```
191 ///
192 /// A duplicated X knot is rejected:
193 ///
194 /// ```compile_fail
195 /// use ph_surfaces::BilinearSurface;
196 ///
197 /// static X: [u16; 2] = [5, 5];
198 /// static Y: [u16; 2] = [0, 10];
199 /// static VALUES: [[i32; 2]; 2] = [[0, 1], [2, 3]];
200 ///
201 /// static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);
202 /// ```
203 ///
204 /// A descending X axis is rejected:
205 ///
206 /// ```compile_fail
207 /// use ph_surfaces::BilinearSurface;
208 ///
209 /// static X: [u16; 2] = [10, 0];
210 /// static Y: [u16; 2] = [0, 10];
211 /// static VALUES: [[i32; 2]; 2] = [[0, 1], [2, 3]];
212 ///
213 /// static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);
214 /// ```
215 ///
216 /// A duplicated Y knot is rejected independently of the X axis:
217 ///
218 /// ```compile_fail
219 /// use ph_surfaces::BilinearSurface;
220 ///
221 /// static X: [u16; 2] = [0, 10];
222 /// static Y: [u16; 2] = [5, 5];
223 /// static VALUES: [[i32; 2]; 2] = [[0, 1], [2, 3]];
224 ///
225 /// static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);
226 /// ```
227 ///
228 /// A descending Y axis is rejected independently of the X axis:
229 ///
230 /// ```compile_fail
231 /// use ph_surfaces::BilinearSurface;
232 ///
233 /// static X: [u16; 2] = [0, 10];
234 /// static Y: [u16; 2] = [10, 0];
235 /// static VALUES: [[i32; 2]; 2] = [[0, 1], [2, 3]];
236 ///
237 /// static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X, &Y, &VALUES);
238 /// ```
239 #[must_use]
240 pub const fn new(
241 x_axis: &'static [u16; NX],
242 y_axis: &'static [u16; NY],
243 values: &'static [[i32; NX]; NY],
244 ) -> Self {
245 // The axis constructors below enforce the same two rules, but they
246 // cannot say which axis failed: a `const fn` panic message is a literal,
247 // and the strategies are axis-neutral by design. These assertions exist
248 // so that the default construction path keeps naming the offending axis.
249 assert!(NX >= 2, "x axis must declare at least two knots");
250 assert!(NY >= 2, "y axis must declare at least two knots");
251
252 let mut i = 1;
253 while i < NX {
254 assert!(
255 x_axis[i - 1] < x_axis[i],
256 "x axis knots must be strictly increasing"
257 );
258 i += 1;
259 }
260
261 let mut i = 1;
262 while i < NY {
263 assert!(
264 y_axis[i - 1] < y_axis[i],
265 "y axis knots must be strictly increasing"
266 );
267 i += 1;
268 }
269
270 Self {
271 x: BinaryAxis::new(x_axis),
272 y: BinaryAxis::new(y_axis),
273 values,
274 policy: BoundaryPolicy::new(),
275 }
276 }
277}
278
279impl<const NX: usize, const NY: usize, X: AxisLookup<NX>, Y: AxisLookup<NY>>
280 BilinearSurface<NX, NY, X, Y>
281{
282 /// Bytes of the referenced value grid: `NX*NY` elements of `i32`.
283 ///
284 /// Exact and target-independent. It excludes the axis tables, the handle,
285 /// alignment, code, and stack, and it is not a total memory figure.
286 pub const VALUE_BYTES: usize = 4 * NX * NY;
287
288 /// Bytes of referenced table elements this surface names:
289 /// `X::KNOT_BYTES + X::INDEX_BYTES + Y::KNOT_BYTES + Y::INDEX_BYTES + VALUE_BYTES`.
290 ///
291 /// Exact and target-independent. For the default binary pairing it equals
292 /// `2*NX + 2*NY + 4*NX*NY`. It is only the referenced element payload: not
293 /// total RAM, flash, binary, or linker cost.
294 pub const PAYLOAD_BYTES: usize =
295 X::KNOT_BYTES + X::INDEX_BYTES + Y::KNOT_BYTES + Y::INDEX_BYTES + Self::VALUE_BYTES;
296
297 /// Size of this handle on the current target, including alignment padding.
298 ///
299 /// Target-dependent: it follows pointer width and the selected strategies'
300 /// fields (Uniform stores no axis reference, Linear/Binary one, Bucketed
301 /// two), plus the value-grid reference and the four-byte policy. It does
302 /// not grow with `NX` or `NY` for a fixed pairing, and it is not a flash
303 /// or binary cost.
304 pub const HANDLE_BYTES: usize = core::mem::size_of::<Self>();
305
306 /// Scalar interpolations a successful [`evaluate`](Self::evaluate) performs.
307 ///
308 /// Always three: X on the lower-Y row, X on the upper-Y row, then Y
309 /// between those two already-rounded results. A rejected evaluation
310 /// returns before any of them. This is operation structure, not a cycle
311 /// count.
312 pub const SUCCESS_INTERPOLATIONS: u32 = 3;
313
314 /// Value-grid reads a successful [`evaluate`](Self::evaluate) performs.
315 ///
316 /// Always four: the corners of the located cell. A rejected evaluation
317 /// returns before any of them. The grid is never scanned. This is
318 /// operation structure, not a cycle count.
319 pub const SUCCESS_GRID_READS: u32 = 4;
320
321 /// Declares a surface over two axes that have already chosen their lookup
322 /// strategies, and a static row-major value grid.
323 ///
324 /// Each axis validated itself when it was declared, so there is nothing left
325 /// to reject here: the knot counts are carried in the axis types, so an axis
326 /// that does not match the grid is a type error rather than a runtime one.
327 ///
328 /// Every domain side defaults to [`Boundary::Error`](crate::Boundary::Error);
329 /// use [`BilinearSurface::with_policy`] to select clamping on any side.
330 ///
331 /// # Examples
332 ///
333 /// A surface whose X axis is evenly spaced — so its knots are described
334 /// rather than stored — and whose Y axis keeps the default strategy:
335 ///
336 /// ```
337 /// use ph_surfaces::{BilinearSurface, BinaryAxis, UniformAxis};
338 ///
339 /// static Y: [u16; 2] = [0, 10];
340 /// static VALUES: [[i32; 5]; 2] = [[0, 25, 50, 75, 100], [10, 35, 60, 85, 110]];
341 ///
342 /// static SURFACE: BilinearSurface<5, 2, UniformAxis<5, 0, 25>, BinaryAxis<2>> =
343 /// BilinearSurface::from_axes(UniformAxis::new(), BinaryAxis::new(&Y), &VALUES);
344 ///
345 /// assert_eq!(SURFACE.evaluate(50, 0), Ok(50));
346 /// assert_eq!(SURFACE.x_knot(1), 25);
347 /// ```
348 #[must_use]
349 pub const fn from_axes(x: X, y: Y, values: &'static [[i32; NX]; NY]) -> Self {
350 Self {
351 x,
352 y,
353 values,
354 policy: BoundaryPolicy::new(),
355 }
356 }
357
358 /// Returns this surface with its boundary policy replaced.
359 ///
360 /// The referenced tables are unchanged; only the four domain-side
361 /// selections differ.
362 #[must_use]
363 pub const fn with_policy(self, policy: BoundaryPolicy) -> Self {
364 Self {
365 x: self.x,
366 y: self.y,
367 values: self.values,
368 policy,
369 }
370 }
371
372 /// Returns the X axis together with its lookup strategy.
373 ///
374 /// This is the generic route to the axis: through it, code bounded on
375 /// [`AxisLookup`] (or [`KnotArray`](crate::KnotArray) for the stored
376 /// strategies) can read the domain bounds, individual knots, and cost
377 /// constants of any surface's axis without carrying the knot arrays
378 /// separately. The strategy-specific `x_knot` / `x_min` / `x_max`
379 /// accessors remain the constant-context conveniences.
380 #[must_use]
381 pub const fn x(&self) -> &X {
382 &self.x
383 }
384
385 /// Returns the Y axis together with its lookup strategy.
386 ///
387 /// The Y counterpart of [`x`](BilinearSurface::x); see there.
388 #[must_use]
389 pub const fn y(&self) -> &Y {
390 &self.y
391 }
392
393 /// Returns the declared row-major value grid, addressed as `values[y][x]`.
394 #[must_use]
395 pub const fn values(&self) -> &'static [[i32; NX]; NY] {
396 self.values
397 }
398
399 /// Returns the number of X knots.
400 #[must_use]
401 pub const fn nx(&self) -> usize {
402 NX
403 }
404
405 /// Returns the number of Y knots.
406 #[must_use]
407 pub const fn ny(&self) -> usize {
408 NY
409 }
410
411 /// Returns the four domain-side selections.
412 #[must_use]
413 pub const fn policy(&self) -> BoundaryPolicy {
414 self.policy
415 }
416}
417
418macro_rules! impl_stored_x_accessors {
419 ($axis:ident) => {
420 impl<const NX: usize, const NY: usize, Y: AxisLookup<NY>>
421 BilinearSurface<NX, NY, $axis<NX>, Y>
422 {
423 /// Returns the X knot at `index`.
424 ///
425 /// # Panics
426 ///
427 /// Panics if `index >= NX`.
428 #[must_use]
429 pub const fn x_knot(&self, index: usize) -> u16 {
430 assert!(index < NX, "knot index is outside the axis");
431 self.x.knots()[index]
432 }
433
434 /// Returns the first X knot: the inclusive lower bound of the X domain.
435 #[must_use]
436 pub const fn x_min(&self) -> u16 {
437 self.x.knots()[0]
438 }
439
440 /// Returns the last X knot: the inclusive upper bound of the X domain.
441 #[must_use]
442 pub const fn x_max(&self) -> u16 {
443 self.x.knots()[NX - 1]
444 }
445
446 /// Returns the declared X axis.
447 #[must_use]
448 pub const fn x_axis(&self) -> &'static [u16; NX] {
449 self.x.knots()
450 }
451 }
452 };
453}
454
455macro_rules! impl_stored_y_accessors {
456 ($axis:ident) => {
457 impl<const NX: usize, const NY: usize, X: AxisLookup<NX>>
458 BilinearSurface<NX, NY, X, $axis<NY>>
459 {
460 /// Returns the Y knot at `index`.
461 ///
462 /// # Panics
463 ///
464 /// Panics if `index >= NY`.
465 #[must_use]
466 pub const fn y_knot(&self, index: usize) -> u16 {
467 assert!(index < NY, "knot index is outside the axis");
468 self.y.knots()[index]
469 }
470
471 /// Returns the first Y knot: the inclusive lower bound of the Y domain.
472 #[must_use]
473 pub const fn y_min(&self) -> u16 {
474 self.y.knots()[0]
475 }
476
477 /// Returns the last Y knot: the inclusive upper bound of the Y domain.
478 #[must_use]
479 pub const fn y_max(&self) -> u16 {
480 self.y.knots()[NY - 1]
481 }
482
483 /// Returns the declared Y axis.
484 #[must_use]
485 pub const fn y_axis(&self) -> &'static [u16; NY] {
486 self.y.knots()
487 }
488 }
489 };
490}
491
492impl_stored_x_accessors!(BinaryAxis);
493impl_stored_x_accessors!(LinearAxis);
494impl_stored_y_accessors!(BinaryAxis);
495impl_stored_y_accessors!(LinearAxis);
496
497impl<const NX: usize, const NY: usize, const B: usize, Y: AxisLookup<NY>>
498 BilinearSurface<NX, NY, BucketedAxis<NX, B>, Y>
499{
500 /// Returns the X knot at `index`.
501 ///
502 /// # Panics
503 ///
504 /// Panics if `index >= NX`.
505 #[must_use]
506 pub const fn x_knot(&self, index: usize) -> u16 {
507 assert!(index < NX, "knot index is outside the axis");
508 self.x.knots()[index]
509 }
510
511 /// Returns the first X knot: the inclusive lower bound of the X domain.
512 #[must_use]
513 pub const fn x_min(&self) -> u16 {
514 self.x.knots()[0]
515 }
516
517 /// Returns the last X knot: the inclusive upper bound of the X domain.
518 #[must_use]
519 pub const fn x_max(&self) -> u16 {
520 self.x.knots()[NX - 1]
521 }
522
523 /// Returns the declared X axis.
524 #[must_use]
525 pub const fn x_axis(&self) -> &'static [u16; NX] {
526 self.x.knots()
527 }
528}
529
530impl<const NX: usize, const NY: usize, const B: usize, X: AxisLookup<NX>>
531 BilinearSurface<NX, NY, X, BucketedAxis<NY, B>>
532{
533 /// Returns the Y knot at `index`.
534 ///
535 /// # Panics
536 ///
537 /// Panics if `index >= NY`.
538 #[must_use]
539 pub const fn y_knot(&self, index: usize) -> u16 {
540 assert!(index < NY, "knot index is outside the axis");
541 self.y.knots()[index]
542 }
543
544 /// Returns the first Y knot: the inclusive lower bound of the Y domain.
545 #[must_use]
546 pub const fn y_min(&self) -> u16 {
547 self.y.knots()[0]
548 }
549
550 /// Returns the last Y knot: the inclusive upper bound of the Y domain.
551 #[must_use]
552 pub const fn y_max(&self) -> u16 {
553 self.y.knots()[NY - 1]
554 }
555
556 /// Returns the declared Y axis.
557 #[must_use]
558 pub const fn y_axis(&self) -> &'static [u16; NY] {
559 self.y.knots()
560 }
561}
562
563impl<const NX: usize, const NY: usize, const ORIGIN: u16, const STEP: u16, Y: AxisLookup<NY>>
564 BilinearSurface<NX, NY, UniformAxis<NX, ORIGIN, STEP>, Y>
565{
566 /// Returns the X knot at `index`, calculated from the uniform descriptor.
567 ///
568 /// # Panics
569 ///
570 /// Panics if `index >= NX`.
571 #[must_use]
572 pub const fn x_knot(&self, index: usize) -> u16 {
573 self.x.knot(index)
574 }
575
576 /// Returns the first X knot: the inclusive lower bound of the X domain.
577 #[must_use]
578 pub const fn x_min(&self) -> u16 {
579 self.x.origin()
580 }
581
582 /// Returns the last X knot: the inclusive upper bound of the X domain.
583 #[must_use]
584 pub const fn x_max(&self) -> u16 {
585 self.x.last()
586 }
587}
588
589impl<const NX: usize, const NY: usize, X: AxisLookup<NX>, const ORIGIN: u16, const STEP: u16>
590 BilinearSurface<NX, NY, X, UniformAxis<NY, ORIGIN, STEP>>
591{
592 /// Returns the Y knot at `index`, calculated from the uniform descriptor.
593 ///
594 /// # Panics
595 ///
596 /// Panics if `index >= NY`.
597 #[must_use]
598 pub const fn y_knot(&self, index: usize) -> u16 {
599 self.y.knot(index)
600 }
601
602 /// Returns the first Y knot: the inclusive lower bound of the Y domain.
603 #[must_use]
604 pub const fn y_min(&self) -> u16 {
605 self.y.origin()
606 }
607
608 /// Returns the last Y knot: the inclusive upper bound of the Y domain.
609 #[must_use]
610 pub const fn y_max(&self) -> u16 {
611 self.y.last()
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::BilinearSurface;
618 use crate::axis::{AxisLookup, BinaryAxis, BucketedAxis, LinearAxis, UniformAxis};
619 use crate::boundary::{Boundary, BoundaryPolicy};
620 use crate::{bucket_index, max_local_comparisons};
621 use core::mem::{align_of, size_of, size_of_val};
622
623 static X2: [u16; 2] = [0, 10];
624 static Y2: [u16; 2] = [0, 20];
625 static V22: [[i32; 2]; 2] = [[1, 2], [3, 4]];
626
627 // Declaring this as a `static` is itself the evidence that a valid 2x2
628 // surface is constant-evaluable without allocation: it either const-
629 // evaluates or the crate does not build.
630 static SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X2, &Y2, &V22);
631
632 // These declarations guard the pre-strategy public API: default-surface
633 // axis and endpoint accessors remain usable in constant expressions.
634 const CONST_SURFACE: BilinearSurface<2, 2> = BilinearSurface::new(&X2, &Y2, &V22);
635 const CONST_X_AXIS: &[u16; 2] = CONST_SURFACE.x_axis();
636 const CONST_Y_AXIS: &[u16; 2] = CONST_SURFACE.y_axis();
637 const CONST_X_MIN: u16 = CONST_SURFACE.x_min();
638 const CONST_X_MAX: u16 = CONST_SURFACE.x_max();
639 const CONST_Y_MIN: u16 = CONST_SURFACE.y_min();
640 const CONST_Y_MAX: u16 = CONST_SURFACE.y_max();
641
642 // A deliberately asymmetric 3x2 fixture: NX = 3, NY = 2.
643 static X3: [u16; 3] = [0, 5, 100];
644 static Y2B: [u16; 2] = [7, 900];
645 static V23: [[i32; 3]; 2] = [[10, 20, 30], [40, 50, 60]];
646 static WIDE: BilinearSurface<3, 2> = BilinearSurface::new(&X3, &Y2B, &V23);
647
648 // The same 2x2 tables, declared through the general constructor with named
649 // strategies: one stored-knot axis and one described axis.
650 static MIXED: BilinearSurface<2, 2, LinearAxis<2>, UniformAxis<2, 0, 20>> =
651 BilinearSurface::from_axes(LinearAxis::new(&X2), UniformAxis::new(), &V22);
652
653 const fn side_from_bit(bits: usize, shift: u32) -> Boundary {
654 if (bits >> shift) & 1 == 0 {
655 Boundary::Error
656 } else {
657 Boundary::Clamp
658 }
659 }
660
661 const fn policy_from_bits(bits: usize) -> BoundaryPolicy {
662 BoundaryPolicy::new()
663 .with_x_below(side_from_bit(bits, 0))
664 .with_x_above(side_from_bit(bits, 1))
665 .with_y_below(side_from_bit(bits, 2))
666 .with_y_above(side_from_bit(bits, 3))
667 }
668
669 const POLICIES: [BoundaryPolicy; 16] = {
670 let mut out = [BoundaryPolicy::new(); 16];
671 let mut bits = 0;
672 while bits < 16 {
673 out[bits] = policy_from_bits(bits);
674 bits += 1;
675 }
676 out
677 };
678
679 // All sixteen selections attached to the one shared table, in const context.
680 static SURFACES: [BilinearSurface<2, 2>; 16] = {
681 let mut out = [BilinearSurface::new(&X2, &Y2, &V22); 16];
682 let mut bits = 0;
683 while bits < 16 {
684 out[bits] = BilinearSurface::new(&X2, &Y2, &V22).with_policy(POLICIES[bits]);
685 bits += 1;
686 }
687 out
688 };
689
690 #[test]
691 fn two_by_two_surface_declares_as_a_static() {
692 assert_eq!(SURFACE.nx(), 2);
693 assert_eq!(SURFACE.ny(), 2);
694 assert_eq!(SURFACE.x_axis(), &[0, 10]);
695 assert_eq!(SURFACE.y_axis(), &[0, 20]);
696 assert_eq!(SURFACE.values(), &[[1, 2], [3, 4]]);
697 }
698
699 #[test]
700 fn values_are_addressed_row_major_by_y_then_x() {
701 // Y selects the row, X selects the column.
702 assert_eq!(WIDE.nx(), 3);
703 assert_eq!(WIDE.ny(), 2);
704 assert_eq!(WIDE.values()[0][2], 30);
705 assert_eq!(WIDE.values()[1][0], 40);
706 }
707
708 #[test]
709 fn new_defaults_every_side_to_error() {
710 let policy = SURFACE.policy();
711
712 assert_eq!(policy, BoundaryPolicy::new());
713 assert_eq!(policy.x_below(), Boundary::Error);
714 assert_eq!(policy.x_above(), Boundary::Error);
715 assert_eq!(policy.y_below(), Boundary::Error);
716 assert_eq!(policy.y_above(), Boundary::Error);
717 }
718
719 #[test]
720 fn from_axes_defaults_every_side_to_error_as_well() {
721 assert_eq!(MIXED.policy(), BoundaryPolicy::new());
722 assert_eq!(MIXED.policy().x_below(), Boundary::Error);
723 assert_eq!(MIXED.policy().y_above(), Boundary::Error);
724 }
725
726 #[test]
727 fn endpoint_accessors_report_declared_extremes() {
728 assert_eq!(WIDE.x_min(), 0);
729 assert_eq!(WIDE.x_max(), 100);
730 assert_eq!(WIDE.y_min(), 7);
731 assert_eq!(WIDE.y_max(), 900);
732 }
733
734 #[test]
735 fn default_axis_accessors_remain_const_compatible() {
736 assert_eq!(CONST_X_AXIS, &X2);
737 assert_eq!(CONST_Y_AXIS, &Y2);
738 assert_eq!((CONST_X_MIN, CONST_X_MAX), (0, 10));
739 assert_eq!((CONST_Y_MIN, CONST_Y_MAX), (0, 20));
740 }
741
742 #[test]
743 fn endpoint_and_knot_accessors_do_not_depend_on_a_stored_knot_array() {
744 // The Y axis of this surface stores nothing, and still answers.
745 assert_eq!(MIXED.y_min(), 0);
746 assert_eq!(MIXED.y_max(), 20);
747 assert_eq!(MIXED.y_knot(0), 0);
748 assert_eq!(MIXED.y_knot(1), 20);
749
750 // The X axis stores its knots, so both spellings agree.
751 assert_eq!(MIXED.x_knot(0), MIXED.x_axis()[0]);
752 assert_eq!(MIXED.x_knot(1), MIXED.x_axis()[1]);
753 assert_eq!(SURFACE.x_knot(1), 10);
754 assert_eq!(SURFACE.y_knot(1), 20);
755 }
756
757 #[test]
758 fn accessors_return_the_declared_tables_without_copying() {
759 assert!(core::ptr::eq(SURFACE.x_axis(), &X2));
760 assert!(core::ptr::eq(SURFACE.y_axis(), &Y2));
761 assert!(core::ptr::eq(SURFACE.values(), &V22));
762 }
763
764 #[test]
765 fn handle_size_is_independent_of_table_size() {
766 // The handle stores references, not tables, so growing the grid by
767 // three orders of magnitude does not grow the handle.
768 assert_eq!(
769 size_of::<BilinearSurface<2, 2>>(),
770 size_of::<BilinearSurface<64, 64>>()
771 );
772 }
773
774 /// The documented referenced element payload: `2*NX + 2*NY + 4*NX*NY`.
775 const fn documented_payload(nx: usize, ny: usize) -> usize {
776 2 * nx + 2 * ny + 4 * nx * ny
777 }
778
779 /// The size of the three tables a `BilinearSurface<NX, NY>` references.
780 const fn referenced_payload<const NX: usize, const NY: usize>() -> usize {
781 size_of::<[u16; NX]>() + size_of::<[u16; NY]>() + size_of::<[[i32; NX]; NY]>()
782 }
783
784 #[test]
785 fn cost_constants_match_the_declared_tables() {
786 assert_eq!(
787 BilinearSurface::<3, 2>::VALUE_BYTES,
788 size_of::<[[i32; 3]; 2]>()
789 );
790 assert_eq!(
791 BilinearSurface::<3, 2>::PAYLOAD_BYTES,
792 size_of::<[u16; 3]>() + size_of::<[u16; 2]>() + size_of::<[[i32; 3]; 2]>()
793 );
794 assert_eq!(<LinearAxis<3>>::KNOT_BYTES, size_of::<[u16; 3]>());
795 assert_eq!(<LinearAxis<3>>::INDEX_BYTES, 0);
796 assert_eq!(<BucketedAxis<5, 8>>::KNOT_BYTES, size_of::<[u16; 5]>());
797 assert_eq!(<BucketedAxis<5, 8>>::INDEX_BYTES, size_of::<[u16; 8]>());
798 assert_eq!(<UniformAxis<3, 0, 50>>::KNOT_BYTES, 0);
799 assert_eq!(<UniformAxis<3, 0, 50>>::INDEX_BYTES, 0);
800 }
801
802 #[test]
803 fn uniform_uniform_payload_is_only_the_grid() {
804 type UniformUniform = BilinearSurface<2, 2, UniformAxis<2, 0, 10>, UniformAxis<2, 0, 20>>;
805
806 assert_eq!(UniformUniform::PAYLOAD_BYTES, UniformUniform::VALUE_BYTES);
807 assert_eq!(UniformUniform::PAYLOAD_BYTES, size_of::<[[i32; 2]; 2]>());
808 assert_eq!(UniformUniform::VALUE_BYTES, 16);
809 }
810
811 #[test]
812 fn handle_bytes_matches_size_of_self() {
813 assert_eq!(
814 BilinearSurface::<2, 2>::HANDLE_BYTES,
815 size_of::<BilinearSurface<2, 2>>()
816 );
817 assert_eq!(
818 BilinearSurface::<64, 64>::HANDLE_BYTES,
819 size_of::<BilinearSurface<64, 64>>()
820 );
821 type Mixed = BilinearSurface<2, 2, LinearAxis<2>, UniformAxis<2, 0, 20>>;
822 assert_eq!(Mixed::HANDLE_BYTES, size_of::<Mixed>());
823 }
824
825 #[test]
826 fn success_work_constants_are_three_interpolations_and_four_reads() {
827 assert_eq!(BilinearSurface::<2, 2>::SUCCESS_INTERPOLATIONS, 3);
828 assert_eq!(BilinearSurface::<2, 2>::SUCCESS_GRID_READS, 4);
829 type Mixed = BilinearSurface<5, 3, BucketedAxis<5, 8>, UniformAxis<3, 0, 50>>;
830 assert_eq!(Mixed::SUCCESS_INTERPOLATIONS, 3);
831 assert_eq!(Mixed::SUCCESS_GRID_READS, 4);
832 }
833
834 #[test]
835 fn default_binary_payload_matches_the_documented_formula() {
836 assert_eq!(
837 BilinearSurface::<5, 4>::PAYLOAD_BYTES,
838 2 * 5 + 2 * 4 + 4 * 5 * 4
839 );
840 assert_eq!(BilinearSurface::<5, 4>::PAYLOAD_BYTES, 98);
841 assert_eq!(BilinearSurface::<5, 4>::VALUE_BYTES, 80);
842 assert_eq!(
843 BilinearSurface::<2, 2>::PAYLOAD_BYTES,
844 documented_payload(2, 2)
845 );
846 }
847
848 #[test]
849 fn mixed_bucketed_uniform_payload_drops_the_uniform_knots() {
850 static X: [u16; 17] = [
851 0, 100, 210, 300, 405, 500, 610, 700, 805, 900, 1_010, 1_100, 1_205, 1_300, 1_410,
852 1_500, 1_600,
853 ];
854 static X_INDEX: [u16; 8] = bucket_index(&X);
855 type Mixed = BilinearSurface<17, 9, BucketedAxis<17, 8>, UniformAxis<9, 0, 200>>;
856 type AllBinary = BilinearSurface<17, 9>;
857
858 assert_eq!(Mixed::VALUE_BYTES, 612);
859 assert_eq!(Mixed::PAYLOAD_BYTES, 34 + 16 + 612);
860 assert_eq!(Mixed::PAYLOAD_BYTES, 662);
861 assert_eq!(AllBinary::PAYLOAD_BYTES, 664);
862 assert_eq!(max_local_comparisons(&X, &X_INDEX), 3);
863 assert_eq!(<BinaryAxis<17>>::MAX_SEARCH_COMPARISONS, 5);
864 }
865
866 #[test]
867 fn referenced_payload_matches_the_documented_formula() {
868 // `u16` and `i32` have guaranteed sizes and arrays have no padding, so
869 // this holds on every target; the fixtures below merely spell out
870 // representative shapes, including the asymmetric and large ones.
871 assert_eq!(referenced_payload::<2, 2>(), documented_payload(2, 2));
872 assert_eq!(referenced_payload::<2, 2>(), 24);
873 assert_eq!(referenced_payload::<3, 2>(), documented_payload(3, 2));
874 assert_eq!(referenced_payload::<3, 2>(), 34);
875 assert_eq!(referenced_payload::<2, 3>(), documented_payload(2, 3));
876 assert_eq!(referenced_payload::<16, 8>(), documented_payload(16, 8));
877 assert_eq!(referenced_payload::<64, 64>(), documented_payload(64, 64));
878 assert_eq!(referenced_payload::<64, 64>(), 16_640);
879
880 // The same figure measured on the live tables behind a handle.
881 let measured = size_of_val(SURFACE.x_axis())
882 + size_of_val(SURFACE.y_axis())
883 + size_of_val(SURFACE.values());
884 assert_eq!(measured, documented_payload(SURFACE.nx(), SURFACE.ny()));
885
886 let measured =
887 size_of_val(WIDE.x_axis()) + size_of_val(WIDE.y_axis()) + size_of_val(WIDE.values());
888 assert_eq!(measured, documented_payload(WIDE.nx(), WIDE.ny()));
889 }
890
891 #[test]
892 fn default_handle_is_three_references_plus_four_policy_bytes_and_padding() {
893 // The handle holds three references to sized arrays (thin, so each is
894 // one target-width pointer) and one four-byte policy. Its size is
895 // therefore that sum rounded up to the handle's alignment: at least the
896 // sum, less than the sum plus one alignment unit, and a multiple of
897 // the alignment. Nothing here assumes a particular pointer width or a
898 // particular field order.
899 let reference = size_of::<&'static [u16; 2]>();
900 assert_eq!(
901 reference,
902 size_of::<usize>(),
903 "a reference to a sized array is thin"
904 );
905 assert_eq!(size_of::<&'static [[i32; 2]; 2]>(), reference);
906
907 let fields = 3 * reference + size_of::<BoundaryPolicy>();
908 let handle = size_of::<BilinearSurface<2, 2>>();
909 let align = align_of::<BilinearSurface<2, 2>>();
910
911 assert_eq!(size_of::<BoundaryPolicy>(), 4);
912 assert!(
913 handle >= fields,
914 "handle {handle} smaller than its fields {fields}"
915 );
916 assert!(
917 handle < fields + align,
918 "handle {handle} carries more than alignment padding over {fields}"
919 );
920 assert_eq!(handle % align, 0);
921 }
922
923 fn assert_handle_layout<T>(references: usize) {
924 let fields = references * size_of::<usize>() + size_of::<BoundaryPolicy>();
925 let handle = size_of::<T>();
926 let align = align_of::<T>();
927
928 assert!(
929 handle >= fields,
930 "handle {handle} smaller than its fields {fields}"
931 );
932 assert!(
933 handle < fields + align,
934 "handle {handle} carries more than alignment padding over {fields}"
935 );
936 assert_eq!(handle % align, 0);
937 }
938
939 #[test]
940 fn handle_reference_count_follows_the_selected_strategies() {
941 type UniformUniform = BilinearSurface<2, 2, UniformAxis<2, 0, 10>, UniformAxis<2, 0, 20>>;
942 type LinearUniform = BilinearSurface<2, 2, LinearAxis<2>, UniformAxis<2, 0, 20>>;
943 type BinaryBinary = BilinearSurface<2, 2, BinaryAxis<2>, BinaryAxis<2>>;
944 type BucketedBucketed = BilinearSurface<2, 2, BucketedAxis<2, 1>, BucketedAxis<2, 1>>;
945
946 // Every handle has one value-grid reference. Uniform contributes no
947 // axis reference, linear/binary one, and bucketed two.
948 assert_handle_layout::<UniformUniform>(1);
949 assert_handle_layout::<LinearUniform>(2);
950 assert_handle_layout::<BinaryBinary>(3);
951 assert_handle_layout::<BucketedBucketed>(5);
952 }
953
954 #[test]
955 fn selecting_strategies_adds_no_discriminant_to_the_handle() {
956 // The default surface is exactly the binary pairing spelled out, and a
957 // surface whose axes store less is smaller rather than larger: nothing
958 // was added to remember which strategy is in use.
959 assert_eq!(
960 size_of::<BilinearSurface<2, 2>>(),
961 size_of::<BilinearSurface<2, 2, BinaryAxis<2>, BinaryAxis<2>>>()
962 );
963 assert!(
964 size_of::<BilinearSurface<2, 2, LinearAxis<2>, UniformAxis<2, 0, 20>>>()
965 < size_of::<BilinearSurface<2, 2>>()
966 );
967 }
968
969 #[test]
970 fn with_policy_keeps_the_declared_tables() {
971 let clamped = SURFACE.with_policy(BoundaryPolicy::new().with_x_below(Boundary::Clamp));
972
973 assert!(core::ptr::eq(clamped.x_axis(), &X2));
974 assert!(core::ptr::eq(clamped.y_axis(), &Y2));
975 assert!(core::ptr::eq(clamped.values(), &V22));
976 assert_eq!(clamped.policy().x_below(), Boundary::Clamp);
977 }
978
979 #[test]
980 fn with_policy_keeps_the_declared_strategies() {
981 let clamped = MIXED.with_policy(policy_from_bits(0b1111));
982
983 assert!(core::ptr::eq(clamped.x_axis(), &X2));
984 assert_eq!(clamped.y_max(), 20);
985 assert_eq!(clamped.policy(), policy_from_bits(0b1111));
986 }
987
988 #[test]
989 fn all_sixteen_boundary_combinations_are_representable() {
990 for (bits, surface) in SURFACES.iter().enumerate() {
991 let policy = surface.policy();
992
993 assert_eq!(policy.x_below(), side_from_bit(bits, 0));
994 assert_eq!(policy.x_above(), side_from_bit(bits, 1));
995 assert_eq!(policy.y_below(), side_from_bit(bits, 2));
996 assert_eq!(policy.y_above(), side_from_bit(bits, 3));
997
998 // Selecting a policy never changes the table data.
999 assert!(core::ptr::eq(surface.values(), &V22));
1000 assert!(core::ptr::eq(surface.x_axis(), &X2));
1001 assert!(core::ptr::eq(surface.y_axis(), &Y2));
1002 }
1003 }
1004
1005 #[test]
1006 fn the_sixteen_combinations_are_pairwise_distinct() {
1007 for (i, left) in POLICIES.iter().enumerate() {
1008 for (j, right) in POLICIES.iter().enumerate() {
1009 if i == j {
1010 assert_eq!(left, right);
1011 } else {
1012 assert_ne!(left, right);
1013 }
1014 }
1015 }
1016 }
1017
1018 #[test]
1019 #[should_panic(expected = "x axis must declare at least two knots")]
1020 fn zero_knot_x_axis_is_rejected() {
1021 static X0: [u16; 0] = [];
1022 static V20: [[i32; 0]; 2] = [[], []];
1023
1024 let _ = BilinearSurface::new(&X0, &Y2, &V20);
1025 }
1026
1027 #[test]
1028 #[should_panic(expected = "x axis must declare at least two knots")]
1029 fn one_knot_x_axis_is_rejected() {
1030 static X1: [u16; 1] = [3];
1031 static V21: [[i32; 1]; 2] = [[0], [1]];
1032
1033 let _ = BilinearSurface::new(&X1, &Y2, &V21);
1034 }
1035
1036 #[test]
1037 #[should_panic(expected = "y axis must declare at least two knots")]
1038 fn zero_knot_y_axis_is_rejected() {
1039 static Y0: [u16; 0] = [];
1040 static V02: [[i32; 2]; 0] = [];
1041
1042 let _ = BilinearSurface::new(&X2, &Y0, &V02);
1043 }
1044
1045 #[test]
1046 #[should_panic(expected = "y axis must declare at least two knots")]
1047 fn one_knot_y_axis_is_rejected() {
1048 static Y1: [u16; 1] = [3];
1049 static V12: [[i32; 2]; 1] = [[0, 1]];
1050
1051 let _ = BilinearSurface::new(&X2, &Y1, &V12);
1052 }
1053
1054 #[test]
1055 #[should_panic(expected = "x axis knots must be strictly increasing")]
1056 fn duplicate_x_knots_are_rejected() {
1057 static DUPLICATE: [u16; 2] = [5, 5];
1058
1059 let _ = BilinearSurface::new(&DUPLICATE, &Y2, &V22);
1060 }
1061
1062 #[test]
1063 #[should_panic(expected = "x axis knots must be strictly increasing")]
1064 fn descending_x_knots_are_rejected() {
1065 static DESCENDING: [u16; 2] = [10, 0];
1066
1067 let _ = BilinearSurface::new(&DESCENDING, &Y2, &V22);
1068 }
1069
1070 #[test]
1071 #[should_panic(expected = "y axis knots must be strictly increasing")]
1072 fn duplicate_y_knots_are_rejected() {
1073 static DUPLICATE: [u16; 2] = [5, 5];
1074
1075 let _ = BilinearSurface::new(&X2, &DUPLICATE, &V22);
1076 }
1077
1078 #[test]
1079 #[should_panic(expected = "y axis knots must be strictly increasing")]
1080 fn descending_y_knots_are_rejected() {
1081 static DESCENDING: [u16; 2] = [20, 0];
1082
1083 let _ = BilinearSurface::new(&X2, &DESCENDING, &V22);
1084 }
1085
1086 #[test]
1087 #[should_panic(expected = "y axis knots must be strictly increasing")]
1088 fn a_valid_x_axis_does_not_mask_an_invalid_y_axis() {
1089 // The X axis here is the valid nonuniform three-knot axis, so the
1090 // reported failure can only have come from the Y axis.
1091 static DESCENDING: [u16; 2] = [900, 7];
1092
1093 let _ = BilinearSurface::new(&X3, &DESCENDING, &V23);
1094 }
1095
1096 #[test]
1097 #[should_panic(expected = "x axis knots must be strictly increasing")]
1098 fn a_descending_interior_x_step_is_rejected() {
1099 static NOT_SORTED: [u16; 3] = [0, 100, 50];
1100
1101 let _ = BilinearSurface::new(&NOT_SORTED, &Y2B, &V23);
1102 }
1103}