Skip to content

Commit cccec70

Browse files
authored
refactor: allow passing strings to default_grouping, also build docs (#1648)
* refactor: allow un-tupled string to make life easier * docs: update * fix: actually allow str | * tests: fix tests, and properly handle input data that looks like new data * docs: improve how allow_tag_failures is explained * chore: lint * fix: overenthusiastic defensiveness
1 parent d59d48f commit cccec70

File tree

9 files changed

+127
-31
lines changed

9 files changed

+127
-31
lines changed

docs/base/core/quality_control.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ tags = {
3939
}
4040
```
4141

42-
Use the `QualityControl.default_grouping` list to define how users should organize a visualization by default. In almost all cases *modality should be the top-level grouping*. For example, building on the example above you might group by: `[["modality"], ["probe", "video"], ["shank"]]` to get a tree split by modality first (which naturally splits ephys and behavior-videos tags into two groups), then by which probe or video a metric belongs to, and finally only for probes the individual shanks are split into groups.
42+
Use the `QualityControl.default_grouping` list to define how users should organize a visualization by default. In almost all cases *modality should be the top-level grouping*. For example, building on the example above you might group by: `["modality", ("probe", "video"), "shank"]` to get a tree split by modality first (which naturally splits ephys and behavior-videos tags into two groups), then by which probe or video a metric belongs to, and finally only for probes the individual shanks are split into groups.
4343

4444
### QualityControl.evaluate_status()
4545

4646
You can evaluate the state of a set of metrics filtered by any combination of modalities, stages, and tags on a specific date (by default, today). When evaluating the [Status](#status) of a group of metrics the following rules apply:
4747

48-
First, any metric that is failing and also has a matching tag *value* in the `QualityControl.allow_tag_failures` list is set to pass. This allows you to specify that certain metrics are not critical to a data asset.
48+
First, any metric that has a tag *value* in the `QualityControl.allow_tag_failures` list is ignored. This allows you to specify that certain metrics are not critical to a data asset.
4949

50-
Then, given the status of all the metrics in the group:
50+
Then, given the status of all the remaining metrics in the group:
5151

5252
1. If any metric is still failing, the evaluation fails
5353
2. If any metric is pending and the rest pass the evaluation is pending

docs/source/components/identifiers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Code or script identifier
1212
| `name` | `Optional[str]` | Name |
1313
| `version` | `Optional[str]` | Code version |
1414
| `container` | Optional[[Container](#container)] | Container |
15-
| `run_script` | `Optional[pathlib.Path]` | Run script (Path to run script) |
15+
| `run_script` | `Optional[pathlib._local.Path]` | Run script (Path to run script) |
1616
| `language` | `Optional[str]` | Programming language (Programming language used) |
1717
| `language_version` | `Optional[str]` | Programming language version |
1818
| `input_data` | Optional[List[[DataAsset](#dataasset) or [CombinedData](#combineddata)]] | Input data (Input data used in the code or script) |

docs/source/components/subjects.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Description of breeding info for subject
88

99
| Field | Type | Title (Description) |
1010
|-------|------|-------------|
11-
| <del>`breeding_group`</del> | `str` | **[DEPRECATED]** Field will be removed in future releases. Breeding Group |
11+
| <del>`breeding_group`</del> | `Optional[str]` | **[DEPRECATED]** Field will be removed in future releases. Breeding Group |
1212
| `maternal_id` | `str` | Maternal specimen ID |
1313
| `maternal_genotype` | `str` | Maternal genotype |
1414
| `paternal_id` | `str` | Paternal specimen ID |

docs/source/quality_control.md

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,30 @@ If you find yourself computing a value for something smaller than an entire moda
2424

2525
### Tags
2626

27-
`tags` are any string that naturally groups sets of metrics together. Good tags are things like: "Probe A", "Motion correction", and "Pose tracking". The stage and modality are automatically treated as tags, you do not need to include them in the tags list.
27+
`tags` are groups of descriptors that define how metrics are organized hierarchically, making it easier to visualize metrics. Good tag keys (groups) are things like "probe" and good tag values are things like "Probe A" or just "A".
28+
29+
```{python}
30+
# For an electrophysiology metric
31+
tags = {
32+
"probe": "A",
33+
"shank": "0",
34+
}
35+
36+
# For a behavioral video metric
37+
tags = {
38+
"video": "left body",
39+
}
40+
```
41+
42+
Use the `QualityControl.default_grouping` list to define how users should organize a visualization by default. In almost all cases *modality should be the top-level grouping*. For example, building on the example above you might group by: `["modality", ("probe", "video"), "shank"]` to get a tree split by modality first (which naturally splits ephys and behavior-videos tags into two groups), then by which probe or video a metric belongs to, and finally only for probes the individual shanks are split into groups.
2843

2944
### QualityControl.evaluate_status()
3045

3146
You can evaluate the state of a set of metrics filtered by any combination of modalities, stages, and tags on a specific date (by default, today). When evaluating the [Status](#status) of a group of metrics the following rules apply:
3247

33-
First, any metric that is failing and also has a matching tag (or tuple of tags) in the `QualityControl.allow_tag_failures` list is set to pass. This allows you to specify that certain metrics are not critical to a data asset.
48+
First, any metric that has a tag *value* in the `QualityControl.allow_tag_failures` list is ignored. This allows you to specify that certain metrics are not critical to a data asset.
3449

35-
Then, given the status of all the metrics in the group:
50+
Then, given the status of all the remaining metrics in the group:
3651

3752
1. If any metric is still failing, the evaluation fails
3853
2. If any metric is pending and the rest pass the evaluation is pending
@@ -72,8 +87,8 @@ Collection of quality control metrics evaluated on a data asset to determine pas
7287
| `metrics` | List[[QCMetric](quality_control.md#qcmetric) or [CurationMetric](quality_control.md#curationmetric)] | Evaluations |
7388
| `key_experimenters` | `Optional[List[str]]` | Key experimenters (Experimenters who are responsible for quality control of this data asset) |
7489
| `notes` | `Optional[str]` | Notes |
75-
| `default_grouping` | `List[str]` | Default grouping (Default tag grouping for this QualityControl object, used in visualizations) |
76-
| `allow_tag_failures` | `List[str or tuple]` | Allow tag failures (List of tags that are allowed to fail without failing the overall QC) |
90+
| `default_grouping` | `List[str or tuple[str, ...]]` | Default grouping (Tag *keys* that should be used to group metrics hierarchically for visualization) |
91+
| `allow_tag_failures` | `List[str]` | Allow tag failures (List of tag *values* that are allowed to fail without failing the overall QC) |
7792
| `status` | `Optional[dict]` | Status mapping (Mapping of tags, modalities, and stages to their evaluated status, automatically computed) |
7893

7994

@@ -104,7 +119,7 @@ Description of a curation metric
104119
| `status_history` | List[[QCStatus](quality_control.md#qcstatus)] | Metric status history |
105120
| `description` | `Optional[str]` | Metric description |
106121
| `reference` | `Optional[str]` | Metric reference image URL or plot type |
107-
| `tags` | `List[str]` | Tags (Tags group QCMetric objects to allow for grouping and filtering) |
122+
| `tags` | `Dict[str, str]` | Tags (Tags group QCMetric objects. Unique keys define groups of tags, for example {'probe': 'probeA'}.) |
108123
| `evaluated_assets` | `Optional[List[str]]` | List of asset names that this metric depends on (Set to None except when a metric's calculation required data coming from a different data asset.) |
109124

110125

@@ -121,7 +136,7 @@ Description of a single quality control metric
121136
| `status_history` | List[[QCStatus](quality_control.md#qcstatus)] | Metric status history |
122137
| `description` | `Optional[str]` | Metric description |
123138
| `reference` | `Optional[str]` | Metric reference image URL or plot type |
124-
| `tags` | `List[str]` | Tags (Tags group QCMetric objects to allow for grouping and filtering) |
139+
| `tags` | `Dict[str, str]` | Tags (Tags group QCMetric objects. Unique keys define groups of tags, for example {'probe': 'probeA'}.) |
125140
| `evaluated_assets` | `Optional[List[str]]` | List of asset names that this metric depends on (Set to None except when a metric's calculation required data coming from a different data asset.) |
126141

127142

examples/exaspim_quality_control.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@
134134
),
135135
]
136136

137-
quality_control = QualityControl(metrics=metrics, default_grouping=[["neuron_id"]])
137+
quality_control = QualityControl(metrics=metrics, default_grouping=["neuron_id"])
138138

139139
serialized = quality_control.model_dump_json()
140140
deserialized = QualityControl.model_validate_json(serialized)

examples/quality_control.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@
133133
q = QualityControl(
134134
metrics=metrics,
135135
# in visualizations split first by modality, then by probe / video tags
136-
default_grouping=[["modality"], ["probe", "video"]],
136+
default_grouping=["modality", ("probe", "video")],
137137
# allow any metrics with tag video: Video 2 to fail without failing overall QC
138138
allow_tag_failures=["Video 2"],
139139
)

src/aind_data_schema/core/quality_control.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ class QualityControl(DataCoreModel):
128128
)
129129
notes: Optional[str] = Field(default=None, title="Notes")
130130

131-
default_grouping: List[tuple[str, ...]] = Field(
131+
default_grouping: List[str | tuple[str, ...]] = Field(
132132
...,
133133
title="Default grouping",
134134
description="Tag *keys* that should be used to group metrics hierarchically for visualization",
@@ -283,9 +283,13 @@ def fix_default_grouping_list(cls, value: dict) -> dict:
283283
"""
284284
if "default_grouping" not in value:
285285
return value
286-
if value["default_grouping"] and isinstance(value["default_grouping"][0], str):
287-
# Add the modality as the top-level grouping, then tag_1 as the second level, similar to old portal behavior
288-
value["default_grouping"] = [["modality"], ["tag_1"]]
286+
287+
if all(isinstance(item, str) for item in value["default_grouping"]):
288+
first_metric = value["metrics"][0]
289+
if isinstance(first_metric, dict) and "tags" in first_metric:
290+
if isinstance(first_metric["tags"], list):
291+
value["default_grouping"] = [["modality"], ["tag_1"]]
292+
289293
return value
290294

291295

tests/test_composability_merge.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,12 @@ def test_merge_quality_control(self):
5555

5656
q1 = QualityControl(
5757
metrics=metrics,
58-
default_grouping=[("group1",)],
58+
default_grouping=["group1"],
5959
)
6060

6161
q2 = QualityControl(
6262
metrics=metrics + metrics,
63-
default_grouping=[("group1",)],
63+
default_grouping=["group1"],
6464
)
6565

6666
q3 = q1 + q2
@@ -97,14 +97,14 @@ def test_merge_quality_control(self):
9797
q1 = QualityControl(
9898
metrics=metrics,
9999
key_experimenters=["Alice", "Bob"],
100-
default_grouping=[("group1",), ("group1", "group2")],
100+
default_grouping=["group1", ("group1", "group2")],
101101
allow_tag_failures=["FailTag1", "FailTag2"],
102102
)
103103

104104
q2 = QualityControl(
105105
metrics=metrics,
106106
key_experimenters=["Bob", "Charlie"], # Bob is duplicate
107-
default_grouping=[("group1", "group2"), ("group3",)], # ("group1", "group2") is duplicate
107+
default_grouping=[("group1", "group2"), "group3"], # ("group1", "group2") is duplicate
108108
allow_tag_failures=["FailTag2", "FailTag3"], # FailTag2 is duplicate
109109
)
110110

@@ -125,7 +125,7 @@ def test_merge_quality_control(self):
125125
self.assertEqual(set(q3.key_experimenters), {"Alice", "Bob", "Charlie"})
126126

127127
self.assertEqual(q3.default_grouping.count(("group1", "group2")), 1) # Should be deduplicated
128-
self.assertEqual(set(q3.default_grouping), {("group1",), ("group1", "group2"), ("group3",)})
128+
self.assertEqual(set(q3.default_grouping), {"group1", ("group1", "group2"), "group3"})
129129

130130
self.assertEqual(q3.allow_tag_failures.count("FailTag2"), 1) # Should be deduplicated
131131
self.assertEqual(set(q3.allow_tag_failures), {"FailTag1", "FailTag2", "FailTag3"})

tests/test_quality_control.py

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@ def test_tags_list_to_dict_conversion(self):
3737
"modality": {"name": "Extracellular electrophysiology", "abbreviation": "ecephys"},
3838
"stage": "Processing",
3939
"value": 42,
40-
"status_history": [
41-
{"evaluator": "Test", "timestamp": "2020-10-10", "status": "Pass"}
42-
],
40+
"status_history": [{"evaluator": "Test", "timestamp": "2020-10-10", "status": "Pass"}],
4341
"tags": ["tag1", "tag2", "tag3"],
4442
}
4543

@@ -79,7 +77,7 @@ def test_overall_status(self):
7977

8078
self.assertEqual(test_metrics[0].status.status, Status.PASS)
8179

82-
q = QualityControl(metrics=test_metrics + test_metrics, default_grouping=[("group")]) # duplicate the metrics
80+
q = QualityControl(metrics=test_metrics + test_metrics, default_grouping=["group"]) # duplicate the metrics
8381

8482
# check that overall status gets auto-set if it has never been set before
8583
self.assertEqual(q.evaluate_status(), Status.PASS)
@@ -149,7 +147,7 @@ def test_evaluation_status(self):
149147
),
150148
]
151149

152-
qc = QualityControl(metrics=metrics, default_grouping=[("group")])
150+
qc = QualityControl(metrics=metrics, default_grouping=["group"])
153151
self.assertEqual(qc.evaluate_status(tag="Drift map"), Status.PASS)
154152

155153
# Add a pending metric, evaluation should now evaluate to pending
@@ -223,7 +221,7 @@ def test_allowed_failed_metrics(self):
223221
# First check that a pending evaluation still evaluates properly
224222
qc = QualityControl(
225223
metrics=metrics,
226-
default_grouping=[("group")],
224+
default_grouping=["group"],
227225
)
228226

229227
self.assertEqual(qc.evaluate_status(tag="Drift map"), Status.PENDING)
@@ -480,7 +478,7 @@ def test_status_date(self):
480478

481479
# Note: The date filtering is currently not implemented in the new schema
482480
# This test would need to be updated once date filtering is implemented
483-
qc = QualityControl(metrics=[metric], default_grouping=[("group")])
481+
qc = QualityControl(metrics=[metric], default_grouping=["group"])
484482

485483
self.assertEqual(qc.evaluate_status(date=t3), Status.PASS)
486484
self.assertEqual(qc.evaluate_status(date=t2), Status.PENDING)
@@ -752,7 +750,7 @@ def test_helper_functions_integration(self):
752750

753751
qc = QualityControl(
754752
metrics=metrics,
755-
default_grouping=[("group")],
753+
default_grouping=["group"],
756754
)
757755

758756
# Test status at different times
@@ -770,6 +768,85 @@ def test_helper_functions_integration(self):
770768
late_status = qc.evaluate_status(date=late_date, tag="time_sensitive")
771769
self.assertEqual(late_status, Status.FAIL)
772770

771+
def test_backwards_compatibility_default_grouping(self):
772+
"""Test that fix_default_grouping_list validator handles old v2.2.X format correctly"""
773+
774+
# Test old v2.2.X format: list of strings for default_grouping + list-based tags
775+
old_format_dict = {
776+
"metrics": [
777+
{
778+
"object_type": "QC metric",
779+
"name": "Old format metric",
780+
"modality": {"name": "Extracellular electrophysiology", "abbreviation": "ecephys"},
781+
"stage": "Processing",
782+
"value": 42,
783+
"status_history": [{"evaluator": "Test", "timestamp": "2020-10-10", "status": "Pass"}],
784+
"tags": ["old_tag1", "old_tag2"],
785+
}
786+
],
787+
"default_grouping": ["group1", "group2"],
788+
}
789+
790+
with self.assertWarns(DeprecationWarning):
791+
qc_old = QualityControl.model_validate(old_format_dict)
792+
793+
# Should convert to [("modality",), ("tag_1",)] for backwards compatibility
794+
self.assertEqual(qc_old.default_grouping, [("modality",), ("tag_1",)])
795+
# Tags should be converted to dict
796+
self.assertEqual(qc_old.metrics[0].tags, {"tag_1": "old_tag1", "tag_2": "old_tag2"})
797+
798+
def test_new_format_default_grouping_all_strings(self):
799+
"""Test that new format with all strings in default_grouping is NOT converted"""
800+
801+
# Test new format: list of strings for default_grouping + dict-based tags
802+
new_format_dict = {
803+
"metrics": [
804+
{
805+
"object_type": "QC metric",
806+
"name": "New format metric",
807+
"modality": {"name": "Extracellular electrophysiology", "abbreviation": "ecephys"},
808+
"stage": "Processing",
809+
"value": 42,
810+
"status_history": [{"evaluator": "Test", "timestamp": "2020-10-10", "status": "Pass"}],
811+
"tags": {"group": "test_group", "probe": "probeA"},
812+
}
813+
],
814+
"default_grouping": ["group", "probe"],
815+
}
816+
817+
qc_new = QualityControl.model_validate(new_format_dict)
818+
819+
# Should NOT convert - keep as-is
820+
self.assertEqual(qc_new.default_grouping, ["group", "probe"])
821+
# Tags should remain as dict
822+
self.assertEqual(qc_new.metrics[0].tags, {"group": "test_group", "probe": "probeA"})
823+
824+
def test_new_format_default_grouping_mixed(self):
825+
"""Test that new format with mixed strings and tuples in default_grouping is NOT converted"""
826+
827+
# Test new format: mixed strings and tuples for default_grouping + dict-based tags
828+
new_format_dict = {
829+
"metrics": [
830+
{
831+
"object_type": "QC metric",
832+
"name": "New format metric",
833+
"modality": {"name": "Extracellular electrophysiology", "abbreviation": "ecephys"},
834+
"stage": "Processing",
835+
"value": 42,
836+
"status_history": [{"evaluator": "Test", "timestamp": "2020-10-10", "status": "Pass"}],
837+
"tags": {"group": "test_group", "probe": "probeA", "shank": "shank1"},
838+
}
839+
],
840+
"default_grouping": ["group", ("probe", "shank")],
841+
}
842+
843+
qc_new = QualityControl.model_validate(new_format_dict)
844+
845+
# Should NOT convert - keep as-is
846+
self.assertEqual(qc_new.default_grouping, ["group", ("probe", "shank")])
847+
# Tags should remain as dict
848+
self.assertEqual(qc_new.metrics[0].tags, {"group": "test_group", "probe": "probeA", "shank": "shank1"})
849+
773850

774851
if __name__ == "__main__":
775852
unittest.main()

0 commit comments

Comments
 (0)