|
| 1 | +"""Export a model to the RAVEN Microsoft Excel format. |
| 2 | +
|
| 3 | +Writes the five-sheet RAVEN xlsx layout — RXNS, METS, COMPS, GENES, MODEL — pulling |
| 4 | +RAVEN-specific values back out of cobra's ``annotation`` / ``notes`` (where the |
| 5 | +raven_python YAML reader stashes them). Excel *import* is intentionally not provided. |
| 6 | +
|
| 7 | +Requires the optional ``openpyxl`` dependency (``pip install raven_python[excel]``). |
| 8 | +""" |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +from pathlib import Path |
| 12 | + |
| 13 | +import cobra |
| 14 | + |
| 15 | + |
| 16 | +def _miriam_string(annotation: dict, exclude: tuple[str, ...] = ()) -> str: |
| 17 | + """RAVEN MIRIAM column: ``namespace/id;namespace/id2;...`` (sorted).""" |
| 18 | + parts = [] |
| 19 | + for namespace in sorted(annotation): |
| 20 | + if namespace in exclude: |
| 21 | + continue |
| 22 | + values = annotation[namespace] |
| 23 | + if isinstance(values, str): |
| 24 | + values = [values] |
| 25 | + parts.extend(f"{namespace}/{value}" for value in values) |
| 26 | + return ";".join(parts) |
| 27 | + |
| 28 | + |
| 29 | +def _equation(rxn: cobra.Reaction) -> str: |
| 30 | + """Human-readable equation in RAVEN ``name[comp]`` form.""" |
| 31 | + |
| 32 | + def side(items): |
| 33 | + return " + ".join( |
| 34 | + f"{abs(coef):g} {met.name}[{met.compartment}]" for met, coef in items |
| 35 | + ) |
| 36 | + |
| 37 | + reactants = [(m, c) for m, c in rxn.metabolites.items() if c < 0] |
| 38 | + products = [(m, c) for m, c in rxn.metabolites.items() if c > 0] |
| 39 | + arrow = " <=> " if rxn.reversibility else " => " |
| 40 | + return f"{side(reactants)}{arrow}{side(products)}" |
| 41 | + |
| 42 | + |
| 43 | +def _ec_codes(rxn: cobra.Reaction) -> str: |
| 44 | + codes = rxn.annotation.get("ec-code", []) |
| 45 | + if isinstance(codes, str): |
| 46 | + codes = [codes] |
| 47 | + return ";".join(codes) |
| 48 | + |
| 49 | + |
| 50 | +def export_to_excel( |
| 51 | + model: cobra.Model, path: str | Path, *, sort_ids: bool = False |
| 52 | +) -> None: |
| 53 | + """Write ``model`` to a RAVEN-format ``.xlsx`` file. |
| 54 | +
|
| 55 | + Parameters |
| 56 | + ---------- |
| 57 | + sort_ids |
| 58 | + If True, write reactions/metabolites/genes sorted alphabetically by ID |
| 59 | + (the model itself is not modified). |
| 60 | + """ |
| 61 | + try: |
| 62 | + from openpyxl import Workbook |
| 63 | + except ImportError as exc: # pragma: no cover - exercised only without openpyxl |
| 64 | + raise ImportError( |
| 65 | + "export_to_excel requires openpyxl. Install it with " |
| 66 | + "`pip install raven_python[excel]` (or `pip install openpyxl`)." |
| 67 | + ) from exc |
| 68 | + |
| 69 | + reactions = sorted(model.reactions, key=lambda r: r.id) if sort_ids else list(model.reactions) |
| 70 | + metabolites = ( |
| 71 | + sorted(model.metabolites, key=lambda m: m.id) if sort_ids else list(model.metabolites) |
| 72 | + ) |
| 73 | + genes = sorted(model.genes, key=lambda g: g.id) if sort_ids else list(model.genes) |
| 74 | + metadata = dict(model.notes.get("metaData", {})) if model.notes else {} |
| 75 | + |
| 76 | + wb = Workbook() |
| 77 | + wb.remove(wb.active) # drop the default empty sheet |
| 78 | + |
| 79 | + # --- RXNS --- |
| 80 | + ws = wb.create_sheet("RXNS") |
| 81 | + ws.append( |
| 82 | + ["#", "ID", "NAME", "EQUATION", "EC-NUMBER", "GENE ASSOCIATION", "LOWER BOUND", |
| 83 | + "UPPER BOUND", "OBJECTIVE", "COMPARTMENT", "MIRIAM", "SUBSYSTEM", |
| 84 | + "REPLACEMENT ID", "NOTE", "REFERENCE", "CONFIDENCE SCORE"] |
| 85 | + ) |
| 86 | + for r in reactions: |
| 87 | + subsystem = r.subsystem |
| 88 | + if isinstance(subsystem, (list, tuple)): |
| 89 | + subsystem = ";".join(subsystem) |
| 90 | + ws.append([ |
| 91 | + None, r.id, r.name, _equation(r), _ec_codes(r), r.gene_reaction_rule, |
| 92 | + r.lower_bound, r.upper_bound, |
| 93 | + r.objective_coefficient or None, None, |
| 94 | + _miriam_string(r.annotation, exclude=("ec-code",)), subsystem, None, |
| 95 | + r.notes.get("note"), r.notes.get("references"), r.notes.get("confidence_score"), |
| 96 | + ]) |
| 97 | + |
| 98 | + # --- METS --- |
| 99 | + ws = wb.create_sheet("METS") |
| 100 | + ws.append(["#", "ID", "NAME", "UNCONSTRAINED", "MIRIAM", "COMPOSITION", "InChI", |
| 101 | + "COMPARTMENT", "REPLACEMENT ID", "CHARGE"]) |
| 102 | + for m in metabolites: |
| 103 | + inchi = m.notes.get("inchis") |
| 104 | + ws.append([ |
| 105 | + None, f"{m.name}[{m.compartment}]", m.name, None, |
| 106 | + _miriam_string(m.annotation, exclude=("smiles",)), |
| 107 | + None if inchi else m.formula, inchi, m.compartment, m.id, m.charge, |
| 108 | + ]) |
| 109 | + |
| 110 | + # --- COMPS --- |
| 111 | + ws = wb.create_sheet("COMPS") |
| 112 | + ws.append(["#", "ABBREVIATION", "NAME", "INSIDE", "MIRIAM"]) |
| 113 | + comps = sorted(model.compartments) if sort_ids else list(model.compartments) |
| 114 | + for cid in comps: |
| 115 | + ws.append([None, cid, model.compartments.get(cid, ""), None, None]) |
| 116 | + |
| 117 | + # --- GENES --- |
| 118 | + if genes: |
| 119 | + ws = wb.create_sheet("GENES") |
| 120 | + ws.append(["#", "NAME", "MIRIAM", "SHORT NAME", "COMPARTMENT"]) |
| 121 | + for g in genes: |
| 122 | + ws.append([None, g.id, _miriam_string(g.annotation), g.name, None]) |
| 123 | + |
| 124 | + # --- MODEL --- |
| 125 | + ws = wb.create_sheet("MODEL") |
| 126 | + ws.append(["#", "ID", "NAME", "TAXONOMY", "DEFAULT LOWER", "DEFAULT UPPER", |
| 127 | + "CONTACT GIVEN NAME", "CONTACT FAMILY NAME", "CONTACT EMAIL", |
| 128 | + "ORGANIZATION", "NOTES"]) |
| 129 | + ws.append([ |
| 130 | + None, model.id or "blankID", model.name or "blankName", |
| 131 | + metadata.get("taxonomy"), metadata.get("defaultLB"), metadata.get("defaultUB"), |
| 132 | + metadata.get("givenName"), metadata.get("familyName"), metadata.get("email"), |
| 133 | + metadata.get("organization"), metadata.get("note"), |
| 134 | + ]) |
| 135 | + |
| 136 | + wb.save(str(path)) |
0 commit comments