Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/metaseed/brapi/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,34 @@ def _observation_unit(unit: dict[str, Any]) -> dict[str, Any]:
"levelCode": unit.get("observation_level_code"),
}
)
# BrAPI v2: block/replicate are expressed via observationLevelRelationships.
relationships = []
if unit.get("observation_unit_block"):
relationships.append(
{"levelName": "block", "levelCode": str(unit["observation_unit_block"])}
)
if unit.get("observation_unit_replicate"):
relationships.append(
{"levelName": "rep", "levelCode": str(unit["observation_unit_replicate"])}
)
position = _clean(
{
"positionCoordinateX": unit.get("observation_unit_x_ref"),
"positionCoordinateXType": unit.get("spatial_distribution_type"),
"positionCoordinateY": unit.get("observation_unit_y_ref"),
"blockNumber": unit.get("observation_unit_block"),
"replicate": unit.get("observation_unit_replicate"),
"entryType": unit.get("entry_type"),
}
)
# BrAPI v2 nests the level inside the position object.
if level:
position["observationLevel"] = level
if relationships:
position["observationLevelRelationships"] = relationships
return _clean(
{
"observationUnitDbId": unit.get("unique_id"),
"studyDbId": unit.get("study_id"),
"germplasmDbId": unit.get("biological_material_id"),
"observationLevel": level,
"observationUnitPosition": position,
}
)
Expand Down
19 changes: 16 additions & 3 deletions src/metaseed/brapi/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,10 @@ def _add_observation_units(
unit_id = unit.get("observationUnitDbId")
if not unit_id:
continue
level = unit.get("observationLevel") or {}
position = unit.get("observationUnitPosition") or {}
# In BrAPI v2 the observationLevel object is nested inside the position,
# and block/replicate are expressed via observationLevelRelationships.
level = position.get("observationLevel") or {}
client.create_entity(
"ObservationUnit",
_clean(
Expand All @@ -173,15 +175,26 @@ def _add_observation_units(
),
"observation_unit_x_ref": position.get("positionCoordinateX"),
"observation_unit_y_ref": position.get("positionCoordinateY"),
"observation_unit_block": position.get("blockNumber"),
"observation_unit_replicate": position.get("replicate"),
"observation_unit_block": _level_code(position, ("block",)),
"observation_unit_replicate": _level_code(
position, ("rep", "replicate")
),
"entry_type": position.get("entryType"),
}
),
skip_validation=True,
)


def _level_code(position: dict[str, Any], level_names: tuple[str, ...]) -> str | None:
"""Return the levelCode for a named level from observationLevelRelationships."""
for rel in position.get("observationLevelRelationships") or []:
if str(rel.get("levelName", "")).lower() in level_names:
code = rel.get("levelCode")
return str(code) if code is not None else None
return None


def _add_observed_variables(
client: MetaseedClient,
observations: list[dict[str, Any]],
Expand Down
12 changes: 9 additions & 3 deletions src/metaseed/ena/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from __future__ import annotations

from itertools import zip_longest
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
Expand Down Expand Up @@ -142,9 +143,14 @@ def build_dataset(
skip_validation=True,
)

urls = [u for u in (row.get("fastq_ftp") or "").split(";") if u]
# fastq_ftp and fastq_md5 are parallel ;-separated lists; pair them
# positionally (zip_longest) so an empty URL segment cannot shift the
# checksums out of alignment.
urls = (row.get("fastq_ftp") or "").split(";")
md5s = (row.get("fastq_md5") or "").split(";")
for i, url in enumerate(urls):
for url, md5 in zip_longest(urls, md5s, fillvalue=""):
if not url:
continue
client.create_entity(
"File",
_clean(
Expand All @@ -153,7 +159,7 @@ def build_dataset(
"filename": url.rsplit("/", 1)[-1],
"filetype": "fastq",
"checksum_method": "MD5",
"checksum": md5s[i] if i < len(md5s) else None,
"checksum": md5 or None,
}
),
skip_validation=True,
Expand Down
56 changes: 49 additions & 7 deletions src/metaseed/isatab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@

This writer is pure and dependency-free (stdlib tab-delimited text). It emits
the metadata structure and *references* raw data files by name; it never reads
or writes spectra. It works for any ISA-shaped profile (e.g. ``isa`` or
``metabolights``) whose serialized entities use the ISA entity types
Investigation, Study, Person, Publication, Factor, Protocol, and Assay.
or writes spectra. It reads the ISA entity types Investigation, Study, Person,
Publication, Factor, Protocol, and Assay using the field codenames of the
``metabolights`` profile; other ISA-shaped profiles (e.g. ``isa``) work only
where their field codenames match.
"""

from __future__ import annotations
Expand Down Expand Up @@ -221,20 +222,61 @@ def to_isatab(client: MetaseedClient) -> dict[str, str]:
assays = by_type.get("Assay", [])
samples = by_type.get("Sample", [])

# Each entity's owning study (None = directly under the Investigation), so a
# multi-study investigation routes factors/protocols/assays/samples and
# study- vs investigation-level people/publications to the right place
# instead of duplicating every child into every study.
owner = _parent_study_map(client)

def of(items: list[dict[str, Any]], study_id: str | None) -> list[dict[str, Any]]:
return [e for e in items if owner.get(str(e.get("_node_id"))) == study_id]

lines = _ontology_source_section()
investigation = investigations[0] if investigations else {}
lines += _investigation_section(investigation)
lines += _publication_section("INVESTIGATION PUBLICATIONS", "Investigation", [])
lines += _contacts_section("INVESTIGATION CONTACTS", "Investigation", [])
lines += _publication_section(
"INVESTIGATION PUBLICATIONS", "Investigation", of(publications, None)
)
lines += _contacts_section(
"INVESTIGATION CONTACTS", "Investigation", of(people, None)
)

for study in studies:
sid = study.get("_node_id")
lines += _study_sections(
study, factors, assays, protocols, publications, people
study,
of(factors, sid),
of(assays, sid),
of(protocols, sid),
of(publications, sid),
of(people, sid),
)

documents: dict[str, str] = {"i_Investigation.txt": "\n".join(lines) + "\n"}
for study in studies:
documents[study_filename(study)] = _study_file(samples)
documents[study_filename(study)] = _study_file(
of(samples, study.get("_node_id"))
)
for assay in assays:
documents[assay_filename(assay)] = _assay_file(assay)
return documents


def _parent_study_map(client: MetaseedClient) -> dict[str, str | None]:
"""Map each entity's node id to the node id of the Study that owns it.

Entities directly under the Investigation (e.g. investigation-level people
or publications) map to ``None``. Built from the dataset tree, since the flat
serialization does not carry parent links.
"""
owner: dict[str, str | None] = {}

def descend(node: Any, study_id: str | None) -> None:
for child in node.children:
owner[child.id] = study_id
next_study = child.id if child.entity_type == "Study" else study_id
descend(child, next_study)

for root in client.get_tree():
descend(root, None)
return owner
4 changes: 2 additions & 2 deletions src/metaseed/metabolights/export.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Export a ``metabolights`` dataset to the MetaboLights submission format.

MetaboLights archives are ISA-Tab plus one MetaboLights-specific file per
assay: the Metabolite Assignment File (MAF, ``m_*.txt``). This module reuses the
assay: the Metabolite Assignment File (MAF, ``m_*.tsv``). This module reuses the
shared :func:`metaseed.isatab.to_isatab` writer for the ISA-Tab documents and
adds a MAF skeleton (the standard column header) for each assay. Pure and
dependency-free; it references files, never reads or writes spectra.
Expand Down Expand Up @@ -57,7 +57,7 @@ def to_metabolights(client: MetaseedClient) -> dict[str, str]:

Returns:
Mapping of document name to text — the ISA-Tab files from
:func:`metaseed.isatab.to_isatab` plus one ``m_*.txt`` MAF (column
:func:`metaseed.isatab.to_isatab` plus one ``m_*.tsv`` MAF (column
header only) per Assay.
"""
documents = dict(to_isatab(client))
Expand Down
27 changes: 15 additions & 12 deletions src/metaseed/pride/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ def _metadata_lines(dataset: dict[str, Any]) -> list[str]:
_mtd("submitter_email", submitter.get("email")),
_mtd("submitter_affiliation", submitter.get("affiliation")),
_mtd("lab_head_name", lab_head.get("name")),
_mtd("lab_head_email", lab_head.get("email")),
_mtd("lab_head_affiliation", lab_head.get("affiliation")),
_mtd("project_title", dataset.get("title")),
_mtd("project_description", dataset.get("description")),
_mtd("keywords", ", ".join(str(k) for k in keywords)),
Expand All @@ -68,18 +70,19 @@ def _metadata_lines(dataset: dict[str, Any]) -> list[str]:


def _file_lines(dataset: dict[str, Any]) -> list[str]:
"""Render one ``FME`` file-mapping entry per referenced data file."""
lines = []
for index, file in enumerate(dataset.get("files") or []):
filename = file.get("filename")
if not filename:
continue
file_type = file.get("file_type") or ""
fields = [str(index), str(filename), str(file_type)]
file_format = file.get("file_format")
if file_format:
fields.append(str(file_format))
lines.append("FME\t" + "\t".join(fields))
"""Render the ``FMH`` header and one ``FME`` entry per referenced data file.

The PRIDE px file-mapping section is an ``FMH`` header row declaring the
columns followed by ``FME`` rows ordered ``file_id, file_type, file_path``.
"""
files = [f for f in (dataset.get("files") or []) if f.get("filename")]
if not files:
return []
lines = ["FMH\tfile_id\tfile_type\tfile_path"]
for file_id, file in enumerate(files):
file_type = str(file.get("file_type") or "")
path = str(file.get("filename"))
lines.append(f"FME\t{file_id}\t{file_type}\t{path}")
return lines


Expand Down
83 changes: 68 additions & 15 deletions tests/test_brapi/fixtures/brapi.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
{
"studies": {
"metadata": {"pagination": {"currentPage": 0, "totalCount": 2, "totalPages": 1}},
"metadata": {
"pagination": {
"currentPage": 0,
"totalCount": 2,
"totalPages": 1
}
},
"result": {
"data": [
{
Expand All @@ -12,8 +18,13 @@
"startDate": "2024-04-01",
"endDate": "2024-09-30",
"locationName": "Wageningen field A",
"experimentalDesign": {"PUI": "CO_715:0000145", "description": "RCBD"},
"growthFacility": {"description": "open field"},
"experimentalDesign": {
"PUI": "CO_715:0000145",
"description": "RCBD"
},
"growthFacility": {
"description": "open field"
},
"documentationURL": "https://example.org/studies/1001",
"dataLinks": [
{
Expand All @@ -38,7 +49,13 @@
}
},
"observationunits": {
"metadata": {"pagination": {"currentPage": 0, "totalCount": 2, "totalPages": 1}},
"metadata": {
"pagination": {
"currentPage": 0,
"totalCount": 2,
"totalPages": 1
}
},
"result": {
"data": [
{
Expand All @@ -47,14 +64,25 @@
"studyDbId": "1001",
"germplasmDbId": "G1",
"germplasmName": "B73",
"observationLevel": {"levelName": "plot", "levelCode": "1"},
"observationUnitPosition": {
"positionCoordinateX": "1",
"positionCoordinateXType": "GRID_COL",
"positionCoordinateY": "1",
"blockNumber": "1",
"replicate": "1",
"entryType": "TEST"
"entryType": "TEST",
"observationLevel": {
"levelName": "plot",
"levelCode": "1"
},
"observationLevelRelationships": [
{
"levelName": "block",
"levelCode": "1"
},
{
"levelName": "rep",
"levelCode": "1"
}
]
}
},
{
Expand All @@ -63,21 +91,38 @@
"studyDbId": "1001",
"germplasmDbId": "G2",
"germplasmName": "Mo17",
"observationLevel": {"levelName": "plot", "levelCode": "2"},
"observationUnitPosition": {
"positionCoordinateX": "2",
"positionCoordinateXType": "GRID_COL",
"positionCoordinateY": "1",
"blockNumber": "1",
"replicate": "2",
"entryType": "TEST"
"entryType": "TEST",
"observationLevel": {
"levelName": "plot",
"levelCode": "2"
},
"observationLevelRelationships": [
{
"levelName": "block",
"levelCode": "1"
},
{
"levelName": "rep",
"levelCode": "2"
}
]
}
}
]
}
},
"observations": {
"metadata": {"pagination": {"currentPage": 0, "totalCount": 3, "totalPages": 1}},
"metadata": {
"pagination": {
"currentPage": 0,
"totalCount": 3,
"totalPages": 1
}
},
"result": {
"data": [
{
Expand Down Expand Up @@ -108,7 +153,13 @@
}
},
"germplasm": {
"metadata": {"pagination": {"currentPage": 0, "totalCount": 2, "totalPages": 1}},
"metadata": {
"pagination": {
"currentPage": 0,
"totalCount": 2,
"totalPages": 1
}
},
"result": {
"data": [
{
Expand All @@ -120,7 +171,9 @@
"subtaxa": "subsp. mays",
"instituteCode": "USDA",
"instituteName": "USDA-ARS",
"studyDbIds": ["1001"]
"studyDbIds": [
"1001"
]
},
{
"germplasmDbId": "G2",
Expand Down
Loading
Loading