Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/signaloid-python.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
args:
- host: ubuntu-22.04
- host: macos-14
python-version: ["3.10", "3.11", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

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

Expand Down
755 changes: 524 additions & 231 deletions poetry.lock

Large diffs are not rendered by default.

21 changes: 15 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,30 @@ style = "pep440"
[tool.poetry.group.dev.dependencies]
wheel = "^0.46.3"
types-toml = "^0.10.8.20240310"
pytest = "^8.4.2"
pytest = "^9.0.2"
mypy = "^1.19.1"
flake8 = "^7.3.0"
toml = "^0.10.2"
black = {version = "^25.11.0", python = ">=3.10"}
black = "^26.3.1"

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

[tool.poetry.dependencies]
python = ">=3.10,<3.14"
numpy = "~2.0.2"
matplotlib = "~3.9.4"
Pillow = "~12.1.1"
python = ">=3.10"
numpy = [
{ version = ">=2.0.0", python = ">=3.10,<3.13" },
{ version = ">=2.1.0", python = ">=3.10,<3.14" },
{ version = ">=2.4.1", python = ">=3.11,<3.15" },
]
matplotlib = [
{ version = ">=3.9.0", python = ">=3.10,<3.13" },
{ version = ">=3.10.0", python = ">=3.10,<3.14" },
{ version = ">=3.10.5", python = ">=3.10,<3.15" },
]
pillow = ">=12.1.1"
pyyaml = ">=6.0.0"

[tool.poetry.scripts]
signaloid-uxdata-toolkit = "signaloid.uxdata_toolkit:main"
Expand Down
1 change: 0 additions & 1 deletion src/signaloid/circuitpython/plot_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
PlotData,
)


# The background color of the plot
BG_COLOR = 0xFFFFFF
BG_COLOR_STR = f"{BG_COLOR:06x}"
Expand Down
30 changes: 30 additions & 0 deletions src/signaloid/distributional/distributional.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,36 @@ def has_special_values(self) -> bool:
or self.pos_inf_dirac_delta.mass > 0
)

@classmethod
def from_samples(cls, samples: np.ndarray | list[float]) -> "DistributionalValue":
"""
Construct a DistributionalValue from an array of float samples.

Each sample becomes a Dirac delta with equal mass (1 / n_total).
Non-finite values (NaN, -Inf, +Inf) are included and will be
separated by the sort() method when the DistributionalValue is
processed.

Args:
samples: 1-D array of float samples (may contain NaN/Inf).

Returns:
A DistributionalValue instance.

Raises:
ValueError: If the samples array is empty.
"""
samples = np.asarray(samples, dtype=np.float64)
n_total = len(samples)
if n_total == 0:
raise ValueError("samples array must not be empty.")

mass_per_sample = 1.0 / n_total
dirac_deltas = [DiracDelta(float(s), mass=mass_per_sample) for s in samples]

dist = cls(dirac_deltas=dirac_deltas)
return dist

def __repr__(self) -> str:
"""Constructs the representation type for the `DistributionalValue`.

Expand Down
75 changes: 72 additions & 3 deletions src/signaloid/distributional/distributional_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.


from __future__ import annotations

import csv
import os
import unittest

import numpy as np
from signaloid.distributional.dirac_delta import DiracDelta
from signaloid.distributional.distributional import DistributionalValue

Expand All @@ -36,8 +41,13 @@ def read_string_bytes_pairs_from_csv(
Returns:
pairs: list of tuples of (string_value, bytearray_value)
"""
__location__ = os.path.realpath(
os.path.join(os.getcwd(), os.path.dirname(__file__))
)
csv_filepath = os.path.join(__location__, csv_filename)

pairs: list[tuple[str, bytes]] = []
with open(csv_filename, "r") as csvfile:
with open(csv_filepath, "r") as csvfile:
reader = csv.reader(csvfile)
for row in reader:
if len(row) == 2:
Expand All @@ -50,7 +60,7 @@ def read_string_bytes_pairs_from_csv(
class TestUxParsing(unittest.TestCase):
def test_parse_ux_strings_values(
self,
input_filename: str = "src/signaloid/distributional/test_ux_value_pairs.csv",
input_filename: str = "./test_ux_value_pairs.csv",
) -> None:
"""
Test parsing Ux string values and converting them to Ux bytes
Expand All @@ -70,7 +80,7 @@ def test_parse_ux_strings_values(

def test_parse_ux_bytes_values(
self,
input_filename: str = "src/signaloid/distributional/test_ux_value_pairs.csv",
input_filename: str = "./test_ux_value_pairs.csv",
) -> None:
"""
Test parsing Ux bytes and converting them to Ux strings
Expand Down Expand Up @@ -359,5 +369,64 @@ def test_check_is_full_valid_TTR(self):
self.assertTrue(dist.check_is_full_valid_TTR())


class TestDistributionalValueFromSamples(unittest.TestCase):
"""Tests for DistributionalValue.from_samples()."""

def test_basic_finite_samples(self) -> None:
"""from_samples should produce a valid DistributionalValue."""
np.random.seed(42)
samples = np.random.normal(0, 1, 100)
dist = DistributionalValue.from_samples(samples)

self.assertEqual(dist.UR_order, 100)
self.assertIsNotNone(dist.mean)
self.assertFalse(dist.has_special_values)

def test_special_values_separated(self) -> None:
"""NaN, -Inf, +Inf should be separated after sort()."""
samples = np.array(
[1.0, 2.0, 3.0, np.nan, np.nan, -np.inf, np.inf, np.inf, np.inf, 4.0]
)
dist = DistributionalValue.from_samples(samples)
dist.sort()

self.assertTrue(dist.has_special_values)
self.assertAlmostEqual(dist.nan_dirac_delta.mass, 2 / 10)
self.assertAlmostEqual(dist.neg_inf_dirac_delta.mass, 1 / 10)
self.assertAlmostEqual(dist.pos_inf_dirac_delta.mass, 3 / 10)

def test_equal_mass_dirac_deltas(self) -> None:
"""Each sample should become a Dirac delta with mass 1/n."""
samples = [1.0, 2.0, 3.0, 4.0]
dist = DistributionalValue.from_samples(samples)

for dd in dist.dirac_deltas:
self.assertAlmostEqual(dd.mass, 0.25)

def test_empty_samples_raises(self) -> None:
"""An empty array should raise ValueError."""
with self.assertRaises(ValueError):
DistributionalValue.from_samples(np.array([]))

def test_all_nan_samples(self) -> None:
"""All-NaN samples should have nan_mass == 1 after sort."""
dist = DistributionalValue.from_samples(np.full(50, np.nan))
dist.sort()

self.assertTrue(dist.has_special_values)
self.assertAlmostEqual(dist.nan_dirac_delta.mass, 1.0)
self.assertEqual(len(dist.finite_dirac_deltas), 0)

def test_all_identical_samples(self) -> None:
"""All-identical samples should combine to one Dirac delta."""
dist = DistributionalValue.from_samples(np.full(100, 3.14))
dist.combine_dirac_deltas()

finite = dist.finite_dirac_deltas
self.assertEqual(len(finite), 1)
self.assertAlmostEqual(finite[0].position, 3.14)
self.assertAlmostEqual(finite[0].mass, 1.0)


if __name__ == "__main__":
unittest.main()
101 changes: 99 additions & 2 deletions src/signaloid/distributional_information_plotting/README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,99 @@
### `PlotHistogramDiracDeltas` class:
Histogram plotter class. Exposes `plot_histogram_dirac_deltas()`, which takes a `DistributionalValue` list and plots each DistributionalValue as a histogram.
# Distributional Information Plotting

Tools for visualising and sampling from Signaloid distributional data.

## Plotting a Ux-string

Parse a Ux-encoded string into a `DistributionalValue`, build a `PlotData` object, and pass it to `plot()`:

```python
from signaloid.distributional.distributional import DistributionalValue
from signaloid.distributional_information_plotting.plot_histogram_dirac_deltas import PlotData
from signaloid.distributional_information_plotting.plot_wrapper import plot

ux_string = "0.40007Ux0000000000000000013FD99AC12423C7C7000000013FD99AC12423C7C78000000000000000"

dist_value = DistributionalValue.parse(ux_string)
if dist_value is None:
raise ValueError(f"Failed to parse Ux string: {ux_string}")

plot_data = PlotData(dist_value)

# Display interactively
plot(plot_data)

# Or save to a file
plot(plot_data, path="output.png", save=True)
```

## Plotting from raw float samples

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`:

```python
import numpy as np
from signaloid.distributional.distributional import DistributionalValue
from signaloid.distributional_information_plotting.plot_histogram_dirac_deltas import PlotData
from signaloid.distributional_information_plotting.plot_wrapper import plot

samples = np.random.normal(0, 1, 10_000)

dist_value = DistributionalValue.from_samples(samples)
plot_data = PlotData(dist_value)
plot(plot_data, path="output.png", save=True)
```

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.

## Customising the plot

The `plot()` function accepts several optional parameters:

```python
plot(
plot_data,
path="output.png", # Output file path
save=True, # Save to file (False = show interactively)
plot_expected_value_line=True, # Vertical line at the mean
x_lim=(-5, 5), # Custom x-axis limits
y_lim=(0, 0.5), # Custom y-axis limits
x_label="My Variable", # Custom x-axis label
x_tick_label_rotation=45, # Rotate x-axis tick labels
font_size=20, # Font size for labels
matplotlib_rc_params_override={...}, # Custom matplotlib rc params
)
```

## Sampling from a Ux-string

Generate random samples from a Ux-encoded distribution:

```python
from signaloid.distributional_information_plotting.sample_generator import sample_generator

ux_string = "0.40007Ux0000000000000000013FD99AC12423C7C7000000013FD99AC12423C7C78000000000000000"

samples = sample_generator(ux_string, n_samples=1000)
```

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.

## CLI usage

These tools are also available via the `signaloid-uxdata-toolkit` command-line interface:

```bash
# Plot a distribution
signaloid-uxdata-toolkit plot --ux-data=0.40007Ux0000000000000000013FD99AC12423C7C7000000013FD99AC12423C7C78000000000000000

# Save plot to file
signaloid-uxdata-toolkit plot -o output.png --ux-data=0.40007Ux...

# Generate samples
signaloid-uxdata-toolkit sample --ux-data=0.40007Ux... --num-samples 100

# Save samples to file
signaloid-uxdata-toolkit sample -o samples.txt --ux-data=0.40007Ux... --num-samples 100
```

> **Note:** Use `=` syntax (`--ux-data=...`) for Ux-strings that start with `-`, otherwise the shell may interpret them as flags.
Loading
Loading