diff --git a/docs/api/mixins.md b/docs/api/mixins.md index 39131a7..5064f85 100644 --- a/docs/api/mixins.md +++ b/docs/api/mixins.md @@ -41,4 +41,8 @@ geometry, datetime handling, change propagation, and more. --- +::: steer_core.Mixins.Thermodynamics + +--- + ::: steer_core.Mixins.TypeChecker diff --git a/steer_core/Constants/Units.py b/steer_core/Constants/Units.py index d762320..3ce6a4b 100644 --- a/steer_core/Constants/Units.py +++ b/steer_core/Constants/Units.py @@ -1,10 +1,28 @@ # SPDX-FileCopyrightText: 2024-2026 Stanford University # SPDX-License-Identifier: AGPL-3.0-or-later -## Unit conversions -# Length units +# ============================================================================= +# MASS UNITS +# ============================================================================= KG_TO_G = 1e3 +KG_TO_T = 1e-3 +T_TO_KG = 1e3 G_TO_KG = 1e-3 +T_TO_MT = 1e-6 # tonnes to megatonnes +MT_TO_T = 1e6 # megatonnes to tonnes +# Cascaded mass conversions +G_TO_T = G_TO_KG * KG_TO_T # 1e-6 +T_TO_G = T_TO_KG * KG_TO_G # 1e6 + +# ============================================================================= +# MOLAR UNITS +# ============================================================================= +KMOL_TO_MOL = 1e3 +MOL_TO_KMOL = 1e-3 + +# ============================================================================= +# LENGTH UNITS +# ============================================================================= M_TO_CM = 1e2 CM_TO_M = 1e-2 M_TO_MM = 1e3 @@ -23,28 +41,30 @@ G_TO_mG = 1e3 CM_TO_UM = 1e4 UM_TO_CM = 1e-4 -KG_TO_T = 1e-3 -T_TO_KG = 1e3 -T_TO_MT = 1e-6 -MT_TO_T = 1e6 LB_TO_KG = 0.453592 KG_TO_LB = 1 / 0.453592 T_TO_SHORT_TON = 1.10231 SHORT_TON_TO_T = 1 / 1.10231 LB_TO_SHORT_TON = 1 / 2000 SHORT_TON_TO_LB = 2000 -LB_TO_T = 1 / 2000 * 1/1.10231 - +# Cascaded imperial-to-metric mass conversions +LB_TO_T = LB_TO_KG * KG_TO_T -# Current units +# ============================================================================= +# CURRENT UNITS +# ============================================================================= A_TO_mA = 1e3 mA_TO_A = 1e-3 -# Time units +# ============================================================================= +# TIME UNITS +# ============================================================================= S_TO_H = 1 / 3600 H_TO_S = 3600 S_TO_MIN = 1 / 60 MIN_TO_S = 60 +H_TO_MIN = 60 +MIN_TO_H = 1 / 60 S_TO_Y = 1 / (3600 * 24 * 365) Y_TO_S = 3600 * 24 * 365 H_TO_Y = 1 / 8760 @@ -52,44 +72,114 @@ S_TO_D = 1 / (3600 * 24) D_TO_S = 3600 * 24 H_TO_D = 1 / 24 +D_TO_H = 24 H_TO_W = 1 / (24 * 7) +D_TO_W = 1 / 7 +W_TO_D = 7 Y_TO_M = 12 -H_TO_US = 3600000 D_TO_H = 24 W_TO_D = 7 -S_TO_US = 1000 M_TO_Y = 1 / 12 +# Millisecond conversions (used for Plotly datetime axis bin sizing) +S_TO_MS = 1e3 +H_TO_MS = H_TO_S * S_TO_MS # 3_600_000 +D_TO_MS = D_TO_S * S_TO_MS # 86_400_000 -D_TO_W = 1 / 7 +# Average-based time conversions AVG_D_TO_MONTH = 12 / 365.25 AVG_D_TO_Y = 1 / 365.25 Y_TO_AVG_D = 365.25 AVG_W_TO_Y = 1 / (365.25 / 7) AVG_H_TO_Y = 1 / (365.25 * 24) - -# Energy units +# ============================================================================= +# ENERGY UNITS +# ============================================================================= W_TO_KW = 1e-3 -J_TO_WH = 1 / 3600 KW_TO_W = 1e3 +KW_TO_MW = 1e-3 +MW_TO_KW = 1e3 +# Cascaded power conversions +W_TO_MW = W_TO_KW * KW_TO_MW # 1e-6 +MW_TO_W = MW_TO_KW * KW_TO_W # 1e6 + +KW_PER_HP = 0.7457 +HP_PER_KW = 1.0 / KW_PER_HP + +J_TO_WH = 1.0 / 3600.0 +MWH_TO_GJ = 3.6 +GJ_TO_MWH = 1.0 / MWH_TO_GJ + +KJ_TO_GJ = 1e-6 +GJ_TO_KJ = 1e6 +GJ_TO_MGJ = 1e-6 +MGJ_TO_GJ = 1e6 + +J_TO_KJ = 1e-3 +KJ_TO_J = 1e3 +J_TO_GJ = J_TO_KJ * KJ_TO_GJ +GJ_TO_J = GJ_TO_KJ * KJ_TO_J + +BTU_TO_J = 1055.05585262 +J_TO_BTU = 1 / 1055.05585262 +BTU_TO_MMBTU = 1e-6 +MMBTU_TO_BTU = 1e6 + + +# ============================================================================= +# PRESSURE UNITS +# ============================================================================= +BAR_TO_PA = 1e5 +PA_TO_BAR = 1e-5 +KPA_TO_PA = 1e3 +PA_TO_KPA = 1e-3 +# Cascaded pressure conversions +BAR_TO_KPA = BAR_TO_PA * PA_TO_KPA # 1e2 +KPA_TO_BAR = KPA_TO_PA * PA_TO_BAR # 1e-2 +MMHG_TO_PA = 133.322 # mmHg to Pascal +PA_TO_MMHG = 1.0 / MMHG_TO_PA # Pascal to mmHg +GPU_TO_SI = 3.35e-10 # mol/(m²·s·Pa) per GPU (gas permeation unit) + +# ============================================================================= +# TEMPERATURE UNITS +# ============================================================================= +K_TO_C = -273.15 +C_TO_K = 273.15 + +# ============================================================================= +# VISCOSITY UNITS +# ============================================================================= +PA_S_TO_CP = 1e3 # Pascal-seconds to Centipoise +CP_TO_PA_S = 1e-3 # Centipoise to Pascal-seconds + +# ============================================================================= +# CURRENCY & MISC +# ============================================================================= +USD_TO_KUSD = 1e-3 +KUSD_TO_USD = 1e3 +USD_TO_MUSD = 1e-6 +MUSD_TO_USD = 1e6 +# Cascaded currency conversions +MUSD_TO_KUSD = MUSD_TO_USD * USD_TO_KUSD # 1e3 +KUSD_TO_MUSD = KUSD_TO_USD * USD_TO_MUSD # 1e-3 -# Angle units DEG_TO_RAD = 0.017453292519943295 +RAD_TO_DEG = 57.29577951308232 -# Percentage units PERCENT_TO_FRACTION = 1e-2 FRACTION_TO_PERCENT = 1e2 FRACTION_TO_PPM = 1e6 PPM_TO_FRACTION = 1e-6 -# Unitless -UNIT_TO_MILLION: float = 1e-6 -MILLION_TO_UNIT: float = 1e6 +UNIT_TO_MILLION = 1e-6 +MILLION_TO_UNIT = 1e6 -# Volume units -L_TO_M3 = 1e-3 -M3_TO_L = 1e3 +# ============================================================================= +# VOLUME UNITS +# ============================================================================= +L_TO_M3 = DM_TO_M**3 # Derived from length (1e-3) +M3_TO_L = 1.0 / L_TO_M3 GAL_TO_L = 3.78541 L_TO_GAL = 1 / 3.78541 MMGAL_TO_GAL = 1e-6 diff --git a/steer_core/Constants/Universal.py b/steer_core/Constants/Universal.py index 86f18d4..722f23b 100644 --- a/steer_core/Constants/Universal.py +++ b/steer_core/Constants/Universal.py @@ -5,7 +5,14 @@ PI = 3.14159265358979323846 TWO_PI = 2.0 * PI -# Molar Masses -MW_G_PER_MOL_NO2 = 46.0055 -MW_G_PER_MOL_SO2 = 64.0638 -MW_G_PER_MOL_CO2 = 44.0095 \ No newline at end of file +# Universal Physical Constants + +R_GAS = 8.314462618 # J/(mol·K) Universal gas constant (CODATA 2018) + +GRAVITY = 9.80665 # m/s² — Standard acceleration due to gravity (exact by definition) + + +# Standard Conditions + +STANDARD_PRESSURE = 101325.0 # Pa (1 atm) +STANDARD_TEMPERATURE = 273.15 # K diff --git a/steer_core/Mixins/Thermodynamics.py b/steer_core/Mixins/Thermodynamics.py new file mode 100644 index 0000000..131c42e --- /dev/null +++ b/steer_core/Mixins/Thermodynamics.py @@ -0,0 +1,60 @@ +""" +Thermodynamics mixin providing generic phase equilibrium calculations. + +This mixin provides reusable thermodynamic functions that can be +composed with material classes. +""" + +import math +from steer_core.Constants.Universal import R_GAS + + +class ThermodynamicsMixin: + """Mixin providing generic thermodynamic calculation methods.""" + + @staticmethod + def calculate_antoine_pressure(T: float, A: float, B: float, C: float) -> float: + """ + Calculate vapor pressure using Antoine equation. + + Equation: log10(P) = A - B / (T + C) + + Args: + T: Temperature (units depend on Antoine coefficients, typically °C or K) + A, B, C: Antoine coefficients for the substance + + Returns: + Vapor pressure (units depend on Antoine coefficients, typically mmHg or bar) + """ + return 10 ** (A - B / (T + C)) + + @staticmethod + def calculate_antoine_temperature(P: float, A: float, B: float, C: float) -> float: + """ + Calculate temperature from vapor pressure using inverse Antoine equation. + + Args: + P: Vapor pressure (same units as Antoine coefficients) + A, B, C: Antoine coefficients for the substance + + Returns: + Temperature (same units as Antoine coefficients) + """ + log_P = math.log10(P) + return B / (A - log_P) - C + + @staticmethod + def calculate_ideal_gas_density(P_Pa: float, T_K: float, MW_kg_mol: float) -> float: + """ + Calculate ideal gas density using ideal gas law. + + Args: + P_Pa: Pressure in Pascals + T_K: Temperature in Kelvin + MW_kg_mol: Molecular weight in kg/mol + + Returns: + Density in kg/m³ + """ + + return P_Pa * MW_kg_mol / (R_GAS * T_K) diff --git a/steer_core/Mixins/TypeChecker.py b/steer_core/Mixins/TypeChecker.py index c59680c..b5ab0a3 100644 --- a/steer_core/Mixins/TypeChecker.py +++ b/steer_core/Mixins/TypeChecker.py @@ -337,3 +337,108 @@ def validate_number(value, name: str) -> None: if not isinstance(value, (int, float, np.int64)): raise TypeError(f"{name} must be a number (int or float). Provided: {type(value).__name__}.") + # ------------------------------------------------------------------ + # Frequency-architecture validators + # ------------------------------------------------------------------ + + @staticmethod + def validate_base_binning_frequency(value, name: str = "base_binning_frequency") -> None: + """Validate that *value* is an allowed base binning frequency ('hourly' or 'daily'). + + Parameters + ---------- + value : Frequency enum or str + The value to validate. + name : str + Parameter name used in the error message. + + Raises + ------ + ValueError + If the value is not 'hourly' or 'daily'. + """ + val = value.value if hasattr(value, "value") else str(value).lower() + if val not in ("hourly", "daily"): + raise ValueError( + f"{name} must be 'hourly' or 'daily'. Provided: '{val}'." + ) + + @staticmethod + def validate_display_frequency(display_freq, base_freq, name: str = "display_frequency") -> None: + """Validate that *display_freq* is equal to or coarser than *base_freq*. + + Parameters + ---------- + display_freq : Frequency enum + The display frequency to validate. + base_freq : Frequency enum + The base binning frequency. + name : str + Parameter name used in the error message. + + Raises + ------ + ValueError + If display_freq is finer than base_freq. + """ + d_rank = display_freq.rank if hasattr(display_freq, "rank") else 0 + b_rank = base_freq.rank if hasattr(base_freq, "rank") else 0 + if d_rank < b_rank: + raise ValueError( + f"{name} '{display_freq.value}' is finer than " + f"base_binning_frequency '{base_freq.value}'." + ) + + @staticmethod + def validate_base_binning_frequency_not_increased(current_freq, new_freq, name: str = "base_binning_frequency") -> None: + """Validate that *new_freq* is not finer than *current_freq*. + + Prevents increasing the resolution of an already-set base binning frequency + (e.g., daily → hourly), which would fabricate precision that was never there. + Going coarser (hourly → daily) is always allowed. + + Parameters + ---------- + current_freq : Frequency enum + The currently set base binning frequency. + new_freq : Frequency enum + The proposed new base binning frequency. + name : str + Parameter name used in the error message. + + Raises + ------ + ValueError + If new_freq is finer than current_freq. + """ + if new_freq.rank < current_freq.rank: + raise ValueError( + f"Cannot change {name} from '{current_freq.value}' to '{new_freq.value}': " + f"cannot increase resolution on an existing instance." + ) + + @staticmethod + def validate_compounding_frequency(base_freq, compounding_freq, name: str = "compounding_frequency") -> None: + """Validate that *base_freq* is equal to or finer than *compounding_freq*. + + Parameters + ---------- + base_freq : Frequency enum + The base binning frequency. + compounding_freq : Frequency enum + The compounding frequency. + name : str + Parameter name used in the error message. + + Raises + ------ + ValueError + If base_freq is coarser than compounding_freq. + """ + b_rank = base_freq.rank if hasattr(base_freq, "rank") else 0 + c_rank = compounding_freq.rank if hasattr(compounding_freq, "rank") else 0 + if b_rank > c_rank: + raise ValueError( + f"base_binning_frequency '{base_freq.value}' is coarser than " + f"{name} '{compounding_freq.value}'." + ) diff --git a/steer_core/__init__.py b/steer_core/__init__.py index 1ee6905..29bc4db 100644 --- a/steer_core/__init__.py +++ b/steer_core/__init__.py @@ -9,5 +9,4 @@ from .Mixins.TypeChecker import ValidationMixin from .Mixins.Dunder import DunderMixin from .Mixins.Serializer import SerializerMixin -from .Mixins.Data import DataMixin - +from .Mixins.Data import DataMixin \ No newline at end of file diff --git a/test/test_constants.py b/test/test_constants.py index 1499ff6..b35eb58 100644 --- a/test/test_constants.py +++ b/test/test_constants.py @@ -67,11 +67,6 @@ def test_pi(self): def test_two_pi(self): assert Universal.TWO_PI == pytest.approx(2 * math.pi) - def test_molar_masses_positive(self): - assert Universal.MW_G_PER_MOL_NO2 > 0 - assert Universal.MW_G_PER_MOL_SO2 > 0 - assert Universal.MW_G_PER_MOL_CO2 > 0 - # Need pytest.approx at module level for parametrized tests import pytest