From 2da5efa558da8d868d8091abf04e86c55130eccd Mon Sep 17 00:00:00 2001 From: Chen Zhang Date: Mon, 10 Feb 2025 16:13:47 -0500 Subject: [PATCH 1/8] add to_string method for top level --- src/pleiades/sammy/parfile.py | 108 +++++++++++++++++++++++++++++----- 1 file changed, 93 insertions(+), 15 deletions(-) diff --git a/src/pleiades/sammy/parfile.py b/src/pleiades/sammy/parfile.py index c05c7ed..5173df6 100644 --- a/src/pleiades/sammy/parfile.py +++ b/src/pleiades/sammy/parfile.py @@ -1,6 +1,7 @@ #!/usr/bin/env python """Top level parameter file handler for SAMMY.""" +from enum import Enum, auto from typing import Optional from pydantic import BaseModel, Field @@ -20,34 +21,111 @@ ) +class CardOrder(Enum): + """Defines the standard order of cards in SAMMY parameter files. + + Order follows Table VI B.2 in the SAMMY documentation. + The order is relevant for writing files, though cards can be read in any order. + """ + + RESONANCE = auto() # Card Set 1: Resonance parameters + FUDGE = auto() # Card Set 2: Fudge factor + EXTERNAL_R = auto() # Card Set 3: External R-function parameters + BROADENING = auto() # Card Set 4: Broadening parameters + UNUSED_CORRELATED = auto() # Card Set 5: Unused but correlated variables + NORMALIZATION = auto() # Card Set 6: Normalization and background + RADIUS = auto() # Card Set 7/7a: Radius parameters + DATA_REDUCTION = auto() # Card Set 8: Data reduction parameters + ORRES = auto() # Card Set 9: Oak Ridge resolution function + ISOTOPE = auto() # Card Set 10: Isotopic/nuclide abundances + PARAMAGNETIC = auto() # Card Set 12: Paramagnetic cross section + # RESOLUTION = auto() # Card Set 14: Facility resolution functions, not implemented yet + USER_RESOLUTION = auto() # Card Set 16: User-defined resolution + + @classmethod + def get_field_name(cls, card_type: "CardOrder") -> str: + """Get the corresponding field name in SammyParameterFile for a card type. + + Args: + card_type: The card type enum value + + Returns: + str: Field name used in the parameter file class + """ + # Map enum values to field names + field_map = { + cls.RESONANCE: "resonance", + cls.FUDGE: "fudge", + cls.EXTERNAL_R: "external_r", + cls.BROADENING: "broadening", + cls.UNUSED_CORRELATED: "unused_correlated", + cls.NORMALIZATION: "normalization", + cls.RADIUS: "radius", + cls.DATA_REDUCTION: "data_reduction", + cls.ORRES: "orres", + cls.ISOTOPE: "isotope", + cls.PARAMAGNETIC: "paramagnetic", + # cls.RESOLUTION: "resolution", # Not implemented yet + cls.USER_RESOLUTION: "user_resolution", + } + return field_map[card_type] + + class SammyParameterFile(BaseModel): - """Top level parameter file for SAMMY.""" + """Top level parameter file for SAMMY. + + All components are optional as parameter files may contain different + combinations of cards based on the analysis needs. + """ - 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") + # REQUIRED CARDS + fudge: float = Field(default=0.1, description="Fudge factor for initial uncertainties", ge=0.0, le=1.0) + # OPTIONAL CARDS + resonance: Optional[ResonanceEntry] = Field(None, description="Resonance parameters") + external_r: Optional[ExternalREntry] = Field(None, description="External R matrix parameters") broadening: Optional[BroadeningParameterCard] = Field(None, description="Broadening parameters") unused_correlated: Optional[UnusedCorrelatedCard] = Field(None, description="Unused but correlated variables") normalization: Optional[NormalizationBackgroundCard] = Field(None, description="Normalization and background parameters") radius: Optional[RadiusCard] = Field(None, description="Radius parameters") data_reduction: Optional[DataReductionCard] = Field(None, description="Data reduction parameters") orres: Optional[ORRESCard] = Field(None, description="ORRES card parameters") - isotope: Optional[IsotopeCard] = Field(None, description="Isotope parameters") paramagnetic: Optional[ParamagneticParameters] = Field(None, description="Paramagnetic parameters") - reonance: Optional[ResonanceEntry] = Field(None, description="Resonance parameters") user_resolution: Optional[UserResolutionParameters] = Field(None, description="User-defined resolution function parameters") + # TODO: Need to verify by Sammy experts on whether the following are mandatory or optional + isotope: Optional[IsotopeCard] = Field(None, description="Isotope 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() + def to_string(self) -> str: + """Convert parameter file to string format. + + Returns: + str: Parameter file content in SAMMY fixed-width format + + The output follows the standard card order from Table VI B.2. + Each card is separated by appropriate blank lines. + """ + lines = [] + + # Process each card type in standard order + for card_type in CardOrder: + field_name = CardOrder.get_field_name(card_type) + value = getattr(self, field_name) + + # Skip None values (optional cards not present) + if value is None: + continue + + # Special handling for fudge factor + if card_type == CardOrder.FUDGE: + lines.append(f"{value:10.4f}") + continue - # Parse resonance card - resonance = ResonanceEntry.from_str(lines[0]) + # For all other cards, use their to_lines() method + card_lines = value.to_lines() + if card_lines: # Only add non-empty line lists + lines.extend(card_lines) - return cls(resonance=resonance) + # Join all lines with newlines + return "\n".join(lines) if __name__ == "__main__": From 3d218d5310d9f9501f2591b61e0ef2c4eba60486 Mon Sep 17 00:00:00 2001 From: Chen Zhang Date: Wed, 12 Feb 2025 13:44:45 -0500 Subject: [PATCH 2/8] add from string and aux func to top param class --- src/pleiades/sammy/parameters/__init__.py | 2 +- src/pleiades/sammy/parfile.py | 117 +++++++++++++++++++++- tests/unit/pleiades/sammy/test_parfile.py | 117 ++++++++++++++++++++++ 3 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 tests/unit/pleiades/sammy/test_parfile.py diff --git a/src/pleiades/sammy/parameters/__init__.py b/src/pleiades/sammy/parameters/__init__.py index e1caaba..d937191 100644 --- a/src/pleiades/sammy/parameters/__init__.py +++ b/src/pleiades/sammy/parameters/__init__.py @@ -1,6 +1,6 @@ from pleiades.sammy.parameters.broadening import BroadeningParameterCard # noqa: F401 from pleiades.sammy.parameters.data_reduction import DataReductionCard # noqa: F401 -from pleiades.sammy.parameters.external_r import ExternalREntry # noqa: F401 +from pleiades.sammy.parameters.external_r import ExternalREntry, ExternalRFunction # noqa: F401 from pleiades.sammy.parameters.isotope import IsotopeCard # noqa: F401 from pleiades.sammy.parameters.normalization import NormalizationBackgroundCard # noqa: F401 from pleiades.sammy.parameters.orres import ORRESCard # noqa: F401 diff --git a/src/pleiades/sammy/parfile.py b/src/pleiades/sammy/parfile.py index 5173df6..f017481 100644 --- a/src/pleiades/sammy/parfile.py +++ b/src/pleiades/sammy/parfile.py @@ -2,7 +2,7 @@ """Top level parameter file handler for SAMMY.""" from enum import Enum, auto -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, Field @@ -10,6 +10,7 @@ BroadeningParameterCard, DataReductionCard, ExternalREntry, + ExternalRFunction, IsotopeCard, NormalizationBackgroundCard, ORRESCard, @@ -127,6 +128,120 @@ def to_string(self) -> str: # Join all lines with newlines return "\n".join(lines) + @classmethod + def _get_card_class_with_header(cls, line: str): + """Get card class that matches the header line. + + Args: + line: Input line to check + + Returns: + tuple: (CardOrder enum, card class) if found, or (None, None) + """ + card_checks = [ + (CardOrder.BROADENING, BroadeningParameterCard), + (CardOrder.DATA_REDUCTION, DataReductionCard), + (CardOrder.EXTERNAL_R, ExternalRFunction), + (CardOrder.ISOTOPE, IsotopeCard), + (CardOrder.NORMALIZATION, NormalizationBackgroundCard), + (CardOrder.ORRES, ORRESCard), + (CardOrder.RADIUS, RadiusCard), + (CardOrder.USER_RESOLUTION, UserResolutionParameters), + (CardOrder.UNUSED_CORRELATED, UnusedCorrelatedCard), + (CardOrder.PARAMAGNETIC, ParamagneticParameters), + ] + + for card_type, card_class in card_checks: + if hasattr(card_class, "is_header_line") and card_class.is_header_line(line): + return card_type, card_class + + return None, None + + @classmethod + def from_string(cls, content: str) -> "SammyParameterFile": + """Parse parameter file content into SammyParameterFile object. + + Args: + content: String containing parameter file content + + Returns: + SammyParameterFile: Parsed parameter file object + + Raises: + ValueError: If content format is invalid + """ + lines = content.splitlines() + if not lines: + raise ValueError("Empty parameter file content") + + params = {} + current_card = None + card_lines = [] + + # Process lines + line_idx = 0 + while line_idx < len(lines): + line = lines[line_idx] + + # First non-empty line should be fudge factor + if not current_card and line.strip(): + try: + params["fudge"] = float(line.strip()) + line_idx += 1 + continue + except ValueError: + # Not a fudge factor, try other cards + pass + + # Check for card headers + card_type, card_class = cls._get_card_class_with_header(line) + if card_class: + # Process previous card if exists + if current_card and card_lines: + params[CardOrder.get_field_name(current_card)] = cls._parse_card(current_card, card_lines) + + # Start new card + current_card = card_type + card_lines = [line] + else: + # Not a header, add to current card if exists + if current_card: + card_lines.append(line) + + line_idx += 1 + + # Process final card + if current_card and card_lines: + params[CardOrder.get_field_name(current_card)] = cls._parse_card(current_card, card_lines) + + return cls(**params) + + @classmethod + def _parse_card(cls, card_type: CardOrder, lines: List[str]): + """Parse a card's lines into the appropriate object.""" + card_class = cls._get_card_class(card_type) + if not card_class: + raise ValueError(f"No parser implemented for card type: {card_type}") + return card_class.from_lines(lines) + + @classmethod + def _get_card_class(cls, card_type: CardOrder): + """Get the card class for a given card type.""" + card_map = { + CardOrder.RESONANCE: ResonanceEntry, + CardOrder.EXTERNAL_R: ExternalRFunction, + CardOrder.BROADENING: BroadeningParameterCard, + CardOrder.UNUSED_CORRELATED: UnusedCorrelatedCard, + CardOrder.NORMALIZATION: NormalizationBackgroundCard, + CardOrder.RADIUS: RadiusCard, + CardOrder.DATA_REDUCTION: DataReductionCard, + CardOrder.ORRES: ORRESCard, + CardOrder.ISOTOPE: IsotopeCard, + CardOrder.PARAMAGNETIC: ParamagneticParameters, + CardOrder.USER_RESOLUTION: UserResolutionParameters, + } + return card_map.get(card_type) + if __name__ == "__main__": print("TODO: usage example for SAMMY parameter file handling") diff --git a/tests/unit/pleiades/sammy/test_parfile.py b/tests/unit/pleiades/sammy/test_parfile.py new file mode 100644 index 0000000..6f9bc35 --- /dev/null +++ b/tests/unit/pleiades/sammy/test_parfile.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +"""Unit tests for SAMMY parameter file parsing.""" + +import pytest + +from pleiades.sammy.parameters.helper import VaryFlag +from pleiades.sammy.parfile import SammyParameterFile + + +@pytest.fixture +def basic_fudge_input(): + """Sample input with just fudge factor.""" + return "0.1000\n" + + +@pytest.fixture +def single_card_input(): + """Sample input with fudge factor and single broadening card.""" + return ( + "0.1000\n" + "BROADening parameters may be varied\n" + "1.234E+00 2.980E+02 1.500E-01 2.500E-02 1.000E+00 5.000E-01 1 0 1 0 1 0\n" + "1.000E-02 1.000E+00 1.000E-03 1.000E-03 1.000E-02 1.000E-02\n" + "\n" + ) + + +@pytest.fixture +def multi_card_input(): + """Sample input with multiple cards.""" + return ( + "0.1000\n" + "BROADening parameters may be varied\n" + "1.234E+00 2.980E+02 1.500E-01 2.500E-02 1.000E+00 5.000E-01 1 0 1 0 1 0\n" + "1.000E-02 1.000E+00 1.000E-03 1.000E-03 1.000E-02 1.000E-02\n" + "\n" + "NORMAlization and background are next\n" + "1.234E+00 2.980E+02 1.500E-01 2.500E-02 1.000E+00 5.000E-01 1 0 1 0 1 0\n" + "1.000E-02 1.000E+00 1.000E-03 1.000E-03 1.000E-02 1.000E-02\n" + "\n" + ) + + +def test_parse_fudge_only(basic_fudge_input): + """Test parsing file with only fudge factor.""" + parfile = SammyParameterFile.from_string(basic_fudge_input) + assert parfile.fudge == pytest.approx(0.1) + assert parfile.broadening is None + assert parfile.resonance is None + assert parfile.normalization is None + + +def test_parse_single_card(single_card_input): + """Test parsing file with fudge factor and single broadening card.""" + parfile = SammyParameterFile.from_string(single_card_input) + + # Check fudge factor + assert parfile.fudge == pytest.approx(0.1) + + # Check broadening card parsed correctly + assert parfile.broadening is not None + assert parfile.broadening.parameters.crfn == pytest.approx(1.234) + assert parfile.broadening.parameters.temp == pytest.approx(298.0) + assert parfile.broadening.parameters.flag_crfn == VaryFlag.YES + + # Verify other cards are None + assert parfile.resonance is None + assert parfile.normalization is None + assert parfile.radius is None + + +def test_parse_multi_card(multi_card_input): + """Test parsing file with multiple cards.""" + parfile = SammyParameterFile.from_string(multi_card_input) + + # Check fudge factor + assert parfile.fudge == pytest.approx(0.1) + + # Check broadening card + assert parfile.broadening is not None + assert parfile.broadening.parameters.crfn == pytest.approx(1.234) + + # Check normalization card + assert parfile.normalization is not None + assert parfile.normalization.angle_sets[0].anorm == pytest.approx(1.234) + + +def test_roundtrip_single_card(single_card_input): + """Test round-trip parsing and formatting of single card file.""" + parfile = SammyParameterFile.from_string(single_card_input) + output = parfile.to_string() + reparsed = SammyParameterFile.from_string(output) + + # Compare original and reparsed objects + assert parfile.fudge == reparsed.fudge + assert parfile.broadening.parameters.crfn == reparsed.broadening.parameters.crfn + assert parfile.broadening.parameters.temp == reparsed.broadening.parameters.temp + assert parfile.broadening.parameters.flag_crfn == reparsed.broadening.parameters.flag_crfn + + +# @pytest.mark.parametrize( +# "invalid_input,error_pattern", +# [ +# ("", "Empty parameter file content"), +# ("abc\n", "Invalid fudge factor"), +# ("0.1\nINVALID card header\n", "Invalid card header"), +# ("0.1\nBROADening parameters may be varied\nINVALID DATA\n", "Failed to parse parameter line"), +# ] +# ) +# def test_parse_errors(invalid_input, error_pattern): +# """Test error handling for invalid inputs.""" +# with pytest.raises(ValueError, match=error_pattern): +# SammyParameterFile.from_string(invalid_input) + + +if __name__ == "__main__": + pytest.main(["-v", __file__]) From cb7f20a273264929dce714967b2736cd8d0a32c8 Mon Sep 17 00:00:00 2001 From: Chen Zhang Date: Wed, 12 Feb 2025 14:22:46 -0500 Subject: [PATCH 3/8] fix unit basic unit tests --- src/pleiades/sammy/parfile.py | 10 ++++++++- tests/unit/pleiades/sammy/test_parfile.py | 27 ++++++++++++----------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/pleiades/sammy/parfile.py b/src/pleiades/sammy/parfile.py index f017481..e01af28 100644 --- a/src/pleiades/sammy/parfile.py +++ b/src/pleiades/sammy/parfile.py @@ -207,6 +207,9 @@ def from_string(cls, content: str) -> "SammyParameterFile": # Not a header, add to current card if exists if current_card: card_lines.append(line) + else: + # Line is not numeric, not header, and not part of any card + raise ValueError(f"Invalid content: {line}") line_idx += 1 @@ -222,7 +225,12 @@ def _parse_card(cls, card_type: CardOrder, lines: List[str]): card_class = cls._get_card_class(card_type) if not card_class: raise ValueError(f"No parser implemented for card type: {card_type}") - return card_class.from_lines(lines) + + try: + return card_class.from_lines(lines) + except Exception as e: + # Convert any parsing error into ValueError with context + raise ValueError(f"Failed to parse {card_type.name} card: {str(e)}") from e @classmethod def _get_card_class(cls, card_type: CardOrder): diff --git a/tests/unit/pleiades/sammy/test_parfile.py b/tests/unit/pleiades/sammy/test_parfile.py index 6f9bc35..5f39045 100644 --- a/tests/unit/pleiades/sammy/test_parfile.py +++ b/tests/unit/pleiades/sammy/test_parfile.py @@ -98,19 +98,20 @@ def test_roundtrip_single_card(single_card_input): assert parfile.broadening.parameters.flag_crfn == reparsed.broadening.parameters.flag_crfn -# @pytest.mark.parametrize( -# "invalid_input,error_pattern", -# [ -# ("", "Empty parameter file content"), -# ("abc\n", "Invalid fudge factor"), -# ("0.1\nINVALID card header\n", "Invalid card header"), -# ("0.1\nBROADening parameters may be varied\nINVALID DATA\n", "Failed to parse parameter line"), -# ] -# ) -# def test_parse_errors(invalid_input, error_pattern): -# """Test error handling for invalid inputs.""" -# with pytest.raises(ValueError, match=error_pattern): -# SammyParameterFile.from_string(invalid_input) +@pytest.mark.parametrize( + "invalid_input,error_pattern", + [ + ("", "Empty parameter file content"), + ("abc\n", "Invalid content"), + ("0.1\nINVALID card header\n", "Invalid content"), + ("0.1\nBROADening parameters may be varied\nINVALID DATA\n", "Failed to parse BROADENING card"), + ], +) +def test_parse_errors(invalid_input, error_pattern): + """Test error handling for invalid inputs.""" + with pytest.raises(ValueError, match=error_pattern): + tmp = SammyParameterFile.from_string(invalid_input) + print(tmp) if __name__ == "__main__": From 31a2fdb01accf5d28b867aec27fe1076d544ce46 Mon Sep 17 00:00:00 2001 From: Chen Zhang Date: Wed, 12 Feb 2025 15:23:31 -0500 Subject: [PATCH 4/8] fix broadening card str format issue --- src/pleiades/sammy/parameters/broadening.py | 24 +++++++++++-------- .../sammy/parameters/test_broadening.py | 4 ++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/pleiades/sammy/parameters/broadening.py b/src/pleiades/sammy/parameters/broadening.py index ca893df..d9ca065 100644 --- a/src/pleiades/sammy/parameters/broadening.py +++ b/src/pleiades/sammy/parameters/broadening.py @@ -200,6 +200,9 @@ def to_lines(self) -> List[str]: format_float(self.deltal, width=9), format_float(self.deltag, width=9), format_float(self.deltae, width=9), + ] + main_seg = " ".join(main_parts) + flag_parts = [ format_vary(self.flag_crfn), format_vary(self.flag_temp), format_vary(self.flag_thick), @@ -207,31 +210,32 @@ def to_lines(self) -> List[str]: format_vary(self.flag_deltag), format_vary(self.flag_deltae), ] - lines.append(" ".join(main_parts)) + flag_seg = " ".join(flag_parts) + main_line = f"{main_seg} {flag_seg}" + lines.append(main_line) # 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"]): unc_parts = [ - format_float(getattr(self, f"d_{param}", 0.0), width=10) - for param in ["crfn", "temp", "thick", "deltal", "deltag", "deltae"] + format_float(getattr(self, f"d_{param}", 0.0), width=9) for param in ["crfn", "temp", "thick", "deltal", "deltag", "deltae"] ] - lines.append("".join(unc_parts)) + lines.append(" ".join(unc_parts)) # Add Gaussian parameters if present if self.deltc1 is not None and self.deltc2 is not None: gaussian_parts = [ - format_float(self.deltc1, width=10), - format_float(self.deltc2, width=10), - " " * 50, # Padding + format_float(self.deltc1, width=9), + format_float(self.deltc2, width=9), + " " * 40, # Padding format_vary(self.flag_deltc1), format_vary(self.flag_deltc2), ] - lines.append("".join(gaussian_parts)) + lines.append(" ".join(gaussian_parts)) # Add Gaussian uncertainties if present if self.d_deltc1 is not None or self.d_deltc2 is not None: - gaussian_unc_parts = [format_float(self.d_deltc1 or 0.0, width=10), format_float(self.d_deltc2 or 0.0, width=10)] - lines.append("".join(gaussian_unc_parts)) + gaussian_unc_parts = [format_float(self.d_deltc1 or 0.0, width=9), format_float(self.d_deltc2 or 0.0, width=9)] + lines.append(" ".join(gaussian_unc_parts)) return lines diff --git a/tests/unit/pleiades/sammy/parameters/test_broadening.py b/tests/unit/pleiades/sammy/parameters/test_broadening.py index b223720..2e9d947 100644 --- a/tests/unit/pleiades/sammy/parameters/test_broadening.py +++ b/tests/unit/pleiades/sammy/parameters/test_broadening.py @@ -149,6 +149,10 @@ def test_roundtrip(): card = BroadeningParameterCard.from_lines(COMPLETE_CARD) output_lines = card.to_lines() + for i, line in enumerate(COMPLETE_CARD): + print(f"Original: {line}") + print(f"Output: {output_lines[i]}") + # Parse the output again reparsed_card = BroadeningParameterCard.from_lines(output_lines) From df3fcfb5d3439a24456958e3805f6c2a9e0a2d74 Mon Sep 17 00:00:00 2001 From: Chen Zhang Date: Thu, 13 Feb 2025 13:25:25 -0500 Subject: [PATCH 5/8] fixing logic errors in parsing cards --- src/pleiades/sammy/parameters/__init__.py | 2 +- src/pleiades/sammy/parameters/resonance.py | 54 ++++++- src/pleiades/sammy/parfile.py | 155 ++++++++++++------ tests/unit/pleiades/sammy/test_parfile.py | 178 ++++++++++++++++++++- 4 files changed, 340 insertions(+), 49 deletions(-) diff --git a/src/pleiades/sammy/parameters/__init__.py b/src/pleiades/sammy/parameters/__init__.py index d937191..298b515 100644 --- a/src/pleiades/sammy/parameters/__init__.py +++ b/src/pleiades/sammy/parameters/__init__.py @@ -6,6 +6,6 @@ from pleiades.sammy.parameters.orres import ORRESCard # noqa: F401 from pleiades.sammy.parameters.paramagnetic import ParamagneticParameters # noqa: F401 from pleiades.sammy.parameters.radius import RadiusCard # noqa: F401 -from pleiades.sammy.parameters.resonance import ResonanceEntry # noqa: F401 +from pleiades.sammy.parameters.resonance import ResonanceCard # noqa: F401 from pleiades.sammy.parameters.unused_var import UnusedCorrelatedCard # noqa: F401 from pleiades.sammy.parameters.user_resolution import UserResolutionParameters # noqa: F401 diff --git a/src/pleiades/sammy/parameters/resonance.py b/src/pleiades/sammy/parameters/resonance.py index fc10cad..f399a50 100644 --- a/src/pleiades/sammy/parameters/resonance.py +++ b/src/pleiades/sammy/parameters/resonance.py @@ -2,7 +2,7 @@ """Data class for card 01::resonance.""" import logging -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, Field @@ -135,6 +135,58 @@ def format_vary(value: VaryFlag) -> str: return line.rstrip() +class ResonanceCard(BaseModel): + """Container for a complete set of resonance entries (Card Set 1). + + Card Set 1 is unique as it has no header, contains multiple resonance entries, + and is terminated by a blank line. + + Attributes: + entries: List of resonance parameter entries + """ + + entries: List[ResonanceEntry] = Field(default_factory=list) + + @classmethod + def from_lines(cls, lines: List[str]) -> "ResonanceCard": + """Parse resonance entries from lines. + + Args: + lines: List of resonance entry lines + + Returns: + ResonanceCard: Container with parsed entries + + Raises: + ValueError: If any line cannot be parsed as a resonance entry + """ + entries = [] + for line in lines: + if line.strip(): # Skip blank lines + try: + entry = ResonanceEntry.from_str(line) + entries.append(entry) + except Exception as e: + raise ValueError(f"Failed to parse resonance entry: {str(e)}\nLine: {line}") + + if not entries: + raise ValueError("No valid resonance entries found") + + return cls(entries=entries) + + def to_lines(self) -> List[str]: + """Convert entries to fixed-width format lines. + + Returns: + List[str]: Formatted lines including blank terminator + """ + lines = [] + for entry in self.entries: + lines.append(entry.to_str()) + lines.append("") # Blank line terminator + return lines + + if __name__ == "__main__": # Enable logging for debugging logging.basicConfig(level=logging.DEBUG) diff --git a/src/pleiades/sammy/parfile.py b/src/pleiades/sammy/parfile.py index e01af28..9686cea 100644 --- a/src/pleiades/sammy/parfile.py +++ b/src/pleiades/sammy/parfile.py @@ -1,8 +1,9 @@ #!/usr/bin/env python """Top level parameter file handler for SAMMY.""" +import pathlib from enum import Enum, auto -from typing import List, Optional +from typing import List, Optional, Union from pydantic import BaseModel, Field @@ -16,7 +17,7 @@ ORRESCard, ParamagneticParameters, RadiusCard, - ResonanceEntry, + ResonanceCard, UnusedCorrelatedCard, UserResolutionParameters, ) @@ -82,7 +83,7 @@ class SammyParameterFile(BaseModel): # REQUIRED CARDS fudge: float = Field(default=0.1, description="Fudge factor for initial uncertainties", ge=0.0, le=1.0) # OPTIONAL CARDS - resonance: Optional[ResonanceEntry] = Field(None, description="Resonance parameters") + resonance: Optional[ResonanceCard] = Field(None, description="Resonance parameters") external_r: Optional[ExternalREntry] = Field(None, description="External R matrix parameters") broadening: Optional[BroadeningParameterCard] = Field(None, description="Broadening parameters") unused_correlated: Optional[UnusedCorrelatedCard] = Field(None, description="Unused but correlated variables") @@ -117,7 +118,7 @@ def to_string(self) -> str: # Special handling for fudge factor if card_type == CardOrder.FUDGE: - lines.append(f"{value:10.4f}") + lines.append(f"{value:<10.4f}") continue # For all other cards, use their to_lines() method @@ -159,63 +160,76 @@ def _get_card_class_with_header(cls, line: str): @classmethod def from_string(cls, content: str) -> "SammyParameterFile": - """Parse parameter file content into SammyParameterFile object. + """Parse content string into a parameter file object. Args: - content: String containing parameter file content + content: Content of the parameter file. Returns: - SammyParameterFile: Parsed parameter file object - - Raises: - ValueError: If content format is invalid + SammyParameterFile: Parsed parameter file object. """ + # Split content into lines lines = content.splitlines() + + # Early exit for empty content if not lines: raise ValueError("Empty parameter file content") + # Initialize parameters params = {} - current_card = None - card_lines = [] - - # Process lines - line_idx = 0 - while line_idx < len(lines): - line = lines[line_idx] - # First non-empty line should be fudge factor - if not current_card and line.strip(): + # First parse out fudge factor if it exists + fudge_idx = None + for i, line in enumerate(lines): + stripped = line.strip() + if stripped: # Skip empty lines try: - params["fudge"] = float(line.strip()) - line_idx += 1 - continue + params["fudge"] = float(stripped) + fudge_idx = i + break except ValueError: - # Not a fudge factor, try other cards - pass + continue + # now remove the fudge factor line from the list to simplify grouping + # lines into cards + if fudge_idx is not None: + del lines[fudge_idx] + + # Second, partition lines into group of lines based on blank lines + card_groups = [] + current_group = [] + + for line in lines: + if line.strip(): + current_group.append(line) + else: + if current_group: # Only add non-empty groups + card_groups.append(current_group) + current_group = [] - # Check for card headers - card_type, card_class = cls._get_card_class_with_header(line) - if card_class: - # Process previous card if exists - if current_card and card_lines: - params[CardOrder.get_field_name(current_card)] = cls._parse_card(current_card, card_lines) + if current_group: # Don't forget last group + card_groups.append(current_group) - # Start new card - current_card = card_type - card_lines = [line] - else: - # Not a header, add to current card if exists - if current_card: - card_lines.append(line) - else: - # Line is not numeric, not header, and not part of any card - raise ValueError(f"Invalid content: {line}") + # Process each group of lines + for group in card_groups: + if not group: # Skip empty groups + continue - line_idx += 1 + # Check first line for header to determine card type + card_type, card_class = cls._get_card_class_with_header(group[0]) - # Process final card - if current_card and card_lines: - params[CardOrder.get_field_name(current_card)] = cls._parse_card(current_card, card_lines) + if card_class: + # Process card with header + try: + params[CardOrder.get_field_name(card_type)] = card_class.from_lines(group) + except Exception as e: + raise ValueError(f"Failed to parse {card_type.name} card: {str(e)}") + else: + # No header - check if it's a resonance table + try: + # Try parsing as resonance table + params["resonance"] = ResonanceCard.from_lines(group) + except Exception as e: + raise ValueError(f"Failed to parse card without header: {str(e)}\nLines: {group}") return cls(**params) @@ -229,6 +243,8 @@ def _parse_card(cls, card_type: CardOrder, lines: List[str]): try: return card_class.from_lines(lines) except Exception as e: + print(card_type) + print(lines) # Convert any parsing error into ValueError with context raise ValueError(f"Failed to parse {card_type.name} card: {str(e)}") from e @@ -236,7 +252,7 @@ def _parse_card(cls, card_type: CardOrder, lines: List[str]): def _get_card_class(cls, card_type: CardOrder): """Get the card class for a given card type.""" card_map = { - CardOrder.RESONANCE: ResonanceEntry, + CardOrder.RESONANCE: ResonanceCard, CardOrder.EXTERNAL_R: ExternalRFunction, CardOrder.BROADENING: BroadeningParameterCard, CardOrder.UNUSED_CORRELATED: UnusedCorrelatedCard, @@ -250,6 +266,55 @@ def _get_card_class(cls, card_type: CardOrder): } return card_map.get(card_type) + @classmethod + def from_file(cls, filepath: Union[str, pathlib.Path]) -> "SammyParameterFile": + """Read parameter file from disk. + + Args: + filepath: Path to parameter file + + Returns: + SammyParameterFile: Parsed parameter file object + + Raises: + FileNotFoundError: If file does not exist + ValueError: If file content is invalid + """ + filepath = pathlib.Path(filepath) + if not filepath.exists(): + raise FileNotFoundError(f"Parameter file not found: {filepath}") + + try: + content = filepath.read_text() + return cls.from_string(content) + except UnicodeDecodeError as e: + raise ValueError(f"Failed to read parameter file - invalid encoding: {e}") + except Exception as e: + raise ValueError(f"Failed to parse parameter file: {e}") + + def to_file(self, filepath: Union[str, pathlib.Path]) -> None: + """Write parameter file to disk. + + Args: + filepath: Path to write parameter file + + Raises: + OSError: If file cannot be written + ValueError: If content cannot be formatted + """ + filepath = pathlib.Path(filepath) + + # Create parent directories if they don't exist + filepath.parent.mkdir(parents=True, exist_ok=True) + + try: + content = self.to_string() + filepath.write_text(content) + except OSError as e: + raise OSError(f"Failed to write parameter file: {e}") + except Exception as e: + raise ValueError(f"Failed to format parameter file content: {e}") + if __name__ == "__main__": print("TODO: usage example for SAMMY parameter file handling") diff --git a/tests/unit/pleiades/sammy/test_parfile.py b/tests/unit/pleiades/sammy/test_parfile.py index 5f39045..634ad2b 100644 --- a/tests/unit/pleiades/sammy/test_parfile.py +++ b/tests/unit/pleiades/sammy/test_parfile.py @@ -1,6 +1,11 @@ #!/usr/bin/env python """Unit tests for SAMMY parameter file parsing.""" +import os +import pathlib +import tempfile +from typing import Generator + import pytest from pleiades.sammy.parameters.helper import VaryFlag @@ -102,8 +107,8 @@ def test_roundtrip_single_card(single_card_input): "invalid_input,error_pattern", [ ("", "Empty parameter file content"), - ("abc\n", "Invalid content"), - ("0.1\nINVALID card header\n", "Invalid content"), + ("abc\n", "Failed to parse"), + ("0.1\nINVALID card header\n", "Failed to parse"), ("0.1\nBROADening parameters may be varied\nINVALID DATA\n", "Failed to parse BROADENING card"), ], ) @@ -114,5 +119,174 @@ def test_parse_errors(invalid_input, error_pattern): print(tmp) +@pytest.fixture +def temp_dir() -> Generator[pathlib.Path, None, None]: + """Provide temporary directory for file I/O tests.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield pathlib.Path(tmpdir) + + +class TestFileIO: + """Test suite for file I/O operations.""" + + def test_write_and_read(self, temp_dir, single_card_input): + """Test writing file to disk and reading it back.""" + # Create parameter file from input + parfile = SammyParameterFile.from_string(single_card_input) + + # Write to temporary file + test_file = temp_dir / "test_params.txt" + parfile.to_file(test_file) + + # Verify file exists and has content + assert test_file.exists() + assert test_file.stat().st_size > 0 + + # Read file back + loaded = SammyParameterFile.from_file(test_file) + + # Compare objects + assert loaded.fudge == parfile.fudge + assert loaded.broadening is not None + assert loaded.broadening.parameters.crfn == parfile.broadening.parameters.crfn + assert loaded.broadening.parameters.temp == parfile.broadening.parameters.temp + assert loaded.broadening.parameters.flag_crfn == parfile.broadening.parameters.flag_crfn + + def test_write_to_nested_path(self, temp_dir): + """Test writing to nested directory structure.""" + nested_path = temp_dir / "a" / "b" / "c" / "params.txt" + + parfile = SammyParameterFile(fudge=0.1) + parfile.to_file(nested_path) + + assert nested_path.exists() + + loaded = SammyParameterFile.from_file(nested_path) + assert loaded.fudge == pytest.approx(0.1) + + def test_file_not_found(self, temp_dir): + """Test error handling for non-existent file.""" + missing_file = temp_dir / "missing.txt" + with pytest.raises(FileNotFoundError): + SammyParameterFile.from_file(missing_file) + + def test_invalid_encoding(self, temp_dir): + """Test error handling for invalid file encoding.""" + bad_file = temp_dir / "bad_encoding.txt" + + # Write some binary data + bad_file.write_bytes(b"\x80\x81") + + with pytest.raises(ValueError, match="invalid encoding"): + SammyParameterFile.from_file(bad_file) + + def test_write_permissions(self, temp_dir): + """Test error handling for write permission issues.""" + readonly_dir = temp_dir / "readonly" + readonly_dir.mkdir() + readonly_file = readonly_dir / "params.txt" + + # Make directory readonly + os.chmod(readonly_dir, 0o444) + + parfile = SammyParameterFile(fudge=0.1) + with pytest.raises(OSError): + parfile.to_file(readonly_file) + + def test_full_roundtrip(self, temp_dir): + """Test complete round-trip with all parameter types. + + Creates a parameter file with all supported card types, + writes it to disk, reads it back, and verifies all parameters + match exactly. + """ + # Create sample parameter file with all card types + input_str = ( + # First resonance table + "-3.6616E+06 1.5877E+05 3.6985E+09 0 0 1 1\n" + "-8.7373E+05 1.0253E+03 1.0151E+02 0 0 1 1\n" + "\n" + # Then fudge factor + "0.1000\n" + # Then broadening parameters + "BROADening parameters may be varied\n" + "1.234E+00 2.980E+02 1.500E-01 2.500E-02 1.000E+00 5.000E-01 1 0 1 0 1 0\n" + "1.000E-02 1.000E+00 1.000E-03 1.000E-03 1.000E-02 1.000E-02\n" + "\n" + # Then normalization parameters + "NORMAlization and background are next\n" + "1.000E+00 2.000E-02 3.000E-03 4.000E-04 5.000E-05 6.000E-06 1 0 1 0 1 0\n" + "1.000E-02 2.000E-03 3.000E-04 4.000E-05 5.000E-06 6.000E-07\n" + "\n" + # Then radius parameters + "RADIUs parameters follow\n" + "3.200E+00 3.200E+00 0 1 -1 1 2 3\n" + "\n" + # Finally data reduction parameters + "DATA reduction parameters are next\n" + "PAR1 1 1.234E+00 5.000E-02 1.234E+00\n" + "PAR2 0 2.345E+00 1.000E-02\n" + "\n" + ) + + # Create initial parameter file + orig_parfile = SammyParameterFile.from_string(input_str) + + # Write to file + test_file = temp_dir / "full_params.txt" + orig_parfile.to_file(test_file) + + # Read back + loaded_parfile = SammyParameterFile.from_file(test_file) + + # Compare fudge factor + assert loaded_parfile.fudge == pytest.approx(orig_parfile.fudge) + + # Compare broadening parameters + assert loaded_parfile.broadening is not None + assert orig_parfile.broadening is not None + assert loaded_parfile.broadening.parameters.crfn == pytest.approx(orig_parfile.broadening.parameters.crfn) + assert loaded_parfile.broadening.parameters.temp == pytest.approx(orig_parfile.broadening.parameters.temp) + assert loaded_parfile.broadening.parameters.thick == pytest.approx(orig_parfile.broadening.parameters.thick) + assert loaded_parfile.broadening.parameters.flag_crfn == orig_parfile.broadening.parameters.flag_crfn + assert loaded_parfile.broadening.parameters.flag_temp == orig_parfile.broadening.parameters.flag_temp + + # Compare normalization parameters + assert loaded_parfile.normalization is not None + assert orig_parfile.normalization is not None + assert len(loaded_parfile.normalization.angle_sets) == len(orig_parfile.normalization.angle_sets) + + loaded_angle = loaded_parfile.normalization.angle_sets[0] + orig_angle = orig_parfile.normalization.angle_sets[0] + assert loaded_angle.anorm == pytest.approx(orig_angle.anorm) + assert loaded_angle.backa == pytest.approx(orig_angle.backa) + assert loaded_angle.flag_anorm == orig_angle.flag_anorm + assert loaded_angle.d_anorm == pytest.approx(orig_angle.d_anorm) + + # Compare radius parameters + assert loaded_parfile.radius is not None + assert orig_parfile.radius is not None + assert loaded_parfile.radius.parameters.effective_radius == pytest.approx(orig_parfile.radius.parameters.effective_radius) + assert loaded_parfile.radius.parameters.true_radius == pytest.approx(orig_parfile.radius.parameters.true_radius) + assert loaded_parfile.radius.parameters.spin_groups == orig_parfile.radius.parameters.spin_groups + assert loaded_parfile.radius.parameters.vary_effective == orig_parfile.radius.parameters.vary_effective + + # Compare data reduction parameters + assert loaded_parfile.data_reduction is not None + assert orig_parfile.data_reduction is not None + assert len(loaded_parfile.data_reduction.parameters) == len(orig_parfile.data_reduction.parameters) + + for loaded_param, orig_param in zip(loaded_parfile.data_reduction.parameters, orig_parfile.data_reduction.parameters): + assert loaded_param.name == orig_param.name + assert loaded_param.value == pytest.approx(orig_param.value) + assert loaded_param.flag == orig_param.flag + assert loaded_param.uncertainty == pytest.approx(orig_param.uncertainty) + if loaded_param.derivative_value is not None: + assert loaded_param.derivative_value == pytest.approx(orig_param.derivative_value) + + # Verify string output matches exactly + assert loaded_parfile.to_string() == orig_parfile.to_string() + + if __name__ == "__main__": pytest.main(["-v", __file__]) From a4ce75b4aa433f704da15677ba048ebc49a1ffc4 Mon Sep 17 00:00:00 2001 From: Chen Zhang Date: Thu, 13 Feb 2025 13:51:35 -0500 Subject: [PATCH 6/8] radius card has serious issue, need special attention, disable for now --- src/pleiades/sammy/parameters/radius.py | 23 ++++++++++++++++++++++- src/pleiades/sammy/parfile.py | 6 ++++++ tests/unit/pleiades/sammy/test_parfile.py | 19 ++++++++++--------- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/pleiades/sammy/parameters/radius.py b/src/pleiades/sammy/parameters/radius.py index bc99ea3..c6afb3f 100644 --- a/src/pleiades/sammy/parameters/radius.py +++ b/src/pleiades/sammy/parameters/radius.py @@ -854,6 +854,27 @@ class RadiusCard(BaseModel): relative_uncertainty: Optional[float] = None absolute_uncertainty: Optional[float] = None + @classmethod + def is_header_line(cls, line: str) -> bool: + """Check if line is a valid radius parameter header line. + + Args: + line: Input line to check + + Returns: + bool: True if line matches any valid radius header format + """ + line_upper = line.strip().upper() + + # Check all valid header formats + valid_headers = [ + "RADIUS PARAMETERS FOLLOW", # Standard/Alternate fixed width + "RADII ARE IN KEY-WORD FORMAT", # Keyword format + "CHANNEL RADIUS PARAMETERS FOLLOW", # Alternative keyword format + ] + + return any(header in line_upper for header in valid_headers) + @classmethod def detect_format(cls, lines: List[str]) -> RadiusFormat: """Detect format from input lines.""" @@ -891,7 +912,7 @@ def from_lines(cls, lines: List[str]) -> "RadiusCard": else: return cls(parameters=RadiusCardDefault.from_lines(lines).parameters) - def to_lines(self, radius_format: RadiusFormat = RadiusFormat.DEFAULT) -> List[str]: + def to_lines(self, radius_format: RadiusFormat = RadiusFormat.KEYWORD) -> List[str]: """Write radius card in specified format.""" if radius_format == RadiusFormat.KEYWORD: return RadiusCardKeyword( diff --git a/src/pleiades/sammy/parfile.py b/src/pleiades/sammy/parfile.py index 9686cea..4b2b589 100644 --- a/src/pleiades/sammy/parfile.py +++ b/src/pleiades/sammy/parfile.py @@ -214,9 +214,15 @@ def from_string(cls, content: str) -> "SammyParameterFile": if not group: # Skip empty groups continue + for line in group: + print(line) + # Check first line for header to determine card type card_type, card_class = cls._get_card_class_with_header(group[0]) + print(card_type, card_class) + print("-----") + if card_class: # Process card with header try: diff --git a/tests/unit/pleiades/sammy/test_parfile.py b/tests/unit/pleiades/sammy/test_parfile.py index 634ad2b..7d3feb1 100644 --- a/tests/unit/pleiades/sammy/test_parfile.py +++ b/tests/unit/pleiades/sammy/test_parfile.py @@ -219,9 +219,9 @@ def test_full_roundtrip(self, temp_dir): "1.000E-02 2.000E-03 3.000E-04 4.000E-05 5.000E-06 6.000E-07\n" "\n" # Then radius parameters - "RADIUs parameters follow\n" - "3.200E+00 3.200E+00 0 1 -1 1 2 3\n" - "\n" + # "RADIUs parameters follow\n" + # " 3.200 3.200 0 1 -1 101 102 103\n" + # "\n" # Finally data reduction parameters "DATA reduction parameters are next\n" "PAR1 1 1.234E+00 5.000E-02 1.234E+00\n" @@ -264,12 +264,13 @@ def test_full_roundtrip(self, temp_dir): assert loaded_angle.d_anorm == pytest.approx(orig_angle.d_anorm) # Compare radius parameters - assert loaded_parfile.radius is not None - assert orig_parfile.radius is not None - assert loaded_parfile.radius.parameters.effective_radius == pytest.approx(orig_parfile.radius.parameters.effective_radius) - assert loaded_parfile.radius.parameters.true_radius == pytest.approx(orig_parfile.radius.parameters.true_radius) - assert loaded_parfile.radius.parameters.spin_groups == orig_parfile.radius.parameters.spin_groups - assert loaded_parfile.radius.parameters.vary_effective == orig_parfile.radius.parameters.vary_effective + # print(loaded_parfile.radius) + # assert loaded_parfile.radius is not None + # assert orig_parfile.radius is not None + # assert loaded_parfile.radius.parameters.effective_radius == pytest.approx(orig_parfile.radius.parameters.effective_radius) + # assert loaded_parfile.radius.parameters.true_radius == pytest.approx(orig_parfile.radius.parameters.true_radius) + # assert loaded_parfile.radius.parameters.spin_groups == orig_parfile.radius.parameters.spin_groups + # assert loaded_parfile.radius.parameters.vary_effective == orig_parfile.radius.parameters.vary_effective # Compare data reduction parameters assert loaded_parfile.data_reduction is not None From a266b938c4551dba7514bd6c286439e65b3d21d7 Mon Sep 17 00:00:00 2001 From: Chen Zhang Date: Mon, 17 Feb 2025 15:24:27 -0500 Subject: [PATCH 7/8] Fix radius card bug --- src/pleiades/sammy/parameters/radius.py | 4 ++-- tests/unit/pleiades/sammy/test_parfile.py | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/pleiades/sammy/parameters/radius.py b/src/pleiades/sammy/parameters/radius.py index c6afb3f..62fa8d6 100644 --- a/src/pleiades/sammy/parameters/radius.py +++ b/src/pleiades/sammy/parameters/radius.py @@ -727,8 +727,8 @@ def from_lines(cls, lines: List[str]) -> "RadiusCardKeyword": elif key == "flags": if isinstance(value, list): - params["vary_effective"] = VaryFlag(value[0]) - params["vary_true"] = VaryFlag(value[1]) + params["vary_effective"] = VaryFlag(int(value[0])) + params["vary_true"] = VaryFlag(int(value[1])) else: params["vary_effective"] = VaryFlag(value) params["vary_true"] = VaryFlag(value) diff --git a/tests/unit/pleiades/sammy/test_parfile.py b/tests/unit/pleiades/sammy/test_parfile.py index 7d3feb1..f866eca 100644 --- a/tests/unit/pleiades/sammy/test_parfile.py +++ b/tests/unit/pleiades/sammy/test_parfile.py @@ -219,9 +219,9 @@ def test_full_roundtrip(self, temp_dir): "1.000E-02 2.000E-03 3.000E-04 4.000E-05 5.000E-06 6.000E-07\n" "\n" # Then radius parameters - # "RADIUs parameters follow\n" - # " 3.200 3.200 0 1 -1 101 102 103\n" - # "\n" + "RADIUs parameters follow\n" + " 3.200 3.20001-1 1 2 3\n" + "\n" # Finally data reduction parameters "DATA reduction parameters are next\n" "PAR1 1 1.234E+00 5.000E-02 1.234E+00\n" @@ -264,13 +264,13 @@ def test_full_roundtrip(self, temp_dir): assert loaded_angle.d_anorm == pytest.approx(orig_angle.d_anorm) # Compare radius parameters - # print(loaded_parfile.radius) - # assert loaded_parfile.radius is not None - # assert orig_parfile.radius is not None - # assert loaded_parfile.radius.parameters.effective_radius == pytest.approx(orig_parfile.radius.parameters.effective_radius) - # assert loaded_parfile.radius.parameters.true_radius == pytest.approx(orig_parfile.radius.parameters.true_radius) - # assert loaded_parfile.radius.parameters.spin_groups == orig_parfile.radius.parameters.spin_groups - # assert loaded_parfile.radius.parameters.vary_effective == orig_parfile.radius.parameters.vary_effective + print(loaded_parfile.radius) + assert loaded_parfile.radius is not None + assert orig_parfile.radius is not None + assert loaded_parfile.radius.parameters.effective_radius == pytest.approx(orig_parfile.radius.parameters.effective_radius) + assert loaded_parfile.radius.parameters.true_radius == pytest.approx(orig_parfile.radius.parameters.true_radius) + assert loaded_parfile.radius.parameters.spin_groups == orig_parfile.radius.parameters.spin_groups + assert loaded_parfile.radius.parameters.vary_effective == orig_parfile.radius.parameters.vary_effective # Compare data reduction parameters assert loaded_parfile.data_reduction is not None From 86266684c0e63293b4a6257927aabb6e5d557bd1 Mon Sep 17 00:00:00 2001 From: Chen Zhang Date: Mon, 17 Feb 2025 15:47:50 -0500 Subject: [PATCH 8/8] remove debug print and add usage example notebook --- notebook/sammy.param/example.ipynb | 287 +++++++++++++++++++++++++++++ src/pleiades/sammy/parfile.py | 6 - 2 files changed, 287 insertions(+), 6 deletions(-) create mode 100644 notebook/sammy.param/example.ipynb diff --git a/notebook/sammy.param/example.ipynb b/notebook/sammy.param/example.ipynb new file mode 100644 index 0000000..d8a4627 --- /dev/null +++ b/notebook/sammy.param/example.ipynb @@ -0,0 +1,287 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Overview\n", + "\n", + "This notebook demonstrates how to use the `sammy.paramter` module to load, construct and manipulate the parameter file (`*.par`) of the SAMMY suite." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pathlib\n", + "import tempfile\n", + "\n", + "from pleiades.sammy.parfile import SammyParameterFile" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Construct a parameter object manually\n", + "\n", + "Let's build a simple parameter file from scratch." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "entries=[ResonanceEntry(resonance_energy=0.0253, capture_width=0.1, channel1_width=0.2, channel2_width=0.3, channel3_width=0.4, vary_energy=, vary_capture_width=, vary_channel1=, vary_channel2=, vary_channel3=, igroup=1), ResonanceEntry(resonance_energy=0.0253, capture_width=0.1, channel1_width=0.2, channel2_width=0.3, channel3_width=0.4, vary_energy=, vary_capture_width=, vary_channel1=, vary_channel2=, vary_channel3=, igroup=2)]\n" + ] + } + ], + "source": [ + "from pleiades.sammy.parameters.helper import VaryFlag\n", + "from pleiades.sammy.parameters.resonance import ResonanceCard, ResonanceEntry\n", + "\n", + "resonance_0 = ResonanceEntry(\n", + " resonance_energy=0.0253,\n", + " capture_width=0.1,\n", + " channel1_width=0.2,\n", + " channel2_width=0.3,\n", + " channel3_width=0.4,\n", + " vary_energy=VaryFlag.NO,\n", + " vary_capture_width=VaryFlag.NO,\n", + " vary_channel1_width=VaryFlag.YES,\n", + " vary_channel2_width=VaryFlag.NO,\n", + " vary_channel3_width=VaryFlag.YES,\n", + " igroup=1,\n", + ")\n", + "\n", + "resonance_1 = ResonanceEntry(\n", + " resonance_energy=0.0253,\n", + " capture_width=0.1,\n", + " channel1_width=0.2,\n", + " channel2_width=0.3,\n", + " channel3_width=0.4,\n", + " vary_energy=VaryFlag.NO,\n", + " vary_capture_width=VaryFlag.NO,\n", + " vary_channel1_width=VaryFlag.NO,\n", + " vary_channel2_width=VaryFlag.YES,\n", + " vary_channel3_width=VaryFlag.NO,\n", + " igroup=2,\n", + ")\n", + "\n", + "resonance_card = ResonanceCard(entries=[resonance_0, resonance_1])\n", + "\n", + "print(resonance_card)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "parameters=RadiusParameters(effective_radius=1.0, true_radius=2.0, channel_mode=1, vary_effective=, vary_true=, spin_groups=[1, 2, 3], channels=[1, 2, 3]) particle_pair=None orbital_momentum=None relative_uncertainty=None absolute_uncertainty=None\n" + ] + } + ], + "source": [ + "from pleiades.sammy.parameters.radius import RadiusCard, RadiusParameters\n", + "\n", + "radius_parameters = RadiusParameters(\n", + " effective_radius=1.0,\n", + " true_radius=2.0,\n", + " channel_mode=1,\n", + " vary_effective=VaryFlag.YES,\n", + " vary_true=VaryFlag.NO,\n", + " spin_groups=[1, 2, 3],\n", + " channels=[1, 2, 3],\n", + ")\n", + "\n", + "radius_card = RadiusCard(parameters=radius_parameters)\n", + "\n", + "print(radius_card)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "now let's create a parameter object to hold everything" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fudge=0.2 resonance=ResonanceCard(entries=[ResonanceEntry(resonance_energy=0.0253, capture_width=0.1, channel1_width=0.2, channel2_width=0.3, channel3_width=0.4, vary_energy=, vary_capture_width=, vary_channel1=, vary_channel2=, vary_channel3=, igroup=1), ResonanceEntry(resonance_energy=0.0253, capture_width=0.1, channel1_width=0.2, channel2_width=0.3, channel3_width=0.4, vary_energy=, vary_capture_width=, vary_channel1=, vary_channel2=, vary_channel3=, igroup=2)]) external_r=None broadening=None unused_correlated=None normalization=None radius=RadiusCard(parameters=RadiusParameters(effective_radius=1.0, true_radius=2.0, channel_mode=1, vary_effective=, vary_true=, spin_groups=[1, 2, 3], channels=[1, 2, 3]), particle_pair=None, orbital_momentum=None, relative_uncertainty=None, absolute_uncertainty=None) data_reduction=None orres=None paramagnetic=None user_resolution=None isotope=None\n" + ] + } + ], + "source": [ + "par_file = SammyParameterFile(\n", + " fudge=0.2,\n", + " resonance=resonance_card,\n", + " radius=radius_card,\n", + ")\n", + "\n", + "print(par_file)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "we can also view the string format before writing to a file" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " 2.5300E-02 1.0000E-01 2.0000E-01 3.0000E-01 4.0000E-01 0 0 0 0 0 1\n", + " 2.5300E-02 1.0000E-01 2.0000E-01 3.0000E-01 4.0000E-01 0 0 0 0 0 2\n", + "\n", + "0.2000 \n", + "RADII are in KEY-WORD format\n", + "Radius= 1.0 2.0\n", + "Flags= 1 0\n", + "Group= 1 Channels= 1 2 3\n", + "Group= 2 Channels= 1 2 3\n", + "Group= 3 Channels= 1 2 3\n", + "\n" + ] + } + ], + "source": [ + "print(par_file.to_string())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "then we can write the parameter object to a file" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " 2.5300E-02 1.0000E-01 2.0000E-01 3.0000E-01 4.0000E-01 0 0 0 0 0 1\n", + " 2.5300E-02 1.0000E-01 2.0000E-01 3.0000E-01 4.0000E-01 0 0 0 0 0 2\n", + "\n", + "0.2000 \n", + "RADII are in KEY-WORD format\n", + "Radius= 1.0 2.0\n", + "Flags= 1 0\n", + "Group= 1 Channels= 1 2 3\n", + "Group= 2 Channels= 1 2 3\n", + "Group= 3 Channels= 1 2 3\n", + "\n" + ] + } + ], + "source": [ + "with tempfile.TemporaryDirectory() as tmpdir:\n", + " par_file_path = pathlib.Path(tmpdir) / \"sammy.par\"\n", + " par_file.to_file(par_file_path)\n", + "\n", + " with open(par_file_path, \"r\") as f:\n", + " print(f.read())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load from string/file\n", + "\n", + "We can also directly load a parameter file from a string or a file." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fudge=0.2 resonance=ResonanceCard(entries=[ResonanceEntry(resonance_energy=0.0253, capture_width=0.1, channel1_width=0.2, channel2_width=0.3, channel3_width=0.4, vary_energy=, vary_capture_width=, vary_channel1=, vary_channel2=, vary_channel3=, igroup=1), ResonanceEntry(resonance_energy=0.0253, capture_width=0.1, channel1_width=0.2, channel2_width=0.3, channel3_width=0.4, vary_energy=, vary_capture_width=, vary_channel1=, vary_channel2=, vary_channel3=, igroup=2)]) external_r=None broadening=None unused_correlated=None normalization=None radius=RadiusCard(parameters=RadiusParameters(effective_radius=1.0, true_radius=2.0, channel_mode=1, vary_effective=, vary_true=, spin_groups=[3], channels=[1, 2, 3]), particle_pair=None, orbital_momentum=None, relative_uncertainty=None, absolute_uncertainty=None) data_reduction=None orres=None paramagnetic=None user_resolution=None isotope=None\n", + " 2.5300E-02 1.0000E-01 2.0000E-01 3.0000E-01 4.0000E-01 0 0 0 0 0 1\n", + " 2.5300E-02 1.0000E-01 2.0000E-01 3.0000E-01 4.0000E-01 0 0 0 0 0 2\n", + "\n", + "0.2000 \n", + "RADII are in KEY-WORD format\n", + "Radius= 1.0 2.0\n", + "Flags= 1 0\n", + "Group= 3 Channels= 1 2 3\n", + "\n" + ] + } + ], + "source": [ + "with tempfile.TemporaryDirectory() as tmpdir:\n", + " par_file_path = pathlib.Path(tmpdir) / \"sammy.par\"\n", + " par_file.to_file(par_file_path)\n", + "\n", + " par_file_read = SammyParameterFile.from_file(par_file_path)\n", + " print(par_file_read)\n", + " print(par_file_read.to_string())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pleiades-j7m2QWN6-py3.13", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/pleiades/sammy/parfile.py b/src/pleiades/sammy/parfile.py index 4b2b589..9686cea 100644 --- a/src/pleiades/sammy/parfile.py +++ b/src/pleiades/sammy/parfile.py @@ -214,15 +214,9 @@ def from_string(cls, content: str) -> "SammyParameterFile": if not group: # Skip empty groups continue - for line in group: - print(line) - # Check first line for header to determine card type card_type, card_class = cls._get_card_class_with_header(group[0]) - print(card_type, card_class) - print("-----") - if card_class: # Process card with header try: