pychnosz.models API documentation
Description: Equation of state models and water property models for CHNOSZ.

# Module `pychnosz.models`

Equation of state models and water property models for CHNOSZ.

## Sub-modules

`pychnosz.models.archer_wang`

  Archer & Wang (1990) dielectric constant correlation for water …

`pychnosz.models.berman`

  Berman mineral equations of state implementation …

`pychnosz.models.dew`

  DEW (Deep Earth Water) model implementation …

`pychnosz.models.hkf_helpers`

  Helper functions for HKF calculations, integrated from HKF_cgl.py …

`pychnosz.models.iapws95`

  IAPWS-95 water model implementation …

`pychnosz.models.supcrt92_fortran`

  SUPCRT92 water model with Fortran backend …

## Functions

`def Berman(name: str,
T: float | List[float] = 298.15,
P: float | List[float] = 1,
check_G: bool = False,
calc_transition: bool = True,
calc_disorder: bool = True) ‑> pandas.core.frame.DataFrame`
  Expand source code

      def Berman(name: str, T: Union[float, List[float]] = 298.15, P: Union[float, List[float]] = 1,
                 check_G: bool = False, calc_transition: bool = True, calc_disorder: bool = True) -> pd.DataFrame:
          """
          Calculate thermodynamic properties of minerals using Berman equations.

          Parameters
          ----------
          name : str
              Name of the mineral
          T : float or list, optional
              Temperature in Kelvin (default: 298.15)
          P : float or list, optional
              Pressure in bar (default: 1)
          check_G : bool, optional
              Check consistency of G in data file (default: False)
          calc_transition : bool, optional
              Calculate polymorphic transition contributions (default: True)
          calc_disorder : bool, optional
              Calculate disorder contributions (default: True)

          Returns
          -------
          pd.DataFrame
              DataFrame with columns T, P, G, H, S, Cp, V
          """

          # Reference temperature and pressure
          Pr = 1
          Tr = 298.15

          # Make T and P the same length
          if isinstance(T, (int, float)):
              T = [T]
          if isinstance(P, (int, float)):
              P = [P]

          # Convert to list if numpy array (to avoid element-wise multiplication bug)
          if isinstance(T, np.ndarray):
              T = T.tolist()
          if isinstance(P, np.ndarray):
              P = P.tolist()

          ncond = max(len(T), len(P))
          T = np.array(T * (ncond // len(T) + 1), dtype=float)[:ncond]
          P = np.array(P * (ncond // len(P) + 1), dtype=float)[:ncond]

          # Get parameters in the Berman equations
          # Start with thermodynamic parameters provided with CHNOSZ
          thermo_sys = thermo()
          if thermo_sys.Berman is None:
              raise RuntimeError("Berman data not loaded. Please run pychnosz.reset() first.")

          dat = thermo_sys.Berman.copy()

          # TODO: Handle user-supplied data file (thermo()$opt$Berman)
          # For now, just use the default data

          # Remove duplicates (only the first, i.e. most recent entry is kept)
          dat = dat.drop_duplicates(subset=['name'], keep='first')

          # Remove the multipliers on volume parameters
          vcols = ['v1', 'v2', 'v3', 'v4']  # columns with v1, v2, v3, v4
          multexp = [5, 5, 5, 8]
          for i, col in enumerate(vcols):
              if col in dat.columns:
                  dat[col] = dat[col] / (10 ** multexp[i])

          # Which row has data for this mineral?
          matching_rows = dat[dat['name'] == name]
          if len(matching_rows) == 0:
              raise ValueError(f"Data for {name} not available in Berman database")

          dat_mineral = matching_rows.iloc[0]

          # Extract parameters for easier access
          GfPrTr = dat_mineral['GfPrTr']
          HfPrTr = dat_mineral['HfPrTr']
          SPrTr = dat_mineral['SPrTr']
          VPrTr = dat_mineral['VPrTr']

          k0 = dat_mineral['k0']
          k1 = dat_mineral['k1']
          k2 = dat_mineral['k2']
          k3 = dat_mineral['k3']
          k4 = dat_mineral['k4'] if not pd.isna(dat_mineral['k4']) else 0
          k5 = dat_mineral['k5'] if not pd.isna(dat_mineral['k5']) else 0
          k6 = dat_mineral['k6'] if not pd.isna(dat_mineral['k6']) else 0

          v1 = dat_mineral['v1'] if not pd.isna(dat_mineral['v1']) else 0
          v2 = dat_mineral['v2'] if not pd.isna(dat_mineral['v2']) else 0
          v3 = dat_mineral['v3'] if not pd.isna(dat_mineral['v3']) else 0
          v4 = dat_mineral['v4'] if not pd.isna(dat_mineral['v4']) else 0

          # Transition parameters
          Tlambda = dat_mineral['Tlambda'] if not pd.isna(dat_mineral['Tlambda']) else None
          Tref = dat_mineral['Tref'] if not pd.isna(dat_mineral['Tref']) else None
          dTdP = dat_mineral['dTdP'] if not pd.isna(dat_mineral['dTdP']) else None
          l1 = dat_mineral['l1'] if not pd.isna(dat_mineral['l1']) else None
          l2 = dat_mineral['l2'] if not pd.isna(dat_mineral['l2']) else None

          # Disorder parameters
          Tmin = dat_mineral['Tmin'] if not pd.isna(dat_mineral['Tmin']) else None
          Tmax = dat_mineral['Tmax'] if not pd.isna(dat_mineral['Tmax']) else None
          d0 = dat_mineral['d0'] if not pd.isna(dat_mineral['d0']) else None
          d1 = dat_mineral['d1'] if not pd.isna(dat_mineral['d1']) else None
          d2 = dat_mineral['d2'] if not pd.isna(dat_mineral['d2']) else None
          d3 = dat_mineral['d3'] if not pd.isna(dat_mineral['d3']) else None
          d4 = dat_mineral['d4'] if not pd.isna(dat_mineral['d4']) else None
          Vad = dat_mineral['Vad'] if not pd.isna(dat_mineral['Vad']) else None

          # Get the entropy of the elements using the chemical formula
          # Get formula from OBIGT and calculate using entropy() function like in R CHNOSZ
          SPrTr_elements = 0
          if thermo_sys.obigt is not None:
              obigt_match = thermo_sys.obigt[thermo_sys.obigt['name'] == name]
              if len(obigt_match) > 0:
                  formula = obigt_match.iloc[0]['formula']
                  # Import entropy function and calculate SPrTr_elements properly
                  from ..utils.formula import entropy
                  SPrTr_elements = entropy(formula)

          # Check that G in data file follows Benson-Helgeson convention
          if check_G and not pd.isna(GfPrTr):
              GfPrTr_calc = HfPrTr - Tr * (SPrTr - SPrTr_elements)
              Gdiff = GfPrTr_calc - GfPrTr
              if abs(Gdiff) >= 1000:
                  warnings.warn(f"{name}: GfPrTr(calc) - GfPrTr(table) is too big! == {round(Gdiff)} J/mol")

          ### Thermodynamic properties ###
          # Calculate Cp and V (Berman, 1988 Eqs. 4 and 5)
          # k4, k5, k6 terms from winTWQ documentation (doi:10.4095/223425)
          Cp = k0 + k1 * T**(-0.5) + k2 * T**(-2) + k3 * T**(-3) + k4 * T**(-1) + k5 * T + k6 * T**2

          P_Pr = P - Pr
          T_Tr = T - Tr
          V = VPrTr * (1 + v1 * T_Tr + v2 * T_Tr**2 + v3 * P_Pr + v4 * P_Pr**2)

          # Calculate Ha (symbolically integrated using sympy - expressions not simplified)
          intCp = (T*k0 - Tr*k0 + k2/Tr - k2/T + k3/(2*Tr**2) - k3/(2*T**2) + 2.0*k1*T**0.5 - 2.0*k1*Tr**0.5 +
                   k4*np.log(T) - k4*np.log(Tr) + k5*T**2/2 - k5*Tr**2/2 - k6*Tr**3/3 + k6*T**3/3)

          intVminusTdVdT = (-VPrTr + P*(VPrTr + VPrTr*v4 - VPrTr*v3 - Tr*VPrTr*v1 + VPrTr*v2*Tr**2 - VPrTr*v2*T**2) +
                            P**2*(VPrTr*v3/2 - VPrTr*v4) + VPrTr*v3/2 - VPrTr*v4/3 + Tr*VPrTr*v1 +
                            VPrTr*v2*T**2 - VPrTr*v2*Tr**2 + VPrTr*v4*P**3/3)

          Ha = HfPrTr + intCp + intVminusTdVdT

          # Calculate S (also symbolically integrated)
          intCpoverT = (k0*np.log(T) - k0*np.log(Tr) - k3/(3*T**3) + k3/(3*Tr**3) + k2/(2*Tr**2) - k2/(2*T**2) +
                        2.0*k1*Tr**(-0.5) - 2.0*k1*T**(-0.5) + k4/Tr - k4/T + T*k5 - Tr*k5 + k6*T**2/2 - k6*Tr**2/2)

          intdVdT = -VPrTr*(v1 + v2*(-2*Tr + 2*T)) + P*VPrTr*(v1 + v2*(-2*Tr + 2*T))

          S = SPrTr + intCpoverT - intdVdT

          # Calculate Ga --> Berman-Brown convention (DG = DH - T*S, no S(element))
          Ga = Ha - T * S

          ### Polymorphic transition properties ###
          if (Tlambda is not None and Tref is not None and
              not pd.isna(Tlambda) and not pd.isna(Tref) and
              np.any(T > Tref) and calc_transition):

              # Starting transition contributions are 0
              Cptr = np.zeros(ncond)
              Htr = np.zeros(ncond)
              Str = np.zeros(ncond)

              # Eq. 9: Tlambda at P
              Tlambda_P = Tlambda + dTdP * (P - 1)

              # Eq. 8a: Cp at P
              Td = Tlambda - Tlambda_P
              Tprime = T + Td

              # With the condition that Tref < Tprime < Tlambda(1bar)
              iTprime = (Tref < Tprime) & (Tprime < Tlambda)
              # Handle NA values
              iTprime = iTprime & ~np.isnan(Tprime)

              if np.any(iTprime):
                  Tprime_valid = Tprime[iTprime]
                  Cptr[iTprime] = Tprime_valid * (l1 + l2 * Tprime_valid)**2

              # We got Cp, now calculate the integrations for H and S
              iTtr = T > Tref
              if np.any(iTtr):
                  Ttr = T[iTtr].copy()
                  Tlambda_P_tr = Tlambda_P[iTtr].copy()
                  Td_tr = Td[iTtr] if hasattr(Td, '__len__') else np.full_like(Ttr, Td)

                  # Handle NA values
                  Tlambda_P_tr[np.isnan(Tlambda_P_tr)] = np.inf

                  # The upper integration limit is Tlambda_P
                  Ttr[Ttr >= Tlambda_P_tr] = Tlambda_P_tr[Ttr >= Tlambda_P_tr]

                  # Derived variables
                  tref = Tref - Td_tr
                  x1 = l1**2 * Td_tr + 2 * l1 * l2 * Td_tr**2 + l2**2 * Td_tr**3
                  x2 = l1**2 + 4 * l1 * l2 * Td_tr + 3 * l2**2 * Td_tr**2
                  x3 = 2 * l1 * l2 + 3 * l2**2 * Td_tr
                  x4 = l2**2

                  # Eqs. 10, 11, 12
                  Htr[iTtr] = (x1 * (Ttr - tref) + x2/2 * (Ttr**2 - tref**2) +
                              x3/3 * (Ttr**3 - tref**3) + x4/4 * (Ttr**4 - tref**4))
                  Str[iTtr] = (x1 * (np.log(Ttr) - np.log(tref)) + x2 * (Ttr - tref) +
                              x3/2 * (Ttr**2 - tref**2) + x4/3 * (Ttr**3 - tref**3))

              Gtr = Htr - T * Str

              # Apply the transition contributions
              Ga = Ga + Gtr
              Ha = Ha + Htr
              S = S + Str
              Cp = Cp + Cptr

          ### Disorder thermodynamic properties ###
          if (Tmin is not None and Tmax is not None and
              not pd.isna(Tmin) and not pd.isna(Tmax) and
              np.any(T > Tmin) and calc_disorder):

              # Starting disorder contributions are 0
              Cpds = np.zeros(ncond)
              Hds = np.zeros(ncond)
              Sds = np.zeros(ncond)
              Vds = np.zeros(ncond)

              # The lower integration limit is Tmin
              iTds = T > Tmin
              if np.any(iTds):
                  Tds = T[iTds].copy()
                  # The upper integration limit is Tmax
                  Tds[Tds > Tmax] = Tmax

                  # Ber88 Eqs. 15, 16, 17
                  Cpds[iTds] = d0 + d1*Tds**(-0.5) + d2*Tds**(-2) + d3*Tds + d4*Tds**2
                  Hds[iTds] = (d0*(Tds - Tmin) + d1*(Tds**0.5 - Tmin**0.5)/0.5 +
                              d2*(Tds**(-1) - Tmin**(-1))/(-1) + d3*(Tds**2 - Tmin**2)/2 + d4*(Tds**3 - Tmin**3)/3)
                  Sds[iTds] = (d0*(np.log(Tds) - np.log(Tmin)) + d1*(Tds**(-0.5) - Tmin**(-0.5))/(-0.5) +
                              d2*(Tds**(-2) - Tmin**(-2))/(-2) + d3*(Tds - Tmin) + d4*(Tds**2 - Tmin**2)/2)

              # Eq. 18; we can't do this if Vad == 0 (dolomite and gehlenite)
              if Vad is not None and not pd.isna(Vad) and Vad != 0:
                  Vds = Hds / Vad

              # Include the Vds term with Hds
              Hds = Hds + Vds * (P - Pr)

              # Disordering properties above Tmax (Eq. 20)
              ihigh = T > Tmax
              if np.any(ihigh):
                  Hds[ihigh] = Hds[ihigh] - (T[ihigh] - Tmax) * Sds[ihigh]

              Gds = Hds - T * Sds

              # Apply the disorder contributions
              Ga = Ga + Gds
              Ha = Ha + Hds
              S = S + Sds
              V = V + Vds
              Cp = Cp + Cpds

          ### (for testing) Use G = H - TS to check that integrals for H and S are written correctly
          Ga_fromHminusTS = Ha - T * S
          if not np.allclose(Ga_fromHminusTS, Ga, atol=1e-6):
              raise RuntimeError(f"{name}: incorrect integrals detected using DG = DH - T*S")

          ### Thermodynamic and unit conventions used in SUPCRT ###
          # Use entropy of the elements in calculation of G --> Benson-Helgeson convention (DG = DH - T*DS)
          Gf = Ga + Tr * SPrTr_elements

          # The output will just have "G" and "H"
          G = Gf
          H = Ha

          # Convert J/bar to cm^3/mol
          V = V * 10

          return pd.DataFrame({
              'T': T,
              'P': P,
              'G': G,
              'H': H,
              'S': S,
              'Cp': Cp,
              'V': V
          })

  Calculate thermodynamic properties of minerals using Berman equations.

  ## Parameters

  `name` :`str`
    Name of the mineral
  `T` :`float` or `list`, optional
    Temperature in Kelvin (default: 298.15)
  `P` :`float` or `list`, optional
    Pressure in bar (default: 1)
  `check_G` :`bool`, optional
    Check consistency of G in data file (default: False)
  `calc_transition` :`bool`, optional
    Calculate polymorphic transition contributions (default: True)
  `calc_disorder` :`bool`, optional
    Calculate disorder contributions (default: True)

  ## Returns

  `pd.DataFrame`
    DataFrame with columns T, P, G, H, S, Cp, V
`def G2logK(G, Tc)`
  Expand source code

      def G2logK(G, Tc):
          # Gas constant R is in cal/mol K
          return G / (-math.log(10) * 1.9872 * (273.15+Tc))

`def OBIGT2eos(OBIGT, fixGHS=True, tocal=True, messages=True)`
  Expand source code

      def OBIGT2eos(OBIGT, fixGHS=True, tocal=True, messages=True):
          """
          Convert OBIGT dataframe to equation of state parameters.

          This function processes the OBIGT thermodynamic database to prepare it for
          equation of state calculations. It handles energy unit conversions and
          optionally fills in missing G, H, or S values.

          Parameters
          ----------
          OBIGT : pd.DataFrame
              OBIGT thermodynamic database
          fixGHS : bool, default True
              Fill in one missing value among G, H, S using thermodynamic relations
          tocal : bool, default True
              Convert energy units from Joules to calories
          messages : bool, default True
              Print informational messages (currently not used, reserved for future)

          Returns
          -------
          pd.DataFrame
              Modified OBIGT dataframe with converted parameters
          """
          OBIGT_out = OBIGT.copy()

          # Get column indices for named columns (to handle varying column positions)
          G_idx = OBIGT.columns.get_loc('G')
          H_idx = OBIGT.columns.get_loc('H')
          S_idx = OBIGT.columns.get_loc('S')
          Cp_idx = OBIGT.columns.get_loc('Cp')
          V_idx = OBIGT.columns.get_loc('V')
          omega_lambda_idx = OBIGT.columns.get_loc('omega.lambda')

          for i in range(0, OBIGT.shape[0]):

              # we only convert omega for aqueous species, not lambda for cgl species
              if tocal and OBIGT.iloc[i, :]["E_units"] == "J" and OBIGT.iloc[i, :]["state"] == "aq":
                  # Convert G, H, S, Cp
                  OBIGT_out.iloc[i, G_idx:Cp_idx+1] = OBIGT.iloc[i, G_idx:Cp_idx+1]/4.184
                  # Convert V through omega (includes omega for aq species)
                  OBIGT_out.iloc[i, V_idx:omega_lambda_idx+1] = OBIGT.iloc[i, V_idx:omega_lambda_idx+1]/4.184
                  OBIGT_out.iloc[i, OBIGT.columns.get_loc('E_units')] = "cal"

              elif tocal and OBIGT.iloc[i, :]["E_units"] == "J":
                  # Convert G, H, S, Cp
                  OBIGT_out.iloc[i, G_idx:Cp_idx+1] = OBIGT.iloc[i, G_idx:Cp_idx+1]/4.184
                  # Convert V through c2.f (exclude omega.lambda for non-aq species)
                  OBIGT_out.iloc[i, V_idx:omega_lambda_idx] = OBIGT.iloc[i, V_idx:omega_lambda_idx]/4.184
                  OBIGT_out.iloc[i, OBIGT.columns.get_loc('E_units')] = "cal"

              # fill in one of missing G, H, S
              # for use esp. by subcrt because NA for one of G, H or S
              # will preclude calculations at high T
              if fixGHS:
                  # which entries are missing just one
                  GHS_values = [OBIGT.iloc[i, G_idx], OBIGT.iloc[i, H_idx], OBIGT.iloc[i, S_idx]]
                  imiss = [pd.isna(v) for v in GHS_values]
                  if sum(imiss) == 1:

                      ii = imiss.index(True)

                      if ii == 0:  # G is missing
                          H = OBIGT_out.iloc[i, H_idx]
                          S = OBIGT_out.iloc[i, S_idx]
                          Selem = entropy(OBIGT_out.iloc[i, OBIGT_out.columns.get_loc('formula')])
                          T = 298.15
                          G = H - T*(S - Selem)
                          OBIGT_out.iloc[i, G_idx] = G
                      elif ii == 1:  # H is missing
                          G = OBIGT_out.iloc[i, G_idx]
                          S = OBIGT_out.iloc[i, S_idx]
                          Selem = entropy(OBIGT_out.iloc[i, OBIGT_out.columns.get_loc('formula')])
                          T = 298.15
                          H = G + T*(S - Selem)
                          OBIGT_out.iloc[i, H_idx] = H
                      elif ii == 2:  # S is missing
                          G = OBIGT_out.iloc[i, G_idx]
                          H = OBIGT_out.iloc[i, H_idx]
                          Selem = entropy(OBIGT_out.iloc[i, OBIGT_out.columns.get_loc('formula')])
                          T = 298.15
                          S = Selem + (H - G)/T
                          OBIGT_out.iloc[i, S_idx] = S

          return OBIGT_out

  Convert OBIGT dataframe to equation of state parameters.

  This function processes the OBIGT thermodynamic database to prepare it for equation of state calculations. It handles energy unit conversions and optionally fills in missing G, H, or S values.

  ## Parameters

  `OBIGT` :`pd.DataFrame`
    OBIGT thermodynamic database
  `fixGHS` :`bool`, default `True`
    Fill in one missing value among G, H, S using thermodynamic relations
  `tocal` :`bool`, default `True`
    Convert energy units from Joules to calories
  `messages` :`bool`, default `True`
    Print informational messages (currently not used, reserved for future)

  ## Returns

  `pd.DataFrame`
    Modified OBIGT dataframe with converted parameters
`def available_properties(model: str = 'SUPCRT92') ‑> List[str]`
  Expand source code

      def available_properties(model: str = 'SUPCRT92') -> List[str]:
          """
          Get list of available properties for a water model.

          Parameters
          ----------
          model : str
              Water model name ('SUPCRT92', 'IAPWS95', or 'DEW')

          Returns
          -------
          List[str]
              List of available property names
          """
          model = model.upper()

          if model in ['SUPCRT92', 'SUPCRT']:
              from .supcrt92 import supcrt92_water
              return supcrt92_water.available_properties()
          elif model in ['IAPWS95', 'IAPWS']:
              from .iapws95 import iapws95_water
              return iapws95_water.available_properties()
          elif model == 'DEW':
              from .dew import dew_water
              return dew_water.available_properties()
          else:
              raise ValueError(f"Unknown water model: {model}")

  Get list of available properties for a water model.

  ## Parameters

  `model` :`str`
    Water model name ('SUPCRT92', 'IAPWS95', or 'DEW')

  ## Returns

  `List[str]`
    List of available property names
`def calc_G_TP(OBIGT, Tc, P, water_model)`
  Expand source code

      def calc_G_TP(OBIGT, Tc, P, water_model):

          aq_out, H2O_Pt = hkf(property=["G"], parameters=OBIGT,
                               T=273.15+Tc, P=P, contrib=["n", "s", "o"],
                               H2O_props=["rho"], water_model=water_model)

          cgl_out = cgl(property=["G"], parameters=OBIGT, T=273.15+Tc, P=P)

          aq_col = pd.DataFrame.from_dict(aq_out, orient="index")
          cgl_col = pd.DataFrame.from_dict(cgl_out, orient="index")

          G_TP_df = pd.concat([aq_col, cgl_col], axis=1)
          G_TP_df.columns = ['aq','cgl']

          OBIGT["G_TP"] = G_TP_df['aq'].combine_first(G_TP_df['cgl'])

          rows_added = 0

          # add a row for water
          if "H2O" not in list(OBIGT["name"]):
              # Set the water model (without printing messages)
              water(water_model, messages=False)
              # water() returns a scalar when called with single property and scalar T, P
              # The result is in J/mol, need to convert to cal/mol by dividing by 4.184
              G_water = water("G", T=Tc+273.15, P=P, messages=False)
              # Handle both scalar and DataFrame returns
              if isinstance(G_water, pd.DataFrame):
                  G_water_cal = G_water.iloc[0]["G"] / 4.184
              else:
                  G_water_cal = float(G_water) / 4.184
              OBIGT = pd.concat([OBIGT, pd.DataFrame({"name": "H2O", "tag": "nan", "G_TP": G_water_cal}, index=[OBIGT.shape[0]])], ignore_index=True)
              rows_added += 1

          # add a row for protons
          if "H+" not in list(OBIGT["name"]):
              OBIGT = pd.concat([OBIGT, pd.DataFrame({"name": "H+", "tag": "nan", "G_TP": 0}, index=[OBIGT.shape[0]])], ignore_index=True)
              rows_added += 1

          return OBIGT, rows_added

`def calc_logK(OBIGT_df, Tc, P, TP_i, water_model)`
  Expand source code

      def calc_logK(OBIGT_df, Tc, P, TP_i, water_model):

          OBIGT_TP, rows_added = calc_G_TP(OBIGT_df, Tc, P, water_model)

          dissrxn2logK_out = []
          for i in OBIGT_TP.index:
              dissrxn2logK_out.append(dissrxn2logK(OBIGT_TP, i, Tc))
          assert len(dissrxn2logK_out) == OBIGT_TP.shape[0]

          OBIGT_TP['dissrxn_logK_'+str(TP_i)] = dissrxn2logK_out

          # remove any rows added by calc_G_TP
          OBIGT_TP.drop(OBIGT_TP.tail(rows_added).index, inplace = True)

          return OBIGT_TP

`def cgl(property=None, parameters=None, T=298.15, P=1)`
  Expand source code

      def cgl(property = None, parameters = None, T = 298.15, P = 1):
          # calculate properties of crystalline, liquid (except H2O) and gas species
          Tr = 298.15
          Pr = 1

          # Convert T and P to arrays for vectorized operations
          T = np.atleast_1d(T)
          P = np.atleast_1d(P)

          # make T and P equal length
          if P.size < T.size:
              P = np.full_like(T, P[0] if P.size == 1 else P)
          if T.size < P.size:
              T = np.full_like(P, T[0] if T.size == 1 else T)

          n_conditions = T.size
          # initialize output dict
          out_dict = dict()
          # loop over each species

          # Iterate over each row by position to handle duplicate indices properly
          for i in range(len(parameters)):
              # Get the index label for this row
              k = parameters.index[i]
              # Get the row data by position (iloc) to avoid duplicate index issues
              PAR = parameters.iloc[i]

              if PAR["state"] == "aq":
                  # For aqueous species processed by CGL, return NaN
                  # (they should be processed by HKF instead)
                  out_dict[k] = {p:float('NaN') for p in property}
              else:

                  # OBIGT database stores G, H, S in calories (E_units = "cal")
                  # CGL calculations use calories (integrals intCpdT, intCpdlnT, intVdP are in cal)
                  # Results are output in calories and converted to J in subcrt.py at line 959

                  # Parameter scaling - SUPCRT92 data is already in correct units
                  # PAR["a2.b"] = copy.copy(PAR["a2.b"]*10**-3)
                  # PAR["a3.c"] = copy.copy(PAR["a3.c"]*10**5)
                  # PAR["c1.e"] = copy.copy(PAR["c1.e"]*10**-5)

                  # Check if this is a Berman mineral (columns 9-21 are all NA in R indexing)
                  # In Python/pandas, we check the relevant thermodynamic parameter columns
                  # NOTE: A mineral is only Berman if it LACKS standard thermodynamic data (G,H,S)
                  # If G,H,S are present, use regular CGL even if heat capacity coefficients are all zero
                  berman_cols = ['a1.a', 'a2.b', 'a3.c', 'a4.d', 'c1.e', 'c2.f', 'omega.lambda', 'z.T']
                  has_standard_thermo = pd.notna(PAR.get('G', np.nan)) and pd.notna(PAR.get('H', np.nan)) and pd.notna(PAR.get('S', np.nan))
                  all_coeffs_zero_or_na = all(pd.isna(PAR.get(col, np.nan)) or PAR.get(col, 0) == 0 for col in berman_cols)
                  is_berman_mineral = all_coeffs_zero_or_na and not has_standard_thermo

                  if is_berman_mineral:
                      # Use Berman equations (parameters not in thermo()$OBIGT)
                      from .berman import Berman
                      try:
                          # Berman is already vectorized - pass T and P arrays directly
                          properties_df = Berman(PAR["name"], T=T, P=P)
                          # Extract the requested properties as arrays
                          values = {}
                          for prop in property:
                              if prop in properties_df.columns:
                                  # Get all values as an array
                                  prop_values = properties_df[prop].values

                                  # IMPORTANT: Berman function returns values in J/mol (Joules)
                                  # but CGL returns values in cal/mol (calories)
                                  # Convert Berman results from J/mol to cal/mol for consistency
                                  # Energy properties that need conversion: G, H, S, Cp
                                  # Volume (V) and other properties don't need conversion
                                  energy_props = ['G', 'H', 'S', 'Cp']
                                  if prop in energy_props:
                                      # Convert J/mol to cal/mol by dividing by 4.184
                                      prop_values = prop_values / 4.184

                                  values[prop] = prop_values
                              else:
                                  values[prop] = np.full(n_conditions, float('NaN'))
                      except Exception as e:
                          # If Berman calculation fails, fall back to NaN arrays
                          values = {prop: np.full(n_conditions, float('NaN')) for prop in property}
                  else:
                      # Use regular CGL equations

                      # in CHNOSZ, we have
                      # 1 cm^3 bar --> convert(1, "calories") == 0.02390057 cal
                      # but REAC92D.F in SUPCRT92 uses
                      cm3bar_to_cal = 0.023901488 # cal
                      # start with NA values
                      values = dict()
                      # a test for availability of heat capacity coefficients (a, b, c, d, e, f)
                      # based on the column assignments in thermo()$OBIGT

                      # Check for heat capacity coefficients, handling both NaN and non-numeric values
                      # Heat capacity coefficients are at positions 14-19 (a1.a through c2.f)
                      # Position 13 is V (volume), not a heat capacity coefficient
                      has_hc_coeffs = False
                      try:
                          hc_values = list(PAR.iloc[14:20])
                          has_hc_coeffs = any([pd.notna(p) and p != 0 for p in hc_values if pd.api.types.is_numeric_dtype(type(p))])

                          # DEBUG
                          if False and PAR["name"] == "rhomboclase":
                              print(f"DEBUG for rhomboclase:")
                              print(f"  hc_values (iloc[14:20]): {hc_values}")
                              print(f"  has_hc_coeffs: {has_hc_coeffs}")
                      except Exception as e:
                          has_hc_coeffs = False

                      if has_hc_coeffs:
                          # we have at least one of the heat capacity coefficients;
                          # zero out any NA's in the rest (leave lambda and T of transition (columns 20-21) alone)
                          for i in range(14, 20):
                              if pd.isna(PAR.iloc[i]) or not pd.api.types.is_numeric_dtype(type(PAR.iloc[i])):
                                  PAR.iloc[i] = 0.0
                          # calculate the heat capacity and its integrals (vectorized)
                          Cp = PAR["a1.a"] + PAR["a2.b"]*T + PAR["a3.c"]*T**-2 + PAR["a4.d"]*T**-0.5 + PAR["c1.e"]*T**2 + PAR["c2.f"]*T**PAR["omega.lambda"]
                          intCpdT = PAR["a1.a"]*(T - Tr) + PAR["a2.b"]*(T**2 - Tr**2)/2 + PAR["a3.c"]*(1/T - 1/Tr)/-1 + PAR["a4.d"]*(T**0.5 - Tr**0.5)/0.5 + PAR["c1.e"]*(T**3-Tr**3)/3
                          intCpdlnT = PAR["a1.a"]*np.log(T / Tr) + PAR["a2.b"]*(T - Tr) + PAR["a3.c"]*(T**-2 - Tr**-2)/-2 + PAR["a4.d"]*(T**-0.5 - Tr**-0.5)/-0.5  + PAR["c1.e"]*(T**2 - Tr**2)/2

                          # do we also have the lambda parameter (Cp term with adjustable exponent on T)?
                          if pd.notna(PAR["omega.lambda"]) and PAR["omega.lambda"] != 0:
                              # equations for lambda adapted from Helgeson et al., 1998 (doi:10.1016/S0016-7037(97)00219-6)
                              if PAR["omega.lambda"] == -1:
                                  intCpdT = intCpdT + PAR["c2.f"]*np.log(T/Tr)
                              else:
                                  intCpdT = intCpdT - PAR["c2.f"]*( T**(PAR["omega.lambda"] + 1) - Tr**(PAR["omega.lambda"] + 1) ) / (PAR["omega.lambda"] + 1)
                              intCpdlnT = intCpdlnT + PAR["c2.f"]*(T**PAR["omega.lambda"] - Tr**PAR["omega.lambda"]) / PAR["omega.lambda"]

                      else:
                          # use constant heat capacity if the coefficients are not available (vectorized)
                          # If Cp is NA/NaN, use 0 (matching R CHNOSZ behavior)
                          Cp_value = PAR["Cp"] if pd.notna(PAR["Cp"]) else 0.0
                          Cp = np.full(n_conditions, Cp_value)
                          intCpdT = Cp_value*(T - Tr)
                          intCpdlnT = Cp_value*np.log(T / Tr)
                          # in case Cp is listed as NA, set the integrals to 0 at Tr
                          at_Tr = (T == Tr)
                          intCpdT = np.where(at_Tr, 0, intCpdT)
                          intCpdlnT = np.where(at_Tr, 0, intCpdlnT)

                      # volume and its integrals (vectorized)
                      if PAR["name"] in ["quartz", "coesite"]:
                          # volume calculations for quartz and coesite
                          qtz = quartz_coesite(PAR, T, P)
                          V = qtz["V"]
                          intVdP = qtz["intVdP"]
                          intdVdTdP = qtz["intdVdTdP"]

                      else:
                          # for other minerals, volume is constant (Helgeson et al., 1978)
                          V = np.full(n_conditions, PAR["V"])
                          # if the volume is NA, set its integrals to zero
                          if pd.isna(PAR["V"]):
                              intVdP = np.zeros(n_conditions)
                              intdVdTdP = np.zeros(n_conditions)
                          else:
                              intVdP = PAR["V"]*(P - Pr) * cm3bar_to_cal
                              intdVdTdP = np.zeros(n_conditions)

                      # get the values of each of the requested thermodynamic properties (vectorized)
                      for i,prop in enumerate(property):
                          if prop == "Cp": values["Cp"] = Cp
                          if prop == "V": values["V"] = V
                          if prop == "E": values["E"] = np.full(n_conditions, float('NaN'))
                          if prop == "kT": values["kT"] = np.full(n_conditions, float('NaN'))
                          if prop == "G":
                              # calculate S * (T - Tr), but set it to 0 at Tr (in case S is NA)
                              Sterm = PAR["S"]*(T - Tr)
                              Sterm = np.where(T == Tr, 0, Sterm)

                              # DEBUG
                              if False and PAR["name"] == "iron" and PAR.get("state") == "cr4":
                                  print(f"DEBUG G calculation for {PAR['name']} {PAR.get('state', 'unknown')}:")
                                  print(f"  PAR['G'] = {PAR['G']}")
                                  print(f"  PAR['S'] = {PAR['S']}")
                                  print(f"  model = {PAR.get('model', 'unknown')}")
                                  print(f"  Sterm[0] = {Sterm[0] if hasattr(Sterm, '__len__') else Sterm}")
                                  print(f"  intCpdT[0] = {intCpdT[0] if hasattr(intCpdT, '__len__') else intCpdT}")
                                  print(f"  T[0]*intCpdlnT[0] = {(T[0]*intCpdlnT[0]) if hasattr(intCpdlnT, '__len__') else T*intCpdlnT}")
                                  print(f"  intVdP[0] = {intVdP[0] if hasattr(intVdP, '__len__') else intVdP}")
                                  G_calc = PAR['G'] - Sterm + intCpdT - T*intCpdlnT + intVdP
                                  print(f"  G[0] (before subcrt conversion) = {G_calc[0] if hasattr(G_calc, '__len__') else G_calc}")

                              values["G"] = PAR["G"] - Sterm + intCpdT - T*intCpdlnT + intVdP
                          if prop == "H":
                              values["H"] = PAR["H"] + intCpdT + intVdP - T*intdVdTdP
                          if prop == "S": values["S"] = PAR["S"] + intCpdlnT - intdVdTdP

                  out_dict[k] = values # species have to be numbered instead of named because of name repeats (e.g., cr polymorphs)

          return out_dict

`def compare_models(property: str,
T: float | numpy.ndarray = 298.15,
P: float | numpy.ndarray = 1.0) ‑> pandas.core.frame.DataFrame`
  Expand source code

      def compare_models(property: str,
                         T: Union[float, np.ndarray] = 298.15,
                         P: Union[float, np.ndarray] = 1.0) -> pd.DataFrame:
          """
          Compare water property calculations across different models.

          Parameters
          ----------
          property : str
              Property to compare
          T : float or array
              Temperature in Kelvin
          P : float or array
              Pressure in bar

          Returns
          -------
          pd.DataFrame
              Comparison of property values from different models
          """
          T = np.atleast_1d(np.asarray(T, dtype=float))
          P = np.atleast_1d(np.asarray(P, dtype=float))

          results = {}
          models = ['SUPCRT92', 'IAPWS95', 'DEW']

          for model in models:
              try:
                  if model == 'SUPCRT92':
                      result = water_SUPCRT92(property, T, P)
                  elif model == 'IAPWS95':
                      # Convert bar to kPa for IAPWS95
                      result = water_IAPWS95(property, T, P * 100.0)
                      # Convert units back if needed
                      if property == 'rho':
                          result = result / 1000.0  # kg/m³ to g/cm³
                  elif model == 'DEW':
                      result = water_DEW(property, T, P)

                  results[model] = result

              except Exception as e:
                  print(f"Error with {model}: {e}")
                  results[model] = np.full_like(T, np.nan)

          # Create DataFrame
          if len(T) == 1 and len(P) == 1:
              # Single point
              data = {model: [results[model]] if np.isscalar(results[model]) else results[model]
                      for model in models}
              df = pd.DataFrame(data, index=[f"T={T[0]:.1f}K, P={P[0]:.1f}bar"])
          else:
              # Multiple points
              data = {model: results[model] for model in models}
              index = [f"T={t:.1f}K, P={p:.1f}bar" for t, p in zip(T, P)]
              df = pd.DataFrame(data, index=index)

          return df

  Compare water property calculations across different models.

  ## Parameters

  `property` :`str`
    Property to compare
  `T` :`float` or `array`
    Temperature in Kelvin
  `P` :`float` or `array`
    Pressure in bar

  ## Returns

  `pd.DataFrame`
    Comparison of property values from different models
`def convert_cm3bar(value)`
  Expand source code

      def convert_cm3bar(value):
          return value*4.184 * 10

`def dissrxn2logK(OBIGT, i, Tc)`
  Expand source code

      def dissrxn2logK(OBIGT, i, Tc):

          this_dissrxn = OBIGT.iloc[i, OBIGT.columns.get_loc('dissrxn')]

          if this_dissrxn == "nan":
              this_dissrxn = OBIGT.iloc[i, OBIGT.columns.get_loc('regenerate_dissrxn')]

      #     print(OBIGT["name"][i], this_dissrxn)

          try:
              this_dissrxn = this_dissrxn.strip()
              split_dissrxn = this_dissrxn.split(" ")
          except:
              return float('NaN')

          coeff = [float(n) for n in split_dissrxn[::2]]
          species = split_dissrxn[1::2]
          try:
              G = sum([float(c*OBIGT.loc[OBIGT["name"]==sp, "G_TP"].iloc[0]) for c,sp in zip(coeff, species)])
          except:
              G_list = []
              for ii, sp in enumerate(species):
                  G_TP = OBIGT.loc[OBIGT["name"]==sp, "G_TP"]
                  if len(G_TP) == 1:
                      G_list.append(float(coeff[ii]*OBIGT.loc[OBIGT["name"]==sp, "G_TP"]))
                  else:
                      ### check valid polymorph T

                      # get polymorph entries of OBIGT that match mineral
                      poly_df = copy.copy(OBIGT.loc[OBIGT["name"]==sp,:])
                      # ensure polymorph df is sorted according to cr, cr2, cr3... etc.
                      poly_df = poly_df.sort_values("state")

                      z_Ts = list(poly_df.loc[poly_df["name"]==sp, "z.T"])

                      last_t = float('-inf')
                      appended=False
                      for iii,t in enumerate(z_Ts):

                          if Tc+273.15 > last_t and Tc+273.15 < t:
                              G_list.append(float(coeff[ii]*list(poly_df.loc[poly_df["name"]==sp, "G_TP"])[iii]))
                              appended=True
                          if not appended and z_Ts[-1] == t:
                              G_list.append(float(coeff[ii]*list(poly_df.loc[poly_df["name"]==sp, "G_TP"])[iii]))
                          last_t = t

              G = sum(G_list)

          return G2logK(G, Tc)

`def get_water_models() ‑> List[str]`
  Expand source code

      def get_water_models() -> List[str]:
          """
          Get list of available water models.

          Returns
          -------
          List[str]
              List of available water model names
          """
          return ['SUPCRT92', 'IAPWS95', 'DEW']

  Get list of available water models.

  ## Returns

  `List[str]`
    List of available water model names
`def gfun(rhohat, Tc, P, alpha, daldT, beta)`
  Expand source code

      def gfun(rhohat, Tc, P, alpha, daldT, beta):
          ## g and f functions for describing effective electrostatic radii of ions
          ## split from hkf() 20120123 jmd
          ## based on equations in
          ## Shock EL, Oelkers EH, Johnson JW, Sverjensky DA, Helgeson HC, 1992
          ## Calculation of the Thermodynamic Properties of Aqueous Species at High Pressures
          ## and Temperatures: Effective Electrostatic Radii, Dissociation Constants and
          ## Standard Partial Molal Properties to 1000 degrees C and 5 kbar
          ## J. Chem. Soc. Faraday Trans., 88(6), 803-826  doi:10.1039/FT9928800803
          # rhohat - density of water in g/cm3
          # Tc - temperature in degrees Celsius
          # P - pressure in bars

          # Vectorized version - handle both scalars and arrays
          rhohat = np.atleast_1d(rhohat)
          Tc = np.atleast_1d(Tc)
          P = np.atleast_1d(P)
          alpha = np.atleast_1d(alpha)
          daldT = np.atleast_1d(daldT)
          beta = np.atleast_1d(beta)

          # Broadcast to same shape
          shape = np.broadcast_shapes(rhohat.shape, Tc.shape, P.shape, alpha.shape, daldT.shape, beta.shape)
          rhohat = np.broadcast_to(rhohat, shape)
          Tc = np.broadcast_to(Tc, shape)
          P = np.broadcast_to(P, shape)
          alpha = np.broadcast_to(alpha, shape)
          daldT = np.broadcast_to(daldT, shape)
          beta = np.broadcast_to(beta, shape)

          # Initialize output arrays
          g = np.zeros(shape)
          dgdT = np.zeros(shape)
          d2gdT2 = np.zeros(shape)
          dgdP = np.zeros(shape)

          # only rhohat less than 1 will give results other than zero
          mask = rhohat < 1
          if not np.any(mask):
              return {"g": g, "dgdT": dgdT, "d2gdT2": d2gdT2, "dgdP": dgdP}

          # eta in Eq. 1
          eta = 1.66027E5
          # Table 3
          ag1 = -2.037662
          ag2 = 5.747000E-3
          ag3 = -6.557892E-6
          bg1 = 6.107361
          bg2 = -1.074377E-2
          bg3 = 1.268348E-5

          # Work only with masked values
          Tc_m = Tc[mask]
          P_m = P[mask]
          rhohat_m = rhohat[mask]
          alpha_m = alpha[mask]
          daldT_m = daldT[mask]
          beta_m = beta[mask]

          # Eq. 25
          ag = ag1 + ag2 * Tc_m + ag3 * Tc_m ** 2
          # Eq. 26
          bg = bg1 + bg2 * Tc_m + bg3 * Tc_m ** 2
          # Eq. 24
          g_m = ag * (1 - rhohat_m) ** bg

          # Table 4
          af1 = 0.3666666E2
          af2 = -0.1504956E-9
          af3 = 0.5017997E-13

          # Eq. 33
          f = ( ((Tc_m - 155) / 300) ** 4.8 + af1 * ((Tc_m - 155) / 300) ** 16 ) * \
              ( af2 * (1000 - P_m) ** 3 + af3 * (1000 - P_m) ** 4 )

          # limits of the f function (region II of Fig. 6)
          ifg = (Tc_m > 155) & (P_m < 1000) & (Tc_m < 355)

          # Eq. 32 - apply f correction where ifg is True
          # Check for complex values
          f_is_real = ~np.iscomplex(f)
          apply_f = ifg & f_is_real
          g_m = np.where(apply_f, g_m - f.real, g_m)

          # at P > 6000 bar (in DEW calculations), g is zero 20170926
          g_m = np.where(P_m > 6000, 0, g_m)

          ## now we have g at P, T
          # put the results in their right place (where rhohat < 1)
          g[mask] = g_m

          ## the rest is to get its partial derivatives with pressure and temperature
          ## after Johnson et al., 1992
          # alpha - coefficient of isobaric expansivity (K^-1)
          # daldT - temperature derivative of coefficient of isobaric expansivity (K^-2)
          # beta - coefficient of isothermal compressibility (bar^-1)

          # Eqn. 76
          d2fdT2 = (0.0608/300*((Tc_m-155)/300)**2.8 + af1/375*((Tc_m-155)/300)**14) * (af2*(1000-P_m)**3 + af3*(1000-P_m)**4)
          # Eqn. 75
          dfdT = (0.016*((Tc_m-155)/300)**3.8 + 16*af1/300*((Tc_m-155)/300)**15) * \
              (af2*(1000-P_m)**3 + af3*(1000-P_m)**4)
          # Eqn. 74
          dfdP = -(((Tc_m-155)/300)**4.8 + af1*((Tc_m-155)/300)**16) * \
              (3*af2*(1000-P_m)**2 + 4*af3*(1000-P_m)**3)
          d2bdT2 = 2 * bg3  # Eqn. 73
          d2adT2 = 2 * ag3  # Eqn. 72
          dbdT = bg2 + 2*bg3*Tc_m  # Eqn. 71
          dadT = ag2 + 2*ag3*Tc_m  # Eqn. 70

          # Convert complex to NaN
          d2fdT2 = np.where(np.iscomplex(d2fdT2), np.nan, np.real(d2fdT2))
          dfdT = np.where(np.iscomplex(dfdT), np.nan, np.real(dfdT))
          dfdP = np.where(np.iscomplex(dfdP), np.nan, np.real(dfdP))

          # Initialize derivative arrays for masked region
          dgdT_m = np.zeros_like(g_m)
          d2gdT2_m = np.zeros_like(g_m)
          dgdP_m = np.zeros_like(g_m)

          # Calculate derivatives where alpha and daldT are not NaN
          alpha_valid = ~np.isnan(alpha_m) & ~np.isnan(daldT_m)
          if np.any(alpha_valid):
              # Work with valid subset
              av_idx = alpha_valid
              bg_av = bg[av_idx]
              rhohat_av = rhohat_m[av_idx]
              alpha_av = alpha_m[av_idx]
              daldT_av = daldT_m[av_idx]
              g_av = g_m[av_idx]
              ag_av = ag[av_idx]
              Tc_av = Tc_m[av_idx]
              dbdT_av = dbdT[av_idx]
              dadT_av = dadT[av_idx]

              # Handle log of (1-rhohat) safely
              with np.errstate(divide='ignore', invalid='ignore'):
                  log_term = np.log(1 - rhohat_av)
                  log_term = np.where(np.isfinite(log_term), log_term, 0)

              # Eqn. 69
              dgadT = bg_av*rhohat_av*alpha_av*(1-rhohat_av)**(bg_av-1) + log_term*g_av/ag_av*dbdT_av
              D = rhohat_av

              # transcribed from SUPCRT92/reac92.f
              dDdT = -D * alpha_av
              dDdTT = -D * (daldT_av - alpha_av**2)
              Db = (1-D)**bg_av
              dDbdT = -bg_av*(1-D)**(bg_av-1)*dDdT + log_term*Db*dbdT_av
              dDbdTT = -(bg_av*(1-D)**(bg_av-1)*dDdTT + (1-D)**(bg_av-1)*dDdT*dbdT_av + \
                  bg_av*dDdT*(-(bg_av-1)*(1-D)**(bg_av-2)*dDdT + log_term*(1-D)**(bg_av-1)*dbdT_av)) + \
                  log_term*(1-D)**bg_av*d2bdT2 - (1-D)**bg_av*dbdT_av*dDdT/(1-D) + log_term*dbdT_av*dDbdT
              d2gdT2_calc = ag_av*dDbdTT + 2*dDbdT*dadT_av + Db*d2adT2

              # Apply f correction where ifg is True
              ifg_av = ifg[av_idx]
              d2fdT2_av = d2fdT2[av_idx]
              dfdT_av = dfdT[av_idx]
              d2gdT2_calc = np.where(ifg_av, d2gdT2_calc - d2fdT2_av, d2gdT2_calc)

              dgdT_calc = g_av/ag_av*dadT_av + ag_av*dgadT  # Eqn. 67
              dgdT_calc = np.where(ifg_av, dgdT_calc - dfdT_av, dgdT_calc)

              dgdT_m[av_idx] = dgdT_calc
              d2gdT2_m[av_idx] = d2gdT2_calc

          # Calculate dgdP where beta is not NaN
          beta_valid = ~np.isnan(beta_m)
          if np.any(beta_valid):
              bv_idx = beta_valid
              bg_bv = bg[bv_idx]
              rhohat_bv = rhohat_m[bv_idx]
              beta_bv = beta_m[bv_idx]
              g_bv = g_m[bv_idx]

              dgdP_calc = -bg_bv*rhohat_bv*beta_bv*g_bv*(1-rhohat_bv)**-1  # Eqn. 66
              ifg_bv = ifg[bv_idx]
              dfdP_bv = dfdP[bv_idx]
              dgdP_calc = np.where(ifg_bv, dgdP_calc - dfdP_bv, dgdP_calc)
              dgdP_m[bv_idx] = dgdP_calc

          # Put results back into full arrays
          dgdT[mask] = dgdT_m
          d2gdT2[mask] = d2gdT2_m
          dgdP[mask] = dgdP_m

          return {"g": g, "dgdT": dgdT, "d2gdT2": d2gdT2, "dgdP": dgdP}

`def hkf(property=None,
parameters=None,
T=298.15,
P=1,
contrib=['n', 's', 'o'],
H2O_props=['rho'],
water_model='SUPCRT92')`
  Expand source code

      def hkf(property=None, parameters=None, T=298.15, P=1,
          contrib = ["n", "s", "o"], H2O_props=["rho"], water_model="SUPCRT92"):
          # calculate G, H, S, Cp, V, kT, and/or E using
          # the revised HKF equations of state
          # H2O_props - H2O properties needed for subcrt() output
          # constants
          Tr = 298.15 # K
          Pr = 1      # bar
          Theta = 228 # K
          Psi = 2600  # bar

          # Convert T and P to arrays for vectorized operations
          T = np.atleast_1d(T)
          P = np.atleast_1d(P)

          # DEBUG
          if False:
              print(f"\nDEBUG HKF input:")
              print(f"  T (K): {T}")
              print(f"  P (bar): {P}")

          # make T and P equal length
          if P.size < T.size:
              P = np.full_like(T, P[0] if P.size == 1 else P)
          if T.size < P.size:
              T = np.full_like(P, T[0] if T.size == 1 else T)

          n_conditions = T.size

          # GB conversion note: handle error messages later
      #     # nonsolvation, solvation, and origination contribution
      #     notcontrib <- ! contrib %in% c("n", "s", "o")
      #     if(TRUE %in% notcontrib) stop(paste("contrib must be in c('n', 's', 'o); got", c2s(contrib[notcontrib])))

          # get water properties
          # rho - for subcrt() output and g function
          # Born functions and epsilon - for HKF calculations
          H2O_props += ["QBorn", "XBorn", "YBorn", "epsilon"]

          if water_model == "SUPCRT92":
            # using H2O92D.f from SUPCRT92: alpha, daldT, beta - for partial derivatives of omega (g function)
            H2O_props += ["alpha", "daldT", "beta"]

          elif water_model == "IAPWS95":
            # using IAPWS-95: NBorn, UBorn - for compressibility, expansibility
            H2O_props += ["alpha", "daldT", "beta", "NBorn", "UBorn"]

          elif water_model == "DEW":
            # using DEW model: get beta to calculate dgdP
            H2O_props += ["alpha", "daldT", "beta"]

          # DEBUG: Print T and P being passed to water
          if False:
              print(f"DEBUG HKF calling water():")
              print(f"  T type: {type(T)}, T: {T}")
              print(f"  P type: {type(P)}, P: {P}")
              print(f"  H2O_props: {H2O_props}")

          H2O_PrTr = water(H2O_props, T=Tr, P=Pr)
          H2O_PT = water(H2O_props, T=T, P=P)

          # DEBUG: Print what water returned
          if False:
              print(f"DEBUG HKF water() returned:")
              print(f"  H2O_PT type: {type(H2O_PT)}")
              if isinstance(H2O_PT, dict):
                  print(f"  H2O_PT keys: {H2O_PT.keys()}")
                  print(f"  epsilon: {H2O_PT.get('epsilon', 'NOT FOUND')}")

          # Handle dict output from water function
          def get_water_prop(water_dict, prop):
              """Helper function to get water property from dict or DataFrame"""
              if isinstance(water_dict, dict):
                  return water_dict[prop]
              else:
                  return water_dict.loc["1", prop]

          # Get epsilon values and handle potential zeros
          epsilon_PT = get_water_prop(H2O_PT, "epsilon")
          epsilon_PrTr = get_water_prop(H2O_PrTr, "epsilon")

          # Check for zero or very small epsilon values and warn
          if np.any(epsilon_PT == 0) or np.any(np.abs(epsilon_PT) < 1e-10):
              warnings.warn(f"HKF: epsilon at P,T is zero or very small: {epsilon_PT}. H2O_PT keys: {H2O_PT.keys() if isinstance(H2O_PT, dict) else 'not dict'}")

          with np.errstate(divide='ignore', invalid='ignore'):
              ZBorn = -1 / epsilon_PT
              ZBorn_PrTr = -1 / epsilon_PrTr

          # a class to store the result
          out_dict = {} # dictionary to store output

          for k in parameters.index:

              if parameters["state"][k] != "aq":
                  out_dict[k] = {p:float('NaN') for p in property}
              else:
                  sp = parameters["name"][k]

                  # loop over each species
                  PAR = copy.copy(parameters.loc[k, :])

                  PAR["a1.a"] = copy.copy(PAR["a1.a"]*10**-1)
                  PAR["a2.b"] = copy.copy(PAR["a2.b"]*10**2)
                  PAR["a4.d"] = copy.copy(PAR["a4.d"]*10**4)
                  PAR["c2.f"] = copy.copy(PAR["c2.f"]*10**4)
                  PAR["omega.lambda"] = copy.copy(PAR["omega.lambda"]*10**5)

                  # substitute Cp and V for missing EoS parameters
                  # here we assume that the parameters are in the same position as in thermo()$OBIGT
                  # we don't need this if we're just looking at solvation properties (Cp_s_var, V_s_var)

                  # GB conversion note: this block checks various things about EOS parameters.
                  # for now, just set hasEOS to True
                  hasEOS = True # delete this once the following block is converted to python
          #         if "n" in contrib:
          #             # put the heat capacity in for c1 if both c1 and c2 are missing
          #             if all(is.na(PAR[, 18:19])):
          #                 PAR[, 18] = PAR["Cp"]
          #             # put the volume in for a1 if a1, a2, a3 and a4 are missing
          #             if all(is.na(PAR[, 14:17])):
          #                 PAR[, 14] = convert(PAR["V"], "calories")
          #             # test for availability of the EoS parameters
          #             hasEOS = any(!is.na(PAR[, 14:21]))
          #             # if at least one of the EoS parameters is available, zero out any NA's in the rest
          #             if hasEOS:
          #                 PAR[, 14:21][, is.na(PAR[, 14:21])] = 0

                  # compute values of omega(P,T) from those of omega(Pr,Tr)
                  # using g function etc. (Shock et al., 1992 and others)
                  omega = PAR["omega.lambda"]  # omega_PrTr
                  # its derivatives are zero unless the g function kicks in
                  dwdP = np.zeros(n_conditions)
                  dwdT = np.zeros(n_conditions)
                  d2wdT2 = np.zeros(n_conditions)
                  Z = PAR["z.T"]

                  omega_PT = np.full(n_conditions, PAR["omega.lambda"])
                  if Z != 0 and Z != "NA" and PAR["name"] != "H+":
                      # compute derivatives of omega: g and f functions (Shock et al., 1992; Johnson et al., 1992)
                      rhohat = get_water_prop(H2O_PT, "rho")/1000  # just converting kg/m3 to g/cm3

                      # temporarily filter out Python's warnings about dividing by zero, which is possible
                      # with the equations in the gfunction
                      # Possible complex output is acounted for in gfun().
                      with warnings.catch_warnings():
                          warnings.simplefilter('ignore')
                          g = gfun(rhohat, T-273.15, P, get_water_prop(H2O_PT, "alpha"), get_water_prop(H2O_PT, "daldT"), get_water_prop(H2O_PT, "beta"))

                      # after SUPCRT92/reac92.f
                      eta = 1.66027E5
                      reref = (Z**2) / (omega/eta + Z/(3.082 + 0))
                      re = reref + abs(Z) * g["g"]
                      omega_PT = eta * (Z**2/re - Z/(3.082 + g["g"]))
                      Z3 = abs(Z**3)/re**2 - Z/(3.082 + g["g"])**2
                      Z4 = abs(Z**4)/re**3 - Z/(3.082 + g["g"])**3
                      dwdP = (-eta * Z3 * g["dgdP"])
                      dwdT = (-eta * Z3 * g["dgdT"])
                      d2wdT2 = (2 * eta * Z4 * g["dgdT"]**2 - eta * Z3 * g["d2gdT2"])

                  # loop over each property
                  w = float('NaN')
                  for i,PROP in enumerate(property) :

                      # over nonsolvation, solvation, or origination contributions - vectorized
                      hkf_p = np.zeros(n_conditions)

                      for icontrib in contrib :
                          # various contributions to the properties
                          if icontrib == "n":
                              # nonsolvation ghs equations
                              if PROP == "H":
                                  p_c = PAR["c1.e"]*(T-Tr) - PAR["c2.f"]*(1/(T-Theta)-1/(Tr-Theta))
                                  p_a = PAR["a1.a"]*(P-Pr) + PAR["a2.b"]*np.log((Psi+P)/(Psi+Pr)) + \
                                    ((2*T-Theta)/(T-Theta)**2)*(PAR["a3.c"]*(P-Pr)+PAR["a4.d"]*np.log((Psi+P)/(Psi+Pr)))
                                  p = p_c + p_a
                              elif PROP == "S":
                                  p_c = PAR["c1.e"]*np.log(T/Tr) - \
                                    (PAR["c2.f"]/Theta)*( 1/(T-Theta)-1/(Tr-Theta) + \
                                    np.log( (Tr*(T-Theta))/(T*(Tr-Theta)) )/Theta )
                                  p_a = (T-Theta)**(-2)*(PAR["a3.c"]*(P-Pr)+PAR["a4.d"]*np.log((Psi+P)/(Psi+Pr)))
                                  p = p_c + p_a
                              elif PROP == "G":
                                  p_c = -PAR["c1.e"]*(T*np.log(T/Tr)-T+Tr) - \
                                    PAR["c2.f"]*( (1/(T-Theta)-1/(Tr-Theta))*((Theta-T)/Theta) - \
                                    (T/Theta**2)*np.log((Tr*(T-Theta))/(T*(Tr-Theta))) )
                                  p_a = PAR["a1.a"]*(P-Pr) + PAR["a2.b"]*np.log((Psi+P)/(Psi+Pr)) + \
                                    (PAR["a3.c"]*(P-Pr) + PAR["a4.d"]*np.log((Psi+P)/(Psi+Pr)))/(T-Theta)
                                  p = p_c + p_a
                                  # at Tr,Pr, if the origination contribution is not NA, ensure the solvation contribution is 0, not NA
                                  if not np.isnan(PAR["G"]):
                                      p = np.where((T==Tr) & (P==Pr), 0, p)
                              # nonsolvation cp v kt e equations
                              elif PROP == "Cp":
                                  p = PAR["c1.e"] + PAR["c2.f"] * ( T - Theta ) ** (-2)
                              elif PROP == "V":
                                  p = convert_cm3bar(PAR["a1.a"]) + \
                                    convert_cm3bar(PAR["a2.b"]) / (Psi + P) + \
                                    (convert_cm3bar(PAR["a3.c"]) + convert_cm3bar(PAR["a4.d"]) / (Psi + P)) / (T - Theta)
      #                         elif PROP == "kT":
      #                             p = (convert(PAR["a2.b"], "cm3bar") + \
      #                               convert(PAR["a4.d"], "cm3bar") / (T - Theta)) * (Psi + P) ** (-2)
      #                         elif PROP == "E":
      #                             p = convert( - (PAR["a3.c"] + PAR["a4.d"] / convert((Psi + P), "calories")) * \
      #                               (T - Theta) ** (-2), "cm3bar")
                              else:
                                  print("BAD")

                          if icontrib == "s":
                              # solvation ghs equations
                              if PROP == "G":
                                  p = -omega_PT*(ZBorn+1) + omega*(ZBorn_PrTr+1) + omega*get_water_prop(H2O_PrTr, "YBorn")*(T-Tr)
                                  # at Tr,Pr, if the origination contribution is not NA, ensure the solvation contribution is 0, not NA
                                  if(np.isnan(PAR["G"])):
                                      p = np.where((T==Tr) & (P==Pr), 0, p)
                              if PROP == "H":
                                  p = -omega_PT*(ZBorn+1) + omega_PT*T*get_water_prop(H2O_PT, "YBorn") + T*(ZBorn+1)*dwdT + \
                                         omega*(ZBorn_PrTr+1) - omega*Tr*get_water_prop(H2O_PrTr, "YBorn")
                              if PROP == "S":
                                  p = omega_PT*get_water_prop(H2O_PT, "YBorn") + (ZBorn+1)*dwdT - omega*get_water_prop(H2O_PrTr, "YBorn")
                              # solvation cp v kt e equations
                              if PROP == "Cp":
                                  p = omega_PT*T*get_water_prop(H2O_PT, "XBorn") + 2*T*get_water_prop(H2O_PT, "YBorn")*dwdT + T*(ZBorn+1)*d2wdT2
                              if PROP == "V":
                                  term1 = -convert_cm3bar(omega_PT) * get_water_prop(H2O_PT, "QBorn")
                                  term2 = convert_cm3bar(dwdP) * (-ZBorn - 1)
                                  p = term1 + term2

                                  # DEBUG
                                  if False:
                                      print(f"\nDEBUG solvation V terms:")
                                      print(f"  omega_PT: {omega_PT}")
                                      print(f"  QBorn: {get_water_prop(H2O_PT, 'QBorn')}")
                                      print(f"  dwdP: {dwdP}")
                                      print(f"  ZBorn: {ZBorn}")
                                      print(f"  term1 (-ω*QBorn): {term1}")
                                      print(f"  term2 (dwdP*(-Z-1)): {term2}")
                                      print(f"  total p: {p}")
                              # TODO: the partial derivatives of omega are not included here here for kt and e
                              # (to do it, see p. 820 of SOJ+92 ... but kt requires d2wdP2 which we don"t have yet)
                              if PROP == "kT":
                                  p = convert_cm3bar(omega) * get_water_prop(H2O_PT, "NBorn")
                              if PROP == "E":
                                  p = -convert_cm3bar(omega) * get_water_prop(H2O_PT, "UBorn")

                          if icontrib == "o":
                              # origination ghs equations
                              if PROP == "G":
                                  p = PAR["G"] - PAR["S"] * (T-Tr)
                                  # don"t inherit NA from PAR$S at Tr
                                  p = np.where(T == Tr, PAR["G"], p)
                              elif PROP == "H":
                                  p = np.full(n_conditions, PAR["H"])
                              elif PROP == "S":
                                  p = np.full(n_conditions, PAR["S"])
                              # origination eos equations (Cp, V, kT, E): senseless
                              else:
                                  p = np.zeros(n_conditions)

                          # accumulate the contribution
                          hkf_p = hkf_p + p

                          # DEBUG
                          if False and PROP == "V":
                              print(f"\nDEBUG HKF V calculation (species {k}, contrib={icontrib}):")
                              print(f"  T: {T}")
                              print(f"  P: {P}")
                              print(f"  contribution p: {p}")
                              print(f"  accumulated hkf_p: {hkf_p}")

                      # species have to be numbered (k) instead of named because of name repeats in db (e.g., cr polymorphs)
                      if i > 0:
                          out_dict[k][PROP] = hkf_p
                      else:
                          out_dict[k] = {PROP:hkf_p}

                      # DEBUG
                      if False and PROP == "V":
                          print(f"\nDEBUG HKF final V for species {k}: {hkf_p}")

          return(out_dict, H2O_PT)

`def quartz_coesite(PAR, T, P)`
  Expand source code

      def quartz_coesite(PAR, T, P):
          # the corrections are 0 for anything other than quartz and coesite
          if not PAR["name"] in ["quartz", "coesite"]:
              n = T.size if isinstance(T, np.ndarray) else 1
              return(dict(G=np.zeros(n), H=np.zeros(n), S=np.zeros(n), V=np.zeros(n)))

          # Vectorized version
          T = np.atleast_1d(T)
          P = np.atleast_1d(P)

          # Tr, Pr and TtPr (transition temperature at Pr)
          Pr = 1      # bar
          Tr = 298.15 # K
          TtPr = 848  # K
          # constants from SUP92D.f
          aa = 549.824
          ba = 0.65995
          ca = -0.4973e-4
          VPtTta = 23.348
          VPrTtb = 23.72
          Stran = 0.342
          # constants from REAC92D.f
          VPrTra = 22.688 # VPrTr(a-quartz)
          Vdiff = 2.047   # VPrTr(a-quartz) - VPrTr(coesite)
          k = 38.5       # dPdTtr(a/b-quartz)
          #k <- 38.45834    # calculated in CHNOSZ: dPdTtr(info("quartz"))
          # code adapted from REAC92D.f
          qphase = PAR["state"].replace("cr", "")

          if qphase == "2":
              Pstar = P.copy()
              Sstar = np.zeros_like(T)
              V = np.full_like(T, VPrTtb)
          else:
              Pstar = Pr + k * (T - TtPr)
              Sstar = np.full_like(T, Stran)
              V = VPrTra + ca*(P-Pr) + (VPtTta - VPrTra - ca*(P-Pr))*(T-Tr) / (TtPr + (P-Pr)/k - Tr)

          # Apply condition: if T < TtPr
          below_transition = T < TtPr
          Pstar = np.where(below_transition, Pr, Pstar)
          Sstar = np.where(below_transition, 0, Sstar)

          if PAR["name"] == "coesite":
              VPrTra = VPrTra - Vdiff
              VPrTtb = VPrTtb - Vdiff
              V = V - Vdiff

          cm3bar_to_cal = 0.023901488

          # Vectorized log calculation
          with np.errstate(divide='ignore', invalid='ignore'):
              log_term = np.log((aa + P/k) / (aa + Pstar/k))
              log_term = np.where(np.isfinite(log_term), log_term, 0)

          GVterm = cm3bar_to_cal * (VPrTra * (P - Pstar) + VPrTtb * (Pstar - Pr) - \
              0.5 * ca * (2 * Pr * (P - Pstar) - (P**2 - Pstar**2)) - \
              ca * k * (T - Tr) * (P - Pstar) + \
              k * (ba + aa * ca * k) * (T - Tr) * log_term)
          SVterm = cm3bar_to_cal * (-k * (ba + aa * ca * k) * log_term + ca * k * (P - Pstar)) - Sstar

          # note the minus sign on "SVterm" in order that intdVdTdP has the correct sign
          return dict(intVdP=GVterm, intdVdTdP=-SVterm, V=V)

`def water(property: str | List[str] | None = None,
T: float | numpy.ndarray | List[float] = 298.15,
P: float | List[float] | numpy.ndarray | str = 1.0,
Psat_floor: float | None = 1.0,
model: str | None = None,
messages: bool = True) ‑> str | float | numpy.ndarray | Dict[str, Any]`
  Expand source code

      def water(property: Optional[Union[str, List[str]]] = None,
                T: Union[float, np.ndarray, List[float]] = 298.15,
                P: Union[float, np.ndarray, List[float], str] = 1.0,
                Psat_floor: Union[float, None] = 1.0,
                model: Optional[str] = None,
                messages: bool = True) -> Union[str, float, np.ndarray, Dict[str, Any]]:
          """
          Calculate thermodynamic and electrostatic properties of liquid H2O.

          This is the main water function that provides the same interface as the
          R CHNOSZ water() function, with support for multiple water models.

          Parameters
          ----------
          property : str, list of str, or None
              Properties to calculate. If None, returns current water model.
              If water model name (SUPCRT92, IAPWS95, DEW), sets the water model.
              Available properties depend on the water model used.
          T : float or array-like
              Temperature in Kelvin
          P : float, array-like, or "Psat"
              Pressure in bar, or "Psat" for saturation pressure
          Psat_floor : float or None
              Minimum pressure floor for Psat calculations (SUPCRT92 only)
          model : str, optional
              Override the default water model for this calculation
          messages : bool, default True
              Whether to print informational messages

          Returns
          -------
          str, float, array, or dict
              Current water model name, single property value, array of values,
              or dictionary with calculated properties

          Examples
          --------
          >>> import pychnosz
          >>> pychnosz.reset()
          >>>
          >>> # Get current water model
          >>> model = pychnosz.water()
          >>> print(model)  # 'SUPCRT92'
          >>>
          >>> # Set water model
          >>> old_model = pychnosz.water('IAPWS95')
          >>>
          >>> # Calculate single property
          >>> density = pychnosz.water('rho', T=298.15, P=1.0)
          >>>
          >>> # Calculate multiple properties
          >>> props = pychnosz.water(['rho', 'epsilon'], T=298.15, P=1.0)
          >>>
          >>> # Temperature array
          >>> temps = np.array([273.15, 298.15, 373.15])
          >>> densities = pychnosz.water('rho', T=temps, P=1.0)
          >>>
          >>> # Saturation pressure
          >>> psat = pychnosz.water('Psat', T=373.15)
          """

          # Get thermo system
          thermo_system = thermo()

          # Ensure thermo is initialized before accessing/setting options
          # This prevents reset() from clearing options later
          if not thermo_system.is_initialized():
              thermo_system.reset(messages=False)

          # Case 1: Query current water model
          if property is None:
              return thermo_system.get_option('water', 'SUPCRT92')

          # Case 2: Set water model
          if isinstance(property, str) and property.upper() in ['SUPCRT92', 'SUPCRT', 'IAPWS95', 'IAPWS', 'DEW']:
              old_model = thermo_system.get_option('water', 'SUPCRT92')

              # Normalize model name
              if property.upper() in ['SUPCRT92', 'SUPCRT']:
                  new_model = 'SUPCRT92'
              elif property.upper() in ['IAPWS95', 'IAPWS']:
                  new_model = 'IAPWS95'
              elif property.upper() == 'DEW':
                  new_model = 'DEW'

              thermo_system.set_option('water', new_model)
              if messages:
                  print(f"water: setting water model to {new_model}")
              return  # Return None instead of the old model

          # Case 3: Calculate properties
          # Determine which model to use
          if model is not None:
              water_model = model.upper()
          else:
              water_model = thermo_system.get_option('water', 'SUPCRT92').upper()

          # Normalize model names
          if water_model in ['SUPCRT92', 'SUPCRT']:
              water_model = 'SUPCRT92'
          elif water_model in ['IAPWS95', 'IAPWS']:
              water_model = 'IAPWS95'
          elif water_model == 'DEW':
              water_model = 'DEW'
          else:
              warnings.warn(f"Unknown water model '{water_model}', using SUPCRT92")
              water_model = 'SUPCRT92'

          # Convert inputs
          T = np.atleast_1d(np.asarray(T, dtype=float))

          if isinstance(P, str):
              P_input = P
          else:
              P_input = np.atleast_1d(np.asarray(P, dtype=float))
              # Make T and P same length
              if len(P_input) < len(T):
                  P_input = np.resize(P_input, len(T))
              elif len(T) < len(P_input):
                  T = np.resize(T, len(P_input))

          # Call appropriate water model
          try:
              if water_model == 'SUPCRT92':
                  result = _call_supcrt92(property, T, P_input, Psat_floor)
              elif water_model == 'IAPWS95':
                  result = _call_iapws95(property, T, P_input, Psat_floor)
              elif water_model == 'DEW':
                  result = _call_dew(property, T, P_input)
              else:
                  raise ValueError(f"Unsupported water model: {water_model}")

          except Exception as e:
              raise WaterModelError(f"Error calculating water properties with {water_model} model: {e}")

          # Apply Psat rounding to match R CHNOSZ behavior
          # Round Psat values to 4 decimal places (round up to ensure liquid phase)
          result = _apply_psat_rounding(result, property)

          return result

  Calculate thermodynamic and electrostatic properties of liquid H2O.

  This is the main water function that provides the same interface as the R CHNOSZ water() function, with support for multiple water models.

  ## Parameters

  `property` :`str, list` of `str,` or `None`
    Properties to calculate. If None, returns current water model. If water model name (SUPCRT92, IAPWS95, DEW), sets the water model. Available properties depend on the water model used.
  `T` :`float` or `array-like`
    Temperature in Kelvin
  `P` :`float, array-like,` or `"Psat"`
    Pressure in bar, or "Psat" for saturation pressure
  `Psat_floor` :`float` or `None`
    Minimum pressure floor for Psat calculations (SUPCRT92 only)
  `model` :`str`, optional
    Override the default water model for this calculation
  `messages` :`bool`, default `True`
    Whether to print informational messages

  ## Returns

  `str, float, array,` or `dict`
    Current water model name, single property value, array of values, or dictionary with calculated properties

  ## Examples

      >>> import pychnosz
      >>> pychnosz.reset()
      >>>
      >>> # Get current water model
      >>> model = pychnosz.water()
      >>> print(model)  # 'SUPCRT92'
      >>>
      >>> # Set water model
      >>> old_model = pychnosz.water('IAPWS95')
      >>>
      >>> # Calculate single property
      >>> density = pychnosz.water('rho', T=298.15, P=1.0)
      >>>
      >>> # Calculate multiple properties
      >>> props = pychnosz.water(['rho', 'epsilon'], T=298.15, P=1.0)
      >>>
      >>> # Temperature array
      >>> temps = np.array([273.15, 298.15, 373.15])
      >>> densities = pychnosz.water('rho', T=temps, P=1.0)
      >>>
      >>> # Saturation pressure
      >>> psat = pychnosz.water('Psat', T=373.15)

`def water_SUPCRT92(property: str | List[str],
T: float | numpy.ndarray = 298.15,
P: float | str | numpy.ndarray = 1.0,
**kwargs) ‑> float | numpy.ndarray | Dict[str, Any]`
  Expand source code

      def water_SUPCRT92(property: Union[str, List[str]],
                         T: Union[float, np.ndarray] = 298.15,
                         P: Union[float, np.ndarray, str] = 1.0,
                         **kwargs) -> Union[float, np.ndarray, Dict[str, Any]]:
          """
          Calculate water properties using SUPCRT92 model.

          This function provides the same interface as the original Python
          implementation but with Fortran backend support for high accuracy.

          Parameters
          ----------
          property : str or list of str
              Property or list of properties to calculate
          T : float or array
              Temperature in Kelvin
          P : float, array, or 'Psat'
              Pressure in bar, or 'Psat' for saturation pressure
          **kwargs
              Additional options (e.g., Psat_floor)

          Returns
          -------
          float, array, or dict
              Calculated properties

          Examples
          --------
          >>> # Basic usage
          >>> rho = water_SUPCRT92('rho', 298.15, 1.0)
          >>> print(f"Density: {rho:.3f} g/cm³")

          >>> # Multiple properties
          >>> props = water_SUPCRT92(['rho', 'epsilon'], 298.15, 1.0)
          >>> print(f"ρ = {props['rho']:.3f}, ε = {props['epsilon']:.1f}")

          >>> # Saturation pressure
          >>> Psat = water_SUPCRT92('Psat', 373.15, 'Psat')
          >>> print(f"Psat at 100°C: {Psat:.2f} bar")
          """
          return supcrt92_water.calculate(property, T, P, **kwargs)

  Calculate water properties using SUPCRT92 model.

  This function provides the same interface as the original Python implementation but with Fortran backend support for high accuracy.

  ## Parameters

  `property` :`str` or `list` of `str`
    Property or list of properties to calculate
  `T` :`float` or `array`
    Temperature in Kelvin
  `P` :`float, array,` or `'Psat'`
    Pressure in bar, or 'Psat' for saturation pressure
  `**kwargs`
    Additional options (e.g., Psat_floor)

  ## Returns

  `float, array,` or `dict`
    Calculated properties

  ## Examples

      >>> # Basic usage
      >>> rho = water_SUPCRT92('rho', 298.15, 1.0)
      >>> print(f"Density: {rho:.3f} g/cm³")

      >>> # Multiple properties
      >>> props = water_SUPCRT92(['rho', 'epsilon'], 298.15, 1.0)
      >>> print(f"ρ = {props['rho']:.3f}, ε = {props['epsilon']:.1f}")

      >>> # Saturation pressure
      >>> Psat = water_SUPCRT92('Psat', 373.15, 'Psat')
      >>> print(f"Psat at 100°C: {Psat:.2f} bar")

## Classes

`class SUPCRT92Water`
  Expand source code

      class SUPCRT92Water:
          """
          SUPCRT92 water model with Fortran backend.

          This class provides an interface to the original SUPCRT92 Fortran
          subroutines for calculating water properties. If the Fortran library
          is not available, it falls back to Python approximations.

          The Fortran implementation gives exact compatibility with R CHNOSZ
          and includes all 23+ thermodynamic properties.
          """

          def __init__(self):
              """
              Initialize SUPCRT92 water model.

              SUPCRT92 requires the compiled FORTRAN interface for accuracy and
              compatibility with R CHNOSZ. No pure Python fallback is provided.
              """
              if not HAS_FORTRAN:
                  raise ImportError(
                      f"SUPCRT92 water model requires compiled FORTRAN interface. "
                      f"Error: {_fortran_error}. "
                      f"Please compile the FORTRAN subroutines using: "
                      f"python compile_fortran.py. "
                      f"See setup_fortran_instructions.md for details."
                  )

              # Initialize Fortran interface (required)
              try:
                  self._fortran_interface = get_h2o92_interface()
              except Exception as e:
                  raise RuntimeError(f"Failed to initialize FORTRAN interface: {e}") from e

          def calculate(self,
                        properties: Union[str, List[str]],
                        T: Union[float, np.ndarray] = 298.15,
                        P: Union[float, np.ndarray, str] = 1.0,
                        **kwargs) -> Union[float, np.ndarray, Dict[str, Any]]:
              """
              Calculate water properties using SUPCRT92 model.

              Parameters
              ----------
              properties : str or list of str
                  Property or list of properties to calculate
              T : float or array
                  Temperature in Kelvin
              P : float, array, or 'Psat'
                  Pressure in bar, or 'Psat' for saturation pressure
              **kwargs
                  Additional options (e.g., Psat_floor)

              Returns
              -------
              float, array, or dict
                  Calculated properties
              """

              # Handle input types
              if isinstance(properties, str):
                  properties = [properties]
                  single_prop = True
              else:
                  single_prop = False

              # Convert inputs to arrays
              T = np.atleast_1d(np.asarray(T, dtype=float))

              # Always use FORTRAN backend (no fallback)
              return self._calculate_fortran(properties, T, P, single_prop, **kwargs)

          def _calculate_fortran(self, properties: List[str], T: np.ndarray,
                                P: Union[np.ndarray, str], single_prop: bool,
                                **kwargs) -> Union[float, np.ndarray, Dict[str, Any]]:
              """Calculate properties using Fortran backend."""

              # Handle pressure input
              if isinstance(P, str) and P == 'Psat':
                  P_vals = 'Psat'
              else:
                  P = np.atleast_1d(np.asarray(P, dtype=float))
                  if len(P) < len(T):
                      P = np.resize(P, len(T))
                  elif len(T) < len(P):
                      T = np.resize(T, len(P))
                  P_vals = P

              # Use batched calculation for better performance
              try:
                  results = self._fortran_interface.calculate_properties_batch(
                      T, P_vals, properties
                  )
              except Exception as e:
                  warnings.warn(f"Batch Fortran calculation failed: {e}")
                  # Fallback to individual calculations if batch fails
                  results = {}
                  for prop in properties:
                      results[prop] = np.full_like(T, np.nan)

                  for i in range(len(T)):
                      T_i = T[i]

                      # Skip invalid points
                      if np.isnan(T_i):
                          continue

                      if isinstance(P_vals, str):
                          P_i = P_vals
                      else:
                          P_i = P_vals[i]
                          if np.isnan(P_i):
                              continue

                      try:
                          # Call Fortran interface
                          props_i = self._fortran_interface.calculate_properties(
                              T_i, P_i, properties
                          )

                          # Store results
                          for prop in properties:
                              if prop in props_i:
                                  results[prop][i] = props_i[prop]

                      except Exception as e:
                          warnings.warn(f"Fortran calculation failed at T={T_i:.1f}K, P={P_i}: {e}")
                          continue

              # Handle Psat_floor for saturation pressure
              if 'Psat' in results and 'Psat_floor' in kwargs:
                  Psat_floor = kwargs['Psat_floor']
                  if Psat_floor is not None:
                      results['Psat'] = np.maximum(results['Psat'], Psat_floor)

              # Return results
              if single_prop:
                  result = results[properties[0]]
                  return result[0] if len(result) == 1 else result
              else:
                  # Convert single-element arrays to scalars if appropriate
                  if len(T) == 1:
                      for key in results:
                          if len(results[key]) == 1:
                              results[key] = results[key][0]
                  return results

          def available_properties(self) -> List[str]:
              """
              Get list of available properties.

              Returns
              -------
              List[str]
                  List of available property names
              """
              if self._use_fortran:
                  return self._fortran_interface.available_properties()
              else:
                  return self._python_backend.available_properties()

          @property
          def backend(self) -> str:
              """Get the active backend ('fortran' or 'python')."""
              return 'fortran' if self._use_fortran else 'python'

          @property
          def has_fortran(self) -> bool:
              """Check if Fortran backend is available."""
              return HAS_FORTRAN

  SUPCRT92 water model with Fortran backend.

  This class provides an interface to the original SUPCRT92 Fortran subroutines for calculating water properties. If the Fortran library is not available, it falls back to Python approximations.

  The Fortran implementation gives exact compatibility with R CHNOSZ and includes all 23+ thermodynamic properties.

  Initialize SUPCRT92 water model.

  SUPCRT92 requires the compiled FORTRAN interface for accuracy and compatibility with R CHNOSZ. No pure Python fallback is provided.

  ### Instance variables

  `prop backend : str`
    Expand source code

        @property
        def backend(self) -> str:
            """Get the active backend ('fortran' or 'python')."""
            return 'fortran' if self._use_fortran else 'python'

    Get the active backend ('fortran' or 'python').

  `prop has_fortran : bool`
    Expand source code

        @property
        def has_fortran(self) -> bool:
            """Check if Fortran backend is available."""
            return HAS_FORTRAN

    Check if Fortran backend is available.

  ### Methods

  `def available_properties(self) ‑> List[str]`
    Expand source code

        def available_properties(self) -> List[str]:
            """
            Get list of available properties.

            Returns
            -------
            List[str]
                List of available property names
            """
            if self._use_fortran:
                return self._fortran_interface.available_properties()
            else:
                return self._python_backend.available_properties()

    Get list of available properties.

    ## Returns

    `List[str]`
      List of available property names
  `def calculate(self,
  properties: str | List[str],
  T: float | numpy.ndarray = 298.15,
  P: float | str | numpy.ndarray = 1.0,
  **kwargs) ‑> float | numpy.ndarray | Dict[str, Any]`
    Expand source code

        def calculate(self,
                      properties: Union[str, List[str]],
                      T: Union[float, np.ndarray] = 298.15,
                      P: Union[float, np.ndarray, str] = 1.0,
                      **kwargs) -> Union[float, np.ndarray, Dict[str, Any]]:
            """
            Calculate water properties using SUPCRT92 model.

            Parameters
            ----------
            properties : str or list of str
                Property or list of properties to calculate
            T : float or array
                Temperature in Kelvin
            P : float, array, or 'Psat'
                Pressure in bar, or 'Psat' for saturation pressure
            **kwargs
                Additional options (e.g., Psat_floor)

            Returns
            -------
            float, array, or dict
                Calculated properties
            """

            # Handle input types
            if isinstance(properties, str):
                properties = [properties]
                single_prop = True
            else:
                single_prop = False

            # Convert inputs to arrays
            T = np.atleast_1d(np.asarray(T, dtype=float))

            # Always use FORTRAN backend (no fallback)
            return self._calculate_fortran(properties, T, P, single_prop, **kwargs)

    Calculate water properties using SUPCRT92 model.

    ## Parameters

    `properties` :`str` or `list` of `str`
      Property or list of properties to calculate
    `T` :`float` or `array`
      Temperature in Kelvin
    `P` :`float, array,` or `'Psat'`
      Pressure in bar, or 'Psat' for saturation pressure
    `**kwargs`
      Additional options (e.g., Psat_floor)

    ## Returns

    `float, array,` or `dict`
      Calculated properties
`class WaterModelError (*args, **kwargs)`
  Expand source code

      class WaterModelError(Exception):
          """Exception raised for water model calculation errors."""
          pass

  Exception raised for water model calculation errors.

  ### Ancestors

  - builtins.Exception
  - builtins.BaseException