From 873ebe3d6f84d4d7acd9028184bd146175296c77 Mon Sep 17 00:00:00 2001 From: Aleksandr Motsjonov Date: Sun, 18 Oct 2026 19:51:46 +1100 Subject: [PATCH] [python] add bindings for rta::core::Spectrum Expose spectral curves so Python callers can construct, resample, and combine them, and read channels from loaded spectral data. Assisted-by: Cursor / Grok 4.7 Assisted-by: Cursor / Claude Opus 5.5 Signed-off-by: Aleksandr Motsjonov Co-authored-by: Cursor --- src/bindings/py_core.cpp | 325 +++++++++++++++++++++++- src/docs/api/python/api_reference.rst | 3 + src/docs/api/python/image_converter.rst | 2 +- src/docs/api/python/index.rst | 3 +- src/docs/api/python/rawtoaces.py | 249 +++++++++++++++++- src/docs/api/python/rawtoaces_core.rst | 31 +++ tests/python/test_core_bindings.py | 168 +++++++++++- 7 files changed, 773 insertions(+), 8 deletions(-) diff --git a/src/bindings/py_core.cpp b/src/bindings/py_core.cpp index d0a709f9..c146cf56 100644 --- a/src/bindings/py_core.cpp +++ b/src/bindings/py_core.cpp @@ -2,15 +2,266 @@ // Copyright Contributors to the rawtoaces Project. #include "py_core.h" +#include #include #include #include +#include +#include #include #include "../misc/pragma.h" using namespace rta::core; using namespace nanobind::literals; +namespace +{ + +// Arithmetic asserts on mismatched curves. Raise ValueError for Python. +void require_same_sampling( const Spectrum &lhs, const Spectrum &rhs ) +{ + if ( !( lhs.shape == rhs.shape ) || lhs.values.size() != rhs.values.size() ) + { + throw std::invalid_argument( + "Spectrum operands must have the same shape and sample count." ); + } +} + +// A positive step makes the C++ constructor cast the sample count to +// size_t. Reject shapes where that cast would be undefined. +void require_valid_shape( const Spectrum::Shape &shape ) +{ + if ( !( shape.step > 0 ) ) + { + return; + } + + if ( !std::isfinite( shape.first ) || !std::isfinite( shape.last ) || + !std::isfinite( shape.step ) || shape.last < shape.first ) + { + throw std::invalid_argument( + "Spectrum shape with a positive step must use finite " + "wavelengths and last >= first." ); + } +} + +} // namespace + +void bind_spectrum( nanobind::module_ &m ) +{ + nanobind::class_ spectrum( m, "Spectrum", R"""( + A regularly sampled spectral curve. + + Corresponds to :cpp:struct:`rta::core::Spectrum`. Per-element + arithmetic requires both curves to share a shape and sample count. + )""" ); + + nanobind::class_ shape( spectrum, "Shape", R"""( + Regular wavelength sampling, in nanometers. + + ``first`` and ``last`` are the endpoint wavelengths and ``step`` is + the spacing. Shapes compare equal when all three values match. + )""" ); + shape.def( + nanobind::init(), + "first"_a = 0.f, + "last"_a = 0.f, + "step"_a = 0.f, + R"""( + Create a sampling description. + + :param first: Wavelength of the first sample, in nanometers. + :param last: Wavelength of the last sample, in nanometers. + :param step: Spacing between samples, in nanometers. A zero step + describes an empty curve. + )""" ); + shape.def( nanobind::init(), "other"_a, R"""( + Copy constructor. + )""" ); + shape.def_rw( "first", &Spectrum::Shape::first, R"""( + Wavelength of the first sample, in nanometers. + )""" ); + shape.def_rw( "last", &Spectrum::Shape::last, R"""( + Wavelength of the last sample, in nanometers. + )""" ); + shape.def_rw( "step", &Spectrum::Shape::step, R"""( + Spacing between samples, in nanometers. + )""" ); + shape.def( nanobind::self == nanobind::self, R"""( + Return whether ``first``, ``last``, and ``step`` all match. + )""" ); + // Equality is by value, and a shape is mutable, so it cannot be hashed. + shape.attr( "__hash__" ) = nanobind::none(); + + spectrum.def( + "__init__", + []( Spectrum *self, double value, const Spectrum::Shape &shape ) { + require_valid_shape( shape ); + new ( self ) Spectrum( value, shape ); + }, + "value"_a = 0.0, + "shape"_a = Spectrum::ReferenceShape, + R"""( + Create a curve and fill every sample with ``value``. + + :param value: Initial value of every sample. + :param shape: Sampling of the new curve. Defaults to + ``Spectrum.ReferenceShape``. Pass ``Spectrum.EmptyShape`` to + allocate no samples. + :raises ValueError: If ``step`` is positive and the wavelengths are + non-finite or ``last`` is less than ``first``. + )""" ); + spectrum.def( nanobind::init(), "other"_a, R"""( + Copy constructor. + )""" ); + spectrum.def_prop_ro_static( + "ReferenceShape", + []( nanobind::handle ) { return Spectrum::ReferenceShape; }, + R"""( + Built-in sampling used by rawtoaces, from 380 nm through 780 nm in + 5 nm steps. Each access returns a new copy. + )""" ); + spectrum.def_prop_ro_static( + "EmptyShape", + []( nanobind::handle ) { return Spectrum::EmptyShape; }, + R"""( + Empty sampling (zero step) used to construct a curve with no samples. + Each access returns a new copy. + )""" ); + spectrum.def_rw( "shape", &Spectrum::shape, R"""( + Wavelength sampling of this curve, in nanometers. Assigning a shape + records that sampling and leaves ``values`` unchanged, so the sample + count can disagree with the shape. ``reshape()`` resamples onto + ``ReferenceShape`` from the current samples and does not check that + the sample count matches the shape. + )""" ); + spectrum.def_rw( "values", &Spectrum::values, R"""( + Spectral samples. Reading returns a new list, so assigning to an + index of that list leaves the curve unchanged. Assign a sequence to + replace the samples. + )""" ); + spectrum.def( + "reshape", + []( Spectrum &curve ) { + if ( curve.values.empty() ) + { + throw std::invalid_argument( + "Cannot reshape a spectrum with no samples." ); + } + curve.reshape(); + }, + R"""( + Resample this curve onto ``ReferenceShape`` with linear interpolation. + + Wavelengths outside the current range are filled from the nearest + endpoint sample. A curve whose shape already equals + ``ReferenceShape`` is left unchanged, including when its sample count + does not match that shape. + + :raises ValueError: If the curve has no samples. + )""" ); + spectrum.def( "integrate", &Spectrum::integrate, R"""( + Sum every sample. + + :return: The sum of ``values``, or ``0`` when the curve is empty. + )""" ); + spectrum.def( "max", &Spectrum::max, R"""( + Return the largest sample, or ``0`` when ``values`` is empty. + )""" ); + + auto bind_binary = [&]( const char *name, auto op, const char *doc ) { + spectrum.def( + name, + [op]( const Spectrum &lhs, const Spectrum &rhs ) { + require_same_sampling( lhs, rhs ); + return op( lhs, rhs ); + }, + nanobind::is_operator(), + doc ); + }; + auto bind_inplace = [&]( const char *name, auto op, const char *doc ) { + spectrum.def( + name, + [op]( Spectrum &lhs, const Spectrum &rhs ) -> Spectrum & { + require_same_sampling( lhs, rhs ); + op( lhs, rhs ); + return lhs; + }, + nanobind::is_operator(), + nanobind::rv_policy::reference, + doc ); + }; + + bind_binary( "__add__", std::plus<>(), R"""( + Per-element addition. + + :param other: Curve with the same shape and sample count. + :return: A new curve. + :raises ValueError: If the operands differ in shape or sample count. + )""" ); + bind_binary( "__sub__", std::minus<>(), R"""( + Per-element subtraction. + + :param other: Curve with the same shape and sample count. + :return: A new curve. + :raises ValueError: If the operands differ in shape or sample count. + )""" ); + bind_binary( "__mul__", std::multiplies<>(), R"""( + Per-element multiplication. + + :param other: Curve with the same shape and sample count. + :return: A new curve. + :raises ValueError: If the operands differ in shape or sample count. + )""" ); + bind_binary( "__truediv__", std::divides<>(), R"""( + Per-element division. + + :param other: Curve with the same shape and sample count. + :return: A new curve. + :raises ValueError: If the operands differ in shape or sample count. + )""" ); + bind_inplace( + "__iadd__", + []( Spectrum &lhs, const Spectrum &rhs ) { lhs += rhs; }, + R"""( + Per-element addition in place. + + :param other: Curve with the same shape and sample count. + :return: This curve. + :raises ValueError: If the operands differ in shape or sample count. + )""" ); + bind_inplace( + "__isub__", + []( Spectrum &lhs, const Spectrum &rhs ) { lhs -= rhs; }, + R"""( + Per-element subtraction in place. + + :param other: Curve with the same shape and sample count. + :return: This curve. + :raises ValueError: If the operands differ in shape or sample count. + )""" ); + bind_inplace( + "__imul__", + []( Spectrum &lhs, const Spectrum &rhs ) { lhs *= rhs; }, + R"""( + Per-element multiplication in place. + + :param other: Curve with the same shape and sample count. + :return: This curve. + :raises ValueError: If the operands differ in shape or sample count. + )""" ); + bind_inplace( + "__itruediv__", + []( Spectrum &lhs, const Spectrum &rhs ) { lhs /= rhs; }, + R"""( + Per-element division in place. + + :param other: Curve with the same shape and sample count. + :return: This curve. + :raises ValueError: If the operands differ in shape or sample count. + )""" ); +} + void bind_spectral_data( nanobind::module_ &m ) { nanobind::class_ spectral_data( m, "SpectralData", R"""( @@ -48,12 +299,81 @@ void bind_spectral_data( nanobind::module_ &m ) :param path: path a path to the file to load data from :type path: str - :param reshape: if set to ``True``, the data will be reshaped to the - reference shape + :param reshape: if set to ``True``, the data will be reshaped to + :py:attr:`rawtoaces.Spectrum.ReferenceShape` :type reshape: bool :return: ``True`` if loaded successfully. )""" ); + spectral_data.def( + "__getitem__", + []( SpectralData &spectral_data_value, const std::string &name ) { + return spectral_data_value[name]; + }, + "name"_a, + R"""( + Return a copy of the named channel in the ``main`` data set. + + :param name: Channel name in the ``main`` set. + :return: A copy of that channel's spectrum. + :raises ValueError: If the ``main`` set or the channel is absent. + )""" ); + spectral_data.def( + "__setitem__", + []( SpectralData &spectral_data_value, + const std::string &name, + const Spectrum &curve ) { spectral_data_value[name] = curve; }, + "name"_a, + "curve"_a, + R"""( + Replace the named channel in the ``main`` data set. + + :param name: Channel name in the ``main`` set. + :param curve: Spectrum to store. The channel must already exist. + :raises ValueError: If the ``main`` set or the channel is absent. + )""" ); + spectral_data.def( + "__contains__", + []( const SpectralData &spectral_data_value, const std::string &name ) { + const auto set = spectral_data_value.data.find( "main" ); + if ( set == spectral_data_value.data.end() ) + { + return false; + } + + for ( const auto &channel: set->second ) + { + if ( channel.first == name ) + return true; + } + return false; + }, + "name"_a, + R"""( + Return whether ``name`` is a channel in the ``main`` data set. + + :param name: Channel name to look up. + :return: ``True`` when that channel exists. An empty container and a + missing ``main`` set return ``False``. + )""" ); + spectral_data.attr( "__iter__" ) = nanobind::none(); + spectral_data.def( + "get", + []( SpectralData &spectral_data_value, + const std::string &set_name, + const std::string &channel_name ) { + return spectral_data_value.get( set_name, channel_name ); + }, + "set_name"_a, + "channel_name"_a, + R"""( + Return a copy of one channel from a named data set. + + :param set_name: Spectral set name, usually ``"main"``. + :param channel_name: Channel name within that set. + :return: A copy of the matching spectrum. + :raises ValueError: If the set or channel is absent. + )""" ); } void bind_metadata( nanobind::module_ &m ) @@ -117,6 +437,7 @@ void bind_metadata( nanobind::module_ &m ) void core_bindings( nanobind::module_ &m ) { + bind_spectrum( m ); bind_spectral_data( m ); bind_metadata( m ); diff --git a/src/docs/api/python/api_reference.rst b/src/docs/api/python/api_reference.rst index 39ec5dba..fbe3448d 100644 --- a/src/docs/api/python/api_reference.rst +++ b/src/docs/api/python/api_reference.rst @@ -22,6 +22,9 @@ Classes .. autoclass:: rawtoaces.ImageConverter :members: +.. autoclass:: rawtoaces.Spectrum + :members: + .. autoclass:: rawtoaces.SpectralData :members: diff --git a/src/docs/api/python/image_converter.rst b/src/docs/api/python/image_converter.rst index 8210bc44..0ed38600 100644 --- a/src/docs/api/python/image_converter.rst +++ b/src/docs/api/python/image_converter.rst @@ -77,7 +77,7 @@ superset; a method shown there may be absent from your installed build. settings, and supported-data queries - Available - Available - * - ``MetadataSolver``, ``SpectralSolver``, and ``SpectralData`` + * - ``Spectrum``, ``MetadataSolver``, ``SpectralSolver``, and ``SpectralData`` - Available - Available * - ``configure(image_spec, options)`` diff --git a/src/docs/api/python/index.rst b/src/docs/api/python/index.rst index f4662cf4..7b9db7c9 100644 --- a/src/docs/api/python/index.rst +++ b/src/docs/api/python/index.rst @@ -16,7 +16,8 @@ The main classes are: (when supported by the build) lower-level OpenImageIO operations. - Core solver bindings for metadata-based and spectral workflows: :py:class:`rawtoaces.Metadata`, :py:class:`rawtoaces.MetadataSolver`, - :py:class:`rawtoaces.SpectralData`, and :py:class:`rawtoaces.SpectralSolver`. + :py:class:`rawtoaces.Spectrum`, :py:class:`rawtoaces.SpectralData`, and + :py:class:`rawtoaces.SpectralSolver`. Some lower-level :py:class:`rawtoaces.ImageConverter` methods require a compatible OpenImageIO 3.2+ build; see diff --git a/src/docs/api/python/rawtoaces.py b/src/docs/api/python/rawtoaces.py index 5ecd677b..33fb2dfc 100644 --- a/src/docs/api/python/rawtoaces.py +++ b/src/docs/api/python/rawtoaces.py @@ -17,11 +17,215 @@ from collections.abc import Sequence import enum -from typing import overload +from typing import Final, overload import OpenImageIO +class Spectrum: + """ + A regularly sampled spectral curve. + + Corresponds to :cpp:struct:`rta::core::Spectrum`. Per-element + arithmetic requires both curves to share a shape and sample count. + """ + + @overload + def __init__(self, value: float = 0.0, shape: Spectrum.Shape = ...) -> None: + """ + Create a curve and fill every sample with ``value``. + + :param value: Initial value of every sample. + :param shape: Sampling of the new curve. Defaults to + ``Spectrum.ReferenceShape``. Pass ``Spectrum.EmptyShape`` to + allocate no samples. + :raises ValueError: If ``step`` is positive and the wavelengths are + non-finite or ``last`` is less than ``first``. + """ + + @overload + def __init__(self, other: Spectrum) -> None: + """Copy constructor.""" + + class Shape: + """ + Regular wavelength sampling, in nanometers. + + ``first`` and ``last`` are the endpoint wavelengths and ``step`` is + the spacing. Shapes compare equal when all three values match. + """ + + @overload + def __init__(self, first: float = 0.0, last: float = 0.0, step: float = 0.0) -> None: + """ + Create a sampling description. + + :param first: Wavelength of the first sample, in nanometers. + :param last: Wavelength of the last sample, in nanometers. + :param step: Spacing between samples, in nanometers. A zero step + describes an empty curve. + """ + + @overload + def __init__(self, other: Spectrum.Shape) -> None: + """Copy constructor.""" + + @property + def first(self) -> float: + """Wavelength of the first sample, in nanometers.""" + + @first.setter + def first(self, arg: float, /) -> None: ... + + @property + def last(self) -> float: + """Wavelength of the last sample, in nanometers.""" + + @last.setter + def last(self, arg: float, /) -> None: ... + + @property + def step(self) -> float: + """Spacing between samples, in nanometers.""" + + @step.setter + def step(self, arg: float, /) -> None: ... + + def __eq__(self, arg: Spectrum.Shape, /) -> bool: + """Return whether ``first``, ``last``, and ``step`` all match.""" + + __hash__: None = None + + ReferenceShape: Final[Spectrum.Shape] = ... + """ + Built-in sampling used by rawtoaces, from 380 nm through 780 nm in + 5 nm steps. Each access returns a new copy. + """ + + EmptyShape: Final[Spectrum.Shape] = ... + """ + Empty sampling (zero step) used to construct a curve with no samples. + Each access returns a new copy. + """ + + @property + def shape(self) -> Spectrum.Shape: + """ + Wavelength sampling of this curve, in nanometers. Assigning a shape + records that sampling and leaves ``values`` unchanged, so the sample + count can disagree with the shape. ``reshape()`` resamples onto + ``ReferenceShape`` from the current samples and does not check that + the sample count matches the shape. + """ + + @shape.setter + def shape(self, arg: Spectrum.Shape, /) -> None: ... + + @property + def values(self) -> list[float]: + """ + Spectral samples. Reading returns a new list, so assigning to an + index of that list leaves the curve unchanged. Assign a sequence to + replace the samples. + """ + + @values.setter + def values(self, arg: Sequence[float], /) -> None: ... + + def reshape(self) -> None: + """ + Resample this curve onto ``ReferenceShape`` with linear interpolation. + + Wavelengths outside the current range are filled from the nearest + endpoint sample. A curve whose shape already equals + ``ReferenceShape`` is left unchanged, including when its sample count + does not match that shape. + + :raises ValueError: If the curve has no samples. + """ + + def integrate(self) -> float: + """ + Sum every sample. + + :return: The sum of ``values``, or ``0`` when the curve is empty. + """ + + def max(self) -> float: + """Return the largest sample, or ``0`` when ``values`` is empty.""" + + def __add__(self, arg: Spectrum, /) -> Spectrum: + """ + Per-element addition. + + :param other: Curve with the same shape and sample count. + :return: A new curve. + :raises ValueError: If the operands differ in shape or sample count. + """ + + def __sub__(self, arg: Spectrum, /) -> Spectrum: + """ + Per-element subtraction. + + :param other: Curve with the same shape and sample count. + :return: A new curve. + :raises ValueError: If the operands differ in shape or sample count. + """ + + def __mul__(self, arg: Spectrum, /) -> Spectrum: + """ + Per-element multiplication. + + :param other: Curve with the same shape and sample count. + :return: A new curve. + :raises ValueError: If the operands differ in shape or sample count. + """ + + def __truediv__(self, arg: Spectrum, /) -> Spectrum: + """ + Per-element division. + + :param other: Curve with the same shape and sample count. + :return: A new curve. + :raises ValueError: If the operands differ in shape or sample count. + """ + + def __iadd__(self, arg: Spectrum, /) -> Spectrum: + """ + Per-element addition in place. + + :param other: Curve with the same shape and sample count. + :return: This curve. + :raises ValueError: If the operands differ in shape or sample count. + """ + + def __isub__(self, arg: Spectrum, /) -> Spectrum: + """ + Per-element subtraction in place. + + :param other: Curve with the same shape and sample count. + :return: This curve. + :raises ValueError: If the operands differ in shape or sample count. + """ + + def __imul__(self, arg: Spectrum, /) -> Spectrum: + """ + Per-element multiplication in place. + + :param other: Curve with the same shape and sample count. + :return: This curve. + :raises ValueError: If the operands differ in shape or sample count. + """ + + def __itruediv__(self, arg: Spectrum, /) -> Spectrum: + """ + Per-element division in place. + + :param other: Curve with the same shape and sample count. + :return: This curve. + :raises ValueError: If the operands differ in shape or sample count. + """ + class SpectralData: """ A data-class for storing spectral data, based on the file format used in @@ -66,13 +270,52 @@ def load(self, path: str, reshape: bool = True) -> bool: :param path: path a path to the file to load data from :type path: str - :param reshape: if set to ``True``, the data will be reshaped to the - reference shape + :param reshape: if set to ``True``, the data will be reshaped to + :py:attr:`rawtoaces.Spectrum.ReferenceShape` :type reshape: bool :return: ``True`` if loaded successfully. """ + def __getitem__(self, name: str) -> Spectrum: + """ + Return a copy of the named channel in the ``main`` data set. + + :param name: Channel name in the ``main`` set. + :return: A copy of that channel's spectrum. + :raises ValueError: If the ``main`` set or the channel is absent. + """ + + def __setitem__(self, name: str, curve: Spectrum) -> None: + """ + Replace the named channel in the ``main`` data set. + + :param name: Channel name in the ``main`` set. + :param curve: Spectrum to store. The channel must already exist. + :raises ValueError: If the ``main`` set or the channel is absent. + """ + + def __contains__(self, name: str) -> bool: + """ + Return whether ``name`` is a channel in the ``main`` data set. + + :param name: Channel name to look up. + :return: ``True`` when that channel exists. An empty container and a + missing ``main`` set return ``False``. + """ + + __iter__: None = None + + def get(self, set_name: str, channel_name: str) -> Spectrum: + """ + Return a copy of one channel from a named data set. + + :param set_name: Spectral set name, usually ``"main"``. + :param channel_name: Channel name within that set. + :return: A copy of the matching spectrum. + :raises ValueError: If the set or channel is absent. + """ + class Metadata: """DNG metadata required to calculate an input transform.""" diff --git a/src/docs/api/python/rawtoaces_core.rst b/src/docs/api/python/rawtoaces_core.rst index 82e44284..ad085f80 100644 --- a/src/docs/api/python/rawtoaces_core.rst +++ b/src/docs/api/python/rawtoaces_core.rst @@ -27,6 +27,7 @@ The core bindings expose these data containers and solvers: - :py:class:`rawtoaces.Metadata`: container for DNG-style calibration/neutral metadata. - :py:class:`rawtoaces.MetadataSolver`: solves CAT/IDT using :py:class:`rawtoaces.Metadata`. +- :py:class:`rawtoaces.Spectrum`: one regularly sampled spectral curve. - :py:class:`rawtoaces.SpectralData`: spectral JSON container/loader. - :py:class:`rawtoaces.SpectralSolver`: solves WB/IDT using spectral datasets. @@ -135,6 +136,30 @@ Finding a standard illuminant best matching the given white-balancing multiplier raise RuntimeError(solver.last_error_message) +Spectrum +-------- + +:py:class:`rawtoaces.Spectrum` stores one regularly sampled spectral curve and +corresponds to :cpp:struct:`rta::core::Spectrum`. Its nested +:py:class:`~rawtoaces.Spectrum.Shape` describes that sampling in nanometers. + +``Spectrum.ReferenceShape`` is the sampling rawtoaces uses internally: 380 nm +through 780 nm in 5 nm steps. ``Spectrum.EmptyShape`` allocates no samples. +A shape with a positive step must use finite wavelengths and ``last >= first``; +otherwise construction raises ``ValueError``. Arithmetic is per sample and +requires both curves to share a shape and sample count; a mismatch raises +``ValueError``. ``reshape()`` interpolates onto ``ReferenceShape``. Reading +``values`` returns a new list, and assigning ``shape`` leaves the current +samples in place. + +.. code-block:: python + + import rawtoaces + + curve = rawtoaces.Spectrum(0.0, rawtoaces.Spectrum.Shape(380, 400, 10)) + curve.values = [10.0, 20.0, 30.0] + curve.reshape() + Spectral Data ------------- @@ -142,6 +167,12 @@ The :py:class:`rawtoaces.SpectralData` class is a minimal spectral data containe for :py:class:`rawtoaces.SpectralSolver` workflows. Corresponds to :cpp:class:`rta::core::SpectralData`. +Channels in a loaded container are :py:class:`rawtoaces.Spectrum` copies. +``data["channel"]`` and ``data.get(set_name, channel_name)`` read them, and +``data["channel"] = curve`` replaces a channel that already exists in the +``main`` set. ``"channel" in data`` tests for that name. The container is not +iterable. + File format and schema references: - Format description: diff --git a/tests/python/test_core_bindings.py b/tests/python/test_core_bindings.py index 27a6f7c2..68b19cc5 100644 --- a/tests/python/test_core_bindings.py +++ b/tests/python/test_core_bindings.py @@ -3,7 +3,7 @@ # Copyright Contributors to the rawtoaces Project. """ -Unit tests for rawtoaces Python bindings - core metadata solver bindings +Unit tests for rawtoaces Python bindings - core solver and spectrum bindings """ import json @@ -103,6 +103,8 @@ def _write_test_spectral_file( class TestMetadataBindings: def test_metadata_types_exist(self): + assert hasattr(rawtoaces, "Spectrum") + assert hasattr(rawtoaces.Spectrum, "Shape") assert hasattr(rawtoaces, "SpectralData") assert hasattr(rawtoaces, "Metadata") assert hasattr(rawtoaces.Metadata, "Calibration") @@ -319,3 +321,167 @@ def test_spectral_solver_full_configuration_and_idt(self, tmp_path): assert len(row) == 3 for value in row: assert math.isfinite(value) + + +def _reference_sample_count(): + shape = rawtoaces.Spectrum.ReferenceShape + return int((shape.last - shape.first) / shape.step) + 1 + + +class TestSpectrumBindings: + def test_reference_and_empty_shapes_are_copies(self): + reference = rawtoaces.Spectrum.ReferenceShape + assert reference == rawtoaces.Spectrum.Shape(380, 780, 5) + reference.first = 0 + assert rawtoaces.Spectrum.ReferenceShape.first == 380 + + empty = rawtoaces.Spectrum.EmptyShape + assert empty == rawtoaces.Spectrum.Shape(0, 0, 0) + assert reference != empty + assert (reference == 1) is False + with pytest.raises(TypeError): + hash(rawtoaces.Spectrum.Shape(380, 780, 5)) + + def test_constructor_fills_samples_for_the_requested_shape(self): + shape = rawtoaces.Spectrum.Shape(380, 390, 5) + curve = rawtoaces.Spectrum(2.5, shape) + + assert curve.shape == shape + assert curve.values == [2.5, 2.5, 2.5] + curve.values[0] = 9.0 + assert curve.values == [2.5, 2.5, 2.5] + assert len(rawtoaces.Spectrum().values) == _reference_sample_count() == 81 + + empty = rawtoaces.Spectrum(1.0, rawtoaces.Spectrum.EmptyShape) + assert empty.values == [] + assert empty.max() == 0 + assert empty.integrate() == 0 + + with pytest.raises(ValueError) as exc_info: + rawtoaces.Spectrum(0.0, rawtoaces.Spectrum.Shape(780, 380, 5)) + assert "last >= first" in str(exc_info.value) + with pytest.raises(ValueError): + rawtoaces.Spectrum( + 0.0, rawtoaces.Spectrum.Shape(float("nan"), 780, 5) + ) + + def test_shape_assignment_keeps_existing_samples(self): + curve = rawtoaces.Spectrum(1.0, rawtoaces.Spectrum.Shape(380, 390, 5)) + curve.shape.last = 400 + assert curve.shape.last == 400 + + curve.shape = rawtoaces.Spectrum.Shape(400, 410, 5) + assert curve.shape.first == 400 + assert curve.values == [1.0, 1.0, 1.0] + + def test_copy_is_independent(self): + original = rawtoaces.Spectrum(4.0, rawtoaces.Spectrum.Shape(380, 390, 5)) + copied = rawtoaces.Spectrum(original) + copied.values = [0.0, 0.0, 0.0] + + assert original.values == [4.0, 4.0, 4.0] + + def test_arithmetic_is_per_element_and_checks_sampling(self): + shape = rawtoaces.Spectrum.Shape(380, 390, 5) + left = rawtoaces.Spectrum(0.0, shape) + right = rawtoaces.Spectrum(0.0, shape) + left.values = [2.0, 3.0, 4.0] + right.values = [5.0, 1.0, 2.0] + + assert (left + right).values == [7.0, 4.0, 6.0] + assert (left - right).values == [-3.0, 2.0, 2.0] + assert (left * right).values == [10.0, 3.0, 8.0] + assert (left / right).values == [0.4, 3.0, 2.0] + assert left.values == [2.0, 3.0, 4.0] + + updated = left + left += right + assert updated is left + assert left.values == [7.0, 4.0, 6.0] + left -= right + left *= right + assert left.values == [10.0, 3.0, 8.0] + left /= right + assert left.values == [2.0, 3.0, 4.0] + + mismatched = rawtoaces.Spectrum(1.0, rawtoaces.Spectrum.Shape(380, 400, 10)) + with pytest.raises(ValueError) as exc_info: + left + mismatched + assert ( + str(exc_info.value) + == "Spectrum operands must have the same shape and sample count." + ) + + shortened = rawtoaces.Spectrum(left) + shortened.values = shortened.values[:-1] + with pytest.raises(ValueError): + left += shortened + assert left.values == [2.0, 3.0, 4.0] + + with pytest.raises(TypeError) as exc_info: + left + 1 + assert "incompatible function arguments" not in str(exc_info.value) + + def test_reshape_interpolates_onto_the_reference_shape(self): + curve = rawtoaces.Spectrum(0.0, rawtoaces.Spectrum.Shape(380, 400, 10)) + curve.values = [10.0, 20.0, 30.0] + curve.reshape() + + assert curve.shape == rawtoaces.Spectrum.ReferenceShape + assert len(curve.values) == 81 + assert curve.values[0] == pytest.approx(10.0) + assert curve.values[1] == pytest.approx(15.0) + assert curve.values[2] == pytest.approx(20.0) + assert curve.values[3] == pytest.approx(25.0) + assert curve.values[4] == pytest.approx(30.0) + assert curve.integrate() == pytest.approx(sum(curve.values)) + assert curve.max() == pytest.approx(max(curve.values)) + + already_reference = rawtoaces.Spectrum(1.0) + already_reference.reshape() + assert already_reference.values == [1.0] * 81 + + with pytest.raises(ValueError) as exc_info: + rawtoaces.Spectrum(0.0, rawtoaces.Spectrum.EmptyShape).reshape() + assert str(exc_info.value) == "Cannot reshape a spectrum with no samples." + + def test_spectral_data_channels_round_trip(self, tmp_path): + _write_test_spectral_file( + tmp_path, + "illuminant", + "flat.json", + ["power"], + {"type": "illuminant"}, + ) + data = rawtoaces.SpectralData() + assert data.load(str(tmp_path / "illuminant" / "flat.json")) is True + + loaded = data["power"] + assert isinstance(loaded, rawtoaces.Spectrum) + assert loaded.shape == rawtoaces.Spectrum.ReferenceShape + assert loaded.values[0] == pytest.approx(1.0) + assert loaded.values[1] == pytest.approx(1.05) + assert data.get("main", "power").values == loaded.values + + loaded.values = [0.0] * len(loaded.values) + assert data["power"].values[0] == pytest.approx(1.0) + + replacement = rawtoaces.Spectrum(3.0) + data["power"] = replacement + assert data["power"].values == [3.0] * 81 + + with pytest.raises(ValueError) as exc_info: + data["missing"] + assert "missing" in str(exc_info.value) + with pytest.raises(ValueError) as exc_info: + data.get("other", "power") + assert "other" in str(exc_info.value) + + assert "power" in data + assert "missing" not in data + assert "power" not in rawtoaces.SpectralData() + with pytest.raises(ValueError) as exc_info: + rawtoaces.SpectralData()["power"] + assert "main" in str(exc_info.value) + with pytest.raises(TypeError): + list(data)