From 39e60843bfdc67675a439c9de0b4189b04c351a5 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Mon, 21 Sep 2026 22:48:26 +0200 Subject: [PATCH 01/76] Fetch the Hammond surface spectra through fwl-io The Hammond et al. (2024) surface reflectance spectra are declared in the PROTEUS manifest as atmos_clim.surface_albedos.hammond_2024, pinned to Zenodo record 15880455 with a checksum registry, and fetched with fetch_dataset into the versioned dataset directory. A failed fetch raises, since AGNI needs the file. atmos_clim.agni.surf_material now names a file inside that dataset directory, for example lunarmarebasalt.dat, so the r segment never appears in a configuration. The path form surface_albedos/Hammond24/ is also accepted and selects the same file. Any other value stays relative to FWL_DATA, and an absolute path is used as given. A bare file name previously resolved against FWL_DATA and now resolves into the Hammond dataset directory. The Hammond24 entry is removed from DATA_SOURCE_MAP. The example and dummy AGNI configurations, the data page and the regenerated configuration reference use the file name form. --- docs/Reference/config/atmosphere.md | 2 +- docs/Reference/config/config_schema.json | 2 +- docs/Reference/data.md | 11 +++- examples/minimal/init_coupler.toml | 2 +- src/proteus/atmos_clim/agni.py | 33 +++++++++- src/proteus/config/_atmos_clim.py | 2 +- src/proteus/data/__init__.py | 1 + ....surface_albedos.hammond_2024.registry.txt | 26 ++++++++ src/proteus/data/proteus_manifest.toml | 5 ++ src/proteus/utils/data.py | 27 +++----- tests/atmos_clim/test_agni.py | 61 +++++++++++++++++++ tests/data/test_manifest.py | 35 +++++++++-- tests/integration/dummy_agni.toml | 2 +- tests/utils/test_data.py | 55 ++++++++--------- 14 files changed, 201 insertions(+), 63 deletions(-) create mode 100644 src/proteus/data/atmos_clim.surface_albedos.hammond_2024.registry.txt diff --git a/docs/Reference/config/atmosphere.md b/docs/Reference/config/atmosphere.md index ed985c957..713dcb254 100644 --- a/docs/Reference/config/atmosphere.md +++ b/docs/Reference/config/atmosphere.md @@ -87,7 +87,7 @@ interior heat flux. The `fastchem_*` parameters apply when | Parameter | Type | Default | Description | |---|---|---|---| -| `surf_material` | str | `"greybody"` | Surface scattering material file. Set to 'greybody' to use surf_greyalbedo. | +| `surf_material` | str | `"greybody"` | Surface scattering material: the file name of a Hammond et al. (2024) spectrum, e.g. 'lunarmarebasalt.dat', or 'greybody' to use surf_greyalbedo. | | `surf_roughness` | float | `0.001` | Characteristic surface roughness scale \[metres\]. Must be > 0. | | `surf_windspeed` | float | `2.0` | Characteristic surface wind speed \[m/s\]. Must be > 0. | diff --git a/docs/Reference/config/config_schema.json b/docs/Reference/config/config_schema.json index a1128a229..240da11f6 100644 --- a/docs/Reference/config/config_schema.json +++ b/docs/Reference/config/config_schema.json @@ -7720,7 +7720,7 @@ "default": "\"greybody\"", "choices": null, "bounds": null, - "description": "Surface scattering material file. Set to 'greybody' to use surf_greyalbedo.", + "description": "Surface scattering material: the file name of a Hammond et al. (2024) spectrum, e.g. 'lunarmarebasalt.dat', or 'greybody' to use surf_greyalbedo.", "doc_source": "attributes", "group_order": 1, "group_position": 0, diff --git a/docs/Reference/data.md b/docs/Reference/data.md index b374ab2e5..f3dd35748 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -182,13 +182,18 @@ increase spectral resolution and runtime cost. ## Surfaces -Single-scattering albedo data from Hammond et al. (2024): -[Zenodo record 13691960](https://zenodo.org/records/13691960). +Surface reflectance spectra from Hammond et al. (2024): +[Zenodo record 15880455](https://zenodo.org/records/15880455). +`proteus get surfaces` downloads them into the versioned dataset directory `$FWL_DATA/atmos_clim/surface_albedos/hammond_2024/r15880455`. + +Set `atmos_clim.agni.surf_material` to the file name of a spectrum, for example `"lunarmarebasalt.dat"`. +The path form `"surface_albedos/Hammond24/lunarmarebasalt.dat"` is also accepted and selects the same file. Available surface types can be listed with: ```console -ls $FWL_DATA/surface_albedos/Hammond24 +proteus get surfaces +ls $FWL_DATA/atmos_clim/surface_albedos/hammond_2024/r15880455 ``` --- diff --git a/examples/minimal/init_coupler.toml b/examples/minimal/init_coupler.toml index 0e736eddd..bef2b8499 100644 --- a/examples/minimal/init_coupler.toml +++ b/examples/minimal/init_coupler.toml @@ -109,7 +109,7 @@ tmp_maximum = 5000.0 spectral_group = "Honeyside" spectral_bands = "48" p_top = 1e-05 -surf_material = "surface_albedos/Hammond24/lunarmarebasalt.dat" +surf_material = "lunarmarebasalt.dat" num_levels = 40 chemistry = "none" solve_energy = true diff --git a/src/proteus/atmos_clim/agni.py b/src/proteus/atmos_clim/agni.py index 58ec581ab..26c1dc629 100644 --- a/src/proteus/atmos_clim/agni.py +++ b/src/proteus/atmos_clim/agni.py @@ -4,6 +4,7 @@ import glob import logging import os +from pathlib import Path from typing import TYPE_CHECKING import numpy as np @@ -453,6 +454,36 @@ def _determine_aerosols(dirs: dict) -> list: return aerosols +def _resolve_surface_material(name: str, fwl_dir: str) -> str: + """Return the path of the surface reflectance file named in the config. + + A bare file name, or the path ``surface_albedos/Hammond24/``, selects + a file of the Hammond et al. (2024) dataset; the ``r`` version + segment is never part of the configured value. Any other value is a path + relative to the reference-data root. + + Parameters + ---------- + name : str + Value of ``atmos_clim.agni.surf_material``. + fwl_dir : str + Root of the reference-data tree. + + Returns + ------- + str + Path to the reflectance file, which may not exist. + """ + from proteus.data import SURFACE_ALBEDOS_HAMMOND_2024, dataset_dir + + parts = Path(name).parts + if parts[:2] == ('surface_albedos', 'Hammond24') and len(parts) == 3: + name = parts[2] + if len(Path(name).parts) == 1: + return os.path.join(dataset_dir(SURFACE_ALBEDOS_HAMMOND_2024, data_root=fwl_dir), name) + return os.path.join(fwl_dir, name) + + def init_agni_atmos(dirs: dict, config: Config, hf_row: dict): """Initialise atmosphere struct for use by AGNI. @@ -555,7 +586,7 @@ def init_agni_atmos(dirs: dict, config: Config, hf_row: dict): else: # Empirical values log.debug(f"Using '{surface_material}' single-scattering surface properties") - surface_material = os.path.join(dirs['fwl'], surface_material) + surface_material = _resolve_surface_material(surface_material, dirs['fwl']) if not os.path.isfile(surface_material): UpdateStatusfile(dirs, 20) raise FileNotFoundError(surface_material) diff --git a/src/proteus/config/_atmos_clim.py b/src/proteus/config/_atmos_clim.py index dda84a5a8..58ef3811b 100644 --- a/src/proteus/config/_atmos_clim.py +++ b/src/proteus/config/_atmos_clim.py @@ -122,7 +122,7 @@ class Agni: verbosity: int Logging and output verbosity for agni (0:none, 1:info, 2:debug). surf_material: str - Surface scattering material file. Set to 'greybody' to use surf_greyalbedo. + Surface scattering material: the file name of a Hammond et al. (2024) spectrum, e.g. 'lunarmarebasalt.dat', or 'greybody' to use surf_greyalbedo. chemistry: str | None Atmospheric chemistry treatment. Choices: 'none', 'eq' (FastChem). solve_energy: bool diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index a4716181c..c816a3886 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -23,6 +23,7 @@ # single edit here and in the manifest. EXOPLANET_REFERENCE = 'observe.exoplanet_reference' MASS_RADIUS_ZENG_2019 = 'observe.mass_radius.zeng_2019' +SURFACE_ALBEDOS_HAMMOND_2024 = 'atmos_clim.surface_albedos.hammond_2024' # The oldest fwl-io that reads this manifest schema. An older fwl-io reads the # manifest as malformed rather than as a version mismatch, so the load names diff --git a/src/proteus/data/atmos_clim.surface_albedos.hammond_2024.registry.txt b/src/proteus/data/atmos_clim.surface_albedos.hammond_2024.registry.txt new file mode 100644 index 000000000..3b9d2668a --- /dev/null +++ b/src/proteus/data/atmos_clim.surface_albedos.hammond_2024.registry.txt @@ -0,0 +1,26 @@ +_readme.txt md5:4cc65db65be7a3c35b8796d31a267492 +albitedust.dat md5:eb355231fb3116360795a85563e360ac +andesite.dat md5:ddd452f6588e98b03763f142a46928db +basaltglass.dat md5:ffed8bf69b515ee3dbca408af3b155b4 +basaltlarge.dat md5:91a846550b384cf8f6758e6dc740fe93 +basaltsmall.dat md5:28447c27c65dd4752a8e40c3768b5360 +basalttuff.dat md5:90193334a63a0e49fd2a7d400adaec46 +diorite.dat md5:5261b3b0355e244547312f60d3f6f5f0 +gabbro.dat md5:9659ff2a02b79caa090161408c25fd20 +granite.dat md5:278b00d6940949f03e010581a4409530 +harzburgite.dat md5:9157ceede7b804e2d1850938b92ac435 +hematite.dat md5:abfe20aab9897a276ab9cf4c89b79760 +lherzolite.dat md5:3a5111bdb6748e31f0f760e3206f87cc +lunaranorthosite.dat md5:d021f13b739131231d649e5e9a7c4c18 +lunarmarebasalt.dat md5:a157ea1d436072264c3bea833997a382 +magnesiumsulphate.dat md5:d10a17913805d901582c27c1a95a7ccd +marsbasalticshergottite.dat md5:990c9a9c412d01824ae5089adfb4df10 +marsbreccia.dat md5:2452dabde1093787b97ec8a4ae33bbea +norite.dat md5:aef1c52c98f794088c370db1a91057fc +phonolite.dat md5:102fb378b45d485292f6e5fe64508e8c +pyrite.dat md5:5d9da232290f5d769de07f77a441457a +rhyolite.dat md5:b99e2c3698924e2305a3f683644b532c +tephrite.dat md5:0d9f77a15c99a098941fbaf70edfe465 +tholeiiticbasalt.dat md5:2302bb1c156abb8a0cc2fc37d796d87a +trachybasalt.dat md5:f45b11b377ee69440d224bd10b2042b0 +trachyte.dat md5:743685f44e7bb9d1b5b79c187223f5d0 diff --git a/src/proteus/data/proteus_manifest.toml b/src/proteus/data/proteus_manifest.toml index 9b3120140..ec5249dd9 100644 --- a/src/proteus/data/proteus_manifest.toml +++ b/src/proteus/data/proteus_manifest.toml @@ -19,3 +19,8 @@ required_by = ["proteus"] name = "Zeng et al. (2019) mass-radius relations" zenodo = "10.5281/zenodo.15727899" required_by = ["proteus"] + +[atmos_clim.surface_albedos.hammond_2024] +name = "Surface material reflectance spectra (Hammond et al. 2024)" +zenodo = "10.5281/zenodo.15880455" +required_by = ["proteus"] diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 480765584..50a24ec5a 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -494,8 +494,6 @@ def validate_zenodo_folder(zenodo_id: str, folder_dir: Path, hash_maxfilesize=10 'osf_id': 'phsxf', 'osf_project': 'phsxf', }, - # Surface albedos (OSF project: 2gcd9) - 'Hammond24': {'zenodo_id': '15880455', 'osf_id': '2gcd9', 'osf_project': '2gcd9'}, # Stellar spectra (OSF project: 8r2sw) 'Named': {'zenodo_id': '15721440', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, # Stellar spectra - PHOENIX (OSF project: 8r2sw) @@ -504,9 +502,9 @@ def validate_zenodo_folder(zenodo_id: str, folder_dir: Path, hash_maxfilesize=10 'MUSCLES': {'zenodo_id': '17802209', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, # Stellar spectra - solar (OSF project: 8r2sw) 'solar': {'zenodo_id': '17981836', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, - # The exoplanet catalogue and the mass-radius relations are declared in - # src/proteus/data/proteus_manifest.toml and fetched through fwl-io, so - # their record pins live there and are absent here. + # The surface albedos, the exoplanet catalogue and the mass-radius relations + # are declared in src/proteus/data/proteus_manifest.toml and fetched through + # fwl-io, so their record pins live there and are absent here. # Population data (OSF project: dpkjb) # NOTE: Population and EOS_Seager2007 currently share Zenodo ID '15727998'. 'Population': {'zenodo_id': '15727998', 'osf_id': 'dpkjb', 'osf_project': 'dpkjb'}, @@ -1069,20 +1067,15 @@ def download( def download_surface_albedos(): """ - Download reflectance data for various surface materials + Download the surface reflectance data through fwl-io. + + The record pin and the file checksums come from the manifest PROTEUS ships, + so the tables land in their version directory and are verified against the + committed registry. AGNI needs these files, so a failed fetch raises. """ - folder = 'Hammond24' - source_info = get_data_source_info(folder) - if not source_info: - raise ValueError(f'No data source mapping found for folder: {folder}') + from proteus.data import SURFACE_ALBEDOS_HAMMOND_2024, fetch_dataset - download( - folder=folder, - target='surface_albedos', - osf_id=source_info['osf_project'], - zenodo_id=source_info['zenodo_id'], - desc='surface reflectance data', - ) + fetch_dataset(SURFACE_ALBEDOS_HAMMOND_2024) def download_scattering(): diff --git a/tests/atmos_clim/test_agni.py b/tests/atmos_clim/test_agni.py index 4f68bb933..0f125b611 100644 --- a/tests/atmos_clim/test_agni.py +++ b/tests/atmos_clim/test_agni.py @@ -18,6 +18,7 @@ from __future__ import annotations import logging +import os from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -29,6 +30,7 @@ from proteus.atmos_clim.agni import ( _determine_aerosols, _determine_condensates, + _resolve_surface_material, _validate_stored_profile, init_agni_atmos, write_atmos_ncdf, @@ -2675,3 +2677,62 @@ def test_a_lost_atmosphere_goes_to_transparent_mode_not_the_pressure_grid(monkey assert 'generate_pgrid' in fake_agni.calls assert 'make_transparent' not in fake_agni.calls status.assert_not_called() + + +# --------------------------------------------------------------------------- +# _resolve_surface_material: the configured surface file names +# --------------------------------------------------------------------------- + + +def _hammond_file(root, name='lunarmarebasalt.dat'): + """Create a stand-in Hammond file in its version directory and return it.""" + from proteus.data import SURFACE_ALBEDOS_HAMMOND_2024, dataset_dir + + folder = dataset_dir(SURFACE_ALBEDOS_HAMMOND_2024, data_root=root) + folder.mkdir(parents=True, exist_ok=True) + path = folder / name + path.write_text('0.5 0.1\n') + return path + + +def test_resolve_surface_material_bare_file_name(tmp_path): + """A bare file name selects a file of the versioned Hammond dataset.""" + expected = _hammond_file(tmp_path) + + resolved = _resolve_surface_material('lunarmarebasalt.dat', str(tmp_path)) + + assert resolved == str(expected) + assert 'r15880455' in resolved + assert os.path.isfile(resolved) + + +def test_resolve_surface_material_legacy_prefixed_path(tmp_path): + """The pre-manifest value resolves to the same file as the bare name.""" + expected = _hammond_file(tmp_path) + + legacy = _resolve_surface_material( + 'surface_albedos/Hammond24/lunarmarebasalt.dat', str(tmp_path) + ) + bare = _resolve_surface_material('lunarmarebasalt.dat', str(tmp_path)) + + assert legacy == bare == str(expected) + # The legacy directory must not be joined literally: that path holds no + # file in the versioned layout. + assert legacy != str(tmp_path / 'surface_albedos' / 'Hammond24' / 'lunarmarebasalt.dat') + + +def test_resolve_surface_material_other_paths_stay_relative_to_the_data_root(tmp_path): + """A value that is neither a bare name nor the legacy prefix keeps its old meaning.""" + resolved = _resolve_surface_material('my_albedos/custom.dat', str(tmp_path)) + + assert resolved == str(tmp_path / 'my_albedos' / 'custom.dat') + # A deeper path under the legacy directory is not a Hammond file name. + nested = _resolve_surface_material('surface_albedos/Hammond24/sub/x.dat', str(tmp_path)) + assert nested == str(tmp_path / 'surface_albedos' / 'Hammond24' / 'sub' / 'x.dat') + + +def test_resolve_surface_material_absolute_path_is_unchanged(tmp_path): + """An absolute path is returned as given.""" + target = tmp_path / 'elsewhere.dat' + + assert _resolve_surface_material(str(target), str(tmp_path / 'root')) == str(target) diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index 32207c602..a641f6fa3 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -26,6 +26,7 @@ EXOPLANET_REFERENCE, FWL_IO_FLOOR, MASS_RADIUS_ZENG_2019, + SURFACE_ALBEDOS_HAMMOND_2024, _dataset, _fwl_io_derives_the_location, dataset_dir, @@ -39,6 +40,7 @@ # test rather than quietly moving every reader to a different deposit. EXOPLANET_RECORD = '15727878' ZENG_2019_RECORD = '15727899' +HAMMOND_2024_RECORD = '15880455' REPO_ROOT = Path(__file__).resolve().parents[2] @@ -49,8 +51,8 @@ def _pyproject() -> dict: return tomllib.load(handle) -def test_manifest_declares_the_observe_datasets(): - """The manifest declares the two reference datasets PROTEUS owns. +def test_manifest_declares_the_datasets(): + """The manifest declares the datasets PROTEUS owns. Both the key set and each dataset's derived location are pinned: fwl-io turns the dotted key into the on-disk path, so a key edit silently relocates @@ -60,12 +62,23 @@ def test_manifest_declares_the_observe_datasets(): datasets = {ds.key: ds for ds in load_manifest(manifest_path())} - assert set(datasets) == {EXOPLANET_REFERENCE, MASS_RADIUS_ZENG_2019} + assert set(datasets) == { + EXOPLANET_REFERENCE, + MASS_RADIUS_ZENG_2019, + SURFACE_ALBEDOS_HAMMOND_2024, + } assert datasets[EXOPLANET_REFERENCE].subdir == 'observe/exoplanet_reference' assert datasets[MASS_RADIUS_ZENG_2019].subdir == 'observe/mass_radius/zeng_2019' + assert ( + datasets[SURFACE_ALBEDOS_HAMMOND_2024].subdir + == 'atmos_clim/surface_albedos/hammond_2024' + ) + assert datasets[SURFACE_ALBEDOS_HAMMOND_2024].zenodo == ( + f'10.5281/zenodo.{HAMMOND_2024_RECORD}' + ) assert datasets[EXOPLANET_REFERENCE].zenodo == f'10.5281/zenodo.{EXOPLANET_RECORD}' assert datasets[MASS_RADIUS_ZENG_2019].zenodo == f'10.5281/zenodo.{ZENG_2019_RECORD}' - # Both are PROTEUS-owned, so "proteus" has to appear in required_by or + # All are PROTEUS-owned, so "proteus" has to appear in required_by or # "fwl-io fetch proteus" would skip them. for ds in datasets.values(): assert 'proteus' in [model.lower() for model in ds.required_by] @@ -80,13 +93,16 @@ def test_registries_pin_committed_checksums(): """ exo = _dataset(EXOPLANET_REFERENCE).registry() zeng = _dataset(MASS_RADIUS_ZENG_2019).registry() + hammond = _dataset(SURFACE_ALBEDOS_HAMMOND_2024).registry() assert len(exo) == 1, 'the catalogue ships exactly one file' assert len(zeng) == 57, 'the Zeng-2019 grid ships 57 curve files' + assert len(hammond) == 26, 'the Hammond-2024 record ships 25 spectra and a readme' + assert hammond['lunarmarebasalt.dat'] == 'md5:a157ea1d436072264c3bea833997a382' assert exo['DACE_PlanetS.csv'] == 'md5:367a90914eba4a209f896a1c72dd3d2b' # Every entry must carry an algorithm prefix, or pooch cannot know what to # verify against; a bare digest would silently be read as the default. - for registry in (exo, zeng): + for registry in (exo, zeng, hammond): assert all(':' in digest for digest in registry.values()) assert 'massradiusEarthlikeRocky.txt' in zeng @@ -106,6 +122,7 @@ def test_manifest_is_discovered_via_entry_point(): assert {ds.key for ds in providers['proteus']} == { EXOPLANET_REFERENCE, MASS_RADIUS_ZENG_2019, + SURFACE_ALBEDOS_HAMMOND_2024, } @@ -126,6 +143,9 @@ def test_dataset_dir_is_versioned(tmp_path): assert dataset_dir(EXOPLANET_REFERENCE, data_root=tmp_path) == ( tmp_path / 'observe' / 'exoplanet_reference' / f'r{EXOPLANET_RECORD}' ) + assert dataset_dir(SURFACE_ALBEDOS_HAMMOND_2024, data_root=tmp_path) == ( + tmp_path / 'atmos_clim' / 'surface_albedos' / 'hammond_2024' / f'r{HAMMOND_2024_RECORD}' + ) def test_dataset_dir_rejects_an_unversioned_resolution(tmp_path, monkeypatch): @@ -287,6 +307,7 @@ def test_manifest_and_registries_are_declared_as_package_data(): assert {name for name in shipped if name.endswith('.registry.txt')} == { f'{EXOPLANET_REFERENCE}.registry.txt', f'{MASS_RADIUS_ZENG_2019}.registry.txt', + f'{SURFACE_ALBEDOS_HAMMOND_2024}.registry.txt', } @@ -301,11 +322,13 @@ def test_migrated_datasets_are_not_also_pinned_in_the_legacy_map(): assert 'Exoplanets' not in DATA_SOURCE_MAP assert 'Zeng2019' not in DATA_SOURCE_MAP + assert 'Hammond24' not in DATA_SOURCE_MAP # Discrimination: the map is still populated for the datasets that have not # migrated, so an emptied map cannot make this pass. - assert 'Hammond24' in DATA_SOURCE_MAP + assert 'Named' in DATA_SOURCE_MAP pinned_records = {entry['zenodo_id'] for entry in DATA_SOURCE_MAP.values()} assert EXOPLANET_RECORD not in pinned_records + assert HAMMOND_2024_RECORD not in pinned_records assert ZENG_2019_RECORD not in pinned_records diff --git a/tests/integration/dummy_agni.toml b/tests/integration/dummy_agni.toml index 822812f86..b20fc25c3 100644 --- a/tests/integration/dummy_agni.toml +++ b/tests/integration/dummy_agni.toml @@ -74,7 +74,7 @@ config_version = "3.0" overlap_method = "ee" # gas overlap method [atmos_clim.agni] chemistry = "none" # "none" | "eq" - surf_material = "surface_albedos/Hammond24/lunarmarebasalt.dat" # surface material file for scattering + surf_material = "lunarmarebasalt.dat" # surface material file for scattering solve_energy = false # solve for energy-conserving atmosphere profile solution_atol = 1e-3 # solver absolute tolerance solution_rtol = 2e-2 # solver relative tolerance diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 06b3c8778..5e90b3e23 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -1063,10 +1063,10 @@ def test_download_folder_mode_fails_if_no_sources_available(mock_check, mock_get def test_get_data_source_info(): """Test unified data source mapping lookup.""" # Test known mapping - info = get_data_source_info('Hammond24') + info = get_data_source_info('Named') assert info is not None - assert info['zenodo_id'] == '15880455' - assert info['osf_project'] == '2gcd9' + assert info['zenodo_id'] == '15721440' + assert info['osf_project'] == '8r2sw' # Test unknown mapping info = get_data_source_info('UnknownFolder') @@ -1076,7 +1076,6 @@ def test_get_data_source_info(): @pytest.mark.unit def test_get_osf_project(): """Test OSF project ID lookup.""" - assert get_osf_project('Hammond24') == '2gcd9' assert get_osf_project('Named') == '8r2sw' assert get_osf_project('UnknownFolder') is None @@ -1098,7 +1097,6 @@ def test_get_zenodo_from_osf(): @pytest.mark.unit def test_get_osf_from_zenodo(): """Test reverse lookup: Zenodo ID -> OSF project.""" - assert get_osf_from_zenodo('15880455') == '2gcd9' # Hammond24 assert get_osf_from_zenodo('15721440') == '8r2sw' # Named assert get_osf_from_zenodo('99999999') is None # Unknown @@ -1288,7 +1286,7 @@ def test_download_automatic_mapping( mock_download_zenodo.return_value = True mock_validate.return_value = True - folder_dir = tmp_path / 'target' / 'Hammond24' + folder_dir = tmp_path / 'target' / 'Named' folder_dir.mkdir(parents=True, exist_ok=True) (folder_dir / 'test_file.txt').write_text('test') @@ -1299,7 +1297,7 @@ def test_download_automatic_mapping( # Call download without explicit IDs - should use mapping result = download( - folder='Hammond24', + folder='Named', target='target', desc='test data', # No osf_id or zenodo_id provided - should use mapping @@ -1309,7 +1307,7 @@ def test_download_automatic_mapping( mock_download_zenodo.assert_called_once() # Should have used mapped Zenodo ID (check kwargs since it's called with keyword args) call_kwargs = mock_download_zenodo.call_args.kwargs - assert call_kwargs['zenodo_id'] == '15880455' # Zenodo ID from mapping + assert call_kwargs['zenodo_id'] == '15721440' # Zenodo ID from mapping assert result is True @@ -1960,17 +1958,28 @@ def test_download_massradius_data(mock_fetch, mock_download): @pytest.mark.unit @patch('proteus.utils.data.download') -def test_download_surface_albedos(mock_download): - """Test surface albedos download.""" +@patch('proteus.data.fetch_dataset') +def test_download_surface_albedos(mock_fetch, mock_download): + """The surface albedos are fetched through fwl-io, not the legacy path.""" + from proteus.data import SURFACE_ALBEDOS_HAMMOND_2024 from proteus.utils.data import download_surface_albedos download_surface_albedos() - mock_download.assert_called_once() - call_kwargs = mock_download.call_args.kwargs - assert call_kwargs['folder'] == 'Hammond24' - assert call_kwargs['target'] == 'surface_albedos' - assert call_kwargs['desc'] == 'surface reflectance data' + mock_fetch.assert_called_once_with(SURFACE_ALBEDOS_HAMMOND_2024) + assert mock_fetch.call_args.args[0] == 'atmos_clim.surface_albedos.hammond_2024' + mock_download.assert_not_called() + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset', side_effect=OSError('mirror unreachable')) +def test_download_surface_albedos_propagates_fetch_failure(mock_fetch): + """AGNI needs the albedo files, so a failed fetch raises instead of being logged.""" + from proteus.utils.data import download_surface_albedos + + with pytest.raises(OSError, match='mirror unreachable'): + download_surface_albedos() + mock_fetch.assert_called_once() @pytest.mark.unit @@ -2337,22 +2346,6 @@ def test_download_melting_curves_canonical_skip_existing( assert (mc_dir / 'solidus.dat').read_text() == 'old solidus' -@pytest.mark.unit -@patch('proteus.utils.data.get_data_source_info') -def test_download_surface_albedos_no_mapping(mock_get_info): - """Test surface albedos download raises error when no mapping found.""" - from proteus.utils.data import download_surface_albedos - - mock_get_info.return_value = None - - with pytest.raises(ValueError, match='No data source mapping found'): - download_surface_albedos() - # Discrimination: confirm the mapping lookup actually ran (otherwise - # the ValueError could come from an unrelated guard before the - # registry is consulted). - mock_get_info.assert_called_once() - - @pytest.mark.unit @patch('proteus.utils.data.get_data_source_info') def test_download_scattering_no_mapping(mock_get_info): From 1fd0640b07fadb2e6de55d7345bc4ca83ff58e1a Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Mon, 21 Sep 2026 23:23:15 +0200 Subject: [PATCH 02/76] Fetch the Seager EOS tables through fwl-io The Seager et al. (2007) iron, silicate and water tables (Zenodo record 15727998) are described by a manifest table under the key interior_struct.eos.seager_2007, with a registry of the three files. download_Seager_EOS fetches the dataset through fetch_dataset, and both get_zalmoxis_EOS and load_zalmoxis_material_dictionaries resolve the tables through dataset_dir, so the location is $FWL_DATA/interior_struct/eos/seager_2007/r15727998. The legacy map entries for the Seager tables and the Population entry are removed, along with the fallbacks to $FWL_DATA/EOS_material_properties/EOS_Seager2007. A copy in that legacy directory is no longer read; the data must be fetched again. --- docs/Reference/data.md | 4 +- src/proteus/data/__init__.py | 1 + ...terior_struct.eos.seager_2007.registry.txt | 3 + src/proteus/data/proteus_manifest.toml | 5 + src/proteus/interior_struct/zalmoxis.py | 6 +- src/proteus/utils/data.py | 57 +++----- tests/data/test_manifest.py | 22 ++- tests/interior_struct/test_zalmoxis.py | 27 ++++ tests/utils/test_data.py | 138 +++++++++--------- 9 files changed, 153 insertions(+), 110 deletions(-) create mode 100644 src/proteus/data/interior_struct.eos.seager_2007.registry.txt diff --git a/docs/Reference/data.md b/docs/Reference/data.md index f3dd35748..794cdc87e 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -231,4 +231,6 @@ not read and can be deleted. ## Equations of state -The interior structure solver of PROTEUS, Zalmoxis, uses equation-of-state tables by [Seager et al. (2007)](https://iopscience.iop.org/article/10.1086/521346), and from [PALEOS](https://github.com/maraattia/PALEOS) by [Attia et al. (2026)](https://ui.adsabs.harvard.edu/abs/2026arXiv260503741A/abstract). An overview of equation of state tables can be found [here](https://proteus-framework.org/Zalmoxis/Reference/data.html#data-inventory). \ No newline at end of file +The interior structure solver of PROTEUS, Zalmoxis, uses equation-of-state tables by [Seager et al. (2007)](https://iopscience.iop.org/article/10.1086/521346), and from [PALEOS](https://github.com/maraattia/PALEOS) by [Attia et al. (2026)](https://ui.adsabs.harvard.edu/abs/2026arXiv260503741A/abstract). An overview of equation of state tables can be found [here](https://proteus-framework.org/Zalmoxis/Reference/data.html#data-inventory). + +The Seager et al. (2007) tables are fetched through fwl-io into `$FWL_DATA/interior_struct/eos/seager_2007/r/`, on the same terms as the datasets above. A copy under `$FWL_DATA/EOS_material_properties/EOS_Seager2007` is not read and can be deleted. diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index c816a3886..8af6c1acf 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -24,6 +24,7 @@ EXOPLANET_REFERENCE = 'observe.exoplanet_reference' MASS_RADIUS_ZENG_2019 = 'observe.mass_radius.zeng_2019' SURFACE_ALBEDOS_HAMMOND_2024 = 'atmos_clim.surface_albedos.hammond_2024' +EOS_SEAGER_2007 = 'interior_struct.eos.seager_2007' # The oldest fwl-io that reads this manifest schema. An older fwl-io reads the # manifest as malformed rather than as a version mismatch, so the load names diff --git a/src/proteus/data/interior_struct.eos.seager_2007.registry.txt b/src/proteus/data/interior_struct.eos.seager_2007.registry.txt new file mode 100644 index 000000000..56a3d0457 --- /dev/null +++ b/src/proteus/data/interior_struct.eos.seager_2007.registry.txt @@ -0,0 +1,3 @@ +eos_seager07_iron.txt md5:7bf215a2bb4da6d27ceeac2ade0ce706 +eos_seager07_silicate.txt md5:60109b1bc020b70cd9371f18fffb2547 +eos_seager07_water.txt md5:b9d6d1bb0a2fe9eb4c88e32b1faf7e65 diff --git a/src/proteus/data/proteus_manifest.toml b/src/proteus/data/proteus_manifest.toml index ec5249dd9..bb875e3f3 100644 --- a/src/proteus/data/proteus_manifest.toml +++ b/src/proteus/data/proteus_manifest.toml @@ -24,3 +24,8 @@ required_by = ["proteus"] name = "Surface material reflectance spectra (Hammond et al. 2024)" zenodo = "10.5281/zenodo.15880455" required_by = ["proteus"] + +[interior_struct.eos.seager_2007] +name = "Static equations of state for iron, silicate and water (Seager et al. 2007)" +zenodo = "10.5281/zenodo.15727998" +required_by = ["proteus"] diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 84d66d09a..280c3b65e 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -17,6 +17,7 @@ from zalmoxis.solver import main from proteus.config import Config +from proteus.data import EOS_SEAGER_2007, dataset_dir from proteus.utils.constants import ( FEI2021_LIQUIDUS_P_CALIB_PA, PALEOS_EOS_PREFIXES, @@ -1229,10 +1230,7 @@ def load_zalmoxis_material_dictionaries(): """ eos_base = get_zalmoxis_eos_dir() - # Seager2007 paths (also in the EOS_material_properties location) - seager_dir = eos_base / 'EOS_Seager2007' - if not seager_dir.exists(): - seager_dir = FWL_DATA_DIR / 'EOS_material_properties' / 'EOS_Seager2007' + seager_dir = dataset_dir(EOS_SEAGER_2007, data_root=FWL_DATA_DIR) _seager_iron = {'eos_file': str(seager_dir / 'eos_seager07_iron.txt')} _seager_silicate = {'eos_file': str(seager_dir / 'eos_seager07_silicate.txt')} diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 50a24ec5a..9b4f3829b 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -502,14 +502,9 @@ def validate_zenodo_folder(zenodo_id: str, folder_dir: Path, hash_maxfilesize=10 'MUSCLES': {'zenodo_id': '17802209', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, # Stellar spectra - solar (OSF project: 8r2sw) 'solar': {'zenodo_id': '17981836', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, - # The surface albedos, the exoplanet catalogue and the mass-radius relations - # are declared in src/proteus/data/proteus_manifest.toml and fetched through + # The surface albedos, the Seager EOS tables, the exoplanet catalogue and the + # mass-radius relations are declared in src/proteus/data/proteus_manifest.toml and fetched through # fwl-io, so their record pins live there and are absent here. - # Population data (OSF project: dpkjb) - # NOTE: Population and EOS_Seager2007 currently share Zenodo ID '15727998'. - 'Population': {'zenodo_id': '15727998', 'osf_id': 'dpkjb', 'osf_project': 'dpkjb'}, - # EOS material properties (OSF project: dpkjb) - 'EOS_Seager2007': {'zenodo_id': '15727998', 'osf_id': 'dpkjb', 'osf_project': 'dpkjb'}, # Zalmoxis EOS: Wolf & Bower 2018 T-dependent MgSiO3 (1 TPa) 'EOS_WolfBower2018_1TPa': {'zenodo_id': '17417017'}, # Zalmoxis EOS: RTPress 100 TPa extended melt @@ -1801,10 +1796,8 @@ def get_spider(dirs=None): def download_eos_static(): """Download static (Zalmoxis-only) EOS files. - Downloads Seager et al. (2007) EOS into - ``FWL_DATA/EOS_material_properties/EOS_Seager2007/``. - Code in ``get_zalmoxis_EOS()`` falls back to this path when the - unified ``EOS/static/Seager2007/`` folder is not yet populated. + Fetches the Seager et al. (2007) EOS tables through fwl-io into + ``FWL_DATA/interior_struct/eos/seager_2007/r/``. """ download_Seager_EOS() @@ -1891,19 +1884,15 @@ def download_eos_dynamic(eos_dir: str = 'WolfBower2018_MgSiO3'): def download_Seager_EOS(): - """Download Seager EOS to the EOS_material_properties location.""" - folder = 'EOS_Seager2007' - source_info = get_data_source_info(folder) - if not source_info: - raise ValueError(f'No data source mapping found for folder: {folder}') + """Fetch the Seager EOS tables through fwl-io. - download( - folder=folder, - target='EOS_material_properties', - osf_id=source_info['osf_project'], - zenodo_id=source_info['zenodo_id'], - desc='EOS Seager2007 material files', - ) + The record pin and the file checksums come from the manifest PROTEUS ships. + Zalmoxis needs these tables for every Seager component, so a failed fetch + raises. + """ + from proteus.data import EOS_SEAGER_2007, fetch_dataset + + fetch_dataset(EOS_SEAGER_2007, data_root=FWL_DATA_DIR) # ── Zalmoxis EOS download helpers ──────────────────────────────────── @@ -2232,12 +2221,10 @@ def get_zalmoxis_melting_curves(config: Config): def get_zalmoxis_EOS(): """Build and return material properties dictionaries for Zalmoxis. - Reads EOS files from the unified folder structure under - ``FWL_DATA/interior_lookup_tables/EOS/``. Static (Zalmoxis-only) EOS - like Seager2007 live in ``EOS/static/``, while dynamic EOS like - Wolf & Bower 2018 live in ``EOS/dynamic/WolfBower2018_MgSiO3/P-T/``. - - Falls back to the EOS_material_properties paths if the unified structure is not yet populated. + The Seager2007 tables are fetched through fwl-io into their versioned + dataset directory. Dynamic EOS like Wolf & Bower 2018 are read from the + unified folder structure under ``FWL_DATA/interior_lookup_tables/EOS/`` + (``EOS/dynamic/WolfBower2018_MgSiO3/P-T/``). The folder name matches the Zalmoxis ``mantle_eos`` source string (``WolfBower2018_MgSiO3``), not ``struct.eos_dir``. @@ -2251,16 +2238,12 @@ def get_zalmoxis_EOS(): """ eos_base = FWL_DATA_DIR / 'interior_lookup_tables' / 'EOS' - # Seager2007: try the unified location, fall back to EOS_material_properties - seager_folder = eos_base / 'static' / 'Seager2007' - if not seager_folder.exists(): - seager_folder = FWL_DATA_DIR / 'EOS_material_properties' / 'EOS_Seager2007' - if not seager_folder.exists(): + from proteus.data import EOS_SEAGER_2007, dataset_dir + + seager_folder = dataset_dir(EOS_SEAGER_2007, data_root=FWL_DATA_DIR) + if not (seager_folder / 'eos_seager07_iron.txt').exists(): log.debug('Get EOS material properties from Seager et al. (2007)') download_eos_static() - seager_folder = eos_base / 'static' / 'Seager2007' - if not seager_folder.exists(): - seager_folder = FWL_DATA_DIR / 'EOS_material_properties' / 'EOS_Seager2007' # Wolf-Bower: fixed mapping from source name to data folder wb_folder = eos_base / 'dynamic' / 'WolfBower2018_MgSiO3' / 'P-T' diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index a641f6fa3..c32e4c609 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -23,6 +23,7 @@ import pytest from proteus.data import ( + EOS_SEAGER_2007, EXOPLANET_REFERENCE, FWL_IO_FLOOR, MASS_RADIUS_ZENG_2019, @@ -41,6 +42,7 @@ EXOPLANET_RECORD = '15727878' ZENG_2019_RECORD = '15727899' HAMMOND_2024_RECORD = '15880455' +SEAGER_2007_RECORD = '15727998' REPO_ROOT = Path(__file__).resolve().parents[2] @@ -66,6 +68,7 @@ def test_manifest_declares_the_datasets(): EXOPLANET_REFERENCE, MASS_RADIUS_ZENG_2019, SURFACE_ALBEDOS_HAMMOND_2024, + EOS_SEAGER_2007, } assert datasets[EXOPLANET_REFERENCE].subdir == 'observe/exoplanet_reference' assert datasets[MASS_RADIUS_ZENG_2019].subdir == 'observe/mass_radius/zeng_2019' @@ -73,6 +76,8 @@ def test_manifest_declares_the_datasets(): datasets[SURFACE_ALBEDOS_HAMMOND_2024].subdir == 'atmos_clim/surface_albedos/hammond_2024' ) + assert datasets[EOS_SEAGER_2007].subdir == 'interior_struct/eos/seager_2007' + assert datasets[EOS_SEAGER_2007].zenodo == f'10.5281/zenodo.{SEAGER_2007_RECORD}' assert datasets[SURFACE_ALBEDOS_HAMMOND_2024].zenodo == ( f'10.5281/zenodo.{HAMMOND_2024_RECORD}' ) @@ -94,15 +99,22 @@ def test_registries_pin_committed_checksums(): exo = _dataset(EXOPLANET_REFERENCE).registry() zeng = _dataset(MASS_RADIUS_ZENG_2019).registry() hammond = _dataset(SURFACE_ALBEDOS_HAMMOND_2024).registry() + seager = _dataset(EOS_SEAGER_2007).registry() assert len(exo) == 1, 'the catalogue ships exactly one file' assert len(zeng) == 57, 'the Zeng-2019 grid ships 57 curve files' assert len(hammond) == 26, 'the Hammond-2024 record ships 25 spectra and a readme' + assert set(seager) == { + 'eos_seager07_iron.txt', + 'eos_seager07_silicate.txt', + 'eos_seager07_water.txt', + } + assert seager['eos_seager07_iron.txt'] == 'md5:7bf215a2bb4da6d27ceeac2ade0ce706' assert hammond['lunarmarebasalt.dat'] == 'md5:a157ea1d436072264c3bea833997a382' assert exo['DACE_PlanetS.csv'] == 'md5:367a90914eba4a209f896a1c72dd3d2b' # Every entry must carry an algorithm prefix, or pooch cannot know what to # verify against; a bare digest would silently be read as the default. - for registry in (exo, zeng, hammond): + for registry in (exo, zeng, hammond, seager): assert all(':' in digest for digest in registry.values()) assert 'massradiusEarthlikeRocky.txt' in zeng @@ -123,6 +135,7 @@ def test_manifest_is_discovered_via_entry_point(): EXOPLANET_REFERENCE, MASS_RADIUS_ZENG_2019, SURFACE_ALBEDOS_HAMMOND_2024, + EOS_SEAGER_2007, } @@ -146,6 +159,9 @@ def test_dataset_dir_is_versioned(tmp_path): assert dataset_dir(SURFACE_ALBEDOS_HAMMOND_2024, data_root=tmp_path) == ( tmp_path / 'atmos_clim' / 'surface_albedos' / 'hammond_2024' / f'r{HAMMOND_2024_RECORD}' ) + assert dataset_dir(EOS_SEAGER_2007, data_root=tmp_path) == ( + tmp_path / 'interior_struct' / 'eos' / 'seager_2007' / f'r{SEAGER_2007_RECORD}' + ) def test_dataset_dir_rejects_an_unversioned_resolution(tmp_path, monkeypatch): @@ -308,6 +324,7 @@ def test_manifest_and_registries_are_declared_as_package_data(): f'{EXOPLANET_REFERENCE}.registry.txt', f'{MASS_RADIUS_ZENG_2019}.registry.txt', f'{SURFACE_ALBEDOS_HAMMOND_2024}.registry.txt', + f'{EOS_SEAGER_2007}.registry.txt', } @@ -323,12 +340,15 @@ def test_migrated_datasets_are_not_also_pinned_in_the_legacy_map(): assert 'Exoplanets' not in DATA_SOURCE_MAP assert 'Zeng2019' not in DATA_SOURCE_MAP assert 'Hammond24' not in DATA_SOURCE_MAP + assert 'EOS_Seager2007' not in DATA_SOURCE_MAP + assert 'Population' not in DATA_SOURCE_MAP # Discrimination: the map is still populated for the datasets that have not # migrated, so an emptied map cannot make this pass. assert 'Named' in DATA_SOURCE_MAP pinned_records = {entry['zenodo_id'] for entry in DATA_SOURCE_MAP.values()} assert EXOPLANET_RECORD not in pinned_records assert HAMMOND_2024_RECORD not in pinned_records + assert SEAGER_2007_RECORD not in pinned_records assert ZENG_2019_RECORD not in pinned_records diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 33176722c..6761fb313 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -2884,3 +2884,30 @@ def test_twophase_registry_key_selects_table_family(mantle_eos, expected): from proteus.interior_struct.zalmoxis import twophase_registry_key assert twophase_registry_key(mantle_eos) == expected + + +def test_material_dictionaries_seager_paths_use_the_versioned_dataset_dir( + monkeypatch, tmp_path +): + """The Seager entries point into the fwl-io dataset directory, not a legacy folder.""" + import proteus.interior_struct.zalmoxis as zalmoxis_wrapper + from proteus.data import EOS_SEAGER_2007, dataset_dir + + monkeypatch.setattr(zalmoxis_wrapper, 'FWL_DATA_DIR', tmp_path) + monkeypatch.setattr( + zalmoxis_wrapper, 'get_zalmoxis_eos_dir', lambda: tmp_path / 'zalmoxis_eos' + ) + + registry = zalmoxis_wrapper.load_zalmoxis_material_dictionaries() + + seager = dataset_dir(EOS_SEAGER_2007, data_root=tmp_path) + assert registry['Seager2007:iron']['core']['eos_file'] == str( + seager / 'eos_seager07_iron.txt' + ) + assert registry['Seager2007:MgSiO3']['mantle']['eos_file'] == str( + seager / 'eos_seager07_silicate.txt' + ) + assert registry['Seager2007:H2O']['ice_layer']['eos_file'] == str( + seager / 'eos_seager07_water.txt' + ) + assert seager.name.startswith('r') diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 5e90b3e23..ead771653 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -2005,13 +2005,7 @@ def test_get_Seager_EOS_exists(mock_download, tmp_path): from proteus.utils.data import get_Seager_EOS # Create EOS folder - eos_folder = tmp_path / 'EOS_material_properties' / 'EOS_Seager2007' - eos_folder.mkdir(parents=True, exist_ok=True) - - # Create required files - (eos_folder / 'eos_seager07_silicate.txt').write_text('test') - (eos_folder / 'eos_seager07_iron.txt').write_text('test') - (eos_folder / 'eos_seager07_water.txt').write_text('test') + eos_folder = _seed_seager(tmp_path) # Patch FWL_DATA_DIR at module level with patch('proteus.utils.data.FWL_DATA_DIR', tmp_path): @@ -2039,11 +2033,14 @@ def test_get_Seager_EOS_exists(mock_download, tmp_path): @patch('proteus.utils.data.download_Seager_EOS') def test_get_Seager_EOS_not_exists(mock_download, tmp_path): """Test get_Seager_EOS when EOS folder doesn't exist.""" + from proteus.data import EOS_SEAGER_2007, dataset_dir from proteus.utils.data import get_Seager_EOS # Precondition: the EOS folder really is absent before the call so # the missing-folder code path is what gets exercised. - assert not (tmp_path / 'EOS_material_properties' / 'EOS_Seager2007').exists() + assert not ( + dataset_dir(EOS_SEAGER_2007, data_root=tmp_path) / 'eos_seager07_iron.txt' + ).exists() # Patch FWL_DATA_DIR and call function with patch('proteus.utils.data.FWL_DATA_DIR', tmp_path): @@ -2054,18 +2051,21 @@ def test_get_Seager_EOS_not_exists(mock_download, tmp_path): @pytest.mark.unit -@patch('proteus.utils.data.download') -def test_download_Seager_EOS(mock_download): - """Test Seager EOS download.""" +def test_download_Seager_EOS(monkeypatch, tmp_path): + """The Seager EOS is fetched through fwl-io under the manifest key.""" + import proteus.data as data_pkg + import proteus.utils.data as data_mod from proteus.utils.data import download_Seager_EOS + calls = [] + monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) + monkeypatch.setattr( + data_pkg, 'fetch_dataset', lambda key, data_root=None: calls.append((key, data_root)) + ) + download_Seager_EOS() - mock_download.assert_called_once() - call_kwargs = mock_download.call_args.kwargs - assert call_kwargs['folder'] == 'EOS_Seager2007' - assert call_kwargs['target'] == 'EOS_material_properties' - assert call_kwargs['desc'] == 'EOS Seager2007 material files' + assert calls == [('interior_struct.eos.seager_2007', tmp_path)] @pytest.mark.unit @@ -2367,19 +2367,20 @@ def test_download_scattering_no_mapping(mock_get_info): @pytest.mark.unit -@patch('proteus.utils.data.get_data_source_info') -def test_download_Seager_EOS_no_mapping(mock_get_info): - """Test Seager EOS download raises error when no mapping found.""" +def test_download_Seager_EOS_failure_raises(monkeypatch, tmp_path): + """A failed Seager fetch raises: Zalmoxis cannot run without the tables.""" + import proteus.data as data_pkg + import proteus.utils.data as data_mod from proteus.utils.data import download_Seager_EOS - mock_get_info.return_value = None + def boom(key, data_root=None): + raise OSError('no network') - with pytest.raises(ValueError, match='No data source mapping found'): + monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) + monkeypatch.setattr(data_pkg, 'fetch_dataset', boom) + + with pytest.raises(OSError, match='no network'): download_Seager_EOS() - # Discrimination: confirm the mapping lookup actually ran; a regression - # that raised ValueError from an unrelated guard before consulting the - # source-info registry would still pass the raises-match check. - mock_get_info.assert_called_once() @pytest.mark.unit @@ -5496,31 +5497,55 @@ def osf_side_effect(*, storage, files, data_dir): # ============================================================================ +_SEAGER_FILES = ( + 'eos_seager07_iron.txt', + 'eos_seager07_silicate.txt', + 'eos_seager07_water.txt', +) + + +def _seed_seager(root): + """Create the Seager EOS files in their versioned dataset directory under ``root``.""" + from proteus.data import EOS_SEAGER_2007, dataset_dir + + folder = dataset_dir(EOS_SEAGER_2007, data_root=root) + folder.mkdir(parents=True, exist_ok=True) + for fname in _SEAGER_FILES: + (folder / fname).write_text('eos') + return folder + + @pytest.mark.unit -def test_get_zalmoxis_EOS_unified_static_path_used(monkeypatch, tmp_path): - """When EOS/static/Seager2007 exists, that path is preferred over the legacy fallback.""" +def test_get_zalmoxis_EOS_seager_versioned_dir_used(monkeypatch, tmp_path): + """The Seager tables are read from their versioned fwl-io dataset directory.""" import proteus.utils.data as data_mod from proteus.utils.data import get_zalmoxis_EOS monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - - eos_base = tmp_path / 'interior_lookup_tables' / 'EOS' - seager_unified = eos_base / 'static' / 'Seager2007' - seager_unified.mkdir(parents=True, exist_ok=True) - for fname in ( - 'eos_seager07_iron.txt', - 'eos_seager07_silicate.txt', - 'eos_seager07_water.txt', - ): - (seager_unified / fname).write_text('eos') + seager = _seed_seager(tmp_path) iron_silicate, _, water, _ = get_zalmoxis_EOS() - # Discrimination: the iron file path must point inside the unified - # location, NOT the legacy EOS_material_properties path. - assert 'EOS/static/Seager2007' in iron_silicate['core']['eos_file'] - assert 'EOS_material_properties' not in iron_silicate['core']['eos_file'] - # Water dict picks up the water EOS file as well - assert water['ice_layer']['eos_file'].endswith('eos_seager07_water.txt') + + assert Path(iron_silicate['core']['eos_file']) == seager / 'eos_seager07_iron.txt' + assert Path(iron_silicate['mantle']['eos_file']) == seager / 'eos_seager07_silicate.txt' + assert Path(water['ice_layer']['eos_file']) == seager / 'eos_seager07_water.txt' + assert seager.parts[-4:-1] == ('interior_struct', 'eos', 'seager_2007') + assert seager.name.startswith('r') + + +@pytest.mark.unit +def test_get_zalmoxis_EOS_seager_missing_fetches(monkeypatch, tmp_path): + """Absent Seager files trigger one fetch through download_eos_static.""" + import proteus.utils.data as data_mod + from proteus.utils.data import get_zalmoxis_EOS + + monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) + calls = [] + monkeypatch.setattr(data_mod, 'download_eos_static', lambda: calls.append(1)) + + get_zalmoxis_EOS() + + assert calls == [1] @pytest.mark.unit @@ -5569,14 +5594,7 @@ def test_get_zalmoxis_EOS_rt_legacy_folder_used(monkeypatch, tmp_path, caplog): monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - seager_legacy = tmp_path / 'EOS_material_properties' / 'EOS_Seager2007' - seager_legacy.mkdir(parents=True, exist_ok=True) - for fname in ( - 'eos_seager07_iron.txt', - 'eos_seager07_silicate.txt', - 'eos_seager07_water.txt', - ): - (seager_legacy / fname).write_text('eos') + _seed_seager(tmp_path) rt_legacy = tmp_path / 'EOS_material_properties' / 'EOS_RTPress_melt_100TPa' rt_legacy.mkdir(parents=True, exist_ok=True) @@ -5606,14 +5624,7 @@ def test_get_zalmoxis_EOS_rt_missing_cp_warns(monkeypatch, tmp_path, caplog): monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) eos_base = tmp_path / 'interior_lookup_tables' / 'EOS' - seager_legacy = tmp_path / 'EOS_material_properties' / 'EOS_Seager2007' - seager_legacy.mkdir(parents=True, exist_ok=True) - for fname in ( - 'eos_seager07_iron.txt', - 'eos_seager07_silicate.txt', - 'eos_seager07_water.txt', - ): - (seager_legacy / fname).write_text('eos') + _seed_seager(tmp_path) rt_unified = eos_base / 'RTPress_melt_100TPa' rt_unified.mkdir(parents=True, exist_ok=True) @@ -5639,14 +5650,7 @@ def test_get_zalmoxis_EOS_rt_folder_missing_warns(monkeypatch, tmp_path, caplog) # Only Seager is present (legacy); RTPress folder is absent in BOTH # locations. - seager_legacy = tmp_path / 'EOS_material_properties' / 'EOS_Seager2007' - seager_legacy.mkdir(parents=True, exist_ok=True) - for fname in ( - 'eos_seager07_iron.txt', - 'eos_seager07_silicate.txt', - 'eos_seager07_water.txt', - ): - (seager_legacy / fname).write_text('eos') + _seed_seager(tmp_path) with caplog.at_level('WARNING'): _, _, _, iron_rt = get_zalmoxis_EOS() From 4af9732062ac8105e832a8254bf0e2ee981830a9 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Mon, 21 Sep 2026 23:32:51 +0200 Subject: [PATCH 03/76] Fetch the SOCRATES spectral files through fwl-io manifests The 13 spectral-file records (Dayspring, Frostflow and Honeyside at 16, 48, 256 and 4096 bands, and Oak at 318 bands) are declared in the manifest as `atmos_clim.spectral_files._`, each with a committed registry of file checksums. `spectral_file_key(group, bands)` builds the key, and `download_spectral_file` fetches it through `fetch_dataset`. `get_spfile_path` resolves the file inside the dataset's version directory, `$FWL_DATA/atmos_clim/spectral_files/_/r/`, so AGNI and JANUS receive the versioned path. The 13 spectral entries are removed from `DATA_SOURCE_MAP`, so the OSF fallback for these files is gone and the data must be fetched again into the new location. `proteus doctor` now checks `FWL_DATA/atmos_clim/spectral_files`, and `tools/postprocess.jl` finds the Honeyside 4096-band file in the versioned directory. The reference and doctor docs describe the new layout. Tests pin each record and location, check that every entry in `SPECTRAL_FILE_FOLDERS` has a manifest table and no leftover map entry, and check that `get_spfile_path` resolves into the versioned directory. --- docs/How-to/doctor.md | 4 +- docs/Reference/data.md | 2 +- src/proteus/atmos_clim/common.py | 11 ++- src/proteus/data/__init__.py | 21 ++++ ...m.spectral_files.dayspring_16.registry.txt | 5 + ....spectral_files.dayspring_256.registry.txt | 5 + ...spectral_files.dayspring_4096.registry.txt | 5 + ...m.spectral_files.dayspring_48.registry.txt | 4 + ...m.spectral_files.frostflow_16.registry.txt | 5 + ....spectral_files.frostflow_256.registry.txt | 5 + ...spectral_files.frostflow_4096.registry.txt | 5 + ...m.spectral_files.frostflow_48.registry.txt | 5 + ...m.spectral_files.honeyside_16.registry.txt | 5 + ....spectral_files.honeyside_256.registry.txt | 5 + ...spectral_files.honeyside_4096.registry.txt | 5 + ...m.spectral_files.honeyside_48.registry.txt | 5 + ...s_clim.spectral_files.oak_318.registry.txt | 2 + src/proteus/data/proteus_manifest.toml | 65 +++++++++++++ src/proteus/doctor.py | 2 +- src/proteus/utils/data.py | 29 +----- tests/atmos_clim/test_common.py | 15 ++- tests/data/test_manifest.py | 97 ++++++++++++++++--- tests/test_doctor.py | 8 +- tests/utils/test_data.py | 67 +++++-------- tools/postprocess.jl | 5 +- 25 files changed, 289 insertions(+), 98 deletions(-) create mode 100644 src/proteus/data/atmos_clim.spectral_files.dayspring_16.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.dayspring_256.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.dayspring_4096.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.dayspring_48.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.frostflow_16.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.frostflow_256.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.frostflow_4096.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.frostflow_48.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.honeyside_16.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.honeyside_256.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.honeyside_4096.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.honeyside_48.registry.txt create mode 100644 src/proteus/data/atmos_clim.spectral_files.oak_318.registry.txt diff --git a/docs/How-to/doctor.md b/docs/How-to/doctor.md index 77968b983..a8c24737d 100644 --- a/docs/How-to/doctor.md +++ b/docs/How-to/doctor.md @@ -41,7 +41,7 @@ non-empty: | Check | Fix command | |-------|-------------| -| `FWL_DATA/spectral_files` | `proteus get spectral` | +| `FWL_DATA/atmos_clim/spectral_files` | `proteus get spectral` | | `FWL_DATA/stellar_spectra` | `proteus get stellar` | ### Package versions @@ -84,7 +84,7 @@ Environment [ok] julia: 1.11.8 Reference data - [ok] FWL_DATA/spectral_files: present + [ok] FWL_DATA/atmos_clim/spectral_files: present [ok] FWL_DATA/stellar_spectra: present Package versions diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 794cdc87e..5de683485 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -164,7 +164,7 @@ with `#`. Correlated-k opacity tables used by the atmosphere climate modules (AGNI, JANUS). Selected via `atmos_clim.spectral_group` and `atmos_clim.spectral_bands` in the config. Stored under -`$FWL_DATA/spectral_files///`. +`$FWL_DATA/atmos_clim/spectral_files/_/r/`, with the group name in lower case. For a full description of each group's spectral coverage and gas species, see `docs/assets/spectral_files.pdf` in the PROTEUS repository. diff --git a/src/proteus/atmos_clim/common.py b/src/proteus/atmos_clim/common.py index 4c08ccf04..d54a3b62f 100644 --- a/src/proteus/atmos_clim/common.py +++ b/src/proteus/atmos_clim/common.py @@ -338,13 +338,20 @@ def get_spfile_name_and_bands(config: Config): def get_spfile_path(fwl_dir: str, config: Config): """ Get path to spectral file, given name and bands. + + The file lies in the version directory of the spectral-file dataset for the + configured group and band count; the path may not exist before the dataset + is fetched. """ + from proteus.data import dataset_dir, spectral_file_key # Get group and bands (strings) from config group, bands = get_spfile_name_and_bands(config) - # Construct file path - return os.path.join(fwl_dir, 'spectral_files', group, bands, group) + '.sf' + return ( + os.path.join(dataset_dir(spectral_file_key(group, bands), data_root=fwl_dir), group) + + '.sf' + ) def clip_radius_to_hill(config: Config, hf_row: dict, radius: float) -> float: diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index 8af6c1acf..2774cfe96 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -26,6 +26,27 @@ SURFACE_ALBEDOS_HAMMOND_2024 = 'atmos_clim.surface_albedos.hammond_2024' EOS_SEAGER_2007 = 'interior_struct.eos.seager_2007' + +def spectral_file_key(group: str, bands: str | int) -> str: + """Return the manifest key of one spectral-file dataset. + + Parameters + ---------- + group : str + Spectral file group, e.g. ``Dayspring``. + bands : str or int + Number of bands, e.g. ``256``. + + Returns + ------- + str + Dotted manifest key, e.g. ``atmos_clim.spectral_files.dayspring_256``. + The key is not checked against the manifest; an unknown pair raises + ``KeyError`` when the dataset is resolved. + """ + return f'atmos_clim.spectral_files.{str(group).lower()}_{bands}' + + # The oldest fwl-io that reads this manifest schema. An older fwl-io reads the # manifest as malformed rather than as a version mismatch, so the load names # which side is out of date. The fwl-io requirement in pyproject.toml must be at diff --git a/src/proteus/data/atmos_clim.spectral_files.dayspring_16.registry.txt b/src/proteus/data/atmos_clim.spectral_files.dayspring_16.registry.txt new file mode 100644 index 000000000..0599c8b03 --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.dayspring_16.registry.txt @@ -0,0 +1,5 @@ +Dayspring.chk md5:bfa0e8f873ca26e5420d09c298a879df +Dayspring.chk_k md5:0067ee8d145216771c4c1bbfa2951bb7 +Dayspring.sf md5:2e4b9f055620606d6eb5947974dccdde +Dayspring.sf_k md5:58294dd2e05821860921692c4751f578 +ds16.log md5:31c8f66ced3e4185edafce8d0abe4ebc diff --git a/src/proteus/data/atmos_clim.spectral_files.dayspring_256.registry.txt b/src/proteus/data/atmos_clim.spectral_files.dayspring_256.registry.txt new file mode 100644 index 000000000..a659228f2 --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.dayspring_256.registry.txt @@ -0,0 +1,5 @@ +Dayspring.chk md5:e144dbff417cfb1fc5d5ef607d391924 +Dayspring.chk_k md5:609c95edac86ec1303aca769f526edca +Dayspring.sf md5:297a149988e6e603d826e117269c823e +Dayspring.sf_k md5:a46c17b39d894a118ae98e96f7f10ef7 +ds256.log md5:f7cba6f2bd2c77b0ec7702eaf5a681be diff --git a/src/proteus/data/atmos_clim.spectral_files.dayspring_4096.registry.txt b/src/proteus/data/atmos_clim.spectral_files.dayspring_4096.registry.txt new file mode 100644 index 000000000..6fed964e9 --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.dayspring_4096.registry.txt @@ -0,0 +1,5 @@ +Dayspring.chk md5:55b70bb342557ee316e256e09b038696 +Dayspring.chk_k md5:f0e593262bc2c3430f11b890cb6cbce1 +Dayspring.sf md5:94ffb27e593636318063d136decb426b +Dayspring.sf_k md5:42988ab81500d64b57b9e4904a0fc223 +ds4096.log md5:630072d8e0ff8ccc0ac5a9709cc87540 diff --git a/src/proteus/data/atmos_clim.spectral_files.dayspring_48.registry.txt b/src/proteus/data/atmos_clim.spectral_files.dayspring_48.registry.txt new file mode 100644 index 000000000..c4353b8dc --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.dayspring_48.registry.txt @@ -0,0 +1,4 @@ +Dayspring.chk md5:7139c0e7fac225c866f6e2fed3640e41 +Dayspring.chk_k md5:7a506ab9959aca2f0223ed810a6a3589 +Dayspring.sf md5:2553c44b08c924a2105c1267816d8688 +Dayspring.sf_k md5:557b78181c18831cacae1484606c7517 diff --git a/src/proteus/data/atmos_clim.spectral_files.frostflow_16.registry.txt b/src/proteus/data/atmos_clim.spectral_files.frostflow_16.registry.txt new file mode 100644 index 000000000..f81c93cd7 --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.frostflow_16.registry.txt @@ -0,0 +1,5 @@ +Frostflow.chk md5:da9dc8a79f8f85c5397c2d107bce4be9 +Frostflow.chk_k md5:6882e3e81cf4ccec63afe1f5f13958f3 +Frostflow.sf md5:495d24acb80b81090b6abcc7e612aeea +Frostflow.sf_k md5:3ba48a9634dc284149e6ef09e3f95b85 +Frostflow16.log md5:dc9a50ebded1bd0f368adda3a97a6365 diff --git a/src/proteus/data/atmos_clim.spectral_files.frostflow_256.registry.txt b/src/proteus/data/atmos_clim.spectral_files.frostflow_256.registry.txt new file mode 100644 index 000000000..6d966416c --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.frostflow_256.registry.txt @@ -0,0 +1,5 @@ +Frostflow.chk md5:e68122d473ddf36b2422101c2fdc92c8 +Frostflow.chk_k md5:297160caeb48411a69e02915c3da4030 +Frostflow.sf md5:9ffa1ae15c69430a6a4c7a94ea00febe +Frostflow.sf_k md5:08a7df9c7ba22ac159a2588b924bda1a +Frostflow256.log md5:f890a6b820efdc90e72439fb9ef2280d diff --git a/src/proteus/data/atmos_clim.spectral_files.frostflow_4096.registry.txt b/src/proteus/data/atmos_clim.spectral_files.frostflow_4096.registry.txt new file mode 100644 index 000000000..2119bcdd1 --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.frostflow_4096.registry.txt @@ -0,0 +1,5 @@ +Frostflow.chk md5:26b2e36ecc00125834a11a4bb4559fa4 +Frostflow.chk_k md5:816030cfa90f8468c9c8d9261bfb6459 +Frostflow.sf md5:94a47aa70cf0f5f3d08a827734a33b42 +Frostflow.sf_k md5:32957ef9d3a45098927b9afcba0cb8e0 +Frostflow4096.log md5:cbbf5db778f4bf1f42811be4128e10fe diff --git a/src/proteus/data/atmos_clim.spectral_files.frostflow_48.registry.txt b/src/proteus/data/atmos_clim.spectral_files.frostflow_48.registry.txt new file mode 100644 index 000000000..7f546f282 --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.frostflow_48.registry.txt @@ -0,0 +1,5 @@ +Frostflow.chk md5:894f35cfd072c976a88b3b522b9bb9eb +Frostflow.chk_k md5:9e271ceecf61d62aa1f7c038d4a28294 +Frostflow.sf md5:a34e1ebc2832e61c1d2e0193d0c0ffd6 +Frostflow.sf_k md5:3b79eb68ffb01bd9c3b983b08e41cf06 +Frostflow48.log md5:14677e64064ee61aea6bf7680adb1215 diff --git a/src/proteus/data/atmos_clim.spectral_files.honeyside_16.registry.txt b/src/proteus/data/atmos_clim.spectral_files.honeyside_16.registry.txt new file mode 100644 index 000000000..6a223ae92 --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.honeyside_16.registry.txt @@ -0,0 +1,5 @@ +Honeyside.chk md5:9e22bc9bdb748b79250f8bb36240eda5 +Honeyside.chk_k md5:21c24e03a686b12da09db403d94cf764 +Honeyside.sf md5:fc86acc395120e30764c47f56bd4a1b5 +Honeyside.sf_k md5:afe06f25d7d969abf9c5a4a27dc884cf +hs16.log md5:ea28d7f4b84d5b24a01464b545f12259 diff --git a/src/proteus/data/atmos_clim.spectral_files.honeyside_256.registry.txt b/src/proteus/data/atmos_clim.spectral_files.honeyside_256.registry.txt new file mode 100644 index 000000000..6c48b8075 --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.honeyside_256.registry.txt @@ -0,0 +1,5 @@ +Honeyside.chk md5:db4c17a81d12da7ddcf241fbb7c9b6ba +Honeyside.chk_k md5:7e0ed758e52d95856030f872ae874627 +Honeyside.sf md5:10dc7627410d46f9b31260503e194118 +Honeyside.sf_k md5:93a595b7bcff4a7ec776d965076a5481 +hs256.log md5:05d48bf63ae6ff16948369730c4bff32 diff --git a/src/proteus/data/atmos_clim.spectral_files.honeyside_4096.registry.txt b/src/proteus/data/atmos_clim.spectral_files.honeyside_4096.registry.txt new file mode 100644 index 000000000..0c272dfe9 --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.honeyside_4096.registry.txt @@ -0,0 +1,5 @@ +Honeyside.chk md5:9c3a9233d25db56def286566fb611733 +Honeyside.chk_k md5:5911296f53c49e2b7918b469b08f93f3 +Honeyside.sf md5:d97ae170840736ab16cb61aa5eceffbc +Honeyside.sf_k md5:9c19cc6910dee7a577181f3ae8d2cff9 +hs4096.log md5:33836f37447a582b421c61cb30d52e90 diff --git a/src/proteus/data/atmos_clim.spectral_files.honeyside_48.registry.txt b/src/proteus/data/atmos_clim.spectral_files.honeyside_48.registry.txt new file mode 100644 index 000000000..0098b46ff --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.honeyside_48.registry.txt @@ -0,0 +1,5 @@ +Honeyside.chk md5:fa7e0e040077af444081fa5cd0e6ae97 +Honeyside.chk_k md5:85ed68bb9f3026c3d0cbf97c9593dcd9 +Honeyside.sf md5:6e35b67e141a1c70f88d847e4dc65254 +Honeyside.sf_k md5:8a53c96f87ab6ce19fe1e904ead69f3f +hs48.log md5:543018db4e719f6cea3e684d5b8a66de diff --git a/src/proteus/data/atmos_clim.spectral_files.oak_318.registry.txt b/src/proteus/data/atmos_clim.spectral_files.oak_318.registry.txt new file mode 100644 index 000000000..67dd15750 --- /dev/null +++ b/src/proteus/data/atmos_clim.spectral_files.oak_318.registry.txt @@ -0,0 +1,2 @@ +Oak.sf md5:2102d1bd115d79ff4d1f310bb6388009 +Oak.sf_k md5:d65c0fd610d6ee6c8955f63266e87f9b diff --git a/src/proteus/data/proteus_manifest.toml b/src/proteus/data/proteus_manifest.toml index bb875e3f3..e6343d967 100644 --- a/src/proteus/data/proteus_manifest.toml +++ b/src/proteus/data/proteus_manifest.toml @@ -29,3 +29,68 @@ required_by = ["proteus"] name = "Static equations of state for iron, silicate and water (Seager et al. 2007)" zenodo = "10.5281/zenodo.15727998" required_by = ["proteus"] + +[atmos_clim.spectral_files.frostflow_16] +name = "SOCRATES spectral file Frostflow, 16 bands" +zenodo = "10.5281/zenodo.15799743" +required_by = ["proteus"] + +[atmos_clim.spectral_files.frostflow_48] +name = "SOCRATES spectral file Frostflow, 48 bands" +zenodo = "10.5281/zenodo.15696415" +required_by = ["proteus"] + +[atmos_clim.spectral_files.frostflow_256] +name = "SOCRATES spectral file Frostflow, 256 bands" +zenodo = "10.5281/zenodo.15799754" +required_by = ["proteus"] + +[atmos_clim.spectral_files.frostflow_4096] +name = "SOCRATES spectral file Frostflow, 4096 bands" +zenodo = "10.5281/zenodo.15799776" +required_by = ["proteus"] + +[atmos_clim.spectral_files.dayspring_16] +name = "SOCRATES spectral file Dayspring, 16 bands" +zenodo = "10.5281/zenodo.15799318" +required_by = ["proteus"] + +[atmos_clim.spectral_files.dayspring_48] +name = "SOCRATES spectral file Dayspring, 48 bands" +zenodo = "10.5281/zenodo.15721749" +required_by = ["proteus"] + +[atmos_clim.spectral_files.dayspring_256] +name = "SOCRATES spectral file Dayspring, 256 bands" +zenodo = "10.5281/zenodo.15799474" +required_by = ["proteus"] + +[atmos_clim.spectral_files.dayspring_4096] +name = "SOCRATES spectral file Dayspring, 4096 bands" +zenodo = "10.5281/zenodo.15799495" +required_by = ["proteus"] + +[atmos_clim.spectral_files.honeyside_16] +name = "SOCRATES spectral file Honeyside, 16 bands" +zenodo = "10.5281/zenodo.15799607" +required_by = ["proteus"] + +[atmos_clim.spectral_files.honeyside_48] +name = "SOCRATES spectral file Honeyside, 48 bands" +zenodo = "10.5281/zenodo.15799652" +required_by = ["proteus"] + +[atmos_clim.spectral_files.honeyside_256] +name = "SOCRATES spectral file Honeyside, 256 bands" +zenodo = "10.5281/zenodo.15799731" +required_by = ["proteus"] + +[atmos_clim.spectral_files.honeyside_4096] +name = "SOCRATES spectral file Honeyside, 4096 bands" +zenodo = "10.5281/zenodo.15696457" +required_by = ["proteus"] + +[atmos_clim.spectral_files.oak_318] +name = "SOCRATES spectral file Oak, 318 bands" +zenodo = "10.5281/zenodo.15743843" +required_by = ["proteus"] diff --git a/src/proteus/doctor.py b/src/proteus/doctor.py index 899f8357d..fe9af007e 100644 --- a/src/proteus/doctor.py +++ b/src/proteus/doctor.py @@ -349,7 +349,7 @@ def check_fwl_data() -> list[CheckResult]: return results expected = { - 'spectral_files': 'proteus get spectral', + 'atmos_clim/spectral_files': 'proteus get spectral', 'stellar_spectra': 'proteus get stellar', } for subdir, fix in expected.items(): diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 9b4f3829b..ba7631ee4 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -445,20 +445,6 @@ def validate_zenodo_folder(zenodo_id: str, folder_dir: Path, hash_maxfilesize=10 # Unified mapping of folder names to both Zenodo and OSF identifiers # Structure: folder_name -> {'zenodo_id': str, 'osf_id': str, 'osf_project': str} DATA_SOURCE_MAP: dict[str, dict[str, str]] = { - # Spectral files (OSF project: vehxg) - 'Frostflow/16': {'zenodo_id': '15799743', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Frostflow/48': {'zenodo_id': '15696415', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Frostflow/256': {'zenodo_id': '15799754', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Frostflow/4096': {'zenodo_id': '15799776', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Dayspring/16': {'zenodo_id': '15799318', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Dayspring/48': {'zenodo_id': '15721749', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Dayspring/256': {'zenodo_id': '15799474', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Dayspring/4096': {'zenodo_id': '15799495', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Honeyside/16': {'zenodo_id': '15799607', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Honeyside/48': {'zenodo_id': '15799652', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Honeyside/256': {'zenodo_id': '15799731', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Honeyside/4096': {'zenodo_id': '15696457', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, - 'Oak/318': {'zenodo_id': '15743843', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, # Interior lookup tables (OSF project: phsxf) '1TPa-dK09-elec-free/MgSiO3_Wolf_Bower_2018': { 'zenodo_id': '15877374', @@ -528,7 +514,7 @@ def validate_zenodo_folder(zenodo_id: str, folder_dir: Path, hash_maxfilesize=10 # Spectral file folders served by `proteus get spectral`. One entry per # line, grouped by k-table set, ordered by band count. Every entry must -# have a matching DATA_SOURCE_MAP record. +# have a matching manifest table (see proteus.data.spectral_file_key). SPECTRAL_FILE_FOLDERS: tuple[str, ...] = ( 'Dayspring/16', 'Dayspring/48', @@ -1107,18 +1093,13 @@ def download_spectral_file(name: str, bands: str): if not isinstance(bands, str) or (len(bands) < 1): raise ValueError('Must provide number of bands in spectral file') + from proteus.data import fetch_dataset, spectral_file_key + folder = f'{name}/{bands}' - source_info = get_data_source_info(folder) - if not source_info: + if folder not in SPECTRAL_FILE_FOLDERS: raise ValueError(f'No data source mapping found for folder: {folder}') - download( - folder=folder, - target='spectral_files', - osf_id=source_info['osf_project'], - zenodo_id=source_info['zenodo_id'], - desc=f'{name}{bands} spectral file', - ) + fetch_dataset(spectral_file_key(name, bands)) def download_spectral_files(name: str | None = None, bands: str | None = None): diff --git a/tests/atmos_clim/test_common.py b/tests/atmos_clim/test_common.py index abbf27ad4..04715c3d4 100644 --- a/tests/atmos_clim/test_common.py +++ b/tests/atmos_clim/test_common.py @@ -602,7 +602,7 @@ def test_get_radius_from_pressure(): @pytest.mark.unit -def test_spfile_helpers(): +def test_spfile_helpers(tmp_path): """ Test spectral file configuration helpers. @@ -621,9 +621,16 @@ def test_spfile_helpers(): assert bands == '16' # Test get_spfile_path construction - # Expected: /spectral_files///.sf - path = get_spfile_path('/fwl/data', mock_conf) - assert path == '/fwl/data/spectral_files/Dayspring/16/Dayspring.sf' + # Expected: /atmos_clim/spectral_files/_/r/.sf + path = get_spfile_path(str(tmp_path), mock_conf) + assert path == str( + tmp_path + / 'atmos_clim' + / 'spectral_files' + / 'dayspring_16' + / 'r15799318' + / 'Dayspring.sf' + ) # --------------------------------------------------------------------------- diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index c32e4c609..7e8a16391 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -33,6 +33,7 @@ dataset_dir, fetch_dataset, manifest_path, + spectral_file_key, ) pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] @@ -46,6 +47,30 @@ REPO_ROOT = Path(__file__).resolve().parents[2] +# Spectral-file datasets: (group, bands) -> Zenodo record, one dataset each. +SPECTRAL_RECORDS = { + ('Frostflow', '16'): '15799743', + ('Frostflow', '48'): '15696415', + ('Frostflow', '256'): '15799754', + ('Frostflow', '4096'): '15799776', + ('Dayspring', '16'): '15799318', + ('Dayspring', '48'): '15721749', + ('Dayspring', '256'): '15799474', + ('Dayspring', '4096'): '15799495', + ('Honeyside', '16'): '15799607', + ('Honeyside', '48'): '15799652', + ('Honeyside', '256'): '15799731', + ('Honeyside', '4096'): '15696457', + ('Oak', '318'): '15743843', +} + +_OWNED_KEYS = { + EXOPLANET_REFERENCE, + MASS_RADIUS_ZENG_2019, + SURFACE_ALBEDOS_HAMMOND_2024, + EOS_SEAGER_2007, +} | {spectral_file_key(group, bands) for group, bands in SPECTRAL_RECORDS} + def _pyproject() -> dict: """Return the parsed pyproject.toml of the repository under test.""" @@ -64,12 +89,7 @@ def test_manifest_declares_the_datasets(): datasets = {ds.key: ds for ds in load_manifest(manifest_path())} - assert set(datasets) == { - EXOPLANET_REFERENCE, - MASS_RADIUS_ZENG_2019, - SURFACE_ALBEDOS_HAMMOND_2024, - EOS_SEAGER_2007, - } + assert set(datasets) == _OWNED_KEYS assert datasets[EXOPLANET_REFERENCE].subdir == 'observe/exoplanet_reference' assert datasets[MASS_RADIUS_ZENG_2019].subdir == 'observe/mass_radius/zeng_2019' assert ( @@ -131,12 +151,7 @@ def test_manifest_is_discovered_via_entry_point(): providers = discover_manifests() assert 'proteus' in providers, f'proteus not among providers: {sorted(providers)}' - assert {ds.key for ds in providers['proteus']} == { - EXOPLANET_REFERENCE, - MASS_RADIUS_ZENG_2019, - SURFACE_ALBEDOS_HAMMOND_2024, - EOS_SEAGER_2007, - } + assert {ds.key for ds in providers['proteus']} == _OWNED_KEYS def test_dataset_dir_is_versioned(tmp_path): @@ -321,13 +336,63 @@ def test_manifest_and_registries_are_declared_as_package_data(): shipped = {path.name for path in manifest_path().parent.iterdir() if path.is_file()} assert 'proteus_manifest.toml' in shipped assert {name for name in shipped if name.endswith('.registry.txt')} == { - f'{EXOPLANET_REFERENCE}.registry.txt', - f'{MASS_RADIUS_ZENG_2019}.registry.txt', - f'{SURFACE_ALBEDOS_HAMMOND_2024}.registry.txt', - f'{EOS_SEAGER_2007}.registry.txt', + f'{key}.registry.txt' for key in _OWNED_KEYS } +def test_spectral_file_datasets_pin_their_records_and_locations(tmp_path): + """Each (group, bands) spectral file set is its own dataset at a derived path. + + The record ids are repeated literally so a silent re-pin fails here, and the + resolved directory is pinned because AGNI and JANUS read the file from it. + """ + for (group, bands), record in SPECTRAL_RECORDS.items(): + key = spectral_file_key(group, bands) + dataset = _dataset(key) + assert dataset.zenodo == f'10.5281/zenodo.{record}' + assert dataset.subdir == f'atmos_clim/spectral_files/{group.lower()}_{bands}' + assert dataset_dir(key, data_root=tmp_path) == ( + tmp_path / 'atmos_clim' / 'spectral_files' / f'{group.lower()}_{bands}' / f'r{record}' + ) + assert _dataset(key).registry(), f'empty registry for {key}' + + +def test_every_spectral_folder_has_a_dataset_and_no_legacy_entry(): + """The download list and the manifest agree, and the OSF-era map holds none. + + A folder without a manifest table would fail only when a user runs the + download; a leftover map entry would give the record a second pin. + """ + from proteus.utils.data import DATA_SOURCE_MAP, SPECTRAL_FILE_FOLDERS + + assert {tuple(folder.split('/')) for folder in SPECTRAL_FILE_FOLDERS} == set(SPECTRAL_RECORDS) + for folder in SPECTRAL_FILE_FOLDERS: + group, bands = folder.split('/') + assert spectral_file_key(group, bands) in _OWNED_KEYS + assert folder not in DATA_SOURCE_MAP, f'{folder} is still pinned in DATA_SOURCE_MAP' + + +def test_get_spfile_path_resolves_into_the_versioned_dataset_dir(tmp_path): + """The path PROTEUS hands to AGNI and JANUS is inside the dataset's version dir.""" + from types import SimpleNamespace + + from proteus.atmos_clim.common import get_spfile_path + + config = SimpleNamespace( + atmos_clim=SimpleNamespace(module='agni', spectral_group='Oak', spectral_bands='318') + ) + path = Path(get_spfile_path(str(tmp_path), config)) + + assert path == dataset_dir(spectral_file_key('Oak', '318'), data_root=tmp_path) / 'Oak.sf' + assert path.parent.name == 'r15743843' + + +def test_unknown_spectral_pair_is_rejected(): + """An unlisted (group, bands) pair fails loudly instead of resolving to a path.""" + with pytest.raises(KeyError): + dataset_dir(spectral_file_key('Oak', '16')) + + def test_migrated_datasets_are_not_also_pinned_in_the_legacy_map(): """A migrated dataset is pinned in one place only. diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 4249f7056..9a5d137c6 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -240,14 +240,14 @@ class TestCheckFwlData: def test_reports_present_subdirs(self, tmp_path): """Populated subdirectories report as present.""" - (tmp_path / 'spectral_files').mkdir() - (tmp_path / 'spectral_files' / 'data.bin').touch() + (tmp_path / 'atmos_clim' / 'spectral_files').mkdir(parents=True) + (tmp_path / 'atmos_clim' / 'spectral_files' / 'data.bin').touch() (tmp_path / 'stellar_spectra').mkdir() (tmp_path / 'stellar_spectra' / 'sun.txt').touch() with patch.dict(os.environ, {'FWL_DATA': str(tmp_path)}): results = check_fwl_data() statuses = {r.name: r.status for r in results} - assert statuses['FWL_DATA/spectral_files'] == PASS + assert statuses['FWL_DATA/atmos_clim/spectral_files'] == PASS assert statuses['FWL_DATA/stellar_spectra'] == PASS def test_reports_missing_subdirs(self, tmp_path): @@ -1273,7 +1273,7 @@ def test_unfixable_warning_only_is_healthy(self, capsys): """A warning with no fix is surfaced but does not mark the install unhealthy: warnings are not failures.""" results = [ - CheckResult('FWL_DATA/spectral_files', 'data', WARN, 'empty', None), + CheckResult('FWL_DATA/atmos_clim/spectral_files', 'data', WARN, 'empty', None), ] with patch('proteus.doctor.run_all_checks', return_value=results): result = update_entry(dry_run=False) diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index ead771653..c342d4b5a 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -46,8 +46,8 @@ def test_get_zenodo_record(): Ensures that known configuration keys map to correct Zenodo repository IDs. """ - # Known mapping: Frostflow 16 band table - assert get_zenodo_record('Frostflow/16') == '15799743' + # Known mapping: interior lookup table + assert get_zenodo_record('1TPa-dK09-elec-free/MgSiO3_Wolf_Bower_2018') == '15877374' # Unknown key should return None safely assert get_zenodo_record('Unknown/Folder') is None @@ -568,63 +568,44 @@ def test_download_spectral_files_dispatch(mock_single): @pytest.mark.unit -def test_spectral_folder_registry_matches_source_map(): - """Every spectral folder is downloadable and no spectral source is hidden. +def test_spectral_folder_registry_matches_manifest(): + """Every spectral folder is a manifest dataset and none is left in the source map. - Forward direction: each SPECTRAL_FILE_FOLDERS entry must have a - DATA_SOURCE_MAP record (the registry comment promises it; a missing - record only fails at download time otherwise). Reverse direction: - every DATA_SOURCE_MAP key shaped like a spectral folder - (Group/ in the spectral OSF project) must be listed in - SPECTRAL_FILE_FOLDERS, so the bare `proteus get spectral` cannot - silently skip a newly added k-table set. + Forward direction: each SPECTRAL_FILE_FOLDERS entry must resolve to a + manifest table, so the bare `proteus get spectral` cannot fail on an entry + with no source. Reverse direction: no DATA_SOURCE_MAP key shaped like a + spectral folder (Group/) may remain, since it would pin the same + record a second time. """ import re + from proteus.data import _dataset, spectral_file_key from proteus.utils.data import DATA_SOURCE_MAP, SPECTRAL_FILE_FOLDERS - missing = [f for f in SPECTRAL_FILE_FOLDERS if f not in DATA_SOURCE_MAP] - assert missing == [], f'SPECTRAL_FILE_FOLDERS entries without source records: {missing}' + for folder in SPECTRAL_FILE_FOLDERS: + group, bands = folder.split('/') + assert _dataset(spectral_file_key(group, bands)).zenodo.startswith('10.5281/zenodo.') - spectral_like = [ - k - for k, v in DATA_SOURCE_MAP.items() - if re.fullmatch(r'[A-Za-z]+/[0-9]+', k) and v.get('osf_project') == 'vehxg' - ] - unlisted = sorted(set(spectral_like) - set(SPECTRAL_FILE_FOLDERS)) - assert unlisted == [], ( - f'Spectral source-map entries not in SPECTRAL_FILE_FOLDERS: {unlisted}' - ) - # Sanity: the heuristic actually matched the registry (not vacuous). - assert len(spectral_like) == len(SPECTRAL_FILE_FOLDERS) + leftover = [k for k in DATA_SOURCE_MAP if re.fullmatch(r'[A-Za-z]+/[0-9]+', k)] + assert leftover == [], f'Spectral-folder entries left in DATA_SOURCE_MAP: {leftover}' @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.get_data_source_info') -def test_download_spectral_file_call(mock_get_info, mock_download): +@patch('proteus.data.fetch_dataset') +def test_download_spectral_file_call(mock_fetch): """ Test spectral file download wrapper. - Ensures that the convenience function correctly interprets the - name/bands arguments to construct the target folder path and - resolves the correct Zenodo and OSF IDs from mapping. + Ensures that the convenience function fetches the manifest dataset for the + requested group and band count, and rejects an unlisted pair. """ - # Mock mapping lookup - mock_get_info.return_value = { - 'zenodo_id': '99999', - 'osf_project': 'test_osf', - 'osf_id': 'test_osf', - } + download_spectral_file('Oak', '318') - download_spectral_file('TestName', '123') + mock_fetch.assert_called_once_with('atmos_clim.spectral_files.oak_318') - mock_download.assert_called_once() - args, kwargs = mock_download.call_args - assert kwargs['folder'] == 'TestName/123' # Folder structure - assert kwargs['zenodo_id'] == '99999' # Resolved ID from mapping - assert kwargs['osf_id'] == 'test_osf' # OSF ID from mapping - assert kwargs['target'] == 'spectral_files' # Target subdir + with pytest.raises(ValueError, match='No data source mapping found for folder: Oak/16'): + download_spectral_file('Oak', '16') + mock_fetch.assert_called_once() @pytest.mark.unit diff --git a/tools/postprocess.jl b/tools/postprocess.jl index 1226e86e7..be886d44e 100755 --- a/tools/postprocess.jl +++ b/tools/postprocess.jl @@ -264,7 +264,10 @@ function postproc(output_dir::String, nsamples::Int, spfile::String) # use high resolution file if isempty(spfile) - spectral_file = joinpath(ENV["FWL_DATA"], "spectral_files", "Honeyside", "4096", "Honeyside.sf") + sf_matches = sort(glob("r*/Honeyside.sf", + joinpath(ENV["FWL_DATA"], "atmos_clim", "spectral_files", "honeyside_4096"))) + isempty(sf_matches) && error("Honeyside 4096 spectral file not found; run `proteus get spectral`") + spectral_file = last(sf_matches) star_file = joinpath(output_dir, "data", "$(star_years[1]).sflux") @info "Spectral file not provided. Will use $spectral_file" else From 60ef006a24aa8dc9b51f3270f9a98bdb796d8e8b Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Tue, 22 Sep 2026 00:43:36 +0200 Subject: [PATCH 04/76] Fetch the solar, Named and MUSCLES stellar spectra through fwl-io The three records (solar 17981836, Named 15721440, MUSCLES 17802209) are declared in the manifest as `stellar_spectra.solar`, `stellar_spectra.named` and `stellar_spectra.muscles`, each with a committed registry of file checksums. Files land in `$FWL_DATA/stellar_spectra//r/`. `init_star` and `proteus solar` resolve the directory through `dataset_dir`, and `download_stellar_spectra` fetches the three datasets through `fetch_dataset`. PHOENIX stays on the legacy downloader. `download_muscles` keeps its per-star mode through the new `fetch_dataset_file`, which fetches one file of a declared dataset and verifies it against the registry. `force=True` deletes the existing files before the fetch. The three entries are removed from `DATA_SOURCE_MAP`, so the OSF fallback for these files is gone and the data must be fetched again into the new location. The lowercase `named` and `muscles` directories differ from the former `Named` and `MUSCLES` only by case, which a case-insensitive filesystem treats as one directory. Tests pin each record, location and registry size, and cover the whole-record, single-star, multi-star and failed-fetch paths of `download_muscles`. The reference page describes the new layout and corrects the provisioning table for the datasets that already use fwl-io. The integration fixture prefetches data for runs that select MORS, so the star spectra exist before the offline run starts. The packaging test derives the expected registry set from the manifest instead of a fixed count. --- docs/Reference/data.md | 14 +- src/proteus/cli.py | 3 +- src/proteus/data/__init__.py | 34 ++++ src/proteus/data/proteus_manifest.toml | 15 ++ .../data/stellar_spectra.muscles.registry.txt | 38 ++++ .../data/stellar_spectra.named.registry.txt | 11 ++ .../data/stellar_spectra.solar.registry.txt | 10 + src/proteus/star/wrapper.py | 16 +- src/proteus/utils/data.py | 132 ++++++------- tests/data/test_manifest.py | 53 ++++- tests/integration/conftest.py | 7 +- tests/integration/test_packaging.py | 11 +- tests/star/test_stellar_spectra.py | 24 ++- tests/test_cli.py | 23 ++- tests/utils/test_data.py | 182 +++++++----------- 15 files changed, 365 insertions(+), 208 deletions(-) create mode 100644 src/proteus/data/stellar_spectra.muscles.registry.txt create mode 100644 src/proteus/data/stellar_spectra.named.registry.txt create mode 100644 src/proteus/data/stellar_spectra.solar.registry.txt diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 5de683485..d12856204 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -28,14 +28,15 @@ used with lower rate limits. | Dataset | Provisioned by | Downloaded by | |---|---|---| -| Stellar spectra (solar, MUSCLES) | PROTEUS downloader | `proteus get solar`, `proteus get muscles` | +| Stellar spectra (solar, named stars, MUSCLES) | fwl-io | `proteus get solar`, `proteus get muscles` | | PHOENIX synthetic spectra | PROTEUS downloader | `proteus get phoenix` | | Stellar evolution tracks | PROTEUS downloader | `proteus get stellar` | -| Spectral k-tables | PROTEUS downloader | `proteus get spectral` | -| Surface albedos | PROTEUS downloader | `proteus get surfaces` | +| Spectral k-tables | fwl-io | `proteus get spectral` | +| Surface albedos | fwl-io | `proteus get surfaces` | | Scattering properties | PROTEUS downloader | `proteus get scattering` | | Exoplanet populations, mass-radius curves | fwl-io | `proteus get reference` | -| Interior EOS tables, melting curves | PROTEUS downloader | `proteus get interiordata` | +| Seager (2007) EOS tables | fwl-io | fetched when a run needs them | +| Other interior EOS tables, melting curves | PROTEUS downloader | `proteus get interiordata` | To configure a Zenodo API token, see the [Troubleshooting guide](../How-to/troubleshooting.md#data-download-errors-or-slow-zenodo-downloads). @@ -51,7 +52,8 @@ for full usage instructions. ### Solar spectra -Observed solar spectra are stored under `$FWL_DATA/stellar_spectra/solar/`. +Observed solar spectra are stored under `$FWL_DATA/stellar_spectra/solar/r/`, +where `` is the Zenodo record that the manifest pins. The modern spectrum is from [Gueymard (2003)](https://www.sciencedirect.com/science/article/pii/S0038092X03003967) via [NREL](https://www.nrel.gov/grid/solar-resource/spectra.html). Historical and future spectra are from @@ -76,7 +78,7 @@ Historical and future spectra are from Observed UV–optical–IR spectra from the [MUSCLES](https://archive.stsci.edu/prepds/muscles/) and [Mega-MUSCLES](https://archive.stsci.edu/prepds/mega-muscles/) surveys, -stored under `$FWL_DATA/stellar_spectra/MUSCLES/` as `.txt`. +stored under `$FWL_DATA/stellar_spectra/muscles/r/` as `.txt`. `star_name` matching is case-insensitive. ??? info "Full star catalog" diff --git a/src/proteus/cli.py b/src/proteus/cli.py index 71d3dbf80..ba0f9c5f9 100644 --- a/src/proteus/cli.py +++ b/src/proteus/cli.py @@ -470,10 +470,11 @@ def phoenix(FeH: float, alpha: float, teff: float | None): @click.command() def solar(): """Download the available solar spectra.""" + from .data import STELLAR_SPECTRA_SOLAR, dataset_dir from .utils.data import GetFWLData, download_stellar_spectra # Where the data should end up - solar_dir = GetFWLData() / 'stellar_spectra' / 'solar' + solar_dir = dataset_dir(STELLAR_SPECTRA_SOLAR, data_root=GetFWLData()) try: download_stellar_spectra(folders=('solar',)) diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index 2774cfe96..f7c178520 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -25,6 +25,9 @@ MASS_RADIUS_ZENG_2019 = 'observe.mass_radius.zeng_2019' SURFACE_ALBEDOS_HAMMOND_2024 = 'atmos_clim.surface_albedos.hammond_2024' EOS_SEAGER_2007 = 'interior_struct.eos.seager_2007' +STELLAR_SPECTRA_SOLAR = 'stellar_spectra.solar' +STELLAR_SPECTRA_NAMED = 'stellar_spectra.named' +STELLAR_SPECTRA_MUSCLES = 'stellar_spectra.muscles' def spectral_file_key(group: str, bands: str | int) -> str: @@ -227,3 +230,34 @@ def fetch_dataset(key: str, data_root: str | Path | None = None) -> list[Path]: The verified files of the dataset. """ return _fetcher(key, data_root=data_root).fetch_all() + + +def fetch_dataset_file(key: str, name: str, data_root: str | Path | None = None) -> Path: + """Fetch one file of a declared dataset, verifying it against the registry. + + Datasets that hold many independent files (one observed spectrum per star) + can be fetched file by file so a reader downloads only what it needs. The + fetch is idempotent: a file already present with a matching checksum is left + alone. + + Parameters + ---------- + key : str + Dotted manifest key of the dataset. + name : str + File name as listed in the dataset registry. + data_root : str or Path, optional + Reference-data tree to fetch into. Defaults to the tree PROTEUS resolves + from the environment. + + Returns + ------- + Path + The verified file. + + Raises + ------ + KeyError + ``name`` is not in the dataset registry. + """ + return _fetcher(key, data_root=data_root).fetch(name) diff --git a/src/proteus/data/proteus_manifest.toml b/src/proteus/data/proteus_manifest.toml index e6343d967..6ddb564a3 100644 --- a/src/proteus/data/proteus_manifest.toml +++ b/src/proteus/data/proteus_manifest.toml @@ -94,3 +94,18 @@ required_by = ["proteus"] name = "SOCRATES spectral file Oak, 318 bands" zenodo = "10.5281/zenodo.15743843" required_by = ["proteus"] + +[stellar_spectra.solar] +name = "Solar spectra at present and past epochs" +zenodo = "10.5281/zenodo.17981836" +required_by = ["proteus"] + +[stellar_spectra.named] +name = "Named stellar spectra" +zenodo = "10.5281/zenodo.15721440" +required_by = ["proteus"] + +[stellar_spectra.muscles] +name = "MUSCLES observed stellar spectra" +zenodo = "10.5281/zenodo.17802209" +required_by = ["proteus"] diff --git a/src/proteus/data/stellar_spectra.muscles.registry.txt b/src/proteus/data/stellar_spectra.muscles.registry.txt new file mode 100644 index 000000000..519ad00f1 --- /dev/null +++ b/src/proteus/data/stellar_spectra.muscles.registry.txt @@ -0,0 +1,38 @@ +_readme.md md5:892561e7367dc34b6e28c0b8cb190ffc +gj1132.txt md5:54c70fecc46efe2b1821ca2a37a724a4 +gj1214.txt md5:b5808ed0c6f811c3ec5dbe5774f0f22d +gj15a.txt md5:92523e738dbc424110f3a451d3553823 +gj163.txt md5:846ca42c8a3b37b0dab1177b6961fd19 +gj176.txt md5:db66f8bc053888cd402f5aaf15f17c72 +gj436.txt md5:cffa59a9f554debb50cd9c2dbd1fc510 +gj551.txt md5:e44d52db1b511e787a6f9c5cd458f272 +gj581.txt md5:db8989c0e2eb27919b6391d859ed7914 +gj649.txt md5:04100febfcca9641fa9c915b27f359fc +gj667c.txt md5:8187800ae9d7259d4861eea6d1037097 +gj674.txt md5:d122d27f4905c548378fe19d0637425e +gj676a.txt md5:3e8d4385705803bfc67c77fbcf513306 +gj699.txt md5:cbab13715ba41cb1b6fe88c0976f3db3 +gj729.txt md5:55bb866fa62f8fd3284facfd3b25a771 +gj832.txt md5:f84c0695e9f4a9019a2c82ed364defa9 +gj832_synth.txt md5:2371c50479ff939d6cd548cabbac2a55 +gj849.txt md5:a7fbe9d3e2c1f291b5431d65e445aa5c +gj876.txt md5:4b4e7299bad9545ee8fef82cb4c8d4d8 +hat-p-12.txt md5:a6c8209457db31b9255bc87808e47551 +hat-p-26.txt md5:4f0aa37cde071fcf15fee95da1d7ff34 +hd-149026.txt md5:25801615325ca11dde0737c93cacc37d +hd40307.txt md5:7f67b98ad9500f65764f2423dd56c203 +hd85512.txt md5:daa07e82c91d6cc54aedbc18b1b28f67 +hd97658.txt md5:21346f9ee3ada0ae24526ede0ac26465 +l-678-39.txt md5:4bea9792c1543aec0c48814c8a772caf +l-98-59.txt md5:9709fe575a10db7ea8c62f546b3a3dc0 +l-980-5.txt md5:87f20087e6a965fca0da631bcc96b8c5 +lhs-2686.txt md5:bc72478d2028ef9d4f65b8af1caec319 +lp-791-18.txt md5:dda981826c090ef1af413dfd856f4239 +muscles_scaling_distances.csv md5:d5008bef930b29f1ee3dcdb0a120e587 +toi-193.txt md5:ca354292302f20f395b9a6811b14b74d +trappist-1.txt md5:ed00bec981d4332922305c449b3a6339 +v-eps-eri.txt md5:dc5da668f6cc455c0f550b3f190c0af9 +wasp-127.txt md5:e6b0af652f6ba4b46abd8cfdd83274e0 +wasp-17.txt md5:1ae0c7758aaefa401e01e258fb2cc5ff +wasp-43.txt md5:cd3e1263df93e62635828f044ed849ac +wasp-77a.txt md5:9b498dbb5830c83c5a4a90fcee2e73e2 diff --git a/src/proteus/data/stellar_spectra.named.registry.txt b/src/proteus/data/stellar_spectra.named.registry.txt new file mode 100644 index 000000000..1848d87e6 --- /dev/null +++ b/src/proteus/data/stellar_spectra.named.registry.txt @@ -0,0 +1,11 @@ +HIP67522.txt md5:173af82407eb3ba1c831ef616870153a +eps-eri.txt md5:1eaf42a702bc7dbafde4408044e093c5 +gj1132.txt md5:4c01b4dc5b5c3f5e08955afdeda10b29 +gj1214.txt md5:1ef28a77d46cb582cd451eb732c7a685 +gj176.txt md5:dd652004b6ad0ed7df992d2548703da6 +gj849.txt md5:a4f860f754c31056d646a72c2f6f9ea6 +hd85512.txt md5:ba819e0283d490b28561b6934d465ba4 +hd97658.txt md5:0c259e8e6bc66c1bc876ccf522830add +l-98-59.txt md5:fc4059008fc09620c60de6c2c65ac961 +sun.txt md5:0c5225b847ca250673edc9690f02e055 +trappist-1.txt md5:2ab391fa858af21bf5edee37aafc8879 diff --git a/src/proteus/data/stellar_spectra.solar.registry.txt b/src/proteus/data/stellar_spectra.solar.registry.txt new file mode 100644 index 000000000..a5776249e --- /dev/null +++ b/src/proteus/data/stellar_spectra.solar.registry.txt @@ -0,0 +1,10 @@ +Sun0.6Ga.txt md5:4d436397be6385b00f6b5e223f133123 +Sun1.8Ga.txt md5:f694106a6b6e0f2ac4f7a20ddf307392 +Sun2.4Ga.txt md5:817f7b2649ae73ef5cf8f213c9234cf5 +Sun2.7Ga.txt md5:0ec923765beeadb410735b2318488f62 +Sun3.8Ga.txt md5:475be7cab37e4c9e314dcb9deed98e06 +Sun4.4Ga.txt md5:410507a37b2375ece9e2e2235cabb093 +Sun5.6Gyr.txt md5:c680e847629e6e140c85c5bbc8a7cae2 +SunModern.txt md5:e2209fd85970361b8ee007c88f21b058 +_readme.md md5:96d33bdba86c949afd8df080aed156be +sun.txt md5:6e4b6540d952cf3c01a3bd0511aa6c10 diff --git a/src/proteus/star/wrapper.py b/src/proteus/star/wrapper.py index be779e26e..9f239561f 100644 --- a/src/proteus/star/wrapper.py +++ b/src/proteus/star/wrapper.py @@ -82,16 +82,22 @@ def init_star(handler: Proteus): 'sunmodern': 'SunModern.txt', } + from proteus.data import ( + STELLAR_SPECTRA_MUSCLES, + STELLAR_SPECTRA_SOLAR, + dataset_dir, + ) + # Paths to MUSCLES spectra - muscles_path = os.path.join(fwl_dir, 'stellar_spectra/MUSCLES', star_file) + muscles_dir = dataset_dir(STELLAR_SPECTRA_MUSCLES, data_root=fwl_dir) + solar_dir = dataset_dir(STELLAR_SPECTRA_SOLAR, data_root=fwl_dir) + muscles_path = os.path.join(muscles_dir, star_file) # Pick the intended solar_path: if solar_key in solar_map: - solar_path = os.path.join( - fwl_dir, 'stellar_spectra/solar', solar_map[solar_key] - ) + solar_path = os.path.join(solar_dir, solar_map[solar_key]) else: - solar_path = os.path.join(fwl_dir, 'stellar_spectra/solar', star_file) + solar_path = os.path.join(solar_dir, star_file) src = mors_cfg.spectrum_source diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index ba7631ee4..e9133ca8c 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -480,16 +480,11 @@ def validate_zenodo_folder(zenodo_id: str, folder_dir: Path, hash_maxfilesize=10 'osf_id': 'phsxf', 'osf_project': 'phsxf', }, - # Stellar spectra (OSF project: 8r2sw) - 'Named': {'zenodo_id': '15721440', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, # Stellar spectra - PHOENIX (OSF project: 8r2sw) 'PHOENIX': {'zenodo_id': '17674612', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, - # Stellar spectra - MUSCLES (OSF project: 8r2sw) - 'MUSCLES': {'zenodo_id': '17802209', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, - # Stellar spectra - solar (OSF project: 8r2sw) - 'solar': {'zenodo_id': '17981836', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, - # The surface albedos, the Seager EOS tables, the exoplanet catalogue and the - # mass-radius relations are declared in src/proteus/data/proteus_manifest.toml and fetched through + # The surface albedos, the Seager EOS tables, the exoplanet catalogue, the + # mass-radius relations and the solar, Named and MUSCLES stellar spectra are + # declared in src/proteus/data/proteus_manifest.toml and fetched through # fwl-io, so their record pins live there and are absent here. # Zalmoxis EOS: Wolf & Bower 2018 T-dependent MgSiO3 (1 TPa) 'EOS_WolfBower2018_1TPa': {'zenodo_id': '17417017'}, @@ -1218,63 +1213,53 @@ def download_phoenix(*, alpha: float = 0.0, FeH: float = 0.0, force: bool = Fals def download_muscles(stars: str | list[str] | None = None, *, force: bool = False) -> bool: """ - Specifically download MUSCLES stellar spectrum(s). Uses the unified `download()` mechanism. - Currently not called in the main codebase, but available for users who want to download specific MUSCLES spectra manually through the CLI. + Download MUSCLES stellar spectrum(s) through fwl-io. + + Available to users who want to download specific MUSCLES spectra manually through the CLI. Parameters ---------- stars: - - None: download the whole MUSCLES catalogue (previous behaviour) + - None: download the whole MUSCLES catalogue - str: download one star (e.g. "trappist-1") - list[str]: download multiple stars force: - Force re-download even if present. + Remove the requested files first so they are fetched again. Returns ------- bool True if requested downloads succeeded (all of them, when list provided). """ - folder = 'MUSCLES' - source_info = get_data_source_info(folder) - if not source_info: - raise ValueError(f'No data source mapping found for folder: {folder}') - - # Old behavior: download everything - if stars is None: - return download( - folder=folder, - target='stellar_spectra', - osf_id=source_info['osf_project'], - zenodo_id=source_info['zenodo_id'], - desc='MUSCLES stellar spectra catalogue', - force=force, - ) - - # Normalize to list - if isinstance(stars, str): - stars_list = [stars] - else: - stars_list = list(stars) - - def muscles_filename(star: str) -> str: - return f'{star}.txt' + from proteus.data import ( + STELLAR_SPECTRA_MUSCLES, + dataset_dir, + fetch_dataset, + fetch_dataset_file, + ) - ok_all = True - for star in stars_list: - f = muscles_filename(star) - ok = download( - folder=folder, - target='stellar_spectra', - osf_id=source_info['osf_project'], - zenodo_id=source_info['zenodo_id'], - desc=f'MUSCLES stellar spectrum ({star})', - force=force, - file=f, # uses single-file mode - ) - ok_all = ok_all and ok + try: + if stars is None: + if force: + safe_rm(dataset_dir(STELLAR_SPECTRA_MUSCLES)) + fetch_dataset(STELLAR_SPECTRA_MUSCLES) + return True - return ok_all + stars_list = [stars] if isinstance(stars, str) else list(stars) + ok_all = True + for star in stars_list: + fname = f'{star}.txt' + if force: + safe_rm(dataset_dir(STELLAR_SPECTRA_MUSCLES) / fname) + try: + fetch_dataset_file(STELLAR_SPECTRA_MUSCLES, fname) + except KeyError: + log.error('No MUSCLES spectrum named %s in the dataset registry', star) + ok_all = False + return ok_all + except Exception as exc: # noqa: BLE001 -- the caller reports a failed download + log.error('Failed to download MUSCLES stellar spectra: %s', exc) + return False def download_interior_lookuptables(clean=False): @@ -1375,33 +1360,50 @@ def download_melting_curves(config: Config, clean: bool = False): def download_stellar_spectra(*, folders: tuple[str, ...] | None = None): """ - Download stellar spectra folders into ``FWL_DATA/stellar_spectra``. + Download stellar spectra collections into ``FWL_DATA/stellar_spectra``. Notes ----- - PROTEUS expects different stellar spectra collections in subfolders like: - - ``stellar_spectra/solar/`` - - ``stellar_spectra/MUSCLES/`` - - ``stellar_spectra/Named/`` + Each collection is a manifest dataset and lands in its own version + directory: ``stellar_spectra/solar/``, ``stellar_spectra/named/`` and + ``stellar_spectra/muscles/``, each below an ``r`` directory. Parameters ---------- folders: - Specific folders to download. If None, downloads a minimal set that covers - common configurations. - """ + Specific collections to download (``'Named'``, ``'solar'``, + ``'MUSCLES'``, or a collection that is still in ``DATA_SOURCE_MAP``). + If None, downloads a minimal set that covers common configurations. + + Raises + ------ + ValueError + A requested collection is not known. + """ + from proteus.data import ( + STELLAR_SPECTRA_MUSCLES, + STELLAR_SPECTRA_NAMED, + STELLAR_SPECTRA_SOLAR, + fetch_dataset, + ) + + keys = { + 'Named': STELLAR_SPECTRA_NAMED, + 'solar': STELLAR_SPECTRA_SOLAR, + 'MUSCLES': STELLAR_SPECTRA_MUSCLES, + } if folders is None: - # Minimal set for most configurations: - # - Named: general named spectra - # - solar: solar spectra (e.g., sun.txt, Sun0.6Ga.txt, ...) - # - MUSCLES: observed stellar spectra catalogue - folders = ('Named', 'solar', 'MUSCLES') + folders = tuple(keys) for folder in folders: - source_info = get_data_source_info(folder) - if not source_info: + if folder not in keys and not get_data_source_info(folder): raise ValueError(f'No data source mapping found for folder: {folder}') + for folder in folders: + if folder in keys: + fetch_dataset(keys[folder]) + continue + source_info = get_data_source_info(folder) download( folder=folder, target='stellar_spectra', diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index 7e8a16391..18df9e1da 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -27,6 +27,9 @@ EXOPLANET_REFERENCE, FWL_IO_FLOOR, MASS_RADIUS_ZENG_2019, + STELLAR_SPECTRA_MUSCLES, + STELLAR_SPECTRA_NAMED, + STELLAR_SPECTRA_SOLAR, SURFACE_ALBEDOS_HAMMOND_2024, _dataset, _fwl_io_derives_the_location, @@ -44,6 +47,9 @@ ZENG_2019_RECORD = '15727899' HAMMOND_2024_RECORD = '15880455' SEAGER_2007_RECORD = '15727998' +SOLAR_RECORD = '17981836' +NAMED_RECORD = '15721440' +MUSCLES_RECORD = '17802209' REPO_ROOT = Path(__file__).resolve().parents[2] @@ -69,6 +75,9 @@ MASS_RADIUS_ZENG_2019, SURFACE_ALBEDOS_HAMMOND_2024, EOS_SEAGER_2007, + STELLAR_SPECTRA_SOLAR, + STELLAR_SPECTRA_NAMED, + STELLAR_SPECTRA_MUSCLES, } | {spectral_file_key(group, bands) for group, bands in SPECTRAL_RECORDS} @@ -98,6 +107,12 @@ def test_manifest_declares_the_datasets(): ) assert datasets[EOS_SEAGER_2007].subdir == 'interior_struct/eos/seager_2007' assert datasets[EOS_SEAGER_2007].zenodo == f'10.5281/zenodo.{SEAGER_2007_RECORD}' + assert datasets[STELLAR_SPECTRA_SOLAR].subdir == 'stellar_spectra/solar' + assert datasets[STELLAR_SPECTRA_NAMED].subdir == 'stellar_spectra/named' + assert datasets[STELLAR_SPECTRA_MUSCLES].subdir == 'stellar_spectra/muscles' + assert datasets[STELLAR_SPECTRA_SOLAR].zenodo == f'10.5281/zenodo.{SOLAR_RECORD}' + assert datasets[STELLAR_SPECTRA_NAMED].zenodo == f'10.5281/zenodo.{NAMED_RECORD}' + assert datasets[STELLAR_SPECTRA_MUSCLES].zenodo == f'10.5281/zenodo.{MUSCLES_RECORD}' assert datasets[SURFACE_ALBEDOS_HAMMOND_2024].zenodo == ( f'10.5281/zenodo.{HAMMOND_2024_RECORD}' ) @@ -120,6 +135,9 @@ def test_registries_pin_committed_checksums(): zeng = _dataset(MASS_RADIUS_ZENG_2019).registry() hammond = _dataset(SURFACE_ALBEDOS_HAMMOND_2024).registry() seager = _dataset(EOS_SEAGER_2007).registry() + solar = _dataset(STELLAR_SPECTRA_SOLAR).registry() + named = _dataset(STELLAR_SPECTRA_NAMED).registry() + muscles = _dataset(STELLAR_SPECTRA_MUSCLES).registry() assert len(exo) == 1, 'the catalogue ships exactly one file' assert len(zeng) == 57, 'the Zeng-2019 grid ships 57 curve files' @@ -129,12 +147,18 @@ def test_registries_pin_committed_checksums(): 'eos_seager07_silicate.txt', 'eos_seager07_water.txt', } + assert len(solar) == 10, 'the solar record ships 10 spectra' + assert len(named) == 11, 'the named-star record ships 11 spectra' + assert len(muscles) == 38, 'the MUSCLES record ships 36 spectra, a readme and a table' + assert solar['sun.txt'] == 'md5:6e4b6540d952cf3c01a3bd0511aa6c10' + assert named['sun.txt'] == 'md5:0c5225b847ca250673edc9690f02e055' + assert muscles['gj876.txt'] == 'md5:4b4e7299bad9545ee8fef82cb4c8d4d8' assert seager['eos_seager07_iron.txt'] == 'md5:7bf215a2bb4da6d27ceeac2ade0ce706' assert hammond['lunarmarebasalt.dat'] == 'md5:a157ea1d436072264c3bea833997a382' assert exo['DACE_PlanetS.csv'] == 'md5:367a90914eba4a209f896a1c72dd3d2b' # Every entry must carry an algorithm prefix, or pooch cannot know what to # verify against; a bare digest would silently be read as the default. - for registry in (exo, zeng, hammond, seager): + for registry in (exo, zeng, hammond, seager, solar, named, muscles): assert all(':' in digest for digest in registry.values()) assert 'massradiusEarthlikeRocky.txt' in zeng @@ -177,6 +201,15 @@ def test_dataset_dir_is_versioned(tmp_path): assert dataset_dir(EOS_SEAGER_2007, data_root=tmp_path) == ( tmp_path / 'interior_struct' / 'eos' / 'seager_2007' / f'r{SEAGER_2007_RECORD}' ) + assert dataset_dir(STELLAR_SPECTRA_SOLAR, data_root=tmp_path) == ( + tmp_path / 'stellar_spectra' / 'solar' / f'r{SOLAR_RECORD}' + ) + assert dataset_dir(STELLAR_SPECTRA_NAMED, data_root=tmp_path) == ( + tmp_path / 'stellar_spectra' / 'named' / f'r{NAMED_RECORD}' + ) + assert dataset_dir(STELLAR_SPECTRA_MUSCLES, data_root=tmp_path) == ( + tmp_path / 'stellar_spectra' / 'muscles' / f'r{MUSCLES_RECORD}' + ) def test_dataset_dir_rejects_an_unversioned_resolution(tmp_path, monkeypatch): @@ -352,7 +385,11 @@ def test_spectral_file_datasets_pin_their_records_and_locations(tmp_path): assert dataset.zenodo == f'10.5281/zenodo.{record}' assert dataset.subdir == f'atmos_clim/spectral_files/{group.lower()}_{bands}' assert dataset_dir(key, data_root=tmp_path) == ( - tmp_path / 'atmos_clim' / 'spectral_files' / f'{group.lower()}_{bands}' / f'r{record}' + tmp_path + / 'atmos_clim' + / 'spectral_files' + / f'{group.lower()}_{bands}' + / f'r{record}' ) assert _dataset(key).registry(), f'empty registry for {key}' @@ -365,7 +402,9 @@ def test_every_spectral_folder_has_a_dataset_and_no_legacy_entry(): """ from proteus.utils.data import DATA_SOURCE_MAP, SPECTRAL_FILE_FOLDERS - assert {tuple(folder.split('/')) for folder in SPECTRAL_FILE_FOLDERS} == set(SPECTRAL_RECORDS) + assert {tuple(folder.split('/')) for folder in SPECTRAL_FILE_FOLDERS} == set( + SPECTRAL_RECORDS + ) for folder in SPECTRAL_FILE_FOLDERS: group, bands = folder.split('/') assert spectral_file_key(group, bands) in _OWNED_KEYS @@ -409,12 +448,18 @@ def test_migrated_datasets_are_not_also_pinned_in_the_legacy_map(): assert 'Population' not in DATA_SOURCE_MAP # Discrimination: the map is still populated for the datasets that have not # migrated, so an emptied map cannot make this pass. - assert 'Named' in DATA_SOURCE_MAP + assert 'Named' not in DATA_SOURCE_MAP + assert 'solar' not in DATA_SOURCE_MAP + assert 'MUSCLES' not in DATA_SOURCE_MAP + assert 'PHOENIX' in DATA_SOURCE_MAP pinned_records = {entry['zenodo_id'] for entry in DATA_SOURCE_MAP.values()} assert EXOPLANET_RECORD not in pinned_records assert HAMMOND_2024_RECORD not in pinned_records assert SEAGER_2007_RECORD not in pinned_records assert ZENG_2019_RECORD not in pinned_records + assert SOLAR_RECORD not in pinned_records + assert NAMED_RECORD not in pinned_records + assert MUSCLES_RECORD not in pinned_records def test_fetch_dataset_delegates_to_the_pinned_fetcher(monkeypatch, tmp_path): diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 48106bbef..9207d0702 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -150,13 +150,14 @@ def _run_proteus( runner.config.params.out.write_mod = 0 runner.config.params.out.archive_mod = 'none' - # Ensure required data exists when using ARAGOG, AGNI, or Zalmoxis - # (download if missing). Runs locally and in CI; no-op or quick when - # data already present. + # Ensure required data exists when using ARAGOG, AGNI, MORS, or + # Zalmoxis (download if missing). Runs locally and in CI; no-op or + # quick when data already present. if ( runner.config.interior_energetics.module == 'aragog' or runner.config.atmos_clim.module == 'agni' or runner.config.interior_struct.module == 'zalmoxis' + or runner.config.star.module == 'mors' ): from proteus.utils.data import download_sufficient_data diff --git a/tests/integration/test_packaging.py b/tests/integration/test_packaging.py index 8f4e29d2f..a3325499f 100644 --- a/tests/integration/test_packaging.py +++ b/tests/integration/test_packaging.py @@ -21,7 +21,7 @@ import pytest -from proteus.data import EXOPLANET_REFERENCE, MASS_RADIUS_ZENG_2019 +from proteus.data import EXOPLANET_REFERENCE, MASS_RADIUS_ZENG_2019, manifest_path pytestmark = [pytest.mark.integration, pytest.mark.timeout(300)] @@ -29,7 +29,7 @@ def test_built_wheel_carries_the_manifest_and_registries(tmp_path): - """A built wheel contains the manifest and both committed registries. + """A built wheel contains the manifest and one registry per dataset. Without them the package imports cleanly and fails at the first fetch with a missing-manifest error, which points the reader at the wrong problem. @@ -59,4 +59,9 @@ def test_built_wheel_carries_the_manifest_and_registries(tmp_path): assert f'proteus/data/{MASS_RADIUS_ZENG_2019}.registry.txt' in names # Discrimination: a wheel that shipped the manifest but no registry would # satisfy a looser check while failing every checksum verification. - assert len([n for n in names if n.endswith('.registry.txt')]) == 2 + from fwl_io import load_manifest + + expected = {f'proteus/data/{ds.key}.registry.txt' for ds in load_manifest(manifest_path())} + shipped = {n for n in names if n.endswith('.registry.txt')} + assert expected, 'the manifest declares no datasets' + assert shipped == expected diff --git a/tests/star/test_stellar_spectra.py b/tests/star/test_stellar_spectra.py index fd5350f82..cfafb4738 100644 --- a/tests/star/test_stellar_spectra.py +++ b/tests/star/test_stellar_spectra.py @@ -309,6 +309,20 @@ def fake_download_phoenix(*, alpha, FeH): # init_star() spectrum selection tests +def _solar_dir(root): + """Version directory of the solar spectra dataset below ``root``.""" + from proteus.data import STELLAR_SPECTRA_SOLAR, dataset_dir + + return dataset_dir(STELLAR_SPECTRA_SOLAR, data_root=root) + + +def _muscles_dir(root): + """Version directory of the MUSCLES spectra dataset below ``root``.""" + from proteus.data import STELLAR_SPECTRA_MUSCLES, dataset_dir + + return dataset_dir(STELLAR_SPECTRA_MUSCLES, data_root=root) + + @pytest.mark.unit def test_init_star_source_none_prefers_muscles_when_available(tmp_path, monkeypatch): """With ``spectrum_source=None``, ``init_star`` prefers MUSCLES over @@ -321,8 +335,8 @@ def test_init_star_source_none_prefers_muscles_when_available(tmp_path, monkeypa handler = _make_handler_for_init_star(tmp_path, spectrum_source=None) star_file = 'gj876.txt' - solar = tmp_path / 'stellar_spectra' / 'solar' / star_file - muscles = tmp_path / 'stellar_spectra' / 'MUSCLES' / star_file + solar = _solar_dir(tmp_path) / star_file + muscles = _muscles_dir(tmp_path) / star_file _write_spectrum_file(solar, fl=(10.0, 20.0)) _write_spectrum_file(muscles, fl=(30.0, 40.0)) @@ -350,7 +364,7 @@ def test_init_star_source_none_uses_muscles_when_solar_missing(tmp_path, monkeyp handler = _make_handler_for_init_star(tmp_path, spectrum_source=None) starname_proper = 'gj876.txt' - muscles = tmp_path / 'stellar_spectra' / 'MUSCLES' / starname_proper + muscles = _muscles_dir(tmp_path) / starname_proper _write_spectrum_file(muscles, fl=(30.0, 40.0)) init_star(handler) @@ -380,7 +394,7 @@ def test_init_star_source_solar_falls_back_to_muscles_with_warning( handler = _make_handler_for_init_star(tmp_path, spectrum_source='solar') starname_proper = 'gj876.txt' - muscles = tmp_path / 'stellar_spectra' / 'MUSCLES' / starname_proper + muscles = _muscles_dir(tmp_path) / starname_proper _write_spectrum_file(muscles, fl=(30.0, 40.0)) init_star(handler) @@ -406,7 +420,7 @@ def test_init_star_source_muscles_falls_back_to_solar_with_warning( handler = _make_handler_for_init_star(tmp_path, spectrum_source='muscles') starname_proper = 'gj876.txt' - solar = tmp_path / 'stellar_spectra' / 'solar' / starname_proper + solar = _solar_dir(tmp_path) / starname_proper _write_spectrum_file(solar, fl=(10.0, 20.0)) init_star(handler) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e9e80bd0..cb66a718a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,13 @@ runner = CliRunner() +def _solar_dir(root): + """Version directory of the solar spectra dataset below ``root``.""" + from proteus.data import STELLAR_SPECTRA_SOLAR, dataset_dir + + return dataset_dir(STELLAR_SPECTRA_SOLAR, data_root=root) + + @pytest.mark.unit def test_doctor(): """``proteus doctor`` renders its report and returns a health-coded exit. @@ -59,7 +66,7 @@ def test_get(monkeypatch, tmp_path): The downloaders touch the network; each is replaced with a no-op so the test stays a pure CLI dispatch check rather than a network smoke. Some subcommands also assert post-conditions (e.g. `solar` requires - that files exist under FWL_DATA/stellar_spectra/solar after the + that files exist under the solar dataset directory after the downloader returns), so the no-op for download_stellar_spectra writes a stub file at the expected path. FWL_DATA itself is monkeypatched to a tmp_path so the test never touches the user's real data tree. @@ -68,7 +75,7 @@ def test_get(monkeypatch, tmp_path): the specific exit code; a regression in any one of them surfaces the name of the failing subcommand in the assertion output. The previous failure (test_get fails on `solar` in CI but not locally because the - user's FWL_DATA happened to have stellar_spectra/solar/ already + user's FWL_DATA happened to have the solar dataset already populated) is now eliminated by controlling FWL_DATA explicitly. """ # Monkeypatch FWL_DATA to a writable tmp_path so post-condition file @@ -82,12 +89,12 @@ def test_get(monkeypatch, tmp_path): def stub_download_stellar_spectra(folders=('solar',), **kwargs): # The CLI `solar` subcommand checks for files under - # GetFWLData()/stellar_spectra/solar after the downloader returns, + # the solar dataset directory after the downloader returns, # raising ClickException if none are present. The no-op honours # that contract by writing a stub file so the post-condition # passes without touching the network. for folder in folders: - target = tmp_path / 'stellar_spectra' / folder + target = _solar_dir(tmp_path) target.mkdir(parents=True, exist_ok=True) (target / '_stub.txt').write_text('stub') return True @@ -444,14 +451,14 @@ def fake_download_phoenix(alpha, FeH): @pytest.mark.unit def test_get_solar_success_when_files_present(monkeypatch, tmp_path): - """``proteus get solar`` succeeds when files materialise under FWL_DATA/stellar_spectra/solar.""" + """``proteus get solar`` succeeds when files materialise in the solar dataset directory.""" runner = CliRunner() def fake_GetFWLData(): return tmp_path def fake_download_stellar_spectra(folders=('solar',)): - solar_dir = tmp_path / 'stellar_spectra' / 'solar' + solar_dir = _solar_dir(tmp_path) solar_dir.mkdir(parents=True, exist_ok=True) (solar_dir / 'dummy.txt').write_text('ok') @@ -463,7 +470,7 @@ def fake_download_stellar_spectra(folders=('solar',)): res = runner.invoke(cli.cli, ['get', 'solar']) assert res.exit_code == 0 assert 'Solar spectra downloaded successfully.' in res.output - assert str(tmp_path / 'stellar_spectra' / 'solar') in res.output + assert str(_solar_dir(tmp_path)) in res.output @pytest.mark.unit @@ -478,7 +485,7 @@ def fake_GetFWLData(): def fake_download_stellar_spectra(folders=('solar',)): # create directory but no files - (tmp_path / 'stellar_spectra' / 'solar').mkdir(parents=True, exist_ok=True) + _solar_dir(tmp_path).mkdir(parents=True, exist_ok=True) monkeypatch.setattr('proteus.utils.data.GetFWLData', fake_GetFWLData) monkeypatch.setattr( diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index c342d4b5a..7b1973922 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -1044,9 +1044,9 @@ def test_download_folder_mode_fails_if_no_sources_available(mock_check, mock_get def test_get_data_source_info(): """Test unified data source mapping lookup.""" # Test known mapping - info = get_data_source_info('Named') + info = get_data_source_info('PHOENIX') assert info is not None - assert info['zenodo_id'] == '15721440' + assert info['zenodo_id'] == '17674612' assert info['osf_project'] == '8r2sw' # Test unknown mapping @@ -1057,7 +1057,7 @@ def test_get_data_source_info(): @pytest.mark.unit def test_get_osf_project(): """Test OSF project ID lookup.""" - assert get_osf_project('Named') == '8r2sw' + assert get_osf_project('PHOENIX') == '8r2sw' assert get_osf_project('UnknownFolder') is None @@ -1078,7 +1078,7 @@ def test_get_zenodo_from_osf(): @pytest.mark.unit def test_get_osf_from_zenodo(): """Test reverse lookup: Zenodo ID -> OSF project.""" - assert get_osf_from_zenodo('15721440') == '8r2sw' # Named + assert get_osf_from_zenodo('17674612') == '8r2sw' # PHOENIX assert get_osf_from_zenodo('99999999') is None # Unknown @@ -1267,7 +1267,7 @@ def test_download_automatic_mapping( mock_download_zenodo.return_value = True mock_validate.return_value = True - folder_dir = tmp_path / 'target' / 'Named' + folder_dir = tmp_path / 'target' / 'PHOENIX' folder_dir.mkdir(parents=True, exist_ok=True) (folder_dir / 'test_file.txt').write_text('test') @@ -1278,7 +1278,7 @@ def test_download_automatic_mapping( # Call download without explicit IDs - should use mapping result = download( - folder='Named', + folder='PHOENIX', target='target', desc='test data', # No osf_id or zenodo_id provided - should use mapping @@ -1288,7 +1288,7 @@ def test_download_automatic_mapping( mock_download_zenodo.assert_called_once() # Should have used mapped Zenodo ID (check kwargs since it's called with keyword args) call_kwargs = mock_download_zenodo.call_args.kwargs - assert call_kwargs['zenodo_id'] == '15721440' # Zenodo ID from mapping + assert call_kwargs['zenodo_id'] == '17674612' # Zenodo ID from mapping assert result is True @@ -1592,121 +1592,81 @@ def test_download_phoenix_returns_false_if_download_fails(mock_download, tmp_pat @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.get_data_source_info') -def test_download_muscles_default_download_all(mock_get_info, mock_download): - """If stars is None, download_muscles should download the whole MUSCLES catalogue (folder mode).""" +@patch('proteus.data.fetch_dataset') +def test_download_muscles_default_download_all(mock_fetch): + """If stars is None, download_muscles fetches the whole MUSCLES dataset.""" + from proteus.data import STELLAR_SPECTRA_MUSCLES from proteus.utils.data import download_muscles - mock_get_info.return_value = { - 'zenodo_id': 'ZEN', - 'osf_project': 'OSFPROJ', - 'osf_id': 'OSFPROJ', - } - mock_download.return_value = True - ok = download_muscles(stars=None, force=False) assert ok is True - mock_get_info.assert_called_once_with('MUSCLES') - mock_download.assert_called_once_with( - folder='MUSCLES', - target='stellar_spectra', - osf_id='OSFPROJ', - zenodo_id='ZEN', - desc='MUSCLES stellar spectra catalogue', - force=False, - ) + mock_fetch.assert_called_once_with(STELLAR_SPECTRA_MUSCLES) @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.get_data_source_info') -def test_download_muscles_single_star(mock_get_info, mock_download): - """If stars is a string, download_muscles should call download() once in single-file mode.""" +@patch('proteus.data.fetch_dataset_file') +def test_download_muscles_single_star(mock_fetch_file): + """If stars is a string, download_muscles fetches that one registry file.""" + from proteus.data import STELLAR_SPECTRA_MUSCLES from proteus.utils.data import download_muscles - mock_get_info.return_value = { - 'zenodo_id': 'ZEN', - 'osf_project': 'OSFPROJ', - 'osf_id': 'OSFPROJ', - } - mock_download.return_value = True - - ok = download_muscles(stars='trappist-1', force=True) + ok = download_muscles(stars='trappist-1', force=False) assert ok is True - mock_get_info.assert_called_once_with('MUSCLES') - mock_download.assert_called_once_with( - folder='MUSCLES', - target='stellar_spectra', - osf_id='OSFPROJ', - zenodo_id='ZEN', - desc='MUSCLES stellar spectrum (trappist-1)', - force=True, - file='trappist-1.txt', - ) + mock_fetch_file.assert_called_once_with(STELLAR_SPECTRA_MUSCLES, 'trappist-1.txt') @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.get_data_source_info') -def test_download_muscles_multiple_stars(mock_get_info, mock_download): - """If stars is a list, download_muscles should call download() once per star and return AND of results.""" +@patch('proteus.data.fetch_dataset_file') +def test_download_muscles_multiple_stars(mock_fetch_file): + """A star absent from the registry fails the call but not the other stars.""" + from proteus.data import STELLAR_SPECTRA_MUSCLES from proteus.utils.data import download_muscles - mock_get_info.return_value = { - 'zenodo_id': 'ZEN', - 'osf_project': 'OSFPROJ', - 'osf_id': 'OSFPROJ', - } - - # First star succeeds, second fails -> overall False - mock_download.side_effect = [True, False] + mock_fetch_file.side_effect = [Path('a'), KeyError('starB.txt')] ok = download_muscles(stars=['starA', 'starB'], force=False) assert ok is False - mock_get_info.assert_called_once_with('MUSCLES') - assert mock_download.call_count == 2 - - # Check both calls precisely (order matters) - expected_calls = [ - call( - folder='MUSCLES', - target='stellar_spectra', - osf_id='OSFPROJ', - zenodo_id='ZEN', - desc='MUSCLES stellar spectrum (starA)', - force=False, - file='starA.txt', - ), - call( - folder='MUSCLES', - target='stellar_spectra', - osf_id='OSFPROJ', - zenodo_id='ZEN', - desc='MUSCLES stellar spectrum (starB)', - force=False, - file='starB.txt', - ), + assert mock_fetch_file.call_args_list == [ + call(STELLAR_SPECTRA_MUSCLES, 'starA.txt'), + call(STELLAR_SPECTRA_MUSCLES, 'starB.txt'), ] - assert mock_download.call_args_list == expected_calls @pytest.mark.unit -@patch('proteus.utils.data.get_data_source_info') -def test_download_muscles_no_mapping_raises(mock_get_info): - """download_muscles should raise if MUSCLES is not in the mapping.""" +@patch('proteus.data.fetch_dataset') +def test_download_muscles_reports_fetch_failure(mock_fetch): + """A fetch error is reported as a failed download, not raised.""" from proteus.utils.data import download_muscles - mock_get_info.return_value = None + mock_fetch.side_effect = RuntimeError('mirror down') - with pytest.raises(ValueError): - download_muscles(stars=None) - # Discrimination: confirm the MUSCLES registry lookup actually ran; - # otherwise the ValueError could come from an unrelated earlier guard. - mock_get_info.assert_called() + assert download_muscles(stars=None) is False + + +@pytest.mark.unit +def test_download_muscles_force_removes_the_requested_file_first(tmp_path, monkeypatch): + """``force`` deletes the file before the fetch, so a present file is fetched again.""" + from proteus.data import STELLAR_SPECTRA_MUSCLES, dataset_dir + from proteus.utils.data import download_muscles + + monkeypatch.setenv('FWL_DATA', str(tmp_path)) + star_file = dataset_dir(STELLAR_SPECTRA_MUSCLES, data_root=tmp_path) / 'gj876.txt' + star_file.parent.mkdir(parents=True, exist_ok=True) + star_file.write_text('stale') + seen = {} + + def fake_fetch(key, name, data_root=None): + seen['present'] = star_file.exists() + return star_file + + monkeypatch.setattr('proteus.data.fetch_dataset_file', fake_fetch) + monkeypatch.setattr('proteus.data._data_root', lambda: tmp_path) + + assert download_muscles(stars='gj876', force=True) is True + assert seen == {'present': False} @pytest.mark.unit @@ -1778,32 +1738,38 @@ def test_download_melting_curves(mock_rm, mock_getfwl, mock_download, tmp_path): @pytest.mark.unit @patch('proteus.utils.data.download') -def test_download_stellar_spectra_default(mock_download): - """Test stellar spectra download with default folders.""" +@patch('proteus.data.fetch_dataset') +def test_download_stellar_spectra_default(mock_fetch, mock_download): + """Default stellar spectra download fetches Named, solar and MUSCLES through fwl-io.""" + from proteus.data import ( + STELLAR_SPECTRA_MUSCLES, + STELLAR_SPECTRA_NAMED, + STELLAR_SPECTRA_SOLAR, + ) from proteus.utils.data import download_stellar_spectra download_stellar_spectra() - # Should download Named, solar, and MUSCLES - assert mock_download.call_count == 3 - folders = [call.kwargs['folder'] for call in mock_download.call_args_list] - assert 'Named' in folders - assert 'solar' in folders - assert 'MUSCLES' in folders + fetched = [c.args[0] for c in mock_fetch.call_args_list] + assert sorted(fetched) == sorted( + [STELLAR_SPECTRA_NAMED, STELLAR_SPECTRA_SOLAR, STELLAR_SPECTRA_MUSCLES] + ) + mock_download.assert_not_called() @pytest.mark.unit @patch('proteus.utils.data.download') -def test_download_stellar_spectra_custom(mock_download): - """Test stellar spectra download with custom folders.""" +@patch('proteus.data.fetch_dataset') +def test_download_stellar_spectra_custom(mock_fetch, mock_download): + """A collection still in DATA_SOURCE_MAP keeps its legacy download path.""" + from proteus.data import STELLAR_SPECTRA_NAMED from proteus.utils.data import download_stellar_spectra download_stellar_spectra(folders=('Named', 'PHOENIX')) - assert mock_download.call_count == 2 - folders = [call.kwargs['folder'] for call in mock_download.call_args_list] - assert 'Named' in folders - assert 'PHOENIX' in folders + mock_fetch.assert_called_once_with(STELLAR_SPECTRA_NAMED) + mock_download.assert_called_once() + assert mock_download.call_args.kwargs['folder'] == 'PHOENIX' @pytest.mark.unit From c53fdae4e599db60074592907bda66a07b91b4fb Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Tue, 22 Sep 2026 00:58:46 +0200 Subject: [PATCH 05/76] Fetch the Zalmoxis equation-of-state tables through fwl-io Six equation-of-state datasets move from the legacy record downloader into the manifest under interior_struct.eos: Wolf and Bower 2018, RTPress 100 TPa, PALEOS MgSiO3 two-phase (standard and high resolution), PALEOS unified, and Chabrier 2021. The tables land in $FWL_DATA/interior_struct/eos//r/, and the Zalmoxis material dictionaries resolve their paths from the versioned directories. The PALEOS unified record is fetched one file at a time, so a run that needs one component does not pull the full 2.29 GB record. The RTPress family also fetches the Wolf and Bower solid density file that its registry entry references, which closes a missing-file failure for RTPress-only configurations. The Chabrier archive is extracted by fwl-io, and the archive metadata files it contains are now kept in the extracted tree. The seven legacy DATA_SOURCE_MAP entries and their download helpers are removed. Copies under $FWL_DATA/zalmoxis_eos are no longer read, so an existing installation fetches the tables again on the next run that needs them. --- docs/Reference/data.md | 18 +- src/proteus/data/__init__.py | 6 + ...rior_struct.eos.chabrier_2021.registry.txt | 1 + ...ruct.eos.paleos_mgsio3_2phase.registry.txt | 2 + ....paleos_mgsio3_2phase_highres.registry.txt | 2 + ...ior_struct.eos.paleos_unified.registry.txt | 3 + ...ior_struct.eos.rtpress_100tpa.registry.txt | 2 + ...or_struct.eos.wolf_bower_2018.registry.txt | 3 + src/proteus/data/proteus_manifest.toml | 46 ++ src/proteus/interior_struct/zalmoxis.py | 50 +- src/proteus/utils/data.py | 230 ++----- tests/data/test_manifest.py | 89 ++- tests/interior_struct/test_zalmoxis.py | 57 +- tests/utils/test_data.py | 586 +++++++----------- 14 files changed, 522 insertions(+), 573 deletions(-) create mode 100644 src/proteus/data/interior_struct.eos.chabrier_2021.registry.txt create mode 100644 src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase.registry.txt create mode 100644 src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase_highres.registry.txt create mode 100644 src/proteus/data/interior_struct.eos.paleos_unified.registry.txt create mode 100644 src/proteus/data/interior_struct.eos.rtpress_100tpa.registry.txt create mode 100644 src/proteus/data/interior_struct.eos.wolf_bower_2018.registry.txt diff --git a/docs/Reference/data.md b/docs/Reference/data.md index d12856204..0159b3616 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -35,8 +35,8 @@ used with lower rate limits. | Surface albedos | fwl-io | `proteus get surfaces` | | Scattering properties | PROTEUS downloader | `proteus get scattering` | | Exoplanet populations, mass-radius curves | fwl-io | `proteus get reference` | -| Seager (2007) EOS tables | fwl-io | fetched when a run needs them | -| Other interior EOS tables, melting curves | PROTEUS downloader | `proteus get interiordata` | +| Interior structure EOS tables | fwl-io | `proteus get interiordata`, or fetched when a run needs them | +| Interior lookup tables, melting curves | PROTEUS downloader | `proteus get interiordata` | To configure a Zenodo API token, see the [Troubleshooting guide](../How-to/troubleshooting.md#data-download-errors-or-slow-zenodo-downloads). @@ -235,4 +235,16 @@ not read and can be deleted. The interior structure solver of PROTEUS, Zalmoxis, uses equation-of-state tables by [Seager et al. (2007)](https://iopscience.iop.org/article/10.1086/521346), and from [PALEOS](https://github.com/maraattia/PALEOS) by [Attia et al. (2026)](https://ui.adsabs.harvard.edu/abs/2026arXiv260503741A/abstract). An overview of equation of state tables can be found [here](https://proteus-framework.org/Zalmoxis/Reference/data.html#data-inventory). -The Seager et al. (2007) tables are fetched through fwl-io into `$FWL_DATA/interior_struct/eos/seager_2007/r/`, on the same terms as the datasets above. A copy under `$FWL_DATA/EOS_material_properties/EOS_Seager2007` is not read and can be deleted. +All equation-of-state tables are fetched through fwl-io into `$FWL_DATA/interior_struct/eos//r/`, on the same terms as the datasets above. Only the tables that the selected equations of state reference are fetched. A copy under `$FWL_DATA/EOS_material_properties/EOS_Seager2007` is not read and can be deleted. + +| Dataset directory | Contents | Fetched | +|---|---|---| +| `seager_2007` | Seager et al. (2007) iron, silicate and water tables | whole record | +| `wolf_bower_2018` | Wolf and Bower (2018) MgSiO3 melt and solid tables to 1 TPa | whole dataset for `WolfBower2018`; the solid density file alone for `RTPress100TPa` | +| `rtpress_100tpa` | RTPress MgSiO3 melt tables to 100 TPa | whole dataset | +| `paleos_mgsio3_2phase` | PALEOS MgSiO3 solid and liquid tables, 150 points per decade | whole dataset | +| `paleos_mgsio3_2phase_highres` | PALEOS MgSiO3 solid and liquid tables, 600 points per decade | whole dataset, only for `PALEOS-2phase:MgSiO3-highres` | +| `paleos_unified` | PALEOS unified tables for iron, MgSiO3 and water | one file per selected component, not the full 2.29 GB record | +| `chabrier_2021` | Chabrier et al. hydrogen and helium tables | archive extracted into `r/EOS_Chabrier2021_HHe/` | + +Copies of these tables under `$FWL_DATA/zalmoxis_eos` and `$FWL_DATA/EOS_material_properties` are not read and can be deleted. diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index f7c178520..8368c8dd2 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -25,6 +25,12 @@ MASS_RADIUS_ZENG_2019 = 'observe.mass_radius.zeng_2019' SURFACE_ALBEDOS_HAMMOND_2024 = 'atmos_clim.surface_albedos.hammond_2024' EOS_SEAGER_2007 = 'interior_struct.eos.seager_2007' +EOS_WOLF_BOWER_2018 = 'interior_struct.eos.wolf_bower_2018' +EOS_RTPRESS_100TPA = 'interior_struct.eos.rtpress_100tpa' +EOS_PALEOS_MGSIO3_2PHASE = 'interior_struct.eos.paleos_mgsio3_2phase' +EOS_PALEOS_MGSIO3_2PHASE_HIGHRES = 'interior_struct.eos.paleos_mgsio3_2phase_highres' +EOS_PALEOS_UNIFIED = 'interior_struct.eos.paleos_unified' +EOS_CHABRIER_2021 = 'interior_struct.eos.chabrier_2021' STELLAR_SPECTRA_SOLAR = 'stellar_spectra.solar' STELLAR_SPECTRA_NAMED = 'stellar_spectra.named' STELLAR_SPECTRA_MUSCLES = 'stellar_spectra.muscles' diff --git a/src/proteus/data/interior_struct.eos.chabrier_2021.registry.txt b/src/proteus/data/interior_struct.eos.chabrier_2021.registry.txt new file mode 100644 index 000000000..14e7f614e --- /dev/null +++ b/src/proteus/data/interior_struct.eos.chabrier_2021.registry.txt @@ -0,0 +1 @@ +EOS_Chabrier2021_HHe.tar.gz md5:18ce96ed0526d4ade283807a7da2e091 diff --git a/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase.registry.txt b/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase.registry.txt new file mode 100644 index 000000000..46c84ffcf --- /dev/null +++ b/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase.registry.txt @@ -0,0 +1,2 @@ +paleos_mgsio3_tables_pt_proteus_liquid.dat md5:4b5c14255ca845880a2e940110861df1 +paleos_mgsio3_tables_pt_proteus_solid.dat md5:9cab570e3331b329c15715628a504fad diff --git a/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase_highres.registry.txt b/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase_highres.registry.txt new file mode 100644 index 000000000..8808a4f61 --- /dev/null +++ b/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase_highres.registry.txt @@ -0,0 +1,2 @@ +paleos_mgsio3_tables_pt_proteus_liquid_highres.dat md5:f67e0f049fe5f3d5cba0adef6ab7632f +paleos_mgsio3_tables_pt_proteus_solid_highres.dat md5:fddb2c3f392e1c2f73a326d702a98c70 diff --git a/src/proteus/data/interior_struct.eos.paleos_unified.registry.txt b/src/proteus/data/interior_struct.eos.paleos_unified.registry.txt new file mode 100644 index 000000000..df8f0e58c --- /dev/null +++ b/src/proteus/data/interior_struct.eos.paleos_unified.registry.txt @@ -0,0 +1,3 @@ +paleos_iron_eos_table_pt.dat md5:259a2267dcf58af3b2c4dd389105b597 +paleos_mgsio3_eos_table_pt.dat md5:1f75d518bf192e66b94ec0bda190ada7 +paleos_water_eos_table_pt.dat md5:2f62eea322f0dee1268565e4182bde11 diff --git a/src/proteus/data/interior_struct.eos.rtpress_100tpa.registry.txt b/src/proteus/data/interior_struct.eos.rtpress_100tpa.registry.txt new file mode 100644 index 000000000..a97d21203 --- /dev/null +++ b/src/proteus/data/interior_struct.eos.rtpress_100tpa.registry.txt @@ -0,0 +1,2 @@ +adiabat_temp_grad_melt.dat md5:5aedafc17fec4cab5a38d30d4029fe58 +density_melt.dat md5:0ba9c4468ac9405f3ced86d4edf283c6 diff --git a/src/proteus/data/interior_struct.eos.wolf_bower_2018.registry.txt b/src/proteus/data/interior_struct.eos.wolf_bower_2018.registry.txt new file mode 100644 index 000000000..abcf667cf --- /dev/null +++ b/src/proteus/data/interior_struct.eos.wolf_bower_2018.registry.txt @@ -0,0 +1,3 @@ +adiabat_temp_grad_melt.dat md5:8ec5859f6500c3ecb2837a1f32c5b102 +density_melt.dat md5:8af47f385066fd8b85640a8731dbcda7 +density_solid.dat md5:2dcc6a431f4b19b08702936046fd12a3 diff --git a/src/proteus/data/proteus_manifest.toml b/src/proteus/data/proteus_manifest.toml index 6ddb564a3..28af8e56e 100644 --- a/src/proteus/data/proteus_manifest.toml +++ b/src/proteus/data/proteus_manifest.toml @@ -109,3 +109,49 @@ required_by = ["proteus"] name = "MUSCLES observed stellar spectra" zenodo = "10.5281/zenodo.17802209" required_by = ["proteus"] + +[interior_struct.eos.wolf_bower_2018] +name = "Wolf & Bower (2018) MgSiO3 equation of state to 1 TPa, Zalmoxis tables" +zenodo = "10.5281/zenodo.17417017" +required_by = ["proteus"] +files = ["density_melt.dat", "density_solid.dat", "adiabat_temp_grad_melt.dat"] + +[interior_struct.eos.rtpress_100tpa] +name = "RTPress MgSiO3 melt equation of state to 100 TPa, Zalmoxis tables" +zenodo = "10.5281/zenodo.18819027" +required_by = ["proteus"] +files = ["density_melt.dat", "adiabat_temp_grad_melt.dat"] + +[interior_struct.eos.paleos_mgsio3_2phase] +name = "PALEOS MgSiO3 solid and liquid tables, 150 points per decade" +zenodo = "10.5281/zenodo.19680050" +required_by = ["proteus"] +files = [ + "paleos_mgsio3_tables_pt_proteus_liquid.dat", + "paleos_mgsio3_tables_pt_proteus_solid.dat", +] + +[interior_struct.eos.paleos_mgsio3_2phase_highres] +name = "PALEOS MgSiO3 solid and liquid tables, 600 points per decade" +zenodo = "10.5281/zenodo.19680050" +required_by = ["proteus"] +files = [ + "paleos_mgsio3_tables_pt_proteus_liquid_highres.dat", + "paleos_mgsio3_tables_pt_proteus_solid_highres.dat", +] + +[interior_struct.eos.paleos_unified] +name = "PALEOS unified tables for iron, MgSiO3 and water" +zenodo = "10.5281/zenodo.22776069" +required_by = ["proteus"] +files = [ + "paleos_iron_eos_table_pt.dat", + "paleos_mgsio3_eos_table_pt.dat", + "paleos_water_eos_table_pt.dat", +] + +[interior_struct.eos.chabrier_2021] +name = "Chabrier et al. hydrogen and helium tables in PALEOS format" +zenodo = "10.5281/zenodo.19135021" +required_by = ["proteus"] +extract = "tar" diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 280c3b65e..da5286ea9 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -17,7 +17,16 @@ from zalmoxis.solver import main from proteus.config import Config -from proteus.data import EOS_SEAGER_2007, dataset_dir +from proteus.data import ( + EOS_CHABRIER_2021, + EOS_PALEOS_MGSIO3_2PHASE, + EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, + EOS_PALEOS_UNIFIED, + EOS_RTPRESS_100TPA, + EOS_SEAGER_2007, + EOS_WOLF_BOWER_2018, + dataset_dir, +) from proteus.utils.constants import ( FEI2021_LIQUIDUS_P_CALIB_PA, PALEOS_EOS_PREFIXES, @@ -25,7 +34,7 @@ R_earth, element_list, ) -from proteus.utils.data import get_zalmoxis_eos_dir, get_zalmoxis_melting_curves +from proteus.utils.data import get_zalmoxis_melting_curves FWL_DATA_DIR = Path(os.environ.get('FWL_DATA', platformdirs.user_data_dir('fwl_data'))) @@ -1217,10 +1226,10 @@ def load_zalmoxis_material_dictionaries(): """Build an EOS registry dict with file paths pointing to FWL_DATA. Returns the same dict format as Zalmoxis ``EOS_REGISTRY``, but with - every ``eos_file`` path resolved under ``FWL_DATA/zalmoxis_eos/`` - instead of ``ZALMOXIS_ROOT/data/``. This ensures that Zalmoxis, - when called from PROTEUS, reads EOS data from the central FWL_DATA - location managed by ``download_zalmoxis_eos()``. + every ``eos_file`` path resolved to the fwl-io dataset directory under + ``FWL_DATA/interior_struct/eos/`` instead of ``ZALMOXIS_ROOT/data/``. + This ensures that Zalmoxis, when called from PROTEUS, reads EOS data + from the central FWL_DATA location managed by ``download_zalmoxis_eos()``. Returns ------- @@ -1228,8 +1237,6 @@ def load_zalmoxis_material_dictionaries(): Flat dict keyed by EOS identifier string (e.g. ``"Seager2007:iron"``, ``"PALEOS:MgSiO3"``, ``"Chabrier:H"``). """ - eos_base = get_zalmoxis_eos_dir() - seager_dir = dataset_dir(EOS_SEAGER_2007, data_root=FWL_DATA_DIR) _seager_iron = {'eos_file': str(seager_dir / 'eos_seager07_iron.txt')} @@ -1237,7 +1244,7 @@ def load_zalmoxis_material_dictionaries(): _seager_water = {'eos_file': str(seager_dir / 'eos_seager07_water.txt')} # Wolf & Bower 2018 - wb_dir = eos_base / 'EOS_WolfBower2018_1TPa' + wb_dir = dataset_dir(EOS_WOLF_BOWER_2018, data_root=FWL_DATA_DIR) _wb_melted = { 'eos_file': str(wb_dir / 'density_melt.dat'), 'adiabat_grad_file': str(wb_dir / 'adiabat_temp_grad_melt.dat'), @@ -1245,7 +1252,7 @@ def load_zalmoxis_material_dictionaries(): _wb_solid = {'eos_file': str(wb_dir / 'density_solid.dat')} # RTPress 100 TPa - rt_dir = eos_base / 'EOS_RTPress_melt_100TPa' + rt_dir = dataset_dir(EOS_RTPRESS_100TPA, data_root=FWL_DATA_DIR) _rt_melted = { 'eos_file': str(rt_dir / 'density_melt.dat'), 'adiabat_grad_file': str(rt_dir / 'adiabat_temp_grad_melt.dat'), @@ -1253,7 +1260,8 @@ def load_zalmoxis_material_dictionaries(): # PALEOS 2-phase MgSiO3 (separate solid/liquid, Zenodo 19680050). # 150 pts/decade (default) and 600 pts/decade (highres) variants. - paleos2ph_dir = eos_base / 'EOS_PALEOS_MgSiO3' + paleos2ph_dir = dataset_dir(EOS_PALEOS_MGSIO3_2PHASE, data_root=FWL_DATA_DIR) + paleos2ph_hr_dir = dataset_dir(EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, data_root=FWL_DATA_DIR) _paleos2ph_melted = { 'eos_file': str(paleos2ph_dir / 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), 'format': 'paleos', @@ -1263,33 +1271,35 @@ def load_zalmoxis_material_dictionaries(): 'format': 'paleos', } _paleos2ph_melted_highres = { - 'eos_file': str(paleos2ph_dir / 'paleos_mgsio3_tables_pt_proteus_liquid_highres.dat'), + 'eos_file': str( + paleos2ph_hr_dir / 'paleos_mgsio3_tables_pt_proteus_liquid_highres.dat' + ), 'format': 'paleos', } _paleos2ph_solid_highres = { - 'eos_file': str(paleos2ph_dir / 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat'), + 'eos_file': str(paleos2ph_hr_dir / 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat'), 'format': 'paleos', } - # PALEOS unified tables + # PALEOS unified tables (iron, MgSiO3 and water share one dataset) + paleos_unified_dir = dataset_dir(EOS_PALEOS_UNIFIED, data_root=FWL_DATA_DIR) _paleos_iron = { - 'eos_file': str(eos_base / 'EOS_PALEOS_iron' / 'paleos_iron_eos_table_pt.dat'), + 'eos_file': str(paleos_unified_dir / 'paleos_iron_eos_table_pt.dat'), 'format': 'paleos_unified', } _paleos_mgsio3 = { - 'eos_file': str( - eos_base / 'EOS_PALEOS_MgSiO3_unified' / 'paleos_mgsio3_eos_table_pt.dat' - ), + 'eos_file': str(paleos_unified_dir / 'paleos_mgsio3_eos_table_pt.dat'), 'format': 'paleos_unified', } _paleos_h2o = { - 'eos_file': str(eos_base / 'EOS_PALEOS_H2O' / 'paleos_water_eos_table_pt.dat'), + 'eos_file': str(paleos_unified_dir / 'paleos_water_eos_table_pt.dat'), 'format': 'paleos_unified', } # Chabrier H/He + chabrier_dir = dataset_dir(EOS_CHABRIER_2021, data_root=FWL_DATA_DIR) _chabrier_h = { - 'eos_file': str(eos_base / 'EOS_Chabrier2021_HHe' / 'chabrier2021_H.dat'), + 'eos_file': str(chabrier_dir / 'EOS_Chabrier2021_HHe' / 'chabrier2021_H.dat'), 'format': 'paleos_unified', } diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index e9133ca8c..188da9509 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -483,26 +483,10 @@ def validate_zenodo_folder(zenodo_id: str, folder_dir: Path, hash_maxfilesize=10 # Stellar spectra - PHOENIX (OSF project: 8r2sw) 'PHOENIX': {'zenodo_id': '17674612', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, # The surface albedos, the Seager EOS tables, the exoplanet catalogue, the - # mass-radius relations and the solar, Named and MUSCLES stellar spectra are - # declared in src/proteus/data/proteus_manifest.toml and fetched through - # fwl-io, so their record pins live there and are absent here. - # Zalmoxis EOS: Wolf & Bower 2018 T-dependent MgSiO3 (1 TPa) - 'EOS_WolfBower2018_1TPa': {'zenodo_id': '17417017'}, - # Zalmoxis EOS: RTPress 100 TPa extended melt - 'EOS_RTPress_melt_100TPa': {'zenodo_id': '18819027'}, - # Zalmoxis EOS: PALEOS 2-phase MgSiO3 (separate solid/liquid). - # Zenodo 19680050: ecosystem-wide PALEOS reference; ships 150 + 600 - # pts/decade tables for both phases. - 'EOS_PALEOS_MgSiO3': {'zenodo_id': '19680050'}, - # Zalmoxis EOS: PALEOS unified tables (iron, MgSiO3, H2O share Zenodo - # 22776069, the v1.3.0 release of concept record 19000315). Each folder - # fetches only its own table file via the single-file download mode, so - # the high-res variants in the record are never pulled. - 'EOS_PALEOS_iron': {'zenodo_id': '22776069'}, - 'EOS_PALEOS_MgSiO3_unified': {'zenodo_id': '22776069'}, - 'EOS_PALEOS_H2O': {'zenodo_id': '22776069'}, - # Zalmoxis EOS: Chabrier+2019/2021 H/He - 'EOS_Chabrier2021_HHe': {'zenodo_id': '19135021'}, + # mass-radius relations, the solar, Named and MUSCLES stellar spectra and the + # Zalmoxis equations of state are declared in + # src/proteus/data/proteus_manifest.toml and fetched through fwl-io, so their + # record pins live there and are absent here. # Aerosol scattering data (no OSF project) 'scattering': {'zenodo_id': '19294180', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, } @@ -1890,98 +1874,6 @@ def download_Seager_EOS(): # downloads only the datasets required for the current run. # ───────────────────────────────────────────────────────────────────── -_ZALMOXIS_EOS_TARGET = 'zalmoxis_eos' - - -def _download_zalmoxis_folder(folder: str, file: str | None = None): - """Download a single Zalmoxis EOS folder/file from its DATA_SOURCE_MAP entry. - - Parameters - ---------- - folder : str - Folder key in DATA_SOURCE_MAP (e.g. ``'EOS_PALEOS_iron'``). - file : str or None - If given, download only this file from the Zenodo record. - """ - source_info = get_data_source_info(folder) - if not source_info: - log.warning(f'No data source mapping for Zalmoxis EOS folder: {folder}') - return - download( - folder=folder, - target=_ZALMOXIS_EOS_TARGET, - zenodo_id=source_info['zenodo_id'], - osf_id=source_info.get('osf_project'), - desc=f'Zalmoxis EOS: {folder}', - file=file, - ) - - -def _download_zalmoxis_chabrier(): - """Download and extract Chabrier H/He EOS tarball into FWL_DATA. - - The Zenodo record contains a ``.tar.gz`` with multiple H/He tables. - We download the full record and extract it, mirroring the Zalmoxis - setup script behavior. - """ - folder = 'EOS_Chabrier2021_HHe' - folder_dir = GetFWLData() / _ZALMOXIS_EOS_TARGET / folder - if folder_dir.exists() and any(folder_dir.iterdir()): - log.debug(f'Zalmoxis Chabrier EOS already present at {folder_dir}') - return - - source_info = get_data_source_info(folder) - if not source_info: - log.warning(f'No data source mapping for {folder}') - return - - log.info(f'Downloading Chabrier H/He EOS from Zenodo {source_info["zenodo_id"]}') - folder_dir.mkdir(parents=True, exist_ok=True) - - # Download the full Zenodo record, then keep only relevant .dat files - ok = download_zenodo_folder(source_info['zenodo_id'], folder_dir) - if not ok: - log.warning('Failed to download Chabrier H/He EOS from Zenodo') - return - - # If the record contains a tarball, extract it - import tarfile - - for tb in folder_dir.glob('*.tar.gz'): - with tarfile.open(tb, 'r:gz') as tar: - tar.extractall(path=folder_dir, filter='data') - tb.unlink() - - # Move files out of any nested subdirectory to the top level - for subdir in [d for d in folder_dir.iterdir() if d.is_dir()]: - if subdir.name == '__MACOSX': - shutil.rmtree(subdir) - continue - for item in subdir.iterdir(): - if item.name.startswith('._') or item.name == '.DS_Store': - continue - dest = folder_dir / item.name - if not dest.exists(): - shutil.move(str(item), folder_dir) - if subdir.exists(): - shutil.rmtree(subdir) - - # Clean up md5sums.txt if present - md5file = folder_dir / 'md5sums.txt' - if md5file.exists(): - md5file.unlink() - - # Validate that the expected EOS file was extracted - expected_file = folder_dir / 'chabrier2021_H.dat' - if not expected_file.exists(): - log.warning( - 'Post-extraction validation failed: %s not found in %s. Available files: %s', - expected_file.name, - folder_dir, - [f.name for f in folder_dir.iterdir()], - ) - - # Mantle EOS family prefixes whose registry entry carries the Seager iron # table as its core fallback (see # proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries). @@ -1998,11 +1890,20 @@ def _download_zalmoxis_chabrier(): ) +# PALEOS unified component -> table file inside the shared record. +_PALEOS_UNIFIED_TABLES = { + 'PALEOS:iron': 'paleos_iron_eos_table_pt.dat', + 'PALEOS:MgSiO3': 'paleos_mgsio3_eos_table_pt.dat', + 'PALEOS:H2O': 'paleos_water_eos_table_pt.dat', +} + + def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: str = ''): """Download Zalmoxis EOS data required for the given EOS configuration. Inspects the mantle, core, and ice layer EOS identifiers and downloads - only the datasets needed. All files land in ``FWL_DATA/zalmoxis_eos/``. + only the datasets needed. Each dataset lands in its own directory under + ``FWL_DATA/interior_struct/eos/`` (see ``proteus_manifest.toml``). Parameters ---------- @@ -2013,6 +1914,17 @@ def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: st ice_layer_eos : str Ice layer EOS identifier (e.g. ``'Seager2007:H2O'``, ``'PALEOS:H2O'``, or empty). """ + from proteus.data import ( + EOS_CHABRIER_2021, + EOS_PALEOS_MGSIO3_2PHASE, + EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, + EOS_PALEOS_UNIFIED, + EOS_RTPRESS_100TPA, + EOS_WOLF_BOWER_2018, + fetch_dataset, + fetch_dataset_file, + ) + all_eos = [e for e in (mantle_eos, core_eos, ice_layer_eos) if e] # Multi-component EOS strings: "PALEOS:MgSiO3:0.98+Chabrier:H:0.01" @@ -2035,76 +1947,35 @@ def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: st ): download_eos_static() - # WolfBower2018 T-dependent MgSiO3 - if any(c.startswith('WolfBower2018') for c in components): - _download_zalmoxis_folder( - 'EOS_WolfBower2018_1TPa', - file='density_melt.dat', - ) - _download_zalmoxis_folder( - 'EOS_WolfBower2018_1TPa', - file='density_solid.dat', - ) - _download_zalmoxis_folder( - 'EOS_WolfBower2018_1TPa', - file='adiabat_temp_grad_melt.dat', - ) + # WolfBower2018 T-dependent MgSiO3. The RTPress mantle pairs its melt table + # with the Wolf & Bower solid table, so that file is needed for both. + needs_wb = any(c.startswith('WolfBower2018') for c in components) + needs_rtpress = any(c.startswith('RTPress100TPa') for c in components) + if needs_wb: + fetch_dataset(EOS_WOLF_BOWER_2018, data_root=FWL_DATA_DIR) + elif needs_rtpress: + fetch_dataset_file(EOS_WOLF_BOWER_2018, 'density_solid.dat', data_root=FWL_DATA_DIR) # RTPress 100 TPa extended melt - if any(c.startswith('RTPress100TPa') for c in components): - _download_zalmoxis_folder( - 'EOS_RTPress_melt_100TPa', - file='density_melt.dat', - ) - _download_zalmoxis_folder( - 'EOS_RTPress_melt_100TPa', - file='adiabat_temp_grad_melt.dat', - ) + if needs_rtpress: + fetch_dataset(EOS_RTPRESS_100TPA, data_root=FWL_DATA_DIR) - # PALEOS 2-phase MgSiO3 (separate solid/liquid). The default - # entry uses 150 pts/decade tables; the -highres entry (Zenodo - # 19680050) uses 600 pts/decade. Both ship in the same Zenodo - # record; we fetch only what's selected to keep first-time setup - # fast (the highres pair is ~1.3 GB). + # PALEOS 2-phase MgSiO3 (separate solid/liquid). The default entry uses + # 150 pts/decade tables; the -highres entry uses 600 pts/decade. Each is its + # own dataset, so the highres pair (~1.3 GB) is fetched only when selected. if 'PALEOS-2phase:MgSiO3' in components: - _download_zalmoxis_folder( - 'EOS_PALEOS_MgSiO3', - file='paleos_mgsio3_tables_pt_proteus_liquid.dat', - ) - _download_zalmoxis_folder( - 'EOS_PALEOS_MgSiO3', - file='paleos_mgsio3_tables_pt_proteus_solid.dat', - ) + fetch_dataset(EOS_PALEOS_MGSIO3_2PHASE, data_root=FWL_DATA_DIR) if 'PALEOS-2phase:MgSiO3-highres' in components: - _download_zalmoxis_folder( - 'EOS_PALEOS_MgSiO3', - file='paleos_mgsio3_tables_pt_proteus_liquid_highres.dat', - ) - _download_zalmoxis_folder( - 'EOS_PALEOS_MgSiO3', - file='paleos_mgsio3_tables_pt_proteus_solid_highres.dat', - ) + fetch_dataset(EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, data_root=FWL_DATA_DIR) - # PALEOS unified tables - if 'PALEOS:iron' in components: - _download_zalmoxis_folder( - 'EOS_PALEOS_iron', - file='paleos_iron_eos_table_pt.dat', - ) - if 'PALEOS:MgSiO3' in components: - _download_zalmoxis_folder( - 'EOS_PALEOS_MgSiO3_unified', - file='paleos_mgsio3_eos_table_pt.dat', - ) - if 'PALEOS:H2O' in components: - _download_zalmoxis_folder( - 'EOS_PALEOS_H2O', - file='paleos_water_eos_table_pt.dat', - ) + # PALEOS unified tables share one record; fetch only the selected tables. + for component, table in _PALEOS_UNIFIED_TABLES.items(): + if component in components: + fetch_dataset_file(EOS_PALEOS_UNIFIED, table, data_root=FWL_DATA_DIR) # Chabrier H/He if any(c.startswith('Chabrier') for c in components): - _download_zalmoxis_chabrier() + fetch_dataset(EOS_CHABRIER_2021, data_root=FWL_DATA_DIR) # Defensive warn for any component key that no handler above recognised. # PALEOS-API:* and PALEOS-API-2phase:* are intentionally not downloaded @@ -2135,17 +2006,6 @@ def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: st ) -def get_zalmoxis_eos_dir() -> Path: - """Return the base directory for Zalmoxis EOS data in FWL_DATA. - - Returns - ------- - Path - ``FWL_DATA/zalmoxis_eos/`` - """ - return GetFWLData() / _ZALMOXIS_EOS_TARGET - - def load_melting_curve(melt_file): """ Loads melting curve data for MgSiO3 from a text file. diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index 18df9e1da..5d1257834 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -23,7 +23,13 @@ import pytest from proteus.data import ( + EOS_CHABRIER_2021, + EOS_PALEOS_MGSIO3_2PHASE, + EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, + EOS_PALEOS_UNIFIED, + EOS_RTPRESS_100TPA, EOS_SEAGER_2007, + EOS_WOLF_BOWER_2018, EXOPLANET_REFERENCE, FWL_IO_FLOOR, MASS_RADIUS_ZENG_2019, @@ -50,6 +56,27 @@ SOLAR_RECORD = '17981836' NAMED_RECORD = '15721440' MUSCLES_RECORD = '17802209' +WOLF_BOWER_RECORD = '17417017' +RTPRESS_RECORD = '18819027' +PALEOS_2PHASE_RECORD = '19680050' +PALEOS_UNIFIED_RECORD = '22776069' +CHABRIER_RECORD = '19135021' + +# Equation-of-state datasets: key -> (subdir, record). +EOS_DATASETS = { + EOS_WOLF_BOWER_2018: ('interior_struct/eos/wolf_bower_2018', WOLF_BOWER_RECORD), + EOS_RTPRESS_100TPA: ('interior_struct/eos/rtpress_100tpa', RTPRESS_RECORD), + EOS_PALEOS_MGSIO3_2PHASE: ( + 'interior_struct/eos/paleos_mgsio3_2phase', + PALEOS_2PHASE_RECORD, + ), + EOS_PALEOS_MGSIO3_2PHASE_HIGHRES: ( + 'interior_struct/eos/paleos_mgsio3_2phase_highres', + PALEOS_2PHASE_RECORD, + ), + EOS_PALEOS_UNIFIED: ('interior_struct/eos/paleos_unified', PALEOS_UNIFIED_RECORD), + EOS_CHABRIER_2021: ('interior_struct/eos/chabrier_2021', CHABRIER_RECORD), +} REPO_ROOT = Path(__file__).resolve().parents[2] @@ -78,6 +105,7 @@ STELLAR_SPECTRA_SOLAR, STELLAR_SPECTRA_NAMED, STELLAR_SPECTRA_MUSCLES, + *EOS_DATASETS, } | {spectral_file_key(group, bands) for group, bands in SPECTRAL_RECORDS} @@ -118,6 +146,9 @@ def test_manifest_declares_the_datasets(): ) assert datasets[EXOPLANET_REFERENCE].zenodo == f'10.5281/zenodo.{EXOPLANET_RECORD}' assert datasets[MASS_RADIUS_ZENG_2019].zenodo == f'10.5281/zenodo.{ZENG_2019_RECORD}' + for key, (subdir, record) in EOS_DATASETS.items(): + assert datasets[key].subdir == subdir + assert datasets[key].zenodo == f'10.5281/zenodo.{record}' # All are PROTEUS-owned, so "proteus" has to appear in required_by or # "fwl-io fetch proteus" would skip them. for ds in datasets.values(): @@ -138,6 +169,12 @@ def test_registries_pin_committed_checksums(): solar = _dataset(STELLAR_SPECTRA_SOLAR).registry() named = _dataset(STELLAR_SPECTRA_NAMED).registry() muscles = _dataset(STELLAR_SPECTRA_MUSCLES).registry() + wolf_bower = _dataset(EOS_WOLF_BOWER_2018).registry() + rtpress = _dataset(EOS_RTPRESS_100TPA).registry() + paleos_2phase = _dataset(EOS_PALEOS_MGSIO3_2PHASE).registry() + paleos_highres = _dataset(EOS_PALEOS_MGSIO3_2PHASE_HIGHRES).registry() + paleos_unified = _dataset(EOS_PALEOS_UNIFIED).registry() + chabrier = _dataset(EOS_CHABRIER_2021).registry() assert len(exo) == 1, 'the catalogue ships exactly one file' assert len(zeng) == 57, 'the Zeng-2019 grid ships 57 curve files' @@ -150,6 +187,30 @@ def test_registries_pin_committed_checksums(): assert len(solar) == 10, 'the solar record ships 10 spectra' assert len(named) == 11, 'the named-star record ships 11 spectra' assert len(muscles) == 38, 'the MUSCLES record ships 36 spectra, a readme and a table' + assert set(wolf_bower) == { + 'density_melt.dat', + 'density_solid.dat', + 'adiabat_temp_grad_melt.dat', + } + assert set(rtpress) == {'density_melt.dat', 'adiabat_temp_grad_melt.dat'} + assert set(paleos_2phase) == { + 'paleos_mgsio3_tables_pt_proteus_liquid.dat', + 'paleos_mgsio3_tables_pt_proteus_solid.dat', + } + assert set(paleos_highres) == { + 'paleos_mgsio3_tables_pt_proteus_liquid_highres.dat', + 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat', + } + # The unified record also holds high-resolution variants (2.29 GB in all); + # the registry lists only the three tables the Zalmoxis registry reads, so a + # whole-dataset fetch never pulls the rest. + assert set(paleos_unified) == { + 'paleos_iron_eos_table_pt.dat', + 'paleos_mgsio3_eos_table_pt.dat', + 'paleos_water_eos_table_pt.dat', + } + assert set(chabrier) == {'EOS_Chabrier2021_HHe.tar.gz'} + assert chabrier['EOS_Chabrier2021_HHe.tar.gz'] == 'md5:18ce96ed0526d4ade283807a7da2e091' assert solar['sun.txt'] == 'md5:6e4b6540d952cf3c01a3bd0511aa6c10' assert named['sun.txt'] == 'md5:0c5225b847ca250673edc9690f02e055' assert muscles['gj876.txt'] == 'md5:4b4e7299bad9545ee8fef82cb4c8d4d8' @@ -158,7 +219,21 @@ def test_registries_pin_committed_checksums(): assert exo['DACE_PlanetS.csv'] == 'md5:367a90914eba4a209f896a1c72dd3d2b' # Every entry must carry an algorithm prefix, or pooch cannot know what to # verify against; a bare digest would silently be read as the default. - for registry in (exo, zeng, hammond, seager, solar, named, muscles): + for registry in ( + exo, + zeng, + hammond, + seager, + solar, + named, + muscles, + wolf_bower, + rtpress, + paleos_2phase, + paleos_highres, + paleos_unified, + chabrier, + ): assert all(':' in digest for digest in registry.values()) assert 'massradiusEarthlikeRocky.txt' in zeng @@ -201,6 +276,8 @@ def test_dataset_dir_is_versioned(tmp_path): assert dataset_dir(EOS_SEAGER_2007, data_root=tmp_path) == ( tmp_path / 'interior_struct' / 'eos' / 'seager_2007' / f'r{SEAGER_2007_RECORD}' ) + for key, (subdir, record) in EOS_DATASETS.items(): + assert dataset_dir(key, data_root=tmp_path) == tmp_path / subdir / f'r{record}' assert dataset_dir(STELLAR_SPECTRA_SOLAR, data_root=tmp_path) == ( tmp_path / 'stellar_spectra' / 'solar' / f'r{SOLAR_RECORD}' ) @@ -445,6 +522,16 @@ def test_migrated_datasets_are_not_also_pinned_in_the_legacy_map(): assert 'Zeng2019' not in DATA_SOURCE_MAP assert 'Hammond24' not in DATA_SOURCE_MAP assert 'EOS_Seager2007' not in DATA_SOURCE_MAP + for legacy in ( + 'EOS_WolfBower2018_1TPa', + 'EOS_RTPress_melt_100TPa', + 'EOS_PALEOS_MgSiO3', + 'EOS_PALEOS_iron', + 'EOS_PALEOS_MgSiO3_unified', + 'EOS_PALEOS_H2O', + 'EOS_Chabrier2021_HHe', + ): + assert legacy not in DATA_SOURCE_MAP assert 'Population' not in DATA_SOURCE_MAP # Discrimination: the map is still populated for the datasets that have not # migrated, so an emptied map cannot make this pass. diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 6761fb313..e037bc816 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -2894,9 +2894,6 @@ def test_material_dictionaries_seager_paths_use_the_versioned_dataset_dir( from proteus.data import EOS_SEAGER_2007, dataset_dir monkeypatch.setattr(zalmoxis_wrapper, 'FWL_DATA_DIR', tmp_path) - monkeypatch.setattr( - zalmoxis_wrapper, 'get_zalmoxis_eos_dir', lambda: tmp_path / 'zalmoxis_eos' - ) registry = zalmoxis_wrapper.load_zalmoxis_material_dictionaries() @@ -2911,3 +2908,57 @@ def test_material_dictionaries_seager_paths_use_the_versioned_dataset_dir( seager / 'eos_seager07_water.txt' ) assert seager.name.startswith('r') + + +def test_material_dictionaries_mantle_paths_use_the_versioned_dataset_dirs( + monkeypatch, tmp_path +): + """Every mantle table resolves into its own fwl-io dataset directory.""" + import proteus.interior_struct.zalmoxis as zalmoxis_wrapper + from proteus.data import ( + EOS_PALEOS_MGSIO3_2PHASE, + EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, + EOS_RTPRESS_100TPA, + EOS_WOLF_BOWER_2018, + dataset_dir, + ) + + monkeypatch.setattr(zalmoxis_wrapper, 'FWL_DATA_DIR', tmp_path) + + registry = zalmoxis_wrapper.load_zalmoxis_material_dictionaries() + + wb = dataset_dir(EOS_WOLF_BOWER_2018, data_root=tmp_path) + rt = dataset_dir(EOS_RTPRESS_100TPA, data_root=tmp_path) + p2 = dataset_dir(EOS_PALEOS_MGSIO3_2PHASE, data_root=tmp_path) + p2hr = dataset_dir(EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, data_root=tmp_path) + + wolf = registry['WolfBower2018:MgSiO3'] + assert wolf['melted_mantle']['eos_file'] == str(wb / 'density_melt.dat') + assert wolf['solid_mantle']['eos_file'] == str(wb / 'density_solid.dat') + assert wolf['melted_mantle']['adiabat_grad_file'] == str(wb / 'adiabat_temp_grad_melt.dat') + + rtpress = registry['RTPress100TPa:MgSiO3'] + assert rtpress['melted_mantle']['eos_file'] == str(rt / 'density_melt.dat') + assert rtpress['melted_mantle']['adiabat_grad_file'] == str( + rt / 'adiabat_temp_grad_melt.dat' + ) + # RTPress takes its solid density from the Wolf and Bower dataset. + assert rtpress['solid_mantle']['eos_file'] == str(wb / 'density_solid.dat') + + two = registry['PALEOS-2phase:MgSiO3'] + assert two['melted_mantle']['eos_file'] == str( + p2 / 'paleos_mgsio3_tables_pt_proteus_liquid.dat' + ) + assert two['solid_mantle']['eos_file'] == str( + p2 / 'paleos_mgsio3_tables_pt_proteus_solid.dat' + ) + + high = registry['PALEOS-2phase:MgSiO3-highres'] + assert high['melted_mantle']['eos_file'] == str( + p2hr / 'paleos_mgsio3_tables_pt_proteus_liquid_highres.dat' + ) + assert high['solid_mantle']['eos_file'] == str( + p2hr / 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat' + ) + # The two PALEOS 2-phase sets share a record but not a directory. + assert p2 != p2hr diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 7b1973922..f4ce30b33 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -2705,65 +2705,170 @@ def test_get_sufficient_zalmoxis_paleos( # ============================================================================ +_UNIFIED_IRON = 'paleos_iron_eos_table_pt.dat' +_UNIFIED_MGSIO3 = 'paleos_mgsio3_eos_table_pt.dat' +_UNIFIED_WATER = 'paleos_water_eos_table_pt.dat' + + +def _fetched_datasets(mock_fetch): + """Dataset keys passed to a mocked ``fetch_dataset``, in call order.""" + return [c.args[0] for c in mock_fetch.call_args_list] + + +def _fetched_files(mock_file): + """``(dataset key, file name)`` pairs passed to a mocked ``fetch_dataset_file``.""" + return [(c.args[0], c.args[1]) for c in mock_file.call_args_list] + + @pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_seager(mock_static, mock_folder, mock_chabrier): +def test_download_zalmoxis_eos_seager(mock_static, mock_fetch, mock_file): """download_zalmoxis_eos for Seager2007 calls download_eos_static only.""" from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('Seager2007:MgSiO3', core_eos='Seager2007:iron') mock_static.assert_called_once() - mock_folder.assert_not_called() - mock_chabrier.assert_not_called() + mock_fetch.assert_not_called() + mock_file.assert_not_called() @pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_wolfbower(mock_static, mock_folder, mock_chabrier): - """download_zalmoxis_eos for WolfBower2018 downloads Seager + WB files.""" +def test_download_zalmoxis_eos_wolfbower(mock_static, mock_fetch, mock_file): + """WolfBower2018 fetches the Seager set and the whole Wolf and Bower dataset.""" + from proteus.data import EOS_WOLF_BOWER_2018 from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('WolfBower2018:MgSiO3', core_eos='Seager2007:iron') mock_static.assert_called_once() - # 3 calls for WB2018 files: density_melt, density_solid, adiabat_temp_grad_melt - wb_calls = [c for c in mock_folder.call_args_list if 'WolfBower2018' in str(c)] - assert len(wb_calls) == 3 - mock_chabrier.assert_not_called() + assert _fetched_datasets(mock_fetch) == [EOS_WOLF_BOWER_2018] + mock_file.assert_not_called() + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_rtpress(mock_static, mock_fetch, mock_file): + """RTPress100TPa fetches its own dataset plus the Wolf and Bower solid density only.""" + from proteus.data import EOS_RTPRESS_100TPA, EOS_WOLF_BOWER_2018 + from proteus.utils.data import download_zalmoxis_eos + + download_zalmoxis_eos('RTPress100TPa:MgSiO3', core_eos='Seager2007:iron') + + mock_static.assert_called_once() + assert _fetched_datasets(mock_fetch) == [EOS_RTPRESS_100TPA] + # The RTPress registry entry reads its solid density from the Wolf and + # Bower dataset; the whole dataset must not be pulled for that one file. + assert _fetched_files(mock_file) == [(EOS_WOLF_BOWER_2018, 'density_solid.dat')] + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_rtpress_with_wolfbower_fetches_whole_dataset( + mock_static, mock_fetch, mock_file +): + """When Wolf and Bower is selected too, its whole dataset covers the solid file.""" + from proteus.data import EOS_RTPRESS_100TPA, EOS_WOLF_BOWER_2018 + from proteus.utils.data import download_zalmoxis_eos + + download_zalmoxis_eos( + 'WolfBower2018:MgSiO3+RTPress100TPa:MgSiO3', core_eos='Seager2007:iron' + ) + + assert sorted(_fetched_datasets(mock_fetch)) == sorted( + [EOS_WOLF_BOWER_2018, EOS_RTPRESS_100TPA] + ) + mock_file.assert_not_called() + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_fetch, mock_file): + """PALEOS-2phase:MgSiO3 fetches the standard-resolution dataset only.""" + from proteus.data import EOS_PALEOS_MGSIO3_2PHASE + from proteus.utils.data import download_zalmoxis_eos + + download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='Seager2007:iron') + + assert _fetched_datasets(mock_fetch) == [EOS_PALEOS_MGSIO3_2PHASE] + mock_file.assert_not_called() + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_paleos_2phase_highres(mock_static, mock_fetch, mock_file): + """PALEOS-2phase:MgSiO3-highres fetches the high-resolution dataset only.""" + from proteus.data import EOS_PALEOS_MGSIO3_2PHASE_HIGHRES + from proteus.utils.data import download_zalmoxis_eos + + download_zalmoxis_eos('PALEOS-2phase:MgSiO3-highres', core_eos='Seager2007:iron') + + assert _fetched_datasets(mock_fetch) == [EOS_PALEOS_MGSIO3_2PHASE_HIGHRES] + mock_file.assert_not_called() @pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_paleos_unified(mock_static, mock_folder, mock_chabrier): - """download_zalmoxis_eos for PALEOS unified downloads the right tables.""" +def test_download_zalmoxis_eos_paleos_unified(mock_static, mock_fetch, mock_file): + """PALEOS unified fetches its three tables one file at a time.""" + from proteus.data import EOS_PALEOS_UNIFIED from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS:MgSiO3', core_eos='PALEOS:iron', ice_layer_eos='PALEOS:H2O') # Seager not needed (no Seager component, core_eos is set) mock_static.assert_not_called() - # 3 PALEOS unified files: iron, MgSiO3, H2O - assert mock_folder.call_count == 3 - folders = [str(c) for c in mock_folder.call_args_list] - assert any('PALEOS_iron' in f for f in folders) - assert any('PALEOS_MgSiO3_unified' in f for f in folders) - assert any('PALEOS_H2O' in f for f in folders) - mock_chabrier.assert_not_called() + # A whole-dataset fetch would pull the full 2.29 GB record. + mock_fetch.assert_not_called() + assert sorted(_fetched_files(mock_file)) == sorted( + [ + (EOS_PALEOS_UNIFIED, _UNIFIED_IRON), + (EOS_PALEOS_UNIFIED, _UNIFIED_MGSIO3), + (EOS_PALEOS_UNIFIED, _UNIFIED_WATER), + ] + ) @pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_paleos_unified_only_selected_tables( + mock_static, mock_fetch, mock_file +): + """Only the selected unified tables are fetched, not the whole record.""" + from proteus.data import EOS_PALEOS_UNIFIED + from proteus.utils.data import download_zalmoxis_eos + + download_zalmoxis_eos('PALEOS:MgSiO3', core_eos='PALEOS:iron') + + assert sorted(_fetched_files(mock_file)) == sorted( + [(EOS_PALEOS_UNIFIED, _UNIFIED_IRON), (EOS_PALEOS_UNIFIED, _UNIFIED_MGSIO3)] + ) + mock_fetch.assert_not_called() + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_paleos_2phase_fetches_seager_fallback( - mock_static, mock_folder, mock_chabrier + mock_static, mock_fetch, mock_file ): """PALEOS-2phase with a PALEOS core still fetches the Seager static set. @@ -2773,21 +2878,18 @@ def test_download_zalmoxis_eos_paleos_2phase_fetches_seager_fallback( fallback even though neither the mantle nor the core names Seager. A fetch that skips it leaves a fresh install failing the existence check on its first run of the Earth tutorial config (PALEOS-2phase:MgSiO3 - mantle, PALEOS:iron core). The 2-phase sub-tables and the unified iron + mantle, PALEOS:iron core). The 2-phase tables and the unified iron table must download alongside. """ + from proteus.data import EOS_PALEOS_MGSIO3_2PHASE, EOS_PALEOS_UNIFIED from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='PALEOS:iron') mock_static.assert_called_once() - folders = [str(c) for c in mock_folder.call_args_list] - assert any('paleos_mgsio3_tables_pt_proteus_solid.dat' in f for f in folders) - assert any('paleos_mgsio3_tables_pt_proteus_liquid.dat' in f for f in folders) - assert any('PALEOS_iron' in f for f in folders) # The standard-resolution selection must not pull the ~1.3 GB highres pair. - assert not any('highres' in f for f in folders) - mock_chabrier.assert_not_called() + assert _fetched_datasets(mock_fetch) == [EOS_PALEOS_MGSIO3_2PHASE] + assert _fetched_files(mock_file) == [(EOS_PALEOS_UNIFIED, _UNIFIED_IRON)] @pytest.mark.unit @@ -2800,11 +2902,11 @@ def test_download_zalmoxis_eos_paleos_2phase_fetches_seager_fallback( 'PALEOS-API-2phase:MgSiO3', ], ) -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_seager_fallback_every_family( - mock_static, mock_folder, mock_chabrier, mantle_component + mock_static, mock_fetch, mock_file, mantle_component ): """Every family with a Seager core fallback fetches the static set. @@ -2816,14 +2918,97 @@ def test_download_zalmoxis_eos_seager_fallback_every_family( iron core must download its own table alongside, and the Chabrier fetch must stay untouched. """ + from proteus.data import EOS_CHABRIER_2021, EOS_PALEOS_UNIFIED from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos(mantle_component, core_eos='PALEOS:iron') mock_static.assert_called_once() - folders = [str(c) for c in mock_folder.call_args_list] - assert any('PALEOS_iron' in f for f in folders) - mock_chabrier.assert_not_called() + assert (EOS_PALEOS_UNIFIED, _UNIFIED_IRON) in _fetched_files(mock_file) + assert EOS_CHABRIER_2021 not in _fetched_datasets(mock_fetch) + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_api_2phase_fetches_only_seager( + mock_static, mock_fetch, mock_file +): + """PALEOS-API-2phase tabulates live but still needs the Seager fallback. + + The API-backed 2-phase mantle generates its own tables on demand, so no + dataset fetch may fire for it, yet its registry entry carries the + Seager iron core fallback whose file the existence check requires. The + limit case of the fallback rule: the static set is the only download. + """ + from proteus.utils.data import download_zalmoxis_eos + + download_zalmoxis_eos('PALEOS-API-2phase:MgSiO3', core_eos='PALEOS-API:iron') + + mock_static.assert_called_once() + mock_fetch.assert_not_called() + mock_file.assert_not_called() + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_multi_component(mock_static, mock_fetch, mock_file): + """download_zalmoxis_eos handles multi-component EOS strings.""" + from proteus.data import EOS_CHABRIER_2021, EOS_PALEOS_UNIFIED + from proteus.utils.data import download_zalmoxis_eos + + # Composite mantle with PALEOS + Chabrier + download_zalmoxis_eos( + 'PALEOS:MgSiO3:0.98+Chabrier:H:0.01+PALEOS:H2O:0.01', + core_eos='Seager2007:iron', + ) + + mock_static.assert_called_once() + assert _fetched_datasets(mock_fetch) == [EOS_CHABRIER_2021] + assert sorted(_fetched_files(mock_file)) == sorted( + [(EOS_PALEOS_UNIFIED, _UNIFIED_MGSIO3), (EOS_PALEOS_UNIFIED, _UNIFIED_WATER)] + ) + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_unknown_component_warns( + mock_static, mock_fetch, mock_file, caplog +): + """An unknown EOS family triggers a warning but no exception.""" + from proteus.utils.data import download_zalmoxis_eos + + with caplog.at_level('WARNING'): + download_zalmoxis_eos('UnknownFamily:Foo', core_eos='Seager2007:iron') + + warnings = [r for r in caplog.records if 'no handler' in r.getMessage()] + # Discrimination: at least one warning was emitted for the unknown + # component, and nothing was fetched for it. + assert len(warnings) >= 1 + assert any('UnknownFamily' in w.getMessage() for w in warnings) + mock_fetch.assert_not_called() + mock_file.assert_not_called() + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_paleos_api_no_download(mock_static, mock_fetch, mock_file): + """PALEOS-API:* and PALEOS-API-2phase:* are valid but trigger no dataset fetch.""" + from proteus.utils.data import download_zalmoxis_eos + + download_zalmoxis_eos('PALEOS-API:MgSiO3+PALEOS-API-2phase:H2O', core_eos='Seager2007:iron') + + # Seager static (always for core fallback) is the only download. + mock_static.assert_called_once() + mock_fetch.assert_not_called() + mock_file.assert_not_called() @pytest.mark.unit @@ -2870,50 +3055,6 @@ def test_seager_fallback_families_match_registry(): assert expected == set(SEAGER_FALLBACK_FAMILIES) -@pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') -@patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_api_2phase_fetches_only_seager( - mock_static, mock_folder, mock_chabrier -): - """PALEOS-API-2phase tabulates live but still needs the Seager fallback. - - The API-backed 2-phase mantle generates its own tables on demand, so no - folder download may fire for it, yet its registry entry carries the - Seager iron core fallback whose file the existence check requires. The - limit case of the fallback rule: the static set is the only download. - """ - from proteus.utils.data import download_zalmoxis_eos - - download_zalmoxis_eos('PALEOS-API-2phase:MgSiO3', core_eos='PALEOS-API:iron') - - mock_static.assert_called_once() - mock_folder.assert_not_called() - mock_chabrier.assert_not_called() - - -@pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') -@patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_multi_component(mock_static, mock_folder, mock_chabrier): - """download_zalmoxis_eos handles multi-component EOS strings.""" - from proteus.utils.data import download_zalmoxis_eos - - # Composite mantle with PALEOS + Chabrier - download_zalmoxis_eos( - 'PALEOS:MgSiO3:0.98+Chabrier:H:0.01+PALEOS:H2O:0.01', - core_eos='Seager2007:iron', - ) - - mock_static.assert_called_once() - mock_chabrier.assert_called_once() - folders = [str(c) for c in mock_folder.call_args_list] - assert any('PALEOS_MgSiO3_unified' in f for f in folders) - assert any('PALEOS_H2O' in f for f in folders) - - # ============================================================================ # test download_eos_dynamic / download_eos_static # ============================================================================ @@ -5161,241 +5302,6 @@ def test_get_zalmoxis_melting_curves_returns_two_interpolators(monkeypatch, tmp_ assert s_val < l_val -# ============================================================================ -# get_zalmoxis_eos_dir -# ============================================================================ - - -@pytest.mark.unit -def test_get_zalmoxis_eos_dir_returns_fwl_data_subpath(monkeypatch, tmp_path): - """get_zalmoxis_eos_dir returns FWL_DATA / zalmoxis_eos.""" - import proteus.utils.data as data_mod - from proteus.utils.data import get_zalmoxis_eos_dir - - monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - monkeypatch.setattr(data_mod, 'GetFWLData', lambda: tmp_path) - - result = get_zalmoxis_eos_dir() - assert result == tmp_path / 'zalmoxis_eos' - # Discrimination: the returned path must end in zalmoxis_eos exactly - # (not 'zalmoxis_eos_dir' or some variant). - assert result.name == 'zalmoxis_eos' - - -# ============================================================================ -# _download_zalmoxis_chabrier coverage -# ============================================================================ - - -@pytest.mark.unit -def test_download_zalmoxis_chabrier_already_present_returns_early(monkeypatch, tmp_path): - """When the Chabrier folder already exists with content, the helper returns immediately.""" - import proteus.utils.data as data_mod - from proteus.utils.data import _download_zalmoxis_chabrier - - monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - monkeypatch.setattr(data_mod, 'GetFWLData', lambda: tmp_path) - - folder_dir = tmp_path / 'zalmoxis_eos' / 'EOS_Chabrier2021_HHe' - folder_dir.mkdir(parents=True, exist_ok=True) - (folder_dir / 'placeholder.dat').write_text('data') - - download_calls = [] - monkeypatch.setattr( - data_mod, 'download_zenodo_folder', lambda *a, **k: download_calls.append(a) - ) - - _download_zalmoxis_chabrier() - - # Discrimination: no download attempt was made because folder existed - # with files. - assert download_calls == [] - # The placeholder remains intact. - assert (folder_dir / 'placeholder.dat').exists() - - -@pytest.mark.unit -def test_download_zalmoxis_chabrier_zenodo_failure_returns(monkeypatch, tmp_path): - """When download_zenodo_folder returns False, the helper warns and returns.""" - import proteus.utils.data as data_mod - from proteus.utils.data import _download_zalmoxis_chabrier - - monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - monkeypatch.setattr(data_mod, 'GetFWLData', lambda: tmp_path) - - monkeypatch.setattr(data_mod, 'download_zenodo_folder', lambda *a, **k: False) - - _download_zalmoxis_chabrier() - - folder_dir = tmp_path / 'zalmoxis_eos' / 'EOS_Chabrier2021_HHe' - # Discrimination: folder was created (mkdir line ran) before the - # download attempt, but no contents extracted. - assert folder_dir.exists() - assert list(folder_dir.iterdir()) == [] - - -@pytest.mark.unit -def test_download_zalmoxis_chabrier_extracts_tarball(monkeypatch, tmp_path): - """The helper extracts a downloaded .tar.gz, moves contents up, and cleans up.""" - import tarfile - - import proteus.utils.data as data_mod - from proteus.utils.data import _download_zalmoxis_chabrier - - monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - monkeypatch.setattr(data_mod, 'GetFWLData', lambda: tmp_path) - - folder_dir = tmp_path / 'zalmoxis_eos' / 'EOS_Chabrier2021_HHe' - - def fake_download(zenodo_id, target): - target.mkdir(parents=True, exist_ok=True) - # Build a tarball containing a nested subdir - inner_dir = target / 'inner_temp' - inner_dir.mkdir(parents=True, exist_ok=True) - (inner_dir / 'chabrier2021_H.dat').write_text('chabrier data') - tarball = target / 'chabrier.tar.gz' - with tarfile.open(tarball, 'w:gz') as tar: - tar.add(inner_dir, arcname='inner_dir') - # Remove the inner_temp dir; the test simulates the actual - # tar-only contents. - import shutil - - shutil.rmtree(inner_dir) - # Also write an md5sums.txt that should be cleaned up - (target / 'md5sums.txt').write_text('hash file.dat\n') - return True - - monkeypatch.setattr(data_mod, 'download_zenodo_folder', fake_download) - - _download_zalmoxis_chabrier() - - # The expected file landed at the top level (after move from subdir) - assert (folder_dir / 'chabrier2021_H.dat').exists() - # Discrimination: the md5sums.txt was cleaned up, and no tarball - # remains. - assert not (folder_dir / 'md5sums.txt').exists() - assert list(folder_dir.glob('*.tar.gz')) == [] - - -# ============================================================================ -# download_zalmoxis_eos additional dispatch branches -# ============================================================================ - - -@pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') -@patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_rtpress(mock_static, mock_folder, mock_chabrier): - """RTPress100TPa selects density_melt + adiabat_temp_grad_melt files.""" - from proteus.utils.data import download_zalmoxis_eos - - download_zalmoxis_eos('RTPress100TPa:MgSiO3', core_eos='Seager2007:iron') - - mock_static.assert_called_once() - rt_calls = [c for c in mock_folder.call_args_list if 'RTPress' in str(c)] - # Discrimination: exactly TWO RTPress files (density melt and - # adiabat grad melt) must have been requested; a regression that - # only fetched one would break this pin. - assert len(rt_calls) == 2 - - -@pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') -@patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_folder, mock_chabrier): - """PALEOS-2phase:MgSiO3 selects the liquid + solid tables.""" - from proteus.utils.data import download_zalmoxis_eos - - download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='Seager2007:iron') - - p2_calls = [c for c in mock_folder.call_args_list if 'PALEOS_MgSiO3' in str(c)] - # Discrimination: exactly two files (liquid + solid). A regression - # that downloaded only one would fail this assertion. - assert len(p2_calls) == 2 - files = [c.kwargs.get('file', '') for c in p2_calls] - assert any('liquid' in f for f in files) - assert any('solid' in f for f in files) - - -@pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') -@patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_paleos_2phase_highres(mock_static, mock_folder, mock_chabrier): - """PALEOS-2phase:MgSiO3-highres selects the highres liquid + solid tables.""" - from proteus.utils.data import download_zalmoxis_eos - - download_zalmoxis_eos('PALEOS-2phase:MgSiO3-highres', core_eos='Seager2007:iron') - - p2_calls = [c for c in mock_folder.call_args_list if 'PALEOS_MgSiO3' in str(c)] - assert len(p2_calls) == 2 - files = [c.kwargs.get('file', '') for c in p2_calls] - # Discrimination: the file names must contain 'highres' suffix. - assert all('highres' in f for f in files) - - -@pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') -@patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_unknown_component_warns( - mock_static, mock_folder, mock_chabrier, caplog -): - """An unknown EOS family triggers a warning but no exception.""" - from proteus.utils.data import download_zalmoxis_eos - - with caplog.at_level('WARNING'): - download_zalmoxis_eos('UnknownFamily:Foo', core_eos='Seager2007:iron') - - warnings = [r for r in caplog.records if 'no handler' in r.getMessage()] - # Discrimination: at least one warning was emitted for the unknown - # component, and no folder-download was queued for it. - assert len(warnings) >= 1 - no_handler_messages = [w.getMessage() for w in warnings] - assert any('UnknownFamily' in m for m in no_handler_messages) - - -@pytest.mark.unit -@patch('proteus.utils.data._download_zalmoxis_chabrier') -@patch('proteus.utils.data._download_zalmoxis_folder') -@patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_paleos_api_no_download(mock_static, mock_folder, mock_chabrier): - """PALEOS-API:* and PALEOS-API-2phase:* are valid but trigger no downloads.""" - from proteus.utils.data import download_zalmoxis_eos - - download_zalmoxis_eos('PALEOS-API:MgSiO3+PALEOS-API-2phase:H2O', core_eos='Seager2007:iron') - - # Discrimination: Seager static (always for core fallback) is the - # only download; PALEOS-API prefixes are NOT routed to the folder - # helper. - mock_static.assert_called_once() - assert mock_folder.call_count == 0 - - -# ============================================================================ -# _download_zalmoxis_folder unmapped folder warns -# ============================================================================ - - -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.get_data_source_info', return_value=None) -def test_download_zalmoxis_folder_no_mapping_warns(mock_info, mock_dl, caplog): - """_download_zalmoxis_folder warns when the source mapping is missing.""" - from proteus.utils.data import _download_zalmoxis_folder - - with caplog.at_level('WARNING'): - _download_zalmoxis_folder('UnknownEOS') - - # Discrimination: download() was NOT called; the function warned and - # returned. - mock_dl.assert_not_called() - messages = ' '.join(r.getMessage() for r in caplog.records) - assert 'No data source mapping' in messages - - # ============================================================================ # download() further branches: no Zenodo, OSF cleanup of empty folder # ============================================================================ @@ -5817,48 +5723,6 @@ def test_get_socrates_uses_none_dirs_when_not_given(mock_run, tmp_path, monkeypa assert cmd[0].startswith(str(tmp_path / 'tools')) -# ============================================================================ -# _download_zalmoxis_chabrier: __MACOSX and dotfile branches -# ============================================================================ - - -@pytest.mark.unit -def test_download_zalmoxis_chabrier_removes_macosx_and_dotfiles(monkeypatch, tmp_path): - """The helper removes __MACOSX subdirs and skips ._/.DS_Store entries.""" - import proteus.utils.data as data_mod - from proteus.utils.data import _download_zalmoxis_chabrier - - monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - monkeypatch.setattr(data_mod, 'GetFWLData', lambda: tmp_path) - - folder_dir = tmp_path / 'zalmoxis_eos' / 'EOS_Chabrier2021_HHe' - - def fake_download(zenodo_id, target): - target.mkdir(parents=True, exist_ok=True) - # Create a __MACOSX subdir at the top level that must be removed - macosx = target / '__MACOSX' - macosx.mkdir(parents=True, exist_ok=True) - (macosx / 'junk.dat').write_text('mac junk') - # Create a normal subdir with a dotfile and a real file - inner = target / 'inner' - inner.mkdir(parents=True, exist_ok=True) - (inner / 'chabrier2021_H.dat').write_text('real data') - (inner / '._hidden').write_text('apple metadata') - (inner / '.DS_Store').write_text('finder') - return True - - monkeypatch.setattr(data_mod, 'download_zenodo_folder', fake_download) - - _download_zalmoxis_chabrier() - - # Discrimination: __MACOSX was removed; only the real chabrier file - # survived at the top level. The dotfiles did NOT make it up. - assert not (folder_dir / '__MACOSX').exists() - assert (folder_dir / 'chabrier2021_H.dat').read_text() == 'real data' - assert not (folder_dir / '._hidden').exists() - assert not (folder_dir / '.DS_Store').exists() - - # ============================================================================ # download_stellar_tracks OSF inner-loop exception caught # ============================================================================ From 7c0f650a29fb4db8c246bdc1c766bd46e5fcb492 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Tue, 22 Sep 2026 02:11:52 +0200 Subject: [PATCH 06/76] Fetch PHOENIX stellar spectra through fwl-io The PHOENIX grid moves from the legacy record downloader into the manifest as stellar_spectra.phoenix. The dataset registry lists all 52 archives of the Zenodo record, one per metallicity and alpha enhancement, and the files land in $FWL_DATA/stellar_spectra/phoenix/r17674612/. download_phoenix asks fwl-io for the single archive of the requested grid, verifies it against the registry checksum, and unpacks it into FeH_alpha/ beside the archive. The other archives of the 9.2 GB record are not downloaded, and the alpha override at run time fetches its own archive on demand. The archive is kept after unpacking so that the fwl-io dataset check finds every registry file present. The PHOENIX entry is removed from DATA_SOURCE_MAP. Copies under $FWL_DATA/stellar_spectra/PHOENIX are no longer read, so an existing installation fetches the grid it needs again on the next run. --- docs/Reference/data.md | 9 +- src/proteus/data/__init__.py | 1 + src/proteus/data/proteus_manifest.toml | 5 + .../data/stellar_spectra.phoenix.registry.txt | 52 ++ src/proteus/star/phoenix.py | 7 +- src/proteus/utils/data.py | 110 ++-- tests/data/test_download_robustness.py | 2 +- tests/data/test_manifest.py | 12 +- tests/star/test_stellar_spectra.py | 17 +- tests/utils/test_data.py | 511 +++++++----------- 10 files changed, 329 insertions(+), 397 deletions(-) create mode 100644 src/proteus/data/stellar_spectra.phoenix.registry.txt diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 0159b3616..60fcc5dd3 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -29,7 +29,7 @@ used with lower rate limits. | Dataset | Provisioned by | Downloaded by | |---|---|---| | Stellar spectra (solar, named stars, MUSCLES) | fwl-io | `proteus get solar`, `proteus get muscles` | -| PHOENIX synthetic spectra | PROTEUS downloader | `proteus get phoenix` | +| PHOENIX synthetic spectra | fwl-io | `proteus get phoenix`, or fetched when a run needs a grid | | Stellar evolution tracks | PROTEUS downloader | `proteus get stellar` | | Spectral k-tables | fwl-io | `proteus get spectral` | | Surface albedos | fwl-io | `proteus get surfaces` | @@ -125,8 +125,11 @@ stored under `$FWL_DATA/stellar_spectra/muscles/r/` as `.txt` ### PHOENIX synthetic spectra Med-resolution synthetic spectra from the PHOENIX library, stored under -`$FWL_DATA/stellar_spectra/PHOENIX/_/`. Each subdirectory -corresponds to one metallicity–alpha combination (e.g. `FeH-0.5_alpha+0.0/`). +`$FWL_DATA/stellar_spectra/phoenix/r17674612/FeH_alpha/`. Each +subdirectory corresponds to one metallicity-alpha combination +(e.g. `FeH-0.5_alpha+0.0/`). The Zenodo record holds one zip archive per +combination. PROTEUS fetches only the archive that a run needs, checks it +against the committed checksum, and unpacks it next to the archive. Parameters defining the PHOENIX grid: diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index 8368c8dd2..caede8780 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -34,6 +34,7 @@ STELLAR_SPECTRA_SOLAR = 'stellar_spectra.solar' STELLAR_SPECTRA_NAMED = 'stellar_spectra.named' STELLAR_SPECTRA_MUSCLES = 'stellar_spectra.muscles' +STELLAR_SPECTRA_PHOENIX = 'stellar_spectra.phoenix' def spectral_file_key(group: str, bands: str | int) -> str: diff --git a/src/proteus/data/proteus_manifest.toml b/src/proteus/data/proteus_manifest.toml index 28af8e56e..0af34e121 100644 --- a/src/proteus/data/proteus_manifest.toml +++ b/src/proteus/data/proteus_manifest.toml @@ -110,6 +110,11 @@ name = "MUSCLES observed stellar spectra" zenodo = "10.5281/zenodo.17802209" required_by = ["proteus"] +[stellar_spectra.phoenix] +name = "PHOENIX synthetic stellar spectra grid, one archive per [Fe/H] and [alpha/M]" +zenodo = "10.5281/zenodo.17674612" +required_by = ["proteus"] + [interior_struct.eos.wolf_bower_2018] name = "Wolf & Bower (2018) MgSiO3 equation of state to 1 TPa, Zalmoxis tables" zenodo = "10.5281/zenodo.17417017" diff --git a/src/proteus/data/stellar_spectra.phoenix.registry.txt b/src/proteus/data/stellar_spectra.phoenix.registry.txt new file mode 100644 index 000000000..d92e4561a --- /dev/null +++ b/src/proteus/data/stellar_spectra.phoenix.registry.txt @@ -0,0 +1,52 @@ +FeH+0.5_alpha+0.0_phoenixMedRes_R05000.zip md5:267fb222ab42c9cbaf216b15a273909a +FeH+1.0_alpha+0.0_phoenixMedRes_R05000.zip md5:eb4feee5305e954c587557ad030e9b28 +FeH-0.0_alpha+0.0_phoenixMedRes_R05000.zip md5:9998a2a4f967c874477dafb147f58ce4 +FeH-0.0_alpha+0.2_phoenixMedRes_R05000.zip md5:46f135023f867557d78615a3dde93a4c +FeH-0.0_alpha+0.4_phoenixMedRes_R05000.zip md5:e2f0ad75f29f3a03b9b483b8f03d487c +FeH-0.0_alpha+0.6_phoenixMedRes_R05000.zip md5:f5c84b282ad7bf846e84bc64188c37e6 +FeH-0.0_alpha+0.8_phoenixMedRes_R05000.zip md5:2e6fd541c5dfc583a1049ee01853ef2e +FeH-0.0_alpha+1.0_phoenixMedRes_R05000.zip md5:bde09a8857db04212d6fa9c21ac15e6d +FeH-0.0_alpha+1.2_phoenixMedRes_R05000.zip md5:04d650920ce75eeafd2b5db0e5197490 +FeH-0.0_alpha-0.2_phoenixMedRes_R05000.zip md5:9d4e3650991cee5a7bb4c8530f5b302b +FeH-0.5_alpha+0.0_phoenixMedRes_R05000.zip md5:f0f1c881b613938bb0f08eb5f574cfd8 +FeH-0.5_alpha+0.2_phoenixMedRes_R05000.zip md5:130f0fc79b5f7fc6802d9d88c4d752c3 +FeH-0.5_alpha+0.4_phoenixMedRes_R05000.zip md5:6cca7463922b06a64efc0c24dc0ed3ac +FeH-0.5_alpha+0.6_phoenixMedRes_R05000.zip md5:d39d5d38ef6de2f5a4c8f1dcc8c3a1a8 +FeH-0.5_alpha+0.8_phoenixMedRes_R05000.zip md5:36477b3ea02dc8dc49423a5318a0f3ec +FeH-0.5_alpha+1.0_phoenixMedRes_R05000.zip md5:1412e1e76953f304992364d28a29e36f +FeH-0.5_alpha+1.2_phoenixMedRes_R05000.zip md5:000537469c30ba64d1f5f6cd3a1cc0db +FeH-0.5_alpha-0.2_phoenixMedRes_R05000.zip md5:fa6849d70297a77f7b7da8fda2d8ed5f +FeH-1.0_alpha+0.0_phoenixMedRes_R05000.zip md5:8338b175049696b43a3bd2bd59534701 +FeH-1.0_alpha+0.2_phoenixMedRes_R05000.zip md5:b70c3849a0a762e06e992c1ab5bc6231 +FeH-1.0_alpha+0.4_phoenixMedRes_R05000.zip md5:60bf19e33c10ecac6c772e24f959d8b3 +FeH-1.0_alpha+0.6_phoenixMedRes_R05000.zip md5:0b877f98f121a6b0ff95c01882431092 +FeH-1.0_alpha+0.8_phoenixMedRes_R05000.zip md5:6a639338ed0ec811f8a2edff954784b2 +FeH-1.0_alpha+1.0_phoenixMedRes_R05000.zip md5:6ec69d31387045dc7de9fae1b1fec7e0 +FeH-1.0_alpha+1.2_phoenixMedRes_R05000.zip md5:668c627ee83e4efb3536e16514ace0fa +FeH-1.0_alpha-0.2_phoenixMedRes_R05000.zip md5:d3ea40f56c48eda37eef81234feb9d90 +FeH-1.5_alpha+0.0_phoenixMedRes_R05000.zip md5:50ab1850d36fdec1c4b5ddd91ab3b82d +FeH-1.5_alpha+0.2_phoenixMedRes_R05000.zip md5:ee9807cd5c2658bf5e78e4d2752d1f91 +FeH-1.5_alpha+0.4_phoenixMedRes_R05000.zip md5:f9d2e8ab1ad48846cb6be871a24e2d77 +FeH-1.5_alpha+0.6_phoenixMedRes_R05000.zip md5:53d6d88329b81079045d101011e72388 +FeH-1.5_alpha+0.8_phoenixMedRes_R05000.zip md5:ca94b30ad66232ea710353317215446f +FeH-1.5_alpha+1.0_phoenixMedRes_R05000.zip md5:474d35391357f0814713a8a1c5193698 +FeH-1.5_alpha+1.2_phoenixMedRes_R05000.zip md5:e0153e003faa87413579d0f09452e079 +FeH-1.5_alpha-0.2_phoenixMedRes_R05000.zip md5:f5b11bc118ac5057e3a41a3e4620f59d +FeH-2.0_alpha+0.0_phoenixMedRes_R05000.zip md5:81c1ebf5046db3a5c3c407920e0cd456 +FeH-2.0_alpha+0.2_phoenixMedRes_R05000.zip md5:ba1863704fa6dded8f004870d1dd82da +FeH-2.0_alpha+0.4_phoenixMedRes_R05000.zip md5:addd5dd023165f0773b490c4c2148d24 +FeH-2.0_alpha+0.6_phoenixMedRes_R05000.zip md5:7effc1f21f2b8c94256d61c1524bcf9c +FeH-2.0_alpha+0.8_phoenixMedRes_R05000.zip md5:c85bc80c46a5cd70887bedf1099f32ce +FeH-2.0_alpha+1.0_phoenixMedRes_R05000.zip md5:159cd0e705caf909b7d6f6911dd4f459 +FeH-2.0_alpha+1.2_phoenixMedRes_R05000.zip md5:c251c7aa09d81de588749cd2968f1d5a +FeH-2.0_alpha-0.2_phoenixMedRes_R05000.zip md5:ecd1a7bac93453fca94c91fb5f317ad9 +FeH-3.0_alpha+0.0_phoenixMedRes_R05000.zip md5:44616bdf40565812c689807064d860d2 +FeH-3.0_alpha+0.2_phoenixMedRes_R05000.zip md5:47ba055a48ab6d9015554695f892a899 +FeH-3.0_alpha+0.4_phoenixMedRes_R05000.zip md5:fe5a6af7b484134aa6a6432f084ec442 +FeH-3.0_alpha+0.6_phoenixMedRes_R05000.zip md5:d6546eb91379716793e37f7b983d06ad +FeH-3.0_alpha+0.8_phoenixMedRes_R05000.zip md5:45607ec941792a43306aa7845634dfa9 +FeH-3.0_alpha+1.0_phoenixMedRes_R05000.zip md5:aded444a4fc428165bdf486a296247ad +FeH-3.0_alpha+1.2_phoenixMedRes_R05000.zip md5:19ee4441307ff07ff9c585db6e7cd765 +FeH-3.0_alpha-0.2_phoenixMedRes_R05000.zip md5:732df0380ad4c408c1742e62d6d287e6 +FeH-4.0_alpha+0.0_phoenixMedRes_R05000.zip md5:8762643304781800400a86000a243ae0 +_readme.md md5:16be5f22209ded520b040db90c62fd4d diff --git a/src/proteus/star/phoenix.py b/src/proteus/star/phoenix.py index 2ddb0ec8f..f85da10d3 100644 --- a/src/proteus/star/phoenix.py +++ b/src/proteus/star/phoenix.py @@ -7,6 +7,7 @@ import numpy as np +from proteus.data import STELLAR_SPECTRA_PHOENIX, dataset_dir from proteus.utils.constants import AU, M_sun, R_sun, const_G from proteus.utils.data import GetFWLData, download_phoenix from proteus.utils.helper import UpdateStatusfile @@ -138,8 +139,8 @@ def get_phoenix_modern_spectrum( """ Get a PHOENIX 'modern' spectrum scaled to 1 AU and return its path. - Raw files in stellar_spectra/PHOENIX/FeH*_alpha*/ - Scaled 1 AU files in stellar_spectra/PHOENIX/1AU/ + Raw files in the PHOENIX dataset directory, under FeH*_alpha*/ + Scaled 1 AU files in the same directory, under 1AU/ """ # parameters @@ -168,7 +169,7 @@ def get_phoenix_modern_spectrum( ) log.info('') - base_dir = GetFWLData() / 'stellar_spectra' / 'PHOENIX' + base_dir = dataset_dir(STELLAR_SPECTRA_PHOENIX, data_root=GetFWLData()) feh_str = phoenix_param(FeH_g, kind='FeH') alpha_str = phoenix_param(alpha_g, kind='alpha') diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 188da9509..425efc768 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -480,10 +480,8 @@ def validate_zenodo_folder(zenodo_id: str, folder_dir: Path, hash_maxfilesize=10 'osf_id': 'phsxf', 'osf_project': 'phsxf', }, - # Stellar spectra - PHOENIX (OSF project: 8r2sw) - 'PHOENIX': {'zenodo_id': '17674612', 'osf_id': '8r2sw', 'osf_project': '8r2sw'}, # The surface albedos, the Seager EOS tables, the exoplanet catalogue, the - # mass-radius relations, the solar, Named and MUSCLES stellar spectra and the + # mass-radius relations, the solar, Named, MUSCLES and PHOENIX stellar spectra and the # Zalmoxis equations of state are declared in # src/proteus/data/proteus_manifest.toml and fetched through fwl-io, so their # record pins live there and are absent here. @@ -1123,75 +1121,77 @@ def download_spectral_files(name: str | None = None, bands: str | None = None): def download_phoenix(*, alpha: float = 0.0, FeH: float = 0.0, force: bool = False) -> bool: - """Download PHOENIX stellar spectra into `FWL_DATA`. + """Fetch one PHOENIX grid through fwl-io and unpack it. - Used by `proteus.star.phoenix`. The current implementation downloads the - PHOENIX bundle via the unified `download()` mechanism. + The dataset holds one zip archive per ([Fe/H], [alpha/M]) pair. Only the + archive for the requested pair is fetched and verified against the registry + checksum; the other archives of the record are not downloaded. The archive + is unpacked into ``FeH_alpha/`` inside the dataset directory + and kept beside it. - Downloads the FeH/alpha-specific PHOENIX zip bundle as a single file, then unzips it. + Parameters + ---------- + alpha : float + Alpha-element enhancement [alpha/M] of the grid. + FeH : float + Metallicity [Fe/H] of the grid. + force : bool + Remove the unpacked grid and its archive first, so both are fetched again. + + Returns + ------- + bool + True if the unpacked grid is on disk. """ - desc = f'PHOENIX stellar spectra (alpha={alpha:+0.1f}, [Fe/H]={FeH:+0.1f})' + from proteus.data import STELLAR_SPECTRA_PHOENIX, dataset_dir, fetch_dataset_file feh_str = phoenix_param(FeH, kind='FeH') alpha_str = phoenix_param(alpha, kind='alpha') - - # Published zip name zip_name = f'FeH{feh_str}_alpha{alpha_str}_phoenixMedRes_R05000.zip' + lte_glob = 'LTE_T*_phoenixMedRes_R05000.txt' - base_dir = GetFWLData() / 'stellar_spectra' / 'PHOENIX' - zip_path = base_dir / zip_name - - # Where unpacked files are stored + fwl_data = GetFWLData() + base_dir = dataset_dir(STELLAR_SPECTRA_PHOENIX, data_root=fwl_data) grid_dir = base_dir / f'FeH{feh_str}_alpha{alpha_str}' - ok = download( - folder='PHOENIX', - target='stellar_spectra', - desc=desc, - force=force, - file=zip_name, - ) - if not ok: - return False - - if not zip_path.is_file(): - matches = [p for p in base_dir.rglob(zip_name) if p.is_file()] - if not matches: - log.error(f'Downloaded PHOENIX bundle but cannot find zip on disk: {zip_name}') - return False - zip_path = matches[0] - - # Skip if already there and no force (zip still removed below only if we unzip) - if ( - not force - and grid_dir.exists() - and any(grid_dir.glob('LTE_T*_phoenixMedRes_R05000.txt')) - ): - # If zip exists from a previous run, remove it - if zip_path.exists(): - zip_path.unlink() + if force: + safe_rm(str(grid_dir)) + safe_rm(str(base_dir / zip_name)) + elif any(grid_dir.glob(lte_glob)): return True - if force and grid_dir.exists(): - safe_rm(grid_dir) - grid_dir.mkdir(parents=True, exist_ok=True) + log.info(f'Fetching PHOENIX grid [Fe/H]={FeH:+0.1f}, [alpha/M]={alpha:+0.1f}: {zip_name}') + try: + zip_path = fetch_dataset_file(STELLAR_SPECTRA_PHOENIX, zip_name, data_root=fwl_data) + except KeyError: + log.error(f'No PHOENIX grid archive named {zip_name} in the dataset registry') + return False + except Exception as exc: # noqa: BLE001 - the caller reports the failed download + log.error(f'Failed to fetch PHOENIX grid {zip_name}: {exc}') + return False - log.info(f'Unpacking PHOENIX zip: {zip_path.name} -> {grid_dir}') - with zipfile.ZipFile(zip_path, 'r') as zf: - zf.extractall(grid_dir) + # Unpack next to the final directory and rename, so an interrupted + # extraction is never mistaken for a complete grid. + partial_dir = grid_dir.with_name(grid_dir.name + '.partial') + safe_rm(str(partial_dir)) + partial_dir.mkdir(parents=True, exist_ok=True) - if not any(grid_dir.glob('LTE_T*_phoenixMedRes_R05000.txt')): - log.error(f'Extraction completed but LTE files not found where expected: {grid_dir}') + log.info(f'Unpacking PHOENIX zip: {zip_path.name} -> {grid_dir}') + try: + with zipfile.ZipFile(zip_path, 'r') as zf: + zf.extractall(partial_dir) + except zipfile.BadZipFile as exc: + log.error(f'PHOENIX archive {zip_path.name} cannot be unpacked: {exc}') + safe_rm(str(partial_dir)) return False - # Remove extraction marker - marker = base_dir / f'.extracted_{zip_path.stem}' - if marker.exists(): - marker.unlink() - - # Remove the zip after successful unpack - zip_path.unlink() + if not any(partial_dir.glob(lte_glob)): + log.error(f'Extraction completed but LTE files not found where expected: {partial_dir}') + safe_rm(str(partial_dir)) + return False + safe_rm(str(grid_dir)) + partial_dir.rename(grid_dir) return True diff --git a/tests/data/test_download_robustness.py b/tests/data/test_download_robustness.py index 07cdb8314..4ce23e67c 100644 --- a/tests/data/test_download_robustness.py +++ b/tests/data/test_download_robustness.py @@ -285,7 +285,7 @@ def test_valid_folder(self): """Test lookup of valid folder.""" from proteus.utils.data import get_data_source_info - result = get_data_source_info('PHOENIX') + result = get_data_source_info('scattering') assert result is not None assert 'zenodo_id' in result assert 'osf_id' in result diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index 5d1257834..313f1ba8c 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -35,6 +35,7 @@ MASS_RADIUS_ZENG_2019, STELLAR_SPECTRA_MUSCLES, STELLAR_SPECTRA_NAMED, + STELLAR_SPECTRA_PHOENIX, STELLAR_SPECTRA_SOLAR, SURFACE_ALBEDOS_HAMMOND_2024, _dataset, @@ -56,6 +57,7 @@ SOLAR_RECORD = '17981836' NAMED_RECORD = '15721440' MUSCLES_RECORD = '17802209' +PHOENIX_RECORD = '17674612' WOLF_BOWER_RECORD = '17417017' RTPRESS_RECORD = '18819027' PALEOS_2PHASE_RECORD = '19680050' @@ -105,6 +107,7 @@ STELLAR_SPECTRA_SOLAR, STELLAR_SPECTRA_NAMED, STELLAR_SPECTRA_MUSCLES, + STELLAR_SPECTRA_PHOENIX, *EOS_DATASETS, } | {spectral_file_key(group, bands) for group, bands in SPECTRAL_RECORDS} @@ -138,9 +141,11 @@ def test_manifest_declares_the_datasets(): assert datasets[STELLAR_SPECTRA_SOLAR].subdir == 'stellar_spectra/solar' assert datasets[STELLAR_SPECTRA_NAMED].subdir == 'stellar_spectra/named' assert datasets[STELLAR_SPECTRA_MUSCLES].subdir == 'stellar_spectra/muscles' + assert datasets[STELLAR_SPECTRA_PHOENIX].subdir == 'stellar_spectra/phoenix' assert datasets[STELLAR_SPECTRA_SOLAR].zenodo == f'10.5281/zenodo.{SOLAR_RECORD}' assert datasets[STELLAR_SPECTRA_NAMED].zenodo == f'10.5281/zenodo.{NAMED_RECORD}' assert datasets[STELLAR_SPECTRA_MUSCLES].zenodo == f'10.5281/zenodo.{MUSCLES_RECORD}' + assert datasets[STELLAR_SPECTRA_PHOENIX].zenodo == f'10.5281/zenodo.{PHOENIX_RECORD}' assert datasets[SURFACE_ALBEDOS_HAMMOND_2024].zenodo == ( f'10.5281/zenodo.{HAMMOND_2024_RECORD}' ) @@ -287,6 +292,9 @@ def test_dataset_dir_is_versioned(tmp_path): assert dataset_dir(STELLAR_SPECTRA_MUSCLES, data_root=tmp_path) == ( tmp_path / 'stellar_spectra' / 'muscles' / f'r{MUSCLES_RECORD}' ) + assert dataset_dir(STELLAR_SPECTRA_PHOENIX, data_root=tmp_path) == ( + tmp_path / 'stellar_spectra' / 'phoenix' / f'r{PHOENIX_RECORD}' + ) def test_dataset_dir_rejects_an_unversioned_resolution(tmp_path, monkeypatch): @@ -538,7 +546,8 @@ def test_migrated_datasets_are_not_also_pinned_in_the_legacy_map(): assert 'Named' not in DATA_SOURCE_MAP assert 'solar' not in DATA_SOURCE_MAP assert 'MUSCLES' not in DATA_SOURCE_MAP - assert 'PHOENIX' in DATA_SOURCE_MAP + assert 'PHOENIX' not in DATA_SOURCE_MAP + assert 'scattering' in DATA_SOURCE_MAP pinned_records = {entry['zenodo_id'] for entry in DATA_SOURCE_MAP.values()} assert EXOPLANET_RECORD not in pinned_records assert HAMMOND_2024_RECORD not in pinned_records @@ -547,6 +556,7 @@ def test_migrated_datasets_are_not_also_pinned_in_the_legacy_map(): assert SOLAR_RECORD not in pinned_records assert NAMED_RECORD not in pinned_records assert MUSCLES_RECORD not in pinned_records + assert PHOENIX_RECORD not in pinned_records def test_fetch_dataset_delegates_to_the_pinned_fetcher(monkeypatch, tmp_path): diff --git a/tests/star/test_stellar_spectra.py b/tests/star/test_stellar_spectra.py index cfafb4738..0b58315a0 100644 --- a/tests/star/test_stellar_spectra.py +++ b/tests/star/test_stellar_spectra.py @@ -66,10 +66,15 @@ def _make_handler( return handler +def _phoenix_dir(tmp_path: Path) -> Path: + """Return the versioned PHOENIX dataset directory under a test data root.""" + from proteus.data import STELLAR_SPECTRA_PHOENIX, dataset_dir + + return dataset_dir(STELLAR_SPECTRA_PHOENIX, data_root=tmp_path) + + def _raw_phoenix_path(tmp_path: Path, raw_name: str, FeH_str: str, alpha_str: str) -> Path: - return ( - tmp_path / 'stellar_spectra' / 'PHOENIX' / f'FeH{FeH_str}_alpha{alpha_str}' / raw_name - ) + return _phoenix_dir(tmp_path) / f'FeH{FeH_str}_alpha{alpha_str}' / raw_name def _install_fake_mors(monkeypatch): @@ -269,7 +274,7 @@ def test_get_phoenix_modern_spectrum_offline_missing_raw_raises(tmp_path, monkey # before raising. A regression that wrote a zero-flux fallback to disk # and then raised (or that swallowed the raise) would corrupt the # downstream pipeline; assert no PHOENIX 1AU directory was created. - one_au_dir = tmp_path / 'stellar_spectra' / 'PHOENIX' / '1AU' + one_au_dir = _phoenix_dir(tmp_path) / '1AU' assert not one_au_dir.exists() or not any(one_au_dir.iterdir()) @@ -522,7 +527,7 @@ def test_init_star_phoenix_branch_uses_get_phoenix_modern_spectrum(tmp_path, mon fake_mors = _install_fake_mors(monkeypatch) - phoenix_modern = tmp_path / 'stellar_spectra' / 'PHOENIX' / '1AU' / 'fake_phoenix.txt' + phoenix_modern = _phoenix_dir(tmp_path) / '1AU' / 'fake_phoenix.txt' _write_spectrum_file(phoenix_modern, fl=(7.0, 8.0)) calls = {'n': 0, 'track_type': None} @@ -565,7 +570,7 @@ def test_init_star_phoenix_branch_tolerates_star_name_none(tmp_path, monkeypatch _install_fake_mors(monkeypatch) - phoenix_modern = tmp_path / 'stellar_spectra' / 'PHOENIX' / '1AU' / 'fake_phoenix.txt' + phoenix_modern = _phoenix_dir(tmp_path) / '1AU' / 'fake_phoenix.txt' _write_spectrum_file(phoenix_modern, fl=(7.0, 8.0)) calls = {'n': 0} diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index f4ce30b33..ac4fbd8ee 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -1044,10 +1044,10 @@ def test_download_folder_mode_fails_if_no_sources_available(mock_check, mock_get def test_get_data_source_info(): """Test unified data source mapping lookup.""" # Test known mapping - info = get_data_source_info('PHOENIX') + info = get_data_source_info('scattering') assert info is not None - assert info['zenodo_id'] == '17674612' - assert info['osf_project'] == '8r2sw' + assert info['zenodo_id'] == '19294180' + assert info['osf_project'] == 'vehxg' # Test unknown mapping info = get_data_source_info('UnknownFolder') @@ -1057,7 +1057,7 @@ def test_get_data_source_info(): @pytest.mark.unit def test_get_osf_project(): """Test OSF project ID lookup.""" - assert get_osf_project('PHOENIX') == '8r2sw' + assert get_osf_project('scattering') == 'vehxg' assert get_osf_project('UnknownFolder') is None @@ -1078,7 +1078,7 @@ def test_get_zenodo_from_osf(): @pytest.mark.unit def test_get_osf_from_zenodo(): """Test reverse lookup: Zenodo ID -> OSF project.""" - assert get_osf_from_zenodo('17674612') == '8r2sw' # PHOENIX + assert get_osf_from_zenodo('19294180') == 'vehxg' # scattering assert get_osf_from_zenodo('99999999') is None # Unknown @@ -1267,7 +1267,7 @@ def test_download_automatic_mapping( mock_download_zenodo.return_value = True mock_validate.return_value = True - folder_dir = tmp_path / 'target' / 'PHOENIX' + folder_dir = tmp_path / 'target' / 'scattering' folder_dir.mkdir(parents=True, exist_ok=True) (folder_dir / 'test_file.txt').write_text('test') @@ -1278,7 +1278,7 @@ def test_download_automatic_mapping( # Call download without explicit IDs - should use mapping result = download( - folder='PHOENIX', + folder='scattering', target='target', desc='test data', # No osf_id or zenodo_id provided - should use mapping @@ -1288,7 +1288,7 @@ def test_download_automatic_mapping( mock_download_zenodo.assert_called_once() # Should have used mapped Zenodo ID (check kwargs since it's called with keyword args) call_kwargs = mock_download_zenodo.call_args.kwargs - assert call_kwargs['zenodo_id'] == '17674612' # Zenodo ID from mapping + assert call_kwargs['zenodo_id'] == '19294180' # Zenodo ID from mapping assert result is True @@ -1397,198 +1397,220 @@ def test_download_no_mapping_no_ids( mock_download_zenodo.assert_not_called() -@pytest.mark.unit -@patch('proteus.utils.data.zipfile.ZipFile') -@patch('proteus.utils.data.download') -def test_download_phoenix(mock_download, mock_zipfile, tmp_path, monkeypatch): - """Test PHOENIX stellar spectra download wrapper (single-file + unzip + cleanup).""" +def _phoenix_zip( + path, lte_name='LTE_T05800_logg4.50_FeH-0.0_alpha+0.0_phoenixMedRes_R05000.txt' +): + """Write a small zip that holds one PHOENIX spectrum file.""" + import zipfile - from proteus.utils.data import download_phoenix - from proteus.utils.phoenix_helper import phoenix_param + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, 'w') as zf: + if lte_name: + zf.writestr(lte_name, 'dummy spectrum') + else: + zf.writestr('README.txt', 'no spectra here') + return path - # Arrange inputs - FeH = 0.0 - alpha = 0.0 - feh_str = phoenix_param(FeH, kind='FeH') - alpha_str = phoenix_param(alpha, kind='alpha') - zip_name = f'FeH{feh_str}_alpha{alpha_str}_phoenixMedRes_R05000.zip' - base_dir = tmp_path / 'stellar_spectra' / 'PHOENIX' - base_dir.mkdir(parents=True, exist_ok=True) +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +def test_download_phoenix_fetches_one_zip_and_unpacks(mock_fetch_file, tmp_path, monkeypatch): + """``download_phoenix`` asks fwl-io for the single zip of the requested + grid, unpacks it into the versioned dataset directory and keeps the zip. + """ + from proteus.data import STELLAR_SPECTRA_PHOENIX, dataset_dir + from proteus.utils.data import download_phoenix - # Make GetFWLData() return tmp_path monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + base_dir = dataset_dir(STELLAR_SPECTRA_PHOENIX, data_root=tmp_path) + zip_name = 'FeH-0.0_alpha+0.0_phoenixMedRes_R05000.zip' + zip_path = _phoenix_zip(base_dir / zip_name) + mock_fetch_file.return_value = zip_path - # Our function expects the zip to exist at base_dir/zip_name after download. - # The production code will check zip_path.is_file(), so create it. - zip_path = base_dir / zip_name - zip_path.write_bytes(b'dummy zip bytes') - - # Simulate extracted LTE file presence by patching Path.glob used in: - # any(grid_dir.glob("LTE_T*_phoenixMedRes_R05000.txt")) - grid_dir = base_dir / f'FeH{feh_str}_alpha{alpha_str}' - grid_dir.mkdir(parents=True, exist_ok=True) - lte_file = grid_dir / 'LTE_T05800_logg4.50_FeH-0.0_alpha+0.0_phoenixMedRes_R05000.txt' - lte_file.write_text('dummy spectrum') + assert download_phoenix(alpha=0.0, FeH=0.0) is True - # Mock ZipFile context manager so no real unzip is attempted - zf = MagicMock() - mock_zipfile.return_value.__enter__.return_value = zf - - # Make download() report success - mock_download.return_value = True + mock_fetch_file.assert_called_once_with( + STELLAR_SPECTRA_PHOENIX, zip_name, data_root=tmp_path + ) + grid_dir = base_dir / 'FeH-0.0_alpha+0.0' + assert any(grid_dir.glob('LTE_T*_phoenixMedRes_R05000.txt')) + assert zip_path.is_file() + assert not list(base_dir.glob('*.partial')) + assert base_dir == tmp_path / 'stellar_spectra' / 'phoenix' / 'r17674612' - # Also create a marker to verify it gets removed - marker = base_dir / f'.extracted_{zip_path.stem}' - marker.write_text('marker') - # Act - ok = download_phoenix(alpha=alpha, FeH=FeH, force=False) +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +def test_download_phoenix_skips_fetch_when_grid_is_unpacked( + mock_fetch_file, tmp_path, monkeypatch +): + """An unpacked grid is found on disk and nothing is fetched.""" + from proteus.data import STELLAR_SPECTRA_PHOENIX, dataset_dir + from proteus.utils.data import download_phoenix - # Assert: download() called with new single-file mode - mock_download.assert_called_once_with( - folder='PHOENIX', - target='stellar_spectra', - desc='PHOENIX stellar spectra (alpha=+0.0, [Fe/H]=+0.0)', - force=False, - file=zip_name, + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + grid_dir = dataset_dir(STELLAR_SPECTRA_PHOENIX, data_root=tmp_path) / 'FeH-0.0_alpha+0.0' + grid_dir.mkdir(parents=True) + (grid_dir / 'LTE_T05800_logg4.50_FeH-0.0_alpha+0.0_phoenixMedRes_R05000.txt').write_text( + 'x' ) - assert ok is True - - assert not zip_path.exists() + assert download_phoenix(alpha=0.0, FeH=0.0) is True + mock_fetch_file.assert_not_called() @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.zipfile.ZipFile') -def test_download_phoenix_unzips_and_cleans_up( - mock_zipfile, mock_download, tmp_path, monkeypatch -): - """Covers the unzip path: ZipFile/extractall called, LTE file appears, zip removed.""" - +@patch('proteus.data.fetch_dataset_file') +def test_download_phoenix_force_replaces_stale_grid(mock_fetch_file, tmp_path, monkeypatch): + """``force=True`` removes the unpacked grid and the archive, fetches again, + and leaves no file of the old grid behind. + """ + from proteus.data import STELLAR_SPECTRA_PHOENIX, dataset_dir from proteus.utils.data import download_phoenix - from proteus.utils.phoenix_helper import phoenix_param - - # Arrange inputs - FeH = 0.0 - alpha = 0.0 - feh_str = phoenix_param(FeH, kind='FeH') - alpha_str = phoenix_param(alpha, kind='alpha') - zip_name = f'FeH{feh_str}_alpha{alpha_str}_phoenixMedRes_R05000.zip' - # Make GetFWLData() return tmp_path monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + base_dir = dataset_dir(STELLAR_SPECTRA_PHOENIX, data_root=tmp_path) + zip_name = 'FeH-0.0_alpha+0.0_phoenixMedRes_R05000.zip' + grid_dir = base_dir / 'FeH-0.0_alpha+0.0' + grid_dir.mkdir(parents=True) + stale = grid_dir / 'LTE_T99999_stale_phoenixMedRes_R05000.txt' + stale.write_text('old') - base_dir = tmp_path / 'stellar_spectra' / 'PHOENIX' - base_dir.mkdir(parents=True, exist_ok=True) - - # Production expects zip at base_dir/zip_name after download() - zip_path = base_dir / zip_name - zip_path.write_bytes(b'dummy zip bytes') - - # grid_dir where extraction happens - grid_dir = base_dir / f'FeH{feh_str}_alpha{alpha_str}' + def fetch_again(key, name, data_root=None): + assert not (base_dir / name).exists(), 'the old archive was not removed first' + return _phoenix_zip(base_dir / name) - # IMPORTANT: do NOT pre-create LTE file here; we want the unzip path. + mock_fetch_file.side_effect = fetch_again - # Mock download() report success - mock_download.return_value = True + assert download_phoenix(alpha=0.0, FeH=0.0, force=True) is True + mock_fetch_file.assert_called_once() + assert not stale.exists() + assert ( + grid_dir / 'LTE_T05800_logg4.50_FeH-0.0_alpha+0.0_phoenixMedRes_R05000.txt' + ).is_file() + assert (base_dir / zip_name).is_file() - # Mock ZipFile so no real unzip occurs; simulate extraction by creating LTE file - zf = MagicMock() - def extractall_side_effect(dest): - dest = Path(dest) - dest.mkdir(parents=True, exist_ok=True) - (dest / 'LTE_T02300_logg0.00_FeH-0.0_alpha+0.0_phoenixMedRes_R05000.txt').write_text( - 'dummy spectrum' - ) +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +def test_download_phoenix_alpha_grid_uses_its_own_zip(mock_fetch_file, tmp_path, monkeypatch): + """A grid with nonzero alpha and metallicity is a different zip, named with + explicit signs, and the alpha=0 grid of the same metallicity is another one. + """ + from proteus.data import STELLAR_SPECTRA_PHOENIX + from proteus.utils.data import download_phoenix - zf.extractall.side_effect = extractall_side_effect - mock_zipfile.return_value.__enter__.return_value = zf + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + mock_fetch_file.side_effect = lambda key, name, data_root=None: _phoenix_zip( + tmp_path / name, lte_name=None + ) - # Act - ok = download_phoenix(alpha=alpha, FeH=FeH, force=True) + # The stub zips hold no spectra, so the call fails after the fetch; the + # requested archive name is what this test pins. + assert download_phoenix(alpha=0.2, FeH=-0.5) is False + assert download_phoenix(alpha=0.0, FeH=-0.5) is False + names = [c.args[1] for c in mock_fetch_file.call_args_list] + assert names == [ + 'FeH-0.5_alpha+0.2_phoenixMedRes_R05000.zip', + 'FeH-0.5_alpha+0.0_phoenixMedRes_R05000.zip', + ] + assert all(c.args[0] == STELLAR_SPECTRA_PHOENIX for c in mock_fetch_file.call_args_list) - # Assert - assert ok is True - # unzip was actually attempted - mock_zipfile.assert_called_once_with(zip_path, 'r') - zf.extractall.assert_called_once_with(grid_dir) +@pytest.mark.unit +@pytest.mark.parametrize('error', [KeyError('no such file'), RuntimeError('mirror down')]) +@patch('proteus.data.fetch_dataset_file') +def test_download_phoenix_returns_false_if_fetch_fails( + mock_fetch_file, error, tmp_path, monkeypatch +): + """A missing registry name or an exhausted mirror gives False, not a raise.""" + from proteus.utils.data import download_phoenix - # LTE file now exists after "extraction" - assert any(grid_dir.glob('LTE_T*_phoenixMedRes_R05000.txt')) + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + mock_fetch_file.side_effect = error - # zip removed after successful unpack - assert not zip_path.exists() + assert download_phoenix(alpha=0.0, FeH=0.0) is False + mock_fetch_file.assert_called_once() @pytest.mark.unit -@patch('proteus.utils.data.safe_rm') -@patch('proteus.utils.data.zipfile.ZipFile') -@patch('proteus.utils.data.download') -def test_download_phoenix_force_removes_existing_grid_dir( - mock_download, mock_zipfile, mock_safe_rm, tmp_path, monkeypatch +@patch('proteus.data.fetch_dataset_file') +def test_download_phoenix_rejects_archive_without_spectra( + mock_fetch_file, tmp_path, monkeypatch ): - """``download_phoenix(force=True)`` removes the pre-existing PHOENIX - grid directory before re-extracting, so a corrupted prior download - cannot leak stale files into the new grid. + """A zip that unpacks without any LTE file gives False and leaves no + grid directory that a later call could mistake for a complete grid. """ + from proteus.data import STELLAR_SPECTRA_PHOENIX, dataset_dir from proteus.utils.data import download_phoenix - from proteus.utils.phoenix_helper import phoenix_param monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) - mock_download.return_value = True + base_dir = dataset_dir(STELLAR_SPECTRA_PHOENIX, data_root=tmp_path) + mock_fetch_file.return_value = _phoenix_zip( + base_dir / 'FeH-0.0_alpha+0.0_phoenixMedRes_R05000.zip', lte_name=None + ) - FeH = 0.0 - alpha = 0.0 - feh_str = phoenix_param(FeH, kind='FeH') - alpha_str = phoenix_param(alpha, kind='alpha') - zip_name = f'FeH{feh_str}_alpha{alpha_str}_phoenixMedRes_R05000.zip' - - base_dir = tmp_path / 'stellar_spectra' / 'PHOENIX' - base_dir.mkdir(parents=True, exist_ok=True) - (base_dir / zip_name).write_bytes(b'zip') - - grid_dir = base_dir / f'FeH{feh_str}_alpha{alpha_str}' - grid_dir.mkdir(parents=True, exist_ok=True) - - # Make extraction succeed by pre-creating LTE file after "extractall" - def extractall_side_effect(_dest): - dest = Path(_dest) - dest.mkdir(parents=True, exist_ok=True) - (dest / 'LTE_T02300_logg0.00_FeH-0.0_alpha+0.0_phoenixMedRes_R05000.txt').write_text( - 'ok' - ) + assert download_phoenix(alpha=0.0, FeH=0.0) is False + assert not (base_dir / 'FeH-0.0_alpha+0.0').exists() + assert not list(base_dir.glob('*.partial')) - zf = MagicMock() - zf.extractall.side_effect = extractall_side_effect - mock_zipfile.return_value.__enter__.return_value = zf - ok = download_phoenix(alpha=alpha, FeH=FeH, force=True) - assert ok is True - mock_safe_rm.assert_called_once() +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +def test_download_phoenix_rejects_corrupt_archive(mock_fetch_file, tmp_path, monkeypatch): + """A file that is not a zip gives False and leaves no partial directory.""" + from proteus.data import STELLAR_SPECTRA_PHOENIX, dataset_dir + from proteus.utils.data import download_phoenix + + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + base_dir = dataset_dir(STELLAR_SPECTRA_PHOENIX, data_root=tmp_path) + bad = base_dir / 'FeH-0.0_alpha+0.0_phoenixMedRes_R05000.zip' + bad.parent.mkdir(parents=True, exist_ok=True) + bad.write_bytes(b'not a zip') + mock_fetch_file.return_value = bad + + assert download_phoenix(alpha=0.0, FeH=0.0) is False + assert not (base_dir / 'FeH-0.0_alpha+0.0').exists() + assert not list(base_dir.glob('*.partial')) @pytest.mark.unit -@patch('proteus.utils.data.download') -def test_download_phoenix_returns_false_if_download_fails(mock_download, tmp_path, monkeypatch): - """``download_phoenix`` returns False when the underlying ``download`` - helper returns False, propagating the failure rather than raising. +def test_phoenix_registry_covers_every_reachable_grid(): + """Every (FeH, alpha) pair that ``phoenix_to_grid`` can return names a zip + in the dataset registry, so the runtime alpha override never asks for a + file the record does not hold. """ - from proteus.utils.data import download_phoenix + import logging - monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) - mock_download.return_value = False + import numpy as np - ok = download_phoenix(alpha=0.0, FeH=0.0, force=False) - assert ok is False - # Discrimination: confirm the download helper was actually invoked; - # a regression that returned False from an unrelated early-exit - # (e.g. force-check or alpha/FeH guard) would still pass `is False`. - mock_download.assert_called() + from proteus.data import STELLAR_SPECTRA_PHOENIX, manifest_path + from proteus.utils.phoenix_helper import phoenix_param, phoenix_to_grid + + registry = manifest_path().with_name(f'{STELLAR_SPECTRA_PHOENIX}.registry.txt') + names = {line.split()[0] for line in registry.read_text().splitlines() if line.strip()} + assert len(names) == 52 + + logging.disable(logging.CRITICAL) + try: + pairs = set() + for feh in np.arange(-4.5, 1.6, 0.1): + for alpha in np.arange(-0.4, 1.5, 0.1): + for teff in (None, 2500, 3000, 4000, 5800, 7500, 9000): + grid = phoenix_to_grid( + FeH=feh, alpha=alpha, Teff=teff, logg=4.5 if teff else None + ) + pairs.add((grid['FeH'], grid['alpha'])) + finally: + logging.disable(logging.NOTSET) + + assert len(pairs) > 40 + for feh, alpha in sorted(pairs): + name = ( + f'FeH{phoenix_param(feh, kind="FeH")}_alpha{phoenix_param(alpha, kind="alpha")}' + '_phoenixMedRes_R05000.zip' + ) + assert name in names, name @pytest.mark.unit @@ -1758,18 +1780,16 @@ def test_download_stellar_spectra_default(mock_fetch, mock_download): @pytest.mark.unit -@patch('proteus.utils.data.download') @patch('proteus.data.fetch_dataset') -def test_download_stellar_spectra_custom(mock_fetch, mock_download): - """A collection still in DATA_SOURCE_MAP keeps its legacy download path.""" - from proteus.data import STELLAR_SPECTRA_NAMED +def test_download_stellar_spectra_phoenix_is_not_a_bulk_collection(mock_fetch): + """PHOENIX is fetched one grid at a time through download_phoenix, so the + bulk collection downloader rejects it instead of pulling the 9 GB record. + """ from proteus.utils.data import download_stellar_spectra - download_stellar_spectra(folders=('Named', 'PHOENIX')) - - mock_fetch.assert_called_once_with(STELLAR_SPECTRA_NAMED) - mock_download.assert_called_once() - assert mock_download.call_args.kwargs['folder'] == 'PHOENIX' + with pytest.raises(ValueError, match='No data source mapping found'): + download_stellar_spectra(folders=('PHOENIX',)) + mock_fetch.assert_not_called() @pytest.mark.unit @@ -4441,133 +4461,6 @@ def test_download_folder_mode_osf_raises_caught( mock_osf_folder.assert_called_once() -# ============================================================================ -# download_phoenix additional edge-case coverage -# ============================================================================ - - -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -def test_download_phoenix_zip_not_at_canonical_path_found_via_rglob( - mock_getfwl, mock_download, tmp_path, monkeypatch -): - """When the PHOENIX zip lands at a non-canonical location, rglob finds it.""" - import zipfile - - import proteus.utils.data as data_mod - from proteus.utils.data import download_phoenix - - mock_getfwl.return_value = tmp_path - monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - mock_download.return_value = True - - base_dir = tmp_path / 'stellar_spectra' / 'PHOENIX' - base_dir.mkdir(parents=True, exist_ok=True) - # Place the zip in a non-canonical subdirectory - alt_zip = base_dir / 'weird_layout' / 'FeH-0.0_alpha+0.0_phoenixMedRes_R05000.zip' - alt_zip.parent.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(alt_zip, 'w') as zf: - zf.writestr('LTE_T03000_phoenixMedRes_R05000.txt', 'spectrum data') - - ok = download_phoenix(alpha=0.0, FeH=0.0, force=False) - assert ok is True - # Discrimination: the canonical zip path should now be gone (the - # function unlinks the zip after a successful extract), and the - # extracted LTE file must be present. - grid_dir = base_dir / 'FeH-0.0_alpha+0.0' - assert (grid_dir / 'LTE_T03000_phoenixMedRes_R05000.txt').exists() - - -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -def test_download_phoenix_zip_not_found_returns_false( - mock_getfwl, mock_download, tmp_path, monkeypatch -): - """When download() claims success but no zip is found anywhere, returns False.""" - import proteus.utils.data as data_mod - from proteus.utils.data import download_phoenix - - mock_getfwl.return_value = tmp_path - monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - mock_download.return_value = True - # Do not create any zip; rglob will return empty - - ok = download_phoenix(alpha=0.0, FeH=0.0, force=False) - assert ok is False - # Discrimination: confirm download() was actually invoked (the False - # came from the post-download zip lookup, not from an earlier guard). - mock_download.assert_called_once() - - -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -def test_download_phoenix_extraction_missing_lte_files_returns_false( - mock_getfwl, mock_download, tmp_path, monkeypatch -): - """If the zip extracts but no LTE_T* files appear, function returns False.""" - import zipfile - - import proteus.utils.data as data_mod - from proteus.utils.data import download_phoenix - - mock_getfwl.return_value = tmp_path - monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - mock_download.return_value = True - - base_dir = tmp_path / 'stellar_spectra' / 'PHOENIX' - base_dir.mkdir(parents=True, exist_ok=True) - zip_path = base_dir / 'FeH-0.0_alpha+0.0_phoenixMedRes_R05000.zip' - # Create a zip with the wrong contents (no LTE_T* files) - with zipfile.ZipFile(zip_path, 'w') as zf: - zf.writestr('readme.txt', 'no spectra here') - - ok = download_phoenix(alpha=0.0, FeH=0.0, force=False) - assert ok is False - # Discrimination: the wrong-content file actually landed in grid_dir - # (so extraction ran), but the LTE check failed and the function - # returned False rather than crashing. - grid_dir = base_dir / 'FeH-0.0_alpha+0.0' - assert (grid_dir / 'readme.txt').exists() - - -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -def test_download_phoenix_extraction_marker_cleanup( - mock_getfwl, mock_download, tmp_path, monkeypatch -): - """When a stale .extracted_ marker exists, it is removed after extraction.""" - import zipfile - - import proteus.utils.data as data_mod - from proteus.utils.data import download_phoenix - - mock_getfwl.return_value = tmp_path - monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - mock_download.return_value = True - - base_dir = tmp_path / 'stellar_spectra' / 'PHOENIX' - base_dir.mkdir(parents=True, exist_ok=True) - zip_path = base_dir / 'FeH-0.0_alpha+0.0_phoenixMedRes_R05000.zip' - with zipfile.ZipFile(zip_path, 'w') as zf: - zf.writestr('LTE_T03000_phoenixMedRes_R05000.txt', 'spectrum data') - - # Plant a stale marker - marker = base_dir / '.extracted_FeH-0.0_alpha+0.0_phoenixMedRes_R05000' - marker.write_text('stale') - - ok = download_phoenix(alpha=0.0, FeH=0.0, force=False) - assert ok is True - # Discrimination: marker was removed by the cleanup branch. - assert not marker.exists() - # The LTE file is extracted in place. - grid_dir = base_dir / 'FeH-0.0_alpha+0.0' - assert (grid_dir / 'LTE_T03000_phoenixMedRes_R05000.txt').exists() - - # ============================================================================ # download_scattering / download_interior_lookuptables additional coverage # ============================================================================ @@ -5522,44 +5415,6 @@ def test_get_zalmoxis_EOS_rt_folder_missing_warns(monkeypatch, tmp_path, caplog) assert 'RTPress' in iron_rt['melted_mantle']['eos_file'] -# ============================================================================ -# download_phoenix existing-grid skip branch -# ============================================================================ - - -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -def test_download_phoenix_existing_grid_keeps_zip_and_returns_true( - mock_getfwl, mock_download, tmp_path, monkeypatch -): - """If grid_dir already has LTE files and a leftover zip is present, it is removed and True returned.""" - import proteus.utils.data as data_mod - from proteus.utils.data import download_phoenix - - mock_getfwl.return_value = tmp_path - monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - mock_download.return_value = True - - base_dir = tmp_path / 'stellar_spectra' / 'PHOENIX' - base_dir.mkdir(parents=True, exist_ok=True) - grid_dir = base_dir / 'FeH-0.0_alpha+0.0' - grid_dir.mkdir(parents=True, exist_ok=True) - (grid_dir / 'LTE_T03000_phoenixMedRes_R05000.txt').write_text('spectrum') - - # A leftover zip from a previous run - zip_path = base_dir / 'FeH-0.0_alpha+0.0_phoenixMedRes_R05000.zip' - zip_path.write_bytes(b'leftover') - - ok = download_phoenix(alpha=0.0, FeH=0.0, force=False) - assert ok is True - # Discrimination: the leftover zip is removed AND the LTE file is - # untouched. A regression that re-extracted would have overwritten - # the LTE contents. - assert not zip_path.exists() - assert (grid_dir / 'LTE_T03000_phoenixMedRes_R05000.txt').read_text() == 'spectrum' - - # ============================================================================ # _get_sufficient: MORS star module branch + scattering branch # ============================================================================ From 4adacc7db47d24b511a05f3ef82bbb45cb7e9206 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Tue, 22 Sep 2026 03:20:57 +0200 Subject: [PATCH 07/76] Fetch the melting curves and the P-S lookup table through fwl-io The Wolf and Bower 2018 P-S lookup table and the Monteux+600, Monteux-600 and Wolf and Bower 2018 melting curves move from the legacy record downloader into the manifest under interior_struct.lookup and interior_struct.melting_curves. The lookup table lands in $FWL_DATA/interior_struct/lookup/wolf_bower_2018_1tpa/r19473625/, and each melting curve in $FWL_DATA/interior_struct/melting_curves//r/. resolve_lookup_table_dir and resolve_melting_curve_files locate the fetched datasets for every caller. SPIDER and Aragog read the lookup table in the order a local FWL_DATA/interior_struct/eos/dynamic override, the fetched dataset, then the table bundled with the SPIDER submodule; find_lookup_table_dir reports the fetched directory only when the phase-property files it needs are actually present. A melting curve resolves to a local interior_lookup_tables/Melting_curves/ directory when one holds both P-T files, otherwise to the fetched dataset for one of the three names the manifest serves; any other name raises. The ARAGOG_BASIC constant and the six DATA_SOURCE_MAP entries for the lookup table and the melting curves are removed, and so is the step that copied a fetched solidus.dat/liquidus.dat into solidus_P-T.dat/liquidus_P-T.dat, since resolve_melting_curve_files now reads the fetched files directly. get_zalmoxis_EOS resolves the Wolf and Bower and RTPress directories through dataset_dir instead of a chain of legacy folder guesses. Copies under FWL_DATA/interior_lookup_tables are no longer read, so an existing installation fetches the four datasets again on the next run that needs them. Tests for resolve_melting_curve_files, resolve_lookup_table_dir and find_lookup_table_dir cover the local-directory precedence, a partial local directory falling back to the fetched dataset, and an unrecognised name. The download_melting_curves and download_interior_lookuptables tests replace the old download() mocks with fetch_dataset mocks and check that clean removes only the fetched dataset, never a local directory. The SPIDER and Aragog EOS-path tests exercise the new fetched-tier fallback, the manifest test suite pins the four new dataset entries and their registry checksums, and the reference page documents the new locations and the resolution order. --- docs/Reference/data.md | 74 +- src/proteus/config/_struct.py | 5 +- src/proteus/data/__init__.py | 4 + ...t.lookup.wolf_bower_2018_1tpa.registry.txt | 14 + ...lting_curves.monteux_minus600.registry.txt | 2 + ...elting_curves.monteux_plus600.registry.txt | 2 + ...elting_curves.wolf_bower_2018.registry.txt | 2 + src/proteus/data/proteus_manifest.toml | 35 +- src/proteus/interior_energetics/aragog.py | 36 +- src/proteus/interior_energetics/common.py | 10 +- src/proteus/interior_energetics/spider.py | 13 +- src/proteus/interior_energetics/wrapper.py | 19 +- src/proteus/utils/data.py | 394 +++++----- tests/data/test_manifest.py | 51 ++ tests/interior_energetics/test_aragog.py | 50 +- tests/interior_energetics/test_common.py | 6 +- tests/interior_energetics/test_spider.py | 45 +- tests/utils/test_data.py | 740 +++++++++--------- 18 files changed, 828 insertions(+), 674 deletions(-) create mode 100644 src/proteus/data/interior_struct.lookup.wolf_bower_2018_1tpa.registry.txt create mode 100644 src/proteus/data/interior_struct.melting_curves.monteux_minus600.registry.txt create mode 100644 src/proteus/data/interior_struct.melting_curves.monteux_plus600.registry.txt create mode 100644 src/proteus/data/interior_struct.melting_curves.wolf_bower_2018.registry.txt diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 60fcc5dd3..da8ec4b85 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -75,7 +75,7 @@ Historical and future spectra are from ### MUSCLES / Mega-MUSCLES spectra -Observed UV–optical–IR spectra from the +Observed UV-optical-IR spectra from the [MUSCLES](https://archive.stsci.edu/prepds/muscles/) and [Mega-MUSCLES](https://archive.stsci.edu/prepds/mega-muscles/) surveys, stored under `$FWL_DATA/stellar_spectra/muscles/r/` as `.txt`. @@ -85,42 +85,42 @@ stored under `$FWL_DATA/stellar_spectra/muscles/r/` as `.txt` | Star | `star_name` | Type | Teff (K) | Age | L (L☉) | M (M☉) | R (R☉) | Survey | |---|---|---|---|---|---|---|---|---| - | [Epsilon Eridani](https://exoplanetarchive.ipac.caltech.edu/overview/eps%20Eri) | `v-eps-eri` | K2V | 5020 | 400–800 Myr | 0.32 | 0.82 | 0.759 | MUSCLES | - | [GJ 1132](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%201132) | `gj1132` | M4.5V | 3229 | — | 0.005 | 0.195 | 0.221 | Mega-MUSCLES | - | [GJ 1214](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%201214) | `gj1214` | M4V | 3100 | 5–10 Gyr | 0.00351 | 0.182 | 0.216 | MUSCLES | - | [GJ 15 A](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%2015) | `gj15a` | M1–M2V | ~3700 | — | ~0.021 | ~0.40 | ~0.38 | Mega-MUSCLES | - | [GJ 163](https://exoplanetarchive.ipac.caltech.edu/overview/gj%20163%20b) | `gj163` | M3.5V | ~3300–3500 | ~2–10 Gyr | ~0.02 | ~0.40 | ~0.41 | Mega-MUSCLES | + | [Epsilon Eridani](https://exoplanetarchive.ipac.caltech.edu/overview/eps%20Eri) | `v-eps-eri` | K2V | 5020 | 400-800 Myr | 0.32 | 0.82 | 0.759 | MUSCLES | + | [GJ 1132](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%201132) | `gj1132` | M4.5V | 3229 | n/a | 0.005 | 0.195 | 0.221 | Mega-MUSCLES | + | [GJ 1214](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%201214) | `gj1214` | M4V | 3100 | 5-10 Gyr | 0.00351 | 0.182 | 0.216 | MUSCLES | + | [GJ 15 A](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%2015) | `gj15a` | M1-M2V | ~3700 | n/a | ~0.021 | ~0.40 | ~0.38 | Mega-MUSCLES | + | [GJ 163](https://exoplanetarchive.ipac.caltech.edu/overview/gj%20163%20b) | `gj163` | M3.5V | ~3300-3500 | ~2-10 Gyr | ~0.02 | ~0.40 | ~0.41 | Mega-MUSCLES | | [GJ 176](https://exoplanetarchive.ipac.caltech.edu/overview/HD%20285968) | `gj176` | M2.5V | ~3700 | ~4 Gyr | 0.034 | 0.51 | 0.48 | MUSCLES | - | [GJ 436](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20436) | `gj436` | M2.5–M3V | ~3600 | ~6–15 Gyr | 0.023 | 0.44 | 0.42 | MUSCLES | - | [GJ 551 (Proxima Cen)](https://exoplanetarchive.ipac.caltech.edu/overview/alpha%20Cen) | `gj551` | M5.5Ve | ~2900–3000 | ~4.8 Gyr | 0.0015 | 0.12 | 0.14 | MUSCLES | + | [GJ 436](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20436) | `gj436` | M2.5-M3V | ~3600 | ~6-15 Gyr | 0.023 | 0.44 | 0.42 | MUSCLES | + | [GJ 551 (Proxima Cen)](https://exoplanetarchive.ipac.caltech.edu/overview/alpha%20Cen) | `gj551` | M5.5Ve | ~2900-3000 | ~4.8 Gyr | 0.0015 | 0.12 | 0.14 | MUSCLES | | [GJ 581](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20581) | `gj581` | M3V | ~3500 | ~4 Gyr | ~0.012 | ~0.30 | ~0.30 | MUSCLES | - | [GJ 649](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20649) | `gj649` | M1–M2V | ~3700 | — | ~0.044 | 0.51 | 0.50 | Mega-MUSCLES | - | [GJ 667 C](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20667C) | `gj667c` | M1.5V | ~3700 | >2 Gyr | ~0.014 | ~0.33 | ~0.32–0.42 | MUSCLES | - | [GJ 674](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20674) | `gj674` | M2.5V | ~3400–3600 | ~0.5–3 Gyr | 0.017 | 0.35 | 0.36 | Mega-MUSCLES | - | [GJ 676 A](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20676A) | `gj676a` | M0V | ~3800–4000 | — | 0.083 | 0.63 | 0.65 | Mega-MUSCLES | - | [GJ 699 (Barnard's Star)](https://exoplanetarchive.ipac.caltech.edu/overview/barnard's%20star) | `gj699` | M3.5–4V | ~3200–3300 | ~10 Gyr | 0.0035 | ~0.16 | ~0.19 | Mega-MUSCLES | - | [GJ 729 (Ross 154)](https://simbad.cds.unistra.fr/simbad/sim-id?Ident=GJ+729) | `gj729` | M3.5V | ~3200–3300 | <1–2 Gyr | ~0.004–0.005 | ~0.18 | ~0.20 | Mega-MUSCLES | - | [GJ 832](https://exoplanetarchive.ipac.caltech.edu/overview/HIP%20106440) | `gj832` | M1–M3V | ~3500 | ~4–12 Gyr | ~0.03 | 0.45 | 0.45 | MUSCLES | - | GJ 832 (synthetic) | `gj832_synth` | M1–M3V | ~3500 | ~4–12 Gyr | ~0.03 | 0.45 | 0.45 | MUSCLES | + | [GJ 649](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20649) | `gj649` | M1-M2V | ~3700 | n/a | ~0.044 | 0.51 | 0.50 | Mega-MUSCLES | + | [GJ 667 C](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20667C) | `gj667c` | M1.5V | ~3700 | >2 Gyr | ~0.014 | ~0.33 | ~0.32-0.42 | MUSCLES | + | [GJ 674](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20674) | `gj674` | M2.5V | ~3400-3600 | ~0.5-3 Gyr | 0.017 | 0.35 | 0.36 | Mega-MUSCLES | + | [GJ 676 A](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20676A) | `gj676a` | M0V | ~3800-4000 | n/a | 0.083 | 0.63 | 0.65 | Mega-MUSCLES | + | [GJ 699 (Barnard's Star)](https://exoplanetarchive.ipac.caltech.edu/overview/barnard's%20star) | `gj699` | M3.5-4V | ~3200-3300 | ~10 Gyr | 0.0035 | ~0.16 | ~0.19 | Mega-MUSCLES | + | [GJ 729 (Ross 154)](https://simbad.cds.unistra.fr/simbad/sim-id?Ident=GJ+729) | `gj729` | M3.5V | ~3200-3300 | <1-2 Gyr | ~0.004-0.005 | ~0.18 | ~0.20 | Mega-MUSCLES | + | [GJ 832](https://exoplanetarchive.ipac.caltech.edu/overview/HIP%20106440) | `gj832` | M1-M3V | ~3500 | ~4-12 Gyr | ~0.03 | 0.45 | 0.45 | MUSCLES | + | GJ 832 (synthetic) | `gj832_synth` | M1-M3V | ~3500 | ~4-12 Gyr | ~0.03 | 0.45 | 0.45 | MUSCLES | | [GJ 849](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20849) | `gj849` | M3.5V | 3467 | >3 Gyr | 0.02887 | 0.45 | 0.45 | Mega-MUSCLES | - | [GJ 876](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20876) | `gj876` | M2–4V | ~3200–3300 | ~2–15 Gyr | ~0.013 | ~0.37 | ~0.37 | MUSCLES | - | [HAT-P-12](https://exoplanetarchive.ipac.caltech.edu/overview/HAT-P-12) | `hat-p-12` | K4V | 4500–4800 | 2–14 Gyr | ~0.20 | 0.74 | 0.70 | MUSCLES ext. | - | [HAT-P-26](https://exoplanetarchive.ipac.caltech.edu/overview/HAT-P-26) | `hat-p-26` | K1V | ~5050 | 4–12 Gyr | ~0.44 | 0.85 | 0.86 | MUSCLES ext. | - | [HD 40307](https://exoplanetarchive.ipac.caltech.edu/overview/HD%2040307) | `hd40307` | K2.5V | ~4800–5000 | ~2–5 Gyr | ~0.22 | ~0.79 | ~0.71 | MUSCLES | + | [GJ 876](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20876) | `gj876` | M2-4V | ~3200-3300 | ~2-15 Gyr | ~0.013 | ~0.37 | ~0.37 | MUSCLES | + | [HAT-P-12](https://exoplanetarchive.ipac.caltech.edu/overview/HAT-P-12) | `hat-p-12` | K4V | 4500-4800 | 2-14 Gyr | ~0.20 | 0.74 | 0.70 | MUSCLES ext. | + | [HAT-P-26](https://exoplanetarchive.ipac.caltech.edu/overview/HAT-P-26) | `hat-p-26` | K1V | ~5050 | 4-12 Gyr | ~0.44 | 0.85 | 0.86 | MUSCLES ext. | + | [HD 40307](https://exoplanetarchive.ipac.caltech.edu/overview/HD%2040307) | `hd40307` | K2.5V | ~4800-5000 | ~2-5 Gyr | ~0.22 | ~0.79 | ~0.71 | MUSCLES | | [HD 85512](https://exoplanetarchive.ipac.caltech.edu/overview/HD%2085512) | `hd85512` | M0V | ~4400 | ~6 Gyr | 0.17 | 0.69 | 0.69 | MUSCLES | | [HD 97658](https://exoplanetarchive.ipac.caltech.edu/overview/HD%2097658%20b) | `hd97658` | K1V | 5212 | 3.9 Gyr | 0.351 | 0.773 | 0.728 | MUSCLES | - | [HD 149026](https://exoplanetarchive.ipac.caltech.edu/overview/HD%20149026%20b) | `hd-149026` | G0V | ~6100–6200 | ~2–3 Gyr | ~2.6 | ~1.14 | ~1.46 | MUSCLES ext. | + | [HD 149026](https://exoplanetarchive.ipac.caltech.edu/overview/HD%20149026%20b) | `hd-149026` | G0V | ~6100-6200 | ~2-3 Gyr | ~2.6 | ~1.14 | ~1.46 | MUSCLES ext. | | [L 98-59](https://exoplanetarchive.ipac.caltech.edu/overview/L%2098-59) | `l-98-59` | M3V | 3415 | ~5 Gyr | 0.012 | 0.292 | 0.316 | Mega-MUSCLES | - | [L 678-39 (GJ 357)](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20357) | `l-678-39` | M2.5V | ~3500 | — | 0.0017 | ~0.34 | ~0.34 | MUSCLES ext. | - | [L 980-5](https://simbad.u-strasbg.fr/simbad/sim-id?Ident=l+980-5) | `l-980-5` | M4V | — | — | — | — | — | Mega-MUSCLES | - | [LHS 2686](https://simbad.cds.unistra.fr/simbad/sim-id?Ident=LHS+2686) | `lhs-2686` | M5V | — | — | — | — | — | Mega-MUSCLES | + | [L 678-39 (GJ 357)](https://exoplanetarchive.ipac.caltech.edu/overview/GJ%20357) | `l-678-39` | M2.5V | ~3500 | n/a | 0.0017 | ~0.34 | ~0.34 | MUSCLES ext. | + | [L 980-5](https://simbad.u-strasbg.fr/simbad/sim-id?Ident=l+980-5) | `l-980-5` | M4V | n/a | n/a | n/a | n/a | n/a | Mega-MUSCLES | + | [LHS 2686](https://simbad.cds.unistra.fr/simbad/sim-id?Ident=LHS+2686) | `lhs-2686` | M5V | n/a | n/a | n/a | n/a | n/a | Mega-MUSCLES | | [LP 791-18](https://exoplanetarchive.ipac.caltech.edu/overview/LP%20791-18) | `lp-791-18` | M6V | ~2960 | >0.5 Gyr | ~0.002 | 0.139 | 0.182 | MUSCLES ext. | - | [TOI-193 (LTT 9779)](https://exoplanetarchive.ipac.caltech.edu/overview/LTT%209779) | `toi-193` | G7V | ~5400–5500 | ~2 Gyr | ~0.7 | ~1.0 | ~0.95 | MUSCLES ext. | + | [TOI-193 (LTT 9779)](https://exoplanetarchive.ipac.caltech.edu/overview/LTT%209779) | `toi-193` | G7V | ~5400-5500 | ~2 Gyr | ~0.7 | ~1.0 | ~0.95 | MUSCLES ext. | | [TRAPPIST-1](https://exoplanetarchive.ipac.caltech.edu/overview/TRAPPIST-1) | `trappist-1` | M8V | 2566 | ~8 Gyr | 0.000553 | 0.0898 | 0.1192 | Mega-MUSCLES | - | [WASP-17](https://exoplanetarchive.ipac.caltech.edu/overview/WASP-17) | `wasp-17` | F4–F6V | ~6550 | ~3 Gyr | ~4 | 1.35 | 1.57 | MUSCLES ext. | + | [WASP-17](https://exoplanetarchive.ipac.caltech.edu/overview/WASP-17) | `wasp-17` | F4-F6V | ~6550 | ~3 Gyr | ~4 | 1.35 | 1.57 | MUSCLES ext. | | [WASP-43](https://exoplanetarchive.ipac.caltech.edu/overview/WASP-43) | `wasp-43` | K7V | ~4100 | ~7 Gyr | ~0.15 | ~0.65 | ~0.76 | MUSCLES ext. | | [WASP-77 A](https://exoplanetarchive.ipac.caltech.edu/overview/WASP-77%20A) | `wasp-77a` | G8V | ~5600 | ~6 Gyr | ~0.74 | ~0.90 | ~0.91 | MUSCLES ext. | - | [WASP-127](https://exoplanetarchive.ipac.caltech.edu/overview/WASP-127) | `wasp-127` | G5 | ~5600–5800 | ~10–12 Gyr | ~1.8 | ~0.95–1.10 | ~1.3 | MUSCLES ext. | + | [WASP-127](https://exoplanetarchive.ipac.caltech.edu/overview/WASP-127) | `wasp-127` | G5 | ~5600-5800 | ~10-12 Gyr | ~1.8 | ~0.95-1.10 | ~1.3 | MUSCLES ext. | ### PHOENIX synthetic spectra @@ -251,3 +251,23 @@ All equation-of-state tables are fetched through fwl-io into `$FWL_DATA/interior | `chabrier_2021` | Chabrier et al. hydrogen and helium tables | archive extracted into `r/EOS_Chabrier2021_HHe/` | Copies of these tables under `$FWL_DATA/zalmoxis_eos` and `$FWL_DATA/EOS_material_properties` are not read and can be deleted. + +--- + +## Melting curves and P-S lookup tables + +The `monteux_plus600`, `monteux_minus600` and `wolf_bower_2018` solidus/liquidus +curves used by `interior_struct.melting_dir` and by Zalmoxis are fetched +through fwl-io into `$FWL_DATA/interior_struct/melting_curves//r/`. +A curve generated locally by `tools/solidus_func.py` into +`$FWL_DATA/interior_lookup_tables/Melting_curves//` takes +precedence over the fetched copy; see [Melting curves](melting_curves.md) for +the full list of parametrizations and how to generate them. + +The Wolf and Bower (2018) pressure-entropy lookup table that SPIDER and +Aragog read for `eos_dir = "WolfBower2018_MgSiO3"` is fetched the same way, +into `$FWL_DATA/interior_struct/lookup/wolf_bower_2018_1tpa/r/`. A +local table under `$FWL_DATA/interior_lookup_tables/EOS/dynamic//P-S/` +takes precedence, and the table bundled with SPIDER +(`lookup_data/1TPa-dK09-elec-free/`) is used only when neither the local nor +the fetched table is available. diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index 3cb22298a..fe04b5f76 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -372,8 +372,9 @@ def __attrs_post_init__(self): if self.melting_dir is None: raise ValueError( 'interior_struct.melting_dir must be set when module = "spider". ' - 'Provide a melting curve folder name (e.g. "Monteux-600") from ' - 'FWL_DATA/interior_lookup_tables/Melting_curves/.' + 'Provide a melting curve name (e.g. "Monteux-600"): one of the ' + 'curves fetched into FWL_DATA/interior_struct/melting_curves/, or a ' + 'folder in FWL_DATA/interior_lookup_tables/Melting_curves/.' ) if self.eos_dir is None: raise ValueError( diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index caede8780..603a77e5e 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -31,6 +31,10 @@ EOS_PALEOS_MGSIO3_2PHASE_HIGHRES = 'interior_struct.eos.paleos_mgsio3_2phase_highres' EOS_PALEOS_UNIFIED = 'interior_struct.eos.paleos_unified' EOS_CHABRIER_2021 = 'interior_struct.eos.chabrier_2021' +LOOKUP_WOLF_BOWER_2018_1TPA = 'interior_struct.lookup.wolf_bower_2018_1tpa' +MELTING_MONTEUX_PLUS600 = 'interior_struct.melting_curves.monteux_plus600' +MELTING_MONTEUX_MINUS600 = 'interior_struct.melting_curves.monteux_minus600' +MELTING_WOLF_BOWER_2018 = 'interior_struct.melting_curves.wolf_bower_2018' STELLAR_SPECTRA_SOLAR = 'stellar_spectra.solar' STELLAR_SPECTRA_NAMED = 'stellar_spectra.named' STELLAR_SPECTRA_MUSCLES = 'stellar_spectra.muscles' diff --git a/src/proteus/data/interior_struct.lookup.wolf_bower_2018_1tpa.registry.txt b/src/proteus/data/interior_struct.lookup.wolf_bower_2018_1tpa.registry.txt new file mode 100644 index 000000000..00d3b021c --- /dev/null +++ b/src/proteus/data/interior_struct.lookup.wolf_bower_2018_1tpa.registry.txt @@ -0,0 +1,14 @@ +README.md md5:7f8275870d6ddcd373d6aa57f86853fb +adiabat_temp_grad_melt.dat md5:71793eaed354e6994553514828219e95 +adiabat_temp_grad_solid.dat md5:c4a4b6febfddcb3d5496b415949a1b29 +density_melt.dat md5:8aaa37a5ac723e248895e0e6eb609046 +density_solid.dat md5:29cbf080addf579106068a136594e24b +heat_capacity_melt.dat md5:2df07a56cb3c21cfb9669b01ef8f0ebc +heat_capacity_solid.dat md5:4d3dd3432b791684483ca67f2c6070fa +liquidus_P-S.dat md5:cc1a5a17ebb4c712c06e902b8d4c1121 +md5sums.txt md5:67fae063460fdcdc06a9e16d63baf733 +solidus_P-S.dat md5:6662d0c6b26dd4c97ce81cc4e751cba2 +temperature_melt.dat md5:de85936016692a509a86abaa36943651 +temperature_solid.dat md5:340202b286e9fb3117b6b5f6cac0bc31 +thermal_exp_melt.dat md5:f2983441e654c4d4285f9a0e563247da +thermal_exp_solid.dat md5:7567e301358dae17c34187172f8837f9 diff --git a/src/proteus/data/interior_struct.melting_curves.monteux_minus600.registry.txt b/src/proteus/data/interior_struct.melting_curves.monteux_minus600.registry.txt new file mode 100644 index 000000000..733d5c272 --- /dev/null +++ b/src/proteus/data/interior_struct.melting_curves.monteux_minus600.registry.txt @@ -0,0 +1,2 @@ +liquidus.dat md5:2003c48dfae8b7c020a5cbe5222e3710 +solidus.dat md5:6d48d841639c06f61a872656fc9a5b11 diff --git a/src/proteus/data/interior_struct.melting_curves.monteux_plus600.registry.txt b/src/proteus/data/interior_struct.melting_curves.monteux_plus600.registry.txt new file mode 100644 index 000000000..35794c0f8 --- /dev/null +++ b/src/proteus/data/interior_struct.melting_curves.monteux_plus600.registry.txt @@ -0,0 +1,2 @@ +liquidus.dat md5:7d652243d6b167a323ba5886fdde5d21 +solidus.dat md5:e70660c7ff400609fc3ed442ea0eac84 diff --git a/src/proteus/data/interior_struct.melting_curves.wolf_bower_2018.registry.txt b/src/proteus/data/interior_struct.melting_curves.wolf_bower_2018.registry.txt new file mode 100644 index 000000000..882026425 --- /dev/null +++ b/src/proteus/data/interior_struct.melting_curves.wolf_bower_2018.registry.txt @@ -0,0 +1,2 @@ +liquidus.dat md5:65efbd9f5b2de54109806c3b7f743708 +solidus.dat md5:66b297a120c79e7e58de5761ac40f4c4 diff --git a/src/proteus/data/proteus_manifest.toml b/src/proteus/data/proteus_manifest.toml index 0af34e121..cf1c0d080 100644 --- a/src/proteus/data/proteus_manifest.toml +++ b/src/proteus/data/proteus_manifest.toml @@ -119,44 +119,49 @@ required_by = ["proteus"] name = "Wolf & Bower (2018) MgSiO3 equation of state to 1 TPa, Zalmoxis tables" zenodo = "10.5281/zenodo.17417017" required_by = ["proteus"] -files = ["density_melt.dat", "density_solid.dat", "adiabat_temp_grad_melt.dat"] [interior_struct.eos.rtpress_100tpa] name = "RTPress MgSiO3 melt equation of state to 100 TPa, Zalmoxis tables" zenodo = "10.5281/zenodo.18819027" required_by = ["proteus"] -files = ["density_melt.dat", "adiabat_temp_grad_melt.dat"] [interior_struct.eos.paleos_mgsio3_2phase] name = "PALEOS MgSiO3 solid and liquid tables, 150 points per decade" zenodo = "10.5281/zenodo.19680050" required_by = ["proteus"] -files = [ - "paleos_mgsio3_tables_pt_proteus_liquid.dat", - "paleos_mgsio3_tables_pt_proteus_solid.dat", -] [interior_struct.eos.paleos_mgsio3_2phase_highres] name = "PALEOS MgSiO3 solid and liquid tables, 600 points per decade" zenodo = "10.5281/zenodo.19680050" required_by = ["proteus"] -files = [ - "paleos_mgsio3_tables_pt_proteus_liquid_highres.dat", - "paleos_mgsio3_tables_pt_proteus_solid_highres.dat", -] [interior_struct.eos.paleos_unified] name = "PALEOS unified tables for iron, MgSiO3 and water" zenodo = "10.5281/zenodo.22776069" required_by = ["proteus"] -files = [ - "paleos_iron_eos_table_pt.dat", - "paleos_mgsio3_eos_table_pt.dat", - "paleos_water_eos_table_pt.dat", -] [interior_struct.eos.chabrier_2021] name = "Chabrier et al. hydrogen and helium tables in PALEOS format" zenodo = "10.5281/zenodo.19135021" required_by = ["proteus"] extract = "tar" + +[interior_struct.lookup.wolf_bower_2018_1tpa] +name = "Wolf & Bower (2018) MgSiO3 phase-property and melting tables to 1 TPa, P-S format" +zenodo = "10.5281/zenodo.19473625" +required_by = ["proteus"] + +[interior_struct.melting_curves.monteux_plus600] +name = "Monteux et al. (2016) solidus and liquidus, +600 K liquidus offset" +zenodo = "10.5281/zenodo.15728091" +required_by = ["proteus"] + +[interior_struct.melting_curves.monteux_minus600] +name = "Monteux et al. (2016) solidus and liquidus, -600 K liquidus offset" +zenodo = "10.5281/zenodo.15728138" +required_by = ["proteus"] + +[interior_struct.melting_curves.wolf_bower_2018] +name = "Wolf & Bower (2018) MgSiO3 solidus and liquidus" +zenodo = "10.5281/zenodo.15728072" +required_by = ["proteus"] diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index b884aa150..f9022fafa 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -38,6 +38,7 @@ from proteus.interior_energetics.timestep import next_step from proteus.interior_energetics.wrapper import get_core_density, get_core_heatcap from proteus.utils.constants import radnuc_data +from proteus.utils.data import resolve_lookup_table_dir, resolve_melting_curve_files from proteus.utils.helper import format_subyear_time, parse_subyear_time, snapshot_path_for_time log = logging.getLogger('fwl.' + __name__) @@ -981,26 +982,17 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s 'PALEOS EOS file not found (%s), falling back to the shipped EOS tables', paleos_eos_file, ) - LOOK_UP_DIR = ( - FWL_DATA_DIR - / 'interior_lookup_tables' - / '1TPa-dK09-elec-free' - / 'MgSiO3_Wolf_Bower_2018_1TPa' - ) + LOOK_UP_DIR = resolve_lookup_table_dir(data_root=FWL_DATA_DIR) else: - # Shipped EOS tables; used when interior_struct.eos_dir is - # None (no dynamic EOS selected) or when the dynamic path - # does not resolve to a populated directory. The "EOS/dynamic" - # tree is only materialised when zalmoxis pre-generates - # PALEOS tables; outside that pathway it is empty. - legacy_lookup = ( - FWL_DATA_DIR - / 'interior_lookup_tables' - / '1TPa-dK09-elec-free' - / 'MgSiO3_Wolf_Bower_2018_1TPa' - ) + # Fetched Wolf and Bower 2018 tables; used when + # interior_struct.eos_dir is None (no dynamic EOS selected) or + # when the dynamic path does not resolve to a populated + # directory. The "EOS/dynamic" tree is only materialised when + # zalmoxis pre-generates PALEOS tables; outside that pathway + # it is empty. + default_lookup = resolve_lookup_table_dir(data_root=FWL_DATA_DIR) if config.interior_struct.eos_dir is None: - LOOK_UP_DIR = legacy_lookup + LOOK_UP_DIR = default_lookup else: LOOK_UP_DIR = ( FWL_DATA_DIR @@ -1011,7 +1003,7 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s / 'P-T' ) if not (LOOK_UP_DIR / 'heat_capacity_melt.dat').is_file(): - LOOK_UP_DIR = legacy_lookup + LOOK_UP_DIR = default_lookup # Determine melting curves. When using PALEOS EOS via Zalmoxis, # generate PALEOS-derived melting curves so Aragog uses the SAME # solidus/liquidus as SPIDER (PALEOS-liquidus * mushy_zone_factor). @@ -1030,10 +1022,8 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s 'interior_struct.melting_dir must be set for non-PALEOS EOS. ' 'Provide a melting curve folder name (e.g. "Monteux-600").' ) - MELTING_DIR = FWL_DATA_DIR / 'interior_lookup_tables/Melting_curves/' - solidus_path = MELTING_DIR / config.interior_struct.melting_dir / 'solidus_P-T.dat' - liquidus_path = ( - MELTING_DIR / config.interior_struct.melting_dir / 'liquidus_P-T.dat' + solidus_path, liquidus_path = resolve_melting_curve_files( + config.interior_struct.melting_dir, data_root=FWL_DATA_DIR ) # check data exist diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index a4ee37714..ba72768e2 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -10,6 +10,7 @@ from scipy.special import erf from proteus.utils.constants import B_ein +from proteus.utils.data import find_lookup_table_dir if TYPE_CHECKING: from aragog.eos.entropy import EntropyEOS @@ -649,8 +650,9 @@ def _load_ps_table(self, spider_dir: str, eos_dir: str, filename: str) -> np.nda (E_th computation in :func:`spider.ReadSPIDER`). All three files share the same on-disk layout. - Search order: FWL_DATA dynamic EOS directory first, then - SPIDER's bundled ``lookup_data/1TPa-dK09-elec-free``. + Search order: FWL_DATA dynamic EOS directory, then the fetched + Wolf and Bower 2018 tables, then SPIDER's bundled + ``lookup_data/1TPa-dK09-elec-free``. Parameters ---------- @@ -678,9 +680,13 @@ def _load_ps_table(self, spider_dir: str, eos_dir: str, filename: str) -> np.nda filename, ) local_path = os.path.join(spider_dir, 'lookup_data', '1TPa-dK09-elec-free', filename) + fetched = find_lookup_table_dir() + fetched_path = str(fetched / filename) if fetched else '' if os.path.isfile(fwl_path): filepath = fwl_path + elif fetched and os.path.isfile(fetched_path): + filepath = fetched_path elif os.path.isfile(local_path): filepath = local_path else: diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index 640d37eab..155ac2c9f 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -16,6 +16,7 @@ from proteus.interior_energetics.common import Interior_t, get_file_tides from proteus.interior_energetics.timestep import next_step from proteus.utils.constants import radnuc_data +from proteus.utils.data import find_lookup_table_dir from proteus.utils.helper import UpdateStatusfile, natural_sort, recursive_get if TYPE_CHECKING: @@ -874,7 +875,7 @@ def _try_spider( call_sequence.extend(['-htidal_filename', get_file_tides(dirs['output'])]) # EOS lookup data: prefer per-run generated tables (from Zalmoxis/PALEOS), - # then FWL_DATA, then SPIDER local as final fallback. + # then a local EOS directory, then the fetched dataset, then SPIDER local. if dirs.get('spider_eos_dir') and os.path.isdir(dirs['spider_eos_dir']): eos_dir = dirs['spider_eos_dir'] log.debug('Using Zalmoxis-generated SPIDER EOS tables from %s', eos_dir) @@ -886,8 +887,14 @@ def _try_spider( ) eos_dir = os.path.join(EOS_DYNAMIC_DIR, config.interior_struct.eos_dir, 'P-S') if not os.path.isdir(eos_dir): - # Fall back to SPIDER's local lookup_data directory - eos_dir = os.path.join(dirs['spider'], 'lookup_data', '1TPa-dK09-elec-free') + # The fetched Wolf and Bower 2018 tables are in P-S format and + # SPIDER reads them in place; the SPIDER submodule is the last resort. + fetched = find_lookup_table_dir() + eos_dir = ( + str(fetched) + if fetched + else os.path.join(dirs['spider'], 'lookup_data', '1TPa-dK09-elec-free') + ) if not os.path.isdir(eos_dir): raise FileNotFoundError( f'SPIDER EOS directory not found: {eos_dir}. ' diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index c592a473b..fb664bf03 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -711,11 +711,10 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: melting_dir = getattr(config.interior_struct, 'melting_dir', None) derive_melting = melting_dir is not None if derive_melting: - from proteus.utils.data import GetFWLData as _GetFWL + from proteus.utils.data import resolve_melting_curve_files - melting_pt_dir = _GetFWL() / 'interior_lookup_tables' / 'Melting_curves' / melting_dir - sol_pt_path = melting_pt_dir / 'solidus_P-T.dat' - liq_pt_path = melting_pt_dir / 'liquidus_P-T.dat' + sol_pt_path, liq_pt_path = resolve_melting_curve_files(melting_dir) + melting_pt_dir = sol_pt_path.parent if not (sol_pt_path.is_file() and liq_pt_path.is_file()): log.warning( 'melting_dir=%s configured but P-T files missing at %s; ' @@ -756,15 +755,9 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: # Import lazily so the helper is usable outside of a full PROTEUS # install (e.g. unit tests that stub out FWL_DATA). - from proteus.utils.data import GetFWLData - - fwl_data = GetFWLData() - zenodo_root = ( - fwl_data - / 'interior_lookup_tables' - / '1TPa-dK09-elec-free' - / 'MgSiO3_Wolf_Bower_2018_1TPa' - ) + from proteus.utils.data import resolve_lookup_table_dir + + zenodo_root = resolve_lookup_table_dir() # Case 2: FWL_DATA (Zenodo 19473625) complete set. # We check BOTH that all 12 files exist AND that density_melt.dat diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 425efc768..b585c5325 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -5,7 +5,6 @@ import logging import os import re -import shutil import subprocess as sp import zipfile from pathlib import Path @@ -29,8 +28,6 @@ MAX_DLTIME = 120.0 # seconds RETRY_WAIT = 5.0 # seconds -ARAGOG_BASIC = ('Melting_curves/Wolf_Bower+2018',) - log.debug(f'FWL data location: {FWL_DATA_DIR}') @@ -445,44 +442,10 @@ def validate_zenodo_folder(zenodo_id: str, folder_dir: Path, hash_maxfilesize=10 # Unified mapping of folder names to both Zenodo and OSF identifiers # Structure: folder_name -> {'zenodo_id': str, 'osf_id': str, 'osf_project': str} DATA_SOURCE_MAP: dict[str, dict[str, str]] = { - # Interior lookup tables (OSF project: phsxf) - '1TPa-dK09-elec-free/MgSiO3_Wolf_Bower_2018': { - 'zenodo_id': '15877374', - 'osf_id': 'phsxf', - 'osf_project': 'phsxf', - }, - '1TPa-dK09-elec-free/MgSiO3_Wolf_Bower_2018_400GPa': { - 'zenodo_id': '15877424', - 'osf_id': 'phsxf', - 'osf_project': 'phsxf', - }, - '1TPa-dK09-elec-free/MgSiO3_Wolf_Bower_2018_1TPa': { - # Zenodo 19473625: complete P-S format tables (10 phase-property files - # + 2 P-S melting curves + README + md5sums). Used by BOTH SPIDER and - # Aragog at runtime. - 'zenodo_id': '19473625', - 'osf_id': 'phsxf', - 'osf_project': 'phsxf', - }, - # Melting curves (OSF project: phsxf) - 'Melting_curves/Monteux+600': { - 'zenodo_id': '15728091', - 'osf_id': 'phsxf', - 'osf_project': 'phsxf', - }, - 'Melting_curves/Monteux-600': { - 'zenodo_id': '15728138', - 'osf_id': 'phsxf', - 'osf_project': 'phsxf', - }, - 'Melting_curves/Wolf_Bower+2018': { - 'zenodo_id': '15728072', - 'osf_id': 'phsxf', - 'osf_project': 'phsxf', - }, # The surface albedos, the Seager EOS tables, the exoplanet catalogue, the - # mass-radius relations, the solar, Named, MUSCLES and PHOENIX stellar spectra and the - # Zalmoxis equations of state are declared in + # mass-radius relations, the solar, Named, MUSCLES and PHOENIX stellar spectra, the + # Zalmoxis equations of state, the interior lookup tables and the melting + # curves are declared in # src/proteus/data/proteus_manifest.toml and fetched through fwl-io, so their # record pins live there and are absent here. # Aerosol scattering data (no OSF project) @@ -1246,100 +1209,178 @@ def download_muscles(stars: str | list[str] | None = None, *, force: bool = Fals return False -def download_interior_lookuptables(clean=False): +def _melting_curve_keys() -> dict[str, str]: + """Return the manifest key of each melting-curve name served by fwl-io. + + Returns + ------- + dict + Maps a ``interior_struct.melting_dir`` value to a dotted manifest key. """ - Download basic interior lookup tables + from proteus.data import ( + MELTING_MONTEUX_MINUS600, + MELTING_MONTEUX_PLUS600, + MELTING_WOLF_BOWER_2018, + ) + + return { + 'Monteux+600': MELTING_MONTEUX_PLUS600, + 'Monteux-600': MELTING_MONTEUX_MINUS600, + 'Wolf_Bower+2018': MELTING_WOLF_BOWER_2018, + } + + +def resolve_melting_curve_files( + melting_dir: str, data_root: Path | str | None = None +) -> tuple[Path, Path]: + """Locate the P-T solidus and liquidus files of one melting curve. + + A local directory ``interior_lookup_tables/Melting_curves/`` + that holds both ``solidus_P-T.dat`` and ``liquidus_P-T.dat`` is used first, + so a directory the user supplies or generates takes precedence. Otherwise a + name served through the manifest (``Monteux+600``, ``Monteux-600``, + ``Wolf_Bower+2018``) resolves to ``solidus.dat`` and ``liquidus.dat`` in its + versioned dataset directory. Any other name resolves to the local paths, + which the caller reports as missing. + + Parameters + ---------- + melting_dir : str + Value of ``interior_struct.melting_dir``. + data_root : Path or str, optional + Data root; defaults to ``FWL_DATA``. + + Returns + ------- + tuple of Path + Solidus and liquidus file paths. The files are not checked to exist + when the name is served through the manifest. """ - log.debug('Download basic interior lookup tables') + from proteus.data import dataset_dir - data_dir = GetFWLData() / 'interior_lookup_tables' - data_dir.mkdir(parents=True, exist_ok=True) + root = GetFWLData() if data_root is None else Path(data_root) + local = root / 'interior_lookup_tables' / 'Melting_curves' / melting_dir + solidus, liquidus = local / 'solidus_P-T.dat', local / 'liquidus_P-T.dat' + if solidus.is_file() and liquidus.is_file(): + return solidus, liquidus - for dir in ARAGOG_BASIC: - folder_dir = data_dir / dir - if clean: - safe_rm(folder_dir.as_posix()) - source_info = get_data_source_info(dir) - if not source_info: - log.warning(f'No data source mapping found for {dir}, skipping') - continue + key = _melting_curve_keys().get(melting_dir) + if key is None: + return solidus, liquidus + folder = dataset_dir(key, data_root=root) + return folder / 'solidus.dat', folder / 'liquidus.dat' - download( - folder=dir, - target=data_dir, - osf_id=source_info['osf_project'], - zenodo_id=source_info['zenodo_id'], - desc=f'Interior lookup tables: {dir}', - ) +def resolve_lookup_table_dir(data_root: Path | str | None = None) -> Path: + """Return the directory of the Wolf and Bower 2018 P-S lookup tables. -def download_melting_curves(config: Config, clean: bool = False): + Parameters + ---------- + data_root : Path or str, optional + Data root; defaults to ``FWL_DATA``. + + Returns + ------- + Path + Versioned dataset directory of the tables. The directory is not + checked to hold the files. """ - Ensure melting curve data are available locally. + from proteus.data import LOOKUP_WOLF_BOWER_2018_1TPA, dataset_dir + + root = GetFWLData() if data_root is None else Path(data_root) + return dataset_dir(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=root) + - Expected layout: - interior_lookup_tables/ - Melting_curves/ - / - solidus_P-T.dat - liquidus_P-T.dat - solidus_P-S.dat - liquidus_P-S.dat +def find_lookup_table_dir(data_root: Path | str | None = None) -> Path | None: + """Return the fetched Wolf and Bower 2018 P-S table directory if populated. + + Parameters + ---------- + data_root : Path or str, optional + Data root; defaults to ``FWL_DATA``. + + Returns + ------- + Path or None + The dataset directory when it holds the SPIDER phase files, else None + (also when the data root cannot be created). """ - log.debug('Download melting curve data') - if config.interior_struct.melting_dir is None: - log.debug('melting_dir is None, skipping melting curve download') - return - rel_dir = Path('Melting_curves') / config.interior_struct.melting_dir + try: + folder = resolve_lookup_table_dir(data_root) + except OSError: + return None + if (folder / 'thermal_exp_melt.dat').is_file() and (folder / 'density_melt.dat').is_file(): + return folder + return None - data_dir = GetFWLData() / 'interior_lookup_tables' - data_dir.mkdir(parents=True, exist_ok=True) - folder_dir = data_dir / rel_dir +def download_interior_lookuptables(clean=False): + """Fetch the melting curves that Aragog and SPIDER always need. + + The Wolf and Bower 2018 melting curves are fetched through fwl-io into + ``FWL_DATA/interior_struct/melting_curves/wolf_bower_2018/r/``. + + Parameters + ---------- + clean : bool + Remove the fetched dataset first, so it is fetched again. + """ + from proteus.data import MELTING_WOLF_BOWER_2018, dataset_dir, fetch_dataset + log.debug('Fetch basic interior lookup tables') + root = GetFWLData() if clean: - safe_rm(folder_dir.as_posix()) - - # ------------------------------------------------------------------ - # Canonical flat layout: if files already exist locally, do not download. - # ------------------------------------------------------------------ - solidus_pt = folder_dir / 'solidus_P-T.dat' - liquidus_pt = folder_dir / 'liquidus_P-T.dat' - solidus_ps = folder_dir / 'solidus_P-S.dat' - liquidus_ps = folder_dir / 'liquidus_P-S.dat' - - if all(p.is_file() for p in (solidus_pt, liquidus_pt, solidus_ps, liquidus_ps)): - log.debug('Melting curve data already present locally: %s', folder_dir) + safe_rm(str(dataset_dir(MELTING_WOLF_BOWER_2018, data_root=root))) + fetch_dataset(MELTING_WOLF_BOWER_2018, data_root=root) + + +def download_melting_curves(config: Config, clean: bool = False): + """Ensure the melting curve named in the configuration is available. + + A local directory ``interior_lookup_tables/Melting_curves/`` + with ``solidus_P-T.dat`` and ``liquidus_P-T.dat`` is used as it is and + nothing is fetched. Otherwise ``Monteux+600``, ``Monteux-600`` and + ``Wolf_Bower+2018`` are fetched through fwl-io into + ``FWL_DATA/interior_struct/melting_curves//r/``. + + Parameters + ---------- + config : Config + Configuration object; ``interior_struct.melting_dir`` names the curve. + clean : bool + Remove the fetched dataset first, so it is fetched again. A local + directory is never removed. + + Raises + ------ + ValueError + If the name is neither served through the manifest nor present as a + local directory. + """ + from proteus.data import dataset_dir, fetch_dataset + + log.debug('Fetch melting curve data') + melting_dir = config.interior_struct.melting_dir + if melting_dir is None: + log.debug('melting_dir is None, skipping melting curve download') return - # ------------------------------------------------------------------ - # Fallback: try remote source mapping. - # ------------------------------------------------------------------ - source_info = get_data_source_info(rel_dir.as_posix()) - if not source_info: - raise ValueError( - f'No data source mapping found for folder: {rel_dir}. ' - f'Also did not find local melting curve data in: {folder_dir}' - ) + root = GetFWLData() + key = _melting_curve_keys().get(melting_dir) + if clean and key is not None: + safe_rm(str(dataset_dir(key, data_root=root))) - download( - folder=rel_dir.as_posix(), - target=data_dir, - osf_id=source_info['osf_project'], - zenodo_id=source_info['zenodo_id'], - desc=f'Melting curve data: {rel_dir}', - ) + local = root / 'interior_lookup_tables' / 'Melting_curves' / melting_dir + if (local / 'solidus_P-T.dat').is_file() and (local / 'liquidus_P-T.dat').is_file(): + log.debug('Melting curve data already present locally: %s', local) + return - # ------------------------------------------------------------------ - # If the download contains solidus.dat / liquidus.dat, treat them as - # P-T and create canonical *_P-T.dat copies. - # ------------------------------------------------------------------ - for stem in ('solidus', 'liquidus'): - legacy = folder_dir / f'{stem}.dat' - canonical = folder_dir / f'{stem}_P-T.dat' - if legacy.is_file() and not canonical.is_file(): - shutil.copy2(legacy, canonical) - log.debug('Copied %s -> %s', legacy.name, canonical.name) + if key is None: + raise ValueError( + f"No dataset serves melting_dir='{melting_dir}' and no local melting " + f'curve files were found in: {local}' + ) + fetch_dataset(key, data_root=root) def download_stellar_spectra(*, folders: tuple[str, ...] | None = None): @@ -1770,48 +1811,30 @@ def download_eos_static(): def download_eos_dynamic(eos_dir: str = 'WolfBower2018_MgSiO3'): - """Download dynamic EOS files from Zenodo 19473625. - - Downloads into - ``FWL_DATA/interior_lookup_tables/1TPa-dK09-elec-free/MgSiO3_Wolf_Bower_2018_1TPa/``. - The record provides the complete P-S set that both SPIDER and - Aragog consume at runtime: 10 phase-property files (temperature, - density, heat capacity, adiabatic gradient, thermal expansivity - for melt and solid) plus the two P-S melting curves - (``solidus_P-S.dat``, ``liquidus_P-S.dat``). - - After download, the function verifies that all 12 expected files - landed in the target directory and raises a clear error if any are - missing. This prevents silent downstream failures where Aragog's - ``EntropyEOS`` would otherwise crash with a confusing - ``FileNotFoundError`` for a single missing file. + """Fetch the Wolf and Bower 2018 P-S lookup tables through fwl-io. + + The tables land in + ``FWL_DATA/interior_struct/lookup/wolf_bower_2018_1tpa/r19473625/``. The + record provides the complete P-S set that both SPIDER and Aragog consume + at runtime: 10 phase-property files (temperature, density, heat capacity, + adiabatic gradient, thermal expansivity for melt and solid) plus the two + P-S melting curves (``solidus_P-S.dat``, ``liquidus_P-S.dat``). + + After the fetch, the function warns if any of the 12 expected files is + missing from the dataset directory. Parameters ---------- eos_dir : str Name of the dynamic EOS folder. Reserved for future multi-EOS - support; currently always resolves to - ``1TPa-dK09-elec-free/MgSiO3_Wolf_Bower_2018_1TPa``. + support; currently always resolves to the dataset above. """ - folder = '1TPa-dK09-elec-free/MgSiO3_Wolf_Bower_2018_1TPa' - source_info = get_data_source_info(folder) - if not source_info: - log.warning(f'No data source mapping for dynamic EOS: {folder}') - return + from proteus.data import LOOKUP_WOLF_BOWER_2018_1TPA, dataset_dir, fetch_dataset - download( - folder=folder, - target='interior_lookup_tables', - osf_id=source_info['osf_project'], - zenodo_id=source_info['zenodo_id'], - desc=f'Dynamic EOS (P-S): {folder}', - ) + fwl_data = GetFWLData() + fetch_dataset(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=fwl_data) + target_dir = dataset_dir(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=fwl_data) - # Manifest validation: verify all 12 expected files are present. - # A partial download means the PROTEUS helpers will silently fall - # back to the SPIDER submodule at runtime, which is a warning sign - # that the user's FWL_DATA tree is stale or the Zenodo record - # contents have drifted. expected_files = ( 'temperature_melt.dat', 'temperature_solid.dat', @@ -1826,25 +1849,20 @@ def download_eos_dynamic(eos_dir: str = 'WolfBower2018_MgSiO3'): 'solidus_P-S.dat', 'liquidus_P-S.dat', ) - target_dir = GetFWLData() / 'interior_lookup_tables' / folder missing = [f for f in expected_files if not (target_dir / f).is_file()] if missing: log.warning( - 'Zenodo record %s download landed at %s but is missing %d of ' - '%d expected files: %s. Aragog will fall back to the SPIDER ' - 'submodule at runtime via _provide_spider_eos_tables. To ' - 'refresh, delete %s and rerun with clean=True.', - source_info['zenodo_id'], + 'Lookup tables at %s are missing %d of %d expected files: %s. ' + 'Aragog will fall back to the SPIDER submodule at runtime via ' + '_provide_spider_eos_tables.', target_dir, len(missing), len(expected_files), missing[:5], - target_dir, ) else: log.debug( - 'Zenodo record %s complete: all %d files present at %s', - source_info['zenodo_id'], + 'Lookup tables complete: all %d files present at %s', len(expected_files), target_dir, ) @@ -2030,8 +2048,8 @@ def load_melting_curve(melt_file): def get_zalmoxis_melting_curves(config: Config): """Load solidus and liquidus T(P) melting curves for Zalmoxis. - Reads the melting curve files from the directory specified by - ``config.interior_struct.melting_dir`` under ``FWL_DATA/interior_lookup_tables/Melting_curves/``. + Reads the melting curve files that :func:`resolve_melting_curve_files` + locates for ``config.interior_struct.melting_dir``. Parameters ---------- @@ -2045,19 +2063,17 @@ def get_zalmoxis_melting_curves(config: Config): """ if config.interior_struct.melting_dir is None: return None - melting_curves_folder = ( - FWL_DATA_DIR - / 'interior_lookup_tables' - / 'Melting_curves' - / config.interior_struct.melting_dir + solidus_file, liquidus_file = resolve_melting_curve_files( + config.interior_struct.melting_dir, data_root=FWL_DATA_DIR ) - if not melting_curves_folder.is_dir(): - raise FileNotFoundError( - f'Melting curves directory not found: {melting_curves_folder}. ' - f"Check struct.melting_dir='{config.interior_struct.melting_dir}'." - ) - solidus_func = load_melting_curve(melting_curves_folder / 'solidus_P-T.dat') - liquidus_func = load_melting_curve(melting_curves_folder / 'liquidus_P-T.dat') + for melting_file in (solidus_file, liquidus_file): + if not melting_file.is_file(): + raise FileNotFoundError( + f'Melting curve file not found: {melting_file}. ' + f"Check struct.melting_dir='{config.interior_struct.melting_dir}'." + ) + solidus_func = load_melting_curve(solidus_file) + liquidus_func = load_melting_curve(liquidus_file) return (solidus_func, liquidus_func) @@ -2065,12 +2081,7 @@ def get_zalmoxis_EOS(): """Build and return material properties dictionaries for Zalmoxis. The Seager2007 tables are fetched through fwl-io into their versioned - dataset directory. Dynamic EOS like Wolf & Bower 2018 are read from the - unified folder structure under ``FWL_DATA/interior_lookup_tables/EOS/`` - (``EOS/dynamic/WolfBower2018_MgSiO3/P-T/``). - - The folder name matches the Zalmoxis ``mantle_eos`` source string - (``WolfBower2018_MgSiO3``), not ``struct.eos_dir``. + dataset directory, as are the Wolf & Bower 2018 and RTPress 100 TPa tables. Returns ------- @@ -2079,26 +2090,19 @@ def get_zalmoxis_EOS(): water planet, and iron/RTPress100TPa silicate EOS. T-dep dicts include ``cp_file`` entries for heat capacity tables when the files exist. """ - eos_base = FWL_DATA_DIR / 'interior_lookup_tables' / 'EOS' - - from proteus.data import EOS_SEAGER_2007, dataset_dir + from proteus.data import ( + EOS_RTPRESS_100TPA, + EOS_SEAGER_2007, + EOS_WOLF_BOWER_2018, + dataset_dir, + ) seager_folder = dataset_dir(EOS_SEAGER_2007, data_root=FWL_DATA_DIR) if not (seager_folder / 'eos_seager07_iron.txt').exists(): log.debug('Get EOS material properties from Seager et al. (2007)') download_eos_static() - # Wolf-Bower: fixed mapping from source name to data folder - wb_folder = eos_base / 'dynamic' / 'WolfBower2018_MgSiO3' / 'P-T' - if not wb_folder.exists(): - wb_folder = eos_base / 'dynamic' / 'WolfBower2018_MgSiO3' - if not wb_folder.exists(): - wb_folder = ( - FWL_DATA_DIR - / 'interior_lookup_tables' - / '1TPa-dK09-elec-free' - / 'MgSiO3_Wolf_Bower_2018_1TPa' - ) + wb_folder = dataset_dir(EOS_WOLF_BOWER_2018, data_root=FWL_DATA_DIR) # Iron/silicate (Seager 2007) material_properties_iron_silicate_planets = { @@ -2131,9 +2135,7 @@ def get_zalmoxis_EOS(): } # RTPress100TPa melt EOS with WolfBower2018 solid - rt_folder = eos_base / 'RTPress_melt_100TPa' - if not rt_folder.exists(): - rt_folder = FWL_DATA_DIR / 'EOS_material_properties' / 'EOS_RTPress_melt_100TPa' + rt_folder = dataset_dir(EOS_RTPRESS_100TPA, data_root=FWL_DATA_DIR) if not rt_folder.exists(): log.warning( 'RTPress100TPa EOS folder not found at %s. ' diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index 313f1ba8c..765a07b5a 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -32,7 +32,11 @@ EOS_WOLF_BOWER_2018, EXOPLANET_REFERENCE, FWL_IO_FLOOR, + LOOKUP_WOLF_BOWER_2018_1TPA, MASS_RADIUS_ZENG_2019, + MELTING_MONTEUX_MINUS600, + MELTING_MONTEUX_PLUS600, + MELTING_WOLF_BOWER_2018, STELLAR_SPECTRA_MUSCLES, STELLAR_SPECTRA_NAMED, STELLAR_SPECTRA_PHOENIX, @@ -63,6 +67,10 @@ PALEOS_2PHASE_RECORD = '19680050' PALEOS_UNIFIED_RECORD = '22776069' CHABRIER_RECORD = '19135021' +LOOKUP_WOLF_BOWER_2018_1TPA_RECORD = '19473625' +MELTING_MONTEUX_PLUS600_RECORD = '15728091' +MELTING_MONTEUX_MINUS600_RECORD = '15728138' +MELTING_WOLF_BOWER_2018_RECORD = '15728072' # Equation-of-state datasets: key -> (subdir, record). EOS_DATASETS = { @@ -80,6 +88,26 @@ EOS_CHABRIER_2021: ('interior_struct/eos/chabrier_2021', CHABRIER_RECORD), } +# Interior lookup table and melting curve datasets: key -> (subdir, record). +LOOKUP_AND_MELTING_DATASETS = { + LOOKUP_WOLF_BOWER_2018_1TPA: ( + 'interior_struct/lookup/wolf_bower_2018_1tpa', + LOOKUP_WOLF_BOWER_2018_1TPA_RECORD, + ), + MELTING_MONTEUX_PLUS600: ( + 'interior_struct/melting_curves/monteux_plus600', + MELTING_MONTEUX_PLUS600_RECORD, + ), + MELTING_MONTEUX_MINUS600: ( + 'interior_struct/melting_curves/monteux_minus600', + MELTING_MONTEUX_MINUS600_RECORD, + ), + MELTING_WOLF_BOWER_2018: ( + 'interior_struct/melting_curves/wolf_bower_2018', + MELTING_WOLF_BOWER_2018_RECORD, + ), +} + REPO_ROOT = Path(__file__).resolve().parents[2] # Spectral-file datasets: (group, bands) -> Zenodo record, one dataset each. @@ -109,6 +137,7 @@ STELLAR_SPECTRA_MUSCLES, STELLAR_SPECTRA_PHOENIX, *EOS_DATASETS, + *LOOKUP_AND_MELTING_DATASETS, } | {spectral_file_key(group, bands) for group, bands in SPECTRAL_RECORDS} @@ -154,6 +183,9 @@ def test_manifest_declares_the_datasets(): for key, (subdir, record) in EOS_DATASETS.items(): assert datasets[key].subdir == subdir assert datasets[key].zenodo == f'10.5281/zenodo.{record}' + for key, (subdir, record) in LOOKUP_AND_MELTING_DATASETS.items(): + assert datasets[key].subdir == subdir + assert datasets[key].zenodo == f'10.5281/zenodo.{record}' # All are PROTEUS-owned, so "proteus" has to appear in required_by or # "fwl-io fetch proteus" would skip them. for ds in datasets.values(): @@ -180,6 +212,10 @@ def test_registries_pin_committed_checksums(): paleos_highres = _dataset(EOS_PALEOS_MGSIO3_2PHASE_HIGHRES).registry() paleos_unified = _dataset(EOS_PALEOS_UNIFIED).registry() chabrier = _dataset(EOS_CHABRIER_2021).registry() + lookup = _dataset(LOOKUP_WOLF_BOWER_2018_1TPA).registry() + monteux_plus600 = _dataset(MELTING_MONTEUX_PLUS600).registry() + monteux_minus600 = _dataset(MELTING_MONTEUX_MINUS600).registry() + melting_wolf_bower = _dataset(MELTING_WOLF_BOWER_2018).registry() assert len(exo) == 1, 'the catalogue ships exactly one file' assert len(zeng) == 57, 'the Zeng-2019 grid ships 57 curve files' @@ -216,6 +252,15 @@ def test_registries_pin_committed_checksums(): } assert set(chabrier) == {'EOS_Chabrier2021_HHe.tar.gz'} assert chabrier['EOS_Chabrier2021_HHe.tar.gz'] == 'md5:18ce96ed0526d4ade283807a7da2e091' + assert len(lookup) == 14, 'the Wolf-Bower lookup-table record ships 14 files' + assert set(monteux_plus600) == {'liquidus.dat', 'solidus.dat'} + assert set(monteux_minus600) == {'liquidus.dat', 'solidus.dat'} + assert set(melting_wolf_bower) == {'liquidus.dat', 'solidus.dat'} + assert lookup['thermal_exp_melt.dat'] == 'md5:f2983441e654c4d4285f9a0e563247da' + assert lookup['density_melt.dat'] == 'md5:8aaa37a5ac723e248895e0e6eb609046' + assert monteux_plus600['solidus.dat'] == 'md5:e70660c7ff400609fc3ed442ea0eac84' + assert monteux_minus600['solidus.dat'] == 'md5:6d48d841639c06f61a872656fc9a5b11' + assert melting_wolf_bower['solidus.dat'] == 'md5:66b297a120c79e7e58de5761ac40f4c4' assert solar['sun.txt'] == 'md5:6e4b6540d952cf3c01a3bd0511aa6c10' assert named['sun.txt'] == 'md5:0c5225b847ca250673edc9690f02e055' assert muscles['gj876.txt'] == 'md5:4b4e7299bad9545ee8fef82cb4c8d4d8' @@ -238,6 +283,10 @@ def test_registries_pin_committed_checksums(): paleos_highres, paleos_unified, chabrier, + lookup, + monteux_plus600, + monteux_minus600, + melting_wolf_bower, ): assert all(':' in digest for digest in registry.values()) assert 'massradiusEarthlikeRocky.txt' in zeng @@ -283,6 +332,8 @@ def test_dataset_dir_is_versioned(tmp_path): ) for key, (subdir, record) in EOS_DATASETS.items(): assert dataset_dir(key, data_root=tmp_path) == tmp_path / subdir / f'r{record}' + for key, (subdir, record) in LOOKUP_AND_MELTING_DATASETS.items(): + assert dataset_dir(key, data_root=tmp_path) == tmp_path / subdir / f'r{record}' assert dataset_dir(STELLAR_SPECTRA_SOLAR, data_root=tmp_path) == ( tmp_path / 'stellar_spectra' / 'solar' / f'r{SOLAR_RECORD}' ) diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 796729d41..1ee4dd4ca 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -74,6 +74,16 @@ def _make_aragog_config(*, struct_module='spider', mantle_eos='Seager2007:silica return config +def _seed_lookup_tables(root): + """Create the versioned Wolf and Bower lookup dataset directory under ``root``.""" + from proteus.data import LOOKUP_WOLF_BOWER_2018_1TPA, dataset_dir + + folder = dataset_dir(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=root) + folder.mkdir(parents=True, exist_ok=True) + (folder / 'heat_capacity_melt.dat').write_text('dummy') + return folder + + @pytest.mark.unit def test_setup_solver_zalmoxis_inner_radius(tmp_path): """setup_solver reads R_core from hf_row when struct.module='zalmoxis'.""" @@ -98,11 +108,7 @@ def test_setup_solver_zalmoxis_inner_radius(tmp_path): interior_o._spider_eos_dir = str(spider_eos_dir) # Create EOS dir - eos_dir = ( - tmp_path / 'interior_lookup_tables' / 'EOS' / 'dynamic' / 'WolfBower2018_MgSiO3' / 'P-T' - ) - eos_dir.mkdir(parents=True) - (eos_dir / 'heat_capacity_melt.dat').write_text('dummy') + _seed_lookup_tables(tmp_path) mc_dir = tmp_path / 'interior_lookup_tables' / 'Melting_curves' mc_dir.mkdir(parents=True) @@ -151,11 +157,7 @@ def test_setup_solver_zalmoxis_wolfbower_temp(tmp_path): spider_eos_dir.mkdir(parents=True) interior_o._spider_eos_dir = str(spider_eos_dir) - eos_dir = ( - tmp_path / 'interior_lookup_tables' / 'EOS' / 'dynamic' / 'WolfBower2018_MgSiO3' / 'P-T' - ) - eos_dir.mkdir(parents=True) - (eos_dir / 'heat_capacity_melt.dat').write_text('dummy') + _seed_lookup_tables(tmp_path) mc_dir = tmp_path / 'interior_lookup_tables' / 'Melting_curves' mc_dir.mkdir(parents=True) @@ -181,7 +183,7 @@ def test_setup_solver_zalmoxis_wolfbower_temp(tmp_path): @pytest.mark.unit def test_setup_solver_eos_fallback(tmp_path): - """setup_solver falls back to legacy EOS path when unified path is missing.""" + """setup_solver reads the lookup tables from the versioned dataset directory.""" from proteus.interior_energetics.aragog import AragogRunner outdir = str(tmp_path) @@ -200,15 +202,7 @@ def test_setup_solver_eos_fallback(tmp_path): spider_eos_dir.mkdir(parents=True) interior_o._spider_eos_dir = str(spider_eos_dir) - # Only create legacy path, NOT unified path - legacy_dir = ( - tmp_path - / 'interior_lookup_tables' - / '1TPa-dK09-elec-free' - / 'MgSiO3_Wolf_Bower_2018_1TPa' - ) - legacy_dir.mkdir(parents=True) - (legacy_dir / 'heat_capacity_melt.dat').write_text('dummy') + _seed_lookup_tables(tmp_path) mc_dir = tmp_path / 'interior_lookup_tables' / 'Melting_curves' mc_dir.mkdir(parents=True) @@ -221,10 +215,7 @@ def test_setup_solver_eos_fallback(tmp_path): AragogRunner.setup_solver(config, hf_row, interior_o, outdir) assert mock_solver.called - # Fallback-path discriminator: the solver must have been instantiated - # exactly once (the fallback path runs the setup body to completion; - # a regression that retried after the unified-path miss could call - # the solver more than once or zero times via a swallowed exception). + # The setup body must run to completion exactly once. assert mock_solver.call_count == 1 @@ -540,7 +531,7 @@ def _caps_only_energy_stub( def _spider_fallback_scaffold(tmp_path): - """Build the (hf_row, interior_o) inputs and the legacy EOS/melting dirs a + """Build the (hf_row, interior_o) inputs and the lookup and melting dirs a spider-stack setup_solver needs to reach the _EnergyParameters call.""" hf_row = { 'R_int': 6.371e6, @@ -555,14 +546,7 @@ def _spider_fallback_scaffold(tmp_path): spider_eos_dir.mkdir(parents=True) interior_o._spider_eos_dir = str(spider_eos_dir) - legacy_dir = ( - tmp_path - / 'interior_lookup_tables' - / '1TPa-dK09-elec-free' - / 'MgSiO3_Wolf_Bower_2018_1TPa' - ) - legacy_dir.mkdir(parents=True) - (legacy_dir / 'heat_capacity_melt.dat').write_text('dummy') + _seed_lookup_tables(tmp_path) (tmp_path / 'interior_lookup_tables' / 'Melting_curves').mkdir(parents=True) return hf_row, interior_o diff --git a/tests/interior_energetics/test_common.py b/tests/interior_energetics/test_common.py index 2f2da2839..aa4493d04 100644 --- a/tests/interior_energetics/test_common.py +++ b/tests/interior_energetics/test_common.py @@ -141,15 +141,13 @@ def test_load_ps_table_both_missing(tmp_path): with pytest.MonkeyPatch.context() as mp: mp.setenv('FWL_DATA', str(tmp_path / 'nonexistent')) mp.setattr('proteus.utils.data.FWL_DATA_DIR', tmp_path / 'nonexistent', raising=False) + mp.setattr('proteus.interior_energetics.common.find_lookup_table_dir', lambda: None) result = interior_o._load_ps_table( str(tmp_path / 'also_nonexistent'), 'SomeEOS', 'density_melt.dat', ) - assert result is None # both-missing branch must yield None silently - # Discriminating check: neither candidate path exists on disk; only the - # both-missing branch can have produced the None return on this row. - assert not (tmp_path / 'nonexistent').exists() + assert result is None # all three sources are missing: None, no exception assert not (tmp_path / 'also_nonexistent').exists() diff --git a/tests/interior_energetics/test_spider.py b/tests/interior_energetics/test_spider.py index 50e85b12e..847e11686 100644 --- a/tests/interior_energetics/test_spider.py +++ b/tests/interior_energetics/test_spider.py @@ -1235,9 +1235,10 @@ def test_try_spider_missing_eos_dir(tmp_path): dirs, config, hf_row, _, mc_base, _ = _setup_spider_env(tmp_path) - # Both FWL_DATA EOS path and SPIDER-local fallback (lookup_data/) are absent + # The local EOS path, the fetched lookup dataset and the SPIDER-local fallback are absent with ( patch('proteus.interior_energetics.spider.EOS_DYNAMIC_DIR', '/nonexistent/eos'), + patch('proteus.interior_energetics.spider.find_lookup_table_dir', return_value=None), patch('proteus.interior_energetics.spider.MELTING_CURVES_DIR', mc_base), patch('proteus.interior_energetics.spider.sp.run') as mock_run, patch( @@ -1299,7 +1300,7 @@ def test_try_spider_missing_melting_curves(tmp_path): @pytest.mark.unit def test_try_spider_eos_fallback_to_local(tmp_path): - """EOS dir resolves to SPIDER local fallback when FWL_DATA path missing.""" + """EOS dir resolves to the SPIDER local fallback when no other EOS source exists.""" from proteus.interior_energetics.spider import _try_spider dirs, config, hf_row, _, mc_base, _ = _setup_spider_env(tmp_path) @@ -1312,6 +1313,7 @@ def test_try_spider_eos_fallback_to_local(tmp_path): with ( patch('proteus.interior_energetics.spider.EOS_DYNAMIC_DIR', '/nonexistent/eos'), + patch('proteus.interior_energetics.spider.find_lookup_table_dir', return_value=None), patch('proteus.interior_energetics.spider.MELTING_CURVES_DIR', mc_base), patch('proteus.interior_energetics.spider.sp.run') as mock_run, patch( @@ -1337,6 +1339,45 @@ def test_try_spider_eos_fallback_to_local(tmp_path): assert '1TPa-dK09-elec-free' in call_args[idx + 1] +@pytest.mark.unit +def test_try_spider_eos_uses_fetched_lookup_dataset(tmp_path): + """With no local EOS dir, SPIDER receives the fetched lookup dataset directory.""" + from proteus.interior_energetics.spider import _try_spider + + dirs, config, hf_row, _, mc_base, _ = _setup_spider_env(tmp_path) + + fetched = tmp_path / 'fetched_lookup' + fetched.mkdir() + for name in _EOS_FILE_NAMES: + _make_eos_table(str(fetched / name)) + + with ( + patch('proteus.interior_energetics.spider.EOS_DYNAMIC_DIR', '/nonexistent/eos'), + patch('proteus.interior_energetics.spider.find_lookup_table_dir', return_value=fetched), + patch('proteus.interior_energetics.spider.MELTING_CURVES_DIR', mc_base), + patch('proteus.interior_energetics.spider.sp.run') as mock_run, + patch( + 'proteus.interior_energetics.common.compute_initial_entropy', + return_value=3000.0, + ), + ): + mock_run.return_value = MagicMock(returncode=0) + result = _try_spider( + dirs, + config, + IC_INTERIOR=1, + hf_all=None, + hf_row=hf_row, + step_sf=1.0, + atol_sf=1.0, + ) + + assert result is True + call_args = mock_run.call_args[0][0] + idx = call_args.index('-melt_rho_filename') + assert call_args[idx + 1] == str(fetched / 'density_melt.dat') + + @pytest.mark.unit def test_try_spider_subprocess_timeout(tmp_path): """SPIDER subprocess timeout returns False.""" diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index ac4fbd8ee..bfe07af80 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -46,8 +46,8 @@ def test_get_zenodo_record(): Ensures that known configuration keys map to correct Zenodo repository IDs. """ - # Known mapping: interior lookup table - assert get_zenodo_record('1TPa-dK09-elec-free/MgSiO3_Wolf_Bower_2018') == '15877374' + # Known mapping: aerosol scattering data + assert get_zenodo_record('scattering') == '19294180' # Unknown key should return None safely assert get_zenodo_record('Unknown/Folder') is None @@ -1064,11 +1064,8 @@ def test_get_osf_project(): @pytest.mark.unit def test_get_zenodo_from_osf(): """Test reverse lookup: OSF project -> Zenodo IDs.""" - zenodo_ids = get_zenodo_from_osf('phsxf') - assert len(zenodo_ids) > 0 - # 19473625 is the complete P-S format record used by SPIDER + Aragog at - # runtime (supersedes the partial P-T record 17417017 from 2024). - assert '19473625' in zenodo_ids + zenodo_ids = get_zenodo_from_osf('vehxg') + assert zenodo_ids == ['19294180'] # Test unknown OSF project zenodo_ids = get_zenodo_from_osf('unknown') @@ -1692,70 +1689,261 @@ def fake_fetch(key, name, data_root=None): @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -@patch('proteus.utils.data.safe_rm') -def test_download_interior_lookuptables(mock_rm, mock_getfwl, mock_download, tmp_path): - """Test interior lookup tables download.""" - from proteus.utils.data import ARAGOG_BASIC, download_interior_lookuptables +@patch('proteus.data.fetch_dataset') +def test_download_interior_lookuptables(mock_fetch, tmp_path, monkeypatch): + """The always-needed Wolf and Bower melting curves are fetched through fwl-io.""" + from proteus.data import MELTING_WOLF_BOWER_2018 + from proteus.utils.data import download_interior_lookuptables - mock_getfwl.return_value = tmp_path + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) download_interior_lookuptables(clean=False) - # Should download each directory in ARAGOG_BASIC - assert mock_download.call_count == len(ARAGOG_BASIC) - for dir_name in ARAGOG_BASIC: - # Check that download was called with correct folder - calls = [ - call for call in mock_download.call_args_list if call.kwargs['folder'] == dir_name - ] - assert len(calls) == 1 + mock_fetch.assert_called_once_with(MELTING_WOLF_BOWER_2018, data_root=tmp_path) @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -@patch('proteus.utils.data.safe_rm') -def test_download_interior_lookuptables_clean(mock_rm, mock_getfwl, mock_download, tmp_path): - """Test interior lookup tables download with clean=True.""" - from proteus.utils.data import ARAGOG_BASIC, download_interior_lookuptables +@patch('proteus.data.fetch_dataset') +def test_download_interior_lookuptables_clean(mock_fetch, tmp_path, monkeypatch): + """``clean=True`` removes the fetched dataset directory, then fetches again.""" + from proteus.data import MELTING_WOLF_BOWER_2018, dataset_dir + from proteus.utils.data import download_interior_lookuptables - mock_getfwl.return_value = tmp_path + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + folder = dataset_dir(MELTING_WOLF_BOWER_2018, data_root=tmp_path) + folder.mkdir(parents=True) + stale = folder / 'stale.dat' + stale.write_text('old') download_interior_lookuptables(clean=True) - # Should have called safe_rm for each directory - assert mock_rm.call_count == len(ARAGOG_BASIC) - # Discrimination: clean=True must still trigger the download for each - # cleaned directory; a regression that early-returned after the rm - # sweep would leave mock_download.call_count at zero. - assert mock_download.call_count == len(ARAGOG_BASIC) + assert not stale.exists() + mock_fetch.assert_called_once_with(MELTING_WOLF_BOWER_2018, data_root=tmp_path) @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -@patch('proteus.utils.data.safe_rm') -def test_download_melting_curves(mock_rm, mock_getfwl, mock_download, tmp_path): - """Test melting curves download.""" - from unittest.mock import MagicMock +@pytest.mark.parametrize( + 'name, attr', + [ + ('Monteux+600', 'MELTING_MONTEUX_PLUS600'), + ('Monteux-600', 'MELTING_MONTEUX_MINUS600'), + ('Wolf_Bower+2018', 'MELTING_WOLF_BOWER_2018'), + ], +) +@patch('proteus.data.fetch_dataset') +def test_download_melting_curves_fetches_known_name( + mock_fetch, name, attr, tmp_path, monkeypatch +): + """Each of the three named curves is fetched from its own manifest dataset.""" + import proteus.data as data_mod + from proteus.utils.data import download_melting_curves - from proteus.config import Config + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + config = MagicMock() + config.interior_struct.melting_dir = name + + download_melting_curves(config, clean=False) + + mock_fetch.assert_called_once_with(getattr(data_mod, attr), data_root=tmp_path) + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset') +def test_download_melting_curves_local_dir_wins(mock_fetch, tmp_path, monkeypatch): + """A local directory with both P-T files is used and nothing is fetched.""" from proteus.utils.data import download_melting_curves - mock_getfwl.return_value = tmp_path + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Wolf_Bower+2018' + local.mkdir(parents=True) + for name in ('solidus_P-T.dat', 'liquidus_P-T.dat'): + (local / name).write_text('dummy\n') + config = MagicMock() + config.interior_struct.melting_dir = 'Wolf_Bower+2018' - # Create mock config with melting_dir - mock_config = MagicMock(spec=Config) - mock_config.interior_struct.melting_dir = 'Wolf_Bower+2018' + download_melting_curves(config, clean=False) - download_melting_curves(mock_config, clean=False) + mock_fetch.assert_not_called() - mock_download.assert_called_once() - call_kwargs = mock_download.call_args.kwargs - assert call_kwargs['folder'] == 'Melting_curves/Wolf_Bower+2018' - assert call_kwargs['desc'] == 'Melting curve data: Melting_curves/Wolf_Bower+2018' + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset') +def test_download_melting_curves_incomplete_local_dir_fetches( + mock_fetch, tmp_path, monkeypatch +): + """A local directory with only one of the two P-T files does not count as present.""" + from proteus.data import MELTING_WOLF_BOWER_2018 + from proteus.utils.data import download_melting_curves + + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Wolf_Bower+2018' + local.mkdir(parents=True) + (local / 'solidus_P-T.dat').write_text('dummy\n') + config = MagicMock() + config.interior_struct.melting_dir = 'Wolf_Bower+2018' + + download_melting_curves(config, clean=False) + + mock_fetch.assert_called_once_with(MELTING_WOLF_BOWER_2018, data_root=tmp_path) + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset') +def test_download_melting_curves_unknown_name_raises(mock_fetch, tmp_path, monkeypatch): + """A name that no dataset serves and no local directory holds is an error.""" + from proteus.utils.data import download_melting_curves + + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + config = MagicMock() + config.interior_struct.melting_dir = 'UnknownCurve' + + with pytest.raises(ValueError, match='No dataset serves melting_dir'): + download_melting_curves(config) + mock_fetch.assert_not_called() + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset') +def test_download_melting_curves_unknown_name_local_dir_ok(mock_fetch, tmp_path, monkeypatch): + """A free-form local directory with both P-T files is accepted without a fetch.""" + from proteus.utils.data import download_melting_curves + + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'MyCurve' + local.mkdir(parents=True) + for name in ('solidus_P-T.dat', 'liquidus_P-T.dat'): + (local / name).write_text('dummy\n') + config = MagicMock() + config.interior_struct.melting_dir = 'MyCurve' + + download_melting_curves(config) + + mock_fetch.assert_not_called() + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset') +def test_download_melting_curves_clean_removes_only_fetched_dataset( + mock_fetch, tmp_path, monkeypatch +): + """``clean=True`` removes the fetched dataset and leaves a local directory alone.""" + from proteus.data import MELTING_WOLF_BOWER_2018, dataset_dir + from proteus.utils.data import download_melting_curves + + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + fetched = dataset_dir(MELTING_WOLF_BOWER_2018, data_root=tmp_path) + fetched.mkdir(parents=True) + stale = fetched / 'stale.dat' + stale.write_text('old') + local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Wolf_Bower+2018' + local.mkdir(parents=True) + keep = local / 'keep.dat' + keep.write_text('mine') + config = MagicMock() + config.interior_struct.melting_dir = 'Wolf_Bower+2018' + + download_melting_curves(config, clean=True) + + assert not stale.exists() + assert keep.read_text() == 'mine' + mock_fetch.assert_called_once_with(MELTING_WOLF_BOWER_2018, data_root=tmp_path) + + +@pytest.mark.unit +def test_resolve_melting_curve_files_manifest_layout(tmp_path): + """A known name without a local directory resolves to the versioned dataset files.""" + from proteus.utils.data import resolve_melting_curve_files + + expected = { + 'Monteux+600': ('monteux_plus600', 'r15728091'), + 'Monteux-600': ('monteux_minus600', 'r15728138'), + 'Wolf_Bower+2018': ('wolf_bower_2018', 'r15728072'), + } + for name, (folder, rec) in expected.items(): + solidus, liquidus = resolve_melting_curve_files(name, data_root=tmp_path) + base = tmp_path / 'interior_struct' / 'melting_curves' / folder / rec + assert solidus == base / 'solidus.dat' + assert liquidus == base / 'liquidus.dat' + + +@pytest.mark.unit +def test_resolve_melting_curve_files_local_first(tmp_path): + """A complete local directory takes precedence over the manifest dataset.""" + from proteus.utils.data import resolve_melting_curve_files + + local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Wolf_Bower+2018' + local.mkdir(parents=True) + (local / 'solidus_P-T.dat').write_text('s') + (local / 'liquidus_P-T.dat').write_text('l') + + solidus, liquidus = resolve_melting_curve_files('Wolf_Bower+2018', data_root=tmp_path) + + assert solidus == local / 'solidus_P-T.dat' + assert liquidus == local / 'liquidus_P-T.dat' + + +@pytest.mark.unit +def test_resolve_melting_curve_files_partial_local_falls_back(tmp_path): + """One local file alone does not shadow the manifest dataset.""" + from proteus.utils.data import resolve_melting_curve_files + + local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Monteux+600' + local.mkdir(parents=True) + (local / 'liquidus_P-T.dat').write_text('l') + + solidus, liquidus = resolve_melting_curve_files('Monteux+600', data_root=tmp_path) + + assert 'interior_struct' in solidus.parts + assert solidus.name == 'solidus.dat' and liquidus.name == 'liquidus.dat' + + +@pytest.mark.unit +def test_resolve_melting_curve_files_unknown_name_gives_local_paths(tmp_path): + """An unknown name resolves to the local P-T paths, which the caller reports.""" + from proteus.utils.data import resolve_melting_curve_files + + solidus, liquidus = resolve_melting_curve_files('Free_Form', data_root=tmp_path) + + local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Free_Form' + assert (solidus, liquidus) == (local / 'solidus_P-T.dat', local / 'liquidus_P-T.dat') + assert not tmp_path.joinpath('interior_struct').exists() + + +@pytest.mark.unit +def test_resolve_lookup_table_dir_layout(tmp_path): + """The lookup tables resolve to the versioned dataset directory of record 19473625.""" + from proteus.utils.data import resolve_lookup_table_dir + + folder = resolve_lookup_table_dir(data_root=tmp_path) + + assert ( + folder == tmp_path / 'interior_struct' / 'lookup' / 'wolf_bower_2018_1tpa' / 'r19473625' + ) + + +@pytest.mark.unit +def test_find_lookup_table_dir(tmp_path): + """The directory is returned only once it holds the SPIDER phase files.""" + from proteus.utils.data import find_lookup_table_dir, resolve_lookup_table_dir + + assert find_lookup_table_dir(data_root=tmp_path) is None + + folder = resolve_lookup_table_dir(data_root=tmp_path) + folder.mkdir(parents=True, exist_ok=True) + (folder / 'density_melt.dat').write_text('x') + assert find_lookup_table_dir(data_root=tmp_path) is None + + (folder / 'thermal_exp_melt.dat').write_text('x') + assert find_lookup_table_dir(data_root=tmp_path) == folder + + +@pytest.mark.unit +def test_find_lookup_table_dir_unwritable_root_gives_none(): + """A data root that cannot be created gives None, not an error.""" + from proteus.utils.data import find_lookup_table_dir + + with patch('proteus.data.dataset_dir', side_effect=OSError('read-only')): + assert find_lookup_table_dir(data_root='/nonexistent') is None @pytest.mark.unit @@ -2234,85 +2422,6 @@ def test_download_stellar_spectra_no_mapping(mock_get_info): mock_get_info.assert_called() -@pytest.mark.unit -@patch('proteus.utils.data.get_data_source_info') -def test_download_melting_curves_no_mapping(mock_get_info): - """Test melting curves download raises error when no mapping found.""" - from unittest.mock import MagicMock - - from proteus.config import Config - from proteus.utils.data import download_melting_curves - - mock_config = MagicMock(spec=Config) - mock_config.interior_struct.melting_dir = 'UnknownCurve' - mock_get_info.return_value = None - - with pytest.raises(ValueError, match='No data source mapping found'): - download_melting_curves(mock_config) - # Discrimination: confirm the registry lookup ran with the configured - # melting_dir; a regression that raised before consulting the registry - # would still pass the raises-match check. - mock_get_info.assert_called() - - -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -@patch('proteus.utils.data.safe_rm') -def test_download_melting_curves_canonical_copy(mock_rm, mock_getfwl, mock_download, tmp_path): - """Test canonical P-T copy from legacy Zenodo names (solidus.dat → solidus_P-T.dat).""" - from proteus.utils.data import download_melting_curves - - mock_getfwl.return_value = tmp_path - - # Create legacy files that Zenodo would download - mc_dir = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Wolf_Bower+2018' - mc_dir.mkdir(parents=True) - (mc_dir / 'solidus.dat').write_text('solidus data') - (mc_dir / 'liquidus.dat').write_text('liquidus data') - - mock_config = MagicMock() - mock_config.interior_struct.melting_dir = 'Wolf_Bower+2018' - - download_melting_curves(mock_config, clean=False) - - # Canonical copies should be created - assert (mc_dir / 'solidus_P-T.dat').exists() - assert (mc_dir / 'liquidus_P-T.dat').exists() - assert (mc_dir / 'solidus_P-T.dat').read_text() == 'solidus data' - - -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -@patch('proteus.utils.data.safe_rm') -def test_download_melting_curves_canonical_skip_existing( - mock_rm, mock_getfwl, mock_download, tmp_path -): - """Canonical copy is skipped when P-T file already exists.""" - from proteus.utils.data import download_melting_curves - - mock_getfwl.return_value = tmp_path - - mc_dir = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Wolf_Bower+2018' - mc_dir.mkdir(parents=True) - (mc_dir / 'solidus.dat').write_text('old solidus') - (mc_dir / 'solidus_P-T.dat').write_text('existing canonical') - (mc_dir / 'liquidus.dat').write_text('old liquidus') - - mock_config = MagicMock() - mock_config.interior_struct.melting_dir = 'Wolf_Bower+2018' - - download_melting_curves(mock_config, clean=False) - - # Should NOT overwrite existing canonical file - assert (mc_dir / 'solidus_P-T.dat').read_text() == 'existing canonical' - # Discrimination: the legacy source file must also be untouched (a - # regression that re-copied from solidus.dat to solidus_P-T.dat - # without the skip-guard would have rewritten the canonical file). - assert (mc_dir / 'solidus.dat').read_text() == 'old solidus' - - @pytest.mark.unit @patch('proteus.utils.data.get_data_source_info') def test_download_scattering_no_mapping(mock_get_info): @@ -3081,35 +3190,19 @@ def test_seager_fallback_families_match_registry(): @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.get_data_source_info') -def test_download_eos_dynamic_calls_download(mock_info, mock_dl): - """download_eos_dynamic calls download with the legacy folder path.""" +@patch('proteus.data.fetch_dataset') +def test_download_eos_dynamic_fetches_lookup_dataset(mock_fetch, tmp_path, monkeypatch): + """download_eos_dynamic fetches the Wolf and Bower P-S lookup dataset through fwl-io.""" + from proteus.data import LOOKUP_WOLF_BOWER_2018_1TPA, dataset_dir from proteus.utils.data import download_eos_dynamic - mock_info.return_value = {'osf_project': 'abc123', 'zenodo_id': '999'} - download_eos_dynamic('WolfBower2018_MgSiO3') - - mock_dl.assert_called_once() - call_kwargs = mock_dl.call_args - assert 'interior_lookup_tables' in str(call_kwargs) - - -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.get_data_source_info') -def test_download_eos_dynamic_no_mapping(mock_info, mock_dl): - """download_eos_dynamic returns early when no source mapping found.""" - from proteus.utils.data import download_eos_dynamic + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + target_dir = dataset_dir(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=tmp_path) + mock_fetch.return_value = [target_dir / 'temperature_melt.dat'] - mock_info.return_value = None - download_eos_dynamic('UnknownEOS') + download_eos_dynamic('WolfBower2018_MgSiO3') - mock_dl.assert_not_called() - # Discrimination: confirm the mapping lookup actually ran with the - # caller's key; otherwise assert_not_called could pass on a regression - # that early-exited before consulting the registry at all. - mock_info.assert_called_once() + mock_fetch.assert_called_once_with(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=tmp_path) @pytest.mark.unit @@ -3127,38 +3220,6 @@ def test_download_eos_static_delegates(mock_seager): assert mock_seager.call_args == ((), {}) -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -def test_download_melting_curves_skips_when_canonical_files_exist( - mock_getfwl, mock_download, tmp_path -): - """download_melting_curves should return early when all canonical files already exist.""" - from unittest.mock import MagicMock - - from proteus.utils.data import download_melting_curves - - mock_getfwl.return_value = tmp_path - - mc_dir = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Wolf_Bower+2018' - mc_dir.mkdir(parents=True, exist_ok=True) - - for name in ['solidus_P-T.dat', 'liquidus_P-T.dat', 'solidus_P-S.dat', 'liquidus_P-S.dat']: - (mc_dir / name).write_text('dummy\n') - - mock_config = MagicMock() - mock_config.interior_struct.melting_dir = 'Wolf_Bower+2018' - - download_melting_curves(mock_config, clean=False) - - mock_download.assert_not_called() - # Discrimination: pre-existing dummy files must remain unmodified; a - # regression that issued a Zenodo download anyway would either rewrite - # them or leave the directory tree in a different state. - for name in ['solidus_P-T.dat', 'liquidus_P-T.dat', 'solidus_P-S.dat', 'liquidus_P-S.dat']: - assert (mc_dir / name).read_text() == 'dummy\n' - - # ============================================================================ # AGNI spectral_file dispatch: skip group/bands download when user-provided # ============================================================================ @@ -4497,27 +4558,6 @@ def test_download_scattering_no_mapping_raises(mock_info): mock_info.assert_called_once() -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.get_data_source_info', return_value=None) -@patch('proteus.utils.data.GetFWLData') -def test_download_interior_lookuptables_warns_and_skips_unmapped( - mock_getfwl, mock_info, mock_download, tmp_path, caplog -): - """When the source mapping is missing, the function warns and does not call download().""" - from proteus.utils.data import download_interior_lookuptables - - mock_getfwl.return_value = tmp_path - - with caplog.at_level('WARNING'): - download_interior_lookuptables(clean=False) - - # Discrimination: the registry lookup was attempted but download - # was skipped because the mapping is None. - mock_info.assert_called() - mock_download.assert_not_called() - - # ============================================================================ # download_melting_curves additional coverage # ============================================================================ @@ -4546,118 +4586,75 @@ def test_download_melting_curves_none_dir_is_noop(mock_getfwl, mock_download, tm mock_getfwl.assert_not_called() -@pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') -def test_download_melting_curves_clean_removes_dir(mock_getfwl, mock_download, tmp_path): - """When clean=True, the folder_dir is removed before download is attempted.""" - from unittest.mock import MagicMock - - from proteus.utils.data import download_melting_curves - - mock_getfwl.return_value = tmp_path - - folder_dir = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Wolf_Bower+2018' - folder_dir.mkdir(parents=True, exist_ok=True) - stale = folder_dir / 'stale.dat' - stale.write_text('old') - - config = MagicMock() - config.interior_struct.melting_dir = 'Wolf_Bower+2018' - - download_melting_curves(config, clean=True) - - # Discrimination: clean=True must have removed the stale file - # before consulting the source mapping; download() may or may not - # have been invoked depending on the flat-layout shortcut. - assert not stale.exists() - # The parent directory itself is still intact (safe_rm removed only - # the contents under folder_dir during cleanup). - assert tmp_path.exists() - - # ============================================================================ # download_eos_dynamic manifest validation coverage # ============================================================================ +_LOOKUP_EXPECTED_FILES = [ + 'temperature_melt.dat', + 'temperature_solid.dat', + 'density_melt.dat', + 'density_solid.dat', + 'heat_capacity_melt.dat', + 'heat_capacity_solid.dat', + 'adiabat_temp_grad_melt.dat', + 'adiabat_temp_grad_solid.dat', + 'thermal_exp_melt.dat', + 'thermal_exp_solid.dat', + 'solidus_P-S.dat', + 'liquidus_P-S.dat', +] + + @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') +@patch('proteus.data.fetch_dataset') def test_download_eos_dynamic_manifest_complete_no_warning( - mock_getfwl, mock_download, tmp_path, caplog + mock_fetch, tmp_path, monkeypatch, caplog ): - """When all 12 expected files are present, no manifest-incomplete warning fires.""" + """When all 12 expected files are present, no missing-file warning fires.""" + from proteus.data import LOOKUP_WOLF_BOWER_2018_1TPA, dataset_dir from proteus.utils.data import download_eos_dynamic - mock_getfwl.return_value = tmp_path - mock_download.return_value = True - - target_dir = ( - tmp_path - / 'interior_lookup_tables' - / '1TPa-dK09-elec-free' - / 'MgSiO3_Wolf_Bower_2018_1TPa' - ) + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + target_dir = dataset_dir(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=tmp_path) target_dir.mkdir(parents=True, exist_ok=True) - expected_files = [ - 'temperature_melt.dat', - 'temperature_solid.dat', - 'density_melt.dat', - 'density_solid.dat', - 'heat_capacity_melt.dat', - 'heat_capacity_solid.dat', - 'adiabat_temp_grad_melt.dat', - 'adiabat_temp_grad_solid.dat', - 'thermal_exp_melt.dat', - 'thermal_exp_solid.dat', - 'solidus_P-S.dat', - 'liquidus_P-S.dat', - ] - for fname in expected_files: + for fname in _LOOKUP_EXPECTED_FILES: (target_dir / fname).write_text('data') + mock_fetch.return_value = [target_dir / fname for fname in _LOOKUP_EXPECTED_FILES] with caplog.at_level('WARNING'): download_eos_dynamic('WolfBower2018_MgSiO3') - # Discrimination: no "missing" warning should have fired. - missing_warnings = [r for r in caplog.records if 'missing' in r.getMessage().lower()] - assert missing_warnings == [] - # And the download function was called. - mock_download.assert_called_once() + assert [r for r in caplog.records if 'missing' in r.getMessage().lower()] == [] + mock_fetch.assert_called_once() @pytest.mark.unit -@patch('proteus.utils.data.download') -@patch('proteus.utils.data.GetFWLData') +@patch('proteus.data.fetch_dataset') def test_download_eos_dynamic_manifest_incomplete_warns( - mock_getfwl, mock_download, tmp_path, caplog + mock_fetch, tmp_path, monkeypatch, caplog ): - """When some expected files are missing, the manifest-incomplete warning fires.""" + """When files are missing, the warning names the count of missing files.""" + from proteus.data import LOOKUP_WOLF_BOWER_2018_1TPA, dataset_dir from proteus.utils.data import download_eos_dynamic - mock_getfwl.return_value = tmp_path - mock_download.return_value = True - - target_dir = ( - tmp_path - / 'interior_lookup_tables' - / '1TPa-dK09-elec-free' - / 'MgSiO3_Wolf_Bower_2018_1TPa' - ) + monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + target_dir = dataset_dir(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=tmp_path) target_dir.mkdir(parents=True, exist_ok=True) - # Only create 2 of 12 files (target_dir / 'density_melt.dat').write_text('data') (target_dir / 'density_solid.dat').write_text('data') + mock_fetch.return_value = [ + target_dir / 'density_melt.dat', + target_dir / 'density_solid.dat', + ] with caplog.at_level('WARNING'): download_eos_dynamic('WolfBower2018_MgSiO3') - # Discrimination: the manifest warning must include the count of - # missing files (10 of 12) so consumers can act on it. messages = ' '.join(r.getMessage() for r in caplog.records) - assert 'missing' in messages.lower() or 'fall back' in messages.lower() - mock_download.assert_called_once() + assert 'missing 10 of 12' in messages + mock_fetch.assert_called_once() # ============================================================================ @@ -5144,9 +5141,7 @@ def test_get_zalmoxis_melting_curves_none_dir_returns_none(monkeypatch, tmp_path @pytest.mark.unit def test_get_zalmoxis_melting_curves_missing_dir_raises(monkeypatch, tmp_path): - """When the melting curves directory is missing, FileNotFoundError is raised.""" - from unittest.mock import MagicMock - + """When a free-form melting curve directory is absent, FileNotFoundError names the file.""" import proteus.utils.data as data_mod from proteus.utils.data import get_zalmoxis_melting_curves @@ -5155,14 +5150,59 @@ def test_get_zalmoxis_melting_curves_missing_dir_raises(monkeypatch, tmp_path): config = MagicMock() config.interior_struct.melting_dir = 'NonexistentCurve' - with pytest.raises(FileNotFoundError, match='Melting curves'): + with pytest.raises(FileNotFoundError, match='Melting curve file not found'): get_zalmoxis_melting_curves(config) - # Discrimination: confirm the directory really does not exist (so - # the error path was reached for the right reason). missing = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'NonexistentCurve' assert not missing.exists() +@pytest.mark.unit +def test_get_zalmoxis_melting_curves_reads_manifest_dataset(monkeypatch, tmp_path): + """A known name without a local directory is read from its versioned dataset.""" + import proteus.utils.data as data_mod + from proteus.data import MELTING_MONTEUX_PLUS600, dataset_dir + from proteus.utils.data import get_zalmoxis_melting_curves + + monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) + folder = dataset_dir(MELTING_MONTEUX_PLUS600, data_root=tmp_path) + folder.mkdir(parents=True) + (folder / 'solidus.dat').write_text('1e9 2000\n2e9 3000\n') + (folder / 'liquidus.dat').write_text('1e9 2500\n2e9 3500\n') + + config = MagicMock() + config.interior_struct.melting_dir = 'Monteux+600' + + sol, liq = get_zalmoxis_melting_curves(config) + + assert float(sol(1.5e9)) == pytest.approx(2500.0, rel=1e-12) + assert float(liq(1.5e9)) == pytest.approx(3000.0, rel=1e-12) + + +@pytest.mark.unit +def test_get_zalmoxis_melting_curves_local_dir_beats_manifest(monkeypatch, tmp_path): + """When both exist, the local P-T files are read, not the dataset files.""" + import proteus.utils.data as data_mod + from proteus.data import MELTING_MONTEUX_PLUS600, dataset_dir + from proteus.utils.data import get_zalmoxis_melting_curves + + monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) + folder = dataset_dir(MELTING_MONTEUX_PLUS600, data_root=tmp_path) + folder.mkdir(parents=True) + (folder / 'solidus.dat').write_text('1e9 1000\n2e9 1000\n') + (folder / 'liquidus.dat').write_text('1e9 1000\n2e9 1000\n') + local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Monteux+600' + local.mkdir(parents=True) + (local / 'solidus_P-T.dat').write_text('1e9 2000\n2e9 3000\n') + (local / 'liquidus_P-T.dat').write_text('1e9 2500\n2e9 3500\n') + + config = MagicMock() + config.interior_struct.melting_dir = 'Monteux+600' + + sol, _ = get_zalmoxis_melting_curves(config) + + assert float(sol(1.5e9)) == pytest.approx(2500.0, rel=1e-12) + + @pytest.mark.unit def test_get_zalmoxis_melting_curves_returns_two_interpolators(monkeypatch, tmp_path): """When the directory and files exist, the helper returns (solidus, liquidus) callables.""" @@ -5295,107 +5335,102 @@ def test_get_zalmoxis_EOS_seager_missing_fetches(monkeypatch, tmp_path): @pytest.mark.unit -def test_get_zalmoxis_EOS_wb_pt_subfolder_takes_precedence(monkeypatch, tmp_path): - """When EOS/dynamic/WolfBower2018_MgSiO3/P-T exists, it is preferred over the parent.""" +def test_get_zalmoxis_EOS_wb_versioned_dir_with_optional_tables(monkeypatch, tmp_path): + """Wolf and Bower paths point into the versioned dataset; optional tables are listed.""" import proteus.utils.data as data_mod + from proteus.data import EOS_WOLF_BOWER_2018, dataset_dir from proteus.utils.data import get_zalmoxis_EOS monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - - eos_base = tmp_path / 'interior_lookup_tables' / 'EOS' - seager_unified = eos_base / 'static' / 'Seager2007' - seager_unified.mkdir(parents=True, exist_ok=True) - for fname in ( - 'eos_seager07_iron.txt', - 'eos_seager07_silicate.txt', - 'eos_seager07_water.txt', - ): - (seager_unified / fname).write_text('eos') - - wb_pt = eos_base / 'dynamic' / 'WolfBower2018_MgSiO3' / 'P-T' - wb_pt.mkdir(parents=True, exist_ok=True) - # Optional Cp + adiabat-grad files: when present, they should be - # included in the dict. - (wb_pt / 'heat_capacity_melt.dat').write_text('cp') - (wb_pt / 'heat_capacity_solid.dat').write_text('cp') - (wb_pt / 'adiabat_temp_grad_melt.dat').write_text('grad') + _seed_seager(tmp_path) + wb = dataset_dir(EOS_WOLF_BOWER_2018, data_root=tmp_path) + wb.mkdir(parents=True) + (wb / 'heat_capacity_melt.dat').write_text('cp') + (wb / 'heat_capacity_solid.dat').write_text('cp') + (wb / 'adiabat_temp_grad_melt.dat').write_text('grad') _, iron_Tdep, _, _ = get_zalmoxis_EOS() - # Discrimination: the cp_file and adiabat_grad_file entries appear - # because the optional files exist on disk; a regression that - # dropped the conditional include would leave one or both absent. assert 'cp_file' in iron_Tdep['melted_mantle'] assert 'adiabat_grad_file' in iron_Tdep['melted_mantle'] assert 'cp_file' in iron_Tdep['solid_mantle'] - # Density paths must point inside the P-T subfolder. - assert 'WolfBower2018_MgSiO3/P-T' in iron_Tdep['melted_mantle']['eos_file'] + assert Path(iron_Tdep['melted_mantle']['eos_file']) == wb / 'density_melt.dat' + assert Path(iron_Tdep['solid_mantle']['eos_file']) == wb / 'density_solid.dat' @pytest.mark.unit -def test_get_zalmoxis_EOS_rt_legacy_folder_used(monkeypatch, tmp_path, caplog): - """When the unified RTPress folder is missing but legacy exists, it is used.""" +def test_get_zalmoxis_EOS_wb_without_optional_tables_omits_keys(monkeypatch, tmp_path): + """Without the optional Cp and gradient tables the dict omits their keys.""" import proteus.utils.data as data_mod + from proteus.data import EOS_WOLF_BOWER_2018, dataset_dir from proteus.utils.data import get_zalmoxis_EOS monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - _seed_seager(tmp_path) + dataset_dir(EOS_WOLF_BOWER_2018, data_root=tmp_path).mkdir(parents=True) - rt_legacy = tmp_path / 'EOS_material_properties' / 'EOS_RTPress_melt_100TPa' - rt_legacy.mkdir(parents=True, exist_ok=True) - (rt_legacy / 'heat_capacity_melt.dat').write_text('cp') - (rt_legacy / 'adiabat_temp_grad_melt.dat').write_text('grad') + _, iron_Tdep, _, _ = get_zalmoxis_EOS() + + assert 'cp_file' not in iron_Tdep['melted_mantle'] + assert 'adiabat_grad_file' not in iron_Tdep['melted_mantle'] + assert 'cp_file' not in iron_Tdep['solid_mantle'] + + +@pytest.mark.unit +def test_get_zalmoxis_EOS_rt_versioned_folder_used(monkeypatch, tmp_path, caplog): + """The RTPress tables are read from their versioned dataset directory.""" + import proteus.utils.data as data_mod + from proteus.data import EOS_RTPRESS_100TPA, dataset_dir + from proteus.utils.data import get_zalmoxis_EOS + + monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) + _seed_seager(tmp_path) + rt = dataset_dir(EOS_RTPRESS_100TPA, data_root=tmp_path) + rt.mkdir(parents=True) + (rt / 'heat_capacity_melt.dat').write_text('cp') + (rt / 'adiabat_temp_grad_melt.dat').write_text('grad') with caplog.at_level('WARNING'): _, _, _, iron_rt = get_zalmoxis_EOS() - # Discrimination: the RTPress path lands in the legacy folder - # (because the unified EOS/RTPress_melt_100TPa subfolder is missing). - assert 'EOS_RTPress_melt_100TPa' in iron_rt['melted_mantle']['eos_file'] - # cp_file is populated because the file exists. - assert 'cp_file' in iron_rt['melted_mantle'] - # No "RTPress100TPa EOS folder not found" warning fires; only the - # Cp-table warning would fire if missing, and we created it. - folder_warnings = [r for r in caplog.records if 'EOS folder not found' in r.getMessage()] - assert folder_warnings == [] + assert Path(iron_rt['melted_mantle']['eos_file']) == rt / 'density_melt.dat' + assert Path(iron_rt['melted_mantle']['cp_file']) == rt / 'heat_capacity_melt.dat' + assert ( + Path(iron_rt['melted_mantle']['adiabat_grad_file']) == rt / 'adiabat_temp_grad_melt.dat' + ) + assert [r for r in caplog.records if 'EOS folder not found' in r.getMessage()] == [] @pytest.mark.unit def test_get_zalmoxis_EOS_rt_missing_cp_warns(monkeypatch, tmp_path, caplog): """When the RTPress Cp table is missing, a warning fires and the dict omits cp_file.""" import proteus.utils.data as data_mod + from proteus.data import EOS_RTPRESS_100TPA, dataset_dir from proteus.utils.data import get_zalmoxis_EOS monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - - eos_base = tmp_path / 'interior_lookup_tables' / 'EOS' _seed_seager(tmp_path) - - rt_unified = eos_base / 'RTPress_melt_100TPa' - rt_unified.mkdir(parents=True, exist_ok=True) - # Do NOT create heat_capacity_melt.dat + # The versioned folder exists but holds no heat_capacity_melt.dat. + dataset_dir(EOS_RTPRESS_100TPA, data_root=tmp_path).mkdir(parents=True) with caplog.at_level('WARNING'): _, _, _, iron_rt = get_zalmoxis_EOS() - # Discrimination: a "Cp table not found" warning fired; the cp_file - # key is absent from the dict. cp_warnings = [r for r in caplog.records if 'Cp table not found' in r.getMessage()] + folder_warnings = [r for r in caplog.records if 'EOS folder not found' in r.getMessage()] assert len(cp_warnings) >= 1 + assert folder_warnings == [] assert 'cp_file' not in iron_rt['melted_mantle'] @pytest.mark.unit def test_get_zalmoxis_EOS_rt_folder_missing_warns(monkeypatch, tmp_path, caplog): - """When neither unified nor legacy RTPress folder exists, a folder-not-found warning fires.""" + """When the versioned RTPress folder is absent, a folder-not-found warning fires.""" import proteus.utils.data as data_mod + from proteus.data import EOS_RTPRESS_100TPA, dataset_dir from proteus.utils.data import get_zalmoxis_EOS monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) - - # Only Seager is present (legacy); RTPress folder is absent in BOTH - # locations. _seed_seager(tmp_path) with caplog.at_level('WARNING'): @@ -5405,14 +5440,11 @@ def test_get_zalmoxis_EOS_rt_folder_missing_warns(monkeypatch, tmp_path, caplog) r for r in caplog.records if 'RTPress100TPa EOS folder not found' in r.getMessage() ] cp_warnings = [r for r in caplog.records if 'Cp table not found' in r.getMessage()] - # Discrimination: exactly the folder-not-found warning fired AND the - # Cp-table warning ALSO fired (because the file under the missing - # folder cannot exist either). assert len(folder_warnings) >= 1 assert len(cp_warnings) >= 1 - # The returned dict still resolves a path (legacy default) for the - # density_melt eos_file, even though no file exists on disk. - assert 'RTPress' in iron_rt['melted_mantle']['eos_file'] + # The path still resolves inside the versioned dataset directory. + rt = dataset_dir(EOS_RTPRESS_100TPA, data_root=tmp_path) + assert Path(iron_rt['melted_mantle']['eos_file']) == rt / 'density_melt.dat' # ============================================================================ From b1b1dee38abcc6fe895c0e9a326dd90b1a8872ab Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 23 Sep 2026 03:12:48 +0200 Subject: [PATCH 08/76] Verify the Spada download at MORS's own resolved track path download_stellar_tracks only checked GetFWLData()/stellar_evolution_tracks/Spada after a download, which was the legacy path. A migrated MORS puts the Spada tracks under its own versioned directory, so the check failed and PROTEUS fell back to the OSF mirror even though the fwl-io fetch had already succeeded. I mirror what I already do for Baraffe: when the installed MORS exposes spada_data_dir, verify there instead of the legacy path. An older MORS with no such accessor still falls back to the legacy check, so both MORS versions work. Added tests for the versioned and the legacy case. This needs the MORS release built from tl/mors-spada-manifest; the fwl-mors floor in pyproject still needs bumping to that release separately. --- src/proteus/utils/data.py | 11 +++--- tests/utils/test_data.py | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index b585c5325..8efa072bb 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1516,12 +1516,15 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): # Try MORS download first try: mors_data.DownloadEvolutionTracks(track) - # Verify the download landed. A migrated MORS fetches Baraffe through - # fwl-io into its versioned directory and exposes baraffe_data_dir to - # resolve it; an older MORS wrote Baraffe to the legacy path, like Spada. - # Verify wherever this MORS version actually placed the tracks. + # Verify the download landed. A migrated MORS fetches a track set + # through fwl-io into its versioned directory and exposes a + # _data_dir accessor to resolve it; an older MORS wrote the + # tracks to the legacy path. Verify wherever this MORS version + # actually placed them. if track == 'Baraffe' and hasattr(mors_data, 'baraffe_data_dir'): tracks_path = mors_data.baraffe_data_dir() + elif track == 'Spada' and hasattr(mors_data, 'spada_data_dir'): + tracks_path = mors_data.spada_data_dir() else: tracks_path = GetFWLData() / 'stellar_evolution_tracks' / track if tracks_path.exists() and any(tracks_path.iterdir()): diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index bfe07af80..fdbce1ed3 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -4769,6 +4769,77 @@ def test_download_stellar_tracks_baraffe_legacy_mors_uses_legacy_path(tmp_path, assert osf_calls == [] +@pytest.mark.unit +def test_download_stellar_tracks_spada_verifies_versioned_path(tmp_path, monkeypatch): + """Spada success is verified at the fwl-io versioned path, not the legacy dir.""" + import sys + import types + + import proteus.utils.data as data_mod + + monkeypatch.setattr(data_mod, 'GetFWLData', lambda: tmp_path) + + # The versioned Spada directory holds the tracks; the legacy dir is absent. + versioned = tmp_path / 'star' / 'tracks' / 'spada_2013' / 'r1' + versioned.mkdir(parents=True) + (versioned / 'm0p10.track1').write_text('track') + + checked = [] + fake_mors_data = types.ModuleType('mors.data') + fake_mors_data.DownloadEvolutionTracks = lambda track: None + fake_mors_data.spada_data_dir = lambda: checked.append(versioned) or versioned + fake_mors = types.ModuleType('mors') + fake_mors.data = fake_mors_data + monkeypatch.setitem(sys.modules, 'mors', fake_mors) + monkeypatch.setitem(sys.modules, 'mors.data', fake_mors_data) + + osf_calls = [] + monkeypatch.setattr(data_mod, 'download_OSF_folder', lambda **k: osf_calls.append(k)) + + data_mod.download_stellar_tracks('Spada') + + # Discrimination: verification consulted the versioned resolver, not the + # legacy path (which never exists here), so the fetch succeeded and no OSF + # fallback ran. + assert checked == [versioned] + assert osf_calls == [] + + +@pytest.mark.unit +def test_download_stellar_tracks_spada_legacy_mors_uses_legacy_path(tmp_path, monkeypatch): + """A pre-migration MORS (no spada_data_dir) verifies Spada at the legacy path.""" + import sys + import types + + import proteus.utils.data as data_mod + + monkeypatch.setattr(data_mod, 'GetFWLData', lambda: tmp_path) + + # An older MORS writes Spada to the legacy path and exposes no resolver. + legacy = tmp_path / 'stellar_evolution_tracks' / 'Spada' + legacy.mkdir(parents=True) + (legacy / 'm0p10.track1').write_text('track') + + fake_mors_data = types.ModuleType('mors.data') + fake_mors_data.DownloadEvolutionTracks = lambda track: None + # Deliberately no spada_data_dir attribute (pre-migration MORS). + fake_mors = types.ModuleType('mors') + fake_mors.data = fake_mors_data + monkeypatch.setitem(sys.modules, 'mors', fake_mors) + monkeypatch.setitem(sys.modules, 'mors.data', fake_mors_data) + + osf_calls = [] + monkeypatch.setattr(data_mod, 'download_OSF_folder', lambda **k: osf_calls.append(k)) + + data_mod.download_stellar_tracks('Spada') + + # Discrimination: provisioning succeeds via the legacy path even though the + # versioned directory is never created, so PROTEUS works with both MORS + # versions; no OSF fallback runs. + assert not (tmp_path / 'star' / 'tracks' / 'spada_2013').exists() + assert osf_calls == [] + + @pytest.mark.unit def test_download_stellar_tracks_baraffe_failure_reraises_without_osf(tmp_path, monkeypatch): """A genuine Baraffe failure re-raises; Baraffe has no OSF fallback mirror.""" From 8b49ae631e17d9492b97a30a08f0ddf8d420423b Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 23 Sep 2026 21:58:19 +0200 Subject: [PATCH 09/76] Require fwl-io and fwl-mors 26.9.23 Both releases carry the manifest support the migrated datasets need, so the minimum versions follow them. The data tests now check a second property each: the legacy downloader stays unused, a local melting-curve directory survives a clean, the fetch is attempted once, and the liquidus comes from the local files as well. --- pyproject.toml | 4 ++-- tests/atmos_clim/test_agni.py | 3 +++ tests/data/test_manifest.py | 7 ++++-- tests/utils/test_data.py | 41 ++++++++++++++++++++++++++++++----- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e3adf16a1..326f1ed7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ "attrs", "cattrs", "fwl-janus>=24.11.05", - "fwl-mors>=26.01.02", + "fwl-mors>=26.9.23", "fwl-calliope>=26.06.01", "fwl-zephyrus>=25.03.11", "fwl-aragog>=26.09.18", @@ -86,7 +86,7 @@ dependencies = [ # explicit connect and read timeout, and a transient failure is retried # with backoff instead of ending the fetch. Below that floor a stalled # mirror hangs the start of a run with no diagnostic. - "fwl-io>=26.8.31", + "fwl-io>=26.9.23", "zenodo-get", "zenodo-client>=0.4.1", "osfclient", diff --git a/tests/atmos_clim/test_agni.py b/tests/atmos_clim/test_agni.py index 0f125b611..7e18b5e3d 100644 --- a/tests/atmos_clim/test_agni.py +++ b/tests/atmos_clim/test_agni.py @@ -2735,4 +2735,7 @@ def test_resolve_surface_material_absolute_path_is_unchanged(tmp_path): """An absolute path is returned as given.""" target = tmp_path / 'elsewhere.dat' + nested = tmp_path / 'surface_albedos' / 'Hammond24' / 'basalt.dat' + assert _resolve_surface_material(str(target), str(tmp_path / 'root')) == str(target) + assert _resolve_surface_material(str(nested), str(tmp_path / 'root')) == str(nested) diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index 765a07b5a..e1866baf4 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -564,8 +564,11 @@ def test_get_spfile_path_resolves_into_the_versioned_dataset_dir(tmp_path): def test_unknown_spectral_pair_is_rejected(): """An unlisted (group, bands) pair fails loudly instead of resolving to a path.""" - with pytest.raises(KeyError): - dataset_dir(spectral_file_key('Oak', '16')) + key = spectral_file_key('Oak', '16') + + assert key == 'atmos_clim.spectral_files.oak_16' + with pytest.raises(KeyError, match='oak_16'): + dataset_dir(key) def test_migrated_datasets_are_not_also_pinned_in_the_legacy_map(): diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index fdbce1ed3..1ab30421c 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -1663,6 +1663,7 @@ def test_download_muscles_reports_fetch_failure(mock_fetch): mock_fetch.side_effect = RuntimeError('mirror down') assert download_muscles(stars=None) is False + mock_fetch.assert_called_once() @pytest.mark.unit @@ -1697,9 +1698,13 @@ def test_download_interior_lookuptables(mock_fetch, tmp_path, monkeypatch): monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) + legacy = MagicMock() + monkeypatch.setattr('proteus.utils.data.download', legacy) + download_interior_lookuptables(clean=False) mock_fetch.assert_called_once_with(MELTING_WOLF_BOWER_2018, data_root=tmp_path) + legacy.assert_not_called() @pytest.mark.unit @@ -1745,6 +1750,7 @@ def test_download_melting_curves_fetches_known_name( download_melting_curves(config, clean=False) mock_fetch.assert_called_once_with(getattr(data_mod, attr), data_root=tmp_path) + assert not (tmp_path / 'interior_lookup_tables').exists() @pytest.mark.unit @@ -1761,9 +1767,10 @@ def test_download_melting_curves_local_dir_wins(mock_fetch, tmp_path, monkeypatc config = MagicMock() config.interior_struct.melting_dir = 'Wolf_Bower+2018' - download_melting_curves(config, clean=False) + download_melting_curves(config, clean=True) mock_fetch.assert_not_called() + assert (local / 'solidus_P-T.dat').read_text() == 'dummy\n' @pytest.mark.unit @@ -1785,6 +1792,7 @@ def test_download_melting_curves_incomplete_local_dir_fetches( download_melting_curves(config, clean=False) mock_fetch.assert_called_once_with(MELTING_WOLF_BOWER_2018, data_root=tmp_path) + assert sorted(p.name for p in local.iterdir()) == ['solidus_P-T.dat'] @pytest.mark.unit @@ -1816,9 +1824,10 @@ def test_download_melting_curves_unknown_name_local_dir_ok(mock_fetch, tmp_path, config = MagicMock() config.interior_struct.melting_dir = 'MyCurve' - download_melting_curves(config) + download_melting_curves(config, clean=True) mock_fetch.assert_not_called() + assert (local / 'liquidus_P-T.dat').read_text() == 'dummy\n' @pytest.mark.unit @@ -1919,6 +1928,7 @@ def test_resolve_lookup_table_dir_layout(tmp_path): assert ( folder == tmp_path / 'interior_struct' / 'lookup' / 'wolf_bower_2018_1tpa' / 'r19473625' ) + assert resolve_lookup_table_dir(data_root=str(tmp_path)) == folder @pytest.mark.unit @@ -1942,8 +1952,11 @@ def test_find_lookup_table_dir_unwritable_root_gives_none(): """A data root that cannot be created gives None, not an error.""" from proteus.utils.data import find_lookup_table_dir - with patch('proteus.data.dataset_dir', side_effect=OSError('read-only')): - assert find_lookup_table_dir(data_root='/nonexistent') is None + with patch('proteus.data.dataset_dir', side_effect=OSError('read-only')) as mock_dir: + folder = find_lookup_table_dir(data_root='/nonexistent') + + assert folder is None + mock_dir.assert_called_once() @pytest.mark.unit @@ -2218,9 +2231,13 @@ def test_download_Seager_EOS(monkeypatch, tmp_path): data_pkg, 'fetch_dataset', lambda key, data_root=None: calls.append((key, data_root)) ) + legacy = MagicMock() + monkeypatch.setattr(data_mod, 'download', legacy) + download_Seager_EOS() assert calls == [('interior_struct.eos.seager_2007', tmp_path)] + legacy.assert_not_called() @pytest.mark.unit @@ -2449,7 +2466,10 @@ def test_download_Seager_EOS_failure_raises(monkeypatch, tmp_path): import proteus.utils.data as data_mod from proteus.utils.data import download_Seager_EOS + attempts = [] + def boom(key, data_root=None): + attempts.append(key) raise OSError('no network') monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) @@ -2457,6 +2477,7 @@ def boom(key, data_root=None): with pytest.raises(OSError, match='no network'): download_Seager_EOS() + assert attempts == ['interior_struct.eos.seager_2007'] @pytest.mark.unit @@ -3200,9 +3221,13 @@ def test_download_eos_dynamic_fetches_lookup_dataset(mock_fetch, tmp_path, monke target_dir = dataset_dir(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=tmp_path) mock_fetch.return_value = [target_dir / 'temperature_melt.dat'] + legacy = MagicMock() + monkeypatch.setattr('proteus.utils.data.download', legacy) + download_eos_dynamic('WolfBower2018_MgSiO3') mock_fetch.assert_called_once_with(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=tmp_path) + legacy.assert_not_called() @pytest.mark.unit @@ -5269,9 +5294,10 @@ def test_get_zalmoxis_melting_curves_local_dir_beats_manifest(monkeypatch, tmp_p config = MagicMock() config.interior_struct.melting_dir = 'Monteux+600' - sol, _ = get_zalmoxis_melting_curves(config) + sol, liq = get_zalmoxis_melting_curves(config) assert float(sol(1.5e9)) == pytest.approx(2500.0, rel=1e-12) + assert float(liq(1.5e9)) == pytest.approx(3000.0, rel=1e-12) @pytest.mark.unit @@ -5394,15 +5420,18 @@ def test_get_zalmoxis_EOS_seager_versioned_dir_used(monkeypatch, tmp_path): def test_get_zalmoxis_EOS_seager_missing_fetches(monkeypatch, tmp_path): """Absent Seager files trigger one fetch through download_eos_static.""" import proteus.utils.data as data_mod + from proteus.data import EOS_SEAGER_2007, dataset_dir from proteus.utils.data import get_zalmoxis_EOS monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) calls = [] monkeypatch.setattr(data_mod, 'download_eos_static', lambda: calls.append(1)) - get_zalmoxis_EOS() + eos = get_zalmoxis_EOS() assert calls == [1] + seager = dataset_dir(EOS_SEAGER_2007, data_root=tmp_path) + assert eos[0]['core']['eos_file'] == str(seager / 'eos_seager07_iron.txt') @pytest.mark.unit From b9a1a1dde1cdceceeb480f10ac66a002875b8a41 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 23 Sep 2026 22:39:11 +0200 Subject: [PATCH 10/76] Read multi-model datasets from the fwl-io shared manifest The spectral files, stellar spectra, equations of state, melting curves and the Wolf and Bower lookup table are declared in the shared manifest that fwl-io ships, and fwl-io relocate moves legacy trees to those keys. PROTEUS now reads them under the same keys instead of declaring the records a second time, so an existing tree moves into place without a new download and each record exists once on disk. proteus_manifest.toml keeps the data only PROTEUS reads: the exoplanet catalogue, the Zeng mass-radius curves, the Hammond surface spectra and the Seager tables. A test fails if a record is declared in both manifests. Records that hold more than a run reads are fetched file by file: the Wolf and Bower and RTPress tables Zalmoxis uses, the 150 or the 600 points-per-decade PALEOS MgSiO3 pair, and one unified PALEOS table per material. Each start-of-run fetch step now reports a failure and lets the next step run. fwl-io raises RuntimeError subclasses, which the OSError guard did not catch, so one unreachable mirror aborted the run before any other dataset was tried. The fetched lookup table is used only when every SPIDER table file is present, so an interrupted fetch falls back to the tables bundled with SPIDER. The Zalmoxis EOS paths use the data root the fetch writes to. generate_spider_phase_boundaries.py reads fetched melting curves and writes its output to the local folder SPIDER reads. postprocess.jl picks the Honeyside 4096 version directory by numeric record id. --- docs/Reference/data.md | 53 +++-- src/proteus/config/_struct.py | 2 +- src/proteus/data/__init__.py | 69 +++--- ...m.spectral_files.dayspring_16.registry.txt | 5 - ....spectral_files.dayspring_256.registry.txt | 5 - ...spectral_files.dayspring_4096.registry.txt | 5 - ...m.spectral_files.dayspring_48.registry.txt | 4 - ...m.spectral_files.frostflow_16.registry.txt | 5 - ....spectral_files.frostflow_256.registry.txt | 5 - ...spectral_files.frostflow_4096.registry.txt | 5 - ...m.spectral_files.frostflow_48.registry.txt | 5 - ...m.spectral_files.honeyside_16.registry.txt | 5 - ....spectral_files.honeyside_256.registry.txt | 5 - ...spectral_files.honeyside_4096.registry.txt | 5 - ...m.spectral_files.honeyside_48.registry.txt | 5 - ...s_clim.spectral_files.oak_318.registry.txt | 2 - ...rior_struct.eos.chabrier_2021.registry.txt | 1 - ...ruct.eos.paleos_mgsio3_2phase.registry.txt | 2 - ....paleos_mgsio3_2phase_highres.registry.txt | 2 - ...ior_struct.eos.paleos_unified.registry.txt | 3 - ...ior_struct.eos.rtpress_100tpa.registry.txt | 2 - ...or_struct.eos.wolf_bower_2018.registry.txt | 3 - ...t.lookup.wolf_bower_2018_1tpa.registry.txt | 14 -- ...lting_curves.monteux_minus600.registry.txt | 2 - ...elting_curves.monteux_plus600.registry.txt | 2 - ...elting_curves.wolf_bower_2018.registry.txt | 2 - src/proteus/data/proteus_manifest.toml | 136 ------------ .../data/stellar_spectra.muscles.registry.txt | 38 ---- .../data/stellar_spectra.named.registry.txt | 11 - .../data/stellar_spectra.phoenix.registry.txt | 52 ----- .../data/stellar_spectra.solar.registry.txt | 10 - src/proteus/interior_struct/zalmoxis.py | 50 +++-- src/proteus/utils/data.py | 160 +++++++++----- tests/atmos_clim/test_common.py | 5 +- tests/data/test_manifest.py | 200 ++++++++--------- tests/interior_struct/test_zalmoxis.py | 42 +++- tests/utils/test_data.py | 202 +++++++++++++----- tools/generate_spider_phase_boundaries.py | 57 +++-- tools/postprocess.jl | 9 +- 39 files changed, 532 insertions(+), 658 deletions(-) delete mode 100644 src/proteus/data/atmos_clim.spectral_files.dayspring_16.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.dayspring_256.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.dayspring_4096.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.dayspring_48.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.frostflow_16.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.frostflow_256.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.frostflow_4096.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.frostflow_48.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.honeyside_16.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.honeyside_256.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.honeyside_4096.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.honeyside_48.registry.txt delete mode 100644 src/proteus/data/atmos_clim.spectral_files.oak_318.registry.txt delete mode 100644 src/proteus/data/interior_struct.eos.chabrier_2021.registry.txt delete mode 100644 src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase.registry.txt delete mode 100644 src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase_highres.registry.txt delete mode 100644 src/proteus/data/interior_struct.eos.paleos_unified.registry.txt delete mode 100644 src/proteus/data/interior_struct.eos.rtpress_100tpa.registry.txt delete mode 100644 src/proteus/data/interior_struct.eos.wolf_bower_2018.registry.txt delete mode 100644 src/proteus/data/interior_struct.lookup.wolf_bower_2018_1tpa.registry.txt delete mode 100644 src/proteus/data/interior_struct.melting_curves.monteux_minus600.registry.txt delete mode 100644 src/proteus/data/interior_struct.melting_curves.monteux_plus600.registry.txt delete mode 100644 src/proteus/data/interior_struct.melting_curves.wolf_bower_2018.registry.txt delete mode 100644 src/proteus/data/stellar_spectra.muscles.registry.txt delete mode 100644 src/proteus/data/stellar_spectra.named.registry.txt delete mode 100644 src/proteus/data/stellar_spectra.phoenix.registry.txt delete mode 100644 src/proteus/data/stellar_spectra.solar.registry.txt diff --git a/docs/Reference/data.md b/docs/Reference/data.md index da8ec4b85..563fc00fb 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -12,13 +12,18 @@ Each dataset is provisioned by one of two mechanisms, named per dataset in the table below. **fwl-io.** [fwl-io](https://github.com/FormingWorlds/fwl-io) pins a dataset to -a Zenodo version DOI declared in `src/proteus/data/proteus_manifest.toml`, -verifies every file against a checksum registry committed beside that manifest, +a Zenodo version DOI declared in a manifest, verifies every file against a +checksum registry committed beside that manifest, and places the files in a version directory named for the record, so a re-pinned deposit lands beside its predecessor rather than overwriting it. Every request carries a connect and read timeout, and a transient failure is retried with backoff. Zenodo is currently the only mirror these datasets declare, so an unreachable Zenodo means the fetch fails rather than falling back elsewhere. +Data several models read (spectral files, stellar spectra, equations of state, +melting curves, lookup tables) is declared in the shared manifest that fwl-io +ships; data only PROTEUS reads is declared in `src/proteus/data/proteus_manifest.toml`. +A tree laid out by an older PROTEUS moves into this layout with `fwl-io relocate` +instead of being downloaded again. **The PROTEUS downloader.** `proteus.utils.data` fetches a whole Zenodo record, retrying a few times, and falls back to the corresponding project on the @@ -36,7 +41,8 @@ used with lower rate limits. | Scattering properties | PROTEUS downloader | `proteus get scattering` | | Exoplanet populations, mass-radius curves | fwl-io | `proteus get reference` | | Interior structure EOS tables | fwl-io | `proteus get interiordata`, or fetched when a run needs them | -| Interior lookup tables, melting curves | PROTEUS downloader | `proteus get interiordata` | +| Melting curves | fwl-io | `proteus get interiordata`, or fetched when a run needs them | +| P-S lookup tables | fwl-io | fetched when a run sets `interior_struct.eos_dir` | To configure a Zenodo API token, see the [Troubleshooting guide](../How-to/troubleshooting.md#data-download-errors-or-slow-zenodo-downloads). @@ -52,7 +58,7 @@ for full usage instructions. ### Solar spectra -Observed solar spectra are stored under `$FWL_DATA/stellar_spectra/solar/r/`, +Observed solar spectra are stored under `$FWL_DATA/star/spectra/solar/r/`, where `` is the Zenodo record that the manifest pins. The modern spectrum is from [Gueymard (2003)](https://www.sciencedirect.com/science/article/pii/S0038092X03003967) via [NREL](https://www.nrel.gov/grid/solar-resource/spectra.html). @@ -78,7 +84,7 @@ Historical and future spectra are from Observed UV-optical-IR spectra from the [MUSCLES](https://archive.stsci.edu/prepds/muscles/) and [Mega-MUSCLES](https://archive.stsci.edu/prepds/mega-muscles/) surveys, -stored under `$FWL_DATA/stellar_spectra/muscles/r/` as `.txt`. +stored under `$FWL_DATA/star/spectra/muscles/r/` as `.txt`. `star_name` matching is case-insensitive. ??? info "Full star catalog" @@ -125,7 +131,7 @@ stored under `$FWL_DATA/stellar_spectra/muscles/r/` as `.txt` ### PHOENIX synthetic spectra Med-resolution synthetic spectra from the PHOENIX library, stored under -`$FWL_DATA/stellar_spectra/phoenix/r17674612/FeH_alpha/`. Each +`$FWL_DATA/star/spectra/phoenix/r17674612/FeH_alpha/`. Each subdirectory corresponds to one metallicity-alpha combination (e.g. `FeH-0.5_alpha+0.0/`). The Zenodo record holds one zip archive per combination. PROTEUS fetches only the archive that a run needs, checks it @@ -169,7 +175,7 @@ with `#`. Correlated-k opacity tables used by the atmosphere climate modules (AGNI, JANUS). Selected via `atmos_clim.spectral_group` and `atmos_clim.spectral_bands` in the config. Stored under -`$FWL_DATA/atmos_clim/spectral_files/_/r/`, with the group name in lower case. +`$FWL_DATA/atmos_clim/spectral_files///r/`, with the group name in lower case. For a full description of each group's spectral coverage and gas species, see `docs/assets/spectral_files.pdf` in the PROTEUS repository. @@ -238,27 +244,27 @@ not read and can be deleted. The interior structure solver of PROTEUS, Zalmoxis, uses equation-of-state tables by [Seager et al. (2007)](https://iopscience.iop.org/article/10.1086/521346), and from [PALEOS](https://github.com/maraattia/PALEOS) by [Attia et al. (2026)](https://ui.adsabs.harvard.edu/abs/2026arXiv260503741A/abstract). An overview of equation of state tables can be found [here](https://proteus-framework.org/Zalmoxis/Reference/data.html#data-inventory). -All equation-of-state tables are fetched through fwl-io into `$FWL_DATA/interior_struct/eos//r/`, on the same terms as the datasets above. Only the tables that the selected equations of state reference are fetched. A copy under `$FWL_DATA/EOS_material_properties/EOS_Seager2007` is not read and can be deleted. +All equation-of-state tables are fetched through fwl-io into `$FWL_DATA//r/`, on the same terms as the datasets above. Only the files that the selected equations of state read are fetched. | Dataset directory | Contents | Fetched | |---|---|---| -| `seager_2007` | Seager et al. (2007) iron, silicate and water tables | whole record | -| `wolf_bower_2018` | Wolf and Bower (2018) MgSiO3 melt and solid tables to 1 TPa | whole dataset for `WolfBower2018`; the solid density file alone for `RTPress100TPa` | -| `rtpress_100tpa` | RTPress MgSiO3 melt tables to 100 TPa | whole dataset | -| `paleos_mgsio3_2phase` | PALEOS MgSiO3 solid and liquid tables, 150 points per decade | whole dataset | -| `paleos_mgsio3_2phase_highres` | PALEOS MgSiO3 solid and liquid tables, 600 points per decade | whole dataset, only for `PALEOS-2phase:MgSiO3-highres` | -| `paleos_unified` | PALEOS unified tables for iron, MgSiO3 and water | one file per selected component, not the full 2.29 GB record | -| `chabrier_2021` | Chabrier et al. hydrogen and helium tables | archive extracted into `r/EOS_Chabrier2021_HHe/` | +| `interior_struct/eos/seager_2007` | Seager et al. (2007) iron, silicate and water tables | whole record | +| `interior/eos/wolf_bower_2018_1tpa` | Wolf and Bower (2018) MgSiO3 melt and solid tables to 1 TPa | melt density, melt adiabatic gradient and solid density for `WolfBower2018`; the solid density alone for `RTPress100TPa` | +| `interior/eos/rtpress_melt_100tpa` | RTPress MgSiO3 melt tables to 100 TPa | melt density and melt adiabatic gradient | +| `interior/eos/paleos_mgsio3` | PALEOS MgSiO3 solid and liquid tables, 150 and 600 points per decade | the 150 points-per-decade pair for `PALEOS-2phase:MgSiO3`, the 600 points-per-decade pair for `PALEOS-2phase:MgSiO3-highres` | +| `interior/eos/paleos_iron`, `paleos_mgsio3_unified`, `paleos_h2o` | PALEOS unified tables for iron, MgSiO3 and water | the table of each selected component | +| `interior/eos/chabrier_2021_hhe` | Chabrier et al. hydrogen and helium tables | archive extracted into `r/EOS_Chabrier2021_HHe/` | -Copies of these tables under `$FWL_DATA/zalmoxis_eos` and `$FWL_DATA/EOS_material_properties` are not read and can be deleted. +Tables under `$FWL_DATA/zalmoxis_eos` are not read where they are; `fwl-io relocate` moves them into the directories above. A copy under `$FWL_DATA/EOS_material_properties` is not read and can be deleted. --- ## Melting curves and P-S lookup tables -The `monteux_plus600`, `monteux_minus600` and `wolf_bower_2018` solidus/liquidus +The `Monteux+600`, `Monteux-600` and `Wolf_Bower+2018` solidus/liquidus curves used by `interior_struct.melting_dir` and by Zalmoxis are fetched -through fwl-io into `$FWL_DATA/interior_struct/melting_curves//r/`. +through fwl-io into `$FWL_DATA/interior/melting_curves//r/` +(datasets `monteux_plus_600`, `monteux_minus_600` and `wolf_bower_2018`). A curve generated locally by `tools/solidus_func.py` into `$FWL_DATA/interior_lookup_tables/Melting_curves//` takes precedence over the fetched copy; see [Melting curves](melting_curves.md) for @@ -266,8 +272,9 @@ the full list of parametrizations and how to generate them. The Wolf and Bower (2018) pressure-entropy lookup table that SPIDER and Aragog read for `eos_dir = "WolfBower2018_MgSiO3"` is fetched the same way, -into `$FWL_DATA/interior_struct/lookup/wolf_bower_2018_1tpa/r/`. A -local table under `$FWL_DATA/interior_lookup_tables/EOS/dynamic//P-S/` -takes precedence, and the table bundled with SPIDER -(`lookup_data/1TPa-dK09-elec-free/`) is used only when neither the local nor -the fetched table is available. +into `$FWL_DATA/interior/eos/dk09_1tpa_elec_free/mgsio3_wolf_bower_2018_1tpa/r/` +when a run sets `eos_dir`. A local table takes precedence: SPIDER reads +`$FWL_DATA/interior_lookup_tables/EOS/dynamic//P-S/` and Aragog reads +`$FWL_DATA/interior_lookup_tables/EOS/dynamic//P-T/`. SPIDER uses the +table bundled with it (`lookup_data/1TPa-dK09-elec-free/`) when neither the +local nor a complete fetched table is available. diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index fe04b5f76..9cb43dc28 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -373,7 +373,7 @@ def __attrs_post_init__(self): raise ValueError( 'interior_struct.melting_dir must be set when module = "spider". ' 'Provide a melting curve name (e.g. "Monteux-600"): one of the ' - 'curves fetched into FWL_DATA/interior_struct/melting_curves/, or a ' + 'curves fetched into FWL_DATA/interior/melting_curves/, or a ' 'folder in FWL_DATA/interior_lookup_tables/Melting_curves/.' ) if self.eos_dir is None: diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index 603a77e5e..11f056853 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -1,10 +1,12 @@ """Datasets PROTEUS fetches through fwl-io. -The datasets PROTEUS owns are declared in ``proteus_manifest.toml`` beside this -module, one table per dataset, with a committed registry of file checksums next -to it. fwl-io derives each dataset's location from its manifest key and places -it in a version directory named for the pinned Zenodo record, so a dataset lands -in ``//r`` and the pin, the location, and the +Data only PROTEUS reads is declared in ``proteus_manifest.toml`` beside this +module; data several models read (spectral files, stellar spectra, equations of +state, melting curves) is declared in the shared manifest fwl-io ships. Each +dataset has a committed registry of file checksums next to its manifest. fwl-io +derives each dataset's location from its manifest key and places it in a version +directory named for the pinned Zenodo record, so a dataset lands in +``//r`` and the pin, the location, and the checksums have a single source of truth. Readers resolve a dataset directory through :func:`dataset_dir` rather than @@ -18,27 +20,29 @@ import dataclasses from pathlib import Path -# Manifest keys of the datasets declared beside this module. Readers refer to -# these constants rather than repeating the dotted strings, so a key rename is a -# single edit here and in the manifest. +# Manifest keys of the datasets PROTEUS reads. Readers refer to these constants +# rather than repeating the dotted strings, so a key rename is a single edit here. +# Declared in proteus_manifest.toml: EXOPLANET_REFERENCE = 'observe.exoplanet_reference' MASS_RADIUS_ZENG_2019 = 'observe.mass_radius.zeng_2019' SURFACE_ALBEDOS_HAMMOND_2024 = 'atmos_clim.surface_albedos.hammond_2024' EOS_SEAGER_2007 = 'interior_struct.eos.seager_2007' -EOS_WOLF_BOWER_2018 = 'interior_struct.eos.wolf_bower_2018' -EOS_RTPRESS_100TPA = 'interior_struct.eos.rtpress_100tpa' -EOS_PALEOS_MGSIO3_2PHASE = 'interior_struct.eos.paleos_mgsio3_2phase' -EOS_PALEOS_MGSIO3_2PHASE_HIGHRES = 'interior_struct.eos.paleos_mgsio3_2phase_highres' -EOS_PALEOS_UNIFIED = 'interior_struct.eos.paleos_unified' -EOS_CHABRIER_2021 = 'interior_struct.eos.chabrier_2021' -LOOKUP_WOLF_BOWER_2018_1TPA = 'interior_struct.lookup.wolf_bower_2018_1tpa' -MELTING_MONTEUX_PLUS600 = 'interior_struct.melting_curves.monteux_plus600' -MELTING_MONTEUX_MINUS600 = 'interior_struct.melting_curves.monteux_minus600' -MELTING_WOLF_BOWER_2018 = 'interior_struct.melting_curves.wolf_bower_2018' -STELLAR_SPECTRA_SOLAR = 'stellar_spectra.solar' -STELLAR_SPECTRA_NAMED = 'stellar_spectra.named' -STELLAR_SPECTRA_MUSCLES = 'stellar_spectra.muscles' -STELLAR_SPECTRA_PHOENIX = 'stellar_spectra.phoenix' +# Declared in the fwl-io shared manifest: +EOS_WOLF_BOWER_2018 = 'interior.eos.wolf_bower_2018_1tpa' +EOS_RTPRESS_100TPA = 'interior.eos.rtpress_melt_100tpa' +EOS_PALEOS_MGSIO3 = 'interior.eos.paleos_mgsio3' +EOS_PALEOS_IRON = 'interior.eos.paleos_iron' +EOS_PALEOS_MGSIO3_UNIFIED = 'interior.eos.paleos_mgsio3_unified' +EOS_PALEOS_H2O = 'interior.eos.paleos_h2o' +EOS_CHABRIER_2021 = 'interior.eos.chabrier_2021_hhe' +LOOKUP_WOLF_BOWER_2018_1TPA = 'interior.eos.dk09_1tpa_elec_free.mgsio3_wolf_bower_2018_1tpa' +MELTING_MONTEUX_PLUS600 = 'interior.melting_curves.monteux_plus_600' +MELTING_MONTEUX_MINUS600 = 'interior.melting_curves.monteux_minus_600' +MELTING_WOLF_BOWER_2018 = 'interior.melting_curves.wolf_bower_2018' +STELLAR_SPECTRA_SOLAR = 'star.spectra.solar' +STELLAR_SPECTRA_NAMED = 'star.spectra.named' +STELLAR_SPECTRA_MUSCLES = 'star.spectra.muscles' +STELLAR_SPECTRA_PHOENIX = 'star.spectra.phoenix' def spectral_file_key(group: str, bands: str | int) -> str: @@ -54,18 +58,19 @@ def spectral_file_key(group: str, bands: str | int) -> str: Returns ------- str - Dotted manifest key, e.g. ``atmos_clim.spectral_files.dayspring_256``. - The key is not checked against the manifest; an unknown pair raises - ``KeyError`` when the dataset is resolved. + Dotted key in the fwl-io shared manifest, e.g. + ``atmos_clim.spectral_files.dayspring.256``. The key is not checked + against the manifest; an unknown pair raises ``KeyError`` when the + dataset is resolved. """ - return f'atmos_clim.spectral_files.{str(group).lower()}_{bands}' + return f'atmos_clim.spectral_files.{str(group).lower()}.{bands}' # The oldest fwl-io that reads this manifest schema. An older fwl-io reads the # manifest as malformed rather than as a version mismatch, so the load names # which side is out of date. The fwl-io requirement in pyproject.toml must be at # least this version; the test suite enforces the relation. -FWL_IO_FLOOR = '26.7.25' +FWL_IO_FLOOR = '26.9.23' def manifest_path() -> Path: @@ -123,7 +128,7 @@ def _data_root() -> Path: def _dataset(key: str): - """Return one dataset declared in the shipped manifest. + """Return one dataset declared in the PROTEUS manifest or the fwl-io shared one. Parameters ---------- @@ -140,9 +145,10 @@ def _dataset(key: str): RuntimeError The installed fwl-io predates the manifest schema PROTEUS ships. KeyError - The key is absent from the manifest. + The key is absent from both manifests. """ from fwl_io import load_manifest + from fwl_io.manifest import shared_manifest_path try: datasets = {ds.key: ds for ds in load_manifest(manifest_path())} @@ -153,7 +159,10 @@ def _dataset(key: str): f'fwl-io could not read the manifest PROTEUS ships ({exc}); the installed ' f'fwl-io predates the manifest schema: upgrade to fwl-io>={FWL_IO_FLOOR}.' ) from exc - return datasets[key] + if key in datasets: + return datasets[key] + shared = {ds.key: ds for ds in load_manifest(shared_manifest_path())} + return shared[key] def _fetcher(key: str, data_root: str | Path | None = None): diff --git a/src/proteus/data/atmos_clim.spectral_files.dayspring_16.registry.txt b/src/proteus/data/atmos_clim.spectral_files.dayspring_16.registry.txt deleted file mode 100644 index 0599c8b03..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.dayspring_16.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Dayspring.chk md5:bfa0e8f873ca26e5420d09c298a879df -Dayspring.chk_k md5:0067ee8d145216771c4c1bbfa2951bb7 -Dayspring.sf md5:2e4b9f055620606d6eb5947974dccdde -Dayspring.sf_k md5:58294dd2e05821860921692c4751f578 -ds16.log md5:31c8f66ced3e4185edafce8d0abe4ebc diff --git a/src/proteus/data/atmos_clim.spectral_files.dayspring_256.registry.txt b/src/proteus/data/atmos_clim.spectral_files.dayspring_256.registry.txt deleted file mode 100644 index a659228f2..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.dayspring_256.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Dayspring.chk md5:e144dbff417cfb1fc5d5ef607d391924 -Dayspring.chk_k md5:609c95edac86ec1303aca769f526edca -Dayspring.sf md5:297a149988e6e603d826e117269c823e -Dayspring.sf_k md5:a46c17b39d894a118ae98e96f7f10ef7 -ds256.log md5:f7cba6f2bd2c77b0ec7702eaf5a681be diff --git a/src/proteus/data/atmos_clim.spectral_files.dayspring_4096.registry.txt b/src/proteus/data/atmos_clim.spectral_files.dayspring_4096.registry.txt deleted file mode 100644 index 6fed964e9..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.dayspring_4096.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Dayspring.chk md5:55b70bb342557ee316e256e09b038696 -Dayspring.chk_k md5:f0e593262bc2c3430f11b890cb6cbce1 -Dayspring.sf md5:94ffb27e593636318063d136decb426b -Dayspring.sf_k md5:42988ab81500d64b57b9e4904a0fc223 -ds4096.log md5:630072d8e0ff8ccc0ac5a9709cc87540 diff --git a/src/proteus/data/atmos_clim.spectral_files.dayspring_48.registry.txt b/src/proteus/data/atmos_clim.spectral_files.dayspring_48.registry.txt deleted file mode 100644 index c4353b8dc..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.dayspring_48.registry.txt +++ /dev/null @@ -1,4 +0,0 @@ -Dayspring.chk md5:7139c0e7fac225c866f6e2fed3640e41 -Dayspring.chk_k md5:7a506ab9959aca2f0223ed810a6a3589 -Dayspring.sf md5:2553c44b08c924a2105c1267816d8688 -Dayspring.sf_k md5:557b78181c18831cacae1484606c7517 diff --git a/src/proteus/data/atmos_clim.spectral_files.frostflow_16.registry.txt b/src/proteus/data/atmos_clim.spectral_files.frostflow_16.registry.txt deleted file mode 100644 index f81c93cd7..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.frostflow_16.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Frostflow.chk md5:da9dc8a79f8f85c5397c2d107bce4be9 -Frostflow.chk_k md5:6882e3e81cf4ccec63afe1f5f13958f3 -Frostflow.sf md5:495d24acb80b81090b6abcc7e612aeea -Frostflow.sf_k md5:3ba48a9634dc284149e6ef09e3f95b85 -Frostflow16.log md5:dc9a50ebded1bd0f368adda3a97a6365 diff --git a/src/proteus/data/atmos_clim.spectral_files.frostflow_256.registry.txt b/src/proteus/data/atmos_clim.spectral_files.frostflow_256.registry.txt deleted file mode 100644 index 6d966416c..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.frostflow_256.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Frostflow.chk md5:e68122d473ddf36b2422101c2fdc92c8 -Frostflow.chk_k md5:297160caeb48411a69e02915c3da4030 -Frostflow.sf md5:9ffa1ae15c69430a6a4c7a94ea00febe -Frostflow.sf_k md5:08a7df9c7ba22ac159a2588b924bda1a -Frostflow256.log md5:f890a6b820efdc90e72439fb9ef2280d diff --git a/src/proteus/data/atmos_clim.spectral_files.frostflow_4096.registry.txt b/src/proteus/data/atmos_clim.spectral_files.frostflow_4096.registry.txt deleted file mode 100644 index 2119bcdd1..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.frostflow_4096.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Frostflow.chk md5:26b2e36ecc00125834a11a4bb4559fa4 -Frostflow.chk_k md5:816030cfa90f8468c9c8d9261bfb6459 -Frostflow.sf md5:94a47aa70cf0f5f3d08a827734a33b42 -Frostflow.sf_k md5:32957ef9d3a45098927b9afcba0cb8e0 -Frostflow4096.log md5:cbbf5db778f4bf1f42811be4128e10fe diff --git a/src/proteus/data/atmos_clim.spectral_files.frostflow_48.registry.txt b/src/proteus/data/atmos_clim.spectral_files.frostflow_48.registry.txt deleted file mode 100644 index 7f546f282..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.frostflow_48.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Frostflow.chk md5:894f35cfd072c976a88b3b522b9bb9eb -Frostflow.chk_k md5:9e271ceecf61d62aa1f7c038d4a28294 -Frostflow.sf md5:a34e1ebc2832e61c1d2e0193d0c0ffd6 -Frostflow.sf_k md5:3b79eb68ffb01bd9c3b983b08e41cf06 -Frostflow48.log md5:14677e64064ee61aea6bf7680adb1215 diff --git a/src/proteus/data/atmos_clim.spectral_files.honeyside_16.registry.txt b/src/proteus/data/atmos_clim.spectral_files.honeyside_16.registry.txt deleted file mode 100644 index 6a223ae92..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.honeyside_16.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Honeyside.chk md5:9e22bc9bdb748b79250f8bb36240eda5 -Honeyside.chk_k md5:21c24e03a686b12da09db403d94cf764 -Honeyside.sf md5:fc86acc395120e30764c47f56bd4a1b5 -Honeyside.sf_k md5:afe06f25d7d969abf9c5a4a27dc884cf -hs16.log md5:ea28d7f4b84d5b24a01464b545f12259 diff --git a/src/proteus/data/atmos_clim.spectral_files.honeyside_256.registry.txt b/src/proteus/data/atmos_clim.spectral_files.honeyside_256.registry.txt deleted file mode 100644 index 6c48b8075..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.honeyside_256.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Honeyside.chk md5:db4c17a81d12da7ddcf241fbb7c9b6ba -Honeyside.chk_k md5:7e0ed758e52d95856030f872ae874627 -Honeyside.sf md5:10dc7627410d46f9b31260503e194118 -Honeyside.sf_k md5:93a595b7bcff4a7ec776d965076a5481 -hs256.log md5:05d48bf63ae6ff16948369730c4bff32 diff --git a/src/proteus/data/atmos_clim.spectral_files.honeyside_4096.registry.txt b/src/proteus/data/atmos_clim.spectral_files.honeyside_4096.registry.txt deleted file mode 100644 index 0c272dfe9..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.honeyside_4096.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Honeyside.chk md5:9c3a9233d25db56def286566fb611733 -Honeyside.chk_k md5:5911296f53c49e2b7918b469b08f93f3 -Honeyside.sf md5:d97ae170840736ab16cb61aa5eceffbc -Honeyside.sf_k md5:9c19cc6910dee7a577181f3ae8d2cff9 -hs4096.log md5:33836f37447a582b421c61cb30d52e90 diff --git a/src/proteus/data/atmos_clim.spectral_files.honeyside_48.registry.txt b/src/proteus/data/atmos_clim.spectral_files.honeyside_48.registry.txt deleted file mode 100644 index 0098b46ff..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.honeyside_48.registry.txt +++ /dev/null @@ -1,5 +0,0 @@ -Honeyside.chk md5:fa7e0e040077af444081fa5cd0e6ae97 -Honeyside.chk_k md5:85ed68bb9f3026c3d0cbf97c9593dcd9 -Honeyside.sf md5:6e35b67e141a1c70f88d847e4dc65254 -Honeyside.sf_k md5:8a53c96f87ab6ce19fe1e904ead69f3f -hs48.log md5:543018db4e719f6cea3e684d5b8a66de diff --git a/src/proteus/data/atmos_clim.spectral_files.oak_318.registry.txt b/src/proteus/data/atmos_clim.spectral_files.oak_318.registry.txt deleted file mode 100644 index 67dd15750..000000000 --- a/src/proteus/data/atmos_clim.spectral_files.oak_318.registry.txt +++ /dev/null @@ -1,2 +0,0 @@ -Oak.sf md5:2102d1bd115d79ff4d1f310bb6388009 -Oak.sf_k md5:d65c0fd610d6ee6c8955f63266e87f9b diff --git a/src/proteus/data/interior_struct.eos.chabrier_2021.registry.txt b/src/proteus/data/interior_struct.eos.chabrier_2021.registry.txt deleted file mode 100644 index 14e7f614e..000000000 --- a/src/proteus/data/interior_struct.eos.chabrier_2021.registry.txt +++ /dev/null @@ -1 +0,0 @@ -EOS_Chabrier2021_HHe.tar.gz md5:18ce96ed0526d4ade283807a7da2e091 diff --git a/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase.registry.txt b/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase.registry.txt deleted file mode 100644 index 46c84ffcf..000000000 --- a/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase.registry.txt +++ /dev/null @@ -1,2 +0,0 @@ -paleos_mgsio3_tables_pt_proteus_liquid.dat md5:4b5c14255ca845880a2e940110861df1 -paleos_mgsio3_tables_pt_proteus_solid.dat md5:9cab570e3331b329c15715628a504fad diff --git a/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase_highres.registry.txt b/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase_highres.registry.txt deleted file mode 100644 index 8808a4f61..000000000 --- a/src/proteus/data/interior_struct.eos.paleos_mgsio3_2phase_highres.registry.txt +++ /dev/null @@ -1,2 +0,0 @@ -paleos_mgsio3_tables_pt_proteus_liquid_highres.dat md5:f67e0f049fe5f3d5cba0adef6ab7632f -paleos_mgsio3_tables_pt_proteus_solid_highres.dat md5:fddb2c3f392e1c2f73a326d702a98c70 diff --git a/src/proteus/data/interior_struct.eos.paleos_unified.registry.txt b/src/proteus/data/interior_struct.eos.paleos_unified.registry.txt deleted file mode 100644 index df8f0e58c..000000000 --- a/src/proteus/data/interior_struct.eos.paleos_unified.registry.txt +++ /dev/null @@ -1,3 +0,0 @@ -paleos_iron_eos_table_pt.dat md5:259a2267dcf58af3b2c4dd389105b597 -paleos_mgsio3_eos_table_pt.dat md5:1f75d518bf192e66b94ec0bda190ada7 -paleos_water_eos_table_pt.dat md5:2f62eea322f0dee1268565e4182bde11 diff --git a/src/proteus/data/interior_struct.eos.rtpress_100tpa.registry.txt b/src/proteus/data/interior_struct.eos.rtpress_100tpa.registry.txt deleted file mode 100644 index a97d21203..000000000 --- a/src/proteus/data/interior_struct.eos.rtpress_100tpa.registry.txt +++ /dev/null @@ -1,2 +0,0 @@ -adiabat_temp_grad_melt.dat md5:5aedafc17fec4cab5a38d30d4029fe58 -density_melt.dat md5:0ba9c4468ac9405f3ced86d4edf283c6 diff --git a/src/proteus/data/interior_struct.eos.wolf_bower_2018.registry.txt b/src/proteus/data/interior_struct.eos.wolf_bower_2018.registry.txt deleted file mode 100644 index abcf667cf..000000000 --- a/src/proteus/data/interior_struct.eos.wolf_bower_2018.registry.txt +++ /dev/null @@ -1,3 +0,0 @@ -adiabat_temp_grad_melt.dat md5:8ec5859f6500c3ecb2837a1f32c5b102 -density_melt.dat md5:8af47f385066fd8b85640a8731dbcda7 -density_solid.dat md5:2dcc6a431f4b19b08702936046fd12a3 diff --git a/src/proteus/data/interior_struct.lookup.wolf_bower_2018_1tpa.registry.txt b/src/proteus/data/interior_struct.lookup.wolf_bower_2018_1tpa.registry.txt deleted file mode 100644 index 00d3b021c..000000000 --- a/src/proteus/data/interior_struct.lookup.wolf_bower_2018_1tpa.registry.txt +++ /dev/null @@ -1,14 +0,0 @@ -README.md md5:7f8275870d6ddcd373d6aa57f86853fb -adiabat_temp_grad_melt.dat md5:71793eaed354e6994553514828219e95 -adiabat_temp_grad_solid.dat md5:c4a4b6febfddcb3d5496b415949a1b29 -density_melt.dat md5:8aaa37a5ac723e248895e0e6eb609046 -density_solid.dat md5:29cbf080addf579106068a136594e24b -heat_capacity_melt.dat md5:2df07a56cb3c21cfb9669b01ef8f0ebc -heat_capacity_solid.dat md5:4d3dd3432b791684483ca67f2c6070fa -liquidus_P-S.dat md5:cc1a5a17ebb4c712c06e902b8d4c1121 -md5sums.txt md5:67fae063460fdcdc06a9e16d63baf733 -solidus_P-S.dat md5:6662d0c6b26dd4c97ce81cc4e751cba2 -temperature_melt.dat md5:de85936016692a509a86abaa36943651 -temperature_solid.dat md5:340202b286e9fb3117b6b5f6cac0bc31 -thermal_exp_melt.dat md5:f2983441e654c4d4285f9a0e563247da -thermal_exp_solid.dat md5:7567e301358dae17c34187172f8837f9 diff --git a/src/proteus/data/interior_struct.melting_curves.monteux_minus600.registry.txt b/src/proteus/data/interior_struct.melting_curves.monteux_minus600.registry.txt deleted file mode 100644 index 733d5c272..000000000 --- a/src/proteus/data/interior_struct.melting_curves.monteux_minus600.registry.txt +++ /dev/null @@ -1,2 +0,0 @@ -liquidus.dat md5:2003c48dfae8b7c020a5cbe5222e3710 -solidus.dat md5:6d48d841639c06f61a872656fc9a5b11 diff --git a/src/proteus/data/interior_struct.melting_curves.monteux_plus600.registry.txt b/src/proteus/data/interior_struct.melting_curves.monteux_plus600.registry.txt deleted file mode 100644 index 35794c0f8..000000000 --- a/src/proteus/data/interior_struct.melting_curves.monteux_plus600.registry.txt +++ /dev/null @@ -1,2 +0,0 @@ -liquidus.dat md5:7d652243d6b167a323ba5886fdde5d21 -solidus.dat md5:e70660c7ff400609fc3ed442ea0eac84 diff --git a/src/proteus/data/interior_struct.melting_curves.wolf_bower_2018.registry.txt b/src/proteus/data/interior_struct.melting_curves.wolf_bower_2018.registry.txt deleted file mode 100644 index 882026425..000000000 --- a/src/proteus/data/interior_struct.melting_curves.wolf_bower_2018.registry.txt +++ /dev/null @@ -1,2 +0,0 @@ -liquidus.dat md5:65efbd9f5b2de54109806c3b7f743708 -solidus.dat md5:66b297a120c79e7e58de5761ac40f4c4 diff --git a/src/proteus/data/proteus_manifest.toml b/src/proteus/data/proteus_manifest.toml index cf1c0d080..bb875e3f3 100644 --- a/src/proteus/data/proteus_manifest.toml +++ b/src/proteus/data/proteus_manifest.toml @@ -29,139 +29,3 @@ required_by = ["proteus"] name = "Static equations of state for iron, silicate and water (Seager et al. 2007)" zenodo = "10.5281/zenodo.15727998" required_by = ["proteus"] - -[atmos_clim.spectral_files.frostflow_16] -name = "SOCRATES spectral file Frostflow, 16 bands" -zenodo = "10.5281/zenodo.15799743" -required_by = ["proteus"] - -[atmos_clim.spectral_files.frostflow_48] -name = "SOCRATES spectral file Frostflow, 48 bands" -zenodo = "10.5281/zenodo.15696415" -required_by = ["proteus"] - -[atmos_clim.spectral_files.frostflow_256] -name = "SOCRATES spectral file Frostflow, 256 bands" -zenodo = "10.5281/zenodo.15799754" -required_by = ["proteus"] - -[atmos_clim.spectral_files.frostflow_4096] -name = "SOCRATES spectral file Frostflow, 4096 bands" -zenodo = "10.5281/zenodo.15799776" -required_by = ["proteus"] - -[atmos_clim.spectral_files.dayspring_16] -name = "SOCRATES spectral file Dayspring, 16 bands" -zenodo = "10.5281/zenodo.15799318" -required_by = ["proteus"] - -[atmos_clim.spectral_files.dayspring_48] -name = "SOCRATES spectral file Dayspring, 48 bands" -zenodo = "10.5281/zenodo.15721749" -required_by = ["proteus"] - -[atmos_clim.spectral_files.dayspring_256] -name = "SOCRATES spectral file Dayspring, 256 bands" -zenodo = "10.5281/zenodo.15799474" -required_by = ["proteus"] - -[atmos_clim.spectral_files.dayspring_4096] -name = "SOCRATES spectral file Dayspring, 4096 bands" -zenodo = "10.5281/zenodo.15799495" -required_by = ["proteus"] - -[atmos_clim.spectral_files.honeyside_16] -name = "SOCRATES spectral file Honeyside, 16 bands" -zenodo = "10.5281/zenodo.15799607" -required_by = ["proteus"] - -[atmos_clim.spectral_files.honeyside_48] -name = "SOCRATES spectral file Honeyside, 48 bands" -zenodo = "10.5281/zenodo.15799652" -required_by = ["proteus"] - -[atmos_clim.spectral_files.honeyside_256] -name = "SOCRATES spectral file Honeyside, 256 bands" -zenodo = "10.5281/zenodo.15799731" -required_by = ["proteus"] - -[atmos_clim.spectral_files.honeyside_4096] -name = "SOCRATES spectral file Honeyside, 4096 bands" -zenodo = "10.5281/zenodo.15696457" -required_by = ["proteus"] - -[atmos_clim.spectral_files.oak_318] -name = "SOCRATES spectral file Oak, 318 bands" -zenodo = "10.5281/zenodo.15743843" -required_by = ["proteus"] - -[stellar_spectra.solar] -name = "Solar spectra at present and past epochs" -zenodo = "10.5281/zenodo.17981836" -required_by = ["proteus"] - -[stellar_spectra.named] -name = "Named stellar spectra" -zenodo = "10.5281/zenodo.15721440" -required_by = ["proteus"] - -[stellar_spectra.muscles] -name = "MUSCLES observed stellar spectra" -zenodo = "10.5281/zenodo.17802209" -required_by = ["proteus"] - -[stellar_spectra.phoenix] -name = "PHOENIX synthetic stellar spectra grid, one archive per [Fe/H] and [alpha/M]" -zenodo = "10.5281/zenodo.17674612" -required_by = ["proteus"] - -[interior_struct.eos.wolf_bower_2018] -name = "Wolf & Bower (2018) MgSiO3 equation of state to 1 TPa, Zalmoxis tables" -zenodo = "10.5281/zenodo.17417017" -required_by = ["proteus"] - -[interior_struct.eos.rtpress_100tpa] -name = "RTPress MgSiO3 melt equation of state to 100 TPa, Zalmoxis tables" -zenodo = "10.5281/zenodo.18819027" -required_by = ["proteus"] - -[interior_struct.eos.paleos_mgsio3_2phase] -name = "PALEOS MgSiO3 solid and liquid tables, 150 points per decade" -zenodo = "10.5281/zenodo.19680050" -required_by = ["proteus"] - -[interior_struct.eos.paleos_mgsio3_2phase_highres] -name = "PALEOS MgSiO3 solid and liquid tables, 600 points per decade" -zenodo = "10.5281/zenodo.19680050" -required_by = ["proteus"] - -[interior_struct.eos.paleos_unified] -name = "PALEOS unified tables for iron, MgSiO3 and water" -zenodo = "10.5281/zenodo.22776069" -required_by = ["proteus"] - -[interior_struct.eos.chabrier_2021] -name = "Chabrier et al. hydrogen and helium tables in PALEOS format" -zenodo = "10.5281/zenodo.19135021" -required_by = ["proteus"] -extract = "tar" - -[interior_struct.lookup.wolf_bower_2018_1tpa] -name = "Wolf & Bower (2018) MgSiO3 phase-property and melting tables to 1 TPa, P-S format" -zenodo = "10.5281/zenodo.19473625" -required_by = ["proteus"] - -[interior_struct.melting_curves.monteux_plus600] -name = "Monteux et al. (2016) solidus and liquidus, +600 K liquidus offset" -zenodo = "10.5281/zenodo.15728091" -required_by = ["proteus"] - -[interior_struct.melting_curves.monteux_minus600] -name = "Monteux et al. (2016) solidus and liquidus, -600 K liquidus offset" -zenodo = "10.5281/zenodo.15728138" -required_by = ["proteus"] - -[interior_struct.melting_curves.wolf_bower_2018] -name = "Wolf & Bower (2018) MgSiO3 solidus and liquidus" -zenodo = "10.5281/zenodo.15728072" -required_by = ["proteus"] diff --git a/src/proteus/data/stellar_spectra.muscles.registry.txt b/src/proteus/data/stellar_spectra.muscles.registry.txt deleted file mode 100644 index 519ad00f1..000000000 --- a/src/proteus/data/stellar_spectra.muscles.registry.txt +++ /dev/null @@ -1,38 +0,0 @@ -_readme.md md5:892561e7367dc34b6e28c0b8cb190ffc -gj1132.txt md5:54c70fecc46efe2b1821ca2a37a724a4 -gj1214.txt md5:b5808ed0c6f811c3ec5dbe5774f0f22d -gj15a.txt md5:92523e738dbc424110f3a451d3553823 -gj163.txt md5:846ca42c8a3b37b0dab1177b6961fd19 -gj176.txt md5:db66f8bc053888cd402f5aaf15f17c72 -gj436.txt md5:cffa59a9f554debb50cd9c2dbd1fc510 -gj551.txt md5:e44d52db1b511e787a6f9c5cd458f272 -gj581.txt md5:db8989c0e2eb27919b6391d859ed7914 -gj649.txt md5:04100febfcca9641fa9c915b27f359fc -gj667c.txt md5:8187800ae9d7259d4861eea6d1037097 -gj674.txt md5:d122d27f4905c548378fe19d0637425e -gj676a.txt md5:3e8d4385705803bfc67c77fbcf513306 -gj699.txt md5:cbab13715ba41cb1b6fe88c0976f3db3 -gj729.txt md5:55bb866fa62f8fd3284facfd3b25a771 -gj832.txt md5:f84c0695e9f4a9019a2c82ed364defa9 -gj832_synth.txt md5:2371c50479ff939d6cd548cabbac2a55 -gj849.txt md5:a7fbe9d3e2c1f291b5431d65e445aa5c -gj876.txt md5:4b4e7299bad9545ee8fef82cb4c8d4d8 -hat-p-12.txt md5:a6c8209457db31b9255bc87808e47551 -hat-p-26.txt md5:4f0aa37cde071fcf15fee95da1d7ff34 -hd-149026.txt md5:25801615325ca11dde0737c93cacc37d -hd40307.txt md5:7f67b98ad9500f65764f2423dd56c203 -hd85512.txt md5:daa07e82c91d6cc54aedbc18b1b28f67 -hd97658.txt md5:21346f9ee3ada0ae24526ede0ac26465 -l-678-39.txt md5:4bea9792c1543aec0c48814c8a772caf -l-98-59.txt md5:9709fe575a10db7ea8c62f546b3a3dc0 -l-980-5.txt md5:87f20087e6a965fca0da631bcc96b8c5 -lhs-2686.txt md5:bc72478d2028ef9d4f65b8af1caec319 -lp-791-18.txt md5:dda981826c090ef1af413dfd856f4239 -muscles_scaling_distances.csv md5:d5008bef930b29f1ee3dcdb0a120e587 -toi-193.txt md5:ca354292302f20f395b9a6811b14b74d -trappist-1.txt md5:ed00bec981d4332922305c449b3a6339 -v-eps-eri.txt md5:dc5da668f6cc455c0f550b3f190c0af9 -wasp-127.txt md5:e6b0af652f6ba4b46abd8cfdd83274e0 -wasp-17.txt md5:1ae0c7758aaefa401e01e258fb2cc5ff -wasp-43.txt md5:cd3e1263df93e62635828f044ed849ac -wasp-77a.txt md5:9b498dbb5830c83c5a4a90fcee2e73e2 diff --git a/src/proteus/data/stellar_spectra.named.registry.txt b/src/proteus/data/stellar_spectra.named.registry.txt deleted file mode 100644 index 1848d87e6..000000000 --- a/src/proteus/data/stellar_spectra.named.registry.txt +++ /dev/null @@ -1,11 +0,0 @@ -HIP67522.txt md5:173af82407eb3ba1c831ef616870153a -eps-eri.txt md5:1eaf42a702bc7dbafde4408044e093c5 -gj1132.txt md5:4c01b4dc5b5c3f5e08955afdeda10b29 -gj1214.txt md5:1ef28a77d46cb582cd451eb732c7a685 -gj176.txt md5:dd652004b6ad0ed7df992d2548703da6 -gj849.txt md5:a4f860f754c31056d646a72c2f6f9ea6 -hd85512.txt md5:ba819e0283d490b28561b6934d465ba4 -hd97658.txt md5:0c259e8e6bc66c1bc876ccf522830add -l-98-59.txt md5:fc4059008fc09620c60de6c2c65ac961 -sun.txt md5:0c5225b847ca250673edc9690f02e055 -trappist-1.txt md5:2ab391fa858af21bf5edee37aafc8879 diff --git a/src/proteus/data/stellar_spectra.phoenix.registry.txt b/src/proteus/data/stellar_spectra.phoenix.registry.txt deleted file mode 100644 index d92e4561a..000000000 --- a/src/proteus/data/stellar_spectra.phoenix.registry.txt +++ /dev/null @@ -1,52 +0,0 @@ -FeH+0.5_alpha+0.0_phoenixMedRes_R05000.zip md5:267fb222ab42c9cbaf216b15a273909a -FeH+1.0_alpha+0.0_phoenixMedRes_R05000.zip md5:eb4feee5305e954c587557ad030e9b28 -FeH-0.0_alpha+0.0_phoenixMedRes_R05000.zip md5:9998a2a4f967c874477dafb147f58ce4 -FeH-0.0_alpha+0.2_phoenixMedRes_R05000.zip md5:46f135023f867557d78615a3dde93a4c -FeH-0.0_alpha+0.4_phoenixMedRes_R05000.zip md5:e2f0ad75f29f3a03b9b483b8f03d487c -FeH-0.0_alpha+0.6_phoenixMedRes_R05000.zip md5:f5c84b282ad7bf846e84bc64188c37e6 -FeH-0.0_alpha+0.8_phoenixMedRes_R05000.zip md5:2e6fd541c5dfc583a1049ee01853ef2e -FeH-0.0_alpha+1.0_phoenixMedRes_R05000.zip md5:bde09a8857db04212d6fa9c21ac15e6d -FeH-0.0_alpha+1.2_phoenixMedRes_R05000.zip md5:04d650920ce75eeafd2b5db0e5197490 -FeH-0.0_alpha-0.2_phoenixMedRes_R05000.zip md5:9d4e3650991cee5a7bb4c8530f5b302b -FeH-0.5_alpha+0.0_phoenixMedRes_R05000.zip md5:f0f1c881b613938bb0f08eb5f574cfd8 -FeH-0.5_alpha+0.2_phoenixMedRes_R05000.zip md5:130f0fc79b5f7fc6802d9d88c4d752c3 -FeH-0.5_alpha+0.4_phoenixMedRes_R05000.zip md5:6cca7463922b06a64efc0c24dc0ed3ac -FeH-0.5_alpha+0.6_phoenixMedRes_R05000.zip md5:d39d5d38ef6de2f5a4c8f1dcc8c3a1a8 -FeH-0.5_alpha+0.8_phoenixMedRes_R05000.zip md5:36477b3ea02dc8dc49423a5318a0f3ec -FeH-0.5_alpha+1.0_phoenixMedRes_R05000.zip md5:1412e1e76953f304992364d28a29e36f -FeH-0.5_alpha+1.2_phoenixMedRes_R05000.zip md5:000537469c30ba64d1f5f6cd3a1cc0db -FeH-0.5_alpha-0.2_phoenixMedRes_R05000.zip md5:fa6849d70297a77f7b7da8fda2d8ed5f -FeH-1.0_alpha+0.0_phoenixMedRes_R05000.zip md5:8338b175049696b43a3bd2bd59534701 -FeH-1.0_alpha+0.2_phoenixMedRes_R05000.zip md5:b70c3849a0a762e06e992c1ab5bc6231 -FeH-1.0_alpha+0.4_phoenixMedRes_R05000.zip md5:60bf19e33c10ecac6c772e24f959d8b3 -FeH-1.0_alpha+0.6_phoenixMedRes_R05000.zip md5:0b877f98f121a6b0ff95c01882431092 -FeH-1.0_alpha+0.8_phoenixMedRes_R05000.zip md5:6a639338ed0ec811f8a2edff954784b2 -FeH-1.0_alpha+1.0_phoenixMedRes_R05000.zip md5:6ec69d31387045dc7de9fae1b1fec7e0 -FeH-1.0_alpha+1.2_phoenixMedRes_R05000.zip md5:668c627ee83e4efb3536e16514ace0fa -FeH-1.0_alpha-0.2_phoenixMedRes_R05000.zip md5:d3ea40f56c48eda37eef81234feb9d90 -FeH-1.5_alpha+0.0_phoenixMedRes_R05000.zip md5:50ab1850d36fdec1c4b5ddd91ab3b82d -FeH-1.5_alpha+0.2_phoenixMedRes_R05000.zip md5:ee9807cd5c2658bf5e78e4d2752d1f91 -FeH-1.5_alpha+0.4_phoenixMedRes_R05000.zip md5:f9d2e8ab1ad48846cb6be871a24e2d77 -FeH-1.5_alpha+0.6_phoenixMedRes_R05000.zip md5:53d6d88329b81079045d101011e72388 -FeH-1.5_alpha+0.8_phoenixMedRes_R05000.zip md5:ca94b30ad66232ea710353317215446f -FeH-1.5_alpha+1.0_phoenixMedRes_R05000.zip md5:474d35391357f0814713a8a1c5193698 -FeH-1.5_alpha+1.2_phoenixMedRes_R05000.zip md5:e0153e003faa87413579d0f09452e079 -FeH-1.5_alpha-0.2_phoenixMedRes_R05000.zip md5:f5b11bc118ac5057e3a41a3e4620f59d -FeH-2.0_alpha+0.0_phoenixMedRes_R05000.zip md5:81c1ebf5046db3a5c3c407920e0cd456 -FeH-2.0_alpha+0.2_phoenixMedRes_R05000.zip md5:ba1863704fa6dded8f004870d1dd82da -FeH-2.0_alpha+0.4_phoenixMedRes_R05000.zip md5:addd5dd023165f0773b490c4c2148d24 -FeH-2.0_alpha+0.6_phoenixMedRes_R05000.zip md5:7effc1f21f2b8c94256d61c1524bcf9c -FeH-2.0_alpha+0.8_phoenixMedRes_R05000.zip md5:c85bc80c46a5cd70887bedf1099f32ce -FeH-2.0_alpha+1.0_phoenixMedRes_R05000.zip md5:159cd0e705caf909b7d6f6911dd4f459 -FeH-2.0_alpha+1.2_phoenixMedRes_R05000.zip md5:c251c7aa09d81de588749cd2968f1d5a -FeH-2.0_alpha-0.2_phoenixMedRes_R05000.zip md5:ecd1a7bac93453fca94c91fb5f317ad9 -FeH-3.0_alpha+0.0_phoenixMedRes_R05000.zip md5:44616bdf40565812c689807064d860d2 -FeH-3.0_alpha+0.2_phoenixMedRes_R05000.zip md5:47ba055a48ab6d9015554695f892a899 -FeH-3.0_alpha+0.4_phoenixMedRes_R05000.zip md5:fe5a6af7b484134aa6a6432f084ec442 -FeH-3.0_alpha+0.6_phoenixMedRes_R05000.zip md5:d6546eb91379716793e37f7b983d06ad -FeH-3.0_alpha+0.8_phoenixMedRes_R05000.zip md5:45607ec941792a43306aa7845634dfa9 -FeH-3.0_alpha+1.0_phoenixMedRes_R05000.zip md5:aded444a4fc428165bdf486a296247ad -FeH-3.0_alpha+1.2_phoenixMedRes_R05000.zip md5:19ee4441307ff07ff9c585db6e7cd765 -FeH-3.0_alpha-0.2_phoenixMedRes_R05000.zip md5:732df0380ad4c408c1742e62d6d287e6 -FeH-4.0_alpha+0.0_phoenixMedRes_R05000.zip md5:8762643304781800400a86000a243ae0 -_readme.md md5:16be5f22209ded520b040db90c62fd4d diff --git a/src/proteus/data/stellar_spectra.solar.registry.txt b/src/proteus/data/stellar_spectra.solar.registry.txt deleted file mode 100644 index a5776249e..000000000 --- a/src/proteus/data/stellar_spectra.solar.registry.txt +++ /dev/null @@ -1,10 +0,0 @@ -Sun0.6Ga.txt md5:4d436397be6385b00f6b5e223f133123 -Sun1.8Ga.txt md5:f694106a6b6e0f2ac4f7a20ddf307392 -Sun2.4Ga.txt md5:817f7b2649ae73ef5cf8f213c9234cf5 -Sun2.7Ga.txt md5:0ec923765beeadb410735b2318488f62 -Sun3.8Ga.txt md5:475be7cab37e4c9e314dcb9deed98e06 -Sun4.4Ga.txt md5:410507a37b2375ece9e2e2235cabb093 -Sun5.6Gyr.txt md5:c680e847629e6e140c85c5bbc8a7cae2 -SunModern.txt md5:e2209fd85970361b8ee007c88f21b058 -_readme.md md5:96d33bdba86c949afd8df080aed156be -sun.txt md5:6e4b6540d952cf3c01a3bd0511aa6c10 diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 27862cd55..b81aa2363 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -19,9 +19,10 @@ from proteus.config import Config from proteus.data import ( EOS_CHABRIER_2021, - EOS_PALEOS_MGSIO3_2PHASE, - EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, - EOS_PALEOS_UNIFIED, + EOS_PALEOS_H2O, + EOS_PALEOS_IRON, + EOS_PALEOS_MGSIO3, + EOS_PALEOS_MGSIO3_UNIFIED, EOS_RTPRESS_100TPA, EOS_SEAGER_2007, EOS_WOLF_BOWER_2018, @@ -34,7 +35,7 @@ R_earth, element_list, ) -from proteus.utils.data import get_zalmoxis_melting_curves +from proteus.utils.data import GetFWLData, get_zalmoxis_melting_curves FWL_DATA_DIR = Path(os.environ.get('FWL_DATA', platformdirs.user_data_dir('fwl_data'))) @@ -1548,10 +1549,10 @@ def load_zalmoxis_material_dictionaries(): """Build an EOS registry dict with file paths pointing to FWL_DATA. Returns the same dict format as Zalmoxis ``EOS_REGISTRY``, but with - every ``eos_file`` path resolved to the fwl-io dataset directory under - ``FWL_DATA/interior_struct/eos/`` instead of ``ZALMOXIS_ROOT/data/``. - This ensures that Zalmoxis, when called from PROTEUS, reads EOS data - from the central FWL_DATA location managed by ``download_zalmoxis_eos()``. + every ``eos_file`` path resolved to its fwl-io dataset directory in + ``FWL_DATA`` instead of ``ZALMOXIS_ROOT/data/``. The data root is the one + ``download_zalmoxis_eos()`` fetches into, so Zalmoxis, when called from + PROTEUS, reads the files that were fetched. Returns ------- @@ -1559,14 +1560,15 @@ def load_zalmoxis_material_dictionaries(): Flat dict keyed by EOS identifier string (e.g. ``"Seager2007:iron"``, ``"PALEOS:MgSiO3"``, ``"Chabrier:H"``). """ - seager_dir = dataset_dir(EOS_SEAGER_2007, data_root=FWL_DATA_DIR) + root = GetFWLData() + seager_dir = dataset_dir(EOS_SEAGER_2007, data_root=root) _seager_iron = {'eos_file': str(seager_dir / 'eos_seager07_iron.txt')} _seager_silicate = {'eos_file': str(seager_dir / 'eos_seager07_silicate.txt')} _seager_water = {'eos_file': str(seager_dir / 'eos_seager07_water.txt')} # Wolf & Bower 2018 - wb_dir = dataset_dir(EOS_WOLF_BOWER_2018, data_root=FWL_DATA_DIR) + wb_dir = dataset_dir(EOS_WOLF_BOWER_2018, data_root=root) _wb_melted = { 'eos_file': str(wb_dir / 'density_melt.dat'), 'adiabat_grad_file': str(wb_dir / 'adiabat_temp_grad_melt.dat'), @@ -1574,16 +1576,16 @@ def load_zalmoxis_material_dictionaries(): _wb_solid = {'eos_file': str(wb_dir / 'density_solid.dat')} # RTPress 100 TPa - rt_dir = dataset_dir(EOS_RTPRESS_100TPA, data_root=FWL_DATA_DIR) + rt_dir = dataset_dir(EOS_RTPRESS_100TPA, data_root=root) _rt_melted = { 'eos_file': str(rt_dir / 'density_melt.dat'), 'adiabat_grad_file': str(rt_dir / 'adiabat_temp_grad_melt.dat'), } - # PALEOS 2-phase MgSiO3 (separate solid/liquid, Zenodo 19680050). - # 150 pts/decade (default) and 600 pts/decade (highres) variants. - paleos2ph_dir = dataset_dir(EOS_PALEOS_MGSIO3_2PHASE, data_root=FWL_DATA_DIR) - paleos2ph_hr_dir = dataset_dir(EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, data_root=FWL_DATA_DIR) + # PALEOS 2-phase MgSiO3 (separate solid/liquid, Zenodo 19680050): the + # 150 pts/decade (default) and 600 pts/decade (highres) pairs share one dataset. + paleos2ph_dir = dataset_dir(EOS_PALEOS_MGSIO3, data_root=root) + paleos2ph_hr_dir = paleos2ph_dir _paleos2ph_melted = { 'eos_file': str(paleos2ph_dir / 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), 'format': 'paleos', @@ -1603,23 +1605,29 @@ def load_zalmoxis_material_dictionaries(): 'format': 'paleos', } - # PALEOS unified tables (iron, MgSiO3 and water share one dataset) - paleos_unified_dir = dataset_dir(EOS_PALEOS_UNIFIED, data_root=FWL_DATA_DIR) + # PALEOS unified tables, one dataset per material _paleos_iron = { - 'eos_file': str(paleos_unified_dir / 'paleos_iron_eos_table_pt.dat'), + 'eos_file': str( + dataset_dir(EOS_PALEOS_IRON, data_root=root) / 'paleos_iron_eos_table_pt.dat' + ), 'format': 'paleos_unified', } _paleos_mgsio3 = { - 'eos_file': str(paleos_unified_dir / 'paleos_mgsio3_eos_table_pt.dat'), + 'eos_file': str( + dataset_dir(EOS_PALEOS_MGSIO3_UNIFIED, data_root=root) + / 'paleos_mgsio3_eos_table_pt.dat' + ), 'format': 'paleos_unified', } _paleos_h2o = { - 'eos_file': str(paleos_unified_dir / 'paleos_water_eos_table_pt.dat'), + 'eos_file': str( + dataset_dir(EOS_PALEOS_H2O, data_root=root) / 'paleos_water_eos_table_pt.dat' + ), 'format': 'paleos_unified', } # Chabrier H/He - chabrier_dir = dataset_dir(EOS_CHABRIER_2021, data_root=FWL_DATA_DIR) + chabrier_dir = dataset_dir(EOS_CHABRIER_2021, data_root=root) _chabrier_h = { 'eos_file': str(chabrier_dir / 'EOS_Chabrier2021_HHe' / 'chabrier2021_H.dat'), 'format': 'paleos_unified', diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 8efa072bb..2b26a65eb 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -12,6 +12,8 @@ from typing import TYPE_CHECKING import numpy as np +from fwl_io import DownloadError, MissingDataRootError, OfflineDataError +from fwl_io.archive import ArchiveError from osfclient.api import OSF from scipy.interpolate import interp1d @@ -1302,14 +1304,21 @@ def find_lookup_table_dir(data_root: Path | str | None = None) -> Path | None: Returns ------- Path or None - The dataset directory when it holds the SPIDER phase files, else None - (also when the data root cannot be created). + The dataset directory when it holds every SPIDER phase file and both + P-S melting curves, else None (also when the data root cannot be + created), so an interrupted fetch never shadows a complete table set. """ + from proteus.interior_energetics.common import ( + _SPIDER_EOS_MELTING_CURVES, + _SPIDER_EOS_PHASE_FILES, + ) + try: folder = resolve_lookup_table_dir(data_root) except OSError: return None - if (folder / 'thermal_exp_melt.dat').is_file() and (folder / 'density_melt.dat').is_file(): + names = _SPIDER_EOS_PHASE_FILES + _SPIDER_EOS_MELTING_CURVES + if all((folder / name).is_file() for name in names): return folder return None @@ -1318,7 +1327,7 @@ def download_interior_lookuptables(clean=False): """Fetch the melting curves that Aragog and SPIDER always need. The Wolf and Bower 2018 melting curves are fetched through fwl-io into - ``FWL_DATA/interior_struct/melting_curves/wolf_bower_2018/r/``. + ``FWL_DATA/interior/melting_curves/wolf_bower_2018/r/``. Parameters ---------- @@ -1341,7 +1350,7 @@ def download_melting_curves(config: Config, clean: bool = False): with ``solidus_P-T.dat`` and ``liquidus_P-T.dat`` is used as it is and nothing is fetched. Otherwise ``Monteux+600``, ``Monteux-600`` and ``Wolf_Bower+2018`` are fetched through fwl-io into - ``FWL_DATA/interior_struct/melting_curves//r/``. + ``FWL_DATA/interior/melting_curves//r/``. Parameters ---------- @@ -1390,8 +1399,8 @@ def download_stellar_spectra(*, folders: tuple[str, ...] | None = None): Notes ----- Each collection is a manifest dataset and lands in its own version - directory: ``stellar_spectra/solar/``, ``stellar_spectra/named/`` and - ``stellar_spectra/muscles/``, each below an ``r`` directory. + directory: ``star/spectra/solar/``, ``star/spectra/named/`` and + ``star/spectra/muscles/``, each below an ``r`` directory. Parameters ---------- @@ -1592,6 +1601,38 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): ) +# What a start-of-run fetch raises when a dataset cannot be obtained: the +# filesystem errors, and fwl-io's own errors, which derive from RuntimeError. +_FETCH_ERRORS = (OSError, DownloadError, OfflineDataError, ArchiveError, MissingDataRootError) + + +def _attempt(desc: str, func, *args, **kwargs) -> bool: + """Run one start-of-run fetch step, reporting a failure instead of raising. + + Each dataset is fetched independently, so an unreachable mirror for one of + them does not stop the others; a dataset that is still missing fails later, + where it is read. + + Parameters + ---------- + desc : str + What the step fetches, for the log message. + func : callable + The fetch function, called with the remaining arguments. + + Returns + ------- + bool + Whether the step finished without a fetch error. + """ + try: + func(*args, **kwargs) + except _FETCH_ERRORS as exc: + log.warning('Problem when downloading/checking %s: %s', desc, exc) + return False + return True + + def _get_sufficient(config: Config, clean: bool = False): # Star stuff if config.star.module == 'mors': @@ -1603,16 +1644,18 @@ def _get_sufficient(config: Config, clean: bool = False): folders.append('solar') if spec_src in (None, 'muscles'): folders.append('MUSCLES') - download_stellar_spectra(folders=tuple(dict.fromkeys(folders))) - if config.star.mors.tracks == 'spada': - download_stellar_tracks('Spada') - else: - download_stellar_tracks('Baraffe') + _attempt( + 'stellar spectra', + download_stellar_spectra, + folders=tuple(dict.fromkeys(folders)), + ) + tracks = 'Spada' if config.star.mors.tracks == 'spada' else 'Baraffe' + _attempt('stellar tracks', download_stellar_tracks, tracks) # Spectral files if config.atmos_clim.module in ('janus', 'agni'): # High-res file often used for post-processing - download_spectral_file('Honeyside', '4096') + _attempt('spectral file Honeyside/4096', download_spectral_file, 'Honeyside', '4096') # Skip the group/bands download when AGNI takes its spectral file # directly from the user (a custom path, or 'greygas'). @@ -1623,15 +1666,15 @@ def _get_sufficient(config: Config, clean: bool = False): from proteus.atmos_clim.common import get_spfile_name_and_bands group, bands = get_spfile_name_and_bands(config) - download_spectral_file(group, bands) + _attempt(f'spectral file {group}/{bands}', download_spectral_file, group, bands) # Surface single-scattering data if config.atmos_clim.module == 'agni': - download_surface_albedos() + _attempt('surface albedos', download_surface_albedos) # Aerosol scattering data if config.atmos_clim.module == 'agni' and config.atmos_clim.aerosols_enabled: - download_scattering() + _attempt('aerosol scattering data', download_scattering) # Exoplanet population data download_exoplanet_data() @@ -1641,18 +1684,18 @@ def _get_sufficient(config: Config, clean: bool = False): # Interior lookup tables (melting curves) if config.interior_energetics.module in ('aragog', 'spider'): - download_interior_lookuptables(clean=clean) - download_melting_curves(config, clean=clean) + _attempt('interior lookup tables', download_interior_lookuptables, clean=clean) + _attempt('melting curves', download_melting_curves, config, clean=clean) # Dynamic EOS for SPIDER and Aragog (uses struct.eos_dir, skip if None/PALEOS) if ( config.interior_energetics.module in ('spider', 'aragog') and config.interior_struct.eos_dir is not None ): - download_eos_dynamic(config.interior_struct.eos_dir) + _attempt('EOS lookup tables', download_eos_dynamic, config.interior_struct.eos_dir) # EOS for Zalmoxis (derived from struct.zalmoxis config, not struct.eos_dir) - download_zalmoxis_eos_for_config(config) + _attempt('Zalmoxis EOS tables', download_zalmoxis_eos_for_config, config) def download_zalmoxis_eos_for_config(config) -> None: @@ -1695,7 +1738,7 @@ def download_sufficient_data(config: Config, clean: bool = False): # Some issue. Usually due to lack of internet connection, but print the error # anyway so that the user knows what happened. - except OSError as e: + except _FETCH_ERRORS as e: log.warning('Problem when downloading/checking reference data') log.warning(str(e)) @@ -1816,8 +1859,8 @@ def download_eos_static(): def download_eos_dynamic(eos_dir: str = 'WolfBower2018_MgSiO3'): """Fetch the Wolf and Bower 2018 P-S lookup tables through fwl-io. - The tables land in - ``FWL_DATA/interior_struct/lookup/wolf_bower_2018_1tpa/r19473625/``. The + The tables land in ``FWL_DATA/interior/eos/dk09_1tpa_elec_free/ + mgsio3_wolf_bower_2018_1tpa/r19473625/``. The record provides the complete P-S set that both SPIDER and Aragog consume at runtime: 10 phase-property files (temperature, density, heat capacity, adiabatic gradient, thermal expansivity for melt and solid) plus the two @@ -1885,14 +1928,9 @@ def download_Seager_EOS(): # ── Zalmoxis EOS download helpers ──────────────────────────────────── # -# Each function downloads a specific Zalmoxis EOS dataset into -# ``FWL_DATA/zalmoxis_eos//``. The folder names and Zenodo -# record IDs mirror the Zalmoxis-internal ``setup_utils.download_data()`` -# so that every file ends up at a predictable path. -# -# ``download_zalmoxis_eos()`` is the top-level dispatcher called from -# ``_get_sufficient()``; it inspects the mantle/core EOS config and -# downloads only the datasets required for the current run. +# ``download_zalmoxis_eos()`` is called from ``_get_sufficient()``; it +# inspects the mantle/core EOS config and fetches only the manifest files +# the current run reads. # ───────────────────────────────────────────────────────────────────── # Mantle EOS family prefixes whose registry entry carries the Seager iron @@ -1911,20 +1949,26 @@ def download_Seager_EOS(): ) -# PALEOS unified component -> table file inside the shared record. -_PALEOS_UNIFIED_TABLES = { - 'PALEOS:iron': 'paleos_iron_eos_table_pt.dat', - 'PALEOS:MgSiO3': 'paleos_mgsio3_eos_table_pt.dat', - 'PALEOS:H2O': 'paleos_water_eos_table_pt.dat', -} +# Files Zalmoxis reads from each dataset whose record holds more than it needs. +_WOLF_BOWER_EOS_FILES = ('density_melt.dat', 'adiabat_temp_grad_melt.dat', 'density_solid.dat') +_RTPRESS_EOS_FILES = ('density_melt.dat', 'adiabat_temp_grad_melt.dat') +_PALEOS_2PHASE_FILES = ( + 'paleos_mgsio3_tables_pt_proteus_liquid.dat', + 'paleos_mgsio3_tables_pt_proteus_solid.dat', +) +_PALEOS_2PHASE_HIGHRES_FILES = ( + 'paleos_mgsio3_tables_pt_proteus_liquid_highres.dat', + 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat', +) def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: str = ''): """Download Zalmoxis EOS data required for the given EOS configuration. Inspects the mantle, core, and ice layer EOS identifiers and downloads - only the datasets needed. Each dataset lands in its own directory under - ``FWL_DATA/interior_struct/eos/`` (see ``proteus_manifest.toml``). + only the files needed. Seager 2007 lands under ``FWL_DATA/interior_struct/eos/`` + (``proteus_manifest.toml``); the other datasets land under + ``FWL_DATA/interior/eos/`` (the fwl-io shared manifest). Parameters ---------- @@ -1937,15 +1981,20 @@ def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: st """ from proteus.data import ( EOS_CHABRIER_2021, - EOS_PALEOS_MGSIO3_2PHASE, - EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, - EOS_PALEOS_UNIFIED, + EOS_PALEOS_H2O, + EOS_PALEOS_IRON, + EOS_PALEOS_MGSIO3, + EOS_PALEOS_MGSIO3_UNIFIED, EOS_RTPRESS_100TPA, EOS_WOLF_BOWER_2018, fetch_dataset, fetch_dataset_file, ) + def fetch_files(key, names): + for name in names: + fetch_dataset_file(key, name, data_root=FWL_DATA_DIR) + all_eos = [e for e in (mantle_eos, core_eos, ice_layer_eos) if e] # Multi-component EOS strings: "PALEOS:MgSiO3:0.98+Chabrier:H:0.01" @@ -1973,26 +2022,31 @@ def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: st needs_wb = any(c.startswith('WolfBower2018') for c in components) needs_rtpress = any(c.startswith('RTPress100TPa') for c in components) if needs_wb: - fetch_dataset(EOS_WOLF_BOWER_2018, data_root=FWL_DATA_DIR) + fetch_files(EOS_WOLF_BOWER_2018, _WOLF_BOWER_EOS_FILES) elif needs_rtpress: - fetch_dataset_file(EOS_WOLF_BOWER_2018, 'density_solid.dat', data_root=FWL_DATA_DIR) + fetch_files(EOS_WOLF_BOWER_2018, ('density_solid.dat',)) # RTPress 100 TPa extended melt if needs_rtpress: - fetch_dataset(EOS_RTPRESS_100TPA, data_root=FWL_DATA_DIR) + fetch_files(EOS_RTPRESS_100TPA, _RTPRESS_EOS_FILES) - # PALEOS 2-phase MgSiO3 (separate solid/liquid). The default entry uses - # 150 pts/decade tables; the -highres entry uses 600 pts/decade. Each is its - # own dataset, so the highres pair (~1.3 GB) is fetched only when selected. + # PALEOS 2-phase MgSiO3 (separate solid/liquid), one record holding the + # 150 pts/decade pair and the 600 pts/decade (-highres) pair; fetch only the + # selected pair, since the highres pair alone is about 1.3 GB. if 'PALEOS-2phase:MgSiO3' in components: - fetch_dataset(EOS_PALEOS_MGSIO3_2PHASE, data_root=FWL_DATA_DIR) + fetch_files(EOS_PALEOS_MGSIO3, _PALEOS_2PHASE_FILES) if 'PALEOS-2phase:MgSiO3-highres' in components: - fetch_dataset(EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, data_root=FWL_DATA_DIR) + fetch_files(EOS_PALEOS_MGSIO3, _PALEOS_2PHASE_HIGHRES_FILES) - # PALEOS unified tables share one record; fetch only the selected tables. - for component, table in _PALEOS_UNIFIED_TABLES.items(): + # PALEOS unified tables: one dataset per material. + unified = { + 'PALEOS:iron': (EOS_PALEOS_IRON, 'paleos_iron_eos_table_pt.dat'), + 'PALEOS:MgSiO3': (EOS_PALEOS_MGSIO3_UNIFIED, 'paleos_mgsio3_eos_table_pt.dat'), + 'PALEOS:H2O': (EOS_PALEOS_H2O, 'paleos_water_eos_table_pt.dat'), + } + for component, (key, table) in unified.items(): if component in components: - fetch_dataset_file(EOS_PALEOS_UNIFIED, table, data_root=FWL_DATA_DIR) + fetch_files(key, (table,)) # Chabrier H/He if any(c.startswith('Chabrier') for c in components): diff --git a/tests/atmos_clim/test_common.py b/tests/atmos_clim/test_common.py index 04715c3d4..7f8908d14 100644 --- a/tests/atmos_clim/test_common.py +++ b/tests/atmos_clim/test_common.py @@ -621,13 +621,14 @@ def test_spfile_helpers(tmp_path): assert bands == '16' # Test get_spfile_path construction - # Expected: /atmos_clim/spectral_files/_/r/.sf + # Expected: /atmos_clim/spectral_files///r/.sf path = get_spfile_path(str(tmp_path), mock_conf) assert path == str( tmp_path / 'atmos_clim' / 'spectral_files' - / 'dayspring_16' + / 'dayspring' + / '16' / 'r15799318' / 'Dayspring.sf' ) diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index e1866baf4..1a6ee6979 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -24,9 +24,10 @@ from proteus.data import ( EOS_CHABRIER_2021, - EOS_PALEOS_MGSIO3_2PHASE, - EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, - EOS_PALEOS_UNIFIED, + EOS_PALEOS_H2O, + EOS_PALEOS_IRON, + EOS_PALEOS_MGSIO3, + EOS_PALEOS_MGSIO3_UNIFIED, EOS_RTPRESS_100TPA, EOS_SEAGER_2007, EOS_WOLF_BOWER_2018, @@ -72,40 +73,35 @@ MELTING_MONTEUX_MINUS600_RECORD = '15728138' MELTING_WOLF_BOWER_2018_RECORD = '15728072' -# Equation-of-state datasets: key -> (subdir, record). -EOS_DATASETS = { - EOS_WOLF_BOWER_2018: ('interior_struct/eos/wolf_bower_2018', WOLF_BOWER_RECORD), - EOS_RTPRESS_100TPA: ('interior_struct/eos/rtpress_100tpa', RTPRESS_RECORD), - EOS_PALEOS_MGSIO3_2PHASE: ( - 'interior_struct/eos/paleos_mgsio3_2phase', - PALEOS_2PHASE_RECORD, - ), - EOS_PALEOS_MGSIO3_2PHASE_HIGHRES: ( - 'interior_struct/eos/paleos_mgsio3_2phase_highres', - PALEOS_2PHASE_RECORD, - ), - EOS_PALEOS_UNIFIED: ('interior_struct/eos/paleos_unified', PALEOS_UNIFIED_RECORD), - EOS_CHABRIER_2021: ('interior_struct/eos/chabrier_2021', CHABRIER_RECORD), -} - -# Interior lookup table and melting curve datasets: key -> (subdir, record). -LOOKUP_AND_MELTING_DATASETS = { +# Datasets PROTEUS reads from the fwl-io shared manifest: key -> (subdir, record). +SHARED_DATASETS = { + EOS_WOLF_BOWER_2018: ('interior/eos/wolf_bower_2018_1tpa', WOLF_BOWER_RECORD), + EOS_RTPRESS_100TPA: ('interior/eos/rtpress_melt_100tpa', RTPRESS_RECORD), + EOS_PALEOS_MGSIO3: ('interior/eos/paleos_mgsio3', PALEOS_2PHASE_RECORD), + EOS_PALEOS_IRON: ('interior/eos/paleos_iron', PALEOS_UNIFIED_RECORD), + EOS_PALEOS_MGSIO3_UNIFIED: ('interior/eos/paleos_mgsio3_unified', PALEOS_UNIFIED_RECORD), + EOS_PALEOS_H2O: ('interior/eos/paleos_h2o', PALEOS_UNIFIED_RECORD), + EOS_CHABRIER_2021: ('interior/eos/chabrier_2021_hhe', CHABRIER_RECORD), LOOKUP_WOLF_BOWER_2018_1TPA: ( - 'interior_struct/lookup/wolf_bower_2018_1tpa', + 'interior/eos/dk09_1tpa_elec_free/mgsio3_wolf_bower_2018_1tpa', LOOKUP_WOLF_BOWER_2018_1TPA_RECORD, ), MELTING_MONTEUX_PLUS600: ( - 'interior_struct/melting_curves/monteux_plus600', + 'interior/melting_curves/monteux_plus_600', MELTING_MONTEUX_PLUS600_RECORD, ), MELTING_MONTEUX_MINUS600: ( - 'interior_struct/melting_curves/monteux_minus600', + 'interior/melting_curves/monteux_minus_600', MELTING_MONTEUX_MINUS600_RECORD, ), MELTING_WOLF_BOWER_2018: ( - 'interior_struct/melting_curves/wolf_bower_2018', + 'interior/melting_curves/wolf_bower_2018', MELTING_WOLF_BOWER_2018_RECORD, ), + STELLAR_SPECTRA_SOLAR: ('star/spectra/solar', SOLAR_RECORD), + STELLAR_SPECTRA_NAMED: ('star/spectra/named', NAMED_RECORD), + STELLAR_SPECTRA_MUSCLES: ('star/spectra/muscles', MUSCLES_RECORD), + STELLAR_SPECTRA_PHOENIX: ('star/spectra/phoenix', PHOENIX_RECORD), } REPO_ROOT = Path(__file__).resolve().parents[2] @@ -127,18 +123,13 @@ ('Oak', '318'): '15743843', } +# Datasets declared in proteus_manifest.toml: only data PROTEUS alone reads. _OWNED_KEYS = { EXOPLANET_REFERENCE, MASS_RADIUS_ZENG_2019, SURFACE_ALBEDOS_HAMMOND_2024, EOS_SEAGER_2007, - STELLAR_SPECTRA_SOLAR, - STELLAR_SPECTRA_NAMED, - STELLAR_SPECTRA_MUSCLES, - STELLAR_SPECTRA_PHOENIX, - *EOS_DATASETS, - *LOOKUP_AND_MELTING_DATASETS, -} | {spectral_file_key(group, bands) for group, bands in SPECTRAL_RECORDS} +} def _pyproject() -> dict: @@ -147,8 +138,16 @@ def _pyproject() -> dict: return tomllib.load(handle) +def _shared_manifest() -> dict: + """Return the fwl-io shared manifest, keyed by dataset key.""" + from fwl_io import load_manifest + from fwl_io.manifest import shared_manifest_path + + return {ds.key: ds for ds in load_manifest(shared_manifest_path())} + + def test_manifest_declares_the_datasets(): - """The manifest declares the datasets PROTEUS owns. + """The PROTEUS manifest declares exactly the datasets only PROTEUS reads. Both the key set and each dataset's derived location are pinned: fwl-io turns the dotted key into the on-disk path, so a key edit silently relocates @@ -167,37 +166,62 @@ def test_manifest_declares_the_datasets(): ) assert datasets[EOS_SEAGER_2007].subdir == 'interior_struct/eos/seager_2007' assert datasets[EOS_SEAGER_2007].zenodo == f'10.5281/zenodo.{SEAGER_2007_RECORD}' - assert datasets[STELLAR_SPECTRA_SOLAR].subdir == 'stellar_spectra/solar' - assert datasets[STELLAR_SPECTRA_NAMED].subdir == 'stellar_spectra/named' - assert datasets[STELLAR_SPECTRA_MUSCLES].subdir == 'stellar_spectra/muscles' - assert datasets[STELLAR_SPECTRA_PHOENIX].subdir == 'stellar_spectra/phoenix' - assert datasets[STELLAR_SPECTRA_SOLAR].zenodo == f'10.5281/zenodo.{SOLAR_RECORD}' - assert datasets[STELLAR_SPECTRA_NAMED].zenodo == f'10.5281/zenodo.{NAMED_RECORD}' - assert datasets[STELLAR_SPECTRA_MUSCLES].zenodo == f'10.5281/zenodo.{MUSCLES_RECORD}' - assert datasets[STELLAR_SPECTRA_PHOENIX].zenodo == f'10.5281/zenodo.{PHOENIX_RECORD}' assert datasets[SURFACE_ALBEDOS_HAMMOND_2024].zenodo == ( f'10.5281/zenodo.{HAMMOND_2024_RECORD}' ) assert datasets[EXOPLANET_REFERENCE].zenodo == f'10.5281/zenodo.{EXOPLANET_RECORD}' assert datasets[MASS_RADIUS_ZENG_2019].zenodo == f'10.5281/zenodo.{ZENG_2019_RECORD}' - for key, (subdir, record) in EOS_DATASETS.items(): - assert datasets[key].subdir == subdir - assert datasets[key].zenodo == f'10.5281/zenodo.{record}' - for key, (subdir, record) in LOOKUP_AND_MELTING_DATASETS.items(): - assert datasets[key].subdir == subdir - assert datasets[key].zenodo == f'10.5281/zenodo.{record}' # All are PROTEUS-owned, so "proteus" has to appear in required_by or # "fwl-io fetch proteus" would skip them. for ds in datasets.values(): assert 'proteus' in [model.lower() for model in ds.required_by] +def test_shared_datasets_resolve_through_the_fwl_io_manifest(): + """Every multi-model dataset PROTEUS reads comes from the fwl-io shared manifest. + + The subdir and record are pinned per key, so a key that drifts from the + shared manifest, or a re-pin there, fails here rather than at a user's fetch. + """ + shared = _shared_manifest() + + for key, (subdir, record) in SHARED_DATASETS.items(): + assert key in shared, f'{key} is not declared in the fwl-io shared manifest' + assert _dataset(key).subdir == subdir + assert _dataset(key).zenodo == f'10.5281/zenodo.{record}' + for (group, bands), record in SPECTRAL_RECORDS.items(): + assert shared[spectral_file_key(group, bands)].zenodo == f'10.5281/zenodo.{record}' + # Discrimination: none of these keys is PROTEUS-owned, so a lookup that only + # read proteus_manifest.toml would have raised above. + assert not set(SHARED_DATASETS) & _OWNED_KEYS + + +def test_no_record_is_declared_in_both_manifests(): + """A Zenodo record is pinned in one manifest only. + + Declaring a shared record again in proteus_manifest.toml under another key + would place a second copy elsewhere in FWL_DATA, and ``fwl-io relocate`` + would move a legacy tree to the shared key that PROTEUS then does not read. + """ + from fwl_io import load_manifest + + owned = {ds.zenodo for ds in load_manifest(manifest_path())} + shared = {ds.zenodo for ds in _shared_manifest().values()} + + assert owned & shared == set(), f'records declared twice: {sorted(owned & shared)}' + # Discrimination: both sets are populated, and the shared one holds records + # PROTEUS reads, so the empty intersection is not from an empty load. + assert len(owned) == len(_OWNED_KEYS) + assert f'10.5281/zenodo.{PHOENIX_RECORD}' in shared + + def test_registries_pin_committed_checksums(): - """Each dataset ships a registry of file checksums generated from its record. + """Each dataset PROTEUS reads has a registry with the files PROTEUS fetches. The counts and one literal digest are pinned so a truncated or regenerated registry is caught; an empty registry would otherwise verify nothing while - still loading cleanly. + still loading cleanly. For shared datasets the files PROTEUS fetches one by + one must be listed, or the per-file fetch raises KeyError. """ exo = _dataset(EXOPLANET_REFERENCE).registry() zeng = _dataset(MASS_RADIUS_ZENG_2019).registry() @@ -208,9 +232,7 @@ def test_registries_pin_committed_checksums(): muscles = _dataset(STELLAR_SPECTRA_MUSCLES).registry() wolf_bower = _dataset(EOS_WOLF_BOWER_2018).registry() rtpress = _dataset(EOS_RTPRESS_100TPA).registry() - paleos_2phase = _dataset(EOS_PALEOS_MGSIO3_2PHASE).registry() - paleos_highres = _dataset(EOS_PALEOS_MGSIO3_2PHASE_HIGHRES).registry() - paleos_unified = _dataset(EOS_PALEOS_UNIFIED).registry() + paleos_2phase = _dataset(EOS_PALEOS_MGSIO3).registry() chabrier = _dataset(EOS_CHABRIER_2021).registry() lookup = _dataset(LOOKUP_WOLF_BOWER_2018_1TPA).registry() monteux_plus600 = _dataset(MELTING_MONTEUX_PLUS600).registry() @@ -228,28 +250,23 @@ def test_registries_pin_committed_checksums(): assert len(solar) == 10, 'the solar record ships 10 spectra' assert len(named) == 11, 'the named-star record ships 11 spectra' assert len(muscles) == 38, 'the MUSCLES record ships 36 spectra, a readme and a table' - assert set(wolf_bower) == { - 'density_melt.dat', - 'density_solid.dat', - 'adiabat_temp_grad_melt.dat', - } - assert set(rtpress) == {'density_melt.dat', 'adiabat_temp_grad_melt.dat'} + assert {'density_melt.dat', 'density_solid.dat', 'adiabat_temp_grad_melt.dat'} <= set( + wolf_bower + ) + assert {'density_melt.dat', 'adiabat_temp_grad_melt.dat'} <= set(rtpress) assert set(paleos_2phase) == { 'paleos_mgsio3_tables_pt_proteus_liquid.dat', 'paleos_mgsio3_tables_pt_proteus_solid.dat', - } - assert set(paleos_highres) == { 'paleos_mgsio3_tables_pt_proteus_liquid_highres.dat', 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat', } # The unified record also holds high-resolution variants (2.29 GB in all); - # the registry lists only the three tables the Zalmoxis registry reads, so a - # whole-dataset fetch never pulls the rest. - assert set(paleos_unified) == { - 'paleos_iron_eos_table_pt.dat', - 'paleos_mgsio3_eos_table_pt.dat', - 'paleos_water_eos_table_pt.dat', + # each material's dataset lists only its own table. + assert set(_dataset(EOS_PALEOS_IRON).registry()) == {'paleos_iron_eos_table_pt.dat'} + assert set(_dataset(EOS_PALEOS_MGSIO3_UNIFIED).registry()) == { + 'paleos_mgsio3_eos_table_pt.dat' } + assert set(_dataset(EOS_PALEOS_H2O).registry()) == {'paleos_water_eos_table_pt.dat'} assert set(chabrier) == {'EOS_Chabrier2021_HHe.tar.gz'} assert chabrier['EOS_Chabrier2021_HHe.tar.gz'] == 'md5:18ce96ed0526d4ade283807a7da2e091' assert len(lookup) == 14, 'the Wolf-Bower lookup-table record ships 14 files' @@ -269,25 +286,7 @@ def test_registries_pin_committed_checksums(): assert exo['DACE_PlanetS.csv'] == 'md5:367a90914eba4a209f896a1c72dd3d2b' # Every entry must carry an algorithm prefix, or pooch cannot know what to # verify against; a bare digest would silently be read as the default. - for registry in ( - exo, - zeng, - hammond, - seager, - solar, - named, - muscles, - wolf_bower, - rtpress, - paleos_2phase, - paleos_highres, - paleos_unified, - chabrier, - lookup, - monteux_plus600, - monteux_minus600, - melting_wolf_bower, - ): + for registry in (exo, zeng, hammond, seager): assert all(':' in digest for digest in registry.values()) assert 'massradiusEarthlikeRocky.txt' in zeng @@ -330,21 +329,10 @@ def test_dataset_dir_is_versioned(tmp_path): assert dataset_dir(EOS_SEAGER_2007, data_root=tmp_path) == ( tmp_path / 'interior_struct' / 'eos' / 'seager_2007' / f'r{SEAGER_2007_RECORD}' ) - for key, (subdir, record) in EOS_DATASETS.items(): + for key, (subdir, record) in SHARED_DATASETS.items(): assert dataset_dir(key, data_root=tmp_path) == tmp_path / subdir / f'r{record}' - for key, (subdir, record) in LOOKUP_AND_MELTING_DATASETS.items(): - assert dataset_dir(key, data_root=tmp_path) == tmp_path / subdir / f'r{record}' - assert dataset_dir(STELLAR_SPECTRA_SOLAR, data_root=tmp_path) == ( - tmp_path / 'stellar_spectra' / 'solar' / f'r{SOLAR_RECORD}' - ) - assert dataset_dir(STELLAR_SPECTRA_NAMED, data_root=tmp_path) == ( - tmp_path / 'stellar_spectra' / 'named' / f'r{NAMED_RECORD}' - ) - assert dataset_dir(STELLAR_SPECTRA_MUSCLES, data_root=tmp_path) == ( - tmp_path / 'stellar_spectra' / 'muscles' / f'r{MUSCLES_RECORD}' - ) assert dataset_dir(STELLAR_SPECTRA_PHOENIX, data_root=tmp_path) == ( - tmp_path / 'stellar_spectra' / 'phoenix' / f'r{PHOENIX_RECORD}' + tmp_path / 'star' / 'spectra' / 'phoenix' / f'r{PHOENIX_RECORD}' ) @@ -375,7 +363,7 @@ class _UnversionedFetcher: def test_unknown_dataset_key_is_rejected(): - """A key absent from the manifest raises rather than resolving somewhere. + """A key absent from both manifests raises rather than resolving somewhere. Silently resolving an undeclared key would create an unpinned directory with no registry to verify against. @@ -519,13 +507,9 @@ def test_spectral_file_datasets_pin_their_records_and_locations(tmp_path): key = spectral_file_key(group, bands) dataset = _dataset(key) assert dataset.zenodo == f'10.5281/zenodo.{record}' - assert dataset.subdir == f'atmos_clim/spectral_files/{group.lower()}_{bands}' + assert dataset.subdir == f'atmos_clim/spectral_files/{group.lower()}/{bands}' assert dataset_dir(key, data_root=tmp_path) == ( - tmp_path - / 'atmos_clim' - / 'spectral_files' - / f'{group.lower()}_{bands}' - / f'r{record}' + tmp_path / 'atmos_clim' / 'spectral_files' / group.lower() / bands / f'r{record}' ) assert _dataset(key).registry(), f'empty registry for {key}' @@ -543,7 +527,7 @@ def test_every_spectral_folder_has_a_dataset_and_no_legacy_entry(): ) for folder in SPECTRAL_FILE_FOLDERS: group, bands = folder.split('/') - assert spectral_file_key(group, bands) in _OWNED_KEYS + assert _dataset(spectral_file_key(group, bands)).registry() assert folder not in DATA_SOURCE_MAP, f'{folder} is still pinned in DATA_SOURCE_MAP' @@ -566,8 +550,8 @@ def test_unknown_spectral_pair_is_rejected(): """An unlisted (group, bands) pair fails loudly instead of resolving to a path.""" key = spectral_file_key('Oak', '16') - assert key == 'atmos_clim.spectral_files.oak_16' - with pytest.raises(KeyError, match='oak_16'): + assert key == 'atmos_clim.spectral_files.oak.16' + with pytest.raises(KeyError, match=r'oak\.16'): dataset_dir(key) diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index e037bc816..30effab9e 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -2893,7 +2893,7 @@ def test_material_dictionaries_seager_paths_use_the_versioned_dataset_dir( import proteus.interior_struct.zalmoxis as zalmoxis_wrapper from proteus.data import EOS_SEAGER_2007, dataset_dir - monkeypatch.setattr(zalmoxis_wrapper, 'FWL_DATA_DIR', tmp_path) + monkeypatch.setattr('proteus.utils.data.FWL_DATA_DIR', tmp_path) registry = zalmoxis_wrapper.load_zalmoxis_material_dictionaries() @@ -2910,27 +2910,52 @@ def test_material_dictionaries_seager_paths_use_the_versioned_dataset_dir( assert seager.name.startswith('r') +def test_material_dictionaries_read_the_tree_the_fetch_writes(monkeypatch, tmp_path): + """The EOS paths use the data root that download_zalmoxis_eos fetches into. + + The module keeps its own raw ``FWL_DATA`` value, which is not expanded and + has a different default; building the paths from it would send Zalmoxis to + another tree than the one the fetch populated. + """ + from pathlib import Path + + import proteus.interior_struct.zalmoxis as zalmoxis_wrapper + from proteus.data import EOS_PALEOS_IRON, dataset_dir + + fetch_root = tmp_path / 'fetched' + monkeypatch.setattr('proteus.utils.data.FWL_DATA_DIR', fetch_root) + monkeypatch.setattr(zalmoxis_wrapper, 'FWL_DATA_DIR', tmp_path / 'raw_env_value') + + registry = zalmoxis_wrapper.load_zalmoxis_material_dictionaries() + + iron = Path(registry['PALEOS:iron']['eos_file']) + assert iron == dataset_dir(EOS_PALEOS_IRON, data_root=fetch_root) / ( + 'paleos_iron_eos_table_pt.dat' + ) + assert not iron.is_relative_to(tmp_path / 'raw_env_value') + + def test_material_dictionaries_mantle_paths_use_the_versioned_dataset_dirs( monkeypatch, tmp_path ): """Every mantle table resolves into its own fwl-io dataset directory.""" import proteus.interior_struct.zalmoxis as zalmoxis_wrapper from proteus.data import ( - EOS_PALEOS_MGSIO3_2PHASE, - EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, + EOS_PALEOS_MGSIO3, EOS_RTPRESS_100TPA, EOS_WOLF_BOWER_2018, dataset_dir, ) - monkeypatch.setattr(zalmoxis_wrapper, 'FWL_DATA_DIR', tmp_path) + monkeypatch.setattr('proteus.utils.data.FWL_DATA_DIR', tmp_path) registry = zalmoxis_wrapper.load_zalmoxis_material_dictionaries() wb = dataset_dir(EOS_WOLF_BOWER_2018, data_root=tmp_path) rt = dataset_dir(EOS_RTPRESS_100TPA, data_root=tmp_path) - p2 = dataset_dir(EOS_PALEOS_MGSIO3_2PHASE, data_root=tmp_path) - p2hr = dataset_dir(EOS_PALEOS_MGSIO3_2PHASE_HIGHRES, data_root=tmp_path) + # Both PALEOS 2-phase resolutions live in one shared dataset. + p2 = dataset_dir(EOS_PALEOS_MGSIO3, data_root=tmp_path) + p2hr = p2 wolf = registry['WolfBower2018:MgSiO3'] assert wolf['melted_mantle']['eos_file'] == str(wb / 'density_melt.dat') @@ -2960,5 +2985,6 @@ def test_material_dictionaries_mantle_paths_use_the_versioned_dataset_dirs( assert high['solid_mantle']['eos_file'] == str( p2hr / 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat' ) - # The two PALEOS 2-phase sets share a record but not a directory. - assert p2 != p2hr + # The two PALEOS 2-phase resolutions are distinct files in the shared dataset. + assert high['melted_mantle']['eos_file'] != two['melted_mantle']['eos_file'] + assert p2.parent.name == 'paleos_mgsio3' diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 1ab30421c..4048ea378 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -601,7 +601,7 @@ def test_download_spectral_file_call(mock_fetch): """ download_spectral_file('Oak', '318') - mock_fetch.assert_called_once_with('atmos_clim.spectral_files.oak_318') + mock_fetch.assert_called_once_with('atmos_clim.spectral_files.oak.318') with pytest.raises(ValueError, match='No data source mapping found for folder: Oak/16'): download_spectral_file('Oak', '16') @@ -1433,7 +1433,7 @@ def test_download_phoenix_fetches_one_zip_and_unpacks(mock_fetch_file, tmp_path, assert any(grid_dir.glob('LTE_T*_phoenixMedRes_R05000.txt')) assert zip_path.is_file() assert not list(base_dir.glob('*.partial')) - assert base_dir == tmp_path / 'stellar_spectra' / 'phoenix' / 'r17674612' + assert base_dir == tmp_path / 'star' / 'spectra' / 'phoenix' / 'r17674612' @pytest.mark.unit @@ -1581,11 +1581,10 @@ def test_phoenix_registry_covers_every_reachable_grid(): import numpy as np - from proteus.data import STELLAR_SPECTRA_PHOENIX, manifest_path + from proteus.data import STELLAR_SPECTRA_PHOENIX, _dataset from proteus.utils.phoenix_helper import phoenix_param, phoenix_to_grid - registry = manifest_path().with_name(f'{STELLAR_SPECTRA_PHOENIX}.registry.txt') - names = {line.split()[0] for line in registry.read_text().splitlines() if line.strip()} + names = set(_dataset(STELLAR_SPECTRA_PHOENIX).registry()) assert len(names) == 52 logging.disable(logging.CRITICAL) @@ -1864,13 +1863,13 @@ def test_resolve_melting_curve_files_manifest_layout(tmp_path): from proteus.utils.data import resolve_melting_curve_files expected = { - 'Monteux+600': ('monteux_plus600', 'r15728091'), - 'Monteux-600': ('monteux_minus600', 'r15728138'), + 'Monteux+600': ('monteux_plus_600', 'r15728091'), + 'Monteux-600': ('monteux_minus_600', 'r15728138'), 'Wolf_Bower+2018': ('wolf_bower_2018', 'r15728072'), } for name, (folder, rec) in expected.items(): solidus, liquidus = resolve_melting_curve_files(name, data_root=tmp_path) - base = tmp_path / 'interior_struct' / 'melting_curves' / folder / rec + base = tmp_path / 'interior' / 'melting_curves' / folder / rec assert solidus == base / 'solidus.dat' assert liquidus == base / 'liquidus.dat' @@ -1902,7 +1901,7 @@ def test_resolve_melting_curve_files_partial_local_falls_back(tmp_path): solidus, liquidus = resolve_melting_curve_files('Monteux+600', data_root=tmp_path) - assert 'interior_struct' in solidus.parts + assert solidus.parent.parent.name == 'monteux_plus_600' assert solidus.name == 'solidus.dat' and liquidus.name == 'liquidus.dat' @@ -1926,24 +1925,40 @@ def test_resolve_lookup_table_dir_layout(tmp_path): folder = resolve_lookup_table_dir(data_root=tmp_path) assert ( - folder == tmp_path / 'interior_struct' / 'lookup' / 'wolf_bower_2018_1tpa' / 'r19473625' + folder + == tmp_path + / 'interior' + / 'eos' + / 'dk09_1tpa_elec_free' + / 'mgsio3_wolf_bower_2018_1tpa' + / 'r19473625' ) assert resolve_lookup_table_dir(data_root=str(tmp_path)) == folder @pytest.mark.unit def test_find_lookup_table_dir(tmp_path): - """The directory is returned only once it holds the SPIDER phase files.""" + """The directory is returned only once it holds every SPIDER table file. + + fwl-io fetches the files in sorted order, so an interrupted fetch leaves the + last ones missing; the check must reject that state, not only an empty one. + """ + from proteus.interior_energetics.common import ( + _SPIDER_EOS_MELTING_CURVES, + _SPIDER_EOS_PHASE_FILES, + ) from proteus.utils.data import find_lookup_table_dir, resolve_lookup_table_dir assert find_lookup_table_dir(data_root=tmp_path) is None folder = resolve_lookup_table_dir(data_root=tmp_path) folder.mkdir(parents=True, exist_ok=True) - (folder / 'density_melt.dat').write_text('x') + names = sorted(_SPIDER_EOS_PHASE_FILES + _SPIDER_EOS_MELTING_CURVES) + for name in names[:-1]: + (folder / name).write_text('x') assert find_lookup_table_dir(data_root=tmp_path) is None - (folder / 'thermal_exp_melt.dat').write_text('x') + (folder / names[-1]).write_text('x') assert find_lookup_table_dir(data_root=tmp_path) == folder @@ -2088,6 +2103,74 @@ def _boom(key, data_root=None): assert reached['melting'], 'melting curves must still be fetched' +@pytest.mark.unit +def test_required_dataset_failure_does_not_stop_later_fetches(monkeypatch, tmp_path): + """An unreachable mirror for one required dataset leaves the others to be fetched. + + fwl-io reports a failed fetch with RuntimeError subclasses, which an OSError + guard does not catch; without a per-step guard the first failure (the named + stellar spectra) would abort the run before any interior table is tried. + """ + from fwl_io import DownloadError + + from proteus.config import read_config_object + from proteus.utils import data as data_mod + + attempted = [] + + def _boom(key, *args, data_root=None): + attempted.append(key) + raise DownloadError('could not obtain from any mirror') + + monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path) + monkeypatch.setattr('proteus.data.fetch_dataset', _boom) + monkeypatch.setattr('proteus.data.fetch_dataset_file', _boom) + monkeypatch.setattr(data_mod, 'download_stellar_tracks', lambda *a, **kw: None) + cfg = read_config_object( + str(Path(__file__).resolve().parents[2] / 'input' / 'minimal.toml') + ) + + data_mod.download_sufficient_data(cfg) + + assert attempted[0] == 'star.spectra.named' + # The spectral file, the melting curves and the Zalmoxis tables come after + # the failing stellar fetch and must all still be attempted. + assert 'atmos_clim.spectral_files.honeyside.48' in attempted + assert 'interior.melting_curves.wolf_bower_2018' in attempted + assert 'interior.eos.paleos_iron' in attempted + + +@pytest.mark.unit +@pytest.mark.parametrize( + 'error', + ['DownloadError', 'OfflineDataError', 'ArchiveError', 'MissingDataRootError', 'OSError'], +) +def test_attempt_reports_fetch_errors_and_passes_other_errors(error, caplog): + """Every fetch error is logged and reported, a programming error still raises.""" + import fwl_io + from fwl_io.archive import ArchiveError + + from proteus.utils.data import _attempt + + errors = { + 'DownloadError': fwl_io.DownloadError, + 'OfflineDataError': fwl_io.OfflineDataError, + 'ArchiveError': ArchiveError, + 'MissingDataRootError': fwl_io.MissingDataRootError, + 'OSError': OSError, + } + + def _fail(): + raise errors[error]('mirror down') + + with caplog.at_level('WARNING'): + assert _attempt('test data', _fail) is False + assert 'test data: mirror down' in caplog.text + # Discrimination: an error that is not a fetch failure is not swallowed. + with pytest.raises(TypeError): + _attempt('test data', lambda: (_ for _ in ()).throw(TypeError('bug'))) + + @pytest.mark.unit @patch('proteus.utils.data.download') @patch('proteus.data.fetch_dataset') @@ -2890,15 +2973,20 @@ def test_download_zalmoxis_eos_seager(mock_static, mock_fetch, mock_file): @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_wolfbower(mock_static, mock_fetch, mock_file): - """WolfBower2018 fetches the Seager set and the whole Wolf and Bower dataset.""" + """WolfBower2018 fetches the Seager set and the three Wolf and Bower tables it reads.""" from proteus.data import EOS_WOLF_BOWER_2018 from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('WolfBower2018:MgSiO3', core_eos='Seager2007:iron') mock_static.assert_called_once() - assert _fetched_datasets(mock_fetch) == [EOS_WOLF_BOWER_2018] - mock_file.assert_not_called() + # The shared record holds 8 tables; a whole-dataset fetch would pull all of them. + mock_fetch.assert_not_called() + assert _fetched_files(mock_file) == [ + (EOS_WOLF_BOWER_2018, 'density_melt.dat'), + (EOS_WOLF_BOWER_2018, 'adiabat_temp_grad_melt.dat'), + (EOS_WOLF_BOWER_2018, 'density_solid.dat'), + ] @pytest.mark.unit @@ -2906,27 +2994,31 @@ def test_download_zalmoxis_eos_wolfbower(mock_static, mock_fetch, mock_file): @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_rtpress(mock_static, mock_fetch, mock_file): - """RTPress100TPa fetches its own dataset plus the Wolf and Bower solid density only.""" + """RTPress100TPa fetches its two melt tables plus the Wolf and Bower solid density only.""" from proteus.data import EOS_RTPRESS_100TPA, EOS_WOLF_BOWER_2018 from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('RTPress100TPa:MgSiO3', core_eos='Seager2007:iron') mock_static.assert_called_once() - assert _fetched_datasets(mock_fetch) == [EOS_RTPRESS_100TPA] + mock_fetch.assert_not_called() # The RTPress registry entry reads its solid density from the Wolf and - # Bower dataset; the whole dataset must not be pulled for that one file. - assert _fetched_files(mock_file) == [(EOS_WOLF_BOWER_2018, 'density_solid.dat')] + # Bower dataset; the rest of that dataset must not be pulled for that one file. + assert _fetched_files(mock_file) == [ + (EOS_WOLF_BOWER_2018, 'density_solid.dat'), + (EOS_RTPRESS_100TPA, 'density_melt.dat'), + (EOS_RTPRESS_100TPA, 'adiabat_temp_grad_melt.dat'), + ] @pytest.mark.unit @patch('proteus.data.fetch_dataset_file') @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_rtpress_with_wolfbower_fetches_whole_dataset( +def test_download_zalmoxis_eos_rtpress_with_wolfbower_fetches_solid_once( mock_static, mock_fetch, mock_file ): - """When Wolf and Bower is selected too, its whole dataset covers the solid file.""" + """When Wolf and Bower is selected too, its tables cover the solid file once.""" from proteus.data import EOS_RTPRESS_100TPA, EOS_WOLF_BOWER_2018 from proteus.utils.data import download_zalmoxis_eos @@ -2934,10 +3026,13 @@ def test_download_zalmoxis_eos_rtpress_with_wolfbower_fetches_whole_dataset( 'WolfBower2018:MgSiO3+RTPress100TPa:MgSiO3', core_eos='Seager2007:iron' ) - assert sorted(_fetched_datasets(mock_fetch)) == sorted( - [EOS_WOLF_BOWER_2018, EOS_RTPRESS_100TPA] - ) - mock_file.assert_not_called() + fetched = _fetched_files(mock_file) + assert fetched.count((EOS_WOLF_BOWER_2018, 'density_solid.dat')) == 1 + assert {name for key, name in fetched if key == EOS_RTPRESS_100TPA} == { + 'density_melt.dat', + 'adiabat_temp_grad_melt.dat', + } + mock_fetch.assert_not_called() @pytest.mark.unit @@ -2945,14 +3040,18 @@ def test_download_zalmoxis_eos_rtpress_with_wolfbower_fetches_whole_dataset( @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_fetch, mock_file): - """PALEOS-2phase:MgSiO3 fetches the standard-resolution dataset only.""" - from proteus.data import EOS_PALEOS_MGSIO3_2PHASE + """PALEOS-2phase:MgSiO3 fetches the standard-resolution pair only.""" + from proteus.data import EOS_PALEOS_MGSIO3 from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='Seager2007:iron') - assert _fetched_datasets(mock_fetch) == [EOS_PALEOS_MGSIO3_2PHASE] - mock_file.assert_not_called() + # The shared record also holds the ~1.3 GB highres pair; it must stay unfetched. + mock_fetch.assert_not_called() + assert _fetched_files(mock_file) == [ + (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), + (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid.dat'), + ] @pytest.mark.unit @@ -2960,14 +3059,17 @@ def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_fetch, mock_file) @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_paleos_2phase_highres(mock_static, mock_fetch, mock_file): - """PALEOS-2phase:MgSiO3-highres fetches the high-resolution dataset only.""" - from proteus.data import EOS_PALEOS_MGSIO3_2PHASE_HIGHRES + """PALEOS-2phase:MgSiO3-highres fetches the high-resolution pair only.""" + from proteus.data import EOS_PALEOS_MGSIO3 from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS-2phase:MgSiO3-highres', core_eos='Seager2007:iron') - assert _fetched_datasets(mock_fetch) == [EOS_PALEOS_MGSIO3_2PHASE_HIGHRES] - mock_file.assert_not_called() + mock_fetch.assert_not_called() + assert _fetched_files(mock_file) == [ + (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid_highres.dat'), + (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat'), + ] @pytest.mark.unit @@ -2975,8 +3077,8 @@ def test_download_zalmoxis_eos_paleos_2phase_highres(mock_static, mock_fetch, mo @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_paleos_unified(mock_static, mock_fetch, mock_file): - """PALEOS unified fetches its three tables one file at a time.""" - from proteus.data import EOS_PALEOS_UNIFIED + """PALEOS unified fetches each material's table from its own dataset.""" + from proteus.data import EOS_PALEOS_H2O, EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3_UNIFIED from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS:MgSiO3', core_eos='PALEOS:iron', ice_layer_eos='PALEOS:H2O') @@ -2987,9 +3089,9 @@ def test_download_zalmoxis_eos_paleos_unified(mock_static, mock_fetch, mock_file mock_fetch.assert_not_called() assert sorted(_fetched_files(mock_file)) == sorted( [ - (EOS_PALEOS_UNIFIED, _UNIFIED_IRON), - (EOS_PALEOS_UNIFIED, _UNIFIED_MGSIO3), - (EOS_PALEOS_UNIFIED, _UNIFIED_WATER), + (EOS_PALEOS_IRON, _UNIFIED_IRON), + (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), + (EOS_PALEOS_H2O, _UNIFIED_WATER), ] ) @@ -3002,13 +3104,13 @@ def test_download_zalmoxis_eos_paleos_unified_only_selected_tables( mock_static, mock_fetch, mock_file ): """Only the selected unified tables are fetched, not the whole record.""" - from proteus.data import EOS_PALEOS_UNIFIED + from proteus.data import EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3_UNIFIED from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS:MgSiO3', core_eos='PALEOS:iron') assert sorted(_fetched_files(mock_file)) == sorted( - [(EOS_PALEOS_UNIFIED, _UNIFIED_IRON), (EOS_PALEOS_UNIFIED, _UNIFIED_MGSIO3)] + [(EOS_PALEOS_IRON, _UNIFIED_IRON), (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3)] ) mock_fetch.assert_not_called() @@ -3031,15 +3133,19 @@ def test_download_zalmoxis_eos_paleos_2phase_fetches_seager_fallback( mantle, PALEOS:iron core). The 2-phase tables and the unified iron table must download alongside. """ - from proteus.data import EOS_PALEOS_MGSIO3_2PHASE, EOS_PALEOS_UNIFIED + from proteus.data import EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3 from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='PALEOS:iron') mock_static.assert_called_once() # The standard-resolution selection must not pull the ~1.3 GB highres pair. - assert _fetched_datasets(mock_fetch) == [EOS_PALEOS_MGSIO3_2PHASE] - assert _fetched_files(mock_file) == [(EOS_PALEOS_UNIFIED, _UNIFIED_IRON)] + assert _fetched_files(mock_file) == [ + (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), + (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid.dat'), + (EOS_PALEOS_IRON, _UNIFIED_IRON), + ] + mock_fetch.assert_not_called() @pytest.mark.unit @@ -3068,13 +3174,13 @@ def test_download_zalmoxis_eos_seager_fallback_every_family( iron core must download its own table alongside, and the Chabrier fetch must stay untouched. """ - from proteus.data import EOS_CHABRIER_2021, EOS_PALEOS_UNIFIED + from proteus.data import EOS_CHABRIER_2021, EOS_PALEOS_IRON from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos(mantle_component, core_eos='PALEOS:iron') mock_static.assert_called_once() - assert (EOS_PALEOS_UNIFIED, _UNIFIED_IRON) in _fetched_files(mock_file) + assert (EOS_PALEOS_IRON, _UNIFIED_IRON) in _fetched_files(mock_file) assert EOS_CHABRIER_2021 not in _fetched_datasets(mock_fetch) @@ -3107,7 +3213,7 @@ def test_download_zalmoxis_eos_api_2phase_fetches_only_seager( @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_multi_component(mock_static, mock_fetch, mock_file): """download_zalmoxis_eos handles multi-component EOS strings.""" - from proteus.data import EOS_CHABRIER_2021, EOS_PALEOS_UNIFIED + from proteus.data import EOS_CHABRIER_2021, EOS_PALEOS_H2O, EOS_PALEOS_MGSIO3_UNIFIED from proteus.utils.data import download_zalmoxis_eos # Composite mantle with PALEOS + Chabrier @@ -3119,7 +3225,7 @@ def test_download_zalmoxis_eos_multi_component(mock_static, mock_fetch, mock_fil mock_static.assert_called_once() assert _fetched_datasets(mock_fetch) == [EOS_CHABRIER_2021] assert sorted(_fetched_files(mock_file)) == sorted( - [(EOS_PALEOS_UNIFIED, _UNIFIED_MGSIO3), (EOS_PALEOS_UNIFIED, _UNIFIED_WATER)] + [(EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), (EOS_PALEOS_H2O, _UNIFIED_WATER)] ) diff --git a/tools/generate_spider_phase_boundaries.py b/tools/generate_spider_phase_boundaries.py index c6f947eff..3aa63af84 100644 --- a/tools/generate_spider_phase_boundaries.py +++ b/tools/generate_spider_phase_boundaries.py @@ -2,13 +2,13 @@ """Generate SPIDER S(P) phase boundary files from T(P) melting curves. SPIDER uses entropy-based phase boundaries S(P), while Zalmoxis and Aragog use -temperature-based T(P) curves. Both file types live in the same melting curves -folder under FWL_DATA/interior_lookup_tables/Melting_curves//: +temperature-based T(P) curves. The T(P) curves are read from the local folder +FWL_DATA/interior_lookup_tables/Melting_curves// when it holds +them, otherwise from the fetched melting-curve dataset of that name. The S(P) +files are written into the local folder, which SPIDER reads: - solidus_P-T.dat — T(P) format, used by Zalmoxis/Aragog - liquidus_P-T.dat — T(P) format, used by Zalmoxis/Aragog - solidus_P-S.dat — S(P) format, used by SPIDER (generated by this tool) - liquidus_P-S.dat — S(P) format, used by SPIDER (generated by this tool) + solidus_P-S.dat : S(P) format, used by SPIDER (generated by this tool) + liquidus_P-S.dat : S(P) format, used by SPIDER (generated by this tool) The conversion inverts SPIDER's T(P,S) lookup tables to find S(P) such that T_table(P, S) = T_target(P), using Brent's method at each pressure. @@ -24,7 +24,9 @@ - FWL_DATA environment variable set - SPIDER P-S lookup tables in FWL_DATA/interior_lookup_tables/EOS/dynamic//P-S/ (falls back to /SPIDER/lookup_data//) - - T(P) melting curves (solidus_P-T.dat, liquidus_P-T.dat) in the melting_dir folder + - T(P) melting curves: solidus_P-T.dat and liquidus_P-T.dat in the local + melting_dir folder, or a fetched curve (Monteux+600, Monteux-600, + Wolf_Bower+2018; run `proteus get interiordata` first) """ from __future__ import annotations @@ -45,6 +47,29 @@ EOS_DYNAMIC_BASE = FWL_DATA / 'interior_lookup_tables' / 'EOS' / 'dynamic' +def find_tp_curves(melting_dir: str) -> tuple[Path, Path]: + """Return the T(P) solidus and liquidus files for one melting curve. + + Parameters + ---------- + melting_dir : str + Melting curve name, as in ``interior_struct.melting_dir``. + + Returns + ------- + tuple of Path + Solidus and liquidus files: the local folder's ``*_P-T.dat`` or + ``solidus.dat``/``liquidus.dat`` pair, else the fetched dataset's files. + """ + local = MELTING_CURVES_BASE / melting_dir + for sol, liq in (('solidus_P-T.dat', 'liquidus_P-T.dat'), ('solidus.dat', 'liquidus.dat')): + if (local / sol).is_file() and (local / liq).is_file(): + return local / sol, local / liq + from proteus.utils.data import resolve_melting_curve_files + + return resolve_melting_curve_files(melting_dir, data_root=FWL_DATA) + + # -- SPIDER lookup table loader --------------------------------------------- @@ -231,19 +256,10 @@ def main(): ) args = parser.parse_args() - # Validate paths + # T(P) input: local folder first, then the fetched dataset. The S(P) output + # always goes to the local folder, which SPIDER reads. mc_folder = MELTING_CURVES_BASE / args.melting_dir - if not mc_folder.is_dir(): - print(f'ERROR: Melting curves folder not found: {mc_folder}', file=sys.stderr) - sys.exit(1) - - # Accept both naming conventions: solidus_P-T.dat (canonical) and solidus.dat (Zenodo legacy) - solidus_tp = mc_folder / 'solidus_P-T.dat' - liquidus_tp = mc_folder / 'liquidus_P-T.dat' - if not solidus_tp.is_file(): - solidus_tp = mc_folder / 'solidus.dat' - if not liquidus_tp.is_file(): - liquidus_tp = mc_folder / 'liquidus.dat' + solidus_tp, liquidus_tp = find_tp_curves(args.melting_dir) for f in (solidus_tp, liquidus_tp): if not f.is_file(): print(f'ERROR: T(P) file not found: {f}', file=sys.stderr) @@ -268,7 +284,7 @@ def main(): print(f' Melt T range: {T_melt["values"].min():.0f} - {T_melt["values"].max():.0f} K') # Load T(P) melting curves - print(f'\nLoading T(P) curves from {mc_folder}/') + print(f'\nLoading T(P) curves from {solidus_tp.parent}/') sol_data = np.loadtxt(solidus_tp, comments='#') liq_data = np.loadtxt(liquidus_tp, comments='#') @@ -306,6 +322,7 @@ def main(): # Write S(P) files print(f'\nWriting S(P) phase boundary files to {mc_folder}/') + mc_folder.mkdir(parents=True, exist_ok=True) write_phase_boundary(mc_folder / 'solidus_P-S.dat', P_grid_GPa, S_solidus) write_phase_boundary(mc_folder / 'liquidus_P-S.dat', P_grid_GPa, S_liquidus) diff --git a/tools/postprocess.jl b/tools/postprocess.jl index be886d44e..f04e834da 100755 --- a/tools/postprocess.jl +++ b/tools/postprocess.jl @@ -264,10 +264,13 @@ function postproc(output_dir::String, nsamples::Int, spfile::String) # use high resolution file if isempty(spfile) - sf_matches = sort(glob("r*/Honeyside.sf", - joinpath(ENV["FWL_DATA"], "atmos_clim", "spectral_files", "honeyside_4096"))) + sf_matches = glob("r*/Honeyside.sf", + joinpath(ENV["FWL_DATA"], "atmos_clim", "spectral_files", "honeyside", "4096")) isempty(sf_matches) && error("Honeyside 4096 spectral file not found; run `proteus get spectral`") - spectral_file = last(sf_matches) + # Version directories are r; take the highest record id, numerically. + record_ids = [parse(Int, splitpath(f)[end-1][2:end]) for f in sf_matches] + spectral_file = sf_matches[argmax(record_ids)] + length(sf_matches) > 1 && @warn "Several Honeyside 4096 versions found; using $spectral_file" star_file = joinpath(output_dir, "data", "$(star_years[1]).sflux") @info "Spectral file not provided. Will use $spectral_file" else From f97deda31947ccae7da2bd43e427c12b53c3bec2 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 23 Sep 2026 22:43:18 +0200 Subject: [PATCH 11/76] Keep going after a failed stellar-track download download_stellar_tracks reports a failed download as RuntimeError, like the fwl-io fetch errors, so the start-of-run guard now catches RuntimeError and OSError. A missing track set then fails where it is read, and the spectral files and interior tables are still fetched. --- src/proteus/utils/data.py | 9 ++++----- tests/utils/test_data.py | 10 +++++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 2b26a65eb..11f9189cd 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -12,8 +12,6 @@ from typing import TYPE_CHECKING import numpy as np -from fwl_io import DownloadError, MissingDataRootError, OfflineDataError -from fwl_io.archive import ArchiveError from osfclient.api import OSF from scipy.interpolate import interp1d @@ -1601,9 +1599,10 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): ) -# What a start-of-run fetch raises when a dataset cannot be obtained: the -# filesystem errors, and fwl-io's own errors, which derive from RuntimeError. -_FETCH_ERRORS = (OSError, DownloadError, OfflineDataError, ArchiveError, MissingDataRootError) +# What a start-of-run fetch raises when a dataset cannot be obtained: filesystem +# errors, fwl-io's errors (RuntimeError subclasses) and the downloaders' own +# RuntimeError, such as a failed stellar-track download. +_FETCH_ERRORS = (OSError, RuntimeError) def _attempt(desc: str, func, *args, **kwargs) -> bool: diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 4048ea378..373f39f7e 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -2143,7 +2143,14 @@ def _boom(key, *args, data_root=None): @pytest.mark.unit @pytest.mark.parametrize( 'error', - ['DownloadError', 'OfflineDataError', 'ArchiveError', 'MissingDataRootError', 'OSError'], + [ + 'DownloadError', + 'OfflineDataError', + 'ArchiveError', + 'MissingDataRootError', + 'OSError', + 'RuntimeError', + ], ) def test_attempt_reports_fetch_errors_and_passes_other_errors(error, caplog): """Every fetch error is logged and reported, a programming error still raises.""" @@ -2158,6 +2165,7 @@ def test_attempt_reports_fetch_errors_and_passes_other_errors(error, caplog): 'ArchiveError': ArchiveError, 'MissingDataRootError': fwl_io.MissingDataRootError, 'OSError': OSError, + 'RuntimeError': RuntimeError, } def _fail(): From 21d7c46b1300f939aeca9e7e36a58afa9a97bf89 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 23 Sep 2026 23:09:14 +0200 Subject: [PATCH 12/76] Narrow the start-of-run fetch guard and stop on missing melting curves The per-step guard catches OSError and the fwl-io fetch errors only, so a stale fwl-io or a programming error still raises, and the outer guard in download_sufficient_data catches OSError as before. The stellar-track step also catches RuntimeError, which is how download_stellar_tracks reports a failed download. Each stellar spectrum collection is fetched on its own, so a failing named-star record does not stop the solar or MUSCLES spectra. The melting curves are fetched outside the guard: without them SPIDER and Aragog fall back to other curves, so a failed fetch stops the run instead of changing its physics. A key missing from both manifests names the fwl-io version the shared keys need. proteus doctor checks star/spectra. generate_spider_phase_boundaries.py resolves its T(P) input as a run does. postprocess.jl skips version directories without a numeric record id. The data page says which legacy folders fwl-io relocate moves. --- docs/How-to/doctor.md | 4 +- docs/Reference/data.md | 8 ++- src/proteus/data/__init__.py | 15 ++++- src/proteus/doctor.py | 2 +- src/proteus/interior_struct/zalmoxis.py | 4 -- src/proteus/utils/data.py | 59 +++++++++-------- tests/data/test_manifest.py | 7 +- tests/interior_struct/test_zalmoxis.py | 8 +-- tests/test_doctor.py | 6 +- tests/utils/test_data.py | 81 ++++++++++++++++++----- tools/generate_spider_phase_boundaries.py | 17 ++--- tools/postprocess.jl | 3 +- 12 files changed, 137 insertions(+), 77 deletions(-) diff --git a/docs/How-to/doctor.md b/docs/How-to/doctor.md index a8c24737d..3801e7fe2 100644 --- a/docs/How-to/doctor.md +++ b/docs/How-to/doctor.md @@ -42,7 +42,7 @@ non-empty: | Check | Fix command | |-------|-------------| | `FWL_DATA/atmos_clim/spectral_files` | `proteus get spectral` | -| `FWL_DATA/stellar_spectra` | `proteus get stellar` | +| `FWL_DATA/star/spectra` | `proteus get stellar` | ### Package versions @@ -85,7 +85,7 @@ Environment Reference data [ok] FWL_DATA/atmos_clim/spectral_files: present - [ok] FWL_DATA/stellar_spectra: present + [ok] FWL_DATA/star/spectra: present Package versions [ok] fwl-proteus: 25.10.15 [editable @ PROTEUS -> a1b2c3d] diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 563fc00fb..8591d33e4 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -22,8 +22,10 @@ unreachable Zenodo means the fetch fails rather than falling back elsewhere. Data several models read (spectral files, stellar spectra, equations of state, melting curves, lookup tables) is declared in the shared manifest that fwl-io ships; data only PROTEUS reads is declared in `src/proteus/data/proteus_manifest.toml`. -A tree laid out by an older PROTEUS moves into this layout with `fwl-io relocate` -instead of being downloaded again. +`fwl-io relocate` moves a folder left by an older PROTEUS into this layout +when the folder holds every file its dataset lists. A folder that holds only +part of a dataset, the Chabrier archive, the surface albedos and the Seager +tables stay where they are and are downloaded again. **The PROTEUS downloader.** `proteus.utils.data` fetches a whole Zenodo record, retrying a few times, and falls back to the corresponding project on the @@ -255,7 +257,7 @@ All equation-of-state tables are fetched through fwl-io into `$FWL_DATA//EOS_Chabrier2021_HHe/` | -Tables under `$FWL_DATA/zalmoxis_eos` are not read where they are; `fwl-io relocate` moves them into the directories above. A copy under `$FWL_DATA/EOS_material_properties` is not read and can be deleted. +Tables under `$FWL_DATA/zalmoxis_eos` and `$FWL_DATA/EOS_material_properties` are not read where they are. `fwl-io relocate` moves a `zalmoxis_eos` folder that holds every file of its dataset; the rest can be deleted once the run has fetched them. --- diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index 11f056853..0ca350037 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -148,7 +148,6 @@ def _dataset(key: str): The key is absent from both manifests. """ from fwl_io import load_manifest - from fwl_io.manifest import shared_manifest_path try: datasets = {ds.key: ds for ds in load_manifest(manifest_path())} @@ -161,7 +160,19 @@ def _dataset(key: str): ) from exc if key in datasets: return datasets[key] - shared = {ds.key: ds for ds in load_manifest(shared_manifest_path())} + try: + from fwl_io.manifest import shared_manifest_path + + shared = {ds.key: ds for ds in load_manifest(shared_manifest_path())} + except ImportError as exc: + raise RuntimeError( + f'the installed fwl-io ships no shared manifest; upgrade to fwl-io>={FWL_IO_FLOOR}.' + ) from exc + if key not in shared: + raise KeyError( + f'{key!r} is declared neither in the PROTEUS manifest nor in the fwl-io ' + f'shared manifest (a key PROTEUS reads needs fwl-io>={FWL_IO_FLOOR})' + ) return shared[key] diff --git a/src/proteus/doctor.py b/src/proteus/doctor.py index fe9af007e..d65e9c00b 100644 --- a/src/proteus/doctor.py +++ b/src/proteus/doctor.py @@ -350,7 +350,7 @@ def check_fwl_data() -> list[CheckResult]: expected = { 'atmos_clim/spectral_files': 'proteus get spectral', - 'stellar_spectra': 'proteus get stellar', + 'star/spectra': 'proteus get stellar', } for subdir, fix in expected.items(): path = os.path.join(fwl, subdir) diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index b81aa2363..836592e96 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -8,10 +8,8 @@ import re import shutil import tempfile -from pathlib import Path import numpy as np -import platformdirs from scipy.interpolate import interp1d from zalmoxis.mixing import _PALEOS_UNIFIED_NAMES from zalmoxis.solver import main @@ -37,8 +35,6 @@ ) from proteus.utils.data import GetFWLData, get_zalmoxis_melting_curves -FWL_DATA_DIR = Path(os.environ.get('FWL_DATA', platformdirs.user_data_dir('fwl_data'))) - # Set up logging log = logging.getLogger('fwl.' + __name__) diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 11f9189cd..8c17dd13b 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -12,6 +12,8 @@ from typing import TYPE_CHECKING import numpy as np +from fwl_io import DownloadError, MissingDataRootError, OfflineDataError +from fwl_io.archive import ArchiveError from osfclient.api import OSF from scipy.interpolate import interp1d @@ -1600,17 +1602,17 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): # What a start-of-run fetch raises when a dataset cannot be obtained: filesystem -# errors, fwl-io's errors (RuntimeError subclasses) and the downloaders' own -# RuntimeError, such as a failed stellar-track download. -_FETCH_ERRORS = (OSError, RuntimeError) +# errors and fwl-io's own errors. Other RuntimeErrors (a stale fwl-io, a bug) raise. +_FETCH_ERRORS = (OSError, DownloadError, OfflineDataError, ArchiveError, MissingDataRootError) -def _attempt(desc: str, func, *args, **kwargs) -> bool: +def _attempt(desc: str, func, *args, catch=_FETCH_ERRORS, **kwargs) -> bool: """Run one start-of-run fetch step, reporting a failure instead of raising. - Each dataset is fetched independently, so an unreachable mirror for one of - them does not stop the others; a dataset that is still missing fails later, - where it is read. + Each step is fetched independently, so an unreachable mirror for one + dataset does not stop the others; a dataset that is still missing fails + later, where it is read. Data whose absence a reader would silently replace + (the melting curves) is fetched outside this guard. Parameters ---------- @@ -1618,6 +1620,8 @@ def _attempt(desc: str, func, *args, **kwargs) -> bool: What the step fetches, for the log message. func : callable The fetch function, called with the remaining arguments. + catch : tuple of type + Exceptions reported as a failed fetch. Returns ------- @@ -1626,7 +1630,7 @@ def _attempt(desc: str, func, *args, **kwargs) -> bool: """ try: func(*args, **kwargs) - except _FETCH_ERRORS as exc: + except catch as exc: log.warning('Problem when downloading/checking %s: %s', desc, exc) return False return True @@ -1643,13 +1647,16 @@ def _get_sufficient(config: Config, clean: bool = False): folders.append('solar') if spec_src in (None, 'muscles'): folders.append('MUSCLES') + for folder in dict.fromkeys(folders): + _attempt(f'stellar spectra {folder}', download_stellar_spectra, folders=(folder,)) + # download_stellar_tracks reports a failed download as RuntimeError. + tracks = 'Spada' if config.star.mors.tracks == 'spada' else 'Baraffe' _attempt( - 'stellar spectra', - download_stellar_spectra, - folders=tuple(dict.fromkeys(folders)), + 'stellar tracks', + download_stellar_tracks, + tracks, + catch=_FETCH_ERRORS + (RuntimeError,), ) - tracks = 'Spada' if config.star.mors.tracks == 'spada' else 'Baraffe' - _attempt('stellar tracks', download_stellar_tracks, tracks) # Spectral files if config.atmos_clim.module in ('janus', 'agni'): @@ -1683,8 +1690,10 @@ def _get_sufficient(config: Config, clean: bool = False): # Interior lookup tables (melting curves) if config.interior_energetics.module in ('aragog', 'spider'): - _attempt('interior lookup tables', download_interior_lookuptables, clean=clean) - _attempt('melting curves', download_melting_curves, config, clean=clean) + # Not guarded: without the configured melting curves SPIDER and Aragog + # fall back to other curves, so a failed fetch must stop the run here. + download_interior_lookuptables(clean=clean) + download_melting_curves(config, clean=clean) # Dynamic EOS for SPIDER and Aragog (uses struct.eos_dir, skip if None/PALEOS) if ( @@ -1737,7 +1746,7 @@ def download_sufficient_data(config: Config, clean: bool = False): # Some issue. Usually due to lack of internet connection, but print the error # anyway so that the user knows what happened. - except _FETCH_ERRORS as e: + except OSError as e: log.warning('Problem when downloading/checking reference data') log.warning(str(e)) @@ -1880,20 +1889,12 @@ def download_eos_dynamic(eos_dir: str = 'WolfBower2018_MgSiO3'): fetch_dataset(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=fwl_data) target_dir = dataset_dir(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=fwl_data) - expected_files = ( - 'temperature_melt.dat', - 'temperature_solid.dat', - 'density_melt.dat', - 'density_solid.dat', - 'heat_capacity_melt.dat', - 'heat_capacity_solid.dat', - 'adiabat_temp_grad_melt.dat', - 'adiabat_temp_grad_solid.dat', - 'thermal_exp_melt.dat', - 'thermal_exp_solid.dat', - 'solidus_P-S.dat', - 'liquidus_P-S.dat', + from proteus.interior_energetics.common import ( + _SPIDER_EOS_MELTING_CURVES, + _SPIDER_EOS_PHASE_FILES, ) + + expected_files = _SPIDER_EOS_PHASE_FILES + _SPIDER_EOS_MELTING_CURVES missing = [f for f in expected_files if not (target_dir / f).is_file()] if missing: log.warning( diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index 1a6ee6979..46c818d26 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -205,10 +205,13 @@ def test_no_record_is_declared_in_both_manifests(): """ from fwl_io import load_manifest - owned = {ds.zenodo for ds in load_manifest(manifest_path())} + owned_datasets = load_manifest(manifest_path()) + owned = {ds.zenodo for ds in owned_datasets} shared = {ds.zenodo for ds in _shared_manifest().values()} assert owned & shared == set(), f'records declared twice: {sorted(owned & shared)}' + # A key in both would make the PROTEUS entry shadow the shared one. + assert {ds.key for ds in owned_datasets} & set(_shared_manifest()) == set() # Discrimination: both sets are populated, and the shared one holds records # PROTEUS reads, so the empty intersection is not from an empty load. assert len(owned) == len(_OWNED_KEYS) @@ -368,7 +371,7 @@ def test_unknown_dataset_key_is_rejected(): Silently resolving an undeclared key would create an unpinned directory with no registry to verify against. """ - with pytest.raises(KeyError): + with pytest.raises(KeyError, match=f'fwl-io>={FWL_IO_FLOOR}'): _dataset('observe.not_a_declared_dataset') # Discrimination: a key the manifest does declare resolves, and to that diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 30effab9e..e71fc340e 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -2913,9 +2913,9 @@ def test_material_dictionaries_seager_paths_use_the_versioned_dataset_dir( def test_material_dictionaries_read_the_tree_the_fetch_writes(monkeypatch, tmp_path): """The EOS paths use the data root that download_zalmoxis_eos fetches into. - The module keeps its own raw ``FWL_DATA`` value, which is not expanded and - has a different default; building the paths from it would send Zalmoxis to - another tree than the one the fetch populated. + The raw ``FWL_DATA`` value is not expanded and has a different default from + the resolved root; building the paths from it would send Zalmoxis to another + tree than the one the fetch populated. """ from pathlib import Path @@ -2924,7 +2924,7 @@ def test_material_dictionaries_read_the_tree_the_fetch_writes(monkeypatch, tmp_p fetch_root = tmp_path / 'fetched' monkeypatch.setattr('proteus.utils.data.FWL_DATA_DIR', fetch_root) - monkeypatch.setattr(zalmoxis_wrapper, 'FWL_DATA_DIR', tmp_path / 'raw_env_value') + monkeypatch.setenv('FWL_DATA', str(tmp_path / 'raw_env_value')) registry = zalmoxis_wrapper.load_zalmoxis_material_dictionaries() diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 9a5d137c6..e5d36c6c2 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -242,13 +242,13 @@ def test_reports_present_subdirs(self, tmp_path): """Populated subdirectories report as present.""" (tmp_path / 'atmos_clim' / 'spectral_files').mkdir(parents=True) (tmp_path / 'atmos_clim' / 'spectral_files' / 'data.bin').touch() - (tmp_path / 'stellar_spectra').mkdir() - (tmp_path / 'stellar_spectra' / 'sun.txt').touch() + (tmp_path / 'star' / 'spectra' / 'solar').mkdir(parents=True) + (tmp_path / 'star' / 'spectra' / 'solar' / 'sun.txt').touch() with patch.dict(os.environ, {'FWL_DATA': str(tmp_path)}): results = check_fwl_data() statuses = {r.name: r.status for r in results} assert statuses['FWL_DATA/atmos_clim/spectral_files'] == PASS - assert statuses['FWL_DATA/stellar_spectra'] == PASS + assert statuses['FWL_DATA/star/spectra'] == PASS def test_reports_missing_subdirs(self, tmp_path): """Missing subdirectories warn with a fix command.""" diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 373f39f7e..6afde3a6b 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -2105,11 +2105,11 @@ def _boom(key, data_root=None): @pytest.mark.unit def test_required_dataset_failure_does_not_stop_later_fetches(monkeypatch, tmp_path): - """An unreachable mirror for one required dataset leaves the others to be fetched. + """An unreachable mirror for one dataset leaves the other datasets to be fetched. fwl-io reports a failed fetch with RuntimeError subclasses, which an OSError - guard does not catch; without a per-step guard the first failure (the named - stellar spectra) would abort the run before any interior table is tried. + guard does not catch; each step and each stellar collection is guarded on + its own, so a failing named-star record does not stop the solar spectra. """ from fwl_io import DownloadError @@ -2118,28 +2118,74 @@ def test_required_dataset_failure_does_not_stop_later_fetches(monkeypatch, tmp_p attempted = [] - def _boom(key, *args, data_root=None): + def _fetch(key, *args, data_root=None): attempted.append(key) - raise DownloadError('could not obtain from any mirror') + if not key.startswith('interior.melting_curves.'): + raise DownloadError('could not obtain from any mirror') + return [] monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path) - monkeypatch.setattr('proteus.data.fetch_dataset', _boom) - monkeypatch.setattr('proteus.data.fetch_dataset_file', _boom) + monkeypatch.setattr('proteus.data.fetch_dataset', _fetch) + monkeypatch.setattr('proteus.data.fetch_dataset_file', _fetch) monkeypatch.setattr(data_mod, 'download_stellar_tracks', lambda *a, **kw: None) cfg = read_config_object( str(Path(__file__).resolve().parents[2] / 'input' / 'minimal.toml') ) + cfg.star.mors.spectrum_source = None data_mod.download_sufficient_data(cfg) - assert attempted[0] == 'star.spectra.named' - # The spectral file, the melting curves and the Zalmoxis tables come after - # the failing stellar fetch and must all still be attempted. + assert attempted[:3] == ['star.spectra.named', 'star.spectra.solar', 'star.spectra.muscles'] assert 'atmos_clim.spectral_files.honeyside.48' in attempted assert 'interior.melting_curves.wolf_bower_2018' in attempted assert 'interior.eos.paleos_iron' in attempted +@pytest.mark.unit +@pytest.mark.parametrize( + ('melting_dir', 'failing_key'), + [ + (None, 'interior.melting_curves.wolf_bower_2018'), + ('Monteux-600', 'interior.melting_curves.monteux_minus_600'), + ], +) +def test_melting_curve_fetch_failure_stops_the_run( + monkeypatch, tmp_path, melting_dir, failing_key +): + """A failed melting-curve fetch raises instead of being logged. + + Without the configured curves SPIDER and Aragog fall back to other curves, + so continuing would change the physics of the run rather than fail it. + """ + from fwl_io import DownloadError + + from proteus.config import read_config_object + from proteus.utils import data as data_mod + + attempted = [] + + def _fetch(key, *args, data_root=None): + attempted.append(key) + if key == failing_key: + raise DownloadError('could not obtain from any mirror') + return [] + + monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path) + monkeypatch.setattr('proteus.data.fetch_dataset', _fetch) + monkeypatch.setattr('proteus.data.fetch_dataset_file', _fetch) + monkeypatch.setattr(data_mod, 'download_stellar_tracks', lambda *a, **kw: None) + cfg = read_config_object( + str(Path(__file__).resolve().parents[2] / 'input' / 'minimal.toml') + ) + cfg.interior_struct.melting_dir = melting_dir + + with pytest.raises(DownloadError): + data_mod.download_sufficient_data(cfg) + + assert attempted[-1] == failing_key + assert 'interior.eos.paleos_iron' not in attempted + + @pytest.mark.unit @pytest.mark.parametrize( 'error', @@ -2149,7 +2195,6 @@ def _boom(key, *args, data_root=None): 'ArchiveError', 'MissingDataRootError', 'OSError', - 'RuntimeError', ], ) def test_attempt_reports_fetch_errors_and_passes_other_errors(error, caplog): @@ -2165,7 +2210,6 @@ def test_attempt_reports_fetch_errors_and_passes_other_errors(error, caplog): 'ArchiveError': ArchiveError, 'MissingDataRootError': fwl_io.MissingDataRootError, 'OSError': OSError, - 'RuntimeError': RuntimeError, } def _fail(): @@ -2174,9 +2218,12 @@ def _fail(): with caplog.at_level('WARNING'): assert _attempt('test data', _fail) is False assert 'test data: mirror down' in caplog.text - # Discrimination: an error that is not a fetch failure is not swallowed. + # Discrimination: an error that is not a fetch failure is not swallowed, + # including a plain RuntimeError such as the stale-fwl-io message. with pytest.raises(TypeError): _attempt('test data', lambda: (_ for _ in ()).throw(TypeError('bug'))) + with pytest.raises(RuntimeError, match='stale'): + _attempt('test data', lambda: (_ for _ in ()).throw(RuntimeError('stale'))) @pytest.mark.unit @@ -5700,8 +5747,9 @@ def test_get_sufficient_mors_solar_spectrum_only(monkeypatch): data_mod._get_sufficient(config) - assert len(spectra_calls) == 1 - folders = spectra_calls[0] + # One call per collection, so a failing collection does not stop the others. + assert spectra_calls == [('Named',), ('solar',)] + folders = [name for call in spectra_calls for name in call] # Discrimination: solar source should request 'Named' and 'solar' # but NOT 'MUSCLES'. assert 'Named' in folders @@ -5746,7 +5794,8 @@ def test_get_sufficient_mors_muscles_spectrum_only(monkeypatch): data_mod._get_sufficient(config) - folders = spectra_calls[0] + folders = [name for call in spectra_calls for name in call] + assert len(spectra_calls) == 2 assert 'Named' in folders assert 'MUSCLES' in folders assert 'solar' not in folders diff --git a/tools/generate_spider_phase_boundaries.py b/tools/generate_spider_phase_boundaries.py index 3aa63af84..7ecc1fd7c 100644 --- a/tools/generate_spider_phase_boundaries.py +++ b/tools/generate_spider_phase_boundaries.py @@ -2,10 +2,11 @@ """Generate SPIDER S(P) phase boundary files from T(P) melting curves. SPIDER uses entropy-based phase boundaries S(P), while Zalmoxis and Aragog use -temperature-based T(P) curves. The T(P) curves are read from the local folder -FWL_DATA/interior_lookup_tables/Melting_curves// when it holds -them, otherwise from the fetched melting-curve dataset of that name. The S(P) -files are written into the local folder, which SPIDER reads: +temperature-based T(P) curves. The T(P) curves are the ones a run reads: the +local folder FWL_DATA/interior_lookup_tables/Melting_curves// when +it holds solidus_P-T.dat and liquidus_P-T.dat, otherwise the fetched +melting-curve dataset of that name. The S(P) files are written into the local +folder, where SPIDER looks for P-S curves when the run supplies none of its own: solidus_P-S.dat : S(P) format, used by SPIDER (generated by this tool) liquidus_P-S.dat : S(P) format, used by SPIDER (generated by this tool) @@ -27,6 +28,7 @@ - T(P) melting curves: solidus_P-T.dat and liquidus_P-T.dat in the local melting_dir folder, or a fetched curve (Monteux+600, Monteux-600, Wolf_Bower+2018; run `proteus get interiordata` first) + - PROTEUS importable, for the melting-curve resolver """ from __future__ import annotations @@ -58,13 +60,8 @@ def find_tp_curves(melting_dir: str) -> tuple[Path, Path]: Returns ------- tuple of Path - Solidus and liquidus files: the local folder's ``*_P-T.dat`` or - ``solidus.dat``/``liquidus.dat`` pair, else the fetched dataset's files. + Solidus and liquidus files, resolved as a PROTEUS run resolves them. """ - local = MELTING_CURVES_BASE / melting_dir - for sol, liq in (('solidus_P-T.dat', 'liquidus_P-T.dat'), ('solidus.dat', 'liquidus.dat')): - if (local / sol).is_file() and (local / liq).is_file(): - return local / sol, local / liq from proteus.utils.data import resolve_melting_curve_files return resolve_melting_curve_files(melting_dir, data_root=FWL_DATA) diff --git a/tools/postprocess.jl b/tools/postprocess.jl index f04e834da..464f79bba 100755 --- a/tools/postprocess.jl +++ b/tools/postprocess.jl @@ -268,7 +268,8 @@ function postproc(output_dir::String, nsamples::Int, spfile::String) joinpath(ENV["FWL_DATA"], "atmos_clim", "spectral_files", "honeyside", "4096")) isempty(sf_matches) && error("Honeyside 4096 spectral file not found; run `proteus get spectral`") # Version directories are r; take the highest record id, numerically. - record_ids = [parse(Int, splitpath(f)[end-1][2:end]) for f in sf_matches] + record_ids = [something(tryparse(Int, splitpath(f)[end-1][2:end]), -1) for f in sf_matches] + maximum(record_ids) < 0 && error("No r directory holds Honeyside.sf") spectral_file = sf_matches[argmax(record_ids)] length(sf_matches) > 1 && @warn "Several Honeyside 4096 versions found; using $spectral_file" star_file = joinpath(output_dir, "data", "$(star_years[1]).sflux") From a74827c5d8fdbbfe6f756624390a2e3c85789da8 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 23 Sep 2026 23:41:12 +0200 Subject: [PATCH 13/76] Stop on a missing configured melting curve in the reader When interior_struct.melting_dir is set and its curve files are missing, _provide_spider_eos_tables and the SPIDER phase-boundary setup now raise FileNotFoundError, naming the melting_dir and the missing files. Before, they logged a warning and ran with the curves bundled with SPIDER, a different solidus and liquidus from the configured one. The check sits in the reader, so it also covers offline runs. With the reader enforcing this, the melting-curve fetches go back under the per-step guard: a failed fetch, including an OSError from a read-only or full data root, is logged, the later steps still run, and a curve the run needs stops it when it is read. A Wolf and Bower outage no longer stops runs that use another curve. download_stellar_tracks raises DownloadError when the tracks cannot be obtained, and lets any other error from MORS through unchanged, so a stale fwl-io inside MORS is not reported as a network problem. The fwl-io error classes are imported when needed, so an fwl-io older than the floor gets the upgrade message, which a schema error in the shared manifest now also gives. An unknown key names the installed fwl-io version. postprocess.jl warns about several versions only when several parse. --- docs/Reference/data.md | 4 +- src/proteus/data/__init__.py | 16 +++-- src/proteus/interior_energetics/spider.py | 47 ++++--------- src/proteus/interior_energetics/wrapper.py | 19 ++--- src/proteus/utils/data.py | 75 +++++++++++++------- tests/data/test_manifest.py | 25 +++++++ tests/interior_energetics/test_spider.py | 40 ++++++++++- tests/interior_energetics/test_wrapper.py | 35 ++++++++++ tests/interior_struct/test_zalmoxis.py | 4 +- tests/utils/test_data.py | 81 ++++++++++++++++++---- tools/postprocess.jl | 2 +- 11 files changed, 256 insertions(+), 92 deletions(-) diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 8591d33e4..ee506fdbe 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -270,7 +270,9 @@ through fwl-io into `$FWL_DATA/interior/melting_curves//r/` A curve generated locally by `tools/solidus_func.py` into `$FWL_DATA/interior_lookup_tables/Melting_curves//` takes precedence over the fetched copy; see [Melting curves](melting_curves.md) for -the full list of parametrizations and how to generate them. +the full list of parametrizations and how to generate them. A configured +`melting_dir` whose curve files are missing stops SPIDER and Aragog runs with +an error naming the missing files; they never switch to other curves. The Wolf and Bower (2018) pressure-entropy lookup table that SPIDER and Aragog read for `eos_dir = "WolfBower2018_MgSiO3"` is fetched the same way, diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index 0ca350037..61c4ed174 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -160,18 +160,24 @@ def _dataset(key: str): ) from exc if key in datasets: return datasets[key] - try: - from fwl_io.manifest import shared_manifest_path + from importlib.metadata import version + + from fwl_io.manifest import shared_manifest_path + try: shared = {ds.key: ds for ds in load_manifest(shared_manifest_path())} - except ImportError as exc: + except ValueError as exc: + if _fwl_io_derives_the_location(): + raise raise RuntimeError( - f'the installed fwl-io ships no shared manifest; upgrade to fwl-io>={FWL_IO_FLOOR}.' + f'fwl-io could not read its shared manifest ({exc}); the installed fwl-io ' + f'predates the manifest schema: upgrade to fwl-io>={FWL_IO_FLOOR}.' ) from exc if key not in shared: raise KeyError( f'{key!r} is declared neither in the PROTEUS manifest nor in the fwl-io ' - f'shared manifest (a key PROTEUS reads needs fwl-io>={FWL_IO_FLOOR})' + f'shared manifest (installed fwl-io {version("fwl-io")}; PROTEUS reads the ' + f'shared keys of fwl-io>={FWL_IO_FLOOR})' ) return shared[key] diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index dc5a4844f..e0f3d44ae 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -929,10 +929,10 @@ def _try_spider( # then a local EOS directory, then the fetched dataset, then SPIDER local. eos_dir = _resolve_spider_eos_dir(dirs, config) - # Resolve melting curve S(P) files: prefer generated paths, then FWL_DATA, - # then SPIDER's bundled lookup_data as a final fallback. The bundled - # lookup_data ships P-S melting curves (Andrault+2011 / Hirschmann+2013) - # usable directly without Zalmoxis pre-processing. + # Resolve melting curve S(P) files: the paths derived for this run from the + # configured melting_dir, else P-S files in its local Melting_curves folder. + # A configured curve that is missing stops the run rather than switching to + # other curves. if dirs.get('spider_liquidus_ps') and os.path.isfile(dirs['spider_liquidus_ps']): liquidus_ps = dirs['spider_liquidus_ps'] solidus_ps = dirs['spider_solidus_ps'] @@ -946,37 +946,16 @@ def _try_spider( mc_dir = os.path.join(MELTING_CURVES_DIR, config.interior_struct.melting_dir) liquidus_ps = os.path.join(mc_dir, 'liquidus_P-S.dat') solidus_ps = os.path.join(mc_dir, 'solidus_P-S.dat') - if not (os.path.isfile(liquidus_ps) and os.path.isfile(solidus_ps)): - # Fall back to SPIDER's bundled lookup_data (P-S, ships with the - # SPIDER source tree). The A11_H13 file names are after - # Andrault+2011 (solidus) and Hirschmann+2013 (liquidus). - spider_local_eos = os.path.join( - dirs['spider'], 'lookup_data', '1TPa-dK09-elec-free' + missing_ps = [p for p in (solidus_ps, liquidus_ps) if not os.path.isfile(p)] + if missing_ps: + raise FileNotFoundError( + f'interior_struct.melting_dir={config.interior_struct.melting_dir!r} is ' + f'configured but its P-S melting curves are missing: ' + f'{", ".join(missing_ps)}. Fetch the P-T curves with ' + "'proteus get interiordata', or generate the P-S files with " + "'python tools/generate_spider_phase_boundaries.py --melting-dir " + f"{config.interior_struct.melting_dir}'." ) - liquidus_local = os.path.join(spider_local_eos, 'liquidus_A11_H13.dat') - solidus_local = os.path.join(spider_local_eos, 'solidus_A11_H13.dat') - if os.path.isfile(liquidus_local) and os.path.isfile(solidus_local): - liquidus_ps = liquidus_local - solidus_ps = solidus_local - log.info( - 'Using SPIDER-bundled A11_H13 phase boundaries from %s ' - '(no Zalmoxis-generated or FWL_DATA P-S melting curves ' - 'found for melting_dir=%s)', - spider_local_eos, - config.interior_struct.melting_dir, - ) - else: - raise FileNotFoundError( - f'SPIDER phase boundary files not found at any of: ' - f'\n {liquidus_ps}' - f'\n {solidus_ps}' - f'\n {liquidus_local}' - f'\n {solidus_local}' - f'\nEither generate them with ' - f"'python tools/generate_spider_phase_boundaries.py " - f"--melting-dir {config.interior_struct.melting_dir}' " - f'or use a melting_dir that ships P-S files.' - ) # EOS table setup: skip entirely when const_properties is active # (SPIDER creates a dummy single-phase EOS internally, no tables needed) diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 2c2cfee1e..f9a1dd071 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -679,6 +679,10 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: ``FileNotFoundError`` with a clear message pointing the user at ``proteus get all`` or the Zenodo record. + When ``interior_struct.melting_dir`` is set, the two P-S melting curves + are derived from its P-T files in every case above, and missing P-T files + raise ``FileNotFoundError`` instead of leaving the curves of the source. + Side effects: sets ``dirs['spider_eos_dir']``, ``dirs['spider_solidus_ps']``, ``dirs['spider_liquidus_ps']``. """ @@ -698,15 +702,14 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: from proteus.utils.data import resolve_melting_curve_files sol_pt_path, liq_pt_path = resolve_melting_curve_files(melting_dir) - melting_pt_dir = sol_pt_path.parent - if not (sol_pt_path.is_file() and liq_pt_path.is_file()): - log.warning( - 'melting_dir=%s configured but P-T files missing at %s; ' - 'falling back to byte-copy from upstream EoS distribution', - melting_dir, - melting_pt_dir, + missing_pt = [str(p) for p in (sol_pt_path, liq_pt_path) if not p.is_file()] + if missing_pt: + # Other curves would change the physics of the run, so stop here. + raise FileNotFoundError( + f'interior_struct.melting_dir={melting_dir!r} is configured but its P-T ' + f'melting curves are missing: {", ".join(missing_pt)}. Fetch them with ' + "'proteus get interiordata', or set melting_dir to an available curve." ) - derive_melting = False # Case 1: already populated (e.g. by an earlier call this session or # by Zalmoxis's generate_spider_tables in a prior structure solve). diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 8c17dd13b..9a23776ed 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -12,8 +12,6 @@ from typing import TYPE_CHECKING import numpy as np -from fwl_io import DownloadError, MissingDataRootError, OfflineDataError -from fwl_io.archive import ArchiveError from osfclient.api import OSF from scipy.interpolate import interp1d @@ -1394,7 +1392,7 @@ def download_melting_curves(config: Config, clean: bool = False): def download_stellar_spectra(*, folders: tuple[str, ...] | None = None): """ - Download stellar spectra collections into ``FWL_DATA/stellar_spectra``. + Download stellar spectra collections into ``FWL_DATA/star/spectra``. Notes ----- @@ -1517,6 +1515,13 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): Track name ('Spada' or 'Baraffe') use_osf_fallback : bool If True, attempt OSF download if MORS download fails + + Raises + ------ + DownloadError + The tracks could not be obtained from MORS or the OSF fallback. + An error from MORS that is not a failed download (a stale fwl-io, + a bug) propagates unchanged. """ from mors import data as mors_data @@ -1543,6 +1548,10 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): log.warning(f'MORS download completed but tracks not found at {tracks_path}') raise FileNotFoundError(f'Tracks directory empty or missing: {tracks_path}') except Exception as e: + if not isinstance(e, _fetch_errors()): + raise + from fwl_io import DownloadError + log.warning(f'MORS download failed for {track} tracks: {e}') # Baraffe is hash-verified by fwl-io and has no OSF mirror, so a failure @@ -1590,29 +1599,52 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): f'Could not download {track} tracks via MORS or OSF fallback. ' f'You may need to download manually or check network connectivity.' ) - raise RuntimeError( + raise DownloadError( f'Failed to download {track} tracks: MORS failed, OSF fallback unavailable' ) except Exception as osf_fallback_error: log.error(f'OSF fallback also failed for {track} tracks: {osf_fallback_error}') - raise RuntimeError( + raise DownloadError( f'Failed to download {track} tracks: MORS error ({e}), OSF fallback error ({osf_fallback_error})' - ) + ) from osf_fallback_error + +def _fetch_errors() -> tuple[type[Exception], ...]: + """Return what a fetch raises when a dataset cannot be obtained. -# What a start-of-run fetch raises when a dataset cannot be obtained: filesystem -# errors and fwl-io's own errors. Other RuntimeErrors (a stale fwl-io, a bug) raise. -_FETCH_ERRORS = (OSError, DownloadError, OfflineDataError, ArchiveError, MissingDataRootError) + Filesystem errors and fwl-io's own download errors; any other error (a + stale fwl-io, a bug) is not a failed download and must propagate. + Returns + ------- + tuple of type + ``(OSError, DownloadError, OfflineDataError, ArchiveError, + MissingDataRootError)``. -def _attempt(desc: str, func, *args, catch=_FETCH_ERRORS, **kwargs) -> bool: + Raises + ------ + RuntimeError + The installed fwl-io lacks these classes, so it predates the floor. + """ + try: + from fwl_io import DownloadError, MissingDataRootError, OfflineDataError + from fwl_io.archive import ArchiveError + except ImportError as exc: + from proteus.data import FWL_IO_FLOOR + + raise RuntimeError( + f'the installed fwl-io is too old for PROTEUS; upgrade to fwl-io>={FWL_IO_FLOOR}.' + ) from exc + return (OSError, DownloadError, OfflineDataError, ArchiveError, MissingDataRootError) + + +def _attempt(desc: str, func, *args, **kwargs) -> bool: """Run one start-of-run fetch step, reporting a failure instead of raising. Each step is fetched independently, so an unreachable mirror for one dataset does not stop the others; a dataset that is still missing fails - later, where it is read. Data whose absence a reader would silently replace - (the melting curves) is fetched outside this guard. + later, where it is read. Parameters ---------- @@ -1620,8 +1652,6 @@ def _attempt(desc: str, func, *args, catch=_FETCH_ERRORS, **kwargs) -> bool: What the step fetches, for the log message. func : callable The fetch function, called with the remaining arguments. - catch : tuple of type - Exceptions reported as a failed fetch. Returns ------- @@ -1630,7 +1660,7 @@ def _attempt(desc: str, func, *args, catch=_FETCH_ERRORS, **kwargs) -> bool: """ try: func(*args, **kwargs) - except catch as exc: + except _fetch_errors() as exc: log.warning('Problem when downloading/checking %s: %s', desc, exc) return False return True @@ -1649,14 +1679,8 @@ def _get_sufficient(config: Config, clean: bool = False): folders.append('MUSCLES') for folder in dict.fromkeys(folders): _attempt(f'stellar spectra {folder}', download_stellar_spectra, folders=(folder,)) - # download_stellar_tracks reports a failed download as RuntimeError. tracks = 'Spada' if config.star.mors.tracks == 'spada' else 'Baraffe' - _attempt( - 'stellar tracks', - download_stellar_tracks, - tracks, - catch=_FETCH_ERRORS + (RuntimeError,), - ) + _attempt('stellar tracks', download_stellar_tracks, tracks) # Spectral files if config.atmos_clim.module in ('janus', 'agni'): @@ -1690,10 +1714,9 @@ def _get_sufficient(config: Config, clean: bool = False): # Interior lookup tables (melting curves) if config.interior_energetics.module in ('aragog', 'spider'): - # Not guarded: without the configured melting curves SPIDER and Aragog - # fall back to other curves, so a failed fetch must stop the run here. - download_interior_lookuptables(clean=clean) - download_melting_curves(config, clean=clean) + # A configured curve that is still missing stops the run where it is read. + _attempt('Wolf and Bower melting curves', download_interior_lookuptables, clean=clean) + _attempt('melting curves', download_melting_curves, config, clean=clean) # Dynamic EOS for SPIDER and Aragog (uses struct.eos_dir, skip if None/PALEOS) if ( diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index 46c818d26..b52b1fba9 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -401,6 +401,31 @@ def _rejects(*args, **kwargs): assert isinstance(excinfo.value.__cause__, ValueError), 'the original error stays attached' +def test_stale_shared_manifest_is_named_as_the_stale_side(monkeypatch): + """A shared-manifest schema error on an old fwl-io asks for the upgrade. + + A shared key is resolved after the PROTEUS manifest loads, so the stale + case has to be caught on that second load as well. + """ + import fwl_io + + real_load = fwl_io.load_manifest + + def _load(path): + if Path(path) == manifest_path(): + return real_load(path) + raise ValueError('"subdir" is not a manifest field') + + monkeypatch.setattr(fwl_io, 'load_manifest', _load) + monkeypatch.setattr('proteus.data._fwl_io_derives_the_location', lambda: False) + + with pytest.raises(RuntimeError, match=f'upgrade to fwl-io>={FWL_IO_FLOOR}') as raised: + _dataset(STELLAR_SPECTRA_SOLAR) + + assert 'shared manifest' in str(raised.value) + assert isinstance(raised.value.__cause__, ValueError) + + def test_manifest_error_under_a_current_fwl_io_propagates(monkeypatch): """A real defect in the shipped manifest surfaces as itself, not as a version claim. diff --git a/tests/interior_energetics/test_spider.py b/tests/interior_energetics/test_spider.py index 4325affb2..ae631cf3a 100644 --- a/tests/interior_energetics/test_spider.py +++ b/tests/interior_energetics/test_spider.py @@ -26,6 +26,7 @@ import os import shutil import tempfile +from pathlib import Path from unittest.mock import MagicMock, patch import numpy as np @@ -1328,7 +1329,7 @@ def test_try_spider_missing_melting_curves(tmp_path): return_value=3000.0, ), ): - with pytest.raises(FileNotFoundError, match='SPIDER phase boundary file'): + with pytest.raises(FileNotFoundError, match='melting curves are missing'): _try_spider( dirs, config, @@ -1346,6 +1347,43 @@ def test_try_spider_missing_melting_curves(tmp_path): mock_run.assert_not_called() +@pytest.mark.unit +def test_try_spider_missing_configured_curve_ignores_bundled_curves(tmp_path): + """A missing configured melting curve stops the run even when SPIDER bundles others. + + Falling back to the bundled Andrault/Hirschmann curves would run a different + solidus and liquidus than the configured melting_dir. + """ + from proteus.interior_energetics.spider import _try_spider + + dirs, config, hf_row, eos_base, _, _ = _setup_spider_env(tmp_path) + bundle = Path(dirs['spider']) / 'lookup_data' / '1TPa-dK09-elec-free' + bundle.mkdir(parents=True) + (bundle / 'solidus_A11_H13.dat').write_text('# bundled solidus\n') + (bundle / 'liquidus_A11_H13.dat').write_text('# bundled liquidus\n') + + with ( + patch('proteus.interior_energetics.spider.EOS_DYNAMIC_DIR', eos_base), + patch('proteus.interior_energetics.spider.MELTING_CURVES_DIR', str(tmp_path / 'none')), + patch('proteus.interior_energetics.spider.sp.run') as mock_run, + patch( + 'proteus.interior_energetics.common.compute_initial_entropy', + return_value=3000.0, + ), + ): + with pytest.raises(FileNotFoundError, match="melting_dir='Wolf_Bower\\+2018'"): + _try_spider( + dirs, + config, + IC_INTERIOR=1, + hf_all=None, + hf_row=hf_row, + step_sf=1.0, + atol_sf=1.0, + ) + mock_run.assert_not_called() + + @pytest.mark.unit def test_try_spider_eos_fallback_to_local(tmp_path): """EOS dir resolves to the SPIDER local fallback when no other EOS source exists.""" diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 971533b29..32f97e1d0 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -4450,6 +4450,41 @@ def test_provide_spider_eos_tables_spider_submodule_fallback(tmp_path): assert dirs['spider_solidus_ps'] == str(target / 'solidus_P-S.dat') +@pytest.mark.unit +def test_provide_spider_eos_tables_missing_configured_curve_raises(tmp_path): + """A configured melting_dir without its P-T files stops the run. + + Continuing would leave the bundled A11_H13 curves in place, so the run + would use another solidus and liquidus than the one configured. + """ + from types import SimpleNamespace + from unittest.mock import patch as _patch + + from proteus.interior_energetics.wrapper import ( + _SPIDER_EOS_PHASE_FILES, + _provide_spider_eos_tables, + ) + + config = SimpleNamespace(interior_struct=SimpleNamespace(melting_dir='Monteux-600')) + spider_bundle = tmp_path / 'SPIDER' / 'lookup_data' / '1TPa-dK09-elec-free' + spider_bundle.mkdir(parents=True) + for f in _SPIDER_EOS_PHASE_FILES: + _write_synthetic_ps_table(spider_bundle / f, NX=3, NY=4) + (spider_bundle / 'solidus_A11_H13.dat').write_text('# bundled solidus\n') + (spider_bundle / 'liquidus_A11_H13.dat').write_text('# bundled liquidus\n') + dirs = {'spider': str(tmp_path / 'SPIDER')} + + with ( + _patch('proteus.utils.data.GetFWLData', return_value=tmp_path / 'fwl_empty'), + pytest.raises(FileNotFoundError, match="melting_dir='Monteux-600'") as raised, + ): + _provide_spider_eos_tables(config, str(tmp_path), dirs) + + # The message names the P-T files that are missing. + assert 'monteux_minus_600' in str(raised.value) + assert 'spider_solidus_ps' not in dirs + + # ============================================================================ # run_interior: output-conversion exception bubbles up # ============================================================================ diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index e71fc340e..a99d216c1 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -2914,8 +2914,8 @@ def test_material_dictionaries_read_the_tree_the_fetch_writes(monkeypatch, tmp_p """The EOS paths use the data root that download_zalmoxis_eos fetches into. The raw ``FWL_DATA`` value is not expanded and has a different default from - the resolved root; building the paths from it would send Zalmoxis to another - tree than the one the fetch populated. + the resolved root. It is set to another tree here, so paths built from it + instead of the resolved root fail the test. """ from pathlib import Path diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 6afde3a6b..f8f6270c2 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -20,6 +20,7 @@ from unittest.mock import MagicMock, call, patch import pytest +from fwl_io import DownloadError from proteus.utils.data import ( GetFWLData, @@ -2149,16 +2150,16 @@ def _fetch(key, *args, data_root=None): ('Monteux-600', 'interior.melting_curves.monteux_minus_600'), ], ) -def test_melting_curve_fetch_failure_stops_the_run( - monkeypatch, tmp_path, melting_dir, failing_key +@pytest.mark.parametrize('error', [DownloadError, PermissionError]) +def test_melting_curve_fetch_failure_is_reported_and_later_steps_run( + monkeypatch, tmp_path, melting_dir, failing_key, error ): - """A failed melting-curve fetch raises instead of being logged. + """A failed melting-curve fetch is logged and the EOS fetch still runs. - Without the configured curves SPIDER and Aragog fall back to other curves, - so continuing would change the physics of the run rather than fail it. + A configured curve that is still missing stops the run where it is read + (_provide_spider_eos_tables), so the fetch step needs no special case; + an OSError such as a read-only data root is handled like a mirror error. """ - from fwl_io import DownloadError - from proteus.config import read_config_object from proteus.utils import data as data_mod @@ -2167,7 +2168,7 @@ def test_melting_curve_fetch_failure_stops_the_run( def _fetch(key, *args, data_root=None): attempted.append(key) if key == failing_key: - raise DownloadError('could not obtain from any mirror') + raise error('could not obtain from any mirror') return [] monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path) @@ -2179,11 +2180,63 @@ def _fetch(key, *args, data_root=None): ) cfg.interior_struct.melting_dir = melting_dir - with pytest.raises(DownloadError): - data_mod.download_sufficient_data(cfg) + data_mod.download_sufficient_data(cfg) + + assert failing_key in attempted + assert 'interior.eos.paleos_iron' in attempted + + +@pytest.mark.unit +def test_fetch_errors_on_an_old_fwl_io_ask_for_the_upgrade(monkeypatch): + """An fwl-io without the fetch error classes is reported as too old. + + Importing the classes at module level would fail on import of + proteus.utils.data, before any message could name the required version. + """ + import sys + + from proteus.data import FWL_IO_FLOOR + from proteus.utils.data import _fetch_errors + + assert DownloadError in _fetch_errors() + monkeypatch.setitem(sys.modules, 'fwl_io.archive', None) + + with pytest.raises(RuntimeError, match=f'upgrade to fwl-io>={FWL_IO_FLOOR}') as raised: + _fetch_errors() + + assert isinstance(raised.value.__cause__, ImportError) + + +@pytest.mark.unit +def test_download_stellar_tracks_passes_non_download_errors(monkeypatch): + """An error from MORS that is not a failed download propagates unchanged. + + A stale fwl-io inside MORS raises RuntimeError; reporting it as a failed + download would hide the actionable message behind a network warning. + """ + import sys + import types + + from proteus.utils import data as data_mod + + def _stale(track): + raise RuntimeError('upgrade to fwl-io>=26.9.0') + + fake_mors_data = types.ModuleType('mors.data') + fake_mors_data.DownloadEvolutionTracks = _stale + fake_mors = types.ModuleType('mors') + fake_mors.data = fake_mors_data + monkeypatch.setitem(sys.modules, 'mors', fake_mors) + monkeypatch.setitem(sys.modules, 'mors.data', fake_mors_data) + osf_calls = [] + monkeypatch.setattr(data_mod, 'download_OSF_folder', lambda **k: osf_calls.append('x')) - assert attempted[-1] == failing_key - assert 'interior.eos.paleos_iron' not in attempted + with pytest.raises(RuntimeError, match='upgrade to fwl-io') as raised: + data_mod.download_stellar_tracks('Spada') + + # Discrimination: not converted into the download failure the OSF path raises. + assert not isinstance(raised.value, DownloadError) + assert osf_calls == [] @pytest.mark.unit @@ -5137,7 +5190,7 @@ def test_download_stellar_tracks_osf_fallback_succeeds(tmp_path, monkeypatch): monkeypatch.setattr(data_mod, 'GetFWLData', lambda: tmp_path) def fake_download(track): - raise RuntimeError('MORS HTTP 503') + raise DownloadError('MORS HTTP 503') fake_mors_data = types.ModuleType('mors.data') fake_mors_data.DownloadEvolutionTracks = fake_download @@ -5890,7 +5943,7 @@ def test_download_stellar_tracks_osf_per_project_exception_caught(tmp_path, monk monkeypatch.setattr(data_mod, 'GetFWLData', lambda: tmp_path) def fake_download(track): - raise RuntimeError('MORS HTTP 503') + raise DownloadError('MORS HTTP 503') fake_mors_data = types.ModuleType('mors.data') fake_mors_data.DownloadEvolutionTracks = fake_download diff --git a/tools/postprocess.jl b/tools/postprocess.jl index 464f79bba..566289ecf 100755 --- a/tools/postprocess.jl +++ b/tools/postprocess.jl @@ -271,7 +271,7 @@ function postproc(output_dir::String, nsamples::Int, spfile::String) record_ids = [something(tryparse(Int, splitpath(f)[end-1][2:end]), -1) for f in sf_matches] maximum(record_ids) < 0 && error("No r directory holds Honeyside.sf") spectral_file = sf_matches[argmax(record_ids)] - length(sf_matches) > 1 && @warn "Several Honeyside 4096 versions found; using $spectral_file" + count(>=(0), record_ids) > 1 && @warn "Several Honeyside 4096 versions found; using $spectral_file" star_file = joinpath(output_dir, "data", "$(star_years[1]).sflux") @info "Spectral file not provided. Will use $spectral_file" else From 32f58712e83d93ae2c7bbce2a25e0b0e0570536a Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 00:11:23 +0200 Subject: [PATCH 14/76] Tighten the melting-curve stop and the fetch error handling A constant-property SPIDER run passes no phase boundaries, so it no longer resolves melting curves and cannot stop on missing ones. A missing configured curve raises MissingMeltingCurveError, which the dummy-structure liquidus_super handler passes through unchanged instead of reporting a missing table set. The error names `proteus get interiordata --config-path`, which fetches the configured curve, and the SPIDER message about missing P-S files points to the phase-boundary tool only. The fetch error classes are resolved before a step runs, so on an fwl-io older than the floor an unrelated error in the step is no longer replaced by the upgrade message. An fwl-io without a shared manifest gets the upgrade message, and the unknown-key message no longer depends on package metadata. download_stellar_tracks raises one DownloadError when the OSF fallback finds nothing, and its docstring lists what it raises. The data page and the docstring of _provide_spider_eos_tables say which runs read melting_dir: with a PALEOS mantle equation of state the curves come from the PALEOS liquidus and melting_dir is not read. --- docs/Reference/data.md | 8 +++-- src/proteus/data/__init__.py | 16 ++++++--- src/proteus/interior_energetics/spider.py | 16 +++++---- src/proteus/interior_energetics/wrapper.py | 15 ++++++-- src/proteus/utils/data.py | 42 ++++++++++++---------- tests/data/test_manifest.py | 29 +++++++++++++++ tests/interior_energetics/test_spider.py | 36 +++++++++++++++++++ tests/interior_energetics/test_wrapper.py | 36 +++++++++++++++++++ tests/utils/test_data.py | 27 ++++++++++++-- 9 files changed, 188 insertions(+), 37 deletions(-) diff --git a/docs/Reference/data.md b/docs/Reference/data.md index ee506fdbe..dc8b7a7c3 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -270,9 +270,11 @@ through fwl-io into `$FWL_DATA/interior/melting_curves//r/` A curve generated locally by `tools/solidus_func.py` into `$FWL_DATA/interior_lookup_tables/Melting_curves//` takes precedence over the fetched copy; see [Melting curves](melting_curves.md) for -the full list of parametrizations and how to generate them. A configured -`melting_dir` whose curve files are missing stops SPIDER and Aragog runs with -an error naming the missing files; they never switch to other curves. +the full list of parametrizations and how to generate them. When SPIDER or +Aragog read `melting_dir`, a configured curve whose files are missing stops the +run with an error naming the missing files instead of switching to other curves. +With a PALEOS mantle equation of state, the curves are derived from the PALEOS +liquidus and `melting_dir` is not read. The Wolf and Bower (2018) pressure-entropy lookup table that SPIDER and Aragog read for `eos_dir = "WolfBower2018_MgSiO3"` is fetched the same way, diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index 61c4ed174..7772eedba 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -160,12 +160,16 @@ def _dataset(key: str): ) from exc if key in datasets: return datasets[key] - from importlib.metadata import version - - from fwl_io.manifest import shared_manifest_path + from importlib.metadata import PackageNotFoundError, version try: + from fwl_io.manifest import shared_manifest_path + shared = {ds.key: ds for ds in load_manifest(shared_manifest_path())} + except ImportError as exc: + raise RuntimeError( + f'the installed fwl-io ships no shared manifest; upgrade to fwl-io>={FWL_IO_FLOOR}.' + ) from exc except ValueError as exc: if _fwl_io_derives_the_location(): raise @@ -174,9 +178,13 @@ def _dataset(key: str): f'predates the manifest schema: upgrade to fwl-io>={FWL_IO_FLOOR}.' ) from exc if key not in shared: + try: + installed = version('fwl-io') + except PackageNotFoundError: + installed = 'unknown' raise KeyError( f'{key!r} is declared neither in the PROTEUS manifest nor in the fwl-io ' - f'shared manifest (installed fwl-io {version("fwl-io")}; PROTEUS reads the ' + f'shared manifest (installed fwl-io {installed}; PROTEUS reads the ' f'shared keys of fwl-io>={FWL_IO_FLOOR})' ) return shared[key] diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index e0f3d44ae..4b2ea742d 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -929,11 +929,14 @@ def _try_spider( # then a local EOS directory, then the fetched dataset, then SPIDER local. eos_dir = _resolve_spider_eos_dir(dirs, config) - # Resolve melting curve S(P) files: the paths derived for this run from the - # configured melting_dir, else P-S files in its local Melting_curves folder. - # A configured curve that is missing stops the run rather than switching to - # other curves. - if dirs.get('spider_liquidus_ps') and os.path.isfile(dirs['spider_liquidus_ps']): + # Resolve melting curve S(P) files: the paths derived for this run, else P-S + # files in the local Melting_curves/ folder. A missing curve + # stops the run rather than switching to other curves. Constant-property + # runs pass no phase boundaries to SPIDER, so they need none. + liquidus_ps = solidus_ps = None + if _use_const: + pass + elif dirs.get('spider_liquidus_ps') and os.path.isfile(dirs['spider_liquidus_ps']): liquidus_ps = dirs['spider_liquidus_ps'] solidus_ps = dirs['spider_solidus_ps'] log.info( @@ -951,8 +954,7 @@ def _try_spider( raise FileNotFoundError( f'interior_struct.melting_dir={config.interior_struct.melting_dir!r} is ' f'configured but its P-S melting curves are missing: ' - f'{", ".join(missing_ps)}. Fetch the P-T curves with ' - "'proteus get interiordata', or generate the P-S files with " + f'{", ".join(missing_ps)}. Generate them with ' "'python tools/generate_spider_phase_boundaries.py --melting-dir " f"{config.interior_struct.melting_dir}'." ) diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index f9a1dd071..756c79b35 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -641,6 +641,10 @@ def _is_spider_ps_format(path: str) -> bool: return len(parts) >= 2 and parts[0] == '#' and parts[1] == '5' +class MissingMeltingCurveError(FileNotFoundError): + """The configured ``interior_struct.melting_dir`` has no P-T curve files.""" + + def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: """Ensure that Aragog and SPIDER can find a complete P-S lookup set. @@ -681,7 +685,9 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: When ``interior_struct.melting_dir`` is set, the two P-S melting curves are derived from its P-T files in every case above, and missing P-T files - raise ``FileNotFoundError`` instead of leaving the curves of the source. + raise ``MissingMeltingCurveError`` instead of leaving the curves of the + source. This helper is not called when Zalmoxis generates a PALEOS table + set; those runs use the PALEOS-derived curves and do not read melting_dir. Side effects: sets ``dirs['spider_eos_dir']``, ``dirs['spider_solidus_ps']``, ``dirs['spider_liquidus_ps']``. @@ -705,10 +711,11 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: missing_pt = [str(p) for p in (sol_pt_path, liq_pt_path) if not p.is_file()] if missing_pt: # Other curves would change the physics of the run, so stop here. - raise FileNotFoundError( + raise MissingMeltingCurveError( f'interior_struct.melting_dir={melting_dir!r} is configured but its P-T ' f'melting curves are missing: {", ".join(missing_pt)}. Fetch them with ' - "'proteus get interiordata', or set melting_dir to an available curve." + "'proteus get interiordata --config-path ', or set " + 'melting_dir to an available curve.' ) # Case 1: already populated (e.g. by an earlier call this session or @@ -1046,6 +1053,8 @@ def determine_interior_radius_with_dummy( # The liquidus_super initial entropy solves on these tables. try: _provide_spider_eos_tables(config, outdir, dirs) + except MissingMeltingCurveError: + raise except FileNotFoundError as exc: raise RuntimeError( "planet.temperature_mode='liquidus_super' with " diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 9a23776ed..fe42c2147 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1322,10 +1322,11 @@ def find_lookup_table_dir(data_root: Path | str | None = None) -> Path | None: def download_interior_lookuptables(clean=False): - """Fetch the melting curves that Aragog and SPIDER always need. + """Fetch the Wolf and Bower 2018 melting curves. - The Wolf and Bower 2018 melting curves are fetched through fwl-io into - ``FWL_DATA/interior/melting_curves/wolf_bower_2018/r/``. + They are fetched through fwl-io into + ``FWL_DATA/interior/melting_curves/wolf_bower_2018/r/`` and read + when ``interior_struct.melting_dir`` is ``Wolf_Bower+2018``. Parameters ---------- @@ -1519,12 +1520,17 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): Raises ------ DownloadError - The tracks could not be obtained from MORS or the OSF fallback. - An error from MORS that is not a failed download (a stale fwl-io, - a bug) propagates unchanged. - """ + The Spada tracks could not be obtained from MORS or the OSF fallback. + OSError or fwl-io error + A failed MORS download of the Baraffe tracks, or of any track set + when ``use_osf_fallback`` is False, re-raised as it is. + Exception + Any other error from MORS (a stale fwl-io, a bug), unchanged. + """ + from fwl_io import DownloadError from mors import data as mors_data + fetch_errors = _fetch_errors() log.debug(f'Downloading stellar evolution tracks: {track}') # Try MORS download first @@ -1548,9 +1554,8 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): log.warning(f'MORS download completed but tracks not found at {tracks_path}') raise FileNotFoundError(f'Tracks directory empty or missing: {tracks_path}') except Exception as e: - if not isinstance(e, _fetch_errors()): + if not isinstance(e, fetch_errors): raise - from fwl_io import DownloadError log.warning(f'MORS download failed for {track} tracks: {e}') @@ -1595,20 +1600,20 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): log.debug(f'OSF project {osf_id} did not have {track} tracks: {osf_e}') continue - log.error( - f'Could not download {track} tracks via MORS or OSF fallback. ' - f'You may need to download manually or check network connectivity.' - ) - raise DownloadError( - f'Failed to download {track} tracks: MORS failed, OSF fallback unavailable' - ) - except Exception as osf_fallback_error: log.error(f'OSF fallback also failed for {track} tracks: {osf_fallback_error}') raise DownloadError( f'Failed to download {track} tracks: MORS error ({e}), OSF fallback error ({osf_fallback_error})' ) from osf_fallback_error + log.error( + f'Could not download {track} tracks via MORS or OSF fallback. ' + f'You may need to download manually or check network connectivity.' + ) + raise DownloadError( + f'Failed to download {track} tracks: MORS failed, OSF fallback unavailable' + ) from e + def _fetch_errors() -> tuple[type[Exception], ...]: """Return what a fetch raises when a dataset cannot be obtained. @@ -1658,9 +1663,10 @@ def _attempt(desc: str, func, *args, **kwargs) -> bool: bool Whether the step finished without a fetch error. """ + fetch_errors = _fetch_errors() try: func(*args, **kwargs) - except _fetch_errors() as exc: + except fetch_errors as exc: log.warning('Problem when downloading/checking %s: %s', desc, exc) return False return True diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index b52b1fba9..67ff4111e 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -426,6 +426,35 @@ def _load(path): assert isinstance(raised.value.__cause__, ValueError) +def test_fwl_io_without_shared_manifest_is_named_as_the_stale_side(monkeypatch): + """An fwl-io without shared_manifest_path asks for the upgrade.""" + import fwl_io.manifest + + monkeypatch.delattr(fwl_io.manifest, 'shared_manifest_path') + + with pytest.raises(RuntimeError, match=f'upgrade to fwl-io>={FWL_IO_FLOOR}') as raised: + _dataset(STELLAR_SPECTRA_SOLAR) + + assert isinstance(raised.value.__cause__, ImportError) + # Discrimination: a PROTEUS-owned key still resolves without the shared manifest. + assert _dataset(MASS_RADIUS_ZENG_2019).key == MASS_RADIUS_ZENG_2019 + + +def test_unknown_key_message_survives_missing_package_metadata(monkeypatch): + """The KeyError is raised even when fwl-io's version cannot be read.""" + import importlib.metadata + + def _no_metadata(name): + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, 'version', _no_metadata) + + with pytest.raises(KeyError, match='installed fwl-io unknown') as raised: + _dataset('observe.not_a_declared_dataset') + + assert f'fwl-io>={FWL_IO_FLOOR}' in str(raised.value) + + def test_manifest_error_under_a_current_fwl_io_propagates(monkeypatch): """A real defect in the shipped manifest surfaces as itself, not as a version claim. diff --git a/tests/interior_energetics/test_spider.py b/tests/interior_energetics/test_spider.py index ae631cf3a..718ad5540 100644 --- a/tests/interior_energetics/test_spider.py +++ b/tests/interior_energetics/test_spider.py @@ -1384,6 +1384,42 @@ def test_try_spider_missing_configured_curve_ignores_bundled_curves(tmp_path): mock_run.assert_not_called() +@pytest.mark.unit +def test_try_spider_const_properties_needs_no_melting_curves(tmp_path): + """A constant-property run passes no phase boundaries, so missing curves do not stop it.""" + from proteus.interior_energetics.spider import _try_spider + + dirs, config, hf_row, eos_base, _, _ = _setup_spider_env(tmp_path) + config.interior_energetics.const_properties = True + for name in ('rho', 'Cp', 'alpha', 'cond', 'log10visc', 'T_ref', 'S_ref'): + setattr(config.interior_energetics, f'const_{name}', 1.0) + + with ( + patch('proteus.interior_energetics.spider.EOS_DYNAMIC_DIR', eos_base), + patch('proteus.interior_energetics.spider.MELTING_CURVES_DIR', str(tmp_path / 'none')), + patch('proteus.interior_energetics.spider.sp.run') as mock_run, + patch( + 'proteus.interior_energetics.common.compute_initial_entropy', + return_value=3000.0, + ), + ): + mock_run.return_value.returncode = 0 + _try_spider( + dirs, + config, + IC_INTERIOR=1, + hf_all=None, + hf_row=hf_row, + step_sf=1.0, + atol_sf=1.0, + ) + + mock_run.assert_called_once() + args = mock_run.call_args.args[0] + assert '-use_const_properties' in args + assert '-melt_phase_boundary_filename' not in args + + @pytest.mark.unit def test_try_spider_eos_fallback_to_local(tmp_path): """EOS dir resolves to the SPIDER local fallback when no other EOS source exists.""" diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 32f97e1d0..b9d2c4b32 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -3486,6 +3486,42 @@ def test_dummy_structure_provides_tables_for_liquidus_super_without_generated_se assert provide.call_args.args[1] == str(tmp_path) +@pytest.mark.unit +def test_dummy_structure_liquidus_super_passes_missing_melting_curve_through(tmp_path): + """A missing configured melting curve keeps its own error. + + The handler around the table lookup reports a missing table set and advises + another temperature_mode, which does not fix a missing melting_dir curve. + """ + from unittest.mock import patch as _patch + + from proteus.interior_energetics.wrapper import ( + MissingMeltingCurveError, + determine_interior_radius_with_dummy, + ) + + config = MagicMock() + config.interior_energetics.module = 'aragog' + config.interior_energetics.num_levels = 50 + config.planet.temperature_mode = 'liquidus_super' + hf_row = {'M_int': 5.972e24, 'M_core': 2.0e24, 'R_int': 6.371e6, 'gravity': 9.81} + + with ( + _patch('proteus.interior_struct.dummy.solve_dummy_structure', return_value=None), + _patch('proteus.interior_struct.zalmoxis.generate_spider_tables', return_value=None), + _patch( + 'proteus.interior_energetics.wrapper._provide_spider_eos_tables', + side_effect=MissingMeltingCurveError("melting_dir='Monteux-600' missing"), + ), + _patch('proteus.interior_energetics.wrapper.Interior_t') as interior_t, + pytest.raises(MissingMeltingCurveError, match='Monteux-600') as excinfo, + ): + determine_interior_radius_with_dummy({}, config, None, hf_row, str(tmp_path)) + + assert 'temperature_mode' not in str(excinfo.value) + interior_t.assert_not_called() + + @pytest.mark.unit def test_dummy_structure_liquidus_super_without_tables_raises_named_error(tmp_path): """No generated set and no FWL_DATA or SPIDER table source gives a named diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index f8f6270c2..231b177c9 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -2207,6 +2207,27 @@ def test_fetch_errors_on_an_old_fwl_io_ask_for_the_upgrade(monkeypatch): assert isinstance(raised.value.__cause__, ImportError) +@pytest.mark.unit +def test_attempt_on_an_old_fwl_io_does_not_run_the_step(monkeypatch): + """The fetch error classes are resolved before the step runs. + + Resolving them inside the except clause would replace an unrelated error of + the step with the upgrade message, hiding the real cause. + """ + from proteus.utils import data as data_mod + + def _stale(): + raise RuntimeError('upgrade to fwl-io>=26.9.23') + + calls = [] + monkeypatch.setattr(data_mod, '_fetch_errors', _stale) + + with pytest.raises(RuntimeError, match='upgrade to fwl-io'): + data_mod._attempt('test data', lambda: calls.append(1)) + + assert calls == [] + + @pytest.mark.unit def test_download_stellar_tracks_passes_non_download_errors(monkeypatch): """An error from MORS that is not a failed download propagates unchanged. @@ -5933,7 +5954,7 @@ def test_get_socrates_uses_none_dirs_when_not_given(mock_run, tmp_path, monkeypa @pytest.mark.unit def test_download_stellar_tracks_osf_per_project_exception_caught(tmp_path, monkeypatch): - """If get_osf raises for one project, the function moves on and raises RuntimeError.""" + """If get_osf raises for every project, the function raises one DownloadError.""" import sys import types @@ -5961,8 +5982,10 @@ def bad_get_osf(osf_id): monkeypatch.setattr(data_mod, 'get_osf', bad_get_osf) monkeypatch.setattr(data_mod, 'download_OSF_folder', lambda **k: None) - with pytest.raises(RuntimeError, match='MORS error'): + with pytest.raises(DownloadError, match='OSF fallback unavailable') as raised: data_mod.download_stellar_tracks('Spada', use_osf_fallback=True) + # Not wrapped a second time by the handler of the OSF setup. + assert 'OSF fallback error' not in str(raised.value) # Discrimination: get_osf was actually called for each candidate # OSF project, so the per-project except path fired (not an outer # short-circuit). From 0e307d814273a0107fc71b55873c59a23ccff5d6 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 00:44:19 +0200 Subject: [PATCH 15/76] Narrow the stale fwl-io checks and pin the curve messages download_stellar_tracks now imports DownloadError after _fetch_errors(), so an fwl-io without the error classes gives the upgrade message instead of a bare ImportError, and MORS is not called. In _dataset, only the import of shared_manifest_path is reported as a stale fwl-io. An ImportError raised while the shared manifest loads propagates as it is. The melting-curve resolution in _try_spider sits under one const_properties check. Tests assert the missing-curve messages name the right remedy (the fetch command for P-T files, the generate tool for P-S files), the MissingMeltingCurveError type, and the MORS failure as the cause of the OSF DownloadError. --- src/proteus/data/__init__.py | 4 +- src/proteus/interior_energetics/spider.py | 45 +++++++++++------------ src/proteus/utils/data.py | 3 +- tests/data/test_manifest.py | 22 +++++++++++ tests/interior_energetics/test_spider.py | 7 +++- tests/interior_energetics/test_wrapper.py | 5 ++- tests/utils/test_data.py | 30 +++++++++++++++ 7 files changed, 88 insertions(+), 28 deletions(-) diff --git a/src/proteus/data/__init__.py b/src/proteus/data/__init__.py index 7772eedba..651cada2f 100644 --- a/src/proteus/data/__init__.py +++ b/src/proteus/data/__init__.py @@ -164,12 +164,12 @@ def _dataset(key: str): try: from fwl_io.manifest import shared_manifest_path - - shared = {ds.key: ds for ds in load_manifest(shared_manifest_path())} except ImportError as exc: raise RuntimeError( f'the installed fwl-io ships no shared manifest; upgrade to fwl-io>={FWL_IO_FLOOR}.' ) from exc + try: + shared = {ds.key: ds for ds in load_manifest(shared_manifest_path())} except ValueError as exc: if _fwl_io_derives_the_location(): raise diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index 4b2ea742d..98a3e33f6 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -934,30 +934,29 @@ def _try_spider( # stops the run rather than switching to other curves. Constant-property # runs pass no phase boundaries to SPIDER, so they need none. liquidus_ps = solidus_ps = None - if _use_const: - pass - elif dirs.get('spider_liquidus_ps') and os.path.isfile(dirs['spider_liquidus_ps']): - liquidus_ps = dirs['spider_liquidus_ps'] - solidus_ps = dirs['spider_solidus_ps'] - log.info( - 'Using P-S phase boundaries from %s (interior_struct.module=%s, melting_dir=%s)', - os.path.dirname(liquidus_ps), - config.interior_struct.module, - config.interior_struct.melting_dir, - ) - else: - mc_dir = os.path.join(MELTING_CURVES_DIR, config.interior_struct.melting_dir) - liquidus_ps = os.path.join(mc_dir, 'liquidus_P-S.dat') - solidus_ps = os.path.join(mc_dir, 'solidus_P-S.dat') - missing_ps = [p for p in (solidus_ps, liquidus_ps) if not os.path.isfile(p)] - if missing_ps: - raise FileNotFoundError( - f'interior_struct.melting_dir={config.interior_struct.melting_dir!r} is ' - f'configured but its P-S melting curves are missing: ' - f'{", ".join(missing_ps)}. Generate them with ' - "'python tools/generate_spider_phase_boundaries.py --melting-dir " - f"{config.interior_struct.melting_dir}'." + if not _use_const: + if dirs.get('spider_liquidus_ps') and os.path.isfile(dirs['spider_liquidus_ps']): + liquidus_ps = dirs['spider_liquidus_ps'] + solidus_ps = dirs['spider_solidus_ps'] + log.info( + 'Using P-S phase boundaries from %s (interior_struct.module=%s, melting_dir=%s)', + os.path.dirname(liquidus_ps), + config.interior_struct.module, + config.interior_struct.melting_dir, ) + else: + mc_dir = os.path.join(MELTING_CURVES_DIR, config.interior_struct.melting_dir) + liquidus_ps = os.path.join(mc_dir, 'liquidus_P-S.dat') + solidus_ps = os.path.join(mc_dir, 'solidus_P-S.dat') + missing_ps = [p for p in (solidus_ps, liquidus_ps) if not os.path.isfile(p)] + if missing_ps: + raise FileNotFoundError( + f'interior_struct.melting_dir={config.interior_struct.melting_dir!r} is ' + f'configured but its P-S melting curves are missing: ' + f'{", ".join(missing_ps)}. Generate them with ' + "'python tools/generate_spider_phase_boundaries.py --melting-dir " + f"{config.interior_struct.melting_dir}'." + ) # EOS table setup: skip entirely when const_properties is active # (SPIDER creates a dummy single-phase EOS internally, no tables needed) diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index fe42c2147..6bb7e06f4 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1527,10 +1527,11 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): Exception Any other error from MORS (a stale fwl-io, a bug), unchanged. """ - from fwl_io import DownloadError from mors import data as mors_data fetch_errors = _fetch_errors() + from fwl_io import DownloadError + log.debug(f'Downloading stellar evolution tracks: {track}') # Try MORS download first diff --git a/tests/data/test_manifest.py b/tests/data/test_manifest.py index 67ff4111e..b38579a9c 100644 --- a/tests/data/test_manifest.py +++ b/tests/data/test_manifest.py @@ -440,6 +440,28 @@ def test_fwl_io_without_shared_manifest_is_named_as_the_stale_side(monkeypatch): assert _dataset(MASS_RADIUS_ZENG_2019).key == MASS_RADIUS_ZENG_2019 +def test_import_error_inside_the_shared_manifest_load_is_not_relabelled(monkeypatch): + """Only a missing shared_manifest_path is reported as a stale fwl-io.""" + import fwl_io + from fwl_io.manifest import shared_manifest_path + + real_load = fwl_io.load_manifest + + def _load(path): + if path == shared_manifest_path(): + raise ImportError('a broken dependency') + return real_load(path) + + monkeypatch.setattr(fwl_io, 'load_manifest', _load) + + with pytest.raises(ImportError, match='a broken dependency') as raised: + _dataset(STELLAR_SPECTRA_SOLAR) + + assert raised.type is ImportError + # Discrimination: the PROTEUS manifest still loads through the same wrapper. + assert _dataset(MASS_RADIUS_ZENG_2019).key == MASS_RADIUS_ZENG_2019 + + def test_unknown_key_message_survives_missing_package_metadata(monkeypatch): """The KeyError is raised even when fwl-io's version cannot be read.""" import importlib.metadata diff --git a/tests/interior_energetics/test_spider.py b/tests/interior_energetics/test_spider.py index 718ad5540..e177a1a1d 100644 --- a/tests/interior_energetics/test_spider.py +++ b/tests/interior_energetics/test_spider.py @@ -1371,7 +1371,9 @@ def test_try_spider_missing_configured_curve_ignores_bundled_curves(tmp_path): return_value=3000.0, ), ): - with pytest.raises(FileNotFoundError, match="melting_dir='Wolf_Bower\\+2018'"): + with pytest.raises( + FileNotFoundError, match="melting_dir='Wolf_Bower\\+2018'" + ) as raised: _try_spider( dirs, config, @@ -1382,6 +1384,9 @@ def test_try_spider_missing_configured_curve_ignores_bundled_curves(tmp_path): atol_sf=1.0, ) mock_run.assert_not_called() + # P-S curves come from the generate tool; the fetch command only gives P-T files. + assert 'tools/generate_spider_phase_boundaries.py' in str(raised.value) + assert 'proteus get interiordata' not in str(raised.value) @pytest.mark.unit diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index b9d2c4b32..82a469d11 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -4498,6 +4498,7 @@ def test_provide_spider_eos_tables_missing_configured_curve_raises(tmp_path): from proteus.interior_energetics.wrapper import ( _SPIDER_EOS_PHASE_FILES, + MissingMeltingCurveError, _provide_spider_eos_tables, ) @@ -4516,8 +4517,10 @@ def test_provide_spider_eos_tables_missing_configured_curve_raises(tmp_path): ): _provide_spider_eos_tables(config, str(tmp_path), dirs) - # The message names the P-T files that are missing. + # The message names the P-T files that are missing and the fetch command. + assert raised.type is MissingMeltingCurveError assert 'monteux_minus_600' in str(raised.value) + assert 'proteus get interiordata --config-path' in str(raised.value) assert 'spider_solidus_ps' not in dirs diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 231b177c9..1c8588360 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -2228,6 +2228,35 @@ def _stale(): assert calls == [] +@pytest.mark.unit +def test_download_stellar_tracks_on_an_old_fwl_io_asks_for_the_upgrade(monkeypatch): + """An fwl-io without the error classes gives the upgrade message, not an ImportError. + + MORS is not called, so its own failure cannot hide the stale fwl-io. + """ + import sys + import types + + import fwl_io + + from proteus.utils import data as data_mod + + calls = [] + fake_mors_data = types.ModuleType('mors.data') + fake_mors_data.DownloadEvolutionTracks = lambda track: calls.append(track) + fake_mors = types.ModuleType('mors') + fake_mors.data = fake_mors_data + monkeypatch.setitem(sys.modules, 'mors', fake_mors) + monkeypatch.setitem(sys.modules, 'mors.data', fake_mors_data) + monkeypatch.delattr(fwl_io, 'DownloadError') + + with pytest.raises(RuntimeError, match='upgrade to fwl-io') as raised: + data_mod.download_stellar_tracks('Spada') + + assert isinstance(raised.value.__cause__, ImportError) + assert calls == [] + + @pytest.mark.unit def test_download_stellar_tracks_passes_non_download_errors(monkeypatch): """An error from MORS that is not a failed download propagates unchanged. @@ -5986,6 +6015,7 @@ def bad_get_osf(osf_id): data_mod.download_stellar_tracks('Spada', use_osf_fallback=True) # Not wrapped a second time by the handler of the OSF setup. assert 'OSF fallback error' not in str(raised.value) + assert 'MORS HTTP 503' in str(raised.value.__cause__) # Discrimination: get_osf was actually called for each candidate # OSF project, so the per-project except path fired (not an outer # short-circuit). From c2776225e53853662b8b9efa5aa7d11f62b5486a Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 01:00:46 +0200 Subject: [PATCH 16/76] Require melting_dir without PALEOS tables, warn when PALEOS wins Without a PALEOS table set, SPIDER and Aragog take their solidus and liquidus from interior_struct.melting_dir. When it was unset, _provide_spider_eos_tables used whichever curves it found on disk (the fetched Wolf and Bower set or the SPIDER bundle), and _try_spider failed with a TypeError. Both now raise MissingMeltingCurveError naming melting_dir; a SPIDER run with constant properties reads no curves and is exempt. This matches the rule aragog.py already applies. With module = "zalmoxis" and a PALEOS mantle EOS the curves are derived from PALEOS, so a configured melting_dir is not read. The config now logs one warning when it is set in that case. MissingMeltingCurveError moves to interior_energetics.common so spider.py raises the same type as the wrapper for a missing P-S curve. The resolution tests of _provide_spider_eos_tables now run with a configured curve, and the docs describe both rules. --- docs/How-to/usage_initial_conditions.md | 6 +- docs/Reference/config/config_schema.json | 2 +- docs/Reference/config/interior.md | 2 +- docs/Reference/data.md | 8 ++- src/proteus/config/_struct.py | 21 +++++- src/proteus/interior_energetics/common.py | 8 +++ src/proteus/interior_energetics/spider.py | 14 +++- src/proteus/interior_energetics/wrapper.py | 17 +++-- tests/config/test_struct.py | 24 +++++++ tests/interior_energetics/test_spider.py | 36 +++++++++- tests/interior_energetics/test_wrapper.py | 79 ++++++++++++++++++++-- 11 files changed, 195 insertions(+), 22 deletions(-) diff --git a/docs/How-to/usage_initial_conditions.md b/docs/How-to/usage_initial_conditions.md index 244e512a1..a254d2c5e 100644 --- a/docs/How-to/usage_initial_conditions.md +++ b/docs/How-to/usage_initial_conditions.md @@ -124,9 +124,9 @@ liquidus at the binding depth. `interior_struct.melting_dir` is not used, and Zalmoxis must be installed. - `"spider"`, or `"zalmoxis"` and `"dummy"` when no PALEOS table set is - generated: the tables come from FWL_DATA or the SPIDER lookup data, and - when `interior_struct.melting_dir` is set PROTEUS derives - `liquidus_P-S.dat` from that P-T curve. + generated: the tables come from FWL_DATA or the SPIDER lookup data, + `interior_struct.melting_dir` must be set (the run stops when it is + unset), and PROTEUS derives `liquidus_P-S.dat` from that P-T curve. With `"zalmoxis"`, the structure solve also anchors its temperature profile on a P-T adiabat that is `delta_T_super` above the P-T liquidus, while the diff --git a/docs/Reference/config/config_schema.json b/docs/Reference/config/config_schema.json index 1d569babb..6003f02fe 100644 --- a/docs/Reference/config/config_schema.json +++ b/docs/Reference/config/config_schema.json @@ -4956,7 +4956,7 @@ "default": "none", "choices": null, "bounds": null, - "description": "Melting curve folder name in FWL_DATA, for the SPIDER structure module.", + "description": "Melting curve name in FWL_DATA. Required for the SPIDER structure module and for any run without a PALEOS table set; not read with module = 'zalmoxis' and a PALEOS mantle EOS.", "doc_source": "attributes", "group_order": 0, "group_position": 0, diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index 4ace19c9e..45e714c00 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -26,7 +26,7 @@ with an equation of state (EOS). | `module` | str or none | `"zalmoxis"` | Module for solving the planet's interior structure. Choices: `none`, `"dummy"`, `"spider"`, `"zalmoxis"`. | | `core_density` | float | str | `"self"` | Density of the planet's core \[kg m-3\]. Set to 'self' for self-consistent calculation by Zalmoxis (requires module = 'zalmoxis'). | | `core_heatcap` | float | str | `"self"` | Specific heat capacity of the planet's core \[J kg-1 K-1\]. Set to 'self' for self-consistent calculation by Zalmoxis (requires module = 'zalmoxis'). | -| `melting_dir` | str or none | `none` | Melting curve folder name in FWL_DATA, for the SPIDER structure module. | +| `melting_dir` | str or none | `none` | Melting curve name in FWL_DATA. Required for the SPIDER structure module and for any run without a PALEOS table set; not read with module = 'zalmoxis' and a PALEOS mantle EOS. | | `eos_dir` | str or none | `none` | EOS folder name in FWL_DATA, for the SPIDER structure module. | diff --git a/docs/Reference/data.md b/docs/Reference/data.md index dc8b7a7c3..25bd02d3e 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -273,8 +273,12 @@ precedence over the fetched copy; see [Melting curves](melting_curves.md) for the full list of parametrizations and how to generate them. When SPIDER or Aragog read `melting_dir`, a configured curve whose files are missing stops the run with an error naming the missing files instead of switching to other curves. -With a PALEOS mantle equation of state, the curves are derived from the PALEOS -liquidus and `melting_dir` is not read. +With `interior_struct.module = "zalmoxis"` and a PALEOS mantle equation of +state, the curves are derived from the PALEOS liquidus and `melting_dir` is not +read; a configured `melting_dir` then gives one warning when the configuration +is loaded. Without a PALEOS table set, `melting_dir` must be set: SPIDER and +Aragog stop with an error when it is unset, rather than use whichever curves are +on disk. The Wolf and Bower (2018) pressure-entropy lookup table that SPIDER and Aragog read for `eos_dir = "WolfBower2018_MgSiO3"` is fetched the same way, diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index 9cb43dc28..2e13d047b 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -320,8 +320,9 @@ class Struct: Specific heat capacity of the planet's core [J kg-1 K-1]. Set to 'self' for self-consistent calculation by Zalmoxis (requires module = 'zalmoxis'). melting_dir: str - Melting curve folder name in FWL_DATA, for the SPIDER structure - module. + Melting curve name in FWL_DATA. Required for the SPIDER structure + module and for any run without a PALEOS table set; not read with + module = 'zalmoxis' and a PALEOS mantle EOS. eos_dir: str EOS folder name in FWL_DATA, for the SPIDER structure module. """ @@ -366,6 +367,22 @@ def __attrs_post_init__(self): f'`{param_name}` must be "self" or a positive number, got {val!r}' ) + # Zalmoxis with a PALEOS mantle derives the curves from PALEOS + if ( + self.module == 'zalmoxis' + and self.melting_dir is not None + and self.zalmoxis is not None + and self.zalmoxis.mantle_eos.startswith(PALEOS_EOS_PREFIXES) + ): + import logging + + logging.getLogger('fwl.' + __name__).warning( + 'interior_struct.melting_dir=%r is not read: with module = "zalmoxis" and ' + 'the PALEOS mantle EOS %s, the solidus and liquidus are derived from PALEOS.', + self.melting_dir, + self.zalmoxis.mantle_eos, + ) + # melting_dir and eos_dir: required for the spider struct module # (Zalmoxis and dummy derive EOS from their own config) if self.module == 'spider': diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index d45c35960..166faa3e2 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -206,6 +206,14 @@ def _verify_initial_entropy( _TABLE_SUPERLIQ_N_BISECT = 60 +class MissingMeltingCurveError(FileNotFoundError): + """The melting curves the run must read are not configured or not on disk. + + Raised when ``interior_struct.melting_dir`` names curves whose files are + missing, or is unset while no PALEOS table set provides the curves. + """ + + class InitialConditionError(RuntimeError): """The requested initial condition does not exist for this planet and EOS. diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index 98a3e33f6..b3c7e5bfd 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -13,7 +13,11 @@ import pandas as pd from scipy.interpolate import RegularGridInterpolator -from proteus.interior_energetics.common import Interior_t, get_file_tides +from proteus.interior_energetics.common import ( + Interior_t, + MissingMeltingCurveError, + get_file_tides, +) from proteus.interior_energetics.timestep import next_step from proteus.utils.constants import radnuc_data from proteus.utils.data import find_lookup_table_dir @@ -944,13 +948,19 @@ def _try_spider( config.interior_struct.module, config.interior_struct.melting_dir, ) + elif config.interior_struct.melting_dir is None: + raise MissingMeltingCurveError( + 'interior_struct.melting_dir is not set and no PALEOS table set provides ' + 'the P-S melting curves. Set melting_dir to a melting curve name (e.g. ' + '"Monteux-600").' + ) else: mc_dir = os.path.join(MELTING_CURVES_DIR, config.interior_struct.melting_dir) liquidus_ps = os.path.join(mc_dir, 'liquidus_P-S.dat') solidus_ps = os.path.join(mc_dir, 'solidus_P-S.dat') missing_ps = [p for p in (solidus_ps, liquidus_ps) if not os.path.isfile(p)] if missing_ps: - raise FileNotFoundError( + raise MissingMeltingCurveError( f'interior_struct.melting_dir={config.interior_struct.melting_dir!r} is ' f'configured but its P-S melting curves are missing: ' f'{", ".join(missing_ps)}. Generate them with ' diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 756c79b35..d6187989a 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -17,6 +17,7 @@ ANCHOR_PASSTHROUGH_ERRORS, InitialConditionError, Interior_t, + MissingMeltingCurveError, ) from proteus.outgas.wrapper import calc_target_elemental_inventories from proteus.utils.constants import M_earth, R_earth, const_G, noble_gases, vol_element_list @@ -641,10 +642,6 @@ def _is_spider_ps_format(path: str) -> bool: return len(parts) >= 2 and parts[0] == '#' and parts[1] == '5' -class MissingMeltingCurveError(FileNotFoundError): - """The configured ``interior_struct.melting_dir`` has no P-T curve files.""" - - def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: """Ensure that Aragog and SPIDER can find a complete P-S lookup set. @@ -686,7 +683,8 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: When ``interior_struct.melting_dir`` is set, the two P-S melting curves are derived from its P-T files in every case above, and missing P-T files raise ``MissingMeltingCurveError`` instead of leaving the curves of the - source. This helper is not called when Zalmoxis generates a PALEOS table + source. An unset melting_dir raises ``MissingMeltingCurveError`` too, + except for a SPIDER run with constant properties, which reads no curves. This helper is not called when Zalmoxis generates a PALEOS table set; those runs use the PALEOS-derived curves and do not read melting_dir. Side effects: sets ``dirs['spider_eos_dir']``, @@ -703,6 +701,15 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: # `melting_dir = "PALEOS-Fei2021"` using the configured P-S curves # at runtime instead of the WB+2018 curves. melting_dir = getattr(config.interior_struct, 'melting_dir', None) + energetics = getattr(config, 'interior_energetics', None) + if melting_dir is None and getattr(energetics, 'const_properties', False) is not True: + # Without melting_dir the curves would be whatever set is on disk. + raise MissingMeltingCurveError( + 'interior_struct.melting_dir is not set and no PALEOS table set provides ' + 'the melting curves. Set melting_dir to a melting curve name (e.g. ' + '"Monteux-600"), or use a PALEOS mantle EOS with interior_struct.module ' + '= "zalmoxis".' + ) derive_melting = melting_dir is not None if derive_melting: from proteus.utils.data import resolve_melting_curve_files diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index aec162857..2944d9970 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -241,3 +241,27 @@ def test_warning_tracks_the_factor_not_only_the_eos(self, caplog): # Paired positive: the same EOS at 0.8 fires, so silence at 1.0 is the # factor guard rather than the EOS being exempt. assert self._warns(caplog, 'WolfBower2018:MgSiO3', mzf=0.8) + + +class TestMeltingDirWithPaleos: + """melting_dir is not read with Zalmoxis and a PALEOS mantle EOS.""" + + @staticmethod + def _warned(caplog, **kwargs): + caplog.clear() + with caplog.at_level(logging.WARNING, logger='fwl.proteus.config._struct'): + Struct(**kwargs) + return any('is not read' in r.getMessage() for r in caplog.records) + + def test_warning_for_melting_dir_with_paleos_mantle(self, caplog): + """The PALEOS curves are used, so a configured melting_dir gives one warning.""" + assert self._warned(caplog, module='zalmoxis', melting_dir='Monteux-600') + assert sum('is not read' in r.getMessage() for r in caplog.records) == 1 + + def test_no_warning_where_melting_dir_is_read(self, caplog): + """Discrimination: a file-curve mantle EOS or an unset melting_dir stays silent.""" + wb = Zalmoxis(mantle_eos='WolfBower2018:MgSiO3') + assert not self._warned( + caplog, module='zalmoxis', zalmoxis=wb, melting_dir='Monteux-600' + ) + assert not self._warned(caplog, module='zalmoxis', melting_dir=None) diff --git a/tests/interior_energetics/test_spider.py b/tests/interior_energetics/test_spider.py index e177a1a1d..81a23737d 100644 --- a/tests/interior_energetics/test_spider.py +++ b/tests/interior_energetics/test_spider.py @@ -1354,6 +1354,7 @@ def test_try_spider_missing_configured_curve_ignores_bundled_curves(tmp_path): Falling back to the bundled Andrault/Hirschmann curves would run a different solidus and liquidus than the configured melting_dir. """ + from proteus.interior_energetics.common import MissingMeltingCurveError from proteus.interior_energetics.spider import _try_spider dirs, config, hf_row, eos_base, _, _ = _setup_spider_env(tmp_path) @@ -1372,7 +1373,7 @@ def test_try_spider_missing_configured_curve_ignores_bundled_curves(tmp_path): ), ): with pytest.raises( - FileNotFoundError, match="melting_dir='Wolf_Bower\\+2018'" + MissingMeltingCurveError, match="melting_dir='Wolf_Bower\\+2018'" ) as raised: _try_spider( dirs, @@ -1389,6 +1390,39 @@ def test_try_spider_missing_configured_curve_ignores_bundled_curves(tmp_path): assert 'proteus get interiordata' not in str(raised.value) +@pytest.mark.unit +def test_try_spider_unset_melting_dir_raises(tmp_path): + """Without melting_dir and derived P-S curves, SPIDER stops with a named error.""" + from proteus.interior_energetics.common import MissingMeltingCurveError + from proteus.interior_energetics.spider import _try_spider + + dirs, config, hf_row, eos_base, _, _ = _setup_spider_env(tmp_path) + config.interior_struct.melting_dir = None + dirs.pop('spider_liquidus_ps', None) + + with ( + patch('proteus.interior_energetics.spider.EOS_DYNAMIC_DIR', eos_base), + patch('proteus.interior_energetics.spider.MELTING_CURVES_DIR', str(tmp_path / 'none')), + patch('proteus.interior_energetics.spider.sp.run') as mock_run, + patch( + 'proteus.interior_energetics.common.compute_initial_entropy', + return_value=3000.0, + ), + ): + with pytest.raises(MissingMeltingCurveError, match='melting_dir is not set') as raised: + _try_spider( + dirs, + config, + IC_INTERIOR=1, + hf_all=None, + hf_row=hf_row, + step_sf=1.0, + atol_sf=1.0, + ) + mock_run.assert_not_called() + assert 'Monteux-600' in str(raised.value) + + @pytest.mark.unit def test_try_spider_const_properties_needs_no_melting_curves(tmp_path): """A constant-property run passes no phase boundaries, so missing curves do not stop it.""" diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 82a469d11..f2905e510 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -3224,8 +3224,69 @@ def _write_complete_ps_eos_dir(target_dir): ) +def _configured_melting_curve(tmp_path, monkeypatch): + """Point melting_dir at synthetic P-T files and record the P-S override calls.""" + from proteus.interior_energetics import wrapper as wrapper_mod + from proteus.utils import data as data_mod + + pt_dir = tmp_path / 'melting_pt' + pt_dir.mkdir() + sol, liq = pt_dir / 'solidus_P-T.dat', pt_dir / 'liquidus_P-T.dat' + sol.write_text('# solidus\n') + liq.write_text('# liquidus\n') + monkeypatch.setattr(data_mod, 'resolve_melting_curve_files', lambda name: (sol, liq)) + calls = [] + monkeypatch.setattr( + wrapper_mod, + '_override_melting_curves_from_pt', + lambda eos_dir, sol_pt, liq_pt, label_prefix: calls.append((eos_dir, sol_pt, liq_pt)), + ) + return calls, str(sol), str(liq) + + @pytest.mark.unit -def test_provide_spider_eos_tables_reuses_already_populated_dir(tmp_path): +def test_provide_spider_eos_tables_unset_melting_dir_raises(tmp_path): + """Without melting_dir and a PALEOS table set, the curves would depend on the disk. + + A complete SPIDER bundle is present, so a fallback would succeed; the helper + must stop instead. A constant-property SPIDER run reads no curves and passes. + """ + from types import SimpleNamespace + from unittest.mock import patch as _patch + + from proteus.interior_energetics.wrapper import ( + _SPIDER_EOS_PHASE_FILES, + MissingMeltingCurveError, + _provide_spider_eos_tables, + ) + + spider_bundle = tmp_path / 'SPIDER' / 'lookup_data' / '1TPa-dK09-elec-free' + spider_bundle.mkdir(parents=True) + for f in _SPIDER_EOS_PHASE_FILES: + _write_synthetic_ps_table(spider_bundle / f, NX=3, NY=4) + (spider_bundle / 'solidus_A11_H13.dat').write_text('# bundled solidus\n') + (spider_bundle / 'liquidus_A11_H13.dat').write_text('# bundled liquidus\n') + config = SimpleNamespace( + interior_struct=SimpleNamespace(melting_dir=None), + interior_energetics=SimpleNamespace(const_properties=False), + ) + dirs = {'spider': str(tmp_path / 'SPIDER')} + + with _patch('proteus.utils.data.GetFWLData', return_value=tmp_path / 'fwl_empty'): + with pytest.raises(MissingMeltingCurveError, match='melting_dir is not set') as raised: + _provide_spider_eos_tables(config, str(tmp_path), dirs) + assert 'spider_liquidus_ps' not in dirs + + # Discrimination: constant properties need no curves, so the tables are provided. + config.interior_energetics.const_properties = True + _provide_spider_eos_tables(config, str(tmp_path), dirs) + + assert 'Monteux-600' in str(raised.value) + assert dirs['spider_eos_dir'] == str(tmp_path / 'data' / 'spider_eos') + + +@pytest.mark.unit +def test_provide_spider_eos_tables_reuses_already_populated_dir(tmp_path, monkeypatch): """When dirs['spider_eos_dir'] already holds all 12 expected files, the helper short-circuits with a debug log and sets the melting-curve paths without re-copying anything. @@ -3237,13 +3298,17 @@ def test_provide_spider_eos_tables_reuses_already_populated_dir(tmp_path): eos_dir = tmp_path / 'preexisting_eos' _write_complete_ps_eos_dir(str(eos_dir)) + calls, sol_pt, liq_pt = _configured_melting_curve(tmp_path, monkeypatch) config = SimpleNamespace( - interior_struct=SimpleNamespace(melting_dir=None), + interior_struct=SimpleNamespace(melting_dir='Monteux-600'), ) dirs = {'spider_eos_dir': str(eos_dir)} _provide_spider_eos_tables(config, str(tmp_path), dirs) + # The configured curves replace the P-S curves of the reused set. + assert calls == [(str(eos_dir), sol_pt, liq_pt)] + # The reuse path sets the two melting-curve paths. assert dirs['spider_solidus_ps'] == str(eos_dir / 'solidus_P-S.dat') assert dirs['spider_liquidus_ps'] == str(eos_dir / 'liquidus_P-S.dat') @@ -3267,8 +3332,9 @@ def test_provide_spider_eos_tables_hard_failure_when_no_source(tmp_path, monkeyp from proteus.interior_energetics.wrapper import _provide_spider_eos_tables + _configured_melting_curve(tmp_path, monkeypatch) config = SimpleNamespace( - interior_struct=SimpleNamespace(melting_dir=None), + interior_struct=SimpleNamespace(melting_dir='Monteux-600'), ) dirs = {'spider': str(tmp_path / 'no_spider_submodule')} @@ -4440,7 +4506,7 @@ def test_override_melting_curves_from_pt_clip_warning_when_t_out_of_range(tmp_pa @pytest.mark.unit -def test_provide_spider_eos_tables_spider_submodule_fallback(tmp_path): +def test_provide_spider_eos_tables_spider_submodule_fallback(tmp_path, monkeypatch): """When FWL_DATA is empty but the SPIDER submodule ships lookup_data with the legacy *_A11_H13 filenames, the helper copies them under the canonical *_P-S names. @@ -4453,7 +4519,8 @@ def test_provide_spider_eos_tables_spider_submodule_fallback(tmp_path): _provide_spider_eos_tables, ) - config = SimpleNamespace(interior_struct=SimpleNamespace(melting_dir=None)) + calls, sol_pt, liq_pt = _configured_melting_curve(tmp_path, monkeypatch) + config = SimpleNamespace(interior_struct=SimpleNamespace(melting_dir='Monteux-600')) # SPIDER submodule lookup_data with the legacy melting-curve names. spider_root = tmp_path / 'SPIDER' @@ -4484,6 +4551,8 @@ def test_provide_spider_eos_tables_spider_submodule_fallback(tmp_path): # dirs updated with the new paths. assert dirs['spider_eos_dir'] == str(target) assert dirs['spider_solidus_ps'] == str(target / 'solidus_P-S.dat') + # The configured curves replace the copied bundle curves. + assert calls == [(str(target), sol_pt, liq_pt)] @pytest.mark.unit From 88526bd86d3fad76b9067e9199b339e27271a691 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 01:19:13 +0200 Subject: [PATCH 17/76] Check melting_dir only where the curves are produced _provide_spider_eos_tables now reuses a complete set already placed for the run before it asks for melting_dir, so an unset melting_dir stops the run only when the curves would come from FWL_DATA or the SPIDER bundle. The constant-property exemption applies to SPIDER only, since Aragog reads the curves in that mode too, and both error messages name the configured mantle EOS. The config warning skips PALEOS mixture strings: generate_spider_tables resolves only single-component entries, so a mixture gets no PALEOS table set and melting_dir is read. The docs name the SPIDER exemption and describe the warning as given when the configuration is loaded. --- docs/How-to/usage_initial_conditions.md | 3 +- docs/Reference/config/config_schema.json | 2 +- docs/Reference/config/interior.md | 2 +- docs/Reference/data.md | 9 +++--- src/proteus/config/_struct.py | 10 ++++--- src/proteus/interior_energetics/spider.py | 7 +++-- src/proteus/interior_energetics/wrapper.py | 29 +++++++++++------- tests/config/test_struct.py | 11 +++++++ tests/interior_energetics/test_wrapper.py | 34 ++++++++++++++++++---- 9 files changed, 77 insertions(+), 30 deletions(-) diff --git a/docs/How-to/usage_initial_conditions.md b/docs/How-to/usage_initial_conditions.md index a254d2c5e..3f4d31ccf 100644 --- a/docs/How-to/usage_initial_conditions.md +++ b/docs/How-to/usage_initial_conditions.md @@ -126,7 +126,8 @@ liquidus at the binding depth. - `"spider"`, or `"zalmoxis"` and `"dummy"` when no PALEOS table set is generated: the tables come from FWL_DATA or the SPIDER lookup data, `interior_struct.melting_dir` must be set (the run stops when it is - unset), and PROTEUS derives `liquidus_P-S.dat` from that P-T curve. + unset, except for SPIDER with `const_properties = true`), and PROTEUS + derives `liquidus_P-S.dat` from that P-T curve. With `"zalmoxis"`, the structure solve also anchors its temperature profile on a P-T adiabat that is `delta_T_super` above the P-T liquidus, while the diff --git a/docs/Reference/config/config_schema.json b/docs/Reference/config/config_schema.json index 6003f02fe..f38122695 100644 --- a/docs/Reference/config/config_schema.json +++ b/docs/Reference/config/config_schema.json @@ -4956,7 +4956,7 @@ "default": "none", "choices": null, "bounds": null, - "description": "Melting curve name in FWL_DATA. Required for the SPIDER structure module and for any run without a PALEOS table set; not read with module = 'zalmoxis' and a PALEOS mantle EOS.", + "description": "Melting curve name in FWL_DATA. Required for the SPIDER structure module and for any run without a PALEOS table set; not read with module = 'zalmoxis' and a single PALEOS mantle EOS.", "doc_source": "attributes", "group_order": 0, "group_position": 0, diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index 45e714c00..636c71f7b 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -26,7 +26,7 @@ with an equation of state (EOS). | `module` | str or none | `"zalmoxis"` | Module for solving the planet's interior structure. Choices: `none`, `"dummy"`, `"spider"`, `"zalmoxis"`. | | `core_density` | float | str | `"self"` | Density of the planet's core \[kg m-3\]. Set to 'self' for self-consistent calculation by Zalmoxis (requires module = 'zalmoxis'). | | `core_heatcap` | float | str | `"self"` | Specific heat capacity of the planet's core \[J kg-1 K-1\]. Set to 'self' for self-consistent calculation by Zalmoxis (requires module = 'zalmoxis'). | -| `melting_dir` | str or none | `none` | Melting curve name in FWL_DATA. Required for the SPIDER structure module and for any run without a PALEOS table set; not read with module = 'zalmoxis' and a PALEOS mantle EOS. | +| `melting_dir` | str or none | `none` | Melting curve name in FWL_DATA. Required for the SPIDER structure module and for any run without a PALEOS table set; not read with module = 'zalmoxis' and a single PALEOS mantle EOS. | | `eos_dir` | str or none | `none` | EOS folder name in FWL_DATA, for the SPIDER structure module. | diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 25bd02d3e..28d1d36db 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -273,12 +273,13 @@ precedence over the fetched copy; see [Melting curves](melting_curves.md) for the full list of parametrizations and how to generate them. When SPIDER or Aragog read `melting_dir`, a configured curve whose files are missing stops the run with an error naming the missing files instead of switching to other curves. -With `interior_struct.module = "zalmoxis"` and a PALEOS mantle equation of -state, the curves are derived from the PALEOS liquidus and `melting_dir` is not -read; a configured `melting_dir` then gives one warning when the configuration +With `interior_struct.module = "zalmoxis"` and a single PALEOS mantle equation +of state, the curves are derived from the PALEOS liquidus and `melting_dir` is +not read; a configured `melting_dir` then gives a warning when the configuration is loaded. Without a PALEOS table set, `melting_dir` must be set: SPIDER and Aragog stop with an error when it is unset, rather than use whichever curves are -on disk. +on disk. A SPIDER run with `const_properties = true` reads no melting curves and +is exempt. The Wolf and Bower (2018) pressure-entropy lookup table that SPIDER and Aragog read for `eos_dir = "WolfBower2018_MgSiO3"` is fetched the same way, diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index 2e13d047b..d8b6d8046 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -322,7 +322,7 @@ class Struct: melting_dir: str Melting curve name in FWL_DATA. Required for the SPIDER structure module and for any run without a PALEOS table set; not read with - module = 'zalmoxis' and a PALEOS mantle EOS. + module = 'zalmoxis' and a single PALEOS mantle EOS. eos_dir: str EOS folder name in FWL_DATA, for the SPIDER structure module. """ @@ -367,16 +367,18 @@ def __attrs_post_init__(self): f'`{param_name}` must be "self" or a positive number, got {val!r}' ) - # Zalmoxis with a PALEOS mantle derives the curves from PALEOS + # Zalmoxis with a single PALEOS mantle EOS derives the curves from PALEOS; + # a mixture string gets no generated table set, so melting_dir is read. if ( self.module == 'zalmoxis' and self.melting_dir is not None and self.zalmoxis is not None and self.zalmoxis.mantle_eos.startswith(PALEOS_EOS_PREFIXES) + and '+' not in self.zalmoxis.mantle_eos ): - import logging + import logging as _logging - logging.getLogger('fwl.' + __name__).warning( + _logging.getLogger('fwl.' + __name__).warning( 'interior_struct.melting_dir=%r is not read: with module = "zalmoxis" and ' 'the PALEOS mantle EOS %s, the solidus and liquidus are derived from PALEOS.', self.melting_dir, diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index b3c7e5bfd..d19b35b74 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -949,10 +949,11 @@ def _try_spider( config.interior_struct.melting_dir, ) elif config.interior_struct.melting_dir is None: + zalmoxis_cfg = getattr(config.interior_struct, 'zalmoxis', None) raise MissingMeltingCurveError( - 'interior_struct.melting_dir is not set and no PALEOS table set provides ' - 'the P-S melting curves. Set melting_dir to a melting curve name (e.g. ' - '"Monteux-600").' + 'interior_struct.melting_dir is not set and no PALEOS table set was ' + f'generated (mantle EOS {getattr(zalmoxis_cfg, "mantle_eos", None)!r}). ' + 'Set melting_dir to a melting curve name (e.g. "Monteux-600").' ) else: mc_dir = os.path.join(MELTING_CURVES_DIR, config.interior_struct.melting_dir) diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index d6187989a..6fda617a3 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -683,8 +683,10 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: When ``interior_struct.melting_dir`` is set, the two P-S melting curves are derived from its P-T files in every case above, and missing P-T files raise ``MissingMeltingCurveError`` instead of leaving the curves of the - source. An unset melting_dir raises ``MissingMeltingCurveError`` too, - except for a SPIDER run with constant properties, which reads no curves. This helper is not called when Zalmoxis generates a PALEOS table + source. When melting_dir is unset and case 1 does not apply, the helper + raises ``MissingMeltingCurveError`` rather than take the curves of the + source; a SPIDER run with constant properties reads no curves and is + exempt. This helper is not called when Zalmoxis generates a PALEOS table set; those runs use the PALEOS-derived curves and do not read melting_dir. Side effects: sets ``dirs['spider_eos_dir']``, @@ -701,15 +703,6 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: # `melting_dir = "PALEOS-Fei2021"` using the configured P-S curves # at runtime instead of the WB+2018 curves. melting_dir = getattr(config.interior_struct, 'melting_dir', None) - energetics = getattr(config, 'interior_energetics', None) - if melting_dir is None and getattr(energetics, 'const_properties', False) is not True: - # Without melting_dir the curves would be whatever set is on disk. - raise MissingMeltingCurveError( - 'interior_struct.melting_dir is not set and no PALEOS table set provides ' - 'the melting curves. Set melting_dir to a melting curve name (e.g. ' - '"Monteux-600"), or use a PALEOS mantle EOS with interior_struct.module ' - '= "zalmoxis".' - ) derive_melting = melting_dir is not None if derive_melting: from proteus.utils.data import resolve_melting_curve_files @@ -752,6 +745,20 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: len(missing), ) + energetics = getattr(config, 'interior_energetics', None) + const_spider = ( + getattr(energetics, 'module', None) == 'spider' + and getattr(energetics, 'const_properties', False) is True + ) + if not derive_melting and not const_spider: + # Without melting_dir the curves would be whatever set is on disk. + zalmoxis_cfg = getattr(config.interior_struct, 'zalmoxis', None) + raise MissingMeltingCurveError( + 'interior_struct.melting_dir is not set and no PALEOS table set was ' + f'generated (mantle EOS {getattr(zalmoxis_cfg, "mantle_eos", None)!r}). ' + 'Set melting_dir to a melting curve name (e.g. "Monteux-600").' + ) + os.makedirs(target_dir, exist_ok=True) # Import lazily so the helper is usable outside of a full PROTEUS diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index 2944d9970..ecebba497 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -265,3 +265,14 @@ def test_no_warning_where_melting_dir_is_read(self, caplog): caplog, module='zalmoxis', zalmoxis=wb, melting_dir='Monteux-600' ) assert not self._warned(caplog, module='zalmoxis', melting_dir=None) + + def test_no_warning_for_a_paleos_mixture(self, caplog): + """A mixture string gets no generated PALEOS table set, so melting_dir is read.""" + mix = Zalmoxis(mantle_eos='PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1') + assert not self._warned( + caplog, module='zalmoxis', zalmoxis=mix, melting_dir='Monteux-600' + ) + single = Zalmoxis(mantle_eos='PALEOS-2phase:MgSiO3') + assert self._warned( + caplog, module='zalmoxis', zalmoxis=single, melting_dir='Monteux-600' + ) diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index f2905e510..3e394c954 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -3267,24 +3267,48 @@ def test_provide_spider_eos_tables_unset_melting_dir_raises(tmp_path): (spider_bundle / 'solidus_A11_H13.dat').write_text('# bundled solidus\n') (spider_bundle / 'liquidus_A11_H13.dat').write_text('# bundled liquidus\n') config = SimpleNamespace( - interior_struct=SimpleNamespace(melting_dir=None), - interior_energetics=SimpleNamespace(const_properties=False), + interior_struct=SimpleNamespace( + melting_dir=None, zalmoxis=SimpleNamespace(mantle_eos='WolfBower2018:MgSiO3') + ), + interior_energetics=SimpleNamespace(module='aragog', const_properties=True), ) dirs = {'spider': str(tmp_path / 'SPIDER')} with _patch('proteus.utils.data.GetFWLData', return_value=tmp_path / 'fwl_empty'): + # Aragog reads the curves even with const_properties set. with pytest.raises(MissingMeltingCurveError, match='melting_dir is not set') as raised: _provide_spider_eos_tables(config, str(tmp_path), dirs) assert 'spider_liquidus_ps' not in dirs - # Discrimination: constant properties need no curves, so the tables are provided. - config.interior_energetics.const_properties = True + # Discrimination: SPIDER with constant properties needs no curves. + config.interior_energetics.module = 'spider' _provide_spider_eos_tables(config, str(tmp_path), dirs) - assert 'Monteux-600' in str(raised.value) + assert "'WolfBower2018:MgSiO3'" in str(raised.value) assert dirs['spider_eos_dir'] == str(tmp_path / 'data' / 'spider_eos') +@pytest.mark.unit +def test_provide_spider_eos_tables_reuse_needs_no_melting_dir(tmp_path): + """A complete set already placed for this run is reused without melting_dir.""" + from types import SimpleNamespace + + from proteus.interior_energetics.wrapper import _provide_spider_eos_tables + + eos_dir = tmp_path / 'preexisting_eos' + _write_complete_ps_eos_dir(str(eos_dir)) + config = SimpleNamespace( + interior_struct=SimpleNamespace(melting_dir=None), + interior_energetics=SimpleNamespace(module='aragog', const_properties=False), + ) + dirs = {'spider_eos_dir': str(eos_dir)} + + _provide_spider_eos_tables(config, str(tmp_path), dirs) + + assert dirs['spider_liquidus_ps'] == str(eos_dir / 'liquidus_P-S.dat') + assert dirs['spider_solidus_ps'] == str(eos_dir / 'solidus_P-S.dat') + + @pytest.mark.unit def test_provide_spider_eos_tables_reuses_already_populated_dir(tmp_path, monkeypatch): """When dirs['spider_eos_dir'] already holds all 12 expected files, From 3ddff99ed180a32ddca07a933cf4c880fb7a8f54 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 01:26:17 +0200 Subject: [PATCH 18/76] Keep the dummy structure off the PALEOS tables The dummy structure no longer calls generate_spider_tables. With SPIDER or Aragog energetics, it takes the P-S tables from FWL_DATA or the SPIDER lookup data, and the curves named by interior_struct.melting_dir, in every temperature mode, through _provide_spider_eos_tables. A dummy run used PALEOS tables only when an earlier Zalmoxis run had left the PALEOS files in FWL_DATA, so the curves depended on the machine, and Aragog read melting_dir while its entropy tables came from PALEOS. The start-of-run fetch gets the Wolf and Bower P-S set for the dummy structure, also when interior_struct.eos_dir is unset. If no table source exists, a RuntimeError names both modules and the fetch command. The initial-conditions guide and the dummy module page now describe this: the dummy structure reads the FWL_DATA tables, SPIDER or Aragog read melting_dir, and PALEOS is not used. Closes #905 --- docs/Explanations/dummy_modules.md | 7 ++- docs/How-to/usage_initial_conditions.md | 20 +++----- src/proteus/interior_energetics/wrapper.py | 44 ++++++----------- src/proteus/utils/data.py | 8 +-- tests/interior_energetics/test_wrapper.py | 57 ++++++++-------------- tests/utils/test_data.py | 34 +++++++++++++ 6 files changed, 84 insertions(+), 86 deletions(-) diff --git a/docs/Explanations/dummy_modules.md b/docs/Explanations/dummy_modules.md index 3c2ef03ca..83a5d0540 100644 --- a/docs/Explanations/dummy_modules.md +++ b/docs/Explanations/dummy_modules.md @@ -41,8 +41,11 @@ Earth-like mineralogy and variable iron content. The dummy structure module provides all the radial profiles that SPIDER and Aragog need as boundary conditions without running a hydrostatic -equilibrium solver or loading EOS tables. When paired with the dummy -energetics module, the entire interior is analytically specified. +equilibrium solver or loading EOS tables. SPIDER and Aragog then read their +P-S tables from FWL_DATA or the SPIDER lookup data and the melting curves +named by `interior_struct.melting_dir`; the dummy structure never uses PALEOS +tables. When paired with the dummy energetics module, the entire interior is +analytically specified. ### Interior energetics: dummy (`interior_energetics.module = 'dummy'`) diff --git a/docs/How-to/usage_initial_conditions.md b/docs/How-to/usage_initial_conditions.md index 3f4d31ccf..ef438099e 100644 --- a/docs/How-to/usage_initial_conditions.md +++ b/docs/How-to/usage_initial_conditions.md @@ -117,13 +117,12 @@ liquidus at the binding depth. the interior solver uses for its melt fraction. Where those tables come from depends on the structure module: - - `"zalmoxis"` and `"dummy"` with a PALEOS mantle - (`interior_struct.zalmoxis.mantle_eos`, default `"PALEOS:MgSiO3"`) whose - table files are present: Zalmoxis generates the tables from that EOS, - with a liquidus derived from the PALEOS (Fei et al. 2021) curve. - `interior_struct.melting_dir` is not used, and Zalmoxis must be - installed. - - `"spider"`, or `"zalmoxis"` and `"dummy"` when no PALEOS table set is + - `"zalmoxis"` with a PALEOS mantle (`interior_struct.zalmoxis.mantle_eos`, + default `"PALEOS:MgSiO3"`) whose table files are present: Zalmoxis + generates the tables from that EOS, with a liquidus derived from the + PALEOS (Fei et al. 2021) curve. `interior_struct.melting_dir` is not + used. + - `"spider"` and `"dummy"`, or `"zalmoxis"` when no PALEOS table set is generated: the tables come from FWL_DATA or the SPIDER lookup data, `interior_struct.melting_dir` must be set (the run stops when it is unset, except for SPIDER with `const_properties = true`), and PROTEUS @@ -189,13 +188,6 @@ liquidus at the binding depth. which pressures the adiabat samples in that band, so the edge, and the cap with it, can shift with the core-mantle boundary pressure. - With `"dummy"` and PALEOS-generated tables there is no anchor, so a large - `delta_T_super` can land in those filled cells. The two adiabats differ - slightly: for 1 Earth mass at `delta_T_super = 500` K (core-mantle - boundary at 103 GPa), the - P-T anchor evaluated on the P-S tables has a smallest margin of 472 K - above their liquidus, against 500 K for the initial entropy. - The superheat is checked exactly at every pressure where the margin can change slope: the table pressure nodes, the liquidus-file nodes, and the pressures where the liquidus entropy crosses a table entropy node. diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 6fda617a3..dc556f71f 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -902,8 +902,8 @@ def determine_interior_radius( # Provide P-S lookup tables for Aragog's entropy solver (and SPIDER # when it runs under this structure path). Mirrors the - # generate_spider_tables() call at the top of the zalmoxis and dummy - # structure paths. The helper resolves from FWL_DATA/Zenodo first, + # table provision at the top of the zalmoxis and dummy structure + # paths. The helper resolves from FWL_DATA/Zenodo first, # then the SPIDER submodule as a fallback. if config.interior_energetics.module in ('spider', 'aragog'): _provide_spider_eos_tables(config, outdir, dirs) @@ -1054,33 +1054,21 @@ def determine_interior_radius_with_dummy( dirs['spider_mesh'] = spider_mesh_file dirs['spider_mesh_prev'] = spider_mesh_file + '.prev' - # Generate P-S EOS tables for SPIDER/Aragog (if PALEOS) + # P-S EOS tables and melting curves for SPIDER/Aragog. The dummy structure + # never uses PALEOS tables: it reads the FWL_DATA or SPIDER set and melting_dir. if config.interior_energetics.module in ('spider', 'aragog'): - from proteus.interior_struct.zalmoxis import generate_spider_tables - - spider_tables = generate_spider_tables(config, outdir) - if spider_tables is not None: - dirs['spider_eos_dir'] = spider_tables['eos_dir'] - dirs['spider_solidus_ps'] = spider_tables['solidus_path'] - dirs['spider_liquidus_ps'] = spider_tables['liquidus_path'] - elif config.planet.temperature_mode == 'liquidus_super': - # The liquidus_super initial entropy solves on these tables. - try: - _provide_spider_eos_tables(config, outdir, dirs) - except MissingMeltingCurveError: - raise - except FileNotFoundError as exc: - raise RuntimeError( - "planet.temperature_mode='liquidus_super' with " - f"interior_struct.module='dummy' needs SPIDER/Aragog P-S EOS " - 'tables, but interior_struct.zalmoxis.mantle_eos=' - f'{config.interior_struct.zalmoxis.mantle_eos!r} gave no generated ' - 'PALEOS table set and no FWL_DATA or SPIDER lookup_data set is ' - 'available. ' - 'Provide the tables, or set planet.temperature_mode to ' - "'adiabatic' or another mode. " - f'Cause: {exc}' - ) from exc + try: + _provide_spider_eos_tables(config, outdir, dirs) + except MissingMeltingCurveError: + raise + except FileNotFoundError as exc: + raise RuntimeError( + "interior_struct.module='dummy' with interior_energetics.module=" + f'{config.interior_energetics.module!r} needs the SPIDER/Aragog P-S EOS ' + 'tables from FWL_DATA or the SPIDER lookup_data, and neither is ' + "available. Fetch them with 'proteus get all'. " + f'Cause: {exc}' + ) from exc # Derived quantities hf_row['M_mantle'] = hf_row['M_int'] - hf_row['M_core'] diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 6bb7e06f4..37e8f99f4 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1725,10 +1725,10 @@ def _get_sufficient(config: Config, clean: bool = False): _attempt('Wolf and Bower melting curves', download_interior_lookuptables, clean=clean) _attempt('melting curves', download_melting_curves, config, clean=clean) - # Dynamic EOS for SPIDER and Aragog (uses struct.eos_dir, skip if None/PALEOS) - if ( - config.interior_energetics.module in ('spider', 'aragog') - and config.interior_struct.eos_dir is not None + # Dynamic EOS for SPIDER and Aragog: set by struct.eos_dir, and always read + # with the dummy structure, which never uses PALEOS tables + if config.interior_energetics.module in ('spider', 'aragog') and ( + config.interior_struct.eos_dir is not None or config.interior_struct.module == 'dummy' ): _attempt('EOS lookup tables', download_eos_dynamic, config.interior_struct.eos_dir) diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 3e394c954..5fd2f8972 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -3514,13 +3514,10 @@ def test_determine_interior_radius_with_dummy_sets_mesh_paths_for_spider(tmp_pat 'proteus.interior_struct.dummy.solve_dummy_structure', return_value=mesh_file, ), + _patch('proteus.interior_struct.zalmoxis.generate_spider_tables') as generate, _patch( - 'proteus.interior_struct.zalmoxis.generate_spider_tables', - return_value={ - 'eos_dir': str(tmp_path / 'eos'), - 'solidus_path': str(tmp_path / 'eos/solidus_P-S.dat'), - 'liquidus_path': str(tmp_path / 'eos/liquidus_P-S.dat'), - }, + 'proteus.interior_energetics.wrapper._provide_spider_eos_tables', + side_effect=lambda cfg, out, d: d.update(spider_eos_dir=str(tmp_path / 'eos')), ), _patch('proteus.interior_energetics.wrapper.Interior_t'), _patch('proteus.interior_energetics.wrapper.run_interior'), @@ -3534,20 +3531,15 @@ def test_determine_interior_radius_with_dummy_sets_mesh_paths_for_spider(tmp_pat assert dirs['spider_mesh_prev'] == mesh_file + '.prev' # M_mantle = M_int - M_core assert hf_row['M_mantle'] == pytest.approx(5.972e24 - 2.0e24, rel=1e-12) - # Sanity: dispatch was the SPIDER branch so the EOS-table generator - # was wired. + # The dummy structure never uses PALEOS tables; the FWL_DATA/SPIDER set is provided. + generate.assert_not_called() assert dirs['spider_eos_dir'] == str(tmp_path / 'eos') @pytest.mark.unit -@pytest.mark.parametrize( - ('temperature_mode', 'expect_call'), - [('liquidus_super', True), ('adiabatic', False)], -) -def test_dummy_structure_provides_tables_for_liquidus_super_without_generated_set( - tmp_path, temperature_mode, expect_call -): - """With no generated P-S set, only liquidus_super requests the FWL_DATA tables.""" +@pytest.mark.parametrize('temperature_mode', ['liquidus_super', 'adiabatic']) +def test_dummy_structure_provides_tables_in_every_temperature_mode(tmp_path, temperature_mode): + """The dummy structure takes the FWL_DATA/SPIDER tables in every temperature mode.""" from unittest.mock import patch as _patch from proteus.interior_energetics.wrapper import determine_interior_radius_with_dummy @@ -3561,7 +3553,7 @@ def test_dummy_structure_provides_tables_for_liquidus_super_without_generated_se with ( _patch('proteus.interior_struct.dummy.solve_dummy_structure', return_value=None), - _patch('proteus.interior_struct.zalmoxis.generate_spider_tables', return_value=None), + _patch('proteus.interior_struct.zalmoxis.generate_spider_tables') as generate, _patch('proteus.interior_energetics.wrapper._provide_spider_eos_tables') as provide, _patch('proteus.interior_energetics.wrapper.Interior_t'), _patch('proteus.interior_energetics.wrapper.run_interior'), @@ -3571,9 +3563,9 @@ def test_dummy_structure_provides_tables_for_liquidus_super_without_generated_se ): determine_interior_radius_with_dummy({}, config, None, hf_row, str(tmp_path)) - assert provide.called is expect_call - if expect_call: - assert provide.call_args.args[1] == str(tmp_path) + generate.assert_not_called() + assert provide.call_count == 1 + assert provide.call_args.args[1] == str(tmp_path) @pytest.mark.unit @@ -3613,10 +3605,8 @@ def test_dummy_structure_liquidus_super_passes_missing_melting_curve_through(tmp @pytest.mark.unit -def test_dummy_structure_liquidus_super_without_tables_raises_named_error(tmp_path): - """No generated set and no FWL_DATA or SPIDER table source gives a named - RuntimeError that names mantle_eos, the field that selects the tables. - """ +def test_dummy_structure_without_tables_raises_named_error(tmp_path): + """No FWL_DATA or SPIDER table source gives a named RuntimeError in any mode.""" from unittest.mock import patch as _patch from proteus.interior_energetics.wrapper import determine_interior_radius_with_dummy @@ -3625,13 +3615,11 @@ def test_dummy_structure_liquidus_super_without_tables_raises_named_error(tmp_pa config.interior_energetics.module = 'aragog' config.interior_energetics.num_levels = 50 config.interior_struct.eos_dir = 'WolfBower2018_MgSiO3' - config.interior_struct.zalmoxis.mantle_eos = 'Stixrude14:MgSiO3' - config.planet.temperature_mode = 'liquidus_super' + config.planet.temperature_mode = 'adiabatic' hf_row = {'M_int': 5.972e24, 'M_core': 2.0e24, 'R_int': 6.371e6, 'gravity': 9.81} with ( _patch('proteus.interior_struct.dummy.solve_dummy_structure', return_value=None), - _patch('proteus.interior_struct.zalmoxis.generate_spider_tables', return_value=None), _patch( 'proteus.interior_energetics.wrapper._provide_spider_eos_tables', side_effect=FileNotFoundError('no P-S tables'), @@ -3642,11 +3630,9 @@ def test_dummy_structure_liquidus_super_without_tables_raises_named_error(tmp_pa determine_interior_radius_with_dummy({}, config, None, hf_row, str(tmp_path)) msg = str(excinfo.value) - assert 'temperature_mode' in msg - assert 'liquidus_super' in msg - assert 'interior_struct.module' in msg - assert "interior_struct.zalmoxis.mantle_eos='Stixrude14:MgSiO3'" in msg - assert 'WolfBower2018_MgSiO3' not in msg + assert "interior_struct.module='dummy'" in msg + assert "interior_energetics.module='aragog'" in msg + assert 'proteus get all' in msg assert 'no P-S tables' in msg assert not isinstance(excinfo.value, FileNotFoundError) # The failure happens before the first interior step is built. @@ -3680,10 +3666,7 @@ def test_determine_interior_radius_with_dummy_no_mesh_for_non_spider(tmp_path): 'proteus.interior_struct.dummy.solve_dummy_structure', return_value=None, ), - _patch( - 'proteus.interior_struct.zalmoxis.generate_spider_tables', - return_value=None, - ), + _patch('proteus.interior_energetics.wrapper._provide_spider_eos_tables'), _patch('proteus.interior_energetics.wrapper.Interior_t'), _patch('proteus.interior_energetics.wrapper.run_interior'), _patch('proteus.interior_energetics.wrapper.update_gravity'), @@ -3694,8 +3677,6 @@ def test_determine_interior_radius_with_dummy_no_mesh_for_non_spider(tmp_path): # No mesh file -> no spider_mesh key in dirs. assert 'spider_mesh' not in dirs - # generate_spider_tables returned None -> no spider_eos_dir. - assert 'spider_eos_dir' not in dirs # M_mantle still set. assert hf_row['M_mantle'] == pytest.approx(5.972e24 - 2.0e24, rel=1e-12) diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 1c8588360..2967e5200 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3001,6 +3001,40 @@ def test_get_sufficient_zalmoxis_wolf_bower( mock_dyn.assert_called_once() +@pytest.mark.unit +@patch('proteus.utils.data.download_zalmoxis_eos_for_config') +@patch('proteus.utils.data.download_eos_dynamic') +@patch('proteus.utils.data.download_melting_curves') +@patch('proteus.utils.data.download_interior_lookuptables') +@patch('proteus.utils.data.download_massradius_data') +@patch('proteus.utils.data.download_surface_albedos') +@patch('proteus.utils.data.download_exoplanet_data') +@patch('proteus.utils.data.download_stellar_spectra') +@patch('proteus.utils.data.download_spectral_file') +@patch('proteus.utils.data.download_phoenix') +def test_get_sufficient_dummy_structure_fetches_ps_tables_without_eos_dir( + _m_ph, _m_sp, _m_st, _m_ex, _m_sa, _m_mr, _m_il, _m_mc, mock_dyn, _m_zal +): + """The dummy structure always reads the P-S set, so it is fetched without eos_dir.""" + from unittest.mock import MagicMock + + from proteus.utils.data import _get_sufficient + + config = MagicMock() + config.interior_energetics.module = 'aragog' + config.interior_struct.module = 'dummy' + config.interior_struct.eos_dir = None + + _get_sufficient(config, clean=False) + assert mock_dyn.call_count == 1 + + # Discrimination: Zalmoxis without eos_dir takes its tables from its own EOS. + mock_dyn.reset_mock() + config.interior_struct.module = 'zalmoxis' + _get_sufficient(config, clean=False) + assert mock_dyn.call_count == 0 + + @pytest.mark.unit @patch('proteus.utils.data.download_zalmoxis_eos') @patch('proteus.utils.data.download_eos_dynamic') From 6a42f31a9e39ded497108bd6dddbe4e0da1d7f5b Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 02:45:28 +0200 Subject: [PATCH 19/76] Fetch the P-S lookup set whenever SPIDER or Aragog read it needs_spider_ps_tables decides when a run reads the Wolf and Bower P-S set: with SPIDER or Aragog energetics, unless Zalmoxis generates a PALEOS table set from a single-component PALEOS mantle EOS. The start-of-run fetch uses it, and so does proteus get interiordata --config-path, so an offline run with the dummy structure, or with Zalmoxis and a non-PALEOS or mixed mantle EOS, finds the tables it needs. The table and melting-curve errors named proteus get all, which is not a command; they now name proteus get interiordata --config-path. The unset-melting_dir message names the mantle EOS only when Zalmoxis is the structure module, since the dummy and SPIDER structures do not use it. data.md states when the set is fetched. --- docs/Reference/data.md | 7 ++-- src/proteus/cli.py | 6 ++++ src/proteus/interior_energetics/common.py | 8 +++++ src/proteus/interior_energetics/spider.py | 6 ++-- src/proteus/interior_energetics/wrapper.py | 18 +++++----- src/proteus/utils/data.py | 39 ++++++++++++++++++---- tests/interior_energetics/test_wrapper.py | 22 +++++++++--- tests/test_cli.py | 39 ++++++++++++++++++++++ tests/utils/test_data.py | 35 ++++++++++++++++++- 9 files changed, 155 insertions(+), 25 deletions(-) diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 28d1d36db..62c6defb3 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -44,7 +44,7 @@ used with lower rate limits. | Exoplanet populations, mass-radius curves | fwl-io | `proteus get reference` | | Interior structure EOS tables | fwl-io | `proteus get interiordata`, or fetched when a run needs them | | Melting curves | fwl-io | `proteus get interiordata`, or fetched when a run needs them | -| P-S lookup tables | fwl-io | fetched when a run sets `interior_struct.eos_dir` | +| P-S lookup tables | fwl-io | fetched when SPIDER or Aragog run without a generated PALEOS table set | To configure a Zenodo API token, see the [Troubleshooting guide](../How-to/troubleshooting.md#data-download-errors-or-slow-zenodo-downloads). @@ -284,7 +284,10 @@ is exempt. The Wolf and Bower (2018) pressure-entropy lookup table that SPIDER and Aragog read for `eos_dir = "WolfBower2018_MgSiO3"` is fetched the same way, into `$FWL_DATA/interior/eos/dk09_1tpa_elec_free/mgsio3_wolf_bower_2018_1tpa/r/` -when a run sets `eos_dir`. A local table takes precedence: SPIDER reads +when SPIDER or Aragog run without a generated PALEOS table set: with the dummy or +SPIDER structure, or with Zalmoxis and a non-PALEOS or mixed mantle EOS. +`proteus get interiordata --config-path ` fetches it for such a config +before an offline run. A local table takes precedence: SPIDER reads `$FWL_DATA/interior_lookup_tables/EOS/dynamic//P-S/` and Aragog reads `$FWL_DATA/interior_lookup_tables/EOS/dynamic//P-T/`. SPIDER uses the table bundled with it (`lookup_data/1TPa-dK09-elec-free/`) when neither the diff --git a/src/proteus/cli.py b/src/proteus/cli.py index ba0f9c5f9..297bfc47c 100644 --- a/src/proteus/cli.py +++ b/src/proteus/cli.py @@ -539,9 +539,11 @@ def reference(): def interiordata(config_path: Path): """Get interior lookup tables, melting curves, and structure EOS tables""" from .utils.data import ( + download_eos_dynamic, download_interior_lookuptables, download_melting_curves, download_zalmoxis_eos_for_config, + needs_spider_ps_tables, ) download_interior_lookuptables(clean=True) @@ -554,6 +556,10 @@ def interiordata(config_path: Path): # the structure solver. download_zalmoxis_eos_for_config(configuration) + # The P-S lookup set SPIDER and Aragog read without a PALEOS table set. + if needs_spider_ps_tables(configuration): + download_eos_dynamic() + @click.command() def socrates(): diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index 166faa3e2..c12a410ba 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -214,6 +214,14 @@ class MissingMeltingCurveError(FileNotFoundError): """ +def _mantle_note(config) -> str: + """Name the Zalmoxis mantle EOS in an error, only when Zalmoxis is the structure.""" + struct = config.interior_struct + if getattr(struct, 'module', None) != 'zalmoxis': + return '' + return f' (mantle EOS {getattr(getattr(struct, "zalmoxis", None), "mantle_eos", None)!r})' + + class InitialConditionError(RuntimeError): """The requested initial condition does not exist for this planet and EOS. diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index d19b35b74..d8a3410e8 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -16,6 +16,7 @@ from proteus.interior_energetics.common import ( Interior_t, MissingMeltingCurveError, + _mantle_note, get_file_tides, ) from proteus.interior_energetics.timestep import next_step @@ -949,11 +950,10 @@ def _try_spider( config.interior_struct.melting_dir, ) elif config.interior_struct.melting_dir is None: - zalmoxis_cfg = getattr(config.interior_struct, 'zalmoxis', None) raise MissingMeltingCurveError( 'interior_struct.melting_dir is not set and no PALEOS table set was ' - f'generated (mantle EOS {getattr(zalmoxis_cfg, "mantle_eos", None)!r}). ' - 'Set melting_dir to a melting curve name (e.g. "Monteux-600").' + f'generated{_mantle_note(config)}. Set melting_dir to a melting curve ' + 'name (e.g. "Monteux-600").' ) else: mc_dir = os.path.join(MELTING_CURVES_DIR, config.interior_struct.melting_dir) diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index dc556f71f..c4ab713c6 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -18,6 +18,7 @@ InitialConditionError, Interior_t, MissingMeltingCurveError, + _mantle_note, ) from proteus.outgas.wrapper import calc_target_elemental_inventories from proteus.utils.constants import M_earth, R_earth, const_G, noble_gases, vol_element_list @@ -664,7 +665,8 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: 2. **FWL_DATA (Zenodo 19473625)**: if the canonical Zenodo download target exists and is complete, copy the 12 files into the output directory. This is the self-sufficient path: once the user runs - ``proteus get all`` (or any non-offline start), the Zenodo record + ``proteus get interiordata --config-path `` (or any non-offline + start), the Zenodo record populates FWL_DATA and subsequent runs find the complete set here. @@ -678,7 +680,7 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: 4. **Hard failure**: if neither source yields a complete set, raise ``FileNotFoundError`` with a clear message pointing the user at - ``proteus get all`` or the Zenodo record. + ``proteus get interiordata --config-path ``. When ``interior_struct.melting_dir`` is set, the two P-S melting curves are derived from its P-T files in every case above, and missing P-T files @@ -752,11 +754,10 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: ) if not derive_melting and not const_spider: # Without melting_dir the curves would be whatever set is on disk. - zalmoxis_cfg = getattr(config.interior_struct, 'zalmoxis', None) raise MissingMeltingCurveError( 'interior_struct.melting_dir is not set and no PALEOS table set was ' - f'generated (mantle EOS {getattr(zalmoxis_cfg, "mantle_eos", None)!r}). ' - 'Set melting_dir to a melting curve name (e.g. "Monteux-600").' + f'generated{_mantle_note(config)}. Set melting_dir to a melting curve name ' + '(e.g. "Monteux-600").' ) os.makedirs(target_dir, exist_ok=True) @@ -786,7 +787,7 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: 'in SPIDER P-S format. This usually means the directory was ' 'populated by the Zenodo 17417017 record (P-T format). ' 'Falling through to the SPIDER submodule. Refresh FWL_DATA ' - 'with `proteus get all` to fetch Zenodo 19473625.', + 'with `proteus get interiordata --config-path `.', zenodo_root, ) if zenodo_format_ok: @@ -881,7 +882,7 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: f'{len(zenodo_files)} required files ' f'({zenodo_missing[:3]}...), and the SPIDER submodule fallback ' f'was unavailable at {dirs.get("spider", "")}' - '/lookup_data/1TPa-dK09-elec-free/. Run `proteus get all` to ' + '/lookup_data/1TPa-dK09-elec-free/. Run `proteus get interiordata --config-path ` to ' 'fetch Zenodo record 19473625, or ensure the SPIDER submodule ' 'is cloned.' ) @@ -1066,7 +1067,8 @@ def determine_interior_radius_with_dummy( "interior_struct.module='dummy' with interior_energetics.module=" f'{config.interior_energetics.module!r} needs the SPIDER/Aragog P-S EOS ' 'tables from FWL_DATA or the SPIDER lookup_data, and neither is ' - "available. Fetch them with 'proteus get all'. " + "available. Fetch them with 'proteus get interiordata --config-path " + "'. " f'Cause: {exc}' ) from exc diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 37e8f99f4..a7bd38c52 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1673,6 +1673,36 @@ def _attempt(desc: str, func, *args, **kwargs) -> bool: return True +def needs_spider_ps_tables(config) -> bool: + """Return whether a run reads the Wolf and Bower P-S lookup set. + + SPIDER and Aragog read it unless Zalmoxis generates a PALEOS table set, + which happens for a single-component PALEOS mantle EOS. + + Parameters + ---------- + config : Config + The run configuration. + + Returns + ------- + bool + True when the run needs the lookup set in FWL_DATA. + """ + from proteus.utils.constants import PALEOS_EOS_PREFIXES + + energetics = getattr(getattr(config, 'interior_energetics', None), 'module', None) + if energetics not in ('spider', 'aragog'): + return False + struct = getattr(config, 'interior_struct', None) + if getattr(struct, 'eos_dir', None) is not None: + return True + if getattr(struct, 'module', None) == 'zalmoxis': + mantle = str(getattr(getattr(struct, 'zalmoxis', None), 'mantle_eos', '') or '') + return not (mantle.startswith(PALEOS_EOS_PREFIXES) and '+' not in mantle) + return True + + def _get_sufficient(config: Config, clean: bool = False): # Star stuff if config.star.module == 'mors': @@ -1725,12 +1755,9 @@ def _get_sufficient(config: Config, clean: bool = False): _attempt('Wolf and Bower melting curves', download_interior_lookuptables, clean=clean) _attempt('melting curves', download_melting_curves, config, clean=clean) - # Dynamic EOS for SPIDER and Aragog: set by struct.eos_dir, and always read - # with the dummy structure, which never uses PALEOS tables - if config.interior_energetics.module in ('spider', 'aragog') and ( - config.interior_struct.eos_dir is not None or config.interior_struct.module == 'dummy' - ): - _attempt('EOS lookup tables', download_eos_dynamic, config.interior_struct.eos_dir) + # P-S lookup set for SPIDER and Aragog, unless Zalmoxis generates PALEOS tables + if needs_spider_ps_tables(config): + _attempt('EOS lookup tables', download_eos_dynamic) # EOS for Zalmoxis (derived from struct.zalmoxis config, not struct.eos_dir) _attempt('Zalmoxis EOS tables', download_zalmoxis_eos_for_config, config) diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 5fd2f8972..93bbeca36 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -3268,7 +3268,9 @@ def test_provide_spider_eos_tables_unset_melting_dir_raises(tmp_path): (spider_bundle / 'liquidus_A11_H13.dat').write_text('# bundled liquidus\n') config = SimpleNamespace( interior_struct=SimpleNamespace( - melting_dir=None, zalmoxis=SimpleNamespace(mantle_eos='WolfBower2018:MgSiO3') + module='zalmoxis', + melting_dir=None, + zalmoxis=SimpleNamespace(mantle_eos='WolfBower2018:MgSiO3'), ), interior_energetics=SimpleNamespace(module='aragog', const_properties=True), ) @@ -3280,11 +3282,21 @@ def test_provide_spider_eos_tables_unset_melting_dir_raises(tmp_path): _provide_spider_eos_tables(config, str(tmp_path), dirs) assert 'spider_liquidus_ps' not in dirs + # SPIDER without constant properties reads the curves too; the dummy + # structure message names no Zalmoxis mantle EOS. + config.interior_energetics = SimpleNamespace(module='spider', const_properties=False) + config.interior_struct.module = 'dummy' + with pytest.raises( + MissingMeltingCurveError, match='melting_dir is not set' + ) as raised_dummy: + _provide_spider_eos_tables(config, str(tmp_path), dirs) + assert 'mantle EOS' not in str(raised_dummy.value) + # Discrimination: SPIDER with constant properties needs no curves. - config.interior_energetics.module = 'spider' + config.interior_energetics.const_properties = True _provide_spider_eos_tables(config, str(tmp_path), dirs) - assert "'WolfBower2018:MgSiO3'" in str(raised.value) + assert "(mantle EOS 'WolfBower2018:MgSiO3')" in str(raised.value) assert dirs['spider_eos_dir'] == str(tmp_path / 'data' / 'spider_eos') @@ -3370,7 +3382,7 @@ def test_provide_spider_eos_tables_hard_failure_when_no_source(tmp_path, monkeyp # Discrimination: the message points users at the remediation # (`proteus get all`); a regression that silently fell through # would not raise at all. - assert 'proteus get all' in str(exc.value) + assert 'proteus get interiordata --config-path' in str(exc.value) # ============================================================================ @@ -3632,7 +3644,7 @@ def test_dummy_structure_without_tables_raises_named_error(tmp_path): msg = str(excinfo.value) assert "interior_struct.module='dummy'" in msg assert "interior_energetics.module='aragog'" in msg - assert 'proteus get all' in msg + assert 'proteus get interiordata --config-path' in msg assert 'no P-S tables' in msg assert not isinstance(excinfo.value, FileNotFoundError) # The failure happens before the first interior step is built. diff --git a/tests/test_cli.py b/tests/test_cli.py index cb66a718a..4c9454ce7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -665,6 +665,45 @@ def fake_download_zalmoxis_eos(*, mantle_eos, core_eos, ice_layer_eos): assert calls == [('zalmoxis_eos', 'PALEOS:MgSiO3', 'PALEOS:iron', '')] +@pytest.mark.unit +def test_get_interiordata_fetches_ps_lookup_set_for_dummy(monkeypatch, tmp_path): + """``proteus get interiordata`` fetches the P-S lookup set a dummy-structure run reads.""" + from types import SimpleNamespace + + runner = CliRunner() + calls = [] + fake_config = SimpleNamespace( + interior_energetics=SimpleNamespace(module='aragog'), + interior_struct=SimpleNamespace(module='dummy', eos_dir=None, zalmoxis=None), + ) + monkeypatch.setattr( + 'proteus.utils.data.download_interior_lookuptables', lambda clean=False: None + ) + monkeypatch.setattr( + 'proteus.utils.data.download_melting_curves', lambda configuration, clean=False: None + ) + monkeypatch.setattr( + 'proteus.utils.data.download_eos_dynamic', lambda *a, **k: calls.append('ps_set') + ) + monkeypatch.setattr(cli, 'read_config_object', lambda path: fake_config) + cfg = tmp_path / 'cfg.toml' + cfg.write_text('unused') + + res = runner.invoke(cli.cli, ['get', 'interiordata', '--config-path', str(cfg)]) + assert res.exit_code == 0, res.output + assert calls == ['ps_set'] + + # Discrimination: Zalmoxis with a PALEOS mantle generates its own tables. + calls.clear() + fake_config.interior_struct = SimpleNamespace( + module='zalmoxis', eos_dir=None, zalmoxis=SimpleNamespace(mantle_eos='PALEOS:MgSiO3') + ) + monkeypatch.setattr('proteus.utils.data.download_zalmoxis_eos_for_config', lambda c: None) + res = runner.invoke(cli.cli, ['get', 'interiordata', '--config-path', str(cfg)]) + assert res.exit_code == 0, res.output + assert calls == [] + + # ---- tool setup subcommands ---- diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 2967e5200..f58c985b0 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3028,13 +3028,46 @@ def test_get_sufficient_dummy_structure_fetches_ps_tables_without_eos_dir( _get_sufficient(config, clean=False) assert mock_dyn.call_count == 1 - # Discrimination: Zalmoxis without eos_dir takes its tables from its own EOS. + # Discrimination: Zalmoxis with a PALEOS mantle generates its own tables. mock_dyn.reset_mock() config.interior_struct.module = 'zalmoxis' + config.interior_struct.zalmoxis.mantle_eos = 'PALEOS:MgSiO3' _get_sufficient(config, clean=False) assert mock_dyn.call_count == 0 +@pytest.mark.unit +@pytest.mark.parametrize( + ('energetics', 'struct', 'eos_dir', 'mantle_eos', 'expected'), + [ + ('aragog', 'dummy', None, 'PALEOS:MgSiO3', True), + ('spider', 'dummy', None, 'PALEOS:MgSiO3', True), + ('spider', 'spider', 'WolfBower2018_MgSiO3', 'PALEOS:MgSiO3', True), + ('aragog', 'zalmoxis', None, 'PALEOS:MgSiO3', False), + ('aragog', 'zalmoxis', None, 'PALEOS-2phase:MgSiO3', False), + ('aragog', 'zalmoxis', None, 'WolfBower2018:MgSiO3', True), + ('spider', 'zalmoxis', None, 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', True), + ('aragog', 'zalmoxis', 'WolfBower2018_MgSiO3', 'PALEOS:MgSiO3', True), + ('dummy', 'dummy', None, 'PALEOS:MgSiO3', False), + ], +) +def test_needs_spider_ps_tables(energetics, struct, eos_dir, mantle_eos, expected): + """The P-S lookup set is needed exactly when no PALEOS table set is generated.""" + from types import SimpleNamespace + + from proteus.utils.data import needs_spider_ps_tables + + config = SimpleNamespace( + interior_energetics=SimpleNamespace(module=energetics), + interior_struct=SimpleNamespace( + module=struct, eos_dir=eos_dir, zalmoxis=SimpleNamespace(mantle_eos=mantle_eos) + ), + ) + assert needs_spider_ps_tables(config) is expected + # A config without these sections needs nothing. + assert needs_spider_ps_tables({'fake': 'config'}) is False + + @pytest.mark.unit @patch('proteus.utils.data.download_zalmoxis_eos') @patch('proteus.utils.data.download_eos_dynamic') From e437087f06f18ff80cc24e660f2cfb38e18155d4 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 02:50:01 +0200 Subject: [PATCH 20/76] Use melting_dir in Aragog for a PALEOS mixture mantle generates_paleos_tables (utils/helper.py) is now the one test for whether Zalmoxis makes a PALEOS table set: the Zalmoxis structure with a single-component PALEOS mantle EOS. Aragog, the start-of-run table fetch and the config warning use it, so SPIDER, Aragog and the P-S tables of one run read the same curves. Aragog took the PALEOS-derived curves for any mantle EOS with a PALEOS prefix, mixtures included, while a mixture gets no generated table set, so SPIDER and the entropy tables read melting_dir. The curve choice now lives in _melting_curve_files, and a mixture reads melting_dir; the entropy cross-check against the PALEOS anchor is skipped for it as well. --- src/proteus/config/_struct.py | 10 ++-- src/proteus/interior_energetics/aragog.py | 65 ++++++++++++++--------- src/proteus/utils/data.py | 7 +-- src/proteus/utils/helper.py | 25 ++++++++- tests/interior_energetics/test_aragog.py | 38 +++++++++++++ 5 files changed, 107 insertions(+), 38 deletions(-) diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index d8b6d8046..da83326c2 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -369,13 +369,9 @@ def __attrs_post_init__(self): # Zalmoxis with a single PALEOS mantle EOS derives the curves from PALEOS; # a mixture string gets no generated table set, so melting_dir is read. - if ( - self.module == 'zalmoxis' - and self.melting_dir is not None - and self.zalmoxis is not None - and self.zalmoxis.mantle_eos.startswith(PALEOS_EOS_PREFIXES) - and '+' not in self.zalmoxis.mantle_eos - ): + from proteus.utils.helper import generates_paleos_tables + + if self.melting_dir is not None and generates_paleos_tables(self): import logging as _logging _logging.getLogger('fwl.' + __name__).warning( diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 2573beffa..fe04e4c2b 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -38,6 +38,7 @@ from proteus.interior_energetics.wrapper import get_core_density, get_core_heatcap from proteus.utils.constants import radnuc_data from proteus.utils.data import resolve_lookup_table_dir, resolve_melting_curve_files +from proteus.utils.helper import generates_paleos_tables from proteus.utils.helper import format_subyear_time, parse_subyear_time, snapshot_path_for_time log = logging.getLogger('fwl.' + __name__) @@ -63,6 +64,43 @@ _entropy_eos_jax_cache: dict = {} +def _melting_curve_files(config, outdir): + """Return the (solidus, liquidus) files Aragog reads for this run. + + The PALEOS-derived pair when Zalmoxis generates a PALEOS table set, else the + curves named by interior_struct.melting_dir: the same curves as SPIDER and + the P-S tables. + + Parameters + ---------- + config : Config + PROTEUS configuration. + outdir : str + Output directory, where the PALEOS-derived curves are written. + + Returns + ------- + tuple + Paths of the solidus and liquidus files. + + Raises + ------ + ValueError + interior_struct.melting_dir is unset and no PALEOS table set is generated. + """ + if generates_paleos_tables(config.interior_struct): + return _write_paleos_melting_curves(outdir, config) + if config.interior_struct.melting_dir is None: + raise ValueError( + 'interior_struct.melting_dir must be set without a generated PALEOS ' + 'table set (a single PALEOS mantle EOS with the Zalmoxis structure). ' + 'Provide a melting curve folder name (e.g. "Monteux-600").' + ) + return resolve_melting_curve_files( + config.interior_struct.melting_dir, data_root=FWL_DATA_DIR + ) + + def _write_paleos_melting_curves(outdir, config): """Write the PALEOS-derived solidus and liquidus tables for Aragog. @@ -969,27 +1007,7 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s ) if not (LOOK_UP_DIR / 'heat_capacity_melt.dat').is_file(): LOOK_UP_DIR = default_lookup - # Determine melting curves. When using PALEOS EOS via Zalmoxis, - # generate PALEOS-derived melting curves so Aragog uses the SAME - # solidus/liquidus as SPIDER (PALEOS-liquidus * mushy_zone_factor). - # Without this, Aragog uses Monteux-600 which differs by ~600 K, - # making melt fractions incomparable. - if ( - config.interior_struct.module == 'zalmoxis' - and config.interior_struct.zalmoxis.mantle_eos.startswith(PALEOS_EOS_PREFIXES) - ): - sol_file, liq_file = _write_paleos_melting_curves(outdir, config) - solidus_path = sol_file - liquidus_path = liq_file - else: - if config.interior_struct.melting_dir is None: - raise ValueError( - 'interior_struct.melting_dir must be set for non-PALEOS EOS. ' - 'Provide a melting curve folder name (e.g. "Monteux-600").' - ) - solidus_path, liquidus_path = resolve_melting_curve_files( - config.interior_struct.melting_dir, data_root=FWL_DATA_DIR - ) + solidus_path, liquidus_path = _melting_curve_files(config, outdir) # check data exist if not (LOOK_UP_DIR / 'heat_capacity_melt.dat').is_file(): @@ -1406,10 +1424,7 @@ def _verify_entropy_ic( """ from proteus.interior_energetics.common import InitialConditionError - if not ( - config.interior_struct.module == 'zalmoxis' - and config.interior_struct.zalmoxis.mantle_eos.startswith(PALEOS_EOS_PREFIXES) - ): + if not generates_paleos_tables(config.interior_struct): log.debug( 'Entropy IC cross-check skipped: not zalmoxis+PALEOS ' "(interior_struct.module='%s')", diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index a7bd38c52..513c489cf 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1689,7 +1689,7 @@ def needs_spider_ps_tables(config) -> bool: bool True when the run needs the lookup set in FWL_DATA. """ - from proteus.utils.constants import PALEOS_EOS_PREFIXES + from proteus.utils.helper import generates_paleos_tables energetics = getattr(getattr(config, 'interior_energetics', None), 'module', None) if energetics not in ('spider', 'aragog'): @@ -1697,10 +1697,7 @@ def needs_spider_ps_tables(config) -> bool: struct = getattr(config, 'interior_struct', None) if getattr(struct, 'eos_dir', None) is not None: return True - if getattr(struct, 'module', None) == 'zalmoxis': - mantle = str(getattr(getattr(struct, 'zalmoxis', None), 'mantle_eos', '') or '') - return not (mantle.startswith(PALEOS_EOS_PREFIXES) and '+' not in mantle) - return True + return not generates_paleos_tables(struct) def _get_sufficient(config: Config, clean: bool = False): diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 9b1db92ca..27b3b4f57 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -11,11 +11,34 @@ import numpy as np -from proteus.utils.constants import element_list, element_mmw +from proteus.utils.constants import PALEOS_EOS_PREFIXES, element_list, element_mmw log = logging.getLogger('fwl.' + __name__) +def generates_paleos_tables(interior_struct) -> bool: + """Return whether Zalmoxis generates a PALEOS table set for this structure. + + Only a single-component PALEOS mantle EOS under the Zalmoxis structure gets + one; SPIDER, Aragog and the table fetch then use the PALEOS-derived curves + instead of interior_struct.melting_dir. + + Parameters + ---------- + interior_struct : Struct + The interior_struct section of the configuration. + + Returns + ------- + bool + True for module 'zalmoxis' with a PALEOS mantle EOS that is not a mixture. + """ + if getattr(interior_struct, 'module', None) != 'zalmoxis': + return False + mantle = str(getattr(getattr(interior_struct, 'zalmoxis', None), 'mantle_eos', '') or '') + return mantle.startswith(PALEOS_EOS_PREFIXES) and '+' not in mantle + + def resolve_fwl_data_dir() -> Path: """Return the FWL_DATA directory. diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 106c2ea1b..32e691805 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1411,6 +1411,44 @@ def test_solve_with_retry_first_solve_rejects_non_finite_tcore(monkeypatch): assert solver.solve.call_count == 6 +@pytest.mark.unit +@pytest.mark.parametrize( + ('struct_module', 'mantle_eos', 'expect_paleos'), + [ + ('zalmoxis', 'PALEOS:MgSiO3', True), + ('zalmoxis', 'PALEOS-2phase:MgSiO3', True), + ('zalmoxis', 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), + ('zalmoxis', 'WolfBower2018:MgSiO3', False), + ('dummy', 'PALEOS:MgSiO3', False), + ], +) +def test_melting_curve_files_follow_the_generated_table_set( + tmp_path, struct_module, mantle_eos, expect_paleos +): + """Aragog reads PALEOS curves only with a generated PALEOS set, else melting_dir. + + A PALEOS mixture gets no generated set, so SPIDER and the P-S tables use + melting_dir; Aragog must read the same curves. + """ + from proteus.interior_energetics import aragog as aragog_mod + + config = _make_aragog_config(struct_module=struct_module, mantle_eos=mantle_eos) + config.interior_struct.melting_dir = 'Monteux-600' + paleos = (str(tmp_path / 'paleos_sol.dat'), str(tmp_path / 'paleos_liq.dat')) + fetched = (tmp_path / 'monteux_sol.dat', tmp_path / 'monteux_liq.dat') + + with ( + patch.object(aragog_mod, '_write_paleos_melting_curves', return_value=paleos), + patch.object(aragog_mod, 'resolve_melting_curve_files', return_value=fetched) as rmc, + ): + result = aragog_mod._melting_curve_files(config, str(tmp_path)) + + assert result == (paleos if expect_paleos else fetched) + assert rmc.called is (not expect_paleos) + if not expect_paleos: + assert rmc.call_args.args[0] == 'Monteux-600' + + @pytest.mark.unit @pytest.mark.physics_invariant def test_paleos_melting_curves_follow_mzf_in_reused_outdir(tmp_path): From ed1d886949bb627406b77a87d5432d48acc808c9 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 02:52:36 +0200 Subject: [PATCH 21/76] Apply the liquidus_super anchor cap only to a generated PALEOS set compute_initial_entropy solved the PALEOS P-T anchor, and capped the P-S initial entropy at its entropy, for any mantle EOS with a PALEOS prefix. The cap keeps the initial condition off the filled cells of a PALEOS-generated table set, and a mixture gets no such set: its P-S tables come from melting_dir, while the anchor used the pure MgSiO3 PALEOS liquidus. The anchor and the cap now apply only when generates_paleos_tables is true, so a mixture solves its initial condition on the melting_dir tables alone. _anchor_failure_deferred uses the same test, so a failed structure anchor for a mixture propagates instead of waiting for an initial-condition solve that no longer runs. --- src/proteus/interior_energetics/common.py | 6 ++- src/proteus/interior_struct/zalmoxis.py | 7 +-- .../test_liquidus_super_tables.py | 49 +++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index c12a410ba..5113e0a1c 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -9,8 +9,9 @@ import numpy as np from scipy.special import erf -from proteus.utils.constants import PALEOS_EOS_PREFIXES, B_ein +from proteus.utils.constants import B_ein from proteus.utils.data import find_lookup_table_dir +from proteus.utils.helper import generates_paleos_tables if TYPE_CHECKING: from aragog.eos.entropy import EntropyEOS @@ -811,7 +812,8 @@ def compute_initial_entropy( 'interior_struct.zalmoxis section is missing.' ) mantle_eos = str(zcfg.mantle_eos) - if mantle_eos.startswith(PALEOS_EOS_PREFIXES): + # Only a generated PALEOS table set has the filled cells the cap avoids. + if generates_paleos_tables(config.interior_struct): from proteus.interior_struct.zalmoxis import solve_superliquidus_adiabat # The anchor raises its numerical failures as InitialConditionError. diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 836592e96..7ce9be029 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -641,7 +641,7 @@ def _anchor_failure_deferred(config: Config) -> bool: """Whether a failed P-T anchor in a structure solve can fall back. ``compute_initial_entropy`` re-solves the anchor at the converged P_cmb only - for a PALEOS mantle under spider or aragog energetics; everywhere else no + for a generated PALEOS table set under spider or aragog energetics; everywhere else no later step does, so the failure must propagate. Parameters @@ -654,8 +654,9 @@ def _anchor_failure_deferred(config: Config) -> bool: bool True when the initial entropy re-solves the anchor. """ - mantle_eos = str(getattr(config.interior_struct.zalmoxis, 'mantle_eos', '')) - return mantle_eos.startswith(PALEOS_EOS_PREFIXES) and ( + from proteus.utils.helper import generates_paleos_tables + + return generates_paleos_tables(config.interior_struct) and ( config.interior_energetics.module in ('spider', 'aragog') ) diff --git a/tests/interior_energetics/test_liquidus_super_tables.py b/tests/interior_energetics/test_liquidus_super_tables.py index 3f710f568..9dad891f8 100644 --- a/tests/interior_energetics/test_liquidus_super_tables.py +++ b/tests/interior_energetics/test_liquidus_super_tables.py @@ -819,6 +819,55 @@ def test_zalmoxis_anchor_clamp_caps_the_ic_entropy( assert f'{superheat:.0f} K above the P-S table liquidus' in msgs[0] +@pytest.mark.physics_invariant +def test_paleos_mixture_ic_is_solved_without_the_anchor_cap(fake_tables, monkeypatch): + """A PALEOS mixture gets no generated table set, so the IC is solved on the + melting_dir tables without the pure-MgSiO3 P-T anchor or its entropy cap. + """ + zal = pytest.importorskip('proteus.interior_struct.zalmoxis') + calls = [] + + def _anchor(config, hf_row): + calls.append(config.interior_struct.zalmoxis.mantle_eos) + return _anchor_result(2300.0) + + monkeypatch.setattr(common, '_ANCHOR_CAP_WARNED', set()) + monkeypatch.setattr(zal, 'solve_superliquidus_adiabat', _anchor) + cfg = _config(500.0, module='zalmoxis') + cfg.interior_struct.zalmoxis.mantle_eos = 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1' + assert _S_expected(500.0) > 2300.0 # the cap would bind + + S_mix = compute_initial_entropy(cfg, {'P_cmb': P_CMB}, 3300.0, fake_tables) + assert S_mix == pytest.approx(_S_expected(500.0), rel=1e-6) + assert calls == [] + + # Discrimination: a single PALEOS mantle keeps the anchor and its cap. + cfg.interior_struct.zalmoxis.mantle_eos = 'PALEOS-2phase:MgSiO3' + S_single = compute_initial_entropy(cfg, {'P_cmb': P_CMB}, 3300.0, fake_tables) + assert S_single == pytest.approx(2300.0, rel=1e-12) + assert calls == ['PALEOS-2phase:MgSiO3'] + + +@pytest.mark.parametrize( + ('mantle_eos', 'energetics', 'deferred'), + [ + ('PALEOS-2phase:MgSiO3', 'aragog', True), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'aragog', False), + ('WolfBower2018:MgSiO3', 'spider', False), + ('PALEOS:MgSiO3', 'dummy', False), + ], +) +def test_anchor_failure_is_deferred_only_where_the_ic_resolves_it( + mantle_eos, energetics, deferred +): + """A failed structure anchor waits for the IC only when the IC re-solves it.""" + zal = pytest.importorskip('proteus.interior_struct.zalmoxis') + cfg = _config(500.0, module='zalmoxis') + cfg.interior_struct.zalmoxis.mantle_eos = mantle_eos + cfg.interior_energetics.module = energetics + assert zal._anchor_failure_deferred(cfg) is deferred + + @pytest.mark.parametrize( 'anchor', [ From 56ac687343baa3349dab23aa8efd9d83bda4aaa5 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 02:53:01 +0200 Subject: [PATCH 22/76] Check dummy energetics in the anchor deferral test The parametrized test of _anchor_failure_deferred also asserts that energetics without an initial-condition anchor solve never defer a failed structure anchor, for every mantle EOS case. --- tests/interior_energetics/test_liquidus_super_tables.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/interior_energetics/test_liquidus_super_tables.py b/tests/interior_energetics/test_liquidus_super_tables.py index 9dad891f8..e72e73d67 100644 --- a/tests/interior_energetics/test_liquidus_super_tables.py +++ b/tests/interior_energetics/test_liquidus_super_tables.py @@ -866,6 +866,9 @@ def test_anchor_failure_is_deferred_only_where_the_ic_resolves_it( cfg.interior_struct.zalmoxis.mantle_eos = mantle_eos cfg.interior_energetics.module = energetics assert zal._anchor_failure_deferred(cfg) is deferred + # Energetics without an IC anchor re-solve never defer. + cfg.interior_energetics.module = 'dummy' + assert zal._anchor_failure_deferred(cfg) is False @pytest.mark.parametrize( From abb1c64fb08454a7bbabb83a16964f5d32eb3951 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 05:51:49 +0200 Subject: [PATCH 23/76] Read Aragog property tables from the WB set for a PALEOS mixture generates_paleos_tables now asks the Zalmoxis material registry for the exact mantle EOS key, the lookup generate_spider_tables makes. A string such as "PALEOS:MgSiO3:1.0", one with trailing whitespace, or an unknown PALEOS material gets no generated set, so the fetch, the config warning, the anchor cap and Aragog treat it as SPIDER does. Aragog builds its P-T property tables from PALEOS only when that set is generated. A PALEOS mixture now reads density, heat capacity and expansivity from the Wolf and Bower (2018) set or interior_struct.eos_dir, the family of its P-S tables and melting_dir curves, so no cell between the two liquidi reads a PALEOS phase table on the wrong side of its own liquidus. A warning names the table edge when P_cmb is above 1 TPa. needs_spider_ps_tables and data.md state that a set eos_dir asks for the P-S set. The initial-conditions guide states which tables a mixture reads and scopes the constant-property exemption to SPIDER energetics. --- docs/How-to/usage_initial_conditions.md | 24 ++++--- docs/Reference/data.md | 3 +- src/proteus/interior_energetics/aragog.py | 21 ++++-- src/proteus/utils/data.py | 3 +- src/proteus/utils/helper.py | 17 +++-- tests/interior_energetics/test_aragog.py | 69 +++++++++++++++++++ .../test_liquidus_super_parity.py | 4 +- .../test_liquidus_super_tables.py | 2 +- tests/utils/test_data.py | 6 +- tests/utils/test_helper.py | 27 ++++++++ 10 files changed, 149 insertions(+), 27 deletions(-) diff --git a/docs/How-to/usage_initial_conditions.md b/docs/How-to/usage_initial_conditions.md index ef438099e..99e26bb76 100644 --- a/docs/How-to/usage_initial_conditions.md +++ b/docs/How-to/usage_initial_conditions.md @@ -117,16 +117,22 @@ liquidus at the binding depth. the interior solver uses for its melt fraction. Where those tables come from depends on the structure module: - - `"zalmoxis"` with a PALEOS mantle (`interior_struct.zalmoxis.mantle_eos`, - default `"PALEOS:MgSiO3"`) whose table files are present: Zalmoxis - generates the tables from that EOS, with a liquidus derived from the - PALEOS (Fei et al. 2021) curve. `interior_struct.melting_dir` is not - used. + - `"zalmoxis"` with a mantle EOS (`interior_struct.zalmoxis.mantle_eos`, + default `"PALEOS:MgSiO3"`) that is one PALEOS material, with its table + files present: Zalmoxis generates the tables from that EOS, with a + liquidus derived from the PALEOS (Fei et al. 2021) curve, and Aragog + builds its P-T property tables from PALEOS too. + `interior_struct.melting_dir` is not used. - `"spider"` and `"dummy"`, or `"zalmoxis"` when no PALEOS table set is - generated: the tables come from FWL_DATA or the SPIDER lookup data, - `interior_struct.melting_dir` must be set (the run stops when it is - unset, except for SPIDER with `const_properties = true`), and PROTEUS - derives `liquidus_P-S.dat` from that P-T curve. + generated, which includes a PALEOS mixture (a `+` in `mantle_eos`): the + tables come from FWL_DATA (SPIDER also falls back to its own lookup + data), Aragog reads its P-T property tables from the same Wolf & Bower + (2018) set or `interior_struct.eos_dir`, `interior_struct.melting_dir` + must be set (the run stops when it is unset, except for + `interior_energetics.module = "spider"` with `const_properties = true`), + and PROTEUS derives `liquidus_P-S.dat` from that P-T curve. These tables + end at 1 TPa; Aragog logs a warning when the core-mantle boundary + pressure is above that. With `"zalmoxis"`, the structure solve also anchors its temperature profile on a P-T adiabat that is `delta_T_super` above the P-T liquidus, while the diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 62c6defb3..0c026c681 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -285,7 +285,8 @@ The Wolf and Bower (2018) pressure-entropy lookup table that SPIDER and Aragog read for `eos_dir = "WolfBower2018_MgSiO3"` is fetched the same way, into `$FWL_DATA/interior/eos/dk09_1tpa_elec_free/mgsio3_wolf_bower_2018_1tpa/r/` when SPIDER or Aragog run without a generated PALEOS table set: with the dummy or -SPIDER structure, or with Zalmoxis and a non-PALEOS or mixed mantle EOS. +SPIDER structure, or with Zalmoxis and a non-PALEOS or mixed mantle EOS. It is +also fetched whenever `interior_struct.eos_dir` is set. `proteus get interiordata --config-path ` fetches it for such a config before an offline run. A local table takes precedence: SPIDER reads `$FWL_DATA/interior_lookup_tables/EOS/dynamic//P-S/` and Aragog reads diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index fe04e4c2b..8dc78077f 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -33,7 +33,7 @@ build_mixed_phase_params, ) from proteus.interior_energetics.common import Interior_t -from proteus.utils.constants import FEI2021_LIQUIDUS_P_CALIB_PA, PALEOS_EOS_PREFIXES +from proteus.utils.constants import FEI2021_LIQUIDUS_P_CALIB_PA from proteus.interior_energetics.timestep import next_step from proteus.interior_energetics.wrapper import get_core_density, get_core_heatcap from proteus.utils.constants import radnuc_data @@ -63,6 +63,9 @@ _entropy_eos_jax_cache: dict = {} +# Upper pressure of the Wolf and Bower (2018) lookup set (Pa), dataset 1TPa-dK09. +_WB_TABLE_P_MAX = 1.0e12 + def _melting_curve_files(config, outdir): """Return the (solidus, liquidus) files Aragog reads for this run. @@ -857,16 +860,13 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s LOOK_UP_DIR = Path(outdir) / 'data' / 'spider_eos' # EOS lookup directory for phase properties (Cp, alpha, density, entropy). - # For PALEOS EOS: generate P-T tables from PALEOS data. + # With a generated PALEOS set: P-T tables from PALEOS; a mixture reads the WB set. # Prefer PALEOS-2phase (separate solid/liquid) over unified table: # 2-phase tables give clean phase-specific entropy values at # solidus/liquidus, enabling correct Delta_S for mixing flux and IC. # The unified table has interpolation artifacts across the melting # curve discontinuity. - elif ( - config.interior_struct.module == 'zalmoxis' - and config.interior_struct.zalmoxis.mantle_eos.startswith(PALEOS_EOS_PREFIXES) - ): + elif generates_paleos_tables(config.interior_struct): from proteus.interior_struct.zalmoxis import load_zalmoxis_material_dictionaries mat_dicts = load_zalmoxis_material_dictionaries() @@ -1007,6 +1007,15 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s ) if not (LOOK_UP_DIR / 'heat_capacity_melt.dat').is_file(): LOOK_UP_DIR = default_lookup + P_cmb = float(hf_row.get('P_cmb') or 0.0) + if P_cmb > _WB_TABLE_P_MAX: + log.warning( + 'P_cmb=%.0f GPa is above the %.0f GPa edge of the Aragog lookup ' + 'tables in %s; the deep mantle reads values at the table edge.', + P_cmb / 1e9, + _WB_TABLE_P_MAX / 1e9, + LOOK_UP_DIR, + ) solidus_path, liquidus_path = _melting_curve_files(config, outdir) # check data exist diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 513c489cf..d473acf71 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1677,7 +1677,8 @@ def needs_spider_ps_tables(config) -> bool: """Return whether a run reads the Wolf and Bower P-S lookup set. SPIDER and Aragog read it unless Zalmoxis generates a PALEOS table set, - which happens for a single-component PALEOS mantle EOS. + which happens for a mantle EOS that is a PALEOS registry key. A set + interior_struct.eos_dir asks for it whenever SPIDER or Aragog run. Parameters ---------- diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 27b3b4f57..5ff2c9398 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -19,9 +19,10 @@ def generates_paleos_tables(interior_struct) -> bool: """Return whether Zalmoxis generates a PALEOS table set for this structure. - Only a single-component PALEOS mantle EOS under the Zalmoxis structure gets - one; SPIDER, Aragog and the table fetch then use the PALEOS-derived curves - instead of interior_struct.melting_dir. + Only a mantle EOS that is a PALEOS key of the Zalmoxis material registry, + under the Zalmoxis structure, gets one, the same lookup generate_spider_tables + does; SPIDER, Aragog and the table fetch then use the PALEOS-derived curves + instead of interior_struct.melting_dir. A mixture is not a registry key. Parameters ---------- @@ -31,12 +32,16 @@ def generates_paleos_tables(interior_struct) -> bool: Returns ------- bool - True for module 'zalmoxis' with a PALEOS mantle EOS that is not a mixture. + True for module 'zalmoxis' with a mantle EOS that is a PALEOS registry key. """ if getattr(interior_struct, 'module', None) != 'zalmoxis': return False - mantle = str(getattr(getattr(interior_struct, 'zalmoxis', None), 'mantle_eos', '') or '') - return mantle.startswith(PALEOS_EOS_PREFIXES) and '+' not in mantle + mantle = getattr(getattr(interior_struct, 'zalmoxis', None), 'mantle_eos', None) + if not isinstance(mantle, str) or not mantle.startswith(PALEOS_EOS_PREFIXES): + return False + from proteus.interior_struct.zalmoxis import load_zalmoxis_material_dictionaries + + return mantle in load_zalmoxis_material_dictionaries() def resolve_fwl_data_dir() -> Path: diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 32e691805..e6b865100 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -15,6 +15,7 @@ from __future__ import annotations +from pathlib import Path from unittest.mock import MagicMock, create_autospec, patch import numpy as np @@ -1500,3 +1501,71 @@ def test_helpfile_output_t_cmb_node_is_cmb_basic_node(): assert res['T_cmb_node'] == pytest.approx(4100.0) assert res['T_cmb'] == pytest.approx(3800.0) assert res['T_cmb_node'] != pytest.approx(res['T_cmb']) + + +@pytest.mark.unit +@pytest.mark.parametrize( + ('mantle_eos', 'paleos_tables'), + [ + ('PALEOS-2phase:MgSiO3', True), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), + ], +) +def test_setup_solver_property_tables_follow_the_generated_set( + tmp_path, caplog, mantle_eos, paleos_tables +): + """Only a generated PALEOS set gives PALEOS property tables; a mixture reads the WB set.""" + from proteus.interior_energetics.aragog import AragogRunner + + outdir = tmp_path / 'out' + config = _make_aragog_config(struct_module='zalmoxis', mantle_eos=mantle_eos) + config.interior_struct.eos_dir = None + config.planet.mass_tot = 1.0 + pt_dir = outdir / 'data' / 'aragog_pt' + pt_dir.mkdir(parents=True) + for name in ('density_melt.dat', 'heat_capacity_melt.dat'): + (pt_dir / name).write_text('dummy') + solid, liquid = tmp_path / 'solid.dat', tmp_path / 'liquid.dat' + solid.write_text('dummy') + liquid.write_text('dummy') + registry = { + 'PALEOS-2phase:MgSiO3': { + 'solid_mantle': {'eos_file': str(solid)}, + 'melted_mantle': {'eos_file': str(liquid)}, + } + } + wb_dir = _seed_lookup_tables(tmp_path) + hf_row = { + 'R_int': 6.371e6, + 'R_core': 3.48e6, + 'P_cmb': 1.2e12, + 'gravity': 9.81, + 'T_magma': 3000.0, + 'T_eqm': 255.0, + 'F_atm': 100.0, + } + interior_o = MagicMock() + interior_o.tides = np.zeros(20) + interior_o._spider_eos_dir = str(tmp_path) + + with ( + patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path), + patch( + 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', + return_value=registry, + ), + patch( + 'proteus.interior_energetics.aragog._melting_curve_files', + return_value=(solid, liquid), + ), + patch('proteus.interior_energetics.aragog._PhaseParameters') as mock_phase, + patch('proteus.interior_energetics.aragog.Parameters'), + patch('proteus.interior_energetics.aragog.EntropySolver'), + patch('proteus.interior_energetics.aragog._cached_entropy_eos'), + ): + AragogRunner.setup_solver(config, hf_row, interior_o, str(outdir)) + + dirs = {Path(c.kwargs['density']).parent for c in mock_phase.call_args_list} + assert dirs == {pt_dir if paleos_tables else wb_dir} + # P_cmb = 1.2 TPa is past the WB table edge; the PALEOS set is built to reach it. + assert ('edge of the Aragog lookup tables' in caplog.text) is not paleos_tables diff --git a/tests/interior_energetics/test_liquidus_super_parity.py b/tests/interior_energetics/test_liquidus_super_parity.py index ec45da0d2..b844b1c72 100644 --- a/tests/interior_energetics/test_liquidus_super_parity.py +++ b/tests/interior_energetics/test_liquidus_super_parity.py @@ -125,7 +125,9 @@ def _fake_adiabat( 'S_profile': S_profile, } - monkeypatch.setattr(zmod, 'load_zalmoxis_material_dictionaries', lambda: {}) + monkeypatch.setattr( + zmod, 'load_zalmoxis_material_dictionaries', lambda: {'PALEOS:MgSiO3': {}} + ) monkeypatch.setattr( zmod, 'resolve_2phase_mgsio3_paths', diff --git a/tests/interior_energetics/test_liquidus_super_tables.py b/tests/interior_energetics/test_liquidus_super_tables.py index e72e73d67..3043b812a 100644 --- a/tests/interior_energetics/test_liquidus_super_tables.py +++ b/tests/interior_energetics/test_liquidus_super_tables.py @@ -854,7 +854,7 @@ def _anchor(config, hf_row): ('PALEOS-2phase:MgSiO3', 'aragog', True), ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'aragog', False), ('WolfBower2018:MgSiO3', 'spider', False), - ('PALEOS:MgSiO3', 'dummy', False), + ('PALEOS:MgSiO3:1.0', 'aragog', False), ], ) def test_anchor_failure_is_deferred_only_where_the_ic_resolves_it( diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index f58c985b0..52325bb27 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3047,6 +3047,7 @@ def test_get_sufficient_dummy_structure_fetches_ps_tables_without_eos_dir( ('aragog', 'zalmoxis', None, 'PALEOS-2phase:MgSiO3', False), ('aragog', 'zalmoxis', None, 'WolfBower2018:MgSiO3', True), ('spider', 'zalmoxis', None, 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', True), + ('aragog', 'zalmoxis', None, 'PALEOS:MgSiO3:1.0', True), ('aragog', 'zalmoxis', 'WolfBower2018_MgSiO3', 'PALEOS:MgSiO3', True), ('dummy', 'dummy', None, 'PALEOS:MgSiO3', False), ], @@ -3064,8 +3065,9 @@ def test_needs_spider_ps_tables(energetics, struct, eos_dir, mantle_eos, expecte ), ) assert needs_spider_ps_tables(config) is expected - # A config without these sections needs nothing. - assert needs_spider_ps_tables({'fake': 'config'}) is False + # A set eos_dir asks for the set whenever SPIDER or Aragog run. + config.interior_struct.eos_dir = 'WolfBower2018_MgSiO3' + assert needs_spider_ps_tables(config) is (energetics in ('spider', 'aragog')) @pytest.mark.unit diff --git a/tests/utils/test_helper.py b/tests/utils/test_helper.py index b238c6926..e41305337 100644 --- a/tests/utils/test_helper.py +++ b/tests/utils/test_helper.py @@ -820,3 +820,30 @@ def test_parse_subyear_time_rejects_multiple_p(): parse_subyear_time('1p2p3') with pytest.raises(ValueError, match="multiple 'p' characters"): parse_subyear_time('884pp700') + + +@pytest.mark.unit +@pytest.mark.parametrize( + ('module', 'mantle_eos', 'expected'), + [ + ('zalmoxis', 'PALEOS:MgSiO3', True), + ('zalmoxis', 'PALEOS-2phase:MgSiO3-highres', True), + ('zalmoxis', 'PALEOS:MgSiO3:1.0', False), + ('zalmoxis', 'PALEOS:MgSiO3 ', False), + ('zalmoxis', 'PALEOS:Olivine', False), + ('zalmoxis', 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), + ('zalmoxis', 'WolfBower2018:MgSiO3', False), + ('zalmoxis', None, False), + ('dummy', 'PALEOS:MgSiO3', False), + ], +) +def test_generates_paleos_tables_matches_the_registry_lookup(module, mantle_eos, expected): + """Only an exact PALEOS registry key, as generate_spider_tables looks it up, counts.""" + from types import SimpleNamespace + + from proteus.interior_struct.zalmoxis import load_zalmoxis_material_dictionaries + from proteus.utils.helper import generates_paleos_tables + + struct = SimpleNamespace(module=module, zalmoxis=SimpleNamespace(mantle_eos=mantle_eos)) + assert generates_paleos_tables(struct) is expected + assert not expected or mantle_eos in load_zalmoxis_material_dictionaries() From 04476f23b859dffd5e44ca6dcf0d5502c7329844 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 05:52:13 +0200 Subject: [PATCH 24/76] Shorten the Aragog property-table comment --- src/proteus/interior_energetics/aragog.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 8dc78077f..f10a5dd62 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -859,13 +859,9 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s else: LOOK_UP_DIR = Path(outdir) / 'data' / 'spider_eos' - # EOS lookup directory for phase properties (Cp, alpha, density, entropy). - # With a generated PALEOS set: P-T tables from PALEOS; a mixture reads the WB set. - # Prefer PALEOS-2phase (separate solid/liquid) over unified table: - # 2-phase tables give clean phase-specific entropy values at - # solidus/liquidus, enabling correct Delta_S for mixing flux and IC. - # The unified table has interpolation artifacts across the melting - # curve discontinuity. + # Phase-property tables (Cp, alpha, density, entropy) from PALEOS only with a + # generated PALEOS set, preferring PALEOS-2phase: its separate solid/liquid tables + # avoid the unified table's artifacts at the melting curve. A mixture reads WB. elif generates_paleos_tables(config.interior_struct): from proteus.interior_struct.zalmoxis import load_zalmoxis_material_dictionaries From 5b25b984b577b5d49ff29bdb855e45a248ef2d77 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 06:12:17 +0200 Subject: [PATCH 25/76] Check PALEOS registry keys without loading the registry generates_paleos_tables compares the mantle EOS with PALEOS_REGISTRY_KEYS, a fixed set of the PALEOS keys of the Zalmoxis material registry, instead of building the registry. Config validation calls it, and building the registry imported Zalmoxis and created the FWL_DATA folder there. A test keeps the set equal to the PALEOS keys of load_zalmoxis_material_dictionaries. The Aragog table-edge warning applies only to the fetched Wolf and Bower set, not to a table set named by interior_struct.eos_dir, and the test pins the threshold from both sides. The initial-conditions guide says that the Wolf and Bower set is indexed by pressure and entropy and that the edge warning needs the Zalmoxis structure, which sets P_cmb. data.md says that eos_dir asks for the set only with SPIDER or Aragog. --- docs/How-to/usage_initial_conditions.md | 19 ++++---- docs/Reference/data.md | 2 +- src/proteus/interior_energetics/aragog.py | 7 ++- src/proteus/utils/constants.py | 14 ++++++ src/proteus/utils/helper.py | 15 +++---- tests/interior_energetics/test_aragog.py | 23 ++++++---- .../test_liquidus_super_parity.py | 4 +- tests/utils/test_data.py | 1 + tests/utils/test_helper.py | 43 ++++++++++++------- 9 files changed, 77 insertions(+), 51 deletions(-) diff --git a/docs/How-to/usage_initial_conditions.md b/docs/How-to/usage_initial_conditions.md index 99e26bb76..54a7e7e54 100644 --- a/docs/How-to/usage_initial_conditions.md +++ b/docs/How-to/usage_initial_conditions.md @@ -118,21 +118,20 @@ liquidus at the binding depth. from depends on the structure module: - `"zalmoxis"` with a mantle EOS (`interior_struct.zalmoxis.mantle_eos`, - default `"PALEOS:MgSiO3"`) that is one PALEOS material, with its table - files present: Zalmoxis generates the tables from that EOS, with a - liquidus derived from the PALEOS (Fei et al. 2021) curve, and Aragog - builds its P-T property tables from PALEOS too. - `interior_struct.melting_dir` is not used. + default `"PALEOS:MgSiO3"`) that is a single PALEOS table name: Zalmoxis + generates the tables from that EOS, with a liquidus derived from the + PALEOS (Fei et al. 2021) curve, and Aragog builds its phase-property + tables from PALEOS too. `interior_struct.melting_dir` is not used. - `"spider"` and `"dummy"`, or `"zalmoxis"` when no PALEOS table set is generated, which includes a PALEOS mixture (a `+` in `mantle_eos`): the tables come from FWL_DATA (SPIDER also falls back to its own lookup - data), Aragog reads its P-T property tables from the same Wolf & Bower - (2018) set or `interior_struct.eos_dir`, `interior_struct.melting_dir` + data), Aragog reads its phase-property tables from the same Wolf & Bower + (2018) P-S set or `interior_struct.eos_dir`, `interior_struct.melting_dir` must be set (the run stops when it is unset, except for `interior_energetics.module = "spider"` with `const_properties = true`), - and PROTEUS derives `liquidus_P-S.dat` from that P-T curve. These tables - end at 1 TPa; Aragog logs a warning when the core-mantle boundary - pressure is above that. + and PROTEUS derives `liquidus_P-S.dat` from that P-T curve. The Wolf & + Bower (2018) tables end at 1 TPa; with `"zalmoxis"`, Aragog logs a + warning when the core-mantle boundary pressure is above that. With `"zalmoxis"`, the structure solve also anchors its temperature profile on a P-T adiabat that is `delta_T_super` above the P-T liquidus, while the diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 0c026c681..79233bdc4 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -286,7 +286,7 @@ Aragog read for `eos_dir = "WolfBower2018_MgSiO3"` is fetched the same way, into `$FWL_DATA/interior/eos/dk09_1tpa_elec_free/mgsio3_wolf_bower_2018_1tpa/r/` when SPIDER or Aragog run without a generated PALEOS table set: with the dummy or SPIDER structure, or with Zalmoxis and a non-PALEOS or mixed mantle EOS. It is -also fetched whenever `interior_struct.eos_dir` is set. +also fetched whenever `interior_struct.eos_dir` is set and SPIDER or Aragog run. `proteus get interiordata --config-path ` fetches it for such a config before an offline run. A local table takes precedence: SPIDER reads `$FWL_DATA/interior_lookup_tables/EOS/dynamic//P-S/` and Aragog reads diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index f10a5dd62..367ec4e4c 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -1004,13 +1004,12 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s if not (LOOK_UP_DIR / 'heat_capacity_melt.dat').is_file(): LOOK_UP_DIR = default_lookup P_cmb = float(hf_row.get('P_cmb') or 0.0) - if P_cmb > _WB_TABLE_P_MAX: + if LOOK_UP_DIR == default_lookup and P_cmb > _WB_TABLE_P_MAX: log.warning( - 'P_cmb=%.0f GPa is above the %.0f GPa edge of the Aragog lookup ' - 'tables in %s; the deep mantle reads values at the table edge.', + 'P_cmb=%.0f GPa is above the %.0f GPa edge of the Wolf and Bower ' + '(2018) tables; the deep mantle reads values at the table edge.', P_cmb / 1e9, _WB_TABLE_P_MAX / 1e9, - LOOK_UP_DIR, ) solidus_path, liquidus_path = _melting_curve_files(config, outdir) diff --git a/src/proteus/utils/constants.py b/src/proteus/utils/constants.py index 570b84b02..1c6ba6413 100644 --- a/src/proteus/utils/constants.py +++ b/src/proteus/utils/constants.py @@ -20,6 +20,20 @@ # the solidus from mushy_zone_factor; the config validator matches the core # and ice-layer EOS too. PALEOS_EOS_PREFIXES = ('PALEOS:', 'PALEOS-2phase:', 'PALEOS-API:', 'PALEOS-API-2phase:') +# PALEOS keys of the Zalmoxis material registry (load_zalmoxis_material_dictionaries). +PALEOS_REGISTRY_KEYS = frozenset( + { + 'PALEOS:iron', + 'PALEOS:MgSiO3', + 'PALEOS:H2O', + 'PALEOS-2phase:MgSiO3', + 'PALEOS-2phase:MgSiO3-highres', + 'PALEOS-API:iron', + 'PALEOS-API:MgSiO3', + 'PALEOS-API:H2O', + 'PALEOS-API-2phase:MgSiO3', + } +) mol = 6.02214076e23 # mol definition diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 5ff2c9398..7f813ab62 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -11,7 +11,7 @@ import numpy as np -from proteus.utils.constants import PALEOS_EOS_PREFIXES, element_list, element_mmw +from proteus.utils.constants import PALEOS_REGISTRY_KEYS, element_list, element_mmw log = logging.getLogger('fwl.' + __name__) @@ -20,9 +20,10 @@ def generates_paleos_tables(interior_struct) -> bool: """Return whether Zalmoxis generates a PALEOS table set for this structure. Only a mantle EOS that is a PALEOS key of the Zalmoxis material registry, - under the Zalmoxis structure, gets one, the same lookup generate_spider_tables - does; SPIDER, Aragog and the table fetch then use the PALEOS-derived curves - instead of interior_struct.melting_dir. A mixture is not a registry key. + under the Zalmoxis structure, gets one: generate_spider_tables looks up the + exact key. SPIDER, Aragog and the table fetch then use the PALEOS-derived + curves instead of interior_struct.melting_dir. A mixture is not a registry + key. The table files are not checked. Parameters ---------- @@ -37,11 +38,7 @@ def generates_paleos_tables(interior_struct) -> bool: if getattr(interior_struct, 'module', None) != 'zalmoxis': return False mantle = getattr(getattr(interior_struct, 'zalmoxis', None), 'mantle_eos', None) - if not isinstance(mantle, str) or not mantle.startswith(PALEOS_EOS_PREFIXES): - return False - from proteus.interior_struct.zalmoxis import load_zalmoxis_material_dictionaries - - return mantle in load_zalmoxis_material_dictionaries() + return mantle in PALEOS_REGISTRY_KEYS def resolve_fwl_data_dir() -> Path: diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index e6b865100..9c64f642a 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1505,21 +1505,23 @@ def test_helpfile_output_t_cmb_node_is_cmb_basic_node(): @pytest.mark.unit @pytest.mark.parametrize( - ('mantle_eos', 'paleos_tables'), + ('mantle_eos', 'eos_dir', 'P_cmb', 'tables', 'warns'), [ - ('PALEOS-2phase:MgSiO3', True), - ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), + ('PALEOS-2phase:MgSiO3', None, 1.2e12, 'paleos', False), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', None, 1.2e12, 'wb', True), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', None, 0.9e12, 'wb', False), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'Custom', 1.2e12, 'custom', False), ], ) def test_setup_solver_property_tables_follow_the_generated_set( - tmp_path, caplog, mantle_eos, paleos_tables + tmp_path, caplog, mantle_eos, eos_dir, P_cmb, tables, warns ): """Only a generated PALEOS set gives PALEOS property tables; a mixture reads the WB set.""" from proteus.interior_energetics.aragog import AragogRunner outdir = tmp_path / 'out' config = _make_aragog_config(struct_module='zalmoxis', mantle_eos=mantle_eos) - config.interior_struct.eos_dir = None + config.interior_struct.eos_dir = eos_dir config.planet.mass_tot = 1.0 pt_dir = outdir / 'data' / 'aragog_pt' pt_dir.mkdir(parents=True) @@ -1535,10 +1537,13 @@ def test_setup_solver_property_tables_follow_the_generated_set( } } wb_dir = _seed_lookup_tables(tmp_path) + custom_dir = tmp_path / 'interior_lookup_tables' / 'EOS' / 'dynamic' / 'Custom' / 'P-T' + custom_dir.mkdir(parents=True) + (custom_dir / 'heat_capacity_melt.dat').write_text('dummy') hf_row = { 'R_int': 6.371e6, 'R_core': 3.48e6, - 'P_cmb': 1.2e12, + 'P_cmb': P_cmb, 'gravity': 9.81, 'T_magma': 3000.0, 'T_eqm': 255.0, @@ -1566,6 +1571,6 @@ def test_setup_solver_property_tables_follow_the_generated_set( AragogRunner.setup_solver(config, hf_row, interior_o, str(outdir)) dirs = {Path(c.kwargs['density']).parent for c in mock_phase.call_args_list} - assert dirs == {pt_dir if paleos_tables else wb_dir} - # P_cmb = 1.2 TPa is past the WB table edge; the PALEOS set is built to reach it. - assert ('edge of the Aragog lookup tables' in caplog.text) is not paleos_tables + assert dirs == {{'paleos': pt_dir, 'wb': wb_dir, 'custom': custom_dir}[tables]} + # Only the fetched WB set has the fixed 1 TPa edge. + assert ('edge of the Wolf and Bower' in caplog.text) is warns diff --git a/tests/interior_energetics/test_liquidus_super_parity.py b/tests/interior_energetics/test_liquidus_super_parity.py index b844b1c72..ec45da0d2 100644 --- a/tests/interior_energetics/test_liquidus_super_parity.py +++ b/tests/interior_energetics/test_liquidus_super_parity.py @@ -125,9 +125,7 @@ def _fake_adiabat( 'S_profile': S_profile, } - monkeypatch.setattr( - zmod, 'load_zalmoxis_material_dictionaries', lambda: {'PALEOS:MgSiO3': {}} - ) + monkeypatch.setattr(zmod, 'load_zalmoxis_material_dictionaries', lambda: {}) monkeypatch.setattr( zmod, 'resolve_2phase_mgsio3_paths', diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 52325bb27..f8138ada3 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3068,6 +3068,7 @@ def test_needs_spider_ps_tables(energetics, struct, eos_dir, mantle_eos, expecte # A set eos_dir asks for the set whenever SPIDER or Aragog run. config.interior_struct.eos_dir = 'WolfBower2018_MgSiO3' assert needs_spider_ps_tables(config) is (energetics in ('spider', 'aragog')) + assert needs_spider_ps_tables({'fake': 'config'}) is False @pytest.mark.unit diff --git a/tests/utils/test_helper.py b/tests/utils/test_helper.py index e41305337..0eab5ffeb 100644 --- a/tests/utils/test_helper.py +++ b/tests/utils/test_helper.py @@ -824,26 +824,39 @@ def test_parse_subyear_time_rejects_multiple_p(): @pytest.mark.unit @pytest.mark.parametrize( - ('module', 'mantle_eos', 'expected'), + ('mantle_eos', 'expected'), [ - ('zalmoxis', 'PALEOS:MgSiO3', True), - ('zalmoxis', 'PALEOS-2phase:MgSiO3-highres', True), - ('zalmoxis', 'PALEOS:MgSiO3:1.0', False), - ('zalmoxis', 'PALEOS:MgSiO3 ', False), - ('zalmoxis', 'PALEOS:Olivine', False), - ('zalmoxis', 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), - ('zalmoxis', 'WolfBower2018:MgSiO3', False), - ('zalmoxis', None, False), - ('dummy', 'PALEOS:MgSiO3', False), + ('PALEOS:MgSiO3', True), + ('PALEOS-2phase:MgSiO3-highres', True), + ('PALEOS-API:MgSiO3', True), + ('PALEOS:MgSiO3:1.0', False), + ('PALEOS:MgSiO3 ', False), + ('PALEOS:Olivine', False), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), + ('WolfBower2018:MgSiO3', False), + (None, False), ], ) -def test_generates_paleos_tables_matches_the_registry_lookup(module, mantle_eos, expected): - """Only an exact PALEOS registry key, as generate_spider_tables looks it up, counts.""" +def test_generates_paleos_tables_needs_an_exact_registry_key(mantle_eos, expected): + """Only an exact PALEOS registry key under the Zalmoxis structure counts.""" from types import SimpleNamespace - from proteus.interior_struct.zalmoxis import load_zalmoxis_material_dictionaries from proteus.utils.helper import generates_paleos_tables - struct = SimpleNamespace(module=module, zalmoxis=SimpleNamespace(mantle_eos=mantle_eos)) + struct = SimpleNamespace(module='zalmoxis', zalmoxis=SimpleNamespace(mantle_eos=mantle_eos)) assert generates_paleos_tables(struct) is expected - assert not expected or mantle_eos in load_zalmoxis_material_dictionaries() + struct.module = 'dummy' + assert generates_paleos_tables(struct) is False + + +@pytest.mark.unit +def test_paleos_registry_keys_match_the_zalmoxis_registry(): + """The static key set equals the PALEOS keys that generate_spider_tables can find.""" + pytest.importorskip('zalmoxis') + from proteus.interior_struct.zalmoxis import load_zalmoxis_material_dictionaries + from proteus.utils.constants import PALEOS_EOS_PREFIXES, PALEOS_REGISTRY_KEYS + + registry = load_zalmoxis_material_dictionaries() + paleos = {k for k in registry if k.startswith(PALEOS_EOS_PREFIXES)} + assert paleos == PALEOS_REGISTRY_KEYS + assert all(k.startswith(PALEOS_EOS_PREFIXES) for k in PALEOS_REGISTRY_KEYS) From 3eafd117ff624938df8dee5681f4f077f29e19bd Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 06:17:06 +0200 Subject: [PATCH 26/76] Name the fetch command when the Aragog lookup set is missing A PALEOS mixture reads the fetched Wolf and Bower set, which the start-of-run fetch gets for it; an offline run without the set now stops with a message that names proteus get interiordata --config-path. --- src/proteus/interior_energetics/aragog.py | 5 +++- tests/interior_energetics/test_aragog.py | 32 ++++++++++++++++++++++- tests/utils/test_data.py | 5 ++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 367ec4e4c..d05563e04 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -1015,7 +1015,10 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s # check data exist if not (LOOK_UP_DIR / 'heat_capacity_melt.dat').is_file(): - raise FileNotFoundError(f'Aragog lookup data not found at {LOOK_UP_DIR}') + raise FileNotFoundError( + f'Aragog lookup data not found at {LOOK_UP_DIR}. Fetch it with ' + "'proteus get interiordata --config-path '." + ) # Entropy tables (optional): enable entropy-conserving adiabatic IC. # Generated by Zalmoxis from the same PALEOS data as Cp/alpha/rho. diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 9c64f642a..0910a440f 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -242,7 +242,7 @@ def test_setup_solver_eos_not_found(tmp_path): with ( patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path), patch('proteus.interior_energetics.aragog.EntropySolver') as mock_solver, - pytest.raises(FileNotFoundError, match='Aragog lookup data not found'), + pytest.raises(FileNotFoundError, match='not found at .*proteus get interiordata'), ): AragogRunner.setup_solver(config, hf_row, interior_o, outdir) @@ -1574,3 +1574,33 @@ def test_setup_solver_property_tables_follow_the_generated_set( assert dirs == {{'paleos': pt_dir, 'wb': wb_dir, 'custom': custom_dir}[tables]} # Only the fetched WB set has the fixed 1 TPa edge. assert ('edge of the Wolf and Bower' in caplog.text) is warns + + +@pytest.mark.unit +def test_setup_solver_offline_mixture_names_the_fetch_command(tmp_path): + """A PALEOS mixture without the fetched WB set stops with the fetch command.""" + from proteus.interior_energetics.aragog import AragogRunner + + config = _make_aragog_config( + struct_module='zalmoxis', mantle_eos='PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1' + ) + config.interior_struct.eos_dir = None + interior_o = MagicMock() + interior_o.tides = np.zeros(20) + hf_row = { + 'R_int': 6.371e6, + 'R_core': 3.48e6, + 'gravity': 9.81, + 'T_magma': 3000.0, + 'T_eqm': 255.0, + 'F_atm': 100.0, + } + + with ( + patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path), + patch('proteus.interior_energetics.aragog.EntropySolver') as mock_solver, + pytest.raises(FileNotFoundError, match='proteus get interiordata --config-path'), + ): + AragogRunner.setup_solver(config, hf_row, interior_o, str(tmp_path / 'out')) + assert not mock_solver.called + assert not (tmp_path / 'out' / 'data' / 'aragog_pt').exists() diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index f8138ada3..b90bde688 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3035,6 +3035,11 @@ def test_get_sufficient_dummy_structure_fetches_ps_tables_without_eos_dir( _get_sufficient(config, clean=False) assert mock_dyn.call_count == 0 + # A PALEOS mixture gets no generated set, so Aragog reads the fetched set. + config.interior_struct.zalmoxis.mantle_eos = 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1' + _get_sufficient(config, clean=False) + assert mock_dyn.call_count == 1 + @pytest.mark.unit @pytest.mark.parametrize( From da996ae5d6305ccaba8705f4ff4ee213b520e0ef Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 06:35:36 +0200 Subject: [PATCH 27/76] Check P_cmb against the P-S tables Aragog reads Aragog takes its thermodynamics and melt fraction from the P-S entropy tables, not from the phase-property files in its lookup directory. The table-edge warning now compares the core-mantle boundary pressure with the upper pressure of the loaded P-S tables, for every structure module and table set, and uses the Noack and Lasbleis (2020) estimate when the structure gives no P_cmb, as with the dummy structure. The initial-conditions guide states this. The table-choice test covers an eos_dir whose tables are missing, and the test entropy EOS carries an upper pressure. --- docs/How-to/usage_initial_conditions.md | 13 ++-- src/proteus/interior_energetics/aragog.py | 22 +++---- tests/interior_energetics/test_aragog.py | 80 ++++++++++++++++++----- 3 files changed, 82 insertions(+), 33 deletions(-) diff --git a/docs/How-to/usage_initial_conditions.md b/docs/How-to/usage_initial_conditions.md index 54a7e7e54..ad98d5349 100644 --- a/docs/How-to/usage_initial_conditions.md +++ b/docs/How-to/usage_initial_conditions.md @@ -125,13 +125,16 @@ liquidus at the binding depth. - `"spider"` and `"dummy"`, or `"zalmoxis"` when no PALEOS table set is generated, which includes a PALEOS mixture (a `+` in `mantle_eos`): the tables come from FWL_DATA (SPIDER also falls back to its own lookup - data), Aragog reads its phase-property tables from the same Wolf & Bower - (2018) P-S set or `interior_struct.eos_dir`, `interior_struct.melting_dir` + data), Aragog also takes its phase-property files from that Wolf & Bower + (2018) set or `interior_struct.eos_dir`, `interior_struct.melting_dir` must be set (the run stops when it is unset, except for `interior_energetics.module = "spider"` with `const_properties = true`), - and PROTEUS derives `liquidus_P-S.dat` from that P-T curve. The Wolf & - Bower (2018) tables end at 1 TPa; with `"zalmoxis"`, Aragog logs a - warning when the core-mantle boundary pressure is above that. + and PROTEUS derives `liquidus_P-S.dat` from that P-T curve. + + Aragog takes its thermodynamics and melt fraction from the P-S tables. It + logs a warning when the core-mantle boundary pressure, or the Noack & + Lasbleis (2020) estimate where the structure gives none, is above the edge + of those tables; the Wolf & Bower (2018) set ends at about 1 TPa. With `"zalmoxis"`, the structure solve also anchors its temperature profile on a P-T adiabat that is `delta_T_super` above the P-T liquidus, while the diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index d05563e04..5747e3123 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -63,9 +63,6 @@ _entropy_eos_jax_cache: dict = {} -# Upper pressure of the Wolf and Bower (2018) lookup set (Pa), dataset 1TPa-dK09. -_WB_TABLE_P_MAX = 1.0e12 - def _melting_curve_files(config, outdir): """Return the (solidus, liquidus) files Aragog reads for this run. @@ -1003,14 +1000,6 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s ) if not (LOOK_UP_DIR / 'heat_capacity_melt.dat').is_file(): LOOK_UP_DIR = default_lookup - P_cmb = float(hf_row.get('P_cmb') or 0.0) - if LOOK_UP_DIR == default_lookup and P_cmb > _WB_TABLE_P_MAX: - log.warning( - 'P_cmb=%.0f GPa is above the %.0f GPa edge of the Wolf and Bower ' - '(2018) tables; the deep mantle reads values at the table edge.', - P_cmb / 1e9, - _WB_TABLE_P_MAX / 1e9, - ) solidus_path, liquidus_path = _melting_curve_files(config, outdir) # check data exist @@ -1120,6 +1109,17 @@ def _append_radnuc(_iso, _cnc): f'PALEOS P-S tables not found. Aragog entropy solver ' f'requires P-S tables. Checked: {spider_eos_dir}, {fallback_dir}' ) + from proteus.utils.structure_estimate import resolve_P_cmb + + P_cmb, estimated = resolve_P_cmb(hf_row, config) + if P_cmb > entropy_eos.P_max: + log.warning( + 'P_cmb=%.0f GPa%s is above the %.0f GPa edge of the P-S tables; ' + 'the deep mantle reads values at the table edge.', + P_cmb / 1e9, + ' (estimated)' if estimated else '', + entropy_eos.P_max / 1e9, + ) _t_post_eos = time.perf_counter() interior_o.aragog_solver = EntropySolver(param, entropy_eos) _t_post_solver = time.perf_counter() diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 0910a440f..c9f43f358 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -29,6 +29,7 @@ def _make_aragog_config(*, struct_module='spider', mantle_eos='Seager2007:silica config = MagicMock() config.interior_struct.module = struct_module config.interior_struct.core_frac = 0.55 + config.interior_struct.core_frac_mode = 'mass' config.interior_struct.zalmoxis.mantle_eos = mantle_eos config.interior_struct.core_density = 12500.0 config.interior_struct.core_heatcap = 880.0 @@ -75,6 +76,10 @@ def _make_aragog_config(*, struct_module='spider', mantle_eos='Seager2007:silica return config +# Entropy EOS stand-in whose P-S tables reach past any P_cmb in these tests. +_EOS = MagicMock(P_max=1.0e13) + + def _seed_lookup_tables(root): """Create the versioned Wolf and Bower lookup dataset directory under ``root``.""" from proteus.data import LOOKUP_WOLF_BOWER_2018_1TPA, dataset_dir @@ -117,7 +122,7 @@ def test_setup_solver_zalmoxis_inner_radius(tmp_path): patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path), patch('proteus.interior_energetics.aragog.Parameters') as mock_params, patch('proteus.interior_energetics.aragog.EntropySolver'), - patch('proteus.interior_energetics.aragog._cached_entropy_eos'), + patch('proteus.interior_energetics.aragog._cached_entropy_eos', return_value=_EOS), ): AragogRunner.setup_solver(config, hf_row, interior_o, outdir) @@ -170,7 +175,7 @@ def test_setup_solver_zalmoxis_wolfbower_temp(tmp_path): patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path), patch('proteus.interior_energetics.aragog.Parameters'), patch('proteus.interior_energetics.aragog.EntropySolver'), - patch('proteus.interior_energetics.aragog._cached_entropy_eos'), + patch('proteus.interior_energetics.aragog._cached_entropy_eos', return_value=_EOS), patch('proteus.interior_energetics.aragog._InitialConditionParameters') as mock_ic, ): AragogRunner.setup_solver(config, hf_row, interior_o, outdir) @@ -211,7 +216,7 @@ def test_setup_solver_eos_fallback(tmp_path): patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path), patch('proteus.interior_energetics.aragog.Parameters'), patch('proteus.interior_energetics.aragog.EntropySolver') as mock_solver, - patch('proteus.interior_energetics.aragog._cached_entropy_eos'), + patch('proteus.interior_energetics.aragog._cached_entropy_eos', return_value=_EOS), ): AragogRunner.setup_solver(config, hf_row, interior_o, outdir) @@ -577,7 +582,7 @@ def test_setup_solver_threads_phase_boundary_margin(tmp_path): ), patch('proteus.interior_energetics.aragog.Parameters'), patch('proteus.interior_energetics.aragog.EntropySolver'), - patch('proteus.interior_energetics.aragog._cached_entropy_eos'), + patch('proteus.interior_energetics.aragog._cached_entropy_eos', return_value=_EOS), patch('proteus.interior_energetics.aragog._EnergyParameters', mock_ep), patch('proteus.interior_energetics.aragog.log') as mock_log, ): @@ -633,7 +638,7 @@ def test_setup_solver_threads_resolved_step_caps(tmp_path): patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path / name), patch('proteus.interior_energetics.aragog.Parameters'), patch('proteus.interior_energetics.aragog.EntropySolver'), - patch('proteus.interior_energetics.aragog._cached_entropy_eos'), + patch('proteus.interior_energetics.aragog._cached_entropy_eos', return_value=_EOS), patch('proteus.interior_energetics.aragog._EnergyParameters', mock_ep), patch('proteus.interior_energetics.aragog.log'), ): @@ -670,7 +675,7 @@ def test_setup_solver_drops_margin_on_old_aragog(tmp_path): patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path / 'nondefault'), patch('proteus.interior_energetics.aragog.Parameters'), patch('proteus.interior_energetics.aragog.EntropySolver'), - patch('proteus.interior_energetics.aragog._cached_entropy_eos'), + patch('proteus.interior_energetics.aragog._cached_entropy_eos', return_value=_EOS), patch('proteus.interior_energetics.aragog._EnergyParameters', mock_ep), patch('proteus.interior_energetics.aragog.log') as mock_log, ): @@ -698,7 +703,7 @@ def test_setup_solver_drops_margin_on_old_aragog(tmp_path): patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path / 'default'), patch('proteus.interior_energetics.aragog.Parameters'), patch('proteus.interior_energetics.aragog.EntropySolver'), - patch('proteus.interior_energetics.aragog._cached_entropy_eos'), + patch('proteus.interior_energetics.aragog._cached_entropy_eos', return_value=_EOS), patch('proteus.interior_energetics.aragog._EnergyParameters', mock_ep), patch('proteus.interior_energetics.aragog.log') as mock_log, ): @@ -1505,16 +1510,16 @@ def test_helpfile_output_t_cmb_node_is_cmb_basic_node(): @pytest.mark.unit @pytest.mark.parametrize( - ('mantle_eos', 'eos_dir', 'P_cmb', 'tables', 'warns'), + ('mantle_eos', 'eos_dir', 'tables'), [ - ('PALEOS-2phase:MgSiO3', None, 1.2e12, 'paleos', False), - ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', None, 1.2e12, 'wb', True), - ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', None, 0.9e12, 'wb', False), - ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'Custom', 1.2e12, 'custom', False), + ('PALEOS-2phase:MgSiO3', None, 'paleos'), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', None, 'wb'), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'Custom', 'custom'), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'Missing', 'wb'), ], ) def test_setup_solver_property_tables_follow_the_generated_set( - tmp_path, caplog, mantle_eos, eos_dir, P_cmb, tables, warns + tmp_path, mantle_eos, eos_dir, tables ): """Only a generated PALEOS set gives PALEOS property tables; a mixture reads the WB set.""" from proteus.interior_energetics.aragog import AragogRunner @@ -1543,7 +1548,6 @@ def test_setup_solver_property_tables_follow_the_generated_set( hf_row = { 'R_int': 6.371e6, 'R_core': 3.48e6, - 'P_cmb': P_cmb, 'gravity': 9.81, 'T_magma': 3000.0, 'T_eqm': 255.0, @@ -1566,14 +1570,13 @@ def test_setup_solver_property_tables_follow_the_generated_set( patch('proteus.interior_energetics.aragog._PhaseParameters') as mock_phase, patch('proteus.interior_energetics.aragog.Parameters'), patch('proteus.interior_energetics.aragog.EntropySolver'), - patch('proteus.interior_energetics.aragog._cached_entropy_eos'), + patch('proteus.interior_energetics.aragog._cached_entropy_eos', return_value=_EOS), ): AragogRunner.setup_solver(config, hf_row, interior_o, str(outdir)) dirs = {Path(c.kwargs['density']).parent for c in mock_phase.call_args_list} assert dirs == {{'paleos': pt_dir, 'wb': wb_dir, 'custom': custom_dir}[tables]} - # Only the fetched WB set has the fixed 1 TPa edge. - assert ('edge of the Wolf and Bower' in caplog.text) is warns + assert len(mock_phase.call_args_list) == 2 @pytest.mark.unit @@ -1604,3 +1607,46 @@ def test_setup_solver_offline_mixture_names_the_fetch_command(tmp_path): AragogRunner.setup_solver(config, hf_row, interior_o, str(tmp_path / 'out')) assert not mock_solver.called assert not (tmp_path / 'out' / 'data' / 'aragog_pt').exists() + + +@pytest.mark.unit +@pytest.mark.parametrize( + ('P_cmb', 'mass_tot', 'message'), + [ + (1.2e12, 1.0, 'P_cmb=1200 GPa is above the 1000 GPa edge'), + (0.9e12, 1.0, None), + (None, 15.0, 'P_cmb=1229 GPa (estimated) is above the 1000 GPa edge'), + (None, 1.0, None), + ], +) +def test_setup_solver_warns_past_the_ps_table_edge(tmp_path, caplog, P_cmb, mass_tot, message): + """P_cmb, or its estimate without a structure value, is checked against the P-S tables.""" + from proteus.interior_energetics.aragog import AragogRunner + + config = _make_aragog_config(struct_module='spider') + config.planet.mass_tot = mass_tot + _seed_lookup_tables(tmp_path) + interior_o = MagicMock() + interior_o.tides = np.zeros(20) + interior_o._spider_eos_dir = str(tmp_path) + hf_row = {'R_int': 6.371e6, 'gravity': 9.81, 'T_magma': 3000.0, 'T_eqm': 255.0} + hf_row['F_atm'] = 100.0 + if P_cmb is not None: + hf_row['P_cmb'] = P_cmb + + with ( + patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path), + patch('proteus.interior_energetics.aragog.Parameters'), + patch('proteus.interior_energetics.aragog.EntropySolver'), + patch( + 'proteus.interior_energetics.aragog._cached_entropy_eos', + return_value=MagicMock(P_max=1.0e12), + ), + ): + AragogRunner.setup_solver(config, hf_row, interior_o, str(tmp_path / 'out')) + + edge = [ + r.getMessage() for r in caplog.records if 'edge of the P-S tables' in r.getMessage() + ] + assert len(edge) == (message is not None) + assert message is None or message in edge[0] From 80ca13ae457b10e01f98e9238307a697d70adc34 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 24 Sep 2026 06:55:00 +0200 Subject: [PATCH 28/76] Warn on the P-S table edge only for a structure P_cmb The Aragog table-edge warning now checks the core-mantle boundary pressure only when the structure gives it, as Zalmoxis does, and uses the relative tolerance of the liquidus_super check. The Noack and Lasbleis estimate is not used, since Aragog's own mesh pressure can differ from it by more than the margin to the edge. The missing P-S table error also names proteus get interiordata --config-path. Tests cover P_cmb under, at and over the edge, a second table edge, a row without a structure P_cmb, and the error message. The initial-conditions guide states the edge as the highest pressure of the P-S table set in use. --- docs/How-to/usage_initial_conditions.md | 8 ++--- src/proteus/interior_energetics/aragog.py | 14 ++++---- tests/interior_energetics/test_aragog.py | 44 +++++++++++++++++------ 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/docs/How-to/usage_initial_conditions.md b/docs/How-to/usage_initial_conditions.md index ad98d5349..1c6f93687 100644 --- a/docs/How-to/usage_initial_conditions.md +++ b/docs/How-to/usage_initial_conditions.md @@ -131,10 +131,10 @@ liquidus at the binding depth. `interior_energetics.module = "spider"` with `const_properties = true`), and PROTEUS derives `liquidus_P-S.dat` from that P-T curve. - Aragog takes its thermodynamics and melt fraction from the P-S tables. It - logs a warning when the core-mantle boundary pressure, or the Noack & - Lasbleis (2020) estimate where the structure gives none, is above the edge - of those tables; the Wolf & Bower (2018) set ends at about 1 TPa. + Aragog takes its thermodynamics and melt fraction from the P-S tables. With + `"zalmoxis"`, which gives the core-mantle boundary pressure, Aragog logs a + warning when that pressure is above the highest pressure of the P-S table + set in use (about 1 TPa for the fetched Wolf & Bower (2018) set). With `"zalmoxis"`, the structure solve also anchors its temperature profile on a P-T adiabat that is `delta_T_super` above the P-T liquidus, while the diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 5747e3123..feb734029 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -1107,17 +1107,17 @@ def _append_radnuc(_iso, _cnc): else: raise FileNotFoundError( f'PALEOS P-S tables not found. Aragog entropy solver ' - f'requires P-S tables. Checked: {spider_eos_dir}, {fallback_dir}' + f'requires P-S tables. Checked: {spider_eos_dir}, {fallback_dir}. ' + "Fetch them with 'proteus get interiordata --config-path " + "'." ) - from proteus.utils.structure_estimate import resolve_P_cmb - - P_cmb, estimated = resolve_P_cmb(hf_row, config) - if P_cmb > entropy_eos.P_max: + # Only a structure P_cmb (Zalmoxis) is checked; the tolerance absorbs rounding. + P_cmb = hf_row.get('P_cmb') + if P_cmb and P_cmb > float(entropy_eos.P_max) * (1.0 + 1e-9): log.warning( - 'P_cmb=%.0f GPa%s is above the %.0f GPa edge of the P-S tables; ' + 'P_cmb=%.0f GPa is above the %.0f GPa edge of the P-S tables; ' 'the deep mantle reads values at the table edge.', P_cmb / 1e9, - ' (estimated)' if estimated else '', entropy_eos.P_max / 1e9, ) _t_post_eos = time.perf_counter() diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index c9f43f358..41ad4ab94 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -29,7 +29,6 @@ def _make_aragog_config(*, struct_module='spider', mantle_eos='Seager2007:silica config = MagicMock() config.interior_struct.module = struct_module config.interior_struct.core_frac = 0.55 - config.interior_struct.core_frac_mode = 'mass' config.interior_struct.zalmoxis.mantle_eos = mantle_eos config.interior_struct.core_density = 12500.0 config.interior_struct.core_heatcap = 880.0 @@ -1611,20 +1610,20 @@ def test_setup_solver_offline_mixture_names_the_fetch_command(tmp_path): @pytest.mark.unit @pytest.mark.parametrize( - ('P_cmb', 'mass_tot', 'message'), + ('P_cmb', 'P_max', 'message'), [ - (1.2e12, 1.0, 'P_cmb=1200 GPa is above the 1000 GPa edge'), - (0.9e12, 1.0, None), - (None, 15.0, 'P_cmb=1229 GPa (estimated) is above the 1000 GPa edge'), + (1.2e12, 1.0e12, 'P_cmb=1200 GPa is above the 1000 GPa edge'), + (1.0e12 * (1 + 5e-10), 1.0e12, None), + (0.9e12, 1.0e12, None), + (6.0e11, 5.0e11, 'P_cmb=600 GPa is above the 500 GPa edge'), (None, 1.0, None), ], ) -def test_setup_solver_warns_past_the_ps_table_edge(tmp_path, caplog, P_cmb, mass_tot, message): - """P_cmb, or its estimate without a structure value, is checked against the P-S tables.""" +def test_setup_solver_warns_past_the_ps_table_edge(tmp_path, caplog, P_cmb, P_max, message): + """A structure P_cmb above the P-S table edge warns; without one nothing is checked.""" from proteus.interior_energetics.aragog import AragogRunner - config = _make_aragog_config(struct_module='spider') - config.planet.mass_tot = mass_tot + config = _make_aragog_config(struct_module='dummy') _seed_lookup_tables(tmp_path) interior_o = MagicMock() interior_o.tides = np.zeros(20) @@ -1640,7 +1639,7 @@ def test_setup_solver_warns_past_the_ps_table_edge(tmp_path, caplog, P_cmb, mass patch('proteus.interior_energetics.aragog.EntropySolver'), patch( 'proteus.interior_energetics.aragog._cached_entropy_eos', - return_value=MagicMock(P_max=1.0e12), + return_value=MagicMock(P_max=P_max), ), ): AragogRunner.setup_solver(config, hf_row, interior_o, str(tmp_path / 'out')) @@ -1650,3 +1649,28 @@ def test_setup_solver_warns_past_the_ps_table_edge(tmp_path, caplog, P_cmb, mass ] assert len(edge) == (message is not None) assert message is None or message in edge[0] + + +@pytest.mark.unit +def test_setup_solver_missing_ps_tables_names_the_fetch_command(tmp_path): + """Missing P-S tables stop the setup with the fetch command, before the solver.""" + from proteus.interior_energetics.aragog import AragogRunner + + config = _make_aragog_config(struct_module='dummy') + _seed_lookup_tables(tmp_path) + interior_o = MagicMock() + interior_o.tides = np.zeros(20) + interior_o._spider_eos_dir = str(tmp_path / 'absent') + hf_row = {'R_int': 6.371e6, 'gravity': 9.81, 'T_magma': 3000.0, 'T_eqm': 255.0} + hf_row['F_atm'] = 100.0 + + with ( + patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path), + patch('proteus.interior_energetics.aragog.Parameters'), + patch('proteus.interior_energetics.aragog.EntropySolver') as mock_solver, + pytest.raises( + FileNotFoundError, match='P-S tables not found.*proteus get interiordata' + ), + ): + AragogRunner.setup_solver(config, hf_row, interior_o, str(tmp_path / 'out')) + assert not mock_solver.called From c050bdef2a3beb8ead2138a3080017b04dd2c2f5 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 07:42:11 +0200 Subject: [PATCH 29/76] Require every PALEOS table a PALEOS EOS reads A mantle with a PALEOS component also reads the MgSiO3 2-phase pair and the MgSiO3 unified table: the P-S tables, the Aragog property tables and the liquidus_super anchor use them. The fetch now downloads that set for every PALEOS mantle (the highres pair for PALEOS-2phase:MgSiO3-highres), and the run checks it after the data step, online and offline, together with the tables of the core and ice layers. A missing file stops the run before the first solve with an error that lists each missing path and the fetch command. For the PALEOS-API family the check materialises the cached tables and stops when the resolver cannot be imported. generate_spider_tables and the Aragog table setup no longer build tables from the unified table alone when the pair is missing; both stop with the same error. A resumed run that keeps its P-S tables, or its own Aragog tables, continues without the pair, as before. A PALEOS:H2O or PALEOS:iron mantle logs one warning at the start of each run segment: the structure uses that density, while the energetics use the MgSiO3 melting curves and P-S tables. twophase_registry_key and the new paleos_companion_keys move to utils/helper.py, so the fetch does not import the Zalmoxis module. Closes #907 --- docs/Reference/config/interior.md | 8 +- src/proteus/interior_energetics/aragog.py | 94 +++-------- src/proteus/interior_struct/zalmoxis.py | 147 +++++++++++------ src/proteus/proteus.py | 4 + src/proteus/utils/data.py | 5 +- src/proteus/utils/helper.py | 53 +++++- tests/interior_energetics/test_aragog.py | 71 ++++++++ tests/interior_struct/test_zalmoxis.py | 192 +++++++++++++++++++++- tests/test_proteus.py | 2 + tests/utils/test_data.py | 81 +++++++-- tests/utils/test_helper.py | 23 +++ 11 files changed, 536 insertions(+), 144 deletions(-) diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index 3611bf75f..9c22e5be7 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -117,8 +117,7 @@ belong to the experimental binodal-aware mode. The MgSiO$_3$ mantle EOS resolves through two distinct paths depending on the `` prefix of `mantle_eos`. With `mantle_eos = "PALEOS:MgSiO3"` (the default), the hydrostatic structure solve uses the PALEOS *unified* MgSiO$_3$ table for the density profile. -The phase-specific property surfaces used by Aragog (density, heat capacity, thermal expansion, adiabatic gradient) and the pressure-entropy lookup tables are built from the PALEOS *two-phase* solid and liquid tables shipped with Zalmoxis when those tables are present, which keeps the properties well resolved across the melting-curve discontinuity that a single unified table interpolates through. -If the two-phase tables are not available, the property surfaces are built from the unified table alone, and the entropy near the melting curve is less reliable. +The phase-specific property surfaces used by Aragog (density, heat capacity, thermal expansion, adiabatic gradient) and the pressure-entropy lookup tables are built from the PALEOS *two-phase* solid and liquid tables, which keeps the properties well resolved across the melting-curve discontinuity that a single unified table interpolates through. The liquidus is the analytic PALEOS curve (Belonoshko et al. 2005 below 2.55 GPa, Fei et al. 2021 above, in Simon-Glatzel form), and the solidus is derived as $T_\mathrm{sol}(P) = f\,T_\mathrm{liq}(P)$ with $f$ the `mushy_zone_factor` (default 0.8). The default is the constant solidus-to-liquidus ratio ($\approx 0.809$) of the Stixrude (2014)[^cite-stixrude2014] MgSiO$_3$ melting parametrization, applied to the PALEOS liquidus instead of the Stixrude liquidus. The derived solidus is therefore a constant depression of the PALEOS liquidus, not the Stixrude solidus curve: its absolute value lies well above the Stixrude solidus at low to moderate pressure (about 2900 K against 1700 K at 20 GPa, and about 10% higher at 140 GPa) and below it above roughly 200 GPa. @@ -127,6 +126,11 @@ The melt fraction then follows from the lever rule between this solidus and liqu With `mantle_eos = "PALEOS-2phase:MgSiO3"`, the SPIDER/Aragog entropy tables use the separate solid and liquid PALEOS tables, which supply the latent-heat entropy gap across the melting curve directly rather than through a single interpolated unified table. Their phase boundaries follow the same construction as the unified case: the liquidus is the analytic PALEOS curve, and the solidus is derived as $T_\mathrm{sol}(P) = f\,T_\mathrm{liq}(P)$ with $f$ the `mushy_zone_factor`. +A run with a PALEOS EOS requires every PALEOS table it reads: the table of each PALEOS layer and, for a mantle with a PALEOS component, the MgSiO$_3$ unified table and the two-phase solid and liquid pair (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family). +When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. +A resumed run that keeps its pressure-entropy tables needs only the tables of its layers. +With `mantle_eos = "PALEOS:H2O"` or `"PALEOS:iron"`, the structure uses the density of that material while the energetics use the MgSiO$_3$ melting curves and pressure-entropy tables; the run logs one warning about this at the start of each run segment, so a resumed run logs it again. + !!! note "`mushy_zone_factor` and the two-phase structure solve" In a PROTEUS-coupled run, `load_zalmoxis_solidus_liquidus_functions` builds the `mushy_zone_factor * liquidus` solidus described above and passes it into the Zalmoxis structure solve. The same curve pair sets the SPIDER/Aragog table boundaries, the adiabatic gradient in the mushy zone, and the two-phase density (`PALEOS-2phase`, `PALEOS-API-2phase`), so `mushy_zone_factor` acts consistently in the tables and in the structure. A standalone Zalmoxis run uses its own `rock_solidus` and `rock_liquidus` keys (Stixrude 2014 by default). With those defaults a two-phase mantle does not depend on `mushy_zone_factor`; with no unified PALEOS layer elsewhere (for example a `Seager2007:iron` core), a value below 1.0 is rejected at validation. A unified PALEOS layer such as a `PALEOS:iron` core does honor the factor in its own density, so validation accepts it, but it does not change the two-phase mantle. Setting `rock_liquidus = "PALEOS-liquidus"` selects the PALEOS liquidus and derives the solidus as `mushy_zone_factor * liquidus`, as in the coupled case. diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 454400695..6e63db7fe 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -916,10 +916,6 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s mat_dicts = load_zalmoxis_material_dictionaries() - # Get unified table path (needed for melting curves and fallback) - eos_entry = mat_dicts.get(config.interior_struct.zalmoxis.mantle_eos, {}) - paleos_eos_file = eos_entry.get('eos_file', '') - mass_tot = config.planet.mass_tot or 1.0 # P_max for the Aragog phase-boundary + lookup table grid. # The grid must reach the planet's CMB pressure so the mantle @@ -962,75 +958,31 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s and os.path.isfile(liquid_eos) ) - # Aragog's per-phase P-T property tables are built from the - # two-phase solid/liquid tables whenever they are available, for - # BOTH mantle_eos = "PALEOS:MgSiO3" (unified structure) and - # "PALEOS-2phase:MgSiO3". So for the default unified config the - # structure solve uses the unified table while Aragog's densities - # come from these two-phase tables. The "already exist" path below - # reuses the cached per-run aragog_pt tables (generated once from - # the two-phase source). Only when no two-phase tables are present - # does Aragog fall back to building its tables from the unified - # table, where solid and melt share one source surface. - if has_2phase: - if not (LOOK_UP_DIR / 'density_melt.dat').is_file(): - from zalmoxis.eos_export import generate_aragog_pt_tables_2phase - - log.info('Generating phase-specific Aragog P-T tables from PALEOS-2phase') - generate_aragog_pt_tables_2phase( - solid_eos_file=solid_eos, - liquid_eos_file=liquid_eos, - P_range=(1e5, P_max), - n_P=200, - n_T=200, - output_dir=LOOK_UP_DIR, - ) - else: - log.info('PALEOS-2phase tables already exist, skipping generation') - elif not has_2phase: - # Fall back to unified table (identical solid/melt files) - from zalmoxis.eos_export import generate_aragog_pt_tables - - if paleos_eos_file and os.path.isfile(paleos_eos_file): - from proteus.interior_struct.zalmoxis import ( - load_zalmoxis_solidus_liquidus_functions, - ) - - melt_funcs = load_zalmoxis_solidus_liquidus_functions( - config.interior_struct.zalmoxis.mantle_eos, config - ) - if melt_funcs is not None: - sol_func, liq_func = melt_funcs - else: - from zalmoxis.melting_curves import ( - get_solidus_liquidus_functions, - ) - - sol_func, liq_func = get_solidus_liquidus_functions( - 'Stixrude14-solidus', 'PALEOS-liquidus' - ) - - if not (LOOK_UP_DIR / 'density_melt.dat').is_file(): - log.warning( - 'PALEOS-2phase tables not found, falling back to ' - 'unified table (entropy near melting curve may be ' - 'unreliable)' - ) - generate_aragog_pt_tables( - eos_file=paleos_eos_file, - solidus_func=sol_func, - liquidus_func=liq_func, - P_range=(1e5, P_max), - n_P=200, - n_T=200, - output_dir=LOOK_UP_DIR, - ) + # Aragog's per-phase P-T property tables come from the two-phase + # solid/liquid tables for every PALEOS mantle, also for the unified + # "PALEOS:MgSiO3" structure; a run reuses its own aragog_pt tables. + if (LOOK_UP_DIR / 'density_melt.dat').is_file(): + log.info('PALEOS-2phase tables already exist, skipping generation') + elif has_2phase: + from zalmoxis.eos_export import generate_aragog_pt_tables_2phase + + log.info('Generating phase-specific Aragog P-T tables from PALEOS-2phase') + generate_aragog_pt_tables_2phase( + solid_eos_file=solid_eos, + liquid_eos_file=liquid_eos, + P_range=(1e5, P_max), + n_P=200, + n_T=200, + output_dir=LOOK_UP_DIR, + ) else: - log.warning( - 'PALEOS EOS file not found (%s), falling back to the shipped EOS tables', - paleos_eos_file, + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + + raise ZalmoxisMissingEOSFilesError( + f'PALEOS 2-phase MgSiO3 tables {_twophase_key} not found ' + f'(solid: {solid_eos or "unset"}, liquid: {liquid_eos or "unset"}). ' + 'Download them with `proteus get interiordata --config-path `.' ) - LOOK_UP_DIR = resolve_lookup_table_dir(data_root=FWL_DATA_DIR) else: # Fetched Wolf and Bower 2018 tables; used when # interior_struct.eos_dir is None (no dynamic EOS selected) or diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 57c18fd99..e06063794 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -38,6 +38,7 @@ element_list, ) from proteus.utils.data import GetFWLData, get_zalmoxis_melting_curves +from proteus.utils.helper import paleos_companion_keys, twophase_registry_key # Set up logging log = logging.getLogger('fwl.' + __name__) @@ -1834,7 +1835,9 @@ class ZalmoxisMissingEOSFilesError(RuntimeError): """A layer's configured EOS identifier names a table file absent on disk.""" -def check_zalmoxis_eos_files(layer_eos_config: dict, mat_dicts: dict) -> None: +def check_zalmoxis_eos_files( + layer_eos_config: dict, mat_dicts: dict, paleos_companions: bool = False +) -> None: """Fail fast when a selected EOS table file is missing on disk. Walks the registry entries selected by ``layer_eos_config`` and @@ -1843,7 +1846,7 @@ def check_zalmoxis_eos_files(layer_eos_config: dict, mat_dicts: dict) -> None: one read error per shell and ends in a non-convergence failure that hides the real cause. Registry entries without an ``eos_file`` (PALEOS-API live tabulation) generate their tables on demand and - are skipped. + are skipped, except the companions, which are materialised. Parameters ---------- @@ -1851,6 +1854,8 @@ def check_zalmoxis_eos_files(layer_eos_config: dict, mat_dicts: dict) -> None: Per-layer EOS identifier strings (``'core'``, ``'mantle'``, ...). mat_dicts : dict EOS registry from :func:`load_zalmoxis_material_dictionaries`. + paleos_companions : bool + Also require the :func:`paleos_companion_keys` of the mantle. Raises ------ @@ -1858,34 +1863,52 @@ def check_zalmoxis_eos_files(layer_eos_config: dict, mat_dicts: dict) -> None: If any selected EOS table file is missing, naming every missing path and the command that downloads them. """ + selected = [ + (role, _strip_fraction_tokens(component)) + for role, identifier in layer_eos_config.items() + for component in str(identifier).split('+') + ] + companions = [] + if paleos_companions: + from zalmoxis.eos.dispatch import _is_paleos_api + + companions = paleos_companion_keys(layer_eos_config.get('mantle', '')) + selected += [('mantle', key) for key in companions] missing: set[str] = set() - for layer_role, identifier in layer_eos_config.items(): - for component in str(identifier).split('+'): - entry = mat_dicts.get(_strip_fraction_tokens(component)) - if entry is None: - # Unknown identifiers fail later with a registry error. + for layer_role, key in selected: + entry = mat_dicts.get(key) + if entry is None: + # Unknown identifiers fail later with a registry error. + continue + if key in companions and _is_paleos_api(entry): + try: + from zalmoxis.eos.paleos_api_cache import resolve_registry_entry + + resolve_registry_entry(entry) + except ImportError as exc: + missing.add(f'{key} (PALEOS-API resolver unavailable: {exc})') continue - # Flat entries carry 'eos_file' directly; nested entries map - # layer roles (core / melted_mantle / ...) to flat entries. A - # nested 'core' sub-entry is skipped only when the entry has - # other role-specific sub-entries to use instead; a single-key - # 'core' entry is read regardless of which role points at it. - if 'eos_file' in entry: - subentries = [entry] - else: - has_other_roles = any(role != 'core' for role in entry) - subentries = [ - sub - for role, sub in entry.items() - if role != 'core' or layer_role == 'core' or not has_other_roles - ] - for sub in subentries: - if not isinstance(sub, dict): - continue - for field in ('eos_file', 'adiabat_grad_file'): - path = sub.get(field) - if path and not os.path.isfile(path): - missing.add(path) + # Flat entries carry 'eos_file' directly; nested entries map + # layer roles (core / melted_mantle / ...) to flat entries. A + # nested 'core' sub-entry is skipped only when the entry has + # other role-specific sub-entries to use instead; a single-key + # 'core' entry is read regardless of which role points at it. + if 'eos_file' in entry: + subentries = [entry] + else: + has_other_roles = any(role != 'core' for role in entry) + subentries = [ + sub + for role, sub in entry.items() + if role != 'core' or layer_role == 'core' or not has_other_roles + ] + for sub in subentries: + if not isinstance(sub, dict): + continue + for field in ('eos_file', 'adiabat_grad_file'): + path = sub.get(field) + if path and not os.path.isfile(path): + missing.add(path) if missing: listing = '\n '.join(sorted(missing)) raise ZalmoxisMissingEOSFilesError( @@ -1896,26 +1919,49 @@ def check_zalmoxis_eos_files(layer_eos_config: dict, mat_dicts: dict) -> None: ) -def twophase_registry_key(mantle_eos: str) -> str: - """Return the 2-phase MgSiO3 registry key matching a mantle EOS name. +def require_paleos_tables(config: Config, outdir: str) -> None: + """Stop the run before any solve when a table of its EOS set is missing. + + Requires every table file of the core, mantle and ice-layer EOS and, + for a mantle with a PALEOS component, the MgSiO3 2-phase pair and + unified table. A resumed run that keeps its P-S tables is held only to + its layer tables. A PALEOS H2O or iron mantle gets one WARNING: its + energetics use the MgSiO3 melting curves and P-S tables. Parameters ---------- - mantle_eos : str - Configured mantle EOS name. + config : Config + Configuration object with struct.zalmoxis settings. + outdir : str + The run output directory. - Returns - ------- - str - ``'PALEOS-API-2phase:MgSiO3'`` for the PALEOS-API family, - ``'PALEOS-2phase:MgSiO3-highres'`` for the high-resolution shipped - tables, and ``'PALEOS-2phase:MgSiO3'`` otherwise. + Raises + ------ + ZalmoxisMissingEOSFilesError + Naming every missing file and the command that downloads it. """ - if mantle_eos.startswith(('PALEOS-API:', 'PALEOS-API-2phase:')): - return 'PALEOS-API-2phase:MgSiO3' - if mantle_eos == 'PALEOS-2phase:MgSiO3-highres': - return 'PALEOS-2phase:MgSiO3-highres' - return 'PALEOS-2phase:MgSiO3' + zc = config.interior_struct.zalmoxis + layers = {'core': zc.core_eos, 'mantle': zc.mantle_eos} + if zc.ice_layer_eos is not None: + layers['ice_layer'] = zc.ice_layer_eos + mat_dicts = load_zalmoxis_material_dictionaries() + kept = config.params.resume and _resumed_ps_tables( + outdir, lambda: _ps_resume_key(config, mat_dicts.get(zc.mantle_eos), mat_dicts) + ) + check_zalmoxis_eos_files(layers, mat_dicts, paleos_companions=not kept) + mantle = _strip_fraction_tokens(zc.mantle_eos) + if ( + '+' not in mantle + and mantle.startswith(PALEOS_EOS_PREFIXES) + and mantle.endswith((':H2O', ':iron')) + ): + log.warning( + 'mantle_eos=%s: the structure uses its density, while the energetics use the ' + 'MgSiO3 melting curves (PALEOS liquidus, solidus = %.2f x liquidus) and ' + 'MgSiO3 P-S tables', + zc.mantle_eos, + zc.mushy_zone_factor, + ) def resolve_2phase_mgsio3_paths(mantle_eos: str, mat_dicts: dict): @@ -2506,17 +2552,8 @@ def _ps_table_inputs(config: Config, eos_entry: dict, mat_dicts: dict): solid_eos = eos_entry['solid_mantle'].get('eos_file', '') liquid_eos = eos_entry['melted_mantle'].get('eos_file', '') else: - # Unified mantle: also look for sibling 2-phase tables to harden - # the property surfaces (avoids interpolation across the melting - # curve discontinuity in the unified table). Use the API-aware - # helper so PALEOS-API unified runs pull API 2-phase tables - # rather than silently pulling shipped Zenodo ones. - # Net effect for PALEOS:MgSiO3: the structure solve uses the unified - # table, but the per-phase property/density surfaces are taken from - # these two-phase tables when present. If they are absent the code - # below falls back to the unified table alone (entropy near the - # melting curve is then less reliable). The solidus stays synthetic - # (mushy_zone_factor * liquidus) in both cases. + # Unified mantle: the property surfaces come from the 2-phase pair of the + # same family (API or shipped), which generate_spider_tables requires. solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(mantle_eos, mat_dicts) solid_eos = solid_eos if solid_eos and os.path.isfile(solid_eos) else None @@ -2653,6 +2690,8 @@ def generate_spider_tables(config: Config, outdir: str): mantle_eos, ) return None + if paleos_companion_keys(mantle_eos): + check_zalmoxis_eos_files({'mantle': mantle_eos}, mat_dicts, paleos_companions=True) try: inputs = _ps_table_inputs(config, eos_entry, mat_dicts) except _NoPSTables as exc: diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index 7e1e09503..f454f9876 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -548,6 +548,10 @@ def start(self, *, resume: bool = False, offline: bool = False): # Download basic data download_sufficient_data(self.config) + if self.config.interior_struct.module == 'zalmoxis': + from proteus.interior_struct.zalmoxis import require_paleos_tables + + require_paleos_tables(self.config, self.directories['output']) # Initialise interior object if self.config.interior_energetics.module == 'spider': diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index d473acf71..b0a493695 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from proteus.config import Config -from proteus.utils.helper import resolve_fwl_data_dir, safe_rm +from proteus.utils.helper import paleos_companion_keys, resolve_fwl_data_dir, safe_rm from proteus.utils.phoenix_helper import phoenix_param log = logging.getLogger('fwl.' + __name__) @@ -2072,6 +2072,9 @@ def fetch_files(key, names): ): download_eos_static() + # A PALEOS mantle also reads the MgSiO3 2-phase pair and unified table. + components.update(paleos_companion_keys(mantle_eos)) + # WolfBower2018 T-dependent MgSiO3. The RTPress mantle pairs its melt table # with the Wolf & Bower solid table, so that file is needed for both. needs_wb = any(c.startswith('WolfBower2018') for c in components) diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 7f813ab62..372127765 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -11,7 +11,12 @@ import numpy as np -from proteus.utils.constants import PALEOS_REGISTRY_KEYS, element_list, element_mmw +from proteus.utils.constants import ( + PALEOS_EOS_PREFIXES, + PALEOS_REGISTRY_KEYS, + element_list, + element_mmw, +) log = logging.getLogger('fwl.' + __name__) @@ -41,6 +46,52 @@ def generates_paleos_tables(interior_struct) -> bool: return mantle in PALEOS_REGISTRY_KEYS +def twophase_registry_key(mantle_eos: str) -> str: + """Return the 2-phase MgSiO3 registry key matching a mantle EOS name. + + Parameters + ---------- + mantle_eos : str + Configured mantle EOS name. + + Returns + ------- + str + ``'PALEOS-API-2phase:MgSiO3'`` for the PALEOS-API family, + ``'PALEOS-2phase:MgSiO3-highres'`` for the high-resolution shipped + tables, and ``'PALEOS-2phase:MgSiO3'`` otherwise. + """ + if mantle_eos.startswith(('PALEOS-API:', 'PALEOS-API-2phase:')): + return 'PALEOS-API-2phase:MgSiO3' + if mantle_eos == 'PALEOS-2phase:MgSiO3-highres': + return 'PALEOS-2phase:MgSiO3-highres' + return 'PALEOS-2phase:MgSiO3' + + +def paleos_companion_keys(mantle_eos: str) -> list[str]: + """Registry keys a PALEOS mantle reads besides its own tables. + + A mantle EOS with any PALEOS component also reads the MgSiO3 2-phase + pair (P-S tables, Aragog tables, the liquidus_super anchor) and the + MgSiO3 unified table of the same family. + + Parameters + ---------- + mantle_eos : str + Configured mantle EOS, possibly a ``+``-joined mixture. + + Returns + ------- + list[str] + The 2-phase pair key and the MgSiO3 unified key, or an empty list + for a mantle without a PALEOS component. + """ + if not any(p.startswith(PALEOS_EOS_PREFIXES) for p in str(mantle_eos).split('+')): + return [] + pair = twophase_registry_key(mantle_eos) + return [pair, 'PALEOS-API:MgSiO3' if pair.startswith('PALEOS-API') else 'PALEOS:MgSiO3'] + + def resolve_fwl_data_dir() -> Path: """Return the FWL_DATA directory. diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index fac1f5e74..d169d5ddb 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1628,6 +1628,77 @@ def test_setup_solver_property_tables_follow_the_generated_set( assert len(mock_phase.call_args_list) == 2 +@pytest.mark.unit +@pytest.mark.parametrize('kept', [False, True]) +def test_setup_solver_stops_without_the_paleos_pair(tmp_path, kept): + """A PALEOS mantle whose 2-phase liquid table is absent stops at setup and names + it, instead of building its property tables from the unified table; a run that + already has its own Aragog tables keeps them.""" + from proteus.interior_energetics.aragog import AragogRunner + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + + outdir = tmp_path / 'out' + pt_dir = outdir / 'data' / 'aragog_pt' + pt_dir.mkdir(parents=True) + if kept: + for name in ('density_melt.dat', 'heat_capacity_melt.dat'): + (pt_dir / name).write_text('dummy') + config = _make_aragog_config(struct_module='zalmoxis', mantle_eos='PALEOS:MgSiO3') + config.interior_struct.eos_dir = None + config.planet.mass_tot = 1.0 + unified, solid = tmp_path / 'unified.dat', tmp_path / 'solid.dat' + unified.write_text('dummy') + solid.write_text('dummy') + liquid = tmp_path / 'liquid_absent.dat' + registry = { + 'PALEOS:MgSiO3': {'format': 'paleos_unified', 'eos_file': str(unified)}, + 'PALEOS-2phase:MgSiO3': { + 'solid_mantle': {'eos_file': str(solid)}, + 'melted_mantle': {'eos_file': str(liquid)}, + }, + } + hf_row = { + 'R_int': 6.371e6, + 'R_core': 3.48e6, + 'gravity': 9.81, + 'T_magma': 3000.0, + 'T_eqm': 255.0, + 'F_atm': 100.0, + } + interior_o = MagicMock() + interior_o.tides = np.zeros(20) + interior_o._spider_eos_dir = str(tmp_path) + + with ( + patch( + 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', + return_value=registry, + ), + patch( + 'proteus.interior_energetics.aragog._melting_curve_files', + return_value=(solid, solid), + ), + patch('zalmoxis.eos_export.generate_aragog_pt_tables') as unified_build, + patch('proteus.interior_energetics.aragog._PhaseParameters') as mock_phase, + patch('proteus.interior_energetics.aragog.Parameters'), + patch('proteus.interior_energetics.aragog.EntropySolver'), + patch('proteus.interior_energetics.aragog._cached_entropy_eos', return_value=_EOS), + ): + if kept: + AragogRunner.setup_solver(config, hf_row, interior_o, str(outdir)) + else: + with pytest.raises(ZalmoxisMissingEOSFilesError) as excinfo: + AragogRunner.setup_solver(config, hf_row, interior_o, str(outdir)) + unified_build.assert_not_called() + if kept: + dirs = {Path(c.kwargs['density']).parent for c in mock_phase.call_args_list} + assert dirs == {pt_dir} + else: + assert str(liquid) in str(excinfo.value) + assert 'proteus get interiordata' in str(excinfo.value) + assert not mock_phase.called + + @pytest.mark.unit def test_setup_solver_offline_mixture_names_the_fetch_command(tmp_path): """A PALEOS mixture without the fetched WB set stops with the fetch command.""" diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 78085e6dc..086f77336 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -792,6 +792,185 @@ def test_check_eos_files_raises_actionable_error(tmp_path): assert '--offline' in msg +def _paleos_registry(tmp_path, missing: str = '') -> dict: + """Registry with the PALEOS tables a PALEOS mantle reads; ``missing`` names the absent one.""" + files = { + name: tmp_path / f'{name}.dat' for name in ('mgsio3', 'h2o', 'iron', 'solid', 'liquid') + } + for name, path in files.items(): + if name != missing: + path.write_text('eos table stub') + unified = {'format': 'paleos_unified'} + return { + 'PALEOS:MgSiO3': {**unified, 'eos_file': str(files['mgsio3'])}, + 'PALEOS:H2O': {**unified, 'eos_file': str(files['h2o'])}, + 'PALEOS:iron': {**unified, 'eos_file': str(files['iron'])}, + 'PALEOS-2phase:MgSiO3': { + 'core': {'eos_file': str(tmp_path / 'seager_iron_absent.txt')}, + 'melted_mantle': {'eos_file': str(files['liquid']), 'format': 'paleos'}, + 'solid_mantle': {'eos_file': str(files['solid']), 'format': 'paleos'}, + }, + } + + +def _require_config(mantle_eos, *, resume=False, ice=None): + """Mock config for require_paleos_tables with a PALEOS iron core.""" + config = MagicMock() + zc = config.interior_struct.zalmoxis + zc.core_eos, zc.mantle_eos, zc.ice_layer_eos = 'PALEOS:iron', mantle_eos, ice + zc.mushy_zone_factor = 0.8 + config.params.resume = resume + config.params.offline = True + return config + + +@pytest.mark.parametrize('missing', ['mgsio3', 'solid', 'liquid']) +def test_check_eos_files_requires_the_paleos_companions(tmp_path, missing): + """With the companions required, a PALEOS mantle stops on any absent table of the + MgSiO3 set, naming that file and the fetch command; without them only its own + table counts.""" + from proteus.interior_struct.zalmoxis import ( + ZalmoxisMissingEOSFilesError, + check_zalmoxis_eos_files, + ) + + registry = _paleos_registry(tmp_path, missing) + layers = {'core': 'PALEOS:iron', 'mantle': 'PALEOS:H2O'} + with pytest.raises(ZalmoxisMissingEOSFilesError) as excinfo: + check_zalmoxis_eos_files(layers, registry, paleos_companions=True) + msg = str(excinfo.value) + assert f'{missing}.dat' in msg + assert 'proteus get interiordata' in msg + # The Seager core of the 2-phase entry is not read for a mantle role. + assert 'seager_iron_absent' not in msg + assert check_zalmoxis_eos_files(layers, registry) is None + + +def test_check_eos_files_stops_when_the_paleos_api_resolver_is_missing(tmp_path, monkeypatch): + """A PALEOS-API companion that cannot be materialised stops the run; a resolver + that writes the tables lets it pass.""" + import sys + import types + + from proteus.interior_struct.zalmoxis import ( + ZalmoxisMissingEOSFilesError, + check_zalmoxis_eos_files, + ) + + api = {'format': 'paleos_api', 'material': 'mgsio3'} + pair = {r: {'format': 'paleos_api_2phase'} for r in ('solid_mantle', 'melted_mantle')} + registry = {'PALEOS-API:MgSiO3': api, 'PALEOS-API-2phase:MgSiO3': pair} + layers = {'mantle': 'PALEOS-API:MgSiO3'} + monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', None) + with pytest.raises(ZalmoxisMissingEOSFilesError) as excinfo: + check_zalmoxis_eos_files(layers, registry, paleos_companions=True) + assert 'PALEOS-API-2phase:MgSiO3 (PALEOS-API resolver unavailable' in str(excinfo.value) + assert 'PALEOS-API:MgSiO3 (PALEOS-API resolver unavailable' in str(excinfo.value) + + table = tmp_path / 'api.dat' + table.write_text('eos table stub') + + def _resolve(entry): + for sub in [entry, *(v for v in entry.values() if isinstance(v, dict))]: + if 'format' in sub: + sub['eos_file'] = str(table) + + fake = types.ModuleType('zalmoxis.eos.paleos_api_cache') + fake.resolve_registry_entry = _resolve + monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', fake) + assert check_zalmoxis_eos_files(layers, registry, paleos_companions=True) is None + assert pair['solid_mantle']['eos_file'] == str(table) + + +@pytest.mark.parametrize('missing', ['iron', 'solid']) +def test_require_paleos_tables_stops_an_offline_run(tmp_path, monkeypatch, missing): + """An offline fresh run stops before any solve on an absent core table or pair + table and names it; with every table present it passes.""" + from proteus.interior_struct import zalmoxis as zmod + + monkeypatch.setattr( + zmod, 'load_zalmoxis_material_dictionaries', lambda: _paleos_registry(tmp_path, missing) + ) + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match=f'{missing}.dat'): + zmod.require_paleos_tables(_require_config('PALEOS:MgSiO3'), str(tmp_path)) + (tmp_path / f'{missing}.dat').write_text('eos table stub') + assert zmod.require_paleos_tables(_require_config('PALEOS:MgSiO3'), str(tmp_path)) is None + + +def test_require_paleos_tables_lets_a_resume_keep_its_tables(tmp_path, monkeypatch, caplog): + """A resumed run with kept P-S tables continues without the pair, with one WARNING; + a resumed run without kept tables stops like a fresh one.""" + from proteus.interior_struct import zalmoxis as zmod + + monkeypatch.delenv('PROTEUS_PS_CACHE_DIR', raising=False) + monkeypatch.setattr( + zmod, 'load_zalmoxis_material_dictionaries', lambda: _paleos_registry(tmp_path, 'solid') + ) + config = _require_config('PALEOS:MgSiO3', resume=True) + bare = tmp_path / 'bare' + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='solid.dat'): + zmod.require_paleos_tables(config, str(bare)) + + kept = tmp_path / 'kept' + _seed_tables(kept / 'data' / 'spider_eos', 'old-key') + with caplog.at_level('WARNING', logger='fwl.proteus.interior_struct.zalmoxis'): + assert zmod.require_paleos_tables(config, str(kept)) is None + warnings = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] + assert len(warnings) == 1 + assert 'keeps its original energetics P-S entropy tables' in warnings[0] + + +@pytest.mark.parametrize( + 'mantle, warned', + [('PALEOS:H2O', True), ('PALEOS:iron', True), ('PALEOS:MgSiO3', False)], +) +def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( + tmp_path, monkeypatch, caplog, mantle, warned +): + """A PALEOS H2O or iron mantle gets one WARNING at the start of the run that its + energetics use the MgSiO3 curves and tables; the per-solve check adds none, and an + MgSiO3 mantle gets none.""" + from proteus.interior_struct import zalmoxis as zmod + + registry = _paleos_registry(tmp_path) + monkeypatch.setattr(zmod, 'load_zalmoxis_material_dictionaries', lambda: registry) + with caplog.at_level('WARNING', logger='fwl.proteus.interior_struct.zalmoxis'): + zmod.require_paleos_tables(_require_config(mantle), str(tmp_path)) + for _ in range(3): + zmod.check_zalmoxis_eos_files({'core': 'PALEOS:iron', 'mantle': mantle}, registry) + messages = [ + r.getMessage() for r in caplog.records if 'MgSiO3 melting curves' in r.getMessage() + ] + assert len(messages) == (1 if warned else 0) + if warned: + assert f'mantle_eos={mantle}' in messages[0] + assert 'solidus = 0.80 x liquidus' in messages[0] + + +def test_generate_spider_tables_stops_on_a_missing_pair_table(tmp_path, monkeypatch): + """A PALEOS unified mantle whose 2-phase liquid table is absent stops before any + table is built, instead of building the P-S set from the unified table alone.""" + import zalmoxis.eos_export + + from proteus.interior_struct import zalmoxis as zmod + + monkeypatch.delenv('PROTEUS_PS_CACHE_DIR', raising=False) + registry = _paleos_registry(tmp_path, 'liquid') + monkeypatch.setattr(zmod, 'load_zalmoxis_material_dictionaries', lambda: registry) + bounds = MagicMock(side_effect=RuntimeError('build started')) + monkeypatch.setattr(zalmoxis.eos_export, 'generate_spider_phase_boundaries', bounds) + monkeypatch.setattr(zalmoxis.eos_export, 'generate_spider_eos_tables', bounds) + config = _require_config('PALEOS:MgSiO3') + config.planet.mass_tot = 1.0 + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='liquid.dat'): + zmod.generate_spider_tables(config, str(tmp_path / 'run')) + bounds.assert_not_called() + # Discrimination: with the pair present the build starts. + (tmp_path / 'liquid.dat').write_text('eos table stub') + with pytest.raises(RuntimeError, match='build started'): + zmod.generate_spider_tables(config, str(tmp_path / 'run')) + + def test_check_eos_files_parses_extended_mantle_strings(tmp_path): """Volatile-extended mantle EOS strings resolve to their registry keys. @@ -3323,8 +3502,10 @@ def test_resume_keeps_tables_when_the_current_eos_gives_none( run_eos = tmp_path / 'run' / 'data' / 'spider_eos' _, _, _, key = _generate_tables_stubbed(tmp_path, monkeypatch, resume=True, run=False) _seed_tables(run_eos, key) + wb = tmp_path / 'wb.dat' + wb.write_text('table') entry = { - 'not-paleos': {'format': 'WolfBower2018', 'eos_file': 'unused'}, + 'not-paleos': {'format': 'WolfBower2018', 'eos_file': str(wb)}, 'file-missing': {'format': 'paleos_unified', 'eos_file': str(tmp_path / 'gone.dat')}, 'unregistered': None, }[case] @@ -3340,8 +3521,15 @@ def test_resume_keeps_tables_when_the_current_eos_gives_none( assert reason in warnings[0] assert 'pre-existing SPIDER tables' not in caplog.text assert 'skipping' not in caplog.text - # Discrimination: a fresh run with the same EOS falls back to other tables. + # Discrimination: a fresh run stops on the missing PALEOS file; only a + # non-PALEOS or unregistered EOS leaves the tables to the energetics module. caplog.clear() + if case == 'file-missing': + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + + with pytest.raises(ZalmoxisMissingEOSFilesError, match='gone.dat'): + _generate_tables_stubbed(tmp_path, monkeypatch, resume=False, entry=entry) + return with caplog.at_level('INFO', logger='fwl.proteus.interior_struct.zalmoxis'): out, _, _, _ = _generate_tables_stubbed( tmp_path, monkeypatch, resume=False, entry=entry diff --git a/tests/test_proteus.py b/tests/test_proteus.py index bc032c32c..b79157de0 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -120,6 +120,7 @@ def _make_proteus_instance( 'proteus.utils.coupler.validate_module_versions', 'proteus.utils.coupler.UpdateStatusfile', 'proteus.utils.data.download_sufficient_data', + 'proteus.interior_struct.zalmoxis.require_paleos_tables', 'proteus.utils.terminate.print_termination_criteria', ] @@ -1529,6 +1530,7 @@ def _make_main_loop_proteus(tmp_path, *, plot_mod, write_mod, dt_write_rel, vapo _MAIN_LOOP_NOOP_PATCHES = [ 'proteus.utils.coupler.CreateLockFile', 'proteus.utils.data.download_sufficient_data', + 'proteus.interior_struct.zalmoxis.require_paleos_tables', 'proteus.interior_energetics.wrapper.solve_structure', 'proteus.utils.coupler.print_citation', 'proteus.utils.coupler.print_header', diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index b90bde688..ca1a52d43 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -2139,7 +2139,7 @@ def _fetch(key, *args, data_root=None): assert attempted[:3] == ['star.spectra.named', 'star.spectra.solar', 'star.spectra.muscles'] assert 'atmos_clim.spectral_files.honeyside.48' in attempted assert 'interior.melting_curves.wolf_bower_2018' in attempted - assert 'interior.eos.paleos_iron' in attempted + assert 'interior.eos.paleos_mgsio3' in attempted @pytest.mark.unit @@ -3174,6 +3174,10 @@ def test_get_sufficient_zalmoxis_paleos( _UNIFIED_IRON = 'paleos_iron_eos_table_pt.dat' _UNIFIED_MGSIO3 = 'paleos_mgsio3_eos_table_pt.dat' _UNIFIED_WATER = 'paleos_water_eos_table_pt.dat' +_PAIR = ( + 'paleos_mgsio3_tables_pt_proteus_liquid.dat', + 'paleos_mgsio3_tables_pt_proteus_solid.dat', +) def _fetched_datasets(mock_fetch): @@ -3273,8 +3277,8 @@ def test_download_zalmoxis_eos_rtpress_with_wolfbower_fetches_solid_once( @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_fetch, mock_file): - """PALEOS-2phase:MgSiO3 fetches the standard-resolution pair only.""" - from proteus.data import EOS_PALEOS_MGSIO3 + """PALEOS-2phase:MgSiO3 fetches the standard-resolution pair and the MgSiO3 unified table.""" + from proteus.data import EOS_PALEOS_MGSIO3, EOS_PALEOS_MGSIO3_UNIFIED from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='Seager2007:iron') @@ -3284,6 +3288,7 @@ def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_fetch, mock_file) assert _fetched_files(mock_file) == [ (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid.dat'), + (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), ] @@ -3292,8 +3297,9 @@ def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_fetch, mock_file) @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_paleos_2phase_highres(mock_static, mock_fetch, mock_file): - """PALEOS-2phase:MgSiO3-highres fetches the high-resolution pair only.""" - from proteus.data import EOS_PALEOS_MGSIO3 + """PALEOS-2phase:MgSiO3-highres fetches the high-resolution pair and the unified + table, not the standard-resolution pair.""" + from proteus.data import EOS_PALEOS_MGSIO3, EOS_PALEOS_MGSIO3_UNIFIED from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS-2phase:MgSiO3-highres', core_eos='Seager2007:iron') @@ -3302,6 +3308,7 @@ def test_download_zalmoxis_eos_paleos_2phase_highres(mock_static, mock_fetch, mo assert _fetched_files(mock_file) == [ (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid_highres.dat'), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat'), + (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), ] @@ -3310,8 +3317,14 @@ def test_download_zalmoxis_eos_paleos_2phase_highres(mock_static, mock_fetch, mo @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_paleos_unified(mock_static, mock_fetch, mock_file): - """PALEOS unified fetches each material's table from its own dataset.""" - from proteus.data import EOS_PALEOS_H2O, EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3_UNIFIED + """PALEOS unified fetches each material's table from its own dataset, plus the + MgSiO3 2-phase pair the mantle energetics read.""" + from proteus.data import ( + EOS_PALEOS_H2O, + EOS_PALEOS_IRON, + EOS_PALEOS_MGSIO3, + EOS_PALEOS_MGSIO3_UNIFIED, + ) from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS:MgSiO3', core_eos='PALEOS:iron', ice_layer_eos='PALEOS:H2O') @@ -3325,10 +3338,37 @@ def test_download_zalmoxis_eos_paleos_unified(mock_static, mock_fetch, mock_file (EOS_PALEOS_IRON, _UNIFIED_IRON), (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), (EOS_PALEOS_H2O, _UNIFIED_WATER), + *((EOS_PALEOS_MGSIO3, name) for name in _PAIR), ] ) +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_water_mantle_fetches_the_mgsio3_set( + mock_static, mock_fetch, mock_file +): + """A PALEOS water mantle also fetches the MgSiO3 unified table and 2-phase pair its + energetics read, but no Seager set and no iron table.""" + from proteus.data import EOS_PALEOS_H2O, EOS_PALEOS_MGSIO3, EOS_PALEOS_MGSIO3_UNIFIED + from proteus.utils.data import download_zalmoxis_eos + + download_zalmoxis_eos('PALEOS:H2O', core_eos='Seager2007:iron') + + mock_static.assert_called_once() + mock_fetch.assert_not_called() + assert sorted(_fetched_files(mock_file)) == sorted( + [ + (EOS_PALEOS_H2O, _UNIFIED_WATER), + (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), + *((EOS_PALEOS_MGSIO3, name) for name in _PAIR), + ] + ) + assert _UNIFIED_IRON not in {name for _, name in _fetched_files(mock_file)} + + @pytest.mark.unit @patch('proteus.data.fetch_dataset_file') @patch('proteus.data.fetch_dataset') @@ -3336,14 +3376,19 @@ def test_download_zalmoxis_eos_paleos_unified(mock_static, mock_fetch, mock_file def test_download_zalmoxis_eos_paleos_unified_only_selected_tables( mock_static, mock_fetch, mock_file ): - """Only the selected unified tables are fetched, not the whole record.""" - from proteus.data import EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3_UNIFIED + """Only the selected tables are fetched, not the whole record: no water table + and no highres pair.""" + from proteus.data import EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3, EOS_PALEOS_MGSIO3_UNIFIED from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS:MgSiO3', core_eos='PALEOS:iron') assert sorted(_fetched_files(mock_file)) == sorted( - [(EOS_PALEOS_IRON, _UNIFIED_IRON), (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3)] + [ + (EOS_PALEOS_IRON, _UNIFIED_IRON), + (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), + *((EOS_PALEOS_MGSIO3, name) for name in _PAIR), + ] ) mock_fetch.assert_not_called() @@ -3366,7 +3411,7 @@ def test_download_zalmoxis_eos_paleos_2phase_fetches_seager_fallback( mantle, PALEOS:iron core). The 2-phase tables and the unified iron table must download alongside. """ - from proteus.data import EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3 + from proteus.data import EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3, EOS_PALEOS_MGSIO3_UNIFIED from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='PALEOS:iron') @@ -3377,6 +3422,7 @@ def test_download_zalmoxis_eos_paleos_2phase_fetches_seager_fallback( (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid.dat'), (EOS_PALEOS_IRON, _UNIFIED_IRON), + (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), ] mock_fetch.assert_not_called() @@ -3446,7 +3492,12 @@ def test_download_zalmoxis_eos_api_2phase_fetches_only_seager( @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_multi_component(mock_static, mock_fetch, mock_file): """download_zalmoxis_eos handles multi-component EOS strings.""" - from proteus.data import EOS_CHABRIER_2021, EOS_PALEOS_H2O, EOS_PALEOS_MGSIO3_UNIFIED + from proteus.data import ( + EOS_CHABRIER_2021, + EOS_PALEOS_H2O, + EOS_PALEOS_MGSIO3, + EOS_PALEOS_MGSIO3_UNIFIED, + ) from proteus.utils.data import download_zalmoxis_eos # Composite mantle with PALEOS + Chabrier @@ -3458,7 +3509,11 @@ def test_download_zalmoxis_eos_multi_component(mock_static, mock_fetch, mock_fil mock_static.assert_called_once() assert _fetched_datasets(mock_fetch) == [EOS_CHABRIER_2021] assert sorted(_fetched_files(mock_file)) == sorted( - [(EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), (EOS_PALEOS_H2O, _UNIFIED_WATER)] + [ + (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), + (EOS_PALEOS_H2O, _UNIFIED_WATER), + *((EOS_PALEOS_MGSIO3, name) for name in _PAIR), + ] ) diff --git a/tests/utils/test_helper.py b/tests/utils/test_helper.py index 0eab5ffeb..1da7eac17 100644 --- a/tests/utils/test_helper.py +++ b/tests/utils/test_helper.py @@ -860,3 +860,26 @@ def test_paleos_registry_keys_match_the_zalmoxis_registry(): paleos = {k for k in registry if k.startswith(PALEOS_EOS_PREFIXES)} assert paleos == PALEOS_REGISTRY_KEYS assert all(k.startswith(PALEOS_EOS_PREFIXES) for k in PALEOS_REGISTRY_KEYS) + + +@pytest.mark.unit +def test_paleos_companion_keys_follow_the_mantle_family(): + """A mantle with any PALEOS component reads the MgSiO3 pair and unified table of + its family; a mantle without one reads neither.""" + from proteus.utils.helper import paleos_companion_keys + + shipped = ['PALEOS-2phase:MgSiO3', 'PALEOS:MgSiO3'] + assert paleos_companion_keys('PALEOS:MgSiO3') == shipped + assert paleos_companion_keys('PALEOS:H2O:1.0') == shipped + assert paleos_companion_keys('WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1') == shipped + assert paleos_companion_keys('PALEOS-2phase:MgSiO3-highres') == [ + 'PALEOS-2phase:MgSiO3-highres', + 'PALEOS:MgSiO3', + ] + assert paleos_companion_keys('PALEOS-API:iron') == [ + 'PALEOS-API-2phase:MgSiO3', + 'PALEOS-API:MgSiO3', + ] + assert paleos_companion_keys('WolfBower2018:MgSiO3') == [] + assert paleos_companion_keys('Seager2007:H2O') == [] + assert paleos_companion_keys('') == [] From 39e999cf370de8931a886b5152f42da4ac2cfa15 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 07:42:38 +0200 Subject: [PATCH 30/76] Shorten the EOS-file check comment --- src/proteus/interior_struct/zalmoxis.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index e06063794..050eca066 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1888,11 +1888,8 @@ def check_zalmoxis_eos_files( except ImportError as exc: missing.add(f'{key} (PALEOS-API resolver unavailable: {exc})') continue - # Flat entries carry 'eos_file' directly; nested entries map - # layer roles (core / melted_mantle / ...) to flat entries. A - # nested 'core' sub-entry is skipped only when the entry has - # other role-specific sub-entries to use instead; a single-key - # 'core' entry is read regardless of which role points at it. + # Nested entries map layer roles to flat entries; their 'core' sub-entry + # counts only for the core role or when it is the only sub-entry. if 'eos_file' in entry: subentries = [entry] else: From 81eeb2e64d8b3a979de8d2c89d9ebdf8972988e5 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 08:17:39 +0200 Subject: [PATCH 31/76] Check the PALEOS tables after a resume unpacks its data A resumed run now checks its EOS tables after it extracts data.tar, so P-S tables kept inside the archive count as kept; a fresh run checks at the start of its setup, before any structure or anchor solve. The H2O or iron mantle warning is limited to the exact registry keys, since a fraction-suffixed or mixed mantle string does not get PALEOS P-S tables. The fetch downloads the layer tables before the 2-phase pair, so a failed pair download no longer skips the core table. The Aragog stop names only the missing pair table, and the docs describe the MgSiO3 unified table as part of the required set rather than a table every family reads. --- docs/Reference/config/interior.md | 2 +- src/proteus/interior_energetics/aragog.py | 8 ++++-- src/proteus/interior_struct/zalmoxis.py | 17 +++++++------ src/proteus/proteus.py | 13 +++++++--- src/proteus/utils/data.py | 16 ++++++------ tests/interior_energetics/test_aragog.py | 1 + tests/interior_struct/test_zalmoxis.py | 20 ++++++++++----- tests/test_proteus.py | 31 +++++++++++++++++++++++ tests/utils/test_data.py | 10 ++++---- 9 files changed, 84 insertions(+), 34 deletions(-) diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index 9c22e5be7..c8e145cf0 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -126,7 +126,7 @@ The melt fraction then follows from the lever rule between this solidus and liqu With `mantle_eos = "PALEOS-2phase:MgSiO3"`, the SPIDER/Aragog entropy tables use the separate solid and liquid PALEOS tables, which supply the latent-heat entropy gap across the melting curve directly rather than through a single interpolated unified table. Their phase boundaries follow the same construction as the unified case: the liquidus is the analytic PALEOS curve, and the solidus is derived as $T_\mathrm{sol}(P) = f\,T_\mathrm{liq}(P)$ with $f$ the `mushy_zone_factor`. -A run with a PALEOS EOS requires every PALEOS table it reads: the table of each PALEOS layer and, for a mantle with a PALEOS component, the MgSiO$_3$ unified table and the two-phase solid and liquid pair (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family). +A run with a PALEOS EOS requires the full PALEOS table set: the table of each PALEOS layer and, for a mantle with a PALEOS component, the MgSiO$_3$ two-phase solid and liquid pair that the energetics read (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family) together with the MgSiO$_3$ unified table of the same family. When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. A resumed run that keeps its pressure-entropy tables needs only the tables of its layers. With `mantle_eos = "PALEOS:H2O"` or `"PALEOS:iron"`, the structure uses the density of that material while the energetics use the MgSiO$_3$ melting curves and pressure-entropy tables; the run logs one warning about this at the start of each run segment, so a resumed run logs it again. diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 6e63db7fe..9ce91a59f 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -978,9 +978,13 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s else: from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + missing = [ + p or 'unset' + for p in (solid_eos, liquid_eos) + if not (p and os.path.isfile(p)) + ] raise ZalmoxisMissingEOSFilesError( - f'PALEOS 2-phase MgSiO3 tables {_twophase_key} not found ' - f'(solid: {solid_eos or "unset"}, liquid: {liquid_eos or "unset"}). ' + f'PALEOS 2-phase MgSiO3 tables {_twophase_key} not found: {", ".join(missing)}. ' 'Download them with `proteus get interiordata --config-path `.' ) else: diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 050eca066..77e20ca92 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -33,6 +33,7 @@ from proteus.utils.constants import ( FEI2021_LIQUIDUS_P_CALIB_PA, PALEOS_EOS_PREFIXES, + PALEOS_REGISTRY_KEYS, M_earth, R_earth, element_list, @@ -1946,12 +1947,7 @@ def require_paleos_tables(config: Config, outdir: str) -> None: outdir, lambda: _ps_resume_key(config, mat_dicts.get(zc.mantle_eos), mat_dicts) ) check_zalmoxis_eos_files(layers, mat_dicts, paleos_companions=not kept) - mantle = _strip_fraction_tokens(zc.mantle_eos) - if ( - '+' not in mantle - and mantle.startswith(PALEOS_EOS_PREFIXES) - and mantle.endswith((':H2O', ':iron')) - ): + if zc.mantle_eos in PALEOS_REGISTRY_KEYS and zc.mantle_eos.endswith((':H2O', ':iron')): log.warning( 'mantle_eos=%s: the structure uses its density, while the energetics use the ' 'MgSiO3 melting curves (PALEOS liquidus, solidus = %.2f x liquidus) and ' @@ -2629,8 +2625,7 @@ def generate_spider_tables(config: Config, outdir: str): 1. ``paleos_unified`` (e.g. ``PALEOS:MgSiO3``): the structural backbone is the single unified P-T table covering both phases plus mushy zone, while the per-phase property surfaces are built from the sibling two-phase - solid + liquid tables when those are present (see the unified branch - below), so the densities stay resolved across the melting-curve + solid + liquid tables, so the densities stay resolved across the melting-curve discontinuity. The solidus is derived from ``mushy_zone_factor * liquidus`` (default 0.8, the constant Stixrude 2014 solidus/liquidus ratio applied to the PALEOS liquidus); the liquidus is the analytic @@ -2644,6 +2639,7 @@ def generate_spider_tables(config: Config, outdir: str): For non-PALEOS EOS types (WolfBower2018, RTPress100TPa), returns None and the caller is expected to fall back on pre-existing SPIDER tables. + A PALEOS mantle with a missing table raises instead. Parameters ---------- @@ -2660,6 +2656,11 @@ def generate_spider_tables(config: Config, outdir: str): dict or None Keys ``'eos_dir'``, ``'solidus_path'``, ``'liquidus_path'`` with absolute paths. Returns None if the mantle EOS is not PALEOS. + + Raises + ------ + ZalmoxisMissingEOSFilesError + When a table of a PALEOS mantle or its MgSiO3 companions is missing. """ from zalmoxis.eos_export import generate_spider_eos_tables, generate_spider_phase_boundaries from zalmoxis.melting_curves import ( diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index f454f9876..cac15de3e 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -548,10 +548,6 @@ def start(self, *, resume: bool = False, offline: bool = False): # Download basic data download_sufficient_data(self.config) - if self.config.interior_struct.module == 'zalmoxis': - from proteus.interior_struct.zalmoxis import require_paleos_tables - - require_paleos_tables(self.config, self.directories['output']) # Initialise interior object if self.config.interior_energetics.module == 'spider': @@ -573,6 +569,7 @@ def start(self, *, resume: bool = False, offline: bool = False): # Is the model resuming from a previous state? if not self.config.params.resume: # New simulation + self._require_paleos_tables() # SPIDER initial condition self.interior_o.ic = 1 @@ -730,6 +727,7 @@ def start(self, *, resume: bool = False, offline: bool = False): # the loose per-iteration snapshots are present on disk. log.debug('Extracting archived data files') self.extract_archives() + self._require_paleos_tables() # Resume from the latest snapshot pair that is complete and belongs # to its helpfile row. This drops rows whose _int.nc or _atm.nc a @@ -1452,6 +1450,13 @@ def start(self, *, resume: bool = False, offline: bool = False): # Print citation print_citation(self.config) + def _require_paleos_tables(self): + """Stop before any solve when a table of the Zalmoxis EOS set is missing.""" + if self.config.interior_struct.module == 'zalmoxis': + from proteus.interior_struct.zalmoxis import require_paleos_tables + + require_paleos_tables(self.config, self.directories['output']) + def extract_archives(self): """ Extract archived data files in subfolders of the output directory diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index b0a493695..729b4ce20 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -2088,14 +2088,6 @@ def fetch_files(key, names): if needs_rtpress: fetch_files(EOS_RTPRESS_100TPA, _RTPRESS_EOS_FILES) - # PALEOS 2-phase MgSiO3 (separate solid/liquid), one record holding the - # 150 pts/decade pair and the 600 pts/decade (-highres) pair; fetch only the - # selected pair, since the highres pair alone is about 1.3 GB. - if 'PALEOS-2phase:MgSiO3' in components: - fetch_files(EOS_PALEOS_MGSIO3, _PALEOS_2PHASE_FILES) - if 'PALEOS-2phase:MgSiO3-highres' in components: - fetch_files(EOS_PALEOS_MGSIO3, _PALEOS_2PHASE_HIGHRES_FILES) - # PALEOS unified tables: one dataset per material. unified = { 'PALEOS:iron': (EOS_PALEOS_IRON, 'paleos_iron_eos_table_pt.dat'), @@ -2106,6 +2098,14 @@ def fetch_files(key, names): if component in components: fetch_files(key, (table,)) + # PALEOS 2-phase MgSiO3 (separate solid/liquid), one record holding the + # 150 pts/decade pair and the 600 pts/decade (-highres) pair; fetch only the + # selected pair, since the highres pair alone is about 1.3 GB. + if 'PALEOS-2phase:MgSiO3' in components: + fetch_files(EOS_PALEOS_MGSIO3, _PALEOS_2PHASE_FILES) + if 'PALEOS-2phase:MgSiO3-highres' in components: + fetch_files(EOS_PALEOS_MGSIO3, _PALEOS_2PHASE_HIGHRES_FILES) + # Chabrier H/He if any(c.startswith('Chabrier') for c in components): fetch_dataset(EOS_CHABRIER_2021, data_root=FWL_DATA_DIR) diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index d169d5ddb..1e7e1ec37 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1695,6 +1695,7 @@ def test_setup_solver_stops_without_the_paleos_pair(tmp_path, kept): assert dirs == {pt_dir} else: assert str(liquid) in str(excinfo.value) + assert str(solid) not in str(excinfo.value) assert 'proteus get interiordata' in str(excinfo.value) assert not mock_phase.called diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 086f77336..4b70979b1 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -882,19 +882,20 @@ def _resolve(entry): assert pair['solid_mantle']['eos_file'] == str(table) -@pytest.mark.parametrize('missing', ['iron', 'solid']) +@pytest.mark.parametrize('missing', ['iron', 'solid', 'h2o']) def test_require_paleos_tables_stops_an_offline_run(tmp_path, monkeypatch, missing): - """An offline fresh run stops before any solve on an absent core table or pair + """An offline fresh run stops before any solve on an absent core, ice-layer or pair table and names it; with every table present it passes.""" from proteus.interior_struct import zalmoxis as zmod monkeypatch.setattr( zmod, 'load_zalmoxis_material_dictionaries', lambda: _paleos_registry(tmp_path, missing) ) + config = _require_config('PALEOS:MgSiO3', ice='PALEOS:H2O') with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match=f'{missing}.dat'): - zmod.require_paleos_tables(_require_config('PALEOS:MgSiO3'), str(tmp_path)) + zmod.require_paleos_tables(config, str(tmp_path)) (tmp_path / f'{missing}.dat').write_text('eos table stub') - assert zmod.require_paleos_tables(_require_config('PALEOS:MgSiO3'), str(tmp_path)) is None + assert zmod.require_paleos_tables(config, str(tmp_path)) is None def test_require_paleos_tables_lets_a_resume_keep_its_tables(tmp_path, monkeypatch, caplog): @@ -922,14 +923,21 @@ def test_require_paleos_tables_lets_a_resume_keep_its_tables(tmp_path, monkeypat @pytest.mark.parametrize( 'mantle, warned', - [('PALEOS:H2O', True), ('PALEOS:iron', True), ('PALEOS:MgSiO3', False)], + [ + ('PALEOS:H2O', True), + ('PALEOS:iron', True), + ('PALEOS:MgSiO3', False), + # Not registry keys: their energetics use the WB tables, so no warning. + ('PALEOS:H2O:1.0', False), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), + ], ) def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( tmp_path, monkeypatch, caplog, mantle, warned ): """A PALEOS H2O or iron mantle gets one WARNING at the start of the run that its energetics use the MgSiO3 curves and tables; the per-solve check adds none, and an - MgSiO3 mantle gets none.""" + MgSiO3 mantle or a string that is not a registry key gets none.""" from proteus.interior_struct import zalmoxis as zmod registry = _paleos_registry(tmp_path) diff --git a/tests/test_proteus.py b/tests/test_proteus.py index b79157de0..8df08dd0d 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -220,6 +220,37 @@ def test_proteus_resume_restores_zalmoxis_mesh(tmp_path): assert p.directories.get('mesh_convergence_steps') == 0 +@pytest.mark.unit +def test_proteus_resume_checks_the_eos_tables_after_unpacking(tmp_path): + """A resume checks its EOS tables once, after it unpacks the archived data, so + kept tables inside data.tar count.""" + p = _make_proteus_instance(tmp_path) + (tmp_path / 'data').mkdir(exist_ok=True) + order = [] + p.extract_archives = MagicMock(side_effect=lambda: order.append('extract')) + p._require_paleos_tables = MagicMock(side_effect=lambda: order.append('require')) + + _resume_with_patches(p, _make_hf_df()) + + assert order == ['extract', 'require'] + p._require_paleos_tables.assert_called_once_with() + + +@pytest.mark.unit +@pytest.mark.parametrize('struct_module, calls', [('zalmoxis', 1), ('spider', 0)]) +def test_require_paleos_tables_runs_only_for_the_zalmoxis_structure( + tmp_path, struct_module, calls +): + """Only a Zalmoxis structure reads the Zalmoxis EOS tables, so only it is checked, + with the run's output directory.""" + p = _make_proteus_instance(tmp_path, struct_module=struct_module) + with patch('proteus.interior_struct.zalmoxis.require_paleos_tables') as require: + p._require_paleos_tables() + assert require.call_count == calls + if calls: + require.assert_called_once_with(p.config, str(tmp_path)) + + @pytest.mark.unit def test_proteus_resume_no_mesh_file(tmp_path): """start(resume=True) skips mesh restoration when mesh file absent.""" diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index ca1a52d43..f6a762a51 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -2139,7 +2139,7 @@ def _fetch(key, *args, data_root=None): assert attempted[:3] == ['star.spectra.named', 'star.spectra.solar', 'star.spectra.muscles'] assert 'atmos_clim.spectral_files.honeyside.48' in attempted assert 'interior.melting_curves.wolf_bower_2018' in attempted - assert 'interior.eos.paleos_mgsio3' in attempted + assert 'interior.eos.paleos_iron' in attempted @pytest.mark.unit @@ -3286,9 +3286,9 @@ def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_fetch, mock_file) # The shared record also holds the ~1.3 GB highres pair; it must stay unfetched. mock_fetch.assert_not_called() assert _fetched_files(mock_file) == [ + (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid.dat'), - (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), ] @@ -3306,9 +3306,9 @@ def test_download_zalmoxis_eos_paleos_2phase_highres(mock_static, mock_fetch, mo mock_fetch.assert_not_called() assert _fetched_files(mock_file) == [ + (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid_highres.dat'), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat'), - (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), ] @@ -3419,10 +3419,10 @@ def test_download_zalmoxis_eos_paleos_2phase_fetches_seager_fallback( mock_static.assert_called_once() # The standard-resolution selection must not pull the ~1.3 GB highres pair. assert _fetched_files(mock_file) == [ - (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), - (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid.dat'), (EOS_PALEOS_IRON, _UNIFIED_IRON), (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), + (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), + (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid.dat'), ] mock_fetch.assert_not_called() From e0e4d829131df41578f21b67e26399ae02756db2 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 08:46:41 +0200 Subject: [PATCH 32/76] Fetch each Zalmoxis EOS file on its own and record a table stop download_zalmoxis_eos now tries every file of the set when one download fails, logs each failure and raises the first one at the end, so a single unreachable record no longer skips the reachable tables. A stop at the EOS table check writes the error status before it raises, as the other start-of-run stops do. The H2O or iron mantle warning fires only with SPIDER or Aragog energetics, which build the MgSiO3 P-S tables, and the Aragog stop names the phase whose registry path is empty. --- docs/Reference/config/interior.md | 4 +-- src/proteus/interior_energetics/aragog.py | 4 +-- src/proteus/interior_struct/zalmoxis.py | 6 +++- src/proteus/proteus.py | 11 ++++-- src/proteus/utils/data.py | 11 +++++- tests/interior_energetics/test_aragog.py | 1 + tests/interior_struct/test_zalmoxis.py | 8 +++++ tests/test_proteus.py | 43 +++++++++++++++++++++++ tests/utils/test_data.py | 41 ++++++++++++++++++++- 9 files changed, 120 insertions(+), 9 deletions(-) diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index c8e145cf0..3280fae40 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -126,10 +126,10 @@ The melt fraction then follows from the lever rule between this solidus and liqu With `mantle_eos = "PALEOS-2phase:MgSiO3"`, the SPIDER/Aragog entropy tables use the separate solid and liquid PALEOS tables, which supply the latent-heat entropy gap across the melting curve directly rather than through a single interpolated unified table. Their phase boundaries follow the same construction as the unified case: the liquidus is the analytic PALEOS curve, and the solidus is derived as $T_\mathrm{sol}(P) = f\,T_\mathrm{liq}(P)$ with $f$ the `mushy_zone_factor`. -A run with a PALEOS EOS requires the full PALEOS table set: the table of each PALEOS layer and, for a mantle with a PALEOS component, the MgSiO$_3$ two-phase solid and liquid pair that the energetics read (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family) together with the MgSiO$_3$ unified table of the same family. +A run with a PALEOS EOS requires the full PALEOS table set: the table of each PALEOS layer and, for a mantle with a PALEOS component, the MgSiO$_3$ two-phase solid and liquid pair (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family) together with the MgSiO$_3$ unified table of the same family. When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. A resumed run that keeps its pressure-entropy tables needs only the tables of its layers. -With `mantle_eos = "PALEOS:H2O"` or `"PALEOS:iron"`, the structure uses the density of that material while the energetics use the MgSiO$_3$ melting curves and pressure-entropy tables; the run logs one warning about this at the start of each run segment, so a resumed run logs it again. +With `mantle_eos = "PALEOS:H2O"` or `"PALEOS:iron"` and SPIDER or Aragog energetics, the structure uses the density of that material while the energetics use the MgSiO$_3$ melting curves and pressure-entropy tables; the run logs one warning about this at the start of each run segment, so a resumed run logs it again. !!! note "`mushy_zone_factor` and the two-phase structure solve" In a PROTEUS-coupled run, `load_zalmoxis_solidus_liquidus_functions` builds the `mushy_zone_factor * liquidus` solidus described above and passes it into the Zalmoxis structure solve. The same curve pair sets the SPIDER/Aragog table boundaries, the adiabatic gradient in the mushy zone, and the two-phase density (`PALEOS-2phase`, `PALEOS-API-2phase`), so `mushy_zone_factor` acts consistently in the tables and in the structure. diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 9ce91a59f..926b8bf0b 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -979,8 +979,8 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError missing = [ - p or 'unset' - for p in (solid_eos, liquid_eos) + p or f'{phase} table (no registry path)' + for phase, p in (('solid', solid_eos), ('liquid', liquid_eos)) if not (p and os.path.isfile(p)) ] raise ZalmoxisMissingEOSFilesError( diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 77e20ca92..8f02eb240 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1947,7 +1947,11 @@ def require_paleos_tables(config: Config, outdir: str) -> None: outdir, lambda: _ps_resume_key(config, mat_dicts.get(zc.mantle_eos), mat_dicts) ) check_zalmoxis_eos_files(layers, mat_dicts, paleos_companions=not kept) - if zc.mantle_eos in PALEOS_REGISTRY_KEYS and zc.mantle_eos.endswith((':H2O', ':iron')): + if ( + config.interior_energetics.module in ('spider', 'aragog') + and zc.mantle_eos in PALEOS_REGISTRY_KEYS + and zc.mantle_eos.endswith((':H2O', ':iron')) + ): log.warning( 'mantle_eos=%s: the structure uses its density, while the energetics use the ' 'MgSiO3 melting curves (PALEOS liquidus, solidus = %.2f x liquidus) and ' diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index cac15de3e..f678c0b2d 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -1453,9 +1453,16 @@ def start(self, *, resume: bool = False, offline: bool = False): def _require_paleos_tables(self): """Stop before any solve when a table of the Zalmoxis EOS set is missing.""" if self.config.interior_struct.module == 'zalmoxis': - from proteus.interior_struct.zalmoxis import require_paleos_tables + from proteus.interior_struct.zalmoxis import ( + ZalmoxisMissingEOSFilesError, + require_paleos_tables, + ) - require_paleos_tables(self.config, self.directories['output']) + try: + require_paleos_tables(self.config, self.directories['output']) + except ZalmoxisMissingEOSFilesError: + UpdateStatusfile(self.directories, 20) + raise def extract_archives(self): """ diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 729b4ce20..9caac164b 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -2046,9 +2046,16 @@ def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: st fetch_dataset_file, ) + failed = [] + def fetch_files(key, names): + # One unreachable file does not stop the others; the first failure is raised at the end. for name in names: - fetch_dataset_file(key, name, data_root=FWL_DATA_DIR) + try: + fetch_dataset_file(key, name, data_root=FWL_DATA_DIR) + except _fetch_errors() as exc: + log.warning('Could not fetch %s from %s: %s', name, key, exc) + failed.append(exc) all_eos = [e for e in (mantle_eos, core_eos, ice_layer_eos) if e] @@ -2137,6 +2144,8 @@ def fetch_files(key, names): '(typo or unsupported EOS family?); no data downloaded for it', c, ) + if failed: + raise failed[0] def load_melting_curve(melt_file): diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 1e7e1ec37..f3005c922 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1696,6 +1696,7 @@ def test_setup_solver_stops_without_the_paleos_pair(tmp_path, kept): else: assert str(liquid) in str(excinfo.value) assert str(solid) not in str(excinfo.value) + assert 'solid table' not in str(excinfo.value) assert 'proteus get interiordata' in str(excinfo.value) assert not mock_phase.called diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 4b70979b1..db7bf53ba 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -819,6 +819,7 @@ def _require_config(mantle_eos, *, resume=False, ice=None): zc = config.interior_struct.zalmoxis zc.core_eos, zc.mantle_eos, zc.ice_layer_eos = 'PALEOS:iron', mantle_eos, ice zc.mushy_zone_factor = 0.8 + config.interior_energetics.module = 'aragog' config.params.resume = resume config.params.offline = True return config @@ -953,6 +954,13 @@ def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( if warned: assert f'mantle_eos={mantle}' in messages[0] assert 'solidus = 0.80 x liquidus' in messages[0] + # Energetics without P-S tables build none, so no warning. + caplog.clear() + config = _require_config(mantle) + config.interior_energetics.module = 'dummy' + with caplog.at_level('WARNING', logger='fwl.proteus.interior_struct.zalmoxis'): + zmod.require_paleos_tables(config, str(tmp_path)) + assert 'MgSiO3 melting curves' not in caplog.text def test_generate_spider_tables_stops_on_a_missing_pair_table(tmp_path, monkeypatch): diff --git a/tests/test_proteus.py b/tests/test_proteus.py index 8df08dd0d..a5d370b94 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -236,6 +236,36 @@ def test_proteus_resume_checks_the_eos_tables_after_unpacking(tmp_path): p._require_paleos_tables.assert_called_once_with() +@pytest.mark.unit +def test_proteus_fresh_run_checks_the_eos_tables_before_the_structure_solve(tmp_path): + """A fresh run checks its EOS tables once, before the first structure solve.""" + p = _make_proteus_instance(tmp_path) + p.directories.update( + {k: str(tmp_path / k) for k in ('output/observe', 'output/offchem', 'output/plots')} + ) + p.config.interior_energetics.flux_guess = 100.0 + p.config.star.age_ini = 0.1 + order = [] + p._require_paleos_tables = MagicMock(side_effect=lambda: order.append('require')) + + def _solve(*args, **kwargs): + order.append('solve') + raise _StopAfterMeshRestore + + with ExitStack() as stack: + for target in _START_PATCHES: + stack.enter_context(patch(target)) + stack.enter_context(patch('proteus.proteus.CleanDir')) + stack.enter_context( + patch('proteus.interior_energetics.wrapper.solve_structure', _solve) + ) + with pytest.raises(_StopAfterMeshRestore): + p.start(resume=False, offline=True) + + assert order == ['require', 'solve'] + p._require_paleos_tables.assert_called_once_with() + + @pytest.mark.unit @pytest.mark.parametrize('struct_module, calls', [('zalmoxis', 1), ('spider', 0)]) def test_require_paleos_tables_runs_only_for_the_zalmoxis_structure( @@ -243,12 +273,25 @@ def test_require_paleos_tables_runs_only_for_the_zalmoxis_structure( ): """Only a Zalmoxis structure reads the Zalmoxis EOS tables, so only it is checked, with the run's output directory.""" + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + p = _make_proteus_instance(tmp_path, struct_module=struct_module) with patch('proteus.interior_struct.zalmoxis.require_paleos_tables') as require: p._require_paleos_tables() assert require.call_count == calls if calls: require.assert_called_once_with(p.config, str(tmp_path)) + # A stop records the error status, so the run does not read as still running. + with ( + patch( + 'proteus.interior_struct.zalmoxis.require_paleos_tables', + side_effect=ZalmoxisMissingEOSFilesError('missing'), + ), + patch('proteus.proteus.UpdateStatusfile') as status, + pytest.raises(ZalmoxisMissingEOSFilesError), + ): + p._require_paleos_tables() + status.assert_called_once_with(p.directories, 20) @pytest.mark.unit diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index f6a762a51..a1220e994 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -2139,7 +2139,12 @@ def _fetch(key, *args, data_root=None): assert attempted[:3] == ['star.spectra.named', 'star.spectra.solar', 'star.spectra.muscles'] assert 'atmos_clim.spectral_files.honeyside.48' in attempted assert 'interior.melting_curves.wolf_bower_2018' in attempted - assert 'interior.eos.paleos_iron' in attempted + # A failed file inside the EOS step does not stop the next one. + assert { + 'interior.eos.paleos_iron', + 'interior.eos.paleos_mgsio3_unified', + 'interior.eos.paleos_mgsio3', + } <= set(attempted) @pytest.mark.unit @@ -3343,6 +3348,40 @@ def test_download_zalmoxis_eos_paleos_unified(mock_static, mock_fetch, mock_file ) +@pytest.mark.unit +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_one_failed_file_does_not_stop_the_rest( + mock_static, mock_fetch, caplog +): + """An unreachable core table is logged and raised after every other table of the + set was still attempted, so one fetch attempt gets all the reachable files.""" + from fwl_io import DownloadError + + from proteus.data import EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3, EOS_PALEOS_MGSIO3_UNIFIED + from proteus.utils.data import download_zalmoxis_eos + + attempted = [] + + def _fetch(key, name, data_root=None): + attempted.append((key, name)) + if key == EOS_PALEOS_IRON: + raise DownloadError('could not obtain from any mirror') + + with patch('proteus.data.fetch_dataset_file', side_effect=_fetch): + with caplog.at_level('WARNING'), pytest.raises(DownloadError): + download_zalmoxis_eos('PALEOS:MgSiO3', core_eos='PALEOS:iron') + + assert attempted[0] == (EOS_PALEOS_IRON, _UNIFIED_IRON) + assert sorted(attempted[1:]) == sorted( + [ + (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), + *((EOS_PALEOS_MGSIO3, name) for name in _PAIR), + ] + ) + assert f'Could not fetch {_UNIFIED_IRON}' in caplog.text + + @pytest.mark.unit @patch('proteus.data.fetch_dataset_file') @patch('proteus.data.fetch_dataset') From fbf3ad95fbf6c0b0c57351f809a4e8c5dc79647e Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 08:53:53 +0200 Subject: [PATCH 33/76] Require only the PALEOS tables the chosen EOS reads The required set is the table of each PALEOS component plus, for a mantle with a PALEOS component, the MgSiO3 2-phase pair of its family. The MgSiO3 unified table is required and fetched only when a layer is PALEOS:MgSiO3, since no other family reads it, and the check no longer builds a PALEOS-API MgSiO3 unified table that nothing reads. A missing required file still stops the run. --- docs/Reference/config/interior.md | 2 +- src/proteus/interior_struct/zalmoxis.py | 10 +++--- src/proteus/utils/data.py | 2 +- src/proteus/utils/helper.py | 11 +++---- tests/interior_struct/test_zalmoxis.py | 44 ++++++++++++++++++++++--- tests/utils/test_data.py | 26 +++++++-------- tests/utils/test_helper.py | 14 +++----- 7 files changed, 66 insertions(+), 43 deletions(-) diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index 3280fae40..aacca9366 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -126,7 +126,7 @@ The melt fraction then follows from the lever rule between this solidus and liqu With `mantle_eos = "PALEOS-2phase:MgSiO3"`, the SPIDER/Aragog entropy tables use the separate solid and liquid PALEOS tables, which supply the latent-heat entropy gap across the melting curve directly rather than through a single interpolated unified table. Their phase boundaries follow the same construction as the unified case: the liquidus is the analytic PALEOS curve, and the solidus is derived as $T_\mathrm{sol}(P) = f\,T_\mathrm{liq}(P)$ with $f$ the `mushy_zone_factor`. -A run with a PALEOS EOS requires the full PALEOS table set: the table of each PALEOS layer and, for a mantle with a PALEOS component, the MgSiO$_3$ two-phase solid and liquid pair (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family) together with the MgSiO$_3$ unified table of the same family. +A run with a PALEOS EOS requires the tables the chosen EOS reads: the table of each PALEOS layer (the MgSiO$_3$ unified table only when a layer is `PALEOS:MgSiO3`) and, for a mantle with a PALEOS component, the MgSiO$_3$ two-phase solid and liquid pair (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family). When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. A resumed run that keeps its pressure-entropy tables needs only the tables of its layers. With `mantle_eos = "PALEOS:H2O"` or `"PALEOS:iron"` and SPIDER or Aragog energetics, the structure uses the density of that material while the energetics use the MgSiO$_3$ melting curves and pressure-entropy tables; the run logs one warning about this at the start of each run segment, so a resumed run logs it again. diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 8f02eb240..3d89d55da 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1921,10 +1921,10 @@ def require_paleos_tables(config: Config, outdir: str) -> None: """Stop the run before any solve when a table of its EOS set is missing. Requires every table file of the core, mantle and ice-layer EOS and, - for a mantle with a PALEOS component, the MgSiO3 2-phase pair and - unified table. A resumed run that keeps its P-S tables is held only to - its layer tables. A PALEOS H2O or iron mantle gets one WARNING: its - energetics use the MgSiO3 melting curves and P-S tables. + for a mantle with a PALEOS component, the MgSiO3 2-phase pair. A resumed + run that keeps its P-S tables is held only to its layer tables. A PALEOS + H2O or iron mantle gets one WARNING: its energetics use the MgSiO3 + melting curves and P-S tables. Parameters ---------- @@ -2664,7 +2664,7 @@ def generate_spider_tables(config: Config, outdir: str): Raises ------ ZalmoxisMissingEOSFilesError - When a table of a PALEOS mantle or its MgSiO3 companions is missing. + When a table of a PALEOS mantle or its MgSiO3 2-phase pair is missing. """ from zalmoxis.eos_export import generate_spider_eos_tables, generate_spider_phase_boundaries from zalmoxis.melting_curves import ( diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 9caac164b..4b8835b11 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -2079,7 +2079,7 @@ def fetch_files(key, names): ): download_eos_static() - # A PALEOS mantle also reads the MgSiO3 2-phase pair and unified table. + # A PALEOS mantle also reads the MgSiO3 2-phase pair. components.update(paleos_companion_keys(mantle_eos)) # WolfBower2018 T-dependent MgSiO3. The RTPress mantle pairs its melt table diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 372127765..3dd2b16e5 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -69,11 +69,10 @@ def twophase_registry_key(mantle_eos: str) -> str: def paleos_companion_keys(mantle_eos: str) -> list[str]: - """Registry keys a PALEOS mantle reads besides its own tables. + """Registry key of the table pair a PALEOS mantle reads besides its own tables. A mantle EOS with any PALEOS component also reads the MgSiO3 2-phase - pair (P-S tables, Aragog tables, the liquidus_super anchor) and the - MgSiO3 unified table of the same family. + pair of its family (P-S tables, Aragog tables, the liquidus_super anchor). Parameters ---------- @@ -83,13 +82,11 @@ def paleos_companion_keys(mantle_eos: str) -> list[str]: Returns ------- list[str] - The 2-phase pair key and the MgSiO3 unified key, or an empty list - for a mantle without a PALEOS component. + The 2-phase pair key, or an empty list for a mantle without a PALEOS component. """ if not any(p.startswith(PALEOS_EOS_PREFIXES) for p in str(mantle_eos).split('+')): return [] - pair = twophase_registry_key(mantle_eos) - return [pair, 'PALEOS-API:MgSiO3' if pair.startswith('PALEOS-API') else 'PALEOS:MgSiO3'] + return [twophase_registry_key(mantle_eos)] def resolve_fwl_data_dir() -> Path: diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index db7bf53ba..d6de00066 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -825,11 +825,11 @@ def _require_config(mantle_eos, *, resume=False, ice=None): return config -@pytest.mark.parametrize('missing', ['mgsio3', 'solid', 'liquid']) +@pytest.mark.parametrize('missing', ['solid', 'liquid']) def test_check_eos_files_requires_the_paleos_companions(tmp_path, missing): - """With the companions required, a PALEOS mantle stops on any absent table of the - MgSiO3 set, naming that file and the fetch command; without them only its own - table counts.""" + """With the companions required, a PALEOS mantle stops on an absent table of the + MgSiO3 2-phase pair, naming that file and the fetch command; without them only its + own table counts.""" from proteus.interior_struct.zalmoxis import ( ZalmoxisMissingEOSFilesError, check_zalmoxis_eos_files, @@ -847,6 +847,38 @@ def test_check_eos_files_requires_the_paleos_companions(tmp_path, missing): assert check_zalmoxis_eos_files(layers, registry) is None +@pytest.mark.parametrize( + 'mantle, stops', + [ + ('PALEOS-2phase:MgSiO3', False), + ('PALEOS:H2O', False), + ('PALEOS:MgSiO3', True), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', True), + ], +) +def test_check_eos_files_needs_the_mgsio3_unified_table_only_for_an_mgsio3_layer( + tmp_path, mantle, stops +): + """Without the MgSiO3 unified table a mantle that never reads it passes, while a + mantle with a PALEOS:MgSiO3 component stops and names the file.""" + from proteus.interior_struct.zalmoxis import ( + ZalmoxisMissingEOSFilesError, + check_zalmoxis_eos_files, + ) + + registry = _paleos_registry(tmp_path, 'mgsio3') + layers = {'core': 'PALEOS:iron', 'mantle': mantle} + if stops: + with pytest.raises(ZalmoxisMissingEOSFilesError, match='mgsio3.dat'): + check_zalmoxis_eos_files(layers, registry, paleos_companions=True) + else: + assert check_zalmoxis_eos_files(layers, registry, paleos_companions=True) is None + # The pair is still required in every case. + (tmp_path / 'solid.dat').unlink() + with pytest.raises(ZalmoxisMissingEOSFilesError, match='solid.dat'): + check_zalmoxis_eos_files(layers, registry, paleos_companions=True) + + def test_check_eos_files_stops_when_the_paleos_api_resolver_is_missing(tmp_path, monkeypatch): """A PALEOS-API companion that cannot be materialised stops the run; a resolver that writes the tables lets it pass.""" @@ -866,7 +898,8 @@ def test_check_eos_files_stops_when_the_paleos_api_resolver_is_missing(tmp_path, with pytest.raises(ZalmoxisMissingEOSFilesError) as excinfo: check_zalmoxis_eos_files(layers, registry, paleos_companions=True) assert 'PALEOS-API-2phase:MgSiO3 (PALEOS-API resolver unavailable' in str(excinfo.value) - assert 'PALEOS-API:MgSiO3 (PALEOS-API resolver unavailable' in str(excinfo.value) + # The mantle's own API table is built by the solve, not by the check. + assert 'PALEOS-API:MgSiO3 (PALEOS-API resolver unavailable' not in str(excinfo.value) table = tmp_path / 'api.dat' table.write_text('eos table stub') @@ -881,6 +914,7 @@ def _resolve(entry): monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', fake) assert check_zalmoxis_eos_files(layers, registry, paleos_companions=True) is None assert pair['solid_mantle']['eos_file'] == str(table) + assert 'eos_file' not in api @pytest.mark.parametrize('missing', ['iron', 'solid', 'h2o']) diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index a1220e994..f6f21a055 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3282,8 +3282,8 @@ def test_download_zalmoxis_eos_rtpress_with_wolfbower_fetches_solid_once( @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_fetch, mock_file): - """PALEOS-2phase:MgSiO3 fetches the standard-resolution pair and the MgSiO3 unified table.""" - from proteus.data import EOS_PALEOS_MGSIO3, EOS_PALEOS_MGSIO3_UNIFIED + """PALEOS-2phase:MgSiO3 fetches the standard-resolution pair only, not the unified table.""" + from proteus.data import EOS_PALEOS_MGSIO3 from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='Seager2007:iron') @@ -3291,7 +3291,6 @@ def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_fetch, mock_file) # The shared record also holds the ~1.3 GB highres pair; it must stay unfetched. mock_fetch.assert_not_called() assert _fetched_files(mock_file) == [ - (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid.dat'), ] @@ -3302,16 +3301,14 @@ def test_download_zalmoxis_eos_paleos_2phase(mock_static, mock_fetch, mock_file) @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_paleos_2phase_highres(mock_static, mock_fetch, mock_file): - """PALEOS-2phase:MgSiO3-highres fetches the high-resolution pair and the unified - table, not the standard-resolution pair.""" - from proteus.data import EOS_PALEOS_MGSIO3, EOS_PALEOS_MGSIO3_UNIFIED + """PALEOS-2phase:MgSiO3-highres fetches the high-resolution pair only.""" + from proteus.data import EOS_PALEOS_MGSIO3 from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS-2phase:MgSiO3-highres', core_eos='Seager2007:iron') mock_fetch.assert_not_called() assert _fetched_files(mock_file) == [ - (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid_highres.dat'), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid_highres.dat'), ] @@ -3386,12 +3383,12 @@ def _fetch(key, name, data_root=None): @patch('proteus.data.fetch_dataset_file') @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') -def test_download_zalmoxis_eos_water_mantle_fetches_the_mgsio3_set( +def test_download_zalmoxis_eos_water_mantle_fetches_the_mgsio3_pair( mock_static, mock_fetch, mock_file ): - """A PALEOS water mantle also fetches the MgSiO3 unified table and 2-phase pair its - energetics read, but no Seager set and no iron table.""" - from proteus.data import EOS_PALEOS_H2O, EOS_PALEOS_MGSIO3, EOS_PALEOS_MGSIO3_UNIFIED + """A PALEOS water mantle also fetches the MgSiO3 2-phase pair its energetics read, + but not the MgSiO3 unified table and not the iron table, which it never reads.""" + from proteus.data import EOS_PALEOS_H2O, EOS_PALEOS_MGSIO3 from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS:H2O', core_eos='Seager2007:iron') @@ -3401,11 +3398,11 @@ def test_download_zalmoxis_eos_water_mantle_fetches_the_mgsio3_set( assert sorted(_fetched_files(mock_file)) == sorted( [ (EOS_PALEOS_H2O, _UNIFIED_WATER), - (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), *((EOS_PALEOS_MGSIO3, name) for name in _PAIR), ] ) - assert _UNIFIED_IRON not in {name for _, name in _fetched_files(mock_file)} + names = {name for _, name in _fetched_files(mock_file)} + assert _UNIFIED_IRON not in names and _UNIFIED_MGSIO3 not in names @pytest.mark.unit @@ -3450,7 +3447,7 @@ def test_download_zalmoxis_eos_paleos_2phase_fetches_seager_fallback( mantle, PALEOS:iron core). The 2-phase tables and the unified iron table must download alongside. """ - from proteus.data import EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3, EOS_PALEOS_MGSIO3_UNIFIED + from proteus.data import EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3 from proteus.utils.data import download_zalmoxis_eos download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='PALEOS:iron') @@ -3459,7 +3456,6 @@ def test_download_zalmoxis_eos_paleos_2phase_fetches_seager_fallback( # The standard-resolution selection must not pull the ~1.3 GB highres pair. assert _fetched_files(mock_file) == [ (EOS_PALEOS_IRON, _UNIFIED_IRON), - (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_liquid.dat'), (EOS_PALEOS_MGSIO3, 'paleos_mgsio3_tables_pt_proteus_solid.dat'), ] diff --git a/tests/utils/test_helper.py b/tests/utils/test_helper.py index 1da7eac17..b20963d36 100644 --- a/tests/utils/test_helper.py +++ b/tests/utils/test_helper.py @@ -864,22 +864,18 @@ def test_paleos_registry_keys_match_the_zalmoxis_registry(): @pytest.mark.unit def test_paleos_companion_keys_follow_the_mantle_family(): - """A mantle with any PALEOS component reads the MgSiO3 pair and unified table of - its family; a mantle without one reads neither.""" + """A mantle with any PALEOS component reads the MgSiO3 2-phase pair of its family + and no MgSiO3 unified table; a mantle without one reads neither.""" from proteus.utils.helper import paleos_companion_keys - shipped = ['PALEOS-2phase:MgSiO3', 'PALEOS:MgSiO3'] + shipped = ['PALEOS-2phase:MgSiO3'] assert paleos_companion_keys('PALEOS:MgSiO3') == shipped assert paleos_companion_keys('PALEOS:H2O:1.0') == shipped assert paleos_companion_keys('WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1') == shipped assert paleos_companion_keys('PALEOS-2phase:MgSiO3-highres') == [ - 'PALEOS-2phase:MgSiO3-highres', - 'PALEOS:MgSiO3', - ] - assert paleos_companion_keys('PALEOS-API:iron') == [ - 'PALEOS-API-2phase:MgSiO3', - 'PALEOS-API:MgSiO3', + 'PALEOS-2phase:MgSiO3-highres' ] + assert paleos_companion_keys('PALEOS-API:iron') == ['PALEOS-API-2phase:MgSiO3'] assert paleos_companion_keys('WolfBower2018:MgSiO3') == [] assert paleos_companion_keys('Seager2007:H2O') == [] assert paleos_companion_keys('') == [] From cf19e039aadd40a732923e2e95d0ebbae868e1e1 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 09:14:19 +0200 Subject: [PATCH 34/76] Warn for a water or iron mantle on a liquidus_super adiabat With temperature_mode = liquidus_super, the initial adiabat of a PALEOS:H2O or PALEOS:iron mantle is solved on the MgSiO3 2-phase tables for every energetics module except SPIDER, so the start-of-run warning names that case too, next to the SPIDER and Aragog P-S tables. download_zalmoxis_eos also guards the Seager and Chabrier fetches, so an unreachable Seager record no longer stops the PALEOS pair and core fetches, and the Aragog stop test covers a registry entry without a path. --- docs/Reference/config/interior.md | 2 +- src/proteus/interior_struct/zalmoxis.py | 26 +++++++++++++-------- src/proteus/utils/data.py | 21 ++++++++++------- tests/interior_energetics/test_aragog.py | 16 +++++++------ tests/interior_struct/test_zalmoxis.py | 19 ++++++++++------ tests/utils/test_data.py | 29 ++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 33 deletions(-) diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index aacca9366..e19dfaf0c 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -129,7 +129,7 @@ Their phase boundaries follow the same construction as the unified case: the liq A run with a PALEOS EOS requires the tables the chosen EOS reads: the table of each PALEOS layer (the MgSiO$_3$ unified table only when a layer is `PALEOS:MgSiO3`) and, for a mantle with a PALEOS component, the MgSiO$_3$ two-phase solid and liquid pair (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family). When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. A resumed run that keeps its pressure-entropy tables needs only the tables of its layers. -With `mantle_eos = "PALEOS:H2O"` or `"PALEOS:iron"` and SPIDER or Aragog energetics, the structure uses the density of that material while the energetics use the MgSiO$_3$ melting curves and pressure-entropy tables; the run logs one warning about this at the start of each run segment, so a resumed run logs it again. +With `mantle_eos = "PALEOS:H2O"` or `"PALEOS:iron"`, the structure uses the density of that material while SPIDER or Aragog energetics use the MgSiO$_3$ melting curves and pressure-entropy tables, and `temperature_mode = "liquidus_super"` solves the initial adiabat on the MgSiO$_3$ two-phase tables; the run logs one warning that names the cases that apply at the start of each run segment, so a resumed run logs it again. !!! note "`mushy_zone_factor` and the two-phase structure solve" In a PROTEUS-coupled run, `load_zalmoxis_solidus_liquidus_functions` builds the `mushy_zone_factor * liquidus` solidus described above and passes it into the Zalmoxis structure solve. The same curve pair sets the SPIDER/Aragog table boundaries, the adiabatic gradient in the mushy zone, and the two-phase density (`PALEOS-2phase`, `PALEOS-API-2phase`), so `mushy_zone_factor` acts consistently in the tables and in the structure. diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 3d89d55da..ffb44ec4e 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1923,8 +1923,8 @@ def require_paleos_tables(config: Config, outdir: str) -> None: Requires every table file of the core, mantle and ice-layer EOS and, for a mantle with a PALEOS component, the MgSiO3 2-phase pair. A resumed run that keeps its P-S tables is held only to its layer tables. A PALEOS - H2O or iron mantle gets one WARNING: its energetics use the MgSiO3 - melting curves and P-S tables. + H2O or iron mantle gets one WARNING when its energetics or its + liquidus_super initial adiabat use MgSiO3 tables. Parameters ---------- @@ -1947,17 +1947,23 @@ def require_paleos_tables(config: Config, outdir: str) -> None: outdir, lambda: _ps_resume_key(config, mat_dicts.get(zc.mantle_eos), mat_dicts) ) check_zalmoxis_eos_files(layers, mat_dicts, paleos_companions=not kept) - if ( - config.interior_energetics.module in ('spider', 'aragog') - and zc.mantle_eos in PALEOS_REGISTRY_KEYS - and zc.mantle_eos.endswith((':H2O', ':iron')) + if zc.mantle_eos not in PALEOS_REGISTRY_KEYS or not zc.mantle_eos.endswith( + (':H2O', ':iron') ): + return + uses = [] + if config.interior_energetics.module in ('spider', 'aragog'): + uses.append( + 'the energetics use the MgSiO3 melting curves (PALEOS liquidus, ' + f'solidus = {zc.mushy_zone_factor:.2f} x liquidus) and MgSiO3 P-S tables' + ) + if config.planet.temperature_mode == 'liquidus_super': + uses.append('the liquidus_super initial adiabat is solved on the MgSiO3 2-phase tables') + if uses: log.warning( - 'mantle_eos=%s: the structure uses its density, while the energetics use the ' - 'MgSiO3 melting curves (PALEOS liquidus, solidus = %.2f x liquidus) and ' - 'MgSiO3 P-S tables', + 'mantle_eos=%s: the structure uses its density, while %s', zc.mantle_eos, - zc.mushy_zone_factor, + '; '.join(uses), ) diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 4b8835b11..2e7120fd2 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -2048,14 +2048,17 @@ def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: st failed = [] + def attempt(what, func, *args, **kwargs): + # One unreachable dataset does not stop the others; the first failure is raised at the end. + try: + func(*args, **kwargs) + except _fetch_errors() as exc: + log.warning('Could not fetch %s: %s', what, exc) + failed.append(exc) + def fetch_files(key, names): - # One unreachable file does not stop the others; the first failure is raised at the end. for name in names: - try: - fetch_dataset_file(key, name, data_root=FWL_DATA_DIR) - except _fetch_errors() as exc: - log.warning('Could not fetch %s from %s: %s', name, key, exc) - failed.append(exc) + attempt(f'{name} from {key}', fetch_dataset_file, key, name, data_root=FWL_DATA_DIR) all_eos = [e for e in (mantle_eos, core_eos, ice_layer_eos) if e] @@ -2077,7 +2080,7 @@ def fetch_files(key, names): or any(c.startswith(SEAGER_FALLBACK_FAMILIES) for c in components) or not core_eos ): - download_eos_static() + attempt('the Seager 2007 tables', download_eos_static) # A PALEOS mantle also reads the MgSiO3 2-phase pair. components.update(paleos_companion_keys(mantle_eos)) @@ -2115,7 +2118,9 @@ def fetch_files(key, names): # Chabrier H/He if any(c.startswith('Chabrier') for c in components): - fetch_dataset(EOS_CHABRIER_2021, data_root=FWL_DATA_DIR) + attempt( + 'the Chabrier 2021 tables', fetch_dataset, EOS_CHABRIER_2021, data_root=FWL_DATA_DIR + ) # Defensive warn for any component key that no handler above recognised. # PALEOS-API:* and PALEOS-API-2phase:* are intentionally not downloaded diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index f3005c922..165f8f15b 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1629,11 +1629,11 @@ def test_setup_solver_property_tables_follow_the_generated_set( @pytest.mark.unit -@pytest.mark.parametrize('kept', [False, True]) -def test_setup_solver_stops_without_the_paleos_pair(tmp_path, kept): - """A PALEOS mantle whose 2-phase liquid table is absent stops at setup and names - it, instead of building its property tables from the unified table; a run that - already has its own Aragog tables keeps them.""" +@pytest.mark.parametrize('kept, liquid_path', [(False, True), (True, True), (False, False)]) +def test_setup_solver_stops_without_the_paleos_pair(tmp_path, kept, liquid_path): + """A PALEOS mantle whose 2-phase liquid table is absent, or has no registry path, + stops at setup and names it, instead of building its property tables from the + unified table; a run that already has its own Aragog tables keeps them.""" from proteus.interior_energetics.aragog import AragogRunner from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError @@ -1654,7 +1654,7 @@ def test_setup_solver_stops_without_the_paleos_pair(tmp_path, kept): 'PALEOS:MgSiO3': {'format': 'paleos_unified', 'eos_file': str(unified)}, 'PALEOS-2phase:MgSiO3': { 'solid_mantle': {'eos_file': str(solid)}, - 'melted_mantle': {'eos_file': str(liquid)}, + 'melted_mantle': {'eos_file': str(liquid)} if liquid_path else {}, }, } hf_row = { @@ -1694,7 +1694,9 @@ def test_setup_solver_stops_without_the_paleos_pair(tmp_path, kept): dirs = {Path(c.kwargs['density']).parent for c in mock_phase.call_args_list} assert dirs == {pt_dir} else: - assert str(liquid) in str(excinfo.value) + # A registry entry without a path names the phase instead of a file. + missing = str(liquid) if liquid_path else 'liquid table (no registry path)' + assert missing in str(excinfo.value) assert str(solid) not in str(excinfo.value) assert 'solid table' not in str(excinfo.value) assert 'proteus get interiordata' in str(excinfo.value) diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index d6de00066..b6d1bc817 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -820,6 +820,7 @@ def _require_config(mantle_eos, *, resume=False, ice=None): zc.core_eos, zc.mantle_eos, zc.ice_layer_eos = 'PALEOS:iron', mantle_eos, ice zc.mushy_zone_factor = 0.8 config.interior_energetics.module = 'aragog' + config.planet.temperature_mode = 'adiabatic' config.params.resume = resume config.params.offline = True return config @@ -988,13 +989,17 @@ def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( if warned: assert f'mantle_eos={mantle}' in messages[0] assert 'solidus = 0.80 x liquidus' in messages[0] - # Energetics without P-S tables build none, so no warning. - caplog.clear() - config = _require_config(mantle) - config.interior_energetics.module = 'dummy' - with caplog.at_level('WARNING', logger='fwl.proteus.interior_struct.zalmoxis'): - zmod.require_paleos_tables(config, str(tmp_path)) - assert 'MgSiO3 melting curves' not in caplog.text + # Energetics without P-S tables build none, so no warning, unless the + # liquidus_super initial adiabat is solved on the MgSiO3 pair. + for mode, adiabat_warned in (('adiabatic', False), ('liquidus_super', warned)): + caplog.clear() + config = _require_config(mantle) + config.interior_energetics.module = 'dummy' + config.planet.temperature_mode = mode + with caplog.at_level('WARNING', logger='fwl.proteus.interior_struct.zalmoxis'): + zmod.require_paleos_tables(config, str(tmp_path)) + assert 'MgSiO3 melting curves' not in caplog.text + assert ('initial adiabat is solved on the MgSiO3' in caplog.text) is adiabat_warned def test_generate_spider_tables_stops_on_a_missing_pair_table(tmp_path, monkeypatch): diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index f6f21a055..3db51df04 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3379,6 +3379,35 @@ def _fetch(key, name, data_root=None): assert f'Could not fetch {_UNIFIED_IRON}' in caplog.text +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +def test_download_zalmoxis_eos_seager_failure_does_not_stop_the_pair( + mock_fetch, mock_file, caplog +): + """A 2-phase mantle whose Seager fallback set cannot be fetched still gets its pair + and core table attempted, then the Seager error is raised.""" + from fwl_io import DownloadError + + from proteus.data import EOS_PALEOS_IRON, EOS_PALEOS_MGSIO3 + from proteus.utils.data import download_zalmoxis_eos + + with ( + patch( + 'proteus.utils.data.download_eos_static', + side_effect=DownloadError('seager mirror down'), + ), + caplog.at_level('WARNING'), + pytest.raises(DownloadError, match='seager mirror down'), + ): + download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='PALEOS:iron') + + assert sorted(_fetched_files(mock_file)) == sorted( + [(EOS_PALEOS_IRON, _UNIFIED_IRON), *((EOS_PALEOS_MGSIO3, name) for name in _PAIR)] + ) + assert 'Could not fetch the Seager 2007 tables' in caplog.text + + @pytest.mark.unit @patch('proteus.data.fetch_dataset_file') @patch('proteus.data.fetch_dataset') From 3a2ed653ab2790e105564323e2cf2b8231479034 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 09:20:37 +0200 Subject: [PATCH 35/76] Fetch the full table set for the super-liquidus pinned tests The fixture fetches the set of the PALEOS:MgSiO3 mantle its configs name, which is the unified table plus the 2-phase pair, so the pinned solves find every table on a fresh data tree. --- tests/interior_struct/test_superliquidus_adiabat.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/interior_struct/test_superliquidus_adiabat.py b/tests/interior_struct/test_superliquidus_adiabat.py index 2c51c8fb9..95c09ab2e 100644 --- a/tests/interior_struct/test_superliquidus_adiabat.py +++ b/tests/interior_struct/test_superliquidus_adiabat.py @@ -34,10 +34,10 @@ def _cfg(name: str): @pytest.fixture(scope='module', autouse=True) def _two_phase_tables(): - """Fetch the 2-phase MgSiO3 pair the pinned solves read, and require it on disk. + """Fetch the tables the pinned solves read, and require the 2-phase pair on disk. - The solves build the adiabat from that pair when both files exist and from - the unified table the configs name otherwise; the zero-superheat pin is for the pair. + The configs name the unified MgSiO3 mantle, whose fetch also gets the pair the + adiabat is built from; the zero-superheat pin is for the pair. """ from proteus.interior_struct.zalmoxis import ( load_zalmoxis_material_dictionaries, @@ -45,7 +45,7 @@ def _two_phase_tables(): ) from proteus.utils.data import download_zalmoxis_eos - download_zalmoxis_eos('PALEOS-2phase:MgSiO3', core_eos='PALEOS:iron') + download_zalmoxis_eos('PALEOS:MgSiO3', core_eos='PALEOS:iron') tables = resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', load_zalmoxis_material_dictionaries()) solid, liquid = tables assert solid and liquid, f'2-phase MgSiO3 tables missing from FWL_DATA: {solid=}, {liquid=}' From ea5f9c4569ad227fb0c402b9166ee269c2d87c79 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 10:10:00 +0200 Subject: [PATCH 36/76] Check every EOS component, including the dissolved volatiles A mantle EOS string is now split into registry keys in one place, eos_components, which strips spaces and fraction tokens. The table check, the fetch, the 2-phase family choice and generates_paleos_tables all use it, so a spaced mixture or a fraction-suffixed single component is checked and fetched like its plain key. The 2-phase family comes from any component: a PALEOS-API component selects the API pair and a highres component the highres pair, whatever its position in the mixture. With dry_mantle = false the start check and the fetch include the dissolved-volatile EOS (PALEOS:H2O, Chabrier:H), which the structure solve adds to the mantle. Any error while building the PALEOS-API pair is now a missing-table stop that carries its reason. A PALEOS mantle with no MgSiO3 component gets the stand-in warning also as a mixture; for a mixture it names the Wolf and Bower tables and the melting_dir curves its energetics use. --- docs/Reference/config/interior.md | 4 +- src/proteus/interior_struct/zalmoxis.py | 81 +++++++++--------- src/proteus/utils/constants.py | 2 + src/proteus/utils/data.py | 31 ++++--- src/proteus/utils/helper.py | 64 +++++++++++--- .../test_liquidus_super_tables.py | 2 +- tests/interior_struct/test_zalmoxis.py | 85 +++++++++++++++++-- tests/test_cli.py | 7 +- tests/utils/test_data.py | 64 +++++++++++++- tests/utils/test_helper.py | 39 ++++++++- 10 files changed, 297 insertions(+), 82 deletions(-) diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index e19dfaf0c..0e106ea86 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -128,8 +128,10 @@ Their phase boundaries follow the same construction as the unified case: the liq A run with a PALEOS EOS requires the tables the chosen EOS reads: the table of each PALEOS layer (the MgSiO$_3$ unified table only when a layer is `PALEOS:MgSiO3`) and, for a mantle with a PALEOS component, the MgSiO$_3$ two-phase solid and liquid pair (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family). When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. +With `dry_mantle = false` it also requires the tables of the dissolved volatiles (`PALEOS:H2O` and `Chabrier:H`). A resumed run that keeps its pressure-entropy tables needs only the tables of its layers. -With `mantle_eos = "PALEOS:H2O"` or `"PALEOS:iron"`, the structure uses the density of that material while SPIDER or Aragog energetics use the MgSiO$_3$ melting curves and pressure-entropy tables, and `temperature_mode = "liquidus_super"` solves the initial adiabat on the MgSiO$_3$ two-phase tables; the run logs one warning that names the cases that apply at the start of each run segment, so a resumed run logs it again. +A PALEOS mantle with no MgSiO$_3$ component (for example `mantle_eos = "PALEOS:H2O"`, `"PALEOS:iron"`, or a mixture of the two) uses the density of its own material in the structure, while MgSiO$_3$ tables stand in elsewhere: SPIDER or Aragog energetics use the MgSiO$_3$ melting curves and pressure-entropy tables of PALEOS for a single PALEOS component, and the Wolf and Bower (2018) tables with the `melting_dir` curves for a mixture; `temperature_mode = "liquidus_super"` solves the initial adiabat on the MgSiO$_3$ two-phase tables. +The run logs one warning that names the cases that apply at the start of each run segment, so a resumed run logs it again. !!! note "`mushy_zone_factor` and the two-phase structure solve" In a PROTEUS-coupled run, `load_zalmoxis_solidus_liquidus_functions` builds the `mushy_zone_factor * liquidus` solidus described above and passes it into the Zalmoxis structure solve. The same curve pair sets the SPIDER/Aragog table boundaries, the adiabatic gradient in the mushy zone, and the two-phase density (`PALEOS-2phase`, `PALEOS-API-2phase`), so `mushy_zone_factor` acts consistently in the tables and in the structure. diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index ffb44ec4e..72677c265 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -4,7 +4,6 @@ import functools import hashlib import logging -import math import os import re import shutil @@ -33,13 +32,19 @@ from proteus.utils.constants import ( FEI2021_LIQUIDUS_P_CALIB_PA, PALEOS_EOS_PREFIXES, - PALEOS_REGISTRY_KEYS, + VOLATILE_EOS_MAP, M_earth, R_earth, element_list, ) from proteus.utils.data import GetFWLData, get_zalmoxis_melting_curves -from proteus.utils.helper import paleos_companion_keys, twophase_registry_key +from proteus.utils.helper import ( + _strip_fraction_tokens, + eos_components, + generates_paleos_tables, + paleos_companion_keys, + twophase_registry_key, +) # Set up logging log = logging.getLogger('fwl.' + __name__) @@ -211,12 +216,7 @@ def _structure_cache_key(config): ) -# Mapping from PROTEUS volatile species to Zalmoxis EOS component names. -# Only species with Zalmoxis EOS tables are included. -_VOLATILE_EOS_MAP = { - 'H2O': 'PALEOS:H2O', - 'H2': 'Chabrier:H', -} +_VOLATILE_EOS_MAP = VOLATILE_EOS_MAP def get_zalmoxis_output_filepath(outdir: str): @@ -1769,27 +1769,6 @@ def load_zalmoxis_material_dictionaries(): } -def _strip_fraction_tokens(component: str) -> str: - """Strip trailing mass-fraction tokens from an EOS component string. - - Extended mantle EOS strings carry per-component fractions, e.g. - ``'PALEOS:MgSiO3:0.9800'``; the registry key is the prefix without - the numeric token. Only finite numbers count as fraction tokens: - ``'nan'`` and ``'inf'`` parse as floats but are never written by - the EOS extension, so they stay part of the identifier. - """ - tokens = component.split(':') - while tokens: - try: - value = float(tokens[-1]) - except ValueError: - break - if not math.isfinite(value): - break - tokens.pop() - return ':'.join(tokens) - - #: EOS names whose density blend applies a configured mushy_zone_factor. #: Imported from Zalmoxis so both sides read the same list. _UNIFIED_PALEOS_MATERIALS = _PALEOS_UNIFIED_NAMES @@ -1865,9 +1844,9 @@ def check_zalmoxis_eos_files( path and the command that downloads them. """ selected = [ - (role, _strip_fraction_tokens(component)) + (role, key) for role, identifier in layer_eos_config.items() - for component in str(identifier).split('+') + for key in eos_components(identifier) ] companions = [] if paleos_companions: @@ -1886,8 +1865,8 @@ def check_zalmoxis_eos_files( from zalmoxis.eos.paleos_api_cache import resolve_registry_entry resolve_registry_entry(entry) - except ImportError as exc: - missing.add(f'{key} (PALEOS-API resolver unavailable: {exc})') + except Exception as exc: + missing.add(f'{key} (PALEOS-API tables not built: {type(exc).__name__}: {exc})') continue # Nested entries map layer roles to flat entries; their 'core' sub-entry # counts only for the core role or when it is the only sub-entry. @@ -1917,14 +1896,21 @@ def check_zalmoxis_eos_files( ) +def _mantle_entry(mantle_eos: str, mat_dicts: dict) -> dict | None: + """Registry entry of a single-component mantle EOS, or None for a mixture.""" + components = eos_components(mantle_eos) + return mat_dicts.get(components[0]) if len(components) == 1 else None + + def require_paleos_tables(config: Config, outdir: str) -> None: """Stop the run before any solve when a table of its EOS set is missing. Requires every table file of the core, mantle and ice-layer EOS and, for a mantle with a PALEOS component, the MgSiO3 2-phase pair. A resumed - run that keeps its P-S tables is held only to its layer tables. A PALEOS - H2O or iron mantle gets one WARNING when its energetics or its - liquidus_super initial adiabat use MgSiO3 tables. + run that keeps its P-S tables is held only to its layer tables. With + ``dry_mantle = false`` the dissolved-volatile tables are also required. A + PALEOS mantle without an MgSiO3 component gets one WARNING when its + energetics or its liquidus_super initial adiabat use MgSiO3 tables. Parameters ---------- @@ -1942,21 +1928,32 @@ def require_paleos_tables(config: Config, outdir: str) -> None: layers = {'core': zc.core_eos, 'mantle': zc.mantle_eos} if zc.ice_layer_eos is not None: layers['ice_layer'] = zc.ice_layer_eos + if not zc.dry_mantle: + # Dissolved volatiles join the mantle EOS during the run. + layers['volatiles'] = '+'.join(VOLATILE_EOS_MAP.values()) mat_dicts = load_zalmoxis_material_dictionaries() kept = config.params.resume and _resumed_ps_tables( - outdir, lambda: _ps_resume_key(config, mat_dicts.get(zc.mantle_eos), mat_dicts) + outdir, + lambda: _ps_resume_key(config, _mantle_entry(zc.mantle_eos, mat_dicts), mat_dicts), ) check_zalmoxis_eos_files(layers, mat_dicts, paleos_companions=not kept) - if zc.mantle_eos not in PALEOS_REGISTRY_KEYS or not zc.mantle_eos.endswith( - (':H2O', ':iron') + components = eos_components(zc.mantle_eos) + if not any(c.startswith(PALEOS_EOS_PREFIXES) for c in components) or any( + c.split(':')[1].startswith('MgSiO3') for c in components if ':' in c ): return uses = [] - if config.interior_energetics.module in ('spider', 'aragog'): + energetics = config.interior_energetics.module in ('spider', 'aragog') + if energetics and generates_paleos_tables(config.interior_struct): uses.append( 'the energetics use the MgSiO3 melting curves (PALEOS liquidus, ' f'solidus = {zc.mushy_zone_factor:.2f} x liquidus) and MgSiO3 P-S tables' ) + elif energetics: + uses.append( + 'the energetics use the Wolf and Bower (2018) MgSiO3 tables and the ' + f'melting_dir curves ({config.interior_struct.melting_dir})' + ) if config.planet.temperature_mode == 'liquidus_super': uses.append('the liquidus_super initial adiabat is solved on the MgSiO3 2-phase tables') if uses: @@ -2682,7 +2679,7 @@ def generate_spider_tables(config: Config, outdir: str): # Use FWL_DATA paths (not ZALMOXIS_ROOT) for EOS file lookup mat_dicts = load_zalmoxis_material_dictionaries() - eos_entry = mat_dicts.get(mantle_eos) + eos_entry = _mantle_entry(mantle_eos, mat_dicts) # A resumed run stays on the tables it started with; the key only feeds the warning. if config.params.resume: diff --git a/src/proteus/utils/constants.py b/src/proteus/utils/constants.py index 1c6ba6413..346847fa7 100644 --- a/src/proteus/utils/constants.py +++ b/src/proteus/utils/constants.py @@ -20,6 +20,8 @@ # the solidus from mushy_zone_factor; the config validator matches the core # and ice-layer EOS too. PALEOS_EOS_PREFIXES = ('PALEOS:', 'PALEOS-2phase:', 'PALEOS-API:', 'PALEOS-API-2phase:') +# Zalmoxis EOS component of each dissolved volatile species that has a table. +VOLATILE_EOS_MAP = {'H2O': 'PALEOS:H2O', 'H2': 'Chabrier:H'} # PALEOS keys of the Zalmoxis material registry (load_zalmoxis_material_dictionaries). PALEOS_REGISTRY_KEYS = frozenset( { diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 2e7120fd2..9291c62fb 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -18,7 +18,13 @@ if TYPE_CHECKING: from proteus.config import Config -from proteus.utils.helper import paleos_companion_keys, resolve_fwl_data_dir, safe_rm +from proteus.utils.constants import VOLATILE_EOS_MAP +from proteus.utils.helper import ( + eos_components, + paleos_companion_keys, + resolve_fwl_data_dir, + safe_rm, +) from proteus.utils.phoenix_helper import phoenix_param log = logging.getLogger('fwl.' + __name__) @@ -1767,9 +1773,9 @@ def download_zalmoxis_eos_for_config(config) -> None: Single extraction point for the per-layer EOS identifiers, shared by the start-of-run data check and ``proteus get interiordata``. No-op when the config does not select the zalmoxis structure module. The - ``'none'`` ice-layer sentinel maps to ``''`` (no ice EOS). Covers - the statically configured EOS components; volatile-extended mantle - components are resolved at runtime and fetched by the run itself. + ``'none'`` ice-layer sentinel maps to ``''`` (no ice EOS). With + ``dry_mantle = false`` it also fetches the tables of the dissolved + volatiles that the structure solve adds to the mantle EOS. """ struct_cfg = getattr(config, 'interior_struct', None) if getattr(struct_cfg, 'module', None) != 'zalmoxis': @@ -1780,6 +1786,7 @@ def download_zalmoxis_eos_for_config(config) -> None: mantle_eos=getattr(zconf, 'mantle_eos', ''), core_eos=getattr(zconf, 'core_eos', ''), ice_layer_eos='' if ice in (None, 'none') else ice, + volatile_eos='' if zconf.dry_mantle else '+'.join(VOLATILE_EOS_MAP.values()), ) @@ -2017,7 +2024,9 @@ def download_Seager_EOS(): ) -def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: str = ''): +def download_zalmoxis_eos( + mantle_eos: str, core_eos: str = '', ice_layer_eos: str = '', volatile_eos: str = '' +): """Download Zalmoxis EOS data required for the given EOS configuration. Inspects the mantle, core, and ice layer EOS identifiers and downloads @@ -2033,6 +2042,8 @@ def download_zalmoxis_eos(mantle_eos: str, core_eos: str = '', ice_layer_eos: st Core EOS identifier (e.g. ``'Seager2007:iron'``, ``'PALEOS:iron'``). ice_layer_eos : str Ice layer EOS identifier (e.g. ``'Seager2007:H2O'``, ``'PALEOS:H2O'``, or empty). + volatile_eos : str + ``+``-joined EOS components of dissolved volatiles, or empty. """ from proteus.data import ( EOS_CHABRIER_2021, @@ -2060,16 +2071,10 @@ def fetch_files(key, names): for name in names: attempt(f'{name} from {key}', fetch_dataset_file, key, name, data_root=FWL_DATA_DIR) - all_eos = [e for e in (mantle_eos, core_eos, ice_layer_eos) if e] + all_eos = [e for e in (mantle_eos, core_eos, ice_layer_eos, volatile_eos) if e] # Multi-component EOS strings: "PALEOS:MgSiO3:0.98+Chabrier:H:0.01" - components = set() - for eos_str in all_eos: - for part in eos_str.split('+'): - # Strip fraction suffix: "PALEOS:MgSiO3:0.98" -> "PALEOS:MgSiO3" - tokens = part.split(':') - if len(tokens) >= 2: - components.add(f'{tokens[0]}:{tokens[1]}') + components = {c for eos_str in all_eos for c in eos_components(eos_str)} # Seager2007 static EOS. Needed when a Seager component is selected # directly, when no core EOS is given (Seager iron is the default diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 3dd2b16e5..8476d427b 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -4,6 +4,7 @@ import glob import logging +import math import os import re import shutil @@ -21,14 +22,51 @@ log = logging.getLogger('fwl.' + __name__) +def _strip_fraction_tokens(component: str) -> str: + """Strip trailing mass-fraction tokens from an EOS component string. + + Extended mantle EOS strings carry per-component fractions, e.g. + ``'PALEOS:MgSiO3:0.9800'``; the registry key is the prefix without + the numeric token. Only finite numbers count as fraction tokens: + ``'nan'`` and ``'inf'`` parse as floats but are never written by + the EOS extension, so they stay part of the identifier. + """ + tokens = component.split(':') + while tokens: + try: + value = float(tokens[-1]) + except ValueError: + break + if not math.isfinite(value): + break + tokens.pop() + return ':'.join(tokens) + + +def eos_components(eos: str) -> list[str]: + """Registry keys of the components of a ``+``-joined EOS string. + + Parameters + ---------- + eos : str + EOS identifier, e.g. ``'PALEOS:MgSiO3:0.9 + PALEOS:H2O:0.1'``. + + Returns + ------- + list[str] + One key per component, with spaces and fraction tokens stripped. + """ + return [_strip_fraction_tokens(c.strip()) for c in str(eos).split('+') if c.strip()] + + def generates_paleos_tables(interior_struct) -> bool: """Return whether Zalmoxis generates a PALEOS table set for this structure. - Only a mantle EOS that is a PALEOS key of the Zalmoxis material registry, - under the Zalmoxis structure, gets one: generate_spider_tables looks up the - exact key. SPIDER, Aragog and the table fetch then use the PALEOS-derived - curves instead of interior_struct.melting_dir. A mixture is not a registry - key. The table files are not checked. + Only a single-component mantle EOS whose key (spaces and fraction tokens + stripped) is a PALEOS key of the Zalmoxis material registry, under the + Zalmoxis structure, gets one. SPIDER, Aragog and the table fetch then use the + PALEOS-derived curves instead of interior_struct.melting_dir. A mixture gets + none. The table files are not checked. Parameters ---------- @@ -43,7 +81,8 @@ def generates_paleos_tables(interior_struct) -> bool: if getattr(interior_struct, 'module', None) != 'zalmoxis': return False mantle = getattr(getattr(interior_struct, 'zalmoxis', None), 'mantle_eos', None) - return mantle in PALEOS_REGISTRY_KEYS + components = eos_components(mantle or '') + return len(components) == 1 and components[0] in PALEOS_REGISTRY_KEYS def twophase_registry_key(mantle_eos: str) -> str: @@ -57,13 +96,14 @@ def twophase_registry_key(mantle_eos: str) -> str: Returns ------- str - ``'PALEOS-API-2phase:MgSiO3'`` for the PALEOS-API family, - ``'PALEOS-2phase:MgSiO3-highres'`` for the high-resolution shipped - tables, and ``'PALEOS-2phase:MgSiO3'`` otherwise. + ``'PALEOS-API-2phase:MgSiO3'`` when any component is of the PALEOS-API + family, ``'PALEOS-2phase:MgSiO3-highres'`` when any component is the + high-resolution shipped key, and ``'PALEOS-2phase:MgSiO3'`` otherwise. """ - if mantle_eos.startswith(('PALEOS-API:', 'PALEOS-API-2phase:')): + components = eos_components(mantle_eos) + if any(c.startswith(('PALEOS-API:', 'PALEOS-API-2phase:')) for c in components): return 'PALEOS-API-2phase:MgSiO3' - if mantle_eos == 'PALEOS-2phase:MgSiO3-highres': + if 'PALEOS-2phase:MgSiO3-highres' in components: return 'PALEOS-2phase:MgSiO3-highres' return 'PALEOS-2phase:MgSiO3' @@ -84,7 +124,7 @@ def paleos_companion_keys(mantle_eos: str) -> list[str]: list[str] The 2-phase pair key, or an empty list for a mantle without a PALEOS component. """ - if not any(p.startswith(PALEOS_EOS_PREFIXES) for p in str(mantle_eos).split('+')): + if not any(c.startswith(PALEOS_EOS_PREFIXES) for c in eos_components(mantle_eos)): return [] return [twophase_registry_key(mantle_eos)] diff --git a/tests/interior_energetics/test_liquidus_super_tables.py b/tests/interior_energetics/test_liquidus_super_tables.py index 3043b812a..c65fd63b7 100644 --- a/tests/interior_energetics/test_liquidus_super_tables.py +++ b/tests/interior_energetics/test_liquidus_super_tables.py @@ -854,7 +854,7 @@ def _anchor(config, hf_row): ('PALEOS-2phase:MgSiO3', 'aragog', True), ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'aragog', False), ('WolfBower2018:MgSiO3', 'spider', False), - ('PALEOS:MgSiO3:1.0', 'aragog', False), + ('PALEOS:MgSiO3:1.0', 'aragog', True), ], ) def test_anchor_failure_is_deferred_only_where_the_ic_resolves_it( diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index b6d1bc817..ee50fc89c 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -816,9 +816,11 @@ def _paleos_registry(tmp_path, missing: str = '') -> dict: def _require_config(mantle_eos, *, resume=False, ice=None): """Mock config for require_paleos_tables with a PALEOS iron core.""" config = MagicMock() + config.interior_struct.module = 'zalmoxis' + config.interior_struct.melting_dir = 'Monteux-600' zc = config.interior_struct.zalmoxis zc.core_eos, zc.mantle_eos, zc.ice_layer_eos = 'PALEOS:iron', mantle_eos, ice - zc.mushy_zone_factor = 0.8 + zc.mushy_zone_factor, zc.dry_mantle = 0.8, True config.interior_energetics.module = 'aragog' config.planet.temperature_mode = 'adiabatic' config.params.resume = resume @@ -898,9 +900,11 @@ def test_check_eos_files_stops_when_the_paleos_api_resolver_is_missing(tmp_path, monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', None) with pytest.raises(ZalmoxisMissingEOSFilesError) as excinfo: check_zalmoxis_eos_files(layers, registry, paleos_companions=True) - assert 'PALEOS-API-2phase:MgSiO3 (PALEOS-API resolver unavailable' in str(excinfo.value) + assert 'PALEOS-API-2phase:MgSiO3 (PALEOS-API tables not built: ModuleNotFoundError' in str( + excinfo.value + ) # The mantle's own API table is built by the solve, not by the check. - assert 'PALEOS-API:MgSiO3 (PALEOS-API resolver unavailable' not in str(excinfo.value) + assert 'PALEOS-API:MgSiO3 (PALEOS-API' not in str(excinfo.value) table = tmp_path / 'api.dat' table.write_text('eos table stub') @@ -963,8 +967,7 @@ def test_require_paleos_tables_lets_a_resume_keep_its_tables(tmp_path, monkeypat ('PALEOS:H2O', True), ('PALEOS:iron', True), ('PALEOS:MgSiO3', False), - # Not registry keys: their energetics use the WB tables, so no warning. - ('PALEOS:H2O:1.0', False), + ('PALEOS:H2O:1.0', True), ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), ], ) @@ -1002,6 +1005,78 @@ def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( assert ('initial adiabat is solved on the MgSiO3' in caplog.text) is adiabat_warned +def test_require_paleos_tables_needs_the_volatile_tables_of_a_wet_mantle(tmp_path, monkeypatch): + """With dry_mantle = false the water table of the dissolved volatiles is required.""" + from proteus.interior_struct import zalmoxis as zmod + + monkeypatch.setattr( + zmod, 'load_zalmoxis_material_dictionaries', lambda: _paleos_registry(tmp_path, 'h2o') + ) + config = _require_config('PALEOS:MgSiO3') + assert zmod.require_paleos_tables(config, str(tmp_path)) is None + config.interior_struct.zalmoxis.dry_mantle = False + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='h2o.dat'): + zmod.require_paleos_tables(config, str(tmp_path)) + + +def test_check_eos_files_strips_spaces_in_a_mixture(tmp_path): + """Each component of a spaced mixture is checked.""" + from proteus.interior_struct.zalmoxis import ( + ZalmoxisMissingEOSFilesError, + check_zalmoxis_eos_files, + ) + + registry = _paleos_registry(tmp_path, 'h2o') + layers = {'core': 'PALEOS:iron', 'mantle': ' PALEOS:MgSiO3:0.9 + PALEOS:H2O:0.1 '} + with pytest.raises(ZalmoxisMissingEOSFilesError, match='h2o.dat'): + check_zalmoxis_eos_files(layers, registry, paleos_companions=True) + + +def test_check_eos_files_stops_when_the_paleos_api_build_fails(monkeypatch): + """Any error of the PALEOS-API table build is a missing-table stop with its reason.""" + import sys + import types + + from proteus.interior_struct.zalmoxis import ( + ZalmoxisMissingEOSFilesError, + check_zalmoxis_eos_files, + ) + + def _resolve(entry): + raise RuntimeError('grid build failed') + + fake = types.ModuleType('zalmoxis.eos.paleos_api_cache') + fake.resolve_registry_entry = _resolve + monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', fake) + pair = {r: {'format': 'paleos_api_2phase'} for r in ('solid_mantle', 'melted_mantle')} + registry = {'PALEOS-API-2phase:MgSiO3': pair} + with pytest.raises(ZalmoxisMissingEOSFilesError, match='RuntimeError: grid build failed'): + check_zalmoxis_eos_files( + {'mantle': 'PALEOS-API:MgSiO3'}, registry, paleos_companions=True + ) + + +@pytest.mark.parametrize('module', ['aragog', 'spider']) +def test_require_paleos_tables_warns_for_a_mixture_without_mgsio3( + tmp_path, monkeypatch, caplog, module +): + """A PALEOS mixture with no MgSiO3 component gets one WARNING that its energetics + use the Wolf and Bower tables and the melting_dir curves.""" + from proteus.interior_struct import zalmoxis as zmod + + monkeypatch.setattr( + zmod, 'load_zalmoxis_material_dictionaries', lambda: _paleos_registry(tmp_path) + ) + config = _require_config('PALEOS:H2O:0.5+PALEOS:iron:0.5') + config.interior_energetics.module = module + with caplog.at_level('WARNING', logger='fwl.proteus.interior_struct.zalmoxis'): + zmod.require_paleos_tables(config, str(tmp_path)) + warnings = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] + assert len(warnings) == 1 + assert 'Wolf and Bower (2018) MgSiO3 tables' in warnings[0] + assert 'melting_dir curves (Monteux-600)' in warnings[0] + + def test_generate_spider_tables_stops_on_a_missing_pair_table(tmp_path, monkeypatch): """A PALEOS unified mantle whose 2-phase liquid table is absent stops before any table is built, instead of building the P-S set from the unified table alone.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index cec2026ee..f83338b0f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -633,8 +633,8 @@ def test_get_interiordata_fetches_zalmoxis_eos(monkeypatch, tmp_path): runner = CliRunner() calls = [] - def fake_download_zalmoxis_eos(*, mantle_eos, core_eos, ice_layer_eos): - calls.append(('zalmoxis_eos', mantle_eos, core_eos, ice_layer_eos)) + def fake_download_zalmoxis_eos(*, mantle_eos, core_eos, ice_layer_eos, volatile_eos): + calls.append(('zalmoxis_eos', mantle_eos, core_eos, ice_layer_eos, volatile_eos)) fake_config = SimpleNamespace( interior_struct=SimpleNamespace( @@ -643,6 +643,7 @@ def fake_download_zalmoxis_eos(*, mantle_eos, core_eos, ice_layer_eos): mantle_eos='PALEOS:MgSiO3', core_eos='PALEOS:iron', ice_layer_eos=None, + dry_mantle=True, ), ) ) @@ -663,7 +664,7 @@ def fake_download_zalmoxis_eos(*, mantle_eos, core_eos, ice_layer_eos): assert res.exit_code == 0 # ice_layer_eos None must be coerced to '' (the downloader's no-ice value), # not passed through as None. - assert calls == [('zalmoxis_eos', 'PALEOS:MgSiO3', 'PALEOS:iron', '')] + assert calls == [('zalmoxis_eos', 'PALEOS:MgSiO3', 'PALEOS:iron', '', '')] @pytest.mark.unit diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 3db51df04..1db7ae4f7 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3001,6 +3001,7 @@ def test_get_sufficient_zalmoxis_wolf_bower( mantle_eos='WolfBower2018:MgSiO3', core_eos='Seager2007:iron', ice_layer_eos='', + volatile_eos='', ) # SPIDER dynamic EOS still downloaded separately mock_dyn.assert_called_once() @@ -3057,7 +3058,7 @@ def test_get_sufficient_dummy_structure_fetches_ps_tables_without_eos_dir( ('aragog', 'zalmoxis', None, 'PALEOS-2phase:MgSiO3', False), ('aragog', 'zalmoxis', None, 'WolfBower2018:MgSiO3', True), ('spider', 'zalmoxis', None, 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', True), - ('aragog', 'zalmoxis', None, 'PALEOS:MgSiO3:1.0', True), + ('aragog', 'zalmoxis', None, 'PALEOS:MgSiO3:1.0', False), ('aragog', 'zalmoxis', 'WolfBower2018_MgSiO3', 'PALEOS:MgSiO3', True), ('dummy', 'dummy', None, 'PALEOS:MgSiO3', False), ], @@ -3122,6 +3123,7 @@ def test_get_sufficient_zalmoxis_seager_only( mantle_eos='Seager2007:MgSiO3', core_eos='Seager2007:iron', ice_layer_eos='', + volatile_eos='', ) mock_dyn.assert_not_called() @@ -3167,6 +3169,7 @@ def test_get_sufficient_zalmoxis_paleos( mantle_eos='PALEOS:MgSiO3', core_eos='PALEOS:iron', ice_layer_eos='PALEOS:H2O', + volatile_eos='', ) mock_dyn.assert_not_called() @@ -3434,6 +3437,65 @@ def test_download_zalmoxis_eos_water_mantle_fetches_the_mgsio3_pair( assert _UNIFIED_IRON not in names and _UNIFIED_MGSIO3 not in names +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_fetches_the_volatile_tables(mock_static, mock_fetch, mock_file): + """The dissolved-volatile components add the water table and the Chabrier record.""" + from proteus.data import EOS_CHABRIER_2021, EOS_PALEOS_H2O + from proteus.utils.data import download_zalmoxis_eos + + download_zalmoxis_eos( + 'PALEOS:MgSiO3', core_eos='PALEOS:iron', volatile_eos='PALEOS:H2O+Chabrier:H' + ) + assert (EOS_PALEOS_H2O, _UNIFIED_WATER) in _fetched_files(mock_file) + assert [c.args[0] for c in mock_fetch.call_args_list] == [EOS_CHABRIER_2021] + + +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_strips_spaces_in_a_mixture(mock_static, mock_fetch, mock_file): + """A spaced mixture fetches the table of every component.""" + from proteus.data import EOS_PALEOS_H2O, EOS_PALEOS_MGSIO3_UNIFIED + from proteus.utils.data import download_zalmoxis_eos + + download_zalmoxis_eos(' PALEOS:MgSiO3:0.9 + PALEOS:H2O:0.1 ', core_eos='PALEOS:iron') + fetched = _fetched_files(mock_file) + assert (EOS_PALEOS_H2O, _UNIFIED_WATER) in fetched + assert (EOS_PALEOS_MGSIO3_UNIFIED, _UNIFIED_MGSIO3) in fetched + + +@pytest.mark.unit +@pytest.mark.parametrize('dry, volatiles', [(True, ''), (False, 'PALEOS:H2O+Chabrier:H')]) +def test_download_zalmoxis_eos_for_config_adds_the_volatiles_of_a_wet_mantle( + monkeypatch, dry, volatiles +): + """With dry_mantle = false the fetch includes the dissolved-volatile EOS.""" + from types import SimpleNamespace + + from proteus.utils import data as dmod + + calls = [] + monkeypatch.setattr(dmod, 'download_zalmoxis_eos', lambda **kw: calls.append(kw)) + zconf = SimpleNamespace( + mantle_eos='PALEOS:MgSiO3', core_eos='PALEOS:iron', ice_layer_eos=None, dry_mantle=dry + ) + dmod.download_zalmoxis_eos_for_config( + SimpleNamespace(interior_struct=SimpleNamespace(module='zalmoxis', zalmoxis=zconf)) + ) + assert calls == [ + dict( + mantle_eos='PALEOS:MgSiO3', + core_eos='PALEOS:iron', + ice_layer_eos='', + volatile_eos=volatiles, + ) + ] + + @pytest.mark.unit @patch('proteus.data.fetch_dataset_file') @patch('proteus.data.fetch_dataset') diff --git a/tests/utils/test_helper.py b/tests/utils/test_helper.py index b20963d36..136d82d63 100644 --- a/tests/utils/test_helper.py +++ b/tests/utils/test_helper.py @@ -829,16 +829,18 @@ def test_parse_subyear_time_rejects_multiple_p(): ('PALEOS:MgSiO3', True), ('PALEOS-2phase:MgSiO3-highres', True), ('PALEOS-API:MgSiO3', True), - ('PALEOS:MgSiO3:1.0', False), - ('PALEOS:MgSiO3 ', False), + # One component with a fraction token or spaces is the same registry key. + ('PALEOS:MgSiO3:1.0', True), + ('PALEOS:MgSiO3 ', True), ('PALEOS:Olivine', False), ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), ('WolfBower2018:MgSiO3', False), (None, False), ], ) -def test_generates_paleos_tables_needs_an_exact_registry_key(mantle_eos, expected): - """Only an exact PALEOS registry key under the Zalmoxis structure counts.""" +def test_generates_paleos_tables_needs_a_single_registry_key(mantle_eos, expected): + """Only one component whose key is a PALEOS registry key, under the Zalmoxis + structure, counts; a mixture or an unknown material does not.""" from types import SimpleNamespace from proteus.utils.helper import generates_paleos_tables @@ -879,3 +881,32 @@ def test_paleos_companion_keys_follow_the_mantle_family(): assert paleos_companion_keys('WolfBower2018:MgSiO3') == [] assert paleos_companion_keys('Seager2007:H2O') == [] assert paleos_companion_keys('') == [] + + +@pytest.mark.parametrize( + 'mantle_eos, key', + [ + ('PALEOS-2phase:MgSiO3-highres:1.0', 'PALEOS-2phase:MgSiO3-highres'), + ('PALEOS-2phase:MgSiO3-highres:0.9+PALEOS:H2O:0.1', 'PALEOS-2phase:MgSiO3-highres'), + ('Chabrier:H:0.03+PALEOS-API:MgSiO3:0.97', 'PALEOS-API-2phase:MgSiO3'), + (' PALEOS:H2O:0.1 + PALEOS-API:MgSiO3:0.9 ', 'PALEOS-API-2phase:MgSiO3'), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'PALEOS-2phase:MgSiO3'), + ], +) +def test_twophase_registry_key_uses_every_component(mantle_eos, key): + """The pair family comes from any component, with fraction tokens and spaces stripped.""" + from proteus.utils.helper import paleos_companion_keys, twophase_registry_key + + assert twophase_registry_key(mantle_eos) == key + assert paleos_companion_keys(mantle_eos) == [key] + + +def test_eos_components_strip_spaces_and_fractions(): + """Each component becomes its registry key.""" + from proteus.utils.helper import eos_components + + assert eos_components(' PALEOS:MgSiO3:0.9 + Chabrier:H:0.1 ') == [ + 'PALEOS:MgSiO3', + 'Chabrier:H', + ] + assert eos_components('') == [] From 584d1ef8a9e75f5ab9c00934b7c6927e74955c64 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 10:15:30 +0200 Subject: [PATCH 37/76] Prefer verified melting curves and fetch the heat-capacity tables A melting curve the manifest serves now resolves to its checksum-verified dataset; a local Melting_curves directory of that name is read only while the dataset is not on disk, and clean=True removes it together with the dataset. A name the manifest does not serve is read from its local directory, which may hold solidus_P-T.dat and liquidus_P-T.dat or solidus.dat and liquidus.dat. A spectral group that no manifest declares is read from spectral_files///.sf, and a missing file there raises the spectral-file error. download_stellar_spectra accepts Named, solar and MUSCLES only and names them when it rejects a collection. The Wolf and Bower and RTPress EOS fetches include the heat-capacity tables that the Zalmoxis registry entries read. --- docs/Reference/data.md | 11 ++- src/proteus/atmos_clim/common.py | 21 ++++-- src/proteus/utils/data.py | 110 ++++++++++++++------------- tests/atmos_clim/test_common.py | 17 +++++ tests/utils/test_data.py | 124 +++++++++++++++++++++---------- 5 files changed, 183 insertions(+), 100 deletions(-) diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 79233bdc4..1689f75d7 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -267,10 +267,13 @@ The `Monteux+600`, `Monteux-600` and `Wolf_Bower+2018` solidus/liquidus curves used by `interior_struct.melting_dir` and by Zalmoxis are fetched through fwl-io into `$FWL_DATA/interior/melting_curves//r/` (datasets `monteux_plus_600`, `monteux_minus_600` and `wolf_bower_2018`). -A curve generated locally by `tools/solidus_func.py` into -`$FWL_DATA/interior_lookup_tables/Melting_curves//` takes -precedence over the fetched copy; see [Melting curves](melting_curves.md) for -the full list of parametrizations and how to generate them. When SPIDER or +For these three names the checksum-verified fetched copy is read; a local +directory `$FWL_DATA/interior_lookup_tables/Melting_curves//` of the +same name is read only while the fetched copy is not on disk. Any other name is +read from that local directory, which holds `solidus_P-T.dat` and +`liquidus_P-T.dat` (as written by `tools/solidus_func.py`) or `solidus.dat` and +`liquidus.dat`; see [Melting curves](melting_curves.md) for the full list of +parametrizations and how to generate them. When SPIDER or Aragog read `melting_dir`, a configured curve whose files are missing stops the run with an error naming the missing files instead of switching to other curves. With `interior_struct.module = "zalmoxis"` and a single PALEOS mantle equation diff --git a/src/proteus/atmos_clim/common.py b/src/proteus/atmos_clim/common.py index d54a3b62f..648d70d6b 100644 --- a/src/proteus/atmos_clim/common.py +++ b/src/proteus/atmos_clim/common.py @@ -341,17 +341,28 @@ def get_spfile_path(fwl_dir: str, config: Config): The file lies in the version directory of the spectral-file dataset for the configured group and band count; the path may not exist before the dataset - is fetched. + is fetched. A group and band count that no manifest declares is read from + ``/spectral_files///.sf``. + + Raises + ------ + FileNotFoundError + If the group is undeclared and its local file does not exist. """ from proteus.data import dataset_dir, spectral_file_key # Get group and bands (strings) from config group, bands = get_spfile_name_and_bands(config) - return ( - os.path.join(dataset_dir(spectral_file_key(group, bands), data_root=fwl_dir), group) - + '.sf' - ) + try: + version_dir = dataset_dir(spectral_file_key(group, bands), data_root=fwl_dir) + except KeyError: + path = os.path.join(fwl_dir, 'spectral_files', group, str(bands), group) + '.sf' + log.info('Spectral file %s/%s is in no manifest; using %s', group, bands, path) + if not os.path.isfile(path): + raise FileNotFoundError("Spectral file does not exist at '%s'" % path) from None + return path + return os.path.join(version_dir, group) + '.sf' def clip_radius_to_hill(config: Config, hf_row: dict, radius: float) -> float: diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 9291c62fb..c36d3d724 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1241,13 +1241,14 @@ def resolve_melting_curve_files( ) -> tuple[Path, Path]: """Locate the P-T solidus and liquidus files of one melting curve. - A local directory ``interior_lookup_tables/Melting_curves/`` - that holds both ``solidus_P-T.dat`` and ``liquidus_P-T.dat`` is used first, - so a directory the user supplies or generates takes precedence. Otherwise a - name served through the manifest (``Monteux+600``, ``Monteux-600``, + A name served through the manifest (``Monteux+600``, ``Monteux-600``, ``Wolf_Bower+2018``) resolves to ``solidus.dat`` and ``liquidus.dat`` in its - versioned dataset directory. Any other name resolves to the local paths, - which the caller reports as missing. + checksum-verified dataset directory; a local directory + ``interior_lookup_tables/Melting_curves/`` with + ``solidus_P-T.dat`` and ``liquidus_P-T.dat`` is used only when that dataset + is not on disk. Any other name resolves to the local directory, which may + hold ``solidus_P-T.dat`` and ``liquidus_P-T.dat`` or ``solidus.dat`` and + ``liquidus.dat``. Parameters ---------- @@ -1259,22 +1260,24 @@ def resolve_melting_curve_files( Returns ------- tuple of Path - Solidus and liquidus file paths. The files are not checked to exist - when the name is served through the manifest. + Solidus and liquidus file paths; when no pair is on disk, the paths the + caller reports as missing. """ from proteus.data import dataset_dir root = GetFWLData() if data_root is None else Path(data_root) local = root / 'interior_lookup_tables' / 'Melting_curves' / melting_dir - solidus, liquidus = local / 'solidus_P-T.dat', local / 'liquidus_P-T.dat' - if solidus.is_file() and liquidus.is_file(): - return solidus, liquidus - + candidates = [(local / 'solidus_P-T.dat', local / 'liquidus_P-T.dat')] key = _melting_curve_keys().get(melting_dir) if key is None: - return solidus, liquidus - folder = dataset_dir(key, data_root=root) - return folder / 'solidus.dat', folder / 'liquidus.dat' + candidates.append((local / 'solidus.dat', local / 'liquidus.dat')) + else: + folder = dataset_dir(key, data_root=root) + candidates.insert(0, (folder / 'solidus.dat', folder / 'liquidus.dat')) + for solidus, liquidus in candidates: + if solidus.is_file() and liquidus.is_file(): + return solidus, liquidus + return candidates[0] def resolve_lookup_table_dir(data_root: Path | str | None = None) -> Path: @@ -1351,19 +1354,21 @@ def download_interior_lookuptables(clean=False): def download_melting_curves(config: Config, clean: bool = False): """Ensure the melting curve named in the configuration is available. - A local directory ``interior_lookup_tables/Melting_curves/`` - with ``solidus_P-T.dat`` and ``liquidus_P-T.dat`` is used as it is and - nothing is fetched. Otherwise ``Monteux+600``, ``Monteux-600`` and - ``Wolf_Bower+2018`` are fetched through fwl-io into - ``FWL_DATA/interior/melting_curves//r/``. + ``Monteux+600``, ``Monteux-600`` and ``Wolf_Bower+2018`` are fetched through + fwl-io into ``FWL_DATA/interior/melting_curves//r/``. Any + other name must be a local directory + ``interior_lookup_tables/Melting_curves/`` that holds + ``solidus_P-T.dat`` and ``liquidus_P-T.dat`` or ``solidus.dat`` and + ``liquidus.dat``; nothing is fetched for it. Parameters ---------- config : Config Configuration object; ``interior_struct.melting_dir`` names the curve. clean : bool - Remove the fetched dataset first, so it is fetched again. A local - directory is never removed. + For a served name, remove the fetched dataset and the local directory of + that name first, so the curve is fetched again. A local directory of a + name that is not served is never removed. Raises ------ @@ -1381,20 +1386,21 @@ def download_melting_curves(config: Config, clean: bool = False): root = GetFWLData() key = _melting_curve_keys().get(melting_dir) - if clean and key is not None: - safe_rm(str(dataset_dir(key, data_root=root))) - local = root / 'interior_lookup_tables' / 'Melting_curves' / melting_dir - if (local / 'solidus_P-T.dat').is_file() and (local / 'liquidus_P-T.dat').is_file(): - log.debug('Melting curve data already present locally: %s', local) + if key is not None: + if clean: + safe_rm(str(dataset_dir(key, data_root=root))) + safe_rm(str(local)) + fetch_dataset(key, data_root=root) return - if key is None: + solidus, liquidus = resolve_melting_curve_files(melting_dir, data_root=root) + if not (solidus.is_file() and liquidus.is_file()): raise ValueError( f"No dataset serves melting_dir='{melting_dir}' and no local melting " f'curve files were found in: {local}' ) - fetch_dataset(key, data_root=root) + log.debug('Melting curve data already present locally: %s', local) def download_stellar_spectra(*, folders: tuple[str, ...] | None = None): @@ -1410,9 +1416,8 @@ def download_stellar_spectra(*, folders: tuple[str, ...] | None = None): Parameters ---------- folders: - Specific collections to download (``'Named'``, ``'solar'``, - ``'MUSCLES'``, or a collection that is still in ``DATA_SOURCE_MAP``). - If None, downloads a minimal set that covers common configurations. + Collections to download: ``'Named'``, ``'solar'`` or ``'MUSCLES'``. + If None, downloads all three. Raises ------ @@ -1434,22 +1439,13 @@ def download_stellar_spectra(*, folders: tuple[str, ...] | None = None): if folders is None: folders = tuple(keys) - for folder in folders: - if folder not in keys and not get_data_source_info(folder): - raise ValueError(f'No data source mapping found for folder: {folder}') - - for folder in folders: - if folder in keys: - fetch_dataset(keys[folder]) - continue - source_info = get_data_source_info(folder) - download( - folder=folder, - target='stellar_spectra', - osf_id=source_info['osf_project'], - zenodo_id=source_info['zenodo_id'], - desc=f'stellar spectra ({folder})', + unknown = [folder for folder in folders if folder not in keys] + if unknown: + raise ValueError( + f'Unknown stellar spectra collection(s) {unknown}; choose from {list(keys)}' ) + for folder in folders: + fetch_dataset(keys[folder]) def _fetch_optional_dataset(key: str, desc: str) -> bool: @@ -2012,8 +2008,18 @@ def download_Seager_EOS(): # Files Zalmoxis reads from each dataset whose record holds more than it needs. -_WOLF_BOWER_EOS_FILES = ('density_melt.dat', 'adiabat_temp_grad_melt.dat', 'density_solid.dat') -_RTPRESS_EOS_FILES = ('density_melt.dat', 'adiabat_temp_grad_melt.dat') +_WOLF_BOWER_SOLID_FILES = ('density_solid.dat', 'heat_capacity_solid.dat') +_WOLF_BOWER_EOS_FILES = ( + 'density_melt.dat', + 'adiabat_temp_grad_melt.dat', + 'heat_capacity_melt.dat', + *_WOLF_BOWER_SOLID_FILES, +) +_RTPRESS_EOS_FILES = ( + 'density_melt.dat', + 'adiabat_temp_grad_melt.dat', + 'heat_capacity_melt.dat', +) _PALEOS_2PHASE_FILES = ( 'paleos_mgsio3_tables_pt_proteus_liquid.dat', 'paleos_mgsio3_tables_pt_proteus_solid.dat', @@ -2090,14 +2096,14 @@ def fetch_files(key, names): # A PALEOS mantle also reads the MgSiO3 2-phase pair. components.update(paleos_companion_keys(mantle_eos)) - # WolfBower2018 T-dependent MgSiO3. The RTPress mantle pairs its melt table - # with the Wolf & Bower solid table, so that file is needed for both. + # WolfBower2018 T-dependent MgSiO3. The RTPress mantle pairs its melt tables + # with the Wolf & Bower solid tables, so those files are needed for both. needs_wb = any(c.startswith('WolfBower2018') for c in components) needs_rtpress = any(c.startswith('RTPress100TPa') for c in components) if needs_wb: fetch_files(EOS_WOLF_BOWER_2018, _WOLF_BOWER_EOS_FILES) elif needs_rtpress: - fetch_files(EOS_WOLF_BOWER_2018, ('density_solid.dat',)) + fetch_files(EOS_WOLF_BOWER_2018, _WOLF_BOWER_SOLID_FILES) # RTPress 100 TPa extended melt if needs_rtpress: diff --git a/tests/atmos_clim/test_common.py b/tests/atmos_clim/test_common.py index 7f8908d14..072b27a58 100644 --- a/tests/atmos_clim/test_common.py +++ b/tests/atmos_clim/test_common.py @@ -634,6 +634,23 @@ def test_spfile_helpers(tmp_path): ) +@pytest.mark.unit +def test_get_spfile_path_reads_an_undeclared_group_from_the_local_tree(tmp_path, caplog): + """A group that no manifest declares is read from spectral_files//, + with an INFO line; an absent local file raises the spectral-file error.""" + conf = MagicMock() + conf.atmos_clim.spectral_group = 'MyGroup' + conf.atmos_clim.spectral_bands = '48' + local = tmp_path / 'spectral_files' / 'MyGroup' / '48' / 'MyGroup.sf' + with pytest.raises(FileNotFoundError, match='Spectral file does not exist at'): + get_spfile_path(str(tmp_path), conf) + local.parent.mkdir(parents=True) + local.write_text('sf') + with caplog.at_level('INFO', logger='fwl.proteus.atmos_clim.common'): + assert get_spfile_path(str(tmp_path), conf) == str(local) + assert 'is in no manifest' in caplog.text + + # --------------------------------------------------------------------------- # Coverage for previously-untested error branches: missing NetCDF file, # archived-data warning. diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 1db7ae4f7..a320c09bc 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -1726,6 +1726,9 @@ def test_download_interior_lookuptables_clean(mock_fetch, tmp_path, monkeypatch) mock_fetch.assert_called_once_with(MELTING_WOLF_BOWER_2018, data_root=tmp_path) +_LEGACY_CURVES = ('solidus.dat', 'liquidus.dat') + + @pytest.mark.unit @pytest.mark.parametrize( 'name, attr', @@ -1755,8 +1758,11 @@ def test_download_melting_curves_fetches_known_name( @pytest.mark.unit @patch('proteus.data.fetch_dataset') -def test_download_melting_curves_local_dir_wins(mock_fetch, tmp_path, monkeypatch): - """A local directory with both P-T files is used and nothing is fetched.""" +def test_download_melting_curves_served_name_fetches_despite_a_local_dir( + mock_fetch, tmp_path, monkeypatch +): + """A served name is fetched although a local directory holds both P-T files.""" + from proteus.data import MELTING_WOLF_BOWER_2018 from proteus.utils.data import download_melting_curves monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) @@ -1767,9 +1773,9 @@ def test_download_melting_curves_local_dir_wins(mock_fetch, tmp_path, monkeypatc config = MagicMock() config.interior_struct.melting_dir = 'Wolf_Bower+2018' - download_melting_curves(config, clean=True) + download_melting_curves(config, clean=False) - mock_fetch.assert_not_called() + mock_fetch.assert_called_once_with(MELTING_WOLF_BOWER_2018, data_root=tmp_path) assert (local / 'solidus_P-T.dat').read_text() == 'dummy\n' @@ -1812,14 +1818,18 @@ def test_download_melting_curves_unknown_name_raises(mock_fetch, tmp_path, monke @pytest.mark.unit @patch('proteus.data.fetch_dataset') -def test_download_melting_curves_unknown_name_local_dir_ok(mock_fetch, tmp_path, monkeypatch): - """A free-form local directory with both P-T files is accepted without a fetch.""" +@pytest.mark.parametrize('names', [('solidus_P-T.dat', 'liquidus_P-T.dat'), _LEGACY_CURVES]) +def test_download_melting_curves_unknown_name_local_dir_ok( + mock_fetch, tmp_path, monkeypatch, names +): + """A free-form local directory with either pair of names is accepted without a + fetch, and clean=True keeps it.""" from proteus.utils.data import download_melting_curves monkeypatch.setattr('proteus.utils.data.GetFWLData', lambda: tmp_path) local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'MyCurve' local.mkdir(parents=True) - for name in ('solidus_P-T.dat', 'liquidus_P-T.dat'): + for name in names: (local / name).write_text('dummy\n') config = MagicMock() config.interior_struct.melting_dir = 'MyCurve' @@ -1827,15 +1837,15 @@ def test_download_melting_curves_unknown_name_local_dir_ok(mock_fetch, tmp_path, download_melting_curves(config, clean=True) mock_fetch.assert_not_called() - assert (local / 'liquidus_P-T.dat').read_text() == 'dummy\n' + assert (local / names[1]).read_text() == 'dummy\n' @pytest.mark.unit @patch('proteus.data.fetch_dataset') -def test_download_melting_curves_clean_removes_only_fetched_dataset( +def test_download_melting_curves_clean_removes_the_served_dataset_and_local_dir( mock_fetch, tmp_path, monkeypatch ): - """``clean=True`` removes the fetched dataset and leaves a local directory alone.""" + """``clean=True`` on a served name removes the fetched dataset and the local directory.""" from proteus.data import MELTING_WOLF_BOWER_2018, dataset_dir from proteus.utils.data import download_melting_curves @@ -1854,7 +1864,7 @@ def test_download_melting_curves_clean_removes_only_fetched_dataset( download_melting_curves(config, clean=True) assert not stale.exists() - assert keep.read_text() == 'mine' + assert not local.exists() mock_fetch.assert_called_once_with(MELTING_WOLF_BOWER_2018, data_root=tmp_path) @@ -1876,19 +1886,29 @@ def test_resolve_melting_curve_files_manifest_layout(tmp_path): @pytest.mark.unit -def test_resolve_melting_curve_files_local_first(tmp_path): - """A complete local directory takes precedence over the manifest dataset.""" +def test_resolve_melting_curve_files_prefers_the_served_dataset(tmp_path): + """For a served name the fetched dataset wins over a local directory, which is + used only while the dataset is not on disk.""" + from proteus.data import MELTING_WOLF_BOWER_2018, dataset_dir from proteus.utils.data import resolve_melting_curve_files local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Wolf_Bower+2018' local.mkdir(parents=True) (local / 'solidus_P-T.dat').write_text('s') (local / 'liquidus_P-T.dat').write_text('l') + assert resolve_melting_curve_files('Wolf_Bower+2018', data_root=tmp_path) == ( + local / 'solidus_P-T.dat', + local / 'liquidus_P-T.dat', + ) - solidus, liquidus = resolve_melting_curve_files('Wolf_Bower+2018', data_root=tmp_path) - - assert solidus == local / 'solidus_P-T.dat' - assert liquidus == local / 'liquidus_P-T.dat' + folder = dataset_dir(MELTING_WOLF_BOWER_2018, data_root=tmp_path) + folder.mkdir(parents=True) + for name in _LEGACY_CURVES: + (folder / name).write_text('x') + assert resolve_melting_curve_files('Wolf_Bower+2018', data_root=tmp_path) == ( + folder / 'solidus.dat', + folder / 'liquidus.dat', + ) @pytest.mark.unit @@ -1918,6 +1938,26 @@ def test_resolve_melting_curve_files_unknown_name_gives_local_paths(tmp_path): assert not tmp_path.joinpath('interior_struct').exists() +@pytest.mark.unit +def test_resolve_melting_curve_files_legacy_names_only_for_an_unserved_name(tmp_path): + """solidus.dat and liquidus.dat in a local directory count for a name the manifest + does not serve, and not for a served name.""" + from proteus.utils.data import resolve_melting_curve_files + + for name in ('Free_Form', 'Monteux+600'): + local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / name + local.mkdir(parents=True) + for curve in _LEGACY_CURVES: + (local / curve).write_text('x') + free = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Free_Form' + assert resolve_melting_curve_files('Free_Form', data_root=tmp_path) == ( + free / 'solidus.dat', + free / 'liquidus.dat', + ) + solidus, _ = resolve_melting_curve_files('Monteux+600', data_root=tmp_path) + assert solidus.parent.parent.name == 'monteux_plus_600' + + @pytest.mark.unit def test_resolve_lookup_table_dir_layout(tmp_path): """The lookup tables resolve to the versioned dataset directory of record 19473625.""" @@ -2004,7 +2044,7 @@ def test_download_stellar_spectra_phoenix_is_not_a_bulk_collection(mock_fetch): """ from proteus.utils.data import download_stellar_spectra - with pytest.raises(ValueError, match='No data source mapping found'): + with pytest.raises(ValueError, match="choose from \\['Named', 'solar', 'MUSCLES'\\]"): download_stellar_spectra(folders=('PHOENIX',)) mock_fetch.assert_not_called() @@ -2670,19 +2710,18 @@ def failing_write(fp): @pytest.mark.unit -@patch('proteus.utils.data.get_data_source_info') -def test_download_stellar_spectra_no_mapping(mock_get_info): - """Test stellar spectra download raises error when no mapping found.""" +@pytest.mark.parametrize('folder', ['UnknownFolder', 'scattering']) +@patch('proteus.utils.data.download') +@patch('proteus.data.fetch_dataset') +def test_download_stellar_spectra_rejects_other_collections(mock_fetch, mock_download, folder): + """A name other than Named, solar or MUSCLES is rejected before any fetch, also + when DATA_SOURCE_MAP knows it.""" from proteus.utils.data import download_stellar_spectra - mock_get_info.return_value = None - - with pytest.raises(ValueError, match='No data source mapping found'): - download_stellar_spectra(folders=('UnknownFolder',)) - # Discrimination: confirm the registry lookup happened (otherwise the - # ValueError could be raised by an earlier guard that never reached - # the source-info layer). - mock_get_info.assert_called() + with pytest.raises(ValueError, match=f"Unknown stellar spectra collection.*'{folder}'"): + download_stellar_spectra(folders=('solar', folder)) + mock_fetch.assert_not_called() + mock_download.assert_not_called() @pytest.mark.unit @@ -3218,7 +3257,8 @@ def test_download_zalmoxis_eos_seager(mock_static, mock_fetch, mock_file): @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_wolfbower(mock_static, mock_fetch, mock_file): - """WolfBower2018 fetches the Seager set and the three Wolf and Bower tables it reads.""" + """WolfBower2018 fetches the Seager set and the five Wolf and Bower tables it reads, + heat capacities included.""" from proteus.data import EOS_WOLF_BOWER_2018 from proteus.utils.data import download_zalmoxis_eos @@ -3230,7 +3270,9 @@ def test_download_zalmoxis_eos_wolfbower(mock_static, mock_fetch, mock_file): assert _fetched_files(mock_file) == [ (EOS_WOLF_BOWER_2018, 'density_melt.dat'), (EOS_WOLF_BOWER_2018, 'adiabat_temp_grad_melt.dat'), + (EOS_WOLF_BOWER_2018, 'heat_capacity_melt.dat'), (EOS_WOLF_BOWER_2018, 'density_solid.dat'), + (EOS_WOLF_BOWER_2018, 'heat_capacity_solid.dat'), ] @@ -3239,7 +3281,8 @@ def test_download_zalmoxis_eos_wolfbower(mock_static, mock_fetch, mock_file): @patch('proteus.data.fetch_dataset') @patch('proteus.utils.data.download_eos_static') def test_download_zalmoxis_eos_rtpress(mock_static, mock_fetch, mock_file): - """RTPress100TPa fetches its two melt tables plus the Wolf and Bower solid density only.""" + """RTPress100TPa fetches its three melt tables plus the Wolf and Bower solid density and + heat capacity only.""" from proteus.data import EOS_RTPRESS_100TPA, EOS_WOLF_BOWER_2018 from proteus.utils.data import download_zalmoxis_eos @@ -3247,12 +3290,14 @@ def test_download_zalmoxis_eos_rtpress(mock_static, mock_fetch, mock_file): mock_static.assert_called_once() mock_fetch.assert_not_called() - # The RTPress registry entry reads its solid density from the Wolf and - # Bower dataset; the rest of that dataset must not be pulled for that one file. + # The RTPress registry entry reads its solid tables from the Wolf and + # Bower dataset; the rest of that dataset must not be pulled for those files. assert _fetched_files(mock_file) == [ (EOS_WOLF_BOWER_2018, 'density_solid.dat'), + (EOS_WOLF_BOWER_2018, 'heat_capacity_solid.dat'), (EOS_RTPRESS_100TPA, 'density_melt.dat'), (EOS_RTPRESS_100TPA, 'adiabat_temp_grad_melt.dat'), + (EOS_RTPRESS_100TPA, 'heat_capacity_melt.dat'), ] @@ -3276,6 +3321,7 @@ def test_download_zalmoxis_eos_rtpress_with_wolfbower_fetches_solid_once( assert {name for key, name in fetched if key == EOS_RTPRESS_100TPA} == { 'density_melt.dat', 'adiabat_temp_grad_melt.dat', + 'heat_capacity_melt.dat', } mock_fetch.assert_not_called() @@ -5795,8 +5841,8 @@ def test_get_zalmoxis_melting_curves_reads_manifest_dataset(monkeypatch, tmp_pat @pytest.mark.unit -def test_get_zalmoxis_melting_curves_local_dir_beats_manifest(monkeypatch, tmp_path): - """When both exist, the local P-T files are read, not the dataset files.""" +def test_get_zalmoxis_melting_curves_manifest_beats_local_dir(monkeypatch, tmp_path): + """When both exist for a served name, the dataset files are read, not the local ones.""" import proteus.utils.data as data_mod from proteus.data import MELTING_MONTEUX_PLUS600, dataset_dir from proteus.utils.data import get_zalmoxis_melting_curves @@ -5804,12 +5850,12 @@ def test_get_zalmoxis_melting_curves_local_dir_beats_manifest(monkeypatch, tmp_p monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) folder = dataset_dir(MELTING_MONTEUX_PLUS600, data_root=tmp_path) folder.mkdir(parents=True) - (folder / 'solidus.dat').write_text('1e9 1000\n2e9 1000\n') - (folder / 'liquidus.dat').write_text('1e9 1000\n2e9 1000\n') + (folder / 'solidus.dat').write_text('1e9 2000\n2e9 3000\n') + (folder / 'liquidus.dat').write_text('1e9 2500\n2e9 3500\n') local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'Monteux+600' local.mkdir(parents=True) - (local / 'solidus_P-T.dat').write_text('1e9 2000\n2e9 3000\n') - (local / 'liquidus_P-T.dat').write_text('1e9 2500\n2e9 3500\n') + (local / 'solidus_P-T.dat').write_text('1e9 1000\n2e9 1000\n') + (local / 'liquidus_P-T.dat').write_text('1e9 1000\n2e9 1000\n') config = MagicMock() config.interior_struct.melting_dir = 'Monteux+600' From 657405d9c0397bd2fb5182121d47fa192b368174 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 10:18:52 +0200 Subject: [PATCH 38/76] Reject an Aragog config without melting curves at load Aragog reads a solidus and a liquidus in every mode, so without a generated PALEOS table set a configuration with no interior_struct.melting_dir is now rejected when it is loaded, not after the first structure solve. SPIDER keeps its check at the start of the run, because a constant-property run reads no curves and a resumed run can use its kept P-S tables, which the configuration alone does not show. Shorten the comments that ran over three lines, and state the current contract in the test_get docstring. The DATA_SOURCE_MAP comment now names both manifests that declare the fetched datasets. --- docs/Reference/data.md | 8 +++---- src/proteus/config/_config.py | 22 +++++++++++++++++ src/proteus/interior_energetics/aragog.py | 8 ++----- src/proteus/interior_energetics/spider.py | 7 +++--- src/proteus/interior_energetics/wrapper.py | 7 ++---- src/proteus/utils/data.py | 24 +++++-------------- tests/config/test_config.py | 28 ++++++++++++++++++++++ tests/test_cli.py | 16 ++++--------- 8 files changed, 72 insertions(+), 48 deletions(-) diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 1689f75d7..77ca3c875 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -279,10 +279,10 @@ run with an error naming the missing files instead of switching to other curves. With `interior_struct.module = "zalmoxis"` and a single PALEOS mantle equation of state, the curves are derived from the PALEOS liquidus and `melting_dir` is not read; a configured `melting_dir` then gives a warning when the configuration -is loaded. Without a PALEOS table set, `melting_dir` must be set: SPIDER and -Aragog stop with an error when it is unset, rather than use whichever curves are -on disk. A SPIDER run with `const_properties = true` reads no melting curves and -is exempt. +is loaded. Without a PALEOS table set, `melting_dir` must be set, rather than +use whichever curves are on disk: an Aragog configuration without it is rejected +when it is loaded, and a SPIDER run stops with an error when it starts. A SPIDER +run with `const_properties = true` reads no melting curves and is exempt. The Wolf and Bower (2018) pressure-entropy lookup table that SPIDER and Aragog read for `eos_dir = "WolfBower2018_MgSiO3"` is fetched the same way, diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index 6347e8087..fe7e37458 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -240,6 +240,27 @@ def planet_liquidus_super_needs_tables(instance, attribute, value): ) +def aragog_needs_melting_curves(instance, attribute, value): + """Require ``interior_struct.melting_dir`` for Aragog without a PALEOS table set. + + Aragog reads a solidus and a liquidus in every mode, from the generated + PALEOS set or from ``melting_dir``. SPIDER is checked when the run starts + instead: a resumed run continues on its kept P-S tables, and a + ``const_properties`` run reads no curves. + """ + from proteus.utils.helper import generates_paleos_tables + + if value.module != 'aragog' or instance.interior_struct.melting_dir is not None: + return + if generates_paleos_tables(instance.interior_struct): + return + raise ValueError( + "interior_energetics.module = 'aragog' needs interior_struct.melting_dir without a " + 'generated PALEOS table set (a single PALEOS mantle EOS with the Zalmoxis ' + 'structure). Set melting_dir to a melting curve name (e.g. "Monteux-600").' + ) + + def planet_fO2_source_compat(instance, attribute, value): """Validate planet.fO2_source against O_mode, volatile_mode, and against availability. @@ -417,6 +438,7 @@ class Config: validator=( tides_enabled_orbit, boundary_requires_fixed_surface_state, + aragog_needs_melting_curves, ), ) outgas: Outgas = field(factory=Outgas) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 926b8bf0b..440e133bc 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -988,12 +988,8 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s 'Download them with `proteus get interiordata --config-path `.' ) else: - # Fetched Wolf and Bower 2018 tables; used when - # interior_struct.eos_dir is None (no dynamic EOS selected) or - # when the dynamic path does not resolve to a populated - # directory. The "EOS/dynamic" tree is only materialised when - # zalmoxis pre-generates PALEOS tables; outside that pathway - # it is empty. + # Fetched Wolf and Bower 2018 tables, used when eos_dir is None or its + # dynamic path holds no tables (it is filled only for generated PALEOS tables). default_lookup = resolve_lookup_table_dir(data_root=FWL_DATA_DIR) if config.interior_struct.eos_dir is None: LOOK_UP_DIR = default_lookup diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index 2a767e398..ccf187c37 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -935,10 +935,9 @@ def _try_spider( call_sequence.extend(['-HTIDAL', '2']) call_sequence.extend(['-htidal_filename', get_file_tides(dirs['output'])]) - # Resolve melting curve S(P) files: the paths derived for this run, else P-S - # files in the local Melting_curves/ folder. A missing curve - # stops the run rather than switching to other curves. Constant-property - # runs pass no phase boundaries to SPIDER, so they need none. + # Melting curve S(P) files: those derived for this run, else the local + # Melting_curves/ P-S files; a missing curve stops the run. + # Constant-property runs pass no phase boundaries to SPIDER. liquidus_ps = solidus_ps = None if not _use_const: if dirs.get('spider_liquidus_ps') and os.path.isfile(dirs['spider_liquidus_ps']): diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 737b61b9a..47e9881e3 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -902,11 +902,8 @@ def determine_interior_radius( log.info('Using %s interior module to solve structure' % config.interior_energetics.module) - # Provide P-S lookup tables for Aragog's entropy solver (and SPIDER - # when it runs under this structure path). Mirrors the - # table provision at the top of the zalmoxis and dummy structure - # paths. The helper resolves from FWL_DATA/Zenodo first, - # then the SPIDER submodule as a fallback. + # P-S lookup tables for the SPIDER or Aragog energetics, as in the + # zalmoxis and dummy structure paths. if config.interior_energetics.module in ('spider', 'aragog'): _provide_spider_eos_tables(config, outdir, dirs) diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index c36d3d724..42940288a 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -448,13 +448,8 @@ def validate_zenodo_folder(zenodo_id: str, folder_dir: Path, hash_maxfilesize=10 # Unified mapping of folder names to both Zenodo and OSF identifiers # Structure: folder_name -> {'zenodo_id': str, 'osf_id': str, 'osf_project': str} DATA_SOURCE_MAP: dict[str, dict[str, str]] = { - # The surface albedos, the Seager EOS tables, the exoplanet catalogue, the - # mass-radius relations, the solar, Named, MUSCLES and PHOENIX stellar spectra, the - # Zalmoxis equations of state, the interior lookup tables and the melting - # curves are declared in - # src/proteus/data/proteus_manifest.toml and fetched through fwl-io, so their - # record pins live there and are absent here. - # Aerosol scattering data (no OSF project) + # Every other dataset is declared in src/proteus/data/proteus_manifest.toml or + # in the fwl-io shared_manifest.toml and fetched through fwl-io. 'scattering': {'zenodo_id': '19294180', 'osf_id': 'vehxg', 'osf_project': 'vehxg'}, } @@ -1539,11 +1534,8 @@ def download_stellar_tracks(track: str, use_osf_fallback: bool = True): # Try MORS download first try: mors_data.DownloadEvolutionTracks(track) - # Verify the download landed. A migrated MORS fetches a track set - # through fwl-io into its versioned directory and exposes a - # _data_dir accessor to resolve it; an older MORS wrote the - # tracks to the legacy path. Verify wherever this MORS version - # actually placed them. + # Check where this MORS placed the tracks: its _data_dir accessor + # when it has one, else the fixed path. if track == 'Baraffe' and hasattr(mors_data, 'baraffe_data_dir'): tracks_path = mors_data.baraffe_data_dir() elif track == 'Spada' and hasattr(mors_data, 'spada_data_dir'): @@ -1984,12 +1976,8 @@ def download_Seager_EOS(): fetch_dataset(EOS_SEAGER_2007, data_root=FWL_DATA_DIR) -# ── Zalmoxis EOS download helpers ──────────────────────────────────── -# -# ``download_zalmoxis_eos()`` is called from ``_get_sufficient()``; it -# inspects the mantle/core EOS config and fetches only the manifest files -# the current run reads. -# ───────────────────────────────────────────────────────────────────── +# Zalmoxis EOS download helpers: download_zalmoxis_eos fetches only the +# manifest files that the configured layer EOS read. # Mantle EOS family prefixes whose registry entry carries the Seager iron # table as its core fallback (see diff --git a/tests/config/test_config.py b/tests/config/test_config.py index aa4f51db8..b5f631b45 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -2914,3 +2914,31 @@ def test_no_input_toml_uses_bare_interior_section(): # wrong reason and silently pass the assertion above. toml_files = list((repo_root / 'input').rglob('*.toml')) assert len(toml_files) > 0 + + +@pytest.mark.unit +@pytest.mark.parametrize( + 'energetics, mantle, melting_dir, stops', + [ + ('aragog', 'WolfBower2018:MgSiO3', None, True), + ('aragog', 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', None, True), + ('aragog', 'PALEOS:MgSiO3', None, False), + ('aragog', 'WolfBower2018:MgSiO3', 'Monteux-600', False), + ('spider', 'WolfBower2018:MgSiO3', None, False), + ], +) +def test_aragog_needs_melting_curves(energetics, mantle, melting_dir, stops): + """Aragog without a generated PALEOS table set needs melting_dir at config load; + SPIDER is left to the run-time check.""" + from proteus.config._config import aragog_needs_melting_curves + + struct = SimpleNamespace( + module='zalmoxis', melting_dir=melting_dir, zalmoxis=SimpleNamespace(mantle_eos=mantle) + ) + value = SimpleNamespace(module=energetics) + instance = SimpleNamespace(interior_struct=struct, interior_energetics=value) + if stops: + with pytest.raises(ValueError, match='needs interior_struct.melting_dir'): + aragog_needs_melting_curves(instance, None, value) + else: + assert aragog_needs_melting_curves(instance, None, value) is None diff --git a/tests/test_cli.py b/tests/test_cli.py index f83338b0f..7a019cac5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -72,12 +72,9 @@ def test_get(monkeypatch, tmp_path): a stub file at the expected path. FWL_DATA itself is monkeypatched to a tmp_path so the test never touches the user's real data tree. - Anti-happy-path: each subcommand assertion is its own line and asserts - the specific exit code; a regression in any one of them surfaces the - name of the failing subcommand in the assertion output. The previous - failure (test_get fails on `solar` in CI but not locally because the - user's FWL_DATA happened to have the solar dataset already - populated) is now eliminated by controlling FWL_DATA explicitly. + Each subcommand has its own assertion on its exit code, so a failure + names the subcommand. FWL_DATA points at an empty tmp_path, so the + result does not depend on the data tree of the machine. """ # Monkeypatch FWL_DATA to a writable tmp_path so post-condition file # checks have a controlled environment. The module-level FWL_DATA_DIR @@ -89,11 +86,8 @@ def test_get(monkeypatch, tmp_path): monkeypatch.setattr('proteus.utils.data.FWL_DATA_DIR', _Path(tmp_path), raising=False) def stub_download_stellar_spectra(folders=('solar',), **kwargs): - # The CLI `solar` subcommand checks for files under - # the solar dataset directory after the downloader returns, - # raising ClickException if none are present. The no-op honours - # that contract by writing a stub file so the post-condition - # passes without touching the network. + # `proteus get solar` requires files in the solar dataset directory, + # so the stub writes one. for folder in folders: target = _solar_dir(tmp_path) target.mkdir(parents=True, exist_ok=True) From 57f3b7aa63e1ccd782c653ad76f84a6674f4226b Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 10:26:46 +0200 Subject: [PATCH 39/76] Regenerate the config reference for the Aragog melting-curve check The liquidus_super route test sets melting_dir, so it checks the route rule alone. --- docs/Reference/config/config_schema.json | 11 +++++++++++ docs/Reference/config/interior.md | 1 + .../interior_energetics/test_liquidus_super_tables.py | 5 +++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/Reference/config/config_schema.json b/docs/Reference/config/config_schema.json index 258e49d0b..df6ab7c87 100644 --- a/docs/Reference/config/config_schema.json +++ b/docs/Reference/config/config_schema.json @@ -10114,6 +10114,17 @@ ], "doc": "Reject enabling this option when the dummy atmos_clim module is selected." }, + { + "validator": "aragog_needs_melting_curves", + "module": "_config.py", + "attached_to": [ + "interior_energetics" + ], + "touches": [ + "interior_struct.melting_dir" + ], + "doc": "Require ``interior_struct.melting_dir`` for Aragog without a PALEOS table set." + }, { "validator": "boreas_requires_atmosphere", "module": "_config.py", diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index 0e106ea86..f7668fc85 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -390,6 +390,7 @@ with prescribed solidus and liquidus and parameterised convective heat transport Cross-field constraints enforced when the config file loads: +- Require ``interior_struct.melting_dir`` for Aragog without a PALEOS table set. - Boundary backend assumes a fixed surface state coupling. - Interior tidal heating requires an tides module to be enabled. - Aragog requires at least one energy transport term to be enabled. diff --git a/tests/interior_energetics/test_liquidus_super_tables.py b/tests/interior_energetics/test_liquidus_super_tables.py index c65fd63b7..cb1f7e07e 100644 --- a/tests/interior_energetics/test_liquidus_super_tables.py +++ b/tests/interior_energetics/test_liquidus_super_tables.py @@ -1161,7 +1161,8 @@ def test_full_config_with_no_structure_module_is_rejected(energetics): root = pathlib.Path(__file__).resolve().parents[2] cfg = read_config_object(root / 'input' / 'dummy.toml') planet = attrs.evolve(cfg.planet, temperature_mode='liquidus_super') - struct = attrs.evolve(cfg.interior_struct, module=None) + curves = attrs.evolve(cfg.interior_struct, melting_dir='Monteux-600') + struct = attrs.evolve(curves, module=None) energ = attrs.evolve(cfg.interior_energetics, module=energetics) with pytest.raises(ValueError) as exc: @@ -1169,7 +1170,7 @@ def test_full_config_with_no_structure_module_is_rejected(energetics): assert 'temperature_mode' in str(exc.value) assert 'interior_struct.module' in str(exc.value) # Discrimination: the same Config builds once a structure module is set. - ok = attrs.evolve(cfg, planet=planet, interior_energetics=energ) + ok = attrs.evolve(cfg, planet=planet, interior_struct=curves, interior_energetics=energ) assert ok.interior_struct.module == cfg.interior_struct.module From 254562ea2dc21fc7b1e892cdd5b51aedd5bc6cc0 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 10:58:03 +0200 Subject: [PATCH 40/76] Give a mixture mantle the melting curves of its MgSiO3 component energetics_eos_key picks the registry key that sets a mantle's melting curves and energetics tables: the key of a single component; for a mixture, its MgSiO3 component, or, without one, the MgSiO3 2-phase pair of its PALEOS family. The structure melting curves, generates_paleos_tables, the P-S tables, the Aragog curves, property tables and initial condition choice, and the SPIDER isothermal override all follow it, so a run has one melting curve. A mixture with PALEOS:MgSiO3 now gets exactly the tables of a PALEOS:MgSiO3 mantle, with the same P-S key and cache; its other components enter only the structure density. A mixture whose MgSiO3 component is Wolf and Bower keeps the Wolf and Bower tables and the melting_dir curves, which its structure also reads. A PALEOS mixture without MgSiO3 uses the MgSiO3 pair of its family, like a single PALEOS:H2O mantle. Every mixture with a PALEOS component gets one warning per run segment that names the set its energetics follow. --- docs/How-to/usage_initial_conditions.md | 17 ++-- docs/Reference/config/interior.md | 3 +- docs/Reference/data.md | 5 +- src/proteus/interior_energetics/aragog.py | 8 +- src/proteus/interior_energetics/wrapper.py | 7 +- src/proteus/interior_struct/zalmoxis.py | 84 +++++++++++-------- src/proteus/utils/data.py | 4 +- src/proteus/utils/helper.py | 43 ++++++++-- tests/config/test_config.py | 3 +- tests/config/test_struct.py | 9 +- tests/interior_energetics/test_aragog.py | 74 +++++++++++++--- .../test_liquidus_super_tables.py | 23 ++--- tests/interior_struct/test_zalmoxis.py | 60 +++++++++++-- tests/utils/test_data.py | 7 +- tests/utils/test_helper.py | 11 ++- 15 files changed, 256 insertions(+), 102 deletions(-) diff --git a/docs/How-to/usage_initial_conditions.md b/docs/How-to/usage_initial_conditions.md index 1c6f93687..96ea5c19c 100644 --- a/docs/How-to/usage_initial_conditions.md +++ b/docs/How-to/usage_initial_conditions.md @@ -117,13 +117,18 @@ liquidus at the binding depth. the interior solver uses for its melt fraction. Where those tables come from depends on the structure module: - - `"zalmoxis"` with a mantle EOS (`interior_struct.zalmoxis.mantle_eos`, - default `"PALEOS:MgSiO3"`) that is a single PALEOS table name: Zalmoxis - generates the tables from that EOS, with a liquidus derived from the - PALEOS (Fei et al. 2021) curve, and Aragog builds its phase-property - tables from PALEOS too. `interior_struct.melting_dir` is not used. + - `"zalmoxis"` with a PALEOS mantle EOS (`interior_struct.zalmoxis.mantle_eos`, + default `"PALEOS:MgSiO3"`): Zalmoxis generates the tables from that EOS, + with a liquidus derived from the PALEOS (Fei et al. 2021) curve, and + Aragog builds its phase-property tables from PALEOS too. + `interior_struct.melting_dir` is not used. A mixture (a `+` in + `mantle_eos`) follows its MgSiO$_3$ component, so a mixture with + `PALEOS:MgSiO3` gets the tables of a `PALEOS:MgSiO3` mantle; a PALEOS + mixture without an MgSiO$_3$ component gets the MgSiO$_3$ two-phase + tables of its PALEOS family. - `"spider"` and `"dummy"`, or `"zalmoxis"` when no PALEOS table set is - generated, which includes a PALEOS mixture (a `+` in `mantle_eos`): the + generated, which includes a mixture whose MgSiO$_3$ component is not + PALEOS (for example `"WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1"`): the tables come from FWL_DATA (SPIDER also falls back to its own lookup data), Aragog also takes its phase-property files from that Wolf & Bower (2018) set or `interior_struct.eos_dir`, `interior_struct.melting_dir` diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index f7668fc85..293f00295 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -130,7 +130,8 @@ A run with a PALEOS EOS requires the tables the chosen EOS reads: the table of e When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. With `dry_mantle = false` it also requires the tables of the dissolved volatiles (`PALEOS:H2O` and `Chabrier:H`). A resumed run that keeps its pressure-entropy tables needs only the tables of its layers. -A PALEOS mantle with no MgSiO$_3$ component (for example `mantle_eos = "PALEOS:H2O"`, `"PALEOS:iron"`, or a mixture of the two) uses the density of its own material in the structure, while MgSiO$_3$ tables stand in elsewhere: SPIDER or Aragog energetics use the MgSiO$_3$ melting curves and pressure-entropy tables of PALEOS for a single PALEOS component, and the Wolf and Bower (2018) tables with the `melting_dir` curves for a mixture; `temperature_mode = "liquidus_super"` solves the initial adiabat on the MgSiO$_3$ two-phase tables. +A mixture mantle (a `+` in `mantle_eos`) takes its melting curves, in the structure and in the energetics, and its SPIDER or Aragog tables from its MgSiO$_3$ component: with `PALEOS:MgSiO3` it gets exactly the curves and tables of a `PALEOS:MgSiO3` mantle, and with `WolfBower2018:MgSiO3` the Wolf and Bower (2018) tables and the `melting_dir` curves. The other components enter only the structure density. +A PALEOS mantle with no MgSiO$_3$ component (for example `mantle_eos = "PALEOS:H2O"`, `"PALEOS:iron"`, or a mixture of the two) uses the density of its own material in the structure, while the PALEOS MgSiO$_3$ melting curves and the two-phase MgSiO$_3$ tables of its PALEOS family stand in for the energetics; `temperature_mode = "liquidus_super"` solves the initial adiabat on the MgSiO$_3$ two-phase tables. The run logs one warning that names the cases that apply at the start of each run segment, so a resumed run logs it again. !!! note "`mushy_zone_factor` and the two-phase structure solve" diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 77ca3c875..5bfb3e435 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -276,8 +276,9 @@ read from that local directory, which holds `solidus_P-T.dat` and parametrizations and how to generate them. When SPIDER or Aragog read `melting_dir`, a configured curve whose files are missing stops the run with an error naming the missing files instead of switching to other curves. -With `interior_struct.module = "zalmoxis"` and a single PALEOS mantle equation -of state, the curves are derived from the PALEOS liquidus and `melting_dir` is +With `interior_struct.module = "zalmoxis"` and a PALEOS mantle equation of +state (for a mixture: a PALEOS MgSiO$_3$ component, or no MgSiO$_3$ component and +a PALEOS one), the curves are derived from the PALEOS liquidus and `melting_dir` is not read; a configured `melting_dir` then gives a warning when the configuration is loaded. Without a PALEOS table set, `melting_dir` must be set, rather than use whichever curves are on disk: an Aragog configuration without it is rejected diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 440e133bc..a0b44d686 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -38,7 +38,7 @@ from proteus.interior_energetics.wrapper import get_core_density, get_core_heatcap from proteus.utils.constants import radnuc_data from proteus.utils.data import resolve_lookup_table_dir, resolve_melting_curve_files -from proteus.utils.helper import generates_paleos_tables +from proteus.utils.helper import energetics_eos_key, generates_paleos_tables from proteus.utils.helper import format_subyear_time, parse_subyear_time, snapshot_path_for_time log = logging.getLogger('fwl.' + __name__) @@ -844,14 +844,14 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s initial_condition_temperature_profile = 3 init_file_temperature_profile = os.path.join(FWL_DATA_DIR, '') elif config.interior_struct.module == 'zalmoxis': - _TDEP_PREFIXES = ('WolfBower2018', 'RTPress100TPa') - if config.interior_struct.zalmoxis.mantle_eos.startswith(_TDEP_PREFIXES): + _key = energetics_eos_key(config.interior_struct.zalmoxis.mantle_eos) or '' + if _key.startswith(('WolfBower2018', 'RTPress100TPa')): # When using Zalmoxis with temperature-dependent silicate EOS, set initial condition to user-defined temperature field (from file) in Aragog initial_condition_temperature_profile = 2 init_file_temperature_profile = os.path.join( outdir, 'data', 'zalmoxis_output_temp.txt' ) - elif config.interior_struct.zalmoxis.mantle_eos.startswith('PALEOS:'): + elif _key.startswith('PALEOS:'): # For PALEOS EOS with adiabatic IC: Aragog uses IC=3 with # entropy tables for its entropy-conserving adiabat. After # initialization, _verify_entropy_ic compares against an diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 47e9881e3..86f3a9acb 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -23,7 +23,7 @@ from proteus.interior_struct.common import solvus_radius from proteus.outgas.wrapper import calc_target_elemental_inventories from proteus.utils.constants import M_earth, R_earth, const_G, noble_gases, vol_element_list -from proteus.utils.helper import UpdateStatusfile +from proteus.utils.helper import UpdateStatusfile, energetics_eos_key if TYPE_CHECKING: from proteus.config import Config @@ -1566,12 +1566,13 @@ def determine_interior_radius_with_zalmoxis( # are selected inside Zalmoxis, and (b) standalone Zalmoxis can use the # adiabat if the gate is ever fixed. SPIDER provides its own T(r) # through entropy evolution, so the linear T initial guess is fine. - _TDEP_PREFIXES = ('WolfBower2018', 'RTPress100TPa') _temp_mode_override: str | None = None if ( config.interior_energetics.module == 'spider' and config.planet.temperature_mode == 'isothermal' - and config.interior_struct.zalmoxis.mantle_eos.startswith(_TDEP_PREFIXES) + and (energetics_eos_key(config.interior_struct.zalmoxis.mantle_eos) or '').startswith( + ('WolfBower2018', 'RTPress100TPa') + ) ): log.info( 'Overriding Zalmoxis temperature_mode from isothermal to adiabatic ' diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 72677c265..cc107019c 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -40,6 +40,7 @@ from proteus.utils.data import GetFWLData, get_zalmoxis_melting_curves from proteus.utils.helper import ( _strip_fraction_tokens, + energetics_eos_key, eos_components, generates_paleos_tables, paleos_companion_keys, @@ -1896,10 +1897,10 @@ def check_zalmoxis_eos_files( ) -def _mantle_entry(mantle_eos: str, mat_dicts: dict) -> dict | None: - """Registry entry of a single-component mantle EOS, or None for a mixture.""" - components = eos_components(mantle_eos) - return mat_dicts.get(components[0]) if len(components) == 1 else None +def _energetics_entry(mantle_eos: str, mat_dicts: dict) -> tuple[str | None, dict | None]: + """Energetics key of a mantle EOS and its registry entry (None when absent).""" + key = energetics_eos_key(mantle_eos) + return key, mat_dicts.get(key) if key else None def require_paleos_tables(config: Config, outdir: str) -> None: @@ -1909,8 +1910,9 @@ def require_paleos_tables(config: Config, outdir: str) -> None: for a mantle with a PALEOS component, the MgSiO3 2-phase pair. A resumed run that keeps its P-S tables is held only to its layer tables. With ``dry_mantle = false`` the dissolved-volatile tables are also required. A - PALEOS mantle without an MgSiO3 component gets one WARNING when its - energetics or its liquidus_super initial adiabat use MgSiO3 tables. + PALEOS H2O or iron mantle, and a mixture with a PALEOS component, gets one + WARNING that names the tables its energetics and its liquidus_super initial + adiabat use. Parameters ---------- @@ -1934,34 +1936,39 @@ def require_paleos_tables(config: Config, outdir: str) -> None: mat_dicts = load_zalmoxis_material_dictionaries() kept = config.params.resume and _resumed_ps_tables( outdir, - lambda: _ps_resume_key(config, _mantle_entry(zc.mantle_eos, mat_dicts), mat_dicts), + lambda: _ps_resume_key(config, *_energetics_entry(zc.mantle_eos, mat_dicts), mat_dicts), ) check_zalmoxis_eos_files(layers, mat_dicts, paleos_companions=not kept) components = eos_components(zc.mantle_eos) - if not any(c.startswith(PALEOS_EOS_PREFIXES) for c in components) or any( - c.split(':')[1].startswith('MgSiO3') for c in components if ':' in c + mixture = len(components) > 1 + if not any(c.startswith(PALEOS_EOS_PREFIXES) for c in components) or ( + not mixture and components[0].partition(':')[2].startswith('MgSiO3') ): return uses = [] - energetics = config.interior_energetics.module in ('spider', 'aragog') - if energetics and generates_paleos_tables(config.interior_struct): - uses.append( - 'the energetics use the MgSiO3 melting curves (PALEOS liquidus, ' - f'solidus = {zc.mushy_zone_factor:.2f} x liquidus) and MgSiO3 P-S tables' - ) - elif energetics: - uses.append( - 'the energetics use the Wolf and Bower (2018) MgSiO3 tables and the ' - f'melting_dir curves ({config.interior_struct.melting_dir})' - ) + if config.interior_energetics.module in ('spider', 'aragog'): + key = energetics_eos_key(zc.mantle_eos) + if not generates_paleos_tables(config.interior_struct): + uses.append(f'the energetics and melting curves follow {key} and melting_dir') + elif mixture: + uses.append( + f'the energetics and melting curves are PALEOS MgSiO3 ({key}, solidus = ' + f'{zc.mushy_zone_factor:.2f} x PALEOS liquidus)' + ) + else: + uses.append( + 'the energetics use the MgSiO3 melting curves (PALEOS liquidus, ' + f'solidus = {zc.mushy_zone_factor:.2f} x liquidus) and MgSiO3 P-S tables' + ) if config.planet.temperature_mode == 'liquidus_super': uses.append('the liquidus_super initial adiabat is solved on the MgSiO3 2-phase tables') if uses: - log.warning( - 'mantle_eos=%s: the structure uses its density, while %s', - zc.mantle_eos, - '; '.join(uses), + head = ( + 'the non-MgSiO3 components enter only the structure density; ' + if mixture + else 'the structure uses its density, while ' ) + log.warning('mantle_eos=%s: %s%s', zc.mantle_eos, head, '; '.join(uses)) def resolve_2phase_mgsio3_paths(mantle_eos: str, mat_dicts: dict): @@ -2025,7 +2032,9 @@ def load_zalmoxis_solidus_liquidus_functions(mantle_eos: str, config: Config): 1. Temperature-dependent density in the mushy zone (WolfBower2018, RTPress). 2. phi(r) blending in VolatileProfile (any EOS with dissolved volatiles). - For WolfBower2018/RTPress100TPa, loads SPIDER-format P-T files from FWL_DATA. + The curves follow the energetics key of the mantle EOS + (:func:`proteus.utils.helper.energetics_eos_key`), the same key as the + energetics. For WolfBower2018/RTPress100TPa, loads SPIDER-format P-T files from FWL_DATA. For PALEOS unified and PALEOS-2phase, the liquidus comes from the analytic Belonoshko+2005 / Fei+2021 curve (Zalmoxis ``'PALEOS-liquidus'``) which is the basis Zalmoxis uses for MgSiO3 phase separation, and the solidus is @@ -2046,8 +2055,8 @@ def load_zalmoxis_solidus_liquidus_functions(mantle_eos: str, config: Config): tuple or None (solidus_func, liquidus_func) callable P [Pa] -> T [K], or None. """ - _TDEP_PREFIXES = ('WolfBower2018', 'RTPress100TPa') - if mantle_eos.startswith(_TDEP_PREFIXES): + key = energetics_eos_key(mantle_eos) or '' + if key.startswith(('WolfBower2018', 'RTPress100TPa')): return get_zalmoxis_melting_curves(config) # PALEOS unified and PALEOS-2phase: both use the same analytic Belonoshko+2005 / @@ -2059,7 +2068,7 @@ def load_zalmoxis_solidus_liquidus_functions(mantle_eos: str, config: Config): # the unified PALEOS density interpolation. Without these curves, the # 2-phase nabla_ad call fails and Zalmoxis structure solve diverges; the # unified path falls back to phi=0.5 everywhere in VolatileProfile. - if mantle_eos.startswith(PALEOS_EOS_PREFIXES): + if key.startswith(PALEOS_EOS_PREFIXES): try: from zalmoxis.melting_curves import ( derive_solidus_from_liquidus, @@ -2488,7 +2497,7 @@ def __init__(self, reason: str, level: int = logging.WARNING): self.level = level -def _ps_table_inputs(config: Config, eos_entry: dict, mat_dicts: dict): +def _ps_table_inputs(config: Config, mantle_eos: str, eos_entry: dict, mat_dicts: dict): """Resolve the PALEOS files and the cache key of the P-S tables. Materialises PALEOS-API entries, which can build their tables on a cold @@ -2498,8 +2507,10 @@ def _ps_table_inputs(config: Config, eos_entry: dict, mat_dicts: dict): ---------- config : Config Configuration object with struct.zalmoxis settings. + mantle_eos : str + Energetics key of the mantle EOS (:func:`energetics_eos_key`). eos_entry : dict - Registry entry of the mantle EOS. + Registry entry of that key. mat_dicts : dict Zalmoxis material dictionaries. @@ -2513,7 +2524,6 @@ def _ps_table_inputs(config: Config, eos_entry: dict, mat_dicts: dict): _NoPSTables When the mantle EOS is not PALEOS or its files are missing. """ - mantle_eos = config.interior_struct.zalmoxis.mantle_eos # PALEOS-API live tabulation: materialise cached .dat paths in place so the # downstream format / eos_file lookups see concrete paths. No-op for # non-PALEOS-API entries. First call on a cold cache triggers generation. @@ -2607,7 +2617,9 @@ def _ps_table_inputs(config: Config, eos_entry: dict, mat_dicts: dict): return eos_file, solid_eos, liquid_eos, is_twophase, P_max, cache_key -def _ps_resume_key(config: Config, eos_entry: dict | None, mat_dicts: dict) -> str: +def _ps_resume_key( + config: Config, key: str | None, eos_entry: dict | None, mat_dicts: dict +) -> str: """Current P-S cache key for the resume warning, without building PALEOS-API tables.""" from zalmoxis.eos.dispatch import _is_paleos_api @@ -2616,7 +2628,7 @@ def _ps_resume_key(config: Config, eos_entry: dict | None, mat_dicts: dict) -> s raise _NoPSTables(f'mantle EOS {mantle_eos} is not in the material dictionary') if _is_paleos_api(eos_entry): raise ValueError('a PALEOS-API mantle EOS is not resolved on resume') - return _ps_table_inputs(config, eos_entry, mat_dicts)[-1] + return _ps_table_inputs(config, key, eos_entry, mat_dicts)[-1] def generate_spider_tables(config: Config, outdir: str): @@ -2679,12 +2691,12 @@ def generate_spider_tables(config: Config, outdir: str): # Use FWL_DATA paths (not ZALMOXIS_ROOT) for EOS file lookup mat_dicts = load_zalmoxis_material_dictionaries() - eos_entry = _mantle_entry(mantle_eos, mat_dicts) + key, eos_entry = _energetics_entry(mantle_eos, mat_dicts) # A resumed run stays on the tables it started with; the key only feeds the warning. if config.params.resume: resumed = _resumed_ps_tables( - outdir, lambda: _ps_resume_key(config, eos_entry, mat_dicts) + outdir, lambda: _ps_resume_key(config, key, eos_entry, mat_dicts) ) if resumed is not None: return resumed @@ -2698,7 +2710,7 @@ def generate_spider_tables(config: Config, outdir: str): if paleos_companion_keys(mantle_eos): check_zalmoxis_eos_files({'mantle': mantle_eos}, mat_dicts, paleos_companions=True) try: - inputs = _ps_table_inputs(config, eos_entry, mat_dicts) + inputs = _ps_table_inputs(config, key, eos_entry, mat_dicts) except _NoPSTables as exc: log.log(exc.level, 'No PALEOS P-S tables: %s; using pre-existing SPIDER tables.', exc) return None diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 42940288a..c33c685fe 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1670,8 +1670,8 @@ def _attempt(desc: str, func, *args, **kwargs) -> bool: def needs_spider_ps_tables(config) -> bool: """Return whether a run reads the Wolf and Bower P-S lookup set. - SPIDER and Aragog read it unless Zalmoxis generates a PALEOS table set, - which happens for a mantle EOS that is a PALEOS registry key. A set + SPIDER and Aragog read it unless Zalmoxis generates a PALEOS table set + (:func:`proteus.utils.helper.generates_paleos_tables`). A set interior_struct.eos_dir asks for it whenever SPIDER or Aragog run. Parameters diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 8476d427b..2b27afead 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -59,14 +59,42 @@ def eos_components(eos: str) -> list[str]: return [_strip_fraction_tokens(c.strip()) for c in str(eos).split('+') if c.strip()] +def energetics_eos_key(mantle_eos: str) -> str | None: + """Registry key of the EOS whose melting curves and P-S tables a mantle uses. + + The structure melting curves and the energetics both follow this key, so a + run has one melting curve. + + Parameters + ---------- + mantle_eos : str + Mantle EOS, possibly a ``+``-joined mixture. + + Returns + ------- + str or None + The key of a single component. For a mixture, its first MgSiO3 component; + without one, the MgSiO3 2-phase pair of its PALEOS family when a component + is PALEOS; else None. + """ + components = eos_components(mantle_eos) + if len(components) == 1: + return components[0] + mgsio3 = [c for c in components if c.partition(':')[2].startswith('MgSiO3')] + if mgsio3: + return mgsio3[0] + if any(c.startswith(PALEOS_EOS_PREFIXES) for c in components): + return twophase_registry_key(mantle_eos) + return None + + def generates_paleos_tables(interior_struct) -> bool: """Return whether Zalmoxis generates a PALEOS table set for this structure. - Only a single-component mantle EOS whose key (spaces and fraction tokens - stripped) is a PALEOS key of the Zalmoxis material registry, under the - Zalmoxis structure, gets one. SPIDER, Aragog and the table fetch then use the - PALEOS-derived curves instead of interior_struct.melting_dir. A mixture gets - none. The table files are not checked. + Under the Zalmoxis structure, a mantle EOS gets one when its energetics key + (:func:`energetics_eos_key`) is a PALEOS key of the Zalmoxis material + registry. SPIDER, Aragog and the table fetch then use the PALEOS-derived + curves instead of interior_struct.melting_dir. The table files are not checked. Parameters ---------- @@ -76,13 +104,12 @@ def generates_paleos_tables(interior_struct) -> bool: Returns ------- bool - True for module 'zalmoxis' with a mantle EOS that is a PALEOS registry key. + True for module 'zalmoxis' with a PALEOS energetics key. """ if getattr(interior_struct, 'module', None) != 'zalmoxis': return False mantle = getattr(getattr(interior_struct, 'zalmoxis', None), 'mantle_eos', None) - components = eos_components(mantle or '') - return len(components) == 1 and components[0] in PALEOS_REGISTRY_KEYS + return energetics_eos_key(mantle or '') in PALEOS_REGISTRY_KEYS def twophase_registry_key(mantle_eos: str) -> str: diff --git a/tests/config/test_config.py b/tests/config/test_config.py index b5f631b45..c3690c5e0 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -2921,7 +2921,8 @@ def test_no_input_toml_uses_bare_interior_section(): 'energetics, mantle, melting_dir, stops', [ ('aragog', 'WolfBower2018:MgSiO3', None, True), - ('aragog', 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', None, True), + ('aragog', 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', None, True), + ('aragog', 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', None, False), ('aragog', 'PALEOS:MgSiO3', None, False), ('aragog', 'WolfBower2018:MgSiO3', 'Monteux-600', False), ('spider', 'WolfBower2018:MgSiO3', None, False), diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index d4302f66c..24af45f8b 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -274,11 +274,14 @@ def test_no_warning_where_melting_dir_is_read(self, caplog): ) assert not self._warned(caplog, module='zalmoxis', melting_dir=None) - def test_no_warning_for_a_paleos_mixture(self, caplog): - """A mixture string gets no generated PALEOS table set, so melting_dir is read.""" + def test_warning_follows_the_mgsio3_component_of_a_mixture(self, caplog): + """A PALEOS mixture gets the generated set, so melting_dir is not read; a + mixture with a Wolf and Bower MgSiO3 component reads it.""" mix = Zalmoxis(mantle_eos='PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1') + assert self._warned(caplog, module='zalmoxis', zalmoxis=mix, melting_dir='Monteux-600') + wb = Zalmoxis(mantle_eos='WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1') assert not self._warned( - caplog, module='zalmoxis', zalmoxis=mix, melting_dir='Monteux-600' + caplog, module='zalmoxis', zalmoxis=wb, melting_dir='Monteux-600' ) single = Zalmoxis(mantle_eos='PALEOS-2phase:MgSiO3') assert self._warned( diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 165f8f15b..40eadadfc 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1472,7 +1472,8 @@ def test_solve_with_retry_first_solve_rejects_non_finite_tcore(monkeypatch): [ ('zalmoxis', 'PALEOS:MgSiO3', True), ('zalmoxis', 'PALEOS-2phase:MgSiO3', True), - ('zalmoxis', 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), + ('zalmoxis', 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', True), + ('zalmoxis', 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', False), ('zalmoxis', 'WolfBower2018:MgSiO3', False), ('dummy', 'PALEOS:MgSiO3', False), ], @@ -1482,8 +1483,8 @@ def test_melting_curve_files_follow_the_generated_table_set( ): """Aragog reads PALEOS curves only with a generated PALEOS set, else melting_dir. - A PALEOS mixture gets no generated set, so SPIDER and the P-S tables use - melting_dir; Aragog must read the same curves. + A mixture follows its MgSiO3 component, so SPIDER, the P-S tables and Aragog + read the same curves. """ from proteus.interior_energetics import aragog as aragog_mod @@ -1504,6 +1505,56 @@ def test_melting_curve_files_follow_the_generated_table_set( assert rmc.call_args.args[0] == 'Monteux-600' +@pytest.mark.unit +@pytest.mark.physics_invariant +@pytest.mark.parametrize( + ('mantle_eos', 'key', 'paleos'), + [ + ('PALEOS:MgSiO3', 'PALEOS:MgSiO3', True), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'PALEOS:MgSiO3', True), + ('Chabrier:H:0.03+PALEOS:MgSiO3:0.97', 'PALEOS:MgSiO3', True), + ( + 'PALEOS-API:H2O:0.2+PALEOS-2phase:MgSiO3-highres:0.8', + 'PALEOS-2phase:MgSiO3-highres', + True, + ), + ('PALEOS:H2O:0.5+PALEOS:iron:0.5', 'PALEOS-2phase:MgSiO3', True), + ('PALEOS:H2O:0.1+PALEOS-API:iron:0.9', 'PALEOS-API-2phase:MgSiO3', True), + ('PALEOS:H2O:0.1+WolfBower2018:MgSiO3:0.9', 'WolfBower2018:MgSiO3', False), + ('RTPress100TPa:MgSiO3:0.9+PALEOS:H2O:0.1', 'RTPress100TPa:MgSiO3', False), + ], +) +def test_structure_and_energetics_read_one_melting_curve( + tmp_path, monkeypatch, mantle_eos, key, paleos +): + """The structure melting curve and the Aragog curve of a mantle EOS are the same, + for single keys and every kind of mixture.""" + from proteus.interior_energetics import aragog as aragog_mod + from proteus.interior_struct.zalmoxis import load_zalmoxis_solidus_liquidus_functions + from proteus.utils import data as data_mod + from proteus.utils.helper import energetics_eos_key, generates_paleos_tables + + local = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'TestCurve' + local.mkdir(parents=True) + P = np.array([1e5, 1e11, 1e12]) + np.savetxt(local / 'solidus_P-T.dat', np.column_stack([P, [1000.0, 2000.0, 3000.0]])) + np.savetxt(local / 'liquidus_P-T.dat', np.column_stack([P, [1500.0, 2600.0, 3700.0]])) + monkeypatch.setattr(aragog_mod, 'FWL_DATA_DIR', tmp_path) + monkeypatch.setattr(data_mod, 'FWL_DATA_DIR', tmp_path, raising=False) + config = _make_aragog_config(struct_module='zalmoxis', mantle_eos=mantle_eos) + config.interior_struct.melting_dir = 'TestCurve' + config.interior_struct.zalmoxis.mushy_zone_factor = 0.8 + + assert energetics_eos_key(mantle_eos) == key + assert generates_paleos_tables(config.interior_struct) is paleos + sol_fn, liq_fn = load_zalmoxis_solidus_liquidus_functions(mantle_eos, config) + sol_file, liq_file = aragog_mod._melting_curve_files(config, str(tmp_path / 'out')) + for fn, path in ((sol_fn, sol_file), (liq_fn, liq_file)): + table = np.loadtxt(path) + assert float(fn(50e9)) == pytest.approx(np.interp(50e9, *table.T), rel=1e-3) + assert (float(liq_fn(50e9)) == pytest.approx(2050.0)) is not paleos + + @pytest.mark.unit @pytest.mark.physics_invariant def test_paleos_melting_curves_follow_mzf_in_reused_outdir(tmp_path): @@ -1562,15 +1613,18 @@ def test_helpfile_output_t_cmb_node_is_cmb_basic_node(): ('mantle_eos', 'eos_dir', 'tables'), [ ('PALEOS-2phase:MgSiO3', None, 'paleos'), - ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', None, 'wb'), - ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'Custom', 'custom'), - ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'Missing', 'wb'), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', None, 'paleos'), + ('PALEOS:H2O:0.5+PALEOS:iron:0.5', None, 'paleos'), + ('WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', None, 'wb'), + ('WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', 'Custom', 'custom'), + ('WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', 'Missing', 'wb'), ], ) def test_setup_solver_property_tables_follow_the_generated_set( tmp_path, mantle_eos, eos_dir, tables ): - """Only a generated PALEOS set gives PALEOS property tables; a mixture reads the WB set.""" + """A generated PALEOS set, also for a PALEOS mixture, gives PALEOS property tables; + a mixture whose MgSiO3 component is Wolf and Bower reads the WB set.""" from proteus.interior_energetics.aragog import AragogRunner outdir = tmp_path / 'out' @@ -1704,12 +1758,12 @@ def test_setup_solver_stops_without_the_paleos_pair(tmp_path, kept, liquid_path) @pytest.mark.unit -def test_setup_solver_offline_mixture_names_the_fetch_command(tmp_path): - """A PALEOS mixture without the fetched WB set stops with the fetch command.""" +def test_setup_solver_offline_wolf_bower_mixture_names_the_fetch_command(tmp_path): + """A Wolf and Bower mixture without the fetched WB set stops with the fetch command.""" from proteus.interior_energetics.aragog import AragogRunner config = _make_aragog_config( - struct_module='zalmoxis', mantle_eos='PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1' + struct_module='zalmoxis', mantle_eos='WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1' ) config.interior_struct.eos_dir = None interior_o = MagicMock() diff --git a/tests/interior_energetics/test_liquidus_super_tables.py b/tests/interior_energetics/test_liquidus_super_tables.py index cb1f7e07e..179934f82 100644 --- a/tests/interior_energetics/test_liquidus_super_tables.py +++ b/tests/interior_energetics/test_liquidus_super_tables.py @@ -820,9 +820,10 @@ def test_zalmoxis_anchor_clamp_caps_the_ic_entropy( @pytest.mark.physics_invariant -def test_paleos_mixture_ic_is_solved_without_the_anchor_cap(fake_tables, monkeypatch): - """A PALEOS mixture gets no generated table set, so the IC is solved on the - melting_dir tables without the pure-MgSiO3 P-T anchor or its entropy cap. +def test_wolf_bower_mixture_ic_is_solved_without_the_anchor_cap(fake_tables, monkeypatch): + """A mixture whose MgSiO3 component is Wolf and Bower gets no generated table set, + so the IC is solved on the melting_dir tables without the PALEOS P-T anchor or its + entropy cap; a PALEOS mixture keeps both, like a single PALEOS mantle. """ zal = pytest.importorskip('proteus.interior_struct.zalmoxis') calls = [] @@ -834,25 +835,27 @@ def _anchor(config, hf_row): monkeypatch.setattr(common, '_ANCHOR_CAP_WARNED', set()) monkeypatch.setattr(zal, 'solve_superliquidus_adiabat', _anchor) cfg = _config(500.0, module='zalmoxis') - cfg.interior_struct.zalmoxis.mantle_eos = 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1' + cfg.interior_struct.zalmoxis.mantle_eos = 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1' assert _S_expected(500.0) > 2300.0 # the cap would bind S_mix = compute_initial_entropy(cfg, {'P_cmb': P_CMB}, 3300.0, fake_tables) assert S_mix == pytest.approx(_S_expected(500.0), rel=1e-6) assert calls == [] - # Discrimination: a single PALEOS mantle keeps the anchor and its cap. - cfg.interior_struct.zalmoxis.mantle_eos = 'PALEOS-2phase:MgSiO3' - S_single = compute_initial_entropy(cfg, {'P_cmb': P_CMB}, 3300.0, fake_tables) - assert S_single == pytest.approx(2300.0, rel=1e-12) - assert calls == ['PALEOS-2phase:MgSiO3'] + # Discrimination: a single PALEOS mantle and a PALEOS mixture keep the anchor and its cap. + for mantle in ('PALEOS-2phase:MgSiO3', 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1'): + cfg.interior_struct.zalmoxis.mantle_eos = mantle + S_paleos = compute_initial_entropy(cfg, {'P_cmb': P_CMB}, 3300.0, fake_tables) + assert S_paleos == pytest.approx(2300.0, rel=1e-12) + assert calls == ['PALEOS-2phase:MgSiO3', 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1'] @pytest.mark.parametrize( ('mantle_eos', 'energetics', 'deferred'), [ ('PALEOS-2phase:MgSiO3', 'aragog', True), - ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'aragog', False), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'aragog', True), + ('WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', 'aragog', False), ('WolfBower2018:MgSiO3', 'spider', False), ('PALEOS:MgSiO3:1.0', 'aragog', True), ], diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index ee50fc89c..ea726df47 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -968,7 +968,6 @@ def test_require_paleos_tables_lets_a_resume_keep_its_tables(tmp_path, monkeypat ('PALEOS:iron', True), ('PALEOS:MgSiO3', False), ('PALEOS:H2O:1.0', True), - ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), ], ) def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( @@ -976,7 +975,7 @@ def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( ): """A PALEOS H2O or iron mantle gets one WARNING at the start of the run that its energetics use the MgSiO3 curves and tables; the per-solve check adds none, and an - MgSiO3 mantle or a string that is not a registry key gets none.""" + MgSiO3 mantle gets none.""" from proteus.interior_struct import zalmoxis as zmod registry = _paleos_registry(tmp_path) @@ -1057,24 +1056,67 @@ def _resolve(entry): @pytest.mark.parametrize('module', ['aragog', 'spider']) -def test_require_paleos_tables_warns_for_a_mixture_without_mgsio3( - tmp_path, monkeypatch, caplog, module +@pytest.mark.parametrize( + 'mantle, energetics', + [ + ( + 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', + 'are PALEOS MgSiO3 (PALEOS:MgSiO3, solidus = 0.80', + ), + ('PALEOS:H2O:0.5+PALEOS:iron:0.5', 'are PALEOS MgSiO3 (PALEOS-2phase:MgSiO3, solidus'), + ( + 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', + 'follow WolfBower2018:MgSiO3 and melting_dir', + ), + ], +) +def test_require_paleos_tables_warns_once_for_a_paleos_mixture( + tmp_path, monkeypatch, caplog, module, mantle, energetics ): - """A PALEOS mixture with no MgSiO3 component gets one WARNING that its energetics - use the Wolf and Bower tables and the melting_dir curves.""" + """A mixture with a PALEOS component gets one WARNING that its other components + enter only the structure density, naming the set its energetics follow.""" from proteus.interior_struct import zalmoxis as zmod monkeypatch.setattr( zmod, 'load_zalmoxis_material_dictionaries', lambda: _paleos_registry(tmp_path) ) - config = _require_config('PALEOS:H2O:0.5+PALEOS:iron:0.5') + config = _require_config(mantle) config.interior_energetics.module = module with caplog.at_level('WARNING', logger='fwl.proteus.interior_struct.zalmoxis'): zmod.require_paleos_tables(config, str(tmp_path)) warnings = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] assert len(warnings) == 1 - assert 'Wolf and Bower (2018) MgSiO3 tables' in warnings[0] - assert 'melting_dir curves (Monteux-600)' in warnings[0] + assert ( + f'mantle_eos={mantle}: the non-MgSiO3 components enter only the structure density' + in (warnings[0]) + ) + assert f'the energetics and melting curves {energetics}' in warnings[0] + + +@pytest.mark.parametrize( + 'mixture, single', + [ + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'PALEOS:MgSiO3'), + ('PALEOS:H2O:0.3+PALEOS:MgSiO3:0.7', 'PALEOS:MgSiO3'), + ('PALEOS:H2O:0.5+PALEOS:iron:0.5', 'PALEOS-2phase:MgSiO3'), + ], +) +def test_a_paleos_mixture_gets_the_ps_key_of_its_mgsio3_set(tmp_path, mixture, single): + """The P-S tables of a PALEOS mixture carry the key of its MgSiO3 set, the same key + and cache as that single mantle, whatever the fractions.""" + from proteus.interior_struct import zalmoxis as zmod + + registry = _paleos_registry(tmp_path) + keys = [] + for mantle in (mixture, single): + config = _require_config(mantle) + config.planet.mass_tot = 1.0 + config.interior_struct.zalmoxis.lookup_nP = 20 + config.interior_struct.zalmoxis.lookup_nS = 30 + key, entry = zmod._energetics_entry(mantle, registry) + keys.append((key, zmod._ps_table_inputs(config, key, entry, registry)[-1])) + assert keys[0] == keys[1] + assert keys[0][0] == single def test_generate_spider_tables_stops_on_a_missing_pair_table(tmp_path, monkeypatch): diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index a320c09bc..a2f0b0e92 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3080,8 +3080,8 @@ def test_get_sufficient_dummy_structure_fetches_ps_tables_without_eos_dir( _get_sufficient(config, clean=False) assert mock_dyn.call_count == 0 - # A PALEOS mixture gets no generated set, so Aragog reads the fetched set. - config.interior_struct.zalmoxis.mantle_eos = 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1' + # A mixture with a Wolf and Bower MgSiO3 component reads the fetched set. + config.interior_struct.zalmoxis.mantle_eos = 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1' _get_sufficient(config, clean=False) assert mock_dyn.call_count == 1 @@ -3096,7 +3096,8 @@ def test_get_sufficient_dummy_structure_fetches_ps_tables_without_eos_dir( ('aragog', 'zalmoxis', None, 'PALEOS:MgSiO3', False), ('aragog', 'zalmoxis', None, 'PALEOS-2phase:MgSiO3', False), ('aragog', 'zalmoxis', None, 'WolfBower2018:MgSiO3', True), - ('spider', 'zalmoxis', None, 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', True), + ('spider', 'zalmoxis', None, 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), + ('spider', 'zalmoxis', None, 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', True), ('aragog', 'zalmoxis', None, 'PALEOS:MgSiO3:1.0', False), ('aragog', 'zalmoxis', 'WolfBower2018_MgSiO3', 'PALEOS:MgSiO3', True), ('dummy', 'dummy', None, 'PALEOS:MgSiO3', False), diff --git a/tests/utils/test_helper.py b/tests/utils/test_helper.py index 136d82d63..01be85f89 100644 --- a/tests/utils/test_helper.py +++ b/tests/utils/test_helper.py @@ -833,14 +833,17 @@ def test_parse_subyear_time_rejects_multiple_p(): ('PALEOS:MgSiO3:1.0', True), ('PALEOS:MgSiO3 ', True), ('PALEOS:Olivine', False), - ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', True), + ('PALEOS:H2O:0.5+PALEOS:iron:0.5', True), + ('WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', False), ('WolfBower2018:MgSiO3', False), (None, False), ], ) -def test_generates_paleos_tables_needs_a_single_registry_key(mantle_eos, expected): - """Only one component whose key is a PALEOS registry key, under the Zalmoxis - structure, counts; a mixture or an unknown material does not.""" +def test_generates_paleos_tables_follows_the_energetics_key(mantle_eos, expected): + """Under the Zalmoxis structure, a PALEOS energetics key counts: a single PALEOS + key, or a mixture whose MgSiO3 component is PALEOS or that has no MgSiO3 + component but a PALEOS one; an unknown material does not.""" from types import SimpleNamespace from proteus.utils.helper import generates_paleos_tables From ea9176e786b49f83f021bb39081f8d7d07ef4d06 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 11:00:15 +0200 Subject: [PATCH 41/76] Reject a mixture with two MgSiO3 sources at config load A mixture whose MgSiO3 components have different registry keys has no single melting curve, so the configuration is rejected and the error names both keys. The same key repeated is one material and stays allowed. --- src/proteus/config/_struct.py | 8 +++++--- src/proteus/utils/helper.py | 15 +++++++++++++-- tests/config/test_struct.py | 22 ++++++++++++++++++++++ tests/interior_energetics/test_aragog.py | 11 ++++++++--- tests/interior_energetics/test_wrapper.py | 3 +++ 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index 2054c6486..ab2eb8612 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -364,10 +364,12 @@ def __attrs_post_init__(self): f'`{param_name}` must be "self" or a positive number, got {val!r}' ) - # Zalmoxis with a single PALEOS mantle EOS derives the curves from PALEOS; - # a mixture string gets no generated table set, so melting_dir is read. - from proteus.utils.helper import generates_paleos_tables + # A PALEOS energetics key (a mixture follows its MgSiO3 component) derives the + # curves from PALEOS; energetics_eos_key rejects two MgSiO3 sources. + from proteus.utils.helper import energetics_eos_key, generates_paleos_tables + if self.module == 'zalmoxis': + energetics_eos_key(self.zalmoxis.mantle_eos) if self.melting_dir is not None and generates_paleos_tables(self): import logging as _logging diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 2b27afead..8e570b816 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -73,14 +73,25 @@ def energetics_eos_key(mantle_eos: str) -> str | None: Returns ------- str or None - The key of a single component. For a mixture, its first MgSiO3 component; + The key of a single component. For a mixture, its MgSiO3 component; without one, the MgSiO3 2-phase pair of its PALEOS family when a component is PALEOS; else None. + + Raises + ------ + ValueError + If a mixture has MgSiO3 components with different registry keys. """ components = eos_components(mantle_eos) if len(components) == 1: return components[0] - mgsio3 = [c for c in components if c.partition(':')[2].startswith('MgSiO3')] + mgsio3 = sorted({c for c in components if c.partition(':')[2].startswith('MgSiO3')}) + if len(mgsio3) > 1: + raise ValueError( + f'mantle_eos={mantle_eos!r} has MgSiO3 components from different sources ' + f'({", ".join(mgsio3)}); the melting curves and energetics follow one ' + 'MgSiO3 component, so keep one of them.' + ) if mgsio3: return mgsio3[0] if any(c.startswith(PALEOS_EOS_PREFIXES) for c in components): diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index 24af45f8b..6bdbe28d4 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -287,3 +287,25 @@ def test_warning_follows_the_mgsio3_component_of_a_mixture(self, caplog): assert self._warned( caplog, module='zalmoxis', zalmoxis=single, melting_dir='Monteux-600' ) + + +@pytest.mark.parametrize( + 'mantle, rejected', + [ + ('PALEOS:MgSiO3:0.5+WolfBower2018:MgSiO3:0.5', True), + ('PALEOS:MgSiO3:0.5+PALEOS-2phase:MgSiO3:0.5', True), + ('PALEOS:MgSiO3:0.5+PALEOS:MgSiO3:0.5', False), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', False), + ], +) +def test_two_mgsio3_sources_are_rejected_at_load(mantle, rejected): + """A mixture with MgSiO3 components of different keys has no single melting curve, + so it is rejected, naming both; one key repeated is one material.""" + kwargs = dict(module='zalmoxis', zalmoxis=Zalmoxis(mantle_eos=mantle)) + if rejected: + with pytest.raises(ValueError, match='MgSiO3 components from different sources') as exc: + Struct(**kwargs) + for key in mantle.replace(':0.5', '').split('+'): + assert key in str(exc.value) + else: + Struct(**kwargs) diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 40eadadfc..c7f5f6543 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -145,12 +145,17 @@ def test_setup_solver_zalmoxis_inner_radius(tmp_path): @pytest.mark.unit -def test_setup_solver_zalmoxis_wolfbower_temp(tmp_path): - """setup_solver uses Zalmoxis T-profile for WolfBower2018 EOS (initial_condition=2).""" +@pytest.mark.parametrize( + 'mantle_eos', ['WolfBower2018:MgSiO3', 'PALEOS:H2O:0.1+WolfBower2018:MgSiO3:0.9'] +) +def test_setup_solver_zalmoxis_wolfbower_temp(tmp_path, mantle_eos): + """setup_solver uses the Zalmoxis T-profile (initial_condition=2) for a WolfBower2018 + MgSiO3 component, also when a mixture lists another component first.""" from proteus.interior_energetics.aragog import AragogRunner outdir = str(tmp_path) - config = _make_aragog_config(struct_module='zalmoxis', mantle_eos='WolfBower2018:MgSiO3') + config = _make_aragog_config(struct_module='zalmoxis', mantle_eos=mantle_eos) + config.interior_struct.melting_dir = 'Monteux-600' hf_row = { 'R_int': 6.371e6, diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index f73a1bfe7..0830423c1 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -1338,6 +1338,9 @@ def test_determine_zalmoxis_no_adiabatic_switch_non_tdep(caplog): # T-dep prefixes: override expected ('WolfBower2018:MgSiO3', True), ('WolfBower2018:Fe', True), + # A mixture follows its MgSiO3 component, not its first component + ('PALEOS:H2O:0.1+WolfBower2018:MgSiO3:0.9', True), + ('WolfBower2018:Fe:0.1+PALEOS:MgSiO3:0.9', False), ('RTPress100TPa:silicate', True), # Non-T-dep prefixes: override skipped ('Seager2007:silicate', False), From e399c9f97f39c712cff5db405ee67384f488a06c Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 11:16:37 +0200 Subject: [PATCH 42/76] Name fwl-io relocate in the missing-data errors of an offline run The errors for a missing interior EOS table, spectral file, melting curve or P-S table set now also say that data kept in the older FWL_DATA layout can be moved into place with fwl-io relocate. --- src/proteus/atmos_clim/wrapper.py | 5 ++++- src/proteus/interior_energetics/wrapper.py | 8 ++++++-- src/proteus/interior_struct/zalmoxis.py | 4 ++-- src/proteus/utils/data.py | 5 +++++ tests/interior_energetics/test_wrapper.py | 2 ++ tests/interior_struct/test_zalmoxis.py | 3 ++- 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/proteus/atmos_clim/wrapper.py b/src/proteus/atmos_clim/wrapper.py index 799815c3a..1e9e740cd 100644 --- a/src/proteus/atmos_clim/wrapper.py +++ b/src/proteus/atmos_clim/wrapper.py @@ -315,9 +315,12 @@ def run_atmosphere( if no_atm or update_stellar_spectrum: spectral_file_nostar = get_spfile_path(dirs['fwl'], config) if not os.path.exists(spectral_file_nostar): + from proteus.utils.data import RELOCATE_HINT + UpdateStatusfile(dirs, 20) raise FileNotFoundError( - "Spectral file does not exist at '%s'" % spectral_file_nostar + f"Spectral file does not exist at '{spectral_file_nostar}'. Fetch it with " + f'`proteus get spectral`. {RELOCATE_HINT}' ) wl = np.array(wl) diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 86f3a9acb..9d183b321 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -713,12 +713,14 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: sol_pt_path, liq_pt_path = resolve_melting_curve_files(melting_dir) missing_pt = [str(p) for p in (sol_pt_path, liq_pt_path) if not p.is_file()] if missing_pt: + from proteus.utils.data import RELOCATE_HINT + # Other curves would change the physics of the run, so stop here. raise MissingMeltingCurveError( f'interior_struct.melting_dir={melting_dir!r} is configured but its P-T ' f'melting curves are missing: {", ".join(missing_pt)}. Fetch them with ' "'proteus get interiordata --config-path ', or set " - 'melting_dir to an available curve.' + f'melting_dir to an available curve. {RELOCATE_HINT}' ) # Case 1: already populated (e.g. by an earlier call this session or @@ -876,6 +878,8 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: ) # Case 4: neither source yielded a complete set. + from proteus.utils.data import RELOCATE_HINT + raise FileNotFoundError( 'Could not provide SPIDER/Aragog P-S EOS tables at ' f'{target_dir}. FWL_DATA source ' @@ -885,7 +889,7 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: f'was unavailable at {dirs.get("spider", "")}' '/lookup_data/1TPa-dK09-elec-free/. Run `proteus get interiordata --config-path ` to ' 'fetch Zenodo record 19473625, or ensure the SPIDER submodule ' - 'is cloned.' + f'is cloned. {RELOCATE_HINT}' ) diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index cc107019c..055a66f1d 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -37,7 +37,7 @@ R_earth, element_list, ) -from proteus.utils.data import GetFWLData, get_zalmoxis_melting_curves +from proteus.utils.data import RELOCATE_HINT, GetFWLData, get_zalmoxis_melting_curves from proteus.utils.helper import ( _strip_fraction_tokens, energetics_eos_key, @@ -1893,7 +1893,7 @@ def check_zalmoxis_eos_files( f'Interior EOS table file(s) not found:\n {listing}\n' 'Download them with ' '`proteus get interiordata --config-path `, ' - 'or run `proteus start` once without --offline.' + f'or run `proteus start` once without --offline. {RELOCATE_HINT}' ) diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index c33c685fe..c33e5564b 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -30,6 +30,11 @@ log = logging.getLogger('fwl.' + __name__) FWL_DATA_DIR = resolve_fwl_data_dir() + +# Appended to the errors of a data file that an offline run cannot find. +RELOCATE_HINT = ( + 'Data kept in the older FWL_DATA layout can be moved into place with `fwl-io relocate`.' +) MAX_ATTEMPTS = 3 MAX_DLTIME = 120.0 # seconds RETRY_WAIT = 5.0 # seconds diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 0830423c1..edc8ad43c 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -3444,6 +3444,7 @@ def test_provide_spider_eos_tables_hard_failure_when_no_source(tmp_path, monkeyp # (`proteus get all`); a regression that silently fell through # would not raise at all. assert 'proteus get interiordata --config-path' in str(exc.value) + assert '`fwl-io relocate`' in str(exc.value) # ============================================================================ @@ -4668,6 +4669,7 @@ def test_provide_spider_eos_tables_missing_configured_curve_raises(tmp_path): assert raised.type is MissingMeltingCurveError assert 'monteux_minus_600' in str(raised.value) assert 'proteus get interiordata --config-path' in str(raised.value) + assert '`fwl-io relocate`' in str(raised.value) assert 'spider_solidus_ps' not in dirs diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index ea726df47..59e308f0c 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -787,9 +787,10 @@ def test_check_eos_files_raises_actionable_error(tmp_path): assert 'paleos_iron.dat' in msg assert 'mgsio3_liquid.dat' in msg assert 'mgsio3_solid.dat' in msg - # The error names the command that repairs the state. + # The error names the commands that repair the state. assert 'proteus get interiordata' in msg assert '--offline' in msg + assert '`fwl-io relocate`' in msg def _paleos_registry(tmp_path, missing: str = '') -> dict: From 2b43d4e8a121a60cc08e3f187d78215f2c8ad364 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 11:40:12 +0200 Subject: [PATCH 43/76] Build PALEOS P-S tables only for a PALEOS energetics key generate_spider_tables returns None when the energetics key of the mantle is not a PALEOS registry key. A Wolf and Bower or RTPress entry has solid and melt sub-tables too, so with its files on disk it passed as a PALEOS 2-phase set and got P-S tables with PALEOS melting curves; such a mantle, alone or as the MgSiO3 component of a mixture, now reads the Wolf and Bower tables and melting_dir. The 2-phase family follows a PALEOS MgSiO3 component when the mixture has one, so the P-S tables, the table check, the fetch and Aragog read one pair. energetics_eos_key treats one key written as several components as that key, and eos_components drops empty and bare-number components. The PALEOS sentinel table of the initial-condition and anchor helpers follows the energetics key, so a PALEOS:MgSiO3 mixture reads the same file as a single PALEOS:MgSiO3 mantle. The Aragog missing-table errors name fwl-io relocate, and the melting_dir errors describe the table rule for mixtures. --- src/proteus/config/_config.py | 4 +- src/proteus/config/_struct.py | 2 +- src/proteus/interior_energetics/aragog.py | 15 ++++--- src/proteus/interior_energetics/common.py | 6 +-- src/proteus/interior_energetics/wrapper.py | 2 +- src/proteus/interior_struct/zalmoxis.py | 17 ++++---- src/proteus/utils/data.py | 11 ++--- src/proteus/utils/helper.py | 13 +++--- tests/config/test_struct.py | 5 +++ tests/interior_energetics/test_aragog.py | 3 +- tests/interior_struct/test_zalmoxis.py | 47 +++++++++++++++++++--- tests/utils/test_helper.py | 17 +++++++- 12 files changed, 100 insertions(+), 42 deletions(-) diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index fe7e37458..247faeb4b 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -256,8 +256,8 @@ def aragog_needs_melting_curves(instance, attribute, value): return raise ValueError( "interior_energetics.module = 'aragog' needs interior_struct.melting_dir without a " - 'generated PALEOS table set (a single PALEOS mantle EOS with the Zalmoxis ' - 'structure). Set melting_dir to a melting curve name (e.g. "Monteux-600").' + 'generated PALEOS table set (a PALEOS mantle EOS under the Zalmoxis structure; ' + 'a mixture follows its MgSiO3 component). Set melting_dir to a melting curve name (e.g. "Monteux-600").' ) diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index ab2eb8612..15383101d 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -368,7 +368,7 @@ def __attrs_post_init__(self): # curves from PALEOS; energetics_eos_key rejects two MgSiO3 sources. from proteus.utils.helper import energetics_eos_key, generates_paleos_tables - if self.module == 'zalmoxis': + if self.module == 'zalmoxis' and self.zalmoxis is not None: energetics_eos_key(self.zalmoxis.mantle_eos) if self.melting_dir is not None and generates_paleos_tables(self): import logging as _logging diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index a0b44d686..e8fb2b01b 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -37,7 +37,11 @@ from proteus.interior_energetics.timestep import next_step from proteus.interior_energetics.wrapper import get_core_density, get_core_heatcap from proteus.utils.constants import radnuc_data -from proteus.utils.data import resolve_lookup_table_dir, resolve_melting_curve_files +from proteus.utils.data import ( + RELOCATE_HINT, + resolve_lookup_table_dir, + resolve_melting_curve_files, +) from proteus.utils.helper import energetics_eos_key, generates_paleos_tables from proteus.utils.helper import format_subyear_time, parse_subyear_time, snapshot_path_for_time @@ -93,7 +97,7 @@ def _melting_curve_files(config, outdir): if config.interior_struct.melting_dir is None: raise ValueError( 'interior_struct.melting_dir must be set without a generated PALEOS ' - 'table set (a single PALEOS mantle EOS with the Zalmoxis structure). ' + 'table set (a PALEOS mantle EOS under the Zalmoxis structure). ' 'Provide a melting curve folder name (e.g. "Monteux-600").' ) return resolve_melting_curve_files( @@ -985,7 +989,8 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s ] raise ZalmoxisMissingEOSFilesError( f'PALEOS 2-phase MgSiO3 tables {_twophase_key} not found: {", ".join(missing)}. ' - 'Download them with `proteus get interiordata --config-path `.' + 'Download them with `proteus get interiordata --config-path `. ' + f'{RELOCATE_HINT}' ) else: # Fetched Wolf and Bower 2018 tables, used when eos_dir is None or its @@ -1010,7 +1015,7 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s if not (LOOK_UP_DIR / 'heat_capacity_melt.dat').is_file(): raise FileNotFoundError( f'Aragog lookup data not found at {LOOK_UP_DIR}. Fetch it with ' - "'proteus get interiordata --config-path '." + f"'proteus get interiordata --config-path '. {RELOCATE_HINT}" ) # Entropy tables (optional): enable entropy-conserving adiabatic IC. @@ -1466,7 +1471,7 @@ def _verify_entropy_ic( # `eos_file` arg for compute_entropy_adiabat is a sentinel: any # valid PALEOS table works. Prefer unified eos_file when present # (paleos_unified mantle), else fall back to solid 2-phase path. - eos_entry = mat_dicts.get(mantle_eos, {}) + eos_entry = mat_dicts.get(energetics_eos_key(mantle_eos) or '', {}) paleos_eos_file = eos_entry.get('eos_file', '') or solid_eos or '' if not paleos_eos_file or not os.path.isfile(paleos_eos_file): log.debug( diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index 5113e0a1c..de764141f 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -11,7 +11,7 @@ from proteus.utils.constants import B_ein from proteus.utils.data import find_lookup_table_dir -from proteus.utils.helper import generates_paleos_tables +from proteus.utils.helper import energetics_eos_key, generates_paleos_tables if TYPE_CHECKING: from aragog.eos.entropy import EntropyEOS @@ -980,7 +980,7 @@ def compute_initial_entropy( mat_dicts = load_zalmoxis_material_dictionaries() solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(zalmoxis_cfg.mantle_eos, mat_dicts) - eos_entry = mat_dicts.get(zalmoxis_cfg.mantle_eos, {}) + eos_entry = mat_dicts.get(energetics_eos_key(zalmoxis_cfg.mantle_eos) or '', {}) paleos_eos_file = eos_entry.get('eos_file', '') or solid_eos or '' melt_funcs = load_zalmoxis_solidus_liquidus_functions(zalmoxis_cfg.mantle_eos, config) @@ -1079,7 +1079,7 @@ def compute_initial_entropy( # eos_file) can use the solid sub-table as the sentinel. solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(zalmoxis_cfg.mantle_eos, mat_dicts) - eos_entry = mat_dicts.get(zalmoxis_cfg.mantle_eos, {}) + eos_entry = mat_dicts.get(energetics_eos_key(zalmoxis_cfg.mantle_eos) or '', {}) paleos_eos_file = eos_entry.get('eos_file', '') or solid_eos or '' if not paleos_eos_file or not os.path.isfile(paleos_eos_file): raise FileNotFoundError(f'PALEOS table not found: {paleos_eos_file}') diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 9d183b321..3a1dfe35d 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -1155,7 +1155,7 @@ def _build_superliquidus_adiabat_tp(config: Config, hf_row: dict, P_cmb_target: zcfg = config.interior_struct.zalmoxis mat_dicts = load_zalmoxis_material_dictionaries() solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(zcfg.mantle_eos, mat_dicts) - eos_entry = mat_dicts.get(zcfg.mantle_eos, {}) + eos_entry = mat_dicts.get(energetics_eos_key(zcfg.mantle_eos) or '', {}) eos_file = eos_entry.get('eos_file', '') or solid_eos or '' if not eos_file or not os.path.isfile(eos_file): log.warning( diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 055a66f1d..428585bb2 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -32,6 +32,7 @@ from proteus.utils.constants import ( FEI2021_LIQUIDUS_P_CALIB_PA, PALEOS_EOS_PREFIXES, + PALEOS_REGISTRY_KEYS, VOLATILE_EOS_MAP, M_earth, R_earth, @@ -217,9 +218,6 @@ def _structure_cache_key(config): ) -_VOLATILE_EOS_MAP = VOLATILE_EOS_MAP - - def get_zalmoxis_output_filepath(outdir: str): """Returns the output file path for Zalmoxis data. Args: @@ -434,7 +432,7 @@ def build_volatile_profile(hf_row: dict, mantle_eos: str): w_solid = {} has_nonzero = False - for species, eos_name in _VOLATILE_EOS_MAP.items(): + for species, eos_name in VOLATILE_EOS_MAP.items(): kg_liq = float(hf_row.get(f'{species}_kg_liquid', 0.0)) kg_sol = float(hf_row.get(f'{species}_kg_solid', 0.0)) @@ -886,7 +884,8 @@ def _solve_superliquidus_adiabat(config: Config, hf_row: dict | None) -> dict: mat_dicts = load_zalmoxis_material_dictionaries() solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(mantle_eos, mat_dicts) - eos_file = mat_dicts.get(mantle_eos, {}).get('eos_file', '') or solid_eos or '' + eos_file = mat_dicts.get(energetics_eos_key(mantle_eos) or '', {}).get('eos_file', '') + eos_file = eos_file or solid_eos or '' melt_funcs = load_zalmoxis_solidus_liquidus_functions(mantle_eos, config) if melt_funcs is not None: sol_func, liq_func = melt_funcs @@ -1286,7 +1285,7 @@ def load_zalmoxis_configuration( # be excluded: the dissolved mass is already part of the wet-mantle # EOS, and subtracting it again would remove it twice. Note this # keeps ALL dissolved species inside the target, while the EOS blend - # only represents those in _VOLATILE_EOS_MAP (H2O in practice); the + # only represents those in VOLATILE_EOS_MAP (H2O in practice); the # dissolved mass of unmapped species (CO2, CH4, N2, S2) lands in the # silicate remainder fraction and is packed at silicate density. # Mass stays exact, compressibility is misrepresented; the radius @@ -1940,7 +1939,7 @@ def require_paleos_tables(config: Config, outdir: str) -> None: ) check_zalmoxis_eos_files(layers, mat_dicts, paleos_companions=not kept) components = eos_components(zc.mantle_eos) - mixture = len(components) > 1 + mixture = len(set(components)) > 1 if not any(c.startswith(PALEOS_EOS_PREFIXES) for c in components) or ( not mixture and components[0].partition(':')[2].startswith('MgSiO3') ): @@ -2701,9 +2700,9 @@ def generate_spider_tables(config: Config, outdir: str): if resumed is not None: return resumed - if eos_entry is None: + if eos_entry is None or key not in PALEOS_REGISTRY_KEYS: log.info( - 'Mantle EOS %s not found in material dictionary; using pre-existing SPIDER tables.', + 'Mantle EOS %s has no PALEOS table set; using pre-existing SPIDER tables.', mantle_eos, ) return None diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index c33e5564b..03122b38b 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1779,7 +1779,9 @@ def download_zalmoxis_eos_for_config(config) -> None: mantle_eos=getattr(zconf, 'mantle_eos', ''), core_eos=getattr(zconf, 'core_eos', ''), ice_layer_eos='' if ice in (None, 'none') else ice, - volatile_eos='' if zconf.dry_mantle else '+'.join(VOLATILE_EOS_MAP.values()), + volatile_eos='' + if getattr(zconf, 'dry_mantle', True) + else '+'.join(VOLATILE_EOS_MAP.values()), ) @@ -2002,17 +2004,12 @@ def download_Seager_EOS(): # Files Zalmoxis reads from each dataset whose record holds more than it needs. _WOLF_BOWER_SOLID_FILES = ('density_solid.dat', 'heat_capacity_solid.dat') -_WOLF_BOWER_EOS_FILES = ( - 'density_melt.dat', - 'adiabat_temp_grad_melt.dat', - 'heat_capacity_melt.dat', - *_WOLF_BOWER_SOLID_FILES, -) _RTPRESS_EOS_FILES = ( 'density_melt.dat', 'adiabat_temp_grad_melt.dat', 'heat_capacity_melt.dat', ) +_WOLF_BOWER_EOS_FILES = (*_RTPRESS_EOS_FILES, *_WOLF_BOWER_SOLID_FILES) _PALEOS_2PHASE_FILES = ( 'paleos_mgsio3_tables_pt_proteus_liquid.dat', 'paleos_mgsio3_tables_pt_proteus_solid.dat', diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 8e570b816..e4e595cc1 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -56,7 +56,7 @@ def eos_components(eos: str) -> list[str]: list[str] One key per component, with spaces and fraction tokens stripped. """ - return [_strip_fraction_tokens(c.strip()) for c in str(eos).split('+') if c.strip()] + return [k for c in str(eos or '').split('+') if (k := _strip_fraction_tokens(c.strip()))] def energetics_eos_key(mantle_eos: str) -> str | None: @@ -82,10 +82,10 @@ def energetics_eos_key(mantle_eos: str) -> str | None: ValueError If a mixture has MgSiO3 components with different registry keys. """ - components = eos_components(mantle_eos) + components = sorted(set(eos_components(mantle_eos))) if len(components) == 1: return components[0] - mgsio3 = sorted({c for c in components if c.partition(':')[2].startswith('MgSiO3')}) + mgsio3 = [c for c in components if c.partition(':')[2].startswith('MgSiO3')] if len(mgsio3) > 1: raise ValueError( f'mantle_eos={mantle_eos!r} has MgSiO3 components from different sources ' @@ -134,11 +134,14 @@ def twophase_registry_key(mantle_eos: str) -> str: Returns ------- str - ``'PALEOS-API-2phase:MgSiO3'`` when any component is of the PALEOS-API - family, ``'PALEOS-2phase:MgSiO3-highres'`` when any component is the + ``'PALEOS-API-2phase:MgSiO3'`` when a component is of the PALEOS-API + family, ``'PALEOS-2phase:MgSiO3-highres'`` when a component is the high-resolution shipped key, and ``'PALEOS-2phase:MgSiO3'`` otherwise. + A PALEOS MgSiO3 component, when present, alone sets the family. """ components = eos_components(mantle_eos) + mgsio3 = [c for c in components if c.startswith(PALEOS_EOS_PREFIXES) and ':MgSiO3' in c] + components = mgsio3 or components if any(c.startswith(('PALEOS-API:', 'PALEOS-API-2phase:')) for c in components): return 'PALEOS-API-2phase:MgSiO3' if 'PALEOS-2phase:MgSiO3-highres' in components: diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index 6bdbe28d4..939117b84 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -309,3 +309,8 @@ def test_two_mgsio3_sources_are_rejected_at_load(mantle, rejected): assert key in str(exc.value) else: Struct(**kwargs) + + +def test_zalmoxis_structure_without_its_section_loads(): + """The MgSiO3-source check needs the Zalmoxis section and skips when it is absent.""" + assert Struct(module='zalmoxis', zalmoxis=None).zalmoxis is None diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index c7f5f6543..7baaf7c52 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1785,9 +1785,10 @@ def test_setup_solver_offline_wolf_bower_mixture_names_the_fetch_command(tmp_pat with ( patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path), patch('proteus.interior_energetics.aragog.EntropySolver') as mock_solver, - pytest.raises(FileNotFoundError, match='proteus get interiordata --config-path'), + pytest.raises(FileNotFoundError, match='proteus get interiordata --config-path') as exc, ): AragogRunner.setup_solver(config, hf_row, interior_o, str(tmp_path / 'out')) + assert '`fwl-io relocate`' in str(exc.value) assert not mock_solver.called assert not (tmp_path / 'out' / 'data' / 'aragog_pt').exists() diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 59e308f0c..db0afdfd8 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -1005,17 +1005,24 @@ def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( assert ('initial adiabat is solved on the MgSiO3' in caplog.text) is adiabat_warned -def test_require_paleos_tables_needs_the_volatile_tables_of_a_wet_mantle(tmp_path, monkeypatch): - """With dry_mantle = false the water table of the dissolved volatiles is required.""" +@pytest.mark.parametrize('missing', ['h2o', 'chabrier']) +def test_require_paleos_tables_needs_the_volatile_tables_of_a_wet_mantle( + tmp_path, monkeypatch, missing +): + """With dry_mantle = false the water and hydrogen tables of the dissolved volatiles + are required.""" from proteus.interior_struct import zalmoxis as zmod - monkeypatch.setattr( - zmod, 'load_zalmoxis_material_dictionaries', lambda: _paleos_registry(tmp_path, 'h2o') - ) + registry = _paleos_registry(tmp_path, missing) + chabrier = tmp_path / 'chabrier.dat' + if missing != 'chabrier': + chabrier.write_text('eos table stub') + registry['Chabrier:H'] = {'eos_file': str(chabrier), 'format': 'paleos_unified'} + monkeypatch.setattr(zmod, 'load_zalmoxis_material_dictionaries', lambda: registry) config = _require_config('PALEOS:MgSiO3') assert zmod.require_paleos_tables(config, str(tmp_path)) is None config.interior_struct.zalmoxis.dry_mantle = False - with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='h2o.dat'): + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match=f'{missing}.dat'): zmod.require_paleos_tables(config, str(tmp_path)) @@ -1120,6 +1127,34 @@ def test_a_paleos_mixture_gets_the_ps_key_of_its_mgsio3_set(tmp_path, mixture, s assert keys[0][0] == single +@pytest.mark.parametrize( + 'mantle', ['WolfBower2018:MgSiO3', 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1'] +) +def test_generate_spider_tables_builds_no_set_for_a_wolf_bower_mantle( + tmp_path, monkeypatch, mantle +): + """A mantle whose energetics key is not PALEOS gets no generated set and no table + check, also when its registry entry has solid and melt tables on disk.""" + from proteus.interior_struct import zalmoxis as zmod + + solid, melt = tmp_path / 'wb_solid.dat', tmp_path / 'wb_melt.dat' + solid.write_text('stub') + melt.write_text('stub') + registry = { + 'WolfBower2018:MgSiO3': { + 'melted_mantle': {'eos_file': str(melt)}, + 'solid_mantle': {'eos_file': str(solid)}, + } + } + monkeypatch.setattr(zmod, 'load_zalmoxis_material_dictionaries', lambda: registry) + monkeypatch.setattr( + zmod, 'check_zalmoxis_eos_files', lambda *a, **k: pytest.fail('checked tables') + ) + config = _require_config(mantle) + config.params.resume = False + assert zmod.generate_spider_tables(config, str(tmp_path)) is None + + def test_generate_spider_tables_stops_on_a_missing_pair_table(tmp_path, monkeypatch): """A PALEOS unified mantle whose 2-phase liquid table is absent stops before any table is built, instead of building the P-S set from the unified table alone.""" diff --git a/tests/utils/test_helper.py b/tests/utils/test_helper.py index 01be85f89..125ec5c6f 100644 --- a/tests/utils/test_helper.py +++ b/tests/utils/test_helper.py @@ -894,10 +894,13 @@ def test_paleos_companion_keys_follow_the_mantle_family(): ('Chabrier:H:0.03+PALEOS-API:MgSiO3:0.97', 'PALEOS-API-2phase:MgSiO3'), (' PALEOS:H2O:0.1 + PALEOS-API:MgSiO3:0.9 ', 'PALEOS-API-2phase:MgSiO3'), ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'PALEOS-2phase:MgSiO3'), + ('PALEOS:MgSiO3:0.9+PALEOS-API:H2O:0.1', 'PALEOS-2phase:MgSiO3'), + ('PALEOS:H2O:0.5+PALEOS-API:iron:0.5', 'PALEOS-API-2phase:MgSiO3'), ], ) def test_twophase_registry_key_uses_every_component(mantle_eos, key): - """The pair family comes from any component, with fraction tokens and spaces stripped.""" + """The pair family comes from the PALEOS MgSiO3 component when there is one, else from + any component, with fraction tokens and spaces stripped.""" from proteus.utils.helper import paleos_companion_keys, twophase_registry_key assert twophase_registry_key(mantle_eos) == key @@ -912,4 +915,14 @@ def test_eos_components_strip_spaces_and_fractions(): 'PALEOS:MgSiO3', 'Chabrier:H', ] - assert eos_components('') == [] + assert eos_components('PALEOS:MgSiO3:0.9 + 0.1') == ['PALEOS:MgSiO3'] + assert eos_components('') == eos_components(None) == [] + + +def test_energetics_eos_key_treats_a_repeated_key_as_one_material(): + """One key written as several components is that key, not a mixture.""" + from proteus.utils.helper import energetics_eos_key + + assert energetics_eos_key('PALEOS:iron:0.5+PALEOS:iron:0.5') == 'PALEOS:iron' + assert energetics_eos_key('PALEOS:iron:0.5+PALEOS:H2O:0.5') == 'PALEOS-2phase:MgSiO3' + assert energetics_eos_key(None) is None From bb5f3a377b58eda2ab944b35f29a4b9d29832ff4 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 11:40:39 +0200 Subject: [PATCH 44/76] Shorten the structure volatile-mass comment --- src/proteus/interior_struct/zalmoxis.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 428585bb2..2d5d2fd6e 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1273,27 +1273,9 @@ def load_zalmoxis_configuration( config.interior_struct.zalmoxis.ice_layer_eos or 'none', ) - # Calculate the volatile mass excluded from the structure target. - # Whole-planet oxygen accounting (issue #677): atmospheric+dissolved O - # is summed alongside H/C/N/S so the dry-mass target passed to - # Zalmoxis correctly reserves space for the O that CALLIOPE places in - # atmospheric H2O, CO2, SO2, etc. Mantle FeO-bound O remains in M_int - # implicitly via the PALEOS density tables; we do not double-count it. - # With dry_mantle the full inventory is excluded (the mantle EOS - # represents bare silicate). When the mantle EOS carries dissolved - # volatiles (dry_mantle = false), only the atmospheric inventory may - # be excluded: the dissolved mass is already part of the wet-mantle - # EOS, and subtracting it again would remove it twice. Note this - # keeps ALL dissolved species inside the target, while the EOS blend - # only represents those in VOLATILE_EOS_MAP (H2O in practice); the - # dissolved mass of unmapped species (CO2, CH4, N2, S2) lands in the - # silicate remainder fraction and is packed at silicate density. - # Mass stays exact, compressibility is misrepresented; the radius - # error scales as (dissolved mass fraction) x (density contrast) and - # is negligible at typical inventories. Escaped mass is already - # debited from the *_kg_* inventories and needs no separate term. - # Defensive .get(): some pre-IC paths invoke Zalmoxis before - # calc_target_elemental_inventories has populated all element columns. + # Volatile mass (O included) excluded from the structure target: the full inventory + # with dry_mantle, else only the atmosphere, since the wet-mantle EOS holds the dissolved + # mass. Element columns absent before the first inventory count as zero (.get below). dry_mantle = config.interior_struct.zalmoxis.dry_mantle M_volatiles = 0.0 for e in element_list: From 0894292ea8972e1effcd407acbe936c7f4c58320 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 11:51:13 +0200 Subject: [PATCH 45/76] Give a PALEOS H2O or iron mantle the MgSiO3 2-phase energetics A single PALEOS:H2O or PALEOS:iron mantle (or its PALEOS-API form) now takes its melting curves and P-S tables from the MgSiO3 2-phase pair of its family, like a PALEOS mixture without MgSiO3. Its solid-phase P-S tables were built on the unified water or iron table while the melt phase and the curves were MgSiO3, so one phase boundary joined two materials. The structure keeps the density of its own material. --- src/proteus/utils/helper.py | 13 ++++++------- tests/interior_struct/test_zalmoxis.py | 2 ++ tests/utils/test_helper.py | 23 +++++++++++++++++++++-- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index e4e595cc1..dbb5ad056 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -73,9 +73,10 @@ def energetics_eos_key(mantle_eos: str) -> str | None: Returns ------- str or None - The key of a single component. For a mixture, its MgSiO3 component; - without one, the MgSiO3 2-phase pair of its PALEOS family when a component - is PALEOS; else None. + The MgSiO3 component. Without one, the MgSiO3 2-phase pair of the PALEOS + family when a component is a PALEOS registry key (so PALEOS H2O and iron + mantles use MgSiO3 curves and tables); else the key of a single component, + or None for a mixture. Raises ------ @@ -83,8 +84,6 @@ def energetics_eos_key(mantle_eos: str) -> str | None: If a mixture has MgSiO3 components with different registry keys. """ components = sorted(set(eos_components(mantle_eos))) - if len(components) == 1: - return components[0] mgsio3 = [c for c in components if c.partition(':')[2].startswith('MgSiO3')] if len(mgsio3) > 1: raise ValueError( @@ -94,9 +93,9 @@ def energetics_eos_key(mantle_eos: str) -> str | None: ) if mgsio3: return mgsio3[0] - if any(c.startswith(PALEOS_EOS_PREFIXES) for c in components): + if any(c in PALEOS_REGISTRY_KEYS for c in components): return twophase_registry_key(mantle_eos) - return None + return components[0] if len(components) == 1 else None def generates_paleos_tables(interior_struct) -> bool: diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index db0afdfd8..4ea1e7056 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -1107,6 +1107,8 @@ def test_require_paleos_tables_warns_once_for_a_paleos_mixture( ('PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', 'PALEOS:MgSiO3'), ('PALEOS:H2O:0.3+PALEOS:MgSiO3:0.7', 'PALEOS:MgSiO3'), ('PALEOS:H2O:0.5+PALEOS:iron:0.5', 'PALEOS-2phase:MgSiO3'), + ('PALEOS:H2O', 'PALEOS-2phase:MgSiO3'), + ('PALEOS:iron', 'PALEOS-2phase:MgSiO3'), ], ) def test_a_paleos_mixture_gets_the_ps_key_of_its_mgsio3_set(tmp_path, mixture, single): diff --git a/tests/utils/test_helper.py b/tests/utils/test_helper.py index 125ec5c6f..ec31f0002 100644 --- a/tests/utils/test_helper.py +++ b/tests/utils/test_helper.py @@ -923,6 +923,25 @@ def test_energetics_eos_key_treats_a_repeated_key_as_one_material(): """One key written as several components is that key, not a mixture.""" from proteus.utils.helper import energetics_eos_key - assert energetics_eos_key('PALEOS:iron:0.5+PALEOS:iron:0.5') == 'PALEOS:iron' - assert energetics_eos_key('PALEOS:iron:0.5+PALEOS:H2O:0.5') == 'PALEOS-2phase:MgSiO3' + assert energetics_eos_key('PALEOS:MgSiO3:0.5+PALEOS:MgSiO3:0.5') == 'PALEOS:MgSiO3' + assert energetics_eos_key('Seager2007:iron:0.5+Seager2007:iron:0.5') == 'Seager2007:iron' assert energetics_eos_key(None) is None + + +@pytest.mark.parametrize( + 'mantle, key', + [ + ('PALEOS:H2O', 'PALEOS-2phase:MgSiO3'), + ('PALEOS:iron:1.0', 'PALEOS-2phase:MgSiO3'), + ('PALEOS:H2O:0.5+PALEOS:iron:0.5', 'PALEOS-2phase:MgSiO3'), + ('PALEOS-API:H2O', 'PALEOS-API-2phase:MgSiO3'), + ('PALEOS:Olivine', 'PALEOS:Olivine'), + ('WolfBower2018:MgSiO3', 'WolfBower2018:MgSiO3'), + ], +) +def test_energetics_eos_key_gives_a_non_mgsio3_paleos_mantle_the_mgsio3_pair(mantle, key): + """A PALEOS H2O or iron mantle, single or mixed, uses the MgSiO3 pair of its family; + a key that is not a PALEOS registry key is kept.""" + from proteus.utils.helper import energetics_eos_key + + assert energetics_eos_key(mantle) == key From 04673160b5aef5bb882242ba5bb3bfcdfd4840ee Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 12:59:10 +0200 Subject: [PATCH 46/76] Stop when a PALEOS run's initial condition finds no 2-phase pair resolve_2phase_mgsio3_paths takes required=True from its callers in a run with a generated PALEOS table set: the super-liquidus anchor, the initial entropy, the super-liquidus initial adiabat and the Aragog entropy cross-check. A missing table then raises the missing-table error that names it, and the initial-entropy and initial-adiabat handlers let that error through instead of falling back. A resumed run that keeps its P-S tables skips the pair in the start check; when its Aragog mesh changed and the initial condition was rebuilt, the anchor ran on the unified table alone. --- src/proteus/interior_energetics/aragog.py | 4 +- src/proteus/interior_energetics/common.py | 16 +++++++- src/proteus/interior_energetics/wrapper.py | 10 ++++- src/proteus/interior_struct/zalmoxis.py | 40 ++++++++++++++++++- tests/interior_energetics/test_common.py | 28 +++++++++++++ .../test_entropy_ic_verify.py | 14 +++++-- .../test_liquidus_super_parity.py | 2 +- tests/interior_struct/test_zalmoxis.py | 14 +++++++ 8 files changed, 116 insertions(+), 12 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index e8fb2b01b..772131475 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -1466,7 +1466,9 @@ def _verify_entropy_ic( # Prefer 2-phase tables (clean phase-specific entropy at melting curve). # API-aware so PALEOS-API runs use API 2-phase tables, not shipped. - solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(mantle_eos, mat_dicts) + solid_eos, liquid_eos = resolve_2phase_mgsio3_paths( + mantle_eos, mat_dicts, required=True + ) # `eos_file` arg for compute_entropy_adiabat is a sentinel: any # valid PALEOS table works. Prefer unified eos_file when present diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index de764141f..99ede7a7e 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -979,7 +979,11 @@ def compute_initial_entropy( return fallback mat_dicts = load_zalmoxis_material_dictionaries() - solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(zalmoxis_cfg.mantle_eos, mat_dicts) + solid_eos, liquid_eos = resolve_2phase_mgsio3_paths( + zalmoxis_cfg.mantle_eos, + mat_dicts, + required=generates_paleos_tables(config.interior_struct), + ) eos_entry = mat_dicts.get(energetics_eos_key(zalmoxis_cfg.mantle_eos) or '', {}) paleos_eos_file = eos_entry.get('eos_file', '') or solid_eos or '' @@ -1077,7 +1081,11 @@ def compute_initial_entropy( # API-aware 2-phase table lookup. Required before the eos_file # sentinel selection so 2-phase mantle EoS configs (no top-level # eos_file) can use the solid sub-table as the sentinel. - solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(zalmoxis_cfg.mantle_eos, mat_dicts) + solid_eos, liquid_eos = resolve_2phase_mgsio3_paths( + zalmoxis_cfg.mantle_eos, + mat_dicts, + required=generates_paleos_tables(config.interior_struct), + ) eos_entry = mat_dicts.get(energetics_eos_key(zalmoxis_cfg.mantle_eos) or '', {}) paleos_eos_file = eos_entry.get('eos_file', '') or solid_eos or '' @@ -1111,6 +1119,10 @@ def compute_initial_entropy( return S_target except (RuntimeError, FileNotFoundError, KeyError, ValueError) as e: + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + + if isinstance(e, ZalmoxisMissingEOSFilesError): + raise log.warning( 'Could not compute entropy from PALEOS (%s). ' 'Using fallback S=%.1f J/kg/K for tsurf=%.0f K.', diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 3a1dfe35d..4fd528806 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -23,7 +23,7 @@ from proteus.interior_struct.common import solvus_radius from proteus.outgas.wrapper import calc_target_elemental_inventories from proteus.utils.constants import M_earth, R_earth, const_G, noble_gases, vol_element_list -from proteus.utils.helper import UpdateStatusfile, energetics_eos_key +from proteus.utils.helper import UpdateStatusfile, energetics_eos_key, generates_paleos_tables if TYPE_CHECKING: from proteus.config import Config @@ -1154,7 +1154,9 @@ def _build_superliquidus_adiabat_tp(config: Config, hf_row: dict, P_cmb_target: zcfg = config.interior_struct.zalmoxis mat_dicts = load_zalmoxis_material_dictionaries() - solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(zcfg.mantle_eos, mat_dicts) + solid_eos, liquid_eos = resolve_2phase_mgsio3_paths( + zcfg.mantle_eos, mat_dicts, required=generates_paleos_tables(config.interior_struct) + ) eos_entry = mat_dicts.get(energetics_eos_key(zcfg.mantle_eos) or '', {}) eos_file = eos_entry.get('eos_file', '') or solid_eos or '' if not eos_file or not os.path.isfile(eos_file): @@ -1207,6 +1209,10 @@ def _build_superliquidus_adiabat_tp(config: Config, hf_row: dict, P_cmb_target: ValueError, KeyError, ) as exc: + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + + if isinstance(exc, ZalmoxisMissingEOSFilesError): + raise log.warning( 'liquidus_super IC adiabat construction failed (%s); falling back ' 'to the linear-guess structure.', diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 2d5d2fd6e..6ee48a519 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -883,7 +883,9 @@ def _solve_superliquidus_adiabat(config: Config, hf_row: dict | None) -> dict: return cached mat_dicts = load_zalmoxis_material_dictionaries() - solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(mantle_eos, mat_dicts) + solid_eos, liquid_eos = resolve_2phase_mgsio3_paths( + mantle_eos, mat_dicts, required=generates_paleos_tables(config.interior_struct) + ) eos_file = mat_dicts.get(energetics_eos_key(mantle_eos) or '', {}).get('eos_file', '') eos_file = eos_file or solid_eos or '' melt_funcs = load_zalmoxis_solidus_liquidus_functions(mantle_eos, config) @@ -1952,7 +1954,41 @@ def require_paleos_tables(config: Config, outdir: str) -> None: log.warning('mantle_eos=%s: %s%s', zc.mantle_eos, head, '; '.join(uses)) -def resolve_2phase_mgsio3_paths(mantle_eos: str, mat_dicts: dict): +def resolve_2phase_mgsio3_paths(mantle_eos: str, mat_dicts: dict, required: bool = False): + """Return the 2-phase MgSiO3 table paths, stopping on a missing one when required. + + Parameters + ---------- + mantle_eos : str + Configured mantle EOS. + mat_dicts : dict + Zalmoxis material dictionaries. + required : bool + Raise instead of returning None for a missing table; callers set it for a + run with a generated PALEOS table set, which requires the pair. + + Returns + ------- + tuple[str | None, str | None] + See :func:`_twophase_mgsio3_paths`. + + Raises + ------ + ZalmoxisMissingEOSFilesError + If ``required`` and a table of the pair is not available. + """ + paths = _twophase_mgsio3_paths(mantle_eos, mat_dicts) + if required and None in paths: + missing = [p for p, ok in zip(('solid', 'liquid'), paths) if ok is None] + raise ZalmoxisMissingEOSFilesError( + f'PALEOS 2-phase MgSiO3 tables {twophase_registry_key(mantle_eos)} not ' + f'available: {", ".join(missing)}. Download them with ' + f'`proteus get interiordata --config-path `. {RELOCATE_HINT}' + ) + return paths + + +def _twophase_mgsio3_paths(mantle_eos: str, mat_dicts: dict): """Return (solid_eos_path, liquid_eos_path) for the 2-phase MgSiO3 tables. Selects the API key (``PALEOS-API-2phase:MgSiO3``) when ``mantle_eos`` diff --git a/tests/interior_energetics/test_common.py b/tests/interior_energetics/test_common.py index 047dc9279..9b236d8d2 100644 --- a/tests/interior_energetics/test_common.py +++ b/tests/interior_energetics/test_common.py @@ -1118,6 +1118,34 @@ def test_compute_initial_entropy_paleos_failure_logs_warning_and_falls_back( assert any('Could not compute entropy from PALEOS' in r.message for r in caplog.records) +@pytest.mark.unit +def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(tmp_path): + """With a generated PALEOS set a missing MgSiO3 pair stops the initial entropy + instead of falling back to the default entropy.""" + pytest.importorskip('zalmoxis') + from types import SimpleNamespace + from unittest.mock import patch as _patch + + from proteus.interior_energetics.common import compute_initial_entropy + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + + unified = tmp_path / 'unified.dat' + unified.write_text('stub') + config = SimpleNamespace( + planet=SimpleNamespace(temperature_mode='adiabatic', tsurf_init=2400.0), + interior_struct=SimpleNamespace( + module='zalmoxis', zalmoxis=SimpleNamespace(mantle_eos='PALEOS:MgSiO3') + ), + ) + registry = {'PALEOS:MgSiO3': {'eos_file': str(unified)}, 'PALEOS-2phase:MgSiO3': {}} + with _patch( + 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', + return_value=registry, + ): + with pytest.raises(ZalmoxisMissingEOSFilesError, match='PALEOS-2phase:MgSiO3'): + compute_initial_entropy(config, fallback=3175.0) + + # ============================================================================ # compute_initial_entropy success paths via spider_eos_dir + Zalmoxis adiabat # ============================================================================ diff --git a/tests/interior_energetics/test_entropy_ic_verify.py b/tests/interior_energetics/test_entropy_ic_verify.py index 95495f8c3..cf2b04922 100644 --- a/tests/interior_energetics/test_entropy_ic_verify.py +++ b/tests/interior_energetics/test_entropy_ic_verify.py @@ -49,6 +49,12 @@ # ====================================================================== +_FAKE_PAIR = { + 'solid_mantle': {'eos_file': '/fake/solid.dat'}, + 'melted_mantle': {'eos_file': '/fake/liquid.dat'}, +} + + def _make_mock_eos(T_recovered, P_min=0.0, P_max=1.0e12): """Mock EntropyEOS whose ``temperature`` returns a controlled value. @@ -316,7 +322,7 @@ class BrokenSolver: 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', return_value={ 'PALEOS:MgSiO3': {'eos_file': '/fake/paleos.dat'}, - 'PALEOS-2phase:MgSiO3': {}, + 'PALEOS-2phase:MgSiO3': _FAKE_PAIR, }, ), patch( @@ -362,7 +368,7 @@ class WorkingSolver: 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', return_value={ 'PALEOS:MgSiO3': {'eos_file': '/fake/paleos.dat'}, - 'PALEOS-2phase:MgSiO3': {}, + 'PALEOS-2phase:MgSiO3': _FAKE_PAIR, }, ), patch( @@ -419,7 +425,7 @@ def test_aragog_verify_runs_and_overrides_on_warn(tmp_path): 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', return_value={ 'PALEOS:MgSiO3': {'eos_file': '/fake/paleos.dat'}, - 'PALEOS-2phase:MgSiO3': {}, + 'PALEOS-2phase:MgSiO3': _FAKE_PAIR, }, ), patch( @@ -485,7 +491,7 @@ def test_aragog_verify_logs_on_large_mismatch_but_does_not_raise(tmp_path, caplo 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', return_value={ 'PALEOS:MgSiO3': {'eos_file': '/fake/paleos.dat'}, - 'PALEOS-2phase:MgSiO3': {}, + 'PALEOS-2phase:MgSiO3': _FAKE_PAIR, }, ), patch( diff --git a/tests/interior_energetics/test_liquidus_super_parity.py b/tests/interior_energetics/test_liquidus_super_parity.py index ec45da0d2..9fb46caca 100644 --- a/tests/interior_energetics/test_liquidus_super_parity.py +++ b/tests/interior_energetics/test_liquidus_super_parity.py @@ -129,7 +129,7 @@ def _fake_adiabat( monkeypatch.setattr( zmod, 'resolve_2phase_mgsio3_paths', - lambda mantle_eos, mat_dicts: ('solid.eos', 'liquid.eos'), + lambda mantle_eos, mat_dicts, **_: ('solid.eos', 'liquid.eos'), ) monkeypatch.setattr( zmod, diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 4ea1e7056..878c60ea4 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -1157,6 +1157,20 @@ def test_generate_spider_tables_builds_no_set_for_a_wolf_bower_mantle( assert zmod.generate_spider_tables(config, str(tmp_path)) is None +def test_resolve_2phase_paths_stops_on_a_missing_table_when_required(tmp_path): + """A required pair with a missing table raises and names the table; otherwise the + missing table is None.""" + from proteus.interior_struct import zalmoxis as zmod + + registry = _paleos_registry(tmp_path, 'liquid') + assert zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry) == ( + str(tmp_path / 'solid.dat'), + None, + ) + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='not available: liquid'): + zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry, required=True) + + def test_generate_spider_tables_stops_on_a_missing_pair_table(tmp_path, monkeypatch): """A PALEOS unified mantle whose 2-phase liquid table is absent stops before any table is built, instead of building the P-S set from the unified table alone.""" From 236b2febdb3bde82dbc8cd49459b1c152da14c10 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 13:02:43 +0200 Subject: [PATCH 47/76] Pass a missing-table stop through the super-liquidus anchor The anchor turns numerical failures into InitialConditionError; the missing-table error now reaches the caller as itself, so the run stops with the message that names the table. --- src/proteus/interior_struct/zalmoxis.py | 2 +- tests/interior_struct/test_zalmoxis.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 6ee48a519..e58bcd2c8 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -776,7 +776,7 @@ def solve_superliquidus_adiabat(config: Config, hf_row: dict | None) -> dict: except InitialConditionError as exc: _SUPERLIQ_FAILED[key] = InitialConditionError(str(exc)) raise - except ANCHOR_PASSTHROUGH_ERRORS: + except (ZalmoxisMissingEOSFilesError, *ANCHOR_PASSTHROUGH_ERRORS): raise except ANCHOR_NUMERICAL_ERRORS as exc: msg = ( diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 878c60ea4..d817a521c 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -1171,6 +1171,22 @@ def test_resolve_2phase_paths_stops_on_a_missing_table_when_required(tmp_path): zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry, required=True) +def test_superliquidus_anchor_passes_a_missing_table_stop_through(monkeypatch): + """A missing-table stop inside the anchor reaches the caller as that error, not as a + numerical anchor failure.""" + from proteus.interior_struct import zalmoxis as zmod + + def _missing(config, hf_row): + raise zmod.ZalmoxisMissingEOSFilesError('pair not available') + + monkeypatch.setattr(zmod, '_solve_superliquidus_adiabat', _missing) + monkeypatch.setattr(zmod, '_SUPERLIQ_FAILED', {}) + config = _require_config('PALEOS:MgSiO3') + config.planet.mass_tot = 1.0 + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='pair not available'): + zmod.solve_superliquidus_adiabat(config, {'P_cmb': 1.2e11}) + + def test_generate_spider_tables_stops_on_a_missing_pair_table(tmp_path, monkeypatch): """A PALEOS unified mantle whose 2-phase liquid table is absent stops before any table is built, instead of building the P-S set from the unified table alone.""" From 734774424cb2d719d7190167c825bcee6b115fec Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 13:21:19 +0200 Subject: [PATCH 48/76] Cover the missing-table stops and match MgSiO3 components one way twophase_registry_key matches an MgSiO3 component by its material token, as energetics_eos_key does. The stand-in warning names the mantle EOS when it has no energetics key. Tests cover the initial-adiabat stop, a missing solid table, and an RTPress mixture. --- src/proteus/interior_struct/zalmoxis.py | 4 ++- src/proteus/utils/helper.py | 6 +++- tests/interior_energetics/test_common.py | 18 ++++++------ .../test_wrapper_adiabat_ic.py | 21 ++++++++++++++ tests/interior_struct/test_zalmoxis.py | 29 ++++++++++--------- 5 files changed, 53 insertions(+), 25 deletions(-) diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index e58bcd2c8..83a0e05b6 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1932,7 +1932,9 @@ def require_paleos_tables(config: Config, outdir: str) -> None: if config.interior_energetics.module in ('spider', 'aragog'): key = energetics_eos_key(zc.mantle_eos) if not generates_paleos_tables(config.interior_struct): - uses.append(f'the energetics and melting curves follow {key} and melting_dir') + uses.append( + f'the energetics and melting curves follow {key or zc.mantle_eos} and melting_dir' + ) elif mixture: uses.append( f'the energetics and melting curves are PALEOS MgSiO3 ({key}, solidus = ' diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index dbb5ad056..3920cdba0 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -139,7 +139,11 @@ def twophase_registry_key(mantle_eos: str) -> str: A PALEOS MgSiO3 component, when present, alone sets the family. """ components = eos_components(mantle_eos) - mgsio3 = [c for c in components if c.startswith(PALEOS_EOS_PREFIXES) and ':MgSiO3' in c] + mgsio3 = [ + c + for c in components + if c.startswith(PALEOS_EOS_PREFIXES) and c.partition(':')[2].startswith('MgSiO3') + ] components = mgsio3 or components if any(c.startswith(('PALEOS-API:', 'PALEOS-API-2phase:')) for c in components): return 'PALEOS-API-2phase:MgSiO3' diff --git a/tests/interior_energetics/test_common.py b/tests/interior_energetics/test_common.py index 9b236d8d2..29281bc0c 100644 --- a/tests/interior_energetics/test_common.py +++ b/tests/interior_energetics/test_common.py @@ -1119,7 +1119,7 @@ def test_compute_initial_entropy_paleos_failure_logs_warning_and_falls_back( @pytest.mark.unit -def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(tmp_path): +def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(): """With a generated PALEOS set a missing MgSiO3 pair stops the initial entropy instead of falling back to the default entropy.""" pytest.importorskip('zalmoxis') @@ -1129,21 +1129,21 @@ def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(tmp_p from proteus.interior_energetics.common import compute_initial_entropy from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError - unified = tmp_path / 'unified.dat' - unified.write_text('stub') config = SimpleNamespace( planet=SimpleNamespace(temperature_mode='adiabatic', tsurf_init=2400.0), interior_struct=SimpleNamespace( module='zalmoxis', zalmoxis=SimpleNamespace(mantle_eos='PALEOS:MgSiO3') ), ) - registry = {'PALEOS:MgSiO3': {'eos_file': str(unified)}, 'PALEOS-2phase:MgSiO3': {}} - with _patch( - 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', - return_value=registry, + registry = {'PALEOS-2phase:MgSiO3': {}} + with ( + _patch( + 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', + return_value=registry, + ), + pytest.raises(ZalmoxisMissingEOSFilesError, match='PALEOS-2phase:MgSiO3'), ): - with pytest.raises(ZalmoxisMissingEOSFilesError, match='PALEOS-2phase:MgSiO3'): - compute_initial_entropy(config, fallback=3175.0) + compute_initial_entropy(config, fallback=3175.0) # ============================================================================ diff --git a/tests/interior_energetics/test_wrapper_adiabat_ic.py b/tests/interior_energetics/test_wrapper_adiabat_ic.py index 738a38397..4e85accb2 100644 --- a/tests/interior_energetics/test_wrapper_adiabat_ic.py +++ b/tests/interior_energetics/test_wrapper_adiabat_ic.py @@ -1546,3 +1546,24 @@ def _anchor(config, hf_row): with pytest.raises(InitialConditionError, match='no valid molten adiabat'): _build_superliquidus_adiabat_tp(config, {'P_cmb': 1.3e12}, P_cmb_target=1.4e12) assert not [r for r in caplog.records if 'no P-T anchor' in r.getMessage()] + + +def test_adiabat_tp_passes_a_missing_table_stop_through(): + """A missing-table stop in the anchor stops the run instead of the linear-guess + fallback that other construction failures take.""" + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + + with ( + patch( + 'proteus.interior_struct.zalmoxis.solve_superliquidus_adiabat', + side_effect=ZalmoxisMissingEOSFilesError('pair not available'), + ), + pytest.raises(ZalmoxisMissingEOSFilesError, match='pair not available'), + ): + _build_superliquidus_adiabat_tp(_config(), {}, P_cmb_target=1.4e12) + # Discrimination: another RuntimeError still falls back to the linear guess. + with patch( + 'proteus.interior_struct.zalmoxis.solve_superliquidus_adiabat', + side_effect=RuntimeError('numerical'), + ): + assert _build_superliquidus_adiabat_tp(_config(), {}, P_cmb_target=1.4e12) is None diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index d817a521c..7ec987ff1 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -1130,7 +1130,12 @@ def test_a_paleos_mixture_gets_the_ps_key_of_its_mgsio3_set(tmp_path, mixture, s @pytest.mark.parametrize( - 'mantle', ['WolfBower2018:MgSiO3', 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1'] + 'mantle', + [ + 'WolfBower2018:MgSiO3', + 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', + 'RTPress100TPa:MgSiO3:0.9+PALEOS:H2O:0.1', + ], ) def test_generate_spider_tables_builds_no_set_for_a_wolf_bower_mantle( tmp_path, monkeypatch, mantle @@ -1142,12 +1147,8 @@ def test_generate_spider_tables_builds_no_set_for_a_wolf_bower_mantle( solid, melt = tmp_path / 'wb_solid.dat', tmp_path / 'wb_melt.dat' solid.write_text('stub') melt.write_text('stub') - registry = { - 'WolfBower2018:MgSiO3': { - 'melted_mantle': {'eos_file': str(melt)}, - 'solid_mantle': {'eos_file': str(solid)}, - } - } + entry = {'melted_mantle': {'eos_file': str(melt)}, 'solid_mantle': {'eos_file': str(solid)}} + registry = {'WolfBower2018:MgSiO3': entry, 'RTPress100TPa:MgSiO3': entry} monkeypatch.setattr(zmod, 'load_zalmoxis_material_dictionaries', lambda: registry) monkeypatch.setattr( zmod, 'check_zalmoxis_eos_files', lambda *a, **k: pytest.fail('checked tables') @@ -1157,17 +1158,18 @@ def test_generate_spider_tables_builds_no_set_for_a_wolf_bower_mantle( assert zmod.generate_spider_tables(config, str(tmp_path)) is None -def test_resolve_2phase_paths_stops_on_a_missing_table_when_required(tmp_path): +@pytest.mark.parametrize('missing, other', [('liquid', 'solid'), ('solid', 'liquid')]) +def test_resolve_2phase_paths_stops_on_a_missing_table_when_required(tmp_path, missing, other): """A required pair with a missing table raises and names the table; otherwise the missing table is None.""" from proteus.interior_struct import zalmoxis as zmod - registry = _paleos_registry(tmp_path, 'liquid') - assert zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry) == ( - str(tmp_path / 'solid.dat'), - None, + registry = _paleos_registry(tmp_path, missing) + paths = dict( + zip(('solid', 'liquid'), zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry)) ) - with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='not available: liquid'): + assert paths == {missing: None, other: str(tmp_path / f'{other}.dat')} + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match=rf'not available: {missing}\.'): zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry, required=True) @@ -1182,7 +1184,6 @@ def _missing(config, hf_row): monkeypatch.setattr(zmod, '_solve_superliquidus_adiabat', _missing) monkeypatch.setattr(zmod, '_SUPERLIQ_FAILED', {}) config = _require_config('PALEOS:MgSiO3') - config.planet.mass_tot = 1.0 with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='pair not available'): zmod.solve_superliquidus_adiabat(config, {'P_cmb': 1.2e11}) From 4632ed1582a69a3639fe0877a33667ebbc1c2ba7 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 15:02:17 +0200 Subject: [PATCH 49/76] Name melting_dir alone when a mixture has no energetics key The start warning for a mixture whose energetics follow melting_dir named the whole mantle string when no component gives an energetics key; it now says the energetics use melting_dir. Tests cover a required 2-phase pair with both tables present and with both absent, a missing-table stop that leaves the anchor failure cache empty, and a single RTPress100TPa:MgSiO3 mantle, which gets no generated table set. --- src/proteus/interior_struct/zalmoxis.py | 5 ++--- tests/interior_energetics/test_common.py | 3 +-- tests/interior_struct/test_zalmoxis.py | 25 +++++++++++++++++++++++- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 83a0e05b6..c57fd7a99 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1932,9 +1932,8 @@ def require_paleos_tables(config: Config, outdir: str) -> None: if config.interior_energetics.module in ('spider', 'aragog'): key = energetics_eos_key(zc.mantle_eos) if not generates_paleos_tables(config.interior_struct): - uses.append( - f'the energetics and melting curves follow {key or zc.mantle_eos} and melting_dir' - ) + source = f'{key} and melting_dir' if key else 'melting_dir' + uses.append(f'the energetics and melting curves follow {source}') elif mixture: uses.append( f'the energetics and melting curves are PALEOS MgSiO3 ({key}, solidus = ' diff --git a/tests/interior_energetics/test_common.py b/tests/interior_energetics/test_common.py index 29281bc0c..10c198caa 100644 --- a/tests/interior_energetics/test_common.py +++ b/tests/interior_energetics/test_common.py @@ -1135,11 +1135,10 @@ def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(): module='zalmoxis', zalmoxis=SimpleNamespace(mantle_eos='PALEOS:MgSiO3') ), ) - registry = {'PALEOS-2phase:MgSiO3': {}} with ( _patch( 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', - return_value=registry, + return_value={'PALEOS-2phase:MgSiO3': {}}, ), pytest.raises(ZalmoxisMissingEOSFilesError, match='PALEOS-2phase:MgSiO3'), ): diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 7ec987ff1..c41c76011 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -1076,6 +1076,7 @@ def _resolve(entry): 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', 'follow WolfBower2018:MgSiO3 and melting_dir', ), + ('PALEOS-2phase:H2O:0.5+Seager2007:iron:0.5', 'follow melting_dir'), ], ) def test_require_paleos_tables_warns_once_for_a_paleos_mixture( @@ -1088,6 +1089,8 @@ def test_require_paleos_tables_warns_once_for_a_paleos_mixture( monkeypatch.setattr( zmod, 'load_zalmoxis_material_dictionaries', lambda: _paleos_registry(tmp_path) ) + # The last case has no energetics key; its tables are not in the registry. + monkeypatch.setattr(zmod, 'check_zalmoxis_eos_files', lambda *a, **k: None) config = _require_config(mantle) config.interior_energetics.module = module with caplog.at_level('WARNING', logger='fwl.proteus.interior_struct.zalmoxis'): @@ -1099,6 +1102,7 @@ def test_require_paleos_tables_warns_once_for_a_paleos_mixture( in (warnings[0]) ) assert f'the energetics and melting curves {energetics}' in warnings[0] + assert 'Seager2007' not in warnings[0].partition(': ')[2] @pytest.mark.parametrize( @@ -1133,6 +1137,7 @@ def test_a_paleos_mixture_gets_the_ps_key_of_its_mgsio3_set(tmp_path, mixture, s 'mantle', [ 'WolfBower2018:MgSiO3', + 'RTPress100TPa:MgSiO3', 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', 'RTPress100TPa:MgSiO3:0.9+PALEOS:H2O:0.1', ], @@ -1173,6 +1178,21 @@ def test_resolve_2phase_paths_stops_on_a_missing_table_when_required(tmp_path, m zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry, required=True) +def test_resolve_2phase_paths_required_returns_a_full_pair_and_names_two_missing(tmp_path): + """A required pair on disk returns both paths; with both tables absent both are named.""" + from proteus.interior_struct import zalmoxis as zmod + + registry = _paleos_registry(tmp_path) + full = zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry, required=True) + assert full == (str(tmp_path / 'solid.dat'), str(tmp_path / 'liquid.dat')) + for name in ('solid', 'liquid'): + (tmp_path / f'{name}.dat').unlink() + with pytest.raises( + zmod.ZalmoxisMissingEOSFilesError, match=r'not available: solid, liquid\.' + ): + zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry, required=True) + + def test_superliquidus_anchor_passes_a_missing_table_stop_through(monkeypatch): """A missing-table stop inside the anchor reaches the caller as that error, not as a numerical anchor failure.""" @@ -1181,11 +1201,14 @@ def test_superliquidus_anchor_passes_a_missing_table_stop_through(monkeypatch): def _missing(config, hf_row): raise zmod.ZalmoxisMissingEOSFilesError('pair not available') + failed = {} monkeypatch.setattr(zmod, '_solve_superliquidus_adiabat', _missing) - monkeypatch.setattr(zmod, '_SUPERLIQ_FAILED', {}) + monkeypatch.setattr(zmod, '_SUPERLIQ_FAILED', failed) config = _require_config('PALEOS:MgSiO3') with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='pair not available'): zmod.solve_superliquidus_adiabat(config, {'P_cmb': 1.2e11}) + # Not cached as an anchor failure, so a run with the tables fetched retries. + assert failed == {} def test_generate_spider_tables_stops_on_a_missing_pair_table(tmp_path, monkeypatch): From b3a822bc211fb9c853cdfd7149c96d402f392e12 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 15:04:00 +0200 Subject: [PATCH 50/76] Document the upgrade path for an older data folder The data reference page gives the two steps that bring a FWL_DATA folder from an older PROTEUS into the current layout: fwl-io relocate for the datasets that match their checksums, then one online start or proteus get interiordata for what it leaves, and quotes the line the offline errors end with. --- docs/Reference/data.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 5bfb3e435..f3c4197e8 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -22,10 +22,8 @@ unreachable Zenodo means the fetch fails rather than falling back elsewhere. Data several models read (spectral files, stellar spectra, equations of state, melting curves, lookup tables) is declared in the shared manifest that fwl-io ships; data only PROTEUS reads is declared in `src/proteus/data/proteus_manifest.toml`. -`fwl-io relocate` moves a folder left by an older PROTEUS into this layout -when the folder holds every file its dataset lists. A folder that holds only -part of a dataset, the Chabrier archive, the surface albedos and the Seager -tables stay where they are and are downloaded again. +A data folder in the older layout is brought into this one as described in +[Upgrading an older data folder](#upgrading-an-older-data-folder). **The PROTEUS downloader.** `proteus.utils.data` fetches a whole Zenodo record, retrying a few times, and falls back to the corresponding project on the @@ -49,6 +47,15 @@ used with lower rate limits. To configure a Zenodo API token, see the [Troubleshooting guide](../How-to/troubleshooting.md#data-download-errors-or-slow-zenodo-downloads). +### Upgrading an older data folder + +A `$FWL_DATA` folder written by an older PROTEUS keeps its files at paths that are no longer read. Two steps bring it into the current layout: + +1. Run `fwl-io relocate`. It moves every dataset whose files all match their pinned checksums into its version directory. `fwl-io relocate --dry-run` lists what would move, what is incomplete and what does not match, without moving anything. +2. Start the run once without `--offline`, or run `proteus get interiordata --config-path ` for the interior tables alone. This downloads what the first step left: incomplete and mismatched datasets, and those it cannot move (the Chabrier archive, the surface albedos, the Seager tables and the Spada tracks). + +A run with `--offline` that finds an interior EOS table, a melting curve, a P-S lookup table or a spectral file missing stops with an error that names its download command and ends with: "Data kept in the older FWL_DATA layout can be moved into place with `fwl-io relocate`." + --- ## Stellar spectra From a520efe051bf527697481d825b7c17b6ef1f71b8 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 16:18:42 +0200 Subject: [PATCH 51/76] Name the fetch command and fwl-io relocate on every missing-data stop A spectral group that no manifest declares is read from the local tree without a download attempt, which used to raise a ValueError that stopped an online run. One helper returns the configured spectral file for JANUS and AGNI and stops with status 20, `proteus get spectral` and the relocate hint when it is missing. The missing-file errors for the Zalmoxis melting curves, the Aragog and SPIDER P-S tables, the solar, MUSCLES and PHOENIX spectra and the AGNI surface albedos name their download command and end with the relocate hint. proteus doctor proposes fwl-io relocate instead of a download when a data set is only in the older layout. --- src/proteus/atmos_clim/agni.py | 15 ++++++++-- src/proteus/atmos_clim/common.py | 34 +++++++++++++++++------ src/proteus/atmos_clim/wrapper.py | 12 ++------ src/proteus/doctor.py | 15 +++++++--- src/proteus/interior_energetics/aragog.py | 2 +- src/proteus/interior_energetics/spider.py | 5 ++-- src/proteus/star/phoenix.py | 7 +++-- src/proteus/star/wrapper.py | 12 ++++++-- src/proteus/utils/data.py | 10 +++++-- tests/atmos_clim/test_agni.py | 34 +++++++++++++++++++---- tests/atmos_clim/test_common.py | 33 ++++++++++++++++++---- tests/interior_energetics/test_aragog.py | 3 +- tests/interior_energetics/test_spider.py | 4 ++- tests/star/test_stellar_spectra.py | 22 ++++++++++----- tests/test_doctor.py | 9 ++++++ tests/utils/test_data.py | 18 +++++++++--- 16 files changed, 176 insertions(+), 59 deletions(-) diff --git a/src/proteus/atmos_clim/agni.py b/src/proteus/atmos_clim/agni.py index 26c1dc629..4eb221e96 100644 --- a/src/proteus/atmos_clim/agni.py +++ b/src/proteus/atmos_clim/agni.py @@ -12,7 +12,11 @@ from juliacall import convert from scipy.interpolate import PchipInterpolator -from proteus.atmos_clim.common import clip_radius_to_hill, get_oarr_from_parr, get_spfile_path +from proteus.atmos_clim.common import ( + clip_radius_to_hill, + get_oarr_from_parr, + require_spfile_path, +) from proteus.utils.constants import gas_list, noble_gases from proteus.utils.helper import ( UpdateStatusfile, @@ -555,7 +559,7 @@ def init_agni_atmos(dirs: dict, config: Config, hf_row: dict): sflux_path = os.path.join( dirs['output'], 'data', '%d.sflux' % int(sorted(sflux_times)[-1]) ) - input_sf = get_spfile_path(dirs['fwl'], config) + input_sf = require_spfile_path(dirs, config) input_star = sflux_path # Fast I/O folder @@ -588,8 +592,13 @@ def init_agni_atmos(dirs: dict, config: Config, hf_row: dict): log.debug(f"Using '{surface_material}' single-scattering surface properties") surface_material = _resolve_surface_material(surface_material, dirs['fwl']) if not os.path.isfile(surface_material): + from proteus.utils.data import RELOCATE_HINT + UpdateStatusfile(dirs, 20) - raise FileNotFoundError(surface_material) + raise FileNotFoundError( + f'Surface albedo file not found: {surface_material}. Fetch it with ' + f'`proteus get surfaces`. {RELOCATE_HINT}' + ) # Boundary pressures. p_surf = hf_row['P_surf'] diff --git a/src/proteus/atmos_clim/common.py b/src/proteus/atmos_clim/common.py index 648d70d6b..8a61ab6e6 100644 --- a/src/proteus/atmos_clim/common.py +++ b/src/proteus/atmos_clim/common.py @@ -9,7 +9,12 @@ import numpy as np -from proteus.utils.helper import find_nearest, parse_subyear_time, snapshot_path_for_time +from proteus.utils.helper import ( + UpdateStatusfile, + find_nearest, + parse_subyear_time, + snapshot_path_for_time, +) if TYPE_CHECKING: from proteus.config import Config @@ -343,11 +348,6 @@ def get_spfile_path(fwl_dir: str, config: Config): configured group and band count; the path may not exist before the dataset is fetched. A group and band count that no manifest declares is read from ``/spectral_files///.sf``. - - Raises - ------ - FileNotFoundError - If the group is undeclared and its local file does not exist. """ from proteus.data import dataset_dir, spectral_file_key @@ -359,12 +359,30 @@ def get_spfile_path(fwl_dir: str, config: Config): except KeyError: path = os.path.join(fwl_dir, 'spectral_files', group, str(bands), group) + '.sf' log.info('Spectral file %s/%s is in no manifest; using %s', group, bands, path) - if not os.path.isfile(path): - raise FileNotFoundError("Spectral file does not exist at '%s'" % path) from None return path return os.path.join(version_dir, group) + '.sf' +def require_spfile_path(dirs: dict, config: Config) -> str: + """Return the configured spectral file, stopping the run when it is missing. + + Raises + ------ + FileNotFoundError + After writing the error status, naming the download command. + """ + from proteus.utils.data import RELOCATE_HINT + + path = get_spfile_path(dirs['fwl'], config) + if not os.path.isfile(path): + UpdateStatusfile(dirs, 20) + raise FileNotFoundError( + f"Spectral file does not exist at '{path}'. Fetch it with " + f'`proteus get spectral`. {RELOCATE_HINT}' + ) + return path + + def clip_radius_to_hill(config: Config, hf_row: dict, radius: float) -> float: """Limit a level radius to the Hill radius, never below the solid body. diff --git a/src/proteus/atmos_clim/wrapper.py b/src/proteus/atmos_clim/wrapper.py index 1e9e740cd..d6a5df00c 100644 --- a/src/proteus/atmos_clim/wrapper.py +++ b/src/proteus/atmos_clim/wrapper.py @@ -16,7 +16,7 @@ Atmos_t, LevelsSource, clip_radius_to_hill, - get_spfile_path, + require_spfile_path, ) from proteus.utils.constants import const_R, gas_list from proteus.utils.helper import UpdateStatusfile, safe_rm @@ -313,15 +313,7 @@ def run_atmosphere( # Init atm object if first iteration or change in stellar spectrum if no_atm or update_stellar_spectrum: - spectral_file_nostar = get_spfile_path(dirs['fwl'], config) - if not os.path.exists(spectral_file_nostar): - from proteus.utils.data import RELOCATE_HINT - - UpdateStatusfile(dirs, 20) - raise FileNotFoundError( - f"Spectral file does not exist at '{spectral_file_nostar}'. Fetch it with " - f'`proteus get spectral`. {RELOCATE_HINT}' - ) + spectral_file_nostar = require_spfile_path(dirs, config) wl = np.array(wl) fl = np.array(fl) diff --git a/src/proteus/doctor.py b/src/proteus/doctor.py index 590301565..7ac89fd69 100644 --- a/src/proteus/doctor.py +++ b/src/proteus/doctor.py @@ -342,18 +342,25 @@ def check_env_var( def check_fwl_data() -> list[CheckResult]: - """Check FWL_DATA contents for required data sets.""" + """Check FWL_DATA contents for required data sets. + + A data set found only in the older layout is fixed with ``fwl-io relocate``, + which moves it in place, rather than with a new download. + """ results = [] fwl = os.environ.get('FWL_DATA') if not fwl or not os.path.isdir(fwl): return results expected = { - 'atmos_clim/spectral_files': 'proteus get spectral', - 'star/spectra': 'proteus get stellar', + 'atmos_clim/spectral_files': ('proteus get spectral', 'spectral_files'), + 'star/spectra': ('proteus get stellar', 'stellar_spectra'), } - for subdir, fix in expected.items(): + for subdir, (fix, legacy) in expected.items(): path = os.path.join(fwl, subdir) + old = os.path.join(fwl, legacy) + if os.path.isdir(old) and os.listdir(old): + fix = 'fwl-io relocate' if os.path.isdir(path) and os.listdir(path): results.append( CheckResult( diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 772131475..b8e8611d3 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -1118,7 +1118,7 @@ def _append_radnuc(_iso, _cnc): f'PALEOS P-S tables not found. Aragog entropy solver ' f'requires P-S tables. Checked: {spider_eos_dir}, {fallback_dir}. ' "Fetch them with 'proteus get interiordata --config-path " - "'." + f"'. {RELOCATE_HINT}" ) # Only a structure P_cmb (Zalmoxis) is checked; the tolerance absorbs rounding. P_cmb = hf_row.get('P_cmb') diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index ccf187c37..1891dab2a 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -22,7 +22,7 @@ from proteus.interior_energetics.timestep import next_step from proteus.interior_struct.common import solvus_radius from proteus.utils.constants import radnuc_data -from proteus.utils.data import find_lookup_table_dir +from proteus.utils.data import RELOCATE_HINT, find_lookup_table_dir from proteus.utils.helper import UpdateStatusfile, natural_sort, recursive_get if TYPE_CHECKING: @@ -653,7 +653,8 @@ def _resolve_spider_eos_dir(dirs: dict, config: Config) -> str: if not os.path.isdir(eos_dir): raise FileNotFoundError( f'SPIDER EOS directory not found: {eos_dir}. ' - f"Check interior.eos_dir='{config.interior_struct.eos_dir}'." + f"Check interior.eos_dir='{config.interior_struct.eos_dir}', or fetch the " + f'tables with `proteus get interiordata --config-path `. {RELOCATE_HINT}' ) return eos_dir diff --git a/src/proteus/star/phoenix.py b/src/proteus/star/phoenix.py index f85da10d3..8a5401b78 100644 --- a/src/proteus/star/phoenix.py +++ b/src/proteus/star/phoenix.py @@ -9,7 +9,7 @@ from proteus.data import STELLAR_SPECTRA_PHOENIX, dataset_dir from proteus.utils.constants import AU, M_sun, R_sun, const_G -from proteus.utils.data import GetFWLData, download_phoenix +from proteus.utils.data import RELOCATE_HINT, GetFWLData, download_phoenix from proteus.utils.helper import UpdateStatusfile from proteus.utils.phoenix_helper import phoenix_filename, phoenix_param, phoenix_to_grid @@ -201,7 +201,10 @@ def get_phoenix_modern_spectrum( raise RuntimeError(f'PHOENIX file still missing after download: {raw_path}') else: log.error('Running in offline mode, but appropriate phoenix file is not available.') - raise FileNotFoundError(f'PHOENIX file not found: {raw_path} (offline mode)') + raise FileNotFoundError( + f'PHOENIX file not found: {raw_path} (offline mode). Fetch it with ' + f'`proteus get phoenix`. {RELOCATE_HINT}' + ) # scale from stellar surface to 1 AU and save to PHOENIX/1AU if (not au_path.exists()) or (au_path.stat().st_mtime < raw_path.stat().st_mtime): diff --git a/src/proteus/star/wrapper.py b/src/proteus/star/wrapper.py index 9f239561f..c7a4bf596 100644 --- a/src/proteus/star/wrapper.py +++ b/src/proteus/star/wrapper.py @@ -10,6 +10,7 @@ from proteus.star.phoenix import get_phoenix_modern_spectrum from proteus.utils.constants import AU, M_sun, R_sun, const_sigma, ergcm2stoWm2 +from proteus.utils.data import RELOCATE_HINT from proteus.utils.helper import UpdateStatusfile log = logging.getLogger('fwl.' + __name__) @@ -116,7 +117,8 @@ def init_star(handler: Proteus): ) UpdateStatusfile(handler.directories, 23) raise FileNotFoundError( - f"No solar or MUSCLES spectrum found in reference data for '{mors_cfg.star_name}'." + f"No solar or MUSCLES spectrum found in reference data for '{mors_cfg.star_name}'. " + f'Fetch them with `proteus get solar` and `proteus get muscles`. {RELOCATE_HINT}' ) # spectrum_source = 'solar' @@ -141,7 +143,8 @@ def init_star(handler: Proteus): ) UpdateStatusfile(handler.directories, 23) raise FileNotFoundError( - f"No solar or MUSCLES spectrum for '{mors_cfg.star_name}'." + f"No solar or MUSCLES spectrum for '{mors_cfg.star_name}'. Fetch them with " + f'`proteus get solar` and `proteus get muscles`. {RELOCATE_HINT}' ) # spectrum_source = 'muscles' @@ -174,7 +177,10 @@ def init_star(handler: Proteus): "If no observed spectrum is available, consider using a PHOENIX synthetic spectrum by setting star.mors.spectrum_source = 'phoenix'." ) UpdateStatusfile(handler.directories, 23) - raise FileNotFoundError(f"No MUSCLES spectrum for '{mors_cfg.star_name}'.") + raise FileNotFoundError( + f"No MUSCLES spectrum for '{mors_cfg.star_name}'. Fetch it with " + f'`proteus get muscles --star {mors_cfg.star_name}`. {RELOCATE_HINT}' + ) # spectrum_source = 'phoenix' elif src == 'phoenix': diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 03122b38b..9b7f718c1 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1730,7 +1730,12 @@ def _get_sufficient(config: Config, clean: bool = False): from proteus.atmos_clim.common import get_spfile_name_and_bands group, bands = get_spfile_name_and_bands(config) - _attempt(f'spectral file {group}/{bands}', download_spectral_file, group, bands) + if f'{group}/{bands}' in SPECTRAL_FILE_FOLDERS: + _attempt(f'spectral file {group}/{bands}', download_spectral_file, group, bands) + else: + log.info( + 'Spectral file %s/%s is in no manifest; it is read locally', group, bands + ) # Surface single-scattering data if config.atmos_clim.module == 'agni': @@ -2200,7 +2205,8 @@ def get_zalmoxis_melting_curves(config: Config): if not melting_file.is_file(): raise FileNotFoundError( f'Melting curve file not found: {melting_file}. ' - f"Check struct.melting_dir='{config.interior_struct.melting_dir}'." + f"Check struct.melting_dir='{config.interior_struct.melting_dir}', or fetch " + f'it with `proteus get interiordata --config-path `. {RELOCATE_HINT}' ) solidus_func = load_melting_curve(solidus_file) liquidus_func = load_melting_curve(liquidus_file) diff --git a/tests/atmos_clim/test_agni.py b/tests/atmos_clim/test_agni.py index 7e18b5e3d..26a915c1e 100644 --- a/tests/atmos_clim/test_agni.py +++ b/tests/atmos_clim/test_agni.py @@ -364,7 +364,7 @@ def _build_greygas_config(): @pytest.mark.unit def test_init_agni_atmos_greygas_bypasses_spectral_copy(monkeypatch, tmp_path): - """Greygas path should not call get_spfile_path or pass a stellar spectrum. + """Greygas path should not call require_spfile_path or pass a stellar spectrum. When spectral_file='greygas' is set, AGNI uses the grey-gas RT scheme and does not need a SOCRATES spectral file or stellar flux to be copied into @@ -398,9 +398,9 @@ def test_init_agni_atmos_greygas_bypasses_spectral_copy(monkeypatch, tmp_path): monkeypatch.setattr(agni_mod, 'sync_log_files', lambda *_a, **_k: None) monkeypatch.setattr( agni_mod, - 'get_spfile_path', + 'require_spfile_path', lambda *_a, **_k: (_ for _ in ()).throw( - AssertionError('get_spfile_path should not be called for greygas') + AssertionError('require_spfile_path should not be called for greygas') ), ) @@ -572,6 +572,28 @@ def test_init_agni_atmos_passes_unscaled_surface_pressure(monkeypatch, tmp_path) assert passed_p_surf != pytest.approx(0.9 * p_surf_true) +@pytest.mark.unit +def test_init_agni_atmos_missing_surface_albedo_file_stops_with_its_fetch_command( + monkeypatch, tmp_path +): + """A configured albedo file that is absent writes status 20 and names + `proteus get surfaces` and `fwl-io relocate`.""" + fake_jl = SimpleNamespace(AGNI=_FakeAGNI(), Dict=dict, Char=str) + (tmp_path / 'out' / 'data').mkdir(parents=True) + dirs = {'output': str(tmp_path / 'out'), 'agni': '/fake/agni', 'fwl': str(tmp_path)} + config = _build_greygas_config() + config.atmos_clim.agni.surf_material = 'lunarmarebasalt.dat' + hf_row = {'P_surf': 1.0, 'T_surf': 900.0, 'gravity': 9.8, 'R_int': 6.4e6} + monkeypatch.setattr(agni_mod, 'jl', fake_jl) + monkeypatch.setattr(agni_mod, 'convert', lambda _typ, value: value) + monkeypatch.setattr(agni_mod, '_construct_voldict', lambda *_a, **_k: {'H2O': 1.0}) + status = MagicMock() + monkeypatch.setattr(agni_mod, 'UpdateStatusfile', status) + with pytest.raises(FileNotFoundError, match='`proteus get surfaces`.*`fwl-io relocate`'): + init_agni_atmos(dirs, config, hf_row) + status.assert_called_once_with(dirs, 20) + + @pytest.mark.unit def test_init_agni_atmos_greygas_does_not_glob_sflux(monkeypatch, tmp_path): """Regression: in grey-gas mode, init_agni_atmos must not require any @@ -608,9 +630,9 @@ def test_init_agni_atmos_greygas_does_not_glob_sflux(monkeypatch, tmp_path): monkeypatch.setattr(agni_mod, 'sync_log_files', lambda *_a, **_k: None) monkeypatch.setattr( agni_mod, - 'get_spfile_path', + 'require_spfile_path', lambda *_a, **_k: (_ for _ in ()).throw( - AssertionError('get_spfile_path should not be called for greygas') + AssertionError('require_spfile_path should not be called for greygas') ), ) @@ -658,7 +680,7 @@ def test_init_agni_atmos_non_greygas_no_sflux_raises_filenotfound(monkeypatch, t monkeypatch.setattr(agni_mod, '_construct_voldict', lambda *_a, **_k: {'H2O': 1.0}) monkeypatch.setattr(agni_mod, 'sync_log_files', lambda *_a, **_k: None) monkeypatch.setattr(agni_mod, 'UpdateStatusfile', lambda *_a, **_k: None) - monkeypatch.setattr(agni_mod, 'get_spfile_path', lambda *_a, **_k: '/fake/spfile') + monkeypatch.setattr(agni_mod, 'require_spfile_path', lambda *_a, **_k: '/fake/spfile') with pytest.raises(FileNotFoundError, match='No stellar spectrum'): init_agni_atmos(dirs, config, hf_row) diff --git a/tests/atmos_clim/test_common.py b/tests/atmos_clim/test_common.py index 072b27a58..5c7a5a858 100644 --- a/tests/atmos_clim/test_common.py +++ b/tests/atmos_clim/test_common.py @@ -14,6 +14,7 @@ from __future__ import annotations +from pathlib import Path from unittest.mock import MagicMock, patch import numpy as np @@ -637,20 +638,42 @@ def test_spfile_helpers(tmp_path): @pytest.mark.unit def test_get_spfile_path_reads_an_undeclared_group_from_the_local_tree(tmp_path, caplog): """A group that no manifest declares is read from spectral_files//, - with an INFO line; an absent local file raises the spectral-file error.""" + with an INFO line, also before the file exists.""" conf = MagicMock() conf.atmos_clim.spectral_group = 'MyGroup' conf.atmos_clim.spectral_bands = '48' local = tmp_path / 'spectral_files' / 'MyGroup' / '48' / 'MyGroup.sf' - with pytest.raises(FileNotFoundError, match='Spectral file does not exist at'): - get_spfile_path(str(tmp_path), conf) - local.parent.mkdir(parents=True) - local.write_text('sf') with caplog.at_level('INFO', logger='fwl.proteus.atmos_clim.common'): assert get_spfile_path(str(tmp_path), conf) == str(local) assert 'is in no manifest' in caplog.text +@pytest.mark.unit +@pytest.mark.parametrize('group, bands', [('MyGroup', '48'), ('Dayspring', '48')]) +def test_require_spfile_path_stops_with_status_20_and_the_fetch_command(tmp_path, group, bands): + """A missing spectral file, declared or not, writes status 20 and names + `proteus get spectral` and `fwl-io relocate`; a present one is returned.""" + from proteus.atmos_clim.common import require_spfile_path + + conf = MagicMock() + conf.atmos_clim.spectral_group = group + conf.atmos_clim.spectral_bands = bands + dirs = {'fwl': str(tmp_path), 'output': str(tmp_path / 'out')} + with ( + patch('proteus.atmos_clim.common.UpdateStatusfile') as status, + pytest.raises(FileNotFoundError, match='`proteus get spectral`') as exc, + ): + require_spfile_path(dirs, conf) + status.assert_called_once_with(dirs, 20) + assert '`fwl-io relocate`' in str(exc.value) + path = Path(get_spfile_path(str(tmp_path), conf)) + path.parent.mkdir(parents=True) + path.write_text('sf') + with patch('proteus.atmos_clim.common.UpdateStatusfile') as status: + assert require_spfile_path(dirs, conf) == str(path) + status.assert_not_called() + + # --------------------------------------------------------------------------- # Coverage for previously-untested error branches: missing NetCDF file, # archived-data warning. diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 7baaf7c52..22d7ae85b 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1854,7 +1854,8 @@ def test_setup_solver_missing_ps_tables_names_the_fetch_command(tmp_path): patch('proteus.interior_energetics.aragog.Parameters'), patch('proteus.interior_energetics.aragog.EntropySolver') as mock_solver, pytest.raises( - FileNotFoundError, match='P-S tables not found.*proteus get interiordata' + FileNotFoundError, + match='P-S tables not found.*proteus get interiordata.*fwl-io relocate', ), ): AragogRunner.setup_solver(config, hf_row, interior_o, str(tmp_path / 'out')) diff --git a/tests/interior_energetics/test_spider.py b/tests/interior_energetics/test_spider.py index 8030b623b..20821ea3a 100644 --- a/tests/interior_energetics/test_spider.py +++ b/tests/interior_energetics/test_spider.py @@ -1354,7 +1354,9 @@ def test_try_spider_missing_eos_dir(tmp_path): return_value=3000.0, ), ): - with pytest.raises(FileNotFoundError, match='SPIDER EOS directory not found'): + with pytest.raises( + FileNotFoundError, match='SPIDER EOS directory not found.*`fwl-io relocate`' + ): _try_spider( dirs, config, diff --git a/tests/star/test_stellar_spectra.py b/tests/star/test_stellar_spectra.py index 0b58315a0..c6ae1f174 100644 --- a/tests/star/test_stellar_spectra.py +++ b/tests/star/test_stellar_spectra.py @@ -267,7 +267,7 @@ def test_get_phoenix_modern_spectrum_offline_missing_raw_raises(tmp_path, monkey monkeypatch.setattr(phoenix_mod, 'GetFWLData', lambda: tmp_path) handler = _make_handler(tmp_path=tmp_path, offline=True, Teff=5800.0, logg=4.5, radius=1.0) - with pytest.raises(FileNotFoundError): + with pytest.raises(FileNotFoundError, match='`proteus get phoenix`.*`fwl-io relocate`'): get_phoenix_modern_spectrum(handler, stellar_track=None) # Discrimination: the offline branch must NOT silently emit a 1 AU file @@ -437,17 +437,25 @@ def test_init_star_source_muscles_falls_back_to_solar_with_warning( @pytest.mark.unit -def test_init_star_source_none_missing_both_raises(tmp_path, monkeypatch): - """With ``spectrum_source=None`` and neither solar nor MUSCLES on - disk, ``init_star`` raises FileNotFoundError rather than producing - a silent zero-flux spectrum. +@pytest.mark.parametrize( + 'source, command', + [ + (None, 'proteus get solar'), + ('solar', 'proteus get solar'), + ('muscles', 'proteus get muscles'), + ], +) +def test_init_star_source_none_missing_both_raises(tmp_path, monkeypatch, source, command): + """With neither solar nor MUSCLES on disk, ``init_star`` raises FileNotFoundError + naming the download command and ``fwl-io relocate``, rather than producing a silent + zero-flux spectrum. """ from proteus.star.wrapper import init_star _install_fake_mors(monkeypatch) - handler = _make_handler_for_init_star(tmp_path, spectrum_source=None) + handler = _make_handler_for_init_star(tmp_path, spectrum_source=source) - with pytest.raises(FileNotFoundError): + with pytest.raises(FileNotFoundError, match=f'`{command}.*`fwl-io relocate`'): init_star(handler) # Discrimination: the raise must fire BEFORE any backup spectrum is diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 85223f7e7..600e7291b 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -258,6 +258,15 @@ def test_reports_missing_subdirs(self, tmp_path): assert r.status == WARN assert r.fix_cmd is not None + def test_an_older_layout_is_fixed_by_relocate(self, tmp_path): + """A data set found only in the older layout is fixed by moving it, not a download.""" + (tmp_path / 'spectral_files' / 'Dayspring' / '48').mkdir(parents=True) + (tmp_path / 'spectral_files' / 'Dayspring' / '48' / 'Dayspring.sf').touch() + with patch.dict(os.environ, {'FWL_DATA': str(tmp_path)}): + fixes = {r.name: r.fix_cmd for r in check_fwl_data()} + assert fixes['FWL_DATA/atmos_clim/spectral_files'] == 'fwl-io relocate' + assert fixes['FWL_DATA/star/spectra'] == 'proteus get stellar' + def test_skips_when_fwl_data_unset(self): """No checks when FWL_DATA is not set.""" env = os.environ.copy() diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index a2f0b0e92..54d7701d8 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3902,7 +3902,7 @@ def test_get_sufficient_agni_downloads_group_and_bands_when_no_spectral_file(mon monkeypatch.setattr( atmos_common, 'get_spfile_name_and_bands', - lambda *_args: ('Frostflow', '128'), + lambda *_args: ('Frostflow', '256'), ) config = SimpleNamespace( @@ -3919,12 +3919,12 @@ def test_get_sufficient_agni_downloads_group_and_bands_when_no_spectral_file(mon data_mod._get_sufficient(config, clean=False) # Honeyside post-processing file + the resolved group/bands file. - assert spectral_calls == [('Honeyside', '4096'), ('Frostflow', '128')] + assert spectral_calls == [('Honeyside', '4096'), ('Frostflow', '256')] # Discrimination: confirm the resolved group/bands entry came second # (the Honeyside post-processing download always runs first); a # regression that swapped the call order or replayed Honeyside twice # would still produce a 2-element list but break this pin. - assert spectral_calls[-1] == ('Frostflow', '128') + assert spectral_calls[-1] == ('Frostflow', '256') @pytest.mark.unit @@ -3975,6 +3975,13 @@ def _record_lookup(*_args): assert ('Frostflow', '256') in spectral_calls assert lookup_calls == [1] + # A group no manifest declares is read from the local tree, not downloaded, + # since download_spectral_file refuses it with a ValueError. + monkeypatch.setattr(atmos_common, 'get_spfile_name_and_bands', lambda *_: ('MyGroup', '48')) + spectral_calls.clear() + data_mod._get_sufficient(config, clean=False) + assert spectral_calls == [('Honeyside', '4096')] + # ============================================================================ # download_zenodo_folder additional error-branch coverage @@ -5813,8 +5820,11 @@ def test_get_zalmoxis_melting_curves_missing_dir_raises(monkeypatch, tmp_path): config = MagicMock() config.interior_struct.melting_dir = 'NonexistentCurve' - with pytest.raises(FileNotFoundError, match='Melting curve file not found'): + with pytest.raises( + FileNotFoundError, match='Melting curve file not found.*proteus get interiordata' + ) as exc: get_zalmoxis_melting_curves(config) + assert '`fwl-io relocate`' in str(exc.value) missing = tmp_path / 'interior_lookup_tables' / 'Melting_curves' / 'NonexistentCurve' assert not missing.exists() From 3eb80eed361208b65e3b5047e6c256da44ebc221 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 16:29:41 +0200 Subject: [PATCH 52/76] Keep one melting curve and one table set per Zalmoxis mantle SPIDER and Aragog under Zalmoxis now need a PALEOS, WolfBower2018 or RTPress100TPa energetics key at config load. A Seager2007, Chabrier or analytic key has no energetics tables, so such a run read the Wolf and Bower set and the melting_dir curves for the energetics while the structure and the liquidus_super anchor used other curves. EOS components with an inner space or a nan or inf fraction are rejected at load, since they name no registry key and the table check skipped them. The start check requires the MgSiO3 2-phase pair on an Aragog resume, which re-solves its initial condition on the pair when the mesh changes, and for the liquidus_super anchor of any mantle; the anchor itself always requires the pair. Every PALEOS-API table in the EOS set is built by the start check, not only the pair. The stand-in warning covers every mantle with a non-MgSiO3 component and names the eos_dir P-S tables and melting_dir curves that a Wolf and Bower energetics key reads. --- docs/Reference/config/config_schema.json | 11 +++ docs/Reference/config/interior.md | 1 + src/proteus/config/_config.py | 24 +++++++ src/proteus/config/_struct.py | 21 ++++++ src/proteus/interior_energetics/aragog.py | 2 +- src/proteus/interior_struct/zalmoxis.py | 49 +++++++------ src/proteus/utils/constants.py | 2 + src/proteus/utils/helper.py | 13 ++-- tests/config/test_config.py | 31 +++++++++ tests/config/test_struct.py | 20 ++++++ tests/interior_struct/test_zalmoxis.py | 85 ++++++++++++++++------- 11 files changed, 204 insertions(+), 55 deletions(-) diff --git a/docs/Reference/config/config_schema.json b/docs/Reference/config/config_schema.json index df6ab7c87..bb850a490 100644 --- a/docs/Reference/config/config_schema.json +++ b/docs/Reference/config/config_schema.json @@ -10162,6 +10162,17 @@ ], "doc": "Check that required external packages are importable for the selected modules." }, + { + "validator": "energetics_needs_a_thermal_mantle_eos", + "module": "_config.py", + "attached_to": [ + "interior_energetics" + ], + "touches": [ + "interior_struct" + ], + "doc": "Require a PALEOS or temperature-dependent energetics key for SPIDER or Aragog." + }, { "validator": "instmethod_dummy", "module": "_config.py", diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index 293f00295..91b7925dd 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -393,6 +393,7 @@ Cross-field constraints enforced when the config file loads: - Require ``interior_struct.melting_dir`` for Aragog without a PALEOS table set. - Boundary backend assumes a fixed surface state coupling. +- Require a PALEOS or temperature-dependent energetics key for SPIDER or Aragog. - Interior tidal heating requires an tides module to be enabled. - Aragog requires at least one energy transport term to be enabled. - Validate Boundary backend's solidus/liquidus ordering. diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index 247faeb4b..098e93e8c 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -240,6 +240,29 @@ def planet_liquidus_super_needs_tables(instance, attribute, value): ) +def energetics_needs_a_thermal_mantle_eos(instance, attribute, value): + """Require a PALEOS or temperature-dependent energetics key for SPIDER or Aragog. + + Under the Zalmoxis structure the energetics read the tables and melting + curves of the mantle's energetics key; a Seager2007, Chabrier or analytic + key has none, so the run would read another set as a stand-in. + """ + from proteus.utils.constants import PALEOS_EOS_PREFIXES, TDEP_EOS_PREFIXES + from proteus.utils.helper import energetics_eos_key + + struct = instance.interior_struct + if value.module not in ('spider', 'aragog') or struct.module != 'zalmoxis': + return + key = energetics_eos_key(struct.zalmoxis.mantle_eos) + if not (key or '').startswith(PALEOS_EOS_PREFIXES + TDEP_EOS_PREFIXES): + raise ValueError( + f"interior_energetics.module = '{value.module}' with mantle_eos=" + f"'{struct.zalmoxis.mantle_eos}' has no energetics tables (energetics key: {key}). " + 'Use a PALEOS, WolfBower2018 or RTPress100TPa mantle EOS, or an MgSiO3 component ' + 'of one of them in a mixture.' + ) + + def aragog_needs_melting_curves(instance, attribute, value): """Require ``interior_struct.melting_dir`` for Aragog without a PALEOS table set. @@ -438,6 +461,7 @@ class Config: validator=( tides_enabled_orbit, boundary_requires_fixed_surface_state, + energetics_needs_a_thermal_mantle_eos, aragog_needs_melting_curves, ), ) diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index 15383101d..9bcb4ce27 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -43,6 +43,27 @@ def valid_zalmoxis(instance, attribute, value): f"`interior_struct.zalmoxis.ice_layer_eos` must be 'none' or ':' format, " f"got '{ice_layer_eos}'" ) + # A component is ':' plus finite fraction tokens, with no inner space; + # a token that still parses as a number after stripping is nan or inf. + from proteus.utils.helper import _strip_fraction_tokens + + for name, eos_val in [ + ('core_eos', core_eos), + ('mantle_eos', mantle_eos), + ('ice_layer_eos', ice_layer_eos or ''), + ]: + for comp in filter(None, (c.strip() for c in eos_val.split('+'))): + key = _strip_fraction_tokens(comp) + try: + float(key.rsplit(':', 1)[-1]) + bad = True + except ValueError: + bad = any(ch.isspace() for ch in key) + if bad: + raise ValueError( + f"`interior_struct.zalmoxis.{name}` component '{comp}' is not " + "':' with finite fractions and no spaces" + ) # WolfBower2018 EOS is limited to 1 TPa. For planets > 2 M_earth, # CMB pressure exceeds this and Zalmoxis will fail to converge. diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index b8e8611d3..77e1f6b2b 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -914,7 +914,7 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s # Phase-property tables (Cp, alpha, density, entropy) from PALEOS only with a # generated PALEOS set, preferring PALEOS-2phase: its separate solid/liquid tables - # avoid the unified table's artifacts at the melting curve. A mixture reads WB. + # avoid the unified table's artifacts at the melting curve. elif generates_paleos_tables(config.interior_struct): from proteus.interior_struct.zalmoxis import load_zalmoxis_material_dictionaries diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index c57fd7a99..713fb2185 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -44,6 +44,7 @@ energetics_eos_key, eos_components, generates_paleos_tables, + is_mgsio3, paleos_companion_keys, twophase_registry_key, ) @@ -883,9 +884,7 @@ def _solve_superliquidus_adiabat(config: Config, hf_row: dict | None) -> dict: return cached mat_dicts = load_zalmoxis_material_dictionaries() - solid_eos, liquid_eos = resolve_2phase_mgsio3_paths( - mantle_eos, mat_dicts, required=generates_paleos_tables(config.interior_struct) - ) + solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(mantle_eos, mat_dicts, required=True) eos_file = mat_dicts.get(energetics_eos_key(mantle_eos) or '', {}).get('eos_file', '') eos_file = eos_file or solid_eos or '' melt_funcs = load_zalmoxis_solidus_liquidus_functions(mantle_eos, config) @@ -1808,9 +1807,8 @@ def check_zalmoxis_eos_files( collects every referenced table path that does not exist, then raises one actionable error. Without this check the solver emits one read error per shell and ends in a non-convergence failure that - hides the real cause. Registry entries without an ``eos_file`` - (PALEOS-API live tabulation) generate their tables on demand and - are skipped, except the companions, which are materialised. + hides the real cause. PALEOS-API entries (live tabulation) are + materialised here, so a failed build stops the run before any solve. Parameters ---------- @@ -1827,15 +1825,14 @@ def check_zalmoxis_eos_files( If any selected EOS table file is missing, naming every missing path and the command that downloads them. """ + from zalmoxis.eos.dispatch import _is_paleos_api + selected = [ (role, key) for role, identifier in layer_eos_config.items() for key in eos_components(identifier) ] - companions = [] if paleos_companions: - from zalmoxis.eos.dispatch import _is_paleos_api - companions = paleos_companion_keys(layer_eos_config.get('mantle', '')) selected += [('mantle', key) for key in companions] missing: set[str] = set() @@ -1844,7 +1841,7 @@ def check_zalmoxis_eos_files( if entry is None: # Unknown identifiers fail later with a registry error. continue - if key in companions and _is_paleos_api(entry): + if _is_paleos_api(entry): try: from zalmoxis.eos.paleos_api_cache import resolve_registry_entry @@ -1890,12 +1887,12 @@ def require_paleos_tables(config: Config, outdir: str) -> None: """Stop the run before any solve when a table of its EOS set is missing. Requires every table file of the core, mantle and ice-layer EOS and, - for a mantle with a PALEOS component, the MgSiO3 2-phase pair. A resumed - run that keeps its P-S tables is held only to its layer tables. With + for a mantle with a PALEOS component, the MgSiO3 2-phase pair; a resumed + SPIDER run that keeps its P-S tables is held only to its layer tables. The + liquidus_super initial adiabat requires the pair for any mantle. With ``dry_mantle = false`` the dissolved-volatile tables are also required. A - PALEOS H2O or iron mantle, and a mixture with a PALEOS component, gets one - WARNING that names the tables its energetics and its liquidus_super initial - adiabat use. + mantle with a non-MgSiO3 component gets one WARNING that names the tables + its energetics and its liquidus_super initial adiabat use. Parameters ---------- @@ -1921,19 +1918,27 @@ def require_paleos_tables(config: Config, outdir: str) -> None: outdir, lambda: _ps_resume_key(config, *_energetics_entry(zc.mantle_eos, mat_dicts), mat_dicts), ) - check_zalmoxis_eos_files(layers, mat_dicts, paleos_companions=not kept) + liquidus_super = config.planet.temperature_mode == 'liquidus_super' + if liquidus_super: + layers['anchor'] = twophase_registry_key(zc.mantle_eos) + # Aragog re-solves the initial condition on the pair when the mesh changes. + check_zalmoxis_eos_files( + layers, + mat_dicts, + paleos_companions=not kept or config.interior_energetics.module == 'aragog', + ) components = eos_components(zc.mantle_eos) mixture = len(set(components)) > 1 - if not any(c.startswith(PALEOS_EOS_PREFIXES) for c in components) or ( - not mixture and components[0].partition(':')[2].startswith('MgSiO3') - ): + if all(is_mgsio3(c) for c in components): return uses = [] if config.interior_energetics.module in ('spider', 'aragog'): key = energetics_eos_key(zc.mantle_eos) if not generates_paleos_tables(config.interior_struct): - source = f'{key} and melting_dir' if key else 'melting_dir' - uses.append(f'the energetics and melting curves follow {source}') + uses.append( + 'the energetics use the eos_dir P-S tables (by default Wolf and Bower 2018) ' + 'and the melting_dir curves' + ) elif mixture: uses.append( f'the energetics and melting curves are PALEOS MgSiO3 ({key}, solidus = ' @@ -1944,7 +1949,7 @@ def require_paleos_tables(config: Config, outdir: str) -> None: 'the energetics use the MgSiO3 melting curves (PALEOS liquidus, ' f'solidus = {zc.mushy_zone_factor:.2f} x liquidus) and MgSiO3 P-S tables' ) - if config.planet.temperature_mode == 'liquidus_super': + if liquidus_super: uses.append('the liquidus_super initial adiabat is solved on the MgSiO3 2-phase tables') if uses: head = ( diff --git a/src/proteus/utils/constants.py b/src/proteus/utils/constants.py index 346847fa7..8420e7e25 100644 --- a/src/proteus/utils/constants.py +++ b/src/proteus/utils/constants.py @@ -20,6 +20,8 @@ # the solidus from mushy_zone_factor; the config validator matches the core # and ice-layer EOS too. PALEOS_EOS_PREFIXES = ('PALEOS:', 'PALEOS-2phase:', 'PALEOS-API:', 'PALEOS-API-2phase:') +# Temperature-dependent silicate EOS whose energetics read melting_dir and the WB P-S set. +TDEP_EOS_PREFIXES = ('WolfBower2018', 'RTPress100TPa') # Zalmoxis EOS component of each dissolved volatile species that has a table. VOLATILE_EOS_MAP = {'H2O': 'PALEOS:H2O', 'H2': 'Chabrier:H'} # PALEOS keys of the Zalmoxis material registry (load_zalmoxis_material_dictionaries). diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index 3920cdba0..bc7ee7f16 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -59,6 +59,11 @@ def eos_components(eos: str) -> list[str]: return [k for c in str(eos or '').split('+') if (k := _strip_fraction_tokens(c.strip()))] +def is_mgsio3(component: str) -> bool: + """Whether an EOS registry key names an MgSiO3 material, e.g. ``'PALEOS:MgSiO3'``.""" + return component.partition(':')[2].startswith('MgSiO3') + + def energetics_eos_key(mantle_eos: str) -> str | None: """Registry key of the EOS whose melting curves and P-S tables a mantle uses. @@ -84,7 +89,7 @@ def energetics_eos_key(mantle_eos: str) -> str | None: If a mixture has MgSiO3 components with different registry keys. """ components = sorted(set(eos_components(mantle_eos))) - mgsio3 = [c for c in components if c.partition(':')[2].startswith('MgSiO3')] + mgsio3 = [c for c in components if is_mgsio3(c)] if len(mgsio3) > 1: raise ValueError( f'mantle_eos={mantle_eos!r} has MgSiO3 components from different sources ' @@ -139,11 +144,7 @@ def twophase_registry_key(mantle_eos: str) -> str: A PALEOS MgSiO3 component, when present, alone sets the family. """ components = eos_components(mantle_eos) - mgsio3 = [ - c - for c in components - if c.startswith(PALEOS_EOS_PREFIXES) and c.partition(':')[2].startswith('MgSiO3') - ] + mgsio3 = [c for c in components if c.startswith(PALEOS_EOS_PREFIXES) and is_mgsio3(c)] components = mgsio3 or components if any(c.startswith(('PALEOS-API:', 'PALEOS-API-2phase:')) for c in components): return 'PALEOS-API-2phase:MgSiO3' diff --git a/tests/config/test_config.py b/tests/config/test_config.py index c3690c5e0..08f8b0459 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -2916,6 +2916,37 @@ def test_no_input_toml_uses_bare_interior_section(): assert len(toml_files) > 0 +@pytest.mark.unit +@pytest.mark.parametrize( + 'energetics, mantle, stops', + [ + ('aragog', 'Seager2007:MgSiO3', True), + ('spider', 'Chabrier:H', True), + ('aragog', 'Seager2007:iron:0.5+Chabrier:H:0.5', True), + ('aragog', 'Analytic:MgSiO3', True), + ('aragog', 'PALEOS:MgSiO3', False), + ('spider', 'PALEOS:H2O', False), + ('aragog', 'WolfBower2018:MgSiO3:0.9+Chabrier:H:0.1', False), + ('spider', 'RTPress100TPa:MgSiO3', False), + ('dummy', 'Seager2007:MgSiO3', False), + ], +) +def test_energetics_need_a_mantle_eos_with_energetics_tables(energetics, mantle, stops): + """SPIDER and Aragog under Zalmoxis need a PALEOS, WolfBower2018 or RTPress100TPa + energetics key; a Seager2007, Chabrier or analytic key is rejected at load, so no + run reads a stand-in table set and a second melting curve.""" + from proteus.config._config import energetics_needs_a_thermal_mantle_eos + + struct = SimpleNamespace(module='zalmoxis', zalmoxis=SimpleNamespace(mantle_eos=mantle)) + value = SimpleNamespace(module=energetics) + instance = SimpleNamespace(interior_struct=struct, interior_energetics=value) + if stops: + with pytest.raises(ValueError, match='has no energetics tables'): + energetics_needs_a_thermal_mantle_eos(instance, None, value) + else: + assert energetics_needs_a_thermal_mantle_eos(instance, None, value) is None + + @pytest.mark.unit @pytest.mark.parametrize( 'energetics, mantle, melting_dir, stops', diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index 939117b84..f22395365 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -311,6 +311,26 @@ def test_two_mgsio3_sources_are_rejected_at_load(mantle, rejected): Struct(**kwargs) +@pytest.mark.parametrize( + 'mantle, rejected', + [ + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:inf', True), + ('PALEOS:MgSiO3:0.9+PALEOS:H2O:-Infinity', True), + ('PALEOS:MgSiO3:0.9+PALEOS: H2O', True), + ('PALEOS:MgSiO3:0.9 + PALEOS:H2O:0.1', False), + ], +) +def test_an_eos_component_with_a_space_or_a_non_finite_fraction_is_rejected(mantle, rejected): + """A component with an inner space or a nan or inf fraction names no registry key, so + the table check would skip it; it is rejected at load. Spaces around '+' are fine.""" + kwargs = dict(module='zalmoxis', zalmoxis=Zalmoxis(mantle_eos=mantle)) + if rejected: + with pytest.raises(ValueError, match='with finite fractions and no spaces'): + Struct(**kwargs) + else: + assert Struct(**kwargs).zalmoxis.mantle_eos == mantle + + def test_zalmoxis_structure_without_its_section_loads(): """The MgSiO3-source check needs the Zalmoxis section and skips when it is absent.""" assert Struct(module='zalmoxis', zalmoxis=None).zalmoxis is None diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index c41c76011..74fdba93f 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -747,8 +747,7 @@ def _synthetic_registry(tmp_path, *, write_files: bool = True) -> dict: def test_check_eos_files_passes_when_tables_present(tmp_path): """All selected EOS table files on disk: the check is silent. - Exercises both the flat (core) and nested (mantle) registry shapes, - plus the lazy PALEOS-API shape that never carries a file path. + Exercises both the flat (core) and nested (mantle) registry shapes. """ from proteus.interior_struct.zalmoxis import check_zalmoxis_eos_files @@ -756,11 +755,6 @@ def test_check_eos_files_passes_when_tables_present(tmp_path): layer_cfg = {'core': 'PALEOS:iron', 'mantle': 'PALEOS-2phase:MgSiO3'} assert check_zalmoxis_eos_files(layer_cfg, registry) is None - # PALEOS-API entries have no eos_file at build time (tables are - # generated on demand): the check must not flag them. - layer_cfg_api = {'mantle': 'PALEOS-API:MgSiO3'} - assert check_zalmoxis_eos_files(layer_cfg_api, registry) is None - # Unknown identifiers are deferred to the registry lookup in the # solver, which produces its own error; the file check stays silent. layer_cfg_unknown = {'mantle': 'NoSuchEOS:MgSiO3'} @@ -884,8 +878,8 @@ def test_check_eos_files_needs_the_mgsio3_unified_table_only_for_an_mgsio3_layer def test_check_eos_files_stops_when_the_paleos_api_resolver_is_missing(tmp_path, monkeypatch): - """A PALEOS-API companion that cannot be materialised stops the run; a resolver - that writes the tables lets it pass.""" + """A PALEOS-API layer table or companion that cannot be materialised stops the + run; a resolver that writes the tables lets it pass.""" import sys import types @@ -904,8 +898,9 @@ def test_check_eos_files_stops_when_the_paleos_api_resolver_is_missing(tmp_path, assert 'PALEOS-API-2phase:MgSiO3 (PALEOS-API tables not built: ModuleNotFoundError' in str( excinfo.value ) - # The mantle's own API table is built by the solve, not by the check. - assert 'PALEOS-API:MgSiO3 (PALEOS-API' not in str(excinfo.value) + assert 'PALEOS-API:MgSiO3 (PALEOS-API tables not built: ModuleNotFoundError' in str( + excinfo.value + ) table = tmp_path / 'api.dat' table.write_text('eos table stub') @@ -920,7 +915,7 @@ def _resolve(entry): monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', fake) assert check_zalmoxis_eos_files(layers, registry, paleos_companions=True) is None assert pair['solid_mantle']['eos_file'] == str(table) - assert 'eos_file' not in api + assert api['eos_file'] == str(table) @pytest.mark.parametrize('missing', ['iron', 'solid', 'h2o']) @@ -940,8 +935,9 @@ def test_require_paleos_tables_stops_an_offline_run(tmp_path, monkeypatch, missi def test_require_paleos_tables_lets_a_resume_keep_its_tables(tmp_path, monkeypatch, caplog): - """A resumed run with kept P-S tables continues without the pair, with one WARNING; - a resumed run without kept tables stops like a fresh one.""" + """A resumed SPIDER run with kept P-S tables continues without the pair, with one + WARNING; an Aragog resume, which re-solves its initial condition on the pair when + the mesh changes, and a resume without kept tables stop like a fresh run.""" from proteus.interior_struct import zalmoxis as zmod monkeypatch.delenv('PROTEUS_PS_CACHE_DIR', raising=False) @@ -949,6 +945,7 @@ def test_require_paleos_tables_lets_a_resume_keep_its_tables(tmp_path, monkeypat zmod, 'load_zalmoxis_material_dictionaries', lambda: _paleos_registry(tmp_path, 'solid') ) config = _require_config('PALEOS:MgSiO3', resume=True) + config.interior_energetics.module = 'spider' bare = tmp_path / 'bare' with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='solid.dat'): zmod.require_paleos_tables(config, str(bare)) @@ -961,6 +958,10 @@ def test_require_paleos_tables_lets_a_resume_keep_its_tables(tmp_path, monkeypat assert len(warnings) == 1 assert 'keeps its original energetics P-S entropy tables' in warnings[0] + config.interior_energetics.module = 'aragog' + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='solid.dat'): + zmod.require_paleos_tables(config, str(kept)) + @pytest.mark.parametrize( 'mantle, warned', @@ -1063,20 +1064,23 @@ def _resolve(entry): ) +_WB_ENERGETICS = 'the energetics use the eos_dir P-S tables (by default Wolf and Bower 2018)' + + @pytest.mark.parametrize('module', ['aragog', 'spider']) @pytest.mark.parametrize( 'mantle, energetics', [ ( 'PALEOS:MgSiO3:0.9+PALEOS:H2O:0.1', - 'are PALEOS MgSiO3 (PALEOS:MgSiO3, solidus = 0.80', + 'the energetics and melting curves are PALEOS MgSiO3 (PALEOS:MgSiO3, solidus = 0.80', ), - ('PALEOS:H2O:0.5+PALEOS:iron:0.5', 'are PALEOS MgSiO3 (PALEOS-2phase:MgSiO3, solidus'), ( - 'WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', - 'follow WolfBower2018:MgSiO3 and melting_dir', + 'PALEOS:H2O:0.5+PALEOS:iron:0.5', + 'the energetics and melting curves are PALEOS MgSiO3 (PALEOS-2phase:MgSiO3, solidus', ), - ('PALEOS-2phase:H2O:0.5+Seager2007:iron:0.5', 'follow melting_dir'), + ('WolfBower2018:MgSiO3:0.9+PALEOS:H2O:0.1', _WB_ENERGETICS), + ('WolfBower2018:MgSiO3:0.9+Chabrier:H:0.1', _WB_ENERGETICS), ], ) def test_require_paleos_tables_warns_once_for_a_paleos_mixture( @@ -1089,7 +1093,7 @@ def test_require_paleos_tables_warns_once_for_a_paleos_mixture( monkeypatch.setattr( zmod, 'load_zalmoxis_material_dictionaries', lambda: _paleos_registry(tmp_path) ) - # The last case has no energetics key; its tables are not in the registry. + # Chabrier:H is not in the PALEOS test registry. monkeypatch.setattr(zmod, 'check_zalmoxis_eos_files', lambda *a, **k: None) config = _require_config(mantle) config.interior_energetics.module = module @@ -1101,8 +1105,7 @@ def test_require_paleos_tables_warns_once_for_a_paleos_mixture( f'mantle_eos={mantle}: the non-MgSiO3 components enter only the structure density' in (warnings[0]) ) - assert f'the energetics and melting curves {energetics}' in warnings[0] - assert 'Seager2007' not in warnings[0].partition(': ')[2] + assert energetics in warnings[0] @pytest.mark.parametrize( @@ -1193,6 +1196,28 @@ def test_resolve_2phase_paths_required_returns_a_full_pair_and_names_two_missing zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry, required=True) +@pytest.mark.parametrize('mantle', ['WolfBower2018:MgSiO3', 'PALEOS:MgSiO3']) +def test_liquidus_super_needs_the_2phase_pair_for_any_mantle(tmp_path, monkeypatch, mantle): + """The liquidus_super anchor is solved on the MgSiO3 2-phase pair for any mantle, so + a missing pair stops at the start check and in the anchor itself, also for a Wolf and + Bower mantle without a generated PALEOS set; an adiabatic start does not need it.""" + from proteus.interior_struct import zalmoxis as zmod + + registry = _paleos_registry(tmp_path, 'liquid') + registry['WolfBower2018:MgSiO3'] = {'eos_file': str(tmp_path / 'mgsio3.dat')} + monkeypatch.setattr(zmod, 'load_zalmoxis_material_dictionaries', lambda: registry) + monkeypatch.setattr(zmod, '_SUPERLIQ_CACHE', {}) + config = _require_config(mantle) + config.interior_energetics.module = 'spider' + if mantle.startswith('WolfBower'): + assert zmod.require_paleos_tables(config, str(tmp_path)) is None + config.planet.temperature_mode = 'liquidus_super' + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='liquid.dat'): + zmod.require_paleos_tables(config, str(tmp_path)) + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='not available: liquid'): + zmod._solve_superliquidus_adiabat(config, {'P_cmb': 1.2e11}) + + def test_superliquidus_anchor_passes_a_missing_table_stop_through(monkeypatch): """A missing-table stop inside the anchor reaches the caller as that error, not as a numerical anchor failure.""" @@ -1272,7 +1297,7 @@ def test_check_eos_files_parses_extended_mantle_strings(tmp_path): assert _strip_fraction_tokens('PALEOS:MgSiO3:nan') == 'PALEOS:MgSiO3:nan' -def test_check_eos_files_walks_nested_and_lazy_siblings(tmp_path): +def test_check_eos_files_walks_nested_and_lazy_siblings(tmp_path, monkeypatch): """Nested role entries are walked; lazy siblings do not mask misses. The real registry nests Seager-style single-role entries @@ -1281,6 +1306,9 @@ def test_check_eos_files_walks_nested_and_lazy_siblings(tmp_path): one nested entry. A missing file-backed sibling must be flagged even when a lazy sibling sits next to it. """ + import sys + import types + from proteus.interior_struct.zalmoxis import check_zalmoxis_eos_files present = tmp_path / 'seager_iron.txt' @@ -1300,6 +1328,9 @@ def test_check_eos_files_walks_nested_and_lazy_siblings(tmp_path): # All file-backed paths present for the core: silent. assert check_zalmoxis_eos_files({'core': 'Seager2007:iron'}, registry) is None + fake = types.ModuleType('zalmoxis.eos.paleos_api_cache') + fake.resolve_registry_entry = lambda entry: None + monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', fake) # The missing file-backed sibling is flagged; the lazy sibling is not. with pytest.raises(RuntimeError) as excinfo: @@ -3695,6 +3726,8 @@ def test_resume_keeps_tables_without_resolving_a_paleos_api_eos(tmp_path, monkey import zalmoxis.eos.dispatch import zalmoxis.eos.paleos_api_cache + from proteus.interior_struct import zalmoxis as zmod + monkeypatch.delenv('PROTEUS_PS_CACHE_DIR', raising=False) _, _, _, key = _generate_tables_stubbed(tmp_path, monkeypatch, resume=True, run=False) _seed_tables(tmp_path / 'run' / 'data' / 'spider_eos', key) @@ -3708,10 +3741,10 @@ def test_resume_keeps_tables_without_resolving_a_paleos_api_eos(tmp_path, monkey tables.assert_not_called() assert out['eos_dir'] == str(tmp_path / 'run' / 'data' / 'spider_eos') assert 'current key is not checked: a PALEOS-API mantle EOS is not resolved' in caplog.text - # Discrimination: a fresh PALEOS-API run resolves its tables. - with pytest.raises(OSError, match='offline'): + # Discrimination: a fresh PALEOS-API run resolves its tables and stops on the failure. + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='OSError: offline'): _generate_tables_stubbed(tmp_path, monkeypatch, resume=False) - resolve.assert_called_once() + resolve.assert_called() def test_resume_without_kept_tables_warns_before_the_build(tmp_path, monkeypatch, caplog): From 478abf29b6c23d51f05aae162c9dd359907edef6 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 16:34:23 +0200 Subject: [PATCH 53/76] Test the missing-pair stop at every site that reads the pair The liquidus_super IC adiabat now requires the MgSiO3 2-phase pair for any mantle, like the anchor it is built on. New tests stop on a missing pair in the Aragog entropy cross-check, the IC adiabat of a Wolf and Bower mantle and the initial entropy from the CMB anchor, each without stubbing the table lookup, and a repeated key such as PALEOS:H2O:0.5+PALEOS:H2O:0.5 counts as one material in the start warning. --- src/proteus/interior_energetics/wrapper.py | 4 +-- tests/interior_energetics/test_common.py | 13 +++++---- .../test_entropy_ic_verify.py | 27 +++++++++++++++++++ .../test_wrapper_adiabat_ic.py | 25 +++++++++++++++++ tests/interior_struct/test_zalmoxis.py | 1 + 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 4fd528806..6a5a1d2d8 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -23,7 +23,7 @@ from proteus.interior_struct.common import solvus_radius from proteus.outgas.wrapper import calc_target_elemental_inventories from proteus.utils.constants import M_earth, R_earth, const_G, noble_gases, vol_element_list -from proteus.utils.helper import UpdateStatusfile, energetics_eos_key, generates_paleos_tables +from proteus.utils.helper import UpdateStatusfile, energetics_eos_key if TYPE_CHECKING: from proteus.config import Config @@ -1155,7 +1155,7 @@ def _build_superliquidus_adiabat_tp(config: Config, hf_row: dict, P_cmb_target: zcfg = config.interior_struct.zalmoxis mat_dicts = load_zalmoxis_material_dictionaries() solid_eos, liquid_eos = resolve_2phase_mgsio3_paths( - zcfg.mantle_eos, mat_dicts, required=generates_paleos_tables(config.interior_struct) + zcfg.mantle_eos, mat_dicts, required=True ) eos_entry = mat_dicts.get(energetics_eos_key(zcfg.mantle_eos) or '', {}) eos_file = eos_entry.get('eos_file', '') or solid_eos or '' diff --git a/tests/interior_energetics/test_common.py b/tests/interior_energetics/test_common.py index 10c198caa..8274c8371 100644 --- a/tests/interior_energetics/test_common.py +++ b/tests/interior_energetics/test_common.py @@ -1119,9 +1119,10 @@ def test_compute_initial_entropy_paleos_failure_logs_warning_and_falls_back( @pytest.mark.unit -def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(): - """With a generated PALEOS set a missing MgSiO3 pair stops the initial entropy - instead of falling back to the default entropy.""" +@pytest.mark.parametrize('mode', ['adiabatic', 'adiabatic_from_cmb']) +def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(mode): + """With a generated PALEOS set a missing MgSiO3 pair stops the initial entropy, from + the surface and from the CMB anchor, instead of falling back to the default entropy.""" pytest.importorskip('zalmoxis') from types import SimpleNamespace from unittest.mock import patch as _patch @@ -1130,7 +1131,9 @@ def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(): from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError config = SimpleNamespace( - planet=SimpleNamespace(temperature_mode='adiabatic', tsurf_init=2400.0), + planet=SimpleNamespace( + temperature_mode=mode, tsurf_init=2400.0, tcmb_init=4000.0, mass_tot=1.0 + ), interior_struct=SimpleNamespace( module='zalmoxis', zalmoxis=SimpleNamespace(mantle_eos='PALEOS:MgSiO3') ), @@ -1142,7 +1145,7 @@ def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(): ), pytest.raises(ZalmoxisMissingEOSFilesError, match='PALEOS-2phase:MgSiO3'), ): - compute_initial_entropy(config, fallback=3175.0) + compute_initial_entropy(config, {'P_cmb': 1.2e11}, fallback=3175.0) # ============================================================================ diff --git a/tests/interior_energetics/test_entropy_ic_verify.py b/tests/interior_energetics/test_entropy_ic_verify.py index cf2b04922..f5cdd3c68 100644 --- a/tests/interior_energetics/test_entropy_ic_verify.py +++ b/tests/interior_energetics/test_entropy_ic_verify.py @@ -291,6 +291,33 @@ def test_aragog_verify_skips_for_dummy_module(tmp_path): interior_o.aragog_solver.set_initial_entropy.assert_not_called() +@pytest.mark.unit +def test_aragog_verify_stops_on_a_missing_pair(tmp_path): + """The entropy cross-check needs the MgSiO3 2-phase pair: without it the run stops + with the missing-table error, even when the unified table is present.""" + from proteus.interior_energetics.aragog import AragogRunner + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + + config = MagicMock() + config.interior_struct.module = 'zalmoxis' + config.interior_struct.zalmoxis.mantle_eos = 'PALEOS:MgSiO3' + interior_o = MagicMock() + interior_o.aragog_solver = _make_mock_entropy_solver( + P_stag=np.array([1e5, 5e10, 1.35e11]), S_stag=np.array([6437.0, 6437.0, 6437.0]) + ) + registry = {'PALEOS:MgSiO3': {'eos_file': '/fake/paleos.dat'}, 'PALEOS-2phase:MgSiO3': {}} + with ( + patch( + 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', + return_value=registry, + ), + patch('os.path.isfile', return_value=True), + pytest.raises(ZalmoxisMissingEOSFilesError, match='PALEOS-2phase:MgSiO3'), + ): + AragogRunner._verify_entropy_ic(config, interior_o, str(tmp_path)) + interior_o.aragog_solver.set_initial_entropy.assert_not_called() + + @pytest.mark.unit def test_aragog_verify_raises_on_api_drift(tmp_path): """ diff --git a/tests/interior_energetics/test_wrapper_adiabat_ic.py b/tests/interior_energetics/test_wrapper_adiabat_ic.py index 4e85accb2..7ee929ef2 100644 --- a/tests/interior_energetics/test_wrapper_adiabat_ic.py +++ b/tests/interior_energetics/test_wrapper_adiabat_ic.py @@ -1548,6 +1548,31 @@ def _anchor(config, hf_row): assert not [r for r in caplog.records if 'no P-T anchor' in r.getMessage()] +def test_adiabat_tp_stops_on_a_missing_pair_for_any_mantle(): + """The liquidus_super IC adiabat is built on the MgSiO3 2-phase pair, so a missing + pair stops it, also for a Wolf and Bower mantle, instead of the linear-guess fallback.""" + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + + config = _config() + config.interior_struct.zalmoxis.mantle_eos = 'WolfBower2018:MgSiO3' + registry = { + 'WolfBower2018:MgSiO3': {'eos_file': '/fake/wb.dat'}, + 'PALEOS-2phase:MgSiO3': {}, + } + with ( + patch( + 'proteus.interior_struct.zalmoxis.solve_superliquidus_adiabat', + return_value={'surface_T': 4000.0}, + ), + patch( + 'proteus.interior_struct.zalmoxis.load_zalmoxis_material_dictionaries', + return_value=registry, + ), + pytest.raises(ZalmoxisMissingEOSFilesError, match='PALEOS-2phase:MgSiO3'), + ): + _build_superliquidus_adiabat_tp(config, {}, P_cmb_target=1.4e12) + + def test_adiabat_tp_passes_a_missing_table_stop_through(): """A missing-table stop in the anchor stops the run instead of the linear-guess fallback that other construction failures take.""" diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 74fdba93f..75f01e8a9 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -970,6 +970,7 @@ def test_require_paleos_tables_lets_a_resume_keep_its_tables(tmp_path, monkeypat ('PALEOS:iron', True), ('PALEOS:MgSiO3', False), ('PALEOS:H2O:1.0', True), + ('PALEOS:H2O:0.5+PALEOS:H2O:0.5', True), ], ) def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( From f4b5386fb3c61794a56d9e37ae5449b49d8abcf3 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 16:37:58 +0200 Subject: [PATCH 54/76] Share the energetics registry lookup and the WolfBower/RTPress prefixes energetics_entry returns the energetics key of a mantle and its registry entry, empty when absent, for the five places that looked it up by hand. TDEP_EOS_PREFIXES in constants.py replaces four copies of the WolfBower2018 and RTPress100TPa tuple. The start warning has one text for a generated PALEOS set, and three long comments are shortened. --- src/proteus/config/_struct.py | 5 ++- src/proteus/interior_energetics/aragog.py | 14 ++++---- src/proteus/interior_energetics/common.py | 8 +++-- src/proteus/interior_energetics/wrapper.py | 24 +++++++------ src/proteus/interior_struct/zalmoxis.py | 42 +++++++++------------- tests/interior_struct/test_zalmoxis.py | 14 ++++---- 6 files changed, 50 insertions(+), 57 deletions(-) diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index 9bcb4ce27..4c2fc9474 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -5,7 +5,7 @@ from attrs import define, field from attrs.validators import ge, gt, in_, le, lt, optional -from proteus.utils.constants import PALEOS_EOS_PREFIXES +from proteus.utils.constants import PALEOS_EOS_PREFIXES, TDEP_EOS_PREFIXES from ._converters import none_if_none @@ -86,8 +86,7 @@ def valid_zalmoxis(instance, attribute, value): ) # 2-layer model (no ice layer, non-T-dep mantle): mantle_mass_fraction must be 0 - _TDEP_PREFIXES = ('WolfBower2018', 'RTPress100TPa') - if ice_layer_eos is None and not mantle_eos.startswith(_TDEP_PREFIXES): + if ice_layer_eos is None and not mantle_eos.startswith(TDEP_EOS_PREFIXES): if mantle_mass_fraction != 0: raise ValueError( '`interior_struct.zalmoxis.mantle_mass_fraction` must be 0 for a 2-layer model ' diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 77e1f6b2b..2342465a0 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -33,7 +33,7 @@ build_mixed_phase_params, ) from proteus.interior_energetics.common import Interior_t -from proteus.utils.constants import FEI2021_LIQUIDUS_P_CALIB_PA +from proteus.utils.constants import FEI2021_LIQUIDUS_P_CALIB_PA, TDEP_EOS_PREFIXES from proteus.interior_energetics.timestep import next_step from proteus.interior_energetics.wrapper import get_core_density, get_core_heatcap from proteus.utils.constants import radnuc_data @@ -849,18 +849,15 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s init_file_temperature_profile = os.path.join(FWL_DATA_DIR, '') elif config.interior_struct.module == 'zalmoxis': _key = energetics_eos_key(config.interior_struct.zalmoxis.mantle_eos) or '' - if _key.startswith(('WolfBower2018', 'RTPress100TPa')): + if _key.startswith(TDEP_EOS_PREFIXES): # When using Zalmoxis with temperature-dependent silicate EOS, set initial condition to user-defined temperature field (from file) in Aragog initial_condition_temperature_profile = 2 init_file_temperature_profile = os.path.join( outdir, 'data', 'zalmoxis_output_temp.txt' ) elif _key.startswith('PALEOS:'): - # For PALEOS EOS with adiabatic IC: Aragog uses IC=3 with - # entropy tables for its entropy-conserving adiabat. After - # initialization, _verify_entropy_ic compares against an - # independent PALEOS entropy inversion and corrects the IC - # if the discrepancy exceeds 1% (table resolution effect). + # IC=3 on the entropy tables; _verify_entropy_ic then compares it with an + # independent PALEOS inversion and corrects a difference above 1%. initial_condition_temperature_profile = 3 init_file_temperature_profile = '' else: @@ -1456,6 +1453,7 @@ def _verify_entropy_ic( from zalmoxis.eos_export import compute_entropy_adiabat from proteus.interior_struct.zalmoxis import ( + energetics_entry, load_zalmoxis_material_dictionaries, load_zalmoxis_solidus_liquidus_functions, resolve_2phase_mgsio3_paths, @@ -1473,7 +1471,7 @@ def _verify_entropy_ic( # `eos_file` arg for compute_entropy_adiabat is a sentinel: any # valid PALEOS table works. Prefer unified eos_file when present # (paleos_unified mantle), else fall back to solid 2-phase path. - eos_entry = mat_dicts.get(energetics_eos_key(mantle_eos) or '', {}) + eos_entry = energetics_entry(mantle_eos, mat_dicts)[1] paleos_eos_file = eos_entry.get('eos_file', '') or solid_eos or '' if not paleos_eos_file or not os.path.isfile(paleos_eos_file): log.debug( diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index 99ede7a7e..0c5e0f92a 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -11,7 +11,7 @@ from proteus.utils.constants import B_ein from proteus.utils.data import find_lookup_table_dir -from proteus.utils.helper import energetics_eos_key, generates_paleos_tables +from proteus.utils.helper import generates_paleos_tables if TYPE_CHECKING: from aragog.eos.entropy import EntropyEOS @@ -956,6 +956,7 @@ def compute_initial_entropy( from zalmoxis.eos_export import compute_surface_entropy from proteus.interior_struct.zalmoxis import ( + energetics_entry, load_zalmoxis_material_dictionaries, load_zalmoxis_solidus_liquidus_functions, resolve_2phase_mgsio3_paths, @@ -984,7 +985,7 @@ def compute_initial_entropy( mat_dicts, required=generates_paleos_tables(config.interior_struct), ) - eos_entry = mat_dicts.get(energetics_eos_key(zalmoxis_cfg.mantle_eos) or '', {}) + eos_entry = energetics_entry(zalmoxis_cfg.mantle_eos, mat_dicts)[1] paleos_eos_file = eos_entry.get('eos_file', '') or solid_eos or '' melt_funcs = load_zalmoxis_solidus_liquidus_functions(zalmoxis_cfg.mantle_eos, config) @@ -1059,6 +1060,7 @@ def compute_initial_entropy( from zalmoxis.eos_export import compute_entropy_adiabat from proteus.interior_struct.zalmoxis import ( + energetics_entry, load_zalmoxis_material_dictionaries, load_zalmoxis_solidus_liquidus_functions, resolve_2phase_mgsio3_paths, @@ -1087,7 +1089,7 @@ def compute_initial_entropy( required=generates_paleos_tables(config.interior_struct), ) - eos_entry = mat_dicts.get(energetics_eos_key(zalmoxis_cfg.mantle_eos) or '', {}) + eos_entry = energetics_entry(zalmoxis_cfg.mantle_eos, mat_dicts)[1] paleos_eos_file = eos_entry.get('eos_file', '') or solid_eos or '' if not paleos_eos_file or not os.path.isfile(paleos_eos_file): raise FileNotFoundError(f'PALEOS table not found: {paleos_eos_file}') diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 6a5a1d2d8..b1b28a3a3 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -22,7 +22,14 @@ ) from proteus.interior_struct.common import solvus_radius from proteus.outgas.wrapper import calc_target_elemental_inventories -from proteus.utils.constants import M_earth, R_earth, const_G, noble_gases, vol_element_list +from proteus.utils.constants import ( + TDEP_EOS_PREFIXES, + M_earth, + R_earth, + const_G, + noble_gases, + vol_element_list, +) from proteus.utils.helper import UpdateStatusfile, energetics_eos_key if TYPE_CHECKING: @@ -1144,6 +1151,7 @@ def _build_superliquidus_adiabat_tp(config: Config, hf_row: dict, P_cmb_target: from zalmoxis.eos_export import compute_entropy_adiabat from proteus.interior_struct.zalmoxis import ( + energetics_entry, load_zalmoxis_material_dictionaries, load_zalmoxis_solidus_liquidus_functions, resolve_2phase_mgsio3_paths, @@ -1157,7 +1165,7 @@ def _build_superliquidus_adiabat_tp(config: Config, hf_row: dict, P_cmb_target: solid_eos, liquid_eos = resolve_2phase_mgsio3_paths( zcfg.mantle_eos, mat_dicts, required=True ) - eos_entry = mat_dicts.get(energetics_eos_key(zcfg.mantle_eos) or '', {}) + eos_entry = energetics_entry(zcfg.mantle_eos, mat_dicts)[1] eos_file = eos_entry.get('eos_file', '') or solid_eos or '' if not eos_file or not os.path.isfile(eos_file): log.warning( @@ -1569,19 +1577,15 @@ def determine_interior_radius_with_zalmoxis( int_o = Interior_t(nlev_b, spider_dir=spider_dir, eos_dir=config.interior_struct.eos_dir) int_o.ic = 1 - # Set Zalmoxis to 'adiabatic' mode for T-dependent mantle EOS. - # NOTE: In practice, Zalmoxis converges the structure using a linear T - # guess and breaks on mass convergence BEFORE the adiabat gate activates. - # The adiabat flag is still set so that (a) the correct EOS code paths - # are selected inside Zalmoxis, and (b) standalone Zalmoxis can use the - # adiabat if the gate is ever fixed. SPIDER provides its own T(r) - # through entropy evolution, so the linear T initial guess is fine. + # A T-dependent mantle EOS runs Zalmoxis in 'adiabatic' mode to select its EOS code + # paths; the structure converges on the linear T guess before the adiabat gate, and + # SPIDER supplies its own T(r) through entropy evolution. _temp_mode_override: str | None = None if ( config.interior_energetics.module == 'spider' and config.planet.temperature_mode == 'isothermal' and (energetics_eos_key(config.interior_struct.zalmoxis.mantle_eos) or '').startswith( - ('WolfBower2018', 'RTPress100TPa') + TDEP_EOS_PREFIXES ) ): log.info( diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 713fb2185..66cb3d8c3 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -33,6 +33,7 @@ FEI2021_LIQUIDUS_P_CALIB_PA, PALEOS_EOS_PREFIXES, PALEOS_REGISTRY_KEYS, + TDEP_EOS_PREFIXES, VOLATILE_EOS_MAP, M_earth, R_earth, @@ -885,7 +886,7 @@ def _solve_superliquidus_adiabat(config: Config, hf_row: dict | None) -> dict: mat_dicts = load_zalmoxis_material_dictionaries() solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(mantle_eos, mat_dicts, required=True) - eos_file = mat_dicts.get(energetics_eos_key(mantle_eos) or '', {}).get('eos_file', '') + eos_file = energetics_entry(mantle_eos, mat_dicts)[1].get('eos_file', '') eos_file = eos_file or solid_eos or '' melt_funcs = load_zalmoxis_solidus_liquidus_functions(mantle_eos, config) if melt_funcs is not None: @@ -1877,10 +1878,10 @@ def check_zalmoxis_eos_files( ) -def _energetics_entry(mantle_eos: str, mat_dicts: dict) -> tuple[str | None, dict | None]: - """Energetics key of a mantle EOS and its registry entry (None when absent).""" +def energetics_entry(mantle_eos: str, mat_dicts: dict) -> tuple[str | None, dict]: + """Energetics key of a mantle EOS and its registry entry (empty when absent).""" key = energetics_eos_key(mantle_eos) - return key, mat_dicts.get(key) if key else None + return key, mat_dicts.get(key or '', {}) def require_paleos_tables(config: Config, outdir: str) -> None: @@ -1916,7 +1917,7 @@ def require_paleos_tables(config: Config, outdir: str) -> None: mat_dicts = load_zalmoxis_material_dictionaries() kept = config.params.resume and _resumed_ps_tables( outdir, - lambda: _ps_resume_key(config, *_energetics_entry(zc.mantle_eos, mat_dicts), mat_dicts), + lambda: _ps_resume_key(config, *energetics_entry(zc.mantle_eos, mat_dicts), mat_dicts), ) liquidus_super = config.planet.temperature_mode == 'liquidus_super' if liquidus_super: @@ -1933,21 +1934,16 @@ def require_paleos_tables(config: Config, outdir: str) -> None: return uses = [] if config.interior_energetics.module in ('spider', 'aragog'): - key = energetics_eos_key(zc.mantle_eos) if not generates_paleos_tables(config.interior_struct): uses.append( 'the energetics use the eos_dir P-S tables (by default Wolf and Bower 2018) ' 'and the melting_dir curves' ) - elif mixture: - uses.append( - f'the energetics and melting curves are PALEOS MgSiO3 ({key}, solidus = ' - f'{zc.mushy_zone_factor:.2f} x PALEOS liquidus)' - ) else: uses.append( - 'the energetics use the MgSiO3 melting curves (PALEOS liquidus, ' - f'solidus = {zc.mushy_zone_factor:.2f} x liquidus) and MgSiO3 P-S tables' + 'the energetics and melting curves are PALEOS MgSiO3 ' + f'({energetics_eos_key(zc.mantle_eos)}, solidus = ' + f'{zc.mushy_zone_factor:.2f} x PALEOS liquidus)' ) if liquidus_super: uses.append('the liquidus_super initial adiabat is solved on the MgSiO3 2-phase tables') @@ -2079,18 +2075,12 @@ def load_zalmoxis_solidus_liquidus_functions(mantle_eos: str, config: Config): (solidus_func, liquidus_func) callable P [Pa] -> T [K], or None. """ key = energetics_eos_key(mantle_eos) or '' - if key.startswith(('WolfBower2018', 'RTPress100TPa')): + if key.startswith(TDEP_EOS_PREFIXES): return get_zalmoxis_melting_curves(config) - # PALEOS unified and PALEOS-2phase: both use the same analytic Belonoshko+2005 / - # Fei+2021 melting curve (`PALEOS-liquidus`) as the basis for MgSiO3 phase - # separation. The unified path uses it for in-table density interpolation; - # the 2-phase path uses it inside `_compute_paleos_dtdp` to weight nabla_ad - # across the solid/liquid blend (mixing.py:_compute_paleos_dtdp). The solidus - # is derived as `liquidus * mushy_zone_factor` so the mushy band lines up with - # the unified PALEOS density interpolation. Without these curves, the - # 2-phase nabla_ad call fails and Zalmoxis structure solve diverges; the - # unified path falls back to phi=0.5 everywhere in VolatileProfile. + # PALEOS unified and 2-phase share the analytic PALEOS-liquidus (Belonoshko+2005 / + # Fei+2021), with solidus = mushy_zone_factor x liquidus. The 2-phase nabla_ad needs + # both curves, and without them the unified path falls back to phi = 0.5. if key.startswith(PALEOS_EOS_PREFIXES): try: from zalmoxis.melting_curves import ( @@ -2646,7 +2636,7 @@ def _ps_resume_key( """Current P-S cache key for the resume warning, without building PALEOS-API tables.""" from zalmoxis.eos.dispatch import _is_paleos_api - if eos_entry is None: + if not eos_entry: mantle_eos = config.interior_struct.zalmoxis.mantle_eos raise _NoPSTables(f'mantle EOS {mantle_eos} is not in the material dictionary') if _is_paleos_api(eos_entry): @@ -2714,7 +2704,7 @@ def generate_spider_tables(config: Config, outdir: str): # Use FWL_DATA paths (not ZALMOXIS_ROOT) for EOS file lookup mat_dicts = load_zalmoxis_material_dictionaries() - key, eos_entry = _energetics_entry(mantle_eos, mat_dicts) + key, eos_entry = energetics_entry(mantle_eos, mat_dicts) # A resumed run stays on the tables it started with; the key only feeds the warning. if config.params.resume: @@ -2724,7 +2714,7 @@ def generate_spider_tables(config: Config, outdir: str): if resumed is not None: return resumed - if eos_entry is None or key not in PALEOS_REGISTRY_KEYS: + if not eos_entry or key not in PALEOS_REGISTRY_KEYS: log.info( 'Mantle EOS %s has no PALEOS table set; using pre-existing SPIDER tables.', mantle_eos, diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 75f01e8a9..3d9446e5c 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -977,8 +977,8 @@ def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( tmp_path, monkeypatch, caplog, mantle, warned ): """A PALEOS H2O or iron mantle gets one WARNING at the start of the run that its - energetics use the MgSiO3 curves and tables; the per-solve check adds none, and an - MgSiO3 mantle gets none.""" + energetics and melting curves are PALEOS MgSiO3; the per-solve check adds none, and + an MgSiO3 mantle gets none.""" from proteus.interior_struct import zalmoxis as zmod registry = _paleos_registry(tmp_path) @@ -988,12 +988,12 @@ def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( for _ in range(3): zmod.check_zalmoxis_eos_files({'core': 'PALEOS:iron', 'mantle': mantle}, registry) messages = [ - r.getMessage() for r in caplog.records if 'MgSiO3 melting curves' in r.getMessage() + r.getMessage() for r in caplog.records if 'are PALEOS MgSiO3 (' in r.getMessage() ] assert len(messages) == (1 if warned else 0) if warned: - assert f'mantle_eos={mantle}' in messages[0] - assert 'solidus = 0.80 x liquidus' in messages[0] + assert f'mantle_eos={mantle}: the structure uses its density, while' in messages[0] + assert '(PALEOS-2phase:MgSiO3, solidus = 0.80 x PALEOS liquidus)' in messages[0] # Energetics without P-S tables build none, so no warning, unless the # liquidus_super initial adiabat is solved on the MgSiO3 pair. for mode, adiabat_warned in (('adiabatic', False), ('liquidus_super', warned)): @@ -1003,7 +1003,7 @@ def test_require_paleos_tables_warns_once_for_a_water_or_iron_mantle( config.planet.temperature_mode = mode with caplog.at_level('WARNING', logger='fwl.proteus.interior_struct.zalmoxis'): zmod.require_paleos_tables(config, str(tmp_path)) - assert 'MgSiO3 melting curves' not in caplog.text + assert 'are PALEOS MgSiO3 (' not in caplog.text assert ('initial adiabat is solved on the MgSiO3' in caplog.text) is adiabat_warned @@ -1131,7 +1131,7 @@ def test_a_paleos_mixture_gets_the_ps_key_of_its_mgsio3_set(tmp_path, mixture, s config.planet.mass_tot = 1.0 config.interior_struct.zalmoxis.lookup_nP = 20 config.interior_struct.zalmoxis.lookup_nS = 30 - key, entry = zmod._energetics_entry(mantle, registry) + key, entry = zmod.energetics_entry(mantle, registry) keys.append((key, zmod._ps_table_inputs(config, key, entry, registry)[-1])) assert keys[0] == keys[1] assert keys[0][0] == single From 59d3ff02d4756fa6d92f642d660bb24acf88b0c9 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 16:39:05 +0200 Subject: [PATCH 55/76] Document the energetics EOS rule and the data each offline stop names The interior page states that SPIDER and Aragog need a PALEOS, WolfBower2018 or RTPress100TPa energetics EOS and that liquidus_super requires the MgSiO3 two-phase tables for every mantle. The data page gives the exact start command for the online step of the upgrade and lists every data type whose offline stop names its download command and fwl-io relocate. --- docs/Reference/config/interior.md | 6 ++++-- docs/Reference/data.md | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index 91b7925dd..4e0d9b436 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -130,8 +130,10 @@ A run with a PALEOS EOS requires the tables the chosen EOS reads: the table of e When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. With `dry_mantle = false` it also requires the tables of the dissolved volatiles (`PALEOS:H2O` and `Chabrier:H`). A resumed run that keeps its pressure-entropy tables needs only the tables of its layers. -A mixture mantle (a `+` in `mantle_eos`) takes its melting curves, in the structure and in the energetics, and its SPIDER or Aragog tables from its MgSiO$_3$ component: with `PALEOS:MgSiO3` it gets exactly the curves and tables of a `PALEOS:MgSiO3` mantle, and with `WolfBower2018:MgSiO3` the Wolf and Bower (2018) tables and the `melting_dir` curves. The other components enter only the structure density. -A PALEOS mantle with no MgSiO$_3$ component (for example `mantle_eos = "PALEOS:H2O"`, `"PALEOS:iron"`, or a mixture of the two) uses the density of its own material in the structure, while the PALEOS MgSiO$_3$ melting curves and the two-phase MgSiO$_3$ tables of its PALEOS family stand in for the energetics; `temperature_mode = "liquidus_super"` solves the initial adiabat on the MgSiO$_3$ two-phase tables. +A mixture mantle (a `+` in `mantle_eos`) takes its melting curves, in the structure and in the energetics, and its SPIDER or Aragog tables from its MgSiO$_3$ component: with `PALEOS:MgSiO3` it gets exactly the curves and tables of a `PALEOS:MgSiO3` mantle, and with `WolfBower2018:MgSiO3` the `eos_dir` P-S tables (by default Wolf and Bower 2018) and the `melting_dir` curves. The other components enter only the structure density. +A PALEOS mantle with no MgSiO$_3$ component (for example `mantle_eos = "PALEOS:H2O"`, `"PALEOS:iron"`, or a mixture of the two) uses the density of its own material in the structure, while the PALEOS MgSiO$_3$ melting curves and the two-phase MgSiO$_3$ tables of its PALEOS family stand in for the energetics. +With SPIDER or Aragog, the energetics must follow a PALEOS, `WolfBower2018` or `RTPress100TPa` EOS; a mantle whose energetics would follow a Seager2007, Chabrier or analytic EOS is rejected when the configuration loads. +`temperature_mode = "liquidus_super"` solves the initial adiabat on the MgSiO$_3$ two-phase tables for every mantle, so those tables are required at the start of the run. The run logs one warning that names the cases that apply at the start of each run segment, so a resumed run logs it again. !!! note "`mushy_zone_factor` and the two-phase structure solve" diff --git a/docs/Reference/data.md b/docs/Reference/data.md index f3c4197e8..78fff7c3b 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -52,9 +52,9 @@ To configure a Zenodo API token, see the A `$FWL_DATA` folder written by an older PROTEUS keeps its files at paths that are no longer read. Two steps bring it into the current layout: 1. Run `fwl-io relocate`. It moves every dataset whose files all match their pinned checksums into its version directory. `fwl-io relocate --dry-run` lists what would move, what is incomplete and what does not match, without moving anything. -2. Start the run once without `--offline`, or run `proteus get interiordata --config-path ` for the interior tables alone. This downloads what the first step left: incomplete and mismatched datasets, and those it cannot move (the Chabrier archive, the surface albedos, the Seager tables and the Spada tracks). +2. Run `proteus start --config ` once without `--offline`, which downloads the data that configuration needs, or `proteus get interiordata --config-path ` for the interior tables alone. This downloads what the first step left: incomplete and mismatched datasets, and those it cannot move (the Chabrier archive, the surface albedos, the Seager tables and the Spada tracks). -A run with `--offline` that finds an interior EOS table, a melting curve, a P-S lookup table or a spectral file missing stops with an error that names its download command and ends with: "Data kept in the older FWL_DATA layout can be moved into place with `fwl-io relocate`." +A run with `--offline` that finds an interior EOS table, a melting curve, a P-S lookup table, a spectral file, a solar, MUSCLES or PHOENIX spectrum or a surface albedo file missing stops with an error that names its download command and ends with: "Data kept in the older FWL_DATA layout can be moved into place with `fwl-io relocate`." --- From c60975b9bb256f45a8385dca2ab6ff35fd3467b5 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 18:00:04 +0200 Subject: [PATCH 56/76] Fetch the anchor pair for any mantle and tighten the data error texts The liquidus_super anchor and the CMB entropy lookup read the MgSiO3 2-phase pair for every mantle, so both now require it, and `proteus get interiordata` fetches it for a liquidus_super config. A Wolf and Bower mantle no longer logs an unknown-family warning during the fetch. An EOS component must now have a source, a material and non-negative fractions. The thermal mantle EOS check at config load skips a Zalmoxis structure without its section, which the structure check reports. The missing-data errors name the exact command: the PHOENIX grid point with --feh and --alpha, the catalogue star id for MUSCLES, and the full config key for melting_dir and eos_dir. A missing spectral file from a group no manifest declares says to place the file there, and a missing PHOENIX file in offline mode writes status 23 before it stops. --- docs/Reference/config/interior.md | 2 +- src/proteus/atmos_clim/common.py | 11 ++++-- src/proteus/config/_config.py | 2 ++ src/proteus/config/_struct.py | 27 +++++++++----- src/proteus/interior_energetics/common.py | 5 ++- src/proteus/interior_energetics/spider.py | 2 +- src/proteus/interior_struct/zalmoxis.py | 3 +- src/proteus/star/phoenix.py | 4 ++- src/proteus/star/wrapper.py | 9 ++--- src/proteus/utils/data.py | 19 ++++++++-- tests/atmos_clim/test_common.py | 9 +++-- tests/atmos_clim/test_wrapper.py | 37 +++++++++++++++++++ tests/config/test_config.py | 3 ++ tests/config/test_struct.py | 13 +++++-- tests/interior_energetics/test_common.py | 17 ++++++--- tests/star/test_stellar_spectra.py | 16 +++++---- tests/test_cli.py | 10 ++++-- tests/utils/test_data.py | 43 ++++++++++++++++++++--- 18 files changed, 181 insertions(+), 51 deletions(-) diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index 4e0d9b436..e4d3f6405 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -129,7 +129,7 @@ Their phase boundaries follow the same construction as the unified case: the liq A run with a PALEOS EOS requires the tables the chosen EOS reads: the table of each PALEOS layer (the MgSiO$_3$ unified table only when a layer is `PALEOS:MgSiO3`) and, for a mantle with a PALEOS component, the MgSiO$_3$ two-phase solid and liquid pair (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family). When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. With `dry_mantle = false` it also requires the tables of the dissolved volatiles (`PALEOS:H2O` and `Chabrier:H`). -A resumed run that keeps its pressure-entropy tables needs only the tables of its layers. +A resumed SPIDER run that keeps its pressure-entropy tables needs only the tables of its layers; an Aragog resume also needs the two-phase pair, which it reads when the mesh changes. A mixture mantle (a `+` in `mantle_eos`) takes its melting curves, in the structure and in the energetics, and its SPIDER or Aragog tables from its MgSiO$_3$ component: with `PALEOS:MgSiO3` it gets exactly the curves and tables of a `PALEOS:MgSiO3` mantle, and with `WolfBower2018:MgSiO3` the `eos_dir` P-S tables (by default Wolf and Bower 2018) and the `melting_dir` curves. The other components enter only the structure density. A PALEOS mantle with no MgSiO$_3$ component (for example `mantle_eos = "PALEOS:H2O"`, `"PALEOS:iron"`, or a mixture of the two) uses the density of its own material in the structure, while the PALEOS MgSiO$_3$ melting curves and the two-phase MgSiO$_3$ tables of its PALEOS family stand in for the energetics. With SPIDER or Aragog, the energetics must follow a PALEOS, `WolfBower2018` or `RTPress100TPa` EOS; a mantle whose energetics would follow a Seager2007, Chabrier or analytic EOS is rejected when the configuration loads. diff --git a/src/proteus/atmos_clim/common.py b/src/proteus/atmos_clim/common.py index 8a61ab6e6..5a82cff5f 100644 --- a/src/proteus/atmos_clim/common.py +++ b/src/proteus/atmos_clim/common.py @@ -371,14 +371,19 @@ def require_spfile_path(dirs: dict, config: Config) -> str: FileNotFoundError After writing the error status, naming the download command. """ - from proteus.utils.data import RELOCATE_HINT + from proteus.utils.data import RELOCATE_HINT, SPECTRAL_FILE_FOLDERS path = get_spfile_path(dirs['fwl'], config) if not os.path.isfile(path): UpdateStatusfile(dirs, 20) + group, bands = get_spfile_name_and_bands(config) + fix = ( + 'Fetch it with `proteus get spectral`.' + if f'{group}/{bands}' in SPECTRAL_FILE_FOLDERS + else 'No manifest declares this group, so place the file there.' + ) raise FileNotFoundError( - f"Spectral file does not exist at '{path}'. Fetch it with " - f'`proteus get spectral`. {RELOCATE_HINT}' + f"Spectral file does not exist at '{path}'. {fix} {RELOCATE_HINT}" ) return path diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index 098e93e8c..a7c668f58 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -253,6 +253,8 @@ def energetics_needs_a_thermal_mantle_eos(instance, attribute, value): struct = instance.interior_struct if value.module not in ('spider', 'aragog') or struct.module != 'zalmoxis': return + if struct.zalmoxis is None: + return key = energetics_eos_key(struct.zalmoxis.mantle_eos) if not (key or '').startswith(PALEOS_EOS_PREFIXES + TDEP_EOS_PREFIXES): raise ValueError( diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index 4c2fc9474..2d79fbf98 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -43,10 +43,16 @@ def valid_zalmoxis(instance, attribute, value): f"`interior_struct.zalmoxis.ice_layer_eos` must be 'none' or ':' format, " f"got '{ice_layer_eos}'" ) - # A component is ':' plus finite fraction tokens, with no inner space; - # a token that still parses as a number after stripping is nan or inf. + # A component is ':' plus non-negative finite fractions, with no inner + # space; a last token that still parses as a number after stripping is nan or inf. from proteus.utils.helper import _strip_fraction_tokens + def _number(token): + try: + return float(token) + except ValueError: + return None + for name, eos_val in [ ('core_eos', core_eos), ('mantle_eos', mantle_eos), @@ -54,15 +60,18 @@ def valid_zalmoxis(instance, attribute, value): ]: for comp in filter(None, (c.strip() for c in eos_val.split('+'))): key = _strip_fraction_tokens(comp) - try: - float(key.rsplit(':', 1)[-1]) - bad = True - except ValueError: - bad = any(ch.isspace() for ch in key) - if bad: + source, _, material = key.partition(':') + fractions = [_number(t) for t in comp[len(key) :].split(':')[1:]] + if ( + not source + or not material + or any(ch.isspace() for ch in key) + or _number(key.rsplit(':', 1)[-1]) is not None + or any(f < 0 for f in fractions) + ): raise ValueError( f"`interior_struct.zalmoxis.{name}` component '{comp}' is not " - "':' with finite fractions and no spaces" + "':' with non-negative finite fractions and no spaces" ) # WolfBower2018 EOS is limited to 1 TPa. For planets > 2 M_earth, diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index 0c5e0f92a..e741a83ea 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -980,10 +980,9 @@ def compute_initial_entropy( return fallback mat_dicts = load_zalmoxis_material_dictionaries() + # The CMB lookup reads the MgSiO3 2-phase pair for any mantle. solid_eos, liquid_eos = resolve_2phase_mgsio3_paths( - zalmoxis_cfg.mantle_eos, - mat_dicts, - required=generates_paleos_tables(config.interior_struct), + zalmoxis_cfg.mantle_eos, mat_dicts, required=True ) eos_entry = energetics_entry(zalmoxis_cfg.mantle_eos, mat_dicts)[1] paleos_eos_file = eos_entry.get('eos_file', '') or solid_eos or '' diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index 1891dab2a..d399b52cc 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -653,7 +653,7 @@ def _resolve_spider_eos_dir(dirs: dict, config: Config) -> str: if not os.path.isdir(eos_dir): raise FileNotFoundError( f'SPIDER EOS directory not found: {eos_dir}. ' - f"Check interior.eos_dir='{config.interior_struct.eos_dir}', or fetch the " + f"Check interior_struct.eos_dir='{config.interior_struct.eos_dir}', or fetch the " f'tables with `proteus get interiordata --config-path `. {RELOCATE_HINT}' ) return eos_dir diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 66cb3d8c3..2148bfcdf 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -886,8 +886,7 @@ def _solve_superliquidus_adiabat(config: Config, hf_row: dict | None) -> dict: mat_dicts = load_zalmoxis_material_dictionaries() solid_eos, liquid_eos = resolve_2phase_mgsio3_paths(mantle_eos, mat_dicts, required=True) - eos_file = energetics_entry(mantle_eos, mat_dicts)[1].get('eos_file', '') - eos_file = eos_file or solid_eos or '' + eos_file = energetics_entry(mantle_eos, mat_dicts)[1].get('eos_file') or solid_eos or '' melt_funcs = load_zalmoxis_solidus_liquidus_functions(mantle_eos, config) if melt_funcs is not None: sol_func, liq_func = melt_funcs diff --git a/src/proteus/star/phoenix.py b/src/proteus/star/phoenix.py index 8a5401b78..8e6b1bfdd 100644 --- a/src/proteus/star/phoenix.py +++ b/src/proteus/star/phoenix.py @@ -201,9 +201,11 @@ def get_phoenix_modern_spectrum( raise RuntimeError(f'PHOENIX file still missing after download: {raw_path}') else: log.error('Running in offline mode, but appropriate phoenix file is not available.') + UpdateStatusfile(handler.directories, 23) raise FileNotFoundError( f'PHOENIX file not found: {raw_path} (offline mode). Fetch it with ' - f'`proteus get phoenix`. {RELOCATE_HINT}' + f'`proteus get phoenix --feh {FeH_g:+0.1f} --alpha {alpha_g:+0.1f}`. ' + f'{RELOCATE_HINT}' ) # scale from stellar surface to 1 AU and save to PHOENIX/1AU diff --git a/src/proteus/star/wrapper.py b/src/proteus/star/wrapper.py index c7a4bf596..9f3bdc61a 100644 --- a/src/proteus/star/wrapper.py +++ b/src/proteus/star/wrapper.py @@ -66,7 +66,8 @@ def init_star(handler: Proteus): # may be None there; for the file-based sources an empty name falls # through to the not-found handling below. starname_input = (mors_cfg.star_name or '').strip() - star_file = starname_input.lower().replace(' ', '-').replace('gj-', 'gj') + '.txt' + star_id = starname_input.lower().replace(' ', '-').replace('gj-', 'gj') + star_file = star_id + '.txt' # Solar special cases solar_key = starname_input.lower().replace(' ', '') @@ -118,7 +119,7 @@ def init_star(handler: Proteus): UpdateStatusfile(handler.directories, 23) raise FileNotFoundError( f"No solar or MUSCLES spectrum found in reference data for '{mors_cfg.star_name}'. " - f'Fetch them with `proteus get solar` and `proteus get muscles`. {RELOCATE_HINT}' + f'Fetch them with `proteus get solar` and `proteus get muscles --star {star_id}`. {RELOCATE_HINT}' ) # spectrum_source = 'solar' @@ -144,7 +145,7 @@ def init_star(handler: Proteus): UpdateStatusfile(handler.directories, 23) raise FileNotFoundError( f"No solar or MUSCLES spectrum for '{mors_cfg.star_name}'. Fetch them with " - f'`proteus get solar` and `proteus get muscles`. {RELOCATE_HINT}' + f'`proteus get solar` and `proteus get muscles --star {star_id}`. {RELOCATE_HINT}' ) # spectrum_source = 'muscles' @@ -179,7 +180,7 @@ def init_star(handler: Proteus): UpdateStatusfile(handler.directories, 23) raise FileNotFoundError( f"No MUSCLES spectrum for '{mors_cfg.star_name}'. Fetch it with " - f'`proteus get muscles --star {mors_cfg.star_name}`. {RELOCATE_HINT}' + f'`proteus get muscles --star {star_id}`. {RELOCATE_HINT}' ) # spectrum_source = 'phoenix' diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 9b7f718c1..acd7f811f 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -24,6 +24,7 @@ paleos_companion_keys, resolve_fwl_data_dir, safe_rm, + twophase_registry_key, ) from proteus.utils.phoenix_helper import phoenix_param @@ -1787,6 +1788,8 @@ def download_zalmoxis_eos_for_config(config) -> None: volatile_eos='' if getattr(zconf, 'dry_mantle', True) else '+'.join(VOLATILE_EOS_MAP.values()), + anchor_pair=getattr(getattr(config, 'planet', None), 'temperature_mode', None) + == 'liquidus_super', ) @@ -2026,7 +2029,11 @@ def download_Seager_EOS(): def download_zalmoxis_eos( - mantle_eos: str, core_eos: str = '', ice_layer_eos: str = '', volatile_eos: str = '' + mantle_eos: str, + core_eos: str = '', + ice_layer_eos: str = '', + volatile_eos: str = '', + anchor_pair: bool = False, ): """Download Zalmoxis EOS data required for the given EOS configuration. @@ -2045,6 +2052,9 @@ def download_zalmoxis_eos( Ice layer EOS identifier (e.g. ``'Seager2007:H2O'``, ``'PALEOS:H2O'``, or empty). volatile_eos : str ``+``-joined EOS components of dissolved volatiles, or empty. + anchor_pair : bool + Also fetch the MgSiO3 2-phase pair for any mantle, which the + liquidus_super initial adiabat reads. """ from proteus.data import ( EOS_CHABRIER_2021, @@ -2088,8 +2098,10 @@ def fetch_files(key, names): ): attempt('the Seager 2007 tables', download_eos_static) - # A PALEOS mantle also reads the MgSiO3 2-phase pair. + # A PALEOS mantle, and any mantle with the liquidus_super anchor, reads the 2-phase pair. components.update(paleos_companion_keys(mantle_eos)) + if anchor_pair: + components.add(twophase_registry_key(mantle_eos)) # WolfBower2018 T-dependent MgSiO3. The RTPress mantle pairs its melt tables # with the Wolf & Bower solid tables, so those files are needed for both. @@ -2133,6 +2145,7 @@ def fetch_files(key, names): # (generated locally from upstream paleos at runtime) but are valid. known_prefixes = ( 'Seager2007:', + 'WolfBower2018:', 'RTPress100TPa:', 'Chabrier:', 'PALEOS-API:', @@ -2205,7 +2218,7 @@ def get_zalmoxis_melting_curves(config: Config): if not melting_file.is_file(): raise FileNotFoundError( f'Melting curve file not found: {melting_file}. ' - f"Check struct.melting_dir='{config.interior_struct.melting_dir}', or fetch " + f"Check interior_struct.melting_dir='{config.interior_struct.melting_dir}', or fetch " f'it with `proteus get interiordata --config-path `. {RELOCATE_HINT}' ) solidus_func = load_melting_curve(solidus_file) diff --git a/tests/atmos_clim/test_common.py b/tests/atmos_clim/test_common.py index 5c7a5a858..3617bf08f 100644 --- a/tests/atmos_clim/test_common.py +++ b/tests/atmos_clim/test_common.py @@ -651,17 +651,20 @@ def test_get_spfile_path_reads_an_undeclared_group_from_the_local_tree(tmp_path, @pytest.mark.unit @pytest.mark.parametrize('group, bands', [('MyGroup', '48'), ('Dayspring', '48')]) def test_require_spfile_path_stops_with_status_20_and_the_fetch_command(tmp_path, group, bands): - """A missing spectral file, declared or not, writes status 20 and names - `proteus get spectral` and `fwl-io relocate`; a present one is returned.""" + """A missing spectral file writes status 20 and names `fwl-io relocate` and, for a + declared group, `proteus get spectral`; a present one is returned.""" from proteus.atmos_clim.common import require_spfile_path conf = MagicMock() conf.atmos_clim.spectral_group = group conf.atmos_clim.spectral_bands = bands dirs = {'fwl': str(tmp_path), 'output': str(tmp_path / 'out')} + fix = ( + '`proteus get spectral`' if group == 'Dayspring' else 'No manifest declares this group' + ) with ( patch('proteus.atmos_clim.common.UpdateStatusfile') as status, - pytest.raises(FileNotFoundError, match='`proteus get spectral`') as exc, + pytest.raises(FileNotFoundError, match=fix) as exc, ): require_spfile_path(dirs, conf) status.assert_called_once_with(dirs, 20) diff --git a/tests/atmos_clim/test_wrapper.py b/tests/atmos_clim/test_wrapper.py index dc4f19100..04e5a1edb 100644 --- a/tests/atmos_clim/test_wrapper.py +++ b/tests/atmos_clim/test_wrapper.py @@ -452,6 +452,43 @@ def test_run_atmosphere_keeps_the_column_janus_solved(): assert atmos_o._atm_janus_last is solved +@pytest.mark.unit +def test_run_atmosphere_janus_stops_with_status_20_on_a_missing_spectral_file(tmp_path): + """The JANUS branch stops on a missing spectral file with status 20 written to the + run's status file and an error naming `proteus get spectral` and `fwl-io relocate`.""" + from proteus.atmos_clim.common import Atmos_t + + config = SimpleNamespace( + atmos_clim=SimpleNamespace( + module='janus', + surf_state='fixed', + albedo_pl=0.0, + spectral_group='Dayspring', + spectral_bands='48', + ), + params=SimpleNamespace(resume=False), + interior_energetics=SimpleNamespace(module='aragog'), + ) + dirs = {'output': str(tmp_path / 'out'), 'fwl': str(tmp_path / 'fwl')} + with ( + patch('proteus.atmos_clim.janus.RunJANUS') as run_janus, + pytest.raises(FileNotFoundError, match='`proteus get spectral`.*`fwl-io relocate`'), + ): + atmos_wrapper.run_atmosphere( + Atmos_t(), + config, + dirs, + {'total': 0}, + [1.0], + [1.0], + False, + None, + {'T_magma': 1800.0}, + ) + run_janus.assert_not_called() + assert (tmp_path / 'out' / 'status').read_text().splitlines()[0] == '20' + + # --------------------------------------------------------------------------- # carry_converged_levels: levels of a rejected structure are not used # --------------------------------------------------------------------------- diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 08f8b0459..fb090c841 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -2945,6 +2945,9 @@ def test_energetics_need_a_mantle_eos_with_energetics_tables(energetics, mantle, energetics_needs_a_thermal_mantle_eos(instance, None, value) else: assert energetics_needs_a_thermal_mantle_eos(instance, None, value) is None + # A Zalmoxis structure without its section is checked by the structure itself. + struct.zalmoxis = None + assert energetics_needs_a_thermal_mantle_eos(instance, None, value) is None @pytest.mark.unit diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index f22395365..fd78562ad 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -317,15 +317,22 @@ def test_two_mgsio3_sources_are_rejected_at_load(mantle, rejected): ('PALEOS:MgSiO3:0.9+PALEOS:H2O:inf', True), ('PALEOS:MgSiO3:0.9+PALEOS:H2O:-Infinity', True), ('PALEOS:MgSiO3:0.9+PALEOS: H2O', True), + ('PALEOS:MgSiO3:0.9+H2O:0.1', True), + ('PALEOS:MgSiO3:0.9+:H2O:0.1', True), + ('PALEOS:MgSiO3:0.9+PALEOS::0.1', True), + ('PALEOS:MgSiO3:1.1+PALEOS:H2O:-0.1', True), ('PALEOS:MgSiO3:0.9 + PALEOS:H2O:0.1', False), ], ) def test_an_eos_component_with_a_space_or_a_non_finite_fraction_is_rejected(mantle, rejected): - """A component with an inner space or a nan or inf fraction names no registry key, so - the table check would skip it; it is rejected at load. Spaces around '+' are fine.""" + """A component without a source or material, with an inner space, or with a negative, + nan or inf fraction names no registry key or no valid mass fraction, so it is rejected + at load. Spaces around '+' are fine.""" kwargs = dict(module='zalmoxis', zalmoxis=Zalmoxis(mantle_eos=mantle)) if rejected: - with pytest.raises(ValueError, match='with finite fractions and no spaces'): + with pytest.raises( + ValueError, match='with non-negative finite fractions and no spaces' + ): Struct(**kwargs) else: assert Struct(**kwargs).zalmoxis.mantle_eos == mantle diff --git a/tests/interior_energetics/test_common.py b/tests/interior_energetics/test_common.py index 8274c8371..f7e022a87 100644 --- a/tests/interior_energetics/test_common.py +++ b/tests/interior_energetics/test_common.py @@ -1119,10 +1119,17 @@ def test_compute_initial_entropy_paleos_failure_logs_warning_and_falls_back( @pytest.mark.unit -@pytest.mark.parametrize('mode', ['adiabatic', 'adiabatic_from_cmb']) -def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(mode): - """With a generated PALEOS set a missing MgSiO3 pair stops the initial entropy, from - the surface and from the CMB anchor, instead of falling back to the default entropy.""" +@pytest.mark.parametrize( + 'mode, mantle', + [ + ('adiabatic', 'PALEOS:MgSiO3'), + ('adiabatic_from_cmb', 'PALEOS:MgSiO3'), + ('adiabatic_from_cmb', 'WolfBower2018:MgSiO3'), + ], +) +def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(mode, mantle): + """A missing MgSiO3 pair stops the initial entropy, from the surface with a generated + PALEOS set and from the CMB anchor for any mantle, instead of a fallback entropy.""" pytest.importorskip('zalmoxis') from types import SimpleNamespace from unittest.mock import patch as _patch @@ -1135,7 +1142,7 @@ def test_compute_initial_entropy_stops_on_a_missing_pair_with_a_paleos_set(mode) temperature_mode=mode, tsurf_init=2400.0, tcmb_init=4000.0, mass_tot=1.0 ), interior_struct=SimpleNamespace( - module='zalmoxis', zalmoxis=SimpleNamespace(mantle_eos='PALEOS:MgSiO3') + module='zalmoxis', zalmoxis=SimpleNamespace(mantle_eos=mantle) ), ) with ( diff --git a/tests/star/test_stellar_spectra.py b/tests/star/test_stellar_spectra.py index c6ae1f174..34bae4818 100644 --- a/tests/star/test_stellar_spectra.py +++ b/tests/star/test_stellar_spectra.py @@ -267,8 +267,12 @@ def test_get_phoenix_modern_spectrum_offline_missing_raw_raises(tmp_path, monkey monkeypatch.setattr(phoenix_mod, 'GetFWLData', lambda: tmp_path) handler = _make_handler(tmp_path=tmp_path, offline=True, Teff=5800.0, logg=4.5, radius=1.0) - with pytest.raises(FileNotFoundError, match='`proteus get phoenix`.*`fwl-io relocate`'): + with pytest.raises( + FileNotFoundError, + match=r'`proteus get phoenix --feh [+-]\d\.\d --alpha [+-]\d\.\d`.*relocate', + ): get_phoenix_modern_spectrum(handler, stellar_track=None) + assert (tmp_path / 'out' / 'status').read_text().splitlines()[0] == '23' # Discrimination: the offline branch must NOT silently emit a 1 AU file # before raising. A regression that wrote a zero-flux fallback to disk @@ -440,15 +444,15 @@ def test_init_star_source_muscles_falls_back_to_solar_with_warning( @pytest.mark.parametrize( 'source, command', [ - (None, 'proteus get solar'), - ('solar', 'proteus get solar'), - ('muscles', 'proteus get muscles'), + (None, 'proteus get solar` and `proteus get muscles --star gj876'), + ('solar', 'proteus get solar` and `proteus get muscles --star gj876'), + ('muscles', 'proteus get muscles --star gj876'), ], ) def test_init_star_source_none_missing_both_raises(tmp_path, monkeypatch, source, command): """With neither solar nor MUSCLES on disk, ``init_star`` raises FileNotFoundError - naming the download command and ``fwl-io relocate``, rather than producing a silent - zero-flux spectrum. + naming the download command with the catalogue star id and ``fwl-io relocate``, rather + than producing a silent zero-flux spectrum. """ from proteus.star.wrapper import init_star diff --git a/tests/test_cli.py b/tests/test_cli.py index d09f6b4fb..22ce39776 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -629,8 +629,12 @@ def test_get_interiordata_fetches_zalmoxis_eos(monkeypatch, tmp_path): runner = CliRunner() calls = [] - def fake_download_zalmoxis_eos(*, mantle_eos, core_eos, ice_layer_eos, volatile_eos): - calls.append(('zalmoxis_eos', mantle_eos, core_eos, ice_layer_eos, volatile_eos)) + def fake_download_zalmoxis_eos( + *, mantle_eos, core_eos, ice_layer_eos, volatile_eos, anchor_pair + ): + calls.append( + ('zalmoxis_eos', mantle_eos, core_eos, ice_layer_eos, volatile_eos, anchor_pair) + ) fake_config = SimpleNamespace( interior_struct=SimpleNamespace( @@ -660,7 +664,7 @@ def fake_download_zalmoxis_eos(*, mantle_eos, core_eos, ice_layer_eos, volatile_ assert res.exit_code == 0 # ice_layer_eos None must be coerced to '' (the downloader's no-ice value), # not passed through as None. - assert calls == [('zalmoxis_eos', 'PALEOS:MgSiO3', 'PALEOS:iron', '', '')] + assert calls == [('zalmoxis_eos', 'PALEOS:MgSiO3', 'PALEOS:iron', '', '', False)] @pytest.mark.unit diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 54d7701d8..7b6963deb 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3041,6 +3041,7 @@ def test_get_sufficient_zalmoxis_wolf_bower( core_eos='Seager2007:iron', ice_layer_eos='', volatile_eos='', + anchor_pair=False, ) # SPIDER dynamic EOS still downloaded separately mock_dyn.assert_called_once() @@ -3164,6 +3165,7 @@ def test_get_sufficient_zalmoxis_seager_only( core_eos='Seager2007:iron', ice_layer_eos='', volatile_eos='', + anchor_pair=False, ) mock_dyn.assert_not_called() @@ -3210,6 +3212,7 @@ def test_get_sufficient_zalmoxis_paleos( core_eos='PALEOS:iron', ice_layer_eos='PALEOS:H2O', volatile_eos='', + anchor_pair=False, ) mock_dyn.assert_not_called() @@ -3516,11 +3519,19 @@ def test_download_zalmoxis_eos_strips_spaces_in_a_mixture(mock_static, mock_fetc @pytest.mark.unit -@pytest.mark.parametrize('dry, volatiles', [(True, ''), (False, 'PALEOS:H2O+Chabrier:H')]) +@pytest.mark.parametrize( + 'dry, volatiles, mode, pair', + [ + (True, '', 'adiabatic', False), + (False, 'PALEOS:H2O+Chabrier:H', 'adiabatic', False), + (True, '', 'liquidus_super', True), + ], +) def test_download_zalmoxis_eos_for_config_adds_the_volatiles_of_a_wet_mantle( - monkeypatch, dry, volatiles + monkeypatch, dry, volatiles, mode, pair ): - """With dry_mantle = false the fetch includes the dissolved-volatile EOS.""" + """With dry_mantle = false the fetch includes the dissolved-volatile EOS, and with + liquidus_super the 2-phase pair the anchor reads.""" from types import SimpleNamespace from proteus.utils import data as dmod @@ -3531,7 +3542,10 @@ def test_download_zalmoxis_eos_for_config_adds_the_volatiles_of_a_wet_mantle( mantle_eos='PALEOS:MgSiO3', core_eos='PALEOS:iron', ice_layer_eos=None, dry_mantle=dry ) dmod.download_zalmoxis_eos_for_config( - SimpleNamespace(interior_struct=SimpleNamespace(module='zalmoxis', zalmoxis=zconf)) + SimpleNamespace( + interior_struct=SimpleNamespace(module='zalmoxis', zalmoxis=zconf), + planet=SimpleNamespace(temperature_mode=mode), + ) ) assert calls == [ dict( @@ -3539,10 +3553,31 @@ def test_download_zalmoxis_eos_for_config_adds_the_volatiles_of_a_wet_mantle( core_eos='PALEOS:iron', ice_layer_eos='', volatile_eos=volatiles, + anchor_pair=pair, ) ] +@pytest.mark.unit +@patch('proteus.data.fetch_dataset_file') +@patch('proteus.data.fetch_dataset') +@patch('proteus.utils.data.download_eos_static') +def test_download_zalmoxis_eos_fetches_the_anchor_pair_for_a_wolf_bower_mantle( + mock_static, mock_fetch, mock_file, caplog +): + """A Wolf and Bower mantle fetches the MgSiO3 2-phase pair only for the liquidus_super + anchor, and its component raises no unknown-family warning.""" + from proteus.data import EOS_PALEOS_MGSIO3 + from proteus.utils.data import download_zalmoxis_eos + + with caplog.at_level('WARNING', logger='fwl.proteus.utils.data'): + download_zalmoxis_eos('WolfBower2018:MgSiO3', core_eos='PALEOS:iron') + assert EOS_PALEOS_MGSIO3 not in {d for d, _ in _fetched_files(mock_file)} + assert 'no handler for component' not in caplog.text + download_zalmoxis_eos('WolfBower2018:MgSiO3', core_eos='PALEOS:iron', anchor_pair=True) + assert EOS_PALEOS_MGSIO3 in {d for d, _ in _fetched_files(mock_file)} + + @pytest.mark.unit @patch('proteus.data.fetch_dataset_file') @patch('proteus.data.fetch_dataset') From ffd4feba350efe439e3a24ae15077e845f785374 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 18:20:51 +0200 Subject: [PATCH 57/76] Match the EOS component check to the Zalmoxis parser A component must be ':' with at most one fraction token, a non-numeric material, a finite non-negative fraction and no spaces, which is how Zalmoxis splits it; a second fraction or an empty or stray token no longer passes the load check while Zalmoxis reads a different key. `proteus get interiordata` fetches the MgSiO3 2-phase pair for adiabatic_from_cmb as well, since its entropy fallback reads the pair for any mantle. A missing stellar spectrum names the one catalogue that holds the star: `proteus get solar` for the Sun, else `proteus get muscles --star` with the catalogue id. A missing spectral file of an undeclared group and band count says so. --- src/proteus/atmos_clim/common.py | 2 +- src/proteus/config/_struct.py | 24 ++++++++++++------------ src/proteus/star/wrapper.py | 17 +++++++++++------ src/proteus/utils/data.py | 6 +++--- tests/atmos_clim/test_common.py | 10 +++++++--- tests/config/test_config.py | 2 +- tests/config/test_struct.py | 16 ++++++++++++---- tests/star/test_stellar_spectra.py | 22 +++++++++++++--------- tests/utils/test_data.py | 5 +++-- 9 files changed, 63 insertions(+), 41 deletions(-) diff --git a/src/proteus/atmos_clim/common.py b/src/proteus/atmos_clim/common.py index 5a82cff5f..7dfa0b5fb 100644 --- a/src/proteus/atmos_clim/common.py +++ b/src/proteus/atmos_clim/common.py @@ -380,7 +380,7 @@ def require_spfile_path(dirs: dict, config: Config) -> str: fix = ( 'Fetch it with `proteus get spectral`.' if f'{group}/{bands}' in SPECTRAL_FILE_FOLDERS - else 'No manifest declares this group, so place the file there.' + else 'No manifest declares this group and band count, so place the file there.' ) raise FileNotFoundError( f"Spectral file does not exist at '{path}'. {fix} {RELOCATE_HINT}" diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index 2d79fbf98..294fff7e2 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math from typing import Optional from attrs import define, field @@ -43,10 +44,8 @@ def valid_zalmoxis(instance, attribute, value): f"`interior_struct.zalmoxis.ice_layer_eos` must be 'none' or ':' format, " f"got '{ice_layer_eos}'" ) - # A component is ':' plus non-negative finite fractions, with no inner - # space; a last token that still parses as a number after stripping is nan or inf. - from proteus.utils.helper import _strip_fraction_tokens + # A component is ':' with at most one fraction, as Zalmoxis parses it. def _number(token): try: return float(token) @@ -59,19 +58,20 @@ def _number(token): ('ice_layer_eos', ice_layer_eos or ''), ]: for comp in filter(None, (c.strip() for c in eos_val.split('+'))): - key = _strip_fraction_tokens(comp) - source, _, material = key.partition(':') - fractions = [_number(t) for t in comp[len(key) :].split(':')[1:]] + tokens = comp.split(':') + fraction = _number(tokens[2]) if len(tokens) == 3 else 1.0 if ( - not source - or not material - or any(ch.isspace() for ch in key) - or _number(key.rsplit(':', 1)[-1]) is not None - or any(f < 0 for f in fractions) + len(tokens) not in (2, 3) + or not all(tokens[:2]) + or _number(tokens[1]) is not None + or any(ch.isspace() for ch in comp) + or fraction is None + or not 0 <= fraction < math.inf ): raise ValueError( f"`interior_struct.zalmoxis.{name}` component '{comp}' is not " - "':' with non-negative finite fractions and no spaces" + "':[:]' with a non-negative finite fraction " + 'and no spaces' ) # WolfBower2018 EOS is limited to 1 TPa. For planets > 2 M_earth, diff --git a/src/proteus/star/wrapper.py b/src/proteus/star/wrapper.py index 9f3bdc61a..1f67227be 100644 --- a/src/proteus/star/wrapper.py +++ b/src/proteus/star/wrapper.py @@ -102,6 +102,11 @@ def init_star(handler: Proteus): solar_path = os.path.join(solar_dir, star_file) src = mors_cfg.spectrum_source + fetch = ( + '`proteus get solar`' + if solar_key in solar_map + else f'`proteus get muscles --star {star_id or ""}`' + ) # spectrum_source = None -> try MUSCLES, then solar if src is None: @@ -119,7 +124,7 @@ def init_star(handler: Proteus): UpdateStatusfile(handler.directories, 23) raise FileNotFoundError( f"No solar or MUSCLES spectrum found in reference data for '{mors_cfg.star_name}'. " - f'Fetch them with `proteus get solar` and `proteus get muscles --star {star_id}`. {RELOCATE_HINT}' + f'Fetch it with {fetch}. {RELOCATE_HINT}' ) # spectrum_source = 'solar' @@ -144,8 +149,8 @@ def init_star(handler: Proteus): ) UpdateStatusfile(handler.directories, 23) raise FileNotFoundError( - f"No solar or MUSCLES spectrum for '{mors_cfg.star_name}'. Fetch them with " - f'`proteus get solar` and `proteus get muscles --star {star_id}`. {RELOCATE_HINT}' + f"No solar or MUSCLES spectrum for '{mors_cfg.star_name}'. " + f'Fetch it with {fetch}. {RELOCATE_HINT}' ) # spectrum_source = 'muscles' @@ -171,7 +176,7 @@ def init_star(handler: Proteus): 'Check the available MUSCLES spectra at https://proteus-framework.org/proteus/data.html#stellar-spectra' ) log.error( - f'If available, MUSCLES spectra can be downloaded via the command line: proteus get muscles --star {mors_cfg.star_name}' + f'If available, MUSCLES spectra can be downloaded via the command line: proteus get muscles --star {star_id}' ) log.error('To download all MUSCLES spectra: proteus get muscles --all') log.error( @@ -179,8 +184,8 @@ def init_star(handler: Proteus): ) UpdateStatusfile(handler.directories, 23) raise FileNotFoundError( - f"No MUSCLES spectrum for '{mors_cfg.star_name}'. Fetch it with " - f'`proteus get muscles --star {star_id}`. {RELOCATE_HINT}' + f"No MUSCLES spectrum for '{mors_cfg.star_name}'. " + f'Fetch it with {fetch}. {RELOCATE_HINT}' ) # spectrum_source = 'phoenix' diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index acd7f811f..0964d8650 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -1789,7 +1789,7 @@ def download_zalmoxis_eos_for_config(config) -> None: if getattr(zconf, 'dry_mantle', True) else '+'.join(VOLATILE_EOS_MAP.values()), anchor_pair=getattr(getattr(config, 'planet', None), 'temperature_mode', None) - == 'liquidus_super', + in ('liquidus_super', 'adiabatic_from_cmb'), ) @@ -2054,7 +2054,7 @@ def download_zalmoxis_eos( ``+``-joined EOS components of dissolved volatiles, or empty. anchor_pair : bool Also fetch the MgSiO3 2-phase pair for any mantle, which the - liquidus_super initial adiabat reads. + liquidus_super initial adiabat and the adiabatic_from_cmb entropy fallback read. """ from proteus.data import ( EOS_CHABRIER_2021, @@ -2098,7 +2098,7 @@ def fetch_files(key, names): ): attempt('the Seager 2007 tables', download_eos_static) - # A PALEOS mantle, and any mantle with the liquidus_super anchor, reads the 2-phase pair. + # A PALEOS mantle, and any mantle with a CMB or liquidus anchor, reads the 2-phase pair. components.update(paleos_companion_keys(mantle_eos)) if anchor_pair: components.add(twophase_registry_key(mantle_eos)) diff --git a/tests/atmos_clim/test_common.py b/tests/atmos_clim/test_common.py index 3617bf08f..14878fcd3 100644 --- a/tests/atmos_clim/test_common.py +++ b/tests/atmos_clim/test_common.py @@ -649,10 +649,12 @@ def test_get_spfile_path_reads_an_undeclared_group_from_the_local_tree(tmp_path, @pytest.mark.unit -@pytest.mark.parametrize('group, bands', [('MyGroup', '48'), ('Dayspring', '48')]) +@pytest.mark.parametrize( + 'group, bands', [('MyGroup', '48'), ('Dayspring', '64'), ('Dayspring', '48')] +) def test_require_spfile_path_stops_with_status_20_and_the_fetch_command(tmp_path, group, bands): """A missing spectral file writes status 20 and names `fwl-io relocate` and, for a - declared group, `proteus get spectral`; a present one is returned.""" + declared group and band count, `proteus get spectral`; a present one is returned.""" from proteus.atmos_clim.common import require_spfile_path conf = MagicMock() @@ -660,7 +662,9 @@ def test_require_spfile_path_stops_with_status_20_and_the_fetch_command(tmp_path conf.atmos_clim.spectral_bands = bands dirs = {'fwl': str(tmp_path), 'output': str(tmp_path / 'out')} fix = ( - '`proteus get spectral`' if group == 'Dayspring' else 'No manifest declares this group' + '`proteus get spectral`' + if bands == '48' and group == 'Dayspring' + else 'No manifest declares this group and band count' ) with ( patch('proteus.atmos_clim.common.UpdateStatusfile') as status, diff --git a/tests/config/test_config.py b/tests/config/test_config.py index fb090c841..f5925db32 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -2945,7 +2945,7 @@ def test_energetics_need_a_mantle_eos_with_energetics_tables(energetics, mantle, energetics_needs_a_thermal_mantle_eos(instance, None, value) else: assert energetics_needs_a_thermal_mantle_eos(instance, None, value) is None - # A Zalmoxis structure without its section is checked by the structure itself. + # A structure without a Zalmoxis section has no mantle EOS to check. struct.zalmoxis = None assert energetics_needs_a_thermal_mantle_eos(instance, None, value) is None diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index fd78562ad..d9571aea0 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -321,17 +321,25 @@ def test_two_mgsio3_sources_are_rejected_at_load(mantle, rejected): ('PALEOS:MgSiO3:0.9+:H2O:0.1', True), ('PALEOS:MgSiO3:0.9+PALEOS::0.1', True), ('PALEOS:MgSiO3:1.1+PALEOS:H2O:-0.1', True), + ('PALEOS:MgSiO3:', True), + ('PALEOS::MgSiO3', True), + ('PALEOS:MgSiO3:foo', True), + ('PALEOS:MgSiO3:bar:0.5', True), + ('PALEOS:MgSiO3:0.5:0.5', True), + ('PALEOS:MgSiO3: 0.9', True), + ('PALEOS:nan', True), ('PALEOS:MgSiO3:0.9 + PALEOS:H2O:0.1', False), + ('PALEOS:MgSiO3', False), ], ) def test_an_eos_component_with_a_space_or_a_non_finite_fraction_is_rejected(mantle, rejected): - """A component without a source or material, with an inner space, or with a negative, - nan or inf fraction names no registry key or no valid mass fraction, so it is rejected - at load. Spaces around '+' are fine.""" + """A component that is not ':' with at most one non-negative finite + fraction, or that has an inner space, names no registry key or no valid mass fraction for + Zalmoxis, so it is rejected at load. Spaces around '+' are fine.""" kwargs = dict(module='zalmoxis', zalmoxis=Zalmoxis(mantle_eos=mantle)) if rejected: with pytest.raises( - ValueError, match='with non-negative finite fractions and no spaces' + ValueError, match='with a non-negative finite fraction and no spaces' ): Struct(**kwargs) else: diff --git a/tests/star/test_stellar_spectra.py b/tests/star/test_stellar_spectra.py index 34bae4818..f36a2ba04 100644 --- a/tests/star/test_stellar_spectra.py +++ b/tests/star/test_stellar_spectra.py @@ -442,24 +442,28 @@ def test_init_star_source_muscles_falls_back_to_solar_with_warning( @pytest.mark.unit @pytest.mark.parametrize( - 'source, command', + 'source, star, command', [ - (None, 'proteus get solar` and `proteus get muscles --star gj876'), - ('solar', 'proteus get solar` and `proteus get muscles --star gj876'), - ('muscles', 'proteus get muscles --star gj876'), + (None, 'gj 876', 'proteus get muscles --star gj876'), + ('solar', 'gj 876', 'proteus get muscles --star gj876'), + ('muscles', 'gj 876', 'proteus get muscles --star gj876'), + ('solar', 'Sun', 'proteus get solar'), + ('muscles', '', 'proteus get muscles --star '), ], ) -def test_init_star_source_none_missing_both_raises(tmp_path, monkeypatch, source, command): +def test_init_star_source_none_missing_both_raises( + tmp_path, monkeypatch, source, star, command +): """With neither solar nor MUSCLES on disk, ``init_star`` raises FileNotFoundError - naming the download command with the catalogue star id and ``fwl-io relocate``, rather - than producing a silent zero-flux spectrum. + naming the one catalogue that holds the star, with its id, and ``fwl-io relocate``, + rather than producing a silent zero-flux spectrum. """ from proteus.star.wrapper import init_star _install_fake_mors(monkeypatch) - handler = _make_handler_for_init_star(tmp_path, spectrum_source=source) + handler = _make_handler_for_init_star(tmp_path, spectrum_source=source, star_name=star) - with pytest.raises(FileNotFoundError, match=f'`{command}.*`fwl-io relocate`'): + with pytest.raises(FileNotFoundError, match=f'with `{command}`. .*`fwl-io relocate`'): init_star(handler) # Discrimination: the raise must fire BEFORE any backup spectrum is diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 7b6963deb..7dbabf482 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3525,13 +3525,14 @@ def test_download_zalmoxis_eos_strips_spaces_in_a_mixture(mock_static, mock_fetc (True, '', 'adiabatic', False), (False, 'PALEOS:H2O+Chabrier:H', 'adiabatic', False), (True, '', 'liquidus_super', True), + (True, '', 'adiabatic_from_cmb', True), ], ) def test_download_zalmoxis_eos_for_config_adds_the_volatiles_of_a_wet_mantle( monkeypatch, dry, volatiles, mode, pair ): - """With dry_mantle = false the fetch includes the dissolved-volatile EOS, and with - liquidus_super the 2-phase pair the anchor reads.""" + """With dry_mantle = false the fetch includes the dissolved-volatile EOS, and with a + CMB or liquidus anchor the 2-phase pair it reads.""" from types import SimpleNamespace from proteus.utils import data as dmod From 06906f8299df584bc8e5091b73f43ea2e0907e73 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 18:39:59 +0200 Subject: [PATCH 58/76] Reject empty and numeric EOS tokens and trim the fetch hints The component check now also rejects an empty component from a stray '+' and a numeric source token, and it runs for the core and ice layers the same way as for the mantle. A missing MUSCLES spectrum no longer logs a second command that disagrees with the one in the error. A missing spectral file of an undeclared group and band count leaves out the relocate hint, which only moves files a manifest declares. --- src/proteus/atmos_clim/common.py | 6 ++---- src/proteus/config/_struct.py | 14 +++++++------- src/proteus/star/wrapper.py | 3 --- tests/atmos_clim/test_common.py | 6 +++--- tests/config/test_struct.py | 10 ++++++++++ tests/star/test_stellar_spectra.py | 3 ++- tests/utils/test_data.py | 4 ++-- 7 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/proteus/atmos_clim/common.py b/src/proteus/atmos_clim/common.py index 7dfa0b5fb..8a028367f 100644 --- a/src/proteus/atmos_clim/common.py +++ b/src/proteus/atmos_clim/common.py @@ -378,13 +378,11 @@ def require_spfile_path(dirs: dict, config: Config) -> str: UpdateStatusfile(dirs, 20) group, bands = get_spfile_name_and_bands(config) fix = ( - 'Fetch it with `proteus get spectral`.' + f'Fetch it with `proteus get spectral`. {RELOCATE_HINT}' if f'{group}/{bands}' in SPECTRAL_FILE_FOLDERS else 'No manifest declares this group and band count, so place the file there.' ) - raise FileNotFoundError( - f"Spectral file does not exist at '{path}'. {fix} {RELOCATE_HINT}" - ) + raise FileNotFoundError(f"Spectral file does not exist at '{path}'. {fix}") return path diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index 294fff7e2..c81d5e1f2 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -1,6 +1,5 @@ from __future__ import annotations -import math from typing import Optional from attrs import define, field @@ -52,21 +51,22 @@ def _number(token): except ValueError: return None - for name, eos_val in [ + layers = [ ('core_eos', core_eos), ('mantle_eos', mantle_eos), - ('ice_layer_eos', ice_layer_eos or ''), - ]: - for comp in filter(None, (c.strip() for c in eos_val.split('+'))): + ('ice_layer_eos', ice_layer_eos), + ] + for name, eos_val in (layer for layer in layers if layer[1]): + for comp in (c.strip() for c in eos_val.split('+')): tokens = comp.split(':') fraction = _number(tokens[2]) if len(tokens) == 3 else 1.0 if ( len(tokens) not in (2, 3) or not all(tokens[:2]) - or _number(tokens[1]) is not None + or any(_number(t) is not None for t in tokens[:2]) or any(ch.isspace() for ch in comp) or fraction is None - or not 0 <= fraction < math.inf + or not 0 <= fraction < float('inf') ): raise ValueError( f"`interior_struct.zalmoxis.{name}` component '{comp}' is not " diff --git a/src/proteus/star/wrapper.py b/src/proteus/star/wrapper.py index 1f67227be..6537bed7c 100644 --- a/src/proteus/star/wrapper.py +++ b/src/proteus/star/wrapper.py @@ -175,9 +175,6 @@ def init_star(handler: Proteus): log.error( 'Check the available MUSCLES spectra at https://proteus-framework.org/proteus/data.html#stellar-spectra' ) - log.error( - f'If available, MUSCLES spectra can be downloaded via the command line: proteus get muscles --star {star_id}' - ) log.error('To download all MUSCLES spectra: proteus get muscles --all') log.error( "If no observed spectrum is available, consider using a PHOENIX synthetic spectrum by setting star.mors.spectrum_source = 'phoenix'." diff --git a/tests/atmos_clim/test_common.py b/tests/atmos_clim/test_common.py index 14878fcd3..9250bb9b3 100644 --- a/tests/atmos_clim/test_common.py +++ b/tests/atmos_clim/test_common.py @@ -653,8 +653,8 @@ def test_get_spfile_path_reads_an_undeclared_group_from_the_local_tree(tmp_path, 'group, bands', [('MyGroup', '48'), ('Dayspring', '64'), ('Dayspring', '48')] ) def test_require_spfile_path_stops_with_status_20_and_the_fetch_command(tmp_path, group, bands): - """A missing spectral file writes status 20 and names `fwl-io relocate` and, for a - declared group and band count, `proteus get spectral`; a present one is returned.""" + """A missing spectral file writes status 20 and, for a declared group and band count, + names `proteus get spectral` and `fwl-io relocate`; a present one is returned.""" from proteus.atmos_clim.common import require_spfile_path conf = MagicMock() @@ -672,7 +672,7 @@ def test_require_spfile_path_stops_with_status_20_and_the_fetch_command(tmp_path ): require_spfile_path(dirs, conf) status.assert_called_once_with(dirs, 20) - assert '`fwl-io relocate`' in str(exc.value) + assert ('`fwl-io relocate`' in str(exc.value)) == (fix == '`proteus get spectral`') path = Path(get_spfile_path(str(tmp_path), conf)) path.parent.mkdir(parents=True) path.write_text('sf') diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index d9571aea0..f37eff758 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -328,6 +328,9 @@ def test_two_mgsio3_sources_are_rejected_at_load(mantle, rejected): ('PALEOS:MgSiO3:0.5:0.5', True), ('PALEOS:MgSiO3: 0.9', True), ('PALEOS:nan', True), + ('0.5:MgSiO3', True), + ('PALEOS:MgSiO3:0.9+', True), + ('PALEOS:MgSiO3:0.9++PALEOS:H2O:0.1', True), ('PALEOS:MgSiO3:0.9 + PALEOS:H2O:0.1', False), ('PALEOS:MgSiO3', False), ], @@ -346,6 +349,13 @@ def test_an_eos_component_with_a_space_or_a_non_finite_fraction_is_rejected(mant assert Struct(**kwargs).zalmoxis.mantle_eos == mantle +@pytest.mark.parametrize('layer', ['core_eos', 'ice_layer_eos']) +def test_a_core_or_ice_component_is_checked_like_a_mantle_component(layer): + """The core and ice layer EOS components follow the same format rule as the mantle.""" + with pytest.raises(ValueError, match=f'`interior_struct.zalmoxis.{layer}` component'): + Struct(module='zalmoxis', zalmoxis=Zalmoxis(**{layer: 'PALEOS:H2O:foo'})) + + def test_zalmoxis_structure_without_its_section_loads(): """The MgSiO3-source check needs the Zalmoxis section and skips when it is absent.""" assert Struct(module='zalmoxis', zalmoxis=None).zalmoxis is None diff --git a/tests/star/test_stellar_spectra.py b/tests/star/test_stellar_spectra.py index f36a2ba04..f161c47f2 100644 --- a/tests/star/test_stellar_spectra.py +++ b/tests/star/test_stellar_spectra.py @@ -452,7 +452,7 @@ def test_init_star_source_muscles_falls_back_to_solar_with_warning( ], ) def test_init_star_source_none_missing_both_raises( - tmp_path, monkeypatch, source, star, command + tmp_path, monkeypatch, caplog, source, star, command ): """With neither solar nor MUSCLES on disk, ``init_star`` raises FileNotFoundError naming the one catalogue that holds the star, with its id, and ``fwl-io relocate``, @@ -465,6 +465,7 @@ def test_init_star_source_none_missing_both_raises( with pytest.raises(FileNotFoundError, match=f'with `{command}`. .*`fwl-io relocate`'): init_star(handler) + assert all(command in s for s in caplog.messages if 'muscles --star' in s) # Discrimination: the raise must fire BEFORE any backup spectrum is # written. A regression that silently produced a zero-flux fallback diff --git a/tests/utils/test_data.py b/tests/utils/test_data.py index 7dbabf482..c9fd7c422 100644 --- a/tests/utils/test_data.py +++ b/tests/utils/test_data.py @@ -3566,8 +3566,8 @@ def test_download_zalmoxis_eos_for_config_adds_the_volatiles_of_a_wet_mantle( def test_download_zalmoxis_eos_fetches_the_anchor_pair_for_a_wolf_bower_mantle( mock_static, mock_fetch, mock_file, caplog ): - """A Wolf and Bower mantle fetches the MgSiO3 2-phase pair only for the liquidus_super - anchor, and its component raises no unknown-family warning.""" + """A Wolf and Bower mantle fetches the MgSiO3 2-phase pair only for an anchored + temperature mode, and its component raises no unknown-family warning.""" from proteus.data import EOS_PALEOS_MGSIO3 from proteus.utils.data import download_zalmoxis_eos From 16ee963ed0eb82fe64cbbfac4fe3e1733ceeb430 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 19:32:49 +0200 Subject: [PATCH 59/76] Write status 20 wherever a missing EOS table stops the run A missing Zalmoxis EOS table can stop a run after the start check, for example in the CMB entropy fallback. One handler on start() now writes status 20 for every such stop, so the run does not read as still running, and the start check no longer needs its own. --- src/proteus/proteus.py | 29 ++++++++++++++++++++--------- tests/test_proteus.py | 41 ++++++++++++++++++++++++++++------------- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index f678c0b2d..4283fb2f6 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -1,5 +1,6 @@ from __future__ import annotations +import functools import logging import os import sys @@ -60,6 +61,22 @@ AGNI_DEADLOCK_MAX = 3 +def _status_on_missing_eos(start): + """Write status 20 when a missing Zalmoxis EOS table stops ``start``, wherever it is raised.""" + + @functools.wraps(start) + def wrapper(self, *args, **kwargs): + try: + return start(self, *args, **kwargs) + except RuntimeError as exc: + zalmoxis = sys.modules.get('proteus.interior_struct.zalmoxis') + if zalmoxis and isinstance(exc, zalmoxis.ZalmoxisMissingEOSFilesError): + UpdateStatusfile(self.directories, 20) + raise + + return wrapper + + class Proteus: def __init__(self, *, config_path: Path | str) -> None: # Read and parse configuration file. Keys the schema cannot accept are @@ -363,6 +380,7 @@ def _solve_structure_baseline_if_needed(self): self.last_struct_Phi = new_Phi self._baseline_structure_done = True + @_status_on_missing_eos def start(self, *, resume: bool = False, offline: bool = False): """Start PROTEUS simulation. @@ -1453,16 +1471,9 @@ def start(self, *, resume: bool = False, offline: bool = False): def _require_paleos_tables(self): """Stop before any solve when a table of the Zalmoxis EOS set is missing.""" if self.config.interior_struct.module == 'zalmoxis': - from proteus.interior_struct.zalmoxis import ( - ZalmoxisMissingEOSFilesError, - require_paleos_tables, - ) + from proteus.interior_struct.zalmoxis import require_paleos_tables - try: - require_paleos_tables(self.config, self.directories['output']) - except ZalmoxisMissingEOSFilesError: - UpdateStatusfile(self.directories, 20) - raise + require_paleos_tables(self.config, self.directories['output']) def extract_archives(self): """ diff --git a/tests/test_proteus.py b/tests/test_proteus.py index a5d370b94..acada80d2 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -273,25 +273,40 @@ def test_require_paleos_tables_runs_only_for_the_zalmoxis_structure( ): """Only a Zalmoxis structure reads the Zalmoxis EOS tables, so only it is checked, with the run's output directory.""" - from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError - p = _make_proteus_instance(tmp_path, struct_module=struct_module) with patch('proteus.interior_struct.zalmoxis.require_paleos_tables') as require: p._require_paleos_tables() assert require.call_count == calls if calls: require.assert_called_once_with(p.config, str(tmp_path)) - # A stop records the error status, so the run does not read as still running. - with ( - patch( - 'proteus.interior_struct.zalmoxis.require_paleos_tables', - side_effect=ZalmoxisMissingEOSFilesError('missing'), - ), - patch('proteus.proteus.UpdateStatusfile') as status, - pytest.raises(ZalmoxisMissingEOSFilesError), - ): - p._require_paleos_tables() - status.assert_called_once_with(p.directories, 20) + + +@pytest.mark.unit +@pytest.mark.parametrize('error', ['missing', 'other']) +def test_a_missing_eos_table_anywhere_in_the_run_writes_status_20(tmp_path, error): + """A missing Zalmoxis EOS table raised mid-run, here from the structure solve, leaves + status 20, so the run does not read as still running; other errors leave it as is.""" + from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + + p = _make_proteus_instance(tmp_path) + p.directories.update( + {k: str(tmp_path / k) for k in ('output/observe', 'output/offchem', 'output/plots')} + ) + p.config.interior_energetics.flux_guess = 100.0 + p.config.star.age_ini = 0.1 + p._require_paleos_tables = MagicMock() + exc = ZalmoxisMissingEOSFilesError('pair') if error == 'missing' else RuntimeError('x') + with ExitStack() as stack: + for target in _START_PATCHES: + stack.enter_context(patch(target)) + stack.enter_context(patch('proteus.proteus.CleanDir')) + stack.enter_context( + patch('proteus.interior_energetics.wrapper.solve_structure', side_effect=exc) + ) + with pytest.raises(RuntimeError): + p.start(resume=False, offline=True) + status = (tmp_path / 'status').read_text().splitlines()[0] + assert status == ('20' if error == 'missing' else '0') @pytest.mark.unit From 589a74e585e46bbe75f7fff45678b329e211a6a9 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 19:32:51 +0200 Subject: [PATCH 60/76] Stop the start check at once when paleos is missing Without the paleos package the PALEOS-API resolver's worker pool restarts forever, so the start check hung. It now checks for the package first. PALEOS-API tables that are not built get their own lines in the error, since `proteus get` does not fetch them: install paleos or choose a PALEOS or PALEOS-2phase EOS. --- src/proteus/interior_struct/zalmoxis.py | 26 ++++++++++++++++-- tests/interior_struct/test_zalmoxis.py | 35 +++++++++++++++++++------ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 2148bfcdf..7d45e81c6 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -3,6 +3,7 @@ import functools import hashlib +import importlib.util import logging import os import re @@ -1798,6 +1799,11 @@ class ZalmoxisMissingEOSFilesError(RuntimeError): """A layer's configured EOS identifier names a table file absent on disk.""" +def _paleos_installed() -> bool: + """Whether the paleos package, which builds the PALEOS-API tables, is importable.""" + return importlib.util.find_spec('paleos') is not None + + def check_zalmoxis_eos_files( layer_eos_config: dict, mat_dicts: dict, paleos_companions: bool = False ) -> None: @@ -1836,18 +1842,23 @@ def check_zalmoxis_eos_files( companions = paleos_companion_keys(layer_eos_config.get('mantle', '')) selected += [('mantle', key) for key in companions] missing: set[str] = set() + unbuilt: set[str] = set() for layer_role, key in selected: entry = mat_dicts.get(key) if entry is None: # Unknown identifiers fail later with a registry error. continue if _is_paleos_api(entry): + # Without paleos the resolver's worker pool restarts forever instead of failing. + if not _paleos_installed(): + unbuilt.add(f'{key} (the paleos package is not installed)') + continue try: from zalmoxis.eos.paleos_api_cache import resolve_registry_entry resolve_registry_entry(entry) except Exception as exc: - missing.add(f'{key} (PALEOS-API tables not built: {type(exc).__name__}: {exc})') + unbuilt.add(f'{key} ({type(exc).__name__}: {exc})') continue # Nested entries map layer roles to flat entries; their 'core' sub-entry # counts only for the core role or when it is the only sub-entry. @@ -1867,14 +1878,25 @@ def check_zalmoxis_eos_files( path = sub.get(field) if path and not os.path.isfile(path): missing.add(path) + parts = [] if missing: listing = '\n '.join(sorted(missing)) - raise ZalmoxisMissingEOSFilesError( + parts.append( f'Interior EOS table file(s) not found:\n {listing}\n' 'Download them with ' '`proteus get interiordata --config-path `, ' f'or run `proteus start` once without --offline. {RELOCATE_HINT}' ) + if unbuilt: + listing = '\n '.join(sorted(unbuilt)) + parts.append( + f'PALEOS-API tables not built:\n {listing}\n' + 'They are generated at start from the paleos package ' + '(https://github.com/maraattia/PALEOS), not downloaded: install it in this ' + 'environment or choose a PALEOS or PALEOS-2phase EOS.' + ) + if parts: + raise ZalmoxisMissingEOSFilesError('\n'.join(parts)) def energetics_entry(mantle_eos: str, mat_dicts: dict) -> tuple[str | None, dict]: diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 3d9446e5c..f974d1300 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -878,11 +878,14 @@ def test_check_eos_files_needs_the_mgsio3_unified_table_only_for_an_mgsio3_layer def test_check_eos_files_stops_when_the_paleos_api_resolver_is_missing(tmp_path, monkeypatch): - """A PALEOS-API layer table or companion that cannot be materialised stops the - run; a resolver that writes the tables lets it pass.""" + """A PALEOS-API layer table or companion that cannot be materialised stops the run + with its own line, since `proteus get` does not fetch it: without the paleos package + the resolver is not called, whose worker pool would restart forever. A resolver that + writes the tables lets it pass.""" import sys import types + from proteus.interior_struct import zalmoxis as zmod from proteus.interior_struct.zalmoxis import ( ZalmoxisMissingEOSFilesError, check_zalmoxis_eos_files, @@ -892,15 +895,25 @@ def test_check_eos_files_stops_when_the_paleos_api_resolver_is_missing(tmp_path, pair = {r: {'format': 'paleos_api_2phase'} for r in ('solid_mantle', 'melted_mantle')} registry = {'PALEOS-API:MgSiO3': api, 'PALEOS-API-2phase:MgSiO3': pair} layers = {'mantle': 'PALEOS-API:MgSiO3'} + hang = types.ModuleType('zalmoxis.eos.paleos_api_cache') + hang.resolve_registry_entry = MagicMock(side_effect=AssertionError('resolver called')) + monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', hang) + monkeypatch.setattr(zmod, '_paleos_installed', lambda: False) + with pytest.raises(ZalmoxisMissingEOSFilesError) as excinfo: + check_zalmoxis_eos_files(layers, registry, paleos_companions=True) + msg = str(excinfo.value) + assert msg.startswith('PALEOS-API tables not built:') + for key in ('PALEOS-API:MgSiO3', 'PALEOS-API-2phase:MgSiO3'): + assert f'{key} (the paleos package is not installed)' in msg + assert 'proteus get' not in msg + hang.resolve_registry_entry.assert_not_called() + + monkeypatch.setattr(zmod, '_paleos_installed', lambda: True) monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', None) with pytest.raises(ZalmoxisMissingEOSFilesError) as excinfo: check_zalmoxis_eos_files(layers, registry, paleos_companions=True) - assert 'PALEOS-API-2phase:MgSiO3 (PALEOS-API tables not built: ModuleNotFoundError' in str( - excinfo.value - ) - assert 'PALEOS-API:MgSiO3 (PALEOS-API tables not built: ModuleNotFoundError' in str( - excinfo.value - ) + assert 'PALEOS-API-2phase:MgSiO3 (ModuleNotFoundError' in str(excinfo.value) + assert 'PALEOS-API:MgSiO3 (ModuleNotFoundError' in str(excinfo.value) table = tmp_path / 'api.dat' table.write_text('eos table stub') @@ -1046,6 +1059,7 @@ def test_check_eos_files_stops_when_the_paleos_api_build_fails(monkeypatch): import sys import types + from proteus.interior_struct import zalmoxis as zmod from proteus.interior_struct.zalmoxis import ( ZalmoxisMissingEOSFilesError, check_zalmoxis_eos_files, @@ -1054,6 +1068,7 @@ def test_check_eos_files_stops_when_the_paleos_api_build_fails(monkeypatch): def _resolve(entry): raise RuntimeError('grid build failed') + monkeypatch.setattr(zmod, '_paleos_installed', lambda: True) fake = types.ModuleType('zalmoxis.eos.paleos_api_cache') fake.resolve_registry_entry = _resolve monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', fake) @@ -1332,6 +1347,9 @@ def test_check_eos_files_walks_nested_and_lazy_siblings(tmp_path, monkeypatch): fake = types.ModuleType('zalmoxis.eos.paleos_api_cache') fake.resolve_registry_entry = lambda entry: None monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', fake) + monkeypatch.setattr( + 'proteus.interior_struct.zalmoxis._paleos_installed', lambda: True, raising=True + ) # The missing file-backed sibling is flagged; the lazy sibling is not. with pytest.raises(RuntimeError) as excinfo: @@ -3733,6 +3751,7 @@ def test_resume_keeps_tables_without_resolving_a_paleos_api_eos(tmp_path, monkey _, _, _, key = _generate_tables_stubbed(tmp_path, monkeypatch, resume=True, run=False) _seed_tables(tmp_path / 'run' / 'data' / 'spider_eos', key) monkeypatch.setattr(zalmoxis.eos.dispatch, '_is_paleos_api', lambda entry: True) + monkeypatch.setattr(zmod, '_paleos_installed', lambda: True) resolve = MagicMock(side_effect=OSError('offline')) monkeypatch.setattr(zalmoxis.eos.paleos_api_cache, 'resolve_registry_entry', resolve) with caplog.at_level('WARNING', logger='fwl.proteus.interior_struct.zalmoxis'): From c80bfbad41158a8d8a0581603961d672a82db056 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 19:33:50 +0200 Subject: [PATCH 61/76] Reject a mixture whose fractions sum to zero Zalmoxis divides the fractions of a mixture by their sum, so a mixture such as PALEOS:MgSiO3:0+PALEOS:H2O:0 loaded and then failed on a division by zero. The EOS check rejects it at config load. --- src/proteus/config/_struct.py | 8 +++++++- tests/config/test_struct.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index c81d5e1f2..3ca7d4a9a 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -57,7 +57,9 @@ def _number(token): ('ice_layer_eos', ice_layer_eos), ] for name, eos_val in (layer for layer in layers if layer[1]): - for comp in (c.strip() for c in eos_val.split('+')): + comps = [c.strip() for c in eos_val.split('+')] + total = 0.0 + for comp in comps: tokens = comp.split(':') fraction = _number(tokens[2]) if len(tokens) == 3 else 1.0 if ( @@ -73,6 +75,10 @@ def _number(token): "':[:]' with a non-negative finite fraction " 'and no spaces' ) + total += fraction + # Zalmoxis divides a mixture's fractions by their sum. + if len(comps) > 1 and total == 0: + raise ValueError(f'`interior_struct.zalmoxis.{name}` fractions sum to zero') # WolfBower2018 EOS is limited to 1 TPa. For planets > 2 M_earth, # CMB pressure exceeds this and Zalmoxis will fail to converge. diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index f37eff758..c8d41fd23 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -332,6 +332,8 @@ def test_two_mgsio3_sources_are_rejected_at_load(mantle, rejected): ('PALEOS:MgSiO3:0.9+', True), ('PALEOS:MgSiO3:0.9++PALEOS:H2O:0.1', True), ('PALEOS:MgSiO3:0.9 + PALEOS:H2O:0.1', False), + ('PALEOS:MgSiO3:0', False), + ('PALEOS:MgSiO3:0+PALEOS:H2O:0.1', False), ('PALEOS:MgSiO3', False), ], ) @@ -349,6 +351,13 @@ def test_an_eos_component_with_a_space_or_a_non_finite_fraction_is_rejected(mant assert Struct(**kwargs).zalmoxis.mantle_eos == mantle +def test_a_mixture_whose_fractions_sum_to_zero_is_rejected(): + """Zalmoxis divides a mixture's fractions by their sum, so a zero sum is rejected; a + single component takes the fraction 1 whatever its token says.""" + with pytest.raises(ValueError, match='mantle_eos` fractions sum to zero'): + Struct(module='zalmoxis', zalmoxis=Zalmoxis(mantle_eos='PALEOS:MgSiO3:0+PALEOS:H2O:0')) + + @pytest.mark.parametrize('layer', ['core_eos', 'ice_layer_eos']) def test_a_core_or_ice_component_is_checked_like_a_mantle_component(layer): """The core and ice layer EOS components follow the same format rule as the mantle.""" From 065b33855688820f965277887a53b26fb152b364 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 19:36:27 +0200 Subject: [PATCH 62/76] Resolve each PALEOS-API table once per process The EOS check runs before every structure solve on a freshly built registry, and each PALEOS-API resolve runs git to read the paleos version, about 28 ms per entry. The check now keeps the resolved entries for the rest of the process. --- src/proteus/interior_struct/zalmoxis.py | 9 ++++++++- tests/conftest.py | 13 +++++++++++++ tests/interior_struct/test_zalmoxis.py | 9 ++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 7d45e81c6..142aeb438 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1,6 +1,7 @@ # Zalmoxis interior module from __future__ import annotations +import copy import functools import hashlib import importlib.util @@ -119,6 +120,9 @@ def copy(self): # Traceback-free copies of anchor failures that depend only on the key # (InitialConditionError and wrapped numerical errors), keyed like _SUPERLIQ_CACHE. _SUPERLIQ_FAILED: _LRUDict = _LRUDict() +# PALEOS-API registry entries once resolved, by key: a resolve runs git, and the +# EOS check runs before every structure solve on a fresh registry. +_PALEOS_API_RESOLVED: dict[str, dict] = {} # CMB temperature [K] of the most recently solved super-liquidus adiabat. A # structure solve driven by an external temperature source discards this anchor @@ -1848,7 +1852,9 @@ def check_zalmoxis_eos_files( if entry is None: # Unknown identifiers fail later with a registry error. continue - if _is_paleos_api(entry): + if key in _PALEOS_API_RESOLVED: + entry.update(copy.deepcopy(_PALEOS_API_RESOLVED[key])) + elif _is_paleos_api(entry): # Without paleos the resolver's worker pool restarts forever instead of failing. if not _paleos_installed(): unbuilt.add(f'{key} (the paleos package is not installed)') @@ -1860,6 +1866,7 @@ def check_zalmoxis_eos_files( except Exception as exc: unbuilt.add(f'{key} ({type(exc).__name__}: {exc})') continue + _PALEOS_API_RESOLVED[key] = copy.deepcopy(entry) # Nested entries map layer roles to flat entries; their 'core' sub-entry # counts only for the core role or when it is the only sub-entry. if 'eos_file' in entry: diff --git a/tests/conftest.py b/tests/conftest.py index 7bb679fc5..56f8868ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,6 +62,19 @@ def test_with_earth(earth_params): ) +@pytest.fixture(autouse=True) +def _clear_paleos_api_resolutions(): + """Drop the per-process PALEOS-API resolutions around each test, so a fake resolver + in one test does not hand its paths to another.""" + zal = sys.modules.get('proteus.interior_struct.zalmoxis') + if zal is not None: + zal._PALEOS_API_RESOLVED.clear() + yield + zal = sys.modules.get('proteus.interior_struct.zalmoxis') + if zal is not None: + zal._PALEOS_API_RESOLVED.clear() + + @pytest.fixture(autouse=True) def _clear_atmodeller_model_cache(): """Reset the atmodeller equilibrium-model cache around every test. diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index f974d1300..977937852 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -924,11 +924,18 @@ def _resolve(entry): sub['eos_file'] = str(table) fake = types.ModuleType('zalmoxis.eos.paleos_api_cache') - fake.resolve_registry_entry = _resolve + fake.resolve_registry_entry = MagicMock(side_effect=_resolve) monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', fake) assert check_zalmoxis_eos_files(layers, registry, paleos_companions=True) is None assert pair['solid_mantle']['eos_file'] == str(table) assert api['eos_file'] == str(table) + # A later check on a fresh registry, as before each structure solve, reuses the result. + api2 = {'format': 'paleos_api', 'material': 'mgsio3'} + pair2 = {r: {'format': 'paleos_api_2phase'} for r in ('solid_mantle', 'melted_mantle')} + fresh = {'PALEOS-API:MgSiO3': api2, 'PALEOS-API-2phase:MgSiO3': pair2} + assert check_zalmoxis_eos_files(layers, fresh, paleos_companions=True) is None + assert fake.resolve_registry_entry.call_count == 2 + assert api2['eos_file'] == pair2['melted_mantle']['eos_file'] == str(table) @pytest.mark.parametrize('missing', ['iron', 'solid', 'h2o']) From 3b5490385f1026680996663abcc68facea9eaccf Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 19:36:52 +0200 Subject: [PATCH 63/76] State the status each missing-data stop writes The data page now lists the status code of each missing-data stop and names the two stops without a download command: a spectral file of an undeclared group and band count, and PALEOS-API tables, which are built from the paleos package. --- docs/Reference/data.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/Reference/data.md b/docs/Reference/data.md index 78fff7c3b..aeec84b30 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -54,7 +54,16 @@ A `$FWL_DATA` folder written by an older PROTEUS keeps its files at paths that a 1. Run `fwl-io relocate`. It moves every dataset whose files all match their pinned checksums into its version directory. `fwl-io relocate --dry-run` lists what would move, what is incomplete and what does not match, without moving anything. 2. Run `proteus start --config ` once without `--offline`, which downloads the data that configuration needs, or `proteus get interiordata --config-path ` for the interior tables alone. This downloads what the first step left: incomplete and mismatched datasets, and those it cannot move (the Chabrier archive, the surface albedos, the Seager tables and the Spada tracks). -A run with `--offline` that finds an interior EOS table, a melting curve, a P-S lookup table, a spectral file, a solar, MUSCLES or PHOENIX spectrum or a surface albedo file missing stops with an error that names its download command and ends with: "Data kept in the older FWL_DATA layout can be moved into place with `fwl-io relocate`." +A run with `--offline` that finds an interior EOS table, a melting curve, a P-S lookup table, a spectral file, a solar, MUSCLES or PHOENIX spectrum or a surface albedo file missing stops with an error that names its download command and ends with: "Data kept in the older FWL_DATA layout can be moved into place with `fwl-io relocate`." Two stops have no download command. A spectral file of a group and band count that no manifest declares is not downloaded, so its error says to place the file at the path it names. PALEOS-API tables are built at start from the `paleos` package, so their error names that package. + +The stop writes a [status code](output.md) as follows: + +| Missing data | Status | +| ---------------------------------------------------------------- | ------------------ | +| Zalmoxis EOS tables, including the MgSiO$_3$ two-phase pair | 20, wherever the run stops | +| spectral file, surface albedo file | 20 | +| solar, MUSCLES or PHOENIX spectrum | 23 | +| melting curve, SPIDER or Aragog P-S or lookup table | none of its own | --- From c7f44dcd0298deda32a911cd48e3bb554b896541 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 19:38:35 +0200 Subject: [PATCH 64/76] Propose relocate in doctor only when it would move data A legacy folder with wrong or missing files made the doctor propose `fwl-io relocate`, which then moved nothing, so `proteus update` asked again. The doctor now reads the relocate dry-run plan and proposes relocate only for a legacy folder that holds a dataset it would move; otherwise it proposes the download. --- src/proteus/doctor.py | 11 +++++++++-- tests/test_doctor.py | 23 +++++++++++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/proteus/doctor.py b/src/proteus/doctor.py index 7ac89fd69..a9fd28762 100644 --- a/src/proteus/doctor.py +++ b/src/proteus/doctor.py @@ -345,12 +345,19 @@ def check_fwl_data() -> list[CheckResult]: """Check FWL_DATA contents for required data sets. A data set found only in the older layout is fixed with ``fwl-io relocate``, - which moves it in place, rather than with a new download. + which moves it in place, rather than with a new download, but only when a + relocate dry run would move something there. """ results = [] fwl = os.environ.get('FWL_DATA') if not fwl or not os.path.isdir(fwl): return results + try: + from fwl_io.relocate import plan_relocations + + movable = [Path(e.legacy_dir) for e in plan_relocations(fwl).ready] + except Exception: + movable = [] expected = { 'atmos_clim/spectral_files': ('proteus get spectral', 'spectral_files'), @@ -359,7 +366,7 @@ def check_fwl_data() -> list[CheckResult]: for subdir, (fix, legacy) in expected.items(): path = os.path.join(fwl, subdir) old = os.path.join(fwl, legacy) - if os.path.isdir(old) and os.listdir(old): + if any(p.is_relative_to(old) for p in movable): fix = 'fwl-io relocate' if os.path.isdir(path) and os.listdir(path): results.append( diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 600e7291b..a8e3f3d5a 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -258,13 +258,24 @@ def test_reports_missing_subdirs(self, tmp_path): assert r.status == WARN assert r.fix_cmd is not None - def test_an_older_layout_is_fixed_by_relocate(self, tmp_path): - """A data set found only in the older layout is fixed by moving it, not a download.""" - (tmp_path / 'spectral_files' / 'Dayspring' / '48').mkdir(parents=True) - (tmp_path / 'spectral_files' / 'Dayspring' / '48' / 'Dayspring.sf').touch() - with patch.dict(os.environ, {'FWL_DATA': str(tmp_path)}): + @pytest.mark.parametrize('movable', [True, False]) + def test_an_older_layout_is_fixed_by_relocate(self, tmp_path, movable): + """A data set in the older layout is fixed by moving it only when a relocate dry + run would move it; otherwise, e.g. with wrong checksums, by its download.""" + from types import SimpleNamespace + + legacy = tmp_path / 'spectral_files' / 'Dayspring' / '48' + legacy.mkdir(parents=True) + (legacy / 'Dayspring.sf').touch() + plan = SimpleNamespace(ready=[SimpleNamespace(legacy_dir=legacy)] if movable else []) + with ( + patch.dict(os.environ, {'FWL_DATA': str(tmp_path)}), + patch('fwl_io.relocate.plan_relocations', return_value=plan) as planned, + ): fixes = {r.name: r.fix_cmd for r in check_fwl_data()} - assert fixes['FWL_DATA/atmos_clim/spectral_files'] == 'fwl-io relocate' + planned.assert_called_once_with(str(tmp_path)) + want = 'fwl-io relocate' if movable else 'proteus get spectral' + assert fixes['FWL_DATA/atmos_clim/spectral_files'] == want assert fixes['FWL_DATA/star/spectra'] == 'proteus get stellar' def test_skips_when_fwl_data_unset(self): From 27ebd904a297b6c7bd8ad68a50159dd7e5d168a0 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 19:45:26 +0200 Subject: [PATCH 65/76] Cover the missing-data stops with tests that tell the cases apart AGNI stops with status 20 and the fetch command when the spectral file of its group is missing. The PHOENIX command is checked with distinct non-zero [Fe/H] and alpha, the 2-phase pair error with its relocate hint, the energetics check with another structure module and a NaN fraction at load. The stellar test checks that the error is logged and that no line names another star. The required flag of the pair lookup and the resume rule for liquidus_super are described as they are. --- docs/Reference/config/interior.md | 2 +- src/proteus/config/_config.py | 9 ++++----- src/proteus/interior_struct/zalmoxis.py | 5 +++-- tests/atmos_clim/test_agni.py | 19 +++++++++++++++++++ tests/config/test_config.py | 7 +++++-- tests/config/test_struct.py | 1 + tests/interior_struct/test_zalmoxis.py | 6 ++++-- tests/star/test_stellar_spectra.py | 13 ++++++++----- 8 files changed, 45 insertions(+), 17 deletions(-) diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index e4d3f6405..e582610cc 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -129,7 +129,7 @@ Their phase boundaries follow the same construction as the unified case: the liq A run with a PALEOS EOS requires the tables the chosen EOS reads: the table of each PALEOS layer (the MgSiO$_3$ unified table only when a layer is `PALEOS:MgSiO3`) and, for a mantle with a PALEOS component, the MgSiO$_3$ two-phase solid and liquid pair (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family). When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. With `dry_mantle = false` it also requires the tables of the dissolved volatiles (`PALEOS:H2O` and `Chabrier:H`). -A resumed SPIDER run that keeps its pressure-entropy tables needs only the tables of its layers; an Aragog resume also needs the two-phase pair, which it reads when the mesh changes. +A resumed SPIDER run that keeps its pressure-entropy tables needs only the tables of its layers, and with `temperature_mode = "liquidus_super"` also the two-phase pair its anchor reads; an Aragog resume always needs the two-phase pair, which it reads when the mesh changes. A mixture mantle (a `+` in `mantle_eos`) takes its melting curves, in the structure and in the energetics, and its SPIDER or Aragog tables from its MgSiO$_3$ component: with `PALEOS:MgSiO3` it gets exactly the curves and tables of a `PALEOS:MgSiO3` mantle, and with `WolfBower2018:MgSiO3` the `eos_dir` P-S tables (by default Wolf and Bower 2018) and the `melting_dir` curves. The other components enter only the structure density. A PALEOS mantle with no MgSiO$_3$ component (for example `mantle_eos = "PALEOS:H2O"`, `"PALEOS:iron"`, or a mixture of the two) uses the density of its own material in the structure, while the PALEOS MgSiO$_3$ melting curves and the two-phase MgSiO$_3$ tables of its PALEOS family stand in for the energetics. With SPIDER or Aragog, the energetics must follow a PALEOS, `WolfBower2018` or `RTPress100TPa` EOS; a mantle whose energetics would follow a Seager2007, Chabrier or analytic EOS is rejected when the configuration loads. diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index a7c668f58..7c789119c 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -251,15 +251,14 @@ def energetics_needs_a_thermal_mantle_eos(instance, attribute, value): from proteus.utils.helper import energetics_eos_key struct = instance.interior_struct - if value.module not in ('spider', 'aragog') or struct.module != 'zalmoxis': + zc = struct.zalmoxis if struct.module == 'zalmoxis' else None + if value.module not in ('spider', 'aragog') or zc is None: return - if struct.zalmoxis is None: - return - key = energetics_eos_key(struct.zalmoxis.mantle_eos) + key = energetics_eos_key(zc.mantle_eos) if not (key or '').startswith(PALEOS_EOS_PREFIXES + TDEP_EOS_PREFIXES): raise ValueError( f"interior_energetics.module = '{value.module}' with mantle_eos=" - f"'{struct.zalmoxis.mantle_eos}' has no energetics tables (energetics key: {key}). " + f"'{zc.mantle_eos}' has no energetics tables (energetics key: {key}). " 'Use a PALEOS, WolfBower2018 or RTPress100TPa mantle EOS, or an MgSiO3 component ' 'of one of them in a mixture.' ) diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 142aeb438..6ce1d5f22 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1994,8 +1994,9 @@ def resolve_2phase_mgsio3_paths(mantle_eos: str, mat_dicts: dict, required: bool mat_dicts : dict Zalmoxis material dictionaries. required : bool - Raise instead of returning None for a missing table; callers set it for a - run with a generated PALEOS table set, which requires the pair. + Raise instead of returning None for a missing table. The initial-condition + solves that read the pair always set it, the surface-entropy lookup sets it + for a generated PALEOS table set, and the P-S export leaves it off. Returns ------- diff --git a/tests/atmos_clim/test_agni.py b/tests/atmos_clim/test_agni.py index 26a915c1e..f2c6c21ab 100644 --- a/tests/atmos_clim/test_agni.py +++ b/tests/atmos_clim/test_agni.py @@ -594,6 +594,25 @@ def test_init_agni_atmos_missing_surface_albedo_file_stops_with_its_fetch_comman status.assert_called_once_with(dirs, 20) +@pytest.mark.unit +def test_init_agni_atmos_missing_spectral_file_stops_with_status_20(monkeypatch, tmp_path): + """With a stellar spectrum but no spectral file of the configured group in FWL_DATA, + AGNI writes status 20 and names `proteus get spectral` and `fwl-io relocate`.""" + fake_jl = SimpleNamespace(AGNI=_FakeAGNI(), Dict=dict, Char=str) + (tmp_path / 'out' / 'data').mkdir(parents=True) + (tmp_path / 'out' / 'data' / '100.sflux').write_text('sflux', encoding='utf-8') + dirs = {'output': str(tmp_path / 'out'), 'agni': '/fake/agni', 'fwl': str(tmp_path)} + config = _build_greygas_config() + config.atmos_clim.agni.spectral_file = None + config.atmos_clim.spectral_group, config.atmos_clim.spectral_bands = 'Dayspring', '48' + hf_row = {'P_surf': 1.0, 'T_surf': 900.0, 'gravity': 9.8, 'R_int': 6.4e6} + monkeypatch.setattr(agni_mod, 'jl', fake_jl) + monkeypatch.setattr(agni_mod, 'convert', lambda _typ, value: value) + with pytest.raises(FileNotFoundError, match='`proteus get spectral`.*`fwl-io relocate`'): + init_agni_atmos(dirs, config, hf_row) + assert (tmp_path / 'out' / 'status').read_text().splitlines()[0] == '20' + + @pytest.mark.unit def test_init_agni_atmos_greygas_does_not_glob_sflux(monkeypatch, tmp_path): """Regression: in grey-gas mode, init_agni_atmos must not require any diff --git a/tests/config/test_config.py b/tests/config/test_config.py index f5925db32..8d6e3d278 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -2945,8 +2945,11 @@ def test_energetics_need_a_mantle_eos_with_energetics_tables(energetics, mantle, energetics_needs_a_thermal_mantle_eos(instance, None, value) else: assert energetics_needs_a_thermal_mantle_eos(instance, None, value) is None - # A structure without a Zalmoxis section has no mantle EOS to check. - struct.zalmoxis = None + # Another structure module does not read the Zalmoxis mantle EOS, and a structure + # without a Zalmoxis section has none to check. + struct.module = 'spider' + assert energetics_needs_a_thermal_mantle_eos(instance, None, value) is None + struct.module, struct.zalmoxis = 'zalmoxis', None assert energetics_needs_a_thermal_mantle_eos(instance, None, value) is None diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index c8d41fd23..4fbcdf470 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -328,6 +328,7 @@ def test_two_mgsio3_sources_are_rejected_at_load(mantle, rejected): ('PALEOS:MgSiO3:0.5:0.5', True), ('PALEOS:MgSiO3: 0.9', True), ('PALEOS:nan', True), + ('PALEOS:MgSiO3:nan', True), ('0.5:MgSiO3', True), ('PALEOS:MgSiO3:0.9+', True), ('PALEOS:MgSiO3:0.9++PALEOS:H2O:0.1', True), diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 977937852..7923a398f 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -1205,7 +1205,8 @@ def test_resolve_2phase_paths_stops_on_a_missing_table_when_required(tmp_path, m def test_resolve_2phase_paths_required_returns_a_full_pair_and_names_two_missing(tmp_path): - """A required pair on disk returns both paths; with both tables absent both are named.""" + """A required pair on disk returns both paths; with both tables absent both are named, + with the download command and the relocate hint.""" from proteus.interior_struct import zalmoxis as zmod registry = _paleos_registry(tmp_path) @@ -1214,7 +1215,8 @@ def test_resolve_2phase_paths_required_returns_a_full_pair_and_names_two_missing for name in ('solid', 'liquid'): (tmp_path / f'{name}.dat').unlink() with pytest.raises( - zmod.ZalmoxisMissingEOSFilesError, match=r'not available: solid, liquid\.' + zmod.ZalmoxisMissingEOSFilesError, + match=r'not available: solid, liquid\. Download them with .*`fwl-io relocate`', ): zmod.resolve_2phase_mgsio3_paths('PALEOS:MgSiO3', registry, required=True) diff --git a/tests/star/test_stellar_spectra.py b/tests/star/test_stellar_spectra.py index f161c47f2..f536d7234 100644 --- a/tests/star/test_stellar_spectra.py +++ b/tests/star/test_stellar_spectra.py @@ -265,11 +265,12 @@ def test_get_phoenix_modern_spectrum_offline_missing_raw_raises(tmp_path, monkey from proteus.star.phoenix import get_phoenix_modern_spectrum monkeypatch.setattr(phoenix_mod, 'GetFWLData', lambda: tmp_path) - handler = _make_handler(tmp_path=tmp_path, offline=True, Teff=5800.0, logg=4.5, radius=1.0) + handler = _make_handler( + tmp_path=tmp_path, offline=True, Teff=5800.0, logg=4.5, radius=1.0, FeH=-1.0, alpha=0.4 + ) with pytest.raises( - FileNotFoundError, - match=r'`proteus get phoenix --feh [+-]\d\.\d --alpha [+-]\d\.\d`.*relocate', + FileNotFoundError, match=r'`proteus get phoenix --feh -1\.0 --alpha \+0\.4`.*relocate' ): get_phoenix_modern_spectrum(handler, stellar_track=None) assert (tmp_path / 'out' / 'status').read_text().splitlines()[0] == '23' @@ -451,7 +452,7 @@ def test_init_star_source_muscles_falls_back_to_solar_with_warning( ('muscles', '', 'proteus get muscles --star '), ], ) -def test_init_star_source_none_missing_both_raises( +def test_init_star_missing_spectrum_names_its_catalogue_command( tmp_path, monkeypatch, caplog, source, star, command ): """With neither solar nor MUSCLES on disk, ``init_star`` raises FileNotFoundError @@ -465,7 +466,9 @@ def test_init_star_source_none_missing_both_raises( with pytest.raises(FileNotFoundError, match=f'with `{command}`. .*`fwl-io relocate`'): init_star(handler) - assert all(command in s for s in caplog.messages if 'muscles --star' in s) + # The error is logged before the stop, and no log line names another --star. + assert any(r.levelname == 'ERROR' for r in caplog.records) + assert not [s for s in caplog.messages if 'muscles --star' in s and command not in s] # Discrimination: the raise must fire BEFORE any backup spectrum is # written. A regression that silently produced a zero-flux fallback From bf3c2bcc5c0ce69a268c0a4600ca73079582f295 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 20:12:50 +0200 Subject: [PATCH 66/76] Resolve every PALEOS-API table through one guarded, cached helper The 2-phase pair lookup, the P-S table inputs and the Aragog table set called the PALEOS-API resolver directly, so without paleos they hung on the resolver's worker pool and every call ran git again. resolve_paleos_api checks for paleos, keeps resolved entries per process and serves all four sites. A required PALEOS-API pair names the paleos package instead of a download, and a generated table removed since its build is reported as not built and built again. --- src/proteus/interior_energetics/aragog.py | 4 +- src/proteus/interior_struct/zalmoxis.py | 96 +++++++++++++++-------- tests/conftest.py | 13 +-- tests/interior_struct/test_zalmoxis.py | 58 +++++++++++++- 4 files changed, 131 insertions(+), 40 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 2342465a0..ed39823dd 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -947,9 +947,9 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s # cached .dat paths now so the `eos_file` lookups below find concrete # files. No-op for shipped PALEOS-2phase entries. if twophase_entry and _twophase_key.startswith('PALEOS-API'): - from zalmoxis.eos.paleos_api_cache import resolve_registry_entry + from proteus.interior_struct.zalmoxis import resolve_paleos_api - resolve_registry_entry(twophase_entry) + resolve_paleos_api(_twophase_key, twophase_entry) solid_eos = twophase_entry.get('solid_mantle', {}).get('eos_file', '') liquid_eos = twophase_entry.get('melted_mantle', {}).get('eos_file', '') has_2phase = ( diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index 6ce1d5f22..d30a9c85d 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1808,6 +1808,46 @@ def _paleos_installed() -> bool: return importlib.util.find_spec('paleos') is not None +_PALEOS_API_FIX = ( + 'They are generated at start from the paleos package ' + '(https://github.com/maraattia/PALEOS), not downloaded: install it in this ' + 'environment or choose a PALEOS or PALEOS-2phase EOS.' +) + + +def resolve_paleos_api(key: str, entry: dict) -> None: + """Materialise a PALEOS-API registry entry in place, once per process. + + A resolve runs git to read the paleos version, and the EOS check runs before + every structure solve on a fresh registry, so resolved entries are kept by key. + + Parameters + ---------- + key : str + Registry key of the entry, e.g. ``'PALEOS-API-2phase:MgSiO3'``. + entry : dict + Its registry entry, updated in place with the table paths. + + Raises + ------ + ZalmoxisMissingEOSFilesError + When the paleos package is not installed, since the resolver's worker pool + would then restart forever instead of failing. + """ + if key in _PALEOS_API_RESOLVED: + entry.update(copy.deepcopy(_PALEOS_API_RESOLVED[key])) + return + if not _paleos_installed(): + raise ZalmoxisMissingEOSFilesError( + f'PALEOS-API tables not built:\n {key} (the paleos package is not installed)\n' + f'{_PALEOS_API_FIX}' + ) + from zalmoxis.eos.paleos_api_cache import resolve_registry_entry + + resolve_registry_entry(entry) + _PALEOS_API_RESOLVED[key] = copy.deepcopy(entry) + + def check_zalmoxis_eos_files( layer_eos_config: dict, mat_dicts: dict, paleos_companions: bool = False ) -> None: @@ -1852,21 +1892,16 @@ def check_zalmoxis_eos_files( if entry is None: # Unknown identifiers fail later with a registry error. continue - if key in _PALEOS_API_RESOLVED: - entry.update(copy.deepcopy(_PALEOS_API_RESOLVED[key])) - elif _is_paleos_api(entry): - # Without paleos the resolver's worker pool restarts forever instead of failing. - if not _paleos_installed(): + api = key in _PALEOS_API_RESOLVED or _is_paleos_api(entry) + if api: + try: + resolve_paleos_api(key, entry) + except ZalmoxisMissingEOSFilesError: unbuilt.add(f'{key} (the paleos package is not installed)') continue - try: - from zalmoxis.eos.paleos_api_cache import resolve_registry_entry - - resolve_registry_entry(entry) except Exception as exc: unbuilt.add(f'{key} ({type(exc).__name__}: {exc})') continue - _PALEOS_API_RESOLVED[key] = copy.deepcopy(entry) # Nested entries map layer roles to flat entries; their 'core' sub-entry # counts only for the core role or when it is the only sub-entry. if 'eos_file' in entry: @@ -1883,24 +1918,23 @@ def check_zalmoxis_eos_files( continue for field in ('eos_file', 'adiabat_grad_file'): path = sub.get(field) - if path and not os.path.isfile(path): + if path and not os.path.isfile(path) and api: + # A generated table removed since it was built is built again. + _PALEOS_API_RESOLVED.pop(key, None) + unbuilt.add(f'{key} ({path} not found)') + elif path and not os.path.isfile(path): missing.add(path) parts = [] if missing: - listing = '\n '.join(sorted(missing)) parts.append( - f'Interior EOS table file(s) not found:\n {listing}\n' + f'Interior EOS table file(s) not found:\n {"\n ".join(sorted(missing))}\n' 'Download them with ' '`proteus get interiordata --config-path `, ' f'or run `proteus start` once without --offline. {RELOCATE_HINT}' ) if unbuilt: - listing = '\n '.join(sorted(unbuilt)) parts.append( - f'PALEOS-API tables not built:\n {listing}\n' - 'They are generated at start from the paleos package ' - '(https://github.com/maraattia/PALEOS), not downloaded: install it in this ' - 'environment or choose a PALEOS or PALEOS-2phase EOS.' + f'PALEOS-API tables not built:\n {"\n ".join(sorted(unbuilt))}\n{_PALEOS_API_FIX}' ) if parts: raise ZalmoxisMissingEOSFilesError('\n'.join(parts)) @@ -2011,10 +2045,15 @@ def resolve_2phase_mgsio3_paths(mantle_eos: str, mat_dicts: dict, required: bool paths = _twophase_mgsio3_paths(mantle_eos, mat_dicts) if required and None in paths: missing = [p for p, ok in zip(('solid', 'liquid'), paths) if ok is None] + key = twophase_registry_key(mantle_eos) + fix = ( + _PALEOS_API_FIX + if key.startswith('PALEOS-API') + else 'Download them with `proteus get interiordata --config-path `. ' + + RELOCATE_HINT + ) raise ZalmoxisMissingEOSFilesError( - f'PALEOS 2-phase MgSiO3 tables {twophase_registry_key(mantle_eos)} not ' - f'available: {", ".join(missing)}. Download them with ' - f'`proteus get interiordata --config-path `. {RELOCATE_HINT}' + f'PALEOS 2-phase MgSiO3 tables {key} not available: {", ".join(missing)}. {fix}' ) return paths @@ -2055,15 +2094,12 @@ def _twophase_mgsio3_paths(mantle_eos: str, mat_dicts: dict): return None, None if use_api: try: - from zalmoxis.eos.paleos_api_cache import resolve_registry_entry - - resolve_registry_entry(twophase) - except (ImportError, ModuleNotFoundError) as e: + resolve_paleos_api(twophase_key, twophase) + except (ImportError, ZalmoxisMissingEOSFilesError) as e: log.warning( - 'resolve_2phase_mgsio3_paths: PALEOS-API resolver unavailable ' - '(%s); cannot materialise %s tables.', - e, + 'resolve_2phase_mgsio3_paths: cannot materialise %s tables (%s).', twophase_key, + e, ) return None, None solid_eos = twophase.get('solid_mantle', {}).get('eos_file', '') @@ -2572,14 +2608,12 @@ def _ps_table_inputs(config: Config, mantle_eos: str, eos_entry: dict, mat_dicts from zalmoxis.eos.dispatch import _is_paleos_api if _is_paleos_api(eos_entry): - from zalmoxis.eos.paleos_api_cache import resolve_registry_entry - log.info( 'PALEOS-API live tabulation: resolving cached tables for %s ' '(cold-cache build may take up to ~1 h at 600 pts/decade)', mantle_eos, ) - resolve_registry_entry(eos_entry) + resolve_paleos_api(mantle_eos, eos_entry) # Detect format: paleos_unified vs PALEOS-2phase (nested dict). is_unified = eos_entry.get('format') == 'paleos_unified' diff --git a/tests/conftest.py b/tests/conftest.py index 56f8868ed..f01c72869 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,13 +66,14 @@ def test_with_earth(earth_params): def _clear_paleos_api_resolutions(): """Drop the per-process PALEOS-API resolutions around each test, so a fake resolver in one test does not hand its paths to another.""" - zal = sys.modules.get('proteus.interior_struct.zalmoxis') - if zal is not None: - zal._PALEOS_API_RESOLVED.clear() + + def _clear(): + if zal := sys.modules.get('proteus.interior_struct.zalmoxis'): + zal._PALEOS_API_RESOLVED.clear() + + _clear() yield - zal = sys.modules.get('proteus.interior_struct.zalmoxis') - if zal is not None: - zal._PALEOS_API_RESOLVED.clear() + _clear() @pytest.fixture(autouse=True) diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 7923a398f..383b03736 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -935,7 +935,63 @@ def _resolve(entry): fresh = {'PALEOS-API:MgSiO3': api2, 'PALEOS-API-2phase:MgSiO3': pair2} assert check_zalmoxis_eos_files(layers, fresh, paleos_companions=True) is None assert fake.resolve_registry_entry.call_count == 2 - assert api2['eos_file'] == pair2['melted_mantle']['eos_file'] == str(table) + for resolved in (api2, pair2['solid_mantle'], pair2['melted_mantle']): + assert resolved['eos_file'] == str(table) + + +def test_the_paleos_api_pair_lookup_needs_paleos_and_never_names_a_download(monkeypatch): + """Without paleos the 2-phase lookup does not start the resolver, whose worker pool + would restart forever, and a required PALEOS-API pair names the paleos package, since + `proteus get` does not fetch these tables.""" + import sys + import types + + from proteus.interior_struct import zalmoxis as zmod + + hang = types.ModuleType('zalmoxis.eos.paleos_api_cache') + hang.resolve_registry_entry = MagicMock(side_effect=AssertionError('resolver called')) + monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', hang) + monkeypatch.setattr(zmod, '_paleos_installed', lambda: False) + pair = {r: {'format': 'paleos_api_2phase'} for r in ('solid_mantle', 'melted_mantle')} + registry = {'PALEOS-API-2phase:MgSiO3': pair} + assert zmod.resolve_2phase_mgsio3_paths('PALEOS-API:MgSiO3', registry) == (None, None) + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError) as excinfo: + zmod.resolve_2phase_mgsio3_paths('PALEOS-API:MgSiO3', registry, required=True) + assert 'paleos package' in str(excinfo.value) + assert 'proteus get' not in str(excinfo.value) + hang.resolve_registry_entry.assert_not_called() + + +def test_a_removed_paleos_api_table_is_built_again(tmp_path, monkeypatch): + """A PALEOS-API table removed after its entry was resolved is reported as not built, + never as a download, and the next check resolves the entry again.""" + import sys + import types + + from proteus.interior_struct import zalmoxis as zmod + + table = tmp_path / 'api.dat' + table.write_text('eos table stub') + fake = types.ModuleType('zalmoxis.eos.paleos_api_cache') + fake.resolve_registry_entry = MagicMock( + side_effect=lambda entry: entry.update(eos_file=str(table), format='paleos_unified') + ) + monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', fake) + monkeypatch.setattr(zmod, '_paleos_installed', lambda: True) + + def fresh(): + return {'PALEOS-API:MgSiO3': {'format': 'paleos_api', 'material': 'mgsio3'}} + + layers = {'mantle': 'PALEOS-API:MgSiO3'} + assert zmod.check_zalmoxis_eos_files(layers, fresh()) is None + table.unlink() + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError) as excinfo: + zmod.check_zalmoxis_eos_files(layers, fresh()) + assert str(excinfo.value).startswith('PALEOS-API tables not built:') + assert 'proteus get' not in str(excinfo.value) + table.write_text('eos table stub') + assert zmod.check_zalmoxis_eos_files(layers, fresh()) is None + assert fake.resolve_registry_entry.call_count == 2 @pytest.mark.parametrize('missing', ['iron', 'solid', 'h2o']) From 6cfe797d75f520cf3a8e569aec5d18ed1992fd10 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 20:12:51 +0200 Subject: [PATCH 67/76] Require positive EOS fractions and match relocate paths after resolving A zero fraction left a mixture member out of the structure while the energetics could still follow it, so every fraction must now be positive. The doctor compares resolved paths, so a data root reached through a symlink or given with ~ still gets the relocate proposal. The status-20 test also covers a stop at the start check, and three long doc sentences are split. --- docs/Reference/config/interior.md | 4 ++-- docs/Reference/data.md | 2 +- src/proteus/config/_struct.py | 15 +++++---------- src/proteus/doctor.py | 6 +++--- tests/config/test_struct.py | 20 ++++++-------------- tests/test_doctor.py | 20 ++++++++++++++++++-- tests/test_proteus.py | 21 +++++++++++++++------ 7 files changed, 50 insertions(+), 38 deletions(-) diff --git a/docs/Reference/config/interior.md b/docs/Reference/config/interior.md index e582610cc..2819e4eee 100644 --- a/docs/Reference/config/interior.md +++ b/docs/Reference/config/interior.md @@ -126,10 +126,10 @@ The melt fraction then follows from the lever rule between this solidus and liqu With `mantle_eos = "PALEOS-2phase:MgSiO3"`, the SPIDER/Aragog entropy tables use the separate solid and liquid PALEOS tables, which supply the latent-heat entropy gap across the melting curve directly rather than through a single interpolated unified table. Their phase boundaries follow the same construction as the unified case: the liquidus is the analytic PALEOS curve, and the solidus is derived as $T_\mathrm{sol}(P) = f\,T_\mathrm{liq}(P)$ with $f$ the `mushy_zone_factor`. -A run with a PALEOS EOS requires the tables the chosen EOS reads: the table of each PALEOS layer (the MgSiO$_3$ unified table only when a layer is `PALEOS:MgSiO3`) and, for a mantle with a PALEOS component, the MgSiO$_3$ two-phase solid and liquid pair (the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, the PALEOS-API cache for the PALEOS-API family). +A run with a PALEOS EOS requires the table of each PALEOS layer, with the MgSiO$_3$ unified table only when a layer is `PALEOS:MgSiO3`. A mantle with a PALEOS component also requires the MgSiO$_3$ two-phase solid and liquid pair: the high-resolution pair for `PALEOS-2phase:MgSiO3-highres`, and the PALEOS-API cache for the PALEOS-API family. When one is missing, the run stops before the first solve with an error that names each missing file and the command that fetches them, `proteus get interiordata --config-path `; it does not fall back to another table set. With `dry_mantle = false` it also requires the tables of the dissolved volatiles (`PALEOS:H2O` and `Chabrier:H`). -A resumed SPIDER run that keeps its pressure-entropy tables needs only the tables of its layers, and with `temperature_mode = "liquidus_super"` also the two-phase pair its anchor reads; an Aragog resume always needs the two-phase pair, which it reads when the mesh changes. +A resumed SPIDER run that keeps its pressure-entropy tables needs only the tables of its layers. With `temperature_mode = "liquidus_super"` it also needs the two-phase pair that its anchor reads. An Aragog resume always needs the two-phase pair, which it reads when the mesh changes. A mixture mantle (a `+` in `mantle_eos`) takes its melting curves, in the structure and in the energetics, and its SPIDER or Aragog tables from its MgSiO$_3$ component: with `PALEOS:MgSiO3` it gets exactly the curves and tables of a `PALEOS:MgSiO3` mantle, and with `WolfBower2018:MgSiO3` the `eos_dir` P-S tables (by default Wolf and Bower 2018) and the `melting_dir` curves. The other components enter only the structure density. A PALEOS mantle with no MgSiO$_3$ component (for example `mantle_eos = "PALEOS:H2O"`, `"PALEOS:iron"`, or a mixture of the two) uses the density of its own material in the structure, while the PALEOS MgSiO$_3$ melting curves and the two-phase MgSiO$_3$ tables of its PALEOS family stand in for the energetics. With SPIDER or Aragog, the energetics must follow a PALEOS, `WolfBower2018` or `RTPress100TPa` EOS; a mantle whose energetics would follow a Seager2007, Chabrier or analytic EOS is rejected when the configuration loads. diff --git a/docs/Reference/data.md b/docs/Reference/data.md index aeec84b30..fd6bddc11 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -54,7 +54,7 @@ A `$FWL_DATA` folder written by an older PROTEUS keeps its files at paths that a 1. Run `fwl-io relocate`. It moves every dataset whose files all match their pinned checksums into its version directory. `fwl-io relocate --dry-run` lists what would move, what is incomplete and what does not match, without moving anything. 2. Run `proteus start --config ` once without `--offline`, which downloads the data that configuration needs, or `proteus get interiordata --config-path ` for the interior tables alone. This downloads what the first step left: incomplete and mismatched datasets, and those it cannot move (the Chabrier archive, the surface albedos, the Seager tables and the Spada tracks). -A run with `--offline` that finds an interior EOS table, a melting curve, a P-S lookup table, a spectral file, a solar, MUSCLES or PHOENIX spectrum or a surface albedo file missing stops with an error that names its download command and ends with: "Data kept in the older FWL_DATA layout can be moved into place with `fwl-io relocate`." Two stops have no download command. A spectral file of a group and band count that no manifest declares is not downloaded, so its error says to place the file at the path it names. PALEOS-API tables are built at start from the `paleos` package, so their error names that package. +A run with `--offline` stops when an interior EOS table, a melting curve, a P-S lookup table, a spectral file, a stellar spectrum or a surface albedo file is missing. The error names its download command and ends with: "Data kept in the older FWL_DATA layout can be moved into place with `fwl-io relocate`." Two stops have no download command. A spectral file of a group and band count that no manifest declares is not downloaded, so its error says to place the file at the path it names. PALEOS-API tables are built at start from the `paleos` package, so their error names that package. The stop writes a [status code](output.md) as follows: diff --git a/src/proteus/config/_struct.py b/src/proteus/config/_struct.py index 3ca7d4a9a..55ac05536 100644 --- a/src/proteus/config/_struct.py +++ b/src/proteus/config/_struct.py @@ -44,7 +44,8 @@ def valid_zalmoxis(instance, attribute, value): f"got '{ice_layer_eos}'" ) - # A component is ':' with at most one fraction, as Zalmoxis parses it. + # A component is ':' with at most one positive fraction, as Zalmoxis + # parses it; Zalmoxis divides a mixture's fractions by their sum. def _number(token): try: return float(token) @@ -57,9 +58,7 @@ def _number(token): ('ice_layer_eos', ice_layer_eos), ] for name, eos_val in (layer for layer in layers if layer[1]): - comps = [c.strip() for c in eos_val.split('+')] - total = 0.0 - for comp in comps: + for comp in (c.strip() for c in eos_val.split('+')): tokens = comp.split(':') fraction = _number(tokens[2]) if len(tokens) == 3 else 1.0 if ( @@ -68,17 +67,13 @@ def _number(token): or any(_number(t) is not None for t in tokens[:2]) or any(ch.isspace() for ch in comp) or fraction is None - or not 0 <= fraction < float('inf') + or not 0 < fraction < float('inf') ): raise ValueError( f"`interior_struct.zalmoxis.{name}` component '{comp}' is not " - "':[:]' with a non-negative finite fraction " + "':[:]' with a positive finite fraction " 'and no spaces' ) - total += fraction - # Zalmoxis divides a mixture's fractions by their sum. - if len(comps) > 1 and total == 0: - raise ValueError(f'`interior_struct.zalmoxis.{name}` fractions sum to zero') # WolfBower2018 EOS is limited to 1 TPa. For planets > 2 M_earth, # CMB pressure exceeds this and Zalmoxis will fail to converge. diff --git a/src/proteus/doctor.py b/src/proteus/doctor.py index a9fd28762..37b3d8aa4 100644 --- a/src/proteus/doctor.py +++ b/src/proteus/doctor.py @@ -350,12 +350,12 @@ def check_fwl_data() -> list[CheckResult]: """ results = [] fwl = os.environ.get('FWL_DATA') - if not fwl or not os.path.isdir(fwl): + if not fwl or not os.path.isdir(fwl := os.path.expanduser(fwl)): return results try: from fwl_io.relocate import plan_relocations - movable = [Path(e.legacy_dir) for e in plan_relocations(fwl).ready] + movable = [Path(e.legacy_dir).resolve() for e in plan_relocations(fwl).ready] except Exception: movable = [] @@ -366,7 +366,7 @@ def check_fwl_data() -> list[CheckResult]: for subdir, (fix, legacy) in expected.items(): path = os.path.join(fwl, subdir) old = os.path.join(fwl, legacy) - if any(p.is_relative_to(old) for p in movable): + if any(p.is_relative_to(Path(old).resolve()) for p in movable): fix = 'fwl-io relocate' if os.path.isdir(path) and os.listdir(path): results.append( diff --git a/tests/config/test_struct.py b/tests/config/test_struct.py index 4fbcdf470..5d1880066 100644 --- a/tests/config/test_struct.py +++ b/tests/config/test_struct.py @@ -333,32 +333,24 @@ def test_two_mgsio3_sources_are_rejected_at_load(mantle, rejected): ('PALEOS:MgSiO3:0.9+', True), ('PALEOS:MgSiO3:0.9++PALEOS:H2O:0.1', True), ('PALEOS:MgSiO3:0.9 + PALEOS:H2O:0.1', False), - ('PALEOS:MgSiO3:0', False), - ('PALEOS:MgSiO3:0+PALEOS:H2O:0.1', False), + ('PALEOS:MgSiO3:0', True), + ('PALEOS:MgSiO3:0+PALEOS:H2O:0.1', True), ('PALEOS:MgSiO3', False), ], ) def test_an_eos_component_with_a_space_or_a_non_finite_fraction_is_rejected(mantle, rejected): - """A component that is not ':' with at most one non-negative finite + """A component that is not ':' with at most one positive finite fraction, or that has an inner space, names no registry key or no valid mass fraction for - Zalmoxis, so it is rejected at load. Spaces around '+' are fine.""" + Zalmoxis, so it is rejected at load; a zero fraction would leave a mixture member out of + the structure while the energetics still follow it. Spaces around '+' are fine.""" kwargs = dict(module='zalmoxis', zalmoxis=Zalmoxis(mantle_eos=mantle)) if rejected: - with pytest.raises( - ValueError, match='with a non-negative finite fraction and no spaces' - ): + with pytest.raises(ValueError, match='with a positive finite fraction and no spaces'): Struct(**kwargs) else: assert Struct(**kwargs).zalmoxis.mantle_eos == mantle -def test_a_mixture_whose_fractions_sum_to_zero_is_rejected(): - """Zalmoxis divides a mixture's fractions by their sum, so a zero sum is rejected; a - single component takes the fraction 1 whatever its token says.""" - with pytest.raises(ValueError, match='mantle_eos` fractions sum to zero'): - Struct(module='zalmoxis', zalmoxis=Zalmoxis(mantle_eos='PALEOS:MgSiO3:0+PALEOS:H2O:0')) - - @pytest.mark.parametrize('layer', ['core_eos', 'ice_layer_eos']) def test_a_core_or_ice_component_is_checked_like_a_mantle_component(layer): """The core and ice layer EOS components follow the same format rule as the mantle.""" diff --git a/tests/test_doctor.py b/tests/test_doctor.py index a8e3f3d5a..fc3eef473 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -265,8 +265,6 @@ def test_an_older_layout_is_fixed_by_relocate(self, tmp_path, movable): from types import SimpleNamespace legacy = tmp_path / 'spectral_files' / 'Dayspring' / '48' - legacy.mkdir(parents=True) - (legacy / 'Dayspring.sf').touch() plan = SimpleNamespace(ready=[SimpleNamespace(legacy_dir=legacy)] if movable else []) with ( patch.dict(os.environ, {'FWL_DATA': str(tmp_path)}), @@ -278,6 +276,24 @@ def test_an_older_layout_is_fixed_by_relocate(self, tmp_path, movable): assert fixes['FWL_DATA/atmos_clim/spectral_files'] == want assert fixes['FWL_DATA/star/spectra'] == 'proteus get stellar' + def test_relocate_is_proposed_through_a_symlinked_data_root(self, tmp_path): + """FWL_DATA reached through a symlink still matches the resolved legacy folders a + relocate dry run reports.""" + from types import SimpleNamespace + + real = tmp_path / 'real' + (real / 'spectral_files' / 'Dayspring' / '48').mkdir(parents=True) + (tmp_path / 'link').symlink_to(real) + plan = SimpleNamespace( + ready=[SimpleNamespace(legacy_dir=real / 'spectral_files' / 'Dayspring' / '48')] + ) + with ( + patch.dict(os.environ, {'FWL_DATA': str(tmp_path / 'link')}), + patch('fwl_io.relocate.plan_relocations', return_value=plan), + ): + fixes = {r.name: r.fix_cmd for r in check_fwl_data()} + assert fixes['FWL_DATA/atmos_clim/spectral_files'] == 'fwl-io relocate' + def test_skips_when_fwl_data_unset(self): """No checks when FWL_DATA is not set.""" env = os.environ.copy() diff --git a/tests/test_proteus.py b/tests/test_proteus.py index acada80d2..c9a0c9778 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -282,10 +282,13 @@ def test_require_paleos_tables_runs_only_for_the_zalmoxis_structure( @pytest.mark.unit -@pytest.mark.parametrize('error', ['missing', 'other']) -def test_a_missing_eos_table_anywhere_in_the_run_writes_status_20(tmp_path, error): - """A missing Zalmoxis EOS table raised mid-run, here from the structure solve, leaves - status 20, so the run does not read as still running; other errors leave it as is.""" +@pytest.mark.parametrize( + 'error, site', [('missing', 'solve'), ('other', 'solve'), ('missing', 'start check')] +) +def test_a_missing_eos_table_anywhere_in_the_run_writes_status_20(tmp_path, error, site): + """A missing Zalmoxis EOS table raised at the start check or mid-run, here from the + structure solve, leaves status 20, so the run does not read as still running; other + errors leave it as is.""" from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError p = _make_proteus_instance(tmp_path) @@ -294,15 +297,21 @@ def test_a_missing_eos_table_anywhere_in_the_run_writes_status_20(tmp_path, erro ) p.config.interior_energetics.flux_guess = 100.0 p.config.star.age_ini = 0.1 - p._require_paleos_tables = MagicMock() exc = ZalmoxisMissingEOSFilesError('pair') if error == 'missing' else RuntimeError('x') with ExitStack() as stack: for target in _START_PATCHES: stack.enter_context(patch(target)) stack.enter_context(patch('proteus.proteus.CleanDir')) stack.enter_context( - patch('proteus.interior_energetics.wrapper.solve_structure', side_effect=exc) + patch( + 'proteus.interior_struct.zalmoxis.require_paleos_tables', + side_effect=exc if site == 'start check' else None, + ) ) + if site == 'solve': + stack.enter_context( + patch('proteus.interior_energetics.wrapper.solve_structure', side_effect=exc) + ) with pytest.raises(RuntimeError): p.start(resume=False, offline=True) status = (tmp_path / 'status').read_text().splitlines()[0] From 86798e9d48c7f6cda3a62f4fb0f33a78f412f671 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 20:20:06 +0200 Subject: [PATCH 68/76] Write status 20 when a melting curve or P-S table is missing A missing melting curve, SPIDER or Aragog P-S table or Aragog lookup table stopped the run without an error status. These stops now raise MissingDataError, a FileNotFoundError, so every existing fallback catches them as before, and it shares the MissingReferenceData base with the Zalmoxis EOS error, which the start() handler turns into status 20. --- docs/Reference/data.md | 4 +- src/proteus/interior_energetics/aragog.py | 6 +-- src/proteus/interior_energetics/common.py | 4 +- src/proteus/interior_energetics/spider.py | 4 +- src/proteus/interior_energetics/wrapper.py | 4 +- src/proteus/interior_struct/zalmoxis.py | 3 +- src/proteus/proteus.py | 9 ++--- src/proteus/utils/data.py | 3 +- src/proteus/utils/helper.py | 11 ++++++ tests/test_proteus.py | 43 ++++++++++++++++++---- 10 files changed, 65 insertions(+), 26 deletions(-) diff --git a/docs/Reference/data.md b/docs/Reference/data.md index fd6bddc11..49d1f34fa 100644 --- a/docs/Reference/data.md +++ b/docs/Reference/data.md @@ -60,10 +60,10 @@ The stop writes a [status code](output.md) as follows: | Missing data | Status | | ---------------------------------------------------------------- | ------------------ | -| Zalmoxis EOS tables, including the MgSiO$_3$ two-phase pair | 20, wherever the run stops | +| Zalmoxis EOS tables, including the MgSiO$_3$ two-phase pair | 20 | | spectral file, surface albedo file | 20 | | solar, MUSCLES or PHOENIX spectrum | 23 | -| melting curve, SPIDER or Aragog P-S or lookup table | none of its own | +| melting curve, SPIDER or Aragog P-S or lookup table | 20 | --- diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index ed39823dd..4e6715439 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -42,7 +42,7 @@ resolve_lookup_table_dir, resolve_melting_curve_files, ) -from proteus.utils.helper import energetics_eos_key, generates_paleos_tables +from proteus.utils.helper import MissingDataError, energetics_eos_key, generates_paleos_tables from proteus.utils.helper import format_subyear_time, parse_subyear_time, snapshot_path_for_time log = logging.getLogger('fwl.' + __name__) @@ -1010,7 +1010,7 @@ def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: s # check data exist if not (LOOK_UP_DIR / 'heat_capacity_melt.dat').is_file(): - raise FileNotFoundError( + raise MissingDataError( f'Aragog lookup data not found at {LOOK_UP_DIR}. Fetch it with ' f"'proteus get interiordata --config-path '. {RELOCATE_HINT}" ) @@ -1111,7 +1111,7 @@ def _append_radnuc(_iso, _cnc): if fallback_dir.is_dir(): entropy_eos = _cached_entropy_eos(str(fallback_dir)) else: - raise FileNotFoundError( + raise MissingDataError( f'PALEOS P-S tables not found. Aragog entropy solver ' f'requires P-S tables. Checked: {spider_eos_dir}, {fallback_dir}. ' "Fetch them with 'proteus get interiordata --config-path " diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index e741a83ea..26e39cfb7 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -11,7 +11,7 @@ from proteus.utils.constants import B_ein from proteus.utils.data import find_lookup_table_dir -from proteus.utils.helper import generates_paleos_tables +from proteus.utils.helper import MissingDataError, generates_paleos_tables if TYPE_CHECKING: from aragog.eos.entropy import EntropyEOS @@ -207,7 +207,7 @@ def _verify_initial_entropy( _TABLE_SUPERLIQ_N_BISECT = 60 -class MissingMeltingCurveError(FileNotFoundError): +class MissingMeltingCurveError(MissingDataError): """The melting curves the run must read are not configured or not on disk. Raised when ``interior_struct.melting_dir`` names curves whose files are diff --git a/src/proteus/interior_energetics/spider.py b/src/proteus/interior_energetics/spider.py index d399b52cc..50d971dee 100644 --- a/src/proteus/interior_energetics/spider.py +++ b/src/proteus/interior_energetics/spider.py @@ -23,7 +23,7 @@ from proteus.interior_struct.common import solvus_radius from proteus.utils.constants import radnuc_data from proteus.utils.data import RELOCATE_HINT, find_lookup_table_dir -from proteus.utils.helper import UpdateStatusfile, natural_sort, recursive_get +from proteus.utils.helper import MissingDataError, UpdateStatusfile, natural_sort, recursive_get if TYPE_CHECKING: from proteus.config import Config @@ -651,7 +651,7 @@ def _resolve_spider_eos_dir(dirs: dict, config: Config) -> str: else os.path.join(dirs['spider'], 'lookup_data', '1TPa-dK09-elec-free') ) if not os.path.isdir(eos_dir): - raise FileNotFoundError( + raise MissingDataError( f'SPIDER EOS directory not found: {eos_dir}. ' f"Check interior_struct.eos_dir='{config.interior_struct.eos_dir}', or fetch the " f'tables with `proteus get interiordata --config-path `. {RELOCATE_HINT}' diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index b1b28a3a3..a0a967dc0 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -30,7 +30,7 @@ noble_gases, vol_element_list, ) -from proteus.utils.helper import UpdateStatusfile, energetics_eos_key +from proteus.utils.helper import MissingDataError, UpdateStatusfile, energetics_eos_key if TYPE_CHECKING: from proteus.config import Config @@ -887,7 +887,7 @@ def _provide_spider_eos_tables(config: Config, outdir: str, dirs: dict) -> None: # Case 4: neither source yielded a complete set. from proteus.utils.data import RELOCATE_HINT - raise FileNotFoundError( + raise MissingDataError( 'Could not provide SPIDER/Aragog P-S EOS tables at ' f'{target_dir}. FWL_DATA source ' f'{zenodo_root} is missing {len(zenodo_missing)} of ' diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index d30a9c85d..f3d51fc71 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -43,6 +43,7 @@ ) from proteus.utils.data import RELOCATE_HINT, GetFWLData, get_zalmoxis_melting_curves from proteus.utils.helper import ( + MissingReferenceData, _strip_fraction_tokens, energetics_eos_key, eos_components, @@ -1799,7 +1800,7 @@ def _build_mushy_zone_factors(layer_eos_config: dict, mzf: float) -> dict: } -class ZalmoxisMissingEOSFilesError(RuntimeError): +class ZalmoxisMissingEOSFilesError(RuntimeError, MissingReferenceData): """A layer's configured EOS identifier names a table file absent on disk.""" diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index 4283fb2f6..17529fef7 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -29,6 +29,7 @@ from proteus.utils.constants import noble_gases, vap_list, vol_list from proteus.utils.helper import ( CleanDir, + MissingReferenceData, PrintHalfSeparator, PrintSeparator, UpdateStatusfile, @@ -62,16 +63,14 @@ def _status_on_missing_eos(start): - """Write status 20 when a missing Zalmoxis EOS table stops ``start``, wherever it is raised.""" + """Write status 20 when missing reference data stops ``start``, wherever it is raised.""" @functools.wraps(start) def wrapper(self, *args, **kwargs): try: return start(self, *args, **kwargs) - except RuntimeError as exc: - zalmoxis = sys.modules.get('proteus.interior_struct.zalmoxis') - if zalmoxis and isinstance(exc, zalmoxis.ZalmoxisMissingEOSFilesError): - UpdateStatusfile(self.directories, 20) + except MissingReferenceData: + UpdateStatusfile(self.directories, 20) raise return wrapper diff --git a/src/proteus/utils/data.py b/src/proteus/utils/data.py index 0964d8650..a7b5fe734 100644 --- a/src/proteus/utils/data.py +++ b/src/proteus/utils/data.py @@ -20,6 +20,7 @@ from proteus.utils.constants import VOLATILE_EOS_MAP from proteus.utils.helper import ( + MissingDataError, eos_components, paleos_companion_keys, resolve_fwl_data_dir, @@ -2216,7 +2217,7 @@ def get_zalmoxis_melting_curves(config: Config): ) for melting_file in (solidus_file, liquidus_file): if not melting_file.is_file(): - raise FileNotFoundError( + raise MissingDataError( f'Melting curve file not found: {melting_file}. ' f"Check interior_struct.melting_dir='{config.interior_struct.melting_dir}', or fetch " f'it with `proteus get interiordata --config-path `. {RELOCATE_HINT}' diff --git a/src/proteus/utils/helper.py b/src/proteus/utils/helper.py index bc7ee7f16..fbaca6e96 100644 --- a/src/proteus/utils/helper.py +++ b/src/proteus/utils/helper.py @@ -22,6 +22,17 @@ log = logging.getLogger('fwl.' + __name__) +class MissingReferenceData(Exception): + """Marks an error raised because reference data the run needs is missing. + + ``Proteus.start`` writes status 20 when such an error stops the run. + """ + + +class MissingDataError(FileNotFoundError, MissingReferenceData): + """A reference data file or table the run needs is not on disk.""" + + def _strip_fraction_tokens(component: str) -> str: """Strip trailing mass-fraction tokens from an EOS component string. diff --git a/tests/test_proteus.py b/tests/test_proteus.py index c9a0c9778..87f64f78b 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -283,21 +283,48 @@ def test_require_paleos_tables_runs_only_for_the_zalmoxis_structure( @pytest.mark.unit @pytest.mark.parametrize( - 'error, site', [('missing', 'solve'), ('other', 'solve'), ('missing', 'start check')] + 'error, site', + [ + ('missing', 'solve'), + ('other', 'solve'), + ('missing', 'start check'), + ('melting curve', 'solve'), + ('P-S table', 'solve'), + ], ) -def test_a_missing_eos_table_anywhere_in_the_run_writes_status_20(tmp_path, error, site): - """A missing Zalmoxis EOS table raised at the start check or mid-run, here from the - structure solve, leaves status 20, so the run does not read as still running; other - errors leave it as is.""" +def test_a_missing_eos_table_anywhere_in_the_run_writes_status_20( + tmp_path, monkeypatch, error, site +): + """Missing reference data raised at the start check or mid-run, here from the + structure solve, leaves status 20, so the run does not read as still running: a + Zalmoxis EOS table, a melting curve or a SPIDER P-S table, each from its real raise + site. Other errors leave the status as is.""" + from types import SimpleNamespace as NS + + import proteus.interior_energetics.spider as spider + import proteus.utils.data as data from proteus.interior_struct.zalmoxis import ZalmoxisMissingEOSFilesError + monkeypatch.setattr(data, 'FWL_DATA_DIR', tmp_path / 'fwl') + monkeypatch.setattr(spider, 'find_lookup_table_dir', lambda: None) + real_site = { + 'melting curve': lambda *a, **k: data.get_zalmoxis_melting_curves( + NS(interior_struct=NS(melting_dir='Monteux-600')) + ), + 'P-S table': lambda *a, **k: spider._resolve_spider_eos_dir( + {'spider': str(tmp_path / 'nospider')}, NS(interior_struct=NS(eos_dir='none')) + ), + } + p = _make_proteus_instance(tmp_path) p.directories.update( {k: str(tmp_path / k) for k in ('output/observe', 'output/offchem', 'output/plots')} ) p.config.interior_energetics.flux_guess = 100.0 p.config.star.age_ini = 0.1 - exc = ZalmoxisMissingEOSFilesError('pair') if error == 'missing' else RuntimeError('x') + exc = real_site.get(error) or ( + ZalmoxisMissingEOSFilesError('pair') if error == 'missing' else RuntimeError('x') + ) with ExitStack() as stack: for target in _START_PATCHES: stack.enter_context(patch(target)) @@ -312,10 +339,10 @@ def test_a_missing_eos_table_anywhere_in_the_run_writes_status_20(tmp_path, erro stack.enter_context( patch('proteus.interior_energetics.wrapper.solve_structure', side_effect=exc) ) - with pytest.raises(RuntimeError): + with pytest.raises(Exception, match='pair|x|not found'): p.start(resume=False, offline=True) status = (tmp_path / 'status').read_text().splitlines()[0] - assert status == ('20' if error == 'missing' else '0') + assert status == ('0' if error == 'other' else '20') @pytest.mark.unit From 3dec89c22111a8ec1bf3f8f1c650cf54f72bf9d1 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 20:55:44 +0200 Subject: [PATCH 69/76] Keep the missing-data status through the dummy structure and a removed table The dummy structure turned a missing SPIDER or Aragog P-S table into a plain RuntimeError, so the run stopped without status 20; it now raises MissingDataError with the same message. A PALEOS-API entry kept from an earlier resolve whose tables were removed is resolved again, which rebuilds them, before the check reports anything. Tests reach the wrapper, Aragog lookup and Aragog P-S stops at their real raise sites, the paleos guard through importlib itself, and the P-S table inputs of a PALEOS-API mantle. --- src/proteus/interior_energetics/wrapper.py | 2 +- src/proteus/interior_struct/zalmoxis.py | 13 ++++- tests/interior_energetics/test_aragog.py | 30 +++++++++++ tests/interior_energetics/test_wrapper.py | 9 ++-- tests/interior_struct/test_zalmoxis.py | 59 ++++++++++++++++++---- tests/test_proteus.py | 4 ++ 6 files changed, 100 insertions(+), 17 deletions(-) diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index a0a967dc0..0052fdaf3 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -1072,7 +1072,7 @@ def determine_interior_radius_with_dummy( except MissingMeltingCurveError: raise except FileNotFoundError as exc: - raise RuntimeError( + raise MissingDataError( "interior_struct.module='dummy' with interior_energetics.module=" f'{config.interior_energetics.module!r} needs the SPIDER/Aragog P-S EOS ' 'tables from FWL_DATA or the SPIDER lookup_data, and neither is ' diff --git a/src/proteus/interior_struct/zalmoxis.py b/src/proteus/interior_struct/zalmoxis.py index f3d51fc71..429ecfbe6 100644 --- a/src/proteus/interior_struct/zalmoxis.py +++ b/src/proteus/interior_struct/zalmoxis.py @@ -1816,11 +1816,18 @@ def _paleos_installed() -> bool: ) +def _table_paths(entry: dict) -> list[str]: + """Table paths of a resolved registry entry and of its phase sub-entries.""" + subs = [entry, *(v for v in entry.values() if isinstance(v, dict))] + return [s['eos_file'] for s in subs if s.get('eos_file')] + + def resolve_paleos_api(key: str, entry: dict) -> None: """Materialise a PALEOS-API registry entry in place, once per process. A resolve runs git to read the paleos version, and the EOS check runs before every structure solve on a fresh registry, so resolved entries are kept by key. + A kept entry whose tables were removed is resolved again, which rebuilds them. Parameters ---------- @@ -1835,8 +1842,10 @@ def resolve_paleos_api(key: str, entry: dict) -> None: When the paleos package is not installed, since the resolver's worker pool would then restart forever instead of failing. """ - if key in _PALEOS_API_RESOLVED: - entry.update(copy.deepcopy(_PALEOS_API_RESOLVED[key])) + cached = _PALEOS_API_RESOLVED.pop(key, None) + if cached and all(os.path.isfile(p) for p in _table_paths(cached)): + _PALEOS_API_RESOLVED[key] = cached + entry.update(copy.deepcopy(cached)) return if not _paleos_installed(): raise ZalmoxisMissingEOSFilesError( diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 22d7ae85b..099f74a93 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -564,6 +564,36 @@ def _spider_fallback_scaffold(tmp_path): return hf_row, interior_o +@pytest.mark.unit +@pytest.mark.parametrize( + 'missing, message', + [('lookup', 'Aragog lookup data not found'), ('P-S', 'PALEOS P-S tables not found')], +) +def test_setup_solver_stops_with_a_missing_data_error(tmp_path, missing, message): + """A missing Aragog lookup or P-S table stops setup_solver with a missing-data error, + which start() turns into status 20.""" + from proteus.data import LOOKUP_WOLF_BOWER_2018_1TPA, dataset_dir + from proteus.interior_energetics.aragog import AragogRunner + from proteus.utils.helper import MissingReferenceData + + config = _make_aragog_config(struct_module='spider') + hf_row, interior_o = _spider_fallback_scaffold(tmp_path) + if missing == 'lookup': + ( + dataset_dir(LOOKUP_WOLF_BOWER_2018_1TPA, data_root=tmp_path) + / 'heat_capacity_melt.dat' + ).unlink() + else: + interior_o._spider_eos_dir = None + with ( + patch('proteus.interior_energetics.aragog.FWL_DATA_DIR', tmp_path), + patch('proteus.interior_energetics.aragog.Parameters'), + patch('proteus.interior_energetics.aragog.EntropySolver'), + pytest.raises(MissingReferenceData, match=message), + ): + AragogRunner.setup_solver(config, hf_row, interior_o, str(tmp_path / 'out')) + + @pytest.mark.unit def test_setup_solver_threads_phase_boundary_margin(tmp_path): """setup_solver passes phase_boundary_entropy_margin into _EnergyParameters diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index edc8ad43c..195bbeb10 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -3428,6 +3428,7 @@ def test_provide_spider_eos_tables_hard_failure_when_no_source(tmp_path, monkeyp from unittest.mock import patch as _patch from proteus.interior_energetics.wrapper import _provide_spider_eos_tables + from proteus.utils.helper import MissingReferenceData _configured_melting_curve(tmp_path, monkeypatch) config = SimpleNamespace( @@ -3445,6 +3446,7 @@ def test_provide_spider_eos_tables_hard_failure_when_no_source(tmp_path, monkeyp # would not raise at all. assert 'proteus get interiordata --config-path' in str(exc.value) assert '`fwl-io relocate`' in str(exc.value) + assert isinstance(exc.value, MissingReferenceData) # start() writes status 20 # ============================================================================ @@ -3680,10 +3682,12 @@ def test_dummy_structure_liquidus_super_passes_missing_melting_curve_through(tmp @pytest.mark.unit def test_dummy_structure_without_tables_raises_named_error(tmp_path): - """No FWL_DATA or SPIDER table source gives a named RuntimeError in any mode.""" + """No FWL_DATA or SPIDER table source gives a named missing-data error in any mode, so + the run stops with status 20.""" from unittest.mock import patch as _patch from proteus.interior_energetics.wrapper import determine_interior_radius_with_dummy + from proteus.utils.helper import MissingReferenceData config = MagicMock() config.interior_energetics.module = 'aragog' @@ -3699,7 +3703,7 @@ def test_dummy_structure_without_tables_raises_named_error(tmp_path): side_effect=FileNotFoundError('no P-S tables'), ), _patch('proteus.interior_energetics.wrapper.Interior_t') as interior_t, - pytest.raises(RuntimeError) as excinfo, + pytest.raises(MissingReferenceData) as excinfo, ): determine_interior_radius_with_dummy({}, config, None, hf_row, str(tmp_path)) @@ -3708,7 +3712,6 @@ def test_dummy_structure_without_tables_raises_named_error(tmp_path): assert "interior_energetics.module='aragog'" in msg assert 'proteus get interiordata --config-path' in msg assert 'no P-S tables' in msg - assert not isinstance(excinfo.value, FileNotFoundError) # The failure happens before the first interior step is built. interior_t.assert_not_called() diff --git a/tests/interior_struct/test_zalmoxis.py b/tests/interior_struct/test_zalmoxis.py index 383b03736..68313bb3d 100644 --- a/tests/interior_struct/test_zalmoxis.py +++ b/tests/interior_struct/test_zalmoxis.py @@ -877,6 +877,18 @@ def test_check_eos_files_needs_the_mgsio3_unified_table_only_for_an_mgsio3_layer check_zalmoxis_eos_files(layers, registry, paleos_companions=True) +def _hide_paleos(monkeypatch): + """Make the paleos package look absent to importlib, as on an install without it.""" + import importlib.util + + real = importlib.util.find_spec + monkeypatch.setattr( + importlib.util, + 'find_spec', + lambda name, *a, **k: None if name == 'paleos' else real(name, *a, **k), + ) + + def test_check_eos_files_stops_when_the_paleos_api_resolver_is_missing(tmp_path, monkeypatch): """A PALEOS-API layer table or companion that cannot be materialised stops the run with its own line, since `proteus get` does not fetch it: without the paleos package @@ -898,7 +910,7 @@ def test_check_eos_files_stops_when_the_paleos_api_resolver_is_missing(tmp_path, hang = types.ModuleType('zalmoxis.eos.paleos_api_cache') hang.resolve_registry_entry = MagicMock(side_effect=AssertionError('resolver called')) monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', hang) - monkeypatch.setattr(zmod, '_paleos_installed', lambda: False) + _hide_paleos(monkeypatch) with pytest.raises(ZalmoxisMissingEOSFilesError) as excinfo: check_zalmoxis_eos_files(layers, registry, paleos_companions=True) msg = str(excinfo.value) @@ -951,7 +963,7 @@ def test_the_paleos_api_pair_lookup_needs_paleos_and_never_names_a_download(monk hang = types.ModuleType('zalmoxis.eos.paleos_api_cache') hang.resolve_registry_entry = MagicMock(side_effect=AssertionError('resolver called')) monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', hang) - monkeypatch.setattr(zmod, '_paleos_installed', lambda: False) + _hide_paleos(monkeypatch) pair = {r: {'format': 'paleos_api_2phase'} for r in ('solid_mantle', 'melted_mantle')} registry = {'PALEOS-API-2phase:MgSiO3': pair} assert zmod.resolve_2phase_mgsio3_paths('PALEOS-API:MgSiO3', registry) == (None, None) @@ -962,20 +974,43 @@ def test_the_paleos_api_pair_lookup_needs_paleos_and_never_names_a_download(monk hang.resolve_registry_entry.assert_not_called() +def test_ps_table_inputs_needs_paleos_for_a_paleos_api_mantle(monkeypatch): + """The P-S table inputs of a PALEOS-API mantle go through the guarded resolver, so + without paleos they stop at once instead of starting the resolver's worker pool.""" + import sys + import types + + from proteus.interior_struct import zalmoxis as zmod + + hang = types.ModuleType('zalmoxis.eos.paleos_api_cache') + hang.resolve_registry_entry = MagicMock(side_effect=AssertionError('resolver called')) + monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', hang) + _hide_paleos(monkeypatch) + entry = {'format': 'paleos_api', 'material': 'mgsio3'} + with pytest.raises(zmod.ZalmoxisMissingEOSFilesError, match='paleos package'): + zmod._ps_table_inputs(MagicMock(), 'PALEOS-API:MgSiO3', entry, {}) + hang.resolve_registry_entry.assert_not_called() + + def test_a_removed_paleos_api_table_is_built_again(tmp_path, monkeypatch): - """A PALEOS-API table removed after its entry was resolved is reported as not built, - never as a download, and the next check resolves the entry again.""" + """A PALEOS-API table removed after its entry was resolved is built again by the next + check; a build that leaves no table is reported as not built, never as a download.""" import sys import types from proteus.interior_struct import zalmoxis as zmod table = tmp_path / 'api.dat' - table.write_text('eos table stub') + builds = [] + + def _build(entry): + if len(builds) < 2: + table.write_text('eos table stub') + builds.append(1) + entry.update(eos_file=str(table), format='paleos_unified') + fake = types.ModuleType('zalmoxis.eos.paleos_api_cache') - fake.resolve_registry_entry = MagicMock( - side_effect=lambda entry: entry.update(eos_file=str(table), format='paleos_unified') - ) + fake.resolve_registry_entry = _build monkeypatch.setitem(sys.modules, 'zalmoxis.eos.paleos_api_cache', fake) monkeypatch.setattr(zmod, '_paleos_installed', lambda: True) @@ -984,14 +1019,16 @@ def fresh(): layers = {'mantle': 'PALEOS-API:MgSiO3'} assert zmod.check_zalmoxis_eos_files(layers, fresh()) is None + assert zmod.check_zalmoxis_eos_files(layers, fresh()) is None + assert len(builds) == 1 + table.unlink() + assert zmod.check_zalmoxis_eos_files(layers, fresh()) is None + assert len(builds) == 2 and table.is_file() table.unlink() with pytest.raises(zmod.ZalmoxisMissingEOSFilesError) as excinfo: zmod.check_zalmoxis_eos_files(layers, fresh()) assert str(excinfo.value).startswith('PALEOS-API tables not built:') assert 'proteus get' not in str(excinfo.value) - table.write_text('eos table stub') - assert zmod.check_zalmoxis_eos_files(layers, fresh()) is None - assert fake.resolve_registry_entry.call_count == 2 @pytest.mark.parametrize('missing', ['iron', 'solid', 'h2o']) diff --git a/tests/test_proteus.py b/tests/test_proteus.py index 87f64f78b..3a963ff60 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -290,6 +290,7 @@ def test_require_paleos_tables_runs_only_for_the_zalmoxis_structure( ('missing', 'start check'), ('melting curve', 'solve'), ('P-S table', 'solve'), + ('melting curve class', 'solve'), ], ) def test_a_missing_eos_table_anywhere_in_the_run_writes_status_20( @@ -307,7 +308,10 @@ def test_a_missing_eos_table_anywhere_in_the_run_writes_status_20( monkeypatch.setattr(data, 'FWL_DATA_DIR', tmp_path / 'fwl') monkeypatch.setattr(spider, 'find_lookup_table_dir', lambda: None) + from proteus.interior_energetics.common import MissingMeltingCurveError + real_site = { + 'melting curve class': MissingMeltingCurveError('melting curves not found'), 'melting curve': lambda *a, **k: data.get_zalmoxis_melting_curves( NS(interior_struct=NS(melting_dir='Monteux-600')) ), From d82a74a616f4b55e7e7ec51bd575daca7304d15f Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 21:27:56 +0200 Subject: [PATCH 70/76] Regenerate the module version badges The fwl-mors badge now shows the >=26.9.23 pin from pyproject.toml. --- docs/Reference/module_versions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Reference/module_versions.md b/docs/Reference/module_versions.md index a7bea0989..14013be3f 100644 --- a/docs/Reference/module_versions.md +++ b/docs/Reference/module_versions.md @@ -19,7 +19,7 @@ in `[project] dependencies`. Click a badge to view the pinned release. | Module | Role | Pin | Docs | |--------|------|-----|------| | fwl-janus | 1D convective atmosphere | [![fwl-janus](https://img.shields.io/badge/fwl--janus-%3E%3D24.11.05-blue)](https://pypi.org/project/fwl-janus/24.11.05/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/JANUS/) | -| fwl-mors | Stellar evolution | [![fwl-mors](https://img.shields.io/badge/fwl--mors-%3E%3D26.01.02-blue)](https://pypi.org/project/fwl-mors/26.01.02/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/MORS/) | +| fwl-mors | Stellar evolution | [![fwl-mors](https://img.shields.io/badge/fwl--mors-%3E%3D26.9.23-blue)](https://pypi.org/project/fwl-mors/26.9.23/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/MORS/) | | fwl-calliope | Volatile outgassing | [![fwl-calliope](https://img.shields.io/badge/fwl--calliope-%3E%3D26.06.01-blue)](https://pypi.org/project/fwl-calliope/26.06.01/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/CALLIOPE/) | | fwl-zephyrus | Atmospheric escape | [![fwl-zephyrus](https://img.shields.io/badge/fwl--zephyrus-%3E%3D25.03.11-blue)](https://pypi.org/project/fwl-zephyrus/25.03.11/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/ZEPHYRUS) | | fwl-aragog | Interior thermal evolution | [![fwl-aragog](https://img.shields.io/badge/fwl--aragog-%3E%3D26.09.18-blue)](https://pypi.org/project/fwl-aragog/26.09.18/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/aragog/) | From fea18c932e2d0aa9ce7e1af5c028db640c26db99 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 21:27:56 +0200 Subject: [PATCH 71/76] Run the doctor's relocate dry run only for a missing data set The relocate dry run hashes every dataset in the data tree, which made `proteus doctor` several seconds slower on every call. It now runs only when a data set is missing and its older-layout folder holds files, the one case where its result changes the proposed fix. --- src/proteus/doctor.py | 25 ++++++++++++++++--------- tests/test_doctor.py | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/proteus/doctor.py b/src/proteus/doctor.py index 37b3d8aa4..b2b38cc67 100644 --- a/src/proteus/doctor.py +++ b/src/proteus/doctor.py @@ -9,6 +9,7 @@ from __future__ import annotations import datetime +import functools import importlib.metadata import importlib.util import io @@ -346,18 +347,22 @@ def check_fwl_data() -> list[CheckResult]: A data set found only in the older layout is fixed with ``fwl-io relocate``, which moves it in place, rather than with a new download, but only when a - relocate dry run would move something there. + relocate dry run would move something there. The dry run hashes the whole + tree, so it runs only for a missing data set with an older-layout folder. """ results = [] fwl = os.environ.get('FWL_DATA') if not fwl or not os.path.isdir(fwl := os.path.expanduser(fwl)): return results - try: - from fwl_io.relocate import plan_relocations - movable = [Path(e.legacy_dir).resolve() for e in plan_relocations(fwl).ready] - except Exception: - movable = [] + @functools.cache + def movable(): + try: + from fwl_io.relocate import plan_relocations + + return [Path(e.legacy_dir).resolve() for e in plan_relocations(fwl).ready] + except Exception: + return [] expected = { 'atmos_clim/spectral_files': ('proteus get spectral', 'spectral_files'), @@ -366,9 +371,11 @@ def check_fwl_data() -> list[CheckResult]: for subdir, (fix, legacy) in expected.items(): path = os.path.join(fwl, subdir) old = os.path.join(fwl, legacy) - if any(p.is_relative_to(Path(old).resolve()) for p in movable): - fix = 'fwl-io relocate' - if os.path.isdir(path) and os.listdir(path): + present = os.path.isdir(path) and os.listdir(path) + if not present and os.path.isdir(old) and os.listdir(old): + if any(p.is_relative_to(Path(old).resolve()) for p in movable()): + fix = 'fwl-io relocate' + if present: results.append( CheckResult( name=f'FWL_DATA/{subdir}', diff --git a/tests/test_doctor.py b/tests/test_doctor.py index fc3eef473..3cf55d6ea 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -265,6 +265,8 @@ def test_an_older_layout_is_fixed_by_relocate(self, tmp_path, movable): from types import SimpleNamespace legacy = tmp_path / 'spectral_files' / 'Dayspring' / '48' + legacy.mkdir(parents=True) + (legacy / 'Dayspring.sf').touch() plan = SimpleNamespace(ready=[SimpleNamespace(legacy_dir=legacy)] if movable else []) with ( patch.dict(os.environ, {'FWL_DATA': str(tmp_path)}), @@ -276,6 +278,19 @@ def test_an_older_layout_is_fixed_by_relocate(self, tmp_path, movable): assert fixes['FWL_DATA/atmos_clim/spectral_files'] == want assert fixes['FWL_DATA/star/spectra'] == 'proteus get stellar' + def test_the_relocate_dry_run_runs_only_for_a_missing_set_with_older_data(self, tmp_path): + """The dry run hashes the whole tree, so it runs only when a data set is missing + and its older-layout folder holds files.""" + (tmp_path / 'atmos_clim' / 'spectral_files' / 'Dayspring').mkdir(parents=True) + (tmp_path / 'spectral_files' / 'Dayspring').mkdir(parents=True) + (tmp_path / 'spectral_files' / 'Dayspring' / 'old.sf').touch() + with ( + patch.dict(os.environ, {'FWL_DATA': str(tmp_path)}), + patch('fwl_io.relocate.plan_relocations') as planned, + ): + check_fwl_data() + planned.assert_not_called() + def test_relocate_is_proposed_through_a_symlinked_data_root(self, tmp_path): """FWL_DATA reached through a symlink still matches the resolved legacy folders a relocate dry run reports.""" From 05fa850b2cadccd880bb5b3c79a3cbd2f47416b5 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 21:55:44 +0200 Subject: [PATCH 72/76] Keep test_doctor independent of the environment summary A failing doctor check writes a failure log whose environment summary runs slow commands such as conda list, so the test hit its 30 s timeout in the pixi environment. The test stubs that summary and writes the log under tmp_path instead of the repository root. --- tests/test_cli.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 22ce39776..e752d10ef 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,14 +29,18 @@ def _solar_dir(root): @pytest.mark.unit -def test_doctor(): +def test_doctor(monkeypatch, tmp_path): """``proteus doctor`` renders its report and returns a health-coded exit. The exit code is 0 when the install is healthy and 1 when a check fails, so both are valid here depending on the environment; the report itself, with the package section and the AGNI and fwl-mors checks, is rendered either - way. A usage error or crash would exit 2 and skip the report. + way. A usage error or crash would exit 2 and skip the report. A failing check + writes a failure log, whose environment summary runs slow commands such as + ``conda list``, so the summary is stubbed and the log goes to ``tmp_path``. """ + monkeypatch.setattr('proteus.doctor._collect_environment_info', lambda: 'stub') + monkeypatch.chdir(tmp_path) # run PROTEUS doctor command response = runner.invoke(cli.doctor, []) From ad05603a84dbbca526ecf3f2089d4b08203e9f87 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 22:55:04 +0200 Subject: [PATCH 73/76] Stub conda in the no-env installer test The failure path of install.sh runs `conda list` to collect diagnostics, and in the pixi environment that call can exceed the 15 s test timeout. A stub conda first on PATH keeps the test to the error message, which still has to name conda and `conda create`. --- tests/test_installer.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index 263d2495a..9071959d7 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -136,15 +136,20 @@ def _run_preflight(bindir: Path) -> str: class TestPreflightChecks: """Verify pre-flight check failure modes.""" - def test_fails_without_conda_env(self): + def test_fails_without_conda_env(self, tmp_path): """Without CONDA_DEFAULT_ENV, the installer fails with conda instructions. Discrimination: the error must mention 'conda' so users know what to do. A generic error would not be actionable. """ + # A stub conda keeps the failure diagnostics (`conda list`) fast. + stub = tmp_path / 'conda' + stub.write_text('#!/bin/sh\nexit 0\n') + stub.chmod(0o755) env = os.environ.copy() env.pop('CONDA_DEFAULT_ENV', None) env.pop('CONDA_PREFIX', None) + env['PATH'] = f'{tmp_path}{os.pathsep}{env["PATH"]}' result = subprocess.run( ['bash', str(INSTALL_SH)], capture_output=True, From d98a338daafd62738f09d2bb0d8d4bf862863064 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 25 Sep 2026 23:42:56 +0200 Subject: [PATCH 74/76] Stub conda for every installer test The failure path of install.sh runs `conda list` for its diagnostics, and the first conda call on a CI runner can take longer than the 15 s test timeout. A module-level fixture now puts a no-op conda first on PATH for every test in the module, replacing the stub in the single no-env test. The tests still check the error messages, and install.sh is unchanged. --- tests/test_installer.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test_installer.py b/tests/test_installer.py index 9071959d7..a12e085f5 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -24,6 +24,16 @@ INSTALL_SH = PROTEUS_ROOT / 'install.sh' +@pytest.fixture(autouse=True) +def stub_conda(tmp_path_factory, monkeypatch): + """Put a no-op conda first on PATH, so the failure diagnostics do not wait on a cold conda.""" + bindir = tmp_path_factory.mktemp('stub_conda') + stub = bindir / 'conda' + stub.write_text('#!/bin/sh\nexit 0\n') + stub.chmod(0o755) + monkeypatch.setenv('PATH', f'{bindir}{os.pathsep}{os.environ["PATH"]}') + + class TestInstallerArgParsing: """Verify argument parsing and help output.""" @@ -136,20 +146,15 @@ def _run_preflight(bindir: Path) -> str: class TestPreflightChecks: """Verify pre-flight check failure modes.""" - def test_fails_without_conda_env(self, tmp_path): + def test_fails_without_conda_env(self): """Without CONDA_DEFAULT_ENV, the installer fails with conda instructions. Discrimination: the error must mention 'conda' so users know what to do. A generic error would not be actionable. """ - # A stub conda keeps the failure diagnostics (`conda list`) fast. - stub = tmp_path / 'conda' - stub.write_text('#!/bin/sh\nexit 0\n') - stub.chmod(0o755) env = os.environ.copy() env.pop('CONDA_DEFAULT_ENV', None) env.pop('CONDA_PREFIX', None) - env['PATH'] = f'{tmp_path}{os.pathsep}{env["PATH"]}' result = subprocess.run( ['bash', str(INSTALL_SH)], capture_output=True, From 82be0619d07503ea8d79cf53a536e13f678a895e Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 1 Oct 2026 10:17:13 +0200 Subject: [PATCH 75/76] Align the merged tests and output scan with the required data checks The spectral-cache tests patch require_spfile_path, the function the AGNI wrapper calls. The accretion main-loop test stubs the EOS table provision, which this branch runs at structure setup, so the test runs without melting curve files as the same test does on main. The output scan reads VOLATILE_EOS_MAP from proteus.utils.constants as a species domain, so the volatile profile reads in zalmoxis.py resolve to H2O and H2 and the output reference matches main. --- docs/Reference/output.md | 6 ++---- docs/Reference/output_schema.json | 14 ++------------ tests/accretion/test_wrapper.py | 1 + tests/atmos_clim/test_agni.py | 2 +- tools/_helpfile_scan.py | 1 + 5 files changed, 7 insertions(+), 17 deletions(-) diff --git a/docs/Reference/output.md b/docs/Reference/output.md index 73286cb3c..eb6d9b7b4 100644 --- a/docs/Reference/output.md +++ b/docs/Reference/output.md @@ -273,7 +273,7 @@ Each iteration carries the previous row forward and overwrites only the columns | `H2O_mol_liquid` | `mol` | number in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `H2O_mol_total` | `mol` | number in whole planet | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | | `H2O_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, utils | -| `H2O_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | +| `H2O_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | interior_struct, outgas | | `H2O_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | interior_energetics, interior_struct, outgas | | `H2O_kg_total` | `kg` | mass in whole planet | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas | | `H2O_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, atmos_clim, outgas | @@ -306,7 +306,7 @@ Each iteration carries the previous row forward and overwrites only the columns | `H2_mol_liquid` | `mol` | number in liquid mantle | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `H2_mol_total` | `mol` | number in whole planet | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | | `H2_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | interior_struct, outgas, utils | -| `H2_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy" | outgas | +| `H2_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy" | interior_struct, outgas | | `H2_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy" | interior_energetics, interior_struct, outgas | | `H2_kg_total` | `kg` | mass in whole planet | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | interior_struct, outgas | | `H2_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, atmos_clim, outgas | @@ -1049,8 +1049,6 @@ A computed key is a helpfile column name constructed dynamically at runtime thro - `src/proteus/escape/common.py::calc_unfract_fluxes`: `dynamic key e + key` (touches _kg_total, _kg_atm) - `src/proteus/escape/wrapper.py::escapable_mass`: `dynamic key f'{e}{key}'` (touches _kg_total, _kg_atm) - `src/proteus/escape/wrapper.py::calc_new_elements`: `dynamic key f'{e}{key}'` (touches _kg_total, _kg_atm) -- `src/proteus/interior_struct/zalmoxis.py::build_volatile_profile`: `template _kg_liquid` -- `src/proteus/interior_struct/zalmoxis.py::build_volatile_profile`: `template _kg_solid` - `src/proteus/observe/petitRADTRANS.py::_get_mix`: `dynamic key key` (touches _vmr) - `src/proteus/outgas/atmodeller.py::_populate_volatile_element_reservoirs`: `dynamic key f'{sp}_kg_{r}'` (touches _kg_) - `src/proteus/outgas/atmodeller.py::calc_surface_pressures_atmodeller` (2 sites): `dynamic key key` (touches _kg_total) diff --git a/docs/Reference/output_schema.json b/docs/Reference/output_schema.json index 74110bb48..acf84c96e 100644 --- a/docs/Reference/output_schema.json +++ b/docs/Reference/output_schema.json @@ -3199,6 +3199,7 @@ } ], "consumers": [ + "interior_struct", "outgas" ], "consumers_possible": [] @@ -4288,6 +4289,7 @@ } ], "consumers": [ + "interior_struct", "outgas" ], "consumers_possible": [] @@ -24748,18 +24750,6 @@ "reason": "dynamic key f'{e}{key}'", "function": "calc_new_elements" }, - { - "file": "src/proteus/interior_struct/zalmoxis.py", - "kind": "read", - "reason": "template _kg_liquid", - "function": "build_volatile_profile" - }, - { - "file": "src/proteus/interior_struct/zalmoxis.py", - "kind": "read", - "reason": "template _kg_solid", - "function": "build_volatile_profile" - }, { "file": "src/proteus/observe/petitRADTRANS.py", "kind": "read", diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 45ef6f9db..204dd8fb6 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -2211,6 +2211,7 @@ def mock_run_interior( staticmethod(lambda config, interior_o, outdir, hf_row=None: np.array([6000.0])), ) monkeypatch.setattr(zalmoxis, 'generate_spider_tables', lambda config, outdir: None) + monkeypatch.setattr(interior_wrapper, '_provide_spider_eos_tables', lambda *_a: None) data_dir.mkdir(parents=True, exist_ok=True) earlier_snap = data_dir / '0p000_int.nc' diff --git a/tests/atmos_clim/test_agni.py b/tests/atmos_clim/test_agni.py index db7efd4fe..3d73cce0d 100644 --- a/tests/atmos_clim/test_agni.py +++ b/tests/atmos_clim/test_agni.py @@ -586,7 +586,7 @@ def _fake_tmp_folder(): monkeypatch.setattr(agni_mod, 'convert', lambda _typ, value: value) monkeypatch.setattr(agni_mod, '_construct_voldict', lambda *_a, **_k: {'H2O': 1.0}) monkeypatch.setattr(agni_mod, 'sync_log_files', lambda *_a, **_k: None) - monkeypatch.setattr(agni_mod, 'get_spfile_path', lambda *_a, **_k: str(base_sf)) + monkeypatch.setattr(agni_mod, 'require_spfile_path', lambda *_a, **_k: str(base_sf)) monkeypatch.setattr(agni_mod, 'create_tmp_folder', _fake_tmp_folder) return SimpleNamespace( diff --git a/tools/_helpfile_scan.py b/tools/_helpfile_scan.py index 3ffd555f3..f9bb486f2 100644 --- a/tools/_helpfile_scan.py +++ b/tools/_helpfile_scan.py @@ -41,6 +41,7 @@ 'vol_gas_list', 'vol_element_list', 'vap_element_list', + 'VOLATILE_EOS_MAP', } # Backend functions whose returned dict the area wrapper merges into hf_row. From c25d4fb512a9b6dfd839be18f7adf83f7712632b Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 1 Oct 2026 11:24:48 +0200 Subject: [PATCH 76/76] Test the impact-time table provision and the crystallization check The accretion main-loop test counts the P-S table provisions and expects one at setup and one at the impact re-solve, with a real melting_dir set. A second test makes the provision at the impact raise MissingMeltingCurveError and checks that start() re-raises it, writes status 20 and leaves the impact row out of the helpfile. A third test spies on _check_crystallization and checks that start() calls it after the init stage and never during it. --- tests/accretion/test_wrapper.py | 103 ++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 17 deletions(-) diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 204dd8fb6..230816245 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -2120,22 +2120,8 @@ def _handler(module, time=300.0): ) -@pytest.mark.unit -@pytest.mark.physics_invariant -def test_main_loop_discards_preimpact_snapshot_on_impact_step(tmp_path, monkeypatch): - """The main loop discards the pre-impact snapshot when an impact occurs. - - Contract clause: when an impact occurs during a step, the interior - snapshot written at the start of that step predates the mantle remelt. - The main loop must call discard_preimpact_snapshot to remove the stale - snapshot while preserving earlier valid snapshots. - - Verifies: - - The snapshot written on the impact step was initially created. - - The impact landed and delivered rock mass. - - The snapshot written on the impact step is removed from disk. - - Earlier valid snapshots remain intact for resuming. - """ +def _impact_loop_runner(tmp_path, monkeypatch, provide_tables): + """Build a 2-step aragog run on the dummy structure with one dummy impact at 2 yr.""" from pathlib import Path import numpy as np @@ -2156,6 +2142,7 @@ def test_main_loop_discards_preimpact_snapshot_on_impact_step(tmp_path, monkeypa runner = Proteus(config_path=cfg) runner.config.interior_energetics.module = 'aragog' + runner.config.interior_struct.melting_dir = 'Monteux-600' runner.config.atmos_chem.module = None runner.config.params.stop.time.minimum = 0.0 runner.config.params.stop.time.maximum = 2.0 @@ -2211,12 +2198,37 @@ def mock_run_interior( staticmethod(lambda config, interior_o, outdir, hf_row=None: np.array([6000.0])), ) monkeypatch.setattr(zalmoxis, 'generate_spider_tables', lambda config, outdir: None) - monkeypatch.setattr(interior_wrapper, '_provide_spider_eos_tables', lambda *_a: None) + monkeypatch.setattr(interior_wrapper, '_provide_spider_eos_tables', provide_tables) data_dir.mkdir(parents=True, exist_ok=True) earlier_snap = data_dir / '0p000_int.nc' earlier_snap.write_text('initial snapshot') + return runner, data_dir, written_snapshots, earlier_snap + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_main_loop_discards_preimpact_snapshot_on_impact_step(tmp_path, monkeypatch): + """The main loop discards the pre-impact snapshot when an impact occurs. + Contract clause: when an impact occurs during a step, the interior + snapshot written at the start of that step predates the mantle remelt. + The main loop must call discard_preimpact_snapshot to remove the stale + snapshot while preserving earlier valid snapshots. + + Verifies: + - The snapshot written on the impact step was initially created. + - The impact landed and delivered rock mass. + - The snapshot written on the impact step is removed from disk. + - Earlier valid snapshots remain intact for resuming. + - The P-S tables are provided at setup and again at the impact re-solve. + """ + from proteus.utils.helper import format_subyear_time + + calls = [] + runner, data_dir, written_snapshots, earlier_snap = _impact_loop_runner( + tmp_path, monkeypatch, lambda *a: calls.append(a) + ) runner.start(resume=False, offline=True) expected_impact_snap = data_dir / f'{format_subyear_time(2.0)}_int.nc' @@ -2231,6 +2243,63 @@ def mock_run_interior( assert not expected_impact_snap.exists(), ( 'impact step snapshot must be discarded by main loop' ) + assert len(calls) == 2, 'tables are provided at setup and at the impact re-solve' + + +@pytest.mark.unit +def test_main_loop_checks_crystallization_only_after_the_init_stage(tmp_path, monkeypatch): + """start() calls _check_crystallization on every step after the init stage and never + during it, where the init stage recalculates the volatile targets instead.""" + import proteus.outgas.wrapper as outgas_wrapper + from proteus import Proteus + + runner, *_ = _impact_loop_runner(tmp_path, monkeypatch, lambda *a: None) + targets, checks = [], [] + real_targets, real_check = ( + outgas_wrapper.calc_target_elemental_inventories, + Proteus._check_crystallization, + ) + + def count_targets(*args): + targets.append(runner.init_stage) + return real_targets(*args) + + def spy_check(self): + checks.append(self.init_stage) + return real_check(self) + + monkeypatch.setattr(outgas_wrapper, 'calc_target_elemental_inventories', count_targets) + monkeypatch.setattr(Proteus, '_check_crystallization', spy_check) + runner.start(resume=False, offline=True) + + assert any(targets), 'the init stage must run first' + assert len(checks) >= 1 and not any(checks) + + +@pytest.mark.unit +def test_missing_melting_curves_at_the_impact_stop_the_run_with_status_20( + tmp_path, monkeypatch +): + """A melting curve missing at the impact's structure re-solve stops start() + with status 20, before the impact step reaches the helpfile.""" + from proteus.interior_energetics.common import MissingMeltingCurveError + + calls = [] + + def provide_tables(*args): + calls.append(args) + if len(calls) == 2: + raise MissingMeltingCurveError('melting curves missing at the impact') + + runner, *_ = _impact_loop_runner(tmp_path, monkeypatch, provide_tables) + with pytest.raises(MissingMeltingCurveError, match='at the impact'): + runner.start(resume=False, offline=True) + + assert len(calls) == 2 + status = (tmp_path / 'run' / 'status').read_text().splitlines()[0] + assert status == '20' + assert (runner.hf_all['M_accreted_rock'] == 0.0).all(), 'no impact row is written' + assert runner.hf_row['n_impacts_applied'] == 1 @pytest.mark.unit