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
@@ -0,0 +1,714 @@
1
+ """モデル線形化 (ADR-0026)。
2
+
3
+ 動作点 ``(t*, x*, u*)`` 周りで非線形モデルを線形化し、状態空間モデル
4
+ ``(A, B, C, D)`` を返す。
5
+
6
+ 設計方針:
7
+
8
+ - **数値手法**: 中心差分 default (誤差 O(h²))、`method="forward"` で前進差分
9
+ (半分のコスト + 誤差 O(h)) を opt-in。
10
+ - **依存追加なし**: numpy / scipy のみ。`python-control` は :func:`LinearSystem.to_control_ss`
11
+ 経由でのみ使い、未インストールでも本モジュールは動作する。
12
+ - **既存 33 ブロック無改修**: ``Block.derivative`` / ``Block.output`` (および SM-B の
13
+ ``output_v``) を観察するだけ。Block 基底契約は不変。
14
+ - **MVP scope**: 連続のみ線形化。離散ブロックは動作点で固定 + warning。連続状態が
15
+ ゼロのモデルは :class:`BlockSpecError` で拒否。
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import warnings
22
+ from dataclasses import dataclass
23
+ from typing import TYPE_CHECKING, Any, Literal
24
+
25
+ import numpy as np
26
+ import numpy.typing as npt
27
+
28
+ from ..exceptions import BlockSpecError, SolverError
29
+
30
+ if TYPE_CHECKING: # pragma: no cover - import 循環回避
31
+ from ..core.block import Block
32
+ from ..core.simulator import Simulator
33
+ from .frequency_response import BodeResponse, NyquistResponse
34
+ from .stability import RootLocus
35
+
36
+ _logger = logging.getLogger("flode.analysis.linearize")
37
+
38
+ #: 中心差分 / 前進差分の摂動係数 default。
39
+ #: ``sqrt(machine_eps)`` は中心差分の最適 step として古典的に採用される値
40
+ #: (打ち切り誤差と桁落ち誤差のバランス)。
41
+ SQRT_EPS: float = float(np.sqrt(np.finfo(np.float64).eps))
42
+
43
+
44
+ @dataclass(frozen=True, eq=False)
45
+ class LinearSystem:
46
+ """線形化結果の状態空間表現。
47
+
48
+ ``x_dot = A @ x + B @ u``、``y = C @ x + D @ u`` を表す。
49
+
50
+ Attributes:
51
+ A: システム行列、shape ``(n, n)``。``n`` は連続状態の総次元
52
+ (Subsystem 内部含む、:meth:`Simulator._state_layout` の登録順)。
53
+ B: 入力行列、shape ``(n, m)``。``m`` は外部入力 (= 結線されていない
54
+ 入力ポートを SM-B port shape ごと flatten した次元) の合計。
55
+ C: 出力行列、shape ``(p, n)``。``p`` は外部出力 (= ``Scope`` を駆動する
56
+ か、どのブロックにも消費されない出力ポートを flatten した次元) の合計。
57
+ D: 直達行列、shape ``(p, m)``。
58
+ state_names: 各状態次元のラベル ``"{block_id}.x[{i}]"``。長さ ``n``。
59
+ input_names: 各入力次元のラベル ``"{block_id}.in[{port_idx}][{flat_idx}]"``。
60
+ 長さ ``m``。
61
+ output_names: 各出力次元のラベル ``"{block_id}.out[{port_idx}][{flat_idx}]"``。
62
+ 長さ ``p``。
63
+ operating_point: 動作点 ``{"t": float, "x": ndarray, "u": ndarray}``
64
+ (再現性 + デバッグ用)。
65
+
66
+ Note:
67
+ ``frozen=True, eq=False`` で実装している。``eq=True`` だと dataclass の
68
+ 自動生成 ``__eq__`` が numpy 配列を ``==`` 比較し、ブロードキャスト結果が
69
+ truth-ambiguous になるため。テストでは :func:`numpy.testing.assert_allclose`
70
+ で要素ごとに比較する。
71
+ """
72
+
73
+ A: npt.NDArray[Any]
74
+ B: npt.NDArray[Any]
75
+ C: npt.NDArray[Any]
76
+ D: npt.NDArray[Any]
77
+ state_names: list[str]
78
+ input_names: list[str]
79
+ output_names: list[str]
80
+ operating_point: dict[str, Any]
81
+
82
+ def to_control_ss(self) -> Any:
83
+ """``python-control`` の ``StateSpace`` インスタンスに変換する。
84
+
85
+ Returns:
86
+ ``control.StateSpace(A, B, C, D)``。
87
+
88
+ Raises:
89
+ ImportError: ``python-control`` がインストールされていない。
90
+ ``pip install flode[control]`` を案内する。
91
+
92
+ Example:
93
+ >>> ls = sim.linearize() # doctest: +SKIP
94
+ >>> ax = ls.bode().plot() # 直接 Bode 線図を描画 (ADR-0027)
95
+ """
96
+ try:
97
+ import control as _control
98
+ except ImportError as e:
99
+ raise ImportError(
100
+ "LinearSystem.to_control_ss() requires the optional `python-control` "
101
+ "package. Install via `pip install flode[control]` or "
102
+ "`pip install python-control`."
103
+ ) from e
104
+ return _control.ss(self.A, self.B, self.C, self.D)
105
+
106
+ # ADR-0027 §(1)C: 委譲メソッド (= ``Simulator.linearize`` パターン)。循環 import を
107
+ # 避けるため、各メソッドの中で対応関数を遅延 import する。
108
+ def bode(
109
+ self,
110
+ *,
111
+ omega: npt.NDArray[Any] | None = None,
112
+ omega_limits: tuple[float, float] | None = None,
113
+ omega_num: int | None = None,
114
+ Hz: bool = False,
115
+ ) -> BodeResponse:
116
+ """Bode 応答を計算する (:func:`flode.bode` への薄ラッパ、ADR-0027)。"""
117
+ from .frequency_response import bode as _bode
118
+
119
+ return _bode(
120
+ self,
121
+ omega=omega,
122
+ omega_limits=omega_limits,
123
+ omega_num=omega_num,
124
+ Hz=Hz,
125
+ )
126
+
127
+ def nyquist(
128
+ self,
129
+ *,
130
+ omega: npt.NDArray[Any] | None = None,
131
+ omega_limits: tuple[float, float] | None = None,
132
+ omega_num: int | None = None,
133
+ ) -> NyquistResponse:
134
+ """Nyquist 軌跡を計算する (:func:`flode.nyquist` への薄ラッパ、ADR-0027)。"""
135
+ from .frequency_response import nyquist as _nyquist
136
+
137
+ return _nyquist(self, omega=omega, omega_limits=omega_limits, omega_num=omega_num)
138
+
139
+ def eigenvalues(self) -> npt.NDArray[Any]:
140
+ """A 行列の固有値 (:func:`flode.eigenvalues` への薄ラッパ、ADR-0027)。"""
141
+ from .stability import eigenvalues as _eigenvalues
142
+
143
+ return _eigenvalues(self)
144
+
145
+ def is_stable(self, *, tol: float = 1e-9) -> bool:
146
+ """漸近安定性判定 (:func:`flode.is_stable` への薄ラッパ、ADR-0027)。"""
147
+ from .stability import is_stable as _is_stable
148
+
149
+ return _is_stable(self, tol=tol)
150
+
151
+ def root_locus(
152
+ self,
153
+ *,
154
+ k_range: tuple[float, float] | npt.NDArray[Any] | None = None,
155
+ input_idx: int = 0,
156
+ output_idx: int = 0,
157
+ ) -> RootLocus:
158
+ """根軌跡を計算する (:func:`flode.root_locus` への薄ラッパ、ADR-0027)。"""
159
+ from .stability import root_locus as _root_locus
160
+
161
+ return _root_locus(self, k_range=k_range, input_idx=input_idx, output_idx=output_idx)
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # 入出力次元の解決
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def _is_sink(block: Block) -> bool:
170
+ """記録専用 (sink) ブロックの duck-type 判定。
171
+
172
+ 判定条件: ``n_outputs == 0`` かつ ``record(t, u)`` メソッドを持つ。Phase 3
173
+ 時点で該当するのは Scope / Display / XYGraph の 3 種。``Terminator`` は
174
+ ``record`` を持たないので sink としてはカウントしない (= 線形化の出力
175
+ ベクトルに観察ポイントを追加したい場合は Scope 等に繋ぎ替える必要がある)。
176
+ """
177
+ return block.n_outputs == 0 and hasattr(block, "record")
178
+
179
+
180
+ def _drives_sink(simulator: Simulator, src_block: Block, src_idx: int) -> bool:
181
+ """``(src_block, src_idx)`` を入力に持つ sink ブロックがあるか。"""
182
+ for b in simulator.blocks:
183
+ for src in b.input_sources:
184
+ if src is not None and src[0] is src_block and src[1] == src_idx:
185
+ if _is_sink(b):
186
+ return True
187
+ return False
188
+
189
+
190
+ def _has_non_sink_consumer(simulator: Simulator, src_block: Block, src_idx: int) -> bool:
191
+ """``(src_block, src_idx)`` を sink 以外のブロックが入力にしているか。"""
192
+ for b in simulator.blocks:
193
+ for src in b.input_sources:
194
+ if src is not None and src[0] is src_block and src[1] == src_idx:
195
+ if not _is_sink(b):
196
+ return True
197
+ return False
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class _InputSpec:
202
+ """B 行列の 1 列に対応する外部入力次元。"""
203
+
204
+ block: Block
205
+ port_idx: int
206
+ flat_idx: int # SM-B vector port 内の flatten index (scalar port は 0)
207
+ u_slice: int # 全体 u_ext 配列内の位置 (= B 行列の列インデックス)
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class _OutputSpec:
212
+ """C 行列の 1 行に対応する外部出力次元。"""
213
+
214
+ block: Block
215
+ port_idx: int
216
+ flat_idx: int
217
+ y_slice: int # 全体 y_ext 配列内の位置 (= C 行列の行インデックス)
218
+
219
+
220
+ def _build_input_specs(simulator: Simulator) -> list[_InputSpec]:
221
+ """外部入力ポート (= 結線されていない入力ポート) の flatten 仕様を構築。
222
+
223
+ sink ブロック (Scope / Display / XYGraph、= ``_is_sink`` で True 判定。
224
+ ``Terminator`` は ``record`` を持たないので除外) の未結線入力は **外部入力
225
+ として扱わない** — sink は記録専用で動的システムを駆動しないため、
226
+ Jacobian 列に含めると常に全ゼロ列になり、ユーザーが Bode / 安定性解析する
227
+ 際に混乱の元になる。
228
+ """
229
+ specs: list[_InputSpec] = []
230
+ pos = 0
231
+ for b in simulator.blocks:
232
+ if _is_sink(b):
233
+ # sink の未結線入力は線形化の対象外 (= record しか呼ばれない)
234
+ continue
235
+ for i in range(b.n_inputs):
236
+ if b.input_sources[i] is not None:
237
+ continue
238
+ shape = b.port_shapes_in[i]
239
+ n = int(np.prod(shape)) if shape else 1
240
+ for flat in range(n):
241
+ specs.append(_InputSpec(block=b, port_idx=i, flat_idx=flat, u_slice=pos))
242
+ pos += 1
243
+ return specs
244
+
245
+
246
+ def _build_output_specs(simulator: Simulator) -> list[_OutputSpec]:
247
+ """外部出力ポートの flatten 仕様を構築。
248
+
249
+ 採用条件 (ADR-0026 §(6) 出力次元):
250
+ - 下流に sink (Scope / Display / XYGraph 等) を駆動するポート、または
251
+ - どのブロックにも消費されていないポート
252
+
253
+ ただし sink 以外の non-sink 消費者がいるポートは内部信号として除外する
254
+ (= 純粋な内部結線は外部に露出しない)。
255
+ """
256
+ specs: list[_OutputSpec] = []
257
+ pos = 0
258
+ for b in simulator.blocks:
259
+ if _is_sink(b):
260
+ # sink ブロック自体は外部出力を提供しない (記録は record で済む)
261
+ continue
262
+ for j in range(b.n_outputs):
263
+ drives_sink = _drives_sink(simulator, b, j)
264
+ has_non_sink = _has_non_sink_consumer(simulator, b, j)
265
+ if has_non_sink and not drives_sink:
266
+ # 純粋な内部信号
267
+ continue
268
+ shape = b.port_shapes_out[j]
269
+ n = int(np.prod(shape)) if shape else 1
270
+ for flat in range(n):
271
+ specs.append(_OutputSpec(block=b, port_idx=j, flat_idx=flat, y_slice=pos))
272
+ pos += 1
273
+ return specs
274
+
275
+
276
+ def _build_state_names(layout: list[tuple[Block, slice]]) -> list[str]:
277
+ """A 行列の各次元のラベル ``{block_id}.x[{i}]`` を生成。"""
278
+ names: list[str] = []
279
+ for b, sl in layout:
280
+ for i in range(sl.stop - sl.start):
281
+ names.append(f"{b.id}.x[{i}]")
282
+ return names
283
+
284
+
285
+ def _build_input_names(specs: list[_InputSpec]) -> list[str]:
286
+ return [f"{s.block.id}.in[{s.port_idx}][{s.flat_idx}]" for s in specs]
287
+
288
+
289
+ def _build_output_names(specs: list[_OutputSpec]) -> list[str]:
290
+ return [f"{s.block.id}.out[{s.port_idx}][{s.flat_idx}]" for s in specs]
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # 動作点での評価 (xdot, y_external) の計算
295
+ # ---------------------------------------------------------------------------
296
+
297
+
298
+ def _evaluate(
299
+ simulator: Simulator,
300
+ t: float,
301
+ x_cont: npt.NDArray[Any],
302
+ u_ext: npt.NDArray[Any],
303
+ *,
304
+ order: list[Block],
305
+ layout: list[tuple[Block, slice]],
306
+ n_states: int,
307
+ discrete_state: dict[Block, npt.NDArray[Any]],
308
+ input_specs: list[_InputSpec],
309
+ output_specs: list[_OutputSpec],
310
+ sm_a_mode: bool,
311
+ ) -> tuple[npt.NDArray[Any], npt.NDArray[Any]]:
312
+ """動作点 ``(t, x_cont, u_ext)`` で ``(xdot, y_external)`` を計算。
313
+
314
+ ``Simulator._step`` / ``_step_vector`` のロジックを copy しつつ、結線されていない
315
+ 入力ポート (= ``input_sources[i] is None``) に ``u_ext`` の対応 slice を注入する
316
+ (= 既存 hot path に minimal-invasive)。``record`` / ``update`` は副作用がある
317
+ ため一切呼ばない (動作点固定)。
318
+ """
319
+ # ----- u_ext を block ごとの input dict に振り分ける -----
320
+ # SM-A: block.id -> {port_idx: float}
321
+ # SM-B: block.id -> {port_idx: ndarray (port_shape)}
322
+ external_inputs_a: dict[int, dict[int, float]] = {}
323
+ external_inputs_b: dict[int, dict[int, npt.NDArray[Any]]] = {}
324
+ if sm_a_mode:
325
+ for s in input_specs:
326
+ external_inputs_a.setdefault(id(s.block), {})[s.port_idx] = float(u_ext[s.u_slice])
327
+ else:
328
+ # SM-B: port ごとに flat_idx を集めて C-order reshape
329
+ # まず block_id -> port_idx -> flat array を組み立てる
330
+ per_port: dict[int, dict[int, list[tuple[int, float]]]] = {}
331
+ for s in input_specs:
332
+ per_port.setdefault(id(s.block), {}).setdefault(s.port_idx, []).append(
333
+ (s.flat_idx, float(u_ext[s.u_slice]))
334
+ )
335
+ for bid, ports in per_port.items():
336
+ external_inputs_b[bid] = {}
337
+ for p_idx, entries in ports.items():
338
+ # block を再取得 (id() 経由ではなく specs から拾う)
339
+ # 同 (bid, p_idx) に対応する任意の spec から block を取り出す
340
+ _block = next(
341
+ s.block for s in input_specs if id(s.block) == bid and s.port_idx == p_idx
342
+ )
343
+ shape = _block.port_shapes_in[p_idx]
344
+ flat_size = int(np.prod(shape)) if shape else 1
345
+ buf = np.zeros(flat_size, dtype=float)
346
+ for flat, val in entries:
347
+ buf[flat] = val
348
+ external_inputs_b[bid][p_idx] = buf.reshape(shape) if shape else buf.reshape(())
349
+
350
+ # ----- 連続状態を block 別に slice -----
351
+ cont_state = {b: x_cont[sl] for b, sl in layout}
352
+
353
+ def state_for(b: Block) -> npt.NDArray[Any]:
354
+ if b in cont_state:
355
+ return cont_state[b]
356
+ if b in discrete_state:
357
+ return discrete_state[b]
358
+ return np.zeros(0)
359
+
360
+ # ----- SM-A 経路 -----
361
+ if sm_a_mode:
362
+ outputs_a: dict[Block, npt.NDArray[Any]] = {}
363
+ inputs_a: dict[Block, npt.NDArray[Any]] = {}
364
+
365
+ def gather_inputs_a(b: Block) -> npt.NDArray[Any]:
366
+ u = np.zeros(b.n_inputs)
367
+ ext = external_inputs_a.get(id(b), {})
368
+ for i, src in enumerate(b.input_sources):
369
+ if src is not None:
370
+ sb, si = src
371
+ u[i] = outputs_a[sb][si]
372
+ elif i in ext:
373
+ u[i] = ext[i]
374
+ # else: 0 (= 未結線で u_ext 対象でないケースは MVP では起きない)
375
+ return u
376
+
377
+ # Pass 1: direct_feedthrough ブロックの output
378
+ for b in order:
379
+ if b.direct_feedthrough:
380
+ u = gather_inputs_a(b)
381
+ inputs_a[b] = u
382
+ else:
383
+ u = np.zeros(b.n_inputs)
384
+ xb = state_for(b)
385
+ y = np.atleast_1d(np.asarray(b.output(t, xb, u), dtype=float))
386
+ outputs_a[b] = y
387
+ # Pass 2: 非 direct_feedthrough の入力を後から組み立て
388
+ for b in order:
389
+ if not b.direct_feedthrough:
390
+ inputs_a[b] = gather_inputs_a(b)
391
+
392
+ # 連続ブロックの xdot
393
+ xdot = np.zeros(n_states)
394
+ for b, sl in layout:
395
+ xdot[sl] = np.asarray(b.derivative(t, x_cont[sl], inputs_a[b]), dtype=float)
396
+
397
+ # 外部出力 y_external を取り出す
398
+ y_ext = np.zeros(len(output_specs))
399
+ for os_a in output_specs:
400
+ y_ext[os_a.y_slice] = float(outputs_a[os_a.block][os_a.port_idx])
401
+ return xdot, y_ext
402
+
403
+ # ----- SM-B 経路 -----
404
+ outputs_b: dict[Block, tuple[npt.NDArray[Any], ...]] = {}
405
+ inputs_b: dict[Block, tuple[npt.NDArray[Any], ...]] = {}
406
+
407
+ def zero_inputs_b(b: Block) -> tuple[npt.NDArray[Any], ...]:
408
+ return tuple(np.zeros(shape, dtype=float) for shape in b.port_shapes_in)
409
+
410
+ def gather_inputs_b(b: Block) -> tuple[npt.NDArray[Any], ...]:
411
+ u_list: list[npt.NDArray[Any]] = []
412
+ ext = external_inputs_b.get(id(b), {})
413
+ for i, src in enumerate(b.input_sources):
414
+ if src is None:
415
+ if i in ext:
416
+ u_list.append(ext[i])
417
+ else:
418
+ u_list.append(np.zeros(b.port_shapes_in[i], dtype=float))
419
+ else:
420
+ sb, si = src
421
+ u_list.append(outputs_b[sb][si])
422
+ return tuple(u_list)
423
+
424
+ for b in order:
425
+ u_b: tuple[npt.NDArray[Any], ...]
426
+ if b.direct_feedthrough:
427
+ u_b = gather_inputs_b(b)
428
+ inputs_b[b] = u_b
429
+ else:
430
+ u_b = zero_inputs_b(b)
431
+ xb = state_for(b)
432
+ y_b = b.output_v(t, xb, u_b)
433
+ if len(y_b) != b.n_outputs:
434
+ raise BlockSpecError(
435
+ f"{type(b).__name__} {b.id!r}.output_v returned {len(y_b)} "
436
+ f"output(s), expected {b.n_outputs}"
437
+ )
438
+ outputs_b[b] = tuple(np.asarray(yi, dtype=float) for yi in y_b)
439
+ for b in order:
440
+ if not b.direct_feedthrough:
441
+ inputs_b[b] = gather_inputs_b(b)
442
+
443
+ # SM-B 連続ブロックの xdot: SM-A 互換 wrapper (Simulator.f_continuous_vector と同じ)。
444
+ # NOTE: ``Block.derivative`` の契約 (ADR-0001) は SM-A 1D ndarray (= 各 port が
445
+ # scalar) を前提とする。SM-B vector port を持つカスタム連続ブロック (= ``@block``
446
+ # で states>=1 かつ非 scalar port を宣言) は本 MVP では未対応。``derivative_v``
447
+ # の追加は Phase 5+ (ADR-0026 §(10) note)。各入力 port の値が rank-0 でない場合
448
+ # は明示エラーで誘導する (silent な ValueError を防ぐ)。
449
+ xdot = np.zeros(n_states)
450
+ for b, sl in layout:
451
+ u_tuple = inputs_b[b]
452
+ if any(np.asarray(ui).size != 1 for ui in u_tuple):
453
+ raise BlockSpecError(
454
+ f"linearize: block {b.id!r} has continuous states and SM-B vector "
455
+ f"input ports. Vector-port continuous blocks are not yet supported "
456
+ f"(Phase 5+, ADR-0026 §(10))."
457
+ )
458
+ u_1d = np.array([float(np.asarray(ui).item()) for ui in u_tuple], dtype=float)
459
+ xdot[sl] = np.asarray(b.derivative(t, x_cont[sl], u_1d), dtype=float)
460
+
461
+ # 外部出力 y_external を取り出す (port shape を C-order で flatten)
462
+ y_ext = np.zeros(len(output_specs))
463
+ for os_b in output_specs:
464
+ out_arr = np.asarray(outputs_b[os_b.block][os_b.port_idx], dtype=float).ravel(order="C")
465
+ y_ext[os_b.y_slice] = float(out_arr[os_b.flat_idx])
466
+ return xdot, y_ext
467
+
468
+
469
+ # ---------------------------------------------------------------------------
470
+ # 中心差分 / 前進差分による Jacobian 計算
471
+ # ---------------------------------------------------------------------------
472
+
473
+
474
+ def _step_size(value: float, epsilon: float | None) -> float:
475
+ """1 次元あたりの摂動 step。
476
+
477
+ ``epsilon`` 指定時は相対 step、未指定時は ``sqrt(eps_machine)`` を採用。
478
+ どちらも ``max(|value|, 1)`` でスケーリングする (零点近傍での桁落ちを防ぐ)。
479
+ """
480
+ base = epsilon if epsilon is not None else SQRT_EPS
481
+ return base * max(abs(value), 1.0)
482
+
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # 公開 API: linearize()
486
+ # ---------------------------------------------------------------------------
487
+
488
+
489
+ def linearize(
490
+ simulator: Simulator,
491
+ *,
492
+ t: float = 0.0,
493
+ x: npt.NDArray[Any] | None = None,
494
+ u: npt.NDArray[Any] | None = None,
495
+ method: Literal["central", "forward"] = "central",
496
+ epsilon: float | None = None,
497
+ ) -> LinearSystem:
498
+ """動作点 ``(t, x, u)`` 周りでモデルを線形化し ``(A, B, C, D)`` を返す。
499
+
500
+ Args:
501
+ simulator: 線形化対象の :class:`Simulator`。``run()`` 前後どちらでも可
502
+ (本関数は副作用を持たない: ``record`` / ``update`` を呼ばず、
503
+ ``Simulator`` の状態を変更しない)。
504
+ t: 動作点時刻 [s]。default ``0.0``。
505
+ x: 連続状態の動作点。shape ``(n_states,)``。``None`` のとき各ブロックの
506
+ ``x0`` を ``simulator._state_layout()`` 順に concat したもの。
507
+ u: 外部入力の動作点。shape ``(n_inputs_total,)``。``None`` のとき全ゼロ。
508
+ method: 数値手法。
509
+
510
+ - ``"central"`` (default): 中心差分、誤差 O(h²)、評価 2n+1 回
511
+ - ``"forward"``: 前進差分、誤差 O(h)、評価 n+1 回
512
+ epsilon: 摂動相対サイズ。``None`` のとき次元ごとに
513
+ ``h_i = sqrt(eps_machine) * max(|x_i|, 1.0)`` を自動採用。
514
+
515
+ Returns:
516
+ :class:`LinearSystem` インスタンス。
517
+
518
+ Raises:
519
+ AlgebraicLoopError: 動作点でビルド時に代数ループが検出される
520
+ (``Simulator._execution_order()`` 経由)。Sum + Gain で
521
+ direct_feedthrough だけのフィードバックを組むと発火する。
522
+ BlockSpecError: 連続状態がゼロのモデル / ``x`` ``u`` の shape 不整合 /
523
+ 出力 ndim 不整合などの構造エラー。
524
+ SolverError: 動作点で ``derivative`` または ``output`` が NaN / Inf を返す。
525
+ ValueError: ``method`` / ``epsilon`` の値が不正。
526
+
527
+ Example:
528
+ Integrator with one external input:
529
+
530
+ >>> from flode import Simulator, linearize
531
+ >>> from flode.blocks import Integrator, Scope
532
+ >>> sim = Simulator(t_end=10.0, dt=0.01)
533
+ >>> i_block = sim.add(Integrator())
534
+ >>> sim.connect(i_block, sim.add(Scope()))
535
+ >>> ls = linearize(sim)
536
+ >>> ls.A.shape # (n_states, n_states)
537
+ (1, 1)
538
+ >>> ls.B.shape # (n_states, n_external_inputs)
539
+ (1, 1)
540
+ >>> ls.C.shape # (n_external_outputs, n_states)
541
+ (1, 1)
542
+ """
543
+ if method not in ("central", "forward"):
544
+ raise ValueError(f"linearize: method must be 'central' / 'forward', got {method!r}")
545
+ if epsilon is not None and epsilon <= 0.0:
546
+ raise ValueError(f"linearize: epsilon must be > 0 (or None for auto), got {epsilon}")
547
+
548
+ # ----- Simulator setup (build / sample-time / state layout) -----
549
+ order = simulator._execution_order() # _build() を triggered
550
+
551
+ # SM-D Stage 1 (SPEC-0028 Q10 / AC-8): 非 float64 信号を含むモデルは明示拒否。
552
+ # 数値線形化の摂動 (sqrt(eps) ~ 1.5e-8) は整数ポートで切り捨てられ、
553
+ # Jacobian の列が黙って 0 になるため、誤った結果よりエラーが誠実。
554
+ from ..core.dtypes import (
555
+ has_declared_dtype,
556
+ reject_nested_dtype_declarations,
557
+ resolve_for_execution,
558
+ )
559
+
560
+ # security MUST-1: ネスト dtype 宣言は run と同様に fail-closed
561
+ reject_nested_dtype_declarations(simulator)
562
+
563
+ # Note (code-reviewer NIT 2026-09-08): linearize は `_dtype_plan` を構築しない。
564
+ # 下の拒否により「dtype 宣言モデルは non_float_ports == 0 のときだけ通る」
565
+ # という不変条件が成立し、その場合 plan なし (= 全ポート float64 強制) と
566
+ # 解決結果 (全ポート float64) は数値的に同一になるため。Q10 を緩和して
567
+ # 非 float64 モデルを通すようになったら、この省略は成立しなくなる。
568
+ if has_declared_dtype(simulator):
569
+ dtype_res = resolve_for_execution(simulator)
570
+ if dtype_res.summary.non_float_ports > 0:
571
+ raise BlockSpecError(
572
+ "linearize: model contains non-float64 signals (SM-D dtype). "
573
+ "Numerical linearisation requires float64 signals because the "
574
+ "perturbation (sqrt(eps) ~ 1.5e-8) is truncated on integer "
575
+ "ports, silently producing zero Jacobian columns. Remove the "
576
+ "dtype declarations or insert Cast(dtype='float64') before "
577
+ "the linearisation boundary."
578
+ )
579
+
580
+ if simulator._is_sm_a_mode():
581
+ sm_a_mode = True
582
+ else:
583
+ sm_a_mode = False
584
+ simulator._check_scope_inputs_are_scalar()
585
+ simulator._check_subsystem_sm_b_unsupported()
586
+ simulator._resolve_sample_times(order)
587
+ simulator._compute_dt_base()
588
+ layout, n_states = simulator._state_layout()
589
+
590
+ if n_states == 0:
591
+ raise BlockSpecError(
592
+ "linearize: model has no continuous states. Linearisation requires "
593
+ "at least one continuous block (e.g. Integrator / StateSpace / "
594
+ "TransferFunction). Pure discrete or static models are not supported "
595
+ "in this MVP (ADR-0026 §(8))."
596
+ )
597
+
598
+ # 離散ブロックは動作点固定 (x0) で warning を出す
599
+ discrete_state = simulator._init_discrete_state()
600
+ if discrete_state:
601
+ msg = (
602
+ "linearize: discrete blocks are held at their initial values during "
603
+ "linearisation (continuous-only Jacobian, ADR-0026 §(8)). Hybrid "
604
+ "linearisation will be addressed in Phase 5+."
605
+ )
606
+ warnings.warn(msg, UserWarning, stacklevel=2)
607
+ _logger.warning(msg)
608
+
609
+ # ----- 入出力次元の解決 -----
610
+ input_specs = _build_input_specs(simulator)
611
+ output_specs = _build_output_specs(simulator)
612
+ n_in = len(input_specs)
613
+ n_out = len(output_specs)
614
+
615
+ # ----- 動作点 (x*, u*) のセットアップ -----
616
+ x_op: npt.NDArray[Any]
617
+ if x is None:
618
+ x_op = np.zeros(n_states)
619
+ for b, sl in layout:
620
+ x_op[sl] = np.asarray(b.x0, dtype=float)
621
+ else:
622
+ x_op = np.asarray(x, dtype=float).copy()
623
+ if x_op.shape != (n_states,):
624
+ raise BlockSpecError(f"linearize: x must have shape ({n_states},), got {x_op.shape}")
625
+
626
+ u_op: npt.NDArray[Any]
627
+ if u is None:
628
+ u_op = np.zeros(n_in)
629
+ else:
630
+ u_op = np.asarray(u, dtype=float).copy()
631
+ if u_op.shape != (n_in,):
632
+ raise BlockSpecError(f"linearize: u must have shape ({n_in},), got {u_op.shape}")
633
+
634
+ # ----- 動作点で 1 回評価 (Forward 差分用 base、結果 sanity check) -----
635
+ def evaluate(
636
+ t_eval: float, x_eval: npt.NDArray[Any], u_eval: npt.NDArray[Any]
637
+ ) -> tuple[npt.NDArray[Any], npt.NDArray[Any]]:
638
+ return _evaluate(
639
+ simulator,
640
+ t_eval,
641
+ x_eval,
642
+ u_eval,
643
+ order=order,
644
+ layout=layout,
645
+ n_states=n_states,
646
+ discrete_state=discrete_state,
647
+ input_specs=input_specs,
648
+ output_specs=output_specs,
649
+ sm_a_mode=sm_a_mode,
650
+ )
651
+
652
+ xdot0, y0 = evaluate(t, x_op, u_op)
653
+ if not np.all(np.isfinite(xdot0)) or not np.all(np.isfinite(y0)):
654
+ raise SolverError(
655
+ "linearize: derivative() or output() returned NaN/Inf at the operating "
656
+ "point. Check block parameters and the chosen (t, x, u)."
657
+ )
658
+
659
+ # ----- A = ∂xdot/∂x、C = ∂y/∂x の Jacobian -----
660
+ A = np.zeros((n_states, n_states), dtype=float)
661
+ C = np.zeros((n_out, n_states), dtype=float)
662
+ for i in range(n_states):
663
+ h = _step_size(float(x_op[i]), epsilon)
664
+ if h == 0.0:
665
+ continue
666
+ if method == "central":
667
+ x_plus = x_op.copy()
668
+ x_plus[i] += h
669
+ xdot_p, y_p = evaluate(t, x_plus, u_op)
670
+ x_minus = x_op.copy()
671
+ x_minus[i] -= h
672
+ xdot_m, y_m = evaluate(t, x_minus, u_op)
673
+ A[:, i] = (xdot_p - xdot_m) / (2.0 * h)
674
+ C[:, i] = (y_p - y_m) / (2.0 * h)
675
+ else: # forward
676
+ x_plus = x_op.copy()
677
+ x_plus[i] += h
678
+ xdot_p, y_p = evaluate(t, x_plus, u_op)
679
+ A[:, i] = (xdot_p - xdot0) / h
680
+ C[:, i] = (y_p - y0) / h
681
+
682
+ # ----- B = ∂xdot/∂u、D = ∂y/∂u の Jacobian -----
683
+ B = np.zeros((n_states, n_in), dtype=float)
684
+ D = np.zeros((n_out, n_in), dtype=float)
685
+ for i in range(n_in):
686
+ h = _step_size(float(u_op[i]), epsilon)
687
+ if h == 0.0:
688
+ continue
689
+ if method == "central":
690
+ u_plus = u_op.copy()
691
+ u_plus[i] += h
692
+ xdot_p, y_p = evaluate(t, x_op, u_plus)
693
+ u_minus = u_op.copy()
694
+ u_minus[i] -= h
695
+ xdot_m, y_m = evaluate(t, x_op, u_minus)
696
+ B[:, i] = (xdot_p - xdot_m) / (2.0 * h)
697
+ D[:, i] = (y_p - y_m) / (2.0 * h)
698
+ else: # forward
699
+ u_plus = u_op.copy()
700
+ u_plus[i] += h
701
+ xdot_p, y_p = evaluate(t, x_op, u_plus)
702
+ B[:, i] = (xdot_p - xdot0) / h
703
+ D[:, i] = (y_p - y0) / h
704
+
705
+ return LinearSystem(
706
+ A=A,
707
+ B=B,
708
+ C=C,
709
+ D=D,
710
+ state_names=_build_state_names(layout),
711
+ input_names=_build_input_names(input_specs),
712
+ output_names=_build_output_names(output_specs),
713
+ operating_point={"t": float(t), "x": x_op.copy(), "u": u_op.copy()},
714
+ )