pychnosz.core.unicurve API documentation
Description: CHNOSZ unicurve() function - Calculate univariant curves for geothermometry/geobarometry …

# Module `pychnosz.core.unicurve`

CHNOSZ unicurve() function - Calculate univariant curves for geothermometry/geobarometry.

This module implements functions to solve for temperatures or pressures of equilibration for a given logK value, producing univariant curves useful for aqueous geothermometry and geobarometry applications.

Author: Based on pyCHNOSZ univariant.r by Grayson Boyer Optimized: Uses scipy.optimize.brentq() for efficient root-finding

## Functions

`def unicurve(logK: int | float | List[int | float],
species: str | List[str] | int | List[int],
coeff: int | float | List[int | float],
state: str | List[str],
pressures: float | List[float] = 1,
temperatures: float | List[float] = 25,
IS: float = 0,
minT: float = 0.1,
maxT: float = 100,
minP: float = 1,
maxP: float = 500,
tol: float | None = None,
solve: str = 'T',
messages: bool = True,
show: bool = True,
plot_it: bool = True,
width: int = 600,
height: int = 400,
res: int = 200) ‑> UnivariantResult | List[UnivariantResult]`
  Expand source code

      def unicurve(logK: Union[float, int, List[Union[float, int]]],
                   species: Union[str, List[str], int, List[int]],
                   coeff: Union[int, float, List[Union[int, float]]],
                   state: Union[str, List[str]],
                   pressures: Union[float, List[float]] = 1,
                   temperatures: Union[float, List[float]] = 25,
                   IS: float = 0,
                   minT: float = 0.1,
                   maxT: float = 100,
                   minP: float = 1,
                   maxP: float = 500,
                   tol: Optional[float] = None,
                   solve: str = "T",
                   messages: bool = True,
                   show: bool = True,
                   plot_it: bool = True,
                   width: int = 600,
                   height: int = 400,
                   res: int = 200) -> Union[UnivariantResult, List[UnivariantResult]]:
          """
          Solve for temperatures or pressures of equilibration for a given logK value(s).

          This function calculates univariant curves useful for aqueous geothermometry
          and geobarometry. Given a measured equilibrium constant (logK) for a reaction,
          it solves for the temperatures (at specified pressures) or pressures (at
          specified temperatures) where the reaction would produce that logK value.

          The solver uses scipy.optimize.brentq (Brent's method), which combines
          bisection, secant, and inverse quadratic interpolation for efficient and
          robust convergence. This is ~100x faster than the original binary search
          algorithm while maintaining identical numerical accuracy.

          Parameters
          ----------
          logK : float, int, or list of float or int
              Logarithm (base 10) of the equilibrium constant(s). When a list is
              provided, each logK value is processed separately and a list of results
              is returned.
          species : str, int, or list of str or int
              Name, formula, or database index of species involved in the reaction
          coeff : int, float, or list
              Reaction stoichiometric coefficients (negative for reactants, positive for products)
          state : str or list of str
              Physical state(s) of species: "aq", "cr", "gas", "liq"
          pressures : float or list of float, default 1
              Pressure(s) in bars (used when solving for temperature)
          temperatures : float or list of float, default 25
              Temperature(s) in °C (used when solving for pressure)
          IS : float, default 0
              Ionic strength for activity corrections (mol/kg)
          minT : float, default 0.1
              Minimum temperature (°C) to search (ignored when solving for pressure)
          maxT : float, default 100
              Maximum temperature (°C) to search (ignored when solving for pressure)
          minP : float, default 1
              Minimum pressure (bar) to search (ignored when solving for temperature)
          maxP : float, default 500
              Maximum pressure (bar) to search (ignored when solving for temperature)
          tol : float, optional
              Tolerance for convergence. Default: 1/(10^(n+2)) where n is number of
              decimal places in logK, with maximum default of 1e-5
          solve : str, default "T"
              What to solve for: "T" for temperature or "P" for pressure
          messages : bool, default True
              Print informational messages
          show : bool, default True
              Display result table
          plot_it : bool, default True
              Display interactive plotly plot showing logK vs T (or P) with target logK
              as horizontal line and intersection points marked
          width : int, default 600
              Plot width in pixels (used if plot_it=True)
          height : int, default 400
              Plot height in pixels (used if plot_it=True)
          res : int, default 200
              Number of points to calculate for plotting the logK curve
              (used if plot_it=True)

          Returns
          -------
          UnivariantResult or list of UnivariantResult
              When logK is a single value: returns a UnivariantResult object.
              When logK is a list: returns a list of UnivariantResult objects.
              Each result contains:
              - reaction: DataFrame with reaction stoichiometry
              - out: DataFrame with solved T or P values and thermodynamic properties
              - warnings: List of warning messages

          Examples
          --------
          >>> from pychnosz import unicurve, reset
          >>> reset()
          >>>
          >>> # Solve for temperature: quartz dissolution
          >>> # SiO2(quartz) = SiO2(aq)
          >>> result = unicurve(logK=-2.71, species=["quartz", "SiO2"],
          ...                   state=["cr", "aq"], coeff=[-1, 1],
          ...                   pressures=200, minT=1, maxT=350)
          >>> print(result.out[["P", "T", "logK"]])
          >>>
          >>> # Solve for pressure: water dissociation
          >>> result = unicurve(logK=-14, species=["H2O", "H+", "OH-"],
          ...                   state=["liq", "aq", "aq"], coeff=[-1, 1, 1],
          ...                   temperatures=[25, 50, 75], solve="P",
          ...                   minP=1, maxP=1000)
          >>> print(result.out[["T", "P", "logK"]])

          Notes
          -----
          This function uses scipy.optimize.brentq for root-finding, which provides:
          - Guaranteed convergence if root is bracketed
          - Typical convergence in 5-15 function evaluations
          - ~100x speedup compared to custom binary search (1600 → 15 evaluations)
          - Identical numerical results to original implementation

          The algorithm also implements "warm start" optimization: when solving for
          multiple pressures/temperatures, previous solutions are used to intelligently
          bracket subsequent searches, further improving performance.

          References
          ----------
          Based on univariant.r from pyCHNOSZ by Grayson Boyer
          Optimized using Brent, R. P. (1973). Algorithms for Minimization without Derivatives.
          """
          # Track whether input was a single value or list
          single_logK_input = not isinstance(logK, list)

          # Ensure logK is a list for processing
          if single_logK_input:
              logK_list = [logK]
          else:
              logK_list = logK

          # Ensure species, state, and coeff are lists
          if not isinstance(species, list):
              species = [species]
          if not isinstance(state, list):
              state = [state]
          if not isinstance(coeff, list):
              coeff = [coeff]

          # Process each logK value
          results = []

          for this_logK in logK_list:
              result = UnivariantResult()

              # Set default tolerance based on logK precision
              if tol is None:
                  # Count decimal places in logK
                  logK_str = str(float(this_logK))
                  if '.' in logK_str:
                      n_decimals = len(logK_str.split('.')[1].rstrip('0'))
                  else:
                      n_decimals = 0
                  this_tol = 10 ** (-(n_decimals + 2))
                  if this_tol > 1e-5:
                      this_tol = 1e-5
              else:
                  this_tol = tol

              # Get reaction information from first subcrt call
              try:
                  initial_calc = subcrt(species, coeff=coeff, state=state, T=25, P=1,
                                       exceed_Ttr=True, messages=False, show=False)
                  result.reaction = initial_calc.reaction
              except Exception as e:
                  if messages:
                      warnings.warn(f"Error getting reaction information: {str(e)}")
                  result.reaction = None

              if solve.upper() == "T":
                  # Solve for temperature at given pressure(s)
                  if not isinstance(pressures, list):
                      pressures = [pressures]

                  results_list = []
                  prev_T = None  # For warm start optimization

                  for i, pressure in enumerate(pressures):
                      if messages:
                          print(f"Solving for T at P = {pressure} bar (logK = {this_logK})...")

                      # Warm start: use previous solution to narrow search range if available
                      current_minT = minT
                      current_maxT = maxT
                      if prev_T is not None and minT < prev_T < maxT:
                          # Center search around previous solution with a safety margin
                          # logK typically changes by ~0.006 per °C, so ±50°C should be safe
                          margin = 50
                          current_minT = max(minT, prev_T - margin)
                          current_maxT = min(maxT, prev_T + margin)
                          if messages:
                              print(f"  Using warm start: searching {current_minT:.1f} to {current_maxT:.1f}°C")

                      result_dict = _solve_T_for_pressure(this_logK, species, state, coeff, pressure,
                                                 IS, current_minT, current_maxT, this_tol,
                                                 initial_guess=prev_T, messages=messages)

                      # If warm start failed, try full range
                      if result_dict['T'] is None and prev_T is not None:
                          if messages:
                              print(f"  Warm start failed, searching full range...")
                          result_dict = _solve_T_for_pressure(this_logK, species, state, coeff, pressure,
                                                     IS, minT, maxT, this_tol, messages=messages)

                      results_list.append(result_dict)

                      # Update for next warm start
                      if result_dict['T'] is not None:
                          prev_T = result_dict['T']

                  result.out = pd.DataFrame(results_list)

              elif solve.upper() == "P":
                  # Solve for pressure at given temperature(s)
                  if not isinstance(temperatures, list):
                      temperatures = [temperatures]

                  results_list = []
                  prev_P = None  # For warm start optimization

                  for i, temperature in enumerate(temperatures):
                      if messages:
                          print(f"Solving for P at T = {temperature} °C (logK = {this_logK})...")

                      # Warm start: use previous solution to narrow search range if available
                      current_minP = minP
                      current_maxP = maxP
                      if prev_P is not None and minP < prev_P < maxP:
                          # Center search around previous solution with a safety margin
                          # Pressure effects vary, use a generous ±500 bar margin
                          margin = 500
                          current_minP = max(minP, prev_P - margin)
                          current_maxP = min(maxP, prev_P + margin)
                          if messages:
                              print(f"  Using warm start: searching {current_minP:.0f} to {current_maxP:.0f} bar")

                      result_dict = _solve_P_for_temperature(this_logK, species, state, coeff, temperature,
                                                    IS, current_minP, current_maxP, this_tol,
                                                    initial_guess=prev_P, messages=messages)

                      # If warm start failed, try full range
                      if result_dict['P'] is None and prev_P is not None:
                          if messages:
                              print(f"  Warm start failed, searching full range...")
                          result_dict = _solve_P_for_temperature(this_logK, species, state, coeff, temperature,
                                                        IS, minP, maxP, this_tol, messages=messages)

                      results_list.append(result_dict)

                      # Update for next warm start
                      if result_dict['P'] is not None:
                          prev_P = result_dict['P']

                  result.out = pd.DataFrame(results_list)

              else:
                  raise ValueError(f"solve must be 'T' or 'P', got '{solve}'")

              # Create interactive plot if requested
              if plot_it:
                  if not PLOTLY_AVAILABLE:
                      warnings.warn("plotly is not installed. Set plot_it=False to suppress this warning, "
                                   "or install plotly with: pip install plotly")
                  else:
                      result.fig = _create_unicurve_plot(this_logK, species, state, coeff, result, solve,
                                                         minT, maxT, minP, maxP, IS, width, height, res, messages)

              # Display result if requested
              if show and result.out is not None:
                  try:
                      from IPython.display import display
                      if result.reaction is not None:
                          print("\nReaction:")
                          display(result.reaction)
                      print(f"\nResults (logK = {this_logK}):")
                      display(result.out)
                  except ImportError:
                      # Not in Jupyter, just print
                      if result.reaction is not None:
                          print("\nReaction:")
                          print(result.reaction)
                      print(f"\nResults (logK = {this_logK}):")
                      print(result.out)

              # Add this result to the list
              results.append(result)

          # Return single result or list based on input
          if single_logK_input:
              return results[0]
          else:
              return results

  Solve for temperatures or pressures of equilibration for a given logK value(s).

  This function calculates univariant curves useful for aqueous geothermometry and geobarometry. Given a measured equilibrium constant (logK) for a reaction, it solves for the temperatures (at specified pressures) or pressures (at specified temperatures) where the reaction would produce that logK value.

  The solver uses scipy.optimize.brentq (Brent's method), which combines bisection, secant, and inverse quadratic interpolation for efficient and robust convergence. This is ~100x faster than the original binary search algorithm while maintaining identical numerical accuracy.

  ## Parameters

  `logK` :`float, int,` or `list` of `float` or `int`
    Logarithm (base 10) of the equilibrium constant(s). When a list is provided, each logK value is processed separately and a list of results is returned.
  `species` :`str, int,` or `list` of `str` or `int`
    Name, formula, or database index of species involved in the reaction
  `coeff` :`int, float,` or `list`
    Reaction stoichiometric coefficients (negative for reactants, positive for products)
  `state` :`str` or `list` of `str`
    Physical state(s) of species: "aq", "cr", "gas", "liq"
  `pressures` :`float` or `list` of `float`, default `1`
    Pressure(s) in bars (used when solving for temperature)
  `temperatures` :`float` or `list` of `float`, default `25`
    Temperature(s) in °C (used when solving for pressure)
  `IS` :`float`, default `0`
    Ionic strength for activity corrections (mol/kg)
  `minT` :`float`, default `0.1`
    Minimum temperature (°C) to search (ignored when solving for pressure)
  `maxT` :`float`, default `100`
    Maximum temperature (°C) to search (ignored when solving for pressure)
  `minP` :`float`, default `1`
    Minimum pressure (bar) to search (ignored when solving for temperature)
  `maxP` :`float`, default `500`
    Maximum pressure (bar) to search (ignored when solving for temperature)
  `tol` :`float`, optional
    Tolerance for convergence. Default: 1/(10^(n+2)) where n is number of decimal places in logK, with maximum default of 1e-5
  `solve` :`str`, default `"T"`
    What to solve for: "T" for temperature or "P" for pressure
  `messages` :`bool`, default `True`
    Print informational messages
  `show` :`bool`, default `True`
    Display result table
  `plot_it` :`bool`, default `True`
    Display interactive plotly plot showing logK vs T (or P) with target logK as horizontal line and intersection points marked
  `width` :`int`, default `600`
    Plot width in pixels (used if plot_it=True)
  `height` :`int`, default `400`
    Plot height in pixels (used if plot_it=True)
  `res` :`int`, default `200`
    Number of points to calculate for plotting the logK curve (used if plot_it=True)

  ## Returns

  `UnivariantResult` or `list` of `UnivariantResult`
    When logK is a single value: returns a UnivariantResult object. When logK is a list: returns a list of UnivariantResult objects. Each result contains: - reaction: DataFrame with reaction stoichiometry - out: DataFrame with solved T or P values and thermodynamic properties - warnings: List of warning messages

  ## Examples

      >>> from pychnosz import unicurve, reset
      >>> reset()
      >>>
      >>> # Solve for temperature: quartz dissolution
      >>> # SiO2(quartz) = SiO2(aq)
      >>> result = unicurve(logK=-2.71, species=["quartz", "SiO2"],
      ...                   state=["cr", "aq"], coeff=[-1, 1],
      ...                   pressures=200, minT=1, maxT=350)
      >>> print(result.out[["P", "T", "logK"]])
      >>>
      >>> # Solve for pressure: water dissociation
      >>> result = unicurve(logK=-14, species=["H2O", "H+", "OH-"],
      ...                   state=["liq", "aq", "aq"], coeff=[-1, 1, 1],
      ...                   temperatures=[25, 50, 75], solve="P",
      ...                   minP=1, maxP=1000)
      >>> print(result.out[["T", "P", "logK"]])

  ## Notes

  This function uses scipy.optimize.brentq for root-finding, which provides: - Guaranteed convergence if root is bracketed - Typical convergence in 5-15 function evaluations - ~100x speedup compared to custom binary search (1600 → 15 evaluations) - Identical numerical results to original implementation

  The algorithm also implements "warm start" optimization: when solving for multiple pressures/temperatures, previous solutions are used to intelligently bracket subsequent searches, further improving performance.

  ## References

  Based on univariant.r from pyCHNOSZ by Grayson Boyer Optimized using Brent, R. P. (1973). Algorithms for Minimization without Derivatives.

`def univariant_TP(logK: int | float | List[int | float],
species: str | List[str] | int | List[int],
coeff: int | float | List[int | float],
state: str | List[str],
Trange: List[float],
Prange: List[float],
IS: float = 0,
xlim: List[float] | None = None,
ylim: List[float] | None = None,
line_type: str = 'markers+lines',
tol: float | None = None,
title: str | None = None,
res: int = 10,
width: int = 500,
height: int = 400,
save_as: str | None = None,
save_format: str = 'png',
save_scale: float = 1,
show: bool = False,
messages: bool = False,
parallel: bool = True,
plot_it: bool = True) ‑> List[UnivariantResult]`
  Expand source code

      def univariant_TP(logK: Union[float, int, List[Union[float, int]]],
                        species: Union[str, List[str], int, List[int]],
                        coeff: Union[int, float, List[Union[int, float]]],
                        state: Union[str, List[str]],
                        Trange: List[float],
                        Prange: List[float],
                        IS: float = 0,
                        xlim: Optional[List[float]] = None,
                        ylim: Optional[List[float]] = None,
                        line_type: str = "markers+lines",
                        tol: Optional[float] = None,
                        title: Optional[str] = None,
                        res: int = 10,
                        width: int = 500,
                        height: int = 400,
                        save_as: Optional[str] = None,
                        save_format: str = "png",
                        save_scale: float = 1,
                        show: bool = False,
                        messages: bool = False,
                        parallel: bool = True,
                        plot_it: bool = True) -> List[UnivariantResult]:
          """
          Solve for temperatures and pressures of equilibration for given logK value(s)
          and produce an interactive T-P diagram.

          This function calculates univariant curves in temperature-pressure (T-P) space
          for one or more logK values. For each pressure in a range, it solves for the
          temperature where the reaction achieves the target logK. The resulting curves
          show phase boundaries or equilibrium conditions in T-P space.

          Parameters
          ----------
          logK : float, int, or list
              Logarithm (base 10) of equilibrium constant(s). Multiple values produce
              multiple curves on the same plot.
          species : str, int, or list of str or int
              Name, formula, or database index of species involved in the reaction
          coeff : int, float, or list
              Reaction stoichiometric coefficients (negative for reactants, positive for products)
          state : str or list of str
              Physical state(s) of species: "aq", "cr", "gas", "liq"
          Trange : list of two floats
              [min, max] temperature range (°C) to search for solutions
          Prange : list of two floats
              [min, max] pressure range (bar) to calculate along
          IS : float, default 0
              Ionic strength for activity corrections (mol/kg)
          xlim : list of two floats, optional
              [min, max] range for x-axis (temperature) in plot
          ylim : list of two floats, optional
              [min, max] range for y-axis (pressure) in plot
          line_type : str, default "markers+lines"
              Plotly line type: "markers+lines", "markers", or "lines"
          tol : float, optional
              Convergence tolerance. Default: 1/(10^(n+2)) where n is decimal places in logK
          title : str, optional
              Plot title. Default: auto-generated from reaction
          res : int, default 10
              Number of pressure points to calculate along the curve
          width : int, default 500
              Plot width in pixels
          height : int, default 400
              Plot height in pixels
          save_as : str, optional
              Filename to save plot (without extension)
          save_format : str, default "png"
              Save format: "png", "jpg", "jpeg", "webp", "svg", "pdf", "html"
          save_scale : float, default 1
              Scale factor for saved plot
          show : bool, default False
              Display subcrt result tables
          messages : bool, default False
              Print informational messages
          parallel : bool, default True
              Use parallel processing across multiple logK values for faster computation.
              Utilizes multiple CPU cores when processing multiple logK curves.
          plot_it : bool, default True
              Display the plot

          Returns
          -------
          list of UnivariantResult
              List of UnivariantResult objects, one for each logK value.
              Each contains reaction information and T-P curve data.

          Examples
          --------
          >>> from pychnosz import univariant_TP, reset
          >>> reset()
          >>>
          >>> # Calcite-aragonite phase boundary
          >>> result = univariant_TP(
          ...     logK=0,
          ...     species=["calcite", "aragonite"],
          ...     state=["cr", "cr"],
          ...     coeff=[-1, 1],
          ...     Trange=[0, 700],
          ...     Prange=[2000, 16000]
          ... )
          >>>
          >>> # Multiple curves for K-feldspar stability
          >>> result = univariant_TP(
          ...     logK=[-8, -6, -4, -2],
          ...     species=["K-feldspar", "kaolinite", "H2O", "SiO2", "muscovite"],
          ...     state=["cr", "cr", "liq", "aq", "cr"],
          ...     coeff=[-1, -1, 1, 2, 1],
          ...     Trange=[0, 350],
          ...     Prange=[1, 5000],
          ...     res=20
          ... )

          Notes
          -----
          This function creates T-P diagrams by:
          1. Generating a range of pressures from Prange[0] to Prange[1]
          2. For each pressure, solving for T where logK matches the target
          3. Plotting the resulting T-P points as a curve

          For multiple logK values, each curve represents a different equilibrium
          condition. This is useful for:
          - Phase diagrams (e.g., mineral stability fields)
          - Isopleths (lines of constant logK)
          - Reaction boundaries

          Requires plotly for interactive plotting. If plotly is not installed,
          set plot_it=False to just return the data without plotting.

          References
          ----------
          Based on univariant_TP from pyCHNOSZ by Grayson Boyer
          """

          # Check if plotly is available
          if plot_it and not PLOTLY_AVAILABLE:
              warnings.warn("plotly is not installed. Set plot_it=False to suppress this warning, "
                           "or install plotly with: pip install plotly")
              plot_it = False

          # Ensure logK is a list
          if not isinstance(logK, list):
              logK = [logK]

          # Create plotly figure
          if plot_it:
              fig = go.Figure()

          output = []

          # Generate pressure array
          pressures = np.linspace(Prange[0], Prange[1], res)

          # Process each logK value (in parallel if enabled)
          if parallel and len(logK) > 1:
              # Parallel processing
              max_workers = min(len(logK), multiprocessing.cpu_count())

              # Prepare arguments for each logK value
              args_list = [
                  (this_logK, species, state, coeff, pressures, Trange, IS, tol, show, messages)
                  for this_logK in logK
              ]

              # Process in parallel
              with ProcessPoolExecutor(max_workers=max_workers) as executor:
                  # Submit all tasks
                  future_to_logK = {
                      executor.submit(_process_single_logK, args): args[0]
                      for args in args_list
                  }

                  # Collect results as they complete (maintains order via logK list)
                  results_dict = {}
                  for future in as_completed(future_to_logK):
                      this_logK = future_to_logK[future]
                      try:
                          out = future.result()
                          results_dict[this_logK] = out
                      except Exception as e:
                          if messages:
                              print(f"Error processing logK={this_logK}: {str(e)}")
                          # Create empty result
                          results_dict[this_logK] = None

                  # Reorder results to match input logK order
                  for this_logK in logK:
                      out = results_dict.get(this_logK)
                      if out is not None:
                          output.append(out)

                          # Add to plot if we have valid data
                          if plot_it and not out.out['T'].isnull().all():
                              fig.add_trace(go.Scatter(
                                  x=out.out['T'],
                                  y=out.out['P'],
                                  mode=line_type,
                                  name=f"logK={this_logK}",
                                  text=[f"logK={this_logK}" for _ in range(len(out.out['T']))],
                                  hovertemplate='%{text}<br>T, °C=%{x:.2f}<br>P, bar=%{y:.2f}<extra></extra>',
                              ))
                          elif out.out['T'].isnull().all():
                              if messages:
                                  print(f"Could not find any T or P values in this range that correspond to a logK value of {this_logK}")

          else:
              # Sequential processing (original code)
              for this_logK in logK:
                  # Set tolerance if not specified
                  if tol is None:
                      logK_str = str(float(this_logK))
                      if '.' in logK_str:
                          n_decimals = len(logK_str.split('.')[1].rstrip('0'))
                      else:
                          n_decimals = 0
                      this_tol = 10 ** (-(n_decimals + 2))
                      if this_tol > 1e-5:
                          this_tol = 1e-5
                  else:
                      this_tol = tol

                  # Solve for T at each pressure
                  out = unicurve(
                      solve="T",
                      logK=this_logK,
                      species=species,
                      state=state,
                      coeff=coeff,
                      pressures=list(pressures),
                      minT=Trange[0],
                      maxT=Trange[1],
                      IS=IS,
                      tol=this_tol,
                      show=show,
                      messages=messages,
                      plot_it=False  # Don't plot individual curves - univariant_TP makes its own plot
                  )

                  # Add to plot if we have valid data
                  if plot_it and not out.out['T'].isnull().all():
                      fig.add_trace(go.Scatter(
                          x=out.out['T'],
                          y=out.out['P'],
                          mode=line_type,
                          name=f"logK={this_logK}",
                          text=[f"logK={this_logK}" for _ in range(len(out.out['T']))],
                          hovertemplate='%{text}<br>T, °C=%{x:.2f}<br>P, bar=%{y:.2f}<extra></extra>',
                      ))
                  elif out.out['T'].isnull().all():
                      if messages:
                          print(f"Could not find any T or P values in this range that correspond to a logK value of {this_logK}")

                  output.append(out)

          # Generate plot title if not specified
          if plot_it:
              if title is None and len(output) > 0 and output[0].reaction is not None:
                  react_grid = output[0].reaction

                  # Build reaction string
                  reactants = []
                  products = []
                  for i, row in react_grid.iterrows():
                      coeff_val = row['coeff']
                      name = row['name'] if row['name'] != 'water' else 'H2O'

                      if coeff_val < 0:
                          coeff_str = str(int(-coeff_val)) if -coeff_val != 1 else ""
                          reactants.append(f"{coeff_str} {name}".strip())
                      elif coeff_val > 0:
                          coeff_str = str(int(coeff_val)) if coeff_val != 1 else ""
                          products.append(f"{coeff_str} {name}".strip())

                  title = " + ".join(reactants) + " = " + " + ".join(products)

              # Update layout
              fig.update_layout(
                  template="simple_white",
                  title=str(title) if title else "",
                  xaxis_title="T, °C",
                  yaxis_title="P, bar",
                  width=width,
                  height=height,
                  hoverlabel=dict(bgcolor="white"),
              )

              # Set axis limits if specified
              if xlim is not None:
                  fig.update_xaxes(range=xlim)
              if ylim is not None:
                  fig.update_yaxes(range=ylim)

              # Configure plot controls
              config = {
                  'displaylogo': False,
                  'modeBarButtonsToRemove': ['resetScale2d', 'toggleSpikelines'],
                  'toImageButtonOptions': {
                      'format': save_format,
                      'filename': save_as if save_as else 'univariant_TP',
                      'height': height,
                      'width': width,
                      'scale': save_scale,
                  },
              }

              # Save plot if requested
              if save_as is not None:
                  full_filename = f"{save_as}.{save_format}"
                  if save_format == 'html':
                      fig.write_html(full_filename)
                  else:
                      fig.write_image(full_filename, format=save_format,
                                    width=width, height=height, scale=save_scale)
                  if messages:
                      print(f"Plot saved to {full_filename}")

              # Display plot
              fig.show(config=config)

              # Store figure in all result objects
              for out in output:
                  out.fig = fig

          return output

  Solve for temperatures and pressures of equilibration for given logK value(s) and produce an interactive T-P diagram.

  This function calculates univariant curves in temperature-pressure (T-P) space for one or more logK values. For each pressure in a range, it solves for the temperature where the reaction achieves the target logK. The resulting curves show phase boundaries or equilibrium conditions in T-P space.

  ## Parameters

  `logK` :`float, int,` or `list`
    Logarithm (base 10) of equilibrium constant(s). Multiple values produce multiple curves on the same plot.
  `species` :`str, int,` or `list` of `str` or `int`
    Name, formula, or database index of species involved in the reaction
  `coeff` :`int, float,` or `list`
    Reaction stoichiometric coefficients (negative for reactants, positive for products)
  `state` :`str` or `list` of `str`
    Physical state(s) of species: "aq", "cr", "gas", "liq"
  `Trange` :`list` of `two floats`
    [min, max] temperature range (°C) to search for solutions
  `Prange` :`list` of `two floats`
    [min, max] pressure range (bar) to calculate along
  `IS` :`float`, default `0`
    Ionic strength for activity corrections (mol/kg)
  `xlim` :`list` of `two floats`, optional
    [min, max] range for x-axis (temperature) in plot
  `ylim` :`list` of `two floats`, optional
    [min, max] range for y-axis (pressure) in plot
  `line_type` :`str`, default `"markers+lines"`
    Plotly line type: "markers+lines", "markers", or "lines"
  `tol` :`float`, optional
    Convergence tolerance. Default: 1/(10^(n+2)) where n is decimal places in logK
  `title` :`str`, optional
    Plot title. Default: auto-generated from reaction
  `res` :`int`, default `10`
    Number of pressure points to calculate along the curve
  `width` :`int`, default `500`
    Plot width in pixels
  `height` :`int`, default `400`
    Plot height in pixels
  `save_as` :`str`, optional
    Filename to save plot (without extension)
  `save_format` :`str`, default `"png"`
    Save format: "png", "jpg", "jpeg", "webp", "svg", "pdf", "html"
  `save_scale` :`float`, default `1`
    Scale factor for saved plot
  `show` :`bool`, default `False`
    Display subcrt result tables
  `messages` :`bool`, default `False`
    Print informational messages
  `parallel` :`bool`, default `True`
    Use parallel processing across multiple logK values for faster computation. Utilizes multiple CPU cores when processing multiple logK curves.
  `plot_it` :`bool`, default `True`
    Display the plot

  ## Returns

  `list` of `UnivariantResult`
    List of UnivariantResult objects, one for each logK value. Each contains reaction information and T-P curve data.

  ## Examples

      >>> from pychnosz import univariant_TP, reset
      >>> reset()
      >>>
      >>> # Calcite-aragonite phase boundary
      >>> result = univariant_TP(
      ...     logK=0,
      ...     species=["calcite", "aragonite"],
      ...     state=["cr", "cr"],
      ...     coeff=[-1, 1],
      ...     Trange=[0, 700],
      ...     Prange=[2000, 16000]
      ... )
      >>>
      >>> # Multiple curves for K-feldspar stability
      >>> result = univariant_TP(
      ...     logK=[-8, -6, -4, -2],
      ...     species=["K-feldspar", "kaolinite", "H2O", "SiO2", "muscovite"],
      ...     state=["cr", "cr", "liq", "aq", "cr"],
      ...     coeff=[-1, -1, 1, 2, 1],
      ...     Trange=[0, 350],
      ...     Prange=[1, 5000],
      ...     res=20
      ... )

  ## Notes

  This function creates T-P diagrams by: 1. Generating a range of pressures from Prange[0] to Prange[1] 2. For each pressure, solving for T where logK matches the target 3. Plotting the resulting T-P points as a curve

  For multiple logK values, each curve represents a different equilibrium condition. This is useful for: - Phase diagrams (e.g., mineral stability fields) - Isopleths (lines of constant logK) - Reaction boundaries

  Requires plotly for interactive plotting. If plotly is not installed, set plot_it=False to just return the data without plotting.

  ## References

  Based on univariant_TP from pyCHNOSZ by Grayson Boyer

## Classes

`class UnivariantResult`
  Expand source code

      class UnivariantResult:
          """Result structure for univariant curve calculations."""

          def __init__(self):
              self.reaction = None    # Reaction summary DataFrame
              self.out = None         # Results DataFrame with T/P and thermodynamic properties
              self.warnings = []      # Warning messages
              self.fig = None         # Plotly figure object (if plot_it=True)

          def __repr__(self):
              if self.out is not None:
                  return f"UnivariantResult with {len(self.out)} points"
              return "UnivariantResult (no calculations performed)"

          def __getitem__(self, key):
              """Allow dictionary-style access to attributes."""
              return getattr(self, key)

  Result structure for univariant curve calculations.