diff --git a/examples/generate/sources/data.csv b/examples/generate/sources/data.csv new file mode 100644 index 0000000..812a8b0 --- /dev/null +++ b/examples/generate/sources/data.csv @@ -0,0 +1,101 @@ +time,y +0.0,0.0 +0.1,0.09983341664682815 +0.2,0.19866933079506122 +0.30000000000000004,0.2955202066613396 +0.4,0.3894183423086505 +0.5,0.479425538604203 +0.6000000000000001,0.5646424733950355 +0.7000000000000001,0.6442176872376911 +0.8,0.7173560908995228 +0.9,0.7833269096274834 +1.0,0.8414709848078965 +1.1,0.8912073600614354 +1.2000000000000002,0.9320390859672264 +1.3,0.963558185417193 +1.4000000000000001,0.9854497299884603 +1.5,0.9974949866040544 +1.6,0.9995736030415051 +1.7000000000000002,0.9916648104524686 +1.8,0.9738476308781951 +1.9000000000000001,0.9463000876874145 +2.0,0.9092974268256817 +2.1,0.8632093666488737 +2.2,0.8084964038195901 +2.3000000000000003,0.74570521217672 +2.4000000000000004,0.6754631805511506 +2.5,0.5984721441039565 +2.6,0.5155013718214642 +2.7,0.4273798802338298 +2.8000000000000003,0.33498815015590466 +2.9000000000000004,0.23924932921398198 +3.0,0.1411200080598672 +3.1,0.04158066243329049 +3.2,-0.058374143427580086 +3.3000000000000003,-0.15774569414324865 +3.4000000000000004,-0.25554110202683167 +3.5,-0.35078322768961984 +3.6,-0.44252044329485246 +3.7,-0.5298361409084934 +3.8000000000000003,-0.6118578909427193 +3.9000000000000004,-0.6877661591839741 +4.0,-0.7568024953079282 +4.1000000000000005,-0.8182771110644108 +4.2,-0.8715757724135882 +4.3,-0.9161659367494549 +4.4,-0.951602073889516 +4.5,-0.977530117665097 +4.6000000000000005,-0.9936910036334645 +4.7,-0.9999232575641008 +4.800000000000001,-0.9961646088358406 +4.9,-0.9824526126243325 +5.0,-0.9589242746631385 +5.1000000000000005,-0.9258146823277321 +5.2,-0.8834546557201531 +5.300000000000001,-0.8322674422239008 +5.4,-0.7727644875559871 +5.5,-0.7055403255703919 +5.6000000000000005,-0.6312666378723208 +5.7,-0.5506855425976376 +5.800000000000001,-0.4646021794137566 +5.9,-0.373876664830236 +6.0,-0.27941549819892586 +6.1000000000000005,-0.18216250427209502 +6.2,-0.0830894028174964 +6.300000000000001,0.0168139004843506 +6.4,0.11654920485049364 +6.5,0.21511998808781552 +6.6000000000000005,0.3115413635133787 +6.7,0.4048499206165983 +6.800000000000001,0.49411335113860894 +6.9,0.5784397643882001 +7.0,0.6569865987187891 +7.1000000000000005,0.7289690401258765 +7.2,0.7936678638491531 +7.300000000000001,0.8504366206285648 +7.4,0.8987080958116269 +7.5,0.9379999767747389 +7.6000000000000005,0.9679196720314865 +7.7,0.9881682338770004 +7.800000000000001,0.998543345374605 +7.9,0.998941341839772 +8.0,0.9893582466233818 +8.1,0.9698898108450863 +8.200000000000001,0.9407305566797726 +8.3,0.9021718337562933 +8.4,0.8545989080882804 +8.5,0.7984871126234903 +8.6,0.7343970978741133 +8.700000000000001,0.662969230082182 +8.8,0.5849171928917617 +8.9,0.5010208564578846 +9.0,0.4121184852417566 +9.1,0.3190983623493521 +9.200000000000001,0.22288991410024592 +9.3,0.1244544235070617 +9.4,0.024775425453357765 +9.5,-0.0751511204618093 +9.600000000000001,-0.1743267812229814 +9.700000000000001,-0.2717606264109442 +9.8,-0.3664791292519284 +9.9,-0.45753589377532133 diff --git a/examples/generate/sources/data.npy b/examples/generate/sources/data.npy new file mode 100644 index 0000000..5436a49 Binary files /dev/null and b/examples/generate/sources/data.npy differ diff --git a/examples/generate/sources/data.npz b/examples/generate/sources/data.npz new file mode 100644 index 0000000..021d7fc Binary files /dev/null and b/examples/generate/sources/data.npz differ diff --git a/examples/generate/sources/layout.yaml b/examples/generate/sources/layout.yaml new file mode 100644 index 0000000..02fed48 --- /dev/null +++ b/examples/generate/sources/layout.yaml @@ -0,0 +1,20 @@ +version: 1 +blocks: + npz: + x: -305.0 + y: -59.0 + orientation: normal + width: 120.0 + height: 60.0 + npy: + x: -300.0 + y: 15.0 + orientation: normal + width: 120.0 + height: 60.0 + csv: + x: -295.0 + y: 95.0 + orientation: normal + width: 120.0 + height: 60.0 diff --git a/examples/generate/sources/model.yaml b/examples/generate/sources/model.yaml new file mode 100644 index 0000000..f3a1509 --- /dev/null +++ b/examples/generate/sources/model.yaml @@ -0,0 +1,11 @@ +blocks: +- name: npz + category: sources + type: file_source +- name: npy + category: sources + type: file_source +- name: csv + category: sources + type: file_source +connections: [] diff --git a/examples/generate/sources/parameters.yaml b/examples/generate/sources/parameters.yaml new file mode 100644 index 0000000..1c1bba0 --- /dev/null +++ b/examples/generate/sources/parameters.yaml @@ -0,0 +1,23 @@ +simulation: + dt: 0.05 + T: 10.0 + solver: fixed +blocks: + npz: + file_path: data.npz + key: y + repeat: 'False' + use_time: 'True' + npy: + file_path: data.npy + repeat: 'True' + csv: + file_path: data.csv + key: y + repeat: 'False' + use_time: false +logging: +- npz.outputs.out +- npy.outputs.out +- csv.outputs.out +plots: [] diff --git a/examples/generate/sources/temp.py b/examples/generate/sources/temp.py new file mode 100644 index 0000000..3eec5a3 --- /dev/null +++ b/examples/generate/sources/temp.py @@ -0,0 +1,16 @@ +import csv +import numpy as np + +t = np.arange(0, 10, 0.1) +y = np.sin(t) +y = y.reshape(-1, 1) + +np.savez('data.npz', time=t, y=y) + +np.save('data.npy', y) + +with open('data.csv', 'w', newline='') as csvfile: + writer = csv.writer(csvfile) + writer.writerow(['time', 'y']) # Write header + for t_val, y_val in zip(t, y): + writer.writerow([t_val, y_val[0]]) # Write each row of data diff --git a/pySimBlocks/blocks/sources/__init__.py b/pySimBlocks/blocks/sources/__init__.py index 11ebee0..01d57b5 100644 --- a/pySimBlocks/blocks/sources/__init__.py +++ b/pySimBlocks/blocks/sources/__init__.py @@ -19,6 +19,7 @@ # ****************************************************************************** from pySimBlocks.blocks.sources.constant import Constant +from pySimBlocks.blocks.sources.file_source import FileSource from pySimBlocks.blocks.sources.ramp import Ramp from pySimBlocks.blocks.sources.step import Step from pySimBlocks.blocks.sources.sinusoidal import Sinusoidal @@ -26,6 +27,7 @@ __all__ = [ "Constant", + "FileSource", "Ramp", "Step", "Sinusoidal", diff --git a/pySimBlocks/blocks/sources/file_source.py b/pySimBlocks/blocks/sources/file_source.py new file mode 100644 index 0000000..0263662 --- /dev/null +++ b/pySimBlocks/blocks/sources/file_source.py @@ -0,0 +1,294 @@ +# ****************************************************************************** +# 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 pathlib import Path +from typing import Any, Dict + +import numpy as np + +from pySimBlocks.core.block_source import BlockSource + + +class FileSource(BlockSource): + """ + Source block that plays samples loaded from a file. + + Supported file types: + - npz: load an array from a key in a .npz archive (key mandatory) + - npy: load an array from a .npy file (no key) + - csv: load one numeric column by name (key=column name) + + Output policy: + - loaded data must be 1D or 2D + - each simulation step emits one row as a column vector + - when the end is reached: + * repeat=True -> restart from first sample + * repeat=False -> output zeros + """ + + VALID_FILE_TYPES = {"npz", "npy", "csv"} + + def __init__( + self, + name: str, + file_path: str, + key: str | None = None, + repeat: bool = False, + use_time: bool = False, + sample_time: float | None = None, + ): + super().__init__(name, sample_time) + + self.file_path = str(file_path) + self.file_type = self._infer_file_type(self.file_path) + self.key = key + self.repeat = self._to_bool(repeat, "repeat") + self.use_time = self._to_bool(use_time, "use_time") + + if self.use_time and self.file_type == "npy": + raise ValueError( + f"[{self.name}] use_time is supported only for NPZ and CSV inputs." + ) + if self.use_time and self.repeat: + raise ValueError( + f"[{self.name}] repeat cannot be used when use_time=True." + ) + + self._time: np.ndarray | None = None + self._samples = self._load_samples() + self._index = 0 + self._output_shape = (self._samples.shape[1], 1) + + self.outputs["out"] = np.zeros(self._output_shape, dtype=float) + + # -------------------------------------------------------------------------- + # Class Methods + # -------------------------------------------------------------------------- + @classmethod + def adapt_params( + cls, + params: Dict[str, Any], + params_dir: Path | None = None, + ) -> Dict[str, Any]: + """ + Resolve relative file_path against parameters directory when provided. + """ + adapted = dict(params) + file_path = adapted.get("file_path") + if file_path is None: + return adapted + + path = Path(file_path) + if not path.is_absolute() and params_dir is not None: + path = (params_dir / path).resolve() + + adapted["file_path"] = str(path) + # Backward compatibility with older models that still contain file_type + adapted.pop("file_type", None) + return adapted + + # -------------------------------------------------------------------------- + # Public methods + # -------------------------------------------------------------------------- + def initialize(self, t0: float) -> None: + if self.use_time: + self.outputs["out"] = self._current_output_at_time(t0) + else: + self._index = 0 + self.outputs["out"] = self._current_output() + + # ------------------------------------------------------------------ + def output_update(self, t: float, dt: float) -> None: + if self.use_time: + self.outputs["out"] = self._current_output_at_time(t) + else: + self.outputs["out"] = self._current_output() + self._index += 1 + + # ------------------------------------------------------------------ + def state_update(self, t: float, dt: float) -> None: + pass + + # -------------------------------------------------------------------------- + # Private methods + # -------------------------------------------------------------------------- + def _load_samples(self) -> np.ndarray: + path = Path(self.file_path) + if not path.exists(): + raise FileNotFoundError(f"[{self.name}] File not found: {path}") + + if self.file_type == "npz": + arr, time = self._load_npz(path) + elif self.file_type == "npy": + arr, time = self._load_npy(path) + else: + arr, time = self._load_csv(path) + + if arr.ndim == 1: + arr = arr.reshape(-1, 1) + elif arr.ndim != 2: + raise ValueError( + f"[{self.name}] Loaded data must be 1D or 2D. Got shape {arr.shape}." + ) + + if arr.shape[0] == 0: + raise ValueError(f"[{self.name}] Loaded file contains no samples.") + + self._time = time + + return arr.astype(float, copy=False) + + # ------------------------------------------------------------------ + def _load_npz(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]: + with np.load(path) as data: + keys = list(data.files) + if len(keys) == 0: + raise ValueError(f"[{self.name}] NPZ archive contains no arrays.") + + selected_key = self.key + if not selected_key: + raise ValueError( + f"[{self.name}] key is mandatory for NPZ input." + ) + + if selected_key not in data: + raise KeyError( + f"[{self.name}] key '{selected_key}' not found in NPZ. " + f"Available keys: {keys}" + ) + + arr = np.asarray(data[selected_key], dtype=float) + time = None + if self.use_time: + if "time" not in data: + raise KeyError( + f"[{self.name}] use_time=True requires NPZ key 'time'." + ) + time = np.asarray(data["time"], dtype=float).reshape(-1) + self._validate_time(time, arr.shape[0]) + return arr, time + + # ------------------------------------------------------------------ + def _load_npy(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]: + if self.key not in (None, ""): + raise ValueError( + f"[{self.name}] key is not used for NPY input." + ) + return np.asarray(np.load(path), dtype=float), None + + # ------------------------------------------------------------------ + def _load_csv(self, path: Path) -> tuple[np.ndarray, np.ndarray | None]: + if not self.key: + raise ValueError( + f"[{self.name}] key is mandatory for CSV input and must be a column name." + ) + + arr = np.genfromtxt(path, delimiter=",", names=True, dtype=float) + + if arr.size == 0: + raise ValueError(f"[{self.name}] CSV file is empty.") + if arr.dtype.names is None: + raise ValueError( + f"[{self.name}] CSV must contain a header row with column names." + ) + if self.key not in arr.dtype.names: + raise KeyError( + f"[{self.name}] column '{self.key}' not found in CSV. " + f"Available columns: {list(arr.dtype.names)}" + ) + + col = np.asarray(arr[self.key], dtype=float).reshape(-1, 1) + if np.isnan(col).any(): + raise ValueError( + f"[{self.name}] CSV column '{self.key}' contains non-numeric or missing values." + ) + time = None + if self.use_time: + if "time" not in arr.dtype.names: + raise KeyError( + f"[{self.name}] use_time=True requires CSV column 'time'." + ) + time = np.asarray(arr["time"], dtype=float).reshape(-1) + self._validate_time(time, col.shape[0]) + return col, time + + # ------------------------------------------------------------------ + def _to_bool(self, value: bool | str, name: str) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes"}: + return True + if lowered in {"false", "0", "no"}: + return False + raise ValueError(f"[{self.name}] '{name}' must be a bool.") + + # ------------------------------------------------------------------ + def _infer_file_type(self, file_path: str) -> str: + ext = Path(file_path).suffix.lower().lstrip(".") + if ext not in self.VALID_FILE_TYPES: + raise ValueError( + f"[{self.name}] Unsupported file extension '.{ext}'. " + f"Supported extensions: {sorted(self.VALID_FILE_TYPES)}" + ) + return ext + + # ------------------------------------------------------------------ + def _current_output(self) -> np.ndarray: + n = self._samples.shape[0] + if self._index < n: + idx = self._index + elif self.repeat: + idx = self._index % n + else: + return np.zeros(self._output_shape, dtype=float) + + row = self._samples[idx] + return np.asarray(row, dtype=float).reshape(-1, 1) + + # ------------------------------------------------------------------ + def _current_output_at_time(self, t: float) -> np.ndarray: + if self._time is None: + raise RuntimeError( + f"[{self.name}] Internal error: use_time=True but time data is missing." + ) + + idx = int(np.searchsorted(self._time, t, side="right") - 1) + if idx < 0: + idx = 0 + + row = self._samples[idx] + return np.asarray(row, dtype=float).reshape(-1, 1) + + # ------------------------------------------------------------------ + def _validate_time(self, time: np.ndarray, n_samples: int) -> None: + if time.ndim != 1: + raise ValueError(f"[{self.name}] time must be a 1D array.") + if time.shape[0] != n_samples: + raise ValueError( + f"[{self.name}] time length ({time.shape[0]}) must match number of samples ({n_samples})." + ) + if np.isnan(time).any(): + raise ValueError(f"[{self.name}] time contains NaN values.") + if not np.all(np.diff(time) > 0.0): + raise ValueError( + f"[{self.name}] time must be strictly increasing." + ) diff --git a/pySimBlocks/docs/blocks/sources/file_source.md b/pySimBlocks/docs/blocks/sources/file_source.md new file mode 100644 index 0000000..eadde2a --- /dev/null +++ b/pySimBlocks/docs/blocks/sources/file_source.md @@ -0,0 +1,51 @@ +# FileSource Block + +## Description + +The FileSource block loads a sequence of numeric samples from a file and outputs one sample per simulation step. + +Supported file formats: +- `npz` +- `npy` +- `csv` + +--- + +## Parameters + +| Name | Type | Description | Optional | +|------|------|-------------|----------| +| `file_path` | str | Path to source file. | False | +| `key` | str | Mandatory for `*.npz` (array key) and `*.csv` (column name). Unused for `*.npy`. | True | +| `repeat` | bool | End-of-file behavior. If `false`, outputs zeros after the last sample. If `true`, restarts from the first sample. | True (default: `False`) | +| `use_time` | bool | If `true` (only for `*.npz` and `*.csv`), uses a `time` signal and applies ZOH: at time `t`, output sample at largest index `i` such that `T[i] <= t`. | True (default: `False`) | +| `sample_time` | float | Block sample time. If omitted, global simulation step is used. | True | + +--- + +## Inputs + +None. + +--- + +## Outputs + +| Port | Description | +|------|-------------| +| `out` | Current sample as a column vector. | + +--- + +## Notes + +- File format is inferred from `file_path` extension (`.npz`, `.npy`, `.csv`). +- `npz`: loaded array must be 1D or 2D. +- `npy`: loaded array must be 1D or 2D. +- `csv`: `key` selects one named numeric column, producing shape `(1,1)` at each step. +- With `use_time=true`, `time` must exist and be strictly increasing. + - `npz`: requires key `time`. + - `csv`: requires column `time`. + +--- +© 2026 Université de Lille & INRIA - Licensed under LGPL-3.0-or-later diff --git a/pySimBlocks/gui/blocks/sources/file_source.py b/pySimBlocks/gui/blocks/sources/file_source.py new file mode 100644 index 0000000..35ca298 --- /dev/null +++ b/pySimBlocks/gui/blocks/sources/file_source.py @@ -0,0 +1,97 @@ +# ****************************************************************************** +# 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 Any, Dict + +from pySimBlocks.gui.blocks.block_meta import BlockMeta +from pySimBlocks.gui.blocks.parameter_meta import ParameterMeta +from pySimBlocks.gui.blocks.port_meta import PortMeta + + +class FileSourceMeta(BlockMeta): + + def __init__(self): + self.name = "FileSource" + self.category = "sources" + self.type = "file_source" + self.summary = "Read a sequence of samples from a CSV, NPY, or NPZ file." + self.description = ( + "Loads samples from file and outputs one sample per simulation step.\n\n" + "- `.npz`: reads one array from the archive (`key` mandatory).\n" + "- `.npy`: reads one array from a NPY file (`key` unused).\n" + "- `.csv`: reads one numeric CSV column (`key` = column name).\n\n" + "Each step emits one row as a column vector." + ) + + self.parameters = [ + ParameterMeta( + name="file_path", + type="str", + required=True, + description="Path to the source file." + ), + ParameterMeta( + name="key", + type="str", + description="NPZ array key or CSV column name." + ), + ParameterMeta( + name="repeat", + type="enum", + autofill=True, + default=False, + enum=[False, True], + description="If true, replay samples from the beginning after end of file." + ), + ParameterMeta( + name="use_time", + type="enum", + autofill=True, + default=False, + enum=[False, True], + description="If true (NPZ/CSV), use 'time' data and apply ZOH at simulation time t." + ), + ParameterMeta( + name="sample_time", + type="float", + description="Block execution period." + ), + ] + + self.outputs = [ + PortMeta( + name="out", + display_as="out", + shape=["n", 1], + description="Current output sample." + ) + ] + + def is_parameter_active(self, param_name: str, instance_values: Dict[str, Any]) -> bool: + file_path = str(instance_values.get("file_path", "") or "") + ext = file_path.rsplit(".", 1)[-1].lower() if "." in file_path else "" + + if param_name == "key": + return ext in {"npz", "csv"} + + if param_name == "use_time": + return ext in {"npz", "csv"} + + return super().is_parameter_active(param_name, instance_values) diff --git a/pySimBlocks/gui/dialogs/block_dialog.py b/pySimBlocks/gui/dialogs/block_dialog.py index 65ab64c..2f526c0 100644 --- a/pySimBlocks/gui/dialogs/block_dialog.py +++ b/pySimBlocks/gui/dialogs/block_dialog.py @@ -245,6 +245,9 @@ def _create_param_widget(self, edit.setText(str(value)) elif meta.default: edit.setText(str(meta.default)) + edit.textChanged.connect( + lambda val, name=param_name: self._on_param_changed(name, val) + ) return edit diff --git a/pySimBlocks/project/pySimBlocks_blocks_index.yaml b/pySimBlocks/project/pySimBlocks_blocks_index.yaml index 225a559..6c6e166 100644 --- a/pySimBlocks/project/pySimBlocks_blocks_index.yaml +++ b/pySimBlocks/project/pySimBlocks_blocks_index.yaml @@ -67,6 +67,9 @@ sources: constant: class: Constant module: pySimBlocks.blocks.sources.constant + file_source: + class: FileSource + module: pySimBlocks.blocks.sources.file_source ramp: class: Ramp module: pySimBlocks.blocks.sources.ramp diff --git a/test/blocks/sources/test_file_source.py b/test/blocks/sources/test_file_source.py new file mode 100644 index 0000000..59dad77 --- /dev/null +++ b/test/blocks/sources/test_file_source.py @@ -0,0 +1,185 @@ +from pathlib import Path + +import numpy as np +import pytest + +from pySimBlocks.blocks.sources.file_source import FileSource + + +def test_file_source_npz_single_array(tmp_path: Path): + path = tmp_path / "data.npz" + np.savez(path, y=np.array([[1.0, 2.0], [3.0, 4.0]])) + + blk = FileSource("src", file_path=str(path), key="y") + blk.initialize(0.0) + assert np.allclose(blk.outputs["out"], [[1.0], [2.0]]) + + blk.output_update(0.0, 0.1) + assert np.allclose(blk.outputs["out"], [[1.0], [2.0]]) + + blk.output_update(0.1, 0.1) + assert np.allclose(blk.outputs["out"], [[3.0], [4.0]]) + + +def test_file_source_npz_requires_key(tmp_path: Path): + path = tmp_path / "data.npz" + np.savez(path, a=np.array([1.0]), b=np.array([2.0])) + + with pytest.raises(ValueError): + FileSource("src", file_path=str(path)) + + +def test_file_source_npz_invalid_key(tmp_path: Path): + path = tmp_path / "data.npz" + np.savez(path, a=np.array([1.0])) + + with pytest.raises(KeyError): + FileSource("src", file_path=str(path), key="missing") + + +def test_file_source_csv(tmp_path: Path): + path = tmp_path / "data.csv" + path.write_text("a,b\n1.0,2.0\n3.0,4.0\n", encoding="utf-8") + + blk = FileSource("src", file_path=str(path), key="b") + blk.initialize(0.0) + assert np.allclose(blk.outputs["out"], [[2.0]]) + + blk.output_update(0.0, 0.1) + assert np.allclose(blk.outputs["out"], [[2.0]]) + + blk.output_update(0.1, 0.1) + assert np.allclose(blk.outputs["out"], [[4.0]]) + + +def test_file_source_repeat_false_outputs_zeros_after_end(tmp_path: Path): + path = tmp_path / "data.npz" + np.savez(path, y=np.array([[5.0], [7.0]])) + + blk = FileSource( + "src", + file_path=str(path), + key="y", + repeat=False, + ) + blk.initialize(0.0) + assert np.allclose(blk.outputs["out"], [[5.0]]) + + blk.output_update(0.0, 0.1) + assert np.allclose(blk.outputs["out"], [[5.0]]) + + blk.output_update(0.1, 0.1) + assert np.allclose(blk.outputs["out"], [[7.0]]) + + blk.output_update(0.2, 0.1) + assert np.allclose(blk.outputs["out"], [[0.0]]) + + +def test_file_source_repeat_true_restarts_after_end(tmp_path: Path): + path = tmp_path / "data.npy" + np.save(path, np.array([[10.0], [20.0]])) + + blk = FileSource( + "src", + file_path=str(path), + repeat=True, + ) + blk.initialize(0.0) + assert np.allclose(blk.outputs["out"], [[10.0]]) + + blk.output_update(0.0, 0.1) + assert np.allclose(blk.outputs["out"], [[10.0]]) + + blk.output_update(0.1, 0.1) + assert np.allclose(blk.outputs["out"], [[20.0]]) + + blk.output_update(0.2, 0.1) + assert np.allclose(blk.outputs["out"], [[10.0]]) + + +def test_file_source_npy_key_not_allowed(tmp_path: Path): + path = tmp_path / "data.npy" + np.save(path, np.array([1.0, 2.0])) + + with pytest.raises(ValueError): + FileSource("src", file_path=str(path), key="k") + + +def test_file_source_npy_use_time_not_allowed(tmp_path: Path): + path = tmp_path / "data.npy" + np.save(path, np.array([1.0, 2.0])) + + with pytest.raises(ValueError): + FileSource("src", file_path=str(path), use_time=True) + + +def test_file_source_csv_missing_key(tmp_path: Path): + path = tmp_path / "data.csv" + path.write_text("a,b\n1.0,2.0\n", encoding="utf-8") + + with pytest.raises(ValueError): + FileSource("src", file_path=str(path)) + + +def test_file_source_invalid_extension(tmp_path: Path): + path = tmp_path / "data.txt" + path.write_text("1.0\n", encoding="utf-8") + with pytest.raises(ValueError): + FileSource("src", file_path=str(path)) + + +def test_file_source_missing_file(): + with pytest.raises(FileNotFoundError): + FileSource("src", file_path="does-not-exist.npz") + + +def test_file_source_npz_use_time_zoh(tmp_path: Path): + path = tmp_path / "data.npz" + t = np.array([0.0, 0.2, 0.5], dtype=float) + y = np.array([[10.0], [20.0], [50.0]], dtype=float) + np.savez(path, time=t, y=y) + + blk = FileSource("src", file_path=str(path), key="y", use_time=True) + + blk.initialize(0.0) + assert np.allclose(blk.outputs["out"], [[10.0]]) + + blk.output_update(0.19, 0.1) + assert np.allclose(blk.outputs["out"], [[10.0]]) + + blk.output_update(0.20, 0.1) + assert np.allclose(blk.outputs["out"], [[20.0]]) + + blk.output_update(0.8, 0.1) + assert np.allclose(blk.outputs["out"], [[50.0]]) + + +def test_file_source_csv_use_time_zoh(tmp_path: Path): + path = tmp_path / "data.csv" + path.write_text( + "time,y\n0.0,1.0\n0.5,2.0\n1.0,3.0\n", + encoding="utf-8", + ) + + blk = FileSource("src", file_path=str(path), key="y", use_time=True) + blk.initialize(0.1) + assert np.allclose(blk.outputs["out"], [[1.0]]) + + blk.output_update(0.75, 0.1) + assert np.allclose(blk.outputs["out"], [[2.0]]) + + +def test_file_source_npz_time_must_be_strictly_increasing(tmp_path: Path): + path = tmp_path / "data.npz" + np.savez(path, time=np.array([0.0, 0.2, 0.2]), y=np.array([1.0, 2.0, 3.0])) + + with pytest.raises(ValueError): + FileSource("src", file_path=str(path), key="y", use_time=True) + + +def test_file_source_csv_use_time_requires_time_column(tmp_path: Path): + path = tmp_path / "data.csv" + path.write_text("y\n1.0\n2.0\n", encoding="utf-8") + + with pytest.raises(KeyError): + FileSource("src", file_path=str(path), key="y", use_time=True) diff --git a/test/gui/test_file_source_meta.py b/test/gui/test_file_source_meta.py new file mode 100644 index 0000000..939374d --- /dev/null +++ b/test/gui/test_file_source_meta.py @@ -0,0 +1,17 @@ +from pySimBlocks.gui.blocks.sources.file_source import FileSourceMeta + + +def test_file_source_meta_conditional_parameters(): + meta = FileSourceMeta() + + npz_values = {"file_path": "data.npz"} + assert meta.is_parameter_active("key", npz_values) is True + assert meta.is_parameter_active("use_time", npz_values) is True + + npy_values = {"file_path": "data.npy"} + assert meta.is_parameter_active("key", npy_values) is False + assert meta.is_parameter_active("use_time", npy_values) is False + + csv_values = {"file_path": "data.csv"} + assert meta.is_parameter_active("key", csv_values) is True + assert meta.is_parameter_active("use_time", csv_values) is True