Source code for pymixef.interoperability.sbml

"""SBML-compatible ODE declaration subset."""

from __future__ import annotations

import xml.etree.ElementTree as ET
from collections.abc import Mapping
from pathlib import Path
from typing import Any

from .._contracts import CompatibilityIssue
from .base import CompatibilityReport, InterchangeResult

_NS = "http://www.sbml.org/sbml/level3/version2/core"
_SUPPORTED_ELEMENTS = {
    "sbml",
    "model",
    "listOfCompartments",
    "compartment",
    "listOfSpecies",
    "species",
    "listOfParameters",
    "parameter",
}
_SUPPORTED_ATTRIBUTES = {
    "sbml": {"level", "version"},
    "model": {"id", "name"},
    "listOfCompartments": set(),
    "compartment": {"id", "size", "constant"},
    "listOfSpecies": set(),
    "species": {
        "id",
        "initialAmount",
        "initialConcentration",
        "compartment",
        "hasOnlySubstanceUnits",
        "boundaryCondition",
        "constant",
    },
    "listOfParameters": set(),
    "parameter": {"id", "value", "constant"},
}
_UNSUPPORTED_MESSAGES = {
    "event": (
        "General SBML event algebra is not automatically translated; encode "
        "dosing events in the canonical event table."
    ),
    "reaction": (
        "SBML reactions and kinetic laws are outside the declaration-only ODE import subset."
    ),
    "rateRule": (
        "SBML rate rules are executable equations and are not translated by the "
        "declaration-only import subset."
    ),
    "assignmentRule": (
        "SBML assignment rules are executable equations and are not translated "
        "by the declaration-only import subset."
    ),
    "algebraicRule": (
        "SBML algebraic rules are executable equations and are not translated "
        "by the declaration-only import subset."
    ),
}


def _local(tag: str) -> str:
    return tag.rsplit("}", 1)[-1]


def _namespace(tag: str) -> str | None:
    if tag.startswith("{") and "}" in tag:
        return tag[1:].split("}", 1)[0]
    return None


def _walk_with_locations(
    element: ET.Element,
    location: str | None = None,
) -> list[tuple[ET.Element, str]]:
    name = _local(element.tag)
    current = location or f"/{name}[1]"
    found = [(element, current)]
    occurrences: dict[str, int] = {}
    for child in element:
        child_name = _local(child.tag)
        occurrences[child_name] = occurrences.get(child_name, 0) + 1
        found.extend(
            _walk_with_locations(
                child,
                f"{current}/{child_name}[{occurrences[child_name]}]",
            )
        )
    return found


[docs] def import_sbml(path: str | Path) -> InterchangeResult[dict[str, Any]]: """Import the declaration-only SBML Level 3 Version 2 Core subset. Compartments, species, and parameters are returned as plain mappings. Reactions, rules, events, foreign namespaces, and unrecognized content are retained as ``unsupported`` compatibility issues instead of being silently translated. Call :meth:`InterchangeResult.require_supported` to refuse partial imports. """ source = Path(path) root = ET.parse(source).getroot() elements = _walk_with_locations(root) species = [ { "id": element.attrib["id"], "initial_amount": element.attrib.get("initialAmount"), "initial_concentration": element.attrib.get("initialConcentration"), "compartment": element.attrib.get("compartment"), "has_only_substance_units": element.attrib.get("hasOnlySubstanceUnits"), "boundary_condition": element.attrib.get("boundaryCondition"), "constant": element.attrib.get("constant"), } for element, _ in elements if _local(element.tag) == "species" if _namespace(element.tag) == _NS if "id" in element.attrib ] parameters = [ { "id": element.attrib["id"], "value": element.attrib.get("value"), "constant": element.attrib.get("constant", "true"), } for element, _ in elements if _local(element.tag) == "parameter" if _namespace(element.tag) == _NS if "id" in element.attrib ] compartments = [ { "id": element.attrib["id"], "size": element.attrib.get("size"), "constant": element.attrib.get("constant", "true"), } for element, _ in elements if _local(element.tag) == "compartment" if _namespace(element.tag) == _NS if "id" in element.attrib ] issues = [ CompatibilityIssue( "declarations", "transformed", f"Imported {len(compartments)} compartments, {len(species)} species, " f"and {len(parameters)} parameters.", ) ] if _namespace(root.tag) != _NS: issues.append( CompatibilityIssue( "SBML namespace", "unsupported", "Only SBML Level 3 Version 2 Core is in the documented import subset.", "/sbml[1]", ) ) for element, location in elements: name = _local(element.tag) if name not in _SUPPORTED_ELEMENTS or _namespace(element.tag) != _NS: issues.append( CompatibilityIssue( "SBML events" if name == "event" else name, "unsupported", _UNSUPPORTED_MESSAGES.get( name, "Element is outside the declaration-only SBML import subset.", ), location, ) ) continue if name in {"species", "parameter", "compartment"} and "id" not in element.attrib: issues.append( CompatibilityIssue( name, "unsupported", "A declaration without an id cannot be represented in the PyMixEF ODE subset.", location, ) ) supported_attributes = _SUPPORTED_ATTRIBUTES[name] issues.extend( CompatibilityIssue( f"{name}.@{attribute}", "unsupported", "Attribute is outside the declaration-only SBML import subset.", location, ) for attribute in sorted(element.attrib) if attribute not in supported_attributes ) if element.text and element.text.strip(): issues.append( CompatibilityIssue( f"{name}.text", "unsupported", "Text content is not represented by the declaration-only SBML import subset.", location, ) ) if element.tail and element.tail.strip(): issues.append( CompatibilityIssue( f"{name}.tail", "unsupported", "Mixed text content is not represented by the declaration-only " "SBML import subset.", location, ) ) return InterchangeResult( { "species": species, "parameters": parameters, "compartments": compartments, "source": str(source), "sbml_attributes": dict(root.attrib), }, CompatibilityReport("SBML", "PyMixEF ODE subset", tuple(issues)), )
[docs] def export_sbml(model: Mapping[str, Any], path: str | Path) -> InterchangeResult[Path]: """Export state declarations to a reviewable SBML Level 3 Version 2 file. States become species in one default compartment. Rate equations and model fields outside the declaration-only subset are reported as ``unsupported``; the file is still written so callers must inspect the report or call :meth:`InterchangeResult.require_supported` when partial export is unsafe. """ ET.register_namespace("", _NS) root = ET.Element(f"{{{_NS}}}sbml", {"level": "3", "version": "2"}) model_node = ET.SubElement( root, f"{{{_NS}}}model", {"id": str(model.get("name", "pymixef_model"))} ) compartments = ET.SubElement(model_node, f"{{{_NS}}}listOfCompartments") ET.SubElement( compartments, f"{{{_NS}}}compartment", {"id": "default", "constant": "true", "size": "1"}, ) species_node = ET.SubElement(model_node, f"{{{_NS}}}listOfSpecies") issues: list[CompatibilityIssue] = [] exported_states = 0 for index, state in enumerate(model.get("states", ())): if isinstance(state, Mapping): raw_name = state.get("name", state.get("id")) if raw_name is None or not str(raw_name).strip(): issues.append( CompatibilityIssue( f"states[{index}]", "unsupported", "An SBML species declaration requires a non-empty name.", ) ) continue name = str(raw_name) issues.extend( CompatibilityIssue( f"states[{index}].{key}", "unsupported", "State property is not serialized by the declaration-only SBML exporter.", ) for key in sorted(map(str, state)) if key not in {"name", "id"} ) else: name = str(state) ET.SubElement( species_node, f"{{{_NS}}}species", { "id": name, "compartment": "default", "initialAmount": "0", "hasOnlySubstanceUnits": "false", "boundaryCondition": "false", "constant": "false", }, ) exported_states += 1 issues.extend( CompatibilityIssue( str(key), "unsupported", "Top-level model construct is not serialized by the declaration-only SBML exporter.", ) for key in sorted(map(str, model)) if key not in {"name", "states"} ) destination = Path(path) destination.parent.mkdir(parents=True, exist_ok=True) ET.ElementTree(root).write(destination, encoding="utf-8", xml_declaration=True) return InterchangeResult( destination, CompatibilityReport( "PyMixEF ODE subset", "SBML", ( CompatibilityIssue( "states", "exact", f"Exported {exported_states} state(s) as SBML species declarations.", ), CompatibilityIssue( "rate equations", "unsupported", "Arbitrary Python rate callables cannot be serialized as MathML.", ), *issues, ), ), )