Source code for ewoksxas.converters.tests.test_orange

"""Tests for the Converter class."""

import numpy as np
import numpy.typing as npt
import pytest
from Orange.data import ContinuousVariable, DiscreteVariable, StringVariable

from ewoksxas.converters.orange import Column, Converter, VarType


# #####################################################################################
# Var Type Tests
# #####################################################################################
[docs] def test_vartype_as_enum(): values = [ContinuousVariable, DiscreteVariable, StringVariable, None] tokens = ["numeric", "categorical", "text", "auto"] labels = ["Numeric", "Categorical", "Text", "Auto"] assert len(VarType) == 4 assert [val.value for val in VarType] == values assert [val.name.lower() for val in VarType] == tokens assert [val.name.title() for val in VarType] == labels assert VarType(ContinuousVariable) is VarType.NUMERIC assert VarType(DiscreteVariable) is VarType.CATEGORICAL assert VarType(StringVariable) is VarType.TEXT assert VarType(None) is VarType.AUTO
[docs] def test_vartype_from_label(): assert VarType.from_label("numeric") is VarType.NUMERIC assert VarType.from_label("categorical") is VarType.CATEGORICAL assert VarType.from_label("text") is VarType.TEXT assert VarType.from_label("auto") is VarType.AUTO assert VarType.from_label("Numeric") is VarType.NUMERIC assert VarType.from_label("Categorical") is VarType.CATEGORICAL assert VarType.from_label("Text") is VarType.TEXT assert VarType.from_label("Auto") is VarType.AUTO
[docs] def test_vartype_labels(): labels = ["Numeric", "Categorical", "Text", "Auto"] assert VarType.labels() == labels
[docs] def test_vartype_infer(): ...
# ##################################################################################### # Role Tests # #####################################################################################
[docs] def test_role_as_enum(): ...
# ##################################################################################### # Feature Tests # ##################################################################################### # ##################################################################################### # Column Tests # #####################################################################################
[docs] def test_is_compatible(): column_a = Column(name="A", data=np.array([1, 2]), var_type=VarType.NUMERIC) column_b = Column(name="A", data=np.array([1, 2]), var_type=VarType.NUMERIC) column_c = Column(name="B", data=np.array([1, 2]), var_type=VarType.NUMERIC) assert column_a.is_compatible(column_b) assert not column_a.is_compatible(column_c)
# ##################################################################################### # Converter Tests # #####################################################################################
[docs] def test_empty_initialization(): """Test that Converter can be initialized empty.""" converter = Converter() assert converter.n_rows == 0 assert len(converter.metas) == 0 assert len(converter.targets) == 0 with pytest.raises(ValueError, match="no features data"): _ = converter.features
[docs] def test_add_features_first_call(): """Test adding features for the first time.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([[0.1, 0.2, 0.3]]) converter = Converter().add_features(x, y) names, data = converter.features assert np.array_equal(names, x) assert np.array_equal(data, y)
[docs] def test_add_features_1d_data(): """Test that 1D feature_data is reshaped to 2D.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([0.1, 0.2, 0.3]) # 1D converter = Converter().add_features(x, y) _, data = converter.features assert data.shape == (1, 3) assert np.array_equal(data[0], y)
[docs] def test_table_unique_id(): """Verify that every row has a unique id handled natively by orange.""" x = np.array([8.97, 8.98, 8.99]) y = np.array( [ [10.0, 20.0, 30.0], [1.0, 2.0, 3.0], [40.0, 50.0, 60.0], [4.0, 5.0, 6.0], ] ) table = Converter().add_features(x, y).to_table() np.testing.assert_array_equal(table.ids, (0, 1, 2, 3))
[docs] def test_add_features_shape_mismatch(): """Test that shape mismatch raises ValueError.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([[0.1, 0.2]]) # Wrong shape with pytest.raises(ValueError, match="Shape mismatch"): Converter().add_features(x, y)
[docs] def test_add_features_overwrites(): """Test that a second call replaces the existing feature block.""" x1 = np.array([1.0, 2.0, 3.0]) x2 = np.array([4.0, 5.0, 6.0]) y1 = np.array([[0.1, 0.2, 0.3]]) y2 = np.array([[0.7, 0.8, 0.9]]) converter = Converter().add_features(x1, y1) converter.add_features(x2, y2) names, data = converter.features assert np.array_equal(names, x2) assert np.array_equal(data, y2)
[docs] def test_add_features_then_metas(): """Test adding features then metas.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) labels = np.array(["A", "B"]) converter = Converter().add_features(x, y).add_meta("Label", labels, VarType.TEXT) table = converter.to_table() assert len(table) == 2 assert len(table.domain.attributes) == 3 assert len(table.domain.metas) == 1
[docs] def test_add_metas_then_features(): """Test adding metas then features.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) labels = np.array(["A", "B"]) converter = Converter().add_meta("Label", labels, VarType.TEXT).add_features(x, y) table = converter.to_table() assert len(table) == 2 assert len(table.domain.attributes) == 3 assert len(table.domain.metas) == 1
[docs] def test_to_table_empty(): """Test creating an empty table.""" converter = Converter() table = converter.to_table() assert len(table) == 0 assert len(table.domain.attributes) == 0
[docs] def test_to_table_features_only(): """Test creating a table with features only.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) converter = Converter().add_features(x, y) table = converter.to_table() assert len(table) == 2 assert len(table.domain.attributes) == 3 assert len(table.domain.metas) == 0
[docs] def test_to_table_metas_only(): """Test creating a table with metas only.""" labels = np.array(["A", "B", "C"]) converter = Converter().add_meta("Label", labels, VarType.TEXT) table = converter.to_table() assert len(table) == 3 assert len(table.domain.attributes) == 0 assert len(table.domain.metas) == 1
[docs] def test_round_trip(): """Test round-trip conversion: Converter -> Table -> Converter.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) labels = np.array(["A", "B"]) # Create table table = ( Converter() .add_features(x, y) .add_meta("Label", labels, VarType.TEXT) .to_table() ) # Convert back converter = Converter.from_table(table) names, data = converter.features # Verify assert np.allclose(names, x) assert np.allclose(data, y) assert len(converter.metas) == 1
[docs] def test_features_property(): """Test the features property returns correct tuple.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([[0.1, 0.2, 0.3]]) converter = Converter().add_features(x, y) names, data = converter.features assert np.array_equal(names, x) assert np.array_equal(data, y)
[docs] def test_features_property_empty(): """Test that features property raises error when no features set.""" converter = Converter() with pytest.raises(ValueError, match="no features data"): _ = converter.features
[docs] def test_metas_property_returns_columns(): """The metas property exposes Column objects with attribute access.""" converter = Converter().add_meta("Label", np.array(["a", "b"])) col = converter.metas[0] assert col.name == "Label" assert col.var_type.value is DiscreteVariable
[docs] def test_add_meta_numeric_stays_continuous(): """Numeric metadata is inferred as ContinuousVariable, data untouched.""" data = np.array([1.5, 2.5, 3.5]) col = Converter().add_meta("Area", data).metas[0] assert col.var_type.value is ContinuousVariable assert col.values == [] assert np.allclose(col.data, data)
[docs] def test_add_meta_autodetect_discrete(): """Non-numeric metadata with few unique values is auto-detected discrete.""" labels = np.array(["a", "b", "a", "c"]) col = Converter().add_meta("Label", labels).metas[0] assert col.var_type.value is DiscreteVariable # Categories follow order of first appearance; data are integer indices. assert list(col.values) == ["a", "b", "c"] assert np.array_equal(col.data, [0, 1, 0, 2])
[docs] def test_add_meta_autodetect_threshold(): """At most MAX_DISCRETE_VALUES unique -> discrete, otherwise string.""" ten = np.array([f"v{i}" for i in range(10)]) assert Converter().add_meta("ten", ten).metas[0].var_type.value is DiscreteVariable eleven = np.array([f"v{i}" for i in range(11)]) assert ( Converter().add_meta("eleven", eleven).metas[0].var_type.value is StringVariable )
[docs] def test_add_meta_explicit_discrete_now_allowed(): """DiscreteVariable is now accepted for metas.""" labels = np.array(["a", "b", "a"]) converter = Converter().add_meta("L", labels, VarType.CATEGORICAL) col = converter.metas[0] assert col.var_type.value is DiscreteVariable assert list(col.values) == ["a", "b"] assert isinstance(converter.to_table().domain["L"], DiscreteVariable)
[docs] def test_meta_values_continuous_returns_numbers(): """meta_values on a continuous meta returns the numeric array unchanged.""" areas = np.array([1.5, 2.5, 3.5]) result = Converter().add_meta("Area", areas).get_meta_values("Area") assert np.allclose(result, areas) assert result.mean() == pytest.approx(2.5)
[docs] def test_get_meta_values_missing_without_default_raises(): """A missing meta without a default raises KeyError.""" converter = Converter().add_meta("Area", np.array([1.0, 2.0])) with pytest.raises(KeyError, match="is not in the converter"): converter.get_meta_values("Absent")
[docs] def test_get_meta_values_missing_scalar_default_broadcasts(): """A scalar default is broadcast to one value per row.""" converter = Converter().add_meta("Area", np.array([1.0, 2.0, 3.0])) assert list(converter.get_meta_values("Absent", 7.5)) == [7.5, 7.5, 7.5]
[docs] def test_get_meta_values_missing_per_row_default(): """A per-row default is used verbatim, for both lists and arrays.""" converter = Converter().add_meta("Area", np.array([1.0, 2.0])) assert list(converter.get_meta_values("Absent", ["a", "b"])) == ["a", "b"] assert list(converter.get_meta_values("Absent", np.array([5.0, 6.0]))) == [5.0, 6.0]
[docs] def test_get_meta_values_wrong_length_default_raises(): """A non-scalar default with the wrong length raises instead of truncating.""" converter = Converter().add_meta("Area", np.array([1.0, 2.0, 3.0])) with pytest.raises(ValueError, match=r"must be a scalar or have the shape \(3,\)"): converter.get_meta_values("Absent", [1.0, 2.0])
[docs] def test_get_meta_row(): """get_meta_row returns decoded per-spectrum values keyed by meta name.""" converter = ( Converter() .add_meta("Area", np.array([1.5, 2.5])) .add_meta("Label", np.array(["a", "b"])) ) assert converter.get_meta_row(1) == {"Area": 2.5, "Label": "b"}
[docs] def test_n_rows_source_priority(): """Row count comes from features, then metas, then targets.""" x = np.array([1.0, 2.0]) y = np.array([[1.0, 1.0], [2.0, 2.0], [3.0, 3.0]]) assert Converter().n_rows == 0 assert Converter().add_features(x, y).n_rows == 3 assert Converter().add_meta("Area", np.array([1.0, 2.0])).n_rows == 2 assert Converter().add_target("q", np.array([1.0])).n_rows == 1
[docs] def test_round_trip_discrete_meta(): """A discrete meta survives to_table -> from_table as its labels.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]]) labels = np.array(["red", "blue", "red"]) table = Converter().add_features(x, y).add_meta("Color", labels).to_table() assert isinstance(table.domain["Color"], DiscreteVariable) converter = Converter.from_table(table) assert list(converter.get_meta_values("Color")) == ["red", "blue", "red"] assert converter.metas[0].var_type.value is DiscreteVariable
[docs] def test_add_metas_from_copies_all_types(): """Continuous, string, and discrete metas are carried over unchanged.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]]) source = ( Converter() .add_features(x, y) .add_meta("e0", np.array([7100.0, 7101.0, 7102.0])) .add_meta("Scan Name", np.array(["1.1", "2.1", "3.1"]), VarType.TEXT) .add_meta("Counter", np.array(["mu", "mu", "ref"])) ) target = Converter().add_features(x, y * 2).add_metas_from(source) table = target.to_table() assert [m.name for m in table.domain.metas] == ["e0", "Scan Name", "Counter"] assert isinstance(table.domain["Counter"], DiscreteVariable) converter = Converter.from_table(table) assert np.allclose(converter.get_meta_values("e0"), [7100.0, 7101.0, 7102.0]) assert list(converter.get_meta_values("Scan Name")) == ["1.1", "2.1", "3.1"] assert list(converter.get_meta_values("Counter")) == ["mu", "mu", "ref"]
[docs] def test_add_metas_from_skips_existing_by_default(): """A same-named meta already present is kept, not overwritten.""" x = np.array([1.0, 2.0]) y = np.array([[0.1, 0.2], [0.3, 0.4]]) source = Converter().add_features(x, y).add_meta("e0", np.array([1.0, 2.0])) target = ( Converter() .add_features(x, y) .add_meta("e0", np.array([9.0, 9.0])) .add_metas_from(source) ) assert len(target.metas) == 1 assert np.allclose(target.get_meta_values("e0"), [9.0, 9.0])
[docs] def test_add_metas_from_overwrite(): """overwrite=True replaces the existing same-named meta in place.""" x = np.array([1.0, 2.0]) y = np.array([[0.1, 0.2], [0.3, 0.4]]) source = ( Converter() .add_features(x, y) .add_meta("e0", np.array([1.0, 2.0])) .add_meta("Scan Name", np.array(["a", "b"]), VarType.TEXT) ) target = ( Converter() .add_features(x, y) .add_meta("e0", np.array([9.0, 9.0])) .add_metas_from(source, overwrite=True) ) assert [m.name for m in target.metas] == ["e0", "Scan Name"] assert np.allclose(target.get_meta_values("e0"), [1.0, 2.0])
[docs] def test_add_metas_from_row_mismatch_raises(): """Carrying metas from a source with a different row count raises.""" source = ( Converter() .add_features(np.array([1.0, 2.0]), np.array([[0.1, 0.2], [0.3, 0.4]])) .add_meta("e0", np.array([1.0, 2.0])) ) target = Converter().add_features(np.array([1.0, 2.0]), np.array([[0.1, 0.2]])) with pytest.raises(ValueError, match="Cannot carry over metas"): target.add_metas_from(source)
[docs] def test_add_target_string_rejected(): """StringVariable is not a valid target type.""" with pytest.raises(ValueError, match="StringVariable is not allowed for TARGET"): Converter().add_target("Q", np.array(["a", "b"]), VarType.TEXT)
[docs] def test_add_target_too_many_categories(): """A non-numeric target with too many unique values cannot be inferred.""" data = np.array([f"cat{i}" for i in range(11)]) with pytest.raises( ValueError, match="Cannot infer a type for 'Q' with role TARGET" ): Converter().add_target("Q", data)
[docs] def test_meta_names(): """Test that meta_names lists metadata columns in order.""" converter = ( Converter() .add_meta("A", np.array(["1"]), VarType.TEXT) .add_meta("B", np.array(["2"]), VarType.TEXT) ) assert converter.get_meta_names() == ["A", "B"]
[docs] def test_take_rows_subset_and_reorder(): """Test selecting a subset of rows in a new order.""" x = np.array([1.0, 2.0]) y = np.array([[1.0, 1.0], [2.0, 2.0], [3.0, 3.0]]) labels = np.array(["a", "b", "c"]) converter = Converter().add_features(x, y).add_meta("L", labels, VarType.TEXT) selected = converter.take_rows([2, 0]) names, data = selected.features assert np.array_equal(names, x) assert np.array_equal(data, np.array([[3.0, 3.0], [1.0, 1.0]])) assert list(selected.get_meta_values("L")) == ["c", "a"] # The original Converter must be left untouched. assert converter.features[1].shape == (3, 2) assert list(converter.get_meta_values("L")) == ["a", "b", "c"]
[docs] def test_take_rows_duplicate(): """Test that a row index may be repeated.""" x = np.array([1.0, 2.0]) y = np.array([[1.0, 1.0], [2.0, 2.0]]) converter = Converter().add_features(x, y) selected = converter.take_rows([0, 0, 1]) assert selected.features[1].shape == (3, 2) assert np.array_equal(selected.features[1][0], selected.features[1][1])
[docs] def test_take_rows_preserves_targets(): """Test that targets are subset alongside features.""" x = np.array([1.0, 2.0]) y = np.array([[1.0, 1.0], [2.0, 2.0], [3.0, 3.0]]) converter = ( Converter().add_features(x, y).add_target("score", np.array([0.1, 0.2, 0.3])) ) selected = converter.take_rows([2, 1]) assert list(selected.targets[0].data) == [0.3, 0.2]
[docs] def test_take_rows_empty(): """Test that selecting zero rows yields an empty but valid table.""" x = np.array([1.0, 2.0]) converter = ( Converter() .add_features(x, np.array([[1.0, 1.0], [2.0, 2.0]])) .add_meta("L", np.array(["a", "b"]), VarType.TEXT) ) table = converter.take_rows([]).to_table() assert len(table) == 0 assert len(table.domain.metas) == 1
[docs] def test_take_rows_isolation(): """Test that mutating a selection does not affect the source.""" x = np.array([1.0, 2.0]) converter = Converter().add_features(x, np.array([[1.0, 1.0], [2.0, 2.0]])) selected = converter.take_rows([0, 1]) selected.features[1][0, 0] = 999.0 assert converter.features[1][0, 0] == 1.0
[docs] def test_take_rows_with_discrete_target(): """Test that take_rows preserves (and isolates) DiscreteVariable values.""" x = np.array([1.0, 2.0]) y = np.array([[1.0, 1.0], [2.0, 2.0], [3.0, 3.0]]) converter = ( Converter() .add_features(x, y) .add_target( "q", np.array(["A", "B", "A"]), VarType.CATEGORICAL, values=["A", "B"] ) ) selected = converter.take_rows([2, 0]) target = selected.targets[0] # Data are integer indices: ["A", "B", "A"] -> [0, 1, 0]; rows [2, 0] -> [0, 0]. assert list(target.data) == [0, 0] assert target.values == ["A", "B"] # The category list must be a copy, not aliased with the source. assert target.values is not converter.targets[0].values
[docs] def test_take_rows_with_discrete_meta(): """Test that take_rows subsets an auto-detected categorical meta.""" x = np.array([1.0, 2.0]) y = np.array([[1.0, 1.0], [2.0, 2.0], [3.0, 3.0]]) converter = Converter().add_features(x, y).add_meta("L", np.array(["a", "b", "a"])) assert converter.metas[0].var_type.value is DiscreteVariable selected = converter.take_rows([2, 1]) assert list(selected.get_meta_values("L")) == ["a", "b"]
[docs] def test_concatenate_rows(): """Test stacking two converters row-wise.""" x = np.array([1.0, 2.0]) a = ( Converter() .add_features(x, np.array([[1.0, 1.0]])) .add_meta("L", np.array(["a"]), VarType.TEXT) ) b = ( Converter() .add_features(x, np.array([[2.0, 2.0]])) .add_meta("L", np.array(["b"]), VarType.TEXT) ) combined = Converter.concatenate([a, b]) assert combined.features[1].shape == (2, 2) assert np.array_equal(combined.features[0], x) assert list(combined.get_meta_values("L")) == ["a", "b"]
[docs] def test_concatenate_empty_raises(): """Test that concatenating an empty sequence raises.""" with pytest.raises(ValueError, match="at least one"): Converter.concatenate([])
[docs] def test_concatenate_feature_mismatch_raises(): """Test that mismatched feature x-axes cannot be concatenated.""" a = Converter().add_features(np.array([1.0, 2.0]), np.array([[1.0, 1.0]])) b = Converter().add_features(np.array([3.0, 4.0]), np.array([[2.0, 2.0]])) with pytest.raises(ValueError, match="different feature x-axes"): Converter.concatenate([a, b])
[docs] def test_concatenate_column_mismatch_raises(): """Test that mismatched metadata columns cannot be concatenated.""" x = np.array([1.0, 2.0]) a = ( Converter() .add_features(x, np.array([[1.0, 1.0]])) .add_meta("L", np.array(["a"]), VarType.TEXT) ) b = ( Converter() .add_features(x, np.array([[2.0, 2.0]])) .add_meta("M", np.array(["b"]), VarType.TEXT) ) with pytest.raises(ValueError, match="different column structure"): Converter.concatenate([a, b])
[docs] def test_concatenate_discrete_targets(): """Test concatenating DiscreteVariable targets with matching values.""" x = np.array([1.0, 2.0]) a = ( Converter() .add_features(x, np.array([[1.0, 1.0]])) .add_target("q", np.array(["A"]), VarType.CATEGORICAL, values=["A", "B"]) ) b = ( Converter() .add_features(x, np.array([[2.0, 2.0]])) .add_target("q", np.array(["B"]), VarType.CATEGORICAL, values=["A", "B"]) ) combined = Converter.concatenate([a, b]) assert list(combined.targets[0].data) == [0, 1] assert combined.targets[0].values == ["A", "B"]
[docs] def test_concatenate_discrete_reinfers_values(): """Discrete columns with differing categories merge into a new column. Concatenation stacks the *decoded* labels and lets ``Column.from_data`` re-infer the type and recompute the category values from the combined labels. The category sets of the sources are not required to match: the result is identical to concatenating the same labels as string columns, just re-encoded as a fresh categorical. """ x = np.array([1.0, 2.0]) a = ( Converter() .add_features(x, np.array([[1.0, 1.0]])) .add_target("q", np.array(["A"]), VarType.CATEGORICAL, values=["A", "B"]) ) b = ( Converter() .add_features(x, np.array([[2.0, 2.0]])) .add_target("q", np.array(["X"]), VarType.CATEGORICAL, values=["X", "Y"]) ) combined = Converter.concatenate([a, b]) target = combined.targets[0] # Data representation is unchanged: the decoded labels are simply stacked. assert list(target.get_decoded_data()) == ["A", "X"] # Categories are recomputed from the combined labels (order of first # appearance), not inherited from either source's ["A", "B"] / ["X", "Y"]. assert target.var_type is VarType.CATEGORICAL assert list(target.values) == ["A", "X"] assert list(target.data) == [0, 1]
[docs] def test_concatenate_mixed_none_features_raises(): """Test that mixing feature-less and featured converters raises.""" x = np.array([1.0, 2.0]) a = Converter().add_features(x, np.array([[1.0, 1.0]])) b = Converter().add_meta("L", np.array(["b"]), VarType.TEXT) with pytest.raises(ValueError, match="mix presence and absence"): Converter.concatenate([a, b])
[docs] def test_concatenate_extra_column_raises(): """Test that a converter with extra columns cannot be concatenated.""" x = np.array([1.0, 2.0]) a = ( Converter() .add_features(x, np.array([[1.0, 1.0]])) .add_meta("L", np.array(["a"]), VarType.TEXT) ) b = ( Converter() .add_features(x, np.array([[2.0, 2.0]])) .add_meta("L", np.array(["b"]), VarType.TEXT) .add_meta("M", np.array(["m"]), VarType.TEXT) ) with pytest.raises(ValueError, match="different numbers of columns"): Converter.concatenate([a, b])
[docs] def test_concatenate_string_feature_names(): """Test that converters with string feature names can be concatenated.""" names = np.array(["c1", "c2"]) a = Converter().add_features(names, np.array([[1.0, 1.0]])) b = Converter().add_features(names, np.array([[2.0, 2.0]])) combined = Converter.concatenate([a, b]) assert list(combined.features[0]) == ["c1", "c2"] assert combined.features[1].shape == (2, 2)
[docs] def test_with_features_carries_metas(): """with_features() returns a new Converter with new features and the metas.""" x = np.array([1.0, 2.0, 3.0]) y = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) source = ( Converter() .add_features(x, y) .add_meta("Scan Name", np.array(["1.1", "2.1"]), VarType.TEXT) ) x_out = np.array([10.0, 20.0]) y_out = np.array([[1.0, 2.0], [3.0, 4.0]]) derived = source.with_features(x_out, y_out) names, data = derived.features assert np.array_equal(names, x_out) assert np.array_equal(data, y_out) assert list(derived.get_meta_values("Scan Name")) == ["1.1", "2.1"] # The source is left untouched. assert np.array_equal(source.features[0], x)
[docs] def test_with_features_row_mismatch_raises(): """with_features() rejects data that does not have one row per source row.""" x = np.array([1.0, 2.0]) y = np.array([[0.1, 0.2], [0.3, 0.4]]) source = Converter().add_features(x, y).add_meta("Area", np.array([1.0, 2.0])) with pytest.raises(ValueError, match="Cannot carry over metas"): source.with_features(x, np.array([[1.0, 2.0]]))
[docs] def test_with_features_then_add_meta_replaces_carried(): """Task-computed metas added after with_features() replace carried ones in place.""" x = np.array([1.0, 2.0]) y = np.array([[0.1, 0.2], [0.3, 0.4]]) source = Converter().add_features(x, y).add_meta("e0", np.array([1.0, 2.0])) derived = source.with_features(x, y * 2).add_meta("e0", np.array([9.0, 9.0])) assert list(derived.get_meta_values("e0")) == [9.0, 9.0] # Replacement rebinds the column slot, so the source keeps its values. assert list(source.get_meta_values("e0")) == [1.0, 2.0]
[docs] def test_add_meta_numeric_replacement_of_discrete_becomes_continuous(): """Numeric data replacing a carried categorical column becomes continuous.""" x = np.array([1.0, 2.0]) y = np.array([[0.1, 0.2], [0.3, 0.4]]) source = Converter().add_features(x, y).add_meta("e0", np.array(["7100", "7105"])) assert source.metas[0].var_type.value is DiscreteVariable derived = source.with_features(x, y).add_meta("e0", np.array([7100.2, 7105.3])) assert derived.metas[0].var_type.value is ContinuousVariable assert list(derived.get_meta_values("e0")) == [7100.2, 7105.3]
[docs] def test_add_meta_numeric_replacement_of_string_becomes_continuous(): """Numeric data replacing a carried text column becomes continuous.""" x = np.array([1.0, 2.0]) y = np.array([[0.1, 0.2], [0.3, 0.4]]) source = ( Converter() .add_features(x, y) .add_meta("e0", np.array(["a", "b"]), VarType.TEXT) ) derived = source.with_features(x, y).add_meta("e0", np.array([7100.2, 7105.3])) assert derived.metas[0].var_type.value is ContinuousVariable assert list(derived.get_meta_values("e0")) == [7100.2, 7105.3]
[docs] def test_add_meta_string_replacement(): """Non-numeric data still inherits the type of the replaced column.""" x = np.array([1.0, 2.0]) converter = ( Converter() .add_features(x, np.array([[0.1, 0.2], [0.3, 0.4]])) .add_meta("terms", np.array(["a", "b"]), VarType.TEXT) ) converter.add_meta("terms", np.array(["a / b", "a / b"], dtype=object)) assert converter.metas[0].var_type is VarType.CATEGORICAL
[docs] def test_add_meta_row_count_mismatch_raises(): """A meta whose length differs from the existing rows is rejected.""" x = np.array([1.0, 2.0]) y = np.array([[0.1, 0.2], [0.3, 0.4]]) converter = Converter().add_features(x, y) with pytest.raises(ValueError, match="but the converter has 2 rows"): converter.add_meta("Area", np.array([1.0, 2.0, 3.0]))
[docs] def test_add_meta_scalar_raises_value_error(): """A scalar meta on a non-empty converter raises ValueError, not IndexError.""" x = np.array([1.0, 2.0]) y = np.array([[0.1, 0.2], [0.3, 0.4]]) converter = Converter().add_features(x, y) with pytest.raises(ValueError, match="must be a 1-D array with one value per row"): converter.add_meta("Area", 5.0)
[docs] def test_add_target_row_count_mismatch_raises(): """A target whose length differs from the existing rows is rejected.""" converter = Converter().add_meta("Area", np.array([1.0, 2.0])) with pytest.raises(ValueError, match="but the converter has 2 rows"): converter.add_target("q", np.array([1.0]))
[docs] def test_add_target_replaces_in_place(): """add_target replaces an existing same-named target instead of appending.""" converter = Converter().add_target("q", np.array([1.0, 2.0])) converter.add_target("q", np.array([3.0, 4.0])) assert len(converter.targets) == 1 assert list(converter.targets[0].data) == [3.0, 4.0]
[docs] def test_round_trip_numeric_looking_string_features(): """A string feature axis that parses as numbers survives two round trips.""" names = np.array(["1.1", "2.1"]) data = np.array([[0.1, 0.2], [0.3, 0.4]]) table = Converter().add_features(names, data).to_table() once = Converter.from_table(table) assert list(once.features[0]) == ["1.1", "2.1"] twice = Converter.from_table(once.to_table()) assert list(twice.features[0]) == ["1.1", "2.1"] assert [var.name for var in twice.to_table().domain.attributes] == ["1.1", "2.1"]
[docs] def test_add_meta_replaces_in_place(): """Test that add_meta replaces an existing column without reordering.""" x = np.array([1.0, 2.0]) converter = ( Converter() .add_features(x, np.array([[1.0, 1.0], [2.0, 2.0]])) .add_meta("group", np.array(["g", "g"]), VarType.TEXT) .add_meta("type", np.array(["a", "b"]), VarType.TEXT) ) converter.add_meta("type", np.array(["x", "y"], dtype=object)) assert converter.get_meta_names() == ["group", "type"] assert list(converter.get_meta_values("type")) == ["x", "y"] assert list(converter.get_meta_values("group")) == ["g", "g"]
[docs] def test_add_meta_adds_when_absent(): """Test that add_meta adds a new column when the name is unknown.""" x = np.array([1.0, 2.0]) converter = Converter().add_features(x, np.array([[1.0, 1.0]])) converter.add_meta("new", np.array(["z"], dtype=object), VarType.TEXT) assert converter.get_meta_names() == ["new"] assert list(converter.get_meta_values("new")) == ["z"]
[docs] def test_add_meta_preserves_continuous_type(): """Test that replacing a column reuses its existing variable type.""" x = np.array([1.0, 2.0]) converter = ( Converter() .add_features(x, np.array([[1.0, 1.0], [2.0, 2.0]])) .add_meta("area", np.array([1.0, 2.0]), VarType.NUMERIC) ) converter.add_meta("area", np.array([3.0, 4.0])) assert converter.metas[0].var_type.value is ContinuousVariable assert list(converter.get_meta_values("area")) == [3.0, 4.0]
[docs] def test_add_meta_changes_discrete_to_string(): """Test that an explicit var_type converts a categorical column to text.""" x = np.array([1.0, 2.0]) converter = ( Converter() .add_features(x, np.array([[1.0, 1.0], [2.0, 2.0]])) .add_meta("type", np.array(["a", "b"])) ) assert converter.metas[0].var_type.value is DiscreteVariable converter.add_meta( "type", np.array(["a", "a / b"], dtype=object), VarType.TEXT, ) assert converter.metas[0].var_type.value is StringVariable assert list(converter.get_meta_values("type")) == ["a", "a / b"]
def _filename_scan_table(rows): """Build a table with (Filename, Scan Name) metas from ``(file, scan)`` rows.""" x = np.array([8.97, 8.98, 8.99]) return ( Converter() .add_features(x, np.tile(x, (len(rows), 1))) .add_meta( "Filename", np.array([r[0] for r in rows], dtype=object), VarType.TEXT, ) .add_meta( "Scan Name", np.array([r[1] for r in rows], dtype=object), VarType.TEXT, ) .to_table() )
[docs] def test_hash_id_unique_per_filename_scan_pair(): """from_table hashes each row by (Filename, Scan Name), content-based. Mirrors the BLISS collision: scan names repeat across files and a single file holds several scans, so only the pair is unique. The hash is not a visible meta column. """ rows = [ ("xanes_0004.h5", "1.1"), ("xanes_0004.h5", "1.1"), ("xanes_0004.h5", "2.1"), ("xanes_0004.h5", "2.1"), ("xanes_0002.h5", "1.1"), ("xanes_0002.h5", "1.1"), ] converter = Converter.from_table(_filename_scan_table(rows)) group_id = converter.get_group_id() assert group_id is not None assert group_id.dtype == np.uint64 assert "ID" not in converter.get_meta_names() # not a visible meta column # Same (Filename, Scan Name) -> same hash; different pair -> different hash. assert group_id[0] == group_id[1] assert group_id[2] == group_id[3] assert group_id[4] == group_id[5] assert len({int(group_id[i]) for i in (0, 2, 4)}) == 3
[docs] def test_hash_id_stable_across_tables(): """The same (Filename, Scan Name) hashes identically in separate tables. This is what makes the identity survive merges: unlike a first-seen integer id, the content hash does not depend on row position or which table it is in. """ a = Converter.from_table(_filename_scan_table([("f.h5", "1.1")])).get_group_id() b = Converter.from_table( _filename_scan_table([("other.h5", "9.9"), ("f.h5", "1.1")]) ).get_group_id() assert a[0] == b[1]
[docs] def test_hash_id_none_when_columns_missing(): """A table without the hash columns leaves hash_id None (callers use .ids).""" x = np.array([1.0, 2.0]) table = ( Converter() .add_features(x, np.array([[1.0, 1.0], [2.0, 2.0]])) .add_meta("Filename", np.array(["a", "b"], dtype=object), VarType.TEXT) .to_table() ) np.testing.assert_array_equal(Converter.from_table(table).get_group_id(), [0, 1])
[docs] def test_hash_id_custom_columns(): """get_group_id accepts user-chosen hash columns on demand (2+ supported). The identity is not persisted on the converter; the caller picks which columns to hash at the point of the call. """ x = np.array([1.0, 2.0, 3.0]) table = ( Converter() .add_features(x, np.tile(x, (3, 1))) .add_meta("A", np.array(["p", "p", "q"], dtype=object), VarType.TEXT) .add_meta("B", np.array(["1", "2", "1"], dtype=object), VarType.TEXT) .to_table() ) hash_id = Converter.from_table(table).get_group_id(id_columns=("A", "B")) assert hash_id is not None assert len({int(v) for v in hash_id}) == 3 # (p,1), (p,2), (q,1) all distinct
def _example_spectra() -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: """Five Gaussian-like spectra on a common x grid.""" x = np.linspace(0, 10, 100) parameters = [ (5.0, 0.5, 3.0), (2.0, 1.0, 5.0), (8.0, 0.8, 4.0), (1.5, 1.5, 6.0), (3.5, 0.6, 3.5), ] spectra = np.stack( [ intensity * np.exp(-((x - center) ** 2) / (2 * width**2)) + 0.5 for intensity, width, center in parameters ] ) return x, spectra
[docs] def test_round_trip_features_and_metas(): """Features and continuous metas survive to_table -> from_table.""" x, spectra = _example_spectra() areas = np.asarray(np.trapezoid(spectra, x)) maxima = np.max(spectra, axis=1) table = ( Converter() .add_features(x, spectra) .add_meta("Area", areas) .add_meta("Maximum", maxima) .to_table() ) converter = Converter.from_table(table) feature_names, feature_data = converter.features assert feature_names.shape == (100,) assert feature_data.shape == (5, 100) assert np.allclose(feature_names, x) assert np.allclose(feature_data, spectra) metas = converter.metas assert len(metas) == 2 assert metas[0].name == "Area" assert np.allclose(metas[0].get_decoded_data(), areas) assert metas[1].name == "Maximum" assert np.allclose(metas[1].get_decoded_data(), maxima)
[docs] def test_table_with_target_and_metas(): """Targets and metas end up in the expected domain slots.""" x, spectra = _example_spectra() areas = np.asarray(np.trapezoid(spectra, x)) quality_scores = np.random.default_rng(42).random(len(spectra)) locations = np.asarray(["root", "leaf", "stem", "leaf", "root"]) table = ( Converter() .add_features(x, spectra) .add_target("QualityScore", quality_scores) .add_meta("Area", areas) .add_meta("Location", locations, VarType.TEXT) .to_table() ) assert len(table.domain.class_vars) == 1 assert table.domain.class_vars[0].name == "QualityScore" assert len(table.domain.metas) == 2 assert table.metas.shape == (5, 2)