From 89d23e9a5cb45bb8878274afac816ff3e0ac30db Mon Sep 17 00:00:00 2001 From: jplfaria Date: Tue, 29 Sep 2026 19:50:32 +0000 Subject: [PATCH] fix: allow Empty media (zero compounds) in _load_media and _apply_media Refine _apply_media to distinguish three cases: - compounds matched: open those exchanges - compounds present but no match: warning, leave default bounds - zero compounds (Empty media): close all exchanges Add regression tests in tests/unit/test_load_media_and_apply_media.py covering all three cases plus _load_media empty-compound handling. Fixes ModelSEED/modelseed-api-ops#34 --- src/modelseed_api/jobs/tasks.py | 7 +- tests/unit/test_load_media_and_apply_media.py | 86 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_load_media_and_apply_media.py diff --git a/src/modelseed_api/jobs/tasks.py b/src/modelseed_api/jobs/tasks.py index 8626bed..11f62d5 100644 --- a/src/modelseed_api/jobs/tasks.py +++ b/src/modelseed_api/jobs/tasks.py @@ -378,11 +378,14 @@ def _apply_media(cobra_model, ms_media): exc_rxn_id = f"EX_{cpd.id}_e0" if exc_rxn_id in rxn_ids: medium[exc_rxn_id] = cpd.maxFlux or 1000.0 - cobra_model.medium = medium if medium: + cobra_model.medium = medium logger.info("Applied media: %d exchange reactions open", len(medium)) + elif ms_media.mediacompounds: + logger.warning("Media had no matching exchange reactions — running with default bounds") else: - logger.info("Applied empty media: all exchange reactions closed") + cobra_model.medium = {} + logger.info("Applied Empty media: all exchanges closed") def _fix_gapfilling_metadata(ws_data: dict, media_workspace_ref: str | None) -> None: diff --git a/tests/unit/test_load_media_and_apply_media.py b/tests/unit/test_load_media_and_apply_media.py new file mode 100644 index 0000000..9809308 --- /dev/null +++ b/tests/unit/test_load_media_and_apply_media.py @@ -0,0 +1,86 @@ +"""Unit tests for _load_media and _apply_media. + +Regression tests for the bug where _load_media raised ValueError for the +Empty media (zero compounds), and _apply_media left default exchange bounds +instead of closing all exchanges for an empty MSMedia. +""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from modelseed_api.jobs.tasks import _apply_media, _load_media + + +def _mock_storage(raw_data): + """Return a mock storage service that serves raw_data for any path.""" + ws = MagicMock() + ws.get.return_value = [[None, raw_data]] + return ws + + +class TestLoadMediaEmpty: + def test_empty_json_returns_zero_compound_msmedia(self): + raw = json.dumps({"mediacompounds": []}) + with patch("modelseed_api.jobs.tasks.get_storage_service", return_value=_mock_storage(raw)): + ms_media = _load_media("/chenry/public/modelsupport/media/Empty", "tok") + assert ms_media is not None + assert len(ms_media.mediacompounds) == 0 + + def test_empty_dict_returns_zero_compound_msmedia(self): + raw = {"mediacompounds": []} + with patch("modelseed_api.jobs.tasks.get_storage_service", return_value=_mock_storage(raw)): + ms_media = _load_media("/chenry/public/modelsupport/media/Empty", "tok") + assert ms_media is not None + assert len(ms_media.mediacompounds) == 0 + + def test_nonempty_media_still_loads_compounds(self): + raw = {"mediacompounds": [{"compound_ref": "~/compounds/cpd00001", "concentration": 0.001, "minFlux": -100, "maxFlux": 100}]} + with patch("modelseed_api.jobs.tasks.get_storage_service", return_value=_mock_storage(raw)): + ms_media = _load_media("/chenry/public/modelsupport/media/NMS", "tok") + assert len(ms_media.mediacompounds) == 1 + + +class TestApplyMediaEmpty: + def _make_cobra_model(self, exchange_ids): + model = MagicMock() + reactions = [MagicMock(id=rid) for rid in exchange_ids] + model.reactions = reactions + model.medium = {"EX_cpd00001_e0": 100.0} + return model + + def test_empty_media_closes_all_exchanges(self): + from modelseedpy.core.msmedia import MSMedia + ms_media = MSMedia("Empty", name="Empty") + cobra_model = self._make_cobra_model(["EX_cpd00001_e0", "EX_cpd00002_e0"]) + + _apply_media(cobra_model, ms_media) + + cobra_model.__setattr__ + assert cobra_model.medium == {} + + def test_media_with_matching_compounds_opens_exchanges(self): + from modelseedpy.core.msmedia import MSMedia, MediaCompound + ms_media = MSMedia("NMS", name="NMS") + ms_media.mediacompounds.append(MediaCompound("cpd00001", 100.0, -100.0)) + + cobra_model = self._make_cobra_model(["EX_cpd00001_e0", "EX_cpd00002_e0"]) + _apply_media(cobra_model, ms_media) + + assert cobra_model.medium == {"EX_cpd00001_e0": 100.0} + + def test_media_with_no_matching_reactions_leaves_default_bounds(self): + """Compounds present but none match model reactions -- not an Empty case.""" + from modelseedpy.core.msmedia import MSMedia, MediaCompound + ms_media = MSMedia("Weird", name="Weird") + ms_media.mediacompounds.append(MediaCompound("cpd99999", 100.0, -100.0)) + + original_medium = {"EX_cpd00001_e0": 100.0} + cobra_model = self._make_cobra_model(["EX_cpd00001_e0"]) + cobra_model.medium = original_medium.copy() + + _apply_media(cobra_model, ms_media) + + assert cobra_model.medium == original_medium