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
# #####################################################################################
# Role Tests
# #####################################################################################
# #####################################################################################
# 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)