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