Skip to content

Flash and equilibrium

Initial estimates

torch_flash.initialization

Phase-equilibrium initial estimates.

wilson_k_values

wilson_k_values(
    components: ComponentSet,
    temperature: Tensor,
    pressure: Tensor,
) -> Tensor

Evaluate Wilson initial vapor-to-liquid equilibrium ratios.

Parameters:

Name Type Description Default
components ComponentSet

Ordered critical temperatures, critical pressures, and acentric factors.

required
temperature Tensor

Temperature in K with arbitrary leading batch dimensions.

required
pressure Tensor

Positive pressure in Pa, broadcast-compatible with temperature.

required

Returns:

Type Description
Tensor

Positive dimensionless K-value estimates with shape batch_shape + (ncomponents,).

Notes

These are initialization estimates, not converged equilibrium ratios and not a phase-stability criterion.

Source code in src/torch_flash/initialization.py
def wilson_k_values(
    components: ComponentSet,
    temperature: Tensor,
    pressure: Tensor,
) -> Tensor:
    """Evaluate Wilson initial vapor-to-liquid equilibrium ratios.

    Parameters
    ----------
    components
        Ordered critical temperatures, critical pressures, and acentric
        factors.
    temperature
        Temperature in K with arbitrary leading batch dimensions.
    pressure
        Positive pressure in Pa, broadcast-compatible with ``temperature``.

    Returns
    -------
    Tensor
        Positive dimensionless K-value estimates with shape
        ``batch_shape + (ncomponents,)``.

    Notes
    -----
    These are initialization estimates, not converged equilibrium ratios and
    not a phase-stability criterion.
    """
    return (
        components.critical_pressure
        / pressure[..., None]
        * torch.exp(
            5.373
            * (1.0 + components.acentric_factor)
            * (1.0 - components.critical_temperature / temperature[..., None])
        )
    )

Flash and stability

torch_flash.flash

Stability-tested phase-equilibrium flash calculations.

BinaryThreePhaseInvariant dataclass

BinaryThreePhaseInvariant(
    temperature: Tensor,
    pressure: Tensor,
    phase_compositions: Tensor,
    residual_norm: Tensor,
    iterations: int,
    converged: bool,
)

Solved binary three-phase invariant used by a grid flash.

Attributes:

Name Type Description
temperature Tensor

Scalar invariant temperature in K.

pressure Tensor

Scalar invariant pressure in Pa.

phase_compositions Tensor

Tensor with shape (3, 2) containing normalized binary phase mole fractions. Rows are sorted by increasing first-component mole fraction; root labels are not encoded in this ordering.

residual_norm Tensor

Maximum absolute dimensionless log-fugacity mismatch among the three phases.

iterations int

Number of damped autodiff-Newton iterations attempted.

converged bool

Whether residual_norm satisfies the requested solver tolerance.

Notes

A binary three-phase invariant fixes phase compositions and pressure at a specified temperature, but it does not uniquely fix all three phase fractions. :func:flash_grid applies a positive centered lever-rule representative only for feeds strictly inside the outer phase-composition interval.

BinaryThreePhaseInvariantBatch dataclass

BinaryThreePhaseInvariantBatch(
    temperature: Tensor,
    pressure: Tensor,
    phase_compositions: Tensor,
    residual_norm: Tensor,
    iterations: Tensor,
    converged: Tensor,
)

Independent binary three-phase invariants solved in one tensor batch.

Attributes:

Name Type Description
temperature, pressure

State temperatures in K and solved pressures in Pa with shape (batch,).

phase_compositions Tensor

Sorted binary phase compositions with shape (batch, 3, 2).

residual_norm Tensor

Per-state maximum dimensionless log-fugacity mismatch.

iterations Tensor

Per-state damped-Newton iteration counts.

converged Tensor

Per-state residual-convergence flags.

GridEquilibrium dataclass

GridEquilibrium(
    temperatures: Tensor,
    pressures: Tensor,
    feeds: Tensor,
    grid_shape: tuple[int, ...],
    phase_fractions: Tensor,
    phase_compositions: Tensor,
    phase_counts: Tensor,
    gibbs_reduction: Tensor,
    fugacity_residual: Tensor,
    material_balance_residual: Tensor,
    converged: Tensor,
    elapsed_seconds: float,
    batched_search_seconds: float,
    refinement_seconds: float,
    difficult_state_count: int,
    topology_audit_count: int,
    initial_fallback_replacements: int,
    topology_audit_replacements: int,
)

Padded phase-equilibrium results over independent TP states.

State fields are flattened in row-major order. grid_shape records the original temperature/pressure batch shape so callers can reshape any per-state tensor without reconstructing axis lengths.

Attributes:

Name Type Description
temperatures Tensor

Flattened temperatures in K with shape (nstates,).

pressures Tensor

Flattened pressures in Pa with shape (nstates,).

feeds Tensor

Normalized overall mole fractions with shape (nstates, ncomponents).

grid_shape tuple[int, ...]

Original non-scalar batch shape of the input temperatures and pressures.

phase_fractions Tensor

Molar phase fractions with shape (nstates, 3). Entries beyond phase_counts are zero.

phase_compositions Tensor

Equilibrium mole fractions with shape (nstates, 3, ncomponents). Padded phase rows are NaN.

phase_counts Tensor

Number of retained distinct phases per state, from one through three.

gibbs_reduction Tensor

Dimensionless reduction in G/(R*T) relative to the homogeneous feed state.

fugacity_residual Tensor

Maximum absolute log-fugacity mismatch among retained phases.

material_balance_residual Tensor

Maximum absolute component mole-fraction material-balance residual.

converged Tensor

Per-state flag requiring both fugacity and material-balance gates. Callers must inspect this tensor before interpreting phase counts.

elapsed_seconds float

Total wall-clock seconds for the grid call.

batched_search_seconds float

Wall-clock seconds spent in stability, vectorized two-phase work, and initial Gibbs candidate discovery.

refinement_seconds float

Wall-clock seconds spent refining and auditing difficult states.

difficult_state_count int

Number of states sent to the difficult-state Gibbs fallback. For :func:flash_grid_oracle, this is the number of multicomponent states examined independently.

topology_audit_count int

Number of distinct additional cells selected by the two-dimensional enclosed-cell and three-phase-boundary continuation audits. This is zero for :func:flash_grid_oracle.

initial_fallback_replacements int

Number of first-pass grid candidates replaced by a lower-Gibbs, converged difficult-state result.

topology_audit_replacements int

Number of already-installed grid candidates replaced by a lower-Gibbs, converged result during the subsequent two-dimensional audits.

Notes

Root selection, physical phase identification, and equilibrium phase count are separate. Use :func:identify_grid_phases to label the returned equilibrium compositions. Grid flashes are solver operations with discrete phase-count decisions; returned tensors are diagnostic results, not a differentiable end-to-end flash mapping.

GridFlashOptions dataclass

GridFlashOptions(
    chunk_size: int = 2048,
    random_allocation_starts: int = 8,
    random_seed: int = 20260724,
    fallback_workers: int = 1,
    debug: bool = False,
    phase_fraction_tolerance: float = 0.0001,
    phase_composition_merge_tolerance: float = 0.002,
    gibbs_reduction_tolerance: float = 2e-07,
    fugacity_tolerance: float = 1e-08,
    material_balance_tolerance: float = 5e-11,
    flash_newton_tolerance: float = 1e-11,
    stability_tolerance: float = 1e-07,
    independent_reflash_starts: int = 4,
    stability_iterations: int = 40,
    two_phase_substitution_iterations: int = 30,
    two_phase_newton_iterations: int = 8,
    gibbs_fallback_adam_iterations: int = 80,
    three_phase_newton_iterations: int = 48,
)

Numerical controls for batched phase-count-discovering TP flashes.

The defaults target float64 reference calculations. Tolerances are applied to dimensionless log-fugacity or composition residuals unless stated otherwise. Changing a start count, merge tolerance, or Gibbs acceptance tolerance can change which basin is selected; these fields are therefore part of the scientific configuration rather than hidden performance switches.

Attributes:

Name Type Description
chunk_size int

Maximum number of independent states passed to one batched stability or fixed two-phase kernel. It bounds temporary tensor size; it does not subdivide or couple the thermodynamic problem.

random_allocation_starts int

Number of reproducible random three-phase component-allocation starts added to the deterministic Wilson and component-rich starts.

random_seed int

PyTorch generator seed used only for random allocation starts.

fallback_workers int

Number of Python threads used for the final sparse scalar refinement. The batched search is already vectorized. Values above one may be slower when PyTorch intra-operation threading is also enabled.

debug bool

Print fallback candidate counts and Newton acceptance summaries. Library results and numerical tolerances are unchanged.

phase_fraction_tolerance float

Minimum retained molar phase fraction. Smaller phases are treated as disappearing and removed after refinement.

phase_composition_merge_tolerance float

Maximum absolute mole-fraction difference for merging two candidate phase compositions.

gibbs_reduction_tolerance float

Minimum dimensionless reduction in G/(R*T) required to replace a converged lower-phase-count state.

fugacity_tolerance float

Maximum absolute log-fugacity equality residual accepted for a multiphase result.

material_balance_tolerance float

Maximum absolute component mole-fraction material-balance residual.

flash_newton_tolerance float

Maximum residual used to terminate each scalar equal-fugacity Newton refinement.

stability_tolerance float

Tangent-plane-distance and stability-iteration tolerance. A state is treated as unstable when its minimum TPD is less than the negative of this value.

independent_reflash_starts int

Number of lowest-Gibbs starts independently refined by :func:flash_grid_oracle for each multicomponent state.

stability_iterations int

Maximum successive-substitution passes in each batched stability calculation.

two_phase_substitution_iterations int

Maximum fixed-substitution passes before the batched two-phase Newton correction.

two_phase_newton_iterations int

Maximum batched autodiff-Newton corrections for a known two-phase state.

gibbs_fallback_adam_iterations int

Adam iterations applied simultaneously to the sparse difficult-state component-allocation problems.

three_phase_newton_iterations int

Maximum batched autodiff-Newton corrections for candidate three-phase states.

Raises:

Type Description
ValueError

If an iteration, chunk, worker, or independent-start count is not positive; if random_allocation_starts is negative; or if a tolerance is not positive.

GridPhaseIdentification dataclass

GridPhaseIdentification(
    methods: tuple[PhaseIdentificationCriterion, ...],
    phase_identity_codes: Tensor,
    criterion_values: Tensor,
    thresholds: Tensor,
    ambiguous: Tensor,
    region_codes: Tensor,
    elapsed_seconds: float,
    method_elapsed_seconds: tuple[float, ...],
)

Physical identities evaluated at every equilibrium composition.

Attributes:

Name Type Description
methods tuple[PhaseIdentificationCriterion, ...]

Ordered phase-identification criteria. This is the leading axis of all returned tensors.

phase_identity_codes Tensor

Integer tensor with shape (nmethods, nstates, 3). Codes are -1 for unknown or padded, 0 for liquid, and 1 for vapor.

criterion_values Tensor

Native scalar criterion values with the same padded shape as phase_identity_codes. Units depend on the method: dimensionless T/Tc, dimensionless V/b, dimensionless negative-flash residual, 1/(Pa K), 1/K^2, or the dimensionless Venkatarathnam-Oellrich phase-identification parameter.

thresholds Tensor

Decision threshold in the same units as each criterion value.

ambiguous Tensor

Boolean ambiguity flags with shape (nmethods, nstates, 3). Padded, unavailable, and non-converged entries are true.

region_codes Tensor

Integer tensor with shape (nmethods, *grid_shape) indexing :data:GRID_PHASE_REGION_LABELS.

elapsed_seconds float

Wall-clock seconds spent evaluating all requested methods.

method_elapsed_seconds tuple[float, ...]

Wall-clock seconds for each complete method pass, in methods order. Their sum differs from elapsed_seconds only by result allocation and assembly overhead. Timings are workload diagnostics, not deterministic numerical outputs.

Notes

The "three-phase" region is deliberately identity-neutral. Individual phase codes retain whether a criterion returned LLV, LLL, or another combination. Scalar diagnostic values are detached to avoid retaining one higher-order autodiff graph per grid cell. Call :func:identify_phase directly when derivatives of a criterion with respect to state or model parameters are required.

PhaseRegionBoundaryCurve dataclass

PhaseRegionBoundaryCurve(
    region_code: int,
    temperature: Tensor,
    pressure: Tensor,
    closed: bool,
)

One adaptively localized boundary of a classified phase region.

Attributes:

Name Type Description
region_code int

Index into :data:GRID_PHASE_REGION_LABELS for the region enclosed or followed by this curve.

temperature, pressure

Ordered one-dimensional curve coordinates in K and Pa. Their lengths match and are at least two.

closed bool

Whether graph traversal returned to the first refined grid edge.

Notes

The curve connects equilibrium classifications and is not a coexistence continuation solve. Its vertices are localized by re-flashing bisection states on grid edges whose endpoint region codes differ.

PhaseRegionBoundarySet dataclass

PhaseRegionBoundarySet(
    curves: tuple[PhaseRegionBoundaryCurve, ...],
    refined_edge_count: int,
    refinement_state_count: int,
    failed_midpoint_count: int,
    ambiguous_cell_count: int,
    maximum_temperature_bracket: float,
    maximum_pressure_bracket: float,
)

Adaptively refined boundaries and localization diagnostics.

Attributes:

Name Type Description
curves tuple[PhaseRegionBoundaryCurve, ...]

Connected phase-region boundary curves. Different region codes can share a physical interface and therefore have coincident curves.

refined_edge_count int

Number of unique coarse-grid edges whose endpoint classifications differ.

refinement_state_count int

Total midpoint states submitted to batched flashes.

failed_midpoint_count int

Number of midpoint flashes that did not pass the flash convergence gate. Such classifications remain explicit in the bisection audit.

ambiguous_cell_count int

Number of checkerboard marching-squares cells resolved with the deterministic isolated-corner convention.

maximum_temperature_bracket, maximum_pressure_bracket

Largest final edge-bracket widths in K and Pa.

TrustRegionGridPolishResult dataclass

TrustRegionGridPolishResult(
    equilibrium: GridEquilibrium,
    attempted: Tensor,
    converged: Tensor,
    iterations: Tensor,
    accepted_steps: Tensor,
    rejected_steps: Tensor,
    gradient_norm: Tensor,
    minimum_hessian_eigenvalue: Tensor,
    minimum_tpd: Tensor,
    maximum_phase_composition_change: Tensor,
    elapsed_seconds: float,
)

Batched NLVM Trust-Region audit of a phase-discovered grid.

Attributes:

Name Type Description
equilibrium GridEquilibrium

Copy of the supplied grid with every attempted multiphase state replaced by the Trust-Region result. A one-phase state whose Trust-Region TPD minimum is negative is promoted to a two-phase Trust-Region flash when the instability supplies a physical seed.

attempted Tensor

Boolean tensor with shape (nstates,) selecting converged input states. One-phase rows receive a Trust-Region stability audit; two- and three-phase rows receive an NLVM Trust-Region flash.

converged Tensor

Per-state Trust-Region convergence flags. Unattempted states are false. A true value requires the optimization, fugacity, material balance, positive-fraction, and separated-phase gates.

iterations, accepted_steps, rejected_steps

Per-state outer iteration and step-decision counts. Unattempted rows contain zero.

gradient_norm Tensor

Final infinity norm of the dimensionless Gibbs objective gradient.

minimum_hessian_eigenvalue Tensor

Smallest eigenvalue of the final symmetrized exact Hessian.

minimum_tpd Tensor

Final modified tangent-plane-distance minimum for audited one-phase states. Multiphase rows contain NaN.

maximum_phase_composition_change Tensor

Largest absolute mole-fraction change from the independently discovered seed, comparing the preserved phase rows.

elapsed_seconds float

Wall-clock seconds spent in the Trust-Region audit.

Notes

This result validates a local fixed-phase-count solver against phase counts discovered independently. It is not itself a global phase-count proof. Inspect attempted and converged before using the polished equilibrium.

batched_tangent_plane_stability

batched_tangent_plane_stability(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_compositions: Tensor | None = None,
    tolerance: float = 1e-07,
    max_iterations: int = 40,
) -> BatchedStabilityResult

Screen independent states with vectorized tangent-plane iterations.

Michelsen's stationary-point substitution is applied to all states and trial compositions in tensor batches. This routine is intended as the inexpensive first stage of a grid flash: clearly unstable cells seed a phase-split calculation, while non-converged or near-zero TPD cells remain explicit candidates for a stricter stability fallback.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model supporting batched stable-root fugacity coefficients.

required
state ChemicalState

One-dimensional temperature and pressure batches. Composition may be common to the batch or contain one row per state.

required
initial_compositions Tensor | None

Optional trial compositions with shape (starts, ncomponents) or (batch, starts, ncomponents). By default the feed, Wilson vapor- and liquid-like trials, and one component-rich trial per component are evaluated.

None
tolerance float

Maximum absolute stationary-point log-mole update.

1e-07
max_iterations int

Maximum vectorized successive-substitution passes.

40

Returns:

Type Description
BatchedStabilityResult

Per-state TPD minima, trial compositions, convergence diagnostics, and stability decisions.

Notes

The normalized TPD at a stationary point is -log(sum(W)). A non-converged nonnegative result is not a global stability proof; callers performing automatic phase discovery should send those cells to a strict fallback or retain an ambiguity flag.

Source code in src/torch_flash/flash/batched.py
def batched_tangent_plane_stability(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_compositions: Tensor | None = None,
    tolerance: float = 1.0e-7,
    max_iterations: int = 40,
) -> BatchedStabilityResult:
    """Screen independent states with vectorized tangent-plane iterations.

    Michelsen's stationary-point substitution is applied to all states and
    trial compositions in tensor batches. This routine is intended as the
    inexpensive first stage of a grid flash: clearly unstable cells seed a
    phase-split calculation, while non-converged or near-zero TPD cells remain
    explicit candidates for a stricter stability fallback.

    Parameters
    ----------
    model
        Homogeneous-state model supporting batched stable-root fugacity
        coefficients.
    state
        One-dimensional temperature and pressure batches. Composition may be
        common to the batch or contain one row per state.
    initial_compositions
        Optional trial compositions with shape ``(starts, ncomponents)`` or
        ``(batch, starts, ncomponents)``. By default the feed, Wilson vapor-
        and liquid-like trials, and one component-rich trial per component
        are evaluated.
    tolerance
        Maximum absolute stationary-point log-mole update.
    max_iterations
        Maximum vectorized successive-substitution passes.

    Returns
    -------
    BatchedStabilityResult
        Per-state TPD minima, trial compositions, convergence diagnostics,
        and stability decisions.

    Notes
    -----
    The normalized TPD at a stationary point is ``-log(sum(W))``. A
    non-converged nonnegative result is not a global stability proof; callers
    performing automatic phase discovery should send those cells to a strict
    fallback or retain an ambiguity flag.
    """
    if tolerance <= 0.0:
        raise ValueError("tolerance must be positive")
    if max_iterations <= 0:
        raise ValueError("max_iterations must be positive")
    temperature, pressure, composition = _batch_inputs(state)
    batch_size, ncomponents = composition.shape
    log_composition = torch.log(torch.clamp_min(composition, 1.0e-30))
    reference = log_composition + model.log_fugacity_coefficients(
        temperature,
        pressure,
        composition,
        "stable",
    )

    if initial_compositions is None:
        k_values = wilson_k_values(
            _model_components(model),
            temperature,
            pressure,
        )
        identity = torch.eye(
            ncomponents,
            dtype=composition.dtype,
            device=composition.device,
        )
        component_rich = 0.95 * identity[None, :, :] + 0.05 * composition[:, None, :]
        trials = torch.cat(
            (
                composition[:, None, :],
                (composition * k_values)[:, None, :],
                (composition / k_values)[:, None, :],
                component_rich,
            ),
            dim=1,
        )
    else:
        if initial_compositions.ndim == 2:
            if initial_compositions.shape[-1] != ncomponents:
                raise ValueError("trial compositions must use the model component count")
            trials = initial_compositions[None, :, :].expand(batch_size, -1, -1)
        elif (
            initial_compositions.ndim == 3
            and initial_compositions.shape[0] == batch_size
            and initial_compositions.shape[-1] == ncomponents
        ):
            trials = initial_compositions
        else:
            raise ValueError(
                "trial compositions must have shape (starts, components) "
                "or (batch, starts, components)"
            )
        if bool((~torch.isfinite(trials)).any() | (trials < 0.0).any()):
            raise ValueError("trial compositions must be finite and nonnegative")
        if bool((trials.sum(dim=-1) <= 0.0).any()):
            raise ValueError("every trial composition must have a positive sum")
        trials = trials.to(dtype=composition.dtype, device=composition.device)

    log_trial_moles = torch.log(torch.clamp_min(trials, 1.0e-30))
    residual_norm = torch.full(
        log_trial_moles.shape[:-1],
        torch.inf,
        dtype=composition.dtype,
        device=composition.device,
    )
    completed_iterations = 0
    for iteration in range(1, max_iterations + 1):
        trial_composition = torch.softmax(log_trial_moles, dim=-1)
        target = reference[:, None, :] - model.log_fugacity_coefficients(
            temperature[:, None],
            pressure[:, None],
            trial_composition,
            "stable",
        )
        residual_norm = (target - log_trial_moles).abs().amax(dim=-1)
        active = residual_norm > tolerance
        log_trial_moles = torch.where(
            active[..., None],
            _bounded_log_k(target),
            log_trial_moles,
        )
        completed_iterations = iteration
        if not bool(active.any()):
            break

    trial_composition = torch.softmax(log_trial_moles, dim=-1)
    target = reference[:, None, :] - model.log_fugacity_coefficients(
        temperature[:, None],
        pressure[:, None],
        trial_composition,
        "stable",
    )
    residual_norm = (target - log_trial_moles).abs().amax(dim=-1)
    candidate_tpd = -torch.logsumexp(target, dim=-1)
    best_start = candidate_tpd.argmin(dim=-1)
    batch_index = torch.arange(batch_size, device=composition.device)
    minimum_tpd = candidate_tpd[batch_index, best_start]
    best_composition = trial_composition[batch_index, best_start]
    best_residual = residual_norm[batch_index, best_start]
    return BatchedStabilityResult(
        minimum_tpd >= -10.0 * tolerance,
        minimum_tpd,
        best_composition,
        completed_iterations,
        best_residual <= tolerance,
        best_residual,
    )

batched_two_phase_flash

batched_two_phase_flash(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_k_values: Tensor | None = None,
    phase_roots: tuple[PhaseKind, PhaseKind] = (
        "liquid",
        "vapor",
    ),
    tolerance: float = 1e-08,
    substitution_iterations: int = 12,
    newton_iterations: int = 16,
) -> BatchedTwoPhaseFlashResult

Solve independent known-two-phase TP states in one tensor batch.

The hybrid algorithm performs vectorized successive substitution followed by block-diagonal Newton updates obtained with PyTorch autodiff. Inputs must have Kmin < 1 < Kmax for every state.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model supporting batched log fugacity coefficients.

required
state ChemicalState

Temperatures in K and pressures in Pa as one-dimensional tensors of shape (batch,). Composition may have shape (ncomponents,) for a common feed or (batch, ncomponents) for state-specific feeds.

required
initial_k_values Tensor | None

Optional positive vapor-to-liquid ratios of shape (batch, ncomponents). Wilson estimates are used when omitted.

None
phase_roots tuple[PhaseKind, PhaseKind]

Cubic-root requests for the denominator and numerator phases, respectively. The default ("liquid", "vapor") solves a known vapor-liquid branch. ("stable", "stable") is useful when a preceding stability calculation supplies a generic phase-split seed.

('liquid', 'vapor')
tolerance float

Per-state convergence threshold for the maximum absolute log-fugacity residual.

1e-08
substitution_iterations int

Maximum vectorized successive-substitution passes.

12
newton_iterations int

Maximum block-diagonal Newton passes after substitution.

16

Returns:

Type Description
BatchedTwoPhaseFlashResult

Per-state phase fractions, compositions, K values, convergence flags, residuals, and the shared number of executed iteration passes.

Raises:

Type Description
ValueError

If batch shapes are inconsistent, iteration controls are invalid, initial K values are invalid, or a state does not satisfy Kmin < 1 < Kmax.

Notes

converged is reported per state and additionally requires a physical vapor fraction in [0, 1].

This routine does not perform tangent-plane stability analysis. That separation is intentional: an envelope or another phase classifier can cheaply select thousands of known two-phase grid cells, while ambiguous cells can be sent to the full scalar stability-tested flash.

Source code in src/torch_flash/flash/batched.py
def batched_two_phase_flash(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_k_values: Tensor | None = None,
    phase_roots: tuple[PhaseKind, PhaseKind] = ("liquid", "vapor"),
    tolerance: float = 1.0e-8,
    substitution_iterations: int = 12,
    newton_iterations: int = 16,
) -> BatchedTwoPhaseFlashResult:
    """Solve independent known-two-phase TP states in one tensor batch.

    The hybrid algorithm performs vectorized successive substitution followed
    by block-diagonal Newton updates obtained with PyTorch autodiff. Inputs
    must have ``Kmin < 1 < Kmax`` for every state.

    Parameters
    ----------
    model
        Homogeneous-state model supporting batched log fugacity coefficients.
    state
        Temperatures in K and pressures in Pa as one-dimensional tensors of
        shape ``(batch,)``. Composition may have shape ``(ncomponents,)`` for
        a common feed or ``(batch, ncomponents)`` for state-specific feeds.
    initial_k_values
        Optional positive vapor-to-liquid ratios of shape
        ``(batch, ncomponents)``. Wilson estimates are used when omitted.
    phase_roots
        Cubic-root requests for the denominator and numerator phases,
        respectively. The default ``("liquid", "vapor")`` solves a known
        vapor-liquid branch. ``("stable", "stable")`` is useful when a
        preceding stability calculation supplies a generic phase-split seed.
    tolerance
        Per-state convergence threshold for the maximum absolute
        log-fugacity residual.
    substitution_iterations
        Maximum vectorized successive-substitution passes.
    newton_iterations
        Maximum block-diagonal Newton passes after substitution.

    Returns
    -------
    BatchedTwoPhaseFlashResult
        Per-state phase fractions, compositions, K values, convergence flags,
        residuals, and the shared number of executed iteration passes.

    Raises
    ------
    ValueError
        If batch shapes are inconsistent, iteration controls are invalid,
        initial K values are invalid, or a state does not satisfy
        ``Kmin < 1 < Kmax``.

    Notes
    -----
    ``converged`` is reported per state and additionally requires a physical
    vapor fraction in ``[0, 1]``.

    This routine does not perform tangent-plane stability analysis. That
    separation is intentional: an envelope or another phase classifier can
    cheaply select thousands of known two-phase grid cells, while ambiguous
    cells can be sent to the full scalar stability-tested flash.
    """
    if tolerance <= 0.0:
        raise ValueError("tolerance must be positive")
    if substitution_iterations < 0 or newton_iterations < 0:
        raise ValueError("iteration counts must be nonnegative")
    if len(phase_roots) != 2 or any(
        phase not in ("liquid", "vapor", "stable") for phase in phase_roots
    ):
        raise ValueError("phase_roots must contain two valid phase-root requests")
    temperature, pressure, composition = _batch_inputs(state)
    if initial_k_values is None:
        k_values = wilson_k_values(
            _model_components(model),
            temperature,
            pressure,
        )
    else:
        if initial_k_values.shape != composition.shape:
            raise ValueError("initial K values must have one row per state and component")
        if bool((~torch.isfinite(initial_k_values)).any() | (initial_k_values <= 0.0).any()):
            raise ValueError("initial K values must be finite and strictly positive")
        k_values = initial_k_values.to(
            dtype=composition.dtype,
            device=composition.device,
        )
    log_k = _bounded_log_k(torch.log(k_values))
    if not bool(_straddles_unity(log_k).all()):
        raise ValueError("every batched state requires Kmin < 1 < Kmax")

    def equilibrium_residual(current_log_k: Tensor) -> Tensor:
        split = rachford_rice(
            composition,
            torch.exp(current_log_k),
            tolerance=1.0e-13,
        )
        log_phi_liquid = model.log_fugacity_coefficients(
            temperature,
            pressure,
            split.liquid_composition,
            phase_roots[0],
        )
        log_phi_vapor = model.log_fugacity_coefficients(
            temperature,
            pressure,
            split.vapor_composition,
            phase_roots[1],
        )
        return current_log_k - (log_phi_liquid - log_phi_vapor)

    completed_iterations = 0
    for index in range(substitution_iterations):
        residual = equilibrium_residual(log_k)
        norm = residual.abs().amax(dim=-1)
        active = norm > tolerance
        if not bool(active.any()):
            completed_iterations = index + 1
            break
        damping = 0.8 if index < 4 else 0.5
        target = log_k - damping * residual
        updated = _admissible_update(log_k, target)
        log_k = torch.where(active[..., None], updated, log_k)
        completed_iterations = index + 1

    ncomponents = composition.shape[-1]
    eye = torch.eye(
        ncomponents,
        dtype=composition.dtype,
        device=composition.device,
    )
    for index in range(newton_iterations):
        current = log_k.detach().requires_grad_(True)
        residual = equilibrium_residual(current)
        norm = residual.abs().amax(dim=-1)
        active = norm > tolerance
        if not bool(active.any()):
            log_k = current.detach()
            completed_iterations += index
            break
        jacobian_rows = tuple(
            torch.autograd.grad(
                residual[:, component].sum(),
                current,
                retain_graph=component + 1 < ncomponents,
            )[0]
            for component in range(ncomponents)
        )
        jacobian = torch.stack(jacobian_rows, dim=-2)
        try:
            step = torch.linalg.solve(
                jacobian + 1.0e-10 * eye,
                -residual[..., None],
            ).squeeze(-1)
        except torch.linalg.LinAlgError:
            step = -0.25 * residual
        step = torch.clamp(step, -3.0, 3.0)

        factor = torch.ones_like(norm)
        accepted = ~active
        next_log_k = current.detach()
        for _ in range(12):
            trial = _bounded_log_k(current.detach() + factor[..., None] * step.detach())
            straddles = _straddles_unity(trial)
            safe_trial = torch.where(straddles[..., None], trial, current.detach())
            trial_norm = equilibrium_residual(safe_trial).detach().abs().amax(dim=-1)
            improved = active & ~accepted & straddles & (trial_norm < norm.detach())
            next_log_k = torch.where(improved[..., None], trial, next_log_k)
            accepted = accepted | improved
            if bool(accepted.all()):
                break
            factor = torch.where(accepted, factor, 0.5 * factor)
        log_k = next_log_k
        completed_iterations += 1

    final_residual = equilibrium_residual(log_k)
    residual_norm = final_residual.abs().amax(dim=-1)
    split = rachford_rice(
        composition,
        torch.exp(log_k),
        tolerance=1.0e-13,
    )
    physical_fraction = (split.vapor_fraction >= -10.0 * tolerance) & (
        split.vapor_fraction <= 1.0 + 10.0 * tolerance
    )
    converged = (residual_norm <= tolerance) & physical_fraction
    return BatchedTwoPhaseFlashResult(
        torch.clamp(split.vapor_fraction, 0.0, 1.0),
        torch.clamp(split.liquid_fraction, 0.0, 1.0),
        split.liquid_composition,
        split.vapor_composition,
        torch.exp(log_k),
        completed_iterations,
        converged,
        residual_norm,
    )

flash_grid

flash_grid(
    model: StateModel,
    state: ChemicalState,
    *,
    options: GridFlashOptions | None = None,
    binary_invariants: tuple[
        BinaryThreePhaseInvariant, ...
    ] = (),
) -> GridEquilibrium

Flash independent states with batched screening and sparse Gibbs fallback.

The temperature and pressure tensors must have the same non-scalar batch shape. The composition can be common to the batch or have that batch shape as leading dimensions. Two-dimensional batches additionally receive a topology audit and a Gibbs-gated continuation audit along resolved three-phase boundaries.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state thermodynamic model supplying differentiable log-fugacity coefficients, cubic critical constants for Wilson starts, and stable-root selection.

required
state ChemicalState

Batched TP states. Temperature is in K, pressure is in Pa, and composition contains mole fractions on the final axis. Temperature and pressure must have the same non-scalar batch shape. Composition can be common to all states or have matching leading dimensions.

required
options GridFlashOptions | None

Numerical controls. Defaults target float64 reference calculations. The model, state, and all options are reused across the batch without changing process-wide PyTorch runtime settings.

None
binary_invariants tuple[BinaryThreePhaseInvariant, ...]

Optional converged binary three-phase solutions. A matching temperature and pressure uses their fixed phase compositions and a positive centered lever rule for feeds inside the outer composition interval.

()

Returns:

Type Description
GridEquilibrium

Padded phase fractions, compositions, convergence diagnostics, and timing counters flattened in row-major order with grid_shape recording the original batch shape.

Raises:

Type Description
ValueError

If temperature and pressure are scalar or have different shapes, the composition batch does not match, a numerical option is invalid, or a supplied binary invariant is malformed.

RuntimeError

If model evaluation, an optimizer, or an autodiff Newton system fails.

Notes

The hierarchy is:

  1. batched tangent-plane stability screening;
  2. a batched known-two-phase flash for unstable feeds;
  3. stability screening of both returned phases;
  4. sparse three-phase Gibbs-allocation minimization and autodiff Newton refinement for failed or child-unstable states; and
  5. for two-dimensional batches, an independent reflash of lower-phase cells bracketed by higher-phase states and of two-phase cells on the advancing boundary of a resolved three-phase region.

The two-dimensional audits only propose candidates. Three-phase continuation advances to a neighboring cell until no new lower-Gibbs split is accepted. Every replacement still requires lower Gibbs energy plus fugacity and material-balance convergence; visual smoothness never overrides the thermodynamic gates.

Forward-only screening runs without graph retention. Gibbs minimization uses PyTorch autograd and Newton Jacobians use torch.func.jacrev. Phase-count decisions, merging, and padded output assembly are discrete, so call-level gradients through GridEquilibrium are not defined. Use homogeneous property APIs and :func:identify_phase when a differentiable diagnostic is required.

chunk_size limits memory but does not introduce thermodynamic coupling. The states remain independent. On CPU, configure dtype and thread count once before constructing the model; library calls never change global PyTorch thread settings.

Source code in src/torch_flash/flash/grid.py
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
def flash_grid(
    model: StateModel,
    state: ChemicalState,
    *,
    options: GridFlashOptions | None = None,
    binary_invariants: tuple[BinaryThreePhaseInvariant, ...] = (),
) -> GridEquilibrium:
    """Flash independent states with batched screening and sparse Gibbs fallback.

    The temperature and pressure tensors must have the same non-scalar batch
    shape. The composition can be common to the batch or have that batch shape
    as leading dimensions. Two-dimensional batches additionally receive a
    topology audit and a Gibbs-gated continuation audit along resolved
    three-phase boundaries.

    Parameters
    ----------
    model
        Homogeneous-state thermodynamic model supplying differentiable
        log-fugacity coefficients, cubic critical constants for Wilson starts,
        and stable-root selection.
    state
        Batched TP states. Temperature is in K, pressure is in Pa, and
        composition contains mole fractions on the final axis. Temperature
        and pressure must have the same non-scalar batch shape. Composition
        can be common to all states or have matching leading dimensions.
    options
        Numerical controls. Defaults target float64 reference calculations.
        The model, state, and all options are reused across the batch without
        changing process-wide PyTorch runtime settings.
    binary_invariants
        Optional converged binary three-phase solutions. A matching temperature
        and pressure uses their fixed phase compositions and a positive
        centered lever rule for feeds inside the outer composition interval.

    Returns
    -------
    GridEquilibrium
        Padded phase fractions, compositions, convergence diagnostics, and
        timing counters flattened in row-major order with ``grid_shape``
        recording the original batch shape.

    Raises
    ------
    ValueError
        If temperature and pressure are scalar or have different shapes, the
        composition batch does not match, a numerical option is invalid, or a
        supplied binary invariant is malformed.
    RuntimeError
        If model evaluation, an optimizer, or an autodiff Newton system fails.

    Notes
    -----
    The hierarchy is:

    1. batched tangent-plane stability screening;
    2. a batched known-two-phase flash for unstable feeds;
    3. stability screening of both returned phases;
    4. sparse three-phase Gibbs-allocation minimization and autodiff Newton
       refinement for failed or child-unstable states; and
    5. for two-dimensional batches, an independent reflash of lower-phase
       cells bracketed by higher-phase states and of two-phase cells on the
       advancing boundary of a resolved three-phase region.

    The two-dimensional audits only propose candidates. Three-phase
    continuation advances to a neighboring cell until no new lower-Gibbs
    split is accepted. Every replacement still requires lower Gibbs energy
    plus fugacity and material-balance convergence; visual smoothness never
    overrides the thermodynamic gates.

    Forward-only screening runs without graph retention. Gibbs minimization
    uses PyTorch autograd and Newton Jacobians use ``torch.func.jacrev``.
    Phase-count decisions, merging, and padded output assembly are discrete,
    so call-level gradients through ``GridEquilibrium`` are not defined. Use
    homogeneous property APIs and :func:`identify_phase` when a differentiable
    diagnostic is required.

    ``chunk_size`` limits memory but does not introduce thermodynamic coupling.
    The states remain independent. On CPU, configure dtype and thread count
    once before constructing the model; library calls never change global
    PyTorch thread settings.
    """
    resolved_options = options or GridFlashOptions()
    temperatures, pressures, feeds = _grid_states(state)
    grid_shape = tuple(state.temperature.shape)
    state_count, component_count = feeds.shape
    started = time.perf_counter()
    if component_count == 1:
        return flash_grid_oracle(
            model,
            state,
            options=resolved_options,
            binary_invariants=binary_invariants,
        )

    with torch.no_grad():
        one_phase_log_phi = model.log_fugacity_coefficients(
            temperatures,
            pressures,
            feeds,
            "stable",
        )
        one_phase_gibbs = torch.sum(
            feeds * (torch.log(feeds) + one_phase_log_phi),
            dim=-1,
        )
    padded_fractions = temperatures.new_zeros((state_count, MAX_PHASES))
    padded_fractions[:, 0] = 1.0
    padded_compositions = temperatures.new_full(
        (state_count, MAX_PHASES, component_count),
        torch.nan,
    )
    padded_compositions[:, 0, :] = feeds
    phase_counts = torch.ones(
        state_count,
        dtype=torch.int64,
        device=feeds.device,
    )
    gibbs_reduction = temperatures.new_zeros(state_count)
    fugacity_residual = temperatures.new_zeros(state_count)
    material_balance_residual = temperatures.new_zeros(state_count)
    converged = torch.ones(
        state_count,
        dtype=torch.bool,
        device=feeds.device,
    )

    stability = _batched_stability_in_chunks(
        model,
        temperatures,
        pressures,
        feeds,
        resolved_options,
    )
    unstable_indices = torch.nonzero(
        stability.minimum_tpd < -resolved_options.stability_tolerance,
    ).flatten()
    difficult_indices: set[int] = set()
    fallback_seed_logits: dict[int, Tensor] = {}

    if unstable_indices.numel():
        converged[unstable_indices] = False
        initial_k = stability.trial_composition[unstable_indices] / torch.clamp_min(
            feeds[unstable_indices],
            1.0e-30,
        )
        two_phase = _batched_two_phase_in_chunks(
            model,
            temperatures[unstable_indices],
            pressures[unstable_indices],
            feeds[unstable_indices],
            initial_k,
            resolved_options,
        )
        converged_two_phase = two_phase.converged
        failed_local = torch.nonzero(~converged_two_phase).flatten()
        difficult_indices.update(int(index) for index in unstable_indices[failed_local])

        if bool(converged_two_phase.any()):
            local = torch.nonzero(converged_two_phase).flatten()
            global_indices = unstable_indices[local]
            fractions = torch.stack(
                (
                    two_phase.liquid_fraction[local],
                    two_phase.vapor_fraction[local],
                ),
                dim=-1,
            )
            compositions = torch.stack(
                (
                    two_phase.liquid_composition[local],
                    two_phase.vapor_composition[local],
                ),
                dim=1,
            )
            with torch.no_grad():
                phase_log_phi = model.log_fugacity_coefficients(
                    temperatures[global_indices, None],
                    pressures[global_indices, None],
                    compositions,
                    "stable",
                )
                phase_gibbs = torch.sum(
                    compositions * (torch.log(compositions) + phase_log_phi),
                    dim=-1,
                )
                split_gibbs = torch.sum(fractions * phase_gibbs, dim=-1)
            reduction = one_phase_gibbs[global_indices] - split_gibbs
            balance = torch.sum(fractions[..., None] * compositions, dim=1)
            balance_residual = torch.abs(
                balance - feeds[global_indices],
            ).amax(dim=-1)
            composition_distance = torch.abs(
                compositions[:, 0] - compositions[:, 1],
            ).amax(dim=-1)
            active = (
                (fractions.amin(dim=-1) > resolved_options.phase_fraction_tolerance)
                & (composition_distance > resolved_options.phase_composition_merge_tolerance)
                & (reduction > resolved_options.gibbs_reduction_tolerance)
                & (balance_residual <= resolved_options.material_balance_tolerance)
            )
            converged[global_indices] = True
            fugacity_residual[global_indices] = two_phase.residual_norm[local]
            material_balance_residual[global_indices] = balance_residual
            active_local = torch.nonzero(active).flatten()
            active_split_indices = global_indices[active_local]
            if active_split_indices.numel():
                active_fractions = fractions[active_local]
                active_compositions = compositions[active_local]
                padded_fractions[active_split_indices, :2] = active_fractions
                padded_compositions[active_split_indices, :2] = active_compositions
                phase_counts[active_split_indices] = 2
                gibbs_reduction[active_split_indices] = reduction[active_local]

                phase_feed = torch.cat(
                    (
                        active_compositions[:, 0],
                        active_compositions[:, 1],
                    ),
                    dim=0,
                )
                phase_temperature = temperatures[active_split_indices].repeat(2)
                phase_pressure = pressures[active_split_indices].repeat(2)
                phase_stability = _batched_stability_in_chunks(
                    model,
                    phase_temperature,
                    phase_pressure,
                    phase_feed,
                    resolved_options,
                )
                child_tpd = phase_stability.minimum_tpd.reshape(2, -1)
                child_composition = phase_stability.trial_composition.reshape(
                    2,
                    -1,
                    component_count,
                )
                child_unstable = (child_tpd < -resolved_options.stability_tolerance).any(dim=0)
                child_candidates = torch.nonzero(child_unstable).flatten()
                difficult_indices.update(
                    int(index) for index in active_split_indices[child_candidates]
                )
                for child_index_tensor in child_candidates:
                    child_index = int(child_index_tensor)
                    unstable_phase = int(torch.argmin(child_tpd[:, child_index]))
                    seed_fractions = torch.cat(
                        (
                            0.95 * active_fractions[child_index],
                            active_fractions.new_tensor([0.05]),
                        )
                    )
                    seed_compositions = torch.cat(
                        (
                            active_compositions[child_index],
                            child_composition[unstable_phase, child_index][None, :],
                        )
                    )
                    fallback_seed_logits[int(active_split_indices[child_index])] = (
                        _logits_from_phases(
                            seed_fractions,
                            seed_compositions,
                        )
                    )

    invariant_indices = [
        state_index
        for state_index in range(state_count)
        if _binary_invariant_split(
            binary_invariants,
            temperatures[state_index],
            pressures[state_index],
            feeds[state_index],
            resolved_options,
        )
        is not None
    ]
    difficult_indices.update(invariant_indices)
    if invariant_indices:
        converged[invariant_indices] = False

    batched_search_seconds = time.perf_counter() - started
    refinement_started = time.perf_counter()
    replacements = _gibbs_fallback_grid_states(
        model,
        temperatures,
        pressures,
        feeds,
        list(difficult_indices),
        one_phase_gibbs,
        one_phase_gibbs - gibbs_reduction,
        converged,
        resolved_options,
        binary_invariants,
        fallback_seed_logits,
    )

    def install_replacements(
        current: dict[int, tuple[Tensor, Tensor, Tensor]],
    ) -> set[int]:
        installed: set[int] = set()
        for state_index, (fractions, compositions, split_gibbs) in current.items():
            fractions, balance_residual, equilibrium_residual = _candidate_diagnostics(
                model,
                temperatures[state_index],
                pressures[state_index],
                feeds[state_index],
                fractions,
                compositions,
            )
            reduction = one_phase_gibbs[state_index] - split_gibbs
            existing_gibbs = one_phase_gibbs[state_index] - gibbs_reduction[state_index]
            lower_gibbs = bool(
                split_gibbs < existing_gibbs - resolved_options.gibbs_reduction_tolerance
            )
            resolves_failure = not bool(converged[state_index])
            if not (lower_gibbs or resolves_failure):
                continue
            count = fractions.numel()
            padded_fractions[state_index] = 0.0
            padded_compositions[state_index] = torch.nan
            padded_fractions[state_index, :count] = fractions
            padded_compositions[state_index, :count] = compositions
            phase_counts[state_index] = count
            gibbs_reduction[state_index] = torch.clamp_min(reduction, 0.0)
            fugacity_residual[state_index] = equilibrium_residual
            material_balance_residual[state_index] = balance_residual
            converged[state_index] = bool(
                (equilibrium_residual <= resolved_options.fugacity_tolerance)
                & (balance_residual <= resolved_options.material_balance_tolerance)
            )
            installed.add(state_index)
        return installed

    fallback_replacement_indices = install_replacements(replacements)
    audited_indices: set[int] = set()
    audited_seed_indices: dict[int, int | None] = {}
    audit_replacements = 0
    if len(grid_shape) == 2:
        n_rows, n_columns = grid_shape
        while True:
            bracketed_indices = set(
                _bracketed_lower_phase_indices(
                    phase_counts,
                    n_rows,
                    n_columns,
                )
            )
            boundary_indices = set(
                _three_phase_boundary_indices(
                    phase_counts,
                    n_rows,
                    n_columns,
                )
            )
            proposed_indices = bracketed_indices | boundary_indices
            proposed_seed_indices = _continuation_seed_indices(
                phase_counts,
                sorted(proposed_indices),
                n_rows,
                n_columns,
            )
            audit_indices = sorted(
                index
                for index in proposed_indices
                if index not in audited_indices
                or (
                    index in boundary_indices
                    and proposed_seed_indices.get(index) != audited_seed_indices.get(index)
                )
            )
            if not audit_indices:
                break
            audited_indices.update(audit_indices)
            for index in audit_indices:
                audited_seed_indices[index] = proposed_seed_indices.get(index)
            audit_seed_logits = _continuation_seed_logits(
                phase_counts,
                padded_fractions,
                padded_compositions,
                audit_indices,
                n_rows,
                n_columns,
            )
            continuation_results = _continuation_grid_states(
                model,
                temperatures,
                pressures,
                feeds,
                one_phase_gibbs,
                resolved_options,
                audit_seed_logits,
            )
            continuation_replacements = install_replacements(continuation_results)
            unresolved_audit_indices = [
                index for index in audit_indices if index not in continuation_replacements
            ]
            audit_results = _gibbs_fallback_grid_states(
                model,
                temperatures,
                pressures,
                feeds,
                unresolved_audit_indices,
                one_phase_gibbs,
                one_phase_gibbs - gibbs_reduction,
                converged,
                resolved_options,
                binary_invariants,
                audit_seed_logits,
            )
            fallback_audit_replacements = install_replacements(audit_results)
            audit_replacements += len(continuation_replacements | fallback_audit_replacements)

    refinement_seconds = time.perf_counter() - refinement_started
    return GridEquilibrium(
        temperatures,
        pressures,
        feeds,
        grid_shape,
        padded_fractions,
        padded_compositions,
        phase_counts,
        gibbs_reduction,
        fugacity_residual,
        material_balance_residual,
        converged,
        time.perf_counter() - started,
        batched_search_seconds,
        refinement_seconds,
        len(difficult_indices),
        len(audited_indices),
        len(fallback_replacement_indices),
        audit_replacements,
    )

flash_grid_oracle

flash_grid_oracle(
    model: StateModel,
    state: ChemicalState,
    *,
    options: GridFlashOptions | None = None,
    binary_invariants: tuple[
        BinaryThreePhaseInvariant, ...
    ] = (),
) -> GridEquilibrium

Independently apply multistart Gibbs minimization to every TP state.

This deliberately slower path bypasses stability and two-phase screening. It is intended as a small-batch correctness oracle for :func:flash_grid.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model supplying differentiable log-fugacity coefficients and Wilson critical-property initialization data.

required
state ChemicalState

Batched TP states. Temperature is in K, pressure is in Pa, and composition contains mole fractions on the final axis. Temperature and pressure must share one non-scalar batch shape; composition can be common or have matching leading dimensions.

required
options GridFlashOptions | None

Numerical controls. independent_reflash_starts bounds how many lowest initial Gibbs allocations are strictly refined per state.

None
binary_invariants tuple[BinaryThreePhaseInvariant, ...]

Optional converged binary invariant solutions. A matching temperature and pressure uses their fixed phase compositions and a positive centered lever rule.

()

Returns:

Type Description
GridEquilibrium

Padded one-, two-, or three-phase results with explicit fugacity, material-balance, Gibbs-reduction, convergence, and timing diagnostics.

Raises:

Type Description
ValueError

If batch shapes, compositions, options, or a supplied invariant are invalid.

RuntimeError

If a model evaluation or strict scalar refinement fails.

Notes

Every multicomponent state is optimized independently from the same deterministic and seeded-random start library. The oracle is algorithmic verification, not an independent physical model. Compare phase count, Gibbs energy, and residuals; agreement alone does not validate the EoS parameterization.

Candidate minimization and equal-fugacity Newton Jacobians use PyTorch autodiff. Phase selection and padding are discrete, so the returned record is not an end-to-end differentiable flash result.

Source code in src/torch_flash/flash/grid.py
def flash_grid_oracle(
    model: StateModel,
    state: ChemicalState,
    *,
    options: GridFlashOptions | None = None,
    binary_invariants: tuple[BinaryThreePhaseInvariant, ...] = (),
) -> GridEquilibrium:
    """Independently apply multistart Gibbs minimization to every TP state.

    This deliberately slower path bypasses stability and two-phase screening.
    It is intended as a small-batch correctness oracle for :func:`flash_grid`.

    Parameters
    ----------
    model
        Homogeneous-state model supplying differentiable log-fugacity
        coefficients and Wilson critical-property initialization data.
    state
        Batched TP states. Temperature is in K, pressure is in Pa, and
        composition contains mole fractions on the final axis. Temperature
        and pressure must share one non-scalar batch shape; composition can be
        common or have matching leading dimensions.
    options
        Numerical controls. ``independent_reflash_starts`` bounds how many
        lowest initial Gibbs allocations are strictly refined per state.
    binary_invariants
        Optional converged binary invariant solutions. A matching temperature
        and pressure uses their fixed phase compositions and a positive
        centered lever rule.

    Returns
    -------
    GridEquilibrium
        Padded one-, two-, or three-phase results with explicit fugacity,
        material-balance, Gibbs-reduction, convergence, and timing diagnostics.

    Raises
    ------
    ValueError
        If batch shapes, compositions, options, or a supplied invariant are
        invalid.
    RuntimeError
        If a model evaluation or strict scalar refinement fails.

    Notes
    -----
    Every multicomponent state is optimized independently from the same
    deterministic and seeded-random start library. The oracle is algorithmic
    verification, not an independent physical model. Compare phase count,
    Gibbs energy, and residuals; agreement alone does not validate the EoS
    parameterization.

    Candidate minimization and equal-fugacity Newton Jacobians use PyTorch
    autodiff. Phase selection and padding are discrete, so the returned record
    is not an end-to-end differentiable flash result.
    """
    resolved_options = GridFlashOptions() if options is None else options
    temperatures, pressures, feeds = _grid_states(state)
    grid_shape = tuple(state.temperature.shape)
    state_count, component_count = feeds.shape
    started = time.perf_counter()
    padded_fractions = temperatures.new_zeros((state_count, MAX_PHASES))
    padded_compositions = temperatures.new_full(
        (state_count, MAX_PHASES, component_count),
        torch.nan,
    )
    phase_counts = torch.ones(state_count, dtype=torch.int64, device=feeds.device)
    gibbs_reduction = temperatures.new_zeros(state_count)
    fugacity_residual = temperatures.new_zeros(state_count)
    material_balance_residual = temperatures.new_zeros(state_count)
    converged = torch.ones(state_count, dtype=torch.bool, device=feeds.device)

    if component_count == 1:
        padded_fractions[:, 0] = 1.0
        padded_compositions[:, 0] = feeds
        return GridEquilibrium(
            temperatures,
            pressures,
            feeds,
            grid_shape,
            padded_fractions,
            padded_compositions,
            phase_counts,
            gibbs_reduction,
            fugacity_residual,
            material_balance_residual,
            converged,
            time.perf_counter() - started,
            0.0,
            0.0,
            0,
            0,
            0,
            0,
        )

    with torch.no_grad():
        one_phase_log_phi = model.log_fugacity_coefficients(
            temperatures,
            pressures,
            feeds,
            "stable",
        )
        one_phase_gibbs = torch.sum(
            feeds * (torch.log(feeds) + one_phase_log_phi),
            dim=-1,
        )
        multistart_logits = _allocation_initial_logits(
            model,
            temperatures,
            pressures,
            feeds,
            resolved_options,
        )
    batched_search_seconds = time.perf_counter() - started
    refinement_started = time.perf_counter()
    audited = 0
    replacements = 0
    for state_index in range(state_count):
        invariant_split = _binary_invariant_split(
            binary_invariants,
            temperatures[state_index],
            pressures[state_index],
            feeds[state_index],
            resolved_options,
        )
        if invariant_split is None:
            audited += 1
            replacement = _independent_multistart_reflash(
                model,
                temperatures[state_index],
                pressures[state_index],
                feeds[state_index],
                multistart_logits[state_index],
                one_phase_gibbs[state_index],
                resolved_options,
            )
            if replacement is None:
                fractions = temperatures.new_ones(1)
                compositions = feeds[state_index][None, :]
                split_gibbs = one_phase_gibbs[state_index]
            else:
                fractions, compositions, split_gibbs = replacement
                replacements += 1
        else:
            fractions, compositions = invariant_split
            split_gibbs = _candidate_gibbs_energy(
                model,
                temperatures[state_index],
                pressures[state_index],
                fractions,
                compositions,
            )

        fractions, balance_residual, equilibrium_residual = _candidate_diagnostics(
            model,
            temperatures[state_index],
            pressures[state_index],
            feeds[state_index],
            fractions,
            compositions,
        )
        count = fractions.numel()
        padded_fractions[state_index, :count] = fractions
        padded_compositions[state_index, :count] = compositions
        phase_counts[state_index] = count
        gibbs_reduction[state_index] = torch.clamp_min(
            one_phase_gibbs[state_index] - split_gibbs,
            0.0,
        )
        fugacity_residual[state_index] = equilibrium_residual
        material_balance_residual[state_index] = balance_residual
        converged[state_index] = bool(
            (equilibrium_residual <= resolved_options.fugacity_tolerance)
            & (balance_residual <= resolved_options.material_balance_tolerance)
        )

    refinement_seconds = time.perf_counter() - refinement_started
    return GridEquilibrium(
        temperatures,
        pressures,
        feeds,
        grid_shape,
        padded_fractions,
        padded_compositions,
        phase_counts,
        gibbs_reduction,
        fugacity_residual,
        material_balance_residual,
        converged,
        time.perf_counter() - started,
        batched_search_seconds,
        refinement_seconds,
        0,
        audited,
        replacements,
        0,
    )

identify_grid_phases

identify_grid_phases(
    model: StateModel,
    equilibrium: GridEquilibrium,
    *,
    methods: tuple[
        PhaseIdentificationCriterion, ...
    ] = DEFAULT_GRID_PHASE_IDENTIFICATION_METHODS,
    phase: PhaseKind = "stable",
    volume_to_covolume_threshold: float = 1.75,
    pseudo_critical_temperature_factor: float = 1.0,
    ambiguity_relative_tolerance: float = 0.05,
    pip_denominator_relative_tolerance: float | None = None,
    pip_autodiff_chunk_size: int = 4096,
    response_autodiff_chunk_size: int = 2048,
) -> GridPhaseIdentification

Identify each equilibrium phase and summarize paper-style regions.

Equilibrium phase count and physical identity remain separate: this function never changes the flash result. Each method is applied to every converged phase at its equilibrium composition.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model used to produce equilibrium. It must expose the properties required by every requested criterion.

required
equilibrium GridEquilibrium

Result returned by :func:flash_grid or :func:flash_grid_oracle.

required
methods tuple[PhaseIdentificationCriterion, ...]

Unique ordered criteria to evaluate. The default is the five Bennett and Schmidt methods plus the Venkatarathnam-Oellrich pressure-derivative parameter.

DEFAULT_GRID_PHASE_IDENTIFICATION_METHODS
phase PhaseKind

Homogeneous EoS root request used while evaluating each already separated equilibrium composition. "stable" is normally appropriate.

'stable'
volume_to_covolume_threshold float

Dimensionless Pedersen V/b separator. Values above the threshold are vapor-like.

1.75
pseudo_critical_temperature_factor float

Positive factor multiplying Li's volume-weighted pseudo-critical temperature. Bennett and Schmidt use one.

1.0
ambiguity_relative_tolerance float

Nonnegative relative band around each criterion threshold. It changes only ambiguous flags, not the sign-based identity.

0.05
pip_denominator_relative_tolerance float | None

Optional scaled singularity threshold for the dimensionless Venkatarathnam-Oellrich criterion. None uses the square root of machine epsilon. It is ignored by all other methods.

None
pip_autodiff_chunk_size int

Positive maximum number of independent equilibrium phases passed to one batched forward-mode autodiff evaluation of the Venkatarathnam-Oellrich parameter. Smaller chunks reduce temporary memory; larger chunks reduce repeated nested-JVP setup and result assembly. The default balances throughput with temporary memory for multicomponent CPU batches. This setting does not couple states or change the defining equations.

4096
response_autodiff_chunk_size int

Positive maximum number of independent equilibrium phases passed to one batched forward-mode autodiff evaluation of either response derivative criterion. Each requested method is evaluated independently; work is not shared between methods.

2048

Returns:

Type Description
GridPhaseIdentification

Per-phase identities, native criterion values and thresholds, ambiguity flags, paper-style region codes, total elapsed time, and one measured elapsed time per requested method.

Raises:

Type Description
ValueError

If no method is requested, a method is repeated, the equilibrium arrays are inconsistent, or a criterion option is invalid.

TypeError

If the model lacks a property required by a requested method and that method cannot report itself as unavailable.

Notes

A non-converged equilibrium state or an unavailable/unknown phase identity maps to the "unavailable" region. For two phases, any vapor-like phase maps to "LV" and otherwise to "LL". Three phases share one plotting category; inspect phase_identity_codes for the exact labels.

The two response-derivative criteria and the Venkatarathnam-Oellrich pressure-derivative parameter evaluate first and second PyTorch autodiff derivatives at every equilibrium composition. The Li pseudo-critical-temperature and Perschke negative-flash algebra is also evaluated over one leading phase batch when the required component properties are tensors; models without those properties retain the scalar unavailable-result behavior. Each requested method is executed as an independent complete pass, so method_elapsed_seconds represents the practical single-method workload rather than shared multi-method work. The returned grid tensors are detached intentionally; the scalar :func:identify_phase API retains its autodiff graph.

Source code in src/torch_flash/flash/grid.py
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
def identify_grid_phases(
    model: StateModel,
    equilibrium: GridEquilibrium,
    *,
    methods: tuple[PhaseIdentificationCriterion, ...] = DEFAULT_GRID_PHASE_IDENTIFICATION_METHODS,
    phase: PhaseKind = "stable",
    volume_to_covolume_threshold: float = 1.75,
    pseudo_critical_temperature_factor: float = 1.0,
    ambiguity_relative_tolerance: float = 0.05,
    pip_denominator_relative_tolerance: float | None = None,
    pip_autodiff_chunk_size: int = 4096,
    response_autodiff_chunk_size: int = 2048,
) -> GridPhaseIdentification:
    """Identify each equilibrium phase and summarize paper-style regions.

    Equilibrium phase count and physical identity remain separate: this
    function never changes the flash result. Each method is applied to every
    converged phase at its equilibrium composition.

    Parameters
    ----------
    model
        Homogeneous-state model used to produce ``equilibrium``. It must expose
        the properties required by every requested criterion.
    equilibrium
        Result returned by :func:`flash_grid` or
        :func:`flash_grid_oracle`.
    methods
        Unique ordered criteria to evaluate. The default is the five Bennett
        and Schmidt methods plus the Venkatarathnam-Oellrich
        pressure-derivative parameter.
    phase
        Homogeneous EoS root request used while evaluating each already
        separated equilibrium composition. ``"stable"`` is normally
        appropriate.
    volume_to_covolume_threshold
        Dimensionless Pedersen ``V/b`` separator. Values above the threshold
        are vapor-like.
    pseudo_critical_temperature_factor
        Positive factor multiplying Li's volume-weighted pseudo-critical
        temperature. Bennett and Schmidt use one.
    ambiguity_relative_tolerance
        Nonnegative relative band around each criterion threshold. It changes
        only ``ambiguous`` flags, not the sign-based identity.
    pip_denominator_relative_tolerance
        Optional scaled singularity threshold for the dimensionless
        Venkatarathnam-Oellrich criterion. ``None`` uses the square root of
        machine epsilon. It is ignored by all other methods.
    pip_autodiff_chunk_size
        Positive maximum number of independent equilibrium phases passed to
        one batched forward-mode autodiff evaluation of the
        Venkatarathnam-Oellrich parameter. Smaller chunks reduce temporary
        memory; larger chunks reduce repeated nested-JVP setup and result
        assembly. The default balances throughput with temporary memory for
        multicomponent CPU batches. This setting does not couple states or
        change the defining equations.
    response_autodiff_chunk_size
        Positive maximum number of independent equilibrium phases passed to
        one batched forward-mode autodiff evaluation of either response
        derivative criterion. Each requested method is evaluated
        independently; work is not shared between methods.

    Returns
    -------
    GridPhaseIdentification
        Per-phase identities, native criterion values and thresholds,
        ambiguity flags, paper-style region codes, total elapsed time, and one
        measured elapsed time per requested method.

    Raises
    ------
    ValueError
        If no method is requested, a method is repeated, the equilibrium
        arrays are inconsistent, or a criterion option is invalid.
    TypeError
        If the model lacks a property required by a requested method and that
        method cannot report itself as unavailable.

    Notes
    -----
    A non-converged equilibrium state or an unavailable/unknown phase identity
    maps to the ``"unavailable"`` region. For two phases, any vapor-like phase
    maps to ``"LV"`` and otherwise to ``"LL"``. Three phases share one plotting
    category; inspect ``phase_identity_codes`` for the exact labels.

    The two response-derivative criteria and the
    Venkatarathnam-Oellrich pressure-derivative parameter evaluate first and
    second PyTorch autodiff derivatives at every equilibrium composition. The
    Li pseudo-critical-temperature and Perschke negative-flash algebra is also
    evaluated over one leading phase batch when the required component
    properties are tensors; models without those properties retain the scalar
    unavailable-result behavior. Each requested method is executed as an
    independent complete pass, so ``method_elapsed_seconds`` represents the
    practical single-method workload rather than shared multi-method work. The
    returned grid tensors are detached intentionally; the scalar
    :func:`identify_phase` API retains its autodiff graph.
    """
    from torch_flash.properties.phase_identification import (
        _negative_flash_scale,
        _phase_response_details,
        identify_phase,
        li_pseudo_critical_temperature,
        negative_flash_residual,
        phase_identification_parameter,
        volume_to_covolume_ratio,
    )

    if not methods:
        raise ValueError("at least one phase-identification method is required")
    if len(set(methods)) != len(methods):
        raise ValueError("phase-identification methods must be unique")
    if not math.isfinite(volume_to_covolume_threshold) or volume_to_covolume_threshold <= 0.0:
        raise ValueError("volume-to-covolume threshold must be finite and positive")
    if (
        not math.isfinite(pseudo_critical_temperature_factor)
        or pseudo_critical_temperature_factor <= 0.0
    ):
        raise ValueError("pseudo-critical-temperature factor must be finite and positive")
    if not math.isfinite(ambiguity_relative_tolerance) or ambiguity_relative_tolerance < 0.0:
        raise ValueError("ambiguity relative tolerance must be finite and non-negative")
    if pip_denominator_relative_tolerance is not None and (
        not math.isfinite(pip_denominator_relative_tolerance)
        or pip_denominator_relative_tolerance < 0.0
    ):
        raise ValueError("PIP denominator relative tolerance must be finite and non-negative")
    if pip_autodiff_chunk_size < 1:
        raise ValueError("pip_autodiff_chunk_size must be positive")
    if response_autodiff_chunk_size < 1:
        raise ValueError("response_autodiff_chunk_size must be positive")
    state_count = equilibrium.temperatures.numel()
    if equilibrium.phase_counts.shape != (state_count,):
        raise ValueError("equilibrium phase counts do not match the state batch")

    result_shape = (len(methods), state_count, MAX_PHASES)
    identities = torch.full(
        result_shape,
        -1,
        dtype=torch.int8,
        device=equilibrium.feeds.device,
    )
    criterion_values = equilibrium.temperatures.new_full(result_shape, torch.nan)
    thresholds = equilibrium.temperatures.new_full(result_shape, torch.nan)
    ambiguous = torch.ones(
        result_shape,
        dtype=torch.bool,
        device=equilibrium.feeds.device,
    )
    region_codes = torch.full(
        (len(methods), state_count),
        GRID_PHASE_REGION_LABELS.index("unavailable"),
        dtype=torch.int8,
        device=equilibrium.feeds.device,
    )
    started = time.perf_counter()
    method_elapsed_seconds = []

    def active_phase_indices() -> tuple[Tensor, Tensor, Tensor]:
        active = equilibrium.converged[:, None] & (
            torch.arange(MAX_PHASES, device=equilibrium.feeds.device)[None, :]
            < equilibrium.phase_counts[:, None]
        )
        state_indices, phase_indices = torch.nonzero(active, as_tuple=True)
        return active, state_indices, phase_indices

    def store_batched_identification(
        method_index: int,
        active: Tensor,
        state_indices: Tensor,
        phase_indices: Tensor,
        criterion: Tensor,
        threshold: Tensor,
        *,
        vapor_if_positive: bool,
        ambiguity_margin: Tensor,
        ambiguity_limit: Tensor,
    ) -> None:
        positive = criterion > threshold
        vapor = positive if vapor_if_positive else ~positive
        identities[method_index, state_indices, phase_indices] = vapor.to(torch.int8)
        criterion_values[method_index, state_indices, phase_indices] = criterion.detach()
        thresholds[method_index, state_indices, phase_indices] = torch.broadcast_to(
            threshold,
            criterion.shape,
        ).detach()
        ambiguous[method_index, state_indices, phase_indices] = (
            ambiguity_margin.abs() <= ambiguity_limit
        ).detach()

        state_identities = identities[method_index]
        batched_has_vapor = ((state_identities == 1) & active).any(dim=-1)
        labels = torch.where(
            equilibrium.phase_counts == 1,
            torch.where(batched_has_vapor, 0, 1),
            torch.where(
                equilibrium.phase_counts == 2,
                torch.where(batched_has_vapor, 2, 3),
                4,
            ),
        )
        region_codes[method_index, equilibrium.converged] = labels[equilibrium.converged].to(
            torch.int8
        )

    for method_index, method in enumerate(methods):
        method_started = time.perf_counter()
        if method == "li-pseudo-critical-temperature" and all(
            isinstance(getattr(model, name, None), Tensor)
            for name in ("critical_temperature", "critical_volume")
        ):
            active, state_indices, phase_indices = active_phase_indices()
            if state_indices.numel() == 0:
                method_elapsed_seconds.append(time.perf_counter() - method_started)
                continue
            pseudo_critical = li_pseudo_critical_temperature(
                model,
                equilibrium.phase_compositions[state_indices, phase_indices],
                factor=pseudo_critical_temperature_factor,
            )
            ratio = equilibrium.temperatures[state_indices] / pseudo_critical
            one = ratio.new_tensor(1.0)
            store_batched_identification(
                method_index,
                active,
                state_indices,
                phase_indices,
                ratio,
                one,
                vapor_if_positive=True,
                ambiguity_margin=torch.log(ratio),
                ambiguity_limit=torch.log1p(
                    ratio.new_tensor(ambiguity_relative_tolerance),
                ),
            )
            method_elapsed_seconds.append(time.perf_counter() - method_started)
            continue

        if method == "perschke-negative-flash" and all(
            isinstance(getattr(model, name, None), Tensor)
            for name in ("critical_temperature", "critical_pressure", "acentric_factor")
        ):
            active, state_indices, phase_indices = active_phase_indices()
            if state_indices.numel() == 0:
                method_elapsed_seconds.append(time.perf_counter() - method_started)
                continue
            state = ChemicalState(
                equilibrium.temperatures[state_indices],
                equilibrium.pressures[state_indices],
                equilibrium.phase_compositions[state_indices, phase_indices],
            )
            residual = negative_flash_residual(model, state)
            scale = _negative_flash_scale(model, state)
            zero = residual.new_zeros(())
            store_batched_identification(
                method_index,
                active,
                state_indices,
                phase_indices,
                residual,
                zero,
                vapor_if_positive=True,
                ambiguity_margin=residual,
                ambiguity_limit=scale * ambiguity_relative_tolerance,
            )
            method_elapsed_seconds.append(time.perf_counter() - method_started)
            continue

        if (
            method == "pedersen-volume-to-covolume"
            and callable(getattr(model, "select_z", None))
            and callable(getattr(model, "mixture_parameters", None))
        ):
            active, state_indices, phase_indices = active_phase_indices()
            if state_indices.numel() == 0:
                method_elapsed_seconds.append(time.perf_counter() - method_started)
                continue
            batched_succeeded = True
            try:
                ratio = volume_to_covolume_ratio(
                    model,
                    ChemicalState(
                        equilibrium.temperatures[state_indices],
                        equilibrium.pressures[state_indices],
                        equilibrium.phase_compositions[state_indices, phase_indices],
                    ),
                    phase,
                )
            except ValueError:
                batched_succeeded = False
            if batched_succeeded:
                threshold = ratio.new_tensor(volume_to_covolume_threshold)
                store_batched_identification(
                    method_index,
                    active,
                    state_indices,
                    phase_indices,
                    ratio,
                    threshold,
                    vapor_if_positive=True,
                    ambiguity_margin=torch.log(ratio / threshold),
                    ambiguity_limit=torch.log1p(ratio.new_tensor(ambiguity_relative_tolerance)),
                )
                method_elapsed_seconds.append(time.perf_counter() - method_started)
                continue

        if (
            method
            in (
                "pasad-isothermal-compressibility-derivative",
                "bennett-thermal-expansion-derivative",
            )
            and callable(getattr(model, "pressure", None))
            and callable(getattr(model, "molar_volume", None))
        ):
            active, state_indices, phase_indices = active_phase_indices()
            if state_indices.numel() == 0:
                method_elapsed_seconds.append(time.perf_counter() - method_started)
                continue
            criterion_chunks: list[Tensor] = []
            scale_chunks: list[Tensor] = []
            batched_succeeded = True
            try:
                for start in range(0, state_indices.numel(), response_autodiff_chunk_size):
                    stop = min(
                        start + response_autodiff_chunk_size,
                        state_indices.numel(),
                    )
                    current_states = state_indices[start:stop]
                    current_phases = phase_indices[start:stop]
                    response = _phase_response_details(
                        model,
                        ChemicalState(
                            equilibrium.temperatures[current_states],
                            equilibrium.pressures[current_states],
                            equilibrium.phase_compositions[
                                current_states,
                                current_phases,
                            ],
                        ),
                        phase,
                    )
                    if method == "pasad-isothermal-compressibility-derivative":
                        criterion_chunks.append(
                            response.values.isothermal_compressibility_temperature_derivative
                        )
                        scale_chunks.append(response.compressibility_derivative_scale)
                    else:
                        criterion_chunks.append(
                            response.values.thermal_expansion_temperature_derivative
                        )
                        scale_chunks.append(response.expansion_derivative_scale)
            except ValueError:
                batched_succeeded = False
            if batched_succeeded:
                criterion = torch.cat(criterion_chunks)
                scale = torch.cat(scale_chunks)
                zero = criterion.new_zeros(())
                store_batched_identification(
                    method_index,
                    active,
                    state_indices,
                    phase_indices,
                    criterion,
                    zero,
                    vapor_if_positive=False,
                    ambiguity_margin=criterion,
                    ambiguity_limit=scale * ambiguity_relative_tolerance,
                )
                method_elapsed_seconds.append(time.perf_counter() - method_started)
                continue

        if (
            method == "venkatarathnam-oellrich-phase-identification-parameter"
            and callable(getattr(model, "pressure", None))
            and callable(getattr(model, "molar_volume", None))
        ):
            active, state_indices, phase_indices = active_phase_indices()
            if state_indices.numel() == 0:
                method_elapsed_seconds.append(time.perf_counter() - method_started)
                continue
            parameter_chunks: list[Tensor] = []
            batched_succeeded = True
            try:
                for start in range(0, state_indices.numel(), pip_autodiff_chunk_size):
                    stop = min(start + pip_autodiff_chunk_size, state_indices.numel())
                    current_states = state_indices[start:stop]
                    current_phases = phase_indices[start:stop]
                    parameter_chunks.append(
                        phase_identification_parameter(
                            model,
                            ChemicalState(
                                equilibrium.temperatures[current_states],
                                equilibrium.pressures[current_states],
                                equilibrium.phase_compositions[
                                    current_states,
                                    current_phases,
                                ],
                            ),
                            phase,
                            denominator_relative_tolerance=(pip_denominator_relative_tolerance),
                        )
                    )
            except ValueError:
                # Preserve the scalar API's per-state unavailable behavior if
                # one member of a batch fails a physical or singularity gate.
                batched_succeeded = False

            if batched_succeeded:
                parameter = torch.cat(parameter_chunks)
                one = parameter.new_tensor(1.0)
                store_batched_identification(
                    method_index,
                    active,
                    state_indices,
                    phase_indices,
                    parameter,
                    one,
                    vapor_if_positive=False,
                    ambiguity_margin=parameter - one,
                    ambiguity_limit=one * ambiguity_relative_tolerance,
                )
                method_elapsed_seconds.append(time.perf_counter() - method_started)
                continue

        for state_index in range(state_count):
            if not bool(equilibrium.converged[state_index]):
                continue
            phase_count = int(equilibrium.phase_counts[state_index])
            available = True
            for phase_index in range(phase_count):
                state = ChemicalState(
                    equilibrium.temperatures[state_index],
                    equilibrium.pressures[state_index],
                    equilibrium.phase_compositions[state_index, phase_index],
                )
                identification = identify_phase(
                    model,
                    state,
                    phase,
                    method=method,
                    threshold=volume_to_covolume_threshold,
                    pseudo_critical_temperature_factor=(pseudo_critical_temperature_factor),
                    ambiguity_relative_tolerance=ambiguity_relative_tolerance,
                    pip_denominator_relative_tolerance=(pip_denominator_relative_tolerance),
                )
                if identification.kind == "unknown":
                    available = False
                    break
                identities[method_index, state_index, phase_index] = (
                    1 if identification.kind == "vapor" else 0
                )
                ambiguous[method_index, state_index, phase_index] = identification.ambiguous
                if identification.criterion_value is not None:
                    criterion_values[method_index, state_index, phase_index] = (
                        identification.criterion_value.detach()
                    )
                if identification.threshold is not None:
                    thresholds[method_index, state_index, phase_index] = (
                        identification.threshold.detach()
                    )
            if not available:
                continue
            has_vapor = bool((identities[method_index, state_index, :phase_count] == 1).any())
            if phase_count == 1:
                label = "V" if has_vapor else "L"
            elif phase_count == 2:
                label = "LV" if has_vapor else "LL"
            else:
                label = "three-phase"
            region_codes[method_index, state_index] = GRID_PHASE_REGION_LABELS.index(label)
        method_elapsed_seconds.append(time.perf_counter() - method_started)

    return GridPhaseIdentification(
        methods=methods,
        phase_identity_codes=identities,
        criterion_values=criterion_values,
        thresholds=thresholds,
        ambiguous=ambiguous,
        region_codes=region_codes.reshape((len(methods), *equilibrium.grid_shape)),
        elapsed_seconds=time.perf_counter() - started,
        method_elapsed_seconds=tuple(method_elapsed_seconds),
    )

polish_grid_equilibrium_with_trust_region

polish_grid_equilibrium_with_trust_region(
    model: StateModel,
    equilibrium: GridEquilibrium,
    *,
    tolerance: float = 1e-08,
    material_balance_tolerance: float = 5e-11,
    phase_fraction_tolerance: float = 1e-10,
    phase_composition_merge_tolerance: float = 0.002,
    max_iterations: int = 100,
    chunk_size: int = 128,
) -> TrustRegionGridPolishResult

Audit a phase-discovered grid with batched Trust-Region minimization.

The calculation implements the improved mole-number (NLVM) Gibbs formulation of Petitfrere and Nichita with one dependent reference phase per component. Independently screened one-phase states are audited with the paper's modified-TPD Trust-Region objective. The phase count and starting compositions come from a completed :func:flash_grid or :func:flash_grid_oracle result. States sharing a phase count are evaluated in PyTorch batches; their exact Hessian blocks remain mathematically independent.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model used to produce equilibrium.

required
equilibrium GridEquilibrium

Phase-discovered grid containing temperatures in K, pressures in Pa, normalized feeds, phase fractions, and phase compositions.

required
tolerance float

Maximum dimensionless Gibbs-gradient and log-fugacity residual.

1e-08
material_balance_tolerance float

Maximum absolute component mole-fraction reconstruction residual.

5e-11
phase_fraction_tolerance float

Minimum positive molar fraction for every preserved phase.

1e-10
phase_composition_merge_tolerance float

Minimum maximum-component distance between every phase pair.

0.002
max_iterations int

Maximum accepted-or-rejected Trust-Region iterations per state.

100
chunk_size int

Maximum number of equal-phase-count states whose thermodynamic objectives, gradients, and exact Hessians are evaluated together.

128

Returns:

Type Description
TrustRegionGridPolishResult

Polished equilibrium plus per-state optimization, residual, curvature, and seed-change diagnostics.

Raises:

Type Description
ValueError

If controls are invalid, grid arrays are inconsistent, a discovered phase count lies outside one through three, or an attempted feed is not strictly positive.

Notes

This operation follows M. Petitfrere and D. V. Nichita, "Robust and efficient Trust-Region based stability analysis and multiphase flash calculations", Fluid Phase Equilibria 362 (2014), 51-68, equations (8)-(15) and sections 3.1-3.4, doi:10.1016/j.fluid.2013.08.039.

Phase discovery and local minimization answer different questions. The independent grid supplies the initial phase count and branch seeds. A negative one-phase Trust-Region TPD minimum may correct that count from one to two, but a local fixed-count minimum is still not a global stability proof.

Source code in src/torch_flash/flash/grid.py
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
def polish_grid_equilibrium_with_trust_region(
    model: StateModel,
    equilibrium: GridEquilibrium,
    *,
    tolerance: float = 1.0e-8,
    material_balance_tolerance: float = 5.0e-11,
    phase_fraction_tolerance: float = 1.0e-10,
    phase_composition_merge_tolerance: float = 2.0e-3,
    max_iterations: int = 100,
    chunk_size: int = 128,
) -> TrustRegionGridPolishResult:
    """Audit a phase-discovered grid with batched Trust-Region minimization.

    The calculation implements the improved mole-number (NLVM) Gibbs
    formulation of Petitfrere and Nichita with one dependent reference phase
    per component. Independently screened one-phase states are audited with
    the paper's modified-TPD Trust-Region objective. The phase count and
    starting compositions come from a completed :func:`flash_grid` or
    :func:`flash_grid_oracle` result. States sharing a phase count are
    evaluated in PyTorch batches; their exact Hessian blocks remain
    mathematically independent.

    Parameters
    ----------
    model
        Homogeneous-state model used to produce ``equilibrium``.
    equilibrium
        Phase-discovered grid containing temperatures in K, pressures in Pa,
        normalized feeds, phase fractions, and phase compositions.
    tolerance
        Maximum dimensionless Gibbs-gradient and log-fugacity residual.
    material_balance_tolerance
        Maximum absolute component mole-fraction reconstruction residual.
    phase_fraction_tolerance
        Minimum positive molar fraction for every preserved phase.
    phase_composition_merge_tolerance
        Minimum maximum-component distance between every phase pair.
    max_iterations
        Maximum accepted-or-rejected Trust-Region iterations per state.
    chunk_size
        Maximum number of equal-phase-count states whose thermodynamic
        objectives, gradients, and exact Hessians are evaluated together.

    Returns
    -------
    TrustRegionGridPolishResult
        Polished equilibrium plus per-state optimization, residual, curvature,
        and seed-change diagnostics.

    Raises
    ------
    ValueError
        If controls are invalid, grid arrays are inconsistent, a discovered
        phase count lies outside one through three, or an attempted feed is
        not strictly positive.

    Notes
    -----
    This operation follows M. Petitfrere and D. V. Nichita, "Robust and
    efficient Trust-Region based stability analysis and multiphase flash
    calculations", *Fluid Phase Equilibria* 362 (2014), 51-68, equations
    (8)-(15) and sections 3.1-3.4,
    doi:10.1016/j.fluid.2013.08.039.

    Phase discovery and local minimization answer different questions. The
    independent grid supplies the initial phase count and branch seeds. A
    negative one-phase Trust-Region TPD minimum may correct that count from one
    to two, but a local fixed-count minimum is still not a global stability
    proof.
    """
    positive_controls = (
        tolerance,
        material_balance_tolerance,
        phase_fraction_tolerance,
        phase_composition_merge_tolerance,
    )
    if any(not math.isfinite(value) or value <= 0.0 for value in positive_controls):
        raise ValueError("Trust-Region grid-polish tolerances must be finite and positive")
    if max_iterations <= 0 or chunk_size <= 0:
        raise ValueError("Trust-Region grid-polish iteration and chunk counts must be positive")

    state_count, component_count = equilibrium.feeds.shape
    expected_shapes = (
        equilibrium.temperatures.shape == (state_count,),
        equilibrium.pressures.shape == (state_count,),
        equilibrium.phase_fractions.shape == (state_count, MAX_PHASES),
        equilibrium.phase_compositions.shape == (state_count, MAX_PHASES, component_count),
        equilibrium.phase_counts.shape == (state_count,),
        equilibrium.converged.shape == (state_count,),
    )
    if not all(expected_shapes) or math.prod(equilibrium.grid_shape) != state_count:
        raise ValueError("Trust-Region grid-polish arrays are inconsistent")
    if bool(((equilibrium.phase_counts < 1) | (equilibrium.phase_counts > MAX_PHASES)).any()):
        raise ValueError("Trust-Region grid-polish phase counts must lie between one and three")

    started = time.perf_counter()
    attempted = torch.zeros(
        state_count,
        dtype=torch.bool,
        device=equilibrium.feeds.device,
    )
    trust_converged = torch.zeros_like(attempted)
    iterations = torch.zeros(
        state_count,
        dtype=torch.int64,
        device=equilibrium.feeds.device,
    )
    accepted_steps = torch.zeros_like(iterations)
    rejected_steps = torch.zeros_like(iterations)
    gradient_norm = equilibrium.temperatures.new_full((state_count,), torch.nan)
    minimum_hessian_eigenvalue = equilibrium.temperatures.new_full(
        (state_count,),
        torch.nan,
    )
    minimum_tpd = equilibrium.temperatures.new_full((state_count,), torch.nan)
    maximum_phase_composition_change = equilibrium.temperatures.new_full(
        (state_count,),
        torch.nan,
    )
    polished_fractions = equilibrium.phase_fractions.clone()
    polished_compositions = equilibrium.phase_compositions.clone()
    polished_fugacity_residual = equilibrium.fugacity_residual.clone()
    polished_material_balance_residual = equilibrium.material_balance_residual.clone()
    polished_gibbs_reduction = equilibrium.gibbs_reduction.clone()
    polished_converged = equilibrium.converged.clone()
    polished_phase_counts = equilibrium.phase_counts.clone()
    seed_phase_counts = equilibrium.phase_counts.clone()
    seed_fractions = equilibrium.phase_fractions.clone()
    seed_compositions = equilibrium.phase_compositions.clone()

    with torch.no_grad():
        one_phase_log_phi = model.log_fugacity_coefficients(
            equilibrium.temperatures,
            equilibrium.pressures,
            equilibrium.feeds,
            "stable",
        )
        one_phase_gibbs = torch.sum(
            equilibrium.feeds
            * (
                torch.log(
                    torch.clamp_min(equilibrium.feeds, torch.finfo(equilibrium.feeds.dtype).tiny)
                )
                + one_phase_log_phi
            ),
            dim=-1,
        )

    one_phase_indices = torch.nonzero(
        equilibrium.converged & (equilibrium.phase_counts == 1),
    ).flatten()
    for start in range(0, one_phase_indices.numel(), chunk_size):
        selected = one_phase_indices[start : start + chunk_size]
        temperature = equilibrium.temperatures[selected]
        pressure = equilibrium.pressures[selected]
        feed = equilibrium.feeds[selected]
        if not bool(torch.isfinite(feed).all() & (feed > 0.0).all()):
            raise ValueError(
                "Trust-Region grid polish requires finite strictly positive attempted feeds"
            )
        state = ChemicalState(temperature, pressure, feed)
        screening = batched_tangent_plane_stability(
            model,
            state,
            tolerance=max(tolerance, 1.0e-10),
            max_iterations=max_iterations,
        )
        reference = torch.log(feed) + model.log_fugacity_coefficients(
            temperature,
            pressure,
            feed,
            "stable",
        )
        formal_moles = screening.trial_composition * torch.exp(-screening.minimum_tpd)[:, None]
        batch_size = selected.numel()

        def stability_objective(
            current_formal_moles: Tensor,
            *,
            current_temperature: Tensor = temperature,
            current_pressure: Tensor = pressure,
            current_reference: Tensor = reference,
        ) -> Tensor:
            total = current_formal_moles.sum(dim=-1)
            trial_composition = current_formal_moles / total[:, None]
            log_phi = model.log_fugacity_coefficients(
                current_temperature,
                current_pressure,
                trial_composition,
                "stable",
            )
            return (
                1.0
                - total
                + torch.sum(
                    current_formal_moles
                    * (torch.log(current_formal_moles) + log_phi - current_reference),
                    dim=-1,
                )
            )

        def stability_feasible(
            current_formal_moles: Tensor,
            *,
            current_batch_size: int = batch_size,
        ) -> Tensor:
            return (
                (
                    torch.isfinite(current_formal_moles)
                    & (current_formal_moles > torch.finfo(current_formal_moles.dtype).tiny)
                )
                .all(dim=-1)
                .reshape(current_batch_size)
            )

        initial_radius = torch.clamp(
            torch.linalg.vector_norm(formal_moles.detach(), dim=-1) / 10.0,
            min=1.0e-3,
            max=0.1,
        )
        trust_result = minimize_batched_dense_trust_region(
            stability_objective,
            formal_moles,
            is_feasible=stability_feasible,
            initial_radius=initial_radius,
            maximum_radius=torch.maximum(
                10.0 * initial_radius,
                initial_radius.new_ones(initial_radius.shape),
            ),
            gradient_tolerance=tolerance,
            max_iterations=max_iterations,
        )
        current_converged = trust_result.converged & (trust_result.objective >= -10.0 * tolerance)
        attempted[selected] = True
        trust_converged[selected] = current_converged.detach()
        iterations[selected] = trust_result.iterations.detach()
        accepted_steps[selected] = trust_result.accepted_steps.detach()
        rejected_steps[selected] = trust_result.rejected_steps.detach()
        gradient_norm[selected] = trust_result.gradient_norm.detach()
        minimum_hessian_eigenvalue[selected] = trust_result.minimum_hessian_eigenvalue.detach()
        minimum_tpd[selected] = trust_result.objective.detach()
        polished_converged[selected] = current_converged.detach()

        unstable = trust_result.converged & (trust_result.objective < -10.0 * tolerance)
        trial_composition = trust_result.solution / trust_result.solution.sum(
            dim=-1,
            keepdim=True,
        )
        initial_k = trial_composition / feed
        straddles_unity = (initial_k.amin(dim=-1) < 1.0) & (initial_k.amax(dim=-1) > 1.0)
        recovery_local = torch.nonzero(unstable & straddles_unity).flatten()
        if recovery_local.numel():
            recovery = batched_two_phase_flash(
                model,
                ChemicalState(
                    temperature[recovery_local],
                    pressure[recovery_local],
                    feed[recovery_local],
                ),
                initial_k_values=initial_k[recovery_local],
                phase_roots=("stable", "stable"),
                tolerance=tolerance,
                substitution_iterations=40,
                newton_iterations=16,
            )
            recovery_fractions = torch.stack(
                (
                    recovery.liquid_fraction,
                    recovery.vapor_fraction,
                ),
                dim=-1,
            )
            recovery_compositions = torch.stack(
                (
                    recovery.liquid_composition,
                    recovery.vapor_composition,
                ),
                dim=1,
            )
            recovery_separation = (
                (recovery_compositions[:, 0] - recovery_compositions[:, 1]).abs().amax(dim=-1)
            )
            recovered = (
                recovery.converged
                & (recovery_fractions.amin(dim=-1) > phase_fraction_tolerance)
                & (recovery_separation > phase_composition_merge_tolerance)
            )
            recovered_indices = selected[recovery_local[recovered]]
            seed_phase_counts[recovered_indices] = 2
            seed_fractions[recovered_indices] = 0.0
            seed_compositions[recovered_indices] = torch.nan
            seed_fractions[recovered_indices, :2] = recovery_fractions[recovered]
            seed_compositions[recovered_indices, :2] = recovery_compositions[recovered]
            polished_phase_counts[recovered_indices] = 2

    for phase_count in range(2, MAX_PHASES + 1):
        phase_indices = torch.nonzero(
            equilibrium.converged & (seed_phase_counts == phase_count),
        ).flatten()
        for start in range(0, phase_indices.numel(), chunk_size):
            selected = phase_indices[start : start + chunk_size]
            temperature = equilibrium.temperatures[selected]
            pressure = equilibrium.pressures[selected]
            feed = equilibrium.feeds[selected]
            if not bool(torch.isfinite(feed).all() & (feed > 0.0).all()):
                raise ValueError(
                    "Trust-Region grid polish requires finite strictly positive attempted feeds"
                )
            initial_fractions = seed_fractions[selected, :phase_count]
            initial_compositions = seed_compositions[
                selected,
                :phase_count,
            ]
            initial_phase_moles = initial_fractions[..., None] * initial_compositions
            reference_phase = torch.argmax(initial_phase_moles, dim=1)
            phase_axis = torch.arange(
                phase_count,
                dtype=torch.int64,
                device=feed.device,
            ).view(1, 1, phase_count)
            phase_axis = phase_axis.expand(
                selected.numel(),
                component_count,
                phase_count,
            )
            independent_phase = phase_axis[phase_axis != reference_phase[..., None]].reshape(
                selected.numel(), component_count, phase_count - 1
            )
            independent_moles = torch.gather(
                initial_phase_moles.mT,
                2,
                independent_phase,
            )
            mole_floor = 16.0 * torch.finfo(feed.dtype).eps * feed
            independent_moles = torch.maximum(
                independent_moles,
                mole_floor[..., None],
            )
            independent_total = independent_moles.sum(dim=-1)
            independent_scale = torch.minimum(
                torch.ones_like(feed),
                (feed - 2.0 * mole_floor) / independent_total,
            )
            independent_moles = independent_moles * independent_scale[..., None]
            independent_selector = torch.nn.functional.one_hot(
                independent_phase,
                num_classes=phase_count,
            ).to(dtype=feed.dtype)
            reference_selector = torch.nn.functional.one_hot(
                reference_phase,
                num_classes=phase_count,
            ).mT.to(dtype=feed.dtype)

            batch_size = selected.numel()

            def quantities(
                flattened: Tensor,
                *,
                current_batch_size: int = batch_size,
                current_phase_count: int = phase_count,
                current_independent_selector: Tensor = independent_selector,
                current_reference_selector: Tensor = reference_selector,
                current_feed: Tensor = feed,
                current_temperature: Tensor = temperature,
                current_pressure: Tensor = pressure,
            ) -> tuple[Tensor, Tensor, Tensor, Tensor]:
                independent = flattened.reshape(
                    current_batch_size,
                    component_count,
                    current_phase_count - 1,
                )
                independent_by_phase = torch.einsum(
                    "bnq,bnqp->bpn",
                    independent,
                    current_independent_selector,
                )
                reference_moles = current_feed - independent.sum(dim=-1)
                phase_moles = (
                    independent_by_phase + current_reference_selector * reference_moles[:, None, :]
                )
                fractions = phase_moles.sum(dim=-1)
                compositions = phase_moles / fractions[..., None]
                chemical_potential = torch.log(compositions) + model.log_fugacity_coefficients(
                    current_temperature[:, None],
                    current_pressure[:, None],
                    compositions,
                    "stable",
                )
                gibbs = torch.sum(
                    phase_moles * chemical_potential,
                    dim=(-1, -2),
                )
                return gibbs, fractions, compositions, chemical_potential

            flattened_initial = independent_moles.reshape(selected.numel(), -1)

            def feasible(
                flattened: Tensor,
                *,
                current_batch_size: int = batch_size,
                current_phase_count: int = phase_count,
                current_mole_floor: Tensor = mole_floor,
                current_feed: Tensor = feed,
            ) -> Tensor:
                independent = flattened.reshape(
                    current_batch_size,
                    component_count,
                    current_phase_count - 1,
                )
                return (
                    torch.isfinite(independent).all(dim=(-1, -2))
                    & (independent > current_mole_floor[..., None]).all(dim=(-1, -2))
                    & (independent.sum(dim=-1) < current_feed - current_mole_floor).all(dim=-1)
                )

            initial_radius = torch.clamp_min(
                torch.linalg.vector_norm(flattened_initial.detach(), dim=-1) / 10.0,
                0.1,
            )
            trust_result = minimize_batched_dense_trust_region(
                lambda flattened: quantities(flattened)[0],
                flattened_initial,
                is_feasible=feasible,
                initial_radius=initial_radius,
                maximum_radius=10.0 * initial_radius,
                gradient_tolerance=tolerance,
                max_iterations=max_iterations,
            )
            _, fractions, compositions, chemical_potential = quantities(trust_result.solution)
            fugacity_residual = (
                (chemical_potential[:, 1:] - chemical_potential[:, :1]).abs().amax(dim=(-1, -2))
            )
            reconstructed = torch.sum(fractions[..., None] * compositions, dim=1)
            material_balance_residual = (reconstructed - feed).abs().amax(dim=-1)
            pair_distance = (
                (compositions[:, :, None, :] - compositions[:, None, :, :]).abs().amax(dim=-1)
            )
            pair_distance = pair_distance.masked_fill(
                torch.eye(
                    phase_count,
                    dtype=torch.bool,
                    device=feed.device,
                )[None, ...],
                torch.inf,
            )
            separated = pair_distance.amin(dim=(-1, -2)) > phase_composition_merge_tolerance
            physical = fractions.amin(dim=-1) > phase_fraction_tolerance
            current_converged = (
                trust_result.converged
                & torch.isfinite(fugacity_residual)
                & (fugacity_residual <= tolerance)
                & (material_balance_residual <= material_balance_tolerance)
                & physical
                & separated
            )
            split_gibbs = torch.sum(
                fractions
                * torch.sum(
                    compositions * chemical_potential,
                    dim=-1,
                ),
                dim=-1,
            )

            attempted[selected] = True
            trust_converged[selected] = current_converged.detach()
            iterations[selected] = trust_result.iterations.detach()
            accepted_steps[selected] = trust_result.accepted_steps.detach()
            rejected_steps[selected] = trust_result.rejected_steps.detach()
            gradient_norm[selected] = trust_result.gradient_norm.detach()
            minimum_hessian_eigenvalue[selected] = trust_result.minimum_hessian_eigenvalue.detach()
            maximum_phase_composition_change[selected] = (
                (compositions.detach() - initial_compositions).abs().amax(dim=(-1, -2))
            )
            polished_fractions[selected, :phase_count] = fractions.detach()
            polished_compositions[selected, :phase_count] = compositions.detach()
            polished_fugacity_residual[selected] = fugacity_residual.detach()
            polished_material_balance_residual[selected] = material_balance_residual.detach()
            polished_gibbs_reduction[selected] = one_phase_gibbs[selected] - split_gibbs.detach()
            polished_converged[selected] = current_converged.detach()

    elapsed_seconds = time.perf_counter() - started
    polished = GridEquilibrium(
        equilibrium.temperatures,
        equilibrium.pressures,
        equilibrium.feeds,
        equilibrium.grid_shape,
        polished_fractions,
        polished_compositions,
        polished_phase_counts,
        polished_gibbs_reduction,
        polished_fugacity_residual,
        polished_material_balance_residual,
        polished_converged,
        equilibrium.elapsed_seconds + elapsed_seconds,
        equilibrium.batched_search_seconds,
        equilibrium.refinement_seconds + elapsed_seconds,
        equilibrium.difficult_state_count,
        equilibrium.topology_audit_count,
        equilibrium.initial_fallback_replacements,
        equilibrium.topology_audit_replacements,
    )
    return TrustRegionGridPolishResult(
        polished,
        attempted,
        trust_converged,
        iterations,
        accepted_steps,
        rejected_steps,
        gradient_norm,
        minimum_hessian_eigenvalue,
        minimum_tpd,
        maximum_phase_composition_change,
        elapsed_seconds,
    )

refine_flash_grid_phase_boundaries

refine_flash_grid_phase_boundaries(
    model: StateModel,
    temperature_axis: Tensor,
    pressure_axis: Tensor,
    composition: Tensor,
    region_codes: Tensor,
    converged: Tensor,
    *,
    identification_method: PhaseIdentificationCriterion = "pedersen-volume-to-covolume",
    region_labels: tuple[str, ...] = (
        "LV",
        "LL",
        "three-phase",
    ),
    refinement_iterations: int = 8,
    flash_options: GridFlashOptions | None = None,
) -> PhaseRegionBoundarySet

Refine classified phase-region boundaries by batched edge bisection.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model used for both the supplied grid and all refinement flashes.

required
temperature_axis Tensor

Strictly increasing one-dimensional grid coordinates in K and Pa.

required
pressure_axis Tensor

Strictly increasing one-dimensional grid coordinates in K and Pa.

required
composition Tensor

Fixed overall mole-fraction vector for the complete grid.

required
region_codes Tensor

Two-dimensional integer classification with shape (pressure_points, temperature_points). Values index :data:GRID_PHASE_REGION_LABELS.

required
converged Tensor

Boolean grid with the same shape. Edges touching a failed coarse state are excluded rather than relabeled.

required
identification_method PhaseIdentificationCriterion

One physical phase-identification criterion applied to every refinement flash. It must match the criterion used to construct region_codes.

'pedersen-volume-to-covolume'
region_labels tuple[str, ...]

Region labels to connect into curves. The default returns vapor-liquid, liquid-liquid, and identity-neutral three-phase boundaries.

('LV', 'LL', 'three-phase')
refinement_iterations int

Batched bisection passes per unique crossing edge. Eight passes reduce each coarse coordinate interval by a factor of 256.

8
flash_options GridFlashOptions | None

Optional :class:GridFlashOptions used for midpoint states. None uses the package defaults.

None

Returns:

Type Description
PhaseRegionBoundarySet

Connected curves and explicit refinement diagnostics.

Raises:

Type Description
ValueError

If axes, grid arrays, composition, labels, or numerical controls are invalid.

Notes

A coarse grid remains responsible for global topology. This operation spends additional equilibrium solves only on edges whose endpoint region codes differ, then connects the refined crossings with a marching-squares graph. It removes half-cell contour quantization without pretending that interpolation alone solves the phase-equilibrium equations.

Classification and graph connectivity are discrete and intentionally non-differentiable. Midpoint flashes retain the convergence, fugacity, and material-balance gates of :func:flash_grid. A failed midpoint leaves its edge bracket unchanged for that pass; it is counted rather than silently used as a phase classification.

Source code in src/torch_flash/flash/grid.py
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
4161
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
def refine_flash_grid_phase_boundaries(
    model: StateModel,
    temperature_axis: Tensor,
    pressure_axis: Tensor,
    composition: Tensor,
    region_codes: Tensor,
    converged: Tensor,
    *,
    identification_method: PhaseIdentificationCriterion = ("pedersen-volume-to-covolume"),
    region_labels: tuple[str, ...] = ("LV", "LL", "three-phase"),
    refinement_iterations: int = 8,
    flash_options: GridFlashOptions | None = None,
) -> PhaseRegionBoundarySet:
    """Refine classified phase-region boundaries by batched edge bisection.

    Parameters
    ----------
    model
        Homogeneous-state model used for both the supplied grid and all
        refinement flashes.
    temperature_axis, pressure_axis
        Strictly increasing one-dimensional grid coordinates in K and Pa.
    composition
        Fixed overall mole-fraction vector for the complete grid.
    region_codes
        Two-dimensional integer classification with shape
        ``(pressure_points, temperature_points)``. Values index
        :data:`GRID_PHASE_REGION_LABELS`.
    converged
        Boolean grid with the same shape. Edges touching a failed coarse state
        are excluded rather than relabeled.
    identification_method
        One physical phase-identification criterion applied to every
        refinement flash. It must match the criterion used to construct
        ``region_codes``.
    region_labels
        Region labels to connect into curves. The default returns vapor-liquid,
        liquid-liquid, and identity-neutral three-phase boundaries.
    refinement_iterations
        Batched bisection passes per unique crossing edge. Eight passes reduce
        each coarse coordinate interval by a factor of 256.
    flash_options
        Optional :class:`GridFlashOptions` used for midpoint states. ``None``
        uses the package defaults.

    Returns
    -------
    PhaseRegionBoundarySet
        Connected curves and explicit refinement diagnostics.

    Raises
    ------
    ValueError
        If axes, grid arrays, composition, labels, or numerical controls are
        invalid.

    Notes
    -----
    A coarse grid remains responsible for global topology. This operation
    spends additional equilibrium solves only on edges whose endpoint region
    codes differ, then connects the refined crossings with a marching-squares
    graph. It removes half-cell contour quantization without pretending that
    interpolation alone solves the phase-equilibrium equations.

    Classification and graph connectivity are discrete and intentionally
    non-differentiable. Midpoint flashes retain the convergence, fugacity, and
    material-balance gates of :func:`flash_grid`. A failed midpoint leaves its
    edge bracket unchanged for that pass; it is counted rather than silently
    used as a phase classification.
    """
    if (
        temperature_axis.ndim != 1
        or pressure_axis.ndim != 1
        or temperature_axis.numel() < 2
        or pressure_axis.numel() < 2
    ):
        raise ValueError("phase-boundary axes must be one-dimensional with at least two points")
    if (
        not temperature_axis.is_floating_point()
        or not pressure_axis.is_floating_point()
        or temperature_axis.dtype != pressure_axis.dtype
        or temperature_axis.device != pressure_axis.device
        or not bool(
            torch.isfinite(temperature_axis).all()
            & torch.isfinite(pressure_axis).all()
            & (temperature_axis > 0.0).all()
            & (pressure_axis > 0.0).all()
            & (torch.diff(temperature_axis) > 0.0).all()
            & (torch.diff(pressure_axis) > 0.0).all()
        )
    ):
        raise ValueError("phase-boundary axes must be matched, finite, positive, and increasing")
    expected_shape = (pressure_axis.numel(), temperature_axis.numel())
    if region_codes.shape != expected_shape or converged.shape != expected_shape:
        raise ValueError("phase-boundary classifications must match the pressure-temperature grid")
    if converged.dtype != torch.bool or region_codes.dtype == torch.bool:
        raise ValueError("phase-boundary convergence and region-code dtypes are invalid")
    composition = composition.to(
        dtype=temperature_axis.dtype,
        device=temperature_axis.device,
    )
    if (
        composition.ndim != 1
        or composition.numel() < 2
        or not bool(
            torch.isfinite(composition).all()
            & (composition > 0.0).all()
            & torch.isclose(
                composition.sum(),
                composition.new_tensor(1.0),
                rtol=1.0e-8,
                atol=1.0e-10,
            )
        )
    ):
        raise ValueError("phase-boundary composition must be one finite normalized vector")
    if refinement_iterations < 1:
        raise ValueError("phase-boundary refinement iterations must be positive")
    if (
        not region_labels
        or len(set(region_labels)) != len(region_labels)
        or any(label not in GRID_PHASE_REGION_LABELS for label in region_labels)
    ):
        raise ValueError("phase-boundary region labels must be unique known labels")
    if identification_method not in DEFAULT_GRID_PHASE_IDENTIFICATION_METHODS:
        raise ValueError("unknown phase-boundary identification method")

    codes = region_codes.detach().to(device=temperature_axis.device)
    valid = converged.detach().to(device=temperature_axis.device)
    crossing_nodes: dict[
        tuple[int, int, int],
        tuple[Tensor, Tensor, Tensor, Tensor, int],
    ] = {}
    pressure_points, temperature_points = expected_shape
    for pressure_index in range(pressure_points):
        for temperature_index in range(temperature_points - 1):
            if bool(
                valid[pressure_index, temperature_index]
                & valid[pressure_index, temperature_index + 1]
                & (
                    codes[pressure_index, temperature_index]
                    != codes[pressure_index, temperature_index + 1]
                )
            ):
                crossing_nodes[(0, pressure_index, temperature_index)] = (
                    temperature_axis[temperature_index],
                    pressure_axis[pressure_index],
                    temperature_axis[temperature_index + 1],
                    pressure_axis[pressure_index],
                    int(codes[pressure_index, temperature_index]),
                )
    for pressure_index in range(pressure_points - 1):
        for temperature_index in range(temperature_points):
            if bool(
                valid[pressure_index, temperature_index]
                & valid[pressure_index + 1, temperature_index]
                & (
                    codes[pressure_index, temperature_index]
                    != codes[pressure_index + 1, temperature_index]
                )
            ):
                crossing_nodes[(1, pressure_index, temperature_index)] = (
                    temperature_axis[temperature_index],
                    pressure_axis[pressure_index],
                    temperature_axis[temperature_index],
                    pressure_axis[pressure_index + 1],
                    int(codes[pressure_index, temperature_index]),
                )

    node_keys = tuple(crossing_nodes)
    if not node_keys:
        return PhaseRegionBoundarySet((), 0, 0, 0, 0, 0.0, 0.0)
    lower_temperature = torch.stack(tuple(crossing_nodes[key][0] for key in node_keys))
    lower_pressure = torch.stack(tuple(crossing_nodes[key][1] for key in node_keys))
    upper_temperature = torch.stack(tuple(crossing_nodes[key][2] for key in node_keys))
    upper_pressure = torch.stack(tuple(crossing_nodes[key][3] for key in node_keys))
    lower_code = torch.tensor(
        tuple(crossing_nodes[key][4] for key in node_keys),
        dtype=codes.dtype,
        device=codes.device,
    )
    failed_midpoints = 0
    for _ in range(refinement_iterations):
        midpoint_temperature = 0.5 * (lower_temperature + upper_temperature)
        midpoint_pressure = 0.5 * (lower_pressure + upper_pressure)
        midpoint_equilibrium = flash_grid(
            model,
            ChemicalState(
                midpoint_temperature,
                midpoint_pressure,
                composition,
            ),
            options=flash_options,
        )
        midpoint_identification = identify_grid_phases(
            model,
            midpoint_equilibrium,
            methods=(identification_method,),
        )
        midpoint_code = midpoint_identification.region_codes[0].reshape(-1)
        midpoint_converged = midpoint_equilibrium.converged.reshape(-1)
        failed_midpoints += int((~midpoint_converged).sum())
        same_as_lower = midpoint_converged & (midpoint_code == lower_code)
        different_from_lower = midpoint_converged & ~same_as_lower
        lower_temperature = torch.where(
            same_as_lower,
            midpoint_temperature,
            lower_temperature,
        )
        lower_pressure = torch.where(
            same_as_lower,
            midpoint_pressure,
            lower_pressure,
        )
        upper_temperature = torch.where(
            different_from_lower,
            midpoint_temperature,
            upper_temperature,
        )
        upper_pressure = torch.where(
            different_from_lower,
            midpoint_pressure,
            upper_pressure,
        )

    node_points = {
        key: (
            0.5 * (lower_temperature[index] + upper_temperature[index]),
            0.5 * (lower_pressure[index] + upper_pressure[index]),
        )
        for index, key in enumerate(node_keys)
    }
    target_codes = tuple(GRID_PHASE_REGION_LABELS.index(label) for label in region_labels)
    connections: dict[int, list[tuple[tuple[int, int, int], tuple[int, int, int]]]] = {
        target_code: [] for target_code in target_codes
    }
    ambiguous_cells = 0
    for pressure_index in range(pressure_points - 1):
        for temperature_index in range(temperature_points - 1):
            cell_valid = valid[
                pressure_index : pressure_index + 2,
                temperature_index : temperature_index + 2,
            ]
            if not bool(cell_valid.all()):
                continue
            corner_codes = (
                int(codes[pressure_index, temperature_index]),
                int(codes[pressure_index, temperature_index + 1]),
                int(codes[pressure_index + 1, temperature_index + 1]),
                int(codes[pressure_index + 1, temperature_index]),
            )
            edge_nodes = (
                (0, pressure_index, temperature_index),
                (1, pressure_index, temperature_index + 1),
                (0, pressure_index + 1, temperature_index),
                (1, pressure_index, temperature_index),
            )
            for target_code in target_codes:
                target_corners = tuple(code == target_code for code in corner_codes)
                crossings = [
                    edge_nodes[edge_index]
                    for edge_index, (first, second) in enumerate(
                        (
                            (target_corners[0], target_corners[1]),
                            (target_corners[1], target_corners[2]),
                            (target_corners[3], target_corners[2]),
                            (target_corners[0], target_corners[3]),
                        )
                    )
                    if first != second and edge_nodes[edge_index] in node_points
                ]
                if len(crossings) == 2:
                    connections[target_code].append((crossings[0], crossings[1]))
                elif len(crossings) == 4:
                    ambiguous_cells += 1
                    if target_corners[0]:
                        connections[target_code].extend(
                            ((crossings[0], crossings[3]), (crossings[1], crossings[2]))
                        )
                    else:
                        connections[target_code].extend(
                            ((crossings[0], crossings[1]), (crossings[2], crossings[3]))
                        )

    curves = []
    for target_code, target_connections in connections.items():
        adjacency: dict[tuple[int, int, int], list[tuple[int, int, int]]] = {}
        for first, second in target_connections:
            adjacency.setdefault(first, []).append(second)
            adjacency.setdefault(second, []).append(first)
        unused_edges = {frozenset((first, second)) for first, second in target_connections}
        starts = sorted(
            adjacency,
            key=lambda node: (len(adjacency[node]) == 2, node),
        )
        for start in starts:
            while any(
                frozenset((start, neighbor)) in unused_edges for neighbor in adjacency[start]
            ):
                path = [start]
                previous: tuple[int, int, int] | None = None
                current = start
                closed = False
                while True:
                    candidates = [
                        neighbor
                        for neighbor in adjacency[current]
                        if neighbor != previous and frozenset((current, neighbor)) in unused_edges
                    ]
                    if not candidates:
                        break
                    following = candidates[0]
                    unused_edges.remove(frozenset((current, following)))
                    previous, current = current, following
                    if current == start:
                        closed = True
                        path.append(current)
                        break
                    path.append(current)
                if len(path) < 2:
                    continue
                curves.append(
                    PhaseRegionBoundaryCurve(
                        target_code,
                        torch.stack(tuple(node_points[node][0] for node in path)),
                        torch.stack(tuple(node_points[node][1] for node in path)),
                        closed,
                    )
                )

    return PhaseRegionBoundarySet(
        tuple(curves),
        len(node_keys),
        len(node_keys) * refinement_iterations,
        failed_midpoints,
        ambiguous_cells,
        float(torch.max(torch.abs(upper_temperature - lower_temperature))),
        float(torch.max(torch.abs(upper_pressure - lower_pressure))),
    )

solve_batched_binary_three_phase_invariants

solve_batched_binary_three_phase_invariants(
    model: StateModel,
    temperature: Tensor,
    initial_pressure: Tensor,
    initial_phase_compositions: Tensor,
    *,
    phase_roots: tuple[PhaseKind, PhaseKind, PhaseKind] = (
        "liquid",
        "liquid",
        "vapor",
    ),
    tolerance: float = 1e-11,
    max_iterations: int = 12,
    method: Literal["newton", "trust-region"] = "newton",
) -> BinaryThreePhaseInvariantBatch

Close independent binary three-phase invariants in one PyTorch batch.

Parameters:

Name Type Description Default
model StateModel

Two-component homogeneous-state model shared by every state.

required
temperature Tensor

One-dimensional state temperatures in K and positive branch pressure estimates in Pa.

required
initial_pressure Tensor

One-dimensional state temperatures in K and positive branch pressure estimates in Pa.

required
initial_phase_compositions Tensor

Strictly interior normalized guesses with shape (batch, 3, 2). Rows in the phase axis correspond to phase_roots.

required
phase_roots tuple[PhaseKind, PhaseKind, PhaseKind]

Common EoS roots used for the three phases.

('liquid', 'liquid', 'vapor')
tolerance float

Maximum per-state dimensionless log-fugacity residual.

1e-11
max_iterations int

Maximum batched damped-Newton or trust-region iterations.

12
method Literal['newton', 'trust-region']

"newton" uses the fast batched equal-fugacity root solve. "trust-region" minimizes the independent squared residuals with vectorized exact PyTorch Hessians and per-state trust radii.

'newton'

Returns:

Type Description
BinaryThreePhaseInvariantBatch

Solved pressures, sorted compositions, and per-state diagnostics.

Raises:

Type Description
ValueError

If shapes, guesses, roots, or numerical controls are invalid.

Notes

This function solves the same four isofugacity equations as :func:solve_binary_three_phase_invariant. States execute jointly as a tensor batch but remain mathematically independent. Gradients through the model parameters and converged local branches are preserved.

Source code in src/torch_flash/flash/grid.py
def solve_batched_binary_three_phase_invariants(
    model: StateModel,
    temperature: Tensor,
    initial_pressure: Tensor,
    initial_phase_compositions: Tensor,
    *,
    phase_roots: tuple[PhaseKind, PhaseKind, PhaseKind] = (
        "liquid",
        "liquid",
        "vapor",
    ),
    tolerance: float = 1.0e-11,
    max_iterations: int = 12,
    method: Literal["newton", "trust-region"] = "newton",
) -> BinaryThreePhaseInvariantBatch:
    """Close independent binary three-phase invariants in one PyTorch batch.

    Parameters
    ----------
    model
        Two-component homogeneous-state model shared by every state.
    temperature, initial_pressure
        One-dimensional state temperatures in K and positive branch pressure
        estimates in Pa.
    initial_phase_compositions
        Strictly interior normalized guesses with shape ``(batch, 3, 2)``.
        Rows in the phase axis correspond to ``phase_roots``.
    phase_roots
        Common EoS roots used for the three phases.
    tolerance
        Maximum per-state dimensionless log-fugacity residual.
    max_iterations
        Maximum batched damped-Newton or trust-region iterations.
    method
        ``"newton"`` uses the fast batched equal-fugacity root solve.
        ``"trust-region"`` minimizes the independent squared residuals with
        vectorized exact PyTorch Hessians and per-state trust radii.

    Returns
    -------
    BinaryThreePhaseInvariantBatch
        Solved pressures, sorted compositions, and per-state diagnostics.

    Raises
    ------
    ValueError
        If shapes, guesses, roots, or numerical controls are invalid.

    Notes
    -----
    This function solves the same four isofugacity equations as
    :func:`solve_binary_three_phase_invariant`. States execute jointly as a
    tensor batch but remain mathematically independent. Gradients through the
    model parameters and converged local branches are preserved.
    """
    if (
        temperature.ndim != 1
        or temperature.numel() == 0
        or initial_pressure.shape != temperature.shape
    ):
        raise ValueError("batched binary invariant states must have shape (batch,)")
    if initial_phase_compositions.shape != (temperature.shape[0], MAX_PHASES, 2):
        raise ValueError("batched binary invariant guesses must have shape (batch, 3, 2)")
    if tolerance <= 0.0 or max_iterations <= 0:
        raise ValueError("batched binary invariant controls must be positive")
    if method not in ("newton", "trust-region"):
        raise ValueError("batched binary invariant method must be 'newton' or 'trust-region'")
    if any(root not in ("liquid", "vapor", "stable") for root in phase_roots):
        raise ValueError("binary invariant phase roots are invalid")

    compositions = initial_phase_compositions.to(
        dtype=temperature.dtype,
        device=temperature.device,
    )
    pressure = initial_pressure.to(dtype=temperature.dtype, device=temperature.device)
    first_fractions = compositions[:, :, 0]
    if not bool(
        torch.isfinite(temperature).all()
        & torch.isfinite(pressure).all()
        & (temperature > 0.0).all()
        & (pressure > 0.0).all()
        & torch.isfinite(compositions).all()
        & (first_fractions > 0.0).all()
        & (first_fractions < 1.0).all()
    ):
        raise ValueError("batched binary invariant states must be finite and interior")

    def residual(
        variables: Tensor,
        state_temperature: Tensor,
    ) -> Tensor:
        component_fractions = torch.sigmoid(variables[:, :MAX_PHASES])
        phase_compositions = torch.stack(
            (component_fractions, 1.0 - component_fractions),
            dim=-1,
        )
        state_pressure = torch.exp(variables[:, MAX_PHASES])
        chemical_potentials = torch.stack(
            tuple(
                torch.log(phase_compositions[:, index])
                + model.log_fugacity_coefficients(
                    state_temperature,
                    state_pressure,
                    phase_compositions[:, index],
                    phase_roots[index],
                )
                for index in range(MAX_PHASES)
            ),
            dim=1,
        )
        return torch.cat(
            (
                chemical_potentials[:, 1] - chemical_potentials[:, 0],
                chemical_potentials[:, 2] - chemical_potentials[:, 0],
            ),
            dim=-1,
        )

    variables = torch.cat(
        (
            torch.logit(first_fractions),
            torch.log(pressure).unsqueeze(-1),
        ),
        dim=-1,
    )
    if method == "trust-region":

        def objective(current: Tensor, state_temperature: Tensor) -> Tensor:
            return 0.5 * residual(current, state_temperature).square().sum(dim=-1)

        trust_result = minimize_batched_dense_trust_region(
            objective,
            variables,
            temperature,
            gradient_tolerance=max(1.0e-12, 0.01 * tolerance),
            max_iterations=max_iterations,
        )
        solved_variables = trust_result.solution
        solved_iterations = trust_result.iterations
    else:
        newton_result = batched_damped_newton(
            residual,
            variables,
            temperature,
            tolerance=tolerance,
            max_iterations=max_iterations,
        )
        solved_variables = newton_result.solution
        solved_iterations = newton_result.iterations
    solved_first_fractions = torch.sigmoid(solved_variables[:, :MAX_PHASES])
    order = torch.argsort(solved_first_fractions, dim=-1)
    sorted_fractions = torch.gather(solved_first_fractions, 1, order)
    solved_compositions = torch.stack(
        (sorted_fractions, 1.0 - sorted_fractions),
        dim=-1,
    )
    final_residual_norm = residual(solved_variables, temperature).abs().amax(dim=-1)
    return BinaryThreePhaseInvariantBatch(
        temperature,
        torch.exp(solved_variables[:, MAX_PHASES]),
        solved_compositions,
        final_residual_norm,
        solved_iterations,
        torch.isfinite(final_residual_norm) & (final_residual_norm <= tolerance),
    )

solve_binary_three_phase_invariant

solve_binary_three_phase_invariant(
    model: StateModel,
    temperature: Tensor,
    initial_pressure: Tensor,
    initial_phase_compositions: Tensor,
    *,
    phase_roots: tuple[PhaseKind, PhaseKind, PhaseKind] = (
        "liquid",
        "liquid",
        "vapor",
    ),
    tolerance: float = 1e-11,
    max_iterations: int = 12,
    method: Literal["newton", "trust-region"] = "newton",
) -> BinaryThreePhaseInvariant

Close a binary three-phase invariant with an autodiff nonlinear solve.

The unknowns are the first-component mole fraction of each phase and the logarithm of pressure. Temperature is fixed. Equal component fugacities provide the four residual equations.

Parameters:

Name Type Description Default
model StateModel

Two-component homogeneous-state model supplying differentiable log-fugacity coefficients.

required
temperature Tensor

Scalar invariant temperature in K.

required
initial_pressure Tensor

Positive scalar pressure guess in Pa.

required
initial_phase_compositions Tensor

Strictly interior normalized guesses with shape (3, 2). The rows should correspond to phase_roots and should lie near the desired three-phase branch.

required
phase_roots tuple[PhaseKind, PhaseKind, PhaseKind]

EoS roots used for the three guessed phases. The default represents two liquid roots and one vapor root.

('liquid', 'liquid', 'vapor')
tolerance float

Maximum absolute dimensionless log-fugacity mismatch required for convergence.

1e-11
max_iterations int

Maximum damped-Newton or trust-region iterations.

12
method Literal['newton', 'trust-region']

"newton" uses the fast local equal-fugacity root solve. "trust-region" minimizes one half of the squared equal-fugacity residual with exact PyTorch gradients and Hessians. The latter is a recovery path for difficult three-phase branches, not the preferred cost for ordinary invariant states.

'newton'

Returns:

Type Description
BinaryThreePhaseInvariant

Solved pressure, phase compositions sorted by first-component mole fraction, residual, iteration count, and convergence flag. A failed solve is returned explicitly with converged=False.

Raises:

Type Description
ValueError

If temperature or pressure is not scalar, guesses do not have shape (3, 2), a first-component guess is not strictly between zero and one, or a numerical control is not positive.

RuntimeError

If PyTorch cannot solve the Newton linear system or the model fails at a trial state.

Notes

Both methods solve the same four isofugacity equations with respect to three composition logits and log pressure. The Newton method uses :func:torch.func.jacrev and a residual-decreasing backtracking line search. The trust-region method follows the exact-Hessian restricted-step strategy of M. Petitfrere and D. V. Nichita, Fluid Phase Equilibria 362 (2014), 51-68, sections 3.1-3.4, doi:10.1016/j.fluid.2013.08.039.

Both are local branch solves. A residual-converged result can still be the algebraic solution in which two phases coincide; callers must retain a phase-separation gate and use physically informed multiple starts.

Source code in src/torch_flash/flash/grid.py
def solve_binary_three_phase_invariant(
    model: StateModel,
    temperature: Tensor,
    initial_pressure: Tensor,
    initial_phase_compositions: Tensor,
    *,
    phase_roots: tuple[PhaseKind, PhaseKind, PhaseKind] = (
        "liquid",
        "liquid",
        "vapor",
    ),
    tolerance: float = 1.0e-11,
    max_iterations: int = 12,
    method: Literal["newton", "trust-region"] = "newton",
) -> BinaryThreePhaseInvariant:
    """Close a binary three-phase invariant with an autodiff nonlinear solve.

    The unknowns are the first-component mole fraction of each phase and the
    logarithm of pressure. Temperature is fixed. Equal component fugacities
    provide the four residual equations.

    Parameters
    ----------
    model
        Two-component homogeneous-state model supplying differentiable
        log-fugacity coefficients.
    temperature
        Scalar invariant temperature in K.
    initial_pressure
        Positive scalar pressure guess in Pa.
    initial_phase_compositions
        Strictly interior normalized guesses with shape ``(3, 2)``. The rows
        should correspond to ``phase_roots`` and should lie near the desired
        three-phase branch.
    phase_roots
        EoS roots used for the three guessed phases. The default represents
        two liquid roots and one vapor root.
    tolerance
        Maximum absolute dimensionless log-fugacity mismatch required for
        convergence.
    max_iterations
        Maximum damped-Newton or trust-region iterations.
    method
        ``"newton"`` uses the fast local equal-fugacity root solve.
        ``"trust-region"`` minimizes one half of the squared equal-fugacity
        residual with exact PyTorch gradients and Hessians. The latter is a
        recovery path for difficult three-phase branches, not the preferred
        cost for ordinary invariant states.

    Returns
    -------
    BinaryThreePhaseInvariant
        Solved pressure, phase compositions sorted by first-component mole
        fraction, residual, iteration count, and convergence flag. A failed
        solve is returned explicitly with ``converged=False``.

    Raises
    ------
    ValueError
        If temperature or pressure is not scalar, guesses do not have shape
        ``(3, 2)``, a first-component guess is not strictly between zero and
        one, or a numerical control is not positive.
    RuntimeError
        If PyTorch cannot solve the Newton linear system or the model fails at
        a trial state.

    Notes
    -----
    Both methods solve the same four isofugacity equations with respect to
    three composition logits and log pressure. The Newton method uses
    :func:`torch.func.jacrev` and a residual-decreasing backtracking line
    search. The trust-region method follows the exact-Hessian restricted-step
    strategy of M. Petitfrere and D. V. Nichita, *Fluid Phase Equilibria* 362
    (2014), 51-68, sections 3.1-3.4,
    doi:10.1016/j.fluid.2013.08.039.

    Both are local branch solves. A residual-converged result can still be the
    algebraic solution in which two phases coincide; callers must retain a
    phase-separation gate and use physically informed multiple starts.
    """
    if temperature.ndim != 0 or initial_pressure.ndim != 0:
        raise ValueError("binary invariant temperature and pressure must be scalar")
    if initial_phase_compositions.shape != (MAX_PHASES, 2):
        raise ValueError("binary invariant requires three two-component phase guesses")
    if tolerance <= 0.0 or max_iterations <= 0:
        raise ValueError("binary invariant tolerance and max_iterations must be positive")
    if method not in ("newton", "trust-region"):
        raise ValueError("binary invariant method must be 'newton' or 'trust-region'")
    first_fractions = initial_phase_compositions[:, 0]
    if bool(((first_fractions <= 0.0) | (first_fractions >= 1.0)).any()):
        raise ValueError("binary invariant phase guesses must be strictly interior")

    def residual(variables: Tensor) -> Tensor:
        component_fractions = torch.sigmoid(variables[:MAX_PHASES])
        compositions = torch.stack(
            (component_fractions, 1.0 - component_fractions),
            dim=-1,
        )
        pressure = torch.exp(variables[MAX_PHASES])
        chemical_potentials = torch.stack(
            tuple(
                torch.log(compositions[index])
                + model.log_fugacity_coefficients(
                    temperature,
                    pressure,
                    compositions[index],
                    phase_roots[index],
                )
                for index in range(MAX_PHASES)
            )
        )
        return torch.cat(
            (
                chemical_potentials[1] - chemical_potentials[0],
                chemical_potentials[2] - chemical_potentials[0],
            )
        )

    variables = torch.cat((torch.logit(first_fractions), torch.log(initial_pressure).reshape(1)))
    if method == "trust-region":

        def objective(current: Tensor) -> Tensor:
            return 0.5 * residual(current).square().sum()

        trust_result = minimize_dense_trust_region(
            objective,
            variables,
            initial_radius=0.5,
            maximum_radius=5.0,
            gradient_tolerance=max(1.0e-12, 0.01 * tolerance),
            max_iterations=max_iterations,
        )
        variables = trust_result.solution
        _iteration = trust_result.iterations
    else:
        for _iteration in range(1, max_iterations + 1):
            current_residual = residual(variables)
            if float(current_residual.detach().abs().max()) <= tolerance:
                break
            step = torch.linalg.solve(
                torch.func.jacrev(residual)(variables),
                -current_residual,
            )
            baseline = float(current_residual.detach().square().sum())
            for line_search in range(14):
                trial = variables + (0.5**line_search) * step
                if float(residual(trial).detach().square().sum()) < baseline:
                    variables = trial
                    break
            else:
                break

    final_residual = residual(variables)
    converged = bool(final_residual.detach().abs().max() <= tolerance)
    first_fractions = torch.sigmoid(variables[:MAX_PHASES])
    order = torch.argsort(first_fractions)
    phase_compositions = torch.stack(
        (first_fractions[order], 1.0 - first_fractions[order]),
        dim=-1,
    )
    return BinaryThreePhaseInvariant(
        temperature=temperature,
        pressure=torch.exp(variables[MAX_PHASES]),
        phase_compositions=phase_compositions,
        residual_norm=final_residual.abs().max(),
        iterations=_iteration,
        converged=converged,
    )

multiphase_flash

multiphase_flash(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_k_values: Tensor | None = None,
    tolerance: float = 1e-08,
    max_iterations: int = 100,
) -> FlashResult

Solve a fixed-phase-count PT flash by generalized substitution.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model providing phase fugacity coefficients and properties.

required
state ChemicalState

Feed state with temperature in K, pressure in Pa, and a one-dimensional overall mole-fraction vector.

required
initial_k_values Tensor | None

Positive composition ratios to the reference liquid phase, with shape (nphases - 1, ncomponents). Omitting this argument requests a three-phase heuristic containing vapor-like and aqueous-like trials.

None
tolerance float

Convergence threshold for the maximum absolute log-fugacity residual.

1e-08
max_iterations int

Maximum generalized substitution/Newton iterations.

100

Returns:

Type Description
FlashResult

Fractions, phase properties, convergence status, fugacity residual, and generalized material-balance diagnostics.

Raises:

Type Description
ValueError

If the composition is batched or default K values cannot be generated.

Warns:

Type Description
ExperimentalModelWarning

On every call, because automatic phase discovery is not yet a global stability algorithm.

ConvergenceWarning

If the fixed-phase-count equilibrium iterations do not converge.

Notes

The number of phases is fixed by initial_k_values; this routine does not prove that the selected phase count is globally stable. For VLL or VLW calculations, supply physically informed initial ratios and inspect FlashResult.converged, the residual, phase fractions, material balance, and an independent stability analysis.

Source code in src/torch_flash/flash/multiphase.py
def multiphase_flash(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_k_values: Tensor | None = None,
    tolerance: float = 1.0e-8,
    max_iterations: int = 100,
) -> FlashResult:
    """Solve a fixed-phase-count PT flash by generalized substitution.

    Parameters
    ----------
    model
        Homogeneous-state model providing phase fugacity coefficients and
        properties.
    state
        Feed state with temperature in K, pressure in Pa, and a
        one-dimensional overall mole-fraction vector.
    initial_k_values
        Positive composition ratios to the reference liquid phase, with shape
        ``(nphases - 1, ncomponents)``. Omitting this argument requests a
        three-phase heuristic containing vapor-like and aqueous-like trials.
    tolerance
        Convergence threshold for the maximum absolute log-fugacity residual.
    max_iterations
        Maximum generalized substitution/Newton iterations.

    Returns
    -------
    FlashResult
        Fractions, phase properties, convergence status, fugacity residual,
        and generalized material-balance diagnostics.

    Raises
    ------
    ValueError
        If the composition is batched or default K values cannot be generated.

    Warns
    -----
    ExperimentalModelWarning
        On every call, because automatic phase discovery is not yet a global
        stability algorithm.
    ConvergenceWarning
        If the fixed-phase-count equilibrium iterations do not converge.

    Notes
    -----
    The number of phases is fixed by ``initial_k_values``; this routine does
    not prove that the selected phase count is globally stable. For VLL or VLW
    calculations, supply physically informed initial ratios and inspect
    ``FlashResult.converged``, the residual, phase fractions, material balance,
    and an independent stability analysis.
    """
    warnings.warn(
        "automatic multiphase phase discovery is experimental; inspect stability "
        "and material-balance diagnostics",
        ExperimentalModelWarning,
        stacklevel=2,
    )
    if state.composition.ndim != 1:
        raise ValueError("multiphase_flash currently accepts one composition vector")
    log_k = torch.log(
        _default_multiphase_k(model, state) if initial_k_values is None else initial_k_values
    )
    converged = False
    residual_norm = torch.tensor(torch.inf, dtype=log_k.dtype, device=log_k.device)
    fractions = torch.empty(0, dtype=log_k.dtype, device=log_k.device)
    compositions = torch.empty(0, dtype=log_k.dtype, device=log_k.device)
    rr_iterations = 0
    newton_steps = 0

    for iteration in range(1, max_iterations + 1):
        residual, fractions, compositions, rr_iterations, rr_converged = _equilibrium_residual(
            model,
            state,
            log_k,
        )
        if not rr_converged:
            log_k *= 0.8
            continue
        residual_norm = residual.abs().max()
        if float(residual_norm) <= tolerance:
            converged = True
            break

        # A full Jacobian is worthwhile only for three or more phases. It is
        # generated through the generalized material balance and fugacity
        # calculations by PyTorch, following Michelsen's Newton acceleration
        # principle without coding model-specific derivatives.
        if log_k.shape[0] > 1 and iteration >= 3:
            try:
                jacobian = torch.func.jacrev(
                    lambda current_log_k: _equilibrium_residual(
                        model,
                        state,
                        current_log_k,
                    )[0]
                )(log_k)
                flattened = jacobian.reshape(log_k.numel(), log_k.numel())
                step = torch.linalg.solve(flattened, -residual.reshape(-1)).reshape_as(log_k)
                factor = 1.0
                for _ in range(12):
                    candidate = log_k + factor * step
                    (
                        candidate_residual,
                        candidate_fractions,
                        candidate_compositions,
                        candidate_rr_iterations,
                        candidate_rr_converged,
                    ) = _equilibrium_residual(model, state, candidate)
                    candidate_norm = candidate_residual.detach().abs().max()
                    if (
                        candidate_rr_converged
                        and bool(torch.isfinite(candidate_norm))
                        and float(candidate_norm) < float(residual_norm.detach())
                    ):
                        log_k = candidate
                        fractions = candidate_fractions
                        compositions = candidate_compositions
                        rr_iterations = candidate_rr_iterations
                        newton_steps += 1
                        break
                    factor *= 0.5
                else:
                    log_k = log_k - (0.5 if iteration < 20 else 0.2) * residual
                continue
            except torch.linalg.LinAlgError:
                pass
        log_k = log_k - (0.5 if iteration < 20 else 0.2) * residual

    phases = []
    for phase_index, composition in enumerate(compositions):
        kind: PhaseKind = "vapor" if phase_index == 1 else "liquid"
        phase_state = ChemicalState(state.temperature, state.pressure, composition)
        phases.append(phase_properties(model, phase_state, kind, caloric=False))
    if not converged:
        warnings.warn(
            f"multiphase flash did not converge in {max_iterations} iterations",
            ConvergenceWarning,
            stacklevel=2,
        )
    identified_phases = identify_flash_phases(tuple(phases))
    return FlashResult(
        fractions,
        identified_phases,
        converged,
        iteration,
        residual_norm,
        converged,
        {
            "generalized_rachford_rice_iterations": rr_iterations,
            "autodiff_newton_steps": newton_steps,
        },
    )

multiphase_trust_region_flash

multiphase_trust_region_flash(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_k_values: Tensor | None = None,
    phase_roots: tuple[PhaseKind, ...] | None = None,
    tolerance: float = 1e-08,
    max_iterations: int = 100,
    raise_on_failure: bool = False,
) -> FlashResult

Minimize fixed-count multiphase Gibbs energy by a dense trust region.

This implements the improved mole-number (NLVM) formulation of M. Petitfrere and D. V. Nichita, "Robust and efficient Trust-Region based stability analysis and multiphase flash calculations", Fluid Phase Equilibria 362 (2014), 51-68, equations (8)-(15) and sections 3.1-3.4, doi:10.1016/j.fluid.2013.08.039. For every component, the phase containing the largest initial amount is the dependent reference phase. All other phase amounts are independent, and component material balance is exact at every accepted step.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model providing fugacity coefficients and phase properties.

required
state ChemicalState

Scalar temperature in K, pressure in Pa, and one strictly positive feed-composition vector.

required
initial_k_values Tensor | None

Positive ratios to the first phase, shaped (nphases - 1, ncomponents). Omitting them requests the package three-phase Wilson/reciprocal heuristic.

None
phase_roots tuple[PhaseKind, ...] | None

EoS root request for each fixed phase. When omitted, the first phase is liquid, the second vapor, and later phases liquid.

None
tolerance float

Maximum dimensionless chemical-potential residual and trust-region gradient norm.

1e-08
max_iterations int

Maximum accepted-or-rejected trust-region iterations.

100
raise_on_failure bool

Raise RuntimeError instead of emitting ConvergenceWarning when a physical residual-converged split is not obtained.

False

Returns:

Type Description
FlashResult

Fixed-count phase fractions and properties, convergence status, chemical-potential residual, and trust-region diagnostics.

Raises:

Type Description
ValueError

If state shapes, feed fractions, K values, phase roots, or numerical controls are invalid.

RuntimeError

If convergence fails and raise_on_failure is true.

Warns:

Type Description
ExperimentalModelWarning

On every call because this operation does not discover the globally stable phase count.

ConvergenceWarning

If the local minimum fails the physical or residual gates.

Notes

This is a local fixed-phase-count minimization. Multiple stability starts remain necessary, and a converged stationary point does not prove global stability. Exact autodiff Hessians are appropriate for small dense phase systems; their cost can dominate ordinary two-phase states.

Source code in src/torch_flash/flash/multiphase.py
def multiphase_trust_region_flash(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_k_values: Tensor | None = None,
    phase_roots: tuple[PhaseKind, ...] | None = None,
    tolerance: float = 1.0e-8,
    max_iterations: int = 100,
    raise_on_failure: bool = False,
) -> FlashResult:
    """Minimize fixed-count multiphase Gibbs energy by a dense trust region.

    This implements the improved mole-number (NLVM) formulation of
    M. Petitfrere and D. V. Nichita, "Robust and efficient Trust-Region based
    stability analysis and multiphase flash calculations", *Fluid Phase
    Equilibria* 362 (2014), 51-68, equations (8)-(15) and sections 3.1-3.4,
    doi:10.1016/j.fluid.2013.08.039. For every component, the phase containing
    the largest initial amount is the dependent reference phase. All other
    phase amounts are independent, and component material balance is exact at
    every accepted step.

    Parameters
    ----------
    model
        Homogeneous-state model providing fugacity coefficients and phase
        properties.
    state
        Scalar temperature in K, pressure in Pa, and one strictly positive
        feed-composition vector.
    initial_k_values
        Positive ratios to the first phase, shaped
        ``(nphases - 1, ncomponents)``. Omitting them requests the package
        three-phase Wilson/reciprocal heuristic.
    phase_roots
        EoS root request for each fixed phase. When omitted, the first phase is
        liquid, the second vapor, and later phases liquid.
    tolerance
        Maximum dimensionless chemical-potential residual and trust-region
        gradient norm.
    max_iterations
        Maximum accepted-or-rejected trust-region iterations.
    raise_on_failure
        Raise ``RuntimeError`` instead of emitting ``ConvergenceWarning`` when
        a physical residual-converged split is not obtained.

    Returns
    -------
    FlashResult
        Fixed-count phase fractions and properties, convergence status,
        chemical-potential residual, and trust-region diagnostics.

    Raises
    ------
    ValueError
        If state shapes, feed fractions, K values, phase roots, or numerical
        controls are invalid.
    RuntimeError
        If convergence fails and ``raise_on_failure`` is true.

    Warns
    -----
    ExperimentalModelWarning
        On every call because this operation does not discover the globally
        stable phase count.
    ConvergenceWarning
        If the local minimum fails the physical or residual gates.

    Notes
    -----
    This is a local fixed-phase-count minimization. Multiple stability starts
    remain necessary, and a converged stationary point does not prove global
    stability. Exact autodiff Hessians are appropriate for small dense phase
    systems; their cost can dominate ordinary two-phase states.
    """
    warnings.warn(
        "trust-region multiphase flash uses a fixed phase count; inspect "
        "stability and material-balance diagnostics",
        ExperimentalModelWarning,
        stacklevel=2,
    )
    if state.composition.ndim != 1:
        raise ValueError("multiphase trust-region flash accepts one composition vector")
    if tolerance <= 0.0 or max_iterations <= 0:
        raise ValueError("multiphase trust-region controls must be positive")
    z = state.composition / state.composition.sum()
    if not bool(torch.isfinite(z).all() & (z > 0.0).all()):
        raise ValueError(
            "multiphase trust-region flash requires finite strictly positive feed fractions"
        )
    k_values = _default_multiphase_k(model, state) if initial_k_values is None else initial_k_values
    if (
        k_values.ndim != 2
        or k_values.shape[1] != z.numel()
        or not bool(torch.isfinite(k_values).all() & (k_values > 0.0).all())
    ):
        raise ValueError(
            "multiphase trust-region K values must be a finite positive "
            "(nphases - 1, ncomponents) matrix"
        )
    phase_count = k_values.shape[0] + 1
    selected_phase_roots: tuple[PhaseKind, ...]
    if phase_roots is None:
        selected_phase_roots = tuple(
            "vapor" if phase_index == 1 else "liquid" for phase_index in range(phase_count)
        )
    else:
        selected_phase_roots = phase_roots
    if len(selected_phase_roots) != phase_count or any(
        phase not in ("liquid", "vapor", "stable") for phase in selected_phase_roots
    ):
        raise ValueError("multiphase trust-region roots must match the fixed phase count")

    (
        initial_fractions,
        initial_compositions,
        material_balance_iterations,
        material_balance_converged,
    ) = solve_generalized_rachford_rice(z, k_values)
    if not material_balance_converged:
        raise ValueError("initial generalized material-balance split did not converge")
    initial_phase_moles = initial_fractions[:, None] * initial_compositions
    reference_phase = torch.argmax(initial_phase_moles, dim=0)
    independent_phase = torch.stack(
        tuple(
            torch.tensor(
                tuple(
                    phase_index
                    for phase_index in range(phase_count)
                    if phase_index != int(reference_phase[component_index])
                ),
                dtype=torch.int64,
                device=z.device,
            )
            for component_index in range(z.numel())
        )
    )
    initial = torch.gather(
        initial_phase_moles.mT,
        1,
        independent_phase,
    )
    mole_floor = 16.0 * torch.finfo(z.dtype).eps * z
    initial = torch.maximum(initial, mole_floor[:, None])
    total_independent = initial.sum(dim=1)
    rescale = torch.minimum(
        torch.ones_like(z),
        (z - 2.0 * mole_floor) / total_independent,
    )
    initial = initial * rescale[:, None]
    independent_selector = torch.nn.functional.one_hot(
        independent_phase,
        num_classes=phase_count,
    ).to(dtype=z.dtype)
    reference_selector = torch.nn.functional.one_hot(
        reference_phase,
        num_classes=phase_count,
    ).mT.to(dtype=z.dtype)

    def quantities(independent_moles: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]:
        independent_by_phase = torch.einsum(
            "ci,cip->pc",
            independent_moles,
            independent_selector,
        )
        reference_moles = z - independent_moles.sum(dim=1)
        phase_moles = independent_by_phase + reference_selector * reference_moles
        fractions = phase_moles.sum(dim=1)
        compositions = phase_moles / fractions[:, None]
        log_phi = torch.stack(
            tuple(
                model.log_fugacity_coefficients(
                    state.temperature,
                    state.pressure,
                    composition,
                    phase_root,
                )
                for composition, phase_root in zip(
                    compositions,
                    selected_phase_roots,
                    strict=True,
                )
            )
        )
        chemical_potential = torch.log(compositions) + log_phi
        gibbs = torch.sum(phase_moles * chemical_potential)
        return gibbs, fractions, compositions, chemical_potential

    flattened_initial = initial.reshape(-1)

    def reshape_independent(flattened: Tensor) -> Tensor:
        return flattened.reshape(z.numel(), phase_count - 1)

    result = minimize_dense_trust_region(
        lambda flattened: quantities(reshape_independent(flattened))[0],
        flattened_initial,
        is_feasible=lambda flattened: bool(
            (
                torch.isfinite(reshape_independent(flattened))
                & (reshape_independent(flattened) > mole_floor[:, None])
            )
            .detach()
            .all()
            and (reshape_independent(flattened).sum(dim=1) < z - mole_floor).detach().all()
        ),
        gradient_tolerance=tolerance,
        max_iterations=max_iterations,
    )
    independent_moles = reshape_independent(result.solution)
    _, fractions, compositions, chemical_potential = quantities(independent_moles)
    reference_potential = torch.gather(
        chemical_potential,
        0,
        reference_phase[None, :],
    ).squeeze(0)
    independent_potential = torch.gather(
        chemical_potential.mT,
        1,
        independent_phase,
    )
    residual_norm = (independent_potential - reference_potential[:, None]).abs().max()
    phase_fraction_tolerance = max(10.0 * tolerance, 1.0e-10)
    physical = bool((fractions.detach() > phase_fraction_tolerance).all())
    converged = result.converged and physical and bool(residual_norm.detach() <= tolerance)
    phases = tuple(
        phase_properties(
            model,
            ChemicalState(state.temperature, state.pressure, composition),
            phase_root,
            caloric=False,
        )
        for composition, phase_root in zip(
            compositions,
            selected_phase_roots,
            strict=True,
        )
    )
    if not converged:
        message = (
            "multiphase trust-region flash did not produce a physical "
            f"residual-converged split in {result.iterations} iterations "
            f"(chemical-potential residual {float(residual_norm):.3e})"
        )
        if raise_on_failure:
            raise RuntimeError(message)
        warnings.warn(message, ConvergenceWarning, stacklevel=2)
    reconstructed = torch.einsum("p,pi->i", fractions, compositions)
    material_balance_residual = torch.max(torch.abs(reconstructed - z))
    return FlashResult(
        fractions,
        identify_flash_phases(phases),
        converged,
        result.iterations,
        residual_norm,
        converged,
        {
            "generalized_rachford_rice_iterations": material_balance_iterations,
            "material_balance_residual": float(material_balance_residual),
            "trust_region_accepted_steps": result.accepted_steps,
            "trust_region_rejected_steps": result.rejected_steps,
            "trust_region_gradient_norm": float(result.gradient_norm),
            "trust_region_minimum_hessian_eigenvalue": float(result.minimum_hessian_eigenvalue),
        },
    )

solve_generalized_rachford_rice

solve_generalized_rachford_rice(
    composition: Tensor,
    k_values: Tensor,
    *,
    tolerance: float = 1e-12,
    max_iterations: int = 100,
) -> tuple[Tensor, Tensor, int, bool]

Solve multiphase material balance for phase ratios to a reference phase.

Parameters:

Name Type Description Default
composition Tensor

Overall mole fractions with shape (ncomponents,). The vector is normalized internally.

required
k_values Tensor

Positive ratios to the reference-phase composition, with shape (nphases - 1, ncomponents).

required
tolerance float

Convergence threshold for the maximum absolute generalized Rachford--Rice residual.

1e-12
max_iterations int

Maximum safeguarded Newton iterations.

100

Returns:

Type Description
tuple

(phase_fractions, phase_compositions, iterations, converged). Fractions have shape (nphases,) and place the reference phase first. Compositions have shape (nphases, ncomponents).

Raises:

Type Description
ValueError

If shapes are inconsistent or any equilibrium ratio is nonpositive.

Notes

converged reports the material-balance solve only. Fugacity equality must be checked separately by the enclosing multiphase flash.

Source code in src/torch_flash/flash/multiphase.py
def solve_generalized_rachford_rice(
    composition: Tensor,
    k_values: Tensor,
    *,
    tolerance: float = 1.0e-12,
    max_iterations: int = 100,
) -> tuple[Tensor, Tensor, int, bool]:
    """Solve multiphase material balance for phase ratios to a reference phase.

    Parameters
    ----------
    composition
        Overall mole fractions with shape ``(ncomponents,)``. The vector is
        normalized internally.
    k_values
        Positive ratios to the reference-phase composition, with shape
        ``(nphases - 1, ncomponents)``.
    tolerance
        Convergence threshold for the maximum absolute generalized
        Rachford--Rice residual.
    max_iterations
        Maximum safeguarded Newton iterations.

    Returns
    -------
    tuple
        ``(phase_fractions, phase_compositions, iterations, converged)``.
        Fractions have shape ``(nphases,)`` and place the reference phase
        first. Compositions have shape ``(nphases, ncomponents)``.

    Raises
    ------
    ValueError
        If shapes are inconsistent or any equilibrium ratio is nonpositive.

    Notes
    -----
    ``converged`` reports the material-balance solve only. Fugacity equality
    must be checked separately by the enclosing multiphase flash.
    """
    if composition.ndim != 1 or k_values.ndim != 2:
        raise ValueError("expected a composition vector and a K-value matrix")
    if k_values.shape[1] != composition.numel():
        raise ValueError("K-value component dimension does not match composition")
    if bool((k_values.detach() <= 0.0).any()):
        raise ValueError("all generalized K values must be positive")
    z = composition / composition.sum()
    differences = k_values - 1.0
    n_other = k_values.shape[0]
    beta = torch.full(
        (n_other,),
        1.0 / (n_other + 1),
        dtype=z.dtype,
        device=z.device,
    )
    converged = False
    for _iteration in range(1, max_iterations + 1):
        denominator = 1.0 + torch.einsum("p,pi->i", beta, differences)
        residual = torch.sum(z[None, :] * differences / denominator[None, :], dim=1)
        if float(residual.detach().abs().max()) <= tolerance:
            converged = True
            break
        jacobian = -torch.einsum(
            "i,pi,qi->pq",
            z / denominator.square(),
            differences,
            differences,
        )
        try:
            step = torch.linalg.solve(jacobian, -residual)
        except torch.linalg.LinAlgError:
            step = -0.05 * residual
        factor = 1.0
        accepted = False
        for _ in range(30):
            candidate = beta + factor * step
            candidate_denominator = 1.0 + torch.einsum("p,pi->i", candidate, differences)
            if (
                bool((candidate.detach() > 0.0).all())
                and float(candidate.detach().sum()) < 1.0
                and bool((candidate_denominator.detach() > 0.0).all())
            ):
                beta = candidate
                accepted = True
                break
            factor *= 0.5
        if not accepted:
            beta = torch.clamp(beta - 0.01 * residual, min=1.0e-10)
            beta = beta / max(1.0, float(beta.sum() + 1.0e-10)) * 0.95
    iteration = _iteration
    fractions = torch.cat(((1.0 - beta.sum()).reshape(1), beta))
    denominator = 1.0 + torch.einsum("p,pi->i", beta, differences)
    reference_composition = z / denominator
    phase_compositions = torch.cat(
        (reference_composition[None, :], k_values * reference_composition[None, :]),
        dim=0,
    )
    return fractions, phase_compositions, iteration, converged

tangent_plane_stability

tangent_plane_stability(
    model: StateModel,
    state: ChemicalState,
    *,
    reference_phase: PhaseKind = "stable",
    initial_compositions: tuple[Tensor, ...] | None = None,
    tolerance: float = 1e-09,
    max_iterations: int = 40,
    minimizer: Literal[
        "line-search-newton", "trust-region"
    ] = "line-search-newton",
) -> StabilityResult

Assess local phase stability by minimizing tangent-plane distance.

The feed composition is evaluated as the reference state, and each trial composition is optimized in independent log-ratio coordinates. A negative normalized tangent-plane distance (TPD) identifies a composition that can lower the Gibbs energy. Multiple starting points are therefore important: the optimization is local and a single stationary point is not a global stability proof.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model providing log fugacity coefficients.

required
state ChemicalState

Feed state. Temperature is in K, pressure is in Pa, and state.composition must be a one-dimensional mole-fraction vector.

required
reference_phase PhaseKind

Root requested when evaluating the feed fugacity coefficients. Trial phases are evaluated with the model's "stable" root.

'stable'
initial_compositions tuple[Tensor, ...] | None

Optional trial mole-fraction vectors. Values are made positive and normalized before optimization. When omitted, the feed, Wilson-based vapor/liquid trials when available, and component-rich trials are used.

None
tolerance float

Maximum absolute gradient component used by each local minimization. The stability decision allows a numerical margin of 10 * tolerance.

1e-09
max_iterations int

Maximum Newton/globalization iterations for each starting point.

40
minimizer Literal['line-search-newton', 'trust-region']

Local TPD minimizer. "line-search-newton" preserves the default damped Newton path. "trust-region" uses the exact dense More-Sorensen restricted step of Petitfrere and Nichita (2014), sections 3.1-3.4, doi:10.1016/j.fluid.2013.08.039.

'line-search-newton'

Returns:

Type Description
StabilityResult

Lowest TPD found, its trial composition, local-solver diagnostics, and the resulting stability classification.

Raises:

Type Description
ValueError

If the feed composition is batched rather than one-dimensional.

Notes

StabilityResult.converged describes the local minimization associated with the reported minimum. Callers should not interpret a non-converged nonnegative TPD as conclusive evidence of stability.

Source code in src/torch_flash/flash/stability.py
def tangent_plane_stability(
    model: StateModel,
    state: ChemicalState,
    *,
    reference_phase: PhaseKind = "stable",
    initial_compositions: tuple[Tensor, ...] | None = None,
    tolerance: float = 1.0e-9,
    max_iterations: int = 40,
    minimizer: Literal["line-search-newton", "trust-region"] = "line-search-newton",
) -> StabilityResult:
    """Assess local phase stability by minimizing tangent-plane distance.

    The feed composition is evaluated as the reference state, and each trial
    composition is optimized in independent log-ratio coordinates. A negative
    normalized tangent-plane distance (TPD) identifies a composition that can
    lower the Gibbs energy. Multiple starting points are therefore important:
    the optimization is local and a single stationary point is not a global
    stability proof.

    Parameters
    ----------
    model
        Homogeneous-state model providing log fugacity coefficients.
    state
        Feed state. Temperature is in K, pressure is in Pa, and
        ``state.composition`` must be a one-dimensional mole-fraction vector.
    reference_phase
        Root requested when evaluating the feed fugacity coefficients.
        Trial phases are evaluated with the model's ``"stable"`` root.
    initial_compositions
        Optional trial mole-fraction vectors. Values are made positive and
        normalized before optimization. When omitted, the feed, Wilson-based
        vapor/liquid trials when available, and component-rich trials are used.
    tolerance
        Maximum absolute gradient component used by each local minimization.
        The stability decision allows a numerical margin of ``10 * tolerance``.
    max_iterations
        Maximum Newton/globalization iterations for each starting point.
    minimizer
        Local TPD minimizer. ``"line-search-newton"`` preserves the default
        damped Newton path. ``"trust-region"`` uses the exact dense
        More-Sorensen restricted step of Petitfrere and Nichita (2014),
        sections 3.1-3.4, doi:10.1016/j.fluid.2013.08.039.

    Returns
    -------
    StabilityResult
        Lowest TPD found, its trial composition, local-solver diagnostics, and
        the resulting stability classification.

    Raises
    ------
    ValueError
        If the feed composition is batched rather than one-dimensional.

    Notes
    -----
    ``StabilityResult.converged`` describes the local minimization associated
    with the reported minimum. Callers should not interpret a non-converged
    nonnegative TPD as conclusive evidence of stability.
    """
    if state.composition.ndim != 1:
        raise ValueError("stability analysis currently accepts one composition vector")
    if minimizer not in ("line-search-newton", "trust-region"):
        raise ValueError("unknown tangent-plane local minimizer")
    z = state.composition
    log_phi_z = model.log_fugacity_coefficients(
        state.temperature, state.pressure, z, reference_phase
    )
    d = torch.log(torch.clamp_min(z, torch.finfo(z.dtype).tiny)) + log_phi_z

    if initial_compositions is None:
        candidates: list[Tensor] = [z]
        if all(
            hasattr(model, attribute)
            for attribute in (
                "critical_temperature",
                "critical_pressure",
                "acentric_factor",
                "molar_mass",
                "names",
            )
        ):
            components = cast(ComponentSet, model)
            k = wilson_k_values(components, state.temperature, state.pressure)
            candidates.extend((z * k, z / k))
        eye = torch.eye(z.numel(), dtype=z.dtype, device=z.device)
        candidates.extend(0.95 * eye[i] + 0.05 * z for i in range(z.numel()))
        initial_compositions = tuple(candidates)

    best_value = torch.tensor(torch.inf, dtype=z.dtype, device=z.device)
    best_composition = z
    best_iterations = 0
    best_converged = False
    for initial_composition in initial_compositions:
        trial = torch.clamp_min(initial_composition, 1.0e-16)

        if minimizer == "trust-region":
            # Petitfrere and Nichita equations (1)-(5) use positive formal
            # mole numbers directly. Their scale is part of the modified TPD
            # objective and must not be normalized before minimization.
            def formal_objective(formal_moles: Tensor) -> Tensor:
                total = formal_moles.sum()
                w = formal_moles / total
                log_phi = model.log_fugacity_coefficients(
                    state.temperature,
                    state.pressure,
                    w,
                    "stable",
                )
                return (
                    1.0 - total + torch.sum(formal_moles * (torch.log(formal_moles) + log_phi - d))
                )

            coordinates = trial
            initial_radius = torch.clamp(
                torch.linalg.vector_norm(coordinates.detach()) / 10.0,
                min=1.0e-3,
                max=0.1,
            )
            trust_result = minimize_dense_trust_region(
                formal_objective,
                coordinates,
                is_feasible=lambda formal_moles: bool(
                    (
                        torch.isfinite(formal_moles)
                        & (formal_moles > torch.finfo(formal_moles.dtype).tiny)
                    )
                    .detach()
                    .all()
                ),
                initial_radius=initial_radius,
                maximum_radius=torch.maximum(
                    10.0 * initial_radius,
                    initial_radius.new_tensor(1.0),
                ),
                gradient_tolerance=tolerance,
                max_iterations=max_iterations,
            )
            q = trust_result.solution
            value = trust_result.objective
            iterations = trust_result.iterations
            converged = trust_result.converged
            composition = q / q.sum()
        else:
            trial = trial / trial.sum()
            coordinates = torch.log(trial[:-1]) - torch.log(trial[-1])

            def composition_objective(q: Tensor) -> Tensor:
                w = _independent_softmax(q)
                # Stable-root trial evaluation catches both vapor- and liquid-like minima.
                log_phi = model.log_fugacity_coefficients(
                    state.temperature, state.pressure, w, "stable"
                )
                return torch.sum(
                    w * (torch.log(torch.clamp_min(w, torch.finfo(w.dtype).tiny)) + log_phi - d)
                )

            q, value, iterations, converged = _newton_minimize(
                composition_objective,
                coordinates,
                tolerance=tolerance,
                max_iterations=max_iterations,
            )
            composition = _independent_softmax(q)
        if float(value) < float(best_value):
            best_value = value
            best_composition = composition
            best_iterations = iterations
            best_converged = converged

    stability_tolerance = 10.0 * tolerance
    return StabilityResult(
        bool(float(best_value) >= -stability_tolerance),
        best_value,
        best_composition,
        best_iterations,
        best_converged,
    )

two_phase_flash

two_phase_flash(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_k_values: Tensor | None = None,
    check_stability: bool = True,
    tolerance: float = 1e-08,
    max_iterations: int = 100,
    raise_on_failure: bool = False,
) -> FlashResult

Solve a scalar isothermal-isobaric two-phase flash.

The solver optionally applies Michelsen tangent-plane stability analysis, solves the material balance with the safeguarded Rachford--Rice solver, and enforces equality of component fugacities using successive substitution followed by autodiff-assembled Newton steps.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model providing phase fugacity coefficients and properties.

required
state ChemicalState

Feed state with temperature in K, pressure in Pa, and one mole-fraction vector of shape (ncomponents,).

required
initial_k_values Tensor | None

Positive initial vapor-to-liquid equilibrium ratios with shape (ncomponents,). Wilson estimates are used when omitted, which requires critical constants and acentric factors on model.

None
check_stability bool

Run tangent-plane stability analysis before attempting a split. A stable feed returns a one-phase result.

True
tolerance float

Convergence threshold for the maximum absolute log-fugacity residual.

1e-08
max_iterations int

Maximum phase-equilibrium iterations after initialization.

100
raise_on_failure bool

Raise RuntimeError instead of emitting ConvergenceWarning when the phase-equilibrium iterations do not converge.

False

Returns:

Type Description
FlashResult

Phase fractions, identified phase properties, convergence status, iteration count, and residual diagnostics. For a two-phase result, fractions are ordered liquid then vapor before phase identification.

Raises:

Type Description
ValueError

If the composition is batched, suitable initial K values cannot be constructed, or no finite two-phase material-balance root is found.

RuntimeError

If convergence fails and raise_on_failure is true.

Warns:

Type Description
ConvergenceWarning

If the requested split does not converge and raise_on_failure is false. The returned result is explicitly marked non-converged.

Notes

The reported equilibrium residual is max_i |log(K_i) - (log(phi_i^L) - log(phi_i^V))|. A converged Rachford--Rice solve is necessary but is not by itself evidence of phase equilibrium.

Source code in src/torch_flash/flash/two_phase.py
def two_phase_flash(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_k_values: Tensor | None = None,
    check_stability: bool = True,
    tolerance: float = 1.0e-8,
    max_iterations: int = 100,
    raise_on_failure: bool = False,
) -> FlashResult:
    """Solve a scalar isothermal-isobaric two-phase flash.

    The solver optionally applies Michelsen tangent-plane stability analysis,
    solves the material balance with the safeguarded Rachford--Rice solver,
    and enforces equality of component fugacities using successive
    substitution followed by autodiff-assembled Newton steps.

    Parameters
    ----------
    model
        Homogeneous-state model providing phase fugacity coefficients and
        properties.
    state
        Feed state with temperature in K, pressure in Pa, and one
        mole-fraction vector of shape ``(ncomponents,)``.
    initial_k_values
        Positive initial vapor-to-liquid equilibrium ratios with shape
        ``(ncomponents,)``. Wilson estimates are used when omitted, which
        requires critical constants and acentric factors on ``model``.
    check_stability
        Run tangent-plane stability analysis before attempting a split. A
        stable feed returns a one-phase result.
    tolerance
        Convergence threshold for the maximum absolute log-fugacity residual.
    max_iterations
        Maximum phase-equilibrium iterations after initialization.
    raise_on_failure
        Raise ``RuntimeError`` instead of emitting ``ConvergenceWarning`` when
        the phase-equilibrium iterations do not converge.

    Returns
    -------
    FlashResult
        Phase fractions, identified phase properties, convergence status,
        iteration count, and residual diagnostics. For a two-phase result,
        fractions are ordered liquid then vapor before phase identification.

    Raises
    ------
    ValueError
        If the composition is batched, suitable initial K values cannot be
        constructed, or no finite two-phase material-balance root is found.
    RuntimeError
        If convergence fails and ``raise_on_failure`` is true.

    Warns
    -----
    ConvergenceWarning
        If the requested split does not converge and ``raise_on_failure`` is
        false. The returned result is explicitly marked non-converged.

    Notes
    -----
    The reported equilibrium residual is
    ``max_i |log(K_i) - (log(phi_i^L) - log(phi_i^V))|``. A converged
    Rachford--Rice solve is necessary but is not by itself evidence of phase
    equilibrium.
    """
    if state.composition.ndim != 1:
        raise ValueError("two_phase_flash currently accepts one composition vector")
    z = state.composition
    if check_stability:
        stability = tangent_plane_stability(model, state)
        if stability.stable:
            phase = phase_properties(model, state, "stable")
            return FlashResult(
                torch.ones(1, dtype=z.dtype, device=z.device),
                (phase,),
                stability.converged,
                stability.iterations,
                torch.clamp_min(stability.minimum_tpd, 0.0),
                True,
                {"minimum_tpd": float(stability.minimum_tpd)},
            )

    if initial_k_values is None:
        initial_k_values = wilson_k_values(
            _model_components(model), state.temperature, state.pressure
        )
    log_k = torch.log(torch.clamp_min(initial_k_values, 1.0e-30))
    converged = False
    residual_norm = torch.tensor(torch.inf, dtype=z.dtype, device=z.device)
    rr = None

    def equilibrium_residual(current_log_k: Tensor) -> Tensor:
        current_k = torch.exp(current_log_k)
        split = rachford_rice(z, current_k, tolerance=1.0e-13)
        x = split.liquid_composition
        y = split.vapor_composition
        log_phi_l = model.log_fugacity_coefficients(state.temperature, state.pressure, x, "liquid")
        log_phi_v = model.log_fugacity_coefficients(state.temperature, state.pressure, y, "vapor")
        return current_log_k - (log_phi_l - log_phi_v)

    for iteration in range(1, max_iterations + 1):
        k = torch.exp(log_k)
        try:
            rr = rachford_rice(z, k, tolerance=1.0e-13)
        except ValueError:
            # Pull non-straddling Wilson estimates toward a neutral split.
            log_k = log_k - torch.sum(z * log_k)
            continue
        x = rr.liquid_composition
        y = rr.vapor_composition
        log_phi_l = model.log_fugacity_coefficients(state.temperature, state.pressure, x, "liquid")
        log_phi_v = model.log_fugacity_coefficients(state.temperature, state.pressure, y, "vapor")
        residual = log_k - (log_phi_l - log_phi_v)
        residual_norm = residual.abs().max()
        if float(residual_norm) <= tolerance:
            converged = True
            break

        if iteration <= 12:
            damping = 0.8 if iteration < 5 else 0.5
            log_k = log_k - damping * residual
            continue

        jacobian = torch.func.jacrev(equilibrium_residual)(log_k)
        eye = torch.eye(log_k.numel(), dtype=log_k.dtype, device=log_k.device)
        try:
            step = torch.linalg.solve(jacobian + 1.0e-10 * eye, -residual)
        except torch.linalg.LinAlgError:
            step = -0.25 * residual
        accepted = False
        factor = 1.0
        for _ in range(12):
            candidate = log_k + factor * step
            try:
                candidate_residual = equilibrium_residual(candidate)
            except ValueError:
                factor *= 0.5
                continue
            if float(candidate_residual.abs().max()) < float(residual_norm):
                log_k = candidate
                accepted = True
                break
            factor *= 0.5
        if not accepted:
            log_k = log_k - 0.2 * residual

    if rr is None:
        raise ValueError("could not construct a two-phase material-balance split")
    final_k = torch.exp(log_k)
    rr = rachford_rice(z, final_k, tolerance=1.0e-13)
    liquid_state = ChemicalState(state.temperature, state.pressure, rr.liquid_composition)
    vapor_state = ChemicalState(state.temperature, state.pressure, rr.vapor_composition)
    liquid = phase_properties(model, liquid_state, "liquid", caloric=False)
    vapor = phase_properties(model, vapor_state, "vapor", caloric=False)
    if not converged:
        message = (
            f"two-phase flash did not converge in {max_iterations} iterations "
            f"(log-fugacity residual {float(residual_norm):.3e})"
        )
        if raise_on_failure:
            raise RuntimeError(message)
        warnings.warn(message, ConvergenceWarning, stacklevel=2)
    fractions = torch.stack((rr.liquid_fraction, rr.vapor_fraction))
    identified_phases = identify_flash_phases((liquid, vapor))
    return FlashResult(
        fractions,
        identified_phases,
        converged,
        iteration,
        residual_norm,
        converged,
        {"rachford_rice_iterations": rr.iterations},
    )

two_phase_trust_region_flash

two_phase_trust_region_flash(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_k_values: Tensor | None = None,
    phase_roots: tuple[PhaseKind, PhaseKind] = (
        "liquid",
        "vapor",
    ),
    check_stability: bool = True,
    tolerance: float = 1e-08,
    max_iterations: int = 100,
    raise_on_failure: bool = False,
) -> FlashResult

Minimize two-phase Gibbs energy with an exact dense trust region.

The formulation follows M. Petitfrere and D. V. Nichita, "Robust and efficient Trust-Region based stability analysis and multiphase flash calculations", Fluid Phase Equilibria 362 (2014), 51-68, equations (8)-(15) and sections 3.1-3.4, doi:10.1016/j.fluid.2013.08.039. Direct component mole amounts in the initially smaller phase are independent variables; the corresponding amounts in the reference phase are dependent, so component material balance is exact at every accepted step. PyTorch autodiff supplies the exact gradient and Hessian.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model.

required
state ChemicalState

Scalar temperature in K, pressure in Pa, and one feed-composition vector.

required
initial_k_values Tensor | None

Optional positive vapor-to-liquid ratios. Wilson values are used when omitted.

None
phase_roots tuple[PhaseKind, PhaseKind]

Root requests for the two candidate phases.

('liquid', 'vapor')
check_stability bool

Run trust-region tangent-plane stability first. A stable feed returns one homogeneous phase without minimizing a split.

True
tolerance float

Maximum dimensionless log-fugacity residual and trust-region gradient tolerance.

1e-08
max_iterations int

Maximum trust-region iterations.

100
raise_on_failure bool

Raise RuntimeError rather than emitting ConvergenceWarning for a non-converged or phase-collapsed result.

False

Returns:

Type Description
FlashResult

Phase fractions, properties, convergence flag, fugacity residual, and trust-region diagnostics.

Raises:

Type Description
ValueError

If state shapes, phase roots, iteration controls, or initial ratios are invalid.

RuntimeError

If the split fails and raise_on_failure is true.

Warns:

Type Description
ConvergenceWarning

If the trust-region stationary point does not also pass fugacity and phase-fraction gates.

Notes

This is a local two-phase minimization. It does not replace multistart stability analysis or prove that no third phase has lower Gibbs energy. The default :func:two_phase_flash remains preferable for ordinary well-conditioned vapor-liquid states unless matched benchmarks establish a trust-region advantage.

Source code in src/torch_flash/flash/two_phase.py
def two_phase_trust_region_flash(
    model: StateModel,
    state: ChemicalState,
    *,
    initial_k_values: Tensor | None = None,
    phase_roots: tuple[PhaseKind, PhaseKind] = ("liquid", "vapor"),
    check_stability: bool = True,
    tolerance: float = 1.0e-8,
    max_iterations: int = 100,
    raise_on_failure: bool = False,
) -> FlashResult:
    """Minimize two-phase Gibbs energy with an exact dense trust region.

    The formulation follows M. Petitfrere and D. V. Nichita, "Robust and
    efficient Trust-Region based stability analysis and multiphase flash
    calculations", *Fluid Phase Equilibria* 362 (2014), 51-68, equations
    (8)-(15) and sections 3.1-3.4,
    doi:10.1016/j.fluid.2013.08.039. Direct component mole amounts in the
    initially smaller phase are independent variables; the corresponding
    amounts in the reference phase are dependent, so component material
    balance is exact at every accepted step. PyTorch autodiff supplies the
    exact gradient and Hessian.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model.
    state
        Scalar temperature in K, pressure in Pa, and one feed-composition
        vector.
    initial_k_values
        Optional positive vapor-to-liquid ratios. Wilson values are used when
        omitted.
    phase_roots
        Root requests for the two candidate phases.
    check_stability
        Run trust-region tangent-plane stability first. A stable feed returns
        one homogeneous phase without minimizing a split.
    tolerance
        Maximum dimensionless log-fugacity residual and trust-region gradient
        tolerance.
    max_iterations
        Maximum trust-region iterations.
    raise_on_failure
        Raise ``RuntimeError`` rather than emitting ``ConvergenceWarning`` for
        a non-converged or phase-collapsed result.

    Returns
    -------
    FlashResult
        Phase fractions, properties, convergence flag, fugacity residual, and
        trust-region diagnostics.

    Raises
    ------
    ValueError
        If state shapes, phase roots, iteration controls, or initial ratios
        are invalid.
    RuntimeError
        If the split fails and ``raise_on_failure`` is true.

    Warns
    -----
    ConvergenceWarning
        If the trust-region stationary point does not also pass fugacity and
        phase-fraction gates.

    Notes
    -----
    This is a local two-phase minimization. It does not replace multistart
    stability analysis or prove that no third phase has lower Gibbs energy.
    The default :func:`two_phase_flash` remains preferable for ordinary
    well-conditioned vapor-liquid states unless matched benchmarks establish
    a trust-region advantage.
    """
    if state.composition.ndim != 1:
        raise ValueError("two-phase trust-region flash accepts one composition vector")
    if len(phase_roots) != 2 or any(
        phase not in ("liquid", "vapor", "stable") for phase in phase_roots
    ):
        raise ValueError("phase_roots must contain two valid phase-root requests")
    if tolerance <= 0.0 or max_iterations <= 0:
        raise ValueError("two-phase trust-region controls must be positive")
    z = state.composition / state.composition.sum()
    if not bool(torch.isfinite(z).all() & (z > 0.0).all()):
        raise ValueError(
            "two-phase trust-region flash requires finite strictly positive feed fractions"
        )
    if check_stability:
        stability = tangent_plane_stability(
            model,
            state,
            minimizer="trust-region",
            tolerance=min(tolerance, 1.0e-9),
            max_iterations=max_iterations,
        )
        if stability.stable and stability.converged:
            phase = phase_properties(model, state, "stable")
            return FlashResult(
                torch.ones(1, dtype=z.dtype, device=z.device),
                (phase,),
                True,
                stability.iterations,
                torch.clamp_min(stability.minimum_tpd, 0.0),
                True,
                {
                    "minimum_tpd": float(stability.minimum_tpd),
                    "trust_region_stability": True,
                },
            )

    if initial_k_values is None:
        initial_k_values = wilson_k_values(
            _model_components(model),
            state.temperature,
            state.pressure,
        )
    if initial_k_values.shape != z.shape or not bool(
        torch.isfinite(initial_k_values).all() & (initial_k_values > 0.0).all()
    ):
        raise ValueError("initial K values must be a finite positive component vector")
    log_k = torch.log(initial_k_values)
    log_k = log_k - torch.sum(z * log_k)
    split = rachford_rice(z, torch.exp(log_k), tolerance=1.0e-13)
    first_initial_moles = split.liquid_fraction * split.liquid_composition
    second_initial_moles = split.vapor_fraction * split.vapor_composition
    independent_is_first = first_initial_moles <= second_initial_moles
    initial = torch.where(
        independent_is_first,
        first_initial_moles,
        second_initial_moles,
    )
    mole_floor = 16.0 * torch.finfo(z.dtype).eps * z
    initial = torch.minimum(
        torch.maximum(initial, mole_floor),
        z - mole_floor,
    )

    def quantities(independent_moles: Tensor) -> tuple[Tensor, Tensor, Tensor]:
        first_moles = torch.where(
            independent_is_first,
            independent_moles,
            z - independent_moles,
        )
        second_moles = z - first_moles
        phase_moles = torch.stack((first_moles, second_moles))
        fractions = phase_moles.sum(dim=-1)
        compositions = phase_moles / fractions[:, None]
        log_phi = torch.stack(
            tuple(
                model.log_fugacity_coefficients(
                    state.temperature,
                    state.pressure,
                    composition,
                    phase_kind,
                )
                for composition, phase_kind in zip(
                    compositions,
                    phase_roots,
                    strict=True,
                )
            )
        )
        chemical_potential = torch.log(compositions) + log_phi
        gibbs = torch.sum(phase_moles * chemical_potential)
        return gibbs, fractions, compositions

    result = minimize_dense_trust_region(
        lambda independent_moles: quantities(independent_moles)[0],
        initial,
        is_feasible=lambda independent_moles: bool(
            (
                torch.isfinite(independent_moles)
                & (independent_moles > mole_floor)
                & (independent_moles < z - mole_floor)
            )
            .detach()
            .all()
        ),
        gradient_tolerance=tolerance,
        max_iterations=max_iterations,
    )
    _, fractions, compositions = quantities(result.solution)
    log_phi = torch.stack(
        tuple(
            model.log_fugacity_coefficients(
                state.temperature,
                state.pressure,
                composition,
                phase_kind,
            )
            for composition, phase_kind in zip(
                compositions,
                phase_roots,
                strict=True,
            )
        )
    )
    chemical_potential = torch.log(compositions) + log_phi
    residual_norm = (chemical_potential[1] - chemical_potential[0]).abs().max()
    phase_fraction_tolerance = max(10.0 * tolerance, 1.0e-10)
    physical = bool((fractions.detach() > phase_fraction_tolerance).all())
    converged = result.converged and physical and bool(residual_norm.detach() <= tolerance)
    phases = tuple(
        phase_properties(
            model,
            ChemicalState(state.temperature, state.pressure, composition),
            phase_kind,
            caloric=False,
        )
        for composition, phase_kind in zip(
            compositions,
            phase_roots,
            strict=True,
        )
    )
    if not converged:
        message = (
            "two-phase trust-region flash did not produce a physical "
            f"residual-converged split in {result.iterations} iterations "
            f"(log-fugacity residual {float(residual_norm):.3e})"
        )
        if raise_on_failure:
            raise RuntimeError(message)
        warnings.warn(message, ConvergenceWarning, stacklevel=2)
    return FlashResult(
        fractions,
        identify_flash_phases(phases),
        converged,
        result.iterations,
        residual_norm,
        converged,
        {
            "trust_region_accepted_steps": result.accepted_steps,
            "trust_region_rejected_steps": result.rejected_steps,
            "trust_region_gradient_norm": float(result.gradient_norm),
            "trust_region_minimum_hessian_eigenvalue": float(result.minimum_hessian_eigenvalue),
        },
    )

Material balance

torch_flash.material_balance

Material-balance equations.

rachford_rice_numpy

rachford_rice_numpy(
    composition: NDArray[float64],
    k_values: NDArray[float64],
    *,
    tolerance: float = 1e-15,
    max_iterations: int = 100,
) -> tuple[
    int, NDArray[float64], NDArray[float64], float, float
]

NumPy compatibility wrapper matching the Whitson contest signature.

Parameters:

Name Type Description Default
composition NDArray[float64]

One-dimensional overall mole-fraction array.

required
k_values NDArray[float64]

One-dimensional positive vapor-to-liquid equilibrium ratios with the same length as composition.

required
tolerance float

Accepted for signature compatibility; the delegated double-double solver controls its internal stopping criterion.

1e-15
max_iterations int

Accepted for signature compatibility and not used by the delegated solver.

100

Returns:

Type Description
tuple

(iterations, vapor_composition, liquid_composition, vapor_fraction, liquid_fraction). The compatibility iteration count is always one.

Raises:

Type Description
ValueError

If inputs are not equally sized vectors or no finite root is bracketed.

Notes

This exact-compatibility path delegates to the MIT-licensed chemicals dependency. Its implementation applies double-double arithmetic to the transformed/bounded formulation of Leibovici and Neoschil, Fluid Phase Equilibria 74 (1992), 303-308, doi:10.1016/0378-3812(92)85069-K. The dependency notice is retained in THIRD_PARTY_NOTICES.md.

Source code in src/torch_flash/material_balance/rachford_rice.py
def rachford_rice_numpy(
    composition: NDArray[np.float64],
    k_values: NDArray[np.float64],
    *,
    tolerance: float = 1.0e-15,
    max_iterations: int = 100,
) -> tuple[int, NDArray[np.float64], NDArray[np.float64], float, float]:
    """NumPy compatibility wrapper matching the Whitson contest signature.

    Parameters
    ----------
    composition
        One-dimensional overall mole-fraction array.
    k_values
        One-dimensional positive vapor-to-liquid equilibrium ratios with the
        same length as ``composition``.
    tolerance
        Accepted for signature compatibility; the delegated double-double
        solver controls its internal stopping criterion.
    max_iterations
        Accepted for signature compatibility and not used by the delegated
        solver.

    Returns
    -------
    tuple
        ``(iterations, vapor_composition, liquid_composition,
        vapor_fraction, liquid_fraction)``. The compatibility iteration count
        is always one.

    Raises
    ------
    ValueError
        If inputs are not equally sized vectors or no finite root is bracketed.

    Notes
    -----
    This exact-compatibility path delegates to the MIT-licensed
    ``chemicals`` dependency. Its implementation applies double-double
    arithmetic to the transformed/bounded formulation of Leibovici and Neoschil,
    *Fluid Phase Equilibria* 74 (1992), 303-308,
    doi:10.1016/0378-3812(92)85069-K. The dependency notice is retained in
    ``THIRD_PARTY_NOTICES.md``.
    """
    from chemicals.rachford_rice import (
        Rachford_Rice_solution_Leibovici_Neoschil_dd,
    )

    z = np.asarray(composition, dtype=np.float64)
    k = np.asarray(k_values, dtype=np.float64)
    if z.ndim != 1 or z.shape != k.shape:
        raise ValueError("the NumPy wrapper accepts equally sized one-dimensional arrays")
    z = z / z.sum()
    if np.any(k <= 0.0) or np.min(k) >= 1.0 or np.max(k) <= 1.0:
        raise ValueError("a finite root requires positive K values with Kmin < 1 < Kmax")
    del tolerance, max_iterations
    liquid, vapor, x, y = Rachford_Rice_solution_Leibovici_Neoschil_dd(z.tolist(), k.tolist())
    return (
        1,
        np.asarray(y, dtype=np.float64),
        np.asarray(x, dtype=np.float64),
        float(vapor),
        float(liquid),
    )

Saturation, envelopes, and binary equilibrium loci

torch_flash.envelope

Saturation points and phase-envelope tracing.

Thermodynamic formulation and continuation context follow Michelsen and Mollerup, Thermodynamic Models: Fundamentals & Computational Aspects, 2nd ed. (2007), chapter 12, ISBN 978-87-989961-3-2.

SaturationPoint dataclass

SaturationPoint(
    temperature: Tensor,
    pressure: Tensor,
    incipient_composition: Tensor,
    k_values: Tensor,
    kind: SaturationKind,
    iterations: int,
    converged: bool,
    residual_norm: Tensor,
)

One bubble- or dew-point solution.

Attributes:

Name Type Description
temperature Tensor

Specified absolute temperature in K.

pressure Tensor

Solved saturation pressure in Pa.

incipient_composition Tensor

Normalized composition of the incipient phase.

k_values Tensor

Final equilibrium ratios in feed-component order.

kind SaturationKind

"bubble" or "dew".

iterations int

Number of Newton iterations reported by the final solve.

converged bool

Whether the complete equilibrium/closure residual met tolerance.

residual_norm Tensor

Maximum absolute dimensionless residual.

PhaseTransitionPoint dataclass

PhaseTransitionPoint(
    temperature: Tensor,
    pressure: Tensor,
    parent_composition: Tensor,
    incipient_composition: Tensor,
    phase_kinds: tuple[PhaseKind, PhaseKind],
    iterations: int,
    converged: bool,
    residual_norm: Tensor,
    phase_separation: Tensor,
)

One locally solved incipient-phase pressure at specified T and feed.

Attributes:

Name Type Description
temperature Tensor

Specified absolute temperature in K.

pressure Tensor

Solved transition pressure in Pa.

parent_composition Tensor

Specified normalized composition of the disappearing parent phase.

incipient_composition Tensor

Solved normalized composition of the emerging phase.

phase_kinds tuple[PhaseKind, PhaseKind]

Algebraic EoS roots requested for the parent and incipient phases.

iterations int

Number of damped-Newton iterations performed.

converged bool

Whether both the fugacity residual and requested phase separation passed.

residual_norm Tensor

Maximum absolute dimensionless log-fugacity residual.

phase_separation Tensor

Maximum absolute component mole-fraction difference between phases.

PhaseTransitionBatch dataclass

PhaseTransitionBatch(
    temperature: Tensor,
    pressure: Tensor,
    parent_composition: Tensor,
    incipient_composition: Tensor,
    phase_kinds: tuple[PhaseKind, PhaseKind],
    iterations: Tensor,
    solver_converged: Tensor,
    converged: Tensor,
    residual_norm: Tensor,
    phase_separation: Tensor,
)

Independent local transition-pressure solutions in one tensor batch.

Attributes:

Name Type Description
temperature Tensor

Absolute temperatures with shape (batch,) in K.

pressure Tensor

Solved transition pressures with shape (batch,) in Pa.

parent_composition, incipient_composition

Normalized compositions with shape (batch, components).

phase_kinds tuple[PhaseKind, PhaseKind]

Common algebraic EoS roots for the parent and incipient phases.

iterations Tensor

Per-state damped-Newton iteration counts with shape (batch,).

solver_converged Tensor

Per-state fugacity-residual convergence flags.

converged Tensor

Per-state flags requiring both fugacity convergence and the requested minimum phase separation.

residual_norm Tensor

Per-state maximum dimensionless log-fugacity residual.

phase_separation Tensor

Per-state maximum absolute component-composition difference.

PhaseTransitionState dataclass

PhaseTransitionState(
    temperature: Tensor,
    reference_pressure: Tensor,
    parent_composition: Tensor,
    boundary_kind: PhaseBoundaryKind,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    initial_incipient_compositions: tuple[Tensor, ...] = (),
    initial_three_phase_compositions: tuple[
        Tensor, ...
    ] = (),
)

One reference state for a local phase-transition calculation.

Attributes:

Name Type Description
temperature Tensor

Scalar absolute temperature in K.

reference_pressure Tensor

Positive scalar pressure in Pa used to initialize and, when multiple disconnected roots are found, identify the corresponding local branch.

parent_composition Tensor

Strictly positive parent/overall composition vector.

boundary_kind PhaseBoundaryKind

"liquid-vapor", "liquid-liquid", or "liquid-liquid-vapor".

minimum_pressure, maximum_pressure

Optional positive scalar pressure bounds in Pa. If omitted, the evaluator uses one quarter and four times reference_pressure, clipped to 0.2 and 80 MPa.

initial_incipient_compositions tuple[Tensor, ...]

Optional two-phase incipient-composition starts. Generic Wilson, uniform, and component-rich starts are used when empty.

initial_three_phase_compositions tuple[Tensor, ...]

Optional binary three-phase starts with shape (3, 2). Generic separated liquid-liquid-vapor starts are used when empty.

PhaseTransitionEvaluation dataclass

PhaseTransitionEvaluation(
    state: PhaseTransitionState,
    pressure: Tensor,
    phase_compositions: Tensor,
    residual_norm: Tensor,
    phase_separation: Tensor,
    iterations: int,
    solver_converged: bool,
    converged: bool,
    solver: Literal["newton", "trust-region"] = "newton",
)

Selected physical result for one :class:PhaseTransitionState.

Attributes:

Name Type Description
state PhaseTransitionState

Reference state and branch specification.

pressure Tensor

Selected calculated transition pressure in Pa, or NaN if no candidate produced a finite pressure.

phase_compositions Tensor

Selected incipient composition with shape (1, n) for a two-phase boundary, or all three sorted binary compositions with shape (3, 2) for a three-phase invariant.

residual_norm Tensor

Maximum absolute dimensionless log-fugacity mismatch.

phase_separation Tensor

Maximum two-phase component difference or minimum adjacent binary three-phase composition difference.

iterations int

Nonlinear iterations reported by the selected candidate.

solver_converged bool

Whether fugacity equations converged before the phase-separation gate.

converged bool

Whether fugacity and physical phase-separation gates both passed.

solver Literal['newton', 'trust-region']

Nonlinear method that produced the selected candidate.

PhaseEnvelopeSet dataclass

PhaseEnvelopeSet(
    vapor_liquid: dict[
        SaturationKind, tuple[SaturationPoint, ...]
    ],
    liquid_liquid: tuple[
        tuple[PhaseTransitionPoint, ...], ...
    ],
)

Fixed-composition vapor-liquid and liquid-liquid phase boundaries.

Attributes:

Name Type Description
vapor_liquid dict[SaturationKind, tuple[SaturationPoint, ...]]

Bubble and dew branches on the requested vapor-liquid temperature sequence. Failed points remain explicit.

liquid_liquid tuple[tuple[PhaseTransitionPoint, ...], ...]

Independently continued liquid-liquid branches, each ordered by increasing temperature and retaining explicit failed points.

BinaryVLEPoint dataclass

BinaryVLEPoint(
    temperature: Tensor,
    pressure: Tensor,
    liquid_composition: Tensor,
    vapor_composition: Tensor,
    iterations: int,
    converged: bool,
    residual_norm: Tensor,
)

Coexisting binary liquid and vapor compositions at fixed T and P.

Attributes:

Name Type Description
temperature, pressure

Specified temperature in K and pressure in Pa.

liquid_composition, vapor_composition

Two-component normalized mole-fraction vectors.

iterations int

Number of nonlinear iterations performed.

converged bool

Whether fugacity equality converged and the phases remain separated by the requested minimum distance.

residual_norm Tensor

Maximum absolute dimensionless equilibrium residual.

FixedVaporRatioVLEPoint dataclass

FixedVaporRatioVLEPoint(
    temperature: Tensor,
    pressure: Tensor,
    liquid_composition: Tensor,
    vapor_composition: Tensor,
    variable_vapor_component: int,
    iterations: int,
    converged: bool,
    residual_norm: Tensor,
    phase_separation: Tensor,
)

VLE compositions at fixed T, P, and dry-vapor component ratio.

Attributes:

Name Type Description
temperature, pressure

Specified temperature in K and pressure in Pa.

liquid_composition, vapor_composition

Solved normalized liquid and vapor mole-fraction vectors. Ratios among the non-variable vapor components equal the specified reference ratio.

variable_vapor_component int

Component index whose vapor fraction was solved independently.

iterations int

Number of damped-Newton iterations performed.

converged bool

Whether the fugacity residual met tolerance and the two phases remain physically separated.

residual_norm Tensor

Maximum absolute dimensionless log-fugacity residual.

phase_separation Tensor

Maximum absolute component mole-fraction difference between phases.

BinaryVLEPointWithVolumes dataclass

BinaryVLEPointWithVolumes(
    temperature: Tensor,
    pressure: Tensor,
    liquid_composition: Tensor,
    vapor_composition: Tensor,
    iterations: int,
    converged: bool,
    residual_norm: Tensor,
    liquid_molar_volume: Tensor,
    vapor_molar_volume: Tensor,
)

Bases: BinaryVLEPoint

Binary fixed-pressure coexistence point retaining both molar volumes.

This result extends :class:BinaryVLEPoint with the liquid and vapor molar volumes used by a volume-based equilibrium solver. The container is named for its reusable thermodynamic payload; the currently producing operation, :func:binary_helmholtz_vle_point, carries the Helmholtz-model restriction.

Attributes:

Name Type Description
temperature, pressure

Specified temperature in K and pressure in Pa.

liquid_composition, vapor_composition

Solved normalized binary phase compositions.

iterations, converged, residual_norm

Nonlinear iteration count, physical-convergence status, and maximum absolute dimensionless residual.

liquid_molar_volume, vapor_molar_volume

Coexisting molar volumes in m3/mol retained for continuation.

BinaryBubblePoint dataclass

BinaryBubblePoint(
    temperature: Tensor,
    pressure: Tensor,
    liquid_composition: Tensor,
    vapor_composition: Tensor,
    iterations: int,
    converged: bool,
    residual_norm: Tensor,
)

Binary bubble pressure and incipient vapor at specified T, x.

Attributes:

Name Type Description
temperature Tensor

Specified temperature in K.

pressure Tensor

Solved bubble pressure in Pa.

liquid_composition Tensor

Specified normalized binary liquid composition.

vapor_composition Tensor

Solved normalized incipient-vapor composition.

iterations, converged, residual_norm

Nonlinear iteration count, convergence status, and maximum absolute dimensionless residual.

BinaryBubbleTemperature dataclass

BinaryBubbleTemperature(
    temperature: Tensor,
    pressure: Tensor,
    liquid_composition: Tensor,
    vapor_composition: Tensor,
    iterations: int,
    converged: bool,
    residual_norm: Tensor,
    phase_separation: Tensor,
)

Binary bubble temperature and incipient vapor at specified P, x.

Attributes:

Name Type Description
temperature Tensor

Solved bubble temperature in K.

pressure Tensor

Specified pressure in Pa.

liquid_composition Tensor

Specified normalized binary liquid composition.

vapor_composition Tensor

Solved normalized incipient-vapor composition.

iterations, converged, residual_norm

Nonlinear iteration count, physical-convergence status, and maximum absolute dimensionless log-fugacity residual.

phase_separation Tensor

Maximum absolute liquid-vapor mole-fraction difference. This is reported separately because the algebraic homogeneous solution is not a two-phase bubble point.

BinaryBubblePointWithVolumes dataclass

BinaryBubblePointWithVolumes(
    temperature: Tensor,
    pressure: Tensor,
    liquid_composition: Tensor,
    vapor_composition: Tensor,
    iterations: int,
    converged: bool,
    residual_norm: Tensor,
    liquid_molar_volume: Tensor,
    vapor_molar_volume: Tensor,
)

Bases: BinaryBubblePoint

Binary bubble point retaining the coexisting phase molar volumes.

This result extends :class:BinaryBubblePoint with the liquid and vapor molar volumes used by a volume-based equilibrium solver. The payload is a thermodynamic result rather than an equation-of-state implementation: any compatible algorithm can populate it. A Helmholtz-specific public operation currently produces it in :func:binary_helmholtz_bubble_point.

Attributes:

Name Type Description
temperature, pressure

Specified temperature in K and solved bubble pressure in Pa.

liquid_composition, vapor_composition

Specified binary liquid composition and solved incipient-vapor composition.

iterations, converged, residual_norm

Nonlinear iteration count, convergence status, and maximum absolute dimensionless residual.

liquid_molar_volume, vapor_molar_volume

Coexisting molar volumes in m3/mol. Reusing them as continuation variables can avoid nested liquid and vapor density inversions.

BinaryPxyIsotherm dataclass

BinaryPxyIsotherm(
    temperature: Tensor,
    pressure: Tensor,
    liquid_composition: Tensor,
    vapor_composition: Tensor,
    iterations: Tensor,
    converged: Tensor,
    residual_norm: Tensor,
    phase_separation: Tensor,
)

Liquid-composition continuation of a binary pressure-composition trace.

Attributes:

Name Type Description
temperature Tensor

Specified scalar temperature in K.

pressure Tensor

Attempted bubble pressures in Pa.

liquid_composition, vapor_composition

Coexisting binary composition arrays with shape (points, 2).

iterations Tensor

Nonlinear iteration count for every attempted point.

converged Tensor

Physical-convergence mask requiring the nonlinear residual and minimum phase separation to pass.

residual_norm Tensor

Maximum absolute dimensionless equilibrium residual at every point.

phase_separation Tensor

Maximum absolute liquid-vapor mole-fraction difference.

BinaryTxyIsobar dataclass

BinaryTxyIsobar(
    pressure: Tensor,
    temperature: Tensor,
    liquid_composition: Tensor,
    vapor_composition: Tensor,
    iterations: Tensor,
    converged: Tensor,
    residual_norm: Tensor,
    phase_separation: Tensor,
)

Liquid-composition continuation of a binary temperature-composition trace.

Attributes:

Name Type Description
pressure Tensor

Specified scalar pressure in Pa.

temperature Tensor

Attempted bubble temperatures in K.

liquid_composition, vapor_composition

Coexisting binary composition arrays with shape (points, 2).

iterations Tensor

Nonlinear iteration count for every attempted point.

converged Tensor

Physical-convergence mask requiring both the nonlinear residual and minimum phase separation to pass.

residual_norm Tensor

Maximum absolute dimensionless equilibrium residual at every point.

phase_separation Tensor

Maximum absolute liquid-vapor mole-fraction difference.

BinaryFixedCompositionBoundary dataclass

BinaryFixedCompositionBoundary(
    temperature: Tensor,
    bubble_pressure: Tensor,
    dew_pressure: Tensor,
    bubble_converged: Tensor,
    bubble_above_reporting_limit: Tensor,
    dew_converged: Tensor,
    dew_below_scan: Tensor,
    bubble_separation: Tensor,
    bubble_residual: Tensor,
    dew_residual: Tensor,
)

Pressure boundaries of a binary two-phase region at fixed composition.

Attributes:

Name Type Description
temperature Tensor

Requested one-dimensional temperature grid in K.

bubble_pressure, dew_pressure

Upper and lower boundary pressures in Pa. Missing crossings are NaN; an upper boundary above the requested reporting limit is clipped to that limit.

bubble_converged, dew_converged

Whether each boundary was resolved from physically separated, residual-converged bubble points.

bubble_above_reporting_limit Tensor

Whether the upper boundary lies at or above the reporting limit.

dew_below_scan Tensor

Whether the lower crossing lies below the liquid-composition scan.

bubble_separation Tensor

Vapor-liquid separation in the selected volatile-component coordinate at the upper boundary.

bubble_residual, dew_residual

Conservative dimensionless residual associated with each interpolated boundary.

BinaryPhaseEquilibriumPoint dataclass

BinaryPhaseEquilibriumPoint(
    temperature: Tensor,
    pressure: Tensor,
    phase1_composition: Tensor,
    phase2_composition: Tensor,
    phase_kinds: tuple[PhaseKind, PhaseKind],
    iterations: int,
    converged: bool,
    residual_norm: Tensor,
)

Coexisting binary compositions for a requested pair of phase roots.

Attributes:

Name Type Description
temperature, pressure

Specified temperature in K and pressure in Pa.

phase1_composition, phase2_composition

Solved normalized binary phase compositions.

phase_kinds tuple[PhaseKind, PhaseKind]

Algebraic root requested for each phase.

iterations, converged, residual_norm

Nonlinear iteration count, convergence/separation status, and maximum absolute dimensionless residual.

BinaryCriticalPoint dataclass

BinaryCriticalPoint(
    temperature: Tensor,
    pressure: Tensor,
    molar_volume: Tensor,
    composition: Tensor,
    iterations: int,
    converged: bool,
    residual_norm: Tensor,
)

Binary-mixture critical point at a specified overall composition.

Attributes:

Name Type Description
temperature, pressure

Solved critical temperature in K and pressure in Pa.

molar_volume Tensor

Solved critical molar volume in m3/mol.

composition Tensor

Specified normalized binary composition.

iterations int

Number of nonlinear iterations performed.

converged bool

Whether the criticality residual met tolerance.

residual_norm Tensor

Maximum absolute scaled criticality residual.

saturation_point

saturation_point(
    model: StateModel,
    temperature: Tensor,
    composition: Tensor,
    kind: SaturationKind,
    *,
    initial_pressure: Tensor | None = None,
    initial_k_values: Tensor | None = None,
    tolerance: float = 1e-08,
    max_iterations: int = 40,
    substitution_iterations: int | None = None,
) -> SaturationPoint

Calculate an isothermal bubble or dew point with full Newton updates.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model providing liquid and vapor fugacity coefficients and critical constants for default initialization.

required
temperature Tensor

Scalar temperature in K.

required
composition Tensor

Feed-phase mole-fraction vector.

required
kind SaturationKind

"bubble" for an incipient vapor or "dew" for an incipient liquid.

required
initial_pressure Tensor | None

Optional positive pressure estimate in Pa.

None
initial_k_values Tensor | None

Optional positive vapor-to-liquid equilibrium-ratio vector.

None
tolerance float

Maximum absolute equilibrium/closure residual.

1e-08
max_iterations int

Maximum nonlinear iterations.

40
substitution_iterations int | None

Number of Michelsen substitution passes before Newton. None uses up to 20 passes.

None

Returns:

Type Description
SaturationPoint

Pressure, incipient composition, K values, and explicit nonlinear convergence diagnostics.

Raises:

Type Description
ValueError

If kind, initialization, component data, or iteration controls are invalid.

Notes

substitution_iterations=None applies up to 20 Michelsen successive- substitution steps before Newton. A continuation driver with a nearby converged state can reduce this count; :func:phase_envelope does so only after a two-point secant predictor is available. Both the initializer and Newton iterates are confined to the same finite ln(K) and pressure bounds.

Source code in src/torch_flash/envelope.py
def saturation_point(
    model: StateModel,
    temperature: Tensor,
    composition: Tensor,
    kind: SaturationKind,
    *,
    initial_pressure: Tensor | None = None,
    initial_k_values: Tensor | None = None,
    tolerance: float = 1.0e-8,
    max_iterations: int = 40,
    substitution_iterations: int | None = None,
) -> SaturationPoint:
    """Calculate an isothermal bubble or dew point with full Newton updates.

    Parameters
    ----------
    model
        Homogeneous-state model providing liquid and vapor fugacity
        coefficients and critical constants for default initialization.
    temperature
        Scalar temperature in K.
    composition
        Feed-phase mole-fraction vector.
    kind
        ``"bubble"`` for an incipient vapor or ``"dew"`` for an incipient
        liquid.
    initial_pressure
        Optional positive pressure estimate in Pa.
    initial_k_values
        Optional positive vapor-to-liquid equilibrium-ratio vector.
    tolerance
        Maximum absolute equilibrium/closure residual.
    max_iterations
        Maximum nonlinear iterations.
    substitution_iterations
        Number of Michelsen substitution passes before Newton. ``None`` uses
        up to 20 passes.

    Returns
    -------
    SaturationPoint
        Pressure, incipient composition, K values, and explicit nonlinear
        convergence diagnostics.

    Raises
    ------
    ValueError
        If kind, initialization, component data, or iteration controls are
        invalid.

    Notes
    -----
    ``substitution_iterations=None`` applies up to 20 Michelsen successive-
    substitution steps before Newton. A continuation driver with a nearby
    converged state can reduce this count; :func:`phase_envelope` does so only
    after a two-point secant predictor is available. Both the initializer and
    Newton iterates are confined to the same finite ``ln(K)`` and pressure
    bounds.
    """
    if kind not in ("bubble", "dew"):
        raise ValueError(f"unknown saturation kind {kind!r}")
    if substitution_iterations is not None and substitution_iterations < 0:
        raise ValueError("substitution_iterations must be nonnegative")
    z = normalize_composition(composition)
    components = _components_from_model(model)
    if initial_pressure is None:
        if not bool(
            torch.isfinite(components.critical_temperature).all()
            & torch.isfinite(components.critical_pressure).all()
            & torch.isfinite(components.acentric_factor).all()
        ):
            raise ValueError(
                "saturation calculation needs finite critical constants "
                "or an explicit initial pressure"
            )
        reference_pressure = torch.ones((), dtype=z.dtype, device=z.device)
        volatility = wilson_k_values(components, temperature, reference_pressure)
        if kind == "bubble":
            initial_pressure = torch.sum(z * volatility)
        elif kind == "dew":
            initial_pressure = 1.0 / torch.sum(z / volatility)
        else:  # pragma: no cover - validated above for type narrowing
            raise AssertionError
    if not bool(torch.isfinite(initial_pressure) & (initial_pressure > 0.0)):
        raise ValueError("initial saturation pressure must be finite and positive")
    if initial_k_values is None:
        initial_k = wilson_k_values(components, temperature, initial_pressure)
    else:
        if initial_k_values.shape != z.shape:
            raise ValueError("initial saturation K-values must match composition")
        if not bool(torch.isfinite(initial_k_values).all() & (initial_k_values > 0.0).all()):
            raise ValueError("initial saturation K-values must be finite and positive")
        initial_k = initial_k_values.to(dtype=z.dtype, device=z.device)
    pressure_ceiling = 50.0 * torch.max(components.critical_pressure)
    lower = torch.cat(
        (
            torch.full_like(initial_k, -50.0),
            initial_pressure.new_tensor([0.0]),
        )
    )
    upper = torch.cat(
        (
            torch.full_like(initial_k, 50.0),
            torch.log(pressure_ceiling).reshape(1),
        )
    )

    def project(current: Tensor) -> Tensor:
        return torch.minimum(torch.maximum(current, lower), upper)

    variables = torch.cat((torch.log(initial_k), torch.log(initial_pressure).reshape(1)))
    variables = project(variables)

    def residual(current: Tensor) -> Tensor:
        log_k = current[:-1]
        pressure = torch.exp(current[-1])
        k = torch.exp(log_k)
        if kind == "bubble":
            incipient = z * k
            incipient = incipient / incipient.sum()
            log_phi_feed = model.log_fugacity_coefficients(temperature, pressure, z, "liquid")
            log_phi_incipient = model.log_fugacity_coefficients(
                temperature, pressure, incipient, "vapor"
            )
            closure = torch.sum(z * k) - 1.0
            target_log_k = log_phi_feed - log_phi_incipient
        else:
            incipient = z / k
            incipient = incipient / incipient.sum()
            log_phi_feed = model.log_fugacity_coefficients(temperature, pressure, z, "vapor")
            log_phi_incipient = model.log_fugacity_coefficients(
                temperature, pressure, incipient, "liquid"
            )
            closure = torch.sum(z / k) - 1.0
            target_log_k = log_phi_incipient - log_phi_feed
        equilibrium = log_k - target_log_k
        return torch.cat((equilibrium, closure.reshape(1)))

    # Michelsen-style successive substitution provides a physical starting
    # branch and avoids Newton's trivial K=1 solution at extreme pressure.
    # The final full Newton solve retains the fast local convergence and
    # autodifferentiable residual formulation.
    substitution_count = min(
        20 if substitution_iterations is None else substitution_iterations,
        max_iterations,
    )
    for _ in range(substitution_count):
        value = residual(variables)
        if float(value.detach().abs().max()) <= tolerance:
            break
        log_k = variables[:-1] - value[:-1]
        k = torch.exp(log_k)
        closure_sum = torch.sum(z * k) if kind == "bubble" else torch.sum(z / k)
        pressure_update = (
            variables[-1] + torch.log(closure_sum)
            if kind == "bubble"
            else variables[-1] - torch.log(closure_sum)
        )
        target = torch.cat((log_k, pressure_update.reshape(1)))
        candidate = 0.5 * variables + 0.5 * target
        if not bool(torch.isfinite(candidate).all()):
            break
        variables = project(candidate)

    result = damped_newton(
        residual,
        variables,
        tolerance=tolerance,
        max_iterations=max_iterations,
        lower_bound=lower,
        upper_bound=upper,
    )
    variables = result.solution
    k = torch.exp(variables[:-1])
    pressure = torch.exp(variables[-1])
    incipient = z * k if kind == "bubble" else z / k
    incipient = incipient / incipient.sum()
    return SaturationPoint(
        temperature,
        pressure,
        incipient,
        k,
        kind,
        result.iterations,
        result.converged,
        result.residual_norm,
    )

phase_transition_pressure

phase_transition_pressure(
    model: StateModel,
    temperature: Tensor,
    parent_composition: Tensor,
    *,
    phase_kinds: tuple[PhaseKind, PhaseKind] = (
        "liquid",
        "vapor",
    ),
    initial_pressure: Tensor | None = None,
    initial_incipient_composition: Tensor | None = None,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    minimum_phase_separation: float = 1e-06,
    tolerance: float = 1e-08,
    max_iterations: int = 40,
) -> PhaseTransitionPoint

Solve a local incipient-phase pressure at specified T and composition.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model.

required
temperature Tensor

Scalar absolute temperature in K.

required
parent_composition Tensor

Strictly positive parent-phase mole fractions. At an incipient transition this equals the specified overall composition.

required
phase_kinds tuple[PhaseKind, PhaseKind]

Algebraic EoS roots for the parent and incipient phases. Use ("liquid", "vapor") for an L-V boundary or ("liquid", "liquid") for an L-L boundary.

('liquid', 'vapor')
initial_pressure Tensor | None

Positive pressure estimate in Pa. It is required unless phase_kinds is liquid-vapor or vapor-liquid and the model exposes finite critical constants for Wilson initialization.

None
initial_incipient_composition Tensor | None

Strictly positive incipient-phase composition estimate. It is required when both phases request the same root because the homogeneous composition is also an algebraic solution.

None
minimum_pressure Tensor | float | None

Optional positive pressure bounds in Pa. Bounds are important when disconnected L-L branches exist.

None
maximum_pressure Tensor | float | None

Optional positive pressure bounds in Pa. Bounds are important when disconnected L-L branches exist.

None
minimum_phase_separation float

Minimum maximum-component mole-fraction difference required to accept the result as two distinct phases.

1e-06
tolerance float

Maximum absolute dimensionless log-fugacity residual.

1e-08
max_iterations int

Maximum damped-Newton iterations.

40

Returns:

Type Description
PhaseTransitionPoint

Pressure, incipient composition, roots, and explicit residual, iteration, convergence, and phase-separation diagnostics.

Raises:

Type Description
ValueError

If a state, root, initialization, bound, or numerical control is invalid.

Notes

The formulation follows the isofugacity and incipient-phase equations in Michelsen and Mollerup, Thermodynamic Models: Fundamentals & Computational Aspects, 2nd ed. (2007), chapter 12, ISBN 978-87-989961-3-2. The first n-1 unknowns are incipient-phase composition logits and the final unknown is logarithmic pressure.

This is a local branch calculation, not a stability proof or automatic phase-topology search. Initial estimates and pressure bounds select among disconnected roots. A residual-converged homogeneous solution is returned with converged=False when it fails minimum_phase_separation.

Source code in src/torch_flash/envelope.py
def phase_transition_pressure(
    model: StateModel,
    temperature: Tensor,
    parent_composition: Tensor,
    *,
    phase_kinds: tuple[PhaseKind, PhaseKind] = ("liquid", "vapor"),
    initial_pressure: Tensor | None = None,
    initial_incipient_composition: Tensor | None = None,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    minimum_phase_separation: float = 1.0e-6,
    tolerance: float = 1.0e-8,
    max_iterations: int = 40,
) -> PhaseTransitionPoint:
    """Solve a local incipient-phase pressure at specified ``T`` and composition.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model.
    temperature
        Scalar absolute temperature in K.
    parent_composition
        Strictly positive parent-phase mole fractions. At an incipient
        transition this equals the specified overall composition.
    phase_kinds
        Algebraic EoS roots for the parent and incipient phases. Use
        ``("liquid", "vapor")`` for an L-V boundary or
        ``("liquid", "liquid")`` for an L-L boundary.
    initial_pressure
        Positive pressure estimate in Pa. It is required unless
        ``phase_kinds`` is liquid-vapor or vapor-liquid and the model exposes
        finite critical constants for Wilson initialization.
    initial_incipient_composition
        Strictly positive incipient-phase composition estimate. It is required
        when both phases request the same root because the homogeneous
        composition is also an algebraic solution.
    minimum_pressure, maximum_pressure
        Optional positive pressure bounds in Pa. Bounds are important when
        disconnected L-L branches exist.
    minimum_phase_separation
        Minimum maximum-component mole-fraction difference required to accept
        the result as two distinct phases.
    tolerance
        Maximum absolute dimensionless log-fugacity residual.
    max_iterations
        Maximum damped-Newton iterations.

    Returns
    -------
    PhaseTransitionPoint
        Pressure, incipient composition, roots, and explicit residual,
        iteration, convergence, and phase-separation diagnostics.

    Raises
    ------
    ValueError
        If a state, root, initialization, bound, or numerical control is
        invalid.

    Notes
    -----
    The formulation follows the isofugacity and incipient-phase equations in
    Michelsen and Mollerup, *Thermodynamic Models: Fundamentals &
    Computational Aspects*, 2nd ed. (2007), chapter 12,
    ISBN 978-87-989961-3-2. The first ``n-1`` unknowns are incipient-phase
    composition logits and the final unknown is logarithmic pressure.

    This is a local branch calculation, not a stability proof or automatic
    phase-topology search. Initial estimates and pressure bounds select among
    disconnected roots. A residual-converged homogeneous solution is returned
    with ``converged=False`` when it fails ``minimum_phase_separation``.
    """
    if temperature.ndim != 0 or not bool(torch.isfinite(temperature) & (temperature > 0.0)):
        raise ValueError("phase-transition temperature must be one finite positive scalar")
    if tolerance <= 0.0 or max_iterations <= 0:
        raise ValueError("phase-transition tolerance and max_iterations must be positive")
    if minimum_phase_separation < 0.0:
        raise ValueError("minimum phase separation must be nonnegative")
    if any(kind not in ("liquid", "vapor", "stable") for kind in phase_kinds):
        raise ValueError("phase kinds must be 'liquid', 'vapor', or 'stable'")

    parent = normalize_composition(parent_composition)
    if parent.ndim != 1 or parent.numel() < 2:
        raise ValueError("phase transition requires one multicomponent composition vector")
    if not bool(torch.isfinite(parent).all() & (parent > 0.0).all()):
        raise ValueError("parent-phase composition must be finite and strictly positive")

    components = _components_from_model(model)
    reference_pressure = torch.ones((), dtype=parent.dtype, device=parent.device)
    volatility: Tensor | None = None
    if phase_kinds in (("liquid", "vapor"), ("vapor", "liquid")):
        if not bool(
            torch.isfinite(components.critical_temperature).all()
            & torch.isfinite(components.critical_pressure).all()
            & torch.isfinite(components.acentric_factor).all()
        ):
            if initial_pressure is None or initial_incipient_composition is None:
                raise ValueError(
                    "phase transition needs finite critical constants or explicit "
                    "pressure and composition estimates"
                )
        else:
            volatility = wilson_k_values(components, temperature, reference_pressure)

    if initial_pressure is None:
        if volatility is None:
            raise ValueError(
                "initial pressure is required unless Wilson liquid-vapor initialization applies"
            )
        initial_pressure = (
            torch.sum(parent * volatility)
            if phase_kinds == ("liquid", "vapor")
            else 1.0 / torch.sum(parent / volatility)
        )
    initial_pressure = torch.as_tensor(
        initial_pressure,
        dtype=parent.dtype,
        device=parent.device,
    )
    if initial_pressure.ndim != 0 or not bool(
        torch.isfinite(initial_pressure) & (initial_pressure > 0.0)
    ):
        raise ValueError("initial phase-transition pressure must be one finite positive scalar")

    if initial_incipient_composition is None:
        if volatility is None:
            raise ValueError(
                "initial incipient composition is required for same-root phase transitions"
            )
        initial_incipient = normalize_composition(
            parent * volatility if phase_kinds == ("liquid", "vapor") else parent / volatility
        )
    else:
        initial_incipient = normalize_composition(
            initial_incipient_composition.to(dtype=parent.dtype, device=parent.device)
        )
    if initial_incipient.shape != parent.shape or not bool(
        torch.isfinite(initial_incipient).all() & (initial_incipient > 0.0).all()
    ):
        raise ValueError(
            "initial incipient composition must match the finite positive parent composition"
        )

    def pressure_bound(value: Tensor | float | None, name: str) -> Tensor:
        if value is None:
            return parent.new_tensor(-torch.inf if name == "minimum" else torch.inf)
        result = torch.as_tensor(value, dtype=parent.dtype, device=parent.device)
        if result.ndim != 0 or not bool(torch.isfinite(result) & (result > 0.0)):
            raise ValueError(f"{name} phase-transition pressure must be one finite positive scalar")
        return torch.log(result)

    minimum_log_pressure = pressure_bound(minimum_pressure, "minimum")
    maximum_log_pressure = pressure_bound(maximum_pressure, "maximum")
    if not bool(minimum_log_pressure < maximum_log_pressure):
        raise ValueError("minimum phase-transition pressure must be below maximum pressure")

    def composition_logits(composition: Tensor) -> Tensor:
        return torch.log(composition[:-1]) - torch.log(composition[-1])

    def composition_from_logits(logits: Tensor) -> Tensor:
        return torch.softmax(torch.cat((logits, logits.new_zeros(1))), dim=0)

    variables = torch.cat(
        (
            composition_logits(initial_incipient),
            torch.log(initial_pressure).reshape(1),
        )
    )
    lower_bound = torch.cat(
        (
            torch.full_like(variables[:-1], -30.0),
            minimum_log_pressure.reshape(1),
        )
    )
    upper_bound = torch.cat(
        (
            torch.full_like(variables[:-1], 30.0),
            maximum_log_pressure.reshape(1),
        )
    )

    def residual(current: Tensor) -> Tensor:
        incipient = composition_from_logits(current[:-1])
        pressure = torch.exp(current[-1])
        parent_log_phi = model.log_fugacity_coefficients(
            temperature,
            pressure,
            parent,
            phase_kinds[0],
        )
        incipient_log_phi = model.log_fugacity_coefficients(
            temperature,
            pressure,
            incipient,
            phase_kinds[1],
        )
        return torch.log(parent) + parent_log_phi - torch.log(incipient) - incipient_log_phi

    result = damped_newton(
        residual,
        variables,
        tolerance=tolerance,
        max_iterations=max_iterations,
        lower_bound=lower_bound,
        upper_bound=upper_bound,
    )
    incipient = composition_from_logits(result.solution[:-1])
    separation = torch.max(torch.abs(incipient - parent))
    return PhaseTransitionPoint(
        temperature,
        torch.exp(result.solution[-1]),
        parent,
        incipient,
        phase_kinds,
        result.iterations,
        result.converged and bool(separation.detach() > minimum_phase_separation),
        result.residual_norm,
        separation,
    )

solve_batched_phase_transition_pressures

solve_batched_phase_transition_pressures(
    model: StateModel,
    temperature: Tensor,
    parent_composition: Tensor,
    *,
    phase_kinds: tuple[PhaseKind, PhaseKind],
    initial_pressure: Tensor,
    initial_incipient_composition: Tensor,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    minimum_phase_separation: float = 1e-06,
    tolerance: float = 1e-08,
    max_iterations: int = 40,
) -> PhaseTransitionBatch

Solve independent local transition pressures in one PyTorch batch.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model shared by every state.

required
temperature Tensor

Absolute temperatures with shape (batch,) in K.

required
parent_composition Tensor

Strictly positive parent-phase mole fractions with shape (batch, components).

required
phase_kinds tuple[PhaseKind, PhaseKind]

Common algebraic roots for parent and incipient phases. Batch liquid-vapor and liquid-liquid states separately.

required
initial_pressure Tensor

Positive branch pressure estimates with shape (batch,) in Pa.

required
initial_incipient_composition Tensor

Strictly positive branch composition estimates with shape (batch, components).

required
minimum_pressure Tensor | float | None

Optional positive scalar or (batch,) pressure bounds in Pa.

None
maximum_pressure Tensor | float | None

Optional positive scalar or (batch,) pressure bounds in Pa.

None
minimum_phase_separation float

Minimum maximum-component mole-fraction difference required for a physical two-phase result.

1e-06
tolerance float

Maximum per-state dimensionless log-fugacity residual.

1e-08
max_iterations int

Maximum batched damped-Newton iterations.

40

Returns:

Type Description
PhaseTransitionBatch

Pressures, phase compositions, and explicit per-state diagnostics.

Raises:

Type Description
ValueError

If tensor shapes, states, roots, bounds, or numerical controls are invalid.

Notes

This is the batched form of :func:phase_transition_pressure. It solves the same local isofugacity equations and preserves gradients through every state. All states execute together in one tensor batch; they do not share material balance or alter one another's branch seed.

Source code in src/torch_flash/envelope.py
def solve_batched_phase_transition_pressures(
    model: StateModel,
    temperature: Tensor,
    parent_composition: Tensor,
    *,
    phase_kinds: tuple[PhaseKind, PhaseKind],
    initial_pressure: Tensor,
    initial_incipient_composition: Tensor,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    minimum_phase_separation: float = 1.0e-6,
    tolerance: float = 1.0e-8,
    max_iterations: int = 40,
) -> PhaseTransitionBatch:
    """Solve independent local transition pressures in one PyTorch batch.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model shared by every state.
    temperature
        Absolute temperatures with shape ``(batch,)`` in K.
    parent_composition
        Strictly positive parent-phase mole fractions with shape
        ``(batch, components)``.
    phase_kinds
        Common algebraic roots for parent and incipient phases. Batch
        liquid-vapor and liquid-liquid states separately.
    initial_pressure
        Positive branch pressure estimates with shape ``(batch,)`` in Pa.
    initial_incipient_composition
        Strictly positive branch composition estimates with shape
        ``(batch, components)``.
    minimum_pressure, maximum_pressure
        Optional positive scalar or ``(batch,)`` pressure bounds in Pa.
    minimum_phase_separation
        Minimum maximum-component mole-fraction difference required for a
        physical two-phase result.
    tolerance
        Maximum per-state dimensionless log-fugacity residual.
    max_iterations
        Maximum batched damped-Newton iterations.

    Returns
    -------
    PhaseTransitionBatch
        Pressures, phase compositions, and explicit per-state diagnostics.

    Raises
    ------
    ValueError
        If tensor shapes, states, roots, bounds, or numerical controls are
        invalid.

    Notes
    -----
    This is the batched form of :func:`phase_transition_pressure`. It solves
    the same local isofugacity equations and preserves gradients through every
    state. All states execute together in one tensor batch; they do not share
    material balance or alter one another's branch seed.
    """
    if temperature.ndim != 1 or temperature.numel() == 0:
        raise ValueError("batched phase-transition temperature must have shape (batch,)")
    if parent_composition.ndim != 2 or parent_composition.shape[0] != temperature.shape[0]:
        raise ValueError("batched parent composition must have shape (batch, components)")
    if parent_composition.shape[1] < 2:
        raise ValueError("batched phase transitions require at least two components")
    if any(kind not in ("liquid", "vapor", "stable") for kind in phase_kinds):
        raise ValueError("phase kinds must be 'liquid', 'vapor', or 'stable'")
    if tolerance <= 0.0 or max_iterations <= 0 or minimum_phase_separation < 0.0:
        raise ValueError("batched phase-transition controls are invalid")

    parent = normalize_composition(parent_composition)
    initial_incipient = normalize_composition(
        initial_incipient_composition.to(
            dtype=parent.dtype,
            device=parent.device,
        )
    )
    temperature = temperature.to(dtype=parent.dtype, device=parent.device)
    initial_pressure = initial_pressure.to(dtype=parent.dtype, device=parent.device)
    if (
        initial_incipient.shape != parent.shape
        or initial_pressure.shape != temperature.shape
        or not bool(
            torch.isfinite(temperature).all()
            & (temperature > 0.0).all()
            & torch.isfinite(parent).all()
            & (parent > 0.0).all()
            & torch.isfinite(initial_incipient).all()
            & (initial_incipient > 0.0).all()
            & torch.isfinite(initial_pressure).all()
            & (initial_pressure > 0.0).all()
        )
    ):
        raise ValueError("batched phase-transition states must be finite and positive")

    def pressure_bound(
        value: Tensor | float | None,
        *,
        lower: bool,
    ) -> Tensor:
        if value is None:
            fill = -torch.inf if lower else torch.inf
            return parent.new_full(temperature.shape, fill)
        result = torch.as_tensor(value, dtype=parent.dtype, device=parent.device)
        try:
            result = torch.broadcast_to(result, temperature.shape)
        except RuntimeError as error:
            raise ValueError("batched transition pressure bound is not broadcastable") from error
        if not bool(torch.isfinite(result).all() & (result > 0.0).all()):
            raise ValueError("batched transition pressure bounds must be finite and positive")
        return torch.log(result)

    minimum_log_pressure = pressure_bound(minimum_pressure, lower=True)
    maximum_log_pressure = pressure_bound(maximum_pressure, lower=False)
    if not bool((minimum_log_pressure < maximum_log_pressure).all()):
        raise ValueError("minimum transition pressures must be below maximum pressures")

    initial_logits = torch.log(initial_incipient[:, :-1]) - torch.log(initial_incipient[:, -1:])
    variables = torch.cat(
        (initial_logits, torch.log(initial_pressure).unsqueeze(-1)),
        dim=-1,
    )
    lower_bound = torch.cat(
        (
            torch.full_like(initial_logits, -30.0),
            minimum_log_pressure.unsqueeze(-1),
        ),
        dim=-1,
    )
    upper_bound = torch.cat(
        (
            torch.full_like(initial_logits, 30.0),
            maximum_log_pressure.unsqueeze(-1),
        ),
        dim=-1,
    )

    def residual(
        current: Tensor,
        state_temperature: Tensor,
        state_parent: Tensor,
    ) -> Tensor:
        incipient = torch.softmax(
            torch.cat(
                (
                    current[:, :-1],
                    current.new_zeros((current.shape[0], 1)),
                ),
                dim=-1,
            ),
            dim=-1,
        )
        pressure = torch.exp(current[:, -1])
        parent_log_phi = model.log_fugacity_coefficients(
            state_temperature,
            pressure,
            state_parent,
            phase_kinds[0],
        )
        incipient_log_phi = model.log_fugacity_coefficients(
            state_temperature,
            pressure,
            incipient,
            phase_kinds[1],
        )
        return torch.log(state_parent) + parent_log_phi - torch.log(incipient) - incipient_log_phi

    result = batched_damped_newton(
        residual,
        variables,
        temperature,
        parent,
        tolerance=tolerance,
        max_iterations=max_iterations,
        lower_bound=lower_bound,
        upper_bound=upper_bound,
    )
    incipient = torch.softmax(
        torch.cat(
            (
                result.solution[:, :-1],
                result.solution.new_zeros((result.solution.shape[0], 1)),
            ),
            dim=-1,
        ),
        dim=-1,
    )
    separation = torch.abs(incipient - parent).amax(dim=-1)
    physical = result.converged & (separation > minimum_phase_separation)
    return PhaseTransitionBatch(
        temperature,
        torch.exp(result.solution[:, -1]),
        parent,
        incipient,
        phase_kinds,
        result.iterations,
        result.converged,
        physical,
        result.residual_norm,
        separation,
    )

evaluate_phase_transition_state

evaluate_phase_transition_state(
    model: StateModel,
    state: PhaseTransitionState,
    *,
    tolerance: float = 1e-08,
    max_iterations: int = 40,
    minimum_phase_separation: float = 0.002,
    exhaustive_two_phase_starts: bool = False,
    three_phase_solver: ThreePhaseSolver = "newton",
) -> PhaseTransitionEvaluation

Evaluate and select a physical branch at one reference state.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model.

required
state PhaseTransitionState

Temperature, reference pressure, composition, transition identity, optional bounds, and optional multi-start compositions.

required
tolerance float

Maximum absolute dimensionless log-fugacity residual.

1e-08
max_iterations int

Maximum nonlinear iterations per candidate.

40
minimum_phase_separation float

Minimum two-phase component difference or adjacent binary three-phase composition difference.

0.002
exhaustive_two_phase_starts bool

Evaluate every declared liquid-vapor start before selecting the closest physical pressure. By default, the ordered Wilson, uniform, and component-rich starts are lazy fallbacks: the first physical liquid-vapor result is returned. Liquid-liquid and three-phase searches remain exhaustive.

False
three_phase_solver ThreePhaseSolver

"newton" uses the fast local invariant solver. "newton-trust-region" keeps Newton as the fast path, then polishes its selected physical LLV invariant or invokes an exact-Hessian dense trust-region recovery when Newton produced no separated branch.

'newton'

Returns:

Type Description
PhaseTransitionEvaluation

First physical ordered liquid-vapor candidate unless exhaustive search is requested. Exhaustive searches select the candidate closest in logarithmic pressure to reference_pressure among those passing residual and phase-separation gates. If none pass, the finite candidate with the closest pressure and then smallest residual is returned explicitly as non-converged.

Raises:

Type Description
ValueError

If the boundary identity, state tensors, numerical controls, or a three-phase non-binary composition are invalid.

Notes

Two-phase candidates solve the incipient equations documented by :func:phase_transition_pressure. Binary three-phase candidates use :func:torch_flash.flash.solve_binary_three_phase_invariant. Both follow the isofugacity formulation of Michelsen and Mollerup, Thermodynamic Models: Fundamentals & Computational Aspects, 2nd ed. (2007), chapter 12, ISBN 978-87-989961-3-2.

The reference pressure identifies a disconnected experimental or application branch; it is not included as an equilibrium residual. Candidate selection is discrete and therefore not differentiable. The selected pressure and residual retain their PyTorch parameter graph.

Source code in src/torch_flash/envelope.py
def evaluate_phase_transition_state(
    model: StateModel,
    state: PhaseTransitionState,
    *,
    tolerance: float = 1.0e-8,
    max_iterations: int = 40,
    minimum_phase_separation: float = 2.0e-3,
    exhaustive_two_phase_starts: bool = False,
    three_phase_solver: ThreePhaseSolver = "newton",
) -> PhaseTransitionEvaluation:
    """Evaluate and select a physical branch at one reference state.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model.
    state
        Temperature, reference pressure, composition, transition identity,
        optional bounds, and optional multi-start compositions.
    tolerance
        Maximum absolute dimensionless log-fugacity residual.
    max_iterations
        Maximum nonlinear iterations per candidate.
    minimum_phase_separation
        Minimum two-phase component difference or adjacent binary
        three-phase composition difference.
    exhaustive_two_phase_starts
        Evaluate every declared liquid-vapor start before selecting the
        closest physical pressure. By default, the ordered Wilson, uniform,
        and component-rich starts are lazy fallbacks: the first physical
        liquid-vapor result is returned. Liquid-liquid and three-phase
        searches remain exhaustive.
    three_phase_solver
        ``"newton"`` uses the fast local invariant solver.
        ``"newton-trust-region"`` keeps Newton as the fast path, then polishes
        its selected physical LLV invariant or invokes an exact-Hessian dense
        trust-region recovery when Newton produced no separated branch.

    Returns
    -------
    PhaseTransitionEvaluation
        First physical ordered liquid-vapor candidate unless exhaustive search
        is requested. Exhaustive searches select the candidate closest in
        logarithmic pressure to ``reference_pressure`` among those passing
        residual and phase-separation gates. If none pass, the finite candidate
        with the closest pressure and then smallest residual is returned
        explicitly as non-converged.

    Raises
    ------
    ValueError
        If the boundary identity, state tensors, numerical controls, or a
        three-phase non-binary composition are invalid.

    Notes
    -----
    Two-phase candidates solve the incipient equations documented by
    :func:`phase_transition_pressure`. Binary three-phase candidates use
    :func:`torch_flash.flash.solve_binary_three_phase_invariant`. Both follow
    the isofugacity formulation of Michelsen and Mollerup,
    *Thermodynamic Models: Fundamentals & Computational Aspects*, 2nd ed.
    (2007), chapter 12, ISBN 978-87-989961-3-2.

    The reference pressure identifies a disconnected experimental or
    application branch; it is not included as an equilibrium residual.
    Candidate selection is discrete and therefore not differentiable. The
    selected pressure and residual retain their PyTorch parameter graph.
    """
    if state.boundary_kind not in (
        "liquid-liquid",
        "liquid-vapor",
        "liquid-liquid-vapor",
    ):
        raise ValueError("unknown phase-boundary kind")
    if tolerance <= 0.0 or max_iterations <= 0 or minimum_phase_separation < 0.0:
        raise ValueError("phase-transition evaluation controls are invalid")
    if three_phase_solver not in ("newton", "newton-trust-region"):
        raise ValueError("unknown three-phase phase-transition solver")
    temperature = state.temperature
    reference_pressure = state.reference_pressure
    parent = normalize_composition(state.parent_composition)
    if temperature.ndim != 0 or not bool(torch.isfinite(temperature) & (temperature > 0.0)):
        raise ValueError("phase-transition temperature must be one finite positive scalar")
    if reference_pressure.ndim != 0 or not bool(
        torch.isfinite(reference_pressure) & (reference_pressure > 0.0)
    ):
        raise ValueError("phase-transition reference pressure must be finite and positive")
    if (
        parent.ndim != 1
        or parent.numel() < 2
        or not bool(torch.isfinite(parent).all() & (parent > 0.0).all())
    ):
        raise ValueError("phase-transition parent composition must be finite and positive")

    minimum_pressure = (
        max(0.2e6, 0.25 * float(reference_pressure.detach()))
        if state.minimum_pressure is None
        else state.minimum_pressure
    )
    maximum_pressure = (
        min(80.0e6, 4.0 * float(reference_pressure.detach()))
        if state.maximum_pressure is None
        else state.maximum_pressure
    )
    candidates: list[PhaseTransitionEvaluation] = []

    def failed(compositions: Tensor) -> PhaseTransitionEvaluation:
        return PhaseTransitionEvaluation(
            state,
            reference_pressure.new_tensor(torch.nan),
            compositions,
            reference_pressure.new_tensor(torch.inf),
            reference_pressure.new_zeros(()),
            0,
            False,
            False,
        )

    if state.boundary_kind == "liquid-liquid-vapor":
        if parent.numel() != 2:
            raise ValueError("liquid-liquid-vapor invariant evaluation requires a binary state")
        for three_phase_start in _default_three_phase_starts(state):
            try:
                invariant_result = solve_binary_three_phase_invariant(
                    model,
                    temperature,
                    reference_pressure,
                    three_phase_start,
                    tolerance=tolerance,
                    max_iterations=max_iterations,
                )
            except (InvalidStateError, RuntimeError, torch.linalg.LinAlgError):
                candidates.append(failed(three_phase_start))
                continue
            separation = torch.diff(invariant_result.phase_compositions[:, 0]).abs().min()
            physical = bool(
                invariant_result.converged
                and invariant_result.residual_norm <= tolerance
                and separation > minimum_phase_separation
            )
            candidates.append(
                PhaseTransitionEvaluation(
                    state,
                    invariant_result.pressure,
                    invariant_result.phase_compositions,
                    invariant_result.residual_norm,
                    separation,
                    invariant_result.iterations,
                    invariant_result.converged,
                    physical,
                )
            )
    else:
        phase_kinds: tuple[PhaseKind, PhaseKind] = (
            ("liquid", "liquid") if state.boundary_kind == "liquid-liquid" else ("liquid", "vapor")
        )
        for two_phase_start in _default_two_phase_starts(state):
            fallback_composition = parent if two_phase_start is None else two_phase_start
            try:
                transition_result = phase_transition_pressure(
                    model,
                    temperature,
                    parent,
                    phase_kinds=phase_kinds,
                    initial_pressure=reference_pressure,
                    initial_incipient_composition=two_phase_start,
                    minimum_pressure=minimum_pressure,
                    maximum_pressure=maximum_pressure,
                    minimum_phase_separation=minimum_phase_separation,
                    tolerance=tolerance,
                    max_iterations=max_iterations,
                )
            except (InvalidStateError, RuntimeError, torch.linalg.LinAlgError):
                candidates.append(failed(fallback_composition.reshape(1, -1)))
                continue
            physical = bool(
                transition_result.converged
                and transition_result.residual_norm <= tolerance
                and transition_result.phase_separation > minimum_phase_separation
            )
            candidate = PhaseTransitionEvaluation(
                state,
                transition_result.pressure,
                transition_result.incipient_composition.reshape(1, -1),
                transition_result.residual_norm,
                transition_result.phase_separation,
                transition_result.iterations,
                bool(transition_result.residual_norm <= tolerance),
                physical,
            )
            candidates.append(candidate)
            if (
                physical
                and state.boundary_kind == "liquid-vapor"
                and not exhaustive_two_phase_starts
            ):
                return candidate

    if state.boundary_kind == "liquid-liquid-vapor" and three_phase_solver == "newton-trust-region":
        candidates.extend(
            _trust_region_three_phase_candidates(
                model,
                state,
                candidates,
                tolerance=tolerance,
                max_iterations=max_iterations,
                minimum_phase_separation=minimum_phase_separation,
            )
        )

    def score(candidate: PhaseTransitionEvaluation) -> tuple[int, int, float, float]:
        branch_distance = (
            abs(float(torch.log(candidate.pressure.detach() / reference_pressure)))
            if bool(torch.isfinite(candidate.pressure.detach()))
            else float("inf")
        )
        return (
            0 if candidate.converged else 1,
            (
                0
                if three_phase_solver == "newton-trust-region"
                and candidate.solver == "trust-region"
                else 1
            ),
            branch_distance,
            float(candidate.residual_norm.detach()),
        )

    return min(candidates, key=score)

evaluate_phase_transition_states

evaluate_phase_transition_states(
    model: StateModel,
    states: Sequence[PhaseTransitionState],
    *,
    tolerance: float = 1e-08,
    max_iterations: int = 40,
    minimum_phase_separation: float = 0.002,
    exhaustive_two_phase_starts: bool = False,
    three_phase_solver: ThreePhaseSolver = "newton",
) -> tuple[PhaseTransitionEvaluation, ...]

Evaluate independent phase-transition reference states.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model shared by all supplied states.

required
states Sequence[PhaseTransitionState]

Independent states whose composition length must match model.

required
tolerance float
1e-08
max_iterations float
1e-08
minimum_phase_separation float
1e-08
exhaustive_two_phase_starts bool

Controls passed to :func:evaluate_phase_transition_state.

False
three_phase_solver bool

Controls passed to :func:evaluate_phase_transition_state.

False

Returns:

Type Description
tuple

One explicit evaluation per input state, preserving input order.

Notes

States are independent and may select disconnected branches through their reference pressures. No material balance is imposed between states.

Source code in src/torch_flash/envelope.py
def evaluate_phase_transition_states(
    model: StateModel,
    states: Sequence[PhaseTransitionState],
    *,
    tolerance: float = 1.0e-8,
    max_iterations: int = 40,
    minimum_phase_separation: float = 2.0e-3,
    exhaustive_two_phase_starts: bool = False,
    three_phase_solver: ThreePhaseSolver = "newton",
) -> tuple[PhaseTransitionEvaluation, ...]:
    """Evaluate independent phase-transition reference states.

    Parameters
    ----------
    model
        Homogeneous-state model shared by all supplied states.
    states
        Independent states whose composition length must match ``model``.
    tolerance, max_iterations, minimum_phase_separation,
    exhaustive_two_phase_starts, three_phase_solver
        Controls passed to :func:`evaluate_phase_transition_state`.

    Returns
    -------
    tuple
        One explicit evaluation per input state, preserving input order.

    Notes
    -----
    States are independent and may select disconnected branches through their
    reference pressures. No material balance is imposed between states.
    """
    if tolerance <= 0.0 or max_iterations <= 0 or minimum_phase_separation < 0.0:
        raise ValueError("phase-transition evaluation controls are invalid")
    if three_phase_solver not in ("newton", "newton-trust-region"):
        raise ValueError("unknown three-phase phase-transition solver")
    if not states:
        return ()

    components = _components_from_model(model)
    candidate_groups: list[list[PhaseTransitionEvaluation]] = [[] for _ in states]
    selected: list[PhaseTransitionEvaluation | None] = [None for _ in states]

    def pressure_bounds(
        state: PhaseTransitionState,
    ) -> tuple[Tensor | float, Tensor | float]:
        minimum_pressure = (
            max(0.2e6, 0.25 * float(state.reference_pressure.detach()))
            if state.minimum_pressure is None
            else state.minimum_pressure
        )
        maximum_pressure = (
            min(80.0e6, 4.0 * float(state.reference_pressure.detach()))
            if state.maximum_pressure is None
            else state.maximum_pressure
        )
        return minimum_pressure, maximum_pressure

    def failed(
        state: PhaseTransitionState,
        compositions: Tensor,
    ) -> PhaseTransitionEvaluation:
        return PhaseTransitionEvaluation(
            state,
            state.reference_pressure.new_tensor(torch.nan),
            compositions,
            state.reference_pressure.new_tensor(torch.inf),
            state.reference_pressure.new_zeros(()),
            0,
            False,
            False,
        )

    for boundary_kind in ("liquid-liquid", "liquid-vapor"):
        state_indices = [
            index for index, state in enumerate(states) if state.boundary_kind == boundary_kind
        ]
        if not state_indices:
            continue
        two_phase_starts = {
            index: _default_two_phase_starts(states[index]) for index in state_indices
        }
        maximum_starts = max(len(state_starts) for state_starts in two_phase_starts.values())
        for start_index in range(maximum_starts):
            active_indices = [
                index
                for index in state_indices
                if start_index < len(two_phase_starts[index])
                and (
                    boundary_kind == "liquid-liquid"
                    or exhaustive_two_phase_starts
                    or selected[index] is None
                )
            ]
            if not active_indices:
                continue

            temperatures = torch.stack(tuple(states[index].temperature for index in active_indices))
            parents = torch.stack(
                tuple(states[index].parent_composition for index in active_indices)
            )
            reference_pressures = torch.stack(
                tuple(states[index].reference_pressure for index in active_indices)
            )
            incipient_starts = []
            minimum_pressures = []
            maximum_pressures = []
            for index, state_temperature, parent in zip(
                active_indices,
                temperatures,
                parents,
                strict=True,
            ):
                start = two_phase_starts[index][start_index]
                if start is None:
                    volatility = wilson_k_values(
                        components,
                        state_temperature,
                        state_temperature.new_ones(()),
                    )
                    start = normalize_composition(parent * volatility)
                incipient_starts.append(start)
                lower, upper = pressure_bounds(states[index])
                minimum_pressures.append(reference_pressures.new_tensor(lower))
                maximum_pressures.append(reference_pressures.new_tensor(upper))
            try:
                two_phase_batch_result = solve_batched_phase_transition_pressures(
                    model,
                    temperatures,
                    parents,
                    phase_kinds=(
                        ("liquid", "liquid")
                        if boundary_kind == "liquid-liquid"
                        else ("liquid", "vapor")
                    ),
                    initial_pressure=reference_pressures,
                    initial_incipient_composition=torch.stack(tuple(incipient_starts)),
                    minimum_pressure=torch.stack(tuple(minimum_pressures)),
                    maximum_pressure=torch.stack(tuple(maximum_pressures)),
                    tolerance=tolerance,
                    max_iterations=max_iterations,
                    minimum_phase_separation=minimum_phase_separation,
                )
                for batch_index, state_index in enumerate(active_indices):
                    candidate = PhaseTransitionEvaluation(
                        states[state_index],
                        two_phase_batch_result.pressure[batch_index],
                        two_phase_batch_result.incipient_composition[batch_index].reshape(1, -1),
                        two_phase_batch_result.residual_norm[batch_index],
                        two_phase_batch_result.phase_separation[batch_index],
                        int(two_phase_batch_result.iterations[batch_index]),
                        bool(two_phase_batch_result.solver_converged[batch_index]),
                        bool(two_phase_batch_result.converged[batch_index]),
                    )
                    candidate_groups[state_index].append(candidate)
                    if (
                        boundary_kind == "liquid-vapor"
                        and not exhaustive_two_phase_starts
                        and candidate.converged
                    ):
                        selected[state_index] = candidate
            except (InvalidStateError, RuntimeError, torch.linalg.LinAlgError):
                for state_index, start in zip(
                    active_indices,
                    incipient_starts,
                    strict=True,
                ):
                    candidate_groups[state_index].append(
                        failed(states[state_index], start.reshape(1, -1))
                    )

    three_phase_indices = [
        index for index, state in enumerate(states) if state.boundary_kind == "liquid-liquid-vapor"
    ]
    if three_phase_indices:
        if any(states[index].parent_composition.numel() != 2 for index in three_phase_indices):
            raise ValueError("liquid-liquid-vapor invariant evaluation requires binary states")
        three_phase_starts = {
            index: _default_three_phase_starts(states[index]) for index in three_phase_indices
        }
        maximum_starts = max(len(state_starts) for state_starts in three_phase_starts.values())
        for start_index in range(maximum_starts):
            active_indices = [
                index
                for index in three_phase_indices
                if start_index < len(three_phase_starts[index])
            ]
            temperatures = torch.stack(tuple(states[index].temperature for index in active_indices))
            reference_pressures = torch.stack(
                tuple(states[index].reference_pressure for index in active_indices)
            )
            phase_starts = torch.stack(
                tuple(three_phase_starts[index][start_index] for index in active_indices)
            )
            try:
                three_phase_batch_result = solve_batched_binary_three_phase_invariants(
                    model,
                    temperatures,
                    reference_pressures,
                    phase_starts,
                    tolerance=tolerance,
                    max_iterations=max_iterations,
                )
                separations = (
                    torch.diff(
                        three_phase_batch_result.phase_compositions[:, :, 0],
                        dim=-1,
                    )
                    .abs()
                    .amin(dim=-1)
                )
                for batch_index, state_index in enumerate(active_indices):
                    physical = bool(
                        three_phase_batch_result.converged[batch_index]
                        and separations[batch_index] > minimum_phase_separation
                    )
                    candidate_groups[state_index].append(
                        PhaseTransitionEvaluation(
                            states[state_index],
                            three_phase_batch_result.pressure[batch_index],
                            three_phase_batch_result.phase_compositions[batch_index],
                            three_phase_batch_result.residual_norm[batch_index],
                            separations[batch_index],
                            int(three_phase_batch_result.iterations[batch_index]),
                            bool(three_phase_batch_result.converged[batch_index]),
                            physical,
                        )
                    )
            except (InvalidStateError, RuntimeError, torch.linalg.LinAlgError):
                for state_index, start in zip(
                    active_indices,
                    phase_starts,
                    strict=True,
                ):
                    candidate_groups[state_index].append(failed(states[state_index], start))

    unknown = [
        state.boundary_kind
        for state in states
        if state.boundary_kind
        not in (
            "liquid-liquid",
            "liquid-vapor",
            "liquid-liquid-vapor",
        )
    ]
    if unknown:
        raise ValueError("unknown phase-boundary kind")

    if three_phase_solver == "newton-trust-region" and three_phase_indices:
        _batched_trust_region_three_phase_candidates(
            model,
            states,
            three_phase_indices,
            candidate_groups,
            tolerance=tolerance,
            max_iterations=max_iterations,
            minimum_phase_separation=minimum_phase_separation,
        )

    def score(
        candidate: PhaseTransitionEvaluation,
    ) -> tuple[int, int, float, float]:
        reference_pressure = candidate.state.reference_pressure
        branch_distance = (
            abs(float(torch.log(candidate.pressure.detach() / reference_pressure)))
            if bool(torch.isfinite(candidate.pressure.detach()))
            else float("inf")
        )
        return (
            0 if candidate.converged else 1,
            (
                0
                if three_phase_solver == "newton-trust-region"
                and candidate.solver == "trust-region"
                else 1
            ),
            branch_distance,
            float(candidate.residual_norm.detach()),
        )

    return tuple(
        (candidate if candidate is not None else min(candidate_groups[index], key=score))
        for index, candidate in enumerate(selected)
    )

continue_phase_transition_branch

continue_phase_transition_branch(
    model: StateModel,
    temperatures: Tensor,
    initial_point: PhaseTransitionPoint,
    *,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    minimum_phase_separation: float = 1e-06,
    tolerance: float = 1e-08,
    max_iterations: int = 40,
) -> tuple[PhaseTransitionPoint, ...]

Continue one incipient two-phase branch over a temperature sequence.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model used for every continuation point.

required
temperatures Tensor

Nonempty one-dimensional temperature sequence in K, ordered in the desired continuation direction.

required
initial_point PhaseTransitionPoint

Converged branch seed supplying the parent composition, phase-root pair, pressure, and incipient-phase composition.

required
minimum_pressure Tensor | float | None

Optional positive pressure bounds in Pa applied to every point. Bounds can isolate a disconnected liquid-liquid branch.

None
maximum_pressure Tensor | float | None

Optional positive pressure bounds in Pa applied to every point. Bounds can isolate a disconnected liquid-liquid branch.

None
minimum_phase_separation float

Minimum maximum-component mole-fraction difference required to accept two physically distinct phases.

1e-06
tolerance float

Maximum absolute dimensionless log-fugacity residual.

1e-08
max_iterations int

Maximum damped-Newton iterations per temperature.

40

Returns:

Type Description
tuple

One :class:PhaseTransitionPoint per requested temperature. Failed points remain present with converged=False and do not replace the last converged continuation state.

Raises:

Type Description
ValueError

If the temperature grid is invalid or initial_point is not a converged, phase-separated seed.

Notes

The local equilibrium equations are those of :func:phase_transition_pressure, following Michelsen and Mollerup, Thermodynamic Models: Fundamentals & Computational Aspects, 2nd ed. (2007), chapter 12, ISBN 978-87-989961-3-2. Previous-point continuation is a numerical branch-tracking choice made explicitly by torch-flash. The caller controls direction, spacing, and pressure bounds because these choices can select among disconnected phase-transition branches.

Continuation initial states are detached between points. Each returned solve can still carry parameter derivatives, but the discrete decision to retain or reject an initializer is not differentiable.

Source code in src/torch_flash/envelope.py
def continue_phase_transition_branch(
    model: StateModel,
    temperatures: Tensor,
    initial_point: PhaseTransitionPoint,
    *,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    minimum_phase_separation: float = 1.0e-6,
    tolerance: float = 1.0e-8,
    max_iterations: int = 40,
) -> tuple[PhaseTransitionPoint, ...]:
    """Continue one incipient two-phase branch over a temperature sequence.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model used for every continuation point.
    temperatures
        Nonempty one-dimensional temperature sequence in K, ordered in the
        desired continuation direction.
    initial_point
        Converged branch seed supplying the parent composition, phase-root
        pair, pressure, and incipient-phase composition.
    minimum_pressure, maximum_pressure
        Optional positive pressure bounds in Pa applied to every point.
        Bounds can isolate a disconnected liquid-liquid branch.
    minimum_phase_separation
        Minimum maximum-component mole-fraction difference required to accept
        two physically distinct phases.
    tolerance
        Maximum absolute dimensionless log-fugacity residual.
    max_iterations
        Maximum damped-Newton iterations per temperature.

    Returns
    -------
    tuple
        One :class:`PhaseTransitionPoint` per requested temperature. Failed
        points remain present with ``converged=False`` and do not replace the
        last converged continuation state.

    Raises
    ------
    ValueError
        If the temperature grid is invalid or ``initial_point`` is not a
        converged, phase-separated seed.

    Notes
    -----
    The local equilibrium equations are those of
    :func:`phase_transition_pressure`, following Michelsen and Mollerup,
    *Thermodynamic Models: Fundamentals & Computational Aspects*, 2nd ed.
    (2007), chapter 12, ISBN 978-87-989961-3-2. Previous-point continuation is
    a numerical branch-tracking choice made explicitly by ``torch-flash``.
    The caller controls direction, spacing, and pressure bounds because these
    choices can select among disconnected phase-transition branches.

    Continuation initial states are detached between points. Each returned
    solve can still carry parameter derivatives, but the discrete decision to
    retain or reject an initializer is not differentiable.
    """
    if temperatures.ndim != 1 or temperatures.numel() < 1:
        raise ValueError("phase-transition temperatures must be a nonempty vector")
    if not temperatures.is_floating_point() or not bool(
        torch.isfinite(temperatures).all() & (temperatures > 0.0).all()
    ):
        raise ValueError("phase-transition temperatures must be finite and positive")
    if not initial_point.converged or not bool(
        torch.isfinite(initial_point.pressure)
        & (initial_point.pressure > 0.0)
        & torch.isfinite(initial_point.incipient_composition).all()
        & (initial_point.phase_separation > minimum_phase_separation)
    ):
        raise ValueError("initial phase-transition point must be converged and separated")

    parent = initial_point.parent_composition.to(
        dtype=temperatures.dtype,
        device=temperatures.device,
    )
    previous_pressure = initial_point.pressure.to(
        dtype=temperatures.dtype,
        device=temperatures.device,
    ).detach()
    previous_incipient = initial_point.incipient_composition.to(
        dtype=temperatures.dtype,
        device=temperatures.device,
    ).detach()
    points: list[PhaseTransitionPoint] = []
    for temperature in temperatures:
        try:
            point = phase_transition_pressure(
                model,
                temperature,
                parent,
                phase_kinds=initial_point.phase_kinds,
                initial_pressure=previous_pressure,
                initial_incipient_composition=previous_incipient,
                minimum_pressure=minimum_pressure,
                maximum_pressure=maximum_pressure,
                minimum_phase_separation=minimum_phase_separation,
                tolerance=tolerance,
                max_iterations=max_iterations,
            )
        except (InvalidStateError, RuntimeError, torch.linalg.LinAlgError):
            point = PhaseTransitionPoint(
                temperature,
                previous_pressure.new_tensor(torch.nan),
                parent,
                previous_incipient,
                initial_point.phase_kinds,
                0,
                False,
                previous_pressure.new_tensor(torch.inf),
                torch.max(torch.abs(previous_incipient - parent)),
            )
        points.append(point)
        if point.converged:
            previous_pressure = point.pressure.detach()
            previous_incipient = point.incipient_composition.detach()
    return tuple(points)

phase_envelope

phase_envelope(
    model: StateModel,
    temperatures: Tensor,
    composition: Tensor,
    *,
    kinds: tuple[SaturationKind, ...] = ("bubble", "dew"),
    accelerated: bool = True,
) -> dict[SaturationKind, tuple[SaturationPoint, ...]]

Trace bubble/dew branches over a specified temperature grid.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model for saturation calculations.

required
temperatures Tensor

One-dimensional temperature sequence in K, in desired continuation order.

required
composition Tensor

Feed mole-fraction vector.

required
kinds tuple[SaturationKind, ...]

Bubble and/or dew branches to trace.

('bubble', 'dew')
accelerated bool

Use two-point secant prediction after two converged points.

True

Returns:

Type Description
dict

Mapping from saturation kind to one :class:SaturationPoint per input temperature. Failed points remain present and marked non-converged.

Notes

After two converged points, a secant predictor extrapolates ln(K) and ln(P) to the next temperature. Two successive-substitution corrections then keep the estimate on the physical branch before Newton. This avoids repeating the full 20-step initializer at every dense-grid point. A failed accelerated point, or one that abruptly collapses toward the algebraic K=1 solution, is retried with the original robust initializer. Set accelerated=False to reproduce the former previous- point/full-initializer algorithm for numerical comparison.

Source code in src/torch_flash/envelope.py
def phase_envelope(
    model: StateModel,
    temperatures: Tensor,
    composition: Tensor,
    *,
    kinds: tuple[SaturationKind, ...] = ("bubble", "dew"),
    accelerated: bool = True,
) -> dict[SaturationKind, tuple[SaturationPoint, ...]]:
    """Trace bubble/dew branches over a specified temperature grid.

    Parameters
    ----------
    model
        Homogeneous-state model for saturation calculations.
    temperatures
        One-dimensional temperature sequence in K, in desired continuation
        order.
    composition
        Feed mole-fraction vector.
    kinds
        Bubble and/or dew branches to trace.
    accelerated
        Use two-point secant prediction after two converged points.

    Returns
    -------
    dict
        Mapping from saturation kind to one :class:`SaturationPoint` per input
        temperature. Failed points remain present and marked non-converged.

    Notes
    -----
    After two converged points, a secant predictor extrapolates ``ln(K)`` and
    ``ln(P)`` to the next temperature. Two successive-substitution corrections
    then keep the estimate on the physical branch before Newton. This avoids
    repeating the full 20-step initializer at every dense-grid point. A
    failed accelerated point, or one that abruptly collapses toward the
    algebraic ``K=1`` solution, is retried with the original robust
    initializer. Set ``accelerated=False`` to reproduce the former previous-
    point/full-initializer algorithm for numerical comparison.
    """
    branches: dict[SaturationKind, tuple[SaturationPoint, ...]] = {}
    pressure_ceiling_log = torch.log(
        50.0 * torch.max(_components_from_model(model).critical_pressure)
    )

    def collapsed_toward_trivial(candidate: SaturationPoint, reference: Tensor) -> bool:
        reference_scale = torch.max(torch.abs(reference[:-1]))
        candidate_scale = torch.max(torch.abs(torch.log(candidate.k_values.detach())))
        return bool((reference_scale > 1.0e-3) & (candidate_scale < 0.1 * reference_scale))

    for kind in kinds:
        points: list[SaturationPoint] = []
        history: list[tuple[Tensor, Tensor]] = []
        for temperature in temperatures:
            initial_pressure: Tensor | None = None
            initial_k_values: Tensor | None = None
            predicted: Tensor | None = None
            if history:
                predicted = history[-1][1]
                if accelerated and len(history) == 2:
                    temperature_step = history[-1][0] - history[-2][0]
                    if bool(temperature_step != 0.0):
                        ratio = (temperature - history[-1][0]) / temperature_step
                        predicted = history[-1][1] + ratio * (history[-1][1] - history[-2][1])
                initial_k_values = torch.exp(torch.clamp(predicted[:-1], -50.0, 50.0))
                initial_pressure = torch.exp(
                    torch.minimum(
                        torch.clamp_min(predicted[-1], 0.0),
                        pressure_ceiling_log,
                    )
                )
            point = saturation_point(
                model,
                temperature,
                composition,
                kind,
                initial_pressure=initial_pressure,
                initial_k_values=initial_k_values,
                substitution_iterations=2 if accelerated and len(history) == 2 else None,
            )

            predictor_collapsed = (
                accelerated
                and history
                and predicted is not None
                and point.converged
                and collapsed_toward_trivial(point, predicted)
            )
            if accelerated and history and (not point.converged or predictor_collapsed):
                previous = history[-1][1]
                point = saturation_point(
                    model,
                    temperature,
                    composition,
                    kind,
                    initial_pressure=torch.exp(previous[-1]),
                    initial_k_values=torch.exp(previous[:-1]),
                )
                if point.converged and collapsed_toward_trivial(point, previous):
                    point = replace(point, converged=False)
            points.append(point)
            if point.converged:
                solution = torch.cat(
                    (
                        torch.log(point.k_values.detach()),
                        torch.log(point.pressure.detach()).reshape(1),
                    )
                )
                history.append((temperature.detach(), solution))
                history = history[-2:]
        branches[kind] = tuple(points)
    return branches

trace_phase_envelope_set

trace_phase_envelope_set(
    model: StateModel,
    composition: Tensor,
    vapor_liquid_temperatures: Tensor,
    *,
    liquid_liquid_seeds: Sequence[
        PhaseTransitionEvaluation
    ] = (),
    liquid_liquid_temperatures: Tensor | None = None,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    minimum_phase_separation: float = 0.002,
    tolerance: float = 1e-08,
    max_iterations: int = 40,
    vapor_liquid_closure_points: int = 20,
    vapor_liquid_closure_log_k: float = 0.001,
) -> PhaseEnvelopeSet

Trace a complete fixed-composition fluid phase-boundary set.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model.

required
composition Tensor

Fixed overall/parent mole-fraction vector.

required
vapor_liquid_temperatures Tensor

Ordered temperature grid in K for both bubble and dew branches.

required
liquid_liquid_seeds Sequence[PhaseTransitionEvaluation]

Residual- and separation-converged liquid-liquid reference evaluations. If several observations converge to the same branch, only the lowest- and highest-pressure distinct seeds are retained.

()
liquid_liquid_temperatures Tensor | None

Ordered temperature grid in K for each liquid-liquid branch. Required when liquid_liquid_seeds is nonempty.

None
minimum_pressure Tensor | float | None

Optional liquid-liquid continuation bounds in Pa.

None
maximum_pressure Tensor | float | None

Optional liquid-liquid continuation bounds in Pa.

None
minimum_phase_separation float

Minimum maximum-component composition difference for a physical liquid-liquid point.

0.002
tolerance float

Maximum absolute dimensionless log-fugacity residual.

1e-08
max_iterations int

Maximum nonlinear iterations per liquid-liquid continuation point.

40
vapor_liquid_closure_points int

Number of logarithmically spaced log-K continuation points used to approach each bubble/dew critical endpoint after fixed-temperature tracing.

20
vapor_liquid_closure_log_k float

Positive absolute controlled-component log-K target at the final critical-closure point.

0.001

Returns:

Type Description
PhaseEnvelopeSet

Bubble/dew branches and every distinct seeded liquid-liquid branch. Failed continuation points remain explicit.

Raises:

Type Description
ValueError

If liquid-liquid seeds do not match the supplied composition or a continuation grid is missing.

Notes

Bubble/dew tracing starts with :func:phase_envelope, then switches from temperature to a selected log-K continuation coordinate through :func:continue_saturation_branch. This passes the cricondentherm and approaches the mixture critical endpoint without accepting the algebraic K=1 root. Fixed-temperature points after the closure seed are replaced by the ordered log-K continuation; failed continuation points remain explicit. Liquid-liquid tracing uses :func:continue_phase_transition_branch in both temperature directions from each selected seed. The formulation follows Michelsen and Mollerup, Thermodynamic Models: Fundamentals & Computational Aspects, 2nd ed. (2007), chapter 12, ISBN 978-87-989961-3-2.

Temperature continuation can end at a critical merge or fail at a turning point. Such points are retained as non-converged; the result does not silently interpolate across a missing segment.

Source code in src/torch_flash/envelope.py
def trace_phase_envelope_set(
    model: StateModel,
    composition: Tensor,
    vapor_liquid_temperatures: Tensor,
    *,
    liquid_liquid_seeds: Sequence[PhaseTransitionEvaluation] = (),
    liquid_liquid_temperatures: Tensor | None = None,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    minimum_phase_separation: float = 2.0e-3,
    tolerance: float = 1.0e-8,
    max_iterations: int = 40,
    vapor_liquid_closure_points: int = 20,
    vapor_liquid_closure_log_k: float = 1.0e-3,
) -> PhaseEnvelopeSet:
    """Trace a complete fixed-composition fluid phase-boundary set.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model.
    composition
        Fixed overall/parent mole-fraction vector.
    vapor_liquid_temperatures
        Ordered temperature grid in K for both bubble and dew branches.
    liquid_liquid_seeds
        Residual- and separation-converged liquid-liquid reference
        evaluations. If several observations converge to the same branch,
        only the lowest- and highest-pressure distinct seeds are retained.
    liquid_liquid_temperatures
        Ordered temperature grid in K for each liquid-liquid branch. Required
        when ``liquid_liquid_seeds`` is nonempty.
    minimum_pressure, maximum_pressure
        Optional liquid-liquid continuation bounds in Pa.
    minimum_phase_separation
        Minimum maximum-component composition difference for a physical
        liquid-liquid point.
    tolerance
        Maximum absolute dimensionless log-fugacity residual.
    max_iterations
        Maximum nonlinear iterations per liquid-liquid continuation point.
    vapor_liquid_closure_points
        Number of logarithmically spaced log-K continuation points used to
        approach each bubble/dew critical endpoint after fixed-temperature
        tracing.
    vapor_liquid_closure_log_k
        Positive absolute controlled-component log-K target at the final
        critical-closure point.

    Returns
    -------
    PhaseEnvelopeSet
        Bubble/dew branches and every distinct seeded liquid-liquid branch.
        Failed continuation points remain explicit.

    Raises
    ------
    ValueError
        If liquid-liquid seeds do not match the supplied composition or a
        continuation grid is missing.

    Notes
    -----
    Bubble/dew tracing starts with :func:`phase_envelope`, then switches from
    temperature to a selected log-K continuation coordinate through
    :func:`continue_saturation_branch`. This passes the cricondentherm and
    approaches the mixture critical endpoint without accepting the algebraic
    ``K=1`` root. Fixed-temperature points after the closure seed are replaced
    by the ordered log-K continuation; failed continuation points remain
    explicit. Liquid-liquid tracing uses
    :func:`continue_phase_transition_branch` in both temperature directions
    from each selected seed. The formulation follows Michelsen and Mollerup,
    *Thermodynamic Models: Fundamentals & Computational Aspects*, 2nd ed.
    (2007), chapter 12, ISBN 978-87-989961-3-2.

    Temperature continuation can end at a critical merge or fail at a turning
    point. Such points are retained as non-converged; the result does not
    silently interpolate across a missing segment.
    """
    if vapor_liquid_closure_points < 1 or vapor_liquid_closure_log_k <= 0.0:
        raise ValueError("vapor-liquid closure controls must be positive")
    parent = normalize_composition(composition)
    fixed_temperature_vapor_liquid = phase_envelope(
        model,
        vapor_liquid_temperatures,
        parent,
        kinds=("bubble", "dew"),
    )
    vapor_liquid: dict[SaturationKind, tuple[SaturationPoint, ...]] = {}
    for kind, points in fixed_temperature_vapor_liquid.items():
        physical_indices = [
            index
            for index, point in enumerate(points)
            if point.converged
            and bool(point.residual_norm <= tolerance)
            and bool(
                torch.max(torch.abs(point.incipient_composition - parent))
                > minimum_phase_separation
            )
        ]
        if not physical_indices:
            vapor_liquid[kind] = points
            continue
        seed_index = max(
            physical_indices,
            key=lambda index: float(points[index].temperature.detach()),
        )
        vapor_seed = points[seed_index]
        log_k = torch.log(vapor_seed.k_values.detach())
        controlled_component = int(torch.argmax(torch.abs(log_k)))
        initial_coordinate = log_k[controlled_component]
        target_magnitude = initial_coordinate.new_tensor(vapor_liquid_closure_log_k)
        if bool(torch.abs(initial_coordinate) <= target_magnitude):
            vapor_liquid[kind] = points[: seed_index + 1]
            continue
        magnitudes = torch.logspace(
            torch.log10(0.9 * torch.abs(initial_coordinate)),
            torch.log10(target_magnitude),
            vapor_liquid_closure_points,
            dtype=initial_coordinate.dtype,
            device=initial_coordinate.device,
        )
        targets = torch.sign(initial_coordinate) * magnitudes
        closure = continue_saturation_branch(
            model,
            parent,
            vapor_seed,
            targets,
            controlled_component=controlled_component,
            tolerance=tolerance,
            max_iterations=max_iterations,
        )
        vapor_liquid[kind] = (*points[: seed_index + 1], *closure)

    accepted_seeds = [
        seed
        for seed in liquid_liquid_seeds
        if seed.converged and seed.state.boundary_kind == "liquid-liquid"
    ]
    if not accepted_seeds:
        return PhaseEnvelopeSet(vapor_liquid, ())
    if liquid_liquid_temperatures is None:
        raise ValueError("liquid-liquid temperatures are required with branch seeds")
    for accepted_seed in accepted_seeds:
        if accepted_seed.state.parent_composition.shape != parent.shape or not bool(
            torch.allclose(
                normalize_composition(accepted_seed.state.parent_composition).detach(),
                parent.detach(),
                rtol=1.0e-10,
                atol=1.0e-12,
            )
        ):
            raise ValueError("liquid-liquid seed composition must match the envelope composition")

    ordered_seeds = sorted(
        accepted_seeds,
        key=lambda item: float(item.pressure.detach()),
    )
    selected_seeds = [ordered_seeds[0]]
    if (
        len(ordered_seeds) > 1
        and float(ordered_seeds[-1].pressure.detach() / ordered_seeds[0].pressure.detach()) > 1.05
    ):
        selected_seeds.append(ordered_seeds[-1])

    liquid_liquid: list[tuple[PhaseTransitionPoint, ...]] = []
    for evaluation in selected_seeds:
        state = evaluation.state
        liquid_seed = PhaseTransitionPoint(
            state.temperature,
            evaluation.pressure,
            parent,
            evaluation.phase_compositions[0],
            ("liquid", "liquid"),
            evaluation.iterations,
            evaluation.converged,
            evaluation.residual_norm,
            evaluation.phase_separation,
        )
        descending_temperatures = liquid_liquid_temperatures[
            liquid_liquid_temperatures < liquid_seed.temperature
        ].flip(0)
        ascending_temperatures = liquid_liquid_temperatures[
            liquid_liquid_temperatures > liquid_seed.temperature
        ]
        descending = (
            continue_phase_transition_branch(
                model,
                descending_temperatures,
                liquid_seed,
                minimum_pressure=minimum_pressure,
                maximum_pressure=maximum_pressure,
                minimum_phase_separation=minimum_phase_separation,
                tolerance=tolerance,
                max_iterations=max_iterations,
            )
            if descending_temperatures.numel()
            else ()
        )
        ascending = (
            continue_phase_transition_branch(
                model,
                ascending_temperatures,
                liquid_seed,
                minimum_pressure=minimum_pressure,
                maximum_pressure=maximum_pressure,
                minimum_phase_separation=minimum_phase_separation,
                tolerance=tolerance,
                max_iterations=max_iterations,
            )
            if ascending_temperatures.numel()
            else ()
        )
        liquid_liquid.append(
            tuple(
                sorted(
                    (*descending, liquid_seed, *ascending),
                    key=lambda point: float(point.temperature.detach()),
                )
            )
        )
    return PhaseEnvelopeSet(vapor_liquid, tuple(liquid_liquid))

continue_saturation_branch

continue_saturation_branch(
    model: StateModel,
    composition: Tensor,
    initial_point: SaturationPoint,
    target_log_k_values: Tensor,
    *,
    controlled_component: int = 0,
    tolerance: float = 1e-09,
    max_iterations: int = 60,
    accelerated: bool = True,
) -> tuple[SaturationPoint, ...]

Continue a saturation branch using one ln(K_i) as coordinate.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model for saturation fugacity coefficients.

required
composition Tensor

Feed mole-fraction vector.

required
initial_point SaturationPoint

Converged bubble or dew point defining the initial branch.

required
target_log_k_values Tensor

One-dimensional continuation coordinates in requested traversal order.

required
controlled_component int

Component index whose log K value defines the coordinate.

0
tolerance float

Maximum absolute augmented-system residual.

1e-09
max_iterations int

Maximum Newton iterations per target.

60
accelerated bool

Use a two-point secant predictor when available.

True

Returns:

Type Description
tuple

One :class:SaturationPoint per target coordinate, including explicit non-converged points.

Raises:

Type Description
ValueError

If shapes, component index, or initial saturation kind are invalid.

Notes

Temperature continuation becomes singular at a cricondentherm and can jump to the algebraic K=1 solution near a mixture critical point. Replacing temperature by a selected log-K value yields a square (ln K, ln P, ln T) system that can pass both features. Points are returned in the order of target_log_k_values and must not be sorted by temperature when plotting a retrograde branch.

The caller controls step size through the supplied targets. A failed point remains in the returned sequence with converged=False so scientific workflows can expose, rather than silently interpolate across, a continuation failure.

The default secant predictor extrapolates all continuation variables from the previous two converged coordinates. Set accelerated=False to use the former previous-point initializer for performance comparisons.

Source code in src/torch_flash/envelope.py
def continue_saturation_branch(
    model: StateModel,
    composition: Tensor,
    initial_point: SaturationPoint,
    target_log_k_values: Tensor,
    *,
    controlled_component: int = 0,
    tolerance: float = 1.0e-9,
    max_iterations: int = 60,
    accelerated: bool = True,
) -> tuple[SaturationPoint, ...]:
    """Continue a saturation branch using one ``ln(K_i)`` as coordinate.

    Parameters
    ----------
    model
        Homogeneous-state model for saturation fugacity coefficients.
    composition
        Feed mole-fraction vector.
    initial_point
        Converged bubble or dew point defining the initial branch.
    target_log_k_values
        One-dimensional continuation coordinates in requested traversal order.
    controlled_component
        Component index whose log K value defines the coordinate.
    tolerance
        Maximum absolute augmented-system residual.
    max_iterations
        Maximum Newton iterations per target.
    accelerated
        Use a two-point secant predictor when available.

    Returns
    -------
    tuple
        One :class:`SaturationPoint` per target coordinate, including explicit
        non-converged points.

    Raises
    ------
    ValueError
        If shapes, component index, or initial saturation kind are invalid.

    Notes
    -----
    Temperature continuation becomes singular at a cricondentherm and can
    jump to the algebraic ``K=1`` solution near a mixture critical point.
    Replacing temperature by a selected log-K value yields a square
    ``(ln K, ln P, ln T)`` system that can pass both features. Points are
    returned in the order of ``target_log_k_values`` and must not be sorted by
    temperature when plotting a retrograde branch.

    The caller controls step size through the supplied targets. A failed point
    remains in the returned sequence with ``converged=False`` so scientific
    workflows can expose, rather than silently interpolate across, a
    continuation failure.

    The default secant predictor extrapolates all continuation variables from
    the previous two converged coordinates. Set ``accelerated=False`` to use
    the former previous-point initializer for performance comparisons.
    """
    z = normalize_composition(composition)
    if z.ndim != 1:
        raise ValueError("continuation requires one composition vector")
    if initial_point.k_values.shape != z.shape:
        raise ValueError("initial saturation K values must match composition")
    if target_log_k_values.ndim != 1:
        raise ValueError("target_log_k_values must be one-dimensional")
    if controlled_component < 0 or controlled_component >= z.numel():
        raise ValueError("controlled_component is outside the component range")
    if initial_point.kind not in ("bubble", "dew"):
        raise ValueError("initial point must be a bubble or dew point")

    variables = torch.cat(
        (
            torch.log(initial_point.k_values),
            torch.log(initial_point.pressure).reshape(1),
            torch.log(initial_point.temperature).reshape(1),
        )
    )
    ncomponents = z.numel()
    components = _components_from_model(model)
    lower = torch.cat(
        (
            torch.full_like(z, -50.0),
            torch.stack(
                (
                    torch.log(variables.new_tensor(1.0e2)),
                    torch.log(0.2 * torch.min(components.critical_temperature)),
                )
            ),
        )
    )
    upper = torch.cat(
        (
            torch.full_like(z, 50.0),
            torch.stack(
                (
                    torch.log(100.0 * torch.max(components.critical_pressure)),
                    torch.log(2.0 * torch.max(components.critical_temperature)),
                )
            ),
        )
    )
    points: list[SaturationPoint] = []
    history: list[tuple[Tensor, Tensor]] = [
        (
            variables[controlled_component].detach(),
            variables.detach(),
        )
    ]
    for target in target_log_k_values:
        previous_variables = variables
        used_predictor = False
        if accelerated and len(history) == 2:
            coordinate_step = history[-1][0] - history[-2][0]
            if bool(coordinate_step != 0.0):
                ratio = (target - history[-1][0]) / coordinate_step
                variables = history[-1][1] + ratio * (history[-1][1] - history[-2][1])
                variables = torch.minimum(torch.maximum(variables, lower), upper)
                used_predictor = True

        def residual(current: Tensor, target_value: Tensor = target) -> Tensor:
            log_k = current[:ncomponents]
            pressure = torch.exp(current[-2])
            temperature = torch.exp(current[-1])
            k_values = torch.exp(log_k)
            if initial_point.kind == "bubble":
                liquid = z
                vapor = normalize_composition(z * k_values)
                closure = torch.sum(z * k_values) - 1.0
            else:
                vapor = z
                liquid = normalize_composition(z / k_values)
                closure = torch.sum(z / k_values) - 1.0
            equilibrium = (
                log_k
                - model.log_fugacity_coefficients(
                    temperature,
                    pressure,
                    liquid,
                    "liquid",
                )
                + model.log_fugacity_coefficients(
                    temperature,
                    pressure,
                    vapor,
                    "vapor",
                )
            )
            coordinate = log_k[controlled_component] - target_value
            return torch.cat(
                (
                    equilibrium,
                    closure.reshape(1),
                    coordinate.reshape(1),
                )
            )

        result = damped_newton(
            residual,
            variables,
            tolerance=tolerance,
            max_iterations=max_iterations,
            lower_bound=lower,
            upper_bound=upper,
        )
        if accelerated and used_predictor and not result.converged:
            result = damped_newton(
                residual,
                previous_variables,
                tolerance=tolerance,
                max_iterations=max_iterations,
                lower_bound=lower,
                upper_bound=upper,
            )
        variables = result.solution.detach()
        k_values = torch.exp(variables[:ncomponents])
        pressure = torch.exp(variables[-2])
        temperature = torch.exp(variables[-1])
        incipient = (
            normalize_composition(z * k_values)
            if initial_point.kind == "bubble"
            else normalize_composition(z / k_values)
        )
        points.append(
            SaturationPoint(
                temperature,
                pressure,
                incipient,
                k_values,
                initial_point.kind,
                result.iterations,
                result.converged,
                result.residual_norm,
            )
        )
        if result.converged:
            history.append((target.detach(), variables))
            history = history[-2:]
    return tuple(points)

binary_critical_point

binary_critical_point(
    model: StateModel,
    composition: Tensor,
    *,
    initial_temperature: Tensor | None = None,
    initial_pressure: Tensor | None = None,
    tolerance: float = 1e-09,
    max_iterations: int = 30,
) -> BinaryCriticalPoint

Solve the binary mixture criticality conditions with autodiff.

Parameters:

Name Type Description Default
model StateModel

Smooth homogeneous-state model providing stable-root Gibbs properties.

required
composition Tensor

Strictly interior two-component mole-fraction vector.

required
initial_temperature Tensor | None

Optional positive scalar estimates in K and Pa. Composition-weighted critical constants are used when omitted.

None
initial_pressure Tensor | None

Optional positive scalar estimates in K and Pa. Composition-weighted critical constants are used when omitted.

None
tolerance float

Maximum absolute scaled criticality residual.

1e-09
max_iterations int

Maximum damped-Newton iterations.

30

Returns:

Type Description
BinaryCriticalPoint

Solved temperature, pressure, volume, composition, and convergence diagnostics.

Raises:

Type Description
ValueError

If composition is not binary/interior or initial estimates are not positive scalars.

Notes

At fixed T and P, the second and third derivatives of the reduced molar Gibbs energy of mixing with respect to the first mole fraction both vanish at a binary critical point. Newton's Jacobian therefore uses up to fourth-order PyTorch derivatives of the model; no hand-coded EoS derivative expressions are required.

This composition-space formulation is specific to a binary mixture and a smooth homogeneous root. It is not a general multicomponent critical-locus solver.

Source code in src/torch_flash/envelope.py
def binary_critical_point(
    model: StateModel,
    composition: Tensor,
    *,
    initial_temperature: Tensor | None = None,
    initial_pressure: Tensor | None = None,
    tolerance: float = 1.0e-9,
    max_iterations: int = 30,
) -> BinaryCriticalPoint:
    """Solve the binary mixture criticality conditions with autodiff.

    Parameters
    ----------
    model
        Smooth homogeneous-state model providing stable-root Gibbs properties.
    composition
        Strictly interior two-component mole-fraction vector.
    initial_temperature, initial_pressure
        Optional positive scalar estimates in K and Pa. Composition-weighted
        critical constants are used when omitted.
    tolerance
        Maximum absolute scaled criticality residual.
    max_iterations
        Maximum damped-Newton iterations.

    Returns
    -------
    BinaryCriticalPoint
        Solved temperature, pressure, volume, composition, and convergence
        diagnostics.

    Raises
    ------
    ValueError
        If composition is not binary/interior or initial estimates are not
        positive scalars.

    Notes
    -----
    At fixed ``T`` and ``P``, the second and third derivatives of the reduced
    molar Gibbs energy of mixing with respect to the first mole fraction both
    vanish at a binary critical point. Newton's Jacobian therefore uses up to
    fourth-order PyTorch derivatives of the model; no hand-coded EoS
    derivative expressions are required.

    This composition-space formulation is specific to a binary mixture and a
    smooth homogeneous root. It is not a general multicomponent critical-locus
    solver.
    """
    z = normalize_composition(composition)
    if z.shape != (2,):
        raise ValueError("binary_critical_point requires two components")
    if bool((z <= 0.0).any()):
        raise ValueError("binary_critical_point requires an interior composition")
    components = _components_from_model(model)
    if initial_temperature is None:
        initial_temperature = torch.sum(z * components.critical_temperature)
    if initial_pressure is None:
        initial_pressure = torch.sum(z * components.critical_pressure)
    if initial_temperature.ndim != 0 or initial_pressure.ndim != 0:
        raise ValueError("initial critical temperature and pressure must be scalar")
    if bool((initial_temperature <= 0.0) | (initial_pressure <= 0.0)):
        raise ValueError("initial critical temperature and pressure must be positive")

    first_fraction = z[0]

    def reduced_gibbs_of_mixing(
        first: Tensor,
        temperature: Tensor,
        pressure: Tensor,
    ) -> Tensor:
        current = torch.stack((first, 1.0 - first))
        log_phi = model.log_fugacity_coefficients(
            temperature,
            pressure,
            current,
            "stable",
        )
        return torch.sum(current * (torch.log(current) + log_phi))

    def residual(log_temperature_pressure: Tensor) -> Tensor:
        temperature = torch.exp(log_temperature_pressure[0])
        pressure = torch.exp(log_temperature_pressure[1])

        def gibbs(first: Tensor) -> Tensor:
            return reduced_gibbs_of_mixing(first, temperature, pressure)

        second = torch.func.grad(torch.func.grad(gibbs))
        third = torch.func.grad(second)
        return torch.stack((second(first_fraction), third(first_fraction)))

    variables = torch.stack((torch.log(initial_temperature), torch.log(initial_pressure)))
    minimum_temperature = 0.2 * torch.min(components.critical_temperature)
    maximum_temperature = 2.0 * torch.max(components.critical_temperature)
    minimum_pressure = variables.new_tensor(1.0e3)
    maximum_pressure = 100.0 * torch.max(components.critical_pressure)
    result = damped_newton(
        residual,
        variables,
        tolerance=tolerance,
        max_iterations=max_iterations,
        lower_bound=torch.stack((torch.log(minimum_temperature), torch.log(minimum_pressure))),
        upper_bound=torch.stack((torch.log(maximum_temperature), torch.log(maximum_pressure))),
    )
    temperature = torch.exp(result.solution[0])
    pressure = torch.exp(result.solution[1])
    volume = model.molar_volume(temperature, pressure, z, "stable")
    return BinaryCriticalPoint(
        temperature,
        pressure,
        volume,
        z,
        result.iterations,
        result.converged,
        result.residual_norm,
    )

binary_phase_equilibrium_point

binary_phase_equilibrium_point(
    model: StateModel,
    temperature: Tensor,
    pressure: Tensor,
    initial_phase1_composition: Tensor,
    initial_phase2_composition: Tensor,
    *,
    phase_kinds: tuple[PhaseKind, PhaseKind] = (
        "stable",
        "stable",
    ),
    tolerance: float = 1e-08,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1e-06,
) -> BinaryPhaseEquilibriumPoint

Solve binary phase coexistence at fixed T and P.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model.

required
temperature Tensor

Scalar temperature in K and pressure in Pa.

required
pressure Tensor

Scalar temperature in K and pressure in Pa.

required
initial_phase1_composition Tensor

Initial two-component mole-fraction vectors.

required
initial_phase2_composition Tensor

Initial two-component mole-fraction vectors.

required
phase_kinds tuple[PhaseKind, PhaseKind]

Root request for each coexisting phase.

('stable', 'stable')
tolerance float

Maximum absolute component log-fugacity residual.

1e-08
max_iterations int

Maximum substitution/Newton iterations.

30
minimum_phase_separation float

Minimum absolute mole-fraction separation needed to reject the homogeneous algebraic solution.

1e-06

Returns:

Type Description
BinaryPhaseEquilibriumPoint

Coexisting compositions, root requests, and convergence diagnostics.

Raises:

Type Description
ValueError

If roots, shapes, or the separation threshold are invalid.

Notes

("stable", "stable") is appropriate for mutual-solubility work where the hydrocarbon-rich phase may switch between liquid and vapor with conditions. Explicit roots support LLE ("liquid", "liquid") and VLE ("liquid", "vapor"). The algebraic equal-composition solution is rejected because it is a homogeneous state, not phase coexistence.

Source code in src/torch_flash/envelope.py
def binary_phase_equilibrium_point(
    model: StateModel,
    temperature: Tensor,
    pressure: Tensor,
    initial_phase1_composition: Tensor,
    initial_phase2_composition: Tensor,
    *,
    phase_kinds: tuple[PhaseKind, PhaseKind] = ("stable", "stable"),
    tolerance: float = 1.0e-8,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1.0e-6,
) -> BinaryPhaseEquilibriumPoint:
    """Solve binary phase coexistence at fixed ``T`` and ``P``.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model.
    temperature, pressure
        Scalar temperature in K and pressure in Pa.
    initial_phase1_composition, initial_phase2_composition
        Initial two-component mole-fraction vectors.
    phase_kinds
        Root request for each coexisting phase.
    tolerance
        Maximum absolute component log-fugacity residual.
    max_iterations
        Maximum substitution/Newton iterations.
    minimum_phase_separation
        Minimum absolute mole-fraction separation needed to reject the
        homogeneous algebraic solution.

    Returns
    -------
    BinaryPhaseEquilibriumPoint
        Coexisting compositions, root requests, and convergence diagnostics.

    Raises
    ------
    ValueError
        If roots, shapes, or the separation threshold are invalid.

    Notes
    -----
    ``("stable", "stable")`` is appropriate for mutual-solubility work where
    the hydrocarbon-rich phase may switch between liquid and vapor with
    conditions. Explicit roots support LLE (``"liquid", "liquid"``) and VLE
    (``"liquid", "vapor"``). The algebraic equal-composition solution is
    rejected because it is a homogeneous state, not phase coexistence.
    """
    if minimum_phase_separation < 0.0:
        raise ValueError("minimum phase separation must be nonnegative")
    if any(kind not in ("liquid", "vapor", "stable") for kind in phase_kinds):
        raise ValueError("binary phase kinds must be 'liquid', 'vapor', or 'stable'")
    phase1_initial = normalize_composition(initial_phase1_composition)
    phase2_initial = normalize_composition(initial_phase2_composition)
    if phase1_initial.shape != (2,) or phase2_initial.shape != (2,):
        raise ValueError("binary equilibrium requires two two-component composition vectors")
    epsilon = 32.0 * torch.finfo(phase1_initial.dtype).eps

    def logit(value: Tensor) -> Tensor:
        bounded = torch.clamp(value, epsilon, 1.0 - epsilon)
        return torch.log(bounded) - torch.log1p(-bounded)

    def unpack(current: Tensor) -> tuple[Tensor, Tensor]:
        first = torch.sigmoid(current)
        return torch.stack((first[0], 1.0 - first[0])), torch.stack((first[1], 1.0 - first[1]))

    variables = torch.stack((logit(phase1_initial[0]), logit(phase2_initial[0])))

    def residual(current: Tensor) -> Tensor:
        phase1, phase2 = unpack(current)
        log_phi_phase1 = model.log_fugacity_coefficients(
            temperature,
            pressure,
            phase1,
            phase_kinds[0],
        )
        log_phi_phase2 = model.log_fugacity_coefficients(
            temperature,
            pressure,
            phase2,
            phase_kinds[1],
        )
        return torch.log(phase1) + log_phi_phase1 - torch.log(phase2) - log_phi_phase2

    substitution_iterations = min(20, max_iterations)
    for iteration in range(1, substitution_iterations + 1):
        phase1, phase2 = unpack(variables)
        value = residual(variables)
        residual_norm = value.abs().max()
        if float(residual_norm.detach()) <= tolerance:
            phase_separation = torch.max(torch.abs(phase2 - phase1))
            return BinaryPhaseEquilibriumPoint(
                temperature,
                pressure,
                phase1,
                phase2,
                phase_kinds,
                iteration,
                bool(float(phase_separation.detach()) > minimum_phase_separation),
                residual_norm,
            )
        k_values = torch.exp(value) * phase2 / phase1
        denominator = k_values[0] - k_values[1]
        if float(denominator.detach().abs()) <= 1.0e-10:
            break
        phase1_first = (1.0 - k_values[1]) / denominator
        phase2_first = k_values[0] * phase1_first
        if not bool(
            torch.isfinite(phase1_first)
            & torch.isfinite(phase2_first)
            & (phase1_first > epsilon)
            & (phase1_first < 1.0 - epsilon)
            & (phase2_first > epsilon)
            & (phase2_first < 1.0 - epsilon)
        ):
            break
        target = torch.stack((logit(phase1_first), logit(phase2_first)))
        variables = 0.5 * variables + 0.5 * target

    result = damped_newton(
        residual,
        variables,
        tolerance=tolerance,
        max_iterations=max_iterations,
        lower_bound=torch.full_like(variables, -30.0),
        upper_bound=torch.full_like(variables, 30.0),
    )
    phase1, phase2 = unpack(result.solution)
    phase_separation = torch.max(torch.abs(phase2 - phase1))
    return BinaryPhaseEquilibriumPoint(
        temperature,
        pressure,
        phase1,
        phase2,
        phase_kinds,
        result.iterations,
        result.converged and bool(float(phase_separation.detach()) > minimum_phase_separation),
        result.residual_norm,
    )

fixed_vapor_ratio_vle_point

fixed_vapor_ratio_vle_point(
    model: StateModel,
    temperature: Tensor,
    pressure: Tensor,
    dry_vapor_composition: Tensor,
    variable_vapor_component: int,
    *,
    initial_liquid_composition: Tensor | None = None,
    initial_variable_vapor_fraction: Tensor | None = None,
    phase_kinds: tuple[PhaseKind, PhaseKind] = (
        "liquid",
        "vapor",
    ),
    tolerance: float = 1e-08,
    max_iterations: int = 40,
    minimum_phase_separation: float = 1e-06,
) -> FixedVaporRatioVLEPoint

Solve VLE at fixed temperature, pressure, and dry-vapor composition.

The selected variable vapor component is excluded from dry_vapor_composition. Its saturated vapor mole fraction and every liquid-phase mole fraction are solved simultaneously from component fugacity equality. For example, a water-saturated H2/N2 gas is represented by dry_vapor_composition=[0.75, 0.25, 0] and variable_vapor_component=2 in an (H2, N2, H2O) model.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model providing liquid and vapor fugacity coefficients.

required
temperature Tensor

Finite positive scalar temperature in K and pressure in Pa.

required
pressure Tensor

Finite positive scalar temperature in K and pressure in Pa.

required
dry_vapor_composition Tensor

One-dimensional nonnegative component ratio in model order. The variable component entry must be exactly zero; every other modeled component must be present with a positive ratio. The vector is normalized internally.

required
variable_vapor_component int

Index of the vapor component whose saturated fraction is solved.

required
initial_liquid_composition Tensor | None

Optional strictly positive liquid composition. The default is 0.1 mol % dissolved dry gas, distributed in the specified dry-gas ratio.

None
initial_variable_vapor_fraction Tensor | None

Optional strictly interior estimate for the variable component vapor fraction. A Wilson estimate is used when omitted.

None
phase_kinds tuple[PhaseKind, PhaseKind]

Homogeneous root requested for the liquid-rich and vapor-rich states.

('liquid', 'vapor')
tolerance float

Positive maximum absolute dimensionless log-fugacity residual.

1e-08
max_iterations int

Positive damped-Newton iteration limit.

40
minimum_phase_separation float

Nonnegative minimum of max(abs(y - x)) required for physical convergence.

1e-06

Returns:

Type Description
FixedVaporRatioVLEPoint

Coexisting compositions, solved component index, nonlinear diagnostics, and phase separation.

Raises:

Type Description
ValueError

If scalar state inputs, component ratios, initial values, phase roots, or solver controls are invalid.

Notes

The square nonlinear system enforces ln(x_i phi_i^L) = ln(y_i phi_i^V) for every component. Independent liquid log-ratios and one vapor logit keep both compositions strictly inside their simplices. The vapor parametrization preserves all specified non-variable component ratios exactly, avoiding an arbitrary overall-feed assumption for experiments that report a saturated gas composition but not the coexisting liquid composition.

Fugacity equality and fixed-composition phase-equilibrium context follow Michelsen and Mollerup, Thermodynamic Models: Fundamentals & Computational Aspects, 2nd ed. (2007), chapters 8 and 12, ISBN 978-87-989961-3-2. Newton Jacobians are assembled by PyTorch automatic differentiation; no equation-of-state-specific derivatives are embedded here.

Source code in src/torch_flash/envelope.py
def fixed_vapor_ratio_vle_point(
    model: StateModel,
    temperature: Tensor,
    pressure: Tensor,
    dry_vapor_composition: Tensor,
    variable_vapor_component: int,
    *,
    initial_liquid_composition: Tensor | None = None,
    initial_variable_vapor_fraction: Tensor | None = None,
    phase_kinds: tuple[PhaseKind, PhaseKind] = ("liquid", "vapor"),
    tolerance: float = 1.0e-8,
    max_iterations: int = 40,
    minimum_phase_separation: float = 1.0e-6,
) -> FixedVaporRatioVLEPoint:
    """Solve VLE at fixed temperature, pressure, and dry-vapor composition.

    The selected variable vapor component is excluded from
    ``dry_vapor_composition``. Its saturated vapor mole fraction and every
    liquid-phase mole fraction are solved simultaneously from component
    fugacity equality. For example, a water-saturated H2/N2 gas is represented
    by ``dry_vapor_composition=[0.75, 0.25, 0]`` and
    ``variable_vapor_component=2`` in an ``(H2, N2, H2O)`` model.

    Parameters
    ----------
    model
        Homogeneous-state model providing liquid and vapor fugacity
        coefficients.
    temperature, pressure
        Finite positive scalar temperature in K and pressure in Pa.
    dry_vapor_composition
        One-dimensional nonnegative component ratio in model order. The
        variable component entry must be exactly zero; every other modeled
        component must be present with a positive ratio. The vector is
        normalized internally.
    variable_vapor_component
        Index of the vapor component whose saturated fraction is solved.
    initial_liquid_composition
        Optional strictly positive liquid composition. The default is 0.1 mol
        % dissolved dry gas, distributed in the specified dry-gas ratio.
    initial_variable_vapor_fraction
        Optional strictly interior estimate for the variable component vapor
        fraction. A Wilson estimate is used when omitted.
    phase_kinds
        Homogeneous root requested for the liquid-rich and vapor-rich states.
    tolerance
        Positive maximum absolute dimensionless log-fugacity residual.
    max_iterations
        Positive damped-Newton iteration limit.
    minimum_phase_separation
        Nonnegative minimum of ``max(abs(y - x))`` required for physical
        convergence.

    Returns
    -------
    FixedVaporRatioVLEPoint
        Coexisting compositions, solved component index, nonlinear
        diagnostics, and phase separation.

    Raises
    ------
    ValueError
        If scalar state inputs, component ratios, initial values, phase roots,
        or solver controls are invalid.

    Notes
    -----
    The square nonlinear system enforces
    ``ln(x_i phi_i^L) = ln(y_i phi_i^V)`` for every component. Independent
    liquid log-ratios and one vapor logit keep both compositions strictly
    inside their simplices. The vapor parametrization preserves all specified
    non-variable component ratios exactly, avoiding an arbitrary overall-feed
    assumption for experiments that report a saturated gas composition but
    not the coexisting liquid composition.

    Fugacity equality and fixed-composition phase-equilibrium context follow
    Michelsen and Mollerup, *Thermodynamic Models: Fundamentals &
    Computational Aspects*, 2nd ed. (2007), chapters 8 and 12,
    ISBN 978-87-989961-3-2. Newton Jacobians are assembled by PyTorch
    automatic differentiation; no equation-of-state-specific derivatives are
    embedded here.
    """
    if temperature.ndim != 0 or not bool(torch.isfinite(temperature) & (temperature > 0.0)):
        raise ValueError("fixed-vapor-ratio VLE requires one finite positive temperature")
    if pressure.ndim != 0 or not bool(torch.isfinite(pressure) & (pressure > 0.0)):
        raise ValueError("fixed-vapor-ratio VLE requires one finite positive pressure")
    if tolerance <= 0.0 or max_iterations < 1:
        raise ValueError("fixed-vapor-ratio VLE requires positive solver controls")
    if minimum_phase_separation < 0.0:
        raise ValueError("minimum phase separation must be nonnegative")
    if any(kind not in ("liquid", "vapor", "stable") for kind in phase_kinds):
        raise ValueError("fixed-vapor-ratio phase kinds must be 'liquid', 'vapor', or 'stable'")
    if not dry_vapor_composition.is_floating_point() or dry_vapor_composition.ndim != 1:
        raise ValueError("dry vapor composition must be a one-dimensional floating tensor")
    ncomponents = dry_vapor_composition.numel()
    if variable_vapor_component < 0 or variable_vapor_component >= ncomponents:
        raise ValueError("variable vapor component is outside the component range")
    if not bool(torch.isfinite(dry_vapor_composition).all() & (dry_vapor_composition >= 0.0).all()):
        raise ValueError("dry vapor composition must be finite and nonnegative")
    if bool(dry_vapor_composition[variable_vapor_component] != 0.0):
        raise ValueError("variable component entry in dry vapor composition must be zero")
    dry_indices = tuple(index for index in range(ncomponents) if index != variable_vapor_component)
    dry_values = torch.stack(tuple(dry_vapor_composition[index] for index in dry_indices))
    if not bool((dry_values > 0.0).all()):
        raise ValueError("every non-variable dry vapor component must have a positive ratio")
    dry_values = dry_values / dry_values.sum()
    dry_composition = torch.stack(
        tuple(
            dry_vapor_composition.new_zeros(())
            if index == variable_vapor_component
            else dry_values[dry_indices.index(index)]
            for index in range(ncomponents)
        )
    )

    if initial_liquid_composition is None:
        initial_dry_total = dry_vapor_composition.new_tensor(1.0e-3)
        initial_liquid = torch.stack(
            tuple(
                1.0 - initial_dry_total
                if index == variable_vapor_component
                else initial_dry_total * dry_composition[index]
                for index in range(ncomponents)
            )
        )
    else:
        if (
            not initial_liquid_composition.is_floating_point()
            or initial_liquid_composition.shape != dry_vapor_composition.shape
            or not bool(
                torch.isfinite(initial_liquid_composition).all()
                & (initial_liquid_composition > 0.0).all()
            )
        ):
            raise ValueError(
                "initial liquid composition must be a finite positive vector "
                "matching the dry vapor composition"
            )
        initial_liquid = normalize_composition(
            initial_liquid_composition.to(
                dtype=dry_vapor_composition.dtype,
                device=dry_vapor_composition.device,
            )
        )

    epsilon = 32.0 * torch.finfo(dry_vapor_composition.dtype).eps
    if initial_variable_vapor_fraction is None:
        components = _components_from_model(model)
        initial_k = wilson_k_values(
            components,
            temperature.to(
                dtype=dry_vapor_composition.dtype,
                device=dry_vapor_composition.device,
            ),
            pressure.to(
                dtype=dry_vapor_composition.dtype,
                device=dry_vapor_composition.device,
            ),
        )
        initial_variable_fraction = (
            initial_liquid[variable_vapor_component] * initial_k[variable_vapor_component]
        )
    else:
        if initial_variable_vapor_fraction.ndim != 0 or not bool(
            torch.isfinite(initial_variable_vapor_fraction)
            & (initial_variable_vapor_fraction > 0.0)
            & (initial_variable_vapor_fraction < 1.0)
        ):
            raise ValueError(
                "initial variable vapor fraction must be one finite scalar inside (0, 1)"
            )
        initial_variable_fraction = initial_variable_vapor_fraction.to(
            dtype=dry_vapor_composition.dtype,
            device=dry_vapor_composition.device,
        )
    initial_variable_fraction = torch.clamp(
        initial_variable_fraction,
        epsilon,
        1.0 - epsilon,
    )
    initial_liquid_ratios = torch.stack(
        tuple(
            initial_liquid[index] / initial_liquid[variable_vapor_component]
            for index in dry_indices
        )
    )
    initial_variable_logit = torch.log(initial_variable_fraction) - torch.log1p(
        -initial_variable_fraction
    )
    variables = torch.cat((torch.log(initial_liquid_ratios), initial_variable_logit.reshape(1)))

    def unpack(current: Tensor) -> tuple[Tensor, Tensor]:
        liquid_ratios = torch.exp(current[:-1])
        liquid_variable = 1.0 / (1.0 + liquid_ratios.sum())
        liquid_dry = liquid_ratios * liquid_variable
        liquid = torch.stack(
            tuple(
                liquid_variable
                if index == variable_vapor_component
                else liquid_dry[dry_indices.index(index)]
                for index in range(ncomponents)
            )
        )
        vapor_variable = torch.sigmoid(current[-1])
        vapor = (1.0 - vapor_variable) * dry_composition
        vapor = torch.stack(
            tuple(
                vapor_variable if index == variable_vapor_component else vapor[index]
                for index in range(ncomponents)
            )
        )
        return liquid, vapor

    solve_temperature = temperature.to(
        dtype=dry_vapor_composition.dtype,
        device=dry_vapor_composition.device,
    )
    solve_pressure = pressure.to(
        dtype=dry_vapor_composition.dtype,
        device=dry_vapor_composition.device,
    )

    def residual(current: Tensor) -> Tensor:
        liquid, vapor = unpack(current)
        return (
            torch.log(liquid)
            + model.log_fugacity_coefficients(
                solve_temperature,
                solve_pressure,
                liquid,
                phase_kinds[0],
            )
            - torch.log(vapor)
            - model.log_fugacity_coefficients(
                solve_temperature,
                solve_pressure,
                vapor,
                phase_kinds[1],
            )
        )

    result = damped_newton(
        residual,
        variables,
        tolerance=tolerance,
        max_iterations=max_iterations,
        lower_bound=torch.full_like(variables, -50.0),
        upper_bound=torch.full_like(variables, 50.0),
    )
    liquid, vapor = unpack(result.solution)
    phase_separation = torch.max(torch.abs(vapor - liquid))
    return FixedVaporRatioVLEPoint(
        solve_temperature,
        solve_pressure,
        liquid,
        vapor,
        variable_vapor_component,
        result.iterations,
        result.converged and bool(float(phase_separation.detach()) > minimum_phase_separation),
        result.residual_norm,
        phase_separation,
    )

binary_vle_point

binary_vle_point(
    model: StateModel,
    temperature: Tensor,
    pressure: Tensor,
    initial_liquid_composition: Tensor,
    initial_vapor_composition: Tensor,
    *,
    tolerance: float = 1e-08,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1e-06,
) -> BinaryVLEPoint

Solve a binary liquid-vapor coexistence point at fixed T and P.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state model used for liquid and vapor fugacity coefficients.

required
temperature Tensor

Scalar temperature in K.

required
pressure Tensor

Scalar pressure in Pa.

required
initial_liquid_composition Tensor

Initial two-component mole-fraction vectors.

required
initial_vapor_composition Tensor

Initial two-component mole-fraction vectors.

required
tolerance float

Maximum absolute component log-fugacity residual.

1e-08
max_iterations int

Maximum substitution/Newton iterations.

30
minimum_phase_separation float

Minimum absolute mole-fraction difference required to reject the algebraic homogeneous solution.

1e-06

Returns:

Type Description
BinaryVLEPoint

Liquid and vapor compositions plus convergence diagnostics.

Notes

A result is converged only when fugacity equality passes and the two compositions remain separated by more than minimum_phase_separation.

Source code in src/torch_flash/envelope.py
def binary_vle_point(
    model: StateModel,
    temperature: Tensor,
    pressure: Tensor,
    initial_liquid_composition: Tensor,
    initial_vapor_composition: Tensor,
    *,
    tolerance: float = 1.0e-8,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1.0e-6,
) -> BinaryVLEPoint:
    """Solve a binary liquid-vapor coexistence point at fixed ``T`` and ``P``.

    Parameters
    ----------
    model
        Homogeneous-state model used for liquid and vapor fugacity
        coefficients.
    temperature
        Scalar temperature in K.
    pressure
        Scalar pressure in Pa.
    initial_liquid_composition, initial_vapor_composition
        Initial two-component mole-fraction vectors.
    tolerance
        Maximum absolute component log-fugacity residual.
    max_iterations
        Maximum substitution/Newton iterations.
    minimum_phase_separation
        Minimum absolute mole-fraction difference required to reject the
        algebraic homogeneous solution.

    Returns
    -------
    BinaryVLEPoint
        Liquid and vapor compositions plus convergence diagnostics.

    Notes
    -----
    A result is converged only when fugacity equality passes and the two
    compositions remain separated by more than
    ``minimum_phase_separation``.
    """
    result = binary_phase_equilibrium_point(
        model,
        temperature,
        pressure,
        initial_liquid_composition,
        initial_vapor_composition,
        phase_kinds=("liquid", "vapor"),
        tolerance=tolerance,
        max_iterations=max_iterations,
        minimum_phase_separation=minimum_phase_separation,
    )
    return BinaryVLEPoint(
        result.temperature,
        result.pressure,
        result.phase1_composition,
        result.phase2_composition,
        result.iterations,
        result.converged,
        result.residual_norm,
    )

binary_helmholtz_vle_point

binary_helmholtz_vle_point(
    model: HelmholtzStateModel,
    temperature: Tensor,
    pressure: Tensor,
    initial_point: BinaryBubblePointWithVolumes
    | BinaryVLEPointWithVolumes,
    *,
    tolerance: float = 1e-08,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1e-06,
) -> BinaryVLEPointWithVolumes

Solve binary fixed-pressure coexistence using explicit phase volumes.

Parameters:

Name Type Description Default
model HelmholtzStateModel

Two-component Helmholtz state model providing residual Helmholtz energy and pressure as functions of temperature, molar volume, and composition.

required
temperature Tensor

One finite positive scalar temperature in K.

required
pressure Tensor

One finite positive scalar coexistence pressure in Pa.

required
initial_point BinaryBubblePointWithVolumes | BinaryVLEPointWithVolumes

Nearby coexistence point containing strictly positive liquid/vapor compositions and molar volumes. It can be a bubble point or another fixed-pressure point from the same branch.

required
tolerance float

Positive maximum absolute dimensionless residual for both component chemical-potential equalities and the two pressure equations.

1e-08
max_iterations int

Positive maximum number of damped-Newton iterations.

30
minimum_phase_separation float

Nonnegative minimum max(abs(y - x)) required for physical convergence.

1e-06

Returns:

Type Description
BinaryVLEPointWithVolumes

Specified state, solved coexisting compositions and molar volumes, and explicit convergence diagnostics. Floating outputs preserve the initial-point dtype and device.

Raises:

Type Description
TypeError

If model does not expose the Helmholtz pressure operation.

ValueError

If the specified state, initial compositions or volumes, tolerance, iteration count, or separation threshold is invalid.

Notes

The four Newton variables are the liquid and vapor composition logits and the logarithms of both molar densities. The residual contains equality of the two component chemical potentials and equality of each phase pressure to the specified pressure. This is the volume-based phase-equilibrium formulation described by Kunz, Klimeck, Wagner, and Jaeschke, The GERG-2004 Wide-Range Equation of State for Natural Gases and Other Mixtures, GERG Technical Monograph 15 (2007), Sec. 5.4.3, ISBN 978-3-18-355706-6. Retaining both volumes removes nested density-root inversions from continuation residual evaluations.

Before the coupled solve, each supplied phase volume is pressure-matched by a bounded one-dimensional Newton correction at its supplied composition. This retains the nearby density root while preventing modest continuation-predictor errors in both volumes from steering the coupled solve toward the homogeneous root.

The discrete physical-separation decision is non-differentiable. The returned tensors otherwise preserve their PyTorch graphs.

Source code in src/torch_flash/envelope.py
def binary_helmholtz_vle_point(
    model: HelmholtzStateModel,
    temperature: Tensor,
    pressure: Tensor,
    initial_point: BinaryBubblePointWithVolumes | BinaryVLEPointWithVolumes,
    *,
    tolerance: float = 1.0e-8,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1.0e-6,
) -> BinaryVLEPointWithVolumes:
    """Solve binary fixed-pressure coexistence using explicit phase volumes.

    Parameters
    ----------
    model
        Two-component Helmholtz state model providing residual Helmholtz
        energy and pressure as functions of temperature, molar volume, and
        composition.
    temperature
        One finite positive scalar temperature in K.
    pressure
        One finite positive scalar coexistence pressure in Pa.
    initial_point
        Nearby coexistence point containing strictly positive liquid/vapor
        compositions and molar volumes. It can be a bubble point or another
        fixed-pressure point from the same branch.
    tolerance
        Positive maximum absolute dimensionless residual for both component
        chemical-potential equalities and the two pressure equations.
    max_iterations
        Positive maximum number of damped-Newton iterations.
    minimum_phase_separation
        Nonnegative minimum ``max(abs(y - x))`` required for physical
        convergence.

    Returns
    -------
    BinaryVLEPointWithVolumes
        Specified state, solved coexisting compositions and molar volumes, and
        explicit convergence diagnostics. Floating outputs preserve the
        initial-point dtype and device.

    Raises
    ------
    TypeError
        If ``model`` does not expose the Helmholtz pressure operation.
    ValueError
        If the specified state, initial compositions or volumes, tolerance,
        iteration count, or separation threshold is invalid.

    Notes
    -----
    The four Newton variables are the liquid and vapor composition logits and
    the logarithms of both molar densities. The residual contains equality of
    the two component chemical potentials and equality of each phase pressure
    to the specified pressure. This is the volume-based phase-equilibrium
    formulation described by Kunz, Klimeck, Wagner, and Jaeschke, *The
    GERG-2004 Wide-Range Equation of State for Natural Gases and Other
    Mixtures*, GERG Technical Monograph 15 (2007), Sec. 5.4.3,
    ISBN 978-3-18-355706-6. Retaining both volumes removes nested density-root
    inversions from continuation residual evaluations.

    Before the coupled solve, each supplied phase volume is pressure-matched
    by a bounded one-dimensional Newton correction at its supplied
    composition. This retains the nearby density root while preventing modest
    continuation-predictor errors in both volumes from steering the coupled
    solve toward the homogeneous root.

    The discrete physical-separation decision is non-differentiable. The
    returned tensors otherwise preserve their PyTorch graphs.
    """
    pressure_function = getattr(model, "pressure", None)
    if not callable(pressure_function):
        raise TypeError("volume-formulation VLE points require a Helmholtz pressure method")

    x = normalize_composition(initial_point.liquid_composition)
    y = normalize_composition(initial_point.vapor_composition.to(dtype=x.dtype, device=x.device))
    specified_temperature = temperature.to(dtype=x.dtype, device=x.device)
    specified_pressure = pressure.to(dtype=x.dtype, device=x.device)
    liquid_volume = initial_point.liquid_molar_volume.to(dtype=x.dtype, device=x.device)
    vapor_volume = initial_point.vapor_molar_volume.to(dtype=x.dtype, device=x.device)
    if specified_temperature.ndim != 0 or not bool(
        torch.isfinite(specified_temperature) & (specified_temperature > 0.0)
    ):
        raise ValueError("binary Helmholtz VLE temperature must be one finite positive scalar")
    if specified_pressure.ndim != 0 or not bool(
        torch.isfinite(specified_pressure) & (specified_pressure > 0.0)
    ):
        raise ValueError("binary Helmholtz VLE pressure must be one finite positive scalar")
    if (
        x.shape != (2,)
        or y.shape != (2,)
        or not bool(
            torch.isfinite(x).all() & torch.isfinite(y).all() & (x > 0.0).all() & (y > 0.0).all()
        )
    ):
        raise ValueError("initial binary Helmholtz VLE compositions must be finite and positive")
    if not bool(
        torch.isfinite(liquid_volume)
        & torch.isfinite(vapor_volume)
        & (liquid_volume > 0.0)
        & (vapor_volume > 0.0)
    ):
        raise ValueError("initial binary Helmholtz VLE molar volumes must be finite and positive")
    if tolerance <= 0.0 or not torch.isfinite(torch.tensor(tolerance)):
        raise ValueError("binary Helmholtz VLE tolerance must be finite and positive")
    if max_iterations < 1:
        raise ValueError("binary Helmholtz VLE max_iterations must be positive")
    if minimum_phase_separation < 0.0 or not torch.isfinite(torch.tensor(minimum_phase_separation)):
        raise ValueError(
            "binary Helmholtz VLE minimum phase separation must be finite and nonnegative"
        )

    epsilon = 32.0 * torch.finfo(x.dtype).eps

    def logit(value: Tensor) -> Tensor:
        bounded = torch.clamp(value, epsilon, 1.0 - epsilon)
        return torch.log(bounded) - torch.log1p(-bounded)

    pressure_scale = torch.clamp_min(
        specified_pressure.detach().abs(),
        x.new_tensor(1.0),
    )
    density_lower_bound = x.new_tensor(-20.0)
    density_upper_bound = x.new_tensor(20.0)

    def pressure_matched_log_density(composition: Tensor, volume: Tensor) -> Tensor:
        initial_log_density = torch.log(volume.reciprocal()).reshape(1)

        def pressure_residual(current: Tensor) -> Tensor:
            current_volume = torch.exp(-current[0])
            current_pressure: Tensor = pressure_function(
                specified_temperature,
                current_volume,
                composition,
            )
            return ((current_pressure - specified_pressure) / pressure_scale).reshape(1)

        initial_residual_norm = pressure_residual(initial_log_density).abs().max()
        if bool(initial_residual_norm <= tolerance):
            return initial_log_density[0]
        density_result = damped_newton(
            pressure_residual,
            initial_log_density,
            tolerance=tolerance,
            max_iterations=min(max_iterations, 12),
            lower_bound=density_lower_bound.reshape(1),
            upper_bound=density_upper_bound.reshape(1),
            jacobian_refresh_interval=1,
        )
        if bool(
            torch.isfinite(density_result.residual_norm)
            & (density_result.residual_norm < initial_residual_norm)
        ):
            return density_result.solution[0]
        return initial_log_density[0]

    variables = torch.stack(
        (
            logit(x[0]),
            logit(y[0]),
            pressure_matched_log_density(x, liquid_volume),
            pressure_matched_log_density(y, vapor_volume),
        )
    )

    def residual(current: Tensor) -> Tensor:
        liquid_first = torch.sigmoid(current[0])
        vapor_first = torch.sigmoid(current[1])
        liquid = torch.stack((liquid_first, 1.0 - liquid_first))
        vapor = torch.stack((vapor_first, 1.0 - vapor_first))
        current_liquid_volume = torch.exp(-current[2])
        current_vapor_volume = torch.exp(-current[3])
        liquid_mu_residual: Tensor = torch.func.grad(
            lambda moles: model.residual_helmholtz_rt(
                specified_temperature,
                current_liquid_volume,
                moles,
            ).sum()
        )(liquid)
        vapor_mu_residual: Tensor = torch.func.grad(
            lambda moles: model.residual_helmholtz_rt(
                specified_temperature,
                current_vapor_volume,
                moles,
            ).sum()
        )(vapor)
        log_fugacity_difference = (
            torch.log(liquid / current_liquid_volume)
            + liquid_mu_residual
            - torch.log(vapor / current_vapor_volume)
            - vapor_mu_residual
        )
        liquid_pressure_residual = (
            pressure_function(
                specified_temperature,
                current_liquid_volume,
                liquid,
            )
            - specified_pressure
        ) / pressure_scale
        vapor_pressure_residual = (
            pressure_function(
                specified_temperature,
                current_vapor_volume,
                vapor,
            )
            - specified_pressure
        ) / pressure_scale
        return torch.cat(
            (
                log_fugacity_difference,
                liquid_pressure_residual.reshape(1),
                vapor_pressure_residual.reshape(1),
            )
        )

    result = damped_newton(
        residual,
        variables,
        tolerance=tolerance,
        max_iterations=max_iterations,
        lower_bound=torch.stack(
            (
                variables.new_tensor(-30.0),
                variables.new_tensor(-30.0),
                variables.new_tensor(-20.0),
                variables.new_tensor(-20.0),
            )
        ),
        upper_bound=torch.stack(
            (
                variables.new_tensor(30.0),
                variables.new_tensor(30.0),
                variables.new_tensor(20.0),
                variables.new_tensor(20.0),
            )
        ),
        jacobian_refresh_interval=1,
    )
    liquid_first = torch.sigmoid(result.solution[0])
    vapor_first = torch.sigmoid(result.solution[1])
    liquid = torch.stack((liquid_first, 1.0 - liquid_first))
    vapor = torch.stack((vapor_first, 1.0 - vapor_first))
    solved_liquid_volume = torch.exp(-result.solution[2])
    solved_vapor_volume = torch.exp(-result.solution[3])
    phase_separation = torch.max(torch.abs(vapor - liquid))
    return BinaryVLEPointWithVolumes(
        specified_temperature,
        specified_pressure,
        liquid,
        vapor,
        result.iterations,
        result.converged and bool(phase_separation > minimum_phase_separation),
        result.residual_norm,
        solved_liquid_volume,
        solved_vapor_volume,
    )

binary_bubble_point

binary_bubble_point(
    model: StateModel,
    temperature: Tensor,
    liquid_composition: Tensor,
    *,
    initial_pressure: Tensor | None = None,
    initial_vapor_composition: Tensor | None = None,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    tolerance: float = 1e-08,
    max_iterations: int = 30,
) -> BinaryBubblePoint

Solve binary bubble pressure and vapor composition at fixed T, x.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model with critical constants for default initialization.

required
temperature Tensor

Scalar temperature in K.

required
liquid_composition Tensor

Strictly positive binary liquid mole fractions.

required
initial_pressure Tensor | None

Optional positive pressure estimate in Pa.

None
initial_vapor_composition Tensor | None

Optional strictly positive binary vapor estimate.

None
minimum_pressure Tensor | float | None

Optional positive pressure bounds in Pa.

None
maximum_pressure Tensor | float | None

Optional positive pressure bounds in Pa.

None
tolerance float

Maximum absolute component log-fugacity residual.

1e-08
max_iterations int

Maximum damped-Newton iterations.

30

Returns:

Type Description
BinaryBubblePoint

Bubble pressure, liquid/vapor compositions, and convergence diagnostics.

Raises:

Type Description
ValueError

If composition, initialization, or pressure bounds are invalid.

Notes

The two unknowns are the log pressure and the logit of the first vapor mole fraction. Unlike a fixed-T,P coexistence calculation, this formulation remains well posed at a homogeneous azeotrope where x == y. Optional pressure bounds can exclude disconnected, physically irrelevant roots in highly non-ideal systems.

Source code in src/torch_flash/envelope.py
def binary_bubble_point(
    model: StateModel,
    temperature: Tensor,
    liquid_composition: Tensor,
    *,
    initial_pressure: Tensor | None = None,
    initial_vapor_composition: Tensor | None = None,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    tolerance: float = 1.0e-8,
    max_iterations: int = 30,
) -> BinaryBubblePoint:
    """Solve binary bubble pressure and vapor composition at fixed ``T, x``.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model with critical constants for default
        initialization.
    temperature
        Scalar temperature in K.
    liquid_composition
        Strictly positive binary liquid mole fractions.
    initial_pressure
        Optional positive pressure estimate in Pa.
    initial_vapor_composition
        Optional strictly positive binary vapor estimate.
    minimum_pressure, maximum_pressure
        Optional positive pressure bounds in Pa.
    tolerance
        Maximum absolute component log-fugacity residual.
    max_iterations
        Maximum damped-Newton iterations.

    Returns
    -------
    BinaryBubblePoint
        Bubble pressure, liquid/vapor compositions, and convergence
        diagnostics.

    Raises
    ------
    ValueError
        If composition, initialization, or pressure bounds are invalid.

    Notes
    -----
    The two unknowns are the log pressure and the logit of the first vapor
    mole fraction.  Unlike a fixed-``T,P`` coexistence calculation, this
    formulation remains well posed at a homogeneous azeotrope where
    ``x == y``. Optional pressure bounds can exclude disconnected,
    physically irrelevant roots in highly non-ideal systems.
    """
    x = normalize_composition(liquid_composition)
    if x.shape != (2,):
        raise ValueError("binary bubble point requires one two-component liquid composition")
    if not bool(torch.isfinite(x).all() & (x > 0.0).all()):
        raise ValueError("binary bubble-point liquid composition must be finite and positive")

    components = _components_from_model(model)
    if initial_pressure is None:
        reference_pressure = torch.ones((), dtype=x.dtype, device=x.device)
        volatility = wilson_k_values(components, temperature, reference_pressure)
        initial_pressure = torch.sum(x * volatility)
    initial_pressure = initial_pressure.to(dtype=x.dtype, device=x.device)
    if initial_pressure.ndim != 0 or not bool(
        torch.isfinite(initial_pressure) & (initial_pressure > 0.0)
    ):
        raise ValueError("initial binary bubble pressure must be one finite positive scalar")

    if initial_vapor_composition is None:
        volatility = wilson_k_values(components, temperature, initial_pressure)
        initial_y = normalize_composition(x * volatility)
    else:
        initial_y = normalize_composition(
            initial_vapor_composition.to(dtype=x.dtype, device=x.device)
        )
    if initial_y.shape != (2,) or not bool(
        torch.isfinite(initial_y).all() & (initial_y > 0.0).all()
    ):
        raise ValueError("initial binary bubble vapor composition must be finite and positive")

    def pressure_bound(value: Tensor | float | None) -> Tensor | None:
        if value is None:
            return None
        return torch.as_tensor(value, dtype=x.dtype, device=x.device)

    minimum = pressure_bound(minimum_pressure)
    maximum = pressure_bound(maximum_pressure)
    for name, value in (("minimum", minimum), ("maximum", maximum)):
        if value is not None and (
            value.ndim != 0 or not bool(torch.isfinite(value) & (value > 0.0))
        ):
            raise ValueError(f"{name} binary bubble pressure must be one finite positive scalar")
    if minimum is not None and maximum is not None and not bool(minimum < maximum):
        raise ValueError("minimum binary bubble pressure must be below maximum pressure")

    epsilon = 32.0 * torch.finfo(x.dtype).eps

    def logit(value: Tensor) -> Tensor:
        bounded = torch.clamp(value, epsilon, 1.0 - epsilon)
        return torch.log(bounded) - torch.log1p(-bounded)

    variables = torch.stack((logit(initial_y[0]), torch.log(initial_pressure)))

    def residual(current: Tensor) -> Tensor:
        y1 = torch.sigmoid(current[0])
        y = torch.stack((y1, 1.0 - y1))
        pressure = torch.exp(current[1])
        return (
            torch.log(x)
            + model.log_fugacity_coefficients(temperature, pressure, x, "liquid")
            - torch.log(y)
            - model.log_fugacity_coefficients(temperature, pressure, y, "vapor")
        )

    lower_pressure_log = variables.new_tensor(-torch.inf) if minimum is None else torch.log(minimum)
    upper_pressure_log = variables.new_tensor(torch.inf) if maximum is None else torch.log(maximum)
    lower_bound = torch.stack((variables.new_tensor(-30.0), lower_pressure_log))
    upper_bound = torch.stack((variables.new_tensor(30.0), upper_pressure_log))
    result = damped_newton(
        residual,
        variables,
        tolerance=tolerance,
        max_iterations=max_iterations,
        lower_bound=lower_bound,
        upper_bound=upper_bound,
    )
    vapor_first = torch.sigmoid(result.solution[0])
    vapor = torch.stack((vapor_first, 1.0 - vapor_first))
    return BinaryBubblePoint(
        temperature,
        torch.exp(result.solution[1]),
        x,
        vapor,
        result.iterations,
        result.converged,
        result.residual_norm,
    )

binary_bubble_temperature

binary_bubble_temperature(
    model: StateModel,
    pressure: Tensor,
    liquid_composition: Tensor,
    *,
    initial_temperature: Tensor | None = None,
    initial_vapor_composition: Tensor | None = None,
    minimum_temperature: Tensor | float | None = None,
    maximum_temperature: Tensor | float | None = None,
    tolerance: float = 1e-08,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1e-06,
) -> BinaryBubbleTemperature

Solve binary bubble temperature and vapor composition at fixed P, x.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model with critical constants for default initialization.

required
pressure Tensor

Specified finite positive scalar pressure in Pa.

required
liquid_composition Tensor

Strictly positive binary liquid mole fractions.

required
initial_temperature Tensor | None

Optional finite positive temperature estimate in K. The composition-weighted critical temperature is used when omitted.

None
initial_vapor_composition Tensor | None

Optional strictly positive binary vapor estimate. Wilson equilibrium ratios initialize the vapor when omitted.

None
minimum_temperature Tensor | float | None

Optional finite positive temperature bounds in K.

None
maximum_temperature Tensor | float | None

Optional finite positive temperature bounds in K.

None
tolerance float

Positive maximum absolute component log-fugacity residual.

1e-08
max_iterations int

Positive damped-Newton iteration limit.

30
minimum_phase_separation float

Nonnegative minimum of max(abs(y - x)) required for physical convergence.

1e-06

Returns:

Type Description
BinaryBubbleTemperature

Bubble temperature, specified pressure, coexisting compositions, and explicit convergence diagnostics.

Raises:

Type Description
ValueError

If the state, composition, bounds, or solver controls are invalid.

Notes

The unknowns are the log temperature and the logit of the first vapor mole fraction. Fugacity equality is enforced with liquid and vapor roots, following Michelsen and Mollerup, Thermodynamic Models: Fundamentals & Computational Aspects, 2nd ed. (2007), chapter 12, ISBN 978-87-989961-3-2. The Jacobian is assembled by PyTorch automatic differentiation, so equation-of-state-specific temperature derivatives are not embedded in this solver.

A converged nonlinear residual is not sufficient near a critical endpoint: the homogeneous x == y solution is rejected unless the requested minimum_phase_separation is exceeded.

Source code in src/torch_flash/envelope.py
def binary_bubble_temperature(
    model: StateModel,
    pressure: Tensor,
    liquid_composition: Tensor,
    *,
    initial_temperature: Tensor | None = None,
    initial_vapor_composition: Tensor | None = None,
    minimum_temperature: Tensor | float | None = None,
    maximum_temperature: Tensor | float | None = None,
    tolerance: float = 1.0e-8,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1.0e-6,
) -> BinaryBubbleTemperature:
    """Solve binary bubble temperature and vapor composition at fixed ``P, x``.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model with critical constants for default
        initialization.
    pressure
        Specified finite positive scalar pressure in Pa.
    liquid_composition
        Strictly positive binary liquid mole fractions.
    initial_temperature
        Optional finite positive temperature estimate in K. The
        composition-weighted critical temperature is used when omitted.
    initial_vapor_composition
        Optional strictly positive binary vapor estimate. Wilson equilibrium
        ratios initialize the vapor when omitted.
    minimum_temperature, maximum_temperature
        Optional finite positive temperature bounds in K.
    tolerance
        Positive maximum absolute component log-fugacity residual.
    max_iterations
        Positive damped-Newton iteration limit.
    minimum_phase_separation
        Nonnegative minimum of ``max(abs(y - x))`` required for physical
        convergence.

    Returns
    -------
    BinaryBubbleTemperature
        Bubble temperature, specified pressure, coexisting compositions, and
        explicit convergence diagnostics.

    Raises
    ------
    ValueError
        If the state, composition, bounds, or solver controls are invalid.

    Notes
    -----
    The unknowns are the log temperature and the logit of the first vapor
    mole fraction. Fugacity equality is enforced with liquid and vapor roots,
    following Michelsen and Mollerup, *Thermodynamic Models: Fundamentals &
    Computational Aspects*, 2nd ed. (2007), chapter 12,
    ISBN 978-87-989961-3-2. The Jacobian is assembled by PyTorch automatic
    differentiation, so equation-of-state-specific temperature derivatives
    are not embedded in this solver.

    A converged nonlinear residual is not sufficient near a critical endpoint:
    the homogeneous ``x == y`` solution is rejected unless the requested
    ``minimum_phase_separation`` is exceeded.
    """
    if tolerance <= 0.0 or max_iterations < 1:
        raise ValueError("binary bubble temperature requires positive solver controls")
    if minimum_phase_separation < 0.0:
        raise ValueError("minimum phase separation must be nonnegative")
    if pressure.ndim != 0 or not bool(torch.isfinite(pressure) & (pressure > 0.0)):
        raise ValueError("binary bubble pressure must be one finite positive scalar")

    x = normalize_composition(liquid_composition)
    if x.shape != (2,) or not bool(torch.isfinite(x).all() & (x > 0.0).all()):
        raise ValueError(
            "binary bubble-temperature liquid composition must be a finite "
            "positive two-component vector"
        )
    solve_pressure = pressure.to(dtype=x.dtype, device=x.device)
    components = _components_from_model(model)

    if initial_temperature is None:
        lower_guess = 0.2 * torch.min(components.critical_temperature)
        upper_guess = 2.0 * torch.max(components.critical_temperature)

        def wilson_closure(temperature: Tensor) -> Tensor:
            return torch.sum(x * wilson_k_values(components, temperature, solve_pressure)) - 1.0

        lower_closure = wilson_closure(lower_guess)
        upper_closure = wilson_closure(upper_guess)
        if bool((lower_closure <= 0.0) & (upper_closure >= 0.0)):
            for _ in range(48):
                midpoint = 0.5 * (lower_guess + upper_guess)
                if bool(wilson_closure(midpoint) < 0.0):
                    lower_guess = midpoint
                else:
                    upper_guess = midpoint
            initial_temperature = 0.5 * (lower_guess + upper_guess)
        else:
            initial_temperature = torch.sum(x * components.critical_temperature)
    solve_initial_temperature = initial_temperature.to(dtype=x.dtype, device=x.device)
    if solve_initial_temperature.ndim != 0 or not bool(
        torch.isfinite(solve_initial_temperature) & (solve_initial_temperature > 0.0)
    ):
        raise ValueError("initial binary bubble temperature must be one finite positive scalar")

    if initial_vapor_composition is None:
        initial_k = wilson_k_values(components, solve_initial_temperature, solve_pressure)
        initial_y = normalize_composition(x * initial_k)
    else:
        initial_y = normalize_composition(
            initial_vapor_composition.to(dtype=x.dtype, device=x.device)
        )
    if initial_y.shape != (2,) or not bool(
        torch.isfinite(initial_y).all() & (initial_y > 0.0).all()
    ):
        raise ValueError("initial binary bubble vapor composition must be finite and positive")

    def temperature_bound(value: Tensor | float | None) -> Tensor | None:
        if value is None:
            return None
        return torch.as_tensor(value, dtype=x.dtype, device=x.device)

    minimum = temperature_bound(minimum_temperature)
    maximum = temperature_bound(maximum_temperature)
    for name, value in (("minimum", minimum), ("maximum", maximum)):
        if value is not None and (
            value.ndim != 0 or not bool(torch.isfinite(value) & (value > 0.0))
        ):
            raise ValueError(f"{name} binary bubble temperature must be one finite positive scalar")
    if minimum is not None and maximum is not None and not bool(minimum < maximum):
        raise ValueError("minimum binary bubble temperature must be below maximum temperature")

    epsilon = 32.0 * torch.finfo(x.dtype).eps

    def logit(value: Tensor) -> Tensor:
        bounded = torch.clamp(value, epsilon, 1.0 - epsilon)
        return torch.log(bounded) - torch.log1p(-bounded)

    variables = torch.stack((logit(initial_y[0]), torch.log(solve_initial_temperature)))

    def residual(current: Tensor) -> Tensor:
        y1 = torch.sigmoid(current[0])
        y = torch.stack((y1, 1.0 - y1))
        temperature = torch.exp(current[1])
        return (
            torch.log(x)
            + model.log_fugacity_coefficients(temperature, solve_pressure, x, "liquid")
            - torch.log(y)
            - model.log_fugacity_coefficients(temperature, solve_pressure, y, "vapor")
        )

    lower_temperature_log = (
        variables.new_tensor(-torch.inf) if minimum is None else torch.log(minimum)
    )
    upper_temperature_log = (
        variables.new_tensor(torch.inf) if maximum is None else torch.log(maximum)
    )
    result = damped_newton(
        residual,
        variables,
        tolerance=tolerance,
        max_iterations=max_iterations,
        lower_bound=torch.stack((variables.new_tensor(-30.0), lower_temperature_log)),
        upper_bound=torch.stack((variables.new_tensor(30.0), upper_temperature_log)),
    )
    vapor_first = torch.sigmoid(result.solution[0])
    vapor = torch.stack((vapor_first, 1.0 - vapor_first))
    phase_separation = torch.max(torch.abs(vapor - x))
    return BinaryBubbleTemperature(
        torch.exp(result.solution[1]),
        solve_pressure,
        x,
        vapor,
        result.iterations,
        result.converged and bool(float(phase_separation.detach()) > minimum_phase_separation),
        result.residual_norm,
        phase_separation,
    )

trace_binary_pxy_isotherm

trace_binary_pxy_isotherm(
    model: StateModel,
    temperature: Tensor,
    liquid_first_fractions: Tensor,
    *,
    initial_pressure: Tensor | None = None,
    initial_vapor_composition: Tensor | None = None,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    tolerance: float = 1e-08,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1e-06,
) -> BinaryPxyIsotherm

Trace a binary P-x-y isotherm with liquid-composition continuation.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model.

required
temperature Tensor

Specified finite positive scalar temperature in K.

required
liquid_first_fractions Tensor

One-dimensional, strictly interior liquid mole fractions of the first component, in the requested continuation order.

required
initial_pressure Tensor | None

Optional estimates for the first point. Every physically converged point initializes the next one.

None
initial_vapor_composition Tensor | None

Optional estimates for the first point. Every physically converged point initializes the next one.

None
minimum_pressure Tensor | float | None

Optional positive pressure bounds in Pa passed to every bubble solve.

None
maximum_pressure Tensor | float | None

Optional positive pressure bounds in Pa passed to every bubble solve.

None
tolerance float

Positive damped-Newton controls.

1e-08
max_iterations float

Positive damped-Newton controls.

1e-08
minimum_phase_separation float

Nonnegative minimum of max(abs(y - x)) used to reject homogeneous algebraic roots.

1e-06

Returns:

Type Description
BinaryPxyIsotherm

Attempted pressures, compositions, and pointwise convergence diagnostics in the same order as liquid_first_fractions.

Notes

This fugacity-based driver is valid for any :class:StateModel. :func:trace_binary_helmholtz_pxy_isotherm is a distinct volume-based continuation algorithm for :class:HelmholtzStateModel models and can follow composition folds that are not single-valued in liquid composition.

The phase-equilibrium formulation follows Michelsen and Mollerup, Thermodynamic Models: Fundamentals & Computational Aspects, 2nd ed. (2007), chapter 12, ISBN 978-87-989961-3-2.

Source code in src/torch_flash/envelope.py
def trace_binary_pxy_isotherm(
    model: StateModel,
    temperature: Tensor,
    liquid_first_fractions: Tensor,
    *,
    initial_pressure: Tensor | None = None,
    initial_vapor_composition: Tensor | None = None,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    tolerance: float = 1.0e-8,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1.0e-6,
) -> BinaryPxyIsotherm:
    """Trace a binary ``P-x-y`` isotherm with liquid-composition continuation.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model.
    temperature
        Specified finite positive scalar temperature in K.
    liquid_first_fractions
        One-dimensional, strictly interior liquid mole fractions of the first
        component, in the requested continuation order.
    initial_pressure, initial_vapor_composition
        Optional estimates for the first point. Every physically converged
        point initializes the next one.
    minimum_pressure, maximum_pressure
        Optional positive pressure bounds in Pa passed to every bubble solve.
    tolerance, max_iterations
        Positive damped-Newton controls.
    minimum_phase_separation
        Nonnegative minimum of ``max(abs(y - x))`` used to reject homogeneous
        algebraic roots.

    Returns
    -------
    BinaryPxyIsotherm
        Attempted pressures, compositions, and pointwise convergence
        diagnostics in the same order as ``liquid_first_fractions``.

    Notes
    -----
    This fugacity-based driver is valid for any :class:`StateModel`.
    :func:`trace_binary_helmholtz_pxy_isotherm` is a distinct volume-based
    continuation algorithm for :class:`HelmholtzStateModel` models and can
    follow composition folds that are not single-valued in liquid
    composition.

    The phase-equilibrium formulation follows Michelsen and Mollerup,
    *Thermodynamic Models: Fundamentals & Computational Aspects*, 2nd ed.
    (2007), chapter 12, ISBN 978-87-989961-3-2.
    """
    if temperature.ndim != 0 or not bool(torch.isfinite(temperature) & (temperature > 0.0)):
        raise ValueError("binary P-x-y trace requires one finite positive temperature")
    if (
        not liquid_first_fractions.is_floating_point()
        or liquid_first_fractions.ndim != 1
        or liquid_first_fractions.numel() == 0
        or not bool(
            torch.isfinite(liquid_first_fractions).all()
            & (liquid_first_fractions > 0.0).all()
            & (liquid_first_fractions < 1.0).all()
        )
    ):
        raise ValueError(
            "binary P-x-y trace requires a nonempty floating vector of "
            "strictly interior liquid first-component fractions"
        )
    if minimum_phase_separation < 0.0:
        raise ValueError("minimum phase separation must be nonnegative")

    pressures: list[Tensor] = []
    liquid_compositions: list[Tensor] = []
    vapor_compositions: list[Tensor] = []
    iterations: list[int] = []
    converged: list[bool] = []
    residuals: list[Tensor] = []
    separations: list[Tensor] = []
    next_pressure = initial_pressure
    next_vapor = initial_vapor_composition
    for first_fraction in liquid_first_fractions:
        liquid = torch.stack((first_fraction, 1.0 - first_fraction))
        point = binary_bubble_point(
            model,
            temperature,
            liquid,
            initial_pressure=next_pressure,
            initial_vapor_composition=next_vapor,
            minimum_pressure=minimum_pressure,
            maximum_pressure=maximum_pressure,
            tolerance=tolerance,
            max_iterations=max_iterations,
        )
        separation = torch.max(torch.abs(point.vapor_composition - point.liquid_composition))
        physical = point.converged and bool(float(separation.detach()) > minimum_phase_separation)
        pressures.append(point.pressure)
        liquid_compositions.append(point.liquid_composition)
        vapor_compositions.append(point.vapor_composition)
        iterations.append(point.iterations)
        converged.append(physical)
        residuals.append(point.residual_norm)
        separations.append(separation)
        if physical:
            next_pressure = point.pressure
            next_vapor = point.vapor_composition

    return BinaryPxyIsotherm(
        temperature,
        torch.stack(pressures),
        torch.stack(liquid_compositions),
        torch.stack(vapor_compositions),
        torch.tensor(iterations, dtype=torch.int64, device=liquid_first_fractions.device),
        torch.tensor(converged, dtype=torch.bool, device=liquid_first_fractions.device),
        torch.stack(residuals),
        torch.stack(separations),
    )

trace_binary_txy_isobar

trace_binary_txy_isobar(
    model: StateModel,
    pressure: Tensor,
    liquid_first_fractions: Tensor,
    *,
    initial_temperature: Tensor | None = None,
    initial_vapor_composition: Tensor | None = None,
    minimum_temperature: Tensor | float | None = None,
    maximum_temperature: Tensor | float | None = None,
    tolerance: float = 1e-08,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1e-06,
) -> BinaryTxyIsobar

Trace a binary T-x-y isobar with liquid-composition continuation.

Parameters:

Name Type Description Default
model StateModel

Homogeneous-state fugacity model.

required
pressure Tensor

Specified finite positive scalar pressure in Pa.

required
liquid_first_fractions Tensor

One-dimensional, strictly interior liquid mole fractions of the first component, in the requested continuation order.

required
initial_temperature Tensor | None

Optional estimates for the first point. Every physically converged point initializes the next one.

None
initial_vapor_composition Tensor | None

Optional estimates for the first point. Every physically converged point initializes the next one.

None
minimum_temperature Tensor | float | None

Optional positive temperature bounds in K passed to every bubble solve.

None
maximum_temperature Tensor | float | None

Optional positive temperature bounds in K passed to every bubble solve.

None
tolerance float

Positive damped-Newton controls.

1e-08
max_iterations float

Positive damped-Newton controls.

1e-08
minimum_phase_separation float

Nonnegative minimum of max(abs(y - x)) used to reject homogeneous algebraic roots.

1e-06

Returns:

Type Description
BinaryTxyIsobar

Attempted temperatures, compositions, and pointwise convergence diagnostics in the same order as liquid_first_fractions.

Notes

Every point is solved by :func:binary_bubble_temperature; both liquid and vapor branches therefore come from one fugacity-equality calculation. The continuation order is scientifically significant near critical endpoints and should be selected to begin on a well-separated branch.

The phase-equilibrium formulation follows Michelsen and Mollerup, Thermodynamic Models: Fundamentals & Computational Aspects, 2nd ed. (2007), chapter 12, ISBN 978-87-989961-3-2.

Source code in src/torch_flash/envelope.py
def trace_binary_txy_isobar(
    model: StateModel,
    pressure: Tensor,
    liquid_first_fractions: Tensor,
    *,
    initial_temperature: Tensor | None = None,
    initial_vapor_composition: Tensor | None = None,
    minimum_temperature: Tensor | float | None = None,
    maximum_temperature: Tensor | float | None = None,
    tolerance: float = 1.0e-8,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1.0e-6,
) -> BinaryTxyIsobar:
    """Trace a binary ``T-x-y`` isobar with liquid-composition continuation.

    Parameters
    ----------
    model
        Homogeneous-state fugacity model.
    pressure
        Specified finite positive scalar pressure in Pa.
    liquid_first_fractions
        One-dimensional, strictly interior liquid mole fractions of the first
        component, in the requested continuation order.
    initial_temperature, initial_vapor_composition
        Optional estimates for the first point. Every physically converged
        point initializes the next one.
    minimum_temperature, maximum_temperature
        Optional positive temperature bounds in K passed to every bubble
        solve.
    tolerance, max_iterations
        Positive damped-Newton controls.
    minimum_phase_separation
        Nonnegative minimum of ``max(abs(y - x))`` used to reject homogeneous
        algebraic roots.

    Returns
    -------
    BinaryTxyIsobar
        Attempted temperatures, compositions, and pointwise convergence
        diagnostics in the same order as ``liquid_first_fractions``.

    Notes
    -----
    Every point is solved by :func:`binary_bubble_temperature`; both liquid
    and vapor branches therefore come from one fugacity-equality calculation.
    The continuation order is scientifically significant near critical
    endpoints and should be selected to begin on a well-separated branch.

    The phase-equilibrium formulation follows Michelsen and Mollerup,
    *Thermodynamic Models: Fundamentals & Computational Aspects*, 2nd ed.
    (2007), chapter 12, ISBN 978-87-989961-3-2.
    """
    if pressure.ndim != 0 or not bool(torch.isfinite(pressure) & (pressure > 0.0)):
        raise ValueError("binary T-x-y trace requires one finite positive pressure")
    if (
        not liquid_first_fractions.is_floating_point()
        or liquid_first_fractions.ndim != 1
        or liquid_first_fractions.numel() == 0
        or not bool(
            torch.isfinite(liquid_first_fractions).all()
            & (liquid_first_fractions > 0.0).all()
            & (liquid_first_fractions < 1.0).all()
        )
    ):
        raise ValueError(
            "binary T-x-y trace requires a nonempty floating vector of "
            "strictly interior liquid first-component fractions"
        )
    if minimum_phase_separation < 0.0:
        raise ValueError("minimum phase separation must be nonnegative")

    temperatures: list[Tensor] = []
    liquid_compositions: list[Tensor] = []
    vapor_compositions: list[Tensor] = []
    iterations: list[int] = []
    converged: list[bool] = []
    residuals: list[Tensor] = []
    separations: list[Tensor] = []
    next_temperature = initial_temperature
    next_vapor = initial_vapor_composition
    for first_fraction in liquid_first_fractions:
        liquid = torch.stack((first_fraction, 1.0 - first_fraction))
        point = binary_bubble_temperature(
            model,
            pressure,
            liquid,
            initial_temperature=next_temperature,
            initial_vapor_composition=next_vapor,
            minimum_temperature=minimum_temperature,
            maximum_temperature=maximum_temperature,
            tolerance=tolerance,
            max_iterations=max_iterations,
            minimum_phase_separation=minimum_phase_separation,
        )
        temperatures.append(point.temperature)
        liquid_compositions.append(point.liquid_composition)
        vapor_compositions.append(point.vapor_composition)
        iterations.append(point.iterations)
        converged.append(point.converged)
        residuals.append(point.residual_norm)
        separations.append(point.phase_separation)
        if point.converged:
            next_temperature = point.temperature
            next_vapor = point.vapor_composition

    return BinaryTxyIsobar(
        pressure,
        torch.stack(temperatures),
        torch.stack(liquid_compositions),
        torch.stack(vapor_compositions),
        torch.tensor(iterations, dtype=torch.int64, device=liquid_first_fractions.device),
        torch.tensor(converged, dtype=torch.bool, device=liquid_first_fractions.device),
        torch.stack(residuals),
        torch.stack(separations),
    )

binary_helmholtz_bubble_point

binary_helmholtz_bubble_point(
    model: HelmholtzStateModel,
    temperature: Tensor,
    liquid_composition: Tensor,
    *,
    initial_point: BinaryBubblePointWithVolumes
    | None = None,
    initial_pressure: Tensor | None = None,
    initial_vapor_composition: Tensor | None = None,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    tolerance: float = 1e-08,
    max_iterations: int = 30,
) -> BinaryBubblePointWithVolumes

Solve a binary bubble point using phase volumes as Newton variables.

Parameters:

Name Type Description Default
model HelmholtzStateModel

Helmholtz state model providing residual Helmholtz energy, pressure, molar-volume roots, and critical constants.

required
temperature Tensor

Scalar temperature in K.

required
liquid_composition Tensor

Strictly positive binary liquid mole fractions.

required
initial_point BinaryBubblePointWithVolumes | None

Previously converged volume-formulation point used as the continuation estimate. When omitted, the pressure formulation initializes the first point.

None
initial_pressure Tensor | None

Optional positive pressure estimate in Pa for first-point initialization.

None
initial_vapor_composition Tensor | None

Optional strictly positive vapor estimate for first-point initialization.

None
minimum_pressure Tensor | float | None

Optional positive pressure bounds in Pa.

None
maximum_pressure Tensor | float | None

Optional positive pressure bounds in Pa.

None
tolerance float

Maximum absolute dimensionless equilibrium residual.

1e-08
max_iterations int

Maximum damped-Newton iterations.

30

Returns:

Type Description
BinaryBubblePointWithVolumes

Bubble pressure, both phase compositions and volumes, and explicit nonlinear convergence diagnostics.

Notes

With no initial_point, the pressure formulation supplies a robust first point and its two density roots. Continuation then solves directly for vapor composition, liquid density, and vapor density from equality of both component fugacities and pressure. This is the volume-based GERG phase-equilibrium formulation described by Kunz, Klimeck, Wagner, and Jaeschke, The GERG-2004 Wide-Range Equation of State for Natural Gases and Other Mixtures, GERG Technical Monograph 15 (2007), Sec. 5.4.3, ISBN 978-3-18-355706-6. It eliminates density inversion from every outer residual evaluation.

A failed direct solve remains explicitly non-converged. Callers tracing a difficult branch can retry without initial_point to invoke the robust pressure initializer.

Source code in src/torch_flash/envelope.py
def binary_helmholtz_bubble_point(
    model: HelmholtzStateModel,
    temperature: Tensor,
    liquid_composition: Tensor,
    *,
    initial_point: BinaryBubblePointWithVolumes | None = None,
    initial_pressure: Tensor | None = None,
    initial_vapor_composition: Tensor | None = None,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    tolerance: float = 1.0e-8,
    max_iterations: int = 30,
) -> BinaryBubblePointWithVolumes:
    """Solve a binary bubble point using phase volumes as Newton variables.

    Parameters
    ----------
    model
        Helmholtz state model providing residual Helmholtz energy, pressure,
        molar-volume roots, and critical constants.
    temperature
        Scalar temperature in K.
    liquid_composition
        Strictly positive binary liquid mole fractions.
    initial_point
        Previously converged volume-formulation point used as the continuation
        estimate. When omitted, the pressure formulation initializes the
        first point.
    initial_pressure
        Optional positive pressure estimate in Pa for first-point
        initialization.
    initial_vapor_composition
        Optional strictly positive vapor estimate for first-point
        initialization.
    minimum_pressure, maximum_pressure
        Optional positive pressure bounds in Pa.
    tolerance
        Maximum absolute dimensionless equilibrium residual.
    max_iterations
        Maximum damped-Newton iterations.

    Returns
    -------
    BinaryBubblePointWithVolumes
        Bubble pressure, both phase compositions and volumes, and explicit
        nonlinear convergence diagnostics.

    Notes
    -----
    With no ``initial_point``, the pressure formulation supplies a robust
    first point and its two density roots. Continuation then solves directly
    for vapor composition, liquid density, and vapor density from equality of
    both component fugacities and pressure. This is the volume-based GERG
    phase-equilibrium formulation described by Kunz, Klimeck, Wagner, and
    Jaeschke, *The GERG-2004 Wide-Range Equation of State for Natural Gases
    and Other Mixtures*, GERG Technical Monograph 15 (2007), Sec. 5.4.3,
    ISBN 978-3-18-355706-6. It eliminates density inversion from every outer
    residual evaluation.

    A failed direct solve remains explicitly non-converged. Callers tracing a
    difficult branch can retry without ``initial_point`` to invoke the robust
    pressure initializer.
    """
    pressure_function = getattr(model, "pressure", None)
    if not callable(pressure_function):
        raise TypeError("volume-formulation bubble points require a Helmholtz pressure method")

    x = normalize_composition(liquid_composition)
    if x.shape != (2,):
        raise ValueError("binary bubble point requires one two-component liquid composition")
    if not bool(torch.isfinite(x).all() & (x > 0.0).all()):
        raise ValueError("binary bubble-point liquid composition must be finite and positive")

    def pressure_bound(value: Tensor | float | None, name: str) -> Tensor | None:
        if value is None:
            return None
        bound = torch.as_tensor(value, dtype=x.dtype, device=x.device)
        if bound.ndim != 0 or not bool(torch.isfinite(bound) & (bound > 0.0)):
            raise ValueError(f"{name} binary bubble pressure must be one finite positive scalar")
        return bound

    minimum = pressure_bound(minimum_pressure, "minimum")
    maximum = pressure_bound(maximum_pressure, "maximum")
    if minimum is not None and maximum is not None and not bool(minimum < maximum):
        raise ValueError("minimum binary bubble pressure must be below maximum pressure")

    if initial_point is None:
        initialized = binary_bubble_point(
            model,
            temperature,
            x,
            initial_pressure=initial_pressure,
            initial_vapor_composition=initial_vapor_composition,
            minimum_pressure=minimum,
            maximum_pressure=maximum,
            tolerance=tolerance,
            max_iterations=max_iterations,
        )
        liquid_volume = model.molar_volume(
            temperature,
            initialized.pressure,
            x,
            "liquid",
        )
        vapor_volume = model.molar_volume(
            temperature,
            initialized.pressure,
            initialized.vapor_composition,
            "vapor",
        )
        return BinaryBubblePointWithVolumes(
            initialized.temperature,
            initialized.pressure,
            initialized.liquid_composition,
            initialized.vapor_composition,
            initialized.iterations,
            initialized.converged,
            initialized.residual_norm,
            liquid_volume,
            vapor_volume,
        )

    initial_y = normalize_composition(
        initial_point.vapor_composition.to(dtype=x.dtype, device=x.device)
    )
    initial_liquid_volume = initial_point.liquid_molar_volume.to(
        dtype=x.dtype,
        device=x.device,
    )
    initial_vapor_volume = initial_point.vapor_molar_volume.to(
        dtype=x.dtype,
        device=x.device,
    )
    if initial_y.shape != (2,) or not bool(
        torch.isfinite(initial_y).all() & (initial_y > 0.0).all()
    ):
        raise ValueError("initial binary bubble vapor composition must be finite and positive")
    if not bool(
        torch.isfinite(initial_liquid_volume)
        & torch.isfinite(initial_vapor_volume)
        & (initial_liquid_volume > 0.0)
        & (initial_vapor_volume > 0.0)
    ):
        raise ValueError("initial binary bubble molar volumes must be finite and positive")

    epsilon = 32.0 * torch.finfo(x.dtype).eps

    def logit(value: Tensor) -> Tensor:
        bounded = torch.clamp(value, epsilon, 1.0 - epsilon)
        return torch.log(bounded) - torch.log1p(-bounded)

    variables = torch.stack(
        (
            logit(initial_y[0]),
            torch.log(initial_liquid_volume.reciprocal()),
            torch.log(initial_vapor_volume.reciprocal()),
        )
    )
    pressure_scale = torch.clamp_min(
        initial_point.pressure.to(dtype=x.dtype, device=x.device).detach().abs(),
        variables.new_tensor(1.0),
    )

    def residual(current: Tensor) -> Tensor:
        vapor_first = torch.sigmoid(current[0])
        vapor = torch.stack((vapor_first, 1.0 - vapor_first))
        liquid_volume = torch.exp(-current[1])
        vapor_volume = torch.exp(-current[2])
        liquid_mu_residual: Tensor = torch.func.grad(
            lambda moles: model.residual_helmholtz_rt(
                temperature,
                liquid_volume,
                moles,
            ).sum()
        )(x)
        vapor_mu_residual: Tensor = torch.func.grad(
            lambda moles: model.residual_helmholtz_rt(
                temperature,
                vapor_volume,
                moles,
            ).sum()
        )(vapor)
        log_fugacity_difference = (
            torch.log(x / liquid_volume)
            + liquid_mu_residual
            - torch.log(vapor / vapor_volume)
            - vapor_mu_residual
        )
        liquid_pressure = pressure_function(temperature, liquid_volume, x)
        vapor_pressure = pressure_function(temperature, vapor_volume, vapor)
        pressure_difference = (liquid_pressure - vapor_pressure) / pressure_scale
        return torch.cat((log_fugacity_difference, pressure_difference.reshape(1)))

    lower_bound = torch.stack(
        (
            variables.new_tensor(-30.0),
            variables.new_tensor(-20.0),
            variables.new_tensor(-20.0),
        )
    )
    upper_bound = torch.stack(
        (
            variables.new_tensor(30.0),
            variables.new_tensor(20.0),
            variables.new_tensor(20.0),
        )
    )
    result = damped_newton(
        residual,
        variables,
        tolerance=tolerance,
        max_iterations=max_iterations,
        lower_bound=lower_bound,
        upper_bound=upper_bound,
        jacobian_refresh_interval=4,
    )
    vapor_first = torch.sigmoid(result.solution[0])
    vapor = torch.stack((vapor_first, 1.0 - vapor_first))
    liquid_volume = torch.exp(-result.solution[1])
    vapor_volume = torch.exp(-result.solution[2])
    liquid_pressure = pressure_function(temperature, liquid_volume, x)
    vapor_pressure = pressure_function(temperature, vapor_volume, vapor)
    pressure = 0.5 * (liquid_pressure + vapor_pressure)
    pressure_in_range = torch.isfinite(pressure) & (pressure > 0.0)
    if minimum is not None:
        pressure_in_range = pressure_in_range & (pressure >= minimum)
    if maximum is not None:
        pressure_in_range = pressure_in_range & (pressure <= maximum)
    return BinaryBubblePointWithVolumes(
        temperature,
        pressure,
        x,
        vapor,
        result.iterations,
        result.converged and bool(pressure_in_range),
        result.residual_norm,
        liquid_volume,
        vapor_volume,
    )

trace_binary_helmholtz_pxy_isotherm

trace_binary_helmholtz_pxy_isotherm(
    model: HelmholtzStateModel,
    temperature: Tensor,
    liquid_compositions: Tensor,
    *,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    tolerance: float = 1e-08,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1e-05,
    minimum_pressure_step_separation_ratio: float = 0.1,
    stop_on_failure: bool = True,
    composition_failure_refinement_steps: int = 0,
    continue_in_pressure_on_failure: bool = False,
    pressure_continuation_points: int = 25,
    pressure_failure_refinement_steps: int = 0,
) -> BinaryPxyIsotherm

Trace a binary pressure-composition isotherm by bubble continuation.

Each row of liquid_compositions specifies a successive liquid point. The first state uses the pressure-formulation initializer; later states reuse both coexisting phase volumes in :func:binary_helmholtz_bubble_point. This exposes a high-level p-x-y workflow without embedding continuation logic in an application study.

Parameters:

Name Type Description Default
model HelmholtzStateModel

Two-component Helmholtz state model with pressure, molar-volume, and residual-Helmholtz methods.

required
temperature Tensor

One finite positive scalar temperature in K.

required
liquid_compositions Tensor

Ordered, strictly positive liquid mole fractions with shape (points, 2). Rows are normalized internally.

required
minimum_pressure Tensor | float | None

Optional finite positive pressure bounds in Pa. When a converged point reaches 99.5% of maximum_pressure, tracing stops.

None
maximum_pressure Tensor | float | None

Optional finite positive pressure bounds in Pa. When a converged point reaches 99.5% of maximum_pressure, tracing stops.

None
tolerance float

Positive maximum absolute dimensionless equilibrium residual.

1e-08
max_iterations int

Positive maximum damped-Newton iteration count per point.

30
minimum_phase_separation float

Nonnegative minimum of max(abs(y - x)) required for physical convergence.

1e-05
minimum_pressure_step_separation_ratio float

Positive ratio, no greater than one, between a candidate phase separation and the preceding accepted separation during fixed-pressure continuation. A larger contraction is treated as a branch jump and triggers pressure refinement instead of accepting a nearly homogeneous algebraic root.

0.1
stop_on_failure bool

Stop after the first rejected point once a physical branch has begun.

True
composition_failure_refinement_steps int

Number of bisection refinements between the last accepted liquid composition and the first rejected one. Refinement approaches a critical endpoint or a turning point in the liquid-composition parametrization without requiring a uniformly fine input grid.

0
continue_in_pressure_on_failure bool

After refinement, continue at increasing fixed pressures from the last accepted coexistence state to maximum_pressure. This follows open phase boundaries through a turning point in liquid composition. Requires stop_on_failure=True and a finite maximum_pressure.

False
pressure_continuation_points int

Number of logarithmically spaced fixed-pressure solves used by the pressure continuation, including its final pressure.

25
pressure_failure_refinement_steps int

Number of logarithmic bisection refinements between the last accepted and first rejected fixed-pressure states. This approaches the critical endpoint of a closed curve without uniformly refining the entire pressure path.

0

Returns:

Type Description
BinaryPxyIsotherm

Attempted pressures, both phase compositions, iterations, residuals, separation, and a physical-convergence mask. All floating tensors retain the input composition dtype and device.

Raises:

Type Description
ValueError

If temperature, compositions, tolerances, or iteration settings are invalid, or if pressure continuation is requested without compatible stopping and pressure-bound settings. Pressure-bound validation is otherwise delegated to the point solver.

Notes

The equilibrium equations and reuse of prior solutions as initial estimates follow Kunz, Klimeck, Wagner, and Jaeschke, The GERG-2004 Wide-Range Equation of State for Natural Gases and Other Mixtures, GERG Technical Monograph 15 (2007), Sec. 5.4.3, ISBN 978-3-18-355706-6, and Michelsen and Mollerup, Thermodynamic Models, 2nd ed. (2007), chapter 12, ISBN 978-87-989961-3-2.

Liquid-composition continuation becomes singular at a fold of an open or closed phase boundary. The optional fallback changes the continuation coordinate to pressure and solves coexistence with :func:binary_helmholtz_vle_point. An open branch continues to the requested pressure bound; a closed branch is refined toward its critical endpoint after the first rejected pressure. Fixed-pressure initial states use a bounded secant extrapolation of both composition logits and both logarithmic phase volumes. A candidate whose phase split contracts by more than minimum_pressure_step_separation_ratio is rejected as a possible jump to the algebraic homogeneous root and causes pressure refinement. The routine never forces a physically open branch to close. Bisection and the discrete accept/stop/coordinate-switch decisions are not differentiable, but accepted equilibrium tensors preserve their PyTorch graphs.

Source code in src/torch_flash/envelope.py
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
4383
4384
4385
4386
4387
4388
4389
4390
4391
4392
4393
4394
4395
4396
4397
4398
4399
4400
4401
4402
4403
4404
4405
4406
4407
4408
4409
4410
4411
4412
4413
4414
4415
4416
4417
4418
4419
4420
4421
4422
4423
4424
4425
4426
4427
4428
4429
4430
4431
4432
4433
4434
4435
4436
4437
4438
4439
4440
4441
4442
4443
4444
4445
4446
4447
4448
4449
4450
4451
4452
4453
4454
4455
4456
4457
4458
4459
4460
4461
4462
4463
4464
4465
4466
4467
4468
4469
4470
4471
4472
4473
4474
4475
4476
4477
4478
4479
4480
4481
4482
4483
4484
4485
4486
4487
4488
4489
4490
4491
4492
4493
4494
4495
4496
4497
4498
4499
4500
4501
4502
4503
4504
4505
4506
4507
4508
4509
4510
4511
4512
4513
4514
4515
4516
4517
4518
4519
4520
4521
4522
4523
4524
4525
4526
4527
4528
4529
4530
4531
4532
4533
4534
4535
4536
4537
4538
4539
4540
4541
4542
4543
4544
4545
4546
4547
4548
4549
4550
4551
4552
4553
4554
4555
4556
4557
4558
4559
4560
4561
4562
4563
4564
4565
4566
4567
4568
4569
4570
4571
4572
4573
4574
4575
4576
4577
4578
4579
4580
4581
4582
4583
4584
4585
4586
4587
4588
4589
4590
4591
4592
4593
4594
4595
4596
4597
4598
4599
4600
4601
4602
4603
4604
4605
4606
4607
4608
4609
4610
4611
4612
4613
4614
4615
4616
4617
def trace_binary_helmholtz_pxy_isotherm(
    model: HelmholtzStateModel,
    temperature: Tensor,
    liquid_compositions: Tensor,
    *,
    minimum_pressure: Tensor | float | None = None,
    maximum_pressure: Tensor | float | None = None,
    tolerance: float = 1.0e-8,
    max_iterations: int = 30,
    minimum_phase_separation: float = 1.0e-5,
    minimum_pressure_step_separation_ratio: float = 0.1,
    stop_on_failure: bool = True,
    composition_failure_refinement_steps: int = 0,
    continue_in_pressure_on_failure: bool = False,
    pressure_continuation_points: int = 25,
    pressure_failure_refinement_steps: int = 0,
) -> BinaryPxyIsotherm:
    """Trace a binary pressure-composition isotherm by bubble continuation.

    Each row of ``liquid_compositions`` specifies a successive liquid point.
    The first state uses the pressure-formulation initializer; later states
    reuse both coexisting phase volumes in
    :func:`binary_helmholtz_bubble_point`. This exposes a high-level ``p-x-y``
    workflow without embedding continuation logic in an application study.

    Parameters
    ----------
    model
        Two-component Helmholtz state model with pressure, molar-volume, and
        residual-Helmholtz methods.
    temperature
        One finite positive scalar temperature in K.
    liquid_compositions
        Ordered, strictly positive liquid mole fractions with shape
        ``(points, 2)``. Rows are normalized internally.
    minimum_pressure, maximum_pressure
        Optional finite positive pressure bounds in Pa. When a converged point
        reaches 99.5% of ``maximum_pressure``, tracing stops.
    tolerance
        Positive maximum absolute dimensionless equilibrium residual.
    max_iterations
        Positive maximum damped-Newton iteration count per point.
    minimum_phase_separation
        Nonnegative minimum of ``max(abs(y - x))`` required for physical
        convergence.
    minimum_pressure_step_separation_ratio
        Positive ratio, no greater than one, between a candidate phase
        separation and the preceding accepted separation during fixed-pressure
        continuation. A larger contraction is treated as a branch jump and
        triggers pressure refinement instead of accepting a nearly homogeneous
        algebraic root.
    stop_on_failure
        Stop after the first rejected point once a physical branch has begun.
    composition_failure_refinement_steps
        Number of bisection refinements between the last accepted liquid
        composition and the first rejected one. Refinement approaches a
        critical endpoint or a turning point in the liquid-composition
        parametrization without requiring a uniformly fine input grid.
    continue_in_pressure_on_failure
        After refinement, continue at increasing fixed pressures from the last
        accepted coexistence state to ``maximum_pressure``. This follows open
        phase boundaries through a turning point in liquid composition.
        Requires ``stop_on_failure=True`` and a finite ``maximum_pressure``.
    pressure_continuation_points
        Number of logarithmically spaced fixed-pressure solves used by the
        pressure continuation, including its final pressure.
    pressure_failure_refinement_steps
        Number of logarithmic bisection refinements between the last accepted
        and first rejected fixed-pressure states. This approaches the critical
        endpoint of a closed curve without uniformly refining the entire
        pressure path.

    Returns
    -------
    BinaryPxyIsotherm
        Attempted pressures, both phase compositions, iterations, residuals,
        separation, and a physical-convergence mask. All floating tensors
        retain the input composition dtype and device.

    Raises
    ------
    ValueError
        If temperature, compositions, tolerances, or iteration settings are
        invalid, or if pressure continuation is requested without compatible
        stopping and pressure-bound settings. Pressure-bound validation is
        otherwise delegated to the point solver.

    Notes
    -----
    The equilibrium equations and reuse of prior solutions as initial
    estimates follow Kunz, Klimeck, Wagner, and Jaeschke, *The GERG-2004
    Wide-Range Equation of State for Natural Gases and Other Mixtures*, GERG
    Technical Monograph 15 (2007), Sec. 5.4.3, ISBN 978-3-18-355706-6, and
    Michelsen and Mollerup, *Thermodynamic Models*, 2nd ed. (2007), chapter
    12, ISBN 978-87-989961-3-2.

    Liquid-composition continuation becomes singular at a fold of an open or
    closed phase boundary. The optional fallback changes the continuation
    coordinate to pressure and solves coexistence with
    :func:`binary_helmholtz_vle_point`. An open branch continues to the
    requested pressure bound; a closed branch is refined toward its critical
    endpoint after the first rejected pressure. Fixed-pressure initial states
    use a bounded secant extrapolation of both composition logits and both
    logarithmic phase volumes. A candidate whose phase split contracts by more
    than ``minimum_pressure_step_separation_ratio`` is rejected as a possible
    jump to the algebraic homogeneous root and causes pressure refinement. The
    routine never forces a physically open branch to close. Bisection and the
    discrete accept/stop/coordinate-switch decisions are not differentiable,
    but accepted equilibrium tensors preserve their PyTorch graphs.
    """
    if temperature.ndim != 0 or not bool(torch.isfinite(temperature) & (temperature > 0.0)):
        raise ValueError("binary p-x-y isotherm requires one finite positive temperature")
    if not liquid_compositions.is_floating_point():
        raise ValueError("binary p-x-y liquid compositions must use a floating dtype")
    compositions = normalize_composition(
        liquid_compositions.to(dtype=liquid_compositions.dtype, device=liquid_compositions.device)
    )
    if compositions.ndim != 2 or compositions.shape[0] < 1 or compositions.shape[1] != 2:
        raise ValueError("binary p-x-y liquid compositions must have nonempty shape (points, 2)")
    if not bool(torch.isfinite(compositions).all() & (compositions > 0.0).all()):
        raise ValueError("binary p-x-y liquid compositions must be finite and positive")
    if tolerance <= 0.0 or not torch.isfinite(torch.tensor(tolerance)):
        raise ValueError("binary p-x-y tolerance must be finite and positive")
    if max_iterations < 1:
        raise ValueError("binary p-x-y max_iterations must be positive")
    if minimum_phase_separation < 0.0 or not torch.isfinite(torch.tensor(minimum_phase_separation)):
        raise ValueError("binary p-x-y minimum phase separation must be finite and nonnegative")
    if (
        minimum_pressure_step_separation_ratio <= 0.0
        or minimum_pressure_step_separation_ratio > 1.0
        or not torch.isfinite(torch.tensor(minimum_pressure_step_separation_ratio))
    ):
        raise ValueError(
            "binary p-x-y minimum pressure-step separation ratio must be finite "
            "and in the interval (0, 1]"
        )
    if composition_failure_refinement_steps < 0:
        raise ValueError("binary p-x-y composition failure refinement steps must be nonnegative")
    if pressure_continuation_points < 1:
        raise ValueError("binary p-x-y pressure continuation points must be positive")
    if pressure_failure_refinement_steps < 0:
        raise ValueError("binary p-x-y pressure failure refinement steps must be nonnegative")
    if continue_in_pressure_on_failure and not stop_on_failure:
        raise ValueError("binary p-x-y pressure continuation requires stop_on_failure=True")
    if continue_in_pressure_on_failure and maximum_pressure is None:
        raise ValueError("binary p-x-y pressure continuation requires maximum_pressure")

    maximum = (
        None
        if maximum_pressure is None
        else torch.as_tensor(
            maximum_pressure,
            dtype=compositions.dtype,
            device=compositions.device,
        )
    )
    points: list[BinaryBubblePointWithVolumes | BinaryVLEPoint] = []
    accepted: list[Tensor] = []
    separations: list[Tensor] = []
    physical_volume_points: list[BinaryBubblePointWithVolumes | BinaryVLEPointWithVolumes] = []
    previous: BinaryBubblePointWithVolumes | None = None
    failed_liquid: Tensor | None = None

    def physical_diagnostics(
        point: BinaryBubblePoint | BinaryVLEPoint,
    ) -> tuple[bool, Tensor]:
        separation = torch.max(torch.abs(point.vapor_composition - point.liquid_composition))
        physical = (
            point.converged
            and bool(point.residual_norm <= tolerance)
            and bool(separation > minimum_phase_separation)
        )
        return physical, separation

    def pressure_continuation_diagnostics(
        point: BinaryVLEPointWithVolumes,
        preceding_point: BinaryBubblePointWithVolumes | BinaryVLEPointWithVolumes,
    ) -> tuple[bool, Tensor]:
        physical, separation = physical_diagnostics(point)
        preceding_separation = torch.max(
            torch.abs(preceding_point.vapor_composition - preceding_point.liquid_composition)
        )
        branch_continuous = bool(
            separation >= minimum_pressure_step_separation_ratio * preceding_separation
        )
        return physical and branch_continuous, separation

    def pressure_predictor(
        target_pressure: Tensor,
        fallback: BinaryBubblePointWithVolumes | BinaryVLEPointWithVolumes,
    ) -> BinaryVLEPointWithVolumes:
        """Extrapolate the last two physical states in logarithmic pressure."""
        if len(physical_volume_points) < 2:
            return BinaryVLEPointWithVolumes(
                fallback.temperature,
                target_pressure,
                fallback.liquid_composition,
                fallback.vapor_composition,
                fallback.iterations,
                fallback.converged,
                fallback.residual_norm,
                fallback.liquid_molar_volume,
                fallback.vapor_molar_volume,
            )

        predecessor, current = physical_volume_points[-2:]
        log_pressure_step = torch.log(current.pressure) - torch.log(predecessor.pressure)
        if not bool(
            torch.isfinite(log_pressure_step)
            & (torch.abs(log_pressure_step) > torch.finfo(log_pressure_step.dtype).eps)
        ):
            factor = log_pressure_step.new_tensor(0.0)
        else:
            factor = (torch.log(target_pressure) - torch.log(current.pressure)) / log_pressure_step
            factor = torch.clamp(factor, 0.0, 2.0)

        epsilon = 32.0 * torch.finfo(current.liquid_composition.dtype).eps

        def logit(value: Tensor) -> Tensor:
            bounded = torch.clamp(value, epsilon, 1.0 - epsilon)
            return torch.log(bounded) - torch.log1p(-bounded)

        def extrapolate_composition(previous_composition: Tensor, composition: Tensor) -> Tensor:
            predicted_first = torch.sigmoid(
                logit(composition[0])
                + factor * (logit(composition[0]) - logit(previous_composition[0]))
            )
            return torch.stack((predicted_first, 1.0 - predicted_first))

        predicted_liquid = extrapolate_composition(
            predecessor.liquid_composition,
            current.liquid_composition,
        )
        predicted_vapor = extrapolate_composition(
            predecessor.vapor_composition,
            current.vapor_composition,
        )
        predicted_liquid_volume = torch.exp(
            torch.log(current.liquid_molar_volume)
            + factor
            * (torch.log(current.liquid_molar_volume) - torch.log(predecessor.liquid_molar_volume))
        )
        predicted_vapor_volume = torch.exp(
            torch.log(current.vapor_molar_volume)
            + factor
            * (torch.log(current.vapor_molar_volume) - torch.log(predecessor.vapor_molar_volume))
        )
        return BinaryVLEPointWithVolumes(
            current.temperature,
            target_pressure,
            predicted_liquid,
            predicted_vapor,
            current.iterations,
            current.converged,
            current.residual_norm,
            predicted_liquid_volume,
            predicted_vapor_volume,
        )

    for liquid in compositions:
        point = binary_helmholtz_bubble_point(
            model,
            temperature.to(dtype=liquid.dtype, device=liquid.device),
            liquid,
            initial_point=previous,
            minimum_pressure=minimum_pressure,
            maximum_pressure=maximum_pressure,
            tolerance=tolerance,
            max_iterations=max_iterations,
        )
        physical, separation = physical_diagnostics(point)
        points.append(point)
        accepted.append(liquid.new_tensor(physical, dtype=torch.bool))
        separations.append(separation)
        if physical:
            previous = point
            physical_volume_points.append(point)
        elif previous is not None and stop_on_failure:
            failed_liquid = liquid
            break
        if physical and maximum is not None and bool(point.pressure >= 0.995 * maximum):
            break

    if failed_liquid is not None and previous is not None:
        rejected_liquid = failed_liquid
        for _ in range(composition_failure_refinement_steps):
            candidate_liquid = normalize_composition(
                0.5 * (previous.liquid_composition + rejected_liquid)
            )
            point = binary_helmholtz_bubble_point(
                model,
                temperature.to(
                    dtype=candidate_liquid.dtype,
                    device=candidate_liquid.device,
                ),
                candidate_liquid,
                initial_point=previous,
                minimum_pressure=minimum_pressure,
                maximum_pressure=maximum_pressure,
                tolerance=tolerance,
                max_iterations=max_iterations,
            )
            physical, separation = physical_diagnostics(point)
            points.append(point)
            accepted.append(candidate_liquid.new_tensor(physical, dtype=torch.bool))
            separations.append(separation)
            if physical:
                previous = point
                physical_volume_points.append(point)
            else:
                rejected_liquid = candidate_liquid

    if (
        continue_in_pressure_on_failure
        and failed_liquid is not None
        and previous is not None
        and maximum is not None
        and bool(previous.pressure < 0.995 * maximum)
    ):
        start_pressure = torch.minimum(previous.pressure * 1.01, maximum)
        continuation_pressures = torch.exp(
            torch.linspace(
                torch.log(start_pressure),
                torch.log(maximum),
                pressure_continuation_points,
                dtype=compositions.dtype,
                device=compositions.device,
            )
        )
        pressure_initial_point: BinaryBubblePointWithVolumes | BinaryVLEPointWithVolumes = previous
        rejected_pressure: Tensor | None = None
        for pressure in continuation_pressures:
            predicted_initial_point = pressure_predictor(pressure, pressure_initial_point)
            vle_point = binary_helmholtz_vle_point(
                model,
                temperature.to(dtype=pressure.dtype, device=pressure.device),
                pressure,
                predicted_initial_point,
                tolerance=tolerance,
                max_iterations=max_iterations,
                minimum_phase_separation=minimum_phase_separation,
            )
            physical, separation = pressure_continuation_diagnostics(
                vle_point,
                pressure_initial_point,
            )
            points.append(vle_point)
            accepted.append(pressure.new_tensor(physical, dtype=torch.bool))
            separations.append(separation)
            if physical:
                pressure_initial_point = vle_point
                physical_volume_points.append(vle_point)
            elif stop_on_failure:
                rejected_pressure = pressure
                break
        if rejected_pressure is not None:
            for _ in range(pressure_failure_refinement_steps):
                candidate_pressure = torch.sqrt(pressure_initial_point.pressure * rejected_pressure)
                predicted_initial_point = pressure_predictor(
                    candidate_pressure,
                    pressure_initial_point,
                )
                vle_point = binary_helmholtz_vle_point(
                    model,
                    temperature.to(
                        dtype=candidate_pressure.dtype,
                        device=candidate_pressure.device,
                    ),
                    candidate_pressure,
                    predicted_initial_point,
                    tolerance=tolerance,
                    max_iterations=max_iterations,
                    minimum_phase_separation=minimum_phase_separation,
                )
                physical, separation = pressure_continuation_diagnostics(
                    vle_point,
                    pressure_initial_point,
                )
                points.append(vle_point)
                accepted.append(candidate_pressure.new_tensor(physical, dtype=torch.bool))
                separations.append(separation)
                if physical:
                    pressure_initial_point = vle_point
                    physical_volume_points.append(vle_point)
                else:
                    rejected_pressure = candidate_pressure

    return BinaryPxyIsotherm(
        temperature.to(dtype=compositions.dtype, device=compositions.device),
        torch.stack(tuple(point.pressure for point in points)),
        torch.stack(tuple(point.liquid_composition for point in points)),
        torch.stack(tuple(point.vapor_composition for point in points)),
        torch.tensor(
            [point.iterations for point in points],
            dtype=torch.int64,
            device=compositions.device,
        ),
        torch.stack(accepted),
        torch.stack(tuple(point.residual_norm for point in points)),
        torch.stack(separations),
    )

trace_binary_helmholtz_fixed_composition_boundary

trace_binary_helmholtz_fixed_composition_boundary(
    model: HelmholtzStateModel,
    temperatures: Tensor,
    overall_composition: Tensor,
    *,
    volatile_component_index: int = 1,
    reporting_pressure_limit: Tensor | float,
    maximum_pressure: Tensor | float,
    minimum_pressure: Tensor | float = 1000.0,
    minimum_volatile_liquid_fraction: float = 1e-05,
    transition_volatile_liquid_fraction: float = 0.05,
    lean_scan_points: int = 12,
    rich_scan_points: int = 15,
    tolerance: float = 1e-08,
    max_iterations: int = 25,
    minimum_phase_separation: float = 1e-05,
) -> BinaryFixedCompositionBoundary

Trace a binary two-phase pressure band at fixed overall composition.

At each temperature, this routine continues the bubble locus from a volatile-component-lean liquid toward the specified overall composition. The first crossing of y[volatile_component_index] through the overall fraction gives the lower (dew) boundary. The upper boundary is the bubble state at x == z or, beyond a critical composition, the return crossing of the vapor branch. Previous-temperature solutions initialize matching liquid-composition points.

Parameters:

Name Type Description Default
model HelmholtzStateModel

Two-component Helmholtz state model.

required
temperatures Tensor

Nonempty one-dimensional ordered temperature grid in K. Either increasing or decreasing order is accepted.

required
overall_composition Tensor

Strictly positive binary overall mole fractions.

required
volatile_component_index int

Component coordinate expected to be enriched in the vapor phase, either 0 or 1.

1
reporting_pressure_limit Tensor | float

Positive reporting limit in Pa. Upper boundaries beyond it are clipped and identified by bubble_above_reporting_limit.

required
maximum_pressure Tensor | float

Positive numerical bubble-solver ceiling in Pa, strictly greater than or equal to reporting_pressure_limit.

required
minimum_pressure Tensor | float

Positive numerical bubble-solver floor in Pa.

1000.0
minimum_volatile_liquid_fraction float

Positive starting mole fraction for the volatile-component scan.

1e-05
transition_volatile_liquid_fraction float

Positive fraction separating geometric lean-side spacing from linear spacing toward the overall composition.

0.05
lean_scan_points int

Number of geometric and linear scan points. Each must be at least two.

12
rich_scan_points int

Number of geometric and linear scan points. Each must be at least two.

12
tolerance float

Positive maximum absolute dimensionless equilibrium residual.

1e-08
max_iterations int

Positive maximum damped-Newton iterations per bubble point.

25
minimum_phase_separation float

Positive minimum y_volatile - x_volatile for an accepted state.

1e-05

Returns:

Type Description
BinaryFixedCompositionBoundary

Boundary pressures in Pa plus convergence, out-of-range, separation, and residual diagnostics on the requested temperature grid. Floating outputs preserve the input dtype and device.

Raises:

Type Description
ValueError

If state grids, compositions, pressure limits, scan settings, or nonlinear tolerances are invalid.

Notes

This is a fixed-composition specialization of bubble-locus continuation and crossing interpolation. The phase-equilibrium equations, Newton-Raphson solution, and continuation initial estimates follow Kunz, Klimeck, Wagner, and Jaeschke, The GERG-2004 Wide-Range Equation of State for Natural Gases and Other Mixtures, GERG Technical Monograph 15 (2007), Sec. 5.4.3 and Table 5.3, ISBN 978-3-18-355706-6. Log-pressure interpolation and pressure-limit clipping are numerical continuation choices made explicitly by torch-flash, not additional equilibrium equations. Discrete branch and clipping decisions are non-differentiable.

Source code in src/torch_flash/envelope.py
4628
4629
4630
4631
4632
4633
4634
4635
4636
4637
4638
4639
4640
4641
4642
4643
4644
4645
4646
4647
4648
4649
4650
4651
4652
4653
4654
4655
4656
4657
4658
4659
4660
4661
4662
4663
4664
4665
4666
4667
4668
4669
4670
4671
4672
4673
4674
4675
4676
4677
4678
4679
4680
4681
4682
4683
4684
4685
4686
4687
4688
4689
4690
4691
4692
4693
4694
4695
4696
4697
4698
4699
4700
4701
4702
4703
4704
4705
4706
4707
4708
4709
4710
4711
4712
4713
4714
4715
4716
4717
4718
4719
4720
4721
4722
4723
4724
4725
4726
4727
4728
4729
4730
4731
4732
4733
4734
4735
4736
4737
4738
4739
4740
4741
4742
4743
4744
4745
4746
4747
4748
4749
4750
4751
4752
4753
4754
4755
4756
4757
4758
4759
4760
4761
4762
4763
4764
4765
4766
4767
4768
4769
4770
4771
4772
4773
4774
4775
4776
4777
4778
4779
4780
4781
4782
4783
4784
4785
4786
4787
4788
4789
4790
4791
4792
4793
4794
4795
4796
4797
4798
4799
4800
4801
4802
4803
4804
4805
4806
4807
4808
4809
4810
4811
4812
4813
4814
4815
4816
4817
4818
4819
4820
4821
4822
4823
4824
4825
4826
4827
4828
4829
4830
4831
4832
4833
4834
4835
4836
4837
4838
4839
4840
4841
4842
4843
4844
4845
4846
4847
4848
4849
4850
4851
4852
4853
4854
4855
4856
4857
4858
4859
4860
4861
4862
4863
4864
4865
4866
4867
4868
4869
4870
4871
4872
4873
4874
4875
4876
4877
4878
4879
4880
4881
4882
4883
4884
4885
4886
4887
4888
4889
4890
4891
4892
4893
4894
4895
4896
4897
4898
4899
4900
4901
4902
4903
4904
4905
4906
4907
4908
4909
4910
4911
4912
4913
4914
4915
4916
4917
4918
4919
4920
4921
4922
4923
4924
4925
4926
4927
4928
4929
4930
4931
4932
4933
4934
4935
4936
4937
4938
4939
4940
4941
4942
4943
4944
4945
4946
4947
4948
4949
4950
4951
4952
4953
4954
4955
4956
4957
4958
4959
4960
4961
4962
4963
4964
4965
4966
4967
4968
4969
4970
4971
4972
4973
4974
4975
4976
4977
4978
4979
4980
4981
4982
4983
4984
4985
4986
4987
4988
4989
4990
4991
4992
4993
4994
4995
4996
4997
4998
4999
5000
5001
5002
5003
5004
5005
5006
5007
5008
5009
5010
5011
5012
5013
5014
5015
5016
5017
5018
5019
5020
5021
5022
5023
5024
5025
5026
5027
5028
5029
5030
5031
5032
5033
5034
5035
5036
5037
5038
5039
5040
5041
5042
5043
5044
5045
5046
5047
5048
5049
5050
5051
5052
5053
5054
5055
5056
5057
5058
5059
5060
5061
def trace_binary_helmholtz_fixed_composition_boundary(
    model: HelmholtzStateModel,
    temperatures: Tensor,
    overall_composition: Tensor,
    *,
    volatile_component_index: int = 1,
    reporting_pressure_limit: Tensor | float,
    maximum_pressure: Tensor | float,
    minimum_pressure: Tensor | float = 1.0e3,
    minimum_volatile_liquid_fraction: float = 1.0e-5,
    transition_volatile_liquid_fraction: float = 0.05,
    lean_scan_points: int = 12,
    rich_scan_points: int = 15,
    tolerance: float = 1.0e-8,
    max_iterations: int = 25,
    minimum_phase_separation: float = 1.0e-5,
) -> BinaryFixedCompositionBoundary:
    """Trace a binary two-phase pressure band at fixed overall composition.

    At each temperature, this routine continues the bubble locus from a
    volatile-component-lean liquid toward the specified overall composition.
    The first crossing of ``y[volatile_component_index]`` through the overall
    fraction gives the lower (dew) boundary. The upper boundary is the bubble
    state at ``x == z`` or, beyond a critical composition, the return crossing
    of the vapor branch. Previous-temperature solutions initialize matching
    liquid-composition points.

    Parameters
    ----------
    model
        Two-component Helmholtz state model.
    temperatures
        Nonempty one-dimensional ordered temperature grid in K. Either
        increasing or decreasing order is accepted.
    overall_composition
        Strictly positive binary overall mole fractions.
    volatile_component_index
        Component coordinate expected to be enriched in the vapor phase,
        either ``0`` or ``1``.
    reporting_pressure_limit
        Positive reporting limit in Pa. Upper boundaries beyond it are clipped
        and identified by ``bubble_above_reporting_limit``.
    maximum_pressure
        Positive numerical bubble-solver ceiling in Pa, strictly greater than
        or equal to ``reporting_pressure_limit``.
    minimum_pressure
        Positive numerical bubble-solver floor in Pa.
    minimum_volatile_liquid_fraction
        Positive starting mole fraction for the volatile-component scan.
    transition_volatile_liquid_fraction
        Positive fraction separating geometric lean-side spacing from linear
        spacing toward the overall composition.
    lean_scan_points, rich_scan_points
        Number of geometric and linear scan points. Each must be at least two.
    tolerance
        Positive maximum absolute dimensionless equilibrium residual.
    max_iterations
        Positive maximum damped-Newton iterations per bubble point.
    minimum_phase_separation
        Positive minimum ``y_volatile - x_volatile`` for an accepted state.

    Returns
    -------
    BinaryFixedCompositionBoundary
        Boundary pressures in Pa plus convergence, out-of-range, separation,
        and residual diagnostics on the requested temperature grid. Floating
        outputs preserve the input dtype and device.

    Raises
    ------
    ValueError
        If state grids, compositions, pressure limits, scan settings, or
        nonlinear tolerances are invalid.

    Notes
    -----
    This is a fixed-composition specialization of bubble-locus continuation
    and crossing interpolation. The phase-equilibrium equations,
    Newton-Raphson solution, and continuation initial estimates follow Kunz,
    Klimeck, Wagner, and Jaeschke, *The GERG-2004 Wide-Range Equation of State
    for Natural Gases and Other Mixtures*, GERG Technical Monograph 15 (2007),
    Sec. 5.4.3 and Table 5.3, ISBN 978-3-18-355706-6. Log-pressure
    interpolation and pressure-limit clipping are numerical continuation
    choices made explicitly by `torch-flash`, not additional equilibrium
    equations. Discrete branch and clipping decisions are non-differentiable.
    """
    if not temperatures.is_floating_point() or temperatures.ndim != 1 or temperatures.numel() < 1:
        raise ValueError("binary fixed-composition temperatures must be a nonempty float vector")
    if not bool(torch.isfinite(temperatures).all() & (temperatures > 0.0).all()):
        raise ValueError("binary fixed-composition temperatures must be finite and positive")
    if not overall_composition.is_floating_point():
        raise ValueError("binary fixed-composition overall composition must use a floating dtype")
    composition = normalize_composition(
        overall_composition.to(dtype=temperatures.dtype, device=temperatures.device)
    )
    if composition.shape != (2,) or not bool(
        torch.isfinite(composition).all() & (composition > 0.0).all()
    ):
        raise ValueError(
            "binary fixed-composition overall composition must contain two finite positive values"
        )
    if volatile_component_index not in (0, 1):
        raise ValueError("binary fixed-composition volatile_component_index must be 0 or 1")
    if lean_scan_points < 2 or rich_scan_points < 2:
        raise ValueError("binary fixed-composition scan point counts must each be at least two")
    if max_iterations < 1:
        raise ValueError("binary fixed-composition max_iterations must be positive")
    scalar_settings = (
        ("minimum_volatile_liquid_fraction", minimum_volatile_liquid_fraction),
        (
            "transition_volatile_liquid_fraction",
            transition_volatile_liquid_fraction,
        ),
        ("tolerance", tolerance),
        ("minimum_phase_separation", minimum_phase_separation),
    )
    for name, value in scalar_settings:
        if value <= 0.0 or not torch.isfinite(torch.tensor(value)):
            raise ValueError(f"binary fixed-composition {name} must be finite and positive")

    lower_pressure = torch.as_tensor(
        minimum_pressure,
        dtype=temperatures.dtype,
        device=temperatures.device,
    )
    reported_limit = torch.as_tensor(
        reporting_pressure_limit,
        dtype=temperatures.dtype,
        device=temperatures.device,
    )
    solver_limit = torch.as_tensor(
        maximum_pressure,
        dtype=temperatures.dtype,
        device=temperatures.device,
    )
    if any(
        value.ndim != 0 or not bool(torch.isfinite(value) & (value > 0.0))
        for value in (lower_pressure, reported_limit, solver_limit)
    ):
        raise ValueError("binary fixed-composition pressure limits must be finite positive scalars")
    if not bool(lower_pressure < reported_limit <= solver_limit):
        raise ValueError(
            "binary fixed-composition pressures require minimum < reporting <= maximum"
        )

    target_fraction = composition[volatile_component_index]
    if not bool(minimum_volatile_liquid_fraction < target_fraction):
        raise ValueError(
            "binary fixed-composition minimum volatile liquid fraction must be below "
            "the overall fraction"
        )
    transition = torch.minimum(
        target_fraction,
        target_fraction.new_tensor(transition_volatile_liquid_fraction),
    )
    transition = torch.maximum(
        transition,
        target_fraction.new_tensor(minimum_volatile_liquid_fraction),
    )
    lean_fractions = torch.exp(
        torch.linspace(
            torch.log(target_fraction.new_tensor(minimum_volatile_liquid_fraction)),
            torch.log(transition),
            lean_scan_points,
            dtype=temperatures.dtype,
            device=temperatures.device,
        )
    )
    if bool(target_fraction > transition):
        rich_fractions = torch.linspace(
            transition,
            target_fraction,
            rich_scan_points,
            dtype=temperatures.dtype,
            device=temperatures.device,
        )[1:]
        scan_fractions = torch.cat((lean_fractions, rich_fractions))
    else:
        scan_fractions = lean_fractions
        scan_fractions = torch.cat((scan_fractions[:-1], target_fraction.reshape(1)))

    temperature_points: list[BinaryBubblePointWithVolumes | None] = [None] * len(scan_fractions)
    branch_above_limit = False
    bubble_history: list[tuple[Tensor, Tensor]] = []
    output_rows: list[tuple[Tensor, Tensor, bool, bool, bool, bool, Tensor, Tensor, Tensor]] = []

    for temperature in temperatures:
        if len(bubble_history) == 2:
            previous_temperature, previous_log_pressure = bubble_history[-1]
            earlier_temperature, earlier_log_pressure = bubble_history[-2]
            temperature_step = previous_temperature - earlier_temperature
            if bool(temperature_step != 0.0):
                ratio = (temperature - previous_temperature) / temperature_step
                predicted_log_pressure = previous_log_pressure + ratio * (
                    previous_log_pressure - earlier_log_pressure
                )
                branch_above_limit = branch_above_limit or bool(
                    predicted_log_pressure >= torch.log(reported_limit)
                )

        previous_scan: _BoundaryScanPoint | None = None
        scan_point: BinaryBubblePointWithVolumes | None = None
        next_temperature_points: list[BinaryBubblePointWithVolumes | None] = [None] * len(
            scan_fractions
        )
        first_difference: Tensor | None = None
        nan = temperature.new_tensor(torch.nan)
        bubble_pressure = reported_limit if branch_above_limit else nan
        bubble_residual = nan
        bubble_separation = nan
        bubble_converged = False
        bubble_above_reporting_limit = branch_above_limit
        final_scan_pressure = nan
        dew_pressure = nan
        dew_residual = nan
        dew_converged = False

        for scan_index, liquid_fraction in enumerate(scan_fractions):
            liquid = (
                torch.stack((liquid_fraction, 1.0 - liquid_fraction))
                if volatile_component_index == 0
                else torch.stack((1.0 - liquid_fraction, liquid_fraction))
            )
            initial_point = temperature_points[scan_index]
            if initial_point is None:
                initial_point = scan_point
            scan = binary_helmholtz_bubble_point(
                model,
                temperature,
                liquid,
                initial_point=initial_point,
                minimum_pressure=lower_pressure,
                maximum_pressure=solver_limit,
                tolerance=tolerance,
                max_iterations=max_iterations,
            )
            phase_separation = scan.vapor_composition[volatile_component_index] - liquid_fraction
            invalid_scan = (
                not scan.converged
                or bool(scan.residual_norm > tolerance)
                or bool(phase_separation <= minimum_phase_separation)
            )
            if (
                invalid_scan
                and temperature_points[scan_index] is not None
                and scan_point is not None
            ):
                scan = binary_helmholtz_bubble_point(
                    model,
                    temperature,
                    liquid,
                    initial_point=scan_point,
                    minimum_pressure=lower_pressure,
                    maximum_pressure=solver_limit,
                    tolerance=tolerance,
                    max_iterations=max_iterations,
                )
                phase_separation = (
                    scan.vapor_composition[volatile_component_index] - liquid_fraction
                )
                invalid_scan = (
                    not scan.converged
                    or bool(scan.residual_norm > tolerance)
                    or bool(phase_separation <= minimum_phase_separation)
                )
            branch_terminated = (
                invalid_scan
                and scan_point is not None
                and previous_scan is not None
                and bool(previous_scan.difference > 0.0)
                and (
                    dew_converged or (first_difference is not None and bool(first_difference > 0.0))
                )
            )
            if branch_terminated:
                break
            if invalid_scan:
                pressure_seed = scan_point if scan_point is not None else initial_point
                scan = binary_helmholtz_bubble_point(
                    model,
                    temperature,
                    liquid,
                    initial_pressure=(None if pressure_seed is None else pressure_seed.pressure),
                    initial_vapor_composition=(
                        None if pressure_seed is None else pressure_seed.vapor_composition
                    ),
                    minimum_pressure=lower_pressure,
                    maximum_pressure=solver_limit,
                    tolerance=tolerance,
                    max_iterations=max_iterations,
                )
                phase_separation = (
                    scan.vapor_composition[volatile_component_index] - liquid_fraction
                )
                invalid_scan = (
                    not scan.converged
                    or bool(scan.residual_norm > tolerance)
                    or bool(phase_separation <= minimum_phase_separation)
                )
            at_target = scan_index == len(scan_fractions) - 1
            if at_target:
                final_scan_pressure = scan.pressure
            if invalid_scan:
                continue

            current_scan = _BoundaryScanPoint(
                scan.vapor_composition[volatile_component_index] - target_fraction,
                torch.log(scan.pressure),
                scan.residual_norm,
                liquid_fraction,
            )
            if first_difference is None:
                first_difference = current_scan.difference
            if (
                not dew_converged
                and previous_scan is not None
                and bool(previous_scan.difference <= 0.0)
                and bool(current_scan.difference >= 0.0)
            ):
                fraction = -previous_scan.difference / (
                    current_scan.difference - previous_scan.difference
                )
                log_pressure = previous_scan.log_pressure + fraction * (
                    current_scan.log_pressure - previous_scan.log_pressure
                )
                dew_pressure = torch.exp(log_pressure)
                dew_residual = torch.maximum(
                    previous_scan.residual,
                    current_scan.residual,
                )
                dew_converged = True
            elif (
                dew_converged
                and not bubble_converged
                and previous_scan is not None
                and bool(previous_scan.difference >= 0.0)
                and bool(current_scan.difference <= 0.0)
            ):
                fraction = previous_scan.difference / (
                    previous_scan.difference - current_scan.difference
                )
                log_pressure = previous_scan.log_pressure + fraction * (
                    current_scan.log_pressure - previous_scan.log_pressure
                )
                liquid_at_boundary = previous_scan.liquid_fraction + fraction * (
                    current_scan.liquid_fraction - previous_scan.liquid_fraction
                )
                final_scan_pressure = torch.exp(log_pressure)
                bubble_pressure = final_scan_pressure
                bubble_residual = torch.maximum(
                    previous_scan.residual,
                    current_scan.residual,
                )
                bubble_separation = target_fraction - liquid_at_boundary
                bubble_converged = True
            if at_target and not bubble_converged:
                bubble_pressure = final_scan_pressure
                bubble_residual = scan.residual_norm
                bubble_separation = phase_separation
                bubble_converged = True

            previous_scan = current_scan
            scan_point = scan
            next_temperature_points[scan_index] = scan
            positive_first_difference = first_difference is not None and bool(
                first_difference > 0.0
            )
            if branch_above_limit and (dew_converged or positive_first_difference):
                break
            if bool(scan.pressure >= reported_limit) and (
                dew_converged or positive_first_difference
            ):
                bubble_pressure = reported_limit
                bubble_above_reporting_limit = True
                branch_above_limit = True
                break
            if bubble_converged and not at_target:
                break

        dew_below_scan = (
            not dew_converged and first_difference is not None and bool(first_difference > 0.0)
        )
        if bool(final_scan_pressure >= reported_limit):
            bubble_above_reporting_limit = True
            bubble_pressure = reported_limit
            branch_above_limit = True
        elif bubble_converged:
            bubble_history.append((temperature, torch.log(final_scan_pressure)))
            bubble_history = bubble_history[-2:]
        output_rows.append(
            (
                bubble_pressure,
                dew_pressure,
                bubble_converged,
                bubble_above_reporting_limit,
                dew_converged,
                dew_below_scan,
                bubble_separation,
                bubble_residual,
                dew_residual,
            )
        )
        for scan_index, next_point in enumerate(next_temperature_points):
            if next_point is not None:
                temperature_points[scan_index] = next_point

    return BinaryFixedCompositionBoundary(
        temperatures,
        torch.stack(tuple(row[0] for row in output_rows)),
        torch.stack(tuple(row[1] for row in output_rows)),
        torch.tensor(
            [row[2] for row in output_rows],
            dtype=torch.bool,
            device=temperatures.device,
        ),
        torch.tensor(
            [row[3] for row in output_rows],
            dtype=torch.bool,
            device=temperatures.device,
        ),
        torch.tensor(
            [row[4] for row in output_rows],
            dtype=torch.bool,
            device=temperatures.device,
        ),
        torch.tensor(
            [row[5] for row in output_rows],
            dtype=torch.bool,
            device=temperatures.device,
        ),
        torch.stack(tuple(row[6] for row in output_rows)),
        torch.stack(tuple(row[7] for row in output_rows)),
        torch.stack(tuple(row[8] for row in output_rows)),
    )