flode 0.60.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. flode/__init__.py +88 -0
  2. flode/analysis/__init__.py +37 -0
  3. flode/analysis/frequency_response.py +349 -0
  4. flode/analysis/linearize.py +714 -0
  5. flode/analysis/stability.py +229 -0
  6. flode/blocks/__init__.py +107 -0
  7. flode/blocks/_lti_utils.py +121 -0
  8. flode/blocks/cast.py +66 -0
  9. flode/blocks/continuous.py +474 -0
  10. flode/blocks/discontinuities.py +258 -0
  11. flode/blocks/discrete.py +612 -0
  12. flode/blocks/logic.py +120 -0
  13. flode/blocks/lookup.py +1025 -0
  14. flode/blocks/mathops.py +598 -0
  15. flode/blocks/pythonfunc.py +560 -0
  16. flode/blocks/pythonfunc_rewrite.py +1935 -0
  17. flode/blocks/pythonfunc_source.py +392 -0
  18. flode/blocks/random_source.py +179 -0
  19. flode/blocks/rounding.py +65 -0
  20. flode/blocks/routing.py +582 -0
  21. flode/blocks/sinks.py +309 -0
  22. flode/blocks/sources.py +215 -0
  23. flode/blocks/transport_delay.py +92 -0
  24. flode/blocks/userfunc.py +359 -0
  25. flode/core/__init__.py +5 -0
  26. flode/core/block.py +497 -0
  27. flode/core/decorator.py +1149 -0
  28. flode/core/dtypes.py +1026 -0
  29. flode/core/identifiers.py +157 -0
  30. flode/core/persistence.py +1319 -0
  31. flode/core/simulator.py +1690 -0
  32. flode/exceptions.py +226 -0
  33. flode/libraries/__init__.py +341 -0
  34. flode/libraries/_loader.py +302 -0
  35. flode/libraries/std.flwlib.json +347 -0
  36. flode/server/__init__.py +23 -0
  37. flode/server/app.py +185 -0
  38. flode/server/cli.py +555 -0
  39. flode/server/config.py +516 -0
  40. flode/server/errors.py +397 -0
  41. flode/server/library_registry.py +125 -0
  42. flode/server/migrations/__init__.py +80 -0
  43. flode/server/registry.py +950 -0
  44. flode/server/registry_translations.py +804 -0
  45. flode/server/routes/__init__.py +16 -0
  46. flode/server/routes/blocks.py +447 -0
  47. flode/server/routes/files.py +800 -0
  48. flode/server/routes/libraries.py +129 -0
  49. flode/server/routes/models.py +67 -0
  50. flode/server/routes/simulations.py +355 -0
  51. flode/server/runtime.py +354 -0
  52. flode/server/security/__init__.py +7 -0
  53. flode/server/security/origin.py +122 -0
  54. flode/server/security/paths.py +143 -0
  55. flode/server/settings.py +42 -0
  56. flode/server/static/.app-version +1 -0
  57. flode/server/static/assets/index-BnL2nNOe.css +1 -0
  58. flode/server/static/assets/index-DMWZBAA_.js +100 -0
  59. flode/server/static/assets/index-DMWZBAA_.js.map +1 -0
  60. flode/server/static/favicon.ico +0 -0
  61. flode/server/static/favicon.svg +9 -0
  62. flode/server/static/index.html +15 -0
  63. flode/subsystems/__init__.py +20 -0
  64. flode/subsystems/_mask.py +154 -0
  65. flode/subsystems/control_blocks.py +245 -0
  66. flode/subsystems/ports.py +146 -0
  67. flode/subsystems/subsystem.py +1173 -0
  68. flode-0.60.3.dist-info/METADATA +91 -0
  69. flode-0.60.3.dist-info/RECORD +73 -0
  70. flode-0.60.3.dist-info/WHEEL +5 -0
  71. flode-0.60.3.dist-info/entry_points.txt +2 -0
  72. flode-0.60.3.dist-info/licenses/LICENSE +21 -0
  73. flode-0.60.3.dist-info/top_level.txt +1 -0
flode/blocks/lookup.py ADDED
@@ -0,0 +1,1025 @@
1
+ """SPEC-0008 + SPEC-0017 + SPEC-0018 + SPEC-0019 / ADR-0059 (v5.1.0+): ルックアップ系ブロック群。
2
+
3
+ 業界標準ブロック線図ツールの "Lookup Tables" カテゴリ全種を提供する。
4
+
5
+ * Wave 1: :class:`LookupTable1D` (SPEC-0008, v5.1.0)
6
+ * Wave 3 第 1 弾: :class:`LookupTable2D` (SPEC-0017, v5.6.0)
7
+ * Wave 3 第 2 弾: :class:`Prelookup` + :class:`InterpolationUsingPrelookup`
8
+ (SPEC-0019, v5.7.0)
9
+ * Wave 3 第 3 弾: :class:`LookupTableND` (SPEC-0018, v5.8.0、3〜n 次元)
10
+
11
+ 補間オブジェクトは ``__init__`` で 1 度だけ構築し、``output`` のホットパスでは
12
+ 評価のみ行う (ADR-0064 §A-1、ADR-0068 §A-1)。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import Any
19
+
20
+ import numpy as np
21
+ import numpy.typing as npt
22
+ from scipy.interpolate import RegularGridInterpolator, interp1d
23
+
24
+ from ..core.block import Block
25
+ from ..exceptions import BlockEvalError, BlockSpecError
26
+
27
+ _logger = logging.getLogger(__name__)
28
+
29
+ #: ADR-0068 §D-1: 軸数がこの値を超えると ``LookupTableND`` 構築時に warning を出す
30
+ #: (hard limit ではない)。
31
+ LOOKUP_ND_AXIS_WARNING_THRESHOLD = 6
32
+
33
+
34
+ class LookupTable1D(Block):
35
+ """1 次元ルックアップテーブル ``y = f(u)``。
36
+
37
+ ブレークポイント配列 ``breakpoints`` (厳密単調増加) と対応するテーブル値
38
+ ``table`` (同長) から区分関数を構成し、``interpolation`` で補間方式、
39
+ ``extrapolation`` で定義域外の挙動を選択する。
40
+
41
+ 補間方式 ``interpolation``:
42
+
43
+ * ``"linear"`` (既定) — 区分線形補間
44
+ * ``"nearest"`` — 最も近いブレークポイントの値 (中点で切替)
45
+ * ``"flat"`` — 前値ホールド (左側ブレークポイントの値、ステップ階段関数)
46
+
47
+ 外挿方式 ``extrapolation``:
48
+
49
+ * ``"clip"`` (既定) — 端点値で飽和 (左端外で ``table[0]``、右端外で ``table[-1]``)
50
+ * ``"linear"`` — 端点近傍 2 ブレークポイントから算出した傾きで線形外挿
51
+ * ``"error"`` — 定義域外で :class:`BlockEvalError` を raise
52
+
53
+ ``__init__`` で ``scipy.interpolate.interp1d`` を 1 度だけ構築し、
54
+ ``self._interp`` に保持する。``output(t, x, u)`` ではこれを呼ぶだけ
55
+ (毎ステップの再構築を避けるため)。
56
+
57
+ Args:
58
+ breakpoints: ブレークポイント配列 (厳密単調増加、長さ >= 2)。既定値
59
+ ``[0.0, 1.0]`` は ``table=[0.0, 1.0]`` と組合せて恒等写像
60
+ ``y = x`` (domain ``[0, 1]``) を表す。
61
+ table: テーブル値配列。長さは ``breakpoints`` と一致必須。
62
+ interpolation: 補間方式 (``"linear"`` / ``"nearest"`` / ``"flat"``)。
63
+ extrapolation: 外挿方式 (``"clip"`` / ``"linear"`` / ``"error"``)。
64
+
65
+ Raises:
66
+ BlockSpecError: ``breakpoints`` の長さ不足・厳密単調増加違反、
67
+ ``table`` 長不一致、``interpolation`` / ``extrapolation`` の enum 値外、
68
+ または要素が float に coerce できないとき。
69
+
70
+ Note:
71
+ 入力 ``nan`` は ``extrapolation`` 設定に関わらず ``nan`` を伝播する
72
+ (scipy 仕様、ADR-0053 寛容方針と整合)。入力 ``±inf`` は ``extrapolation``
73
+ 設定に従う (clip で端点値、linear で ``±inf``、error で例外)。
74
+ """
75
+
76
+ _ALLOWED_INTERPOLATIONS: tuple[str, ...] = ("linear", "nearest", "flat")
77
+ _ALLOWED_EXTRAPOLATIONS: tuple[str, ...] = ("clip", "linear", "error")
78
+ # ADR-0019 / ADR-0039 follow-up: GUI ParameterPanel が enum select を出すヒント
79
+ _param_enums = {
80
+ "interpolation": _ALLOWED_INTERPOLATIONS,
81
+ "extrapolation": _ALLOWED_EXTRAPOLATIONS,
82
+ }
83
+
84
+ # scipy interp1d の kind パラメータへのマッピング ("flat" は scipy の "previous")
85
+ _KIND_MAP: dict[str, str] = {
86
+ "linear": "linear",
87
+ "nearest": "nearest",
88
+ "flat": "previous",
89
+ }
90
+
91
+ def __init__(
92
+ self,
93
+ breakpoints: npt.ArrayLike = (0.0, 1.0),
94
+ table: npt.ArrayLike = (0.0, 1.0),
95
+ interpolation: str = "linear",
96
+ extrapolation: str = "clip",
97
+ *,
98
+ id: str | None = None,
99
+ name: str | None = None,
100
+ ):
101
+ # enum 検証 (numpy 変換より先に明示エラーを出す)
102
+ if interpolation not in self._ALLOWED_INTERPOLATIONS:
103
+ raise BlockSpecError(
104
+ f"LookupTable1D: interpolation must be one of {self._ALLOWED_INTERPOLATIONS}, "
105
+ f"got {interpolation!r}"
106
+ )
107
+ if extrapolation not in self._ALLOWED_EXTRAPOLATIONS:
108
+ raise BlockSpecError(
109
+ f"LookupTable1D: extrapolation must be one of {self._ALLOWED_EXTRAPOLATIONS}, "
110
+ f"got {extrapolation!r}"
111
+ )
112
+
113
+ # 配列を float 化 (混合型 / 非数値は TypeError → BlockSpecError にラップ)
114
+ try:
115
+ bp = np.asarray(breakpoints, dtype=float)
116
+ tbl = np.asarray(table, dtype=float)
117
+ except (TypeError, ValueError) as exc:
118
+ raise BlockSpecError(
119
+ f"LookupTable1D: breakpoints/table must contain numeric values: {exc}"
120
+ ) from exc
121
+
122
+ if bp.ndim != 1 or tbl.ndim != 1:
123
+ raise BlockSpecError(
124
+ f"LookupTable1D: breakpoints and table must be 1-D arrays, "
125
+ f"got shapes {bp.shape} and {tbl.shape}"
126
+ )
127
+ if bp.size < 2:
128
+ raise BlockSpecError(
129
+ f"LookupTable1D: breakpoints must have at least 2 elements, got len={bp.size}"
130
+ )
131
+ if tbl.size != bp.size:
132
+ raise BlockSpecError(
133
+ f"LookupTable1D: len(table)={tbl.size} must equal len(breakpoints)={bp.size}"
134
+ )
135
+ if not np.all(np.diff(bp) > 0):
136
+ raise BlockSpecError(
137
+ f"LookupTable1D: breakpoints must be strictly increasing, got {bp.tolist()!r}"
138
+ )
139
+
140
+ super().__init__(id=id, name=name, n_inputs=1, n_outputs=1)
141
+
142
+ self._breakpoints: npt.NDArray[np.float64] = bp
143
+ self._table: npt.NDArray[np.float64] = tbl
144
+ self.interpolation = interpolation
145
+ self.extrapolation = extrapolation
146
+
147
+ self._interp = self._build_interp(bp, tbl, interpolation, extrapolation)
148
+ # 端点近傍 2 ブレークポイントから左右の slope を算出。
149
+ # linear 外挿 × nearest/flat 補間の組合せでのみ output() で使うが、
150
+ # ホットパスを単純化するため条件分岐せず常に保持する (cost は加減乗のみ)。
151
+ self._left_slope, self._right_slope = self._endpoint_slopes(bp, tbl)
152
+
153
+ # ADR-0008 PS-A: np.ndarray → list 自動変換で永続化、schema bump 不要。
154
+ self._params = {
155
+ "breakpoints": bp.tolist(),
156
+ "table": tbl.tolist(),
157
+ "interpolation": interpolation,
158
+ "extrapolation": extrapolation,
159
+ }
160
+
161
+ @staticmethod
162
+ def _build_interp(
163
+ bp: npt.NDArray[np.float64],
164
+ tbl: npt.NDArray[np.float64],
165
+ interpolation: str,
166
+ extrapolation: str,
167
+ ) -> Any:
168
+ kind = LookupTable1D._KIND_MAP[interpolation]
169
+ if extrapolation == "error":
170
+ # scipy が定義域外で ValueError を raise → output() で BlockEvalError にラップ
171
+ return interp1d(bp, tbl, kind=kind, bounds_error=True, assume_sorted=True)
172
+ if extrapolation == "clip":
173
+ return interp1d(
174
+ bp,
175
+ tbl,
176
+ kind=kind,
177
+ bounds_error=False,
178
+ fill_value=(float(tbl[0]), float(tbl[-1])),
179
+ assume_sorted=True,
180
+ )
181
+ # extrapolation == "linear"
182
+ if interpolation == "linear":
183
+ # scipy 標準の外挿 (端点近傍の傾きを線形に延ばす) と等価
184
+ return interp1d(
185
+ bp,
186
+ tbl,
187
+ kind="linear",
188
+ bounds_error=False,
189
+ fill_value="extrapolate",
190
+ assume_sorted=True,
191
+ )
192
+ # nearest / flat + linear extrapolation は scipy が直接サポートしない。
193
+ # 定義域内は kind で評価し、定義域外は output() 側で独自 slope 外挿する。
194
+ # 定義域外は一旦 nan を返させ、output() で上書きする。
195
+ return interp1d(
196
+ bp,
197
+ tbl,
198
+ kind=kind,
199
+ bounds_error=False,
200
+ fill_value=np.nan,
201
+ assume_sorted=True,
202
+ )
203
+
204
+ @staticmethod
205
+ def _endpoint_slopes(
206
+ bp: npt.NDArray[np.float64], tbl: npt.NDArray[np.float64]
207
+ ) -> tuple[float, float]:
208
+ """端点近傍 2 ブレークポイントから左右の slope を算出する。
209
+
210
+ linear 外挿 × nearest/flat 補間の組合せでのみ使われる。breakpoints は
211
+ 厳密単調増加が __init__ で保証されているため、divisor は常に正。
212
+ """
213
+ left = float((tbl[1] - tbl[0]) / (bp[1] - bp[0]))
214
+ right = float((tbl[-1] - tbl[-2]) / (bp[-1] - bp[-2]))
215
+ return left, right
216
+
217
+ def _extrapolate_from_edge(self, val: float) -> float:
218
+ """定義域外の ``val`` を端点 slope で線形外挿する (linear 外挿専用)。
219
+
220
+ ``slope == 0`` かつ ``val = ±inf`` は ``0 * inf = nan`` になるため、
221
+ 極限値 (= 端点値) を直接返す。
222
+ """
223
+ if val < float(self._breakpoints[0]):
224
+ edge_x, edge_y, slope = (
225
+ float(self._breakpoints[0]),
226
+ float(self._table[0]),
227
+ self._left_slope,
228
+ )
229
+ else:
230
+ edge_x, edge_y, slope = (
231
+ float(self._breakpoints[-1]),
232
+ float(self._table[-1]),
233
+ self._right_slope,
234
+ )
235
+ if slope == 0.0 and np.isinf(val):
236
+ return edge_y
237
+ return edge_y + slope * (val - edge_x)
238
+
239
+ def output(self, t: float, x: npt.NDArray[Any], u: npt.NDArray[Any]) -> npt.NDArray[Any]:
240
+ val = float(np.asarray(u).reshape(-1)[0])
241
+
242
+ # ±inf × linear 外挿: scipy 1.18+ の interp1d は外挿を lerp 形式
243
+ # ``y_lo*(1-t) + y_hi*t`` で計算するため inf - inf = nan (+ RuntimeWarning)
244
+ # になる (~1.17 は point-slope 形式で ±inf を返していた)。scipy を呼ばず
245
+ # 全補間方式で端点 slope から極限値を直接計算する。
246
+ if self.extrapolation == "linear" and np.isinf(val):
247
+ return np.array([self._extrapolate_from_edge(val)])
248
+
249
+ try:
250
+ y = float(self._interp(val))
251
+ except ValueError as exc:
252
+ # extrapolation="error" + 定義域外
253
+ raise BlockEvalError(
254
+ f"LookupTable1D[{self.name}]: input {val} is outside breakpoints "
255
+ f"[{float(self._breakpoints[0])}, {float(self._breakpoints[-1])}] "
256
+ f"and extrapolation='error'",
257
+ block_id=self.id,
258
+ ) from exc
259
+
260
+ # nearest / flat + linear 外挿: scipy が直接サポートしないため、
261
+ # 定義域外 (有限) を独自 slope で上書き (±inf は上で処理済み)
262
+ if (
263
+ self.extrapolation == "linear"
264
+ and self.interpolation in ("nearest", "flat")
265
+ and not np.isnan(val)
266
+ and (val < self._breakpoints[0] or val > self._breakpoints[-1])
267
+ ):
268
+ y = self._extrapolate_from_edge(val)
269
+
270
+ return np.array([y])
271
+
272
+
273
+ class LookupTable2D(Block):
274
+ """2 次元ルックアップテーブル ``y = f(u[0], u[1])``。
275
+
276
+ 2 つのブレークポイント配列 (``breakpoints_row`` / ``breakpoints_col``、
277
+ 各々厳密単調増加) と shape ``(len(breakpoints_row), len(breakpoints_col))``
278
+ のテーブル値 ``table`` から 2-D 区分関数を構成する。``table[i][j]`` は
279
+ ``(breakpoints_row[i], breakpoints_col[j])`` 上の値 (行優先 = numpy 慣習、
280
+ SPEC-0017 §論点 2 確定)。
281
+
282
+ 軸の意味付け (SPEC-0017 §論点 1 確定):
283
+
284
+ * ``u[0]`` = row 軸 (``breakpoints_row``)
285
+ * ``u[1]`` = col 軸 (``breakpoints_col``)
286
+
287
+ 補間方式 ``interpolation``:
288
+
289
+ * ``"linear"`` (既定) — 双線形 (bilinear) 補間。``RegularGridInterpolator``
290
+ の ``method="linear"`` を利用
291
+ * ``"nearest"`` — 最も近い格子点の値。各軸独立に中点で切替
292
+ * ``"flat"`` — 左下角ホールド (SPEC-0017 §論点 3 確定)。``(u[0], u[1])`` を
293
+ 含む cell ``(i, j)`` = ``[bp_row[i], bp_row[i+1]) × [bp_col[j], bp_col[j+1])``
294
+ の値 = ``table[i][j]``。1-D ``"flat"`` (左側 breakpoint 値) の自然な 2-D 拡張、
295
+ ``np.searchsorted(side="right")`` で実装
296
+
297
+ 外挿方式 ``extrapolation``:
298
+
299
+ * ``"clip"`` (既定) — 各軸で端点に飽和してから補間
300
+ * ``"linear"`` — 2 段階 1-D 外挿の合成 (SPEC-0017 §論点 4 / ADR-0064 §B-1
301
+ 確定)。row 軸方向に 1-D 線形外挿で各 col の中間値を求め (= row_strip)、
302
+ 次に col 軸方向に row_strip を 1-D 線形外挿。4 隅外は双線形外挿、エッジ外
303
+ は 1 軸線形外挿になる。``interpolation`` 設定と直交 (内側補間と外側外挿の
304
+ 分離原則)
305
+ * ``"error"`` — 定義域外で :class:`BlockEvalError` を raise
306
+
307
+ Args:
308
+ breakpoints_row: row 軸ブレークポイント (厳密単調増加、長さ >= 2)。
309
+ 既定値 ``[0.0, 1.0]``。``u[0]`` がこの軸に沿って補間される。
310
+ breakpoints_col: col 軸ブレークポイント (厳密単調増加、長さ >= 2)。
311
+ 既定値 ``[0.0, 1.0]``。
312
+ table: 2-D テーブル値。shape ``(len(breakpoints_row), len(breakpoints_col))``。
313
+ 既定値 ``[[0.0, 0.0], [0.0, 0.0]]`` (ゼロ平面 = 対称な自明な既定)。
314
+ interpolation: 補間方式 (``"linear"`` / ``"nearest"`` / ``"flat"``)。
315
+ extrapolation: 外挿方式 (``"clip"`` / ``"linear"`` / ``"error"``)。
316
+
317
+ Raises:
318
+ BlockSpecError: ブレークポイント長不足・厳密単調増加違反、``table`` の
319
+ shape 不一致 / jagged / 次元違反、enum 値外、要素の数値 coerce 失敗。
320
+
321
+ Note:
322
+ 入力 ``nan`` / ``±inf`` の挙動は 1-D と同方針 (寛容)。``extrapolation``
323
+ 設定に従い ``nan`` 伝播 / 端点飽和 / 例外。
324
+
325
+ 実用上限は 50×50 程度を目安とする (SPEC-0017 §非機能要件)。それ以上の
326
+ サイズも構築・評価は可能だが、frontend ``<GridEditor>`` の描画が重くなる。
327
+ virtualization は SPEC-0018 (n-D Lookup) で再考。
328
+ """
329
+
330
+ _ALLOWED_INTERPOLATIONS: tuple[str, ...] = ("linear", "nearest", "flat")
331
+ _ALLOWED_EXTRAPOLATIONS: tuple[str, ...] = ("clip", "linear", "error")
332
+ _param_enums = {
333
+ "interpolation": _ALLOWED_INTERPOLATIONS,
334
+ "extrapolation": _ALLOWED_EXTRAPOLATIONS,
335
+ }
336
+
337
+ def __init__(
338
+ self,
339
+ breakpoints_row: npt.ArrayLike = (0.0, 1.0),
340
+ breakpoints_col: npt.ArrayLike = (0.0, 1.0),
341
+ table: npt.ArrayLike = ((0.0, 0.0), (0.0, 0.0)),
342
+ interpolation: str = "linear",
343
+ extrapolation: str = "clip",
344
+ *,
345
+ id: str | None = None,
346
+ name: str | None = None,
347
+ ):
348
+ if interpolation not in self._ALLOWED_INTERPOLATIONS:
349
+ raise BlockSpecError(
350
+ f"LookupTable2D: interpolation must be one of {self._ALLOWED_INTERPOLATIONS}, "
351
+ f"got {interpolation!r}"
352
+ )
353
+ if extrapolation not in self._ALLOWED_EXTRAPOLATIONS:
354
+ raise BlockSpecError(
355
+ f"LookupTable2D: extrapolation must be one of {self._ALLOWED_EXTRAPOLATIONS}, "
356
+ f"got {extrapolation!r}"
357
+ )
358
+
359
+ try:
360
+ bp_row = np.asarray(breakpoints_row, dtype=float)
361
+ bp_col = np.asarray(breakpoints_col, dtype=float)
362
+ except (TypeError, ValueError) as exc:
363
+ raise BlockSpecError(
364
+ f"LookupTable2D: breakpoints must contain numeric values: {exc}"
365
+ ) from exc
366
+
367
+ if bp_row.ndim != 1:
368
+ raise BlockSpecError(
369
+ f"LookupTable2D: breakpoints_row must be a 1-D array, got shape {bp_row.shape}"
370
+ )
371
+ if bp_col.ndim != 1:
372
+ raise BlockSpecError(
373
+ f"LookupTable2D: breakpoints_col must be a 1-D array, got shape {bp_col.shape}"
374
+ )
375
+ if bp_row.size < 2:
376
+ raise BlockSpecError(
377
+ f"LookupTable2D: breakpoints_row must have at least 2 elements, got len={bp_row.size}"
378
+ )
379
+ if bp_col.size < 2:
380
+ raise BlockSpecError(
381
+ f"LookupTable2D: breakpoints_col must have at least 2 elements, got len={bp_col.size}"
382
+ )
383
+ if not np.all(np.diff(bp_row) > 0):
384
+ raise BlockSpecError(
385
+ f"LookupTable2D: breakpoints_row must be strictly increasing, "
386
+ f"got {bp_row.tolist()!r}"
387
+ )
388
+ if not np.all(np.diff(bp_col) > 0):
389
+ raise BlockSpecError(
390
+ f"LookupTable2D: breakpoints_col must be strictly increasing, "
391
+ f"got {bp_col.tolist()!r}"
392
+ )
393
+
394
+ # jagged な list (= 行ごとに長さが異なる) は np.asarray が numpy >= 2.0 で
395
+ # ValueError を投げる。早期に BlockSpecError へラップする。
396
+ try:
397
+ tbl = np.asarray(table, dtype=float)
398
+ except (TypeError, ValueError) as exc:
399
+ raise BlockSpecError(
400
+ f"LookupTable2D: table must be a regular 2-D array of numeric values "
401
+ f"(got jagged shape or non-numeric): {exc}"
402
+ ) from exc
403
+
404
+ if tbl.ndim != 2:
405
+ raise BlockSpecError(
406
+ f"LookupTable2D: table must be 2-D, got ndim={tbl.ndim} shape={tbl.shape}"
407
+ )
408
+ expected_shape = (int(bp_row.size), int(bp_col.size))
409
+ if tbl.shape != expected_shape:
410
+ raise BlockSpecError(
411
+ f"LookupTable2D: table.shape={tbl.shape} must equal "
412
+ f"(len(breakpoints_row), len(breakpoints_col))={expected_shape}"
413
+ )
414
+
415
+ super().__init__(id=id, name=name, n_inputs=2, n_outputs=1)
416
+
417
+ self._bp_row: npt.NDArray[np.float64] = bp_row
418
+ self._bp_col: npt.NDArray[np.float64] = bp_col
419
+ self._table: npt.NDArray[np.float64] = tbl
420
+ self.interpolation = interpolation
421
+ self.extrapolation = extrapolation
422
+
423
+ # 定義域内補間: linear / nearest は scipy RegularGridInterpolator。
424
+ # flat は独自実装 (scipy 非対応)。"error" 検出は output() 側で行うため、
425
+ # ここでは bounds_error=False で構築 (定義域外は post-process で扱う)。
426
+ scipy_method = "linear" if interpolation == "linear" else "nearest"
427
+ # flat の場合も RegularGridInterpolator は構築するが output で参照しない。
428
+ # 構築コスト微小、コードの単純化を優先 (ADR-0064 §A-1 1 回構築方針)。
429
+ self._interp = RegularGridInterpolator(
430
+ (bp_row, bp_col),
431
+ tbl,
432
+ method=scipy_method,
433
+ bounds_error=False,
434
+ fill_value=np.nan,
435
+ )
436
+
437
+ # linear 外挿用: row 軸方向に table 全体を 1-D 線形補間/外挿する interp1d。
438
+ # axis=0 で構築すると interp1d(u0) は shape (n_col,) を返し、これを再度
439
+ # col 軸方向に 1-D 線形外挿して最終値を得る (2 段階 1-D 外挿の合成、
440
+ # ADR-0064 §B-1)。
441
+ self._row_extrap_1d = interp1d(
442
+ bp_row,
443
+ tbl,
444
+ axis=0,
445
+ kind="linear",
446
+ bounds_error=False,
447
+ fill_value="extrapolate",
448
+ assume_sorted=True,
449
+ )
450
+
451
+ self._params = {
452
+ "breakpoints_row": bp_row.tolist(),
453
+ "breakpoints_col": bp_col.tolist(),
454
+ "table": tbl.tolist(),
455
+ "interpolation": interpolation,
456
+ "extrapolation": extrapolation,
457
+ }
458
+
459
+ def _flat_lookup(self, u0: float, u1: float) -> float:
460
+ """``"flat"`` 補間: cell ``(i, j)`` の左下角値 = ``table[i][j]`` を返す。
461
+
462
+ ``np.searchsorted(side="right") - 1`` + ``clip([0, len-2])`` で 1-D
463
+ ``"flat"`` (左側 breakpoint 値) の 2-D 拡張。境界点 (例 ``u = bp[-1]``)
464
+ は右側 cell の左下角扱い。
465
+ """
466
+ i = int(
467
+ np.clip(np.searchsorted(self._bp_row, u0, side="right") - 1, 0, self._bp_row.size - 2)
468
+ )
469
+ j = int(
470
+ np.clip(np.searchsorted(self._bp_col, u1, side="right") - 1, 0, self._bp_col.size - 2)
471
+ )
472
+ return float(self._table[i, j])
473
+
474
+ def _linear_extrapolate(self, u0: float, u1: float) -> float:
475
+ """``"linear"`` 外挿: 2 段階 1-D 外挿の合成 (ADR-0064 §B-1)。
476
+
477
+ 1. row 軸方向に ``table`` を 1-D 線形外挿で ``u0`` で評価
478
+ → shape ``(n_col,)`` の row_strip
479
+ 2. col 軸方向に row_strip を 1-D 線形外挿で ``u1`` で評価
480
+
481
+ 4 隅外 (両軸外) は数学的に双線形外挿になり cross term を自動含有。
482
+ ``interpolation`` 設定と独立 (内側補間方式に関わらず外挿は常に線形)。
483
+ """
484
+ row_strip = np.asarray(self._row_extrap_1d(u0)).reshape(-1)
485
+ # row_strip は length n_col の 1-D 配列。col 方向に再度 1-D 線形外挿。
486
+ # 毎回構築するが、外挿経路はホットパスではないため許容 (SPEC-0017 §非機能要件)。
487
+ col_extrap = interp1d(
488
+ self._bp_col,
489
+ row_strip,
490
+ kind="linear",
491
+ bounds_error=False,
492
+ fill_value="extrapolate",
493
+ assume_sorted=True,
494
+ )
495
+ return float(col_extrap(u1))
496
+
497
+ def output(self, t: float, x: npt.NDArray[Any], u: npt.NDArray[Any]) -> npt.NDArray[Any]:
498
+ u_arr = np.asarray(u).reshape(-1)
499
+ u0 = float(u_arr[0])
500
+ u1 = float(u_arr[1])
501
+
502
+ # nan は比較が全て False になるため、in_row/in_col は False となり
503
+ # extrapolation 経路に入る (== 1-D と同方針)。
504
+ in_row = bool(self._bp_row[0] <= u0 <= self._bp_row[-1])
505
+ in_col = bool(self._bp_col[0] <= u1 <= self._bp_col[-1])
506
+
507
+ if not (in_row and in_col):
508
+ if self.extrapolation == "error":
509
+ # nan / inf や定義域外で例外 (構造化エラー protocol)
510
+ raise BlockEvalError(
511
+ f"LookupTable2D[{self.name}]: input ({u0}, {u1}) is outside "
512
+ f"breakpoints_row [{float(self._bp_row[0])}, {float(self._bp_row[-1])}] "
513
+ f"× breakpoints_col [{float(self._bp_col[0])}, {float(self._bp_col[-1])}] "
514
+ f"and extrapolation='error'",
515
+ block_id=self.id,
516
+ )
517
+ if self.extrapolation == "linear":
518
+ # nan は伝播 (np.isnan で early return、interp1d が nan を返すため不要)
519
+ y = self._linear_extrapolate(u0, u1)
520
+ return np.array([y])
521
+ # extrapolation == "clip": 端点に飽和してから補間
522
+ u0 = float(np.clip(u0, self._bp_row[0], self._bp_row[-1]))
523
+ u1 = float(np.clip(u1, self._bp_col[0], self._bp_col[-1]))
524
+
525
+ if self.interpolation == "flat":
526
+ # nan が clip 後に残るのは clip(nan)=nan の場合 (ありうる)
527
+ if np.isnan(u0) or np.isnan(u1):
528
+ return np.array([float("nan")])
529
+ y = self._flat_lookup(u0, u1)
530
+ else:
531
+ # RegularGridInterpolator は shape (npts, ndim) を期待 → (1, 2) で渡す。
532
+ # 戻り値は shape (1,) なので .item() で scalar 抽出 (numpy 2.0 警告回避)。
533
+ y = float(self._interp(np.array([[u0, u1]])).item())
534
+ return np.array([y])
535
+
536
+
537
+ class Prelookup(Block):
538
+ """1-D Prelookup: 入力 ``u`` から ``(k, f)`` を分離出力する (SPEC-0019)。
539
+
540
+ breakpoints 検索結果を `(k=index, f=fraction)` の 2 出力に分離することで、
541
+ 後段の :class:`InterpolationUsingPrelookup` 複数本で **検索コストを共有**
542
+ する設計パターンに使う。1-D に対し ``u → k, f`` の前処理段。
543
+
544
+ 数値仕様 (ADR-0067 §A-1 採用、純 numpy):
545
+
546
+ * 定義域内: ``k = clip(searchsorted(bp, u, side="right") - 1, 0, n-2)``、
547
+ ``f = (u - bp[k]) / (bp[k+1] - bp[k])``
548
+ * 定義域外 + ``"clip"``: ``u`` を端点に飽和してから上式
549
+ * 定義域外 + ``"linear"`` (ADR-0067 §B-1): 端点 cell に ``k`` を固定し、
550
+ ``f`` が ``[0, 1]`` を外れる値で出力。後段の線形補間式
551
+ ``y = table[k] + f*(table[k+1]-table[k])`` に流れることで自動で線形外挿
552
+ * 定義域外 + ``"error"``: :class:`BlockEvalError`
553
+
554
+ Args:
555
+ breakpoints: ブレークポイント配列 (厳密単調増加、長さ >= 2)。既定値
556
+ ``[0.0, 1.0]``。
557
+ extrapolation: 外挿方式 (``"clip"`` / ``"linear"`` / ``"error"``)。
558
+
559
+ Raises:
560
+ BlockSpecError: ``breakpoints`` の長さ不足・厳密単調増加違反、
561
+ ``extrapolation`` の enum 値外、または要素が float に coerce できない。
562
+
563
+ Note:
564
+ scipy 不使用の純 numpy 実装 (SPEC-0008/0017 とは対照的)。
565
+ """
566
+
567
+ _ALLOWED_EXTRAPOLATIONS: tuple[str, ...] = ("clip", "linear", "error")
568
+ _param_enums = {"extrapolation": _ALLOWED_EXTRAPOLATIONS}
569
+
570
+ def __init__(
571
+ self,
572
+ breakpoints: npt.ArrayLike = (0.0, 1.0),
573
+ extrapolation: str = "clip",
574
+ *,
575
+ id: str | None = None,
576
+ name: str | None = None,
577
+ ):
578
+ if extrapolation not in self._ALLOWED_EXTRAPOLATIONS:
579
+ raise BlockSpecError(
580
+ f"Prelookup: extrapolation must be one of {self._ALLOWED_EXTRAPOLATIONS}, "
581
+ f"got {extrapolation!r}"
582
+ )
583
+
584
+ try:
585
+ bp = np.asarray(breakpoints, dtype=float)
586
+ except (TypeError, ValueError) as exc:
587
+ raise BlockSpecError(
588
+ f"Prelookup: breakpoints must contain numeric values: {exc}"
589
+ ) from exc
590
+
591
+ if bp.ndim != 1:
592
+ raise BlockSpecError(
593
+ f"Prelookup: breakpoints must be a 1-D array, got shape {bp.shape}"
594
+ )
595
+ if bp.size < 2:
596
+ raise BlockSpecError(
597
+ f"Prelookup: breakpoints must have at least 2 elements, got len={bp.size}"
598
+ )
599
+ if not np.all(np.diff(bp) > 0):
600
+ raise BlockSpecError(
601
+ f"Prelookup: breakpoints must be strictly increasing, got {bp.tolist()!r}"
602
+ )
603
+
604
+ super().__init__(id=id, name=name, n_inputs=1, n_outputs=2)
605
+
606
+ self._breakpoints: npt.NDArray[np.float64] = bp
607
+ self.extrapolation = extrapolation
608
+
609
+ self._params = {
610
+ "breakpoints": bp.tolist(),
611
+ "extrapolation": extrapolation,
612
+ }
613
+
614
+ def output(self, t: float, x: npt.NDArray[Any], u: npt.NDArray[Any]) -> npt.NDArray[Any]:
615
+ val = float(np.asarray(u).reshape(-1)[0])
616
+ bp = self._breakpoints
617
+ n = bp.size
618
+
619
+ if np.isnan(val):
620
+ # nan は 1-D LookupTable と同方針で伝播
621
+ return np.array([float("nan"), float("nan")])
622
+
623
+ in_domain = bool(bp[0] <= val <= bp[-1])
624
+
625
+ if not in_domain:
626
+ if self.extrapolation == "error":
627
+ raise BlockEvalError(
628
+ f"Prelookup[{self.name}]: input {val} is outside breakpoints "
629
+ f"[{float(bp[0])}, {float(bp[-1])}] and extrapolation='error'",
630
+ block_id=self.id,
631
+ )
632
+ if self.extrapolation == "linear":
633
+ # 端点 cell に k を固定、f は区間外値で出力 (後段で線形外挿)
634
+ if val < bp[0]:
635
+ k = 0
636
+ else: # val > bp[-1] (inf 含む)
637
+ k = n - 2
638
+ f = (val - float(bp[k])) / (float(bp[k + 1]) - float(bp[k]))
639
+ return np.array([float(k), f])
640
+ # extrapolation == "clip": 端点に飽和
641
+ val = float(np.clip(val, bp[0], bp[-1]))
642
+
643
+ # 定義域内 (or clip 後): 純 numpy 検索
644
+ k = int(np.clip(np.searchsorted(bp, val, side="right") - 1, 0, n - 2))
645
+ f = (val - float(bp[k])) / (float(bp[k + 1]) - float(bp[k]))
646
+ return np.array([float(k), f])
647
+
648
+
649
+ class InterpolationUsingPrelookup(Block):
650
+ """1-D Prelookup を用いた補間 (SPEC-0019)。
651
+
652
+ :class:`Prelookup` の出力 ``(k, f)`` を受け取り、内部 ``table`` から
653
+ ``y`` を計算する。同じ breakpoints を複数の table で共有する設計
654
+ パターン (= 検索コスト分離) のための後段ブロック。
655
+
656
+ 補間方式 ``interpolation``:
657
+
658
+ * ``"linear"`` (既定) — ``y = table[k] + f * (table[k+1] - table[k])``。
659
+ ``f`` が ``[0, 1]`` 範囲外でも数式が自動的に線形外挿になる (Prelookup
660
+ 側で ``extrapolation="linear"`` を選択した時の経路、ADR-0067 §B-1)
661
+ * ``"nearest"`` — ``y = table[k+1] if f >= 0.5 else table[k]``。
662
+ ``LookupTable1D(interpolation="nearest")`` は scipy の中点丸め
663
+ (``f=0.5`` のとき左側) を採用、本ブロックは ``f>=0.5`` で右側 (1 LSB
664
+ 差を許容、ADR-0067 §OQ5)
665
+ * ``"flat"`` — ``y = table[k]`` (``f`` を無視、左側値ホールド)
666
+
667
+ ``k`` は :class:`Prelookup` が出力する想定 ``[0, n-2]`` だが、上流に
668
+ 別のブロックが入っているケースに備えて黙って ``clip([0, n-2])`` する
669
+ (``BlockEvalError`` は投げない、ADR-0067 §B-1 / Consequences §ネガティブ)。
670
+
671
+ Args:
672
+ table: テーブル値配列 (1-D、長さ >= 2)。既定値 ``[0.0, 1.0]``。
673
+ interpolation: 補間方式 (``"linear"`` / ``"nearest"`` / ``"flat"``)。
674
+
675
+ Raises:
676
+ BlockSpecError: ``table`` の長さ不足 / 1-D 違反 / 数値 coerce 失敗、
677
+ ``interpolation`` の enum 値外。
678
+
679
+ Note:
680
+ scipy 不使用の純 numpy 実装。
681
+ """
682
+
683
+ _ALLOWED_INTERPOLATIONS: tuple[str, ...] = ("linear", "nearest", "flat")
684
+ _param_enums = {"interpolation": _ALLOWED_INTERPOLATIONS}
685
+
686
+ def __init__(
687
+ self,
688
+ table: npt.ArrayLike = (0.0, 1.0),
689
+ interpolation: str = "linear",
690
+ *,
691
+ id: str | None = None,
692
+ name: str | None = None,
693
+ ):
694
+ if interpolation not in self._ALLOWED_INTERPOLATIONS:
695
+ raise BlockSpecError(
696
+ f"InterpolationUsingPrelookup: interpolation must be one of "
697
+ f"{self._ALLOWED_INTERPOLATIONS}, got {interpolation!r}"
698
+ )
699
+
700
+ try:
701
+ tbl = np.asarray(table, dtype=float)
702
+ except (TypeError, ValueError) as exc:
703
+ raise BlockSpecError(
704
+ f"InterpolationUsingPrelookup: table must contain numeric values: {exc}"
705
+ ) from exc
706
+
707
+ if tbl.ndim != 1:
708
+ raise BlockSpecError(
709
+ f"InterpolationUsingPrelookup: table must be a 1-D array, got shape {tbl.shape}"
710
+ )
711
+ if tbl.size < 2:
712
+ raise BlockSpecError(
713
+ f"InterpolationUsingPrelookup: table must have at least 2 elements, "
714
+ f"got len={tbl.size}"
715
+ )
716
+
717
+ super().__init__(id=id, name=name, n_inputs=2, n_outputs=1)
718
+
719
+ self._table: npt.NDArray[np.float64] = tbl
720
+ self.interpolation = interpolation
721
+
722
+ self._params = {
723
+ "table": tbl.tolist(),
724
+ "interpolation": interpolation,
725
+ }
726
+
727
+ def output(self, t: float, x: npt.NDArray[Any], u: npt.NDArray[Any]) -> npt.NDArray[Any]:
728
+ u_arr = np.asarray(u).reshape(-1)
729
+ k_raw = float(u_arr[0])
730
+ f = float(u_arr[1])
731
+ tbl = self._table
732
+ n = tbl.size
733
+
734
+ # k / f の nan は伝播 (= nan 出力)。
735
+ if np.isnan(k_raw) or np.isnan(f):
736
+ return np.array([float("nan")])
737
+ # k は黙って clip ([0, n-2])。Prelookup の出力域だが上流ブロック自由なので保険。
738
+ # ``np.clip`` を ``int()`` より先に評価することで ``k_raw=±inf`` でも
739
+ # OverflowError を避けて n-2 / 0 に飽和させる (ADR-0056 構造化エラー protocol)。
740
+ k = int(np.clip(k_raw, 0, n - 2))
741
+
742
+ if self.interpolation == "linear":
743
+ y = float(tbl[k]) + f * (float(tbl[k + 1]) - float(tbl[k]))
744
+ elif self.interpolation == "nearest":
745
+ y = float(tbl[k + 1]) if f >= 0.5 else float(tbl[k])
746
+ else: # "flat"
747
+ y = float(tbl[k])
748
+
749
+ return np.array([y])
750
+
751
+
752
+ class LookupTableND(Block):
753
+ """n 次元ルックアップテーブル ``y = f(u[0], u[1], ..., u[n-1])`` (SPEC-0018)。
754
+
755
+ 軸数 n は ``len(breakpoints_axes)`` で動的に決まる。``LookupTable1D`` (n=1) /
756
+ ``LookupTable2D`` (n=2) の自然な n-D 拡張。3〜6 軸を想定スコープ、7 軸以上は
757
+ warning + 構築可能 (hard limit なし、ADR-0068 §D-1)。
758
+
759
+ 軸の意味付け:
760
+ ``breakpoints_axes[i]`` は軸 ``i`` の breakpoints。``table`` は
761
+ ``np.ndarray`` 化したとき shape ``tuple(len(bp) for bp in breakpoints_axes)``
762
+ を持つ。``table[i0, i1, ..., i_{n-1}]`` =
763
+ ``(breakpoints_axes[0][i0], ..., breakpoints_axes[n-1][i_{n-1}])`` の値。
764
+
765
+ 補間方式 ``interpolation``:
766
+
767
+ * ``"linear"`` — n 線形 (= tensorial linear) 補間。
768
+ ``scipy.interpolate.RegularGridInterpolator(method="linear")``
769
+ * ``"nearest"`` — 最も近い格子点の値。各軸独立に中点で切替
770
+ * ``"flat"`` — 左下角ホールド (SPEC-0017 と同方針)。各軸独立に
771
+ ``np.searchsorted(side="right")`` で cell を特定
772
+
773
+ 外挿方式 ``extrapolation``:
774
+
775
+ * ``"clip"`` — 各軸で端点に飽和してから補間
776
+ * ``"linear"`` — 2 段階 1-D 外挿の合成を n 軸に再帰拡張 (ADR-0068 §C-1)。
777
+ 各軸独立 1-D 線形延長の合成で n 軸線形外挿が自動成立 (cross term 含む)
778
+ * ``"error"`` — 定義域外で :class:`BlockEvalError` を raise
779
+
780
+ Args:
781
+ breakpoints_axes: 各軸 breakpoints のリスト (各々厳密単調増加、長さ >= 2)。
782
+ 軸数 (= ``len(breakpoints_axes)``) は >= 2 必須。既定値
783
+ ``[[0.0, 1.0], [0.0, 1.0]]`` (= 2 軸ゼロ平面、``LookupTable2D``
784
+ 既定と等価)。
785
+ table: n-D テーブル値。shape は
786
+ ``tuple(len(bp) for bp in breakpoints_axes)`` と一致必須。
787
+ interpolation: 補間方式 (``"linear"`` / ``"nearest"`` / ``"flat"``)。
788
+ extrapolation: 外挿方式 (``"clip"`` / ``"linear"`` / ``"error"``)。
789
+
790
+ Raises:
791
+ BlockSpecError: 軸数 < 2 / 各軸長さ < 2 / 厳密単調増加違反 / shape 不一致 /
792
+ enum 値外 / 数値 coerce 失敗。
793
+ """
794
+
795
+ _ALLOWED_INTERPOLATIONS: tuple[str, ...] = ("linear", "nearest", "flat")
796
+ _ALLOWED_EXTRAPOLATIONS: tuple[str, ...] = ("clip", "linear", "error")
797
+ _param_enums = {
798
+ "interpolation": _ALLOWED_INTERPOLATIONS,
799
+ "extrapolation": _ALLOWED_EXTRAPOLATIONS,
800
+ }
801
+
802
+ def __init__(
803
+ self,
804
+ breakpoints_axes: list[npt.ArrayLike] | None = None,
805
+ table: npt.ArrayLike | None = None,
806
+ interpolation: str = "linear",
807
+ extrapolation: str = "clip",
808
+ *,
809
+ id: str | None = None,
810
+ name: str | None = None,
811
+ ):
812
+ if interpolation not in self._ALLOWED_INTERPOLATIONS:
813
+ raise BlockSpecError(
814
+ f"LookupTableND: interpolation must be one of {self._ALLOWED_INTERPOLATIONS}, "
815
+ f"got {interpolation!r}"
816
+ )
817
+ if extrapolation not in self._ALLOWED_EXTRAPOLATIONS:
818
+ raise BlockSpecError(
819
+ f"LookupTableND: extrapolation must be one of {self._ALLOWED_EXTRAPOLATIONS}, "
820
+ f"got {extrapolation!r}"
821
+ )
822
+
823
+ # 既定: 2 軸ゼロ平面 (LookupTable2D 既定と等価、palette drop 時の自明な初期値)。
824
+ if breakpoints_axes is None:
825
+ breakpoints_axes = [[0.0, 1.0], [0.0, 1.0]]
826
+ if table is None:
827
+ table = [[0.0, 0.0], [0.0, 0.0]]
828
+
829
+ if not isinstance(breakpoints_axes, list) or len(breakpoints_axes) < 2:
830
+ got_len = len(breakpoints_axes) if isinstance(breakpoints_axes, list) else 0
831
+ raise BlockSpecError(
832
+ f"LookupTableND: breakpoints_axes must have at least 2 axes "
833
+ f"(got {got_len}); use LookupTable1D for 1-D"
834
+ )
835
+
836
+ n_axes = len(breakpoints_axes)
837
+ if n_axes > LOOKUP_ND_AXIS_WARNING_THRESHOLD:
838
+ _logger.warning(
839
+ "LookupTableND: %d axes exceeds the recommended threshold of %d. "
840
+ "Construction is allowed but memory usage scales exponentially.",
841
+ n_axes,
842
+ LOOKUP_ND_AXIS_WARNING_THRESHOLD,
843
+ )
844
+
845
+ # 各軸 breakpoints の検証 (SPEC-0008 / 0017 と同パターン)
846
+ bp_arrays: list[npt.NDArray[np.float64]] = []
847
+ for axis_i, bp_raw in enumerate(breakpoints_axes):
848
+ try:
849
+ bp = np.asarray(bp_raw, dtype=float)
850
+ except (TypeError, ValueError) as exc:
851
+ raise BlockSpecError(
852
+ f"LookupTableND: breakpoints_axes[{axis_i}] must contain numeric values: {exc}"
853
+ ) from exc
854
+ if bp.ndim != 1:
855
+ raise BlockSpecError(
856
+ f"LookupTableND: breakpoints_axes[{axis_i}] must be 1-D, got shape {bp.shape}"
857
+ )
858
+ if bp.size < 2:
859
+ raise BlockSpecError(
860
+ f"LookupTableND: breakpoints_axes[{axis_i}] must have at "
861
+ f"least 2 elements, got len={bp.size}"
862
+ )
863
+ if not np.all(np.diff(bp) > 0):
864
+ raise BlockSpecError(
865
+ f"LookupTableND: breakpoints_axes[{axis_i}] must be "
866
+ f"strictly increasing, got {bp.tolist()!r}"
867
+ )
868
+ bp_arrays.append(bp)
869
+
870
+ try:
871
+ tbl = np.asarray(table, dtype=float)
872
+ except (TypeError, ValueError) as exc:
873
+ raise BlockSpecError(
874
+ f"LookupTableND: table must be a regular n-D array of numeric "
875
+ f"values (got jagged shape or non-numeric): {exc}"
876
+ ) from exc
877
+
878
+ if tbl.ndim != n_axes:
879
+ raise BlockSpecError(
880
+ f"LookupTableND: table must be {n_axes}-D (matching "
881
+ f"len(breakpoints_axes)), got ndim={tbl.ndim} shape={tbl.shape}"
882
+ )
883
+ expected_shape = tuple(int(bp.size) for bp in bp_arrays)
884
+ if tbl.shape != expected_shape:
885
+ raise BlockSpecError(
886
+ f"LookupTableND: table.shape={tbl.shape} must equal "
887
+ f"tuple(len(bp) for bp in breakpoints_axes)={expected_shape}"
888
+ )
889
+
890
+ super().__init__(id=id, name=name, n_inputs=n_axes, n_outputs=1)
891
+
892
+ self._bp_arrays: list[npt.NDArray[np.float64]] = bp_arrays
893
+ self._table: npt.NDArray[np.float64] = tbl
894
+ self._n_axes: int = n_axes
895
+ self.interpolation = interpolation
896
+ self.extrapolation = extrapolation
897
+
898
+ scipy_method = "linear" if interpolation == "linear" else "nearest"
899
+ self._interp = RegularGridInterpolator(
900
+ tuple(bp_arrays),
901
+ tbl,
902
+ method=scipy_method,
903
+ bounds_error=False,
904
+ fill_value=np.nan,
905
+ )
906
+
907
+ self._params = {
908
+ "breakpoints_axes": [bp.tolist() for bp in bp_arrays],
909
+ "table": tbl.tolist(),
910
+ "interpolation": interpolation,
911
+ "extrapolation": extrapolation,
912
+ }
913
+
914
+ def _flat_lookup(self, u_vals: npt.NDArray[np.float64]) -> float:
915
+ """各軸独立に左下角 cell を特定し ``table[i0, i1, ..., i_{n-1}]`` を返す。
916
+
917
+ SPEC-0017 の 2-D ``_flat_lookup`` を n 軸に一般化。``searchsorted(side="right")``
918
+ + ``clip([0, n-2])`` を各軸で行い、最終 index タプルで table から取得。
919
+ """
920
+ idx: list[int] = []
921
+ for axis_i in range(self._n_axes):
922
+ bp = self._bp_arrays[axis_i]
923
+ ii = int(
924
+ np.clip(
925
+ np.searchsorted(bp, u_vals[axis_i], side="right") - 1,
926
+ 0,
927
+ bp.size - 2,
928
+ )
929
+ )
930
+ idx.append(ii)
931
+ return float(self._table[tuple(idx)])
932
+
933
+ def _linear_extrapolate(self, u_vals: npt.NDArray[np.float64]) -> float:
934
+ """n 軸線形外挿を「2 段階 1-D 外挿の合成」の n 軸再帰拡張で計算 (ADR-0068 §C-1)。
935
+
936
+ 軸 0 から順に 1-D 線形補間/外挿で table を縮約: 軸 0 で u[0] を eval すると
937
+ shape (n_1, n_2, ..., n_{n-1}) の strip → 軸 1 で u[1] を eval すると
938
+ shape (n_2, ..., n_{n-1}) → ... → 最終的にスカラー。
939
+
940
+ ``interp1d(axis=0, kind="linear", fill_value="extrapolate")`` を各軸で
941
+ 逐次適用 (= n 回構築・eval)。
942
+
943
+ **設計上の注意 (ADR-0068 §C-1 の「補間/外挿の直交分離」)**:
944
+ 本メソッドは ``extrapolation == "linear"`` かつ 1 軸でも定義域外のとき
945
+ 呼ばれ、**全軸を線形で処理する** (定義域内の他軸も含めて)。これは
946
+ ``interpolation`` 設定 (``"nearest"`` / ``"flat"`` でも) に依存せず常に
947
+ 線形で外挿する SPEC-0017 / SPEC-0018 共通方針 (= 内側補間と外側外挿の
948
+ 分離原則)。1-D / 2-D 版とも同じ判断。
949
+
950
+ **性能上の注意**: 2-D 版 (``LookupTable2D``) は ``__init__`` で
951
+ ``_row_extrap_1d`` を 1 つだけ事前構築し、col 軸の ``interp1d`` のみを
952
+ 外挿経路で構築する。n-D 版は軸数が動的なため全軸の ``interp1d`` を
953
+ 事前保持せず、毎外挿呼び出しで n 回構築する。外挿経路はホットパス外
954
+ (clip / 定義域内なら通らない) のため許容するが、外挿が頻発するモデル
955
+ では性能影響あり。
956
+ """
957
+ current = self._table
958
+ for axis_i in range(self._n_axes):
959
+ bp = self._bp_arrays[axis_i]
960
+ # axis=0 で 1-D 線形外挿 (= 最初の残存軸を消費)
961
+ interp_1d = interp1d(
962
+ bp,
963
+ current,
964
+ axis=0,
965
+ kind="linear",
966
+ bounds_error=False,
967
+ fill_value="extrapolate",
968
+ assume_sorted=True,
969
+ )
970
+ current = np.asarray(interp_1d(float(u_vals[axis_i])))
971
+ # current は 0-D scalar (np.ndarray) になっているはず
972
+ return float(np.asarray(current).item())
973
+
974
+ def output(self, t: float, x: npt.NDArray[Any], u: npt.NDArray[Any]) -> npt.NDArray[Any]:
975
+ u_arr = np.asarray(u, dtype=float).reshape(-1)
976
+ if u_arr.size != self._n_axes:
977
+ raise BlockEvalError(
978
+ f"LookupTableND[{self.name}]: expected {self._n_axes} inputs, got {u_arr.size}",
979
+ block_id=self.id,
980
+ )
981
+
982
+ # 各軸の定義域内判定 (nan は <=/>= で False になり外挿経路へ)
983
+ in_domain_flags = [
984
+ bool(self._bp_arrays[i][0] <= u_arr[i] <= self._bp_arrays[i][-1])
985
+ for i in range(self._n_axes)
986
+ ]
987
+ all_in_domain = all(in_domain_flags)
988
+
989
+ if not all_in_domain:
990
+ if self.extrapolation == "error":
991
+ bounds_repr = ", ".join(
992
+ f"axis[{i}] [{float(self._bp_arrays[i][0])}, {float(self._bp_arrays[i][-1])}]"
993
+ for i in range(self._n_axes)
994
+ )
995
+ raise BlockEvalError(
996
+ f"LookupTableND[{self.name}]: input {u_arr.tolist()} is "
997
+ f"outside {bounds_repr} and extrapolation='error'",
998
+ block_id=self.id,
999
+ )
1000
+ if self.extrapolation == "linear":
1001
+ # nan 含むときは結果も nan (interp1d が nan 伝播)
1002
+ y = self._linear_extrapolate(u_arr)
1003
+ return np.array([y])
1004
+ # extrapolation == "clip": 各軸独立に端点飽和
1005
+ u_arr = np.array(
1006
+ [
1007
+ float(
1008
+ np.clip(
1009
+ u_arr[i],
1010
+ self._bp_arrays[i][0],
1011
+ self._bp_arrays[i][-1],
1012
+ )
1013
+ )
1014
+ for i in range(self._n_axes)
1015
+ ]
1016
+ )
1017
+
1018
+ if self.interpolation == "flat":
1019
+ if bool(np.any(np.isnan(u_arr))):
1020
+ return np.array([float("nan")])
1021
+ y = self._flat_lookup(u_arr)
1022
+ else:
1023
+ # RegularGridInterpolator は shape (npts, ndim) を期待 → (1, n_axes)。
1024
+ y = float(self._interp(u_arr.reshape(1, -1)).item())
1025
+ return np.array([y])