Skip to content

Commit a1d1371

Browse files
Import and expose emodel traces (#200)
* Import and expose derivation between ElectricalCellRecording and EModel * Add EModel.electrical_cell_recordings * Add EModel.electrical_cell_recordings * Rebase and update migration * Add endpoint /{entity_route}/{entity_id}/generated-by * Replace generated-by with derived-from * Remove 1 and 2 1. expose GET /emodel/<uuid>/electrical-cell-recording) 2. allow to search by emodel in GET /electrical-cell-recording?generated_emodel__id=<uuid> * Update migration after merge * Remove 3
1 parent 603967c commit a1d1371

16 files changed

+403
-134
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Default migration message
2+
3+
Revision ID: 8f0d631d2bd4
4+
Revises: 1589bff44728
5+
Create Date: 2025-06-02 14:51:26.859717
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
15+
from sqlalchemy import Text
16+
import app.db.types
17+
18+
# revision identifiers, used by Alembic.
19+
revision: str = "8f0d631d2bd4"
20+
down_revision: Union[str, None] = "1589bff44728"
21+
branch_labels: Union[str, Sequence[str], None] = None
22+
depends_on: Union[str, Sequence[str], None] = None
23+
24+
25+
def upgrade() -> None:
26+
# ### commands auto generated by Alembic - please adjust! ###
27+
op.create_table(
28+
"derivation",
29+
sa.Column("used_id", sa.Uuid(), nullable=False),
30+
sa.Column("generated_id", sa.Uuid(), nullable=False),
31+
sa.ForeignKeyConstraint(
32+
["generated_id"], ["entity.id"], name=op.f("fk_derivation_generated_id_entity")
33+
),
34+
sa.ForeignKeyConstraint(
35+
["used_id"], ["entity.id"], name=op.f("fk_derivation_used_id_entity")
36+
),
37+
sa.PrimaryKeyConstraint("used_id", "generated_id", name=op.f("pk_derivation")),
38+
)
39+
# ### end Alembic commands ###
40+
41+
42+
def downgrade() -> None:
43+
# ### commands auto generated by Alembic - please adjust! ###
44+
op.drop_table("derivation")
45+
# ### end Alembic commands ###

app/cli/import_data.py

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from abc import ABC, abstractmethod
88
from collections import Counter, defaultdict
99
from contextlib import closing
10-
from operator import attrgetter
1110
from pathlib import Path
1211
from typing import Any
1312

@@ -18,7 +17,7 @@
1817

1918
from app.cli import curate, utils
2019
from app.cli.brain_region_data import BRAIN_ATLAS_REGION_VOLUMES
21-
from app.cli.curation import electrical_cell_recording
20+
from app.cli.curation import cell_composition, electrical_cell_recording
2221
from app.cli.types import ContentType
2322
from app.cli.utils import (
2423
AUTHORIZED_PUBLIC,
@@ -37,6 +36,7 @@
3736
BrainRegionHierarchy,
3837
CellComposition,
3938
DataMaturityAnnotationBody,
39+
Derivation,
4040
ElectricalCellRecording,
4141
EModel,
4242
Entity,
@@ -71,10 +71,6 @@
7171
from app.logger import L
7272
from app.schemas.base import ProjectContext
7373

74-
from app.cli.curation import electrical_cell_recording, cell_composition
75-
from app.cli.types import ContentType
76-
77-
7874
BRAIN_ATLAS_NAME = "BlueBrain Atlas"
7975

8076
REQUIRED_PATH = click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True)
@@ -561,6 +557,75 @@ def ingest(
561557
db.commit()
562558

563559

560+
class ImportEModelDerivations(Import):
561+
name = "EModelWorkflow"
562+
563+
@staticmethod
564+
def is_correct_type(data):
565+
types = ensurelist(data["@type"])
566+
return "EModelWorkflow" in types
567+
568+
@staticmethod
569+
def ingest(
570+
db,
571+
project_context,
572+
data_list: list[dict],
573+
all_data_by_id: dict[str, dict],
574+
hierarchy_name: str,
575+
):
576+
"""Import emodel derivations from EModelWorkflow."""
577+
legacy_emodel_ids = set()
578+
derivations = {}
579+
for data in tqdm(data_list, desc="EModelWorkflow"):
580+
legacy_emodel_id = utils.find_id_in_entity(data, "EModel", "generates")
581+
legacy_etc_id = utils.find_id_in_entity(
582+
data, "ExtractionTargetsConfiguration", "hasPart"
583+
)
584+
if not legacy_emodel_id:
585+
L.warning("Not found EModel id in EModelWorkflow: {}", data["@id"])
586+
continue
587+
if not legacy_etc_id:
588+
L.warning(
589+
"Not found ExtractionTargetsConfiguration id in EModelWorkflow: {}", data["@id"]
590+
)
591+
continue
592+
if not (etc := all_data_by_id.get(legacy_etc_id)):
593+
L.warning("Not found ExtractionTargetsConfiguration with id {}", legacy_etc_id)
594+
continue
595+
if not (legacy_trace_ids := list(utils.find_ids_in_entity(etc, "Trace", "uses"))):
596+
L.warning(
597+
"Not found traces in ExtractionTargetsConfiguration with id {}", legacy_etc_id
598+
)
599+
continue
600+
if legacy_emodel_id in legacy_emodel_ids:
601+
L.warning("Duplicated and ignored traces for EModel id {}", legacy_emodel_id)
602+
continue
603+
legacy_emodel_ids.add(legacy_emodel_id)
604+
if not (emodel := utils._find_by_legacy_id(legacy_emodel_id, EModel, db)):
605+
L.warning("Not found EModel with legacy id {}", legacy_emodel_id)
606+
continue
607+
if emodel.id in derivations:
608+
L.warning("Duplicated and ignored traces for EModel uuid {}", emodel.id)
609+
derivations[emodel.id] = [
610+
utils._find_by_legacy_id(legacy_trace_id, ElectricalCellRecording, db).id
611+
for legacy_trace_id in legacy_trace_ids
612+
]
613+
614+
rows = [
615+
Derivation(used_id=trace_id, generated_id=emodel_id)
616+
for emodel_id, trace_ids in derivations.items()
617+
for trace_id in trace_ids
618+
]
619+
L.info(
620+
"Imported derivations for {} EModels from {} records", len(derivations), len(data_list)
621+
)
622+
# delete everything from derivation table before adding the records
623+
query = sa.delete(Derivation)
624+
db.execute(query)
625+
db.add_all(rows)
626+
db.commit()
627+
628+
564629
class ImportBrainAtlas(Import):
565630
name = "BrainAtlas"
566631

@@ -1370,6 +1435,7 @@ def _do_import(db, input_dir, project_context, hierarchy_name):
13701435
ImportBrainAtlas,
13711436
ImportDistribution,
13721437
ImportNeuronMorphologyFeatureAnnotation,
1438+
ImportEModelDerivations,
13731439
]
13741440

13751441
for importer in importers:

app/cli/utils.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -361,13 +361,14 @@ def get_or_create_distribution(
361361
def find_id_in_entity(entity: dict | None, type_: str, entity_list_key: str):
362362
if not entity:
363363
return None
364-
return next(
365-
(
366-
part.get("@id")
367-
for part in ensurelist(entity.get(entity_list_key, []))
368-
if is_type(part, type_)
369-
),
370-
None,
364+
return next(find_ids_in_entity(entity, type_, entity_list_key), None)
365+
366+
367+
def find_ids_in_entity(entity: dict, type_: str, entity_list_key: str):
368+
return (
369+
part.get("@id")
370+
for part in ensurelist(entity.get(entity_list_key, []))
371+
if is_type(part, type_)
371372
)
372373

373374

app/db/model.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,3 +919,11 @@ class CellComposition(NameDescriptionVectorMixin, LocationMixin, SpeciesMixin, E
919919
__tablename__ = EntityType.cell_composition
920920
id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), primary_key=True)
921921
__mapper_args__ = {"polymorphic_identity": __tablename__} # noqa: RUF012
922+
923+
924+
class Derivation(Base):
925+
__tablename__ = "derivation"
926+
used_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), primary_key=True)
927+
generated_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), primary_key=True)
928+
used: Mapped["Entity"] = relationship(foreign_keys=[used_id])
929+
generated: Mapped["Entity"] = relationship(foreign_keys=[generated_id])

app/filters/entity.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from typing import Annotated
2+
3+
from fastapi_filter import FilterDepends
4+
5+
from app.db.model import Entity
6+
from app.db.types import EntityType
7+
from app.filters.base import CustomFilter
8+
9+
10+
class BasicEntityFilter(CustomFilter):
11+
type: EntityType | None = None
12+
13+
order_by: list[str] = ["-creation_date"] # noqa: RUF012
14+
15+
class Constants(CustomFilter.Constants):
16+
model = Entity
17+
ordering_model_fields = ["creation_date", "update_date", "name"] # noqa: RUF012
18+
19+
20+
BasicEntityFilterDep = Annotated[BasicEntityFilter, FilterDepends(BasicEntityFilter)]

app/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
brain_region_hierarchy,
1111
cell_composition,
1212
contribution,
13+
derivation,
1314
electrical_cell_recording,
1415
emodel,
1516
etype,
@@ -45,6 +46,7 @@
4546
brain_region_hierarchy.router,
4647
cell_composition.router,
4748
contribution.router,
49+
derivation.router,
4850
electrical_cell_recording.router,
4951
emodel.router,
5052
etype.router,

app/routers/asset.py

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
"""Generic asset routes."""
22

33
import uuid
4-
from enum import StrEnum
54
from http import HTTPStatus
65
from pathlib import Path
7-
from typing import TYPE_CHECKING, Annotated
6+
from typing import Annotated
87

98
from fastapi import APIRouter, Form, HTTPException, UploadFile, status
109
from starlette.responses import RedirectResponse
1110

1211
from app.config import settings
13-
from app.db.types import AssetLabel, EntityType
12+
from app.db.types import AssetLabel
1413
from app.dependencies.auth import UserContextDep, UserContextWithProjectIdDep
1514
from app.dependencies.db import RepoGroupDep
1615
from app.dependencies.s3 import S3ClientDep
@@ -19,6 +18,7 @@
1918
from app.schemas.types import ListResponse, PaginationResponse
2019
from app.service import asset as asset_service
2120
from app.utils.files import calculate_sha256_digest, get_content_type
21+
from app.utils.routers import EntityRoute, entity_route_to_type
2222
from app.utils.s3 import (
2323
delete_from_s3,
2424
generate_presigned_url,
@@ -32,18 +32,6 @@
3232
tags=["assets"],
3333
)
3434

35-
if not TYPE_CHECKING:
36-
# EntityRoute (hyphen-separated) <-> EntityType (underscore_separated)
37-
EntityRoute = StrEnum(
38-
"EntityRoute", {item.name: item.name.replace("_", "-") for item in EntityType}
39-
)
40-
else:
41-
EntityRoute = StrEnum
42-
43-
44-
def _entity_route_to_type(entity_route: EntityRoute) -> EntityType:
45-
return EntityType[entity_route.name]
46-
4735

4836
@router.get("/{entity_route}/{entity_id}/assets")
4937
def get_entity_assets(
@@ -56,7 +44,7 @@ def get_entity_assets(
5644
assets = asset_service.get_entity_assets(
5745
repos,
5846
user_context=user_context,
59-
entity_type=_entity_route_to_type(entity_route),
47+
entity_type=entity_route_to_type(entity_route),
6048
entity_id=entity_id,
6149
)
6250
# TODO: proper pagination
@@ -76,7 +64,7 @@ def get_entity_asset(
7664
return asset_service.get_entity_asset(
7765
repos,
7866
user_context=user_context,
79-
entity_type=_entity_route_to_type(entity_route),
67+
entity_type=entity_route_to_type(entity_route),
8068
entity_id=entity_id,
8169
asset_id=asset_id,
8270
)
@@ -117,7 +105,7 @@ def upload_entity_asset(
117105
asset_read = asset_service.create_entity_asset(
118106
repos=repos,
119107
user_context=user_context,
120-
entity_type=_entity_route_to_type(entity_route),
108+
entity_type=entity_route_to_type(entity_route),
121109
entity_id=entity_id,
122110
filename=file.filename,
123111
content_type=content_type,
@@ -149,7 +137,7 @@ def download_entity_asset(
149137
asset = asset_service.get_entity_asset(
150138
repos,
151139
user_context=user_context,
152-
entity_type=_entity_route_to_type(entity_route),
140+
entity_type=entity_route_to_type(entity_route),
153141
entity_id=entity_id,
154142
asset_id=asset_id,
155143
)
@@ -205,7 +193,7 @@ def delete_entity_asset(
205193
asset = asset_service.delete_entity_asset(
206194
repos,
207195
user_context=user_context,
208-
entity_type=_entity_route_to_type(entity_route),
196+
entity_type=entity_route_to_type(entity_route),
209197
entity_id=entity_id,
210198
asset_id=asset_id,
211199
)

app/routers/derivation.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Generic derivation routes."""
2+
3+
from fastapi import APIRouter
4+
5+
import app.service.derivation
6+
7+
router = APIRouter(
8+
prefix="",
9+
tags=["derivation"],
10+
)
11+
12+
router.get("/{entity_route}/{entity_id}/derived-from")(app.service.derivation.read_many)

app/routers/emodel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from fastapi import APIRouter
22

3+
import app.service.electrical_cell_recording
34
import app.service.emodel
45

56
router = APIRouter(

app/schemas/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,7 @@ class LicensedCreateMixin(BaseModel):
109109
class LicensedReadMixin(BaseModel):
110110
model_config = ConfigDict(from_attributes=True)
111111
license: LicenseRead | None
112+
113+
114+
class BasicEntityRead(IdentifiableMixin, EntityTypeMixin):
115+
pass

0 commit comments

Comments
 (0)