STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
engine_lut.py
Go to the documentation of this file.
1from __future__ import annotations
2
3from dataclasses import dataclass
4from pathlib import Path
5from typing import Any, Dict, Iterable, List, Mapping, Sequence
6
7import json
8import numpy as np
9
10
11@dataclass
13 """Runtime representation of a single LUT axis."""
14
15 name: str
16 values: np.ndarray # 1D monotonically increasing grid
17 units: str = ""
18 description: str = ""
19
20
22 """
23 N-dimensional engine/controller lookup table with multilinear interpolation.
24
25 This class is intentionally independent of engine_sim internal types so it
26 can be used from both Python tooling and (via a thin wrapper) C++ FSW.
27 """
28
29 def __init__(self, path: str | Path):
30 path = Path(path)
31 data = np.load(path, allow_pickle=True)
32
33 if "meta" not in data:
34 raise ValueError(f"LUT file {path} is missing required 'meta' entry")
35
36 meta_raw = data["meta"].tolist()
37 meta: Dict[str, Any] = json.loads(meta_raw)
38
39 axes_meta: Sequence[Mapping[str, Any]] = meta.get("axes", [])
40 self._axes: List[LoadedAxis] = []
41 for axis_meta in axes_meta:
42 name = axis_meta["name"]
43 key = f"axes/{name}"
44 if key not in data:
45 raise ValueError(
46 f"LUT file {path} missing axis array for '{name}' (key '{key}')"
47 )
48 values = np.asarray(data[key], dtype=float)
49 self._axes.append(
51 name=name,
52 values=values,
53 units=axis_meta.get("units", ""),
54 description=axis_meta.get("description", ""),
55 )
56 )
57
58 self._axis_name_to_index = {axis.name: i for i, axis in enumerate(self._axes)}
59
60 # Load data arrays for each output quantity
61 outputs_meta: Iterable[str] = meta.get("outputs", [])
62 self._outputs: Dict[str, np.ndarray] = {}
63 expected_shape: tuple[int, ...] | None = None
64
65 for out_name in outputs_meta:
66 key = f"data/{out_name}"
67 if key not in data:
68 raise ValueError(
69 f"LUT file {path} missing data array for output '{out_name}' (key '{key}')"
70 )
71 arr = np.asarray(data[key], dtype=float)
72 if expected_shape is None:
73 expected_shape = arr.shape
74 elif arr.shape != expected_shape:
75 raise ValueError(
76 f"All output arrays must have the same shape. "
77 f"Got {arr.shape} for '{out_name}', expected {expected_shape}."
78 )
79 self._outputs[out_name] = arr
80
81 # Validate shape consistency with axes
82 axis_sizes = tuple(len(axis.values) for axis in self._axes)
83 if expected_shape is not None and expected_shape != axis_sizes:
84 raise ValueError(
85 f"LUT data shape {expected_shape} does not match axes sizes {axis_sizes}"
86 )
87
88 self.meta = meta
89 self.path = path
90
91 @property
92 def axes(self) -> List[LoadedAxis]:
93 return list(self._axes)
94
95 @property
96 def outputs(self) -> List[str]:
97 return list(self._outputs.keys())
98
99 def evaluate(self, point: Mapping[str, float]) -> Dict[str, float]:
100 """
101 Evaluate the LUT at an arbitrary point using multilinear interpolation.
102
103 Parameters
104 ----------
105 point:
106 Mapping from axis name -> coordinate value in that axis' units.
107 All axes defined in the LUT must be present; extra keys are ignored.
108
109 Returns
110 -------
111 dict:
112 Mapping from output name -> interpolated value.
113 """
114 if not self._axes:
115 raise ValueError("LUT has no axes defined")
116
117 # Pre-compute indices and interpolation weights for each axis
118 indices: List[tuple[int, int]] = []
119 weights: List[tuple[float, float]] = []
120
121 for axis in self._axes:
122 if axis.name not in point:
123 raise KeyError(
124 f"Point is missing coordinate for axis '{axis.name}'. "
125 f"Required axes: {list(self._axis_name_to_index.keys())}"
126 )
127
128 x = float(point[axis.name])
129 grid = axis.values
130
131 # Find insertion point
132 hi = int(np.searchsorted(grid, x))
133 if hi <= 0:
134 hi = 1
135 if hi >= len(grid):
136 hi = len(grid) - 1
137 lo = hi - 1
138
139 x0 = grid[lo]
140 x1 = grid[hi]
141 if x1 == x0:
142 t = 0.0
143 else:
144 t = float((x - x0) / (x1 - x0))
145
146 # Clamp interpolation parameter
147 if t < 0.0:
148 t = 0.0
149 elif t > 1.0:
150 t = 1.0
151
152 indices.append((lo, hi))
153 weights.append((1.0 - t, t))
154
155 n_axes = len(self._axes)
156 if n_axes > 20:
157 raise ValueError(
158 f"Too many axes for multilinear interpolation: {n_axes} (max 20)"
159 )
160
161 result: Dict[str, float] = {name: 0.0 for name in self._outputs.keys()}
162
163 # Enumerate all 2^N corners of the hyper-rectangle
164 n_corners = 1 << n_axes
165 for corner in range(n_corners):
166 w = 1.0
167 idx: List[int] = []
168
169 for axis_idx in range(n_axes):
170 lo, hi = indices[axis_idx]
171 w0, w1 = weights[axis_idx]
172 if (corner >> axis_idx) & 1:
173 idx.append(hi)
174 w *= w1
175 else:
176 idx.append(lo)
177 w *= w0
178
179 if w == 0.0:
180 break
181
182 if w == 0.0:
183 continue
184
185 idx_tuple = tuple(idx)
186 for out_name, arr in self._outputs.items():
187 val = arr[idx_tuple]
188 if np.isfinite(val):
189 result[out_name] += w * float(val)
190
191 return result
List[LoadedAxis] axes(self)
Definition engine_lut.py:92
Dict[str, float] evaluate(self, Mapping[str, float] point)
Definition engine_lut.py:99
__init__(self, str|Path path)
Definition engine_lut.py:29