diff --git a/solarwindpy/__init__.py b/solarwindpy/__init__.py index 0545c67b..fb495a8f 100644 --- a/solarwindpy/__init__.py +++ b/solarwindpy/__init__.py @@ -18,7 +18,7 @@ spacecraft, alfvenic_turbulence, ) -from . import core, plotting, solar_activity, tools, fitfunctions, data +from . import core, plotting, solar_activity, tools, fitfunctions from . import instabilities # noqa: F401 from . import reproducibility @@ -31,6 +31,7 @@ def _configure_pandas() -> None: _configure_pandas() Plasma = core.plasma.Plasma +ReferenceAbundances = core.abundances.ReferenceAbundances at = alfvenic_turbulence sc = spacecraft pp = plotting @@ -41,8 +42,8 @@ def _configure_pandas() -> None: __all__ = [ "core", - "data", "plasma", + "ReferenceAbundances", "ions", "tensor", "vector", diff --git a/solarwindpy/core/__init__.py b/solarwindpy/core/__init__.py index db86118f..30f57c28 100644 --- a/solarwindpy/core/__init__.py +++ b/solarwindpy/core/__init__.py @@ -8,7 +8,7 @@ from .spacecraft import Spacecraft from .units_constants import Units, Constants from .alfvenic_turbulence import AlfvenicTurbulence -from .abundances import ReferenceAbundances +from .abundances import ReferenceAbundances, Abundance __all__ = [ "Base", @@ -22,4 +22,5 @@ "Constants", "AlfvenicTurbulence", "ReferenceAbundances", + "Abundance", ] diff --git a/solarwindpy/core/abundances.py b/solarwindpy/core/abundances.py index 9cec4d69..c6b91c77 100644 --- a/solarwindpy/core/abundances.py +++ b/solarwindpy/core/abundances.py @@ -1,46 +1,131 @@ -__all__ = ["ReferenceAbundances"] +"""Reference elemental abundances from Asplund et al. (2009, 2021). + +This module provides access to solar photospheric and CI chondrite +(meteoritic) abundances from the Asplund reference papers. + +References +---------- +Asplund, M., Amarsi, A. M., & Grevesse, N. (2021). +The chemical make-up of the Sun: A 2020 vision. +A&A, 653, A141. https://doi.org/10.1051/0004-6361/202140445 + +Asplund, M., Grevesse, N., Sauval, A. J., & Scott, P. (2009). +The Chemical Composition of the Sun. +Annu. Rev. Astron. Astrophys., 47, 481-522. +https://doi.org/10.1146/annurev.astro.46.060407.145222 +""" + +__all__ = ["ReferenceAbundances", "Abundance"] import numpy as np import pandas as pd from collections import namedtuple -from pathlib import Path +from importlib import resources Abundance = namedtuple("Abundance", "measurement,uncertainty") +# Alias mapping for backward compatibility +_KIND_ALIASES = { + "Meteorites": "CI_chondrites", +} + class ReferenceAbundances: - """Elemental abundances from Asplund et al. (2009). + """Elemental abundances from Asplund et al. (2009, 2021). + + Provides photospheric and CI chondrite (meteoritic) abundances + in the standard dex scale: log ε_X = log(N_X/N_H) + 12. - Provides both photospheric and meteoritic abundances. + Parameters + ---------- + year : int, default 2021 + Reference year: 2009 or 2021. Default uses Asplund 2021. + + Attributes + ---------- + data : pd.DataFrame + MultiIndex DataFrame with abundances and uncertainties. + year : int + The reference year for the loaded data. References ---------- + Asplund, M., Amarsi, A. M., & Grevesse, N. (2021). + The chemical make-up of the Sun: A 2020 vision. + A&A, 653, A141. https://doi.org/10.1051/0004-6361/202140445 + Asplund, M., Grevesse, N., Sauval, A. J., & Scott, P. (2009). The Chemical Composition of the Sun. - Annual Review of Astronomy and Astrophysics, 47(1), 481–522. + Annu. Rev. Astron. Astrophys., 47, 481-522. https://doi.org/10.1146/annurev.astro.46.060407.145222 + + Examples + -------- + >>> ref = ReferenceAbundances() # doctest: +SKIP + >>> fe = ref.get_element("Fe") # doctest: +SKIP + >>> print(f"Fe = {fe.Ab:.2f} ± {fe.Uncert:.2f}") # doctest: +SKIP + Fe = 7.46 ± 0.04 + + Using 2009 data: + + >>> ref_2009 = ReferenceAbundances(year=2009) # doctest: +SKIP + >>> fe_2009 = ref_2009.get_element("Fe") # doctest: +SKIP + >>> print(f"Fe (2009) = {fe_2009.Ab:.2f}") # doctest: +SKIP + Fe (2009) = 7.50 """ - def __init__(self): - self.load_data() + _VALID_YEARS = (2009, 2021) + + def __init__(self, year=2021): + if not isinstance(year, int): + raise TypeError(f"year must be an integer, got {type(year).__name__}") + if year not in self._VALID_YEARS: + raise ValueError(f"year must be 2009 or 2021, got {year}") + self._year = year + self._load_data() + + @property + def year(self): + """The reference year for the loaded data.""" + return self._year @property def data(self): - r"""Elemental abundances in dex scale: + r"""Elemental abundances in dex scale. - log ε_X = log(N_X/N_H) + 12 + The dex scale is defined as: + log ε_X = log(N_X/N_H) + 12 where N_X is the number density of species X. + + Returns + ------- + pd.DataFrame + MultiIndex DataFrame with index (Z, Symbol) and columns + (CI_chondrites, Photosphere) × (Ab, Uncert). """ return self._data - def load_data(self): - """Load Asplund 2009 data from package CSV.""" - path = Path(__file__).parent / "data" / "asplund2009.csv" - data = pd.read_csv(path, skiprows=4, header=[0, 1], index_col=[0, 1]).astype( - np.float64 - ) - self._data = data + def _load_data(self): + """Load Asplund data from package CSV based on year.""" + filename = f"asplund{self._year}.csv" + data_file = resources.files(__package__).joinpath("data", filename) + + with data_file.open() as f: + data = pd.read_csv(f, skiprows=4, header=[0, 1], index_col=[0, 1]) + + # 2021 has Comment column, extract before float conversion + # Column is ('Comment', 'Unnamed: X_level_1') due to pandas MultiIndex parsing + comment_cols = [col for col in data.columns if col[0] == "Comment"] + if comment_cols: + comment_col = comment_cols[0] + self._comments = data[comment_col].copy() + data = data.drop(columns=[comment_col]) + else: + self._comments = None + + # Convert remaining columns to float64 + self._data = data.astype(np.float64) def get_element(self, key, kind="Photosphere"): r"""Get measurements for element stored at `key`. @@ -50,8 +135,41 @@ def get_element(self, key, kind="Photosphere"): key : str or int Element symbol ('Fe') or atomic number (26). kind : str, default "Photosphere" - Which abundance source: "Photosphere" or "Meteorites". + Which abundance source: "Photosphere", "CI_chondrites", + or "Meteorites" (alias for CI_chondrites). + + Returns + ------- + pd.Series + Series with 'Ab' (abundance in dex) and 'Uncert' (uncertainty). + + Raises + ------ + ValueError + If key is not a string or integer. + KeyError + If element not found or invalid kind. + + Examples + -------- + >>> ref = ReferenceAbundances() # doctest: +SKIP + >>> ref.get_element("Fe") # doctest: +SKIP + Ab 7.46 + Uncert 0.04 + Name: 26, dtype: float64 + >>> ref.get_element(26) # Same result using atomic number # doctest: +SKIP """ + # Handle backward compatibility alias + kind = _KIND_ALIASES.get(kind, kind) + + # Validate kind + valid_kinds = ["Photosphere", "CI_chondrites"] + if kind not in valid_kinds: + raise KeyError( + f"Invalid kind '{kind}'. Must be one of: {valid_kinds} " + f"(or 'Meteorites' as alias for 'CI_chondrites')" + ) + if isinstance(key, str): level = "Symbol" elif isinstance(key, int): @@ -63,8 +181,71 @@ def get_element(self, key, kind="Photosphere"): assert out.shape[0] == 1 return out.iloc[0] + def get_comment(self, key): + """Get the source comment for an element (2021 data only). + + The comment indicates the source methodology for elements where + the adopted abundance is not from photospheric spectroscopy: + - 'definition': H abundance is defined as 12.00 + - 'helioseismology': Derived from helioseismology (He) + - 'meteorites': Adopted from CI chondrite measurements + - 'solar wind': Derived from solar wind measurements (Ne, Ar, Kr) + - 'nuclear physics': Derived from nuclear physics (Xe) + + Parameters + ---------- + key : str or int + Element symbol ('Fe') or atomic number (26). + + Returns + ------- + str or None + The comment string, or None if no comment (spectroscopic + measurement) or if using 2009 data. + + Examples + -------- + >>> ref = ReferenceAbundances() # doctest: +SKIP + >>> ref.get_comment("H") # doctest: +SKIP + 'definition' + >>> print(ref.get_comment("Fe")) # Spectroscopic, no comment # doctest: +SKIP + None + """ + if self._comments is None: + return None + + if isinstance(key, str): + level = "Symbol" + elif isinstance(key, int): + level = "Z" + else: + raise ValueError(f"Unrecognized key type ({type(key)})") + + try: + comment = self._comments.xs(key, axis=0, level=level) + if len(comment) == 1: + comment = comment.iloc[0] + # Handle empty strings and NaN + if pd.isna(comment) or comment == "": + return None + return comment + except KeyError: + return None + @staticmethod def _convert_from_dex(case): + """Convert from dex to linear abundance ratio relative to H. + + Parameters + ---------- + case : pd.Series + Series with 'Ab' and 'Uncert' in dex. + + Returns + ------- + tuple + (measurement, uncertainty) in linear units. + """ m = case.loc["Ab"] u = case.loc["Uncert"] mm = 10.0 ** (m - 12.0) @@ -83,6 +264,21 @@ def abundance_ratio(self, numerator, denominator): ------- Abundance namedtuple with (measurement, uncertainty). + + Notes + ----- + Uncertainty is propagated assuming independent uncertainties: + σ_ratio = ratio × ln(10) × √(σ_X² + σ_Y²) + + For denominator='H', uses the special conversion from dex + since H is the reference element (log ε_H = 12 by definition). + + Examples + -------- + >>> ref = ReferenceAbundances() # doctest: +SKIP + >>> fe_o = ref.abundance_ratio("Fe", "O") # doctest: +SKIP + >>> print(f"Fe/O = {fe_o.measurement:.4f} ± {fe_o.uncertainty:.4f}") # doctest: +SKIP + Fe/O = 0.0589 ± 0.0077 """ top = self.get_element(numerator) tu = top.Uncert diff --git a/solarwindpy/core/data/asplund2009.csv b/solarwindpy/core/data/asplund2009.csv index 32d1ea3a..807a06d9 100644 --- a/solarwindpy/core/data/asplund2009.csv +++ b/solarwindpy/core/data/asplund2009.csv @@ -1,90 +1,90 @@ Chemical composition of the Sun from Table 1 in [1]. -[1] Asplund, M., Grevesse, N., Sauval, A. J., & Scott, P. (2009). The Chemical Composition of the Sun. Annual Review of Astronomy and Astrophysics, 47(1), 481–522. https://doi.org/10.1146/annurev.astro.46.060407.145222 +[1] Asplund, M., Grevesse, N., Sauval, A. J., & Scott, P. (2009). The Chemical Composition of the Sun. Annual Review of Astronomy and Astrophysics, 47(1), 481-522. https://doi.org/10.1146/annurev.astro.46.060407.145222 -Kind,,Meteorites,Meteorites,Photosphere,Photosphere +Kind,,CI_chondrites,CI_chondrites,Photosphere,Photosphere ,,Ab,Uncert,Ab,Uncert Z,Symbol,,,, -1,H,8.22 , 0.04,12.00, -2,He,1.29,,10.93 , 0.01 -3,Li,3.26 , 0.05,1.05 , 0.10 -4,Be,1.30 , 0.03,1.38 , 0.09 -5,B,2.79 , 0.04,2.70 , 0.20 -6,C,7.39 , 0.04,8.43 , 0.05 -7,N,6.26 , 0.06,7.83 , 0.05 -8,O,8.40 , 0.04,8.69 , 0.05 -9,F,4.42 , 0.06,4.56 , 0.30 -10,Ne,-1.12,,7.93 , 0.10 -11,Na,6.27 , 0.02,6.24 , 0.04 -12,Mg,7.53 , 0.01,7.60 , 0.04 -13,Al,6.43 , 0.01,6.45 , 0.03 -14,Si,7.51 , 0.01,7.51 , 0.03 -15,P,5.43 , 0.04,5.41 , 0.03 -16,S,7.15 , 0.02,7.12 , 0.03 -17,Cl,5.23 , 0.06,5.50 , 0.30 -18,Ar,-0.05,,6.40 , 0.13 -19,K,5.08 , 0.02,5.03 , 0.09 -20,Ca,6.29 , 0.02,6.34 , 0.04 -21,Sc,3.05 , 0.02,3.15 , 0.04 -22,Ti,4.91 , 0.03,4.95 , 0.05 -23,V,3.96 , 0.02,3.93 , 0.08 -24,Cr,5.64 , 0.01,5.64 , 0.04 -25,Mn,5.48 , 0.01,5.43 , 0.04 -26,Fe,7.45 , 0.01,7.50 , 0.04 -27,Co,4.87 , 0.01,4.99 , 0.07 -28,Ni,6.20 , 0.01,6.22 , 0.04 -29,Cu,4.25 , 0.04,4.19 , 0.04 -30,Zn,4.63 , 0.04,4.56 , 0.05 -31,Ga,3.08 , 0.02,3.04 , 0.09 -32,Ge,3.58 , 0.04,3.65 , 0.10 -33,As,2.30 , 0.04,, -34,Se,3.34 , 0.03,, -35,Br,2.54 , 0.06,, -36,Kr,-2.27,,3.25 , 0.06 -37,Rb,2.36 , 0.03,2.52 , 0.10 -38,Sr,2.88 , 0.03,2.87 , 0.07 -39,Y,2.17 , 0.04,2.21 , 0.05 -40,Zr,2.53 , 0.04,2.58 , 0.04 -41,Nb,1.41 , 0.04,1.46 , 0.04 -42,Mo,1.94 , 0.04,1.88 , 0.08 -44,Ru,1.76 , 0.03,1.75 , 0.08 -45,Rh,1.06 , 0.04,0.91 , 0.10 -46,Pd,1.65 , 0.02,1.57 , 0.10 -47,Ag,1.20 , 0.02,0.94 , 0.10 -48,Cd,1.71 , 0.03,, -49,In,0.76 , 0.03,0.80 , 0.20 -50,Sn,2.07 , 0.06,2.04 , 0.10 -51,Sb,1.01 , 0.06,, -52,Te,2.18 , 0.03,, -53,I,1.55 , 0.08,, -54,Xe,-1.95,,2.24 , 0.06 -55,Cs,1.08 , 0.02,, -56,Ba,2.18 , 0.03,2.18 , 0.09 -57,La,1.17 , 0.02,1.10 , 0.04 -58,Ce,1.58 , 0.02,1.58 , 0.04 -59,Pr,0.76 , 0.03,0.72 , 0.04 -60,Nd,1.45 , 0.02,1.42 , 0.04 -62,Sm,0.94 , 0.02,0.96 , 0.04 -63,Eu,0.51 , 0.02,0.52 , 0.04 -64,Gd,1.05 , 0.02,1.07 , 0.04 -65,Tb,0.32 , 0.03,0.30 , 0.10 -66,Dy,1.13 , 0.02,1.10 , 0.04 -67,Ho,0.47 , 0.03,0.48 , 0.11 -68,Er,0.92 , 0.02,0.92 , 0.05 -69,Tm,0.12 , 0.03,0.10 , 0.04 -70,Yb,0.92 , 0.02,0.84 , 0.11 -71,Lu,0.09 , 0.02,0.10 , 0.09 -72,Hf,0.71 , 0.02,0.85 , 0.04 -73,Ta,-0.12 , 0.04,, -74,W,0.65 , 0.04,0.85 , 0.12 -75,Re,0.26 , 0.04,, -76,Os,1.35 , 0.03,1.40 , 0.08 -77,Ir,1.32 , 0.02,1.38 , 0.07 -78,Pt,1.62 , 0.03,, -79,Au,0.80 , 0.04,0.92 , 0.10 -80,Hg,1.17 , 0.08,, -81,Tl,0.77 , 0.03,0.90 , 0.20 -82,Pb,2.04 , 0.03,1.75 , 0.10 -83,Bi,0.65 , 0.04,, -90,Th,0.06 , 0.03,0.02 , 0.10 -92,U,-0.54 , 0.03,, +1,H,8.22,0.04,12.00, +2,He,1.29,,10.93,0.01 +3,Li,3.26,0.05,1.05,0.10 +4,Be,1.30,0.03,1.38,0.09 +5,B,2.79,0.04,2.70,0.20 +6,C,7.39,0.04,8.43,0.05 +7,N,6.26,0.06,7.83,0.05 +8,O,8.40,0.04,8.69,0.05 +9,F,4.42,0.06,4.56,0.30 +10,Ne,-1.12,,7.93,0.10 +11,Na,6.27,0.02,6.24,0.04 +12,Mg,7.53,0.01,7.60,0.04 +13,Al,6.43,0.01,6.45,0.03 +14,Si,7.51,0.01,7.51,0.03 +15,P,5.43,0.04,5.41,0.03 +16,S,7.15,0.02,7.12,0.03 +17,Cl,5.23,0.06,5.50,0.30 +18,Ar,-0.05,,6.40,0.13 +19,K,5.08,0.02,5.03,0.09 +20,Ca,6.29,0.02,6.34,0.04 +21,Sc,3.05,0.02,3.15,0.04 +22,Ti,4.91,0.03,4.95,0.05 +23,V,3.96,0.02,3.93,0.08 +24,Cr,5.64,0.01,5.64,0.04 +25,Mn,5.48,0.01,5.43,0.04 +26,Fe,7.45,0.01,7.50,0.04 +27,Co,4.87,0.01,4.99,0.07 +28,Ni,6.20,0.01,6.22,0.04 +29,Cu,4.25,0.04,4.19,0.04 +30,Zn,4.63,0.04,4.56,0.05 +31,Ga,3.08,0.02,3.04,0.09 +32,Ge,3.58,0.04,3.65,0.10 +33,As,2.30,0.04,, +34,Se,3.34,0.03,, +35,Br,2.54,0.06,, +36,Kr,-2.27,,3.25,0.06 +37,Rb,2.36,0.03,2.52,0.10 +38,Sr,2.88,0.03,2.87,0.07 +39,Y,2.17,0.04,2.21,0.05 +40,Zr,2.53,0.04,2.58,0.04 +41,Nb,1.41,0.04,1.46,0.04 +42,Mo,1.94,0.04,1.88,0.08 +44,Ru,1.76,0.03,1.75,0.08 +45,Rh,1.06,0.04,0.91,0.10 +46,Pd,1.65,0.02,1.57,0.10 +47,Ag,1.20,0.02,0.94,0.10 +48,Cd,1.71,0.03,, +49,In,0.76,0.03,0.80,0.20 +50,Sn,2.07,0.06,2.04,0.10 +51,Sb,1.01,0.06,, +52,Te,2.18,0.03,, +53,I,1.55,0.08,, +54,Xe,-1.95,,2.24,0.06 +55,Cs,1.08,0.02,, +56,Ba,2.18,0.03,2.18,0.09 +57,La,1.17,0.02,1.10,0.04 +58,Ce,1.58,0.02,1.58,0.04 +59,Pr,0.76,0.03,0.72,0.04 +60,Nd,1.45,0.02,1.42,0.04 +62,Sm,0.94,0.02,0.96,0.04 +63,Eu,0.51,0.02,0.52,0.04 +64,Gd,1.05,0.02,1.07,0.04 +65,Tb,0.32,0.03,0.30,0.10 +66,Dy,1.13,0.02,1.10,0.04 +67,Ho,0.47,0.03,0.48,0.11 +68,Er,0.92,0.02,0.92,0.05 +69,Tm,0.12,0.03,0.10,0.04 +70,Yb,0.92,0.02,0.84,0.11 +71,Lu,0.09,0.02,0.10,0.09 +72,Hf,0.71,0.02,0.85,0.04 +73,Ta,-0.12,0.04,, +74,W,0.65,0.04,0.85,0.12 +75,Re,0.26,0.04,, +76,Os,1.35,0.03,1.40,0.08 +77,Ir,1.32,0.02,1.38,0.07 +78,Pt,1.62,0.03,, +79,Au,0.80,0.04,0.92,0.10 +80,Hg,1.17,0.08,, +81,Tl,0.77,0.03,0.90,0.20 +82,Pb,2.04,0.03,1.75,0.10 +83,Bi,0.65,0.04,, +90,Th,0.06,0.03,0.02,0.10 +92,U,-0.54,0.03,, diff --git a/solarwindpy/core/data/asplund2021.csv b/solarwindpy/core/data/asplund2021.csv new file mode 100644 index 00000000..c12eb3c9 --- /dev/null +++ b/solarwindpy/core/data/asplund2021.csv @@ -0,0 +1,90 @@ +Chemical composition of the Sun from Table 2 in [1]. + +[1] Asplund, M., Amarsi, A. M., & Grevesse, N. (2021). The chemical make-up of the Sun: A 2020 vision. A&A, 653, A141. https://doi.org/10.1051/0004-6361/202140445 + +Kind,,CI_chondrites,CI_chondrites,Photosphere,Photosphere,Comment +,,Ab,Uncert,Ab,Uncert, +Z,Symbol,,,,, +1,H,8.22,0.04,12.00,0.00,definition +2,He,1.29,0.18,10.914,0.013,helioseismology +3,Li,3.25,0.04,0.96,0.06,meteorites +4,Be,1.32,0.03,1.38,0.09, +5,B,2.79,0.04,2.70,0.20, +6,C,7.39,0.04,8.46,0.04, +7,N,6.26,0.06,7.83,0.07, +8,O,8.39,0.04,8.69,0.04, +9,F,4.42,0.06,4.40,0.25, +10,Ne,,0.18,8.06,0.05,solar wind +11,Na,6.27,0.04,6.22,0.03, +12,Mg,7.53,0.02,7.55,0.03, +13,Al,6.43,0.03,6.43,0.03, +14,Si,7.51,0.01,7.51,0.03, +15,P,5.43,0.03,5.41,0.03, +16,S,7.15,0.02,7.12,0.03, +17,Cl,5.23,0.06,5.31,0.20, +18,Ar,,0.18,6.38,0.10,solar wind +19,K,5.08,0.04,5.07,0.03, +20,Ca,6.29,0.03,6.30,0.03, +21,Sc,3.04,0.03,3.14,0.04, +22,Ti,4.90,0.03,4.97,0.05, +23,V,3.96,0.03,3.90,0.08, +24,Cr,5.63,0.02,5.62,0.04, +25,Mn,5.47,0.03,5.42,0.06, +26,Fe,7.46,0.02,7.46,0.04, +27,Co,4.87,0.02,4.94,0.05, +28,Ni,6.20,0.03,6.20,0.04, +29,Cu,4.25,0.06,4.18,0.05, +30,Zn,4.61,0.02,4.56,0.05, +31,Ga,3.07,0.03,3.02,0.05, +32,Ge,3.58,0.04,3.62,0.10, +33,As,2.30,0.04,,,meteorites +34,Se,3.34,0.03,,,meteorites +35,Br,2.54,0.06,,,meteorites +36,Kr,,0.18,3.12,0.10,solar wind +37,Rb,2.37,0.03,2.32,0.08, +38,Sr,2.88,0.03,2.83,0.06, +39,Y,2.15,0.02,2.21,0.05, +40,Zr,2.53,0.02,2.59,0.04, +41,Nb,1.42,0.04,1.47,0.06, +42,Mo,1.93,0.04,1.88,0.09, +44,Ru,1.77,0.02,1.75,0.08, +45,Rh,1.04,0.02,0.78,0.11, +46,Pd,1.65,0.02,1.57,0.10, +47,Ag,1.20,0.04,0.96,0.10, +48,Cd,1.71,0.03,,,meteorites +49,In,0.76,0.02,0.80,0.20, +50,Sn,2.07,0.06,2.02,0.10, +51,Sb,1.01,0.06,,,meteorites +52,Te,2.18,0.03,,,meteorites +53,I,1.55,0.08,,,meteorites +54,Xe,,0.18,2.22,0.05,nuclear physics +55,Cs,1.08,0.03,,,meteorites +56,Ba,2.18,0.02,2.27,0.05, +57,La,1.17,0.01,1.11,0.04, +58,Ce,1.58,0.01,1.58,0.04, +59,Pr,0.76,0.01,0.75,0.05, +60,Nd,1.45,0.01,1.42,0.04, +62,Sm,0.94,0.01,0.95,0.04, +63,Eu,0.52,0.01,0.52,0.04, +64,Gd,1.05,0.01,1.08,0.04, +65,Tb,0.31,0.01,0.31,0.10, +66,Dy,1.13,0.01,1.10,0.04, +67,Ho,0.47,0.01,0.48,0.11, +68,Er,0.93,0.01,0.93,0.05, +69,Tm,0.12,0.01,0.11,0.04, +70,Yb,0.92,0.01,0.85,0.11, +71,Lu,0.09,0.01,0.10,0.09, +72,Hf,0.71,0.01,0.85,0.05, +73,Ta,-0.15,0.04,,,meteorites +74,W,0.65,0.04,0.79,0.11, +75,Re,0.26,0.02,,,meteorites +76,Os,1.35,0.02,1.35,0.12, +77,Ir,1.32,0.02,,,meteorites +78,Pt,1.61,0.02,,,meteorites +79,Au,0.81,0.05,0.91,0.12, +80,Hg,1.17,0.18,,,meteorites +81,Tl,0.77,0.05,0.92,0.17, +82,Pb,2.03,0.03,1.95,0.08, +83,Bi,0.65,0.04,,,meteorites +90,Th,0.04,0.03,0.03,0.10, +92,U,-0.54,0.03,,,meteorites diff --git a/solarwindpy/data/__init__.py b/solarwindpy/data/__init__.py deleted file mode 100644 index 2f18af03..00000000 --- a/solarwindpy/data/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Solar wind reference data and constants.""" - -from .reference import ReferenceAbundances - -__all__ = ["ReferenceAbundances"] diff --git a/solarwindpy/data/reference/__init__.py b/solarwindpy/data/reference/__init__.py deleted file mode 100644 index 073acddf..00000000 --- a/solarwindpy/data/reference/__init__.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Reference photospheric abundances from Asplund et al. (2009). - -Reference: - Asplund, M., Grevesse, N., Sauval, A. J., & Scott, P. (2009). - The Chemical Composition of the Sun. - Annual Review of Astronomy and Astrophysics, 47(1), 481-522. - https://doi.org/10.1146/annurev.astro.46.060407.145222 -""" - -__all__ = ["ReferenceAbundances", "Abundance"] - -import numpy as np -import pandas as pd -from collections import namedtuple -from importlib import resources - -Abundance = namedtuple("Abundance", "measurement,uncertainty") - - -class ReferenceAbundances: - """Photospheric elemental abundances from Asplund et al. (2009). - - Abundances are stored in 'dex' units: - log(epsilon_X) = log(N_X/N_H) + 12 - - where N_X is the number density of element X and N_H is hydrogen. - - Example - ------- - >>> ref = ReferenceAbundances() - >>> fe_o = ref.photospheric_abundance("Fe", "O") - >>> print(f"Fe/O = {fe_o.measurement:.4f} +/- {fe_o.uncertainty:.4f}") - Fe/O = 0.0646 +/- 0.0060 - """ - - def __init__(self): - self._load_data() - - @property - def data(self): - """Elemental abundances in dex units.""" - return self._data - - def _load_data(self): - """Load Asplund 2009 data from package resources.""" - with resources.files(__package__).joinpath("asplund.csv").open() as f: - data = pd.read_csv(f, skiprows=4, header=[0, 1], index_col=[0, 1]).astype( - np.float64 - ) - self._data = data - - def get_element(self, key, kind="Photosphere"): - """Get abundance measurements for an element. - - Parameters - ---------- - key : str or int - Element symbol (e.g., "Fe") or atomic number (e.g., 26) - kind : str, optional - "Photosphere" or "Meteorites" (default: "Photosphere") - - Returns - ------- - pd.Series - Series with 'Ab' (abundance in dex) and 'Uncert' (uncertainty) - """ - if isinstance(key, str): - level = "Symbol" - elif isinstance(key, int): - level = "Z" - else: - raise ValueError(f"Unrecognized key type ({type(key)})") - - out = self.data.loc[:, kind].xs(key, axis=0, level=level) - assert out.shape[0] == 1, f"Expected 1 row for {key}, got {out.shape[0]}" - - return out.iloc[0] - - @staticmethod - def _convert_from_dex(case): - """Convert from dex to linear abundance ratio.""" - m = case.loc["Ab"] - u = case.loc["Uncert"] - - mm = 10.0 ** (m - 12.0) - uu = mm * np.log(10) * u - return mm, uu - - def photospheric_abundance(self, top, bottom): - """Compute photospheric abundance ratio of two elements. - - Parameters - ---------- - top : str or int - Numerator element (symbol or Z) - bottom : str or int - Denominator element (symbol or Z), or "H" for hydrogen - - Returns - ------- - Abundance - Named tuple with (measurement, uncertainty) for the ratio N_top/N_bottom - - Example - ------- - >>> ref = ReferenceAbundances() - >>> ref.photospheric_abundance("Fe", "O") - Abundance(measurement=0.0646, uncertainty=0.0060) - """ - top_data = self.get_element(top) - tu = top_data.Uncert - if np.isnan(tu): - tu = 0 - - if bottom != "H": - bottom_data = self.get_element(bottom) - bu = bottom_data.Uncert - if np.isnan(bu): - bu = 0 - - rat = 10.0 ** (top_data.Ab - bottom_data.Ab) - uncert = rat * np.log(10) * np.sqrt((tu**2) + (bu**2)) - else: - rat, uncert = self._convert_from_dex(top_data) - - return Abundance(rat, uncert) diff --git a/solarwindpy/data/reference/asplund.csv b/solarwindpy/data/reference/asplund.csv deleted file mode 100644 index 32d1ea3a..00000000 --- a/solarwindpy/data/reference/asplund.csv +++ /dev/null @@ -1,90 +0,0 @@ -Chemical composition of the Sun from Table 1 in [1]. - -[1] Asplund, M., Grevesse, N., Sauval, A. J., & Scott, P. (2009). The Chemical Composition of the Sun. Annual Review of Astronomy and Astrophysics, 47(1), 481–522. https://doi.org/10.1146/annurev.astro.46.060407.145222 - -Kind,,Meteorites,Meteorites,Photosphere,Photosphere -,,Ab,Uncert,Ab,Uncert -Z,Symbol,,,, -1,H,8.22 , 0.04,12.00, -2,He,1.29,,10.93 , 0.01 -3,Li,3.26 , 0.05,1.05 , 0.10 -4,Be,1.30 , 0.03,1.38 , 0.09 -5,B,2.79 , 0.04,2.70 , 0.20 -6,C,7.39 , 0.04,8.43 , 0.05 -7,N,6.26 , 0.06,7.83 , 0.05 -8,O,8.40 , 0.04,8.69 , 0.05 -9,F,4.42 , 0.06,4.56 , 0.30 -10,Ne,-1.12,,7.93 , 0.10 -11,Na,6.27 , 0.02,6.24 , 0.04 -12,Mg,7.53 , 0.01,7.60 , 0.04 -13,Al,6.43 , 0.01,6.45 , 0.03 -14,Si,7.51 , 0.01,7.51 , 0.03 -15,P,5.43 , 0.04,5.41 , 0.03 -16,S,7.15 , 0.02,7.12 , 0.03 -17,Cl,5.23 , 0.06,5.50 , 0.30 -18,Ar,-0.05,,6.40 , 0.13 -19,K,5.08 , 0.02,5.03 , 0.09 -20,Ca,6.29 , 0.02,6.34 , 0.04 -21,Sc,3.05 , 0.02,3.15 , 0.04 -22,Ti,4.91 , 0.03,4.95 , 0.05 -23,V,3.96 , 0.02,3.93 , 0.08 -24,Cr,5.64 , 0.01,5.64 , 0.04 -25,Mn,5.48 , 0.01,5.43 , 0.04 -26,Fe,7.45 , 0.01,7.50 , 0.04 -27,Co,4.87 , 0.01,4.99 , 0.07 -28,Ni,6.20 , 0.01,6.22 , 0.04 -29,Cu,4.25 , 0.04,4.19 , 0.04 -30,Zn,4.63 , 0.04,4.56 , 0.05 -31,Ga,3.08 , 0.02,3.04 , 0.09 -32,Ge,3.58 , 0.04,3.65 , 0.10 -33,As,2.30 , 0.04,, -34,Se,3.34 , 0.03,, -35,Br,2.54 , 0.06,, -36,Kr,-2.27,,3.25 , 0.06 -37,Rb,2.36 , 0.03,2.52 , 0.10 -38,Sr,2.88 , 0.03,2.87 , 0.07 -39,Y,2.17 , 0.04,2.21 , 0.05 -40,Zr,2.53 , 0.04,2.58 , 0.04 -41,Nb,1.41 , 0.04,1.46 , 0.04 -42,Mo,1.94 , 0.04,1.88 , 0.08 -44,Ru,1.76 , 0.03,1.75 , 0.08 -45,Rh,1.06 , 0.04,0.91 , 0.10 -46,Pd,1.65 , 0.02,1.57 , 0.10 -47,Ag,1.20 , 0.02,0.94 , 0.10 -48,Cd,1.71 , 0.03,, -49,In,0.76 , 0.03,0.80 , 0.20 -50,Sn,2.07 , 0.06,2.04 , 0.10 -51,Sb,1.01 , 0.06,, -52,Te,2.18 , 0.03,, -53,I,1.55 , 0.08,, -54,Xe,-1.95,,2.24 , 0.06 -55,Cs,1.08 , 0.02,, -56,Ba,2.18 , 0.03,2.18 , 0.09 -57,La,1.17 , 0.02,1.10 , 0.04 -58,Ce,1.58 , 0.02,1.58 , 0.04 -59,Pr,0.76 , 0.03,0.72 , 0.04 -60,Nd,1.45 , 0.02,1.42 , 0.04 -62,Sm,0.94 , 0.02,0.96 , 0.04 -63,Eu,0.51 , 0.02,0.52 , 0.04 -64,Gd,1.05 , 0.02,1.07 , 0.04 -65,Tb,0.32 , 0.03,0.30 , 0.10 -66,Dy,1.13 , 0.02,1.10 , 0.04 -67,Ho,0.47 , 0.03,0.48 , 0.11 -68,Er,0.92 , 0.02,0.92 , 0.05 -69,Tm,0.12 , 0.03,0.10 , 0.04 -70,Yb,0.92 , 0.02,0.84 , 0.11 -71,Lu,0.09 , 0.02,0.10 , 0.09 -72,Hf,0.71 , 0.02,0.85 , 0.04 -73,Ta,-0.12 , 0.04,, -74,W,0.65 , 0.04,0.85 , 0.12 -75,Re,0.26 , 0.04,, -76,Os,1.35 , 0.03,1.40 , 0.08 -77,Ir,1.32 , 0.02,1.38 , 0.07 -78,Pt,1.62 , 0.03,, -79,Au,0.80 , 0.04,0.92 , 0.10 -80,Hg,1.17 , 0.08,, -81,Tl,0.77 , 0.03,0.90 , 0.20 -82,Pb,2.04 , 0.03,1.75 , 0.10 -83,Bi,0.65 , 0.04,, -90,Th,0.06 , 0.03,0.02 , 0.10 -92,U,-0.54 , 0.03,, diff --git a/tests/core/test_abundances.py b/tests/core/test_abundances.py index a045add1..a2c21b10 100644 --- a/tests/core/test_abundances.py +++ b/tests/core/test_abundances.py @@ -1,14 +1,30 @@ """Tests for ReferenceAbundances class. Tests verify: -1. Data structure matches expected CSV format -2. Values match published Asplund 2009 Table 1 +1. Data structure matches expected CSV format (both 2009 and 2021) +2. Values match published Asplund tables 3. Uncertainty propagation formula is correct -4. Edge cases (NaN, H denominator) handled properly +4. Edge cases (NaN, H denominator, missing photosphere) handled properly +5. Backward compatibility (Meteorites alias, year=2009) +6. Comments column (2021 only) + +References +---------- +Asplund, M., Amarsi, A. M., & Grevesse, N. (2021). +The chemical make-up of the Sun: A 2020 vision. +A&A, 653, A141. https://doi.org/10.1051/0004-6361/202140445 + +Asplund, M., Grevesse, N., Sauval, A. J., & Scott, P. (2009). +The Chemical Composition of the Sun. +Annu. Rev. Astron. Astrophys., 47, 481-522. +https://doi.org/10.1146/annurev.astro.46.060407.145222 Run: pytest tests/core/test_abundances.py -v """ +from dataclasses import dataclass +from typing import Dict, Optional + import numpy as np import pandas as pd import pytest @@ -16,198 +32,768 @@ from solarwindpy.core.abundances import ReferenceAbundances, Abundance +# ============================================================================= +# Test Data Specifications +# ============================================================================= + + +@dataclass(frozen=True) +class ElementData: + """Expected values for a single element from published tables. + + Parameters + ---------- + symbol : str + Element symbol (e.g., 'Fe'). + z : int + Atomic number. + photosphere_ab : float or None + Photospheric abundance in dex (None if no measurement). + photosphere_uncert : float or None + Photospheric uncertainty. + ci_chondrites_ab : float + CI chondrite abundance in dex. + ci_chondrites_uncert : float + CI chondrite uncertainty. + comment : str or None + Source comment (2021 only): 'definition', 'helioseismology', etc. + """ + + symbol: str + z: int + photosphere_ab: Optional[float] + photosphere_uncert: Optional[float] + ci_chondrites_ab: float + ci_chondrites_uncert: float + comment: Optional[str] = None + + +# Reference data keyed by year - values from published Asplund tables +ASPLUND_DATA: Dict[int, Dict[str, ElementData]] = { + 2009: { + "H": ElementData("H", 1, 12.00, None, 8.22, 0.04), + "He": ElementData("He", 2, 10.93, 0.01, 1.29, None), + "Li": ElementData("Li", 3, 1.05, 0.10, 3.26, 0.05), + "C": ElementData("C", 6, 8.43, 0.05, 7.39, 0.04), + "N": ElementData("N", 7, 7.83, 0.05, 6.26, 0.06), + "O": ElementData("O", 8, 8.69, 0.05, 8.40, 0.04), + "Ne": ElementData("Ne", 10, 7.93, 0.10, None, None), + "Fe": ElementData("Fe", 26, 7.50, 0.04, 7.45, 0.01), + "Si": ElementData("Si", 14, 7.51, 0.03, 7.51, 0.01), + "As": ElementData("As", 33, None, None, 2.30, 0.04), + }, + 2021: { + "H": ElementData("H", 1, 12.00, 0.00, 8.22, 0.04, "definition"), + "He": ElementData("He", 2, 10.914, 0.013, 1.29, 0.18, "helioseismology"), + "Li": ElementData("Li", 3, 0.96, 0.06, 3.25, 0.04, "meteorites"), + "C": ElementData("C", 6, 8.46, 0.04, 7.39, 0.04, None), + "N": ElementData("N", 7, 7.83, 0.07, 6.26, 0.06, None), + "O": ElementData("O", 8, 8.69, 0.04, 8.39, 0.04, None), + "Ne": ElementData("Ne", 10, 8.06, 0.05, None, None, "solar wind"), + "Fe": ElementData("Fe", 26, 7.46, 0.04, 7.46, 0.02, None), + "Si": ElementData("Si", 14, 7.51, 0.03, 7.51, 0.01, None), + "As": ElementData("As", 33, None, None, 2.30, 0.04, "meteorites"), + "Xe": ElementData("Xe", 54, 2.22, 0.05, None, None, "nuclear physics"), + }, +} + +# Elements with no photospheric data in BOTH years +# Note: Ir and Pt have photospheric data in 2009 but not 2021 +ELEMENTS_WITHOUT_PHOTOSPHERE = [ + "As", + "Se", + "Br", + "Cd", + "Sb", + "Te", + "I", + "Cs", + "Ta", + "Re", + # "Ir", # Has data in 2009, not 2021 + # "Pt", # Has data in 2009, not 2021 + "Hg", + "Bi", + "U", +] + +# Expected abundance ratios computed from published values +# Format: (expected_ratio, sigma_numerator, sigma_denominator) +EXPECTED_RATIOS: Dict[int, Dict[tuple, tuple]] = { + 2009: { + ("Fe", "O"): (10.0 ** (7.50 - 8.69), 0.04, 0.05), + ("C", "O"): (10.0 ** (8.43 - 8.69), 0.05, 0.05), + ("Fe", "H"): (10.0 ** (7.50 - 12.0), 0.04, 0.0), + }, + 2021: { + ("Fe", "O"): (10.0 ** (7.46 - 8.69), 0.04, 0.04), + ("C", "O"): (10.0 ** (8.46 - 8.69), 0.04, 0.04), + ("Fe", "H"): (10.0 ** (7.46 - 12.0), 0.04, 0.0), + }, +} + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture(params=[2009, 2021], ids=["asplund2009", "asplund2021"]) +def ref_any_year(request): + """ReferenceAbundances instance for both years (structural tests).""" + return ReferenceAbundances(year=request.param) + + +@pytest.fixture +def ref_2021(): + """ReferenceAbundances with 2021 data (default).""" + return ReferenceAbundances() + + +@pytest.fixture +def ref_2009(): + """ReferenceAbundances with 2009 data.""" + return ReferenceAbundances(year=2009) + + +@pytest.fixture(params=[2009, 2021], ids=["asplund2009", "asplund2021"]) +def ref_with_year(request): + """Tuple of (ReferenceAbundances, year) for value-parameterized tests.""" + year = request.param + return ReferenceAbundances(year=year), year + + +# ============================================================================= +# Smoke Tests: Data Loading +# ============================================================================= + + +class TestDataLoading: + """Smoke tests: verify data files load without errors.""" + + def test_default_loads_2021_data(self): + """Default initialization loads 2021 data.""" + ref = ReferenceAbundances() + assert isinstance(ref.data, pd.DataFrame), ( + f"Expected pd.DataFrame, got {type(ref.data).__name__}" + ) + assert ref.year == 2021, f"Expected default year=2021, got {ref.year}" + + def test_explicit_2021_loads(self): + """year=2021 loads 2021 data explicitly.""" + ref = ReferenceAbundances(year=2021) + assert isinstance(ref.data, pd.DataFrame), ( + f"Expected pd.DataFrame, got {type(ref.data).__name__}" + ) + assert ref.year == 2021, f"Expected year=2021, got {ref.year}" + + def test_explicit_2009_loads(self): + """year=2009 loads 2009 data for backward compatibility.""" + ref = ReferenceAbundances(year=2009) + assert isinstance(ref.data, pd.DataFrame), ( + f"Expected pd.DataFrame, got {type(ref.data).__name__}" + ) + assert ref.year == 2009, f"Expected year=2009, got {ref.year}" + + def test_invalid_year_raises_valueerror(self): + """Invalid year raises ValueError with helpful message.""" + with pytest.raises(ValueError, match=r"year must be 2009 or 2021"): + ReferenceAbundances(year=2000) + + def test_invalid_year_type_raises_typeerror(self): + """Non-integer year raises TypeError.""" + with pytest.raises(TypeError, match=r"year must be an integer"): + ReferenceAbundances(year="2021") + + +# ============================================================================= +# Unit Tests: Data Structure +# ============================================================================= + + class TestDataStructure: - """Verify CSV loads with correct structure.""" - - @pytest.fixture - def ref(self): - return ReferenceAbundances() - - def test_data_is_dataframe(self, ref): - # NOT: assert ref.data is not None (trivial) - # GOOD: Verify specific type - assert isinstance( - ref.data, pd.DataFrame - ), f"Expected DataFrame, got {type(ref.data)}" - - def test_data_has_83_elements(self, ref): - # Verify row count matches Asplund Table 1 - assert ( - ref.data.shape[0] == 83 - ), f"Expected 83 elements (Asplund Table 1), got {ref.data.shape[0]}" - - def test_index_is_multiindex_with_z_symbol(self, ref): - assert isinstance( - ref.data.index, pd.MultiIndex - ), f"Expected MultiIndex, got {type(ref.data.index)}" - assert list(ref.data.index.names) == [ - "Z", - "Symbol", - ], f"Expected index levels ['Z', 'Symbol'], got {ref.data.index.names}" - - def test_columns_have_photosphere_and_meteorites(self, ref): - top_level = ref.data.columns.get_level_values(0).unique().tolist() + """Unit tests for DataFrame structure: shape, dtype, index.""" + + def test_data_is_dataframe(self, ref_any_year): + """Data property returns pandas DataFrame.""" + assert isinstance(ref_any_year.data, pd.DataFrame), ( + f"Expected pd.DataFrame, got {type(ref_any_year.data).__name__}" + ) + + def test_data_has_83_elements(self, ref_any_year): + """Both Asplund 2009 and 2021 have 83 elements.""" + assert ref_any_year.data.shape[0] == 83, ( + f"Expected 83 elements, got {ref_any_year.data.shape[0]}" + ) + + def test_index_is_multiindex_with_z_symbol(self, ref_any_year): + """Index is MultiIndex with levels ['Z', 'Symbol'].""" + idx = ref_any_year.data.index + assert isinstance(idx, pd.MultiIndex), ( + f"Expected MultiIndex, got {type(idx).__name__}" + ) + assert list(idx.names) == ["Z", "Symbol"], ( + f"Expected index names ['Z', 'Symbol'], got {list(idx.names)}" + ) + + def test_columns_have_photosphere_and_ci_chondrites(self, ref_any_year): + """Top-level columns include Photosphere and CI_chondrites.""" + top_level = ref_any_year.data.columns.get_level_values(0).unique().tolist() assert "Photosphere" in top_level, "Missing 'Photosphere' column group" - assert "Meteorites" in top_level, "Missing 'Meteorites' column group" - - def test_data_dtype_is_float64(self, ref): - # All values should be float64 after .astype(np.float64) - for col in ref.data.columns: - assert ( - ref.data[col].dtype == np.float64 - ), f"Column {col} has dtype {ref.data[col].dtype}, expected float64" + assert "CI_chondrites" in top_level, "Missing 'CI_chondrites' column group" + + def test_columns_are_multiindex(self, ref_any_year): + """Columns are MultiIndex with at least 2 levels.""" + assert isinstance(ref_any_year.data.columns, pd.MultiIndex), ( + f"Expected MultiIndex columns, got {type(ref_any_year.data.columns).__name__}" + ) + assert ref_any_year.data.columns.nlevels >= 2, ( + f"Expected at least 2 column levels, got {ref_any_year.data.columns.nlevels}" + ) + + def test_abundance_values_are_float64(self, ref_any_year): + """All Ab and Uncert columns are float64.""" + for col in ref_any_year.data.columns: + # Check columns that contain abundance data + if len(col) >= 2 and col[1] in ["Ab", "Uncert"]: + dtype = ref_any_year.data[col].dtype + assert dtype == np.float64, ( + f"Column {col} has dtype {dtype}, expected float64" + ) + + @pytest.mark.parametrize("z", [1, 26, 92]) + def test_key_z_values_present(self, ref_any_year, z): + """Key atomic numbers (H=1, Fe=26, U=92) are present in index.""" + z_values = ref_any_year.data.index.get_level_values("Z").tolist() + assert z in z_values, f"Z={z} not found in index" + + @pytest.mark.parametrize("symbol", ["H", "He", "C", "O", "Fe", "Si"]) + def test_key_symbols_present(self, ref_any_year, symbol): + """Key element symbols are present in index.""" + symbols = ref_any_year.data.index.get_level_values("Symbol").tolist() + assert symbol in symbols, f"Symbol '{symbol}' not found in index" + + def test_z_values_are_integers(self, ref_any_year): + """Z values in index are integers.""" + z_values = ref_any_year.data.index.get_level_values("Z") + # Check that Z values can be used as integers + assert all(isinstance(z, (int, np.integer)) for z in z_values), ( + "Z values should be integers" + ) + + def test_z_range_is_1_to_92(self, ref_any_year): + """Z values range from 1 (H) to 92 (U).""" + z_values = ref_any_year.data.index.get_level_values("Z") + assert min(z_values) == 1, f"Expected min Z=1, got {min(z_values)}" + assert max(z_values) == 92, f"Expected max Z=92, got {max(z_values)}" + + +# ============================================================================= +# Unit Tests: Year Parameter +# ============================================================================= + + +class TestYearParameter: + """Unit tests for year parameter behavior.""" + + def test_year_attribute_stored_2009(self, ref_2009): + """Year is stored as instance attribute for 2009.""" + assert ref_2009.year == 2009, f"Expected year=2009, got {ref_2009.year}" + + def test_year_attribute_stored_2021(self, ref_2021): + """Year is stored as instance attribute for 2021.""" + assert ref_2021.year == 2021, f"Expected year=2021, got {ref_2021.year}" + + def test_2009_fe_differs_from_2021(self): + """Fe photosphere differs: 7.50 (2009) vs 7.46 (2021).""" + ref_2009 = ReferenceAbundances(year=2009) + ref_2021 = ReferenceAbundances(year=2021) + + fe_2009 = ref_2009.get_element("Fe") + fe_2021 = ref_2021.get_element("Fe") + + # 2009: Fe = 7.50, 2021: Fe = 7.46 + assert not np.isclose(fe_2009.Ab, fe_2021.Ab, atol=0.01), ( + f"Fe should differ between years: 2009={fe_2009.Ab}, 2021={fe_2021.Ab}" + ) + assert np.isclose(fe_2009.Ab, 7.50, atol=0.01), ( + f"2009 Fe should be 7.50, got {fe_2009.Ab}" + ) + assert np.isclose(fe_2021.Ab, 7.46, atol=0.01), ( + f"2021 Fe should be 7.46, got {fe_2021.Ab}" + ) + + +# ============================================================================= +# Unit Tests: Column Naming +# ============================================================================= + + +class TestColumnNaming: + """Unit tests for CI_chondrites column with Meteorites alias.""" + + def test_ci_chondrites_in_columns(self, ref_any_year): + """'CI_chondrites' is a top-level column.""" + top_level = ref_any_year.data.columns.get_level_values(0).unique().tolist() + assert "CI_chondrites" in top_level, ( + f"'CI_chondrites' not in columns: {top_level}" + ) + + def test_photosphere_in_columns(self, ref_any_year): + """'Photosphere' is a top-level column.""" + top_level = ref_any_year.data.columns.get_level_values(0).unique().tolist() + assert "Photosphere" in top_level, f"'Photosphere' not in columns: {top_level}" + + def test_meteorites_alias_returns_ci_chondrites_data(self, ref_any_year): + """kind='Meteorites' returns same data as kind='CI_chondrites'.""" + fe_meteorites = ref_any_year.get_element("Fe", kind="Meteorites") + fe_ci_chondrites = ref_any_year.get_element("Fe", kind="CI_chondrites") + + pd.testing.assert_series_equal( + fe_meteorites, + fe_ci_chondrites, + check_names=False, + obj="Fe via kind='Meteorites' vs kind='CI_chondrites'", + ) + + def test_meteorites_alias_works_for_multiple_elements(self, ref_any_year): + """Meteorites alias works consistently for multiple elements.""" + for symbol in ["H", "C", "O", "Si"]: + via_alias = ref_any_year.get_element(symbol, kind="Meteorites") + via_canonical = ref_any_year.get_element(symbol, kind="CI_chondrites") + pd.testing.assert_series_equal( + via_alias, + via_canonical, + check_names=False, + obj=f"{symbol} via Meteorites vs CI_chondrites", + ) + + def test_invalid_kind_raises_keyerror(self, ref_any_year): + """Invalid kind raises KeyError.""" + with pytest.raises(KeyError, match=r"Invalid|not found|unknown"): + ref_any_year.get_element("Fe", kind="InvalidKind") + + +# ============================================================================= +# Unit Tests: Comments Column (2021 only) +# ============================================================================= + + +class TestCommentsColumn: + """Unit tests for Comments metadata column (2021 only).""" + + def test_2021_has_get_comment_method(self, ref_2021): + """2021 instance has get_comment method.""" + assert hasattr(ref_2021, "get_comment"), ( + "ReferenceAbundances should have get_comment method" + ) + + @pytest.mark.parametrize( + "symbol,expected_comment", + [ + ("H", "definition"), + ("He", "helioseismology"), + ("As", "meteorites"), + ("Ne", "solar wind"), + ("Xe", "nuclear physics"), + ("Li", "meteorites"), + ], + ) + def test_comment_values_match_asplund_2021(self, ref_2021, symbol, expected_comment): + """Comment values match Asplund 2021 Table 2.""" + comment = ref_2021.get_comment(symbol) + assert comment == expected_comment, ( + f"{symbol} comment: expected '{expected_comment}', got '{comment}'" + ) + + @pytest.mark.parametrize("symbol", ["C", "O", "Fe", "Si", "N"]) + def test_spectroscopic_elements_have_no_comment(self, ref_2021, symbol): + """Elements with spectroscopic measurements have empty/None comment.""" + comment = ref_2021.get_comment(symbol) + assert comment is None or comment == "" or pd.isna(comment), ( + f"{symbol} should have no comment (spectroscopic), got '{comment}'" + ) + + def test_2009_get_comment_returns_none(self, ref_2009): + """2009 data get_comment returns None (no comments in 2009).""" + comment = ref_2009.get_comment("H") + assert comment is None, ( + f"2009 get_comment should return None, got '{comment}'" + ) + + +# ============================================================================= +# Unit Tests: Get Element +# ============================================================================= - def test_h_has_nan_photosphere_uncertainty(self, ref): - # H photosphere uncertainty is NaN (by definition, H is the reference) - h = ref.get_element("H") - assert np.isnan(h.Uncert), f"H uncertainty should be NaN, got {h.Uncert}" - def test_arsenic_photosphere_is_nan(self, ref): - # As (Z=33) has no photospheric measurement (only meteoritic) - arsenic = ref.get_element("As", kind="Photosphere") - assert np.isnan( - arsenic.Ab - ), f"As photosphere Ab should be NaN, got {arsenic.Ab}" +class TestGetElement: + """Unit tests for element lookup by symbol and Z.""" + + def test_get_by_symbol_returns_series(self, ref_any_year): + """get_element('Fe') returns pd.Series.""" + fe = ref_any_year.get_element("Fe") + assert isinstance(fe, pd.Series), ( + f"Expected pd.Series, got {type(fe).__name__}" + ) + + def test_get_by_symbol_series_has_correct_shape(self, ref_any_year): + """get_element returns Series with shape (2,) for [Ab, Uncert].""" + fe = ref_any_year.get_element("Fe") + assert fe.shape == (2,), ( + f"Expected shape (2,) for [Ab, Uncert], got {fe.shape}" + ) + + def test_get_by_symbol_series_has_correct_index(self, ref_any_year): + """get_element returns Series with index ['Ab', 'Uncert'].""" + fe = ref_any_year.get_element("Fe") + assert list(fe.index) == ["Ab", "Uncert"], ( + f"Expected index ['Ab', 'Uncert'], got {list(fe.index)}" + ) + + def test_get_by_symbol_series_dtype_is_float64(self, ref_any_year): + """get_element returns Series with float64 dtype.""" + fe = ref_any_year.get_element("Fe") + assert fe.dtype == np.float64, ( + f"Expected dtype float64, got {fe.dtype}" + ) + + def test_get_by_z_returns_series(self, ref_any_year): + """get_element(26) returns pd.Series.""" + fe = ref_any_year.get_element(26) + assert isinstance(fe, pd.Series), ( + f"Expected pd.Series, got {type(fe).__name__}" + ) + + def test_symbol_and_z_return_equal_values(self, ref_any_year): + """get_element('Fe') equals get_element(26) in values.""" + by_symbol = ref_any_year.get_element("Fe") + by_z = ref_any_year.get_element(26) + pd.testing.assert_series_equal( + by_symbol, by_z, check_names=False, obj="Fe by symbol vs by Z" + ) + + def test_default_kind_is_photosphere(self, ref_any_year): + """Default kind is 'Photosphere'.""" + default = ref_any_year.get_element("Fe") + explicit = ref_any_year.get_element("Fe", kind="Photosphere") + pd.testing.assert_series_equal( + default, explicit, check_names=False, obj="Default kind vs explicit Photosphere" + ) + + def test_invalid_key_type_raises_valueerror(self, ref_any_year): + """Float key raises ValueError.""" + with pytest.raises(ValueError, match=r"Unrecognized key type"): + ref_any_year.get_element(3.14) + + def test_unknown_element_raises_keyerror(self, ref_any_year): + """Unknown element raises KeyError.""" + with pytest.raises(KeyError): + ref_any_year.get_element("Xx") + + def test_unknown_z_raises_keyerror(self, ref_any_year): + """Unknown atomic number raises KeyError.""" + with pytest.raises(KeyError): + ref_any_year.get_element(999) + + +# ============================================================================= +# Unit Tests: Missing Photosphere Data +# ============================================================================= + + +class TestMissingPhotosphereData: + """Unit tests for elements without photospheric measurements.""" + + @pytest.mark.parametrize("symbol", ELEMENTS_WITHOUT_PHOTOSPHERE) + def test_missing_photosphere_ab_is_nan(self, ref_any_year, symbol): + """Elements without photospheric data have NaN for Ab.""" + element = ref_any_year.get_element(symbol, kind="Photosphere") + assert np.isnan(element.Ab), ( + f"{symbol} photosphere Ab should be NaN, got {element.Ab}" + ) + + @pytest.mark.parametrize("symbol", ELEMENTS_WITHOUT_PHOTOSPHERE[:5]) + def test_missing_photosphere_has_ci_chondrites(self, ref_any_year, symbol): + """Elements without photosphere DO have CI chondrite values.""" + element = ref_any_year.get_element(symbol, kind="CI_chondrites") + assert not np.isnan(element.Ab), ( + f"{symbol} CI chondrites Ab should NOT be NaN, got {element.Ab}" + ) + + def test_h_photosphere_ab_is_12(self, ref_any_year): + """H photosphere Ab is 12.00 (by definition).""" + h = ref_any_year.get_element("H", kind="Photosphere") + assert np.isclose(h.Ab, 12.00, atol=0.001), ( + f"H photosphere Ab should be 12.00, got {h.Ab}" + ) + + def test_h_2009_uncertainty_is_nan(self, ref_2009): + """H uncertainty is NaN in 2009 (undefined).""" + h = ref_2009.get_element("H", kind="Photosphere") + assert np.isnan(h.Uncert), ( + f"H (2009) uncertainty should be NaN, got {h.Uncert}" + ) + + def test_h_2021_uncertainty_is_zero(self, ref_2021): + """H uncertainty is 0.00 in 2021 (by definition).""" + h = ref_2021.get_element("H", kind="Photosphere") + assert np.isclose(h.Uncert, 0.00, atol=0.001), ( + f"H (2021) uncertainty should be 0.00, got {h.Uncert}" + ) + + +# ============================================================================= +# Integration Tests: Value Validation +# ============================================================================= + + +class TestValueValidation: + """Integration tests verifying values match published Asplund tables.""" + + @pytest.mark.parametrize( + "year,symbol", + [ + (2009, "Fe"), + (2009, "C"), + (2009, "O"), + (2009, "Si"), + (2021, "Fe"), + (2021, "C"), + (2021, "O"), + (2021, "He"), + (2021, "Si"), + ], + ) + def test_photosphere_values_match_published(self, year, symbol): + """Photospheric abundances match Asplund Table values.""" + ref = ReferenceAbundances(year=year) + expected = ASPLUND_DATA[year][symbol] + + element = ref.get_element(symbol, kind="Photosphere") + + # Type and shape + assert isinstance(element, pd.Series), ( + f"Expected pd.Series, got {type(element).__name__}" + ) + assert element.shape == (2,), f"Expected shape (2,), got {element.shape}" + + # Content from published table + if expected.photosphere_ab is not None: + assert np.isclose(element.Ab, expected.photosphere_ab, atol=0.005), ( + f"Asplund {year} {symbol} photosphere Ab: " + f"expected {expected.photosphere_ab}, got {element.Ab}" + ) + if expected.photosphere_uncert is not None: + assert np.isclose(element.Uncert, expected.photosphere_uncert, atol=0.005), ( + f"Asplund {year} {symbol} photosphere Uncert: " + f"expected {expected.photosphere_uncert}, got {element.Uncert}" + ) + + @pytest.mark.parametrize( + "year,symbol", + [ + (2009, "Fe"), + (2009, "H"), + (2009, "Si"), + (2021, "Fe"), + (2021, "H"), + (2021, "Si"), + ], + ) + def test_ci_chondrites_values_match_published(self, year, symbol): + """CI chondrite abundances match Asplund Table values.""" + ref = ReferenceAbundances(year=year) + expected = ASPLUND_DATA[year][symbol] + + element = ref.get_element(symbol, kind="CI_chondrites") + + assert np.isclose(element.Ab, expected.ci_chondrites_ab, atol=0.005), ( + f"Asplund {year} {symbol} CI chondrites Ab: " + f"expected {expected.ci_chondrites_ab}, got {element.Ab}" + ) + if expected.ci_chondrites_uncert is not None: + assert np.isclose( + element.Uncert, expected.ci_chondrites_uncert, atol=0.005 + ), ( + f"Asplund {year} {symbol} CI chondrites Uncert: " + f"expected {expected.ci_chondrites_uncert}, got {element.Uncert}" + ) + + +# ============================================================================= +# Integration Tests: Abundance Ratio +# ============================================================================= -class TestGetElement: - """Verify element lookup by symbol and Z.""" +class TestAbundanceRatio: + """Integration tests for abundance ratio calculations.""" + + def test_returns_abundance_namedtuple(self, ref_any_year): + """abundance_ratio returns Abundance namedtuple.""" + result = ref_any_year.abundance_ratio("Fe", "O") + assert isinstance(result, Abundance), ( + f"Expected Abundance namedtuple, got {type(result).__name__}" + ) + + def test_abundance_has_measurement_and_uncertainty(self, ref_any_year): + """Abundance namedtuple has measurement and uncertainty attributes.""" + result = ref_any_year.abundance_ratio("Fe", "O") + assert hasattr(result, "measurement"), "Missing 'measurement' attribute" + assert hasattr(result, "uncertainty"), "Missing 'uncertainty' attribute" - @pytest.fixture - def ref(self): - return ReferenceAbundances() + def test_measurement_is_float(self, ref_any_year): + """measurement attribute is float.""" + result = ref_any_year.abundance_ratio("Fe", "O") + assert isinstance(result.measurement, (float, np.floating)), ( + f"measurement should be float, got {type(result.measurement).__name__}" + ) + + def test_uncertainty_is_float(self, ref_any_year): + """uncertainty attribute is float.""" + result = ref_any_year.abundance_ratio("Fe", "O") + assert isinstance(result.uncertainty, (float, np.floating)), ( + f"uncertainty should be float, got {type(result.uncertainty).__name__}" + ) + + def test_ratio_can_be_destructured(self, ref_any_year): + """Abundance namedtuple can be destructured.""" + measurement, uncertainty = ref_any_year.abundance_ratio("Fe", "O") + assert isinstance(measurement, (float, np.floating)) + assert isinstance(uncertainty, (float, np.floating)) + + @pytest.mark.parametrize( + "year,numerator,denominator", + [ + (2009, "Fe", "O"), + (2009, "C", "O"), + (2021, "Fe", "O"), + (2021, "C", "O"), + ], + ) + def test_ratio_calculation_matches_expected(self, year, numerator, denominator): + """Abundance ratios match calculated values from published data.""" + ref = ReferenceAbundances(year=year) + result = ref.abundance_ratio(numerator, denominator) + + expected_ratio, sigma_num, sigma_den = EXPECTED_RATIOS[year][ + (numerator, denominator) + ] + expected_uncert = ( + expected_ratio * np.log(10) * np.sqrt(sigma_num**2 + sigma_den**2) + ) + + assert np.isclose(result.measurement, expected_ratio, rtol=0.02), ( + f"Asplund {year} {numerator}/{denominator} ratio: " + f"expected {expected_ratio:.5f}, got {result.measurement:.5f}" + ) + assert np.isclose(result.uncertainty, expected_uncert, rtol=0.02), ( + f"Asplund {year} {numerator}/{denominator} uncertainty: " + f"expected {expected_uncert:.5f}, got {result.uncertainty:.5f}" + ) + + @pytest.mark.parametrize("year", [2009, 2021]) + def test_fe_h_ratio_uses_hydrogen_denominator_path(self, year): + """Fe/H ratio uses special hydrogen denominator logic.""" + ref = ReferenceAbundances(year=year) + result = ref.abundance_ratio("Fe", "H") - def test_get_element_by_symbol_returns_series(self, ref): - fe = ref.get_element("Fe") - assert isinstance(fe, pd.Series), f"Expected Series, got {type(fe)}" + expected_ratio, sigma_fe, _ = EXPECTED_RATIOS[year][("Fe", "H")] + # For H denominator, uncertainty comes only from numerator + expected_uncert = expected_ratio * np.log(10) * sigma_fe - def test_iron_photosphere_matches_asplund(self, ref): - # Asplund 2009 Table 1: Fe = 7.50 +/- 0.04 - fe = ref.get_element("Fe") - assert np.isclose( - fe.Ab, 7.50, atol=0.01 - ), f"Fe photosphere Ab: expected 7.50, got {fe.Ab}" - assert np.isclose( - fe.Uncert, 0.04, atol=0.01 - ), f"Fe photosphere Uncert: expected 0.04, got {fe.Uncert}" - - def test_get_element_by_z_matches_symbol(self, ref): - # Z=26 is Fe, should return identical data values - # Note: Series names differ (26 vs 'Fe') but values are identical - by_symbol = ref.get_element("Fe") - by_z = ref.get_element(26) - pd.testing.assert_series_equal(by_symbol, by_z, check_names=False) - - def test_get_element_meteorites_differs_from_photosphere(self, ref): - # Fe meteorites: 7.45 vs photosphere: 7.50 - photo = ref.get_element("Fe", kind="Photosphere") - meteor = ref.get_element("Fe", kind="Meteorites") - assert ( - photo.Ab != meteor.Ab - ), "Photosphere and Meteorites should have different values" - assert np.isclose( - meteor.Ab, 7.45, atol=0.01 - ), f"Fe meteorites Ab: expected 7.45, got {meteor.Ab}" - - def test_invalid_key_type_raises_valueerror(self, ref): - with pytest.raises(ValueError, match="Unrecognized key type"): - ref.get_element(3.14) # float is invalid - - def test_unknown_element_raises_keyerror(self, ref): - with pytest.raises(KeyError, match="Xx"): - ref.get_element("Xx") # No element Xx - - def test_invalid_kind_raises_keyerror(self, ref): - with pytest.raises(KeyError, match="Invalid"): - ref.get_element("Fe", kind="Invalid") + assert np.isclose(result.measurement, expected_ratio, rtol=0.02), ( + f"Asplund {year} Fe/H ratio: " + f"expected {expected_ratio:.3e}, got {result.measurement:.3e}" + ) + assert np.isclose(result.uncertainty, expected_uncert, rtol=0.02), ( + f"Asplund {year} Fe/H uncertainty: " + f"expected {expected_uncert:.3e}, got {result.uncertainty:.3e}" + ) -class TestAbundanceRatio: - """Verify ratio calculation with uncertainty propagation.""" +# ============================================================================= +# Integration Tests: Backward Compatibility +# ============================================================================= - @pytest.fixture - def ref(self): - return ReferenceAbundances() - def test_returns_abundance_namedtuple(self, ref): - result = ref.abundance_ratio("Fe", "O") - assert isinstance( - result, Abundance - ), f"Expected Abundance namedtuple, got {type(result)}" - assert hasattr(result, "measurement"), "Missing 'measurement' attribute" - assert hasattr(result, "uncertainty"), "Missing 'uncertainty' attribute" +class TestBackwardCompatibility: + """Integration tests ensuring backward compatibility with existing code.""" - def test_fe_o_ratio_matches_computed_value(self, ref): - # Fe/O = 10^(7.50 - 8.69) = 0.06457 - result = ref.abundance_ratio("Fe", "O") - expected = 10.0 ** (7.50 - 8.69) - assert np.isclose( - result.measurement, expected, rtol=0.01 - ), f"Fe/O ratio: expected {expected:.5f}, got {result.measurement:.5f}" - - def test_fe_o_uncertainty_matches_formula(self, ref): - # sigma = ratio * ln(10) * sqrt(sigma_Fe^2 + sigma_O^2) - # sigma = 0.06457 * 2.303 * sqrt(0.04^2 + 0.05^2) = 0.00951 - result = ref.abundance_ratio("Fe", "O") - expected_ratio = 10.0 ** (7.50 - 8.69) - expected_uncert = expected_ratio * np.log(10) * np.sqrt(0.04**2 + 0.05**2) - assert np.isclose( - result.uncertainty, expected_uncert, rtol=0.01 - ), f"Fe/O uncertainty: expected {expected_uncert:.5f}, got {result.uncertainty:.5f}" - - def test_c_o_ratio_matches_computed_value(self, ref): - # C/O = 10^(8.43 - 8.69) = 0.5495 + def test_2009_iron_matches_original_tests(self): + """year=2009 Fe matches original test values (7.50±0.04).""" + ref = ReferenceAbundances(year=2009) + fe = ref.get_element("Fe") + assert np.isclose(fe.Ab, 7.50, atol=0.01), ( + f"2009 Fe photosphere should be 7.50, got {fe.Ab}" + ) + assert np.isclose(fe.Uncert, 0.04, atol=0.01), ( + f"2009 Fe uncertainty should be 0.04, got {fe.Uncert}" + ) + + def test_2009_c_o_ratio_matches_original_calculation(self): + """year=2009 C/O ratio matches original expected value.""" + ref = ReferenceAbundances(year=2009) result = ref.abundance_ratio("C", "O") + # Original: 10^(8.43 - 8.69) = 0.5495 expected = 10.0 ** (8.43 - 8.69) - assert np.isclose( - result.measurement, expected, rtol=0.01 - ), f"C/O ratio: expected {expected:.4f}, got {result.measurement:.4f}" + assert np.isclose(result.measurement, expected, rtol=0.01), ( + f"2009 C/O ratio: expected {expected:.4f}, got {result.measurement:.4f}" + ) - def test_ratio_destructuring_works(self, ref): - # Verify namedtuple can be destructured - measurement, uncertainty = ref.abundance_ratio("Fe", "O") - assert isinstance(measurement, float), "measurement should be float" - assert isinstance(uncertainty, float), "uncertainty should be float" + def test_abundance_ratio_method_exists(self, ref_any_year): + """abundance_ratio method exists and is callable.""" + assert hasattr(ref_any_year, "abundance_ratio"), ( + "Missing abundance_ratio method" + ) + assert callable(ref_any_year.abundance_ratio), ( + "abundance_ratio should be callable" + ) + def test_data_property_returns_dataframe(self, ref_any_year): + """data property returns DataFrame as in original API.""" + assert isinstance(ref_any_year.data, pd.DataFrame), ( + f"data property should return DataFrame, got {type(ref_any_year.data)}" + ) -class TestHydrogenDenominator: - """Verify special case when denominator is H.""" + def test_get_element_method_exists(self, ref_any_year): + """get_element method exists and is callable.""" + assert hasattr(ref_any_year, "get_element"), "Missing get_element method" + assert callable(ref_any_year.get_element), "get_element should be callable" - @pytest.fixture - def ref(self): - return ReferenceAbundances() - def test_fe_h_uses_convert_from_dex(self, ref): - # Fe/H = 10^(7.50 - 12) = 3.162e-5 - result = ref.abundance_ratio("Fe", "H") - expected = 10.0 ** (7.50 - 12.0) - assert np.isclose( - result.measurement, expected, rtol=0.01 - ), f"Fe/H ratio: expected {expected:.3e}, got {result.measurement:.3e}" +# ============================================================================= +# Module-Level Tests +# ============================================================================= - def test_fe_h_uncertainty_from_numerator_only(self, ref): - # H has no uncertainty, so sigma = Fe_linear * ln(10) * sigma_Fe - result = ref.abundance_ratio("Fe", "H") - fe_linear = 10.0 ** (7.50 - 12.0) - expected_uncert = fe_linear * np.log(10) * 0.04 - assert np.isclose( - result.uncertainty, expected_uncert, rtol=0.01 - ), f"Fe/H uncertainty: expected {expected_uncert:.3e}, got {result.uncertainty:.3e}" - - -class TestNaNHandling: - """Verify NaN uncertainties are replaced with 0 in ratio calculations.""" - - @pytest.fixture - def ref(self): - return ReferenceAbundances() - - def test_ratio_with_nan_uncertainty_uses_zero(self, ref): - # H/O should use 0 for H's uncertainty - # sigma = ratio * ln(10) * sqrt(0^2 + sigma_O^2) = ratio * ln(10) * sigma_O - result = ref.abundance_ratio("H", "O") - expected_ratio = 10.0 ** (12.00 - 8.69) - expected_uncert = expected_ratio * np.log(10) * 0.05 # Only O contributes - assert np.isclose( - result.uncertainty, expected_uncert, rtol=0.01 - ), f"H/O uncertainty: expected {expected_uncert:.2f}, got {result.uncertainty:.2f}" + +def test_module_exports_referenceabundances(): + """Module __all__ includes ReferenceAbundances.""" + from solarwindpy.core import abundances + + assert hasattr(abundances, "__all__"), "Module missing __all__" + assert "ReferenceAbundances" in abundances.__all__, ( + "ReferenceAbundances not in __all__" + ) + + +def test_module_exports_abundance_namedtuple(): + """Module __all__ includes Abundance namedtuple.""" + from solarwindpy.core import abundances + + assert "Abundance" in abundances.__all__, "Abundance not in __all__" + + +def test_abundance_namedtuple_structure(): + """Abundance namedtuple has correct fields.""" + assert hasattr(Abundance, "_fields"), "Abundance should be a namedtuple" + assert Abundance._fields == ("measurement", "uncertainty"), ( + f"Expected fields ('measurement', 'uncertainty'), got {Abundance._fields}" + ) + + +def test_can_import_from_core(): + """Can import ReferenceAbundances from solarwindpy.core.""" + from solarwindpy.core import ReferenceAbundances as RA + + assert RA is ReferenceAbundances, "Import should resolve to same class"