diff --git a/src/matkit/graspa/graspa.py b/src/matkit/graspa/graspa.py index e22be13..fb76163 100644 --- a/src/matkit/graspa/graspa.py +++ b/src/matkit/graspa/graspa.py @@ -51,7 +51,11 @@ def _extract_adsorbate_averages(text: str) -> dict[str, tuple[float, float]]: for raw in text.splitlines(): line = raw.strip() matched_section = next( - (name for name, marker in _SECTION_MARKERS.items() if marker in line), + ( + name + for name, marker in _SECTION_MARKERS.items() + if marker in line + ), None, ) if matched_section is not None: @@ -116,9 +120,7 @@ def get_output_data( try: text = (Path(output_path) / output_fname).read_text() - time_line = next( - (ln for ln in text.splitlines() if "Work" in ln), None - ) + time_line = next((ln for ln in text.splitlines() if "Work" in ln), None) if time_line is None: raise ValueError("Could not find timing line in output.") diff --git a/tests/data/graspa/single_component.txt b/tests/data/graspa/single_component.txt new file mode 100644 index 0000000..6674c65 --- /dev/null +++ b/tests/data/graspa/single_component.txt @@ -0,0 +1,60 @@ +# Synthetic gRASPA output for parser tests (MIT). +# Values are intentionally distinct, not physically consistent. + +HEAT OF ADSORPTION +COMPONENT [1] (CO2) +Block[0]: Average: 24.0 +Block[1]: Average: 26.0 +Overall: Average: 25.0, +/- 0.1 + +LOADING: # MOLECULES +COMPONENT [0] (framework) +Block[0]: Average: 512.0 +Block[1]: Average: 512.0 +Overall: Average: 512.0, +/- 0.0 +EXCESS LOADING +Overall: Average: 511.0, +/- 0.0 +COMPONENT [1] (CO2) +Block[0]: Average: 7.0 +Block[1]: Average: 9.0 +Overall: Average: 8.0, +/- 0.1 +EXCESS LOADING +Overall: Average: 7.0, +/- 0.2 + +LOADING: mg/g +COMPONENT [0] (framework) +Block[0]: Average: 1000.0 +Block[1]: Average: 1000.0 +Overall: Average: 1000.0, +/- 0.0 +EXCESS LOADING +Overall: Average: 999.0, +/- 0.0 +COMPONENT [1] (CO2) +Block[0]: Average: 5.0 +Block[1]: Average: 7.0 +Overall: Average: 6.0, +/- 0.1 +EXCESS LOADING +Overall: Average: 4.0, +/- 0.2 + +LOADING: mol/kg +COMPONENT [0] (framework) +Block[0]: Average: 2000.0 +Block[1]: Average: 2000.0 +Overall: Average: 2000.0, +/- 0.0 +EXCESS LOADING +Overall: Average: 1999.0, +/- 0.0 +COMPONENT [1] (CO2) +Block[0]: Average: 11.0 +Block[1]: Average: 13.0 +Overall: Average: 12.0, +/- 0.1 +EXCESS LOADING +Overall: Average: 10.0, +/- 0.2 + +LOADING: g/L +COMPONENT [1] (CO2) +Block[0]: Average: 13.0 +Block[1]: Average: 15.0 +Overall: Average: 14.0, +/- 0.1 +EXCESS LOADING +Overall: Average: ********, +/- ******** + +Work time 2.0 diff --git a/tests/fixtures/fake_engine.py b/tests/fixtures/fake_engine.py index 5a01135..e493b56 100644 --- a/tests/fixtures/fake_engine.py +++ b/tests/fixtures/fake_engine.py @@ -22,9 +22,12 @@ (fixtures / f"test_structure.{analysis}").read_bytes() ) elif mode == "graspa": - for i in range(14): - print(f"Overall: Average: {25 if i == 0 else i + 1}, +/- 0.1") - if "--partial" not in sys.argv: - print("Work time 2.0") + fixture = ( + Path(__file__).parents[1] / "data" / "graspa" / "single_component.txt" + ) + for line in fixture.read_text().splitlines(): + if "--partial" in sys.argv and line.startswith("Work time"): + continue + print(line) else: raise SystemExit("unknown fixture engine") diff --git a/tests/test_api_runtime.py b/tests/test_api_runtime.py index e4f0397..27c906a 100644 --- a/tests/test_api_runtime.py +++ b/tests/test_api_runtime.py @@ -316,14 +316,9 @@ def test_subprocess_execution_and_timeout(sample_cif, tmp_path): @pytest.mark.parametrize( - "unit,fugacity,expected", - [ - ("mol/kg", "PR-EOS", 12), - ("mg/g", "PR-EOS", 6), - ("g/L", "PR-EOS", 14), - ("mol/kg", 1.0, 7), - ], + "unit,expected", [("mol/kg", 12), ("mg/g", 6), ("g/L", 14)] ) +@pytest.mark.parametrize("fugacity", ["PR-EOS", 1.0]) def test_single_component_adsorption_requires_charges_and_collects( sample_cif, tmp_path, unit, fugacity, expected ): @@ -360,6 +355,10 @@ def test_single_component_adsorption_requires_charges_and_collects( ) assert result.accepted assert result.payload.uptake == expected + assert result.payload.unit == unit + assert result.payload.uncertainty == 0.1 + assert result.payload.heat_of_adsorption == 25 + assert result.payload.heat_uncertainty == 0.1 assert result.payload.component == "CO2" assert result.checks[0].status == "unknown" assert ( diff --git a/tests/test_graspa.py b/tests/test_graspa.py index 980c388..cf3b02f 100644 --- a/tests/test_graspa.py +++ b/tests/test_graspa.py @@ -6,6 +6,7 @@ import pytest from pathlib import Path +from matkit.graspa import get_output_data from matkit.graspa.graspa import ( generate_component_blocks, setup_batch, @@ -14,6 +15,109 @@ from matkit.utils.cif import sanitize_cif_stem +@pytest.fixture +def graspa_log(test_data_dir): + return (test_data_dir / "graspa" / "single_component.txt").read_text() + + +class TestGetOutputData: + """Select absolute adsorbate statistics from sectioned engine output.""" + + @pytest.mark.parametrize( + "unit,expected", [("mol/kg", 12), ("mg/g", 6), ("g/L", 14)] + ) + @pytest.mark.parametrize("eos", [False, True]) + def test_absolute_adsorbate_statistics( + self, tmp_path, graspa_log, unit, expected, eos + ): + # The fixture also contains framework and excess averages, including + # overflowing excess g/L values that must never be parsed as floats. + (tmp_path / "raspa.log").write_text(graspa_log) + + assert get_output_data(str(tmp_path), unit=unit, eos=eos) == { + "success": True, + "uptake": expected, + "error": 0.1, + "unit": unit, + "qst": 25.0, + "error_qst": 0.1, + "qst_unit": "kJ/mol", + "calc_time_in_s": 2.0, + } + + @pytest.mark.parametrize( + "unit,expected", [("mol/kg", 12), ("mg/g", 6), ("g/L", 14)] + ) + @pytest.mark.parametrize( + "variation", ["extra_blocks", "reordered_sections", "later_component"] + ) + def test_layout_variations( + self, tmp_path, graspa_log, unit, expected, variation + ): + sections = graspa_log.strip().split("\n\n") + if variation == "extra_blocks": + text = graspa_log.replace( + "Overall: Average:", + "Block[2]: Average: 999.0\nOverall: Average:", + ) + elif variation == "reordered_sections": + text = "\n\n".join( + [sections[0], *reversed(sections[1:-1]), sections[-1]] + ) + else: + sections[1:-1] = [ + section + + "\nCOMPONENT [2] (N2)\n" + + "Overall: Average: 999.0, +/- 99.0" + for section in sections[1:-1] + ] + text = "\n\n".join(sections) + (tmp_path / "raspa.log").write_text(text) + + result = get_output_data(str(tmp_path), unit=unit) + assert result["uptake"] == expected + assert result["error"] == 0.1 + assert result["qst"] == 25.0 + assert result["error_qst"] == 0.1 + + @pytest.mark.parametrize( + "missing,message", + [ + ("timing", "Could not find timing line"), + ("loading", "Could not find mol/kg loading"), + ("headers", "Could not find uptake lines"), + ], + ) + def test_incomplete_output_raises( + self, tmp_path, graspa_log, missing, message + ): + if missing == "timing": + text = graspa_log.replace("Work time 2.0", "") + elif missing == "loading": + text = "\n\n".join( + section + for section in graspa_log.split("\n\n") + if not section.startswith("LOADING: mol/kg") + ) + else: + text = "\n".join( + line + for line in graspa_log.splitlines() + if line.startswith(("Overall:", "Work time")) + ) + (tmp_path / "raspa.log").write_text(text) + + with pytest.raises(ValueError, match=message): + get_output_data(str(tmp_path)) + + def test_unsupported_unit_raises(self, tmp_path, graspa_log): + (tmp_path / "raspa.log").write_text(graspa_log) + with pytest.raises( + ValueError, match="Unit unsupported is not supported" + ): + get_output_data(str(tmp_path), unit="unsupported") + + class TestGenerateComponentBlocks: """Tests for gRASPA component block generation.""" @@ -167,9 +271,7 @@ def test_multiple_periods_replaced(self): class TestSetupBatch: """Tests for gRASPA batch setup, focusing on CIF rename mapping.""" - def test_batch_writes_mapping_only_for_renamed( - self, sample_cif, tmp_path - ): + def test_batch_writes_mapping_only_for_renamed(self, sample_cif, tmp_path): """cif_mapping.json should list only CIFs that needed renaming.""" cif_dir = tmp_path / "cifs" cif_dir.mkdir()