diff --git a/carsus/io/literature/__init__.py b/carsus/io/literature/__init__.py new file mode 100644 index 000000000..1f38f95b5 --- /dev/null +++ b/carsus/io/literature/__init__.py @@ -0,0 +1,9 @@ +""" +Literature data readers for the TARDIS/Carsus codebase. +""" + +from carsus.io.literature.seaton import get_seaton_opacity_df + +__all__ = [ + 'get_seaton_opacity_df', +] diff --git a/carsus/io/literature/seaton.py b/carsus/io/literature/seaton.py new file mode 100644 index 000000000..a38390e9a --- /dev/null +++ b/carsus/io/literature/seaton.py @@ -0,0 +1,70 @@ +""" +Reader for Seaton opacity data (Seaton 1992). + +This module provides functionality to read continuum opacity data from +the Seaton archive (VI/80 at CDS). +""" + +import pandas as pd +import requests +import io +import gzip + + +def get_seaton_opacity_df(file_name): + """ + Load and parse a Seaton opacity table from the CDS archive. + + Parameters + ---------- + file_name : str + The name of the gzip-compressed file to load from the Seaton + archive (e.g., 's92.201.gz'). + + Returns + ------- + pandas.DataFrame + A DataFrame with columns: + - logT : float + Log10 of temperature in Kelvin + - logNe : float + Log10 of electron density + - kappa_planck : float + Planck-weighted mean opacity + - kappa_rosseland : float + Rosseland-weighted mean opacity + """ + url = f"https://cdsarc.cds.unistra.fr/ftp/VI/80/{file_name}" + response = requests.get(url) + + with gzip.GzipFile(fileobj=io.BytesIO(response.content)) as f: + lines = [line.decode('utf-8').strip() for line in f.readlines() if line.strip()] + + data_rows = [] + current_log_t = None + + # Standard Seaton format: logT = Index / 40 + for line in lines[1:]: # Skip metadata line + parts = line.split() + if not parts: + continue + + # Detect Block Header: (e.g., 140 14 68 2) + # These define the constant log(T) for the following rows + if len(parts) == 4 and float(parts[0]) >= 140 and '.' not in parts[0]: + current_log_t = float(parts[0]) / 40.0 + continue + + # Parse Data Rows: [Index] [logNe] [Planck_Opacity] [Rosseland_Opacity] + if current_log_t is not None and len(parts) >= 4: + try: + data_rows.append({ + 'logT': current_log_t, + 'logNe': float(parts[1]), + 'kappa_planck': float(parts[2]), + 'kappa_rosseland': float(parts[3]) + }) + except ValueError: + continue + + return pd.DataFrame(data_rows) diff --git a/carsus/io/literature/tests/data/expected_head.csv b/carsus/io/literature/tests/data/expected_head.csv new file mode 100644 index 000000000..fd74009d0 --- /dev/null +++ b/carsus/io/literature/tests/data/expected_head.csv @@ -0,0 +1,6 @@ +logT,logNe,kappa_planck,kappa_rosseland +3.5,-4.0,0.123,0.115 +3.5,-3.5,0.145,0.132 +3.5,-3.0,0.167,0.15 +3.5,-2.5,0.181,0.162 +3.5,-2.0,0.195,0.175 diff --git a/carsus/io/literature/tests/data/s92.201.sample.gz b/carsus/io/literature/tests/data/s92.201.sample.gz new file mode 100644 index 000000000..bc5aad042 Binary files /dev/null and b/carsus/io/literature/tests/data/s92.201.sample.gz differ diff --git a/carsus/io/literature/tests/test_seaton.py b/carsus/io/literature/tests/test_seaton.py new file mode 100644 index 000000000..723c87682 --- /dev/null +++ b/carsus/io/literature/tests/test_seaton.py @@ -0,0 +1,42 @@ +""" +Tests for Seaton opacity reader. +""" + +import gzip +import io +from unittest import mock + +import pandas as pd +import pytest + +from carsus.io.literature import get_seaton_opacity_df + + +def create_sample_data(): + """Create gzip-compressed sample Seaton data.""" + sample_lines = [ + "OP Version 2.0 S92 201", + "140 14 68 2", + "14 -4.0 1.23e-1 1.15e-1", + "16 -3.5 1.45e-1 1.32e-1", + ] + buffer = io.BytesIO() + with gzip.GzipFile(fileobj=buffer, mode='wb') as f: + f.write("\n".join(sample_lines).encode('utf-8')) + return buffer.getvalue() + + +@mock.patch('carsus.io.literature.seaton.requests.get') +def test_get_seaton_opacity_df(mock_get): + """Test reading Seaton opacity data.""" + mock_get.return_value.content = create_sample_data() + + df = get_seaton_opacity_df("s92.201.gz") + + assert isinstance(df, pd.DataFrame) + assert len(df) == 2 + assert list(df.columns) == ['logT', 'logNe', 'kappa_planck', 'kappa_rosseland'] + assert (df['kappa_planck'] > 0).all() + assert (df['kappa_rosseland'] > 0).all() + + diff --git a/carsus/io/literature/tests/test_seaton_regression.py b/carsus/io/literature/tests/test_seaton_regression.py new file mode 100644 index 000000000..fb87117fd --- /dev/null +++ b/carsus/io/literature/tests/test_seaton_regression.py @@ -0,0 +1,32 @@ +"""Regression tests for the Seaton opacity parser.""" + +from pathlib import Path +from unittest import mock + +import pandas as pd +from pandas.testing import assert_frame_equal + +from carsus.io.literature import get_seaton_opacity_df + + +def _load_sample_gz_bytes(): + data_dir = Path(__file__).parent / "data" + sample_path = data_dir / "s92.201.sample.gz" + return sample_path.read_bytes() + + +def _load_expected_head(): + data_dir = Path(__file__).parent / "data" + expected_path = data_dir / "expected_head.csv" + return pd.read_csv(expected_path) + + +@mock.patch("carsus.io.literature.seaton.requests.get") +def test_get_seaton_opacity_df_regression_head(mock_get): + """Ensure parser output head remains stable for known Seaton sample input.""" + mock_get.return_value.content = _load_sample_gz_bytes() + + df = get_seaton_opacity_df("s92.201.gz") + expected_head = _load_expected_head() + + assert_frame_equal(df.head(5).reset_index(drop=True), expected_head)