diff --git a/src/pleiades/sammy/parameters/__init__.py b/src/pleiades/sammy/parameters/__init__.py index c003c5f..04fdf9d 100644 --- a/src/pleiades/sammy/parameters/__init__.py +++ b/src/pleiades/sammy/parameters/__init__.py @@ -1 +1,3 @@ +from pleiades.sammy.parameters.broadening import BroadeningParameterCard # noqa: F401 +from pleiades.sammy.parameters.external_r import ExternalREntry # noqa: F401 from pleiades.sammy.parameters.resonance import ResonanceEntry # noqa: F401 diff --git a/src/pleiades/sammy/parameters/broadening.py b/src/pleiades/sammy/parameters/broadening.py index 18577bd..ca893df 100644 --- a/src/pleiades/sammy/parameters/broadening.py +++ b/src/pleiades/sammy/parameters/broadening.py @@ -194,12 +194,12 @@ def to_lines(self) -> List[str]: # Format main parameter line main_parts = [ - format_float(self.crfn, width=10), - format_float(self.temp, width=10), - format_float(self.thick, width=10), - format_float(self.deltal, width=10), - format_float(self.deltag, width=10), - format_float(self.deltae, width=10), + format_float(self.crfn, width=9), + format_float(self.temp, width=9), + format_float(self.thick, width=9), + format_float(self.deltal, width=9), + format_float(self.deltag, width=9), + format_float(self.deltae, width=9), format_vary(self.flag_crfn), format_vary(self.flag_temp), format_vary(self.flag_thick), @@ -207,7 +207,7 @@ def to_lines(self) -> List[str]: format_vary(self.flag_deltag), format_vary(self.flag_deltae), ] - lines.append("".join(main_parts)) + lines.append(" ".join(main_parts)) # Add uncertainties line if any uncertainties are present if any(getattr(self, f"d_{param}") is not None for param in ["crfn", "temp", "thick", "deltal", "deltag", "deltae"]): diff --git a/src/pleiades/sammy/parameters/helper.py b/src/pleiades/sammy/parameters/helper.py index fd4ede0..b00fcf1 100644 --- a/src/pleiades/sammy/parameters/helper.py +++ b/src/pleiades/sammy/parameters/helper.py @@ -36,8 +36,21 @@ def format_float(value: Optional[float], width: int = 11) -> str: """Helper to format float values in fixed width with proper spacing""" if value is None: return " " * width - # Format with proper scientific notation and alignment - return f"{value:<{width}.4E}" + + # Subtract 5 characters for "E+xx" (scientific notation exponent) + # The rest is for the significant digits (1 before the dot and decimals) + max_decimals = max(0, width - 6) # At least room for "0.E+00" + + # Create a format string with dynamic precision + format_str = f"{{:.{max_decimals}E}}" + formatted = format_str.format(value) + + # Ensure the string fits the width + if len(formatted) > width: + raise ValueError(f"Cannot format value {value} to fit in {width} characters.") + + # Align to the left if required + return f"{formatted:<{width}}" def format_vary(value: VaryFlag) -> str: diff --git a/src/pleiades/sammy/parfile.py b/src/pleiades/sammy/parfile.py new file mode 100644 index 0000000..774b3bf --- /dev/null +++ b/src/pleiades/sammy/parfile.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +"""Top level parameter file handler for SAMMY.""" + +from typing import Optional + +from pydantic import BaseModel, Field + +from pleiades.sammy.parameters import ( + BroadeningParameterCard, + ExternalREntry, + ResonanceEntry, +) + + +class SammyParameterFile(BaseModel): + """Top level parameter file for SAMMY.""" + + resonance: ResonanceEntry = Field(description="Resonance parameters") + fudge: float = Field(0.1, description="Fudge factor", ge=0.0, le=1.0) + # Add additional optional cards + external_r: Optional[ExternalREntry] = Field(None, description="External R matrix") + broadening: Optional[BroadeningParameterCard] = Field(None, description="Broadening parameters") + + @classmethod + def from_file(cls, file_path): + """Load a SAMMY parameter file from disk.""" + with open(file_path, "r") as f: + lines = f.readlines() + + # Parse resonance card + resonance = ResonanceEntry.from_str(lines[0]) + + return cls(resonance=resonance) + + +if __name__ == "__main__": + print("TODO: usage example for SAMMY parameter file handling") diff --git a/tests/unit/pleiades/sammy/parameters/test_broadening.py b/tests/unit/pleiades/sammy/parameters/test_broadening.py new file mode 100644 index 0000000..b223720 --- /dev/null +++ b/tests/unit/pleiades/sammy/parameters/test_broadening.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python +"""Unit tests for card 04::broadening parameters.""" + +import pytest + +from pleiades.sammy.parameters.broadening import BroadeningParameterCard, BroadeningParameters +from pleiades.sammy.parameters.helper import VaryFlag + +# Test data with proper 10-char width formatting +MAIN_ONLY_LINE = "1.234E+00 2.980E+02 1.500E-01 2.500E-02 1.000E+00 5.000E-01 1 0 1 0 1 0" + +WITH_UNC_LINES = [ + "1.234E+00 2.980E+02 1.500E-01 2.500E-02 1.000E+00 5.000E-01 1 0 1 0 1 0", + "1.000E-02 1.000E+00 1.000E-03 1.000E-03 1.000E-02 1.000E-02", +] + +FULL_LINES = [ + "1.234E+00 2.980E+02 1.500E-01 2.500E-02 1.000E+00 5.000E-01 1 0 1 0 1 0", + "1.000E-02 1.000E+00 1.000E-03 1.000E-03 1.000E-02 1.000E-02", + "1.000E-01 2.000E-02 1 1", + "5.000E-03 1.000E-03", +] + +COMPLETE_CARD = [ + "BROADening parameters may be varied", + "1.234E+00 2.980E+02 1.500E-01 2.500E-02 1.000E+00 5.000E-01 1 0 1 0 1 0", + "1.000E-02 1.000E+00 1.000E-03 1.000E-03 1.000E-02 1.000E-02", + "1.000E-01 2.000E-02 1 1", + "5.000E-03 1.000E-03", + "", +] + + +def test_main_parameters_parsing(): + """Test parsing of main parameters only.""" + params = BroadeningParameters.from_lines([MAIN_ONLY_LINE]) + + # Check values + assert params.crfn == pytest.approx(1.234) + assert params.temp == pytest.approx(298.0) + assert params.thick == pytest.approx(0.15) + assert params.deltal == pytest.approx(0.025) + assert params.deltag == pytest.approx(1.0) + assert params.deltae == pytest.approx(0.5) + + # Check flags + assert params.flag_crfn == VaryFlag.YES + assert params.flag_temp == VaryFlag.NO + assert params.flag_thick == VaryFlag.YES + assert params.flag_deltal == VaryFlag.NO + assert params.flag_deltag == VaryFlag.YES + assert params.flag_deltae == VaryFlag.NO + + # Check optional fields are None + assert params.deltc1 is None + assert params.deltc2 is None + assert params.d_crfn is None + assert params.d_temp is None + + +def test_parameters_with_uncertainties(): + """Test parsing of parameters with uncertainties.""" + params = BroadeningParameters.from_lines(WITH_UNC_LINES) + + # Check main values + assert params.crfn == pytest.approx(1.234) + assert params.temp == pytest.approx(298.0) + + # Check uncertainties + assert params.d_crfn == pytest.approx(0.01) + assert params.d_temp == pytest.approx(1.0) + assert params.d_thick == pytest.approx(0.001) + assert params.d_deltal == pytest.approx(0.001) + assert params.d_deltag == pytest.approx(0.01) + assert params.d_deltae == pytest.approx(0.01) + + +def test_full_parameters(): + """Test parsing of full parameter set including Gaussian parameters.""" + params = BroadeningParameters.from_lines(FULL_LINES) + + # Check Gaussian parameters + assert params.deltc1 == pytest.approx(0.1) + assert params.deltc2 == pytest.approx(0.02) + assert params.d_deltc1 == pytest.approx(0.005) + assert params.d_deltc2 == pytest.approx(0.001) + assert params.flag_deltc1 == VaryFlag.YES + assert params.flag_deltc2 == VaryFlag.YES + + +def test_format_compliance(): + """Test that output lines comply with fixed-width format.""" + params = BroadeningParameters.from_lines(FULL_LINES) + output_lines = params.to_lines() + + print(output_lines) + + # Check first line field widths + first_line = output_lines[0] + assert len(first_line[:10].rstrip()) == 9 # 9 chars + 1 space + assert len(first_line[10:20].rstrip()) == 9 + assert len(first_line[20:30].rstrip()) == 9 + assert len(first_line[30:40].rstrip()) == 9 + assert len(first_line[40:50].rstrip()) == 9 + assert len(first_line[50:60].rstrip()) == 9 + + +def test_complete_card(): + """Test parsing and formatting of complete card including header.""" + card = BroadeningParameterCard.from_lines(COMPLETE_CARD) + output_lines = card.to_lines() + + # Check header + assert output_lines[0].startswith("BROAD") + + # Check number of lines + assert len(output_lines) == 6 # Header + 4 data lines + blank + + # Check last line is blank + assert output_lines[-1].strip() == "" + + +def test_invalid_header(): + """Test error handling for invalid header.""" + bad_lines = ["WRONG header", MAIN_ONLY_LINE] + with pytest.raises(ValueError, match="Invalid header"): + BroadeningParameterCard.from_lines(bad_lines) + + +def test_missing_gaussian_parameter(): + """Test error handling for incomplete Gaussian parameters.""" + bad_lines = [ + MAIN_ONLY_LINE, + "1.000E-02 1.000E+00 1.000E-03 1.000E-03 1.000E-02 1.000E-02", + "1.000E-01 1", # Missing DELTC2 + ] + with pytest.raises(ValueError, match="Both DELTC1 and DELTC2 must be present"): + BroadeningParameters.from_lines(bad_lines) + + +def test_empty_input(): + """Test error handling for empty input.""" + with pytest.raises(ValueError, match="No valid parameter line provided"): + BroadeningParameters.from_lines([]) + + +def test_roundtrip(): + """Test that parsing and then formatting produces identical output.""" + card = BroadeningParameterCard.from_lines(COMPLETE_CARD) + output_lines = card.to_lines() + + # Parse the output again + reparsed_card = BroadeningParameterCard.from_lines(output_lines) + + # Compare all attributes + assert card.parameters.crfn == reparsed_card.parameters.crfn + assert card.parameters.temp == reparsed_card.parameters.temp + assert card.parameters.deltc1 == reparsed_card.parameters.deltc1 + assert card.parameters.flag_crfn == reparsed_card.parameters.flag_crfn + + +if __name__ == "__main__": + pytest.main(["-v", __file__])