Skip to content

Commit c0b9465

Browse files
committed
[r] Add support for HCA tissue atlas (#7128)
1 parent 7d00a14 commit c0b9465

7 files changed

Lines changed: 172 additions & 29 deletions

File tree

src/azul/field_type.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
)
2727

2828
from more_itertools import (
29-
first,
3029
one,
3130
)
3231

@@ -431,13 +430,23 @@ def from_index(self, value: str) -> str | None:
431430

432431
class Nested(PassThrough[JSON]):
433432
properties: Mapping[str, FieldType]
434-
agg_property: str
435433

436434
def __init__(self, **properties):
437435
super().__init__(JSON, es_type='nested')
438-
self.agg_property = first(properties.keys())
439436
self.properties = properties
440437

438+
def to_index(self, value: JSON) -> JSON:
439+
return {
440+
field: field_type.to_index(value[field])
441+
for field, field_type in self.properties.items()
442+
}
443+
444+
def from_index(self, value: JSON) -> JSON:
445+
return {
446+
field: field_type.from_index(value[field])
447+
for field, field_type in self.properties.items()
448+
}
449+
441450
def api_filter_values_schema(self, operator: str, mode: Mode) -> JSON:
442451
assert operator == 'is'
443452
schema = super().api_filter_values_schema(operator, mode)

src/azul/indexer/document_service.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ def field_type(self, catalog: CatalogName, path: FieldPath) -> FieldType:
9696
if isinstance(field_types, Nested):
9797
element = next(elements, None)
9898
if element is not None:
99-
assert element == field_types.agg_property, (element, field_types)
10099
field_types = field_types.properties[element]
101100
assert isinstance(field_types, FieldType), (path, field_types)
102101
element = next(elements, None)

src/azul/plugins/metadata/hca/service/response.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -559,8 +559,10 @@ def file_type_summary(aggregate_file: JSON) -> FileTypeSummaryForHit:
559559
return summarized_hit
560560

561561
def make_terms(self, agg) -> Terms:
562-
def choose_entry(_term):
563-
if 'key_as_string' in _term:
562+
def choose_entry(keys, _term):
563+
if keys is not None:
564+
return dict(zip(keys, _term['key']))
565+
elif 'key_as_string' in _term:
564566
return _term['key_as_string']
565567
elif (term_key := _term['key']) is None:
566568
return None
@@ -573,8 +575,18 @@ def choose_entry(_term):
573575

574576
terms: list[Term] = []
575577
for bucket in agg['myTerms']['buckets']:
576-
term = Term(term=choose_entry(bucket),
577-
count=bucket['doc_count'])
578+
if 'reverseNested' in bucket:
579+
# For nested fields, we want the count from the reverse nested
580+
# aggregation which tells us how many (parent) documents had the
581+
# nested field term, and not bucket['doc_count'] which is how
582+
# many times the nested field term was in a single document.
583+
doc_count = bucket['reverseNested']['doc_count']
584+
field_names = [path[-1] for path in agg['myTerms']['meta']['paths']]
585+
else:
586+
doc_count = bucket['doc_count']
587+
field_names = None
588+
term = Term(term=choose_entry(field_names, bucket),
589+
count=doc_count)
578590
try:
579591
sub_agg = bucket['myProjectIds']
580592
except KeyError:

src/azul/service/query_service.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
)
3636
from opensearchpy.helpers.aggs import (
3737
Agg,
38+
MultiTerms,
3839
Terms,
3940
)
4041
from opensearchpy.helpers.query import (
@@ -327,16 +328,33 @@ def _prepare_aggregation(self, *, facet: str, facet_path: FieldPath) -> Agg:
327328

328329
field_type = self.service.field_type(self.catalog, facet_path)
329330
if isinstance(field_type, Nested):
330-
nested_agg = agg.bucket(name='nested',
331-
agg_type='nested',
332-
path=dotted(facet_path))
333-
facet_path = dotted(facet_path, field_type.agg_property)
334-
path = dotted(facet_path, 'keyword')
335-
nested_agg.bucket(name='myTerms',
336-
agg_type='terms',
337-
field=path,
338-
size=config.terms_aggregation_size)
339-
nested_agg.bucket('untagged', 'missing', field=path)
331+
path = dotted(facet_path)
332+
# A nested aggregation to aggregate on fields inside a nested field
333+
agg.bucket(name='nested',
334+
agg_type='nested',
335+
path=path)
336+
# A multi-terms aggregation to form composite keys made from the
337+
# fields inside a nested field
338+
agg.aggs.nested.bucket(name='myTerms',
339+
agg_type='multi_terms',
340+
terms=[
341+
{'field': path + f'.{field}.keyword'}
342+
for field in field_type.properties
343+
],
344+
size=config.terms_aggregation_size)
345+
# A reverse nested aggregation so that a doc_count can be obtained
346+
# which reflects the parent documents, not the separate documents
347+
# created for the nested field
348+
agg.aggs.nested.aggs.myTerms.bucket(name='reverseNested',
349+
agg_type='reverse_nested')
350+
# A filter aggregation to work around that we can't use a missing
351+
# aggregation with a nested field.
352+
# See https://github.com/elastic/elasticsearch/issues/9571
353+
agg.bucket(name='untagged',
354+
agg_type='filter',
355+
filter=Q('bool', must_not=[
356+
Q('nested', path=path, query=Q('exists', field=path))
357+
]))
340358
else:
341359
path = dotted(facet_path, 'keyword')
342360
# FIXME: Approximation errors for terms aggregation are unchecked
@@ -356,13 +374,20 @@ def _annotate_aggs_for_translation(self, request: Search):
356374
"""
357375

358376
def annotate(agg: Agg):
359-
if isinstance(agg, Terms):
360-
path = agg.field.split('.')
361-
if path[-1] == 'keyword':
362-
path.pop()
377+
if isinstance(agg, (Terms, MultiTerms)):
363378
if not hasattr(agg, 'meta'):
364379
agg.meta = {}
365-
agg.meta['path'] = path
380+
if hasattr(agg, 'terms'):
381+
# A MultiTerms agg contains multiple fields, and we need the
382+
# path of each one. We store the paths in the same order the
383+
# fields occur in the `terms` list.
384+
agg.meta['paths'] = []
385+
for term in agg.terms:
386+
path = term['field'].removesuffix('.keyword').split('.')
387+
agg.meta['paths'].append(path)
388+
else:
389+
path = agg.field.removesuffix('.keyword').split('.')
390+
agg.meta['path'] = path
366391
if hasattr(agg, 'aggs'):
367392
subs = agg.aggs
368393
for sub_name in subs:
@@ -395,6 +420,7 @@ def translate(k, v: MutableJSON):
395420
translate(k, v)
396421
else:
397422
try:
423+
# From a Terms aggregation
398424
path = v['meta']['path']
399425
except KeyError:
400426
pass
@@ -404,6 +430,7 @@ def translate(k, v: MutableJSON):
404430
bucket['key'] = field_type.from_index(bucket['key'])
405431
translate(k, bucket)
406432
try:
433+
# From a MultiTerms aggregation
407434
paths = v['meta']['paths']
408435
except KeyError:
409436
pass

test/indexer/test_indexer.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1746,6 +1746,42 @@ def test_organoid_priority(self):
17461746
self.assertEqual(inner_cell_suspensions_in_contributions + inner_cell_suspensions_in_aggregates,
17471747
inner_cell_suspensions)
17481748

1749+
def test_nested_field_aggregation(self):
1750+
bundles = [
1751+
# Bundles with the following tissue_atlas (atlas/version) values:
1752+
# [None/None (x2), Lung/None, Retina/v1.0, Blood/v1.0]
1753+
self.bundle_fqid(uuid='2c7d06b8-658e-4c51-9de4-a768322f84c5',
1754+
version='2021-09-21T17:27:23.898000Z'),
1755+
# [Blood/v1.0]
1756+
self.bundle_fqid(uuid='587d74b4-1075-4bbf-b96a-4d1ede0481b2',
1757+
version='2018-10-10T02:23:43.182000Z'),
1758+
# [] (none)
1759+
self.bundle_fqid(uuid='97f0cc83-f0ac-417a-8a29-221c77debde8',
1760+
version='2019-10-14T19:54:15.397406Z')
1761+
]
1762+
for bundle in bundles:
1763+
self._index_canned_bundle(bundle)
1764+
hits = self._get_all_hits()
1765+
expected = {
1766+
'50151324-f3ed-4358-98af-ec352a940a61': [
1767+
{'atlas': '~null', 'version': '~null'},
1768+
{'atlas': '~null', 'version': '~null'},
1769+
{'atlas': 'Lung', 'version': '~null'},
1770+
{'atlas': 'Retina', 'version': 'v1.0'},
1771+
{'atlas': 'Blood', 'version': 'v1.0'}
1772+
],
1773+
'6615efae-fca8-4dd2-a223-9cfcf30fe94d': [
1774+
{'atlas': 'Blood', 'version': 'v1.0'}
1775+
],
1776+
'4e6f083b-5b9a-4393-9890-2a83da8188f1': [
1777+
]
1778+
}
1779+
for hit in self._filter_hits(hits, DocumentType.aggregate, 'projects'):
1780+
contents = hit['_source']['contents']
1781+
project = cast(JSON, one(contents['projects']))
1782+
project_id = project['document_id']
1783+
self.assertEqual(expected[project_id], project['tissue_atlas'])
1784+
17491785
def test_accessions_fields(self):
17501786
bundle_fqid = self.bundle_fqid(uuid='fa5be5eb-2d64-49f5-8ed8-bd627ac9bc7a',
17511787
version='2019-02-14T19:24:38.034764Z')

test/service/test_app_logging.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def filter_body(organ: str) -> JSON:
153153
elif debug == 1:
154154
expected_log = f'… with a response body starting in {body[:prefix_len]}'
155155
elif debug > 1:
156-
expected_log = f'… with a response body of length 9137 being {body}'
156+
expected_log = f'… with a response body of length 9163 being {body}'
157157
else:
158158
assert False
159159
self.assertEqual(expected_log, body_log_message)

test/service/test_response.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2624,6 +2624,64 @@ def from_response(cls, hit: JSON) -> Self:
26242624
})
26252625

26262626

2627+
class TestNestedFieldAggregation(IndexResponseTestCase):
2628+
maxDiff = None
2629+
2630+
@classmethod
2631+
def bundles(cls) -> list[BundleFQID]:
2632+
return [
2633+
# 1 file, 1 sample
2634+
# tissue_atlas=[None/None (x2), Lung/None, Retina/v1.0, Blood/v1.0]
2635+
cls.bundle_fqid(uuid='2c7d06b8-658e-4c51-9de4-a768322f84c5',
2636+
version='2021-09-21T17:27:23.898000Z'),
2637+
# 20 files, 1 sample
2638+
# tissue_atlas=[Blood/v1.0]
2639+
cls.bundle_fqid(uuid='587d74b4-1075-4bbf-b96a-4d1ede0481b2',
2640+
version='2018-10-10T02:23:43.182000Z'),
2641+
# 2 files, 1 sample
2642+
# tissue_atlas=[] (none)
2643+
cls.bundle_fqid(uuid='97f0cc83-f0ac-417a-8a29-221c77debde8',
2644+
version='2019-10-14T19:54:15.397406Z'),
2645+
]
2646+
2647+
@classmethod
2648+
def setUpClass(cls):
2649+
super().setUpClass()
2650+
cls._setup_indices()
2651+
2652+
@classmethod
2653+
def tearDownClass(cls):
2654+
cls._teardown_indices()
2655+
super().tearDownClass()
2656+
2657+
def test_nested_field_facet(self):
2658+
tissue_atlas_terms = [
2659+
{'atlas': 'Blood', 'version': 'v1.0'},
2660+
{'atlas': None, 'version': None},
2661+
{'atlas': 'Lung', 'version': None},
2662+
{'atlas': 'Retina', 'version': 'v1.0'},
2663+
None,
2664+
]
2665+
tissue_atlas_term_counts = {
2666+
'projects': [2, 1, 1, 1, 1],
2667+
'bundles': [2, 1, 1, 1, 1],
2668+
'samples': [2, 1, 1, 1, 1],
2669+
'files': [21, 1, 1, 1, 2]
2670+
}
2671+
for entity_type, counts in tissue_atlas_term_counts.items():
2672+
with self.subTest(entity_type=entity_type):
2673+
url = self.base_url.set(path='/index/' + entity_type,
2674+
args=(self._params(size=1)))
2675+
response = requests.get(str(url))
2676+
response.raise_for_status()
2677+
response_json = response.json()
2678+
facets = response_json['termFacets']
2679+
expected = []
2680+
for count, term in zip(counts, tissue_atlas_terms):
2681+
expected.append({'count': count, 'term': term})
2682+
self.assertElasticEqual(expected, facets['tissueAtlas']['terms'])
2683+
2684+
26272685
class TestSortAndFilterByCellCount(IndexResponseTestCase):
26282686
maxDiff = None
26292687

@@ -3568,11 +3626,13 @@ def test_projects_response(self):
35683626

35693627
tissue_atlas = response_json['termFacets']['tissueAtlas']
35703628
self.assertEqual(5, tissue_atlas['total'])
3571-
terms = {
3572-
entry['term']: entry['count']
3573-
for entry in tissue_atlas['terms']
3574-
}
3575-
self.assertEqual({None: 2, 'Lung': 1, 'Retina': 1, 'Blood': 1}, terms)
3629+
expected_tissue_atlas_terms = [
3630+
{'count': 1, 'term': {'atlas': None, 'version': None}},
3631+
{'count': 1, 'term': {'atlas': 'Blood', 'version': 'v1.0'}},
3632+
{'count': 1, 'term': {'atlas': 'Lung', 'version': None}},
3633+
{'count': 1, 'term': {'atlas': 'Retina', 'version': 'v1.0'}},
3634+
]
3635+
self.assertEqual(expected_tissue_atlas_terms, tissue_atlas['terms'])
35763636

35773637
def test_data_use_and_duos_id(self):
35783638
test_data = [

0 commit comments

Comments
 (0)