flode 0.63.0__tar.gz → 0.64.0__tar.gz

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 (105) hide show
  1. {flode-0.63.0 → flode-0.64.0}/CHANGELOG.md +50 -0
  2. {flode-0.63.0/flode.egg-info → flode-0.64.0}/PKG-INFO +1 -1
  3. {flode-0.63.0 → flode-0.64.0}/flode/__init__.py +1 -1
  4. {flode-0.63.0 → flode-0.64.0}/flode/blocks/__init__.py +6 -0
  5. {flode-0.63.0 → flode-0.64.0}/flode/blocks/_lti_utils.py +92 -0
  6. {flode-0.63.0 → flode-0.64.0}/flode/blocks/continuous.py +28 -11
  7. {flode-0.63.0 → flode-0.64.0}/flode/blocks/discrete.py +11 -4
  8. {flode-0.63.0 → flode-0.64.0}/flode/blocks/lookup.py +99 -41
  9. {flode-0.63.0 → flode-0.64.0}/flode/blocks/mathops.py +128 -0
  10. {flode-0.63.0 → flode-0.64.0}/flode/blocks/random_source.py +57 -3
  11. {flode-0.63.0 → flode-0.64.0}/flode/blocks/sources.py +38 -9
  12. {flode-0.63.0 → flode-0.64.0}/flode/core/block.py +6 -1
  13. {flode-0.63.0 → flode-0.64.0}/flode/core/persistence.py +188 -2
  14. {flode-0.63.0 → flode-0.64.0}/flode/core/signals.py +70 -10
  15. {flode-0.63.0 → flode-0.64.0}/flode/server/registry.py +8 -0
  16. {flode-0.63.0 → flode-0.64.0}/flode/server/registry_translations.py +45 -0
  17. flode-0.64.0/flode/server/static/.app-version +1 -0
  18. flode-0.63.0/flode/server/static/assets/index-H0vWvCjy.js → flode-0.64.0/flode/server/static/assets/index-B85mUsTb.js +28 -28
  19. flode-0.64.0/flode/server/static/assets/index-B85mUsTb.js.map +1 -0
  20. {flode-0.63.0 → flode-0.64.0}/flode/server/static/index.html +1 -1
  21. {flode-0.63.0 → flode-0.64.0/flode.egg-info}/PKG-INFO +1 -1
  22. {flode-0.63.0 → flode-0.64.0}/flode.egg-info/SOURCES.txt +3 -2
  23. {flode-0.63.0 → flode-0.64.0}/pyproject.toml +1 -1
  24. {flode-0.63.0 → flode-0.64.0}/tests/test_persistence_migration_0_10_to_0_11.py +3 -3
  25. {flode-0.63.0 → flode-0.64.0}/tests/test_persistence_migration_0_11_to_0_12.py +1 -1
  26. {flode-0.63.0 → flode-0.64.0}/tests/test_persistence_migration_0_12_to_0_13.py +2 -2
  27. {flode-0.63.0 → flode-0.64.0}/tests/test_persistence_migration_0_13_to_0_14.py +3 -3
  28. flode-0.64.0/tests/test_persistence_migration_0_14_to_0_15.py +229 -0
  29. {flode-0.63.0 → flode-0.64.0}/tests/test_persistence_migration_0_8_to_0_9.py +4 -4
  30. {flode-0.63.0 → flode-0.64.0}/tests/test_persistence_migration_0_9_to_0_10.py +2 -2
  31. flode-0.63.0/flode/server/static/.app-version +0 -1
  32. flode-0.63.0/flode/server/static/assets/index-H0vWvCjy.js.map +0 -1
  33. {flode-0.63.0 → flode-0.64.0}/LICENSE +0 -0
  34. {flode-0.63.0 → flode-0.64.0}/MANIFEST.in +0 -0
  35. {flode-0.63.0 → flode-0.64.0}/README.md +0 -0
  36. {flode-0.63.0 → flode-0.64.0}/flode/analysis/__init__.py +0 -0
  37. {flode-0.63.0 → flode-0.64.0}/flode/analysis/frequency_response.py +0 -0
  38. {flode-0.63.0 → flode-0.64.0}/flode/analysis/linearize.py +0 -0
  39. {flode-0.63.0 → flode-0.64.0}/flode/analysis/stability.py +0 -0
  40. {flode-0.63.0 → flode-0.64.0}/flode/blocks/_elementwise.py +0 -0
  41. {flode-0.63.0 → flode-0.64.0}/flode/blocks/_vector_state.py +0 -0
  42. {flode-0.63.0 → flode-0.64.0}/flode/blocks/cast.py +0 -0
  43. {flode-0.63.0 → flode-0.64.0}/flode/blocks/discontinuities.py +0 -0
  44. {flode-0.63.0 → flode-0.64.0}/flode/blocks/logic.py +0 -0
  45. {flode-0.63.0 → flode-0.64.0}/flode/blocks/pythonfunc.py +0 -0
  46. {flode-0.63.0 → flode-0.64.0}/flode/blocks/pythonfunc_rewrite.py +0 -0
  47. {flode-0.63.0 → flode-0.64.0}/flode/blocks/pythonfunc_source.py +0 -0
  48. {flode-0.63.0 → flode-0.64.0}/flode/blocks/rounding.py +0 -0
  49. {flode-0.63.0 → flode-0.64.0}/flode/blocks/routing.py +0 -0
  50. {flode-0.63.0 → flode-0.64.0}/flode/blocks/sinks.py +0 -0
  51. {flode-0.63.0 → flode-0.64.0}/flode/blocks/transport_delay.py +0 -0
  52. {flode-0.63.0 → flode-0.64.0}/flode/blocks/userfunc.py +0 -0
  53. {flode-0.63.0 → flode-0.64.0}/flode/core/__init__.py +0 -0
  54. {flode-0.63.0 → flode-0.64.0}/flode/core/decorator.py +0 -0
  55. {flode-0.63.0 → flode-0.64.0}/flode/core/dtypes.py +0 -0
  56. {flode-0.63.0 → flode-0.64.0}/flode/core/identifiers.py +0 -0
  57. {flode-0.63.0 → flode-0.64.0}/flode/core/simulator.py +0 -0
  58. {flode-0.63.0 → flode-0.64.0}/flode/exceptions.py +0 -0
  59. {flode-0.63.0 → flode-0.64.0}/flode/libraries/__init__.py +0 -0
  60. {flode-0.63.0 → flode-0.64.0}/flode/libraries/_loader.py +0 -0
  61. {flode-0.63.0 → flode-0.64.0}/flode/libraries/std.flwlib.json +0 -0
  62. {flode-0.63.0 → flode-0.64.0}/flode/server/__init__.py +0 -0
  63. {flode-0.63.0 → flode-0.64.0}/flode/server/app.py +0 -0
  64. {flode-0.63.0 → flode-0.64.0}/flode/server/cli.py +0 -0
  65. {flode-0.63.0 → flode-0.64.0}/flode/server/config.py +0 -0
  66. {flode-0.63.0 → flode-0.64.0}/flode/server/errors.py +0 -0
  67. {flode-0.63.0 → flode-0.64.0}/flode/server/library_registry.py +0 -0
  68. {flode-0.63.0 → flode-0.64.0}/flode/server/migrations/__init__.py +0 -0
  69. {flode-0.63.0 → flode-0.64.0}/flode/server/routes/__init__.py +0 -0
  70. {flode-0.63.0 → flode-0.64.0}/flode/server/routes/blocks.py +0 -0
  71. {flode-0.63.0 → flode-0.64.0}/flode/server/routes/files.py +0 -0
  72. {flode-0.63.0 → flode-0.64.0}/flode/server/routes/libraries.py +0 -0
  73. {flode-0.63.0 → flode-0.64.0}/flode/server/routes/models.py +0 -0
  74. {flode-0.63.0 → flode-0.64.0}/flode/server/routes/simulations.py +0 -0
  75. {flode-0.63.0 → flode-0.64.0}/flode/server/runtime.py +0 -0
  76. {flode-0.63.0 → flode-0.64.0}/flode/server/security/__init__.py +0 -0
  77. {flode-0.63.0 → flode-0.64.0}/flode/server/security/origin.py +0 -0
  78. {flode-0.63.0 → flode-0.64.0}/flode/server/security/paths.py +0 -0
  79. {flode-0.63.0 → flode-0.64.0}/flode/server/settings.py +0 -0
  80. {flode-0.63.0 → flode-0.64.0}/flode/server/static/assets/index-BnL2nNOe.css +0 -0
  81. {flode-0.63.0 → flode-0.64.0}/flode/server/static/favicon.ico +0 -0
  82. {flode-0.63.0 → flode-0.64.0}/flode/server/static/favicon.svg +0 -0
  83. {flode-0.63.0 → flode-0.64.0}/flode/subsystems/__init__.py +0 -0
  84. {flode-0.63.0 → flode-0.64.0}/flode/subsystems/_mask.py +0 -0
  85. {flode-0.63.0 → flode-0.64.0}/flode/subsystems/control_blocks.py +0 -0
  86. {flode-0.63.0 → flode-0.64.0}/flode/subsystems/ports.py +0 -0
  87. {flode-0.63.0 → flode-0.64.0}/flode/subsystems/subsystem.py +0 -0
  88. {flode-0.63.0 → flode-0.64.0}/flode.egg-info/dependency_links.txt +0 -0
  89. {flode-0.63.0 → flode-0.64.0}/flode.egg-info/entry_points.txt +0 -0
  90. {flode-0.63.0 → flode-0.64.0}/flode.egg-info/requires.txt +0 -0
  91. {flode-0.63.0 → flode-0.64.0}/flode.egg-info/top_level.txt +0 -0
  92. {flode-0.63.0 → flode-0.64.0}/setup.cfg +0 -0
  93. {flode-0.63.0 → flode-0.64.0}/tests/test_adr0014_edge_cases.py +0 -0
  94. {flode-0.63.0 → flode-0.64.0}/tests/test_block_id.py +0 -0
  95. {flode-0.63.0 → flode-0.64.0}/tests/test_console_scripts.py +0 -0
  96. {flode-0.63.0 → flode-0.64.0}/tests/test_discrete_block_semantics.py +0 -0
  97. {flode-0.63.0 → flode-0.64.0}/tests/test_discrete_scheduler.py +0 -0
  98. {flode-0.63.0 → flode-0.64.0}/tests/test_discrete_timing_semantics.py +0 -0
  99. {flode-0.63.0 → flode-0.64.0}/tests/test_discrete_timing_semantics_vector.py +0 -0
  100. {flode-0.63.0 → flode-0.64.0}/tests/test_error_paths.py +0 -0
  101. {flode-0.63.0 → flode-0.64.0}/tests/test_multirate_block_semantics.py +0 -0
  102. {flode-0.63.0 → flode-0.64.0}/tests/test_persistence_migration_0_7_to_0_8.py +0 -0
  103. {flode-0.63.0 → flode-0.64.0}/tests/test_phase0_regression.py +0 -0
  104. {flode-0.63.0 → flode-0.64.0}/tests/test_rate_transition.py +0 -0
  105. {flode-0.63.0 → flode-0.64.0}/tests/test_version_consistency.py +0 -0
@@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.64.0] - 2026-09-21 — StateSpace 系のベクトルポート統一とソース系のベクトル値 (ADR-0079 Stage 3)
9
+
10
+ ### Changed (BREAKING: `StateSpace` 系のポートが変わります)
11
+
12
+ - **`StateSpace` / `DiscreteStateSpace` / `MimoTransferFunction` の入出力がベクトルポート
13
+ 1 本ずつになった** (ADR-0079 §(7) D-9)。入力は `m >= 2` なら shape `(m,)` の 1 ポート
14
+ (`m == 1` はスカラ)、出力も同様 (`p`)。m 本のスカラ信号は `Mux` で束ねて繋ぎ、出力は
15
+ `Demux` で分けるか、ベクトルのまま下流へ渡す。Python API では `sim.connect(src, ss,
16
+ dst_idx=i)` (i ≥ 1) / `src_idx=j` の配線が `PortIndexError` になる (共通 mixin
17
+ `flode.blocks._lti_utils.LtiVectorPortMixin`、SM-A の `output` / `derivative` /
18
+ `update` / `advance` は不変で結果は bit-identical)。`port_shapes_*` は行列次元から
19
+ 一意なので JSON には書かない
20
+ - **モデル schema `0.14 → 0.15`** (`_builtin_migrate_0_14_to_0_15`)。既存ファイルの
21
+ `m >= 2` / `p >= 2` の StateSpace 系ブロックには `Mux(n=m)` (`<id>__in_mux`) /
22
+ `Demux(n=p)` (`<id>__out_demux`) が挿入され、既存の結線・`branch_waypoints` が
23
+ 付け替わる (数値等価、v0.63.0 で採取した基準 npz で固定)。挿入 ID は決定的
24
+ (衝突時のみ `_1`, `_2` …、64 code point 超は切詰め)、レイアウトは元ブロックから
25
+ 水平に 120 px ずらした位置 (既存ブロックの座標は不変)、Subsystem 内部にも再帰。
26
+ SISO / SIMO (`m == p == 1`) は無変更。`linearize` の外部入出力ラベルは
27
+ `ss.in[0][i]` / `ss.out[0][j]` (1 ポートの flat index) になる
28
+ - `TransferFunction` / `DiscreteTransferFunction` (SISO)、時間ソース (`Step` / `Sine` /
29
+ `Ramp` / `Clock` / `PulseGenerator`)、`TransportDelay` / `Relay`、`Fcn`、`XYGraph` は
30
+ スカラ専用のまま (D-10)。エラーメッセージを Stage 3 の実態に更新
31
+
32
+ ### Added
33
+
34
+ - **ソース系のベクトル値** (SPEC-0031 #18): `Constant(value=[…])` は配列の shape の信号を
35
+ 出力する (shape の起点、`_params["value"]` は入れ子リスト)。`RandomSource(shape=(n,))`
36
+ はサンプル境界ごとに `shape` 個の独立サンプルを 1 回で引く (`shape=()` の既定は
37
+ 従来の乱数系列と bit-identical、非既定時のみ JSON に書く)
38
+ - **lookup 5 クラスのベクトル入力** (`LookupTable1D` / `LookupTable2D` / `LookupTableND` /
39
+ `Prelookup` / `InterpolationUsingPrelookup`): 要素ごとにスカラ核 `_eval` を適用する
40
+ (`ElementwiseMixin`、`Demux → × n → Mux` 版と bit-identical、`extrapolation="error"` /
41
+ nan / ±inf の扱いも要素ごと)。2 入力以上はスカラ拡張を含む合流規則に従う
42
+ - **新ブロック** (SPEC-0031 #19、Math カテゴリ): `Reduce(operation="sum" | "product" |
43
+ "min" | "max" | "mean")` (全要素を 1 スカラに縮約、スカラ入力は恒等)、`DotProduct`
44
+ (同 shape 2 入力の内積)、`MatrixMultiply` (`u0 @ u1`、numpy matmul 規則を build 時に検査、
45
+ 片方だけスカラは `shape.mismatch`)。shape 規則 `reduce` / `matmul` を解決器に追加
46
+ - GUI: Inspector の数値欄で **`[1, 2, 3]` を配列として受理** (`Constant.value` / `Gain.k` /
47
+ ベクトル状態 7 クラスの `x0`、frontend の表 `lib/arrayParams.ts`)、配列欄に数値 1 個を
48
+ 書くとスカラに戻る。ブロック面の `Constant` は配列値を `[1, 2, 3]` / `[2×2]` と表示。
49
+ 新ブロックの glyph (Σ / u·v / A×B) と `Reduce` の operation 表示
50
+
51
+ ### Fixed
52
+
53
+ - **GUI で `Mux` → `Gain` / `Integrator` 等の要素ごとブロックを接続できなかった**
54
+ (v0.62.0 から)。接続前検証が registry 既定の `()` と Mux の `(n,)` を strict 比較して
55
+ 拒否していた。`()` は「build 時に推論する」の意味なので、両端とも非 `()` の宣言で
56
+ 食い違うときだけ拒否し、それ以外は backend の信号面解決器に委ねる (ADR-0079 D-3)
57
+
8
58
  ## [0.63.0] - 2026-09-21 — ベクトル状態と Subsystem 境界のテンソル化 (ADR-0079 Stage 2)
9
59
 
10
60
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flode
3
- Version: 0.63.0
3
+ Version: 0.64.0
4
4
  Summary: Block-diagram dynamic system simulator
5
5
  Author: aramoto99
6
6
  License-Expression: MIT
@@ -7,7 +7,7 @@
7
7
  # ADR-0044) は docked split と並存。Phase 6c (Workspace
8
8
  # convergence) Stage 1 として ADR-0040 §Amendments §(1) で位置付け、Stage 2 /
9
9
  # 3 (= activity bar + Launcher、drag-to-split-tab) は後続 ADR で順次着手。
10
- __version__ = "0.63.0"
10
+ __version__ = "0.64.0"
11
11
 
12
12
  from .analysis import (
13
13
  BodeResponse,
@@ -30,10 +30,13 @@ from .mathops import (
30
30
  CompareToZero,
31
31
  DeadZone,
32
32
  Divide,
33
+ DotProduct,
33
34
  Gain,
34
35
  MathFunction,
36
+ MatrixMultiply,
35
37
  MinMax,
36
38
  Product,
39
+ Reduce,
37
40
  Saturation,
38
41
  Sign,
39
42
  Sum,
@@ -64,6 +67,7 @@ __all__ = [
64
67
  "DiscreteStateSpace",
65
68
  "DiscreteTransferFunction",
66
69
  "Divide",
70
+ "DotProduct",
67
71
  "Fcn",
68
72
  "From",
69
73
  "Gain",
@@ -75,6 +79,7 @@ __all__ = [
75
79
  "LookupTable2D",
76
80
  "LookupTableND",
77
81
  "MathFunction",
82
+ "MatrixMultiply",
78
83
  "Merge",
79
84
  "MimoTransferFunction",
80
85
  "MinMax",
@@ -88,6 +93,7 @@ __all__ = [
88
93
  "RandomSource",
89
94
  "RateLimiter",
90
95
  "RateTransition",
96
+ "Reduce",
91
97
  "RelationalOperator",
92
98
  "Relay",
93
99
  "Rounding",
@@ -5,6 +5,7 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ from collections.abc import Callable
8
9
  from typing import Any
9
10
 
10
11
  import numpy as np
@@ -15,6 +16,97 @@ from ..exceptions import BlockSpecError
15
16
  # ADR-0006 §(6): D 行列の最大絶対値がこの閾値を超えたら direct_feedthrough = True
16
17
  _DF_TOLERANCE = 1e-12
17
18
 
19
+ Shape = tuple[int, ...]
20
+
21
+
22
+ def lti_port_layout(n_signals: int) -> tuple[int, tuple[Shape, ...]]:
23
+ """信号数 ``n_signals`` を ADR-0079 D-9 のポート規則に写す。
24
+
25
+ ``StateSpace`` 系は入力 m 本 / 出力 p 本の信号を **ベクトルポート 1 本** で運ぶ:
26
+ ``n >= 2`` なら shape ``(n,)`` の 1 ポート、``n == 1`` なら ``()`` の 1 ポート
27
+ (SISO / SIMO は 0.14 以前と同じ配線)、``n == 0`` ならポートなし。
28
+
29
+ Returns:
30
+ ``(n_ports, port_shapes)``。
31
+ """
32
+ if n_signals <= 0:
33
+ return 0, ()
34
+ if n_signals == 1:
35
+ return 1, ((),)
36
+ return 1, ((n_signals,),)
37
+
38
+
39
+ class LtiVectorPortMixin:
40
+ """``StateSpace`` 系の vector-port API (ADR-0079 §(7) D-9)。
41
+
42
+ SM-A の ``output`` / ``derivative`` / ``update`` / ``advance`` は 0.14 以前と同じ
43
+ 「長さ m の 1-D ``u``」を受け取る契約のまま (1 バイトも変えない)。本 mixin の
44
+ ``*_v`` は 1 本のベクトルポート (``(m,)``、m == 1 なら rank-0) を flat にして
45
+ SM-A 版へ渡し、出力 ``(p,)`` を p == 1 なら rank-0 に整形する。m 本のスカラ
46
+ ポートから組み立てていた配列と同じ値が渡るため、結果は bit-identical。
47
+
48
+ MRO 上は ``Block`` の **前** に置く。leaf は SM-A メソッドだけを定義するので
49
+ dual-override 検査に掛からない。``port_shapes_*`` は行列次元から一意に決まるため
50
+ JSON には書かない。
51
+ """
52
+
53
+ _serialize_port_shapes = False
54
+
55
+ # Block が持つ属性 / SM-A メソッド (型ヒントのみ、mypy 用)
56
+ n_outputs: int
57
+ output: Callable[..., npt.NDArray[Any]]
58
+ derivative: Callable[..., npt.NDArray[Any]]
59
+ update: Callable[..., npt.NDArray[Any]]
60
+ advance: Callable[..., npt.NDArray[Any]]
61
+
62
+ @staticmethod
63
+ def _u_flat(u: tuple[npt.NDArray[Any], ...]) -> npt.NDArray[Any]:
64
+ """ベクトルポート 1 本 (または無し) を SM-A の 1-D ``u`` に戻す。"""
65
+ if not u:
66
+ return np.zeros(0, dtype=float)
67
+ return np.asarray(u[0], dtype=float).reshape(-1)
68
+
69
+ def _y_port(self, y: npt.NDArray[Any]) -> tuple[npt.NDArray[Any], ...]:
70
+ """SM-A の 1-D 出力 ``(p,)`` をベクトルポート 1 本 (p == 1 なら rank-0) にする。"""
71
+ if self.n_outputs == 0:
72
+ return ()
73
+ arr = np.asarray(y, dtype=float).reshape(-1)
74
+ if arr.shape[0] == 1:
75
+ return (arr.reshape(()),)
76
+ return (arr,)
77
+
78
+ def output_v(
79
+ self,
80
+ t: float,
81
+ x: npt.NDArray[Any],
82
+ u: tuple[npt.NDArray[Any], ...],
83
+ ) -> tuple[npt.NDArray[Any], ...]:
84
+ return self._y_port(self.output(t, x, self._u_flat(u)))
85
+
86
+ def derivative_v(
87
+ self,
88
+ t: float,
89
+ x: npt.NDArray[Any],
90
+ u: tuple[npt.NDArray[Any], ...],
91
+ ) -> npt.NDArray[Any]:
92
+ return np.asarray(self.derivative(t, x, self._u_flat(u)), dtype=float).ravel()
93
+
94
+ def update_v(
95
+ self,
96
+ t: float,
97
+ x: npt.NDArray[Any],
98
+ u: tuple[npt.NDArray[Any], ...],
99
+ ) -> npt.NDArray[Any]:
100
+ return np.asarray(self.update(t, x, self._u_flat(u)), dtype=float).ravel()
101
+
102
+ def advance_v(
103
+ self,
104
+ t: float,
105
+ x: npt.NDArray[Any],
106
+ u: tuple[npt.NDArray[Any], ...],
107
+ ) -> npt.NDArray[Any]:
108
+ return np.asarray(self.advance(t, x, self._u_flat(u)), dtype=float).ravel()
109
+
18
110
 
19
111
  def build_companion_form_siso(
20
112
  numerator: npt.NDArray[Any], denominator: npt.NDArray[Any]
@@ -16,7 +16,12 @@ import scipy.signal
16
16
 
17
17
  from ..core.block import Block
18
18
  from ..exceptions import BlockSpecError
19
- from ._lti_utils import _DF_TOLERANCE, build_companion_form_siso
19
+ from ._lti_utils import (
20
+ _DF_TOLERANCE,
21
+ LtiVectorPortMixin,
22
+ build_companion_form_siso,
23
+ lti_port_layout,
24
+ )
20
25
  from ._vector_state import VectorStateMixin, X0Like
21
26
 
22
27
 
@@ -73,16 +78,20 @@ class Integrator(VectorStateMixin, Block):
73
78
  return self._u_state(u[0])
74
79
 
75
80
 
76
- class StateSpace(Block):
81
+ class StateSpace(LtiVectorPortMixin, Block):
77
82
  """連続 LTI 状態空間 ``x_dot = A x + B u``、``y = C x + D u``。
78
83
 
79
84
  ``direct_feedthrough`` は ``D`` 行列の最大絶対値が ``1e-12`` を超えるかで
80
85
  自動推論する (ADR-0006 §(6))。
81
86
 
87
+ ポート (ADR-0079 §(7) D-9、v0.64.0): 入力は **ベクトルポート 1 本** (``m >= 2`` なら
88
+ shape ``(m,)``、``m == 1`` なら スカラ)、出力も同様 (``p``)。m 本のスカラ信号は
89
+ ``Mux`` で束ねて繋ぐ (0.14 以前のファイルは load 時に自動変換される)。
90
+
82
91
  Args:
83
92
  A: システム行列 (shape ``(n, n)``)。
84
- B: 入力行列 (shape ``(n, m)``)、``m = n_inputs``。
85
- C: 出力行列 (shape ``(p, n)``)、``p = n_outputs``。
93
+ B: 入力行列 (shape ``(n, m)``)、``m`` = 入力ベクトルの次元。
94
+ C: 出力行列 (shape ``(p, n)``)、``p`` = 出力ベクトルの次元。
86
95
  D: 直達行列 (shape ``(p, m)``)。``None`` のときゼロ行列。
87
96
  x0: 初期状態 (shape ``(n,)``)。``None`` のときゼロ。
88
97
  """
@@ -131,13 +140,17 @@ class StateSpace(Block):
131
140
  raise BlockSpecError(f"StateSpace: D must have shape ({p}, {m}), got {D_arr.shape}")
132
141
  df = bool(np.max(np.abs(D_arr)) > _DF_TOLERANCE) if D_arr.size else False
133
142
 
143
+ n_in, shapes_in = lti_port_layout(m)
144
+ n_out, shapes_out = lti_port_layout(p)
134
145
  super().__init__(
135
146
  id=id,
136
147
  name=name,
137
- n_inputs=m,
138
- n_outputs=p,
148
+ n_inputs=n_in,
149
+ n_outputs=n_out,
139
150
  n_states=n,
140
151
  direct_feedthrough=df,
152
+ port_shapes_in=shapes_in,
153
+ port_shapes_out=shapes_out,
141
154
  )
142
155
  self._A = A_arr
143
156
  self._B = B_arr
@@ -253,7 +266,7 @@ class TransferFunction(Block):
253
266
  return np.asarray(self._A @ x + self._B @ u, dtype=float).ravel()
254
267
 
255
268
 
256
- class MimoTransferFunction(Block):
269
+ class MimoTransferFunction(LtiVectorPortMixin, Block):
257
270
  """連続 LTI MIMO 伝達関数 ``H(s) = N(s) / d(s)`` (共通分母版、ADR-0010 §(2)、ADR-0016)。
258
271
 
259
272
  入力 ``q`` 個 / 出力 ``p`` 個の伝達関数行列を扱う。共通分母 ``d(s)`` (1D) と、
@@ -280,8 +293,8 @@ class MimoTransferFunction(Block):
280
293
  - ``C``: shape ``(p, p*q*n)``、出力 i は ブロック (i, j) for all j の C を集約
281
294
  - ``D``: shape ``(p, q)``、各 (i, j) で biproper なら非ゼロ
282
295
 
283
- SM-A 信号モデル (ADR-0010 §(1)) に従い、ポートは ``q`` 個のスカラー入力 +
284
- ``p`` 個のスカラー出力。
296
+ ポート (ADR-0079 §(7) D-9、v0.64.0): 入力はベクトルポート 1 本 (``q >= 2`` なら
297
+ shape ``(q,)``、``q == 1`` ならスカラ)、出力も同様 (``p``)。
285
298
 
286
299
  Phase 3 では **共通分母版のみ** をサポート (各 (i, j) で異なる分母を許容する
287
300
  独立分母版は Phase 4+ で再検討、ADR-0010 §(2))。
@@ -397,13 +410,17 @@ class MimoTransferFunction(Block):
397
410
 
398
411
  df = bool(np.max(np.abs(D)) > _DF_TOLERANCE) if D.size else False
399
412
 
413
+ n_in, shapes_in = lti_port_layout(q)
414
+ n_out, shapes_out = lti_port_layout(p)
400
415
  super().__init__(
401
416
  id=id,
402
417
  name=name,
403
- n_inputs=q,
404
- n_outputs=p,
418
+ n_inputs=n_in,
419
+ n_outputs=n_out,
405
420
  n_states=n_total,
406
421
  direct_feedthrough=df,
422
+ port_shapes_in=shapes_in,
423
+ port_shapes_out=shapes_out,
407
424
  )
408
425
  self._A = A
409
426
  self._B = B
@@ -32,7 +32,7 @@ import scipy.signal
32
32
 
33
33
  from ..core.block import Block
34
34
  from ..exceptions import BlockSpecError
35
- from ._lti_utils import _DF_TOLERANCE
35
+ from ._lti_utils import _DF_TOLERANCE, LtiVectorPortMixin, lti_port_layout
36
36
  from ._vector_state import VectorStateMixin, X0Like
37
37
 
38
38
  _zohd_logger = logging.getLogger("flode.blocks.discrete")
@@ -465,12 +465,15 @@ class ZeroOrderHoldDirect(VectorStateMixin, Block):
465
465
  return self._u_state(u[0])
466
466
 
467
467
 
468
- class DiscreteStateSpace(Block):
468
+ class DiscreteStateSpace(LtiVectorPortMixin, Block):
469
469
  """離散 LTI 状態空間 ``x[k+1] = A x[k] + B u[k]``、``y[k] = C x[k] + D u[k]`` (リファレンスツール互換)。
470
470
 
471
471
  ADR-0006 §(5)、ADR-0015 §(3) で 2n-state augmentation。``direct_feedthrough`` は
472
472
  ``D`` の最大絶対値が ``1e-12`` を超えるかで自動推論。
473
473
 
474
+ ポート (ADR-0079 §(7) D-9、v0.64.0): 入力はベクトルポート 1 本 (``m >= 2`` なら
475
+ shape ``(m,)``、``m == 1`` ならスカラ)、出力も同様 (``p``)。
476
+
474
477
  Internal state (n_states=2n、ADR-0015 §(3)):
475
478
  x[0..n-1] = output_curr — 現サンプル境界での状態 (前回 fire で確定)
476
479
  x[n..2n-1] = next_x — 次サンプル境界で前半にシフトされる buffer
@@ -537,14 +540,18 @@ class DiscreteStateSpace(Block):
537
540
  df = bool(np.max(np.abs(D_arr)) > _DF_TOLERANCE) if D_arr.size else False
538
541
 
539
542
  # ADR-0015 §(3): 2n-state augmentation で n_states = 2n
543
+ n_in, shapes_in = lti_port_layout(m)
544
+ n_out, shapes_out = lti_port_layout(p)
540
545
  super().__init__(
541
546
  id=id,
542
547
  name=name,
543
- n_inputs=m,
544
- n_outputs=p,
548
+ n_inputs=n_in,
549
+ n_outputs=n_out,
545
550
  n_states=2 * n,
546
551
  direct_feedthrough=df,
547
552
  sample_time=sample_time,
553
+ port_shapes_in=shapes_in,
554
+ port_shapes_out=shapes_out,
548
555
  )
549
556
  self._A = A_arr
550
557
  self._B = B_arr
@@ -23,6 +23,7 @@ from scipy.interpolate import RegularGridInterpolator, interp1d
23
23
 
24
24
  from ..core.block import Block
25
25
  from ..exceptions import BlockEvalError, BlockSpecError
26
+ from ._elementwise import ElementwiseMixin
26
27
 
27
28
  _logger = logging.getLogger(__name__)
28
29
 
@@ -31,7 +32,25 @@ _logger = logging.getLogger(__name__)
31
32
  LOOKUP_ND_AXIS_WARNING_THRESHOLD = 6
32
33
 
33
34
 
34
- class LookupTable1D(Block):
35
+ def _elementwise(
36
+ fn: Any, *arrays: npt.NDArray[Any], n_out: int = 1
37
+ ) -> tuple[npt.NDArray[Any], ...]:
38
+ """スカラ核 ``fn`` を要素ごとに適用する (ADR-0079 Stage 3、lookup 系のベクトル化)。
39
+
40
+ 入力は解決器の合流規則 (完全一致 + rank-0 拡張) を満たすので ``np.broadcast_arrays``
41
+ で揃え、``np.vectorize`` で ``fn`` を各要素に呼ぶ。全入力が rank-0 なら各出力も
42
+ rank-0 で、SM-A ``output`` と同じスカラ核を通るため bit-identical。補間オブジェクトの
43
+ 配列呼びは使わない (``extrapolation="error"`` / nan / ±inf の要素ごとの分岐を
44
+ スカラ核 1 箇所に保つため。性能より意味論の一致を優先、SPEC-0031 §非機能要件)。
45
+ """
46
+ vec = np.vectorize(fn, otypes=[float] * n_out)
47
+ out = vec(*np.broadcast_arrays(*arrays))
48
+ if n_out == 1:
49
+ return (np.asarray(out, dtype=float),)
50
+ return tuple(np.asarray(o, dtype=float) for o in out)
51
+
52
+
53
+ class LookupTable1D(ElementwiseMixin, Block):
35
54
  """1 次元ルックアップテーブル ``y = f(u)``。
36
55
 
37
56
  ブレークポイント配列 ``breakpoints`` (厳密単調増加) と対応するテーブル値
@@ -237,14 +256,23 @@ class LookupTable1D(Block):
237
256
  return edge_y + slope * (val - edge_x)
238
257
 
239
258
  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])
259
+ return np.array([self._eval(float(np.asarray(u).reshape(-1)[0]))])
260
+
261
+ def _kernel(
262
+ self, t: float, x: npt.NDArray[Any], u: tuple[npt.NDArray[Any], ...]
263
+ ) -> tuple[npt.NDArray[Any], ...]:
264
+ return _elementwise(self._eval, u[0])
265
+
266
+ def _eval(self, val: float) -> float:
267
+ """スカラ核 ``y = f(val)`` (SM-A ``output`` と SM-T ``_kernel`` が共有)。"""
268
+ val = float(val)
241
269
 
242
270
  # ±inf × linear 外挿: scipy 1.18+ の interp1d は外挿を lerp 形式
243
271
  # ``y_lo*(1-t) + y_hi*t`` で計算するため inf - inf = nan (+ RuntimeWarning)
244
272
  # になる (~1.17 は point-slope 形式で ±inf を返していた)。scipy を呼ばず
245
273
  # 全補間方式で端点 slope から極限値を直接計算する。
246
274
  if self.extrapolation == "linear" and np.isinf(val):
247
- return np.array([self._extrapolate_from_edge(val)])
275
+ return self._extrapolate_from_edge(val)
248
276
 
249
277
  try:
250
278
  y = float(self._interp(val))
@@ -267,10 +295,10 @@ class LookupTable1D(Block):
267
295
  ):
268
296
  y = self._extrapolate_from_edge(val)
269
297
 
270
- return np.array([y])
298
+ return y
271
299
 
272
300
 
273
- class LookupTable2D(Block):
301
+ class LookupTable2D(ElementwiseMixin, Block):
274
302
  """2 次元ルックアップテーブル ``y = f(u[0], u[1])``。
275
303
 
276
304
  2 つのブレークポイント配列 (``breakpoints_row`` / ``breakpoints_col``、
@@ -496,8 +524,17 @@ class LookupTable2D(Block):
496
524
 
497
525
  def output(self, t: float, x: npt.NDArray[Any], u: npt.NDArray[Any]) -> npt.NDArray[Any]:
498
526
  u_arr = np.asarray(u).reshape(-1)
499
- u0 = float(u_arr[0])
500
- u1 = float(u_arr[1])
527
+ return np.array([self._eval(float(u_arr[0]), float(u_arr[1]))])
528
+
529
+ def _kernel(
530
+ self, t: float, x: npt.NDArray[Any], u: tuple[npt.NDArray[Any], ...]
531
+ ) -> tuple[npt.NDArray[Any], ...]:
532
+ return _elementwise(self._eval, u[0], u[1])
533
+
534
+ def _eval(self, u0: float, u1: float) -> float:
535
+ """スカラ核 ``y = f(u0, u1)`` (SM-A ``output`` と SM-T ``_kernel`` が共有)。"""
536
+ u0 = float(u0)
537
+ u1 = float(u1)
501
538
 
502
539
  # nan は比較が全て False になるため、in_row/in_col は False となり
503
540
  # extrapolation 経路に入る (== 1-D と同方針)。
@@ -516,8 +553,7 @@ class LookupTable2D(Block):
516
553
  )
517
554
  if self.extrapolation == "linear":
518
555
  # nan は伝播 (np.isnan で early return、interp1d が nan を返すため不要)
519
- y = self._linear_extrapolate(u0, u1)
520
- return np.array([y])
556
+ return self._linear_extrapolate(u0, u1)
521
557
  # extrapolation == "clip": 端点に飽和してから補間
522
558
  u0 = float(np.clip(u0, self._bp_row[0], self._bp_row[-1]))
523
559
  u1 = float(np.clip(u1, self._bp_col[0], self._bp_col[-1]))
@@ -525,16 +561,14 @@ class LookupTable2D(Block):
525
561
  if self.interpolation == "flat":
526
562
  # nan が clip 後に残るのは clip(nan)=nan の場合 (ありうる)
527
563
  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])
564
+ return float("nan")
565
+ return self._flat_lookup(u0, u1)
566
+ # RegularGridInterpolator は shape (npts, ndim) を期待 → (1, 2) で渡す。
567
+ # 戻り値は shape (1,) なので .item() で scalar 抽出 (numpy 2.0 警告回避)。
568
+ return float(self._interp(np.array([[u0, u1]])).item())
535
569
 
536
570
 
537
- class Prelookup(Block):
571
+ class Prelookup(ElementwiseMixin, Block):
538
572
  """1-D Prelookup: 入力 ``u`` から ``(k, f)`` を分離出力する (SPEC-0019)。
539
573
 
540
574
  breakpoints 検索結果を `(k=index, f=fraction)` の 2 出力に分離することで、
@@ -612,13 +646,23 @@ class Prelookup(Block):
612
646
  }
613
647
 
614
648
  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])
649
+ k, f = self._eval(float(np.asarray(u).reshape(-1)[0]))
650
+ return np.array([k, f])
651
+
652
+ def _kernel(
653
+ self, t: float, x: npt.NDArray[Any], u: tuple[npt.NDArray[Any], ...]
654
+ ) -> tuple[npt.NDArray[Any], ...]:
655
+ return _elementwise(self._eval, u[0], n_out=2)
656
+
657
+ def _eval(self, val: float) -> tuple[float, float]:
658
+ """スカラ核 ``(k, f) = g(val)`` (SM-A ``output`` と SM-T ``_kernel`` が共有)。"""
659
+ val = float(val)
616
660
  bp = self._breakpoints
617
661
  n = bp.size
618
662
 
619
663
  if np.isnan(val):
620
664
  # nan は 1-D LookupTable と同方針で伝播
621
- return np.array([float("nan"), float("nan")])
665
+ return float("nan"), float("nan")
622
666
 
623
667
  in_domain = bool(bp[0] <= val <= bp[-1])
624
668
 
@@ -636,17 +680,17 @@ class Prelookup(Block):
636
680
  else: # val > bp[-1] (inf 含む)
637
681
  k = n - 2
638
682
  f = (val - float(bp[k])) / (float(bp[k + 1]) - float(bp[k]))
639
- return np.array([float(k), f])
683
+ return float(k), f
640
684
  # extrapolation == "clip": 端点に飽和
641
685
  val = float(np.clip(val, bp[0], bp[-1]))
642
686
 
643
687
  # 定義域内 (or clip 後): 純 numpy 検索
644
688
  k = int(np.clip(np.searchsorted(bp, val, side="right") - 1, 0, n - 2))
645
689
  f = (val - float(bp[k])) / (float(bp[k + 1]) - float(bp[k]))
646
- return np.array([float(k), f])
690
+ return float(k), f
647
691
 
648
692
 
649
- class InterpolationUsingPrelookup(Block):
693
+ class InterpolationUsingPrelookup(ElementwiseMixin, Block):
650
694
  """1-D Prelookup を用いた補間 (SPEC-0019)。
651
695
 
652
696
  :class:`Prelookup` の出力 ``(k, f)`` を受け取り、内部 ``table`` から
@@ -726,30 +770,37 @@ class InterpolationUsingPrelookup(Block):
726
770
 
727
771
  def output(self, t: float, x: npt.NDArray[Any], u: npt.NDArray[Any]) -> npt.NDArray[Any]:
728
772
  u_arr = np.asarray(u).reshape(-1)
729
- k_raw = float(u_arr[0])
730
- f = float(u_arr[1])
773
+ return np.array([self._eval(float(u_arr[0]), float(u_arr[1]))])
774
+
775
+ def _kernel(
776
+ self, t: float, x: npt.NDArray[Any], u: tuple[npt.NDArray[Any], ...]
777
+ ) -> tuple[npt.NDArray[Any], ...]:
778
+ return _elementwise(self._eval, u[0], u[1])
779
+
780
+ def _eval(self, k_raw: float, f: float) -> float:
781
+ """スカラ核 ``y = h(k, f)`` (SM-A ``output`` と SM-T ``_kernel`` が共有)。"""
782
+ k_raw = float(k_raw)
783
+ f = float(f)
731
784
  tbl = self._table
732
785
  n = tbl.size
733
786
 
734
787
  # k / f の nan は伝播 (= nan 出力)。
735
788
  if np.isnan(k_raw) or np.isnan(f):
736
- return np.array([float("nan")])
789
+ return float("nan")
737
790
  # k は黙って clip ([0, n-2])。Prelookup の出力域だが上流ブロック自由なので保険。
738
791
  # ``np.clip`` を ``int()`` より先に評価することで ``k_raw=±inf`` でも
739
792
  # OverflowError を避けて n-2 / 0 に飽和させる (ADR-0056 構造化エラー protocol)。
740
793
  k = int(np.clip(k_raw, 0, n - 2))
741
794
 
742
795
  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])
796
+ return float(tbl[k]) + f * (float(tbl[k + 1]) - float(tbl[k]))
797
+ if self.interpolation == "nearest":
798
+ return float(tbl[k + 1]) if f >= 0.5 else float(tbl[k])
799
+ # "flat"
800
+ return float(tbl[k])
748
801
 
749
- return np.array([y])
750
802
 
751
-
752
- class LookupTableND(Block):
803
+ class LookupTableND(ElementwiseMixin, Block):
753
804
  """n 次元ルックアップテーブル ``y = f(u[0], u[1], ..., u[n-1])`` (SPEC-0018)。
754
805
 
755
806
  軸数 n は ``len(breakpoints_axes)`` で動的に決まる。``LookupTable1D`` (n=1) /
@@ -978,6 +1029,16 @@ class LookupTableND(Block):
978
1029
  f"LookupTableND[{self.name}]: expected {self._n_axes} inputs, got {u_arr.size}",
979
1030
  block_id=self.id,
980
1031
  )
1032
+ return np.array([self._eval(*u_arr)])
1033
+
1034
+ def _kernel(
1035
+ self, t: float, x: npt.NDArray[Any], u: tuple[npt.NDArray[Any], ...]
1036
+ ) -> tuple[npt.NDArray[Any], ...]:
1037
+ return _elementwise(self._eval, *u)
1038
+
1039
+ def _eval(self, *vals: float) -> float:
1040
+ """スカラ核 ``y = f(u[0], …, u[n-1])`` (SM-A ``output`` と SM-T ``_kernel`` が共有)。"""
1041
+ u_arr = np.asarray(vals, dtype=float)
981
1042
 
982
1043
  # 各軸の定義域内判定 (nan は <=/>= で False になり外挿経路へ)
983
1044
  in_domain_flags = [
@@ -999,8 +1060,7 @@ class LookupTableND(Block):
999
1060
  )
1000
1061
  if self.extrapolation == "linear":
1001
1062
  # nan 含むときは結果も nan (interp1d が nan 伝播)
1002
- y = self._linear_extrapolate(u_arr)
1003
- return np.array([y])
1063
+ return self._linear_extrapolate(u_arr)
1004
1064
  # extrapolation == "clip": 各軸独立に端点飽和
1005
1065
  u_arr = np.array(
1006
1066
  [
@@ -1017,9 +1077,7 @@ class LookupTableND(Block):
1017
1077
 
1018
1078
  if self.interpolation == "flat":
1019
1079
  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])
1080
+ return float("nan")
1081
+ return self._flat_lookup(u_arr)
1082
+ # RegularGridInterpolator は shape (npts, ndim) を期待 → (1, n_axes)。
1083
+ return float(self._interp(u_arr.reshape(1, -1)).item())