From 42a3eba3ee24720efe569c26a9baf94606ac5d95 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 14 Feb 2026 12:40:11 +0100 Subject: [PATCH 1/3] feat(operators): add demux block core gui doc --- pySimBlocks/blocks/operators/__init__.py | 2 + pySimBlocks/blocks/operators/demux.py | 123 ++++++++++++++++++ pySimBlocks/docs/blocks/operators/demux.md | 64 +++++++++ pySimBlocks/gui/blocks/operators/demux.py | 98 ++++++++++++++ .../project/pySimBlocks_blocks_index.yaml | 3 + test/blocks/operators/test_demux.py | 111 ++++++++++++++++ 6 files changed, 401 insertions(+) create mode 100644 pySimBlocks/blocks/operators/demux.py create mode 100644 pySimBlocks/docs/blocks/operators/demux.md create mode 100644 pySimBlocks/gui/blocks/operators/demux.py create mode 100644 test/blocks/operators/test_demux.py diff --git a/pySimBlocks/blocks/operators/__init__.py b/pySimBlocks/blocks/operators/__init__.py index 8fb9741..1a5f8c4 100644 --- a/pySimBlocks/blocks/operators/__init__.py +++ b/pySimBlocks/blocks/operators/__init__.py @@ -19,6 +19,7 @@ # ****************************************************************************** from pySimBlocks.blocks.operators.dead_zone import DeadZone +from pySimBlocks.blocks.operators.demux import Demux from pySimBlocks.blocks.operators.delay import Delay from pySimBlocks.blocks.operators.discrete_derivator import DiscreteDerivator from pySimBlocks.blocks.operators.discrete_integrator import DiscreteIntegrator @@ -32,6 +33,7 @@ __all__ = [ "DeadZone", + "Demux", "Delay", "DiscreteDerivator", "DiscreteIntegrator", diff --git a/pySimBlocks/blocks/operators/demux.py b/pySimBlocks/blocks/operators/demux.py new file mode 100644 index 0000000..a2dc3fc --- /dev/null +++ b/pySimBlocks/blocks/operators/demux.py @@ -0,0 +1,123 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# ****************************************************************************** +# Authors: see Authors.txt +# ****************************************************************************** + +import numpy as np +from numpy.typing import ArrayLike + +from pySimBlocks.core.block import Block + + +class Demux(Block): + """ + Vector split block (inverse of Mux). + + Summary: + Splits one input column vector into multiple output segments. + + Parameters: + num_outputs : int + Number of scalar outputs to produce. + sample_time : float, optional + Block execution period. + + Inputs: + in : vector (n,1) + Input must be a column vector. + + Outputs: + out1, out2, ..., outP : array (k,1) + Output segment sizes follow: + - q = n // p + - m = n % p + - first m outputs have size (q+1,1) + - remaining (p-m) outputs have size (q,1) + """ + + direct_feedthrough = True + + def __init__(self, name: str, num_outputs: int = 2, sample_time: float | None = None): + super().__init__(name, sample_time) + + if not isinstance(num_outputs, int) or num_outputs < 1: + raise ValueError(f"[{self.name}] num_outputs must be a positive integer.") + self.num_outputs = num_outputs + + self.inputs["in"] = None + for i in range(num_outputs): + self.outputs[f"out{i+1}"] = None + + + # -------------------------------------------------------------------------- + # Public methods + # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + if self.inputs["in"] is None: + for i in range(self.num_outputs): + self.outputs[f"out{i+1}"] = np.zeros((1, 1), dtype=float) + return + + self._compute_outputs() + + # --------------------------------------------------------- + def output_update(self, t: float, dt: float) -> None: + self._compute_outputs() + + # --------------------------------------------------------- + def state_update(self, t: float, dt: float) -> None: + return # stateless + + + # -------------------------------------------------------------------------- + # Private methods + # -------------------------------------------------------------------------- + def _to_vector(self, value: ArrayLike) -> np.ndarray: + arr = np.asarray(value, dtype=float) + + if arr.ndim != 2 or arr.shape[1] != 1: + raise ValueError( + f"[{self.name}] Input 'in' must be a column vector (n,1). " + f"Got shape {arr.shape}." + ) + return arr + + # --------------------------------------------------------- + def _compute_outputs(self) -> None: + u = self.inputs["in"] + if u is None: + raise RuntimeError(f"[{self.name}] Input 'in' is not connected or not set.") + + vec = self._to_vector(u) + n = vec.shape[0] + p = self.num_outputs + + if p > n: + raise ValueError( + f"[{self.name}] num_outputs ({p}) must be <= input vector length ({n})." + ) + + q = n // p + m = n % p + + start = 0 + for i in range(p): + seg_len = q + 1 if i < m else q + end = start + seg_len + self.outputs[f"out{i+1}"] = vec[start:end].copy() + start = end diff --git a/pySimBlocks/docs/blocks/operators/demux.md b/pySimBlocks/docs/blocks/operators/demux.md new file mode 100644 index 0000000..5599f7d --- /dev/null +++ b/pySimBlocks/docs/blocks/operators/demux.md @@ -0,0 +1,64 @@ +# Demux + +## Summary + +The **Demux** block splits one input column vector into multiple output +column vectors. + +--- + +## Mathematical definition + +Given an input vector $\mathrm{in} \in \mathbb{R}^{n \times 1}$ and +$p$ outputs: + +$$ +q = \left\lfloor \frac{n}{p} \right\rfloor, \quad +m = n \bmod p +$$ + +The output segments are: +- the first $m$ outputs have size $(q+1, 1)$, +- the remaining $(p-m)$ outputs have size $(q, 1)$. + +This corresponds to an even split with the remainder distributed on the +first outputs. + +--- + +## Parameters + +| Name | Type | Description | Optional | +|------------|-------------|-------------|-------------| +| `num_outputs` | integer | Number of output ports to create. | True | +| `sample_time` | float | Block sample time. If omitted, the global simulation time step is used. | True | + +--- + +## Inputs + +| Port | Description | +|------|------------| +| `in` | Input column vector of shape $(n,1)$. | + +--- + +## Outputs + +| Port | Description | +|------|------------| +| `out1 … outP` | Output vector segments according to the split rule above. | + +--- + +## Notes + +- The block has no internal state. +- The block has direct feedthrough. +- Input must be a column vector of shape $(n,1)$. +- The parameter `num_outputs` must satisfy $1 \le p \le n$. +- This block is equivalent to the Simulink **Demux** concept for vector splitting. + + +--- +© 2026 Université de Lille & INRIA – Licensed under LGPL-3.0-or-later diff --git a/pySimBlocks/gui/blocks/operators/demux.py b/pySimBlocks/gui/blocks/operators/demux.py new file mode 100644 index 0000000..42c652b --- /dev/null +++ b/pySimBlocks/gui/blocks/operators/demux.py @@ -0,0 +1,98 @@ +# ****************************************************************************** +# pySimBlocks +# Copyright (c) 2026 Université de Lille & INRIA +# ****************************************************************************** +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +# ****************************************************************************** +# Authors: see Authors.txt +# ****************************************************************************** + +from typing import Literal + +from pySimBlocks.gui.blocks.block_meta import BlockMeta +from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta +from pySimBlocks.gui.blocks.port_meta import PortMeta +from pySimBlocks.gui.model import BlockInstance, PortInstance + + +class DemuxMeta(BlockMeta): + + def __init__(self): + self.name = "Demux" + self.category = "operators" + self.type = "demux" + self.summary = "Vector split block (inverse of Mux)." + self.description = ( + "Splits one input column vector into multiple output segments.\n" + "For input length $n$ and $p$ outputs:\n" + "$$\n" + "q = n // p, \\quad m = n \\% p\n" + "$$\n" + "The first $m$ outputs have $(q+1)$ elements and the remaining\n" + "$(p-m)$ outputs have $q$ elements.\n" + ) + + self.parameters = [ + ParameterMeta( + name="num_outputs", + type="int", + autofill=True, + default=2 + ), + ParameterMeta( + name="sample_time", + type="float" + ) + ] + + self.inputs = [ + PortMeta( + name="in", + display_as="in", + shape=["n", 1], + description="Input column vector to split." + ) + ] + + self.outputs = [ + PortMeta( + name="out", + display_as="", + shape=["k", 1], + description="Output vector segments." + ) + ] + + def resolve_port_group( + self, + port_meta: PortMeta, + direction: Literal["input", "output"], + instance: "BlockInstance" + ) -> list["PortInstance"]: + + if direction == "output" and port_meta.name == "out": + num_outputs = instance.parameters.get("num_outputs", 0) + ports = [] + for i in range(1, num_outputs + 1): + ports.append( + PortInstance( + name=f"{port_meta.name}{i}", + display_as=f"{i}", + direction="output", + block=instance + ) + ) + return ports + + return super().resolve_port_group(port_meta, direction, instance) diff --git a/pySimBlocks/project/pySimBlocks_blocks_index.yaml b/pySimBlocks/project/pySimBlocks_blocks_index.yaml index 88e804c..8fa60e5 100644 --- a/pySimBlocks/project/pySimBlocks_blocks_index.yaml +++ b/pySimBlocks/project/pySimBlocks_blocks_index.yaml @@ -23,6 +23,9 @@ operators: dead_zone: class: DeadZone module: pySimBlocks.blocks.operators.dead_zone + demux: + class: Demux + module: pySimBlocks.blocks.operators.demux delay: class: Delay module: pySimBlocks.blocks.operators.delay diff --git a/test/blocks/operators/test_demux.py b/test/blocks/operators/test_demux.py new file mode 100644 index 0000000..ba72e4d --- /dev/null +++ b/test/blocks/operators/test_demux.py @@ -0,0 +1,111 @@ +import numpy as np +import pytest + +from pySimBlocks.blocks.operators.demux import Demux +from pySimBlocks.blocks.sources.constant import Constant +from pySimBlocks.core import Model, SimulationConfig, Simulator + + +def run_sim(value, num_outputs): + m = Model() + + c = Constant("c", value) + dmx = Demux("D", num_outputs=num_outputs) + + m.add_block(c) + m.add_block(dmx) + m.connect("c", "out", "D", "in") + + logs = [f"D.outputs.out{i+1}" for i in range(num_outputs)] + sim_cfg = SimulationConfig(0.1, 0.1, logging=logs) + sim = Simulator(m, sim_cfg) + data = sim.run() + return [data[key][-1] for key in logs] + + +def test_demux_basic_column_vector(): + outputs = run_sim([[1.0], [2.0], [3.0]], num_outputs=3) + assert np.allclose(outputs[0], [[1.0]]) + assert np.allclose(outputs[1], [[2.0]]) + assert np.allclose(outputs[2], [[3.0]]) + + +def test_demux_equal_split_when_divisible(): + outputs = run_sim([[1.0], [2.0], [3.0], [4.0]], num_outputs=2) + assert np.allclose(outputs[0], [[1.0], [2.0]]) + assert np.allclose(outputs[1], [[3.0], [4.0]]) + + +def test_demux_remainder_split_rule(): + # n=5, p=3 => q=1, m=2 + # out1/out2 have 2 elems, out3 has 1 elem + outputs = run_sim([[1.0], [2.0], [3.0], [4.0], [5.0]], num_outputs=3) + assert np.allclose(outputs[0], [[1.0], [2.0]]) + assert np.allclose(outputs[1], [[3.0], [4.0]]) + assert np.allclose(outputs[2], [[5.0]]) + + +def test_demux_invalid_parameter(): + with pytest.raises(ValueError) as err: + Demux("D", num_outputs=0) + assert "num_outputs must be a positive integer" in str(err.value) + + +def test_demux_missing_input_raises(): + m = Model() + dmx = Demux("D", num_outputs=2) + m.add_block(dmx) + + sim_cfg = SimulationConfig(0.1, 0.1) + sim = Simulator(m, sim_cfg) + + with pytest.raises(RuntimeError) as err: + sim.run(T=0.1) + + assert "not connected or not set" in str(err.value) + + +def test_demux_rejects_matrix_input(): + m = Model() + c = Constant("c", [[1.0, 2.0], [3.0, 4.0]]) + dmx = Demux("D", num_outputs=4) + + m.add_block(c) + m.add_block(dmx) + m.connect("c", "out", "D", "in") + + sim_cfg = SimulationConfig(0.1, 0.1) + sim = Simulator(m, sim_cfg) + + with pytest.raises(RuntimeError) as err: + sim.run(T=0.1) + + assert "must be a column vector (n,1)" in str(err.value) + + +def test_demux_rejects_1d_input(): + dmx = Demux("D", num_outputs=1) + dmx.inputs["in"] = np.array([5.0]) + + with pytest.raises(ValueError) as err: + dmx.output_update(t=0.0, dt=0.1) + + assert "must be a column vector (n,1)" in str(err.value) + + +def test_demux_rejects_more_outputs_than_input_length(): + m = Model() + c = Constant("c", [[1.0], [2.0]]) + dmx = Demux("D", num_outputs=3) + + m.add_block(c) + m.add_block(dmx) + m.connect("c", "out", "D", "in") + + sim_cfg = SimulationConfig(0.1, 0.1) + sim = Simulator(m, sim_cfg) + + with pytest.raises(RuntimeError) as err: + sim.run(T=0.1) + + assert "must be <= input vector length" in str(err.value) From f3b1b0950089c8ad6a952b140db5aa34c9157fa7 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 14 Feb 2026 12:40:53 +0100 Subject: [PATCH 2/3] fix(chore): remove metada and add test packages --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d6a968f..b940645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,6 @@ where = ["."] [tool.setuptools.package-data] pySimBlocks = [ - "blocks_metadata/**/*.yaml", "docs/blocks/**/*.md", "project/pySimBlocks_blocks_index.yaml", ] @@ -43,5 +42,6 @@ examples = [ ] tests = [ - "control", + "pytest", + "pytest-qt", ] From 92033627c50480e661ece2aa2e5a6d81c5ed7c74 Mon Sep 17 00:00:00 2001 From: Alessandrini Antoine Date: Sat, 14 Feb 2026 12:41:18 +0100 Subject: [PATCH 3/3] docs(sources): add chirp docs --- pySimBlocks/docs/blocks/sources/chirp.md | 102 +++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 pySimBlocks/docs/blocks/sources/chirp.md diff --git a/pySimBlocks/docs/blocks/sources/chirp.md b/pySimBlocks/docs/blocks/sources/chirp.md new file mode 100644 index 0000000..5b4da47 --- /dev/null +++ b/pySimBlocks/docs/blocks/sources/chirp.md @@ -0,0 +1,102 @@ +# Chirp + +## Summary + +The **Chirp** block generates a frequency-swept sinusoidal signal +(linear or logarithmic) with configurable amplitude, phase, offset, +start time, and duration. + +--- + +## Mathematical definition + +For each output component $i$: + +$$ +y_i(t) = A_i \sin(\phi_i(t)) + o_i +$$ + +with parameters: +- $A_i$: amplitude, +- $o_i$: offset, +- $\phi_i(t)$: chirp phase, +- $f_{0,i}$: initial frequency, +- $f_{1,i}$: final frequency, +- $T_i$: chirp duration, +- $t_{0,i}$: chirp start time, +- $\varphi_i$: initial phase. + +Define: +$$ +\tau_i = \max(0, t - t_{0,i}), \quad +\tau_{c,i} = \min(\tau_i, T_i) +$$ + +Linear mode: +$$ +k_i = \frac{f_{1,i} - f_{0,i}}{T_i} +$$ +$$ +\phi_i(t) = +2\pi\left(f_{0,i}\tau_{c,i} + \frac{1}{2}k_i\tau_{c,i}^2\right) ++ 2\pi f_{1,i}\max(0, \tau_i - T_i) ++ \varphi_i +$$ + +Log mode: +$$ +r_i = \frac{f_{1,i}}{f_{0,i}} +$$ +$$ +\phi_i(t) = +\frac{2\pi f_{0,i}T_i}{\ln(r_i)} +\left(r_i^{\tau_{c,i}/T_i} - 1\right) ++ 2\pi f_{1,i}\max(0, \tau_i - T_i) ++ \varphi_i +$$ + +After duration, phase continuity is preserved and oscillation continues at $f_1$. + +--- + +## Parameters + +| Name | Type | Description | Optional | +|------------|-------------|-------------|-------------| +| `amplitude` | scalar or vector or matrix | Signal amplitude. Scalars are broadcast to all dimensions. | False | +| `f0` | scalar or vector or matrix | Initial frequency in Hertz. Scalars are broadcast. | False | +| `f1` | scalar or vector or matrix | Final frequency in Hertz. Scalars are broadcast. | False | +| `duration` | scalar or vector or matrix | Sweep duration in seconds. Must be strictly positive. Scalars are broadcast. | False | +| `start_time` | scalar or vector or matrix | Start time in seconds. Before this time, sweep is not active. Default is `0.0`. | True | +| `offset` | scalar or vector or matrix | Constant offset added to output. Default is `0.0`. | True | +| `phase` | scalar or vector or matrix | Initial phase in radians. Default is `0.0`. | True | +| `mode` | string | Sweep mode: `linear` or `log`. Default is `linear`. | True | +| `sample_time` | float | Block sample time. If omitted, the global simulation time step is used. | True | + +--- + +## Inputs + +This block has **no inputs**. + +--- + +## Outputs + +| Port | Description | +|------|------------| +| `out` | Chirp output signal. | + +--- + +## Notes + +- The block has no internal state. +- All array-like parameters must have compatible shapes. +- Scalar parameters are broadcast to the common signal shape. +- In `log` mode, `f0 > 0`, `f1 > 0`, and `f0 != f1` are required. +- The output keeps oscillating after `duration` at frequency `f1`. + + +--- +© 2026 Université de Lille & INRIA – Licensed under LGPL-3.0-or-later