Skip to content

Commit 49369de

Browse files
Merge pull request #14 from signaloid/version-update
Updated to version `1.8.0`
2 parents 6d7aee8 + 188a1db commit 49369de

14 files changed

Lines changed: 996 additions & 309 deletions

.github/workflows/signaloid-python.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
args:
2121
- host: ubuntu-22.04
2222
- host: macos-14
23-
python-version: ["3.10", "3.11", "3.13"]
23+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
2424

2525
runs-on: ${{ matrix.args.host }}
2626

poetry.lock

Lines changed: 524 additions & 231 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,30 @@ style = "pep440"
1919
[tool.poetry.group.dev.dependencies]
2020
wheel = "^0.46.3"
2121
types-toml = "^0.10.8.20240310"
22-
pytest = "^8.4.2"
22+
pytest = "^9.0.2"
2323
mypy = "^1.19.1"
2424
flake8 = "^7.3.0"
2525
toml = "^0.10.2"
26-
black = {version = "^25.11.0", python = ">=3.10"}
26+
black = "^26.3.1"
2727

2828
[build-system]
2929
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
3030
build-backend = "poetry_dynamic_versioning.backend"
3131

3232
[tool.poetry.dependencies]
33-
python = ">=3.10,<3.14"
34-
numpy = "~2.0.2"
35-
matplotlib = "~3.9.4"
36-
Pillow = "~12.1.1"
33+
python = ">=3.10"
34+
numpy = [
35+
{ version = ">=2.0.0", python = ">=3.10,<3.13" },
36+
{ version = ">=2.1.0", python = ">=3.10,<3.14" },
37+
{ version = ">=2.4.1", python = ">=3.11,<3.15" },
38+
]
39+
matplotlib = [
40+
{ version = ">=3.9.0", python = ">=3.10,<3.13" },
41+
{ version = ">=3.10.0", python = ">=3.10,<3.14" },
42+
{ version = ">=3.10.5", python = ">=3.10,<3.15" },
43+
]
44+
pillow = ">=12.1.1"
45+
pyyaml = ">=6.0.0"
3746

3847
[tool.poetry.scripts]
3948
signaloid-uxdata-toolkit = "signaloid.uxdata_toolkit:main"

src/signaloid/circuitpython/plot_wrapper.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
PlotData,
3939
)
4040

41-
4241
# The background color of the plot
4342
BG_COLOR = 0xFFFFFF
4443
BG_COLOR_STR = f"{BG_COLOR:06x}"

src/signaloid/distributional/distributional.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,36 @@ def has_special_values(self) -> bool:
256256
or self.pos_inf_dirac_delta.mass > 0
257257
)
258258

259+
@classmethod
260+
def from_samples(cls, samples: np.ndarray | list[float]) -> "DistributionalValue":
261+
"""
262+
Construct a DistributionalValue from an array of float samples.
263+
264+
Each sample becomes a Dirac delta with equal mass (1 / n_total).
265+
Non-finite values (NaN, -Inf, +Inf) are included and will be
266+
separated by the sort() method when the DistributionalValue is
267+
processed.
268+
269+
Args:
270+
samples: 1-D array of float samples (may contain NaN/Inf).
271+
272+
Returns:
273+
A DistributionalValue instance.
274+
275+
Raises:
276+
ValueError: If the samples array is empty.
277+
"""
278+
samples = np.asarray(samples, dtype=np.float64)
279+
n_total = len(samples)
280+
if n_total == 0:
281+
raise ValueError("samples array must not be empty.")
282+
283+
mass_per_sample = 1.0 / n_total
284+
dirac_deltas = [DiracDelta(float(s), mass=mass_per_sample) for s in samples]
285+
286+
dist = cls(dirac_deltas=dirac_deltas)
287+
return dist
288+
259289
def __repr__(self) -> str:
260290
"""Constructs the representation type for the `DistributionalValue`.
261291

src/signaloid/distributional/distributional_test.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@
1818
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
1919
# DEALINGS IN THE SOFTWARE.
2020

21+
2122
from __future__ import annotations
23+
2224
import csv
25+
import os
2326
import unittest
27+
28+
import numpy as np
2429
from signaloid.distributional.dirac_delta import DiracDelta
2530
from signaloid.distributional.distributional import DistributionalValue
2631

@@ -36,8 +41,13 @@ def read_string_bytes_pairs_from_csv(
3641
Returns:
3742
pairs: list of tuples of (string_value, bytearray_value)
3843
"""
44+
__location__ = os.path.realpath(
45+
os.path.join(os.getcwd(), os.path.dirname(__file__))
46+
)
47+
csv_filepath = os.path.join(__location__, csv_filename)
48+
3949
pairs: list[tuple[str, bytes]] = []
40-
with open(csv_filename, "r") as csvfile:
50+
with open(csv_filepath, "r") as csvfile:
4151
reader = csv.reader(csvfile)
4252
for row in reader:
4353
if len(row) == 2:
@@ -50,7 +60,7 @@ def read_string_bytes_pairs_from_csv(
5060
class TestUxParsing(unittest.TestCase):
5161
def test_parse_ux_strings_values(
5262
self,
53-
input_filename: str = "src/signaloid/distributional/test_ux_value_pairs.csv",
63+
input_filename: str = "./test_ux_value_pairs.csv",
5464
) -> None:
5565
"""
5666
Test parsing Ux string values and converting them to Ux bytes
@@ -70,7 +80,7 @@ def test_parse_ux_strings_values(
7080

7181
def test_parse_ux_bytes_values(
7282
self,
73-
input_filename: str = "src/signaloid/distributional/test_ux_value_pairs.csv",
83+
input_filename: str = "./test_ux_value_pairs.csv",
7484
) -> None:
7585
"""
7686
Test parsing Ux bytes and converting them to Ux strings
@@ -359,5 +369,64 @@ def test_check_is_full_valid_TTR(self):
359369
self.assertTrue(dist.check_is_full_valid_TTR())
360370

361371

372+
class TestDistributionalValueFromSamples(unittest.TestCase):
373+
"""Tests for DistributionalValue.from_samples()."""
374+
375+
def test_basic_finite_samples(self) -> None:
376+
"""from_samples should produce a valid DistributionalValue."""
377+
np.random.seed(42)
378+
samples = np.random.normal(0, 1, 100)
379+
dist = DistributionalValue.from_samples(samples)
380+
381+
self.assertEqual(dist.UR_order, 100)
382+
self.assertIsNotNone(dist.mean)
383+
self.assertFalse(dist.has_special_values)
384+
385+
def test_special_values_separated(self) -> None:
386+
"""NaN, -Inf, +Inf should be separated after sort()."""
387+
samples = np.array(
388+
[1.0, 2.0, 3.0, np.nan, np.nan, -np.inf, np.inf, np.inf, np.inf, 4.0]
389+
)
390+
dist = DistributionalValue.from_samples(samples)
391+
dist.sort()
392+
393+
self.assertTrue(dist.has_special_values)
394+
self.assertAlmostEqual(dist.nan_dirac_delta.mass, 2 / 10)
395+
self.assertAlmostEqual(dist.neg_inf_dirac_delta.mass, 1 / 10)
396+
self.assertAlmostEqual(dist.pos_inf_dirac_delta.mass, 3 / 10)
397+
398+
def test_equal_mass_dirac_deltas(self) -> None:
399+
"""Each sample should become a Dirac delta with mass 1/n."""
400+
samples = [1.0, 2.0, 3.0, 4.0]
401+
dist = DistributionalValue.from_samples(samples)
402+
403+
for dd in dist.dirac_deltas:
404+
self.assertAlmostEqual(dd.mass, 0.25)
405+
406+
def test_empty_samples_raises(self) -> None:
407+
"""An empty array should raise ValueError."""
408+
with self.assertRaises(ValueError):
409+
DistributionalValue.from_samples(np.array([]))
410+
411+
def test_all_nan_samples(self) -> None:
412+
"""All-NaN samples should have nan_mass == 1 after sort."""
413+
dist = DistributionalValue.from_samples(np.full(50, np.nan))
414+
dist.sort()
415+
416+
self.assertTrue(dist.has_special_values)
417+
self.assertAlmostEqual(dist.nan_dirac_delta.mass, 1.0)
418+
self.assertEqual(len(dist.finite_dirac_deltas), 0)
419+
420+
def test_all_identical_samples(self) -> None:
421+
"""All-identical samples should combine to one Dirac delta."""
422+
dist = DistributionalValue.from_samples(np.full(100, 3.14))
423+
dist.combine_dirac_deltas()
424+
425+
finite = dist.finite_dirac_deltas
426+
self.assertEqual(len(finite), 1)
427+
self.assertAlmostEqual(finite[0].position, 3.14)
428+
self.assertAlmostEqual(finite[0].mass, 1.0)
429+
430+
362431
if __name__ == "__main__":
363432
unittest.main()
Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,99 @@
1-
### `PlotHistogramDiracDeltas` class:
2-
Histogram plotter class. Exposes `plot_histogram_dirac_deltas()`, which takes a `DistributionalValue` list and plots each DistributionalValue as a histogram.
1+
# Distributional Information Plotting
2+
3+
Tools for visualising and sampling from Signaloid distributional data.
4+
5+
## Plotting a Ux-string
6+
7+
Parse a Ux-encoded string into a `DistributionalValue`, build a `PlotData` object, and pass it to `plot()`:
8+
9+
```python
10+
from signaloid.distributional.distributional import DistributionalValue
11+
from signaloid.distributional_information_plotting.plot_histogram_dirac_deltas import PlotData
12+
from signaloid.distributional_information_plotting.plot_wrapper import plot
13+
14+
ux_string = "0.40007Ux0000000000000000013FD99AC12423C7C7000000013FD99AC12423C7C78000000000000000"
15+
16+
dist_value = DistributionalValue.parse(ux_string)
17+
if dist_value is None:
18+
raise ValueError(f"Failed to parse Ux string: {ux_string}")
19+
20+
plot_data = PlotData(dist_value)
21+
22+
# Display interactively
23+
plot(plot_data)
24+
25+
# Or save to a file
26+
plot(plot_data, path="output.png", save=True)
27+
```
28+
29+
## Plotting from raw float samples
30+
31+
If you already have an array of float samples (e.g. from Monte Carlo simulation), use `DistributionalValue.from_samples()` to build a distributional value and then pass it to `PlotData`:
32+
33+
```python
34+
import numpy as np
35+
from signaloid.distributional.distributional import DistributionalValue
36+
from signaloid.distributional_information_plotting.plot_histogram_dirac_deltas import PlotData
37+
from signaloid.distributional_information_plotting.plot_wrapper import plot
38+
39+
samples = np.random.normal(0, 1, 10_000)
40+
41+
dist_value = DistributionalValue.from_samples(samples)
42+
plot_data = PlotData(dist_value)
43+
plot(plot_data, path="output.png", save=True)
44+
```
45+
46+
Non-finite values (`NaN`, `-Inf`, `+Inf`) in the samples array are automatically separated and displayed in a dedicated special-values panel alongside the main histogram.
47+
48+
## Customising the plot
49+
50+
The `plot()` function accepts several optional parameters:
51+
52+
```python
53+
plot(
54+
plot_data,
55+
path="output.png", # Output file path
56+
save=True, # Save to file (False = show interactively)
57+
plot_expected_value_line=True, # Vertical line at the mean
58+
x_lim=(-5, 5), # Custom x-axis limits
59+
y_lim=(0, 0.5), # Custom y-axis limits
60+
x_label="My Variable", # Custom x-axis label
61+
x_tick_label_rotation=45, # Rotate x-axis tick labels
62+
font_size=20, # Font size for labels
63+
matplotlib_rc_params_override={...}, # Custom matplotlib rc params
64+
)
65+
```
66+
67+
## Sampling from a Ux-string
68+
69+
Generate random samples from a Ux-encoded distribution:
70+
71+
```python
72+
from signaloid.distributional_information_plotting.sample_generator import sample_generator
73+
74+
ux_string = "0.40007Ux0000000000000000013FD99AC12423C7C7000000013FD99AC12423C7C78000000000000000"
75+
76+
samples = sample_generator(ux_string, n_samples=1000)
77+
```
78+
79+
Distributions that contain non-finite Dirac deltas (`NaN`, `-Inf`, `+Inf`) are handled via mixture sampling: each sample is drawn from either the finite part (via inverse CDF) or the non-finite part (categorically), proportional to their respective masses.
80+
81+
## CLI usage
82+
83+
These tools are also available via the `signaloid-uxdata-toolkit` command-line interface:
84+
85+
```bash
86+
# Plot a distribution
87+
signaloid-uxdata-toolkit plot --ux-data=0.40007Ux0000000000000000013FD99AC12423C7C7000000013FD99AC12423C7C78000000000000000
88+
89+
# Save plot to file
90+
signaloid-uxdata-toolkit plot -o output.png --ux-data=0.40007Ux...
91+
92+
# Generate samples
93+
signaloid-uxdata-toolkit sample --ux-data=0.40007Ux... --num-samples 100
94+
95+
# Save samples to file
96+
signaloid-uxdata-toolkit sample -o samples.txt --ux-data=0.40007Ux... --num-samples 100
97+
```
98+
99+
> **Note:** Use `=` syntax (`--ux-data=...`) for Ux-strings that start with `-`, otherwise the shell may interpret them as flags.

0 commit comments

Comments
 (0)