Skip to content

Commit 008685b

Browse files
committed
code for building cell type and region hierarchies
1 parent 2bb3fa1 commit 008685b

4 files changed

Lines changed: 565 additions & 1 deletion

File tree

src/test/test_hierarchy.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Tests for get_hierarchy function.
2+
3+
Tests the hierarchy tree builder for both part_of (brain region structure)
4+
and subclass_of (cell type hierarchies), in both ancestor and descendant
5+
directions.
6+
"""
7+
8+
import pytest
9+
10+
from vfbquery.vfb_queries import get_hierarchy
11+
12+
13+
# Known test terms
14+
MUSHROOM_BODY = "FBbt_00005801"
15+
KENYON_CELL = "FBbt_00003686"
16+
17+
18+
class TestHierarchyValidation:
19+
def test_invalid_relationship_raises(self):
20+
with pytest.raises(ValueError, match="relationship"):
21+
get_hierarchy(KENYON_CELL, relationship="invalid")
22+
23+
def test_invalid_direction_raises(self):
24+
with pytest.raises(ValueError, match="direction"):
25+
get_hierarchy(KENYON_CELL, direction="invalid")
26+
27+
28+
class TestSubclassOfDescendants:
29+
@pytest.mark.integration
30+
def test_returns_descendants(self):
31+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'descendants', max_depth=1)
32+
assert result['id'] == KENYON_CELL
33+
assert result['label'] == 'Kenyon cell'
34+
assert result['relationship'] == 'subclass_of'
35+
assert 'descendants' in result
36+
assert len(result['descendants']) > 0
37+
38+
@pytest.mark.integration
39+
def test_descendants_have_id_and_label(self):
40+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'descendants', max_depth=1)
41+
for child in result['descendants']:
42+
assert 'id' in child
43+
assert 'label' in child
44+
assert child['id'].startswith('FBbt_')
45+
assert child['label'] != child['id'] # label should be resolved
46+
47+
@pytest.mark.integration
48+
def test_depth_1_has_no_grandchildren(self):
49+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'descendants', max_depth=1)
50+
for child in result['descendants']:
51+
assert 'descendants' not in child
52+
53+
@pytest.mark.integration
54+
def test_depth_2_has_nested_children(self):
55+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'descendants', max_depth=2)
56+
has_grandchildren = any('descendants' in child for child in result['descendants'])
57+
assert has_grandchildren, "At least one direct subclass should have its own subclasses"
58+
59+
60+
class TestSubclassOfAncestors:
61+
@pytest.mark.integration
62+
def test_returns_ancestors(self):
63+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'ancestors', max_depth=1)
64+
assert 'ancestors' in result
65+
assert len(result['ancestors']) > 0
66+
67+
@pytest.mark.integration
68+
def test_ancestors_have_id_and_label(self):
69+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'ancestors', max_depth=1)
70+
for anc in result['ancestors']:
71+
assert 'id' in anc
72+
assert 'label' in anc
73+
74+
@pytest.mark.integration
75+
def test_kenyon_cell_ancestor_is_mb_intrinsic_neuron(self):
76+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'ancestors', max_depth=1)
77+
ancestor_ids = [a['id'] for a in result['ancestors']]
78+
assert 'FBbt_00007484' in ancestor_ids # mushroom body intrinsic neuron
79+
80+
@pytest.mark.integration
81+
def test_depth_2_has_nested_ancestors(self):
82+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'ancestors', max_depth=2)
83+
has_grandparent = any('ancestors' in anc for anc in result['ancestors'])
84+
assert has_grandparent
85+
86+
87+
class TestPartOfDescendants:
88+
@pytest.mark.integration
89+
def test_returns_parts(self):
90+
result = get_hierarchy(MUSHROOM_BODY, 'part_of', 'descendants', max_depth=1)
91+
assert result['id'] == MUSHROOM_BODY
92+
assert result['label'] == 'mushroom body'
93+
assert 'descendants' in result
94+
assert len(result['descendants']) > 0
95+
96+
@pytest.mark.integration
97+
def test_parts_have_id_and_label(self):
98+
result = get_hierarchy(MUSHROOM_BODY, 'part_of', 'descendants', max_depth=1)
99+
for part in result['descendants']:
100+
assert 'id' in part
101+
assert 'label' in part
102+
assert part['id'].startswith('FBbt_')
103+
104+
105+
class TestPartOfAncestors:
106+
@pytest.mark.integration
107+
def test_mushroom_body_part_of_protocerebrum(self):
108+
result = get_hierarchy(MUSHROOM_BODY, 'part_of', 'ancestors', max_depth=1)
109+
assert 'ancestors' in result
110+
ancestor_ids = [a['id'] for a in result['ancestors']]
111+
assert 'FBbt_00003627' in ancestor_ids # protocerebrum
112+
113+
114+
class TestBothDirections:
115+
@pytest.mark.integration
116+
def test_both_returns_ancestors_and_descendants(self):
117+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'both', max_depth=1)
118+
assert 'ancestors' in result
119+
assert 'descendants' in result
120+
assert len(result['ancestors']) > 0
121+
assert len(result['descendants']) > 0
122+
123+
@pytest.mark.integration
124+
def test_descendants_only_has_no_ancestors(self):
125+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'descendants', max_depth=1)
126+
assert 'descendants' in result
127+
assert 'ancestors' not in result
128+
129+
@pytest.mark.integration
130+
def test_ancestors_only_has_no_descendants(self):
131+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'ancestors', max_depth=1)
132+
assert 'ancestors' in result
133+
assert 'descendants' not in result

src/vfbquery/ha_api.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,39 @@ def post_fn(result):
897897
)
898898

899899

900+
def _run_get_hierarchy(short_form, relationship, direction, max_depth):
901+
"""Worker: run get_hierarchy in a subprocess."""
902+
from . import vfb_queries as _vfb
903+
return _convert_numpy_types(
904+
_vfb.get_hierarchy(short_form, relationship=relationship,
905+
direction=direction, max_depth=max_depth)
906+
)
907+
908+
909+
async def handle_get_hierarchy(request):
910+
"""GET /get_hierarchy?id=FBbt_00005801&relationship=part_of&direction=both&max_depth=1"""
911+
short_form = request.query.get("id")
912+
if not short_form:
913+
return web.json_response({"error": "id parameter is required"}, status=400)
914+
relationship = request.query.get("relationship", "part_of")
915+
if relationship not in ("part_of", "subclass_of"):
916+
return web.json_response(
917+
{"error": "relationship must be 'part_of' or 'subclass_of'"}, status=400
918+
)
919+
direction = request.query.get("direction", "both")
920+
if direction not in ("descendants", "ancestors", "both"):
921+
return web.json_response(
922+
{"error": "direction must be 'descendants', 'ancestors', or 'both'"}, status=400
923+
)
924+
max_depth = int(request.query.get("max_depth", "1"))
925+
926+
key = f"get_hierarchy:{short_form}:{relationship}:{direction}:{max_depth}"
927+
return await _dispatch_to_pool(
928+
request, key, _run_get_hierarchy,
929+
short_form, relationship, direction, max_depth,
930+
)
931+
932+
900933
# ---------------------------------------------------------------------------
901934
# Application factory
902935
# ---------------------------------------------------------------------------
@@ -937,6 +970,7 @@ def create_app(max_workers=None, max_concurrent=None, max_queue_depth=None,
937970
app.router.add_get("/find_combo_publications", handle_find_combo_publications)
938971
app.router.add_get("/list_connectome_datasets", handle_list_connectome_datasets)
939972
app.router.add_get("/query_connectivity", handle_query_connectivity)
973+
app.router.add_get("/get_hierarchy", handle_get_hierarchy)
940974

941975
# Store config for /status and handlers
942976
app["max_workers"] = max_workers

src/vfbquery/owlery_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def convert_short_form_to_iri(match):
105105
# Based on VFBConnect's query() method
106106
params = {
107107
'object': iri_query,
108-
'direct': 'false', # Always use indirect (transitive) queries
108+
'direct': 'true' if direct else 'false',
109109
'includeDeprecated': 'false', # Exclude deprecated terms
110110
'includeEquivalent': 'true' # Include equivalent classes
111111
}

0 commit comments

Comments
 (0)