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.
- flode/__init__.py +88 -0
- flode/analysis/__init__.py +37 -0
- flode/analysis/frequency_response.py +349 -0
- flode/analysis/linearize.py +714 -0
- flode/analysis/stability.py +229 -0
- flode/blocks/__init__.py +107 -0
- flode/blocks/_lti_utils.py +121 -0
- flode/blocks/cast.py +66 -0
- flode/blocks/continuous.py +474 -0
- flode/blocks/discontinuities.py +258 -0
- flode/blocks/discrete.py +612 -0
- flode/blocks/logic.py +120 -0
- flode/blocks/lookup.py +1025 -0
- flode/blocks/mathops.py +598 -0
- flode/blocks/pythonfunc.py +560 -0
- flode/blocks/pythonfunc_rewrite.py +1935 -0
- flode/blocks/pythonfunc_source.py +392 -0
- flode/blocks/random_source.py +179 -0
- flode/blocks/rounding.py +65 -0
- flode/blocks/routing.py +582 -0
- flode/blocks/sinks.py +309 -0
- flode/blocks/sources.py +215 -0
- flode/blocks/transport_delay.py +92 -0
- flode/blocks/userfunc.py +359 -0
- flode/core/__init__.py +5 -0
- flode/core/block.py +497 -0
- flode/core/decorator.py +1149 -0
- flode/core/dtypes.py +1026 -0
- flode/core/identifiers.py +157 -0
- flode/core/persistence.py +1319 -0
- flode/core/simulator.py +1690 -0
- flode/exceptions.py +226 -0
- flode/libraries/__init__.py +341 -0
- flode/libraries/_loader.py +302 -0
- flode/libraries/std.flwlib.json +347 -0
- flode/server/__init__.py +23 -0
- flode/server/app.py +185 -0
- flode/server/cli.py +555 -0
- flode/server/config.py +516 -0
- flode/server/errors.py +397 -0
- flode/server/library_registry.py +125 -0
- flode/server/migrations/__init__.py +80 -0
- flode/server/registry.py +950 -0
- flode/server/registry_translations.py +804 -0
- flode/server/routes/__init__.py +16 -0
- flode/server/routes/blocks.py +447 -0
- flode/server/routes/files.py +800 -0
- flode/server/routes/libraries.py +129 -0
- flode/server/routes/models.py +67 -0
- flode/server/routes/simulations.py +355 -0
- flode/server/runtime.py +354 -0
- flode/server/security/__init__.py +7 -0
- flode/server/security/origin.py +122 -0
- flode/server/security/paths.py +143 -0
- flode/server/settings.py +42 -0
- flode/server/static/.app-version +1 -0
- flode/server/static/assets/index-BnL2nNOe.css +1 -0
- flode/server/static/assets/index-DMWZBAA_.js +100 -0
- flode/server/static/assets/index-DMWZBAA_.js.map +1 -0
- flode/server/static/favicon.ico +0 -0
- flode/server/static/favicon.svg +9 -0
- flode/server/static/index.html +15 -0
- flode/subsystems/__init__.py +20 -0
- flode/subsystems/_mask.py +154 -0
- flode/subsystems/control_blocks.py +245 -0
- flode/subsystems/ports.py +146 -0
- flode/subsystems/subsystem.py +1173 -0
- flode-0.60.3.dist-info/METADATA +91 -0
- flode-0.60.3.dist-info/RECORD +73 -0
- flode-0.60.3.dist-info/WHEEL +5 -0
- flode-0.60.3.dist-info/entry_points.txt +2 -0
- flode-0.60.3.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|