Skip to content

Commit 06f9fb3

Browse files
committed
Test + debug _build_props_metadata()
1 parent 3aab815 commit 06f9fb3

2 files changed

Lines changed: 196 additions & 23 deletions

File tree

pycellin/io/geff/loader.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -569,16 +569,16 @@ def _build_props_metadata(geff_md: geff.GeffMetadata) -> dict[str, Property]:
569569
Dictionary mapping property identifiers to Property objects.
570570
"""
571571
props_dict: dict[str, Property] = {}
572-
if geff_md.node_props_metadata is not None:
572+
if geff_md.node_props_metadata:
573573
_extract_props_metadata(geff_md.node_props_metadata, props_dict, "node")
574-
if geff_md.edge_props_metadata is not None:
574+
if geff_md.edge_props_metadata:
575575
_extract_props_metadata(geff_md.edge_props_metadata, props_dict, "edge")
576576

577577
# TODO: for now lineage properties are not associated to a specific tag but stored
578578
# somewhere in the "extra" field. We need to check recursively if there is a dict
579579
# key called "lineage_props_metadata" in the "extra" field.
580580
# For now I keep this, but it should be replaced by geffception at some point.
581-
if geff_md.extra is not None:
581+
if geff_md.extra:
582582
# Recursive search for the "lineage_props_metadata" key through the "extra"
583583
# field dict of dicts of dicts...
584584
lin_props_metadata = _recursive_dict_search(

tests/io/geff/test_loader.py

Lines changed: 193 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pycellin.classes import Property
1111
from pycellin.io.geff.loader import (
1212
_build_generic_metadata,
13+
_build_props_metadata,
1314
_extract_axes_metadata,
1415
_extract_generic_metadata,
1516
_extract_lin_props_metadata,
@@ -28,20 +29,31 @@
2829
def geff_node_props_md():
2930
"""Geff node properties metadata where the target prop has a unit."""
3031
return {
31-
"frame": geff_spec.PropMetadata(identifier="frame", dtype="int", unit=None),
32+
"frame": geff_spec.PropMetadata(identifier="frame", dtype="int64", unit=None),
3233
"position_x": geff_spec.PropMetadata(
33-
identifier="position_x", dtype="float64", unit="micrometer"
34+
identifier="position_x", dtype="float64", unit="um"
3435
),
3536
}
3637

3738

39+
@pytest.fixture
40+
def geff_edge_props_md():
41+
"""Geff edge properties metadata where the target prop has a unit."""
42+
return {
43+
"speed": geff_spec.PropMetadata(
44+
identifier="speed", dtype="float64", unit="um/second"
45+
),
46+
"cost": geff_spec.PropMetadata(identifier="cost", dtype="float64", unit=None),
47+
}
48+
49+
3850
@pytest.fixture
3951
def geff_axes():
4052
"""A list of geff axes containing time and space axes."""
4153
return [
4254
geff_spec.Axis(name="frame", type="time", unit="second"),
43-
geff_spec.Axis(name="position_x", type="space", unit="micrometer"),
44-
geff_spec.Axis(name="position_y", type="space", unit="micrometer"),
55+
geff_spec.Axis(name="position_x", type="space", unit="um"),
56+
geff_spec.Axis(name="position_y", type="space", unit="um"),
4557
]
4658

4759

@@ -88,9 +100,9 @@ def geff_md_3d_axes(geff_node_props_md):
88100
directed=True,
89101
axes=[
90102
geff_spec.Axis(name="frame", type="time", unit="second"),
91-
geff_spec.Axis(name="position_x", type="space", unit="micrometer"),
92-
geff_spec.Axis(name="position_y", type="space", unit="micrometer"),
93-
geff_spec.Axis(name="position_z", type="space", unit="micrometer"),
103+
geff_spec.Axis(name="position_x", type="space", unit="um"),
104+
geff_spec.Axis(name="position_y", type="space", unit="um"),
105+
geff_spec.Axis(name="position_z", type="space", unit="um"),
94106
],
95107
node_props_metadata=geff_node_props_md,
96108
edge_props_metadata={},
@@ -231,6 +243,20 @@ def prop_pycellin_lin_position_x():
231243
)
232244

233245

246+
@pytest.fixture
247+
def prop_speed():
248+
"""A Property for 'speed' with prop_type='edge'."""
249+
return Property(
250+
identifier="speed",
251+
name="speed",
252+
description="speed",
253+
provenance="test",
254+
prop_type="edge",
255+
lin_type="CellLineage",
256+
dtype="float",
257+
)
258+
259+
234260
@pytest.fixture
235261
def lin_props_md():
236262
"""A dict of lineage properties metadata with expected fields."""
@@ -239,7 +265,7 @@ def lin_props_md():
239265
"displacement": {
240266
"name": "Lineage displacement",
241267
"dtype": "float",
242-
"unit": "micrometer",
268+
"unit": "um",
243269
},
244270
}
245271

@@ -527,7 +553,7 @@ def test_new_key_is_all_fields_added(self, geff_node_props_md):
527553
assert props_dict["position_x"].identifier == "position_x"
528554
assert props_dict["position_x"].prop_type == "node"
529555
assert props_dict["position_x"].dtype == "float64"
530-
assert props_dict["position_x"].unit == "micrometer"
556+
assert props_dict["position_x"].unit == "um"
531557

532558
def test_new_key_name_defaults_to_key_when_prop_name_is_none(
533559
self, geff_node_props_md
@@ -650,7 +676,7 @@ def test_new_key_all_fields_added(self, lin_props_md):
650676
assert props_dict["displacement"].identifier == "displacement"
651677
assert props_dict["displacement"].prop_type == "lineage"
652678
assert props_dict["displacement"].dtype == "float"
653-
assert props_dict["displacement"].unit == "micrometer"
679+
assert props_dict["displacement"].unit == "um"
654680

655681
def test_new_key_name_defaults_to_key_when_name_is_none(self, lin_props_md):
656682
"""When prop dict has name=None, Property.name falls back to the key."""
@@ -741,13 +767,160 @@ def test_both_rename_candidates_taken_raises_key_error(
741767
_extract_lin_props_metadata({"position_x": {"dtype": "float"}}, props_dict)
742768

743769

770+
class TestBuildPropsMetadata:
771+
"""Test cases for _build_props_metadata function."""
772+
773+
def test_empty_node_and_edge_props_returns_empty_dict(self):
774+
"""When node and edge props metadata are empty dicts and extra is None,
775+
return an empty props_dict."""
776+
geff_md = geff.GeffMetadata(
777+
directed=True, node_props_metadata={}, edge_props_metadata={}
778+
)
779+
result = _build_props_metadata(geff_md)
780+
assert result == {}
781+
782+
def test_node_props_only_all_added_as_node(self, geff_node_props_md):
783+
"""When only node_props_metadata is provided, all keys are added with
784+
prop_type='node'."""
785+
geff_md = geff.GeffMetadata(
786+
directed=True,
787+
node_props_metadata=geff_node_props_md,
788+
edge_props_metadata={},
789+
)
790+
result = _build_props_metadata(geff_md)
791+
assert "frame" in result
792+
assert result["frame"].prop_type == "node"
793+
assert result["frame"].dtype == "int64"
794+
assert "position_x" in result
795+
assert result["position_x"].prop_type == "node"
796+
assert result["position_x"].unit == "um"
797+
798+
def test_edge_props_only_all_added_as_edge(self, geff_edge_props_md):
799+
"""When only edge_props_metadata is provided, all keys are added with
800+
prop_type='edge'."""
801+
geff_md = geff.GeffMetadata(
802+
directed=True,
803+
node_props_metadata={},
804+
edge_props_metadata=geff_edge_props_md,
805+
)
806+
result = _build_props_metadata(geff_md)
807+
assert "speed" in result
808+
assert result["speed"].prop_type == "edge"
809+
assert result["speed"].dtype == "float64"
810+
assert "cost" in result
811+
assert result["cost"].prop_type == "edge"
812+
assert result["cost"].unit is None
813+
814+
def test_node_and_edge_props_no_collision_both_added(
815+
self, geff_node_props_md, geff_edge_props_md
816+
):
817+
"""When node and edge metadata have different keys, all props are added."""
818+
geff_md = geff.GeffMetadata(
819+
directed=True,
820+
node_props_metadata=geff_node_props_md,
821+
edge_props_metadata=geff_edge_props_md,
822+
)
823+
result = _build_props_metadata(geff_md)
824+
assert "frame" in result
825+
assert result["frame"].prop_type == "node"
826+
assert "position_x" in result
827+
assert result["position_x"].prop_type == "node"
828+
assert "speed" in result
829+
assert result["speed"].prop_type == "edge"
830+
assert "cost" in result
831+
assert result["cost"].prop_type == "edge"
832+
833+
def test_node_and_edge_collision_both_renamed(self, geff_node_props_md):
834+
"""When a key appears in both node and edge metadata, both props are renamed
835+
with appropriate prefixes."""
836+
geff_md = geff.GeffMetadata(
837+
directed=True,
838+
node_props_metadata=geff_node_props_md,
839+
edge_props_metadata={
840+
"position_x": geff_spec.PropMetadata(
841+
identifier="position_x",
842+
dtype="float64",
843+
unit="um",
844+
)
845+
},
846+
)
847+
with pytest.warns(UserWarning):
848+
result = _build_props_metadata(geff_md)
849+
assert "position_x" not in result
850+
assert "cell_position_x" in result
851+
assert result["cell_position_x"].prop_type == "node"
852+
assert "link_position_x" in result
853+
assert result["link_position_x"].prop_type == "edge"
854+
855+
def test_lineage_props_in_extra_direct_key(self, lin_props_md):
856+
"""When extra contains 'lineage_props_metadata' at the top level, lineage
857+
props are extracted and added with prop_type='lineage'."""
858+
geff_md = geff.GeffMetadata(
859+
directed=True,
860+
node_props_metadata={},
861+
edge_props_metadata={},
862+
extra={"lineage_props_metadata": lin_props_md},
863+
)
864+
result = _build_props_metadata(geff_md)
865+
assert "n_divisions" in result
866+
assert result["n_divisions"].prop_type == "lineage"
867+
assert result["n_divisions"].dtype == "int"
868+
assert "displacement" in result
869+
assert result["displacement"].prop_type == "lineage"
870+
assert result["displacement"].unit == "um"
871+
872+
def test_lineage_props_nested_in_extra(self, lin_props_md):
873+
"""When 'lineage_props_metadata' is nested inside extra, it is still found
874+
and extracted."""
875+
geff_md = geff.GeffMetadata(
876+
directed=True,
877+
node_props_metadata={},
878+
edge_props_metadata={},
879+
extra={"some_section": {"lineage_props_metadata": lin_props_md}},
880+
)
881+
result = _build_props_metadata(geff_md)
882+
assert result["n_divisions"].prop_type == "lineage"
883+
assert result["displacement"].prop_type == "lineage"
884+
885+
def test_extra_without_lineage_props_metadata_no_lineage_added(self):
886+
"""When extra exists but contains no 'lineage_props_metadata' key, no
887+
lineage props are added."""
888+
geff_md = geff.GeffMetadata(
889+
directed=True,
890+
node_props_metadata={},
891+
edge_props_metadata={},
892+
extra={"some_other_key": {"unrelated": "data"}},
893+
)
894+
result = _build_props_metadata(geff_md)
895+
assert result == {}
896+
897+
def test_node_edge_and_lineage_props_all_combined(
898+
self, geff_node_props_md, geff_edge_props_md, lin_props_md
899+
):
900+
"""When node, edge, and lineage props are all present without collisions,
901+
all are added to the result."""
902+
geff_md = geff.GeffMetadata(
903+
directed=True,
904+
node_props_metadata=geff_node_props_md,
905+
edge_props_metadata=geff_edge_props_md,
906+
extra={"lineage_props_metadata": lin_props_md},
907+
)
908+
result = _build_props_metadata(geff_md)
909+
assert result["frame"].prop_type == "node"
910+
assert result["position_x"].prop_type == "node"
911+
assert result["speed"].prop_type == "edge"
912+
assert result["cost"].prop_type == "edge"
913+
assert result["n_divisions"].prop_type == "lineage"
914+
assert result["displacement"].prop_type == "lineage"
915+
916+
744917
class TestGetPropUnit:
745918
"""Test cases for _get_prop_unit function."""
746919

747920
def test_unit_from_node_props_md(self, geff_node_props_md):
748921
"""When node_props_md contains the prop with a unit, return it directly."""
749922
result = _get_prop_unit("position_x", "space", geff_node_props_md, [])
750-
assert result == "micrometer"
923+
assert result == "um"
751924

752925
def test_node_props_md_unit_takes_priority_over_axes(self, geff_node_props_md):
753926
"""When the prop is in node_props_md with a unit, axes are not consulted
@@ -758,7 +931,7 @@ def test_node_props_md_unit_takes_priority_over_axes(self, geff_node_props_md):
758931
result = _get_prop_unit(
759932
"position_x", "space", geff_node_props_md, conflicting_axes
760933
)
761-
assert result == "micrometer"
934+
assert result == "um"
762935

763936
def test_fallback_to_axes_when_prop_unit_is_none(self, geff_node_props_md, geff_axes):
764937
"""When node_props_md has the prop but unit is None, fall back to axes."""
@@ -768,14 +941,14 @@ def test_fallback_to_axes_when_prop_unit_is_none(self, geff_node_props_md, geff_
768941
def test_fallback_to_axes_when_node_props_md_is_none(self, geff_axes):
769942
"""When node_props_md is None, fall back to axes."""
770943
result = _get_prop_unit("position_x", "space", None, geff_axes)
771-
assert result == "micrometer"
944+
assert result == "um"
772945

773946
def test_fallback_to_axes_when_prop_not_in_node_props_md(
774947
self, geff_node_props_md, geff_axes
775948
):
776949
"""When prop is absent from node_props_md, fall back to axes."""
777950
result = _get_prop_unit("position_y", "space", geff_node_props_md, geff_axes)
778-
assert result == "micrometer"
951+
assert result == "um"
779952

780953
def test_returns_none_when_no_unit_found_anywhere(self, geff_node_props_md):
781954
"""When unit is None in node_props_md and the prop has no matching axis, return None."""
@@ -821,7 +994,7 @@ def test_returns_both_time_and_space_unit(self, geff_axes):
821994
geff_md, "frame", "position_x", "position_y", None
822995
)
823996
assert result["time_unit"] == "second"
824-
assert result["space_unit"] == "micrometer"
997+
assert result["space_unit"] == "um"
825998

826999
def test_no_time_unit_warns_and_absent_from_result(self, geff_axes):
8271000
"""When no unit is found for the time property, warn and omit time_unit."""
@@ -831,7 +1004,7 @@ def test_no_time_unit_warns_and_absent_from_result(self, geff_axes):
8311004
geff_md, "unknown_time", "position_x", None, None
8321005
)
8331006
assert "time_unit" not in result
834-
assert result["space_unit"] == "micrometer"
1007+
assert result["space_unit"] == "um"
8351008

8361009
def test_all_space_props_none_warns(self, geff_axes):
8371010
"""When all space props are None, warn and omit space_unit."""
@@ -852,7 +1025,7 @@ def test_multiple_space_units_warns(self):
8521025
"""When x and y props have different units, warn and omit space_unit."""
8531026
mixed_axes = [
8541027
geff_spec.Axis(name="frame", type="time", unit="second"),
855-
geff_spec.Axis(name="position_x", type="space", unit="micrometer"),
1028+
geff_spec.Axis(name="position_x", type="space", unit="um"),
8561029
geff_spec.Axis(name="position_y", type="space", unit="millimeter"),
8571030
]
8581031
geff_md = self._make_geff_md(axes=mixed_axes)
@@ -866,14 +1039,14 @@ def test_partial_space_props_single_unit(self, geff_axes):
8661039
"""When only x_prop is provided and has a unit, space_unit is set."""
8671040
geff_md = self._make_geff_md(axes=geff_axes)
8681041
result = _extract_axes_metadata(geff_md, "frame", "position_x", None, None)
869-
assert result["space_unit"] == "micrometer"
1042+
assert result["space_unit"] == "um"
8701043

8711044
def test_space_unit_from_node_props_md(self, geff_node_props_md):
8721045
"""When unit comes from node_props_md (no axes), space_unit is set correctly."""
8731046
geff_md = self._make_geff_md(axes=None, node_props_md=geff_node_props_md)
8741047
with pytest.warns(UserWarning, match="No unit found for time property"):
8751048
result = _extract_axes_metadata(geff_md, "frame", "position_x", None, None)
876-
assert result["space_unit"] == "micrometer"
1049+
assert result["space_unit"] == "um"
8771050
assert "time_unit" not in result
8781051

8791052

@@ -961,7 +1134,7 @@ def test_units_merged_from_axes(self, geff_md_axes):
9611134
"/some/tracks.geff", geff_md_axes, "frame", "position_x", None, None
9621135
)
9631136
assert result["time_unit"] == "second"
964-
assert result["space_unit"] == "micrometer"
1137+
assert result["space_unit"] == "um"
9651138

9661139
def test_missing_units_produce_warnings_and_absent_from_result(self):
9671140
"""When no units are found, appropriate warnings are raised and

0 commit comments

Comments
 (0)