Skip to content

API

torch_flash

Differentiable phase-equilibrium calculations built on PyTorch.

CubicFractionProperties dataclass

Critical properties and acentric factors for cubic EoS adapters.

Source code in src/torch_flash/characterization/cubic.py
@dataclass(frozen=True)
class CubicFractionProperties:
    """Critical properties and acentric factors for cubic EoS adapters."""

    critical_temperature: Tensor
    critical_pressure: Tensor
    acentric_factor: Tensor
    m: Tensor

LumpedDistribution dataclass

Contiguous pseudo-components produced by a lumping rule.

Source code in src/torch_flash/characterization/types.py
@dataclass(frozen=True)
class LumpedDistribution:
    """Contiguous pseudo-components produced by a lumping rule."""

    names: tuple[str, ...]
    carbon_number_bounds: tuple[tuple[int, int], ...]
    mole_fractions: Tensor
    molar_masses: Tensor
    densities: Tensor | None
    properties: dict[str, Tensor]

PseudoComponentCut dataclass

One measured or estimated heavy-end cut, using SI units.

Source code in src/torch_flash/characterization/types.py
@dataclass(frozen=True)
class PseudoComponentCut:
    """One measured or estimated heavy-end cut, using SI units."""

    name: str
    mole_fraction: float
    normal_boiling_temperature: float
    specific_gravity: float
    molar_mass: float

    def __post_init__(self) -> None:
        if not isinstance(self.name, str) or not self.name.strip():
            raise ValueError("pseudo-component cut name must be a non-empty string")
        values = (
            self.mole_fraction,
            self.normal_boiling_temperature,
            self.specific_gravity,
            self.molar_mass,
        )
        if any(not isfinite(value) for value in values):
            raise ValueError("pseudo-component cut values must be finite")
        if self.mole_fraction < 0.0:
            raise ValueError("pseudo-component cut mole fraction must be nonnegative")
        if any(value <= 0.0 for value in values[1:]):
            raise ValueError(
                "pseudo-component cut temperature, gravity, and molar mass must be positive"
            )

SCNDistribution dataclass

Discrete single-carbon-number representation of a plus fraction.

Source code in src/torch_flash/characterization/types.py
@dataclass(frozen=True)
class SCNDistribution:
    """Discrete single-carbon-number representation of a plus fraction."""

    carbon_numbers: Tensor
    mole_fractions: Tensor
    molar_masses: Tensor
    densities: Tensor | None = None

    def __post_init__(self) -> None:
        vectors = (self.carbon_numbers, self.mole_fractions, self.molar_masses)
        if any(value.ndim != 1 for value in vectors):
            raise ValueError("SCN distribution values must be one-dimensional")
        if not self.carbon_numbers.numel() or any(
            value.shape != self.carbon_numbers.shape for value in vectors[1:]
        ):
            raise ValueError("SCN distribution vectors must have the same nonzero length")
        if self.densities is not None and self.densities.shape != self.carbon_numbers.shape:
            raise ValueError("SCN densities must match the carbon-number vector")
        continuous = [self.mole_fractions, self.molar_masses]
        if self.densities is not None:
            continuous.append(self.densities)
        if any(not bool(torch.isfinite(value).all()) for value in continuous):
            raise ValueError("SCN distribution values must be finite")
        if bool((self.mole_fractions < 0.0).any()) or not bool(self.mole_fractions.sum() > 0.0):
            raise ValueError("SCN mole fractions must be nonnegative with a positive sum")
        if bool((self.molar_masses <= 0.0).any()):
            raise ValueError("SCN molar masses must be positive")
        if self.densities is not None and bool((self.densities <= 0.0).any()):
            raise ValueError("SCN densities must be positive")
        if self.carbon_numbers.numel() > 1 and not bool(
            (self.carbon_numbers[1:] > self.carbon_numbers[:-1]).all()
        ):
            raise ValueError("SCN carbon numbers must be strictly increasing")

    @property
    def total_mole_fraction(self) -> Tensor:
        """Return the total mole fraction represented by the distribution."""
        return self.mole_fractions.sum()

    @property
    def average_molar_mass(self) -> Tensor:
        """Return the mole-average molar mass in kg/mol."""
        return torch.sum(self.mole_fractions * self.molar_masses) / self.total_mole_fraction

    @property
    def bulk_density(self) -> Tensor | None:
        """Return ideal-volume-mixed bulk density in kg/m3, when available."""
        if self.densities is None:
            return None
        mass = self.mole_fractions * self.molar_masses
        return mass.sum() / torch.sum(mass / self.densities)

    def to(
        self,
        *,
        dtype: torch.dtype | None = None,
        device: torch.device | str | None = None,
    ) -> SCNDistribution:
        """Move continuous values to a common dtype/device."""
        return SCNDistribution(
            self.carbon_numbers.to(device=device),
            self.mole_fractions.to(dtype=dtype, device=device),
            self.molar_masses.to(dtype=dtype, device=device),
            None if self.densities is None else self.densities.to(dtype=dtype, device=device),
        )

total_mole_fraction property

total_mole_fraction

Return the total mole fraction represented by the distribution.

average_molar_mass property

average_molar_mass

Return the mole-average molar mass in kg/mol.

bulk_density property

bulk_density

Return ideal-volume-mixed bulk density in kg/m3, when available.

to

to(*, dtype=None, device=None)

Move continuous values to a common dtype/device.

Source code in src/torch_flash/characterization/types.py
def to(
    self,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> SCNDistribution:
    """Move continuous values to a common dtype/device."""
    return SCNDistribution(
        self.carbon_numbers.to(device=device),
        self.mole_fractions.to(dtype=dtype, device=device),
        self.molar_masses.to(dtype=dtype, device=device),
        None if self.densities is None else self.densities.to(dtype=dtype, device=device),
    )

Component dataclass

Shared pure-component properties in SI units.

acentric_factor may be unavailable for components whose bundled use is restricted to a model-specific multifluid equation. Such a record can be resolved by name but cannot be used to construct a cubic EoS until the user supplies a complete custom component database or :class:ComponentSet.

Source code in src/torch_flash/components.py
@dataclass(frozen=True)
class Component:
    """Shared pure-component properties in SI units.

    ``acentric_factor`` may be unavailable for components whose bundled use is
    restricted to a model-specific multifluid equation. Such a record can be
    resolved by name but cannot be used to construct a cubic EoS until the user
    supplies a complete custom component database or :class:`ComponentSet`.
    """

    name: str
    critical_temperature: float
    critical_pressure: float
    acentric_factor: float | None
    molar_mass: float
    critical_volume: float | None = None
    aliases: tuple[str, ...] = ()
    critical_density: float | None = None

ComponentDatabase dataclass

Validated canonical component database loaded from YAML.

Source code in src/torch_flash/components.py
@dataclass(frozen=True)
class ComponentDatabase:
    """Validated canonical component database loaded from YAML."""

    identifier: str
    revision: str
    components: tuple[Component, ...]
    units: Mapping[str, str]
    references: tuple[Mapping[str, str], ...] = ()
    source: str = "<api>"

    def __post_init__(self) -> None:
        if not self.identifier or not self.revision:
            raise ParameterDatabaseError("component database id and revision must be non-empty")
        if not self.components:
            raise ParameterDatabaseError("component database must contain at least one component")
        for property_name, expected in _EXPECTED_UNITS.items():
            if self.units.get(property_name) != expected:
                raise ParameterDatabaseError(
                    f"component database unit for {property_name!r} must be {expected!r}"
                )
        identifiers: set[str] = set()
        for record in self.components:
            if _normalize_name(record.name) != record.name:
                raise ParameterDatabaseError(
                    f"component name {record.name!r} must be canonical lowercase snake case"
                )
            current = (record.name, *(_normalize_name(alias) for alias in record.aliases))
            duplicate = next((name for name in current if name in identifiers), None)
            if duplicate is not None:
                raise ParameterDatabaseError(
                    f"component database contains duplicate name or alias {duplicate!r}"
                )
            identifiers.update(current)
            positive = (
                record.critical_temperature,
                record.critical_pressure,
                record.molar_mass,
            )
            if any(not math.isfinite(value) or value <= 0.0 for value in positive):
                raise ParameterDatabaseError(
                    f"component {record.name!r} requires finite positive Tcrit, Pcrit, and mass"
                )
            optional = (
                record.critical_volume,
                record.critical_density,
            )
            if any(
                value is not None and (not math.isfinite(value) or value <= 0.0)
                for value in optional
            ):
                raise ParameterDatabaseError(
                    f"component {record.name!r} optional volume/density must be positive"
                )
            if record.acentric_factor is not None and not math.isfinite(record.acentric_factor):
                raise ParameterDatabaseError(
                    f"component {record.name!r} acentric factor must be finite or null"
                )
        object.__setattr__(self, "units", MappingProxyType(dict(self.units)))
        object.__setattr__(
            self,
            "references",
            tuple(MappingProxyType(dict(reference)) for reference in self.references),
        )

    @property
    def names(self) -> tuple[str, ...]:
        """Canonical component names in database order."""
        return tuple(record.name for record in self.components)

    def lookup(self, name: str) -> Component:
        """Resolve a canonical name or alias."""
        normalized = _normalize_name(name)
        aliases: dict[str, Component] = {}
        for record in self.components:
            aliases[_normalize_name(record.name)] = record
            for alias in record.aliases:
                aliases[_normalize_name(alias)] = record
        try:
            return aliases[normalized]
        except KeyError as exc:
            available = ", ".join(sorted(self.names))
            raise KeyError(f"unknown component {name!r}; available: {available}") from exc

names property

names

Canonical component names in database order.

lookup

lookup(name)

Resolve a canonical name or alias.

Source code in src/torch_flash/components.py
def lookup(self, name: str) -> Component:
    """Resolve a canonical name or alias."""
    normalized = _normalize_name(name)
    aliases: dict[str, Component] = {}
    for record in self.components:
        aliases[_normalize_name(record.name)] = record
        for alias in record.aliases:
            aliases[_normalize_name(alias)] = record
    try:
        return aliases[normalized]
    except KeyError as exc:
        available = ", ".join(sorted(self.names))
        raise KeyError(f"unknown component {name!r}; available: {available}") from exc

ComponentSet dataclass

Vectorized component constants for use by PyTorch models.

Source code in src/torch_flash/components.py
@dataclass(frozen=True)
class ComponentSet:
    """Vectorized component constants for use by PyTorch models."""

    names: tuple[str, ...]
    critical_temperature: Tensor
    critical_pressure: Tensor
    acentric_factor: Tensor
    molar_mass: Tensor
    critical_volume: Tensor | None = None
    critical_density: Tensor | None = None

    @property
    def ncomponents(self) -> int:
        """Number of components."""
        return len(self.names)

    def to(
        self,
        *,
        dtype: torch.dtype | None = None,
        device: torch.device | str | None = None,
    ) -> ComponentSet:
        """Move all tensors to a common dtype and device."""
        return ComponentSet(
            self.names,
            self.critical_temperature.to(dtype=dtype, device=device),
            self.critical_pressure.to(dtype=dtype, device=device),
            self.acentric_factor.to(dtype=dtype, device=device),
            self.molar_mass.to(dtype=dtype, device=device),
            (
                None
                if self.critical_volume is None
                else self.critical_volume.to(dtype=dtype, device=device)
            ),
            (
                None
                if self.critical_density is None
                else self.critical_density.to(dtype=dtype, device=device)
            ),
        )

ncomponents property

ncomponents

Number of components.

to

to(*, dtype=None, device=None)

Move all tensors to a common dtype and device.

Source code in src/torch_flash/components.py
def to(
    self,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> ComponentSet:
    """Move all tensors to a common dtype and device."""
    return ComponentSet(
        self.names,
        self.critical_temperature.to(dtype=dtype, device=device),
        self.critical_pressure.to(dtype=dtype, device=device),
        self.acentric_factor.to(dtype=dtype, device=device),
        self.molar_mass.to(dtype=dtype, device=device),
        (
            None
            if self.critical_volume is None
            else self.critical_volume.to(dtype=dtype, device=device)
        ),
        (
            None
            if self.critical_density is None
            else self.critical_density.to(dtype=dtype, device=device)
        ),
    )

RuntimeConfig dataclass

Immutable snapshot of torch-flash's tensor and execution policy.

Parameters:

Name Type Description Default
device device

Device used by named model, component, standard-state, and characterization factories when no explicit device is supplied.

required
dtype dtype

Floating-point dtype used by those factories. Float64 is the scientific default; float32 is available for explicit lower-precision studies.

required
num_threads int

Current PyTorch CPU intra-operation thread count.

required
num_interop_threads int

Current PyTorch CPU inter-operation thread count.

required
deterministic bool

Whether PyTorch deterministic algorithms are enabled.

required
deterministic_warn_only bool

Whether unavailable deterministic algorithms warn instead of raising.

required
Source code in src/torch_flash/config.py
@dataclass(frozen=True, slots=True)
class RuntimeConfig:
    """Immutable snapshot of torch-flash's tensor and execution policy.

    Parameters
    ----------
    device:
        Device used by named model, component, standard-state, and
        characterization factories when no explicit device is supplied.
    dtype:
        Floating-point dtype used by those factories. Float64 is the
        scientific default; float32 is available for explicit lower-precision
        studies.
    num_threads:
        Current PyTorch CPU intra-operation thread count.
    num_interop_threads:
        Current PyTorch CPU inter-operation thread count.
    deterministic:
        Whether PyTorch deterministic algorithms are enabled.
    deterministic_warn_only:
        Whether unavailable deterministic algorithms warn instead of raising.
    """

    device: torch.device
    dtype: torch.dtype
    num_threads: int
    num_interop_threads: int
    deterministic: bool
    deterministic_warn_only: bool

    @property
    def accelerated(self) -> bool:
        """Whether tensors are configured for a non-CPU accelerator."""
        return bool(self.device.type != "cpu")

    @property
    def tensor_options(self) -> dict[str, torch.dtype | torch.device]:
        """Return keyword arguments for PyTorch tensor factory functions."""
        return {"dtype": self.dtype, "device": self.device}

    def tensor(
        self,
        data: Any,
        *,
        dtype: torch.dtype | None = None,
        device: torch.device | str | None = None,
        requires_grad: bool = False,
    ) -> Tensor:
        """Construct a copied tensor using this runtime policy by default."""
        return torch.tensor(
            data,
            dtype=self.dtype if dtype is None else dtype,
            device=self.device if device is None else device,
            requires_grad=requires_grad,
        )

    def as_tensor(
        self,
        data: Any,
        *,
        dtype: torch.dtype | None = None,
        device: torch.device | str | None = None,
    ) -> Tensor:
        """Convert data using this runtime policy without unnecessary copies."""
        return torch.as_tensor(
            data,
            dtype=self.dtype if dtype is None else dtype,
            device=self.device if device is None else device,
        )

accelerated property

accelerated

Whether tensors are configured for a non-CPU accelerator.

tensor_options property

tensor_options

Return keyword arguments for PyTorch tensor factory functions.

tensor

tensor(data, *, dtype=None, device=None, requires_grad=False)

Construct a copied tensor using this runtime policy by default.

Source code in src/torch_flash/config.py
def tensor(
    self,
    data: Any,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    requires_grad: bool = False,
) -> Tensor:
    """Construct a copied tensor using this runtime policy by default."""
    return torch.tensor(
        data,
        dtype=self.dtype if dtype is None else dtype,
        device=self.device if device is None else device,
        requires_grad=requires_grad,
    )

as_tensor

as_tensor(data, *, dtype=None, device=None)

Convert data using this runtime policy without unnecessary copies.

Source code in src/torch_flash/config.py
def as_tensor(
    self,
    data: Any,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> Tensor:
    """Convert data using this runtime policy without unnecessary copies."""
    return torch.as_tensor(
        data,
        dtype=self.dtype if dtype is None else dtype,
        device=self.device if device is None else device,
    )

ModelParameterSet dataclass

Validated model parameter set independent of tensor dtype and device.

Parameters can be supplied directly through this dataclass, read from a custom YAML path, or selected from the bundled database by identifier. The stored mappings are recursively read-only. Use :meth:as_dict when a mutable deep copy is needed.

Source code in src/torch_flash/database.py
@dataclass(frozen=True)
class ModelParameterSet:
    """Validated model parameter set independent of tensor dtype and device.

    Parameters can be supplied directly through this dataclass, read from a
    custom YAML path, or selected from the bundled database by identifier.
    The stored mappings are recursively read-only. Use :meth:`as_dict` when a
    mutable deep copy is needed.
    """

    identifier: str
    model_kind: str
    model: str
    version: str
    parameters: ParameterDocument
    units: ParameterDocument = field(default_factory=dict)
    references: tuple[ParameterDocument, ...] = ()
    description: str = ""
    source: str = "<api>"

    def __post_init__(self) -> None:
        for field_name in ("identifier", "model_kind", "model", "version"):
            value = getattr(self, field_name)
            if not isinstance(value, str) or not value.strip():
                raise ParameterDatabaseError(f"{field_name} must be a non-empty string")
        if _IDENTIFIER_PATTERN.fullmatch(self.identifier) is None:
            raise ParameterDatabaseError(
                "identifier must contain only lowercase letters, digits, '.', '_', or '-'"
            )
        if self.model_kind not in _MODEL_KINDS:
            raise ParameterDatabaseError(
                f"model_kind must be one of: {', '.join(sorted(_MODEL_KINDS))}"
            )
        if not isinstance(self.parameters, Mapping):
            raise ParameterDatabaseError("parameters must be a mapping")
        if not isinstance(self.units, Mapping):
            raise ParameterDatabaseError("units must be a mapping")
        if any(
            not isinstance(key, str) or not isinstance(value, str) or not key or not value
            for key, value in self.units.items()
        ):
            raise ParameterDatabaseError("unit names and values must be non-empty strings")
        if not isinstance(self.references, Sequence) or isinstance(self.references, str):
            raise ParameterDatabaseError("references must be a sequence of mappings")
        if any(not isinstance(reference, Mapping) for reference in self.references):
            raise ParameterDatabaseError("each reference must be a mapping")
        if any(
            not isinstance(key, str) or not isinstance(value, str)
            for reference in self.references
            for key, value in reference.items()
        ):
            raise ParameterDatabaseError("reference names and values must be strings")
        if not isinstance(self.description, str):
            raise ParameterDatabaseError("description must be a string")
        object.__setattr__(self, "parameters", _freeze(self.parameters))
        object.__setattr__(self, "units", _freeze(self.units))
        object.__setattr__(
            self,
            "references",
            tuple(_freeze(reference) for reference in self.references),
        )

    @classmethod
    def from_document(
        cls,
        document: Mapping[str, Any],
        *,
        source: str = "<api>",
    ) -> ModelParameterSet:
        """Validate a model-parameter YAML document represented as a mapping."""
        _validate_header(document, _MODEL_FORMAT, source)
        parameters = document.get("parameters")
        if not isinstance(parameters, Mapping):
            raise ParameterDatabaseError(f"{source} requires a 'parameters' mapping")
        units = document.get("units", {})
        references = document.get("references", [])
        if not isinstance(units, Mapping):
            raise ParameterDatabaseError(f"{source} requires 'units' to be a mapping")
        if not isinstance(references, list):
            raise ParameterDatabaseError(f"{source} requires 'references' to be a list")
        description = document.get("description", "")
        if not isinstance(description, str):
            raise ParameterDatabaseError(f"{source} requires 'description' to be a string")
        return cls(
            identifier=_require_string(document, "id", source),
            model_kind=_require_string(document, "model_kind", source),
            model=_require_string(document, "model", source),
            version=_require_string(document, "version", source),
            parameters=parameters,
            units=units,
            references=tuple(cast(list[ParameterDocument], references)),
            description=description,
            source=source,
        )

    def as_dict(self) -> dict[str, Any]:
        """Return a mutable deep copy of the numerical parameter payload."""
        return cast(dict[str, Any], _thaw(self.parameters))

from_document classmethod

from_document(document, *, source='<api>')

Validate a model-parameter YAML document represented as a mapping.

Source code in src/torch_flash/database.py
@classmethod
def from_document(
    cls,
    document: Mapping[str, Any],
    *,
    source: str = "<api>",
) -> ModelParameterSet:
    """Validate a model-parameter YAML document represented as a mapping."""
    _validate_header(document, _MODEL_FORMAT, source)
    parameters = document.get("parameters")
    if not isinstance(parameters, Mapping):
        raise ParameterDatabaseError(f"{source} requires a 'parameters' mapping")
    units = document.get("units", {})
    references = document.get("references", [])
    if not isinstance(units, Mapping):
        raise ParameterDatabaseError(f"{source} requires 'units' to be a mapping")
    if not isinstance(references, list):
        raise ParameterDatabaseError(f"{source} requires 'references' to be a list")
    description = document.get("description", "")
    if not isinstance(description, str):
        raise ParameterDatabaseError(f"{source} requires 'description' to be a string")
    return cls(
        identifier=_require_string(document, "id", source),
        model_kind=_require_string(document, "model_kind", source),
        model=_require_string(document, "model", source),
        version=_require_string(document, "version", source),
        parameters=parameters,
        units=units,
        references=tuple(cast(list[ParameterDocument], references)),
        description=description,
        source=source,
    )

as_dict

as_dict()

Return a mutable deep copy of the numerical parameter payload.

Source code in src/torch_flash/database.py
def as_dict(self) -> dict[str, Any]:
    """Return a mutable deep copy of the numerical parameter payload."""
    return cast(dict[str, Any], _thaw(self.parameters))

BinaryBubblePoint dataclass

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

Source code in src/torch_flash/envelope.py
@dataclass(frozen=True)
class BinaryBubblePoint:
    """Binary bubble pressure and incipient-vapor composition at specified ``T, x``."""

    temperature: Tensor
    pressure: Tensor
    liquid_composition: Tensor
    vapor_composition: Tensor
    iterations: int
    converged: bool
    residual_norm: Tensor

BinaryCriticalPoint dataclass

Binary mixture critical point at a specified overall composition.

Source code in src/torch_flash/envelope.py
@dataclass(frozen=True)
class BinaryCriticalPoint:
    """Binary mixture critical point at a specified overall composition."""

    temperature: Tensor
    pressure: Tensor
    molar_volume: Tensor
    composition: Tensor
    iterations: int
    converged: bool
    residual_norm: Tensor

BinaryPhaseEquilibriumPoint dataclass

Coexisting binary compositions for any requested pair of phase roots.

Source code in src/torch_flash/envelope.py
@dataclass(frozen=True)
class BinaryPhaseEquilibriumPoint:
    """Coexisting binary compositions for any requested pair of phase roots."""

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

BinaryVLEPoint dataclass

Coexisting binary phase compositions at specified temperature and pressure.

Source code in src/torch_flash/envelope.py
@dataclass(frozen=True)
class BinaryVLEPoint:
    """Coexisting binary phase compositions at specified temperature and pressure."""

    temperature: Tensor
    pressure: Tensor
    liquid_composition: Tensor
    vapor_composition: Tensor
    iterations: int
    converged: bool
    residual_norm: Tensor

SaturationPoint dataclass

One bubble- or dew-point solution.

Source code in src/torch_flash/envelope.py
@dataclass(frozen=True)
class SaturationPoint:
    """One bubble- or dew-point solution."""

    temperature: Tensor
    pressure: Tensor
    incipient_composition: Tensor
    k_values: Tensor
    kind: SaturationKind
    iterations: int
    converged: bool
    residual_norm: Tensor

CPAEOS

Bases: Module

SRK cubic-plus-association mixture model.

Source code in src/torch_flash/eos/cpa.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
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
class CPAEOS(nn.Module):
    """SRK cubic-plus-association mixture model."""

    critical_temperature: Tensor
    critical_pressure: Tensor
    acentric_factor: Tensor
    molar_mass: Tensor
    a0: Tensor
    b: Tensor
    c1: Tensor
    association_energy: Tensor
    association_volume: Tensor
    site_types: Tensor
    cross_association_mask: Tensor
    cross_association_energy: Tensor
    cross_association_volume: Tensor
    mixing: QuadraticMixing | TemperatureDependentQuadraticMixing

    def __init__(
        self,
        parameters: tuple[CPAComponent, ...],
        *,
        kij: Tensor | None = None,
        kij_a: Tensor | None = None,
        kij_b: Tensor | None = None,
        lij: Tensor | None = None,
        cross_association_energy: Tensor | None = None,
        cross_association_volume: Tensor | None = None,
        combining_rule: CombiningRule = "ECR",
        trainable: bool = False,
        trainable_lij: bool = False,
        association_iterations: int = 10,
        association_newton_iterations: int = 8,
        dtype: torch.dtype | None = None,
        device: torch.device | str | None = None,
    ) -> None:
        super().__init__()
        if not parameters:
            raise ValueError("CPA requires at least one component")
        if combining_rule not in ("CR1", "ECR"):
            raise ValueError(f"unknown CPA combining rule {combining_rule!r}")
        if association_iterations < 0 or association_newton_iterations < 0:
            raise ValueError("CPA association iteration counts must be nonnegative")
        if kij is not None and (kij_a is not None or kij_b is not None):
            raise ValueError("pass either fixed kij or temperature-dependent kij A/B, not both")
        if (kij_a is None) != (kij_b is None):
            raise ValueError("temperature-dependent CPA interactions require both kij_a and kij_b")
        if (cross_association_energy is None) != (cross_association_volume is None):
            raise ValueError("cross-association overrides require both energy and volume matrices")
        self.names = tuple(item.name for item in parameters)
        self.combining_rule = combining_rule
        self.association_iterations = association_iterations
        self.association_newton_iterations = association_newton_iterations
        supplied_tensors = tuple(
            value
            for value in (
                kij,
                kij_a,
                kij_b,
                lij,
                cross_association_energy,
                cross_association_volume,
            )
            if value is not None
        )
        runtime_dtype, runtime_device = resolve_tensor_options(dtype, device)
        dtype = supplied_tensors[0].dtype if dtype is None and supplied_tensors else runtime_dtype
        device = (
            supplied_tensors[0].device if device is None and supplied_tensors else runtime_device
        )

        def tensor(values: list[float]) -> Tensor:
            return torch.tensor(values, dtype=dtype, device=device)

        numeric_rows = (
            (
                item.critical_temperature,
                item.a0,
                item.b,
                item.c1,
                item.association_energy,
                item.association_volume,
            )
            for item in parameters
        )
        if any(
            not all(torch.isfinite(torch.tensor(row)))
            or row[0] <= 0.0
            or row[1] <= 0.0
            or row[2] <= 0.0
            or row[4] < 0.0
            or row[5] < 0.0
            for row in numeric_rows
        ):
            raise ValueError(
                "CPA temperatures, a0, and b must be positive; association parameters "
                "must be finite and nonnegative"
            )
        self.register_buffer(
            "critical_temperature",
            tensor([item.critical_temperature for item in parameters]),
        )
        fitted_parameters = {
            "a0": tensor([item.a0 for item in parameters]),
            "b": tensor([item.b for item in parameters]),
            "c1": tensor([item.c1 for item in parameters]),
            "association_energy": tensor([item.association_energy for item in parameters]),
            "association_volume": tensor([item.association_volume for item in parameters]),
        }
        for name, value in fitted_parameters.items():
            if trainable:
                setattr(self, name, nn.Parameter(value))
            else:
                self.register_buffer(name, value)
        self.register_buffer(
            "site_types",
            torch.tensor(
                [_site_types(item.scheme) for item in parameters],
                dtype=torch.int64,
                device=device,
            ),
        )

        critical_pressure: list[float] = []
        acentric_factor: list[float] = []
        molar_mass: list[float] = []
        for item in parameters:
            if (
                item.critical_pressure is None
                or item.acentric_factor is None
                or item.molar_mass is None
            ):
                try:
                    shared = component(item.name)
                except KeyError as exc:
                    raise ParameterDatabaseError(
                        f"custom CPA component {item.name!r} must provide critical_pressure, "
                        "acentric_factor, and molar_mass"
                    ) from exc
                shared_pressure = shared.critical_pressure
                shared_acentric = shared.acentric_factor
                shared_mass = shared.molar_mass
            else:
                shared_pressure = item.critical_pressure
                shared_acentric = item.acentric_factor
                shared_mass = item.molar_mass
            resolved_pressure = (
                item.critical_pressure if item.critical_pressure is not None else shared_pressure
            )
            resolved_acentric = (
                item.acentric_factor if item.acentric_factor is not None else shared_acentric
            )
            resolved_mass = item.molar_mass if item.molar_mass is not None else shared_mass
            if resolved_acentric is None:
                raise ParameterDatabaseError(
                    f"CPA component {item.name!r} requires an acentric factor"
                )
            if (
                not torch.isfinite(torch.tensor(resolved_pressure))
                or not torch.isfinite(torch.tensor(resolved_acentric))
                or not torch.isfinite(torch.tensor(resolved_mass))
                or resolved_pressure <= 0.0
                or resolved_mass <= 0.0
            ):
                raise ValueError("CPA critical pressure and molar mass must be finite and positive")
            critical_pressure.append(float(resolved_pressure))
            acentric_factor.append(float(resolved_acentric))
            molar_mass.append(float(resolved_mass))
        self.register_buffer("critical_pressure", tensor(critical_pressure))
        self.register_buffer("acentric_factor", tensor(acentric_factor))
        self.register_buffer("molar_mass", tensor(molar_mass))

        size = len(parameters)
        if kij_a is not None and kij_b is not None:
            self.mixing = TemperatureDependentQuadraticMixing(
                kij_a.to(dtype=dtype, device=device),
                kij_b.to(dtype=dtype, device=device),
                None if lij is None else lij.to(dtype=dtype, device=device),
                trainable=trainable,
                trainable_lij=trainable_lij,
            )
        else:
            fixed_kij = (
                torch.zeros((size, size), dtype=dtype, device=device)
                if kij is None
                else kij.to(dtype=dtype, device=device)
            )
            self.mixing = QuadraticMixing(
                fixed_kij,
                None if lij is None else lij.to(dtype=dtype, device=device),
                trainable=trainable,
                trainable_lij=trainable_lij,
            )

        if cross_association_energy is None or cross_association_volume is None:
            cross_mask = torch.zeros((size, size), dtype=torch.bool, device=device)
            cross_energy = torch.zeros((size, size), dtype=dtype, device=device)
            cross_volume = torch.zeros((size, size), dtype=dtype, device=device)
        else:
            supplied_energy = cross_association_energy.to(dtype=dtype, device=device)
            supplied_volume = cross_association_volume.to(dtype=dtype, device=device)
            if supplied_energy.shape != (size, size) or supplied_volume.shape != (size, size):
                raise ValueError("cross-association matrices must match the CPA component count")
            energy_mask = torch.isfinite(supplied_energy)
            volume_mask = torch.isfinite(supplied_volume)
            if not torch.equal(energy_mask, volume_mask):
                raise ValueError("cross-association energy and volume masks must match")
            if not torch.equal(energy_mask, energy_mask.mT):
                raise ValueError("cross-association overrides must be symmetric")
            if bool(torch.diagonal(energy_mask).any()):
                raise ValueError("cross-association overrides cannot replace pure-component pairs")
            cross_mask = energy_mask
            cross_energy = torch.where(
                energy_mask, supplied_energy, torch.zeros_like(supplied_energy)
            )
            cross_volume = torch.where(
                volume_mask, supplied_volume, torch.zeros_like(supplied_volume)
            )
            if (
                not torch.allclose(cross_energy, cross_energy.mT)
                or not torch.allclose(cross_volume, cross_volume.mT)
                or bool((cross_energy[cross_mask] <= 0.0).any())
                or bool((cross_volume[cross_mask] <= 0.0).any())
            ):
                raise ValueError(
                    "cross-association energies and volumes must be symmetric and positive"
                )
        self.register_buffer("cross_association_mask", cross_mask)
        if trainable:
            self.cross_association_energy = nn.Parameter(cross_energy)
            self.cross_association_volume = nn.Parameter(cross_volume)
        else:
            self.register_buffer("cross_association_energy", cross_energy)
            self.register_buffer("cross_association_volume", cross_volume)

    @property
    def ncomponents(self) -> int:
        """Number of mixture components."""
        return len(self.names)

    def pure_parameters(self, temperature: Tensor) -> tuple[Tensor, Tensor]:
        """Return CPA's fitted SRK energy and covolume parameters."""
        reduced = temperature[..., None] / self.critical_temperature
        a = self.a0 * (1.0 + self.c1 * (1.0 - torch.sqrt(reduced))).square()
        return a, self.b

    def mixture_parameters(self, temperature: Tensor, composition: Tensor) -> tuple[Tensor, Tensor]:
        """Return conventionally mixed physical-term parameters."""
        pure_a, pure_b = self.pure_parameters(temperature)
        result: tuple[Tensor, Tensor] = self.mixing(temperature, composition, pure_a, pure_b)
        return result

    def binary_interaction(self, temperature: Tensor) -> Tensor:
        """Return the symmetric physical-term ``kij`` matrix at ``temperature``."""
        if isinstance(self.mixing, TemperatureDependentQuadraticMixing):
            return self.mixing.kij(temperature)
        return self.mixing.kij

    def association_strength(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return ``Delta[i, A, j, B]`` association strengths in m3/mol."""
        x = normalize_composition(composition)
        _, bm = self.mixture_parameters(temperature, x)
        packing_fraction = 0.25 * bm * molar_density
        radial_distribution = 1.0 / (1.0 - 1.9 * packing_fraction)
        bij = 0.5 * (self.b[:, None] + self.b[None, :])
        temperature_pair = temperature[..., None, None]
        if self.combining_rule == "CR1":
            epsilon = 0.5 * (self.association_energy[:, None] + self.association_energy[None, :])
            beta = torch.sqrt(self.association_volume[:, None] * self.association_volume[None, :])
            pair_strength = (
                radial_distribution[..., None, None]
                * torch.expm1(epsilon / (R * temperature_pair))
                * bij
                * beta
            )
        elif self.combining_rule == "ECR":
            pure_factor = (
                torch.expm1(self.association_energy / (R * temperature[..., None]))
                * self.b
                * self.association_volume
            )
            pair_strength = radial_distribution[..., None, None] * torch.sqrt(
                pure_factor[..., :, None] * pure_factor[..., None, :]
            )
        else:  # pragma: no cover - constructor validation protects this branch
            raise ValueError(f"unknown CPA combining rule {self.combining_rule!r}")

        # Modified CR-1 solvation overrides use the published pair energy and
        # volume directly in Delta = g [exp(epsilon/RT)-1] bij beta.
        override_strength = (
            radial_distribution[..., None, None]
            * torch.expm1(self.cross_association_energy / (R * temperature_pair))
            * bij
            * self.cross_association_volume
        )
        pair_strength = torch.where(
            self.cross_association_mask,
            override_strength,
            pair_strength,
        )
        types_i = self.site_types[:, :, None, None]
        types_j = self.site_types[None, None, :, :]
        compatible = (types_i >= 0) & (types_j >= 0) & (types_i != types_j)
        return pair_strength[..., :, None, :, None] * compatible

    def site_fractions(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Solve the CPA mass-action equations for unbonded site fractions.

        Leading dimensions are broadcast as independent homogeneous states.
        The component axis is the final composition dimension.
        """
        x = normalize_composition(composition)
        if x.shape[-1] != self.ncomponents:
            raise ValueError("CPA composition has the wrong number of components")
        batch_shape = torch.broadcast_shapes(
            temperature.shape,
            molar_density.shape,
            x.shape[:-1],
        )
        temperature = torch.broadcast_to(temperature, batch_shape)
        molar_density = torch.broadcast_to(molar_density, batch_shape)
        x = torch.broadcast_to(x, (*batch_shape, self.ncomponents))
        strength = self.association_strength(temperature, molar_density, x)
        active = self.site_types >= 0
        site_fraction = torch.ones(
            (*batch_shape, self.ncomponents, self.site_types.shape[-1]),
            dtype=x.dtype,
            device=x.device,
        )
        for _ in range(self.association_iterations):
            bonded_sum = torch.einsum(
                "...j,...jb,...iajb->...ia",
                x,
                site_fraction,
                strength,
            )
            update = 1.0 / (1.0 + molar_density[..., None, None] * bonded_sum)
            update = torch.where(active, update, torch.ones_like(update))
            site_fraction = 0.5 * site_fraction + 0.5 * update

        # Newton refinement uses the analytic mass-action Jacobian. A
        # positivity-limited step replaces dozens of slowly convergent Picard
        # iterations in strongly associating liquids while retaining a fixed,
        # differentiable workload suitable for torch.compile and accelerators.
        nsites = self.site_types.shape[-1]
        system_size = self.ncomponents * nsites
        flattened_strength = strength.reshape(*batch_shape, system_size, system_size)
        site_weights = (
            x[..., :, None]
            .expand(*batch_shape, self.ncomponents, nsites)
            .reshape(*batch_shape, system_size)
        )
        flattened_active = active.reshape(system_size)
        identity = torch.eye(system_size, dtype=x.dtype, device=x.device)
        for _ in range(self.association_newton_iterations):
            flattened_sites = site_fraction.reshape(*batch_shape, system_size)
            bonded_sum = torch.einsum(
                "...j,...jb,...iajb->...ia",
                x,
                site_fraction,
                strength,
            ).reshape(*batch_shape, system_size)
            residual = flattened_sites.reciprocal() - 1.0 - molar_density[..., None] * bonded_sum
            jacobian = -torch.diag_embed(flattened_sites.reciprocal().square())
            jacobian = jacobian - (
                molar_density[..., None, None] * flattened_strength * site_weights[..., None, :]
            )
            residual = torch.where(flattened_active, residual, flattened_sites - 1.0)
            jacobian = torch.where(flattened_active[:, None], jacobian, identity)
            step = torch.linalg.solve(jacobian, -residual)
            decreases_site_fraction = (step < 0.0) & flattened_active
            safe_step = torch.where(
                decreases_site_fraction,
                step,
                -torch.ones_like(step),
            )
            positivity_limits = torch.where(
                decreases_site_fraction,
                -0.9 * flattened_sites / safe_step,
                torch.full_like(step, torch.inf),
            )
            step_scale = torch.minimum(
                torch.ones_like(positivity_limits[..., 0]),
                positivity_limits.amin(dim=-1),
            )
            site_fraction = (flattened_sites + step_scale[..., None] * step).reshape(
                *batch_shape, self.ncomponents, nsites
            )
            site_fraction = torch.where(active, site_fraction, torch.ones_like(site_fraction))
        return site_fraction

    def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
        """Return extensive ``Ares/(RT)`` for the combined CPA model."""
        if moles.ndim != 1 or volume.ndim != 0:
            raise ValueError("CPA Helmholtz kernel currently accepts one homogeneous state")
        total = moles.sum()
        x = moles / total
        molar_volume = volume / total
        am, bm = self.mixture_parameters(temperature, x)
        if bool(molar_volume <= bm):
            raise InvalidStateError("CPA molar volume must exceed mixture covolume")
        physical = -total * torch.log1p(-bm / molar_volume)
        physical = physical - total * am / (R * temperature * bm) * torch.log(
            (molar_volume + bm) / molar_volume
        )
        site_fraction = self.site_fractions(temperature, molar_volume.reciprocal(), x)
        active = self.site_types >= 0
        association_terms = torch.where(
            active,
            torch.log(site_fraction) - 0.5 * site_fraction + 0.5,
            torch.zeros_like(site_fraction),
        )
        association = torch.sum(moles[:, None] * association_terms)
        return physical + association

    def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
        """Evaluate the published CPA pressure equation."""
        x = normalize_composition(composition)
        am, bm = self.mixture_parameters(temperature, x)
        density = molar_volume.reciprocal()
        sites = self.site_fractions(temperature, density, x)
        active = self.site_types >= 0
        unbonded = torch.where(active, 1.0 - sites, torch.zeros_like(sites))
        association_sum = torch.sum(x[..., :, None] * unbonded, dim=(-2, -1))
        packing_fraction = 0.25 * bm * density
        density_dlogg = 1.9 * packing_fraction / (1.0 - 1.9 * packing_fraction)
        physical = R * temperature / (molar_volume - bm) - am / (molar_volume * (molar_volume + bm))
        association = (
            -0.5 * R * temperature / molar_volume * (1.0 + density_dlogg) * association_sum
        )
        return physical + association

    def _volume_roots(
        self, temperature: Tensor, pressure: Tensor, composition: Tensor
    ) -> tuple[Tensor, ...]:
        """Locate and polish all positive CPA pressure roots."""
        x = normalize_composition(composition)
        _, bm = self.mixture_parameters(temperature, x)
        minimum = bm * (1.0 + 1.0e-9)
        maximum = torch.maximum(100.0 * R * temperature / pressure, minimum * 1.0e6)
        grid = torch.logspace(
            float(torch.log10(minimum.detach())),
            float(torch.log10(maximum.detach())),
            96,
            dtype=temperature.dtype,
            device=temperature.device,
        )

        def residual(volume: Tensor) -> Tensor:
            return self.pressure(temperature, volume, x) - pressure

        # The scan is one batched pressure call. Association-site iterations
        # therefore operate on the entire grid instead of repeating Python and
        # dispatcher overhead for every candidate volume.
        values = residual(grid)
        finite = torch.isfinite(values[:-1]) & torch.isfinite(values[1:])
        changes_sign = torch.signbit(values[:-1]) != torch.signbit(values[1:])
        exact = (values[:-1] == 0.0) | (values[1:] == 0.0)
        bracket_indices = torch.nonzero(finite & (changes_sign | exact)).flatten().tolist()
        brackets = [(grid[index], grid[index + 1]) for index in bracket_indices]
        if not brackets:
            raise ConvergenceError("CPA volume scan found no pressure root")

        roots: list[Tensor] = []
        for left, right in brackets:
            left_value = residual(left)
            for _ in range(80):
                midpoint = 0.5 * (left + right)
                midpoint_value = residual(midpoint)
                if float((midpoint_value.abs() / pressure).detach()) <= 1.0e-12:
                    break
                if bool(torch.signbit(left_value) != torch.signbit(midpoint_value)):
                    right = midpoint
                else:
                    left = midpoint
                    left_value = midpoint_value

            # Bisection is used only for robust root selection. Newton polishing
            # restores the implicit parameter derivatives of p(T, v, x)=P.
            volume = midpoint
            for _ in range(4):
                value = residual(volume)
                slope = torch.func.grad(residual)(volume)
                volume = volume - value / slope
            roots.append(volume)
        return tuple(roots)

    def _phase_volume_newton(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: Literal["liquid", "vapor"],
    ) -> Tensor | None:
        """Try the fast differentiable phase-specific volume solve."""
        _, bm = self.mixture_parameters(temperature, composition)
        minimum = bm * (1.0 + 1.0e-9)
        ideal = R * temperature / pressure
        initial = minimum * 1.2 if phase == "liquid" else torch.maximum(ideal, minimum * 2.0)
        log_minimum = torch.log(minimum)
        log_volume = torch.log(initial)

        def residual(current: Tensor) -> Tensor:
            volume = torch.exp(current)
            return (self.pressure(temperature, volume, composition) - pressure) / pressure

        for _ in range(40):
            value = residual(log_volume)
            if float(value.detach().abs()) <= 1.0e-10:
                return torch.exp(log_volume)
            slope: Tensor = torch.func.grad(residual)(log_volume)
            step = torch.clamp(-value / slope, -0.5, 0.5)
            if not bool(torch.isfinite(step)):
                return None
            log_volume = torch.maximum(log_volume + step, log_minimum)
        return None

    def molar_volume(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Solve the CPA volume root for a specified phase."""
        if temperature.ndim != 0 or pressure.ndim != 0 or composition.ndim != 1:
            raise ValueError("CPA volume solver currently accepts one scalar T-P state")
        x = normalize_composition(composition)
        if phase in ("liquid", "vapor"):
            fast_volume = self._phase_volume_newton(
                temperature,
                pressure,
                x,
                phase,
            )
            if fast_volume is not None:
                return fast_volume
        roots = self._volume_roots(temperature, pressure, x)
        if phase == "liquid":
            return roots[0]
        if phase == "vapor":
            return roots[-1]
        if phase != "stable":
            raise ValueError(f"unknown phase root {phase!r}")

        gibbs: list[Tensor] = []
        for volume in roots:
            z = pressure * volume / (R * temperature)
            residual_helmholtz = self.residual_helmholtz_rt(temperature, volume, x)
            gibbs.append(residual_helmholtz + z - 1.0 - torch.log(z))
        index = int(torch.argmin(torch.stack(gibbs)).detach())
        return roots[index]

    def select_z(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return the CPA compressibility factor."""
        volume = self.molar_volume(temperature, pressure, composition, phase)
        return pressure * volume / (R * temperature)

    def log_fugacity_coefficients(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return CPA log fugacity coefficients from Helmholtz derivatives."""
        x = normalize_composition(composition)
        volume = self.molar_volume(temperature, pressure, x, phase)

        def at_fixed_volume(moles: Tensor) -> Tensor:
            return self.residual_helmholtz_rt(temperature, volume, moles)

        residual_mu_rt: Tensor = torch.func.grad(at_fixed_volume)(x)
        z = pressure * volume / (R * temperature)
        return residual_mu_rt - torch.log(z)

ncomponents property

ncomponents

Number of mixture components.

pure_parameters

pure_parameters(temperature)

Return CPA's fitted SRK energy and covolume parameters.

Source code in src/torch_flash/eos/cpa.py
def pure_parameters(self, temperature: Tensor) -> tuple[Tensor, Tensor]:
    """Return CPA's fitted SRK energy and covolume parameters."""
    reduced = temperature[..., None] / self.critical_temperature
    a = self.a0 * (1.0 + self.c1 * (1.0 - torch.sqrt(reduced))).square()
    return a, self.b

mixture_parameters

mixture_parameters(temperature, composition)

Return conventionally mixed physical-term parameters.

Source code in src/torch_flash/eos/cpa.py
def mixture_parameters(self, temperature: Tensor, composition: Tensor) -> tuple[Tensor, Tensor]:
    """Return conventionally mixed physical-term parameters."""
    pure_a, pure_b = self.pure_parameters(temperature)
    result: tuple[Tensor, Tensor] = self.mixing(temperature, composition, pure_a, pure_b)
    return result

binary_interaction

binary_interaction(temperature)

Return the symmetric physical-term kij matrix at temperature.

Source code in src/torch_flash/eos/cpa.py
def binary_interaction(self, temperature: Tensor) -> Tensor:
    """Return the symmetric physical-term ``kij`` matrix at ``temperature``."""
    if isinstance(self.mixing, TemperatureDependentQuadraticMixing):
        return self.mixing.kij(temperature)
    return self.mixing.kij

association_strength

association_strength(temperature, molar_density, composition)

Return Delta[i, A, j, B] association strengths in m3/mol.

Source code in src/torch_flash/eos/cpa.py
def association_strength(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return ``Delta[i, A, j, B]`` association strengths in m3/mol."""
    x = normalize_composition(composition)
    _, bm = self.mixture_parameters(temperature, x)
    packing_fraction = 0.25 * bm * molar_density
    radial_distribution = 1.0 / (1.0 - 1.9 * packing_fraction)
    bij = 0.5 * (self.b[:, None] + self.b[None, :])
    temperature_pair = temperature[..., None, None]
    if self.combining_rule == "CR1":
        epsilon = 0.5 * (self.association_energy[:, None] + self.association_energy[None, :])
        beta = torch.sqrt(self.association_volume[:, None] * self.association_volume[None, :])
        pair_strength = (
            radial_distribution[..., None, None]
            * torch.expm1(epsilon / (R * temperature_pair))
            * bij
            * beta
        )
    elif self.combining_rule == "ECR":
        pure_factor = (
            torch.expm1(self.association_energy / (R * temperature[..., None]))
            * self.b
            * self.association_volume
        )
        pair_strength = radial_distribution[..., None, None] * torch.sqrt(
            pure_factor[..., :, None] * pure_factor[..., None, :]
        )
    else:  # pragma: no cover - constructor validation protects this branch
        raise ValueError(f"unknown CPA combining rule {self.combining_rule!r}")

    # Modified CR-1 solvation overrides use the published pair energy and
    # volume directly in Delta = g [exp(epsilon/RT)-1] bij beta.
    override_strength = (
        radial_distribution[..., None, None]
        * torch.expm1(self.cross_association_energy / (R * temperature_pair))
        * bij
        * self.cross_association_volume
    )
    pair_strength = torch.where(
        self.cross_association_mask,
        override_strength,
        pair_strength,
    )
    types_i = self.site_types[:, :, None, None]
    types_j = self.site_types[None, None, :, :]
    compatible = (types_i >= 0) & (types_j >= 0) & (types_i != types_j)
    return pair_strength[..., :, None, :, None] * compatible

site_fractions

site_fractions(temperature, molar_density, composition)

Solve the CPA mass-action equations for unbonded site fractions.

Leading dimensions are broadcast as independent homogeneous states. The component axis is the final composition dimension.

Source code in src/torch_flash/eos/cpa.py
def site_fractions(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Solve the CPA mass-action equations for unbonded site fractions.

    Leading dimensions are broadcast as independent homogeneous states.
    The component axis is the final composition dimension.
    """
    x = normalize_composition(composition)
    if x.shape[-1] != self.ncomponents:
        raise ValueError("CPA composition has the wrong number of components")
    batch_shape = torch.broadcast_shapes(
        temperature.shape,
        molar_density.shape,
        x.shape[:-1],
    )
    temperature = torch.broadcast_to(temperature, batch_shape)
    molar_density = torch.broadcast_to(molar_density, batch_shape)
    x = torch.broadcast_to(x, (*batch_shape, self.ncomponents))
    strength = self.association_strength(temperature, molar_density, x)
    active = self.site_types >= 0
    site_fraction = torch.ones(
        (*batch_shape, self.ncomponents, self.site_types.shape[-1]),
        dtype=x.dtype,
        device=x.device,
    )
    for _ in range(self.association_iterations):
        bonded_sum = torch.einsum(
            "...j,...jb,...iajb->...ia",
            x,
            site_fraction,
            strength,
        )
        update = 1.0 / (1.0 + molar_density[..., None, None] * bonded_sum)
        update = torch.where(active, update, torch.ones_like(update))
        site_fraction = 0.5 * site_fraction + 0.5 * update

    # Newton refinement uses the analytic mass-action Jacobian. A
    # positivity-limited step replaces dozens of slowly convergent Picard
    # iterations in strongly associating liquids while retaining a fixed,
    # differentiable workload suitable for torch.compile and accelerators.
    nsites = self.site_types.shape[-1]
    system_size = self.ncomponents * nsites
    flattened_strength = strength.reshape(*batch_shape, system_size, system_size)
    site_weights = (
        x[..., :, None]
        .expand(*batch_shape, self.ncomponents, nsites)
        .reshape(*batch_shape, system_size)
    )
    flattened_active = active.reshape(system_size)
    identity = torch.eye(system_size, dtype=x.dtype, device=x.device)
    for _ in range(self.association_newton_iterations):
        flattened_sites = site_fraction.reshape(*batch_shape, system_size)
        bonded_sum = torch.einsum(
            "...j,...jb,...iajb->...ia",
            x,
            site_fraction,
            strength,
        ).reshape(*batch_shape, system_size)
        residual = flattened_sites.reciprocal() - 1.0 - molar_density[..., None] * bonded_sum
        jacobian = -torch.diag_embed(flattened_sites.reciprocal().square())
        jacobian = jacobian - (
            molar_density[..., None, None] * flattened_strength * site_weights[..., None, :]
        )
        residual = torch.where(flattened_active, residual, flattened_sites - 1.0)
        jacobian = torch.where(flattened_active[:, None], jacobian, identity)
        step = torch.linalg.solve(jacobian, -residual)
        decreases_site_fraction = (step < 0.0) & flattened_active
        safe_step = torch.where(
            decreases_site_fraction,
            step,
            -torch.ones_like(step),
        )
        positivity_limits = torch.where(
            decreases_site_fraction,
            -0.9 * flattened_sites / safe_step,
            torch.full_like(step, torch.inf),
        )
        step_scale = torch.minimum(
            torch.ones_like(positivity_limits[..., 0]),
            positivity_limits.amin(dim=-1),
        )
        site_fraction = (flattened_sites + step_scale[..., None] * step).reshape(
            *batch_shape, self.ncomponents, nsites
        )
        site_fraction = torch.where(active, site_fraction, torch.ones_like(site_fraction))
    return site_fraction

residual_helmholtz_rt

residual_helmholtz_rt(temperature, volume, moles)

Return extensive Ares/(RT) for the combined CPA model.

Source code in src/torch_flash/eos/cpa.py
def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
    """Return extensive ``Ares/(RT)`` for the combined CPA model."""
    if moles.ndim != 1 or volume.ndim != 0:
        raise ValueError("CPA Helmholtz kernel currently accepts one homogeneous state")
    total = moles.sum()
    x = moles / total
    molar_volume = volume / total
    am, bm = self.mixture_parameters(temperature, x)
    if bool(molar_volume <= bm):
        raise InvalidStateError("CPA molar volume must exceed mixture covolume")
    physical = -total * torch.log1p(-bm / molar_volume)
    physical = physical - total * am / (R * temperature * bm) * torch.log(
        (molar_volume + bm) / molar_volume
    )
    site_fraction = self.site_fractions(temperature, molar_volume.reciprocal(), x)
    active = self.site_types >= 0
    association_terms = torch.where(
        active,
        torch.log(site_fraction) - 0.5 * site_fraction + 0.5,
        torch.zeros_like(site_fraction),
    )
    association = torch.sum(moles[:, None] * association_terms)
    return physical + association

pressure

pressure(temperature, molar_volume, composition)

Evaluate the published CPA pressure equation.

Source code in src/torch_flash/eos/cpa.py
def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
    """Evaluate the published CPA pressure equation."""
    x = normalize_composition(composition)
    am, bm = self.mixture_parameters(temperature, x)
    density = molar_volume.reciprocal()
    sites = self.site_fractions(temperature, density, x)
    active = self.site_types >= 0
    unbonded = torch.where(active, 1.0 - sites, torch.zeros_like(sites))
    association_sum = torch.sum(x[..., :, None] * unbonded, dim=(-2, -1))
    packing_fraction = 0.25 * bm * density
    density_dlogg = 1.9 * packing_fraction / (1.0 - 1.9 * packing_fraction)
    physical = R * temperature / (molar_volume - bm) - am / (molar_volume * (molar_volume + bm))
    association = (
        -0.5 * R * temperature / molar_volume * (1.0 + density_dlogg) * association_sum
    )
    return physical + association

molar_volume

molar_volume(temperature, pressure, composition, phase='stable')

Solve the CPA volume root for a specified phase.

Source code in src/torch_flash/eos/cpa.py
def molar_volume(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Solve the CPA volume root for a specified phase."""
    if temperature.ndim != 0 or pressure.ndim != 0 or composition.ndim != 1:
        raise ValueError("CPA volume solver currently accepts one scalar T-P state")
    x = normalize_composition(composition)
    if phase in ("liquid", "vapor"):
        fast_volume = self._phase_volume_newton(
            temperature,
            pressure,
            x,
            phase,
        )
        if fast_volume is not None:
            return fast_volume
    roots = self._volume_roots(temperature, pressure, x)
    if phase == "liquid":
        return roots[0]
    if phase == "vapor":
        return roots[-1]
    if phase != "stable":
        raise ValueError(f"unknown phase root {phase!r}")

    gibbs: list[Tensor] = []
    for volume in roots:
        z = pressure * volume / (R * temperature)
        residual_helmholtz = self.residual_helmholtz_rt(temperature, volume, x)
        gibbs.append(residual_helmholtz + z - 1.0 - torch.log(z))
    index = int(torch.argmin(torch.stack(gibbs)).detach())
    return roots[index]

select_z

select_z(temperature, pressure, composition, phase='stable')

Return the CPA compressibility factor.

Source code in src/torch_flash/eos/cpa.py
def select_z(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return the CPA compressibility factor."""
    volume = self.molar_volume(temperature, pressure, composition, phase)
    return pressure * volume / (R * temperature)

log_fugacity_coefficients

log_fugacity_coefficients(temperature, pressure, composition, phase='stable')

Return CPA log fugacity coefficients from Helmholtz derivatives.

Source code in src/torch_flash/eos/cpa.py
def log_fugacity_coefficients(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return CPA log fugacity coefficients from Helmholtz derivatives."""
    x = normalize_composition(composition)
    volume = self.molar_volume(temperature, pressure, x, phase)

    def at_fixed_volume(moles: Tensor) -> Tensor:
        return self.residual_helmholtz_rt(temperature, volume, moles)

    residual_mu_rt: Tensor = torch.func.grad(at_fixed_volume)(x)
    z = pressure * volume / (R * temperature)
    return residual_mu_rt - torch.log(z)

CPACharacterizedComponents dataclass

Characterized pseudo-components and their normalized cut fractions.

Source code in src/torch_flash/eos/cpa_heavy_end.py
@dataclass(frozen=True)
class CPACharacterizedComponents:
    """Characterized pseudo-components and their normalized cut fractions."""

    components: tuple[CPAComponent, ...]
    mole_fractions: Tensor
    monomer_properties: tuple[CPAMonomerProperties, ...]

CPAHeavyEndCorrelations dataclass

Published coefficients used by the CPA C7+ monomer correlations.

Source code in src/torch_flash/eos/cpa_heavy_end.py
@dataclass(frozen=True)
class CPAHeavyEndCorrelations:
    """Published coefficients used by the CPA C7+ monomer correlations."""

    normal_boiling_pressure: float
    srk_omega_a: float
    srk_omega_b: float
    srk_m: tuple[float, float, float]
    normal_alkane_temperature: tuple[float, float, float]
    log_normal_alkane_pressure_bar: tuple[float, float, float, float, float]
    normal_alkane_acentric: tuple[float, float, float]
    normal_alkane_specific_gravity: tuple[float, float, float, float, float]
    critical_temperature_numerator: tuple[float, float, float]
    critical_temperature_denominator: tuple[float, float, float]
    log_critical_pressure_ratio: tuple[float, float, float, float, float]

CPAMonomerProperties dataclass

Intermediate and final CPA monomer characterization results.

Source code in src/torch_flash/eos/cpa_heavy_end.py
@dataclass(frozen=True)
class CPAMonomerProperties:
    """Intermediate and final CPA monomer characterization results."""

    normal_boiling_temperature: float
    specific_gravity: float
    normal_alkane_specific_gravity: float
    specific_gravity_perturbation: float
    normal_alkane_temperature: float
    normal_alkane_pressure: float
    critical_temperature: float
    critical_pressure: float
    acentric_factor: float
    used_boiling_point_match: bool
    correlations: CPAHeavyEndCorrelations

    @property
    def m(self) -> float:
        """Return the SRK ``m`` coefficient corresponding to ``omega``."""
        omega = self.acentric_factor
        first, second, third = self.correlations.srk_m
        return first + second * omega + third * omega * omega

    @property
    def a0(self) -> float:
        """Return CPA/SRK energy parameter in Pa m6 mol-2."""
        return (
            self.correlations.srk_omega_a
            * (R * self.critical_temperature) ** 2
            / self.critical_pressure
        )

    @property
    def b(self) -> float:
        """Return CPA/SRK covolume in m3 mol-1."""
        return (
            self.correlations.srk_omega_b * R * self.critical_temperature / self.critical_pressure
        )

m property

m

Return the SRK m coefficient corresponding to omega.

a0 property

a0

Return CPA/SRK energy parameter in Pa m6 mol-2.

b property

b

Return CPA/SRK covolume in m3 mol-1.

CubicEOS

Bases: Module

Generalized cubic equation of state with differentiable parameters.

Source code in src/torch_flash/eos/cubic.py
class CubicEOS(nn.Module):
    """Generalized cubic equation of state with differentiable parameters."""

    critical_temperature: Tensor
    critical_pressure: Tensor
    acentric_factor: Tensor
    molar_mass: Tensor
    volume_translation: Tensor
    volume_translation_slope: Tensor
    volume_translation_reference_temperature: Tensor
    mixing: CubicMixing

    def __init__(
        self,
        components: ComponentSet,
        constants: CubicConstants,
        *,
        mixing: CubicMixing | None = None,
        volume_translation: Tensor | VolumeTranslation | None = None,
    ) -> None:
        super().__init__()
        self.names = components.names
        self.constants = constants
        self.register_buffer("critical_temperature", components.critical_temperature.clone())
        self.register_buffer("critical_pressure", components.critical_pressure.clone())
        self.register_buffer("acentric_factor", components.acentric_factor.clone())
        self.register_buffer("molar_mass", components.molar_mass.clone())
        if mixing is None:
            zeros = torch.zeros(
                (components.ncomponents, components.ncomponents),
                dtype=components.critical_temperature.dtype,
                device=components.critical_temperature.device,
            )
            mixing = QuadraticMixing(zeros)
        self.mixing = mixing
        if isinstance(volume_translation, VolumeTranslation):
            translation = volume_translation.reference_shift.to(
                dtype=components.critical_temperature.dtype,
                device=components.critical_temperature.device,
            )
            translation_slope = volume_translation.temperature_slope.to(
                dtype=components.critical_temperature.dtype,
                device=components.critical_temperature.device,
            )
            reference_temperature = volume_translation.reference_temperature
        else:
            translation = (
                torch.zeros_like(components.critical_temperature)
                if volume_translation is None
                else volume_translation
            )
            translation_slope = torch.zeros_like(translation)
            reference_temperature = 288.15
        if translation.shape != components.critical_temperature.shape:
            raise ValueError("volume_translation must have one value per component")
        if translation_slope.shape != components.critical_temperature.shape:
            raise ValueError("volume_translation slope must have one value per component")
        self.register_buffer("volume_translation", translation.clone())
        self.register_buffer("volume_translation_slope", translation_slope.clone())
        self.register_buffer(
            "volume_translation_reference_temperature",
            components.critical_temperature.new_tensor(reference_temperature),
        )

    @property
    def ncomponents(self) -> int:
        """Number of modeled components."""
        return len(self.names)

    def component_volume_translation(self, temperature: Tensor) -> Tensor:
        """Return additive component volume shifts at ``temperature``."""
        return (
            self.volume_translation
            + (temperature[..., None] - self.volume_translation_reference_temperature)
            * self.volume_translation_slope
        )

    def _kappa(self) -> Tensor:
        omega = self.acentric_factor
        low_coefficients = self.constants.alpha_low
        low = (
            low_coefficients[0] + low_coefficients[1] * omega + low_coefficients[2] * omega.square()
        )
        if self.constants.alpha_kind != "pr78":
            return low
        if self.constants.alpha_high is None or self.constants.alpha_switch is None:
            raise ParameterDatabaseError("PR78 requires high-alpha coefficients and a switch")
        high_coefficients = self.constants.alpha_high
        high = (
            high_coefficients[0]
            + high_coefficients[1] * omega
            + high_coefficients[2] * omega.square()
            + high_coefficients[3] * omega.pow(3)
        )
        return torch.where(omega <= self.constants.alpha_switch, low, high)

    def pure_parameters(self, temperature: Tensor) -> tuple[Tensor, Tensor]:
        """Return temperature-dependent pure ``a_i`` and constant ``b_i``."""
        if bool((temperature <= 0.0).any()):
            raise InvalidStateError("temperature must be positive")
        reduced = temperature[..., None] / self.critical_temperature
        alpha = (1.0 + self._kappa() * (1.0 - torch.sqrt(reduced))).square()
        a = (
            self.constants.omega_a
            * R**2
            * self.critical_temperature.square()
            / self.critical_pressure
            * alpha
        )
        b = self.constants.omega_b * R * self.critical_temperature / self.critical_pressure
        return a, b

    def mixture_parameters(self, temperature: Tensor, composition: Tensor) -> tuple[Tensor, Tensor]:
        """Return mixed attraction and covolume parameters."""
        x = normalize_composition(composition)
        pure_a, pure_b = self.pure_parameters(temperature)
        result: tuple[Tensor, Tensor] = self.mixing(temperature, x, pure_a, pure_b)
        return result

    def dimensionless_parameters(
        self, temperature: Tensor, pressure: Tensor, composition: Tensor
    ) -> tuple[Tensor, Tensor]:
        """Return conventional cubic parameters ``A`` and ``B``."""
        if bool((pressure <= 0.0).any()):
            raise InvalidStateError("pressure must be positive")
        am, bm = self.mixture_parameters(temperature, composition)
        return am * pressure / (R * temperature).square(), bm * pressure / (R * temperature)

    def z_factors(self, temperature: Tensor, pressure: Tensor, composition: Tensor) -> Tensor:
        """Return sorted real compressibility-factor roots."""
        a, b = self.dimensionless_parameters(temperature, pressure, composition)
        u = self.constants.delta1 + self.constants.delta2
        w = self.constants.delta1 * self.constants.delta2
        c2 = (u - 1.0) * b - 1.0
        c1 = a - u * b + (w - u) * b.square()
        c0 = -(a * b + w * b.square() * (1.0 + b))
        return cubic_real_roots(c2, c1, c0)

    def _residual_gibbs_rt_from_z(self, z: Tensor, a: Tensor, b: Tensor) -> Tensor:
        d1 = self.constants.delta1
        d2 = self.constants.delta2
        safe_b = torch.clamp_min(b, torch.finfo(b.dtype).tiny)
        attraction = a / (safe_b * (d1 - d2))
        log_ratio = torch.log((z + d1 * b) / (z + d2 * b))
        value = z - 1.0 - torch.log(z - b) - attraction * log_ratio
        ideal_b_limit = z - 1.0 - torch.log(z) - a / z
        return torch.where(b.abs() > 10.0 * torch.finfo(b.dtype).eps, value, ideal_b_limit)

    def select_z(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Select a physical liquid, vapor, or minimum-Gibbs root."""
        roots = self.z_factors(temperature, pressure, composition)
        _, b = self.dimensionless_parameters(temperature, pressure, composition)
        valid = roots > b[..., None] * (1.0 + 32.0 * torch.finfo(roots.dtype).eps)
        if phase == "liquid":
            selected = torch.where(valid, roots, torch.inf).amin(dim=-1)
        elif phase == "vapor":
            selected = torch.where(valid, roots, -torch.inf).amax(dim=-1)
        elif phase == "stable":
            a, b = self.dimensionless_parameters(temperature, pressure, composition)
            gibbs = self._residual_gibbs_rt_from_z(roots, a[..., None], b[..., None])
            gibbs = torch.where(valid, gibbs, torch.inf)
            selected = torch.gather(roots, -1, gibbs.argmin(dim=-1, keepdim=True)).squeeze(-1)
        else:
            raise ValueError(f"unknown phase root {phase!r}")
        if not bool(torch.isfinite(selected).all()):
            raise InvalidStateError("cubic EoS produced no physical volume root")
        return selected

    def molar_volume(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return translated molar volume in m3/mol."""
        x = normalize_composition(composition)
        z = self.select_z(temperature, pressure, x, phase)
        unshifted = z * R * temperature / pressure
        translation = self.component_volume_translation(temperature)
        return unshifted + torch.sum(x * translation, dim=-1)

    def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
        """Evaluate pressure at a homogeneous ``T, v, x`` state."""
        x = normalize_composition(composition)
        shift = torch.sum(x * self.component_volume_translation(temperature), dim=-1)
        volume = molar_volume - shift
        am, bm = self.mixture_parameters(temperature, x)
        d1 = self.constants.delta1
        d2 = self.constants.delta2
        if bool((volume <= bm).any()):
            raise InvalidStateError("molar volume must exceed the mixture covolume")
        return R * temperature / (volume - bm) - am / ((volume + d1 * bm) * (volume + d2 * bm))

    def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
        """Return extensive residual Helmholtz energy divided by ``RT``."""
        total = moles.sum(dim=-1)
        x = moles / total[..., None]
        translation = self.component_volume_translation(temperature)
        shift = torch.sum(x * translation, dim=-1)
        unshifted_volume = volume - total * shift
        molar_volume = unshifted_volume / total
        am, bm = self.mixture_parameters(temperature, x)
        d1 = self.constants.delta1
        d2 = self.constants.delta2
        repulsive = -total * torch.log1p(-bm / molar_volume)
        logarithm = torch.log((molar_volume + d1 * bm) / (molar_volume + d2 * bm))
        attractive = -total * am / (R * temperature * bm * (d1 - d2)) * logarithm
        # A constant Peneloux-style translation maps the physical volume V to
        # the parent-EoS volume V0 = V - sum(n_i*c_i).  The final logarithm is
        # the ideal-gas reference-volume correction required when A^R is
        # defined relative to an ideal gas at the *physical* V.  Including it
        # makes -dA/dV, fugacity, and the TP/TV routes mutually consistent.
        reference_volume_correction = total * torch.log(volume / unshifted_volume)
        return repulsive + attractive + reference_volume_correction

    def log_fugacity_coefficients(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return component log fugacity coefficients.

        The quadratic rule uses the standard closed form. Non-quadratic mixing
        rules use an autodifferentiated residual Helmholtz energy, preserving
        the exact composition dependence of the selected rule.
        """
        x = normalize_composition(composition)
        z = self.select_z(temperature, pressure, x, phase)
        a, b = self.dimensionless_parameters(temperature, pressure, x)
        if isinstance(
            self.mixing,
            QuadraticMixing | TemperatureDependentQuadraticMixing | PPR78Mixing,
        ):
            pure_a, pure_b = self.pure_parameters(temperature)
            am, bm = self.mixture_parameters(temperature, x)
            if isinstance(self.mixing, PPR78Mixing):
                aij = self.mixing.cross_a(temperature, pure_a, pure_b)
            elif isinstance(self.mixing, TemperatureDependentQuadraticMixing):
                aij = self.mixing.cross_a(temperature, pure_a)
            else:
                aij = self.mixing.cross_a(pure_a)
            sum_aij = torch.einsum("...j,...ij->...i", x, aij)
            partial_b_over_b = self.mixing.partial_b(x, pure_b) / bm[..., None]
            composition_term = 2.0 * sum_aij / am[..., None] - partial_b_over_b
            d1 = self.constants.delta1
            d2 = self.constants.delta2
            log_ratio = torch.log((z + d1 * b) / (z + d2 * b))
            unshifted_log_phi = (
                partial_b_over_b * (z - 1.0)[..., None]
                - torch.log(z - b)[..., None]
                - (a / (b * (d1 - d2)) * log_ratio)[..., None] * composition_term
            )
            # For v = v0 + sum(x_i*c_i), thermodynamic consistency requires
            # ln(phi_i) = ln(phi_i,0) + P*c_i/(R*T).  A common sign error is
            # avoided here by defining ``volume_translation`` as the quantity
            # *added* to the parent-EoS volume in ``molar_volume``.
            return unshifted_log_phi + (pressure / (R * temperature))[
                ..., None
            ] * self.component_volume_translation(temperature)

        translation = self.component_volume_translation(temperature)
        volume = z * R * temperature / pressure + torch.sum(x * translation, dim=-1)

        def at_fixed_volume(moles: Tensor) -> Tensor:
            # Batched states are independent, so differentiating their sum
            # returns the same row-wise chemical potentials as evaluating
            # each scalar state separately. ``torch.func.grad`` requires a
            # scalar output and therefore cannot consume the unsummed batch.
            return self.residual_helmholtz_rt(temperature, volume, moles).sum()

        residual_mu_rt: Tensor = torch.func.grad(at_fixed_volume)(x)
        physical_z = pressure * volume / (R * temperature)
        return residual_mu_rt - torch.log(physical_z)[..., None]

ncomponents property

ncomponents

Number of modeled components.

component_volume_translation

component_volume_translation(temperature)

Return additive component volume shifts at temperature.

Source code in src/torch_flash/eos/cubic.py
def component_volume_translation(self, temperature: Tensor) -> Tensor:
    """Return additive component volume shifts at ``temperature``."""
    return (
        self.volume_translation
        + (temperature[..., None] - self.volume_translation_reference_temperature)
        * self.volume_translation_slope
    )

pure_parameters

pure_parameters(temperature)

Return temperature-dependent pure a_i and constant b_i.

Source code in src/torch_flash/eos/cubic.py
def pure_parameters(self, temperature: Tensor) -> tuple[Tensor, Tensor]:
    """Return temperature-dependent pure ``a_i`` and constant ``b_i``."""
    if bool((temperature <= 0.0).any()):
        raise InvalidStateError("temperature must be positive")
    reduced = temperature[..., None] / self.critical_temperature
    alpha = (1.0 + self._kappa() * (1.0 - torch.sqrt(reduced))).square()
    a = (
        self.constants.omega_a
        * R**2
        * self.critical_temperature.square()
        / self.critical_pressure
        * alpha
    )
    b = self.constants.omega_b * R * self.critical_temperature / self.critical_pressure
    return a, b

mixture_parameters

mixture_parameters(temperature, composition)

Return mixed attraction and covolume parameters.

Source code in src/torch_flash/eos/cubic.py
def mixture_parameters(self, temperature: Tensor, composition: Tensor) -> tuple[Tensor, Tensor]:
    """Return mixed attraction and covolume parameters."""
    x = normalize_composition(composition)
    pure_a, pure_b = self.pure_parameters(temperature)
    result: tuple[Tensor, Tensor] = self.mixing(temperature, x, pure_a, pure_b)
    return result

dimensionless_parameters

dimensionless_parameters(temperature, pressure, composition)

Return conventional cubic parameters A and B.

Source code in src/torch_flash/eos/cubic.py
def dimensionless_parameters(
    self, temperature: Tensor, pressure: Tensor, composition: Tensor
) -> tuple[Tensor, Tensor]:
    """Return conventional cubic parameters ``A`` and ``B``."""
    if bool((pressure <= 0.0).any()):
        raise InvalidStateError("pressure must be positive")
    am, bm = self.mixture_parameters(temperature, composition)
    return am * pressure / (R * temperature).square(), bm * pressure / (R * temperature)

z_factors

z_factors(temperature, pressure, composition)

Return sorted real compressibility-factor roots.

Source code in src/torch_flash/eos/cubic.py
def z_factors(self, temperature: Tensor, pressure: Tensor, composition: Tensor) -> Tensor:
    """Return sorted real compressibility-factor roots."""
    a, b = self.dimensionless_parameters(temperature, pressure, composition)
    u = self.constants.delta1 + self.constants.delta2
    w = self.constants.delta1 * self.constants.delta2
    c2 = (u - 1.0) * b - 1.0
    c1 = a - u * b + (w - u) * b.square()
    c0 = -(a * b + w * b.square() * (1.0 + b))
    return cubic_real_roots(c2, c1, c0)

select_z

select_z(temperature, pressure, composition, phase='stable')

Select a physical liquid, vapor, or minimum-Gibbs root.

Source code in src/torch_flash/eos/cubic.py
def select_z(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Select a physical liquid, vapor, or minimum-Gibbs root."""
    roots = self.z_factors(temperature, pressure, composition)
    _, b = self.dimensionless_parameters(temperature, pressure, composition)
    valid = roots > b[..., None] * (1.0 + 32.0 * torch.finfo(roots.dtype).eps)
    if phase == "liquid":
        selected = torch.where(valid, roots, torch.inf).amin(dim=-1)
    elif phase == "vapor":
        selected = torch.where(valid, roots, -torch.inf).amax(dim=-1)
    elif phase == "stable":
        a, b = self.dimensionless_parameters(temperature, pressure, composition)
        gibbs = self._residual_gibbs_rt_from_z(roots, a[..., None], b[..., None])
        gibbs = torch.where(valid, gibbs, torch.inf)
        selected = torch.gather(roots, -1, gibbs.argmin(dim=-1, keepdim=True)).squeeze(-1)
    else:
        raise ValueError(f"unknown phase root {phase!r}")
    if not bool(torch.isfinite(selected).all()):
        raise InvalidStateError("cubic EoS produced no physical volume root")
    return selected

molar_volume

molar_volume(temperature, pressure, composition, phase='stable')

Return translated molar volume in m3/mol.

Source code in src/torch_flash/eos/cubic.py
def molar_volume(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return translated molar volume in m3/mol."""
    x = normalize_composition(composition)
    z = self.select_z(temperature, pressure, x, phase)
    unshifted = z * R * temperature / pressure
    translation = self.component_volume_translation(temperature)
    return unshifted + torch.sum(x * translation, dim=-1)

pressure

pressure(temperature, molar_volume, composition)

Evaluate pressure at a homogeneous T, v, x state.

Source code in src/torch_flash/eos/cubic.py
def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
    """Evaluate pressure at a homogeneous ``T, v, x`` state."""
    x = normalize_composition(composition)
    shift = torch.sum(x * self.component_volume_translation(temperature), dim=-1)
    volume = molar_volume - shift
    am, bm = self.mixture_parameters(temperature, x)
    d1 = self.constants.delta1
    d2 = self.constants.delta2
    if bool((volume <= bm).any()):
        raise InvalidStateError("molar volume must exceed the mixture covolume")
    return R * temperature / (volume - bm) - am / ((volume + d1 * bm) * (volume + d2 * bm))

residual_helmholtz_rt

residual_helmholtz_rt(temperature, volume, moles)

Return extensive residual Helmholtz energy divided by RT.

Source code in src/torch_flash/eos/cubic.py
def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
    """Return extensive residual Helmholtz energy divided by ``RT``."""
    total = moles.sum(dim=-1)
    x = moles / total[..., None]
    translation = self.component_volume_translation(temperature)
    shift = torch.sum(x * translation, dim=-1)
    unshifted_volume = volume - total * shift
    molar_volume = unshifted_volume / total
    am, bm = self.mixture_parameters(temperature, x)
    d1 = self.constants.delta1
    d2 = self.constants.delta2
    repulsive = -total * torch.log1p(-bm / molar_volume)
    logarithm = torch.log((molar_volume + d1 * bm) / (molar_volume + d2 * bm))
    attractive = -total * am / (R * temperature * bm * (d1 - d2)) * logarithm
    # A constant Peneloux-style translation maps the physical volume V to
    # the parent-EoS volume V0 = V - sum(n_i*c_i).  The final logarithm is
    # the ideal-gas reference-volume correction required when A^R is
    # defined relative to an ideal gas at the *physical* V.  Including it
    # makes -dA/dV, fugacity, and the TP/TV routes mutually consistent.
    reference_volume_correction = total * torch.log(volume / unshifted_volume)
    return repulsive + attractive + reference_volume_correction

log_fugacity_coefficients

log_fugacity_coefficients(temperature, pressure, composition, phase='stable')

Return component log fugacity coefficients.

The quadratic rule uses the standard closed form. Non-quadratic mixing rules use an autodifferentiated residual Helmholtz energy, preserving the exact composition dependence of the selected rule.

Source code in src/torch_flash/eos/cubic.py
def log_fugacity_coefficients(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return component log fugacity coefficients.

    The quadratic rule uses the standard closed form. Non-quadratic mixing
    rules use an autodifferentiated residual Helmholtz energy, preserving
    the exact composition dependence of the selected rule.
    """
    x = normalize_composition(composition)
    z = self.select_z(temperature, pressure, x, phase)
    a, b = self.dimensionless_parameters(temperature, pressure, x)
    if isinstance(
        self.mixing,
        QuadraticMixing | TemperatureDependentQuadraticMixing | PPR78Mixing,
    ):
        pure_a, pure_b = self.pure_parameters(temperature)
        am, bm = self.mixture_parameters(temperature, x)
        if isinstance(self.mixing, PPR78Mixing):
            aij = self.mixing.cross_a(temperature, pure_a, pure_b)
        elif isinstance(self.mixing, TemperatureDependentQuadraticMixing):
            aij = self.mixing.cross_a(temperature, pure_a)
        else:
            aij = self.mixing.cross_a(pure_a)
        sum_aij = torch.einsum("...j,...ij->...i", x, aij)
        partial_b_over_b = self.mixing.partial_b(x, pure_b) / bm[..., None]
        composition_term = 2.0 * sum_aij / am[..., None] - partial_b_over_b
        d1 = self.constants.delta1
        d2 = self.constants.delta2
        log_ratio = torch.log((z + d1 * b) / (z + d2 * b))
        unshifted_log_phi = (
            partial_b_over_b * (z - 1.0)[..., None]
            - torch.log(z - b)[..., None]
            - (a / (b * (d1 - d2)) * log_ratio)[..., None] * composition_term
        )
        # For v = v0 + sum(x_i*c_i), thermodynamic consistency requires
        # ln(phi_i) = ln(phi_i,0) + P*c_i/(R*T).  A common sign error is
        # avoided here by defining ``volume_translation`` as the quantity
        # *added* to the parent-EoS volume in ``molar_volume``.
        return unshifted_log_phi + (pressure / (R * temperature))[
            ..., None
        ] * self.component_volume_translation(temperature)

    translation = self.component_volume_translation(temperature)
    volume = z * R * temperature / pressure + torch.sum(x * translation, dim=-1)

    def at_fixed_volume(moles: Tensor) -> Tensor:
        # Batched states are independent, so differentiating their sum
        # returns the same row-wise chemical potentials as evaluating
        # each scalar state separately. ``torch.func.grad`` requires a
        # scalar output and therefore cannot consume the unsummed batch.
        return self.residual_helmholtz_rt(temperature, volume, moles).sum()

    residual_mu_rt: Tensor = torch.func.grad(at_fixed_volume)(x)
    physical_z = pressure * volume / (R * temperature)
    return residual_mu_rt - torch.log(physical_z)[..., None]

GaoBTerms dataclass

Gao-B critical-region residual Helmholtz terms.

Source code in src/torch_flash/eos/multifluid.py
@dataclass(frozen=True)
class GaoBTerms:
    """Gao-B critical-region residual Helmholtz terms."""

    n: Tensor
    d: Tensor
    t: Tensor
    eta: Tensor
    epsilon: Tensor
    beta: Tensor
    gamma: Tensor
    b: Tensor

    def __post_init__(self) -> None:
        arrays = (self.d, self.t, self.eta, self.epsilon, self.beta, self.gamma, self.b)
        if not all(value.shape == self.n.shape for value in arrays):
            raise ValueError("all Gao-B term arrays must have equal shape")

HelmholtzTerms dataclass

Power, exponential, Gaussian, and GERG-special terms.

The full form is n*delta^d*tau^t*exp(-delta^l -eta*(delta-epsilon)^2-beta*(tau-gamma)^2 -linear_density*(delta-linear_shift)). Setting the optional arrays to zero recovers the power-exponential subset. The final linear-density factor is the binary departure form used by GERG-2008.

Source code in src/torch_flash/eos/multifluid.py
@dataclass(frozen=True)
class HelmholtzTerms:
    """Power, exponential, Gaussian, and GERG-special terms.

    The full form is ``n*delta^d*tau^t*exp(-delta^l
    -eta*(delta-epsilon)^2-beta*(tau-gamma)^2
    -linear_density*(delta-linear_shift))``. Setting the optional arrays to
    zero recovers the power-exponential subset. The final linear-density
    factor is the binary departure form used by GERG-2008.
    """

    n: Tensor
    d: Tensor
    t: Tensor
    decay: Tensor
    eta: Tensor | None = None
    epsilon: Tensor | None = None
    beta: Tensor | None = None
    gamma: Tensor | None = None
    linear_density: Tensor | None = None
    linear_shift: Tensor | None = None

    def __post_init__(self) -> None:
        if not (self.n.shape == self.d.shape == self.t.shape == self.decay.shape):
            raise ValueError("all Helmholtz term arrays must have equal shape")
        gaussian = (self.eta, self.epsilon, self.beta, self.gamma)
        if any(value is not None for value in gaussian) and not all(
            value is not None and value.shape == self.n.shape for value in gaussian
        ):
            raise ValueError("Gaussian arrays must all be present with the term-table shape")
        linear = (self.linear_density, self.linear_shift)
        if any(value is not None for value in linear) and not all(
            value is not None and value.shape == self.n.shape for value in linear
        ):
            raise ValueError("linear-density arrays must both have the term-table shape")

IdealHelmholtzTerms dataclass

Canonical pure-fluid ideal Helmholtz term tables.

Every component has lead, logarithmic, power, Planck--Einstein, and optional GERG sinh/cosh terms. gas_scale is the ratio between the gas constant of the original pure-fluid equation and the common mixture gas constant. It multiplies the density-independent part only, preserving the exact ideal-gas pressure limit.

Source code in src/torch_flash/eos/multifluid.py
@dataclass(frozen=True)
class IdealHelmholtzTerms:
    """Canonical pure-fluid ideal Helmholtz term tables.

    Every component has lead, logarithmic, power, Planck--Einstein, and
    optional GERG sinh/cosh terms. ``gas_scale`` is the ratio between the gas
    constant of the original pure-fluid equation and the common mixture gas
    constant. It multiplies the density-independent part only, preserving the
    exact ideal-gas pressure limit.
    """

    lead_constant: Tensor
    lead_tau: Tensor
    log_tau: Tensor
    tau_log_tau: Tensor
    power_n: Tensor
    power_t: Tensor
    planck_n: Tensor
    planck_theta: Tensor
    gerg_n: Tensor
    gerg_theta: Tensor
    gerg_sign: Tensor
    gas_scale: Tensor

    def __post_init__(self) -> None:
        component_shape = self.lead_constant.shape
        vectors = (
            self.lead_tau,
            self.log_tau,
            self.tau_log_tau,
            self.gas_scale,
        )
        if len(component_shape) != 1 or not all(
            value.shape == component_shape for value in vectors
        ):
            raise ValueError("all ideal Helmholtz lead arrays must have one value per component")
        paired = (
            (self.power_n, self.power_t),
            (self.planck_n, self.planck_theta),
            (self.gerg_n, self.gerg_theta, self.gerg_sign),
        )
        if not all(
            all(value.ndim == 2 and value.shape[0] == component_shape[0] for value in group)
            and all(value.shape == group[0].shape for value in group)
            for group in paired
        ):
            raise ValueError("ideal Helmholtz term tables must have one row per component")

MultiFluidEOS

Bases: Module

Autodifferentiable multifluid Helmholtz model.

Source code in src/torch_flash/eos/multifluid.py
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 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
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
class MultiFluidEOS(nn.Module):
    """Autodifferentiable multifluid Helmholtz model."""

    critical_temperature: Tensor
    critical_density: Tensor
    critical_pressure: Tensor
    acentric_factor: Tensor
    molar_mass: Tensor
    pure_n: Tensor
    pure_d: Tensor
    pure_t: Tensor
    pure_decay: Tensor
    pure_eta: Tensor
    pure_epsilon: Tensor
    pure_beta: Tensor
    pure_gamma: Tensor
    pure_linear_density: Tensor
    pure_linear_shift: Tensor
    departure_n: Tensor
    departure_d: Tensor
    departure_t: Tensor
    departure_decay: Tensor
    departure_eta: Tensor
    departure_epsilon: Tensor
    departure_beta: Tensor
    departure_gamma: Tensor
    departure_linear_density: Tensor
    departure_linear_shift: Tensor
    pure_gaob_n: Tensor
    pure_gaob_d: Tensor
    pure_gaob_t: Tensor
    pure_gaob_eta: Tensor
    pure_gaob_epsilon: Tensor
    pure_gaob_beta: Tensor
    pure_gaob_gamma: Tensor
    pure_gaob_b: Tensor
    pure_nonanalytic_n: Tensor
    pure_nonanalytic_capital_a: Tensor
    pure_nonanalytic_capital_b: Tensor
    pure_nonanalytic_capital_c: Tensor
    pure_nonanalytic_capital_d: Tensor
    pure_nonanalytic_a: Tensor
    pure_nonanalytic_b: Tensor
    pure_nonanalytic_beta: Tensor
    beta_temperature: Tensor
    gamma_temperature: Tensor
    beta_volume: Tensor
    gamma_volume: Tensor
    departure_scale: Tensor
    gas_constant: Tensor
    ideal_lead_constant: Tensor
    ideal_lead_tau: Tensor
    ideal_log_tau: Tensor
    ideal_tau_log_tau: Tensor
    ideal_power_n: Tensor
    ideal_power_t: Tensor
    ideal_planck_n: Tensor
    ideal_planck_theta: Tensor
    ideal_gerg_n: Tensor
    ideal_gerg_theta: Tensor
    ideal_gerg_sign: Tensor
    ideal_gas_scale: Tensor
    _upper_triangle: Tensor
    _critical_temperature_pair: Tensor
    _inverse_density_pair: Tensor

    def __init__(
        self,
        names: tuple[str, ...],
        critical_temperature: Tensor,
        critical_density: Tensor,
        molar_mass: Tensor,
        pure_terms: HelmholtzTerms,
        departure_terms: HelmholtzTerms,
        beta_temperature: Tensor,
        gamma_temperature: Tensor,
        beta_volume: Tensor,
        gamma_volume: Tensor,
        departure_scale: Tensor,
        metadata: MultifluidMetadata,
        *,
        trainable: bool = False,
        gas_constant: float = R,
        pure_gaob_terms: GaoBTerms | None = None,
        pure_nonanalytic_terms: NonAnalyticTerms | None = None,
        ideal_terms: IdealHelmholtzTerms | None = None,
        critical_pressure: Tensor | None = None,
        acentric_factor: Tensor | None = None,
    ) -> None:
        super().__init__()
        ncomponents = len(names)
        if pure_terms.n.shape[0] != ncomponents:
            raise ValueError("pure term table must have one row per component")
        expected_pair = (ncomponents, ncomponents)
        if departure_terms.n.shape[:2] != expected_pair:
            raise ValueError("departure term table must start with (component, component)")
        for matrix in (
            beta_temperature,
            gamma_temperature,
            beta_volume,
            gamma_volume,
            departure_scale,
        ):
            if matrix.shape != expected_pair:
                raise ValueError("all multifluid interaction matrices must be square")
        self.names = names
        self.metadata = metadata
        self.register_buffer(
            "gas_constant",
            critical_temperature.new_tensor(gas_constant),
        )
        self.register_buffer("critical_temperature", critical_temperature.clone())
        self.register_buffer("critical_density", critical_density.clone())
        self.register_buffer(
            "critical_pressure",
            (
                torch.full_like(critical_temperature, torch.nan)
                if critical_pressure is None
                else critical_pressure.clone()
            ),
        )
        self.register_buffer(
            "acentric_factor",
            (
                torch.full_like(critical_temperature, torch.nan)
                if acentric_factor is None
                else acentric_factor.clone()
            ),
        )
        self.register_buffer("molar_mass", molar_mass.clone())
        self._store_terms("pure", pure_terms, trainable)
        self._store_terms("departure", departure_terms, trainable)
        self._store_gaob_terms(pure_gaob_terms, ncomponents, critical_temperature, trainable)
        self._store_nonanalytic_terms(
            pure_nonanalytic_terms, ncomponents, critical_temperature, trainable
        )
        self._store_ideal_terms(ideal_terms, ncomponents, critical_temperature, trainable)
        self._store_parameter("beta_temperature", beta_temperature, trainable)
        self._store_parameter("gamma_temperature", gamma_temperature, trainable)
        self._store_parameter("beta_volume", beta_volume, trainable)
        self._store_parameter("gamma_volume", gamma_volume, trainable)
        self._store_parameter("departure_scale", departure_scale, trainable)
        self.register_buffer(
            "_upper_triangle",
            torch.triu(torch.ones_like(beta_temperature), diagonal=1),
        )
        self.register_buffer(
            "_critical_temperature_pair",
            torch.sqrt(critical_temperature[:, None] * critical_temperature[None, :]),
        )
        self.register_buffer(
            "_inverse_density_pair",
            0.125
            * (
                critical_density[:, None].pow(-1.0 / 3.0)
                + critical_density[None, :].pow(-1.0 / 3.0)
            ).pow(3),
        )

    def _store_parameter(self, name: str, value: Tensor, trainable: bool) -> None:
        if trainable:
            setattr(self, name, nn.Parameter(value.clone()))
        else:
            self.register_buffer(name, value.clone())

    def _store_terms(self, prefix: str, terms: HelmholtzTerms, trainable: bool) -> None:
        self._store_parameter(f"{prefix}_n", terms.n, trainable)
        self.register_buffer(f"{prefix}_d", terms.d.clone())
        self.register_buffer(f"{prefix}_t", terms.t.clone())
        self.register_buffer(f"{prefix}_decay", terms.decay.clone())
        for name in ("eta", "epsilon", "beta", "gamma", "linear_density", "linear_shift"):
            value = getattr(terms, name)
            self.register_buffer(
                f"{prefix}_{name}",
                torch.zeros_like(terms.n) if value is None else value.clone(),
            )

    def _store_gaob_terms(
        self,
        terms: GaoBTerms | None,
        ncomponents: int,
        like: Tensor,
        trainable: bool,
    ) -> None:
        if terms is None:
            zeros = like.new_zeros((ncomponents, 1))
            terms = GaoBTerms(zeros, zeros, zeros, zeros, zeros, zeros, zeros, zeros + 1.0)
        if terms.n.shape[0] != ncomponents:
            raise ValueError("Gao-B term table must have one row per component")
        self._store_parameter("pure_gaob_n", terms.n, trainable)
        for name in ("d", "t", "eta", "epsilon", "beta", "gamma", "b"):
            self.register_buffer(f"pure_gaob_{name}", getattr(terms, name).clone())

    def _store_nonanalytic_terms(
        self,
        terms: NonAnalyticTerms | None,
        ncomponents: int,
        like: Tensor,
        trainable: bool,
    ) -> None:
        if terms is None:
            zeros = like.new_zeros((ncomponents, 1))
            ones = zeros + 1.0
            terms = NonAnalyticTerms(zeros, zeros, zeros, zeros, zeros, ones, ones, ones)
        if terms.n.shape[0] != ncomponents:
            raise ValueError("non-analytic term table must have one row per component")
        self._store_parameter("pure_nonanalytic_n", terms.n, trainable)
        for name in ("capital_a", "capital_b", "capital_c", "capital_d", "a", "b", "beta"):
            self.register_buffer(f"pure_nonanalytic_{name}", getattr(terms, name).clone())

    def _store_ideal_terms(
        self,
        terms: IdealHelmholtzTerms | None,
        ncomponents: int,
        like: Tensor,
        trainable: bool,
    ) -> None:
        self.has_ideal_terms = terms is not None
        if terms is None:
            vector = like.new_zeros(ncomponents)
            table = like.new_zeros((ncomponents, 1))
            terms = IdealHelmholtzTerms(
                vector,
                vector,
                vector,
                vector,
                table,
                table,
                table,
                table + 1.0,
                table,
                table + 1.0,
                table,
                vector + 1.0,
            )
        if terms.lead_constant.shape != (ncomponents,):
            raise ValueError("ideal Helmholtz tables must have one row per component")
        for name in (
            "lead_constant",
            "lead_tau",
            "log_tau",
            "tau_log_tau",
            "power_n",
            "planck_n",
            "gerg_n",
        ):
            self._store_parameter(f"ideal_{name}", getattr(terms, name), trainable)
        for name in (
            "power_t",
            "planck_theta",
            "gerg_theta",
            "gerg_sign",
            "gas_scale",
        ):
            self.register_buffer(f"ideal_{name}", getattr(terms, name).clone())

    def reducing_functions(self, composition: Tensor) -> tuple[Tensor, Tensor]:
        """Return mixture reducing temperature and molar density."""
        x = normalize_composition(composition)
        xi = x[..., :, None]
        xj = x[..., None, :]
        pair_fraction_t = (
            2.0
            * xi
            * xj
            * self.beta_temperature
            * self.gamma_temperature
            * (xi + xj)
            / (self.beta_temperature.square() * xi + xj)
        )
        pair_fraction_v = (
            2.0
            * xi
            * xj
            * self.beta_volume
            * self.gamma_volume
            * (xi + xj)
            / (self.beta_volume.square() * xi + xj)
        )
        reducing_temperature = torch.sum(
            x.square() * self.critical_temperature,
            dim=-1,
        ) + torch.sum(
            self._upper_triangle * pair_fraction_t * self._critical_temperature_pair,
            dim=(-2, -1),
        )
        inverse_reducing_density = torch.sum(
            x.square() / self.critical_density,
            dim=-1,
        ) + torch.sum(
            self._upper_triangle * pair_fraction_v * self._inverse_density_pair,
            dim=(-2, -1),
        )
        return reducing_temperature, inverse_reducing_density.reciprocal()

    @staticmethod
    def _evaluate_terms(
        n: Tensor,
        d: Tensor,
        t: Tensor,
        decay: Tensor,
        eta: Tensor,
        epsilon: Tensor,
        beta: Tensor,
        gamma: Tensor,
        linear_density: Tensor,
        linear_shift: Tensor,
        delta: Tensor,
        tau: Tensor,
    ) -> Tensor:
        term_axes = (None,) * n.ndim
        delta = delta[(..., *term_axes)]
        tau = tau[(..., *term_axes)]
        exponent = (
            -delta.pow(decay) * (decay != 0.0)
            - eta * (delta - epsilon).square()
            - beta * (tau - gamma).square()
            - linear_density * (delta - linear_shift)
        )
        return torch.sum(
            n * delta.pow(d) * tau.pow(t) * torch.exp(exponent),
            dim=-1,
        )

    def _evaluate_gaob(self, delta: Tensor, tau: Tensor) -> Tensor:
        delta = delta[(..., None, None)]
        tau = tau[(..., None, None)]
        exponent = (
            self.pure_gaob_d * torch.log(delta)
            + self.pure_gaob_t * torch.log(tau)
            + self.pure_gaob_eta * (delta - self.pure_gaob_epsilon).square()
            + (
                self.pure_gaob_beta * (tau - self.pure_gaob_gamma).square() + self.pure_gaob_b
            ).reciprocal()
        )
        return torch.sum(self.pure_gaob_n * torch.exp(exponent), dim=-1)

    def _evaluate_nonanalytic(self, delta: Tensor, tau: Tensor) -> Tensor:
        delta = delta[(..., None, None)]
        tau = tau[(..., None, None)]
        delta_offset = delta - 1.0
        delta_squared = delta_offset.square().clamp_min(torch.finfo(delta.dtype).tiny)
        tau_offset = tau - 1.0
        psi = torch.exp(
            -self.pure_nonanalytic_capital_c * delta_squared
            - self.pure_nonanalytic_capital_d * tau_offset.square()
        )
        theta = -tau_offset + self.pure_nonanalytic_capital_a * delta_squared.pow(
            0.5 / self.pure_nonanalytic_beta
        )
        capital_delta = theta.square() + self.pure_nonanalytic_capital_b * delta_squared.pow(
            self.pure_nonanalytic_a
        )
        return torch.sum(
            self.pure_nonanalytic_n * delta * psi * capital_delta.pow(self.pure_nonanalytic_b),
            dim=-1,
        )

    def alpha_residual(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return dimensionless molar residual Helmholtz energy."""
        x = normalize_composition(composition)
        reducing_temperature, reducing_density = self.reducing_functions(x)
        tau = reducing_temperature / temperature
        delta = molar_density / reducing_density
        pure_values = self._evaluate_terms(
            self.pure_n,
            self.pure_d,
            self.pure_t,
            self.pure_decay,
            self.pure_eta,
            self.pure_epsilon,
            self.pure_beta,
            self.pure_gamma,
            self.pure_linear_density,
            self.pure_linear_shift,
            delta,
            tau,
        )
        pure_values = (
            pure_values + self._evaluate_gaob(delta, tau) + self._evaluate_nonanalytic(delta, tau)
        )
        departure_values = self._evaluate_terms(
            self.departure_n,
            self.departure_d,
            self.departure_t,
            self.departure_decay,
            self.departure_eta,
            self.departure_epsilon,
            self.departure_beta,
            self.departure_gamma,
            self.departure_linear_density,
            self.departure_linear_shift,
            delta,
            tau,
        )
        pure = torch.sum(x * pure_values, dim=-1)
        pair_weights = (
            x[..., :, None] * x[..., None, :] * self.departure_scale * self._upper_triangle
        )
        return pure + torch.sum(pair_weights * departure_values, dim=(-2, -1))

    def alpha_ideal(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return dimensionless molar ideal-gas Helmholtz energy."""
        if not self.has_ideal_terms:
            raise RuntimeError("this multifluid model has no ideal Helmholtz coefficient table")
        x = normalize_composition(composition)
        tau = self.critical_temperature / temperature[..., None]
        thermal = (
            self.ideal_lead_constant
            + self.ideal_lead_tau * tau
            + self.ideal_log_tau * torch.log(tau)
            + self.ideal_tau_log_tau * tau * torch.log(tau)
            + torch.sum(
                self.ideal_power_n * tau[..., :, None].pow(self.ideal_power_t),
                dim=-1,
            )
        )
        planck_argument = self.ideal_planck_theta * tau[..., :, None]
        thermal = thermal + torch.sum(
            self.ideal_planck_n * torch.log(-torch.expm1(-planck_argument)),
            dim=-1,
        )
        gerg_argument = (self.ideal_gerg_theta * tau[..., :, None]).abs()
        safe_argument = gerg_argument.clamp_min(torch.finfo(gerg_argument.dtype).tiny)
        log_sinh = (
            safe_argument
            + torch.log1p(-torch.exp(-2.0 * safe_argument))
            - torch.log(safe_argument.new_tensor(2.0))
        )
        log_cosh = torch.logaddexp(safe_argument, -safe_argument) - torch.log(
            safe_argument.new_tensor(2.0)
        )
        gerg_value = torch.where(self.ideal_gerg_sign > 0.0, log_sinh, -log_cosh)
        thermal = thermal + torch.sum(self.ideal_gerg_n * gerg_value, dim=-1)
        pure = (
            torch.log(molar_density[..., None] / self.critical_density)
            + self.ideal_gas_scale * thermal
        )
        return torch.sum(x * pure + torch.special.xlogy(x, x), dim=-1)

    def alpha_total(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return dimensionless total molar Helmholtz energy."""
        return self.alpha_ideal(temperature, molar_density, composition) + self.alpha_residual(
            temperature, molar_density, composition
        )

    def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
        """Return extensive residual Helmholtz energy divided by ``RT``."""
        total = moles.sum(dim=-1)
        x = moles / total[..., None]
        density = total / volume
        return total * self.alpha_residual(temperature, density, x)

    def helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
        """Return extensive total Helmholtz energy divided by ``RT``."""
        total = moles.sum(dim=-1)
        x = moles / total[..., None]
        return total * self.alpha_total(temperature, total / volume, x)

    def molar_helmholtz_energy(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total molar Helmholtz energy in J/mol."""
        return (
            self.gas_constant
            * temperature
            * self.alpha_total(temperature, molar_density, composition)
        )

    def molar_entropy(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total molar entropy in J/(mol K)."""
        derivative: Tensor = torch.func.grad(
            lambda current: self.molar_helmholtz_energy(
                current,
                molar_density,
                composition,
            ).sum()
        )(temperature)
        return -derivative

    def molar_internal_energy(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total molar internal energy in J/mol."""
        helmholtz = self.molar_helmholtz_energy(temperature, molar_density, composition)
        return helmholtz + temperature * self.molar_entropy(temperature, molar_density, composition)

    def molar_heat_capacity_cv(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return isochoric molar heat capacity in J/(mol K)."""
        derivative: Tensor = torch.func.grad(
            lambda current: self.molar_internal_energy(
                current,
                molar_density,
                composition,
            ).sum()
        )(temperature)
        return derivative

    def molar_heat_capacity_cp(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return isobaric molar heat capacity in J/(mol K)."""
        volume = molar_density.reciprocal()
        cv = self.molar_heat_capacity_cv(temperature, molar_density, composition)
        dp_dt: Tensor = torch.func.grad(
            lambda current: self.pressure(current, volume, composition).sum()
        )(temperature)
        dp_drho: Tensor = torch.func.grad(
            lambda density: self.pressure(
                temperature,
                density.reciprocal(),
                composition,
            ).sum()
        )(molar_density)
        return cv + temperature * dp_dt.square() / (molar_density.square() * dp_drho)

    def molar_enthalpy(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total molar enthalpy in J/mol."""
        volume = molar_density.reciprocal()
        return (
            self.molar_internal_energy(temperature, molar_density, composition)
            + self.pressure(temperature, volume, composition) * volume
        )

    def molar_gibbs_energy(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total molar Gibbs energy in J/mol."""
        volume = molar_density.reciprocal()
        return (
            self.molar_helmholtz_energy(temperature, molar_density, composition)
            + self.pressure(temperature, volume, composition) * volume
        )

    def chemical_potentials(
        self, temperature: Tensor, molar_volume: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total component chemical potentials in J/mol."""
        x = normalize_composition(composition)
        reduced: Tensor = torch.func.grad(
            lambda moles: self.helmholtz_rt(
                temperature,
                molar_volume,
                moles,
            ).sum()
        )(x)
        return self.gas_constant * temperature * reduced

    def speed_of_sound(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return homogeneous-phase speed of sound in m/s."""
        x = normalize_composition(composition)
        dp_drho: Tensor = torch.func.grad(
            lambda density: self.pressure(
                temperature,
                density.reciprocal(),
                x,
            ).sum()
        )(molar_density)
        cv = self.molar_heat_capacity_cv(temperature, molar_density, x)
        cp = self.molar_heat_capacity_cp(temperature, molar_density, x)
        mixture_molar_mass = torch.sum(x * self.molar_mass, dim=-1)
        return torch.sqrt((cp / cv) * dp_drho / mixture_molar_mass)

    def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
        """Return pressure from a residual-Helmholtz density derivative.

        Leading state dimensions are broadcast. The summed scalar objective
        used by ``torch.func.grad`` differentiates independent batch entries
        elementwise because the Helmholtz kernel contains no cross-state terms.
        """
        x = normalize_composition(composition)
        molar_density = molar_volume.reciprocal()
        derivative: Tensor = torch.func.grad(
            lambda density: self.alpha_residual(temperature, density, x).sum()
        )(molar_density)
        return self.gas_constant * temperature * molar_density * (1.0 + molar_density * derivative)

    def molar_volume(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Solve and select a homogeneous density root.

        Phase-specific states first use a differentiable logarithmic-density
        Newton solve.  If it fails, a logarithmic scan brackets every
        mechanically admissible positive root before bisection and Newton
        polishing.  This conservative fallback matters because Helmholtz
        mixture models can have three density roots below the mixture critical
        locus, and an initial guess can cross a spinodal during phase-envelope
        calculations.
        """
        x = normalize_composition(composition)
        if x.shape[-1] != len(self.names):
            raise ValueError("multifluid composition has the wrong number of components")
        if phase not in ("vapor", "liquid", "stable"):
            raise ValueError(f"unknown phase root {phase!r}")
        batch_shape = torch.broadcast_shapes(
            temperature.shape,
            pressure.shape,
            x.shape[:-1],
        )
        if batch_shape:
            temperature = torch.broadcast_to(temperature, batch_shape)
            pressure = torch.broadcast_to(pressure, batch_shape)
            x = torch.broadcast_to(x, (*batch_shape, len(self.names)))
            if phase in ("vapor", "liquid"):
                return self._batched_phase_volume(temperature, pressure, x, phase)
            flattened_temperature = temperature.reshape(-1)
            flattened_pressure = pressure.reshape(-1)
            flattened_composition = x.reshape(-1, len(self.names))
            return torch.stack(
                [
                    self.molar_volume(current_temperature, current_pressure, current_x, phase)
                    for current_temperature, current_pressure, current_x in zip(
                        flattened_temperature,
                        flattened_pressure,
                        flattened_composition,
                        strict=True,
                    )
                ]
            ).reshape(batch_shape)

        ideal_density = pressure / (self.gas_constant * temperature)
        density_scale = torch.sum(x * self.critical_density)
        reducing_temperature, _ = self.reducing_functions(x)

        def residual(current: Tensor) -> Tensor:
            volume = torch.exp(-current)
            return (self.pressure(temperature, volume, x) - pressure) / pressure

        needs_implicit_gradient = (
            temperature.requires_grad
            or pressure.requires_grad
            or x.requires_grad
            or any(parameter.requires_grad for parameter in self.parameters())
        )

        def volume_with_implicit_gradient(current: Tensor) -> Tensor:
            """Restore root derivatives after detached numerical iteration."""
            if needs_implicit_gradient:
                value = residual(current)
                derivative: Tensor = torch.func.grad(residual)(current)
                current = current - value / derivative
            return torch.exp(-current)

        # Locate a phase-specific root with detached finite-difference Newton
        # steps. Differentiating every iteration nests reverse-mode AD through
        # the residual Helmholtz pressure derivative and makes saturation
        # calculations unnecessarily expensive. One exact correction at the
        # converged root restores the implicit state and parameter gradients.
        # The scan below remains the conservative fallback near spinodals or
        # when the conventional seed lies in the basin of another root.
        if phase != "stable":
            density = (
                ideal_density
                if phase == "vapor"
                else torch.maximum(ideal_density, 3.0 * density_scale)
            )
            log_density = torch.log(density)
            slope_offset = log_density.new_tensor(1.0e-4)
            for _ in range(20):
                value = residual(log_density)
                if float(value.detach().abs()) <= 1.0e-10:
                    solved_density = torch.exp(log_density)
                    phase_consistent = (
                        solved_density <= density_scale
                        if phase == "vapor"
                        else solved_density >= density_scale
                    )
                    if bool((temperature >= reducing_temperature) | phase_consistent):
                        return volume_with_implicit_gradient(log_density)
                    break
                derivative = (
                    residual(log_density + slope_offset) - residual(log_density - slope_offset)
                ) / (2.0 * slope_offset)
                step = torch.clamp(-value / derivative, -0.5, 0.5)
                if not bool(torch.isfinite(step)):
                    break
                log_density = (log_density + step).detach()

        minimum = torch.minimum(ideal_density * 1.0e-4, density_scale * 1.0e-8)
        maximum = torch.maximum(
            ideal_density * 10.0,
            100.0 * torch.max(self.critical_density),
        )
        grid = torch.logspace(
            float(torch.log10(minimum.detach())),
            float(torch.log10(maximum.detach())),
            96,
            dtype=temperature.dtype,
            device=temperature.device,
        )

        brackets: list[tuple[Tensor, Tensor]] = []
        left = torch.log(grid[0])
        left_value = residual(left)
        for density in grid[1:]:
            right = torch.log(density)
            right_value = residual(right)
            finite = bool(torch.isfinite(left_value) & torch.isfinite(right_value))
            changes_sign = bool(torch.signbit(left_value) != torch.signbit(right_value))
            if finite and (
                float(left_value.detach()) == 0.0
                or float(right_value.detach()) == 0.0
                or changes_sign
            ):
                brackets.append((left, right))
            left, left_value = right, right_value
        if not brackets:
            raise ConvergenceError("multifluid density solve did not converge")

        roots: list[Tensor] = []
        for left, right in brackets:
            left_value = residual(left)
            midpoint = 0.5 * (left + right)
            for _ in range(80):
                midpoint = 0.5 * (left + right)
                midpoint_value = residual(midpoint)
                if float(midpoint_value.detach().abs()) <= 1.0e-12:
                    break
                if bool(torch.signbit(left_value) != torch.signbit(midpoint_value)):
                    right = midpoint
                else:
                    left = midpoint
                    left_value = midpoint_value

            root = torch.exp(midpoint)
            if not roots or abs(float((root / roots[-1] - 1.0).detach())) > 1.0e-8:
                roots.append(root)

        if phase == "vapor":
            density = roots[0]
        elif phase == "liquid":
            density = roots[-1]
        elif phase == "stable":
            gibbs: list[Tensor] = []
            for density_root in roots:
                volume = density_root.reciprocal()
                z = pressure * volume / (self.gas_constant * temperature)
                residual_helmholtz = self.residual_helmholtz_rt(temperature, volume, x)
                gibbs.append(residual_helmholtz + z - 1.0 - torch.log(z))
            density = roots[int(torch.argmin(torch.stack(gibbs)).detach())]
        return volume_with_implicit_gradient(torch.log(density))

    def _batched_phase_volume(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: Literal["liquid", "vapor"],
    ) -> Tensor:
        """Solve independent phase-specific states with one tensor workload.

        A conventional phase-specific seed is advanced for the complete batch.
        Extra density seeds are evaluated only for the failed subset. Selecting
        its lowest or highest converged root handles cases in which the
        requested vapor-like branch is the sole, dense root above saturation
        pressure. Only states for which every seed fails use the conservative
        scalar root scan.
        """
        batch_shape = temperature.shape
        flat_temperature = temperature.reshape(-1)
        flat_pressure = pressure.reshape(-1)
        flat_composition = composition.reshape(-1, len(self.names))
        ideal_density = flat_pressure / (self.gas_constant * flat_temperature)
        density_scale = torch.sum(flat_composition * self.critical_density, dim=-1)
        reducing_temperature, _ = self.reducing_functions(flat_composition)
        density = (
            ideal_density if phase == "vapor" else torch.maximum(ideal_density, 3.0 * density_scale)
        )
        log_density = torch.log(density)

        def residual(current: Tensor) -> Tensor:
            volume = torch.exp(-current)
            return (
                self.pressure(
                    flat_temperature,
                    volume,
                    flat_composition,
                )
                - flat_pressure
            ) / flat_pressure

        # A fixed workload avoids per-state Python dispatch and device
        # synchronization. A centered log-density slope is much faster here
        # than nesting reverse-mode AD through the already differentiated
        # pressure kernel. It is used only to locate the root.
        slope_offset = log_density.new_tensor(1.0e-4)
        for _ in range(10):
            value = residual(log_density)
            derivative = (
                residual(log_density + slope_offset) - residual(log_density - slope_offset)
            ) / (2.0 * slope_offset)
            step = torch.clamp(-value / derivative, -0.5, 0.5)
            step = torch.where(torch.isfinite(step), step, torch.zeros_like(step))
            log_density = (log_density + step).detach()

        primary_residual = residual(log_density)
        primary_density = torch.exp(log_density)
        phase_consistent = (
            primary_density <= density_scale
            if phase == "vapor"
            else primary_density >= density_scale
        )
        primary_converged = (
            torch.isfinite(primary_residual)
            & (primary_residual.abs() <= 1.0e-8)
            & ((flat_temperature >= reducing_temperature) | phase_consistent)
        )
        preliminary_converged = primary_converged

        if not bool(primary_converged.all()):
            failed_indices = torch.nonzero(
                ~primary_converged,
                as_tuple=False,
            ).flatten()
            failed_temperature = flat_temperature[failed_indices]
            failed_pressure = flat_pressure[failed_indices]
            failed_composition = flat_composition[failed_indices]
            failed_ideal_density = ideal_density[failed_indices]
            failed_density_scale = density_scale[failed_indices]
            if phase == "vapor":
                candidate_density = torch.stack(
                    (
                        0.2 * failed_ideal_density,
                        failed_ideal_density,
                        torch.maximum(
                            2.0 * failed_ideal_density,
                            1.5 * failed_density_scale,
                        ),
                    ),
                    dim=-1,
                )
            else:
                candidate_density = torch.stack(
                    (
                        torch.maximum(
                            failed_ideal_density,
                            1.5 * failed_density_scale,
                        ),
                        torch.maximum(
                            failed_ideal_density,
                            3.0 * failed_density_scale,
                        ),
                        torch.maximum(
                            failed_ideal_density,
                            10.0 * failed_density_scale,
                        ),
                    ),
                    dim=-1,
                )
            candidate_log_density = torch.log(candidate_density)

            def candidate_residual(current: Tensor) -> Tensor:
                volume = torch.exp(-current)
                return (
                    self.pressure(
                        failed_temperature[:, None],
                        volume,
                        failed_composition[:, None, :],
                    )
                    - failed_pressure[:, None]
                ) / failed_pressure[:, None]

            for _ in range(10):
                value = candidate_residual(candidate_log_density)
                derivative = (
                    candidate_residual(candidate_log_density + slope_offset)
                    - candidate_residual(candidate_log_density - slope_offset)
                ) / (2.0 * slope_offset)
                step = torch.clamp(-value / derivative, -0.5, 0.5)
                step = torch.where(
                    torch.isfinite(step),
                    step,
                    torch.zeros_like(step),
                )
                candidate_log_density = (candidate_log_density + step).detach()

            candidate_value = candidate_residual(candidate_log_density)
            candidate_converged = torch.isfinite(candidate_value) & (
                candidate_value.abs() <= 1.0e-8
            )
            if phase == "vapor":
                ranked_density = torch.where(
                    candidate_converged,
                    candidate_log_density,
                    torch.full_like(candidate_log_density, torch.inf),
                )
                candidate_index = torch.argmin(
                    ranked_density,
                    dim=-1,
                    keepdim=True,
                )
            else:
                ranked_density = torch.where(
                    candidate_converged,
                    candidate_log_density,
                    torch.full_like(candidate_log_density, -torch.inf),
                )
                candidate_index = torch.argmax(
                    ranked_density,
                    dim=-1,
                    keepdim=True,
                )
            selected_candidate = torch.gather(
                candidate_log_density,
                -1,
                candidate_index,
            ).squeeze(-1)
            log_density = log_density.index_copy(
                0,
                failed_indices,
                selected_candidate,
            )
            preliminary_converged = primary_converged.index_copy(
                0,
                failed_indices,
                candidate_converged.any(dim=-1),
            )

        # Two detached corrections tighten the numerical root without growing
        # an optimization graph through the root-finding history.
        for _ in range(2):
            value = residual(log_density)
            derivative = (
                residual(log_density + slope_offset) - residual(log_density - slope_offset)
            ) / (2.0 * slope_offset)
            step = torch.where(
                torch.isfinite(derivative)
                & (derivative.abs() > torch.finfo(derivative.dtype).tiny),
                value / derivative,
                torch.zeros_like(value),
            )
            log_density = (log_density - step).detach()

        needs_implicit_gradient = (
            temperature.requires_grad
            or pressure.requires_grad
            or composition.requires_grad
            or any(parameter.requires_grad for parameter in self.parameters())
        )
        if needs_implicit_gradient:
            # One exact Newton correction at an already converged root restores
            # the implicit derivatives with respect to state and fitted model
            # parameters; the preceding numerical iterations remain detached.
            value = residual(log_density)
            derivative = torch.func.grad(lambda current: residual(current).sum())(log_density)
            log_density = log_density - value / derivative

        value = residual(log_density)
        solved_density = torch.exp(log_density)
        converged = preliminary_converged & torch.isfinite(value) & (value.abs() <= 1.0e-10)
        if bool(converged.all()):
            return solved_density.reciprocal().reshape(batch_shape)

        # The conservative scalar scan is retained for the uncommon states
        # for which every batched seed fails. Preserve the already solved
        # entries instead of sending the complete batch through Python.
        failed_indices = torch.nonzero(
            ~converged,
            as_tuple=False,
        ).flatten()
        fallback = torch.stack(
            [
                self.molar_volume(
                    flat_temperature[index],
                    flat_pressure[index],
                    flat_composition[index],
                    phase,
                )
                for index in failed_indices.tolist()
            ]
        )
        volumes = solved_density.reciprocal()
        return volumes.index_copy(0, failed_indices, fallback).reshape(batch_shape)

    def select_z(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return compressibility factor."""
        volume = self.molar_volume(temperature, pressure, composition, phase)
        return pressure * volume / (self.gas_constant * temperature)

    def log_fugacity_coefficients(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return log fugacity coefficients from composition derivatives."""
        x = normalize_composition(composition)
        volume = self.molar_volume(temperature, pressure, x, phase)
        residual_mu: Tensor = torch.func.grad(
            lambda moles: self.residual_helmholtz_rt(
                temperature,
                volume,
                moles,
            ).sum()
        )(x)
        z = pressure * volume / (self.gas_constant * temperature)
        return residual_mu - torch.log(z)[..., None]

reducing_functions

reducing_functions(composition)

Return mixture reducing temperature and molar density.

Source code in src/torch_flash/eos/multifluid.py
def reducing_functions(self, composition: Tensor) -> tuple[Tensor, Tensor]:
    """Return mixture reducing temperature and molar density."""
    x = normalize_composition(composition)
    xi = x[..., :, None]
    xj = x[..., None, :]
    pair_fraction_t = (
        2.0
        * xi
        * xj
        * self.beta_temperature
        * self.gamma_temperature
        * (xi + xj)
        / (self.beta_temperature.square() * xi + xj)
    )
    pair_fraction_v = (
        2.0
        * xi
        * xj
        * self.beta_volume
        * self.gamma_volume
        * (xi + xj)
        / (self.beta_volume.square() * xi + xj)
    )
    reducing_temperature = torch.sum(
        x.square() * self.critical_temperature,
        dim=-1,
    ) + torch.sum(
        self._upper_triangle * pair_fraction_t * self._critical_temperature_pair,
        dim=(-2, -1),
    )
    inverse_reducing_density = torch.sum(
        x.square() / self.critical_density,
        dim=-1,
    ) + torch.sum(
        self._upper_triangle * pair_fraction_v * self._inverse_density_pair,
        dim=(-2, -1),
    )
    return reducing_temperature, inverse_reducing_density.reciprocal()

alpha_residual

alpha_residual(temperature, molar_density, composition)

Return dimensionless molar residual Helmholtz energy.

Source code in src/torch_flash/eos/multifluid.py
def alpha_residual(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return dimensionless molar residual Helmholtz energy."""
    x = normalize_composition(composition)
    reducing_temperature, reducing_density = self.reducing_functions(x)
    tau = reducing_temperature / temperature
    delta = molar_density / reducing_density
    pure_values = self._evaluate_terms(
        self.pure_n,
        self.pure_d,
        self.pure_t,
        self.pure_decay,
        self.pure_eta,
        self.pure_epsilon,
        self.pure_beta,
        self.pure_gamma,
        self.pure_linear_density,
        self.pure_linear_shift,
        delta,
        tau,
    )
    pure_values = (
        pure_values + self._evaluate_gaob(delta, tau) + self._evaluate_nonanalytic(delta, tau)
    )
    departure_values = self._evaluate_terms(
        self.departure_n,
        self.departure_d,
        self.departure_t,
        self.departure_decay,
        self.departure_eta,
        self.departure_epsilon,
        self.departure_beta,
        self.departure_gamma,
        self.departure_linear_density,
        self.departure_linear_shift,
        delta,
        tau,
    )
    pure = torch.sum(x * pure_values, dim=-1)
    pair_weights = (
        x[..., :, None] * x[..., None, :] * self.departure_scale * self._upper_triangle
    )
    return pure + torch.sum(pair_weights * departure_values, dim=(-2, -1))

alpha_ideal

alpha_ideal(temperature, molar_density, composition)

Return dimensionless molar ideal-gas Helmholtz energy.

Source code in src/torch_flash/eos/multifluid.py
def alpha_ideal(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return dimensionless molar ideal-gas Helmholtz energy."""
    if not self.has_ideal_terms:
        raise RuntimeError("this multifluid model has no ideal Helmholtz coefficient table")
    x = normalize_composition(composition)
    tau = self.critical_temperature / temperature[..., None]
    thermal = (
        self.ideal_lead_constant
        + self.ideal_lead_tau * tau
        + self.ideal_log_tau * torch.log(tau)
        + self.ideal_tau_log_tau * tau * torch.log(tau)
        + torch.sum(
            self.ideal_power_n * tau[..., :, None].pow(self.ideal_power_t),
            dim=-1,
        )
    )
    planck_argument = self.ideal_planck_theta * tau[..., :, None]
    thermal = thermal + torch.sum(
        self.ideal_planck_n * torch.log(-torch.expm1(-planck_argument)),
        dim=-1,
    )
    gerg_argument = (self.ideal_gerg_theta * tau[..., :, None]).abs()
    safe_argument = gerg_argument.clamp_min(torch.finfo(gerg_argument.dtype).tiny)
    log_sinh = (
        safe_argument
        + torch.log1p(-torch.exp(-2.0 * safe_argument))
        - torch.log(safe_argument.new_tensor(2.0))
    )
    log_cosh = torch.logaddexp(safe_argument, -safe_argument) - torch.log(
        safe_argument.new_tensor(2.0)
    )
    gerg_value = torch.where(self.ideal_gerg_sign > 0.0, log_sinh, -log_cosh)
    thermal = thermal + torch.sum(self.ideal_gerg_n * gerg_value, dim=-1)
    pure = (
        torch.log(molar_density[..., None] / self.critical_density)
        + self.ideal_gas_scale * thermal
    )
    return torch.sum(x * pure + torch.special.xlogy(x, x), dim=-1)

alpha_total

alpha_total(temperature, molar_density, composition)

Return dimensionless total molar Helmholtz energy.

Source code in src/torch_flash/eos/multifluid.py
def alpha_total(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return dimensionless total molar Helmholtz energy."""
    return self.alpha_ideal(temperature, molar_density, composition) + self.alpha_residual(
        temperature, molar_density, composition
    )

residual_helmholtz_rt

residual_helmholtz_rt(temperature, volume, moles)

Return extensive residual Helmholtz energy divided by RT.

Source code in src/torch_flash/eos/multifluid.py
def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
    """Return extensive residual Helmholtz energy divided by ``RT``."""
    total = moles.sum(dim=-1)
    x = moles / total[..., None]
    density = total / volume
    return total * self.alpha_residual(temperature, density, x)

helmholtz_rt

helmholtz_rt(temperature, volume, moles)

Return extensive total Helmholtz energy divided by RT.

Source code in src/torch_flash/eos/multifluid.py
def helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
    """Return extensive total Helmholtz energy divided by ``RT``."""
    total = moles.sum(dim=-1)
    x = moles / total[..., None]
    return total * self.alpha_total(temperature, total / volume, x)

molar_helmholtz_energy

molar_helmholtz_energy(temperature, molar_density, composition)

Return total molar Helmholtz energy in J/mol.

Source code in src/torch_flash/eos/multifluid.py
def molar_helmholtz_energy(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return total molar Helmholtz energy in J/mol."""
    return (
        self.gas_constant
        * temperature
        * self.alpha_total(temperature, molar_density, composition)
    )

molar_entropy

molar_entropy(temperature, molar_density, composition)

Return total molar entropy in J/(mol K).

Source code in src/torch_flash/eos/multifluid.py
def molar_entropy(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return total molar entropy in J/(mol K)."""
    derivative: Tensor = torch.func.grad(
        lambda current: self.molar_helmholtz_energy(
            current,
            molar_density,
            composition,
        ).sum()
    )(temperature)
    return -derivative

molar_internal_energy

molar_internal_energy(temperature, molar_density, composition)

Return total molar internal energy in J/mol.

Source code in src/torch_flash/eos/multifluid.py
def molar_internal_energy(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return total molar internal energy in J/mol."""
    helmholtz = self.molar_helmholtz_energy(temperature, molar_density, composition)
    return helmholtz + temperature * self.molar_entropy(temperature, molar_density, composition)

molar_heat_capacity_cv

molar_heat_capacity_cv(temperature, molar_density, composition)

Return isochoric molar heat capacity in J/(mol K).

Source code in src/torch_flash/eos/multifluid.py
def molar_heat_capacity_cv(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return isochoric molar heat capacity in J/(mol K)."""
    derivative: Tensor = torch.func.grad(
        lambda current: self.molar_internal_energy(
            current,
            molar_density,
            composition,
        ).sum()
    )(temperature)
    return derivative

molar_heat_capacity_cp

molar_heat_capacity_cp(temperature, molar_density, composition)

Return isobaric molar heat capacity in J/(mol K).

Source code in src/torch_flash/eos/multifluid.py
def molar_heat_capacity_cp(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return isobaric molar heat capacity in J/(mol K)."""
    volume = molar_density.reciprocal()
    cv = self.molar_heat_capacity_cv(temperature, molar_density, composition)
    dp_dt: Tensor = torch.func.grad(
        lambda current: self.pressure(current, volume, composition).sum()
    )(temperature)
    dp_drho: Tensor = torch.func.grad(
        lambda density: self.pressure(
            temperature,
            density.reciprocal(),
            composition,
        ).sum()
    )(molar_density)
    return cv + temperature * dp_dt.square() / (molar_density.square() * dp_drho)

molar_enthalpy

molar_enthalpy(temperature, molar_density, composition)

Return total molar enthalpy in J/mol.

Source code in src/torch_flash/eos/multifluid.py
def molar_enthalpy(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return total molar enthalpy in J/mol."""
    volume = molar_density.reciprocal()
    return (
        self.molar_internal_energy(temperature, molar_density, composition)
        + self.pressure(temperature, volume, composition) * volume
    )

molar_gibbs_energy

molar_gibbs_energy(temperature, molar_density, composition)

Return total molar Gibbs energy in J/mol.

Source code in src/torch_flash/eos/multifluid.py
def molar_gibbs_energy(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return total molar Gibbs energy in J/mol."""
    volume = molar_density.reciprocal()
    return (
        self.molar_helmholtz_energy(temperature, molar_density, composition)
        + self.pressure(temperature, volume, composition) * volume
    )

chemical_potentials

chemical_potentials(temperature, molar_volume, composition)

Return total component chemical potentials in J/mol.

Source code in src/torch_flash/eos/multifluid.py
def chemical_potentials(
    self, temperature: Tensor, molar_volume: Tensor, composition: Tensor
) -> Tensor:
    """Return total component chemical potentials in J/mol."""
    x = normalize_composition(composition)
    reduced: Tensor = torch.func.grad(
        lambda moles: self.helmholtz_rt(
            temperature,
            molar_volume,
            moles,
        ).sum()
    )(x)
    return self.gas_constant * temperature * reduced

speed_of_sound

speed_of_sound(temperature, molar_density, composition)

Return homogeneous-phase speed of sound in m/s.

Source code in src/torch_flash/eos/multifluid.py
def speed_of_sound(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return homogeneous-phase speed of sound in m/s."""
    x = normalize_composition(composition)
    dp_drho: Tensor = torch.func.grad(
        lambda density: self.pressure(
            temperature,
            density.reciprocal(),
            x,
        ).sum()
    )(molar_density)
    cv = self.molar_heat_capacity_cv(temperature, molar_density, x)
    cp = self.molar_heat_capacity_cp(temperature, molar_density, x)
    mixture_molar_mass = torch.sum(x * self.molar_mass, dim=-1)
    return torch.sqrt((cp / cv) * dp_drho / mixture_molar_mass)

pressure

pressure(temperature, molar_volume, composition)

Return pressure from a residual-Helmholtz density derivative.

Leading state dimensions are broadcast. The summed scalar objective used by torch.func.grad differentiates independent batch entries elementwise because the Helmholtz kernel contains no cross-state terms.

Source code in src/torch_flash/eos/multifluid.py
def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
    """Return pressure from a residual-Helmholtz density derivative.

    Leading state dimensions are broadcast. The summed scalar objective
    used by ``torch.func.grad`` differentiates independent batch entries
    elementwise because the Helmholtz kernel contains no cross-state terms.
    """
    x = normalize_composition(composition)
    molar_density = molar_volume.reciprocal()
    derivative: Tensor = torch.func.grad(
        lambda density: self.alpha_residual(temperature, density, x).sum()
    )(molar_density)
    return self.gas_constant * temperature * molar_density * (1.0 + molar_density * derivative)

molar_volume

molar_volume(temperature, pressure, composition, phase='stable')

Solve and select a homogeneous density root.

Phase-specific states first use a differentiable logarithmic-density Newton solve. If it fails, a logarithmic scan brackets every mechanically admissible positive root before bisection and Newton polishing. This conservative fallback matters because Helmholtz mixture models can have three density roots below the mixture critical locus, and an initial guess can cross a spinodal during phase-envelope calculations.

Source code in src/torch_flash/eos/multifluid.py
def molar_volume(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Solve and select a homogeneous density root.

    Phase-specific states first use a differentiable logarithmic-density
    Newton solve.  If it fails, a logarithmic scan brackets every
    mechanically admissible positive root before bisection and Newton
    polishing.  This conservative fallback matters because Helmholtz
    mixture models can have three density roots below the mixture critical
    locus, and an initial guess can cross a spinodal during phase-envelope
    calculations.
    """
    x = normalize_composition(composition)
    if x.shape[-1] != len(self.names):
        raise ValueError("multifluid composition has the wrong number of components")
    if phase not in ("vapor", "liquid", "stable"):
        raise ValueError(f"unknown phase root {phase!r}")
    batch_shape = torch.broadcast_shapes(
        temperature.shape,
        pressure.shape,
        x.shape[:-1],
    )
    if batch_shape:
        temperature = torch.broadcast_to(temperature, batch_shape)
        pressure = torch.broadcast_to(pressure, batch_shape)
        x = torch.broadcast_to(x, (*batch_shape, len(self.names)))
        if phase in ("vapor", "liquid"):
            return self._batched_phase_volume(temperature, pressure, x, phase)
        flattened_temperature = temperature.reshape(-1)
        flattened_pressure = pressure.reshape(-1)
        flattened_composition = x.reshape(-1, len(self.names))
        return torch.stack(
            [
                self.molar_volume(current_temperature, current_pressure, current_x, phase)
                for current_temperature, current_pressure, current_x in zip(
                    flattened_temperature,
                    flattened_pressure,
                    flattened_composition,
                    strict=True,
                )
            ]
        ).reshape(batch_shape)

    ideal_density = pressure / (self.gas_constant * temperature)
    density_scale = torch.sum(x * self.critical_density)
    reducing_temperature, _ = self.reducing_functions(x)

    def residual(current: Tensor) -> Tensor:
        volume = torch.exp(-current)
        return (self.pressure(temperature, volume, x) - pressure) / pressure

    needs_implicit_gradient = (
        temperature.requires_grad
        or pressure.requires_grad
        or x.requires_grad
        or any(parameter.requires_grad for parameter in self.parameters())
    )

    def volume_with_implicit_gradient(current: Tensor) -> Tensor:
        """Restore root derivatives after detached numerical iteration."""
        if needs_implicit_gradient:
            value = residual(current)
            derivative: Tensor = torch.func.grad(residual)(current)
            current = current - value / derivative
        return torch.exp(-current)

    # Locate a phase-specific root with detached finite-difference Newton
    # steps. Differentiating every iteration nests reverse-mode AD through
    # the residual Helmholtz pressure derivative and makes saturation
    # calculations unnecessarily expensive. One exact correction at the
    # converged root restores the implicit state and parameter gradients.
    # The scan below remains the conservative fallback near spinodals or
    # when the conventional seed lies in the basin of another root.
    if phase != "stable":
        density = (
            ideal_density
            if phase == "vapor"
            else torch.maximum(ideal_density, 3.0 * density_scale)
        )
        log_density = torch.log(density)
        slope_offset = log_density.new_tensor(1.0e-4)
        for _ in range(20):
            value = residual(log_density)
            if float(value.detach().abs()) <= 1.0e-10:
                solved_density = torch.exp(log_density)
                phase_consistent = (
                    solved_density <= density_scale
                    if phase == "vapor"
                    else solved_density >= density_scale
                )
                if bool((temperature >= reducing_temperature) | phase_consistent):
                    return volume_with_implicit_gradient(log_density)
                break
            derivative = (
                residual(log_density + slope_offset) - residual(log_density - slope_offset)
            ) / (2.0 * slope_offset)
            step = torch.clamp(-value / derivative, -0.5, 0.5)
            if not bool(torch.isfinite(step)):
                break
            log_density = (log_density + step).detach()

    minimum = torch.minimum(ideal_density * 1.0e-4, density_scale * 1.0e-8)
    maximum = torch.maximum(
        ideal_density * 10.0,
        100.0 * torch.max(self.critical_density),
    )
    grid = torch.logspace(
        float(torch.log10(minimum.detach())),
        float(torch.log10(maximum.detach())),
        96,
        dtype=temperature.dtype,
        device=temperature.device,
    )

    brackets: list[tuple[Tensor, Tensor]] = []
    left = torch.log(grid[0])
    left_value = residual(left)
    for density in grid[1:]:
        right = torch.log(density)
        right_value = residual(right)
        finite = bool(torch.isfinite(left_value) & torch.isfinite(right_value))
        changes_sign = bool(torch.signbit(left_value) != torch.signbit(right_value))
        if finite and (
            float(left_value.detach()) == 0.0
            or float(right_value.detach()) == 0.0
            or changes_sign
        ):
            brackets.append((left, right))
        left, left_value = right, right_value
    if not brackets:
        raise ConvergenceError("multifluid density solve did not converge")

    roots: list[Tensor] = []
    for left, right in brackets:
        left_value = residual(left)
        midpoint = 0.5 * (left + right)
        for _ in range(80):
            midpoint = 0.5 * (left + right)
            midpoint_value = residual(midpoint)
            if float(midpoint_value.detach().abs()) <= 1.0e-12:
                break
            if bool(torch.signbit(left_value) != torch.signbit(midpoint_value)):
                right = midpoint
            else:
                left = midpoint
                left_value = midpoint_value

        root = torch.exp(midpoint)
        if not roots or abs(float((root / roots[-1] - 1.0).detach())) > 1.0e-8:
            roots.append(root)

    if phase == "vapor":
        density = roots[0]
    elif phase == "liquid":
        density = roots[-1]
    elif phase == "stable":
        gibbs: list[Tensor] = []
        for density_root in roots:
            volume = density_root.reciprocal()
            z = pressure * volume / (self.gas_constant * temperature)
            residual_helmholtz = self.residual_helmholtz_rt(temperature, volume, x)
            gibbs.append(residual_helmholtz + z - 1.0 - torch.log(z))
        density = roots[int(torch.argmin(torch.stack(gibbs)).detach())]
    return volume_with_implicit_gradient(torch.log(density))

select_z

select_z(temperature, pressure, composition, phase='stable')

Return compressibility factor.

Source code in src/torch_flash/eos/multifluid.py
def select_z(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return compressibility factor."""
    volume = self.molar_volume(temperature, pressure, composition, phase)
    return pressure * volume / (self.gas_constant * temperature)

log_fugacity_coefficients

log_fugacity_coefficients(temperature, pressure, composition, phase='stable')

Return log fugacity coefficients from composition derivatives.

Source code in src/torch_flash/eos/multifluid.py
def log_fugacity_coefficients(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return log fugacity coefficients from composition derivatives."""
    x = normalize_composition(composition)
    volume = self.molar_volume(temperature, pressure, x, phase)
    residual_mu: Tensor = torch.func.grad(
        lambda moles: self.residual_helmholtz_rt(
            temperature,
            volume,
            moles,
        ).sum()
    )(x)
    z = pressure * volume / (self.gas_constant * temperature)
    return residual_mu - torch.log(z)[..., None]

MultifluidMetadata dataclass

Identity and validation scope of one coefficient set.

Source code in src/torch_flash/eos/multifluid.py
@dataclass(frozen=True)
class MultifluidMetadata:
    """Identity and validation scope of one coefficient set."""

    model: str
    reference: str
    version: str
    validated_components: tuple[str, ...]

NonAnalyticTerms dataclass

Span-Wagner non-analytic critical-region residual terms.

Source code in src/torch_flash/eos/multifluid.py
@dataclass(frozen=True)
class NonAnalyticTerms:
    """Span-Wagner non-analytic critical-region residual terms."""

    n: Tensor
    capital_a: Tensor
    capital_b: Tensor
    capital_c: Tensor
    capital_d: Tensor
    a: Tensor
    b: Tensor
    beta: Tensor

    def __post_init__(self) -> None:
        arrays = (
            self.capital_a,
            self.capital_b,
            self.capital_c,
            self.capital_d,
            self.a,
            self.b,
            self.beta,
        )
        if not all(value.shape == self.n.shape for value in arrays):
            raise ValueError("all non-analytic term arrays must have equal shape")

VolumeTranslation dataclass

Component translations in the additive torch-flash convention.

reference_shift is in m3/mol, temperature_slope in m3/(mol K), and reference_temperature in K. A published positive Péneloux c therefore appears as a negative reference_shift.

Source code in src/torch_flash/eos/volume_translation.py
@dataclass(frozen=True)
class VolumeTranslation:
    """Component translations in the additive torch-flash convention.

    ``reference_shift`` is in m3/mol, ``temperature_slope`` in
    m3/(mol K), and ``reference_temperature`` in K.  A published positive
    Péneloux ``c`` therefore appears as a negative ``reference_shift``.
    """

    reference_shift: Tensor
    temperature_slope: Tensor
    reference_temperature: float = 288.15
    source: str = "custom"

    def __post_init__(self) -> None:
        if self.reference_shift.ndim != 1:
            raise ValueError("reference_shift must be a one-dimensional component vector")
        if self.temperature_slope.shape != self.reference_shift.shape:
            raise ValueError("temperature_slope must match reference_shift")
        if not bool(
            torch.isfinite(self.reference_shift).all()
            & torch.isfinite(self.temperature_slope).all()
        ):
            raise ValueError("volume-translation coefficients must be finite")
        if not torch.is_floating_point(self.reference_shift):
            raise TypeError("volume-translation coefficients must use a floating dtype")
        if (
            self.temperature_slope.dtype != self.reference_shift.dtype
            or self.temperature_slope.device != self.reference_shift.device
        ):
            raise ValueError("volume-translation tensors must share dtype and device")
        if not torch.isfinite(torch.tensor(self.reference_temperature)):
            raise ValueError("reference_temperature must be finite")
        if self.reference_temperature <= 0.0:
            raise ValueError("reference_temperature must be positive")

    @classmethod
    def constant(cls, shift: Tensor, *, source: str = "custom") -> VolumeTranslation:
        """Construct a temperature-independent additive translation."""
        return cls(shift, torch.zeros_like(shift), source=source)

    def at_temperature(self, temperature: Tensor) -> Tensor:
        """Return each component's additive shift at ``temperature``."""
        return (
            self.reference_shift
            + (temperature[..., None] - self.reference_temperature) * self.temperature_slope
        )

    def to(
        self,
        *,
        dtype: torch.dtype | None = None,
        device: torch.device | str | None = None,
    ) -> VolumeTranslation:
        """Move translation coefficients to a common dtype and device."""
        return VolumeTranslation(
            self.reference_shift.to(dtype=dtype, device=device),
            self.temperature_slope.to(dtype=dtype, device=device),
            self.reference_temperature,
            self.source,
        )

constant classmethod

constant(shift, *, source='custom')

Construct a temperature-independent additive translation.

Source code in src/torch_flash/eos/volume_translation.py
@classmethod
def constant(cls, shift: Tensor, *, source: str = "custom") -> VolumeTranslation:
    """Construct a temperature-independent additive translation."""
    return cls(shift, torch.zeros_like(shift), source=source)

at_temperature

at_temperature(temperature)

Return each component's additive shift at temperature.

Source code in src/torch_flash/eos/volume_translation.py
def at_temperature(self, temperature: Tensor) -> Tensor:
    """Return each component's additive shift at ``temperature``."""
    return (
        self.reference_shift
        + (temperature[..., None] - self.reference_temperature) * self.temperature_slope
    )

to

to(*, dtype=None, device=None)

Move translation coefficients to a common dtype and device.

Source code in src/torch_flash/eos/volume_translation.py
def to(
    self,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> VolumeTranslation:
    """Move translation coefficients to a common dtype and device."""
    return VolumeTranslation(
        self.reference_shift.to(dtype=dtype, device=device),
        self.temperature_slope.to(dtype=dtype, device=device),
        self.reference_temperature,
        self.source,
    )

CubicInteractionParameters dataclass

Symmetric dimensionless kij and lij matrices.

Source code in src/torch_flash/parameters/cubic_interactions.py
@dataclass(frozen=True)
class CubicInteractionParameters:
    """Symmetric dimensionless ``kij`` and ``lij`` matrices."""

    kij: Tensor
    lij: Tensor
    parameter_set: str

    def __post_init__(self) -> None:
        if self.kij.ndim != 2 or self.kij.shape[0] != self.kij.shape[1]:
            raise ValueError("kij must be a square matrix")
        if self.lij.shape != self.kij.shape:
            raise ValueError("lij must have the same square shape as kij")
        if not bool(torch.isfinite(self.kij).all()) or not bool(torch.isfinite(self.lij).all()):
            raise ValueError("interaction matrices must contain only finite values")
        if not torch.allclose(self.kij, self.kij.mT):
            raise ValueError("kij must be symmetric")
        if not torch.allclose(self.lij, self.lij.mT):
            raise ValueError("lij must be symmetric")
        if bool(torch.diagonal(self.kij).count_nonzero()) or bool(
            torch.diagonal(self.lij).count_nonzero()
        ):
            raise ValueError("interaction matrices must have zero diagonals")

PPR78GroupContributionParameters dataclass

Selected PPR78 group fractions and universal interaction tensors.

Source code in src/torch_flash/parameters/group_contribution.py
@dataclass(frozen=True)
class PPR78GroupContributionParameters:
    """Selected PPR78 group fractions and universal interaction tensors."""

    group_names: tuple[str, ...]
    group_fractions: Tensor
    group_a: Tensor
    group_b: Tensor
    reference_temperature: float
    parameter_set: str

    def __post_init__(self) -> None:
        if not self.group_names or len(set(self.group_names)) != len(self.group_names):
            raise ValueError("PPR78 group names must be non-empty and unique")
        expected = len(self.group_names)
        if self.group_fractions.ndim != 2 or self.group_fractions.shape[1] != expected:
            raise ValueError("PPR78 group fractions must match the group inventory")
        if self.group_a.shape != (expected, expected) or self.group_b.shape != self.group_a.shape:
            raise ValueError("PPR78 group interactions must match the group inventory")

ThermalProperties dataclass

Caloric and response properties of one homogeneous phase.

All quantities are molar SI except the Joule-Thomson coefficient (K/Pa) and speed of sound (m/s). reduced_* free energies are dimensionless molar quantities divided by R*T.

Source code in src/torch_flash/properties/thermal.py
@dataclass(frozen=True)
class ThermalProperties:
    """Caloric and response properties of one homogeneous phase.

    All quantities are molar SI except the Joule-Thomson coefficient (K/Pa)
    and speed of sound (m/s). ``reduced_*`` free energies are dimensionless
    molar quantities divided by ``R*T``.
    """

    molar_enthalpy: Tensor
    molar_internal_energy: Tensor
    molar_entropy: Tensor
    molar_helmholtz_energy: Tensor
    molar_gibbs_energy: Tensor
    reduced_helmholtz_energy: Tensor
    reduced_gibbs_energy: Tensor
    reduced_residual_helmholtz_energy: Tensor
    reduced_residual_gibbs_energy: Tensor
    isobaric_heat_capacity: Tensor
    isochoric_heat_capacity: Tensor
    joule_thomson_coefficient: Tensor
    speed_of_sound: Tensor | None
    residual_enthalpy: Tensor
    residual_entropy: Tensor

ThermodynamicDerivatives dataclass

First homogeneous-state derivatives.

Component derivatives are provided in both unconstrained softmax-logit coordinates and n-1 independent mole fractions, where the last mole fraction is 1 - sum(x_independent). Temperature and pressure derivatives hold composition fixed.

Mole-number derivatives hold T and P fixed and are evaluated at n_i = x_i mol (a one-mole total basis). Because all returned properties are intensive, the corresponding derivative at another total amount is this value divided by that amount in mol.

Fugacity derivatives have Pa per coordinate units. dlog_fugacity_* differentiates the dimensionless ln(f_i / p_standard). Chemical-potential derivatives have J/mol per coordinate units; the reduced variants differentiate mu_i / (R*T). Molar-volume derivatives have m3/mol per coordinate units.

Source code in src/torch_flash/properties/state.py
@dataclass(frozen=True)
class ThermodynamicDerivatives:
    """First homogeneous-state derivatives.

    Component derivatives are provided in both unconstrained softmax-logit
    coordinates and ``n-1`` independent mole fractions, where the last mole
    fraction is ``1 - sum(x_independent)``. Temperature and pressure
    derivatives hold composition fixed.

    Mole-number derivatives hold ``T`` and ``P`` fixed and are evaluated at
    ``n_i = x_i mol`` (a one-mole total basis). Because all returned
    properties are intensive, the corresponding derivative at another total
    amount is this value divided by that amount in mol.

    Fugacity derivatives have Pa per coordinate units.
    ``dlog_fugacity_*`` differentiates the dimensionless
    ``ln(f_i / p_standard)``. Chemical-potential derivatives have J/mol per
    coordinate units; the reduced variants differentiate ``mu_i / (R*T)``.
    Molar-volume derivatives have m3/mol per coordinate units.
    """

    dfugacity_coefficient_dlogits: Tensor
    dfugacity_coefficient_dindependent_composition: Tensor
    dfugacity_coefficient_dtemperature: Tensor
    dfugacity_coefficient_dpressure: Tensor
    dfugacity_coefficient_dmoles: Tensor
    dlog_fugacity_coefficient_dlogits: Tensor
    dlog_fugacity_coefficient_dindependent_composition: Tensor
    dlog_fugacity_coefficient_dtemperature: Tensor
    dlog_fugacity_coefficient_dpressure: Tensor
    dlog_fugacity_coefficient_dmoles: Tensor
    dfugacity_dlogits: Tensor
    dfugacity_dindependent_composition: Tensor
    dfugacity_dtemperature: Tensor
    dfugacity_dpressure: Tensor
    dfugacity_dmoles: Tensor
    dlog_fugacity_dlogits: Tensor
    dlog_fugacity_dindependent_composition: Tensor
    dlog_fugacity_dtemperature: Tensor
    dlog_fugacity_dpressure: Tensor
    dlog_fugacity_dmoles: Tensor

    dchemical_potential_dlogits: Tensor
    dchemical_potential_dindependent_composition: Tensor
    dchemical_potential_dtemperature: Tensor
    dchemical_potential_dpressure: Tensor
    dchemical_potential_dmoles: Tensor
    dreduced_chemical_potential_dlogits: Tensor
    dreduced_chemical_potential_dindependent_composition: Tensor
    dreduced_chemical_potential_dtemperature: Tensor
    dreduced_chemical_potential_dpressure: Tensor
    dreduced_chemical_potential_dmoles: Tensor
    dmolar_volume_dlogits: Tensor
    dmolar_volume_dindependent_composition: Tensor
    dmolar_volume_dtemperature: Tensor
    dmolar_volume_dpressure: Tensor
    dmolar_volume_dmoles: Tensor
    dgibbs_dtemperature: Tensor
    dgibbs_dpressure: Tensor

IdealGasPolynomial

Bases: Module

Polynomial ideal-gas heat-capacity standard state.

For m columns, the heat capacity is sum(c[j]*T**j, j=0..m-1) in J/(mol K). Four columns recover Pedersen's Eq. 8.4; five columns support the Poling data bank. Reference enthalpies and entropies make the otherwise arbitrary caloric datum explicit and allow the coefficients to be fitted with PyTorch.

Source code in src/torch_flash/standard_state.py
class IdealGasPolynomial(nn.Module):
    """Polynomial ideal-gas heat-capacity standard state.

    For ``m`` columns, the heat capacity is
    ``sum(c[j]*T**j, j=0..m-1)`` in J/(mol K). Four columns recover
    Pedersen's Eq. 8.4; five columns support the Poling data bank. Reference
    enthalpies and entropies make the otherwise arbitrary caloric datum
    explicit and allow the coefficients to be fitted with PyTorch.
    """

    heat_capacity_coefficients: Tensor
    reference_enthalpy: Tensor
    reference_entropy: Tensor

    def __init__(
        self,
        heat_capacity_coefficients: Tensor,
        reference_enthalpy: Tensor,
        reference_entropy: Tensor,
        *,
        reference_temperature: float = 298.15,
        trainable: bool = False,
    ) -> None:
        super().__init__()
        if heat_capacity_coefficients.ndim != 2 or heat_capacity_coefficients.shape[1] < 1:
            raise ValueError("heat-capacity coefficients must be a nonempty matrix")
        expected = (heat_capacity_coefficients.shape[0],)
        if reference_enthalpy.shape != expected or reference_entropy.shape != expected:
            raise ValueError("one reference enthalpy and entropy are required per component")
        if reference_temperature <= 0.0:
            raise ValueError("reference temperature must be positive")
        self.reference_temperature = float(reference_temperature)
        if trainable:
            self.heat_capacity_coefficients = nn.Parameter(heat_capacity_coefficients.clone())
        else:
            self.register_buffer(
                "heat_capacity_coefficients",
                heat_capacity_coefficients.clone(),
            )
        self.register_buffer("reference_enthalpy", reference_enthalpy.clone())
        self.register_buffer("reference_entropy", reference_entropy.clone())

    def heat_capacity(self, temperature: Tensor) -> Tensor:
        """Return ideal-gas component heat capacities in J/(mol K)."""
        powers = torch.arange(
            self.heat_capacity_coefficients.shape[1],
            dtype=temperature.dtype,
            device=temperature.device,
        )
        return torch.sum(
            self.heat_capacity_coefficients * temperature[..., None, None].pow(powers),
            dim=-1,
        )

    def enthalpy(self, temperature: Tensor) -> Tensor:
        """Return ideal-gas component enthalpies in J/mol."""
        powers = torch.arange(
            1,
            self.heat_capacity_coefficients.shape[1] + 1,
            dtype=temperature.dtype,
            device=temperature.device,
        )
        reference = temperature.new_tensor(self.reference_temperature)
        integral = (
            self.heat_capacity_coefficients
            * (temperature[..., None, None].pow(powers) - reference.pow(powers))
            / powers
        )
        return self.reference_enthalpy + torch.sum(integral, dim=-1)

    def entropy(self, temperature: Tensor) -> Tensor:
        """Return ideal-gas component entropy at standard pressure in J/(mol K)."""
        reference = temperature.new_tensor(self.reference_temperature)
        coefficients = self.heat_capacity_coefficients
        leading = coefficients[:, 0] * torch.log(temperature[..., None] / reference)
        powers = torch.arange(
            1,
            coefficients.shape[1],
            dtype=temperature.dtype,
            device=temperature.device,
        )
        remaining = (
            coefficients[:, 1:]
            * (temperature[..., None, None].pow(powers) - reference.pow(powers))
            / powers
        )
        return self.reference_entropy + leading + torch.sum(remaining, dim=-1)

    def chemical_potential(self, temperature: Tensor) -> Tensor:
        """Return ideal-gas standard chemical potentials in J/mol."""
        return self.enthalpy(temperature) - temperature[..., None] * self.entropy(temperature)

heat_capacity

heat_capacity(temperature)

Return ideal-gas component heat capacities in J/(mol K).

Source code in src/torch_flash/standard_state.py
def heat_capacity(self, temperature: Tensor) -> Tensor:
    """Return ideal-gas component heat capacities in J/(mol K)."""
    powers = torch.arange(
        self.heat_capacity_coefficients.shape[1],
        dtype=temperature.dtype,
        device=temperature.device,
    )
    return torch.sum(
        self.heat_capacity_coefficients * temperature[..., None, None].pow(powers),
        dim=-1,
    )

enthalpy

enthalpy(temperature)

Return ideal-gas component enthalpies in J/mol.

Source code in src/torch_flash/standard_state.py
def enthalpy(self, temperature: Tensor) -> Tensor:
    """Return ideal-gas component enthalpies in J/mol."""
    powers = torch.arange(
        1,
        self.heat_capacity_coefficients.shape[1] + 1,
        dtype=temperature.dtype,
        device=temperature.device,
    )
    reference = temperature.new_tensor(self.reference_temperature)
    integral = (
        self.heat_capacity_coefficients
        * (temperature[..., None, None].pow(powers) - reference.pow(powers))
        / powers
    )
    return self.reference_enthalpy + torch.sum(integral, dim=-1)

entropy

entropy(temperature)

Return ideal-gas component entropy at standard pressure in J/(mol K).

Source code in src/torch_flash/standard_state.py
def entropy(self, temperature: Tensor) -> Tensor:
    """Return ideal-gas component entropy at standard pressure in J/(mol K)."""
    reference = temperature.new_tensor(self.reference_temperature)
    coefficients = self.heat_capacity_coefficients
    leading = coefficients[:, 0] * torch.log(temperature[..., None] / reference)
    powers = torch.arange(
        1,
        coefficients.shape[1],
        dtype=temperature.dtype,
        device=temperature.device,
    )
    remaining = (
        coefficients[:, 1:]
        * (temperature[..., None, None].pow(powers) - reference.pow(powers))
        / powers
    )
    return self.reference_entropy + leading + torch.sum(remaining, dim=-1)

chemical_potential

chemical_potential(temperature)

Return ideal-gas standard chemical potentials in J/mol.

Source code in src/torch_flash/standard_state.py
def chemical_potential(self, temperature: Tensor) -> Tensor:
    """Return ideal-gas standard chemical potentials in J/mol."""
    return self.enthalpy(temperature) - temperature[..., None] * self.entropy(temperature)

BatchedTwoPhaseFlashResult dataclass

Fixed two-phase flash results for a batch of known unstable states.

Source code in src/torch_flash/types.py
@dataclass(frozen=True)
class BatchedTwoPhaseFlashResult:
    """Fixed two-phase flash results for a batch of known unstable states."""

    vapor_fraction: Tensor
    liquid_fraction: Tensor
    liquid_composition: Tensor
    vapor_composition: Tensor
    k_values: Tensor
    iterations: int
    converged: Tensor
    residual_norm: Tensor

ChemicalState dataclass

Specified temperature, pressure, and overall composition.

Source code in src/torch_flash/types.py
@dataclass(frozen=True)
class ChemicalState:
    """Specified temperature, pressure, and overall composition."""

    temperature: Tensor
    pressure: Tensor
    composition: Tensor

    def __post_init__(self) -> None:
        if bool((self.temperature <= 0.0).any()):
            raise ValueError("temperature must be positive")
        if bool((self.pressure <= 0.0).any()):
            raise ValueError("pressure must be positive")
        object.__setattr__(self, "composition", normalize_composition(self.composition))

FlashResult dataclass

Equilibrium phases and numerical diagnostics.

Source code in src/torch_flash/types.py
@dataclass(frozen=True)
class FlashResult:
    """Equilibrium phases and numerical diagnostics."""

    phase_fractions: Tensor
    phases: tuple[PhaseProperties, ...]
    converged: bool
    iterations: int
    residual_norm: Tensor
    stable: bool
    diagnostics: dict[str, float | int | bool | str] = field(default_factory=dict)

    @property
    def nphases(self) -> int:
        """Number of equilibrium phases."""
        return len(self.phases)

    @property
    def phase_identifications(self) -> tuple[PhaseIdentification | None, ...]:
        """Per-phase physical-identification diagnostics."""
        return tuple(phase.phase_identification for phase in self.phases)

    @property
    def phase_kinds(self) -> tuple[PhaseIdentityKind, ...]:
        """Likely physical phase kinds, using ``unknown`` when unavailable."""
        return tuple(
            "unknown" if phase.phase_identification is None else phase.phase_identification.kind
            for phase in self.phases
        )

    @property
    def phase_regime(self) -> str:
        """Return a compact overall label such as ``vapor-liquid``."""
        kinds = self.phase_kinds
        if not kinds:
            return "unknown"
        if len(kinds) == 1:
            return kinds[0]
        if "unknown" in kinds:
            return f"{len(kinds)}-phase-unknown"
        vapor_count = kinds.count("vapor")
        liquid_count = kinds.count("liquid")
        labels = ["vapor"] * vapor_count + ["liquid"] * liquid_count
        return "-".join(labels)

nphases property

nphases

Number of equilibrium phases.

phase_identifications property

phase_identifications

Per-phase physical-identification diagnostics.

phase_kinds property

phase_kinds

Likely physical phase kinds, using unknown when unavailable.

phase_regime property

phase_regime

Return a compact overall label such as vapor-liquid.

PhaseIdentification dataclass

Likely physical identity of a homogeneous phase.

kind is deliberately separate from the EoS root requested through :class:PhaseProperties. For the Pedersen cubic-EoS criterion, criterion_value is V/b and threshold is normally 1.75. For density ordering, they are the phase molar volume and the geometric-mean separator between the two least-dense phases. The Boolean ambiguous marks values within the configured relative band around the separator.

Phase identification is a naming diagnostic; it does not change the equilibrium calculation or any thermodynamic property.

Source code in src/torch_flash/types.py
@dataclass(frozen=True)
class PhaseIdentification:
    """Likely physical identity of a homogeneous phase.

    ``kind`` is deliberately separate from the EoS root requested through
    :class:`PhaseProperties`. For the Pedersen cubic-EoS criterion,
    ``criterion_value`` is ``V/b`` and ``threshold`` is normally 1.75. For
    density ordering, they are the phase molar volume and the geometric-mean
    separator between the two least-dense phases. The Boolean ``ambiguous``
    marks values within the configured relative band around the separator.

    Phase identification is a naming diagnostic; it does not change the
    equilibrium calculation or any thermodynamic property.
    """

    kind: PhaseIdentityKind
    method: PhaseIdentificationMethod
    criterion_value: Tensor | None
    threshold: Tensor | None
    ambiguous: bool

PhaseProperties dataclass

Thermodynamic properties of one homogeneous phase.

Fugacities are in Pa and log_fugacities are the dimensionless ln(f_i / p_standard) values. Chemical potentials and molar free energies are in J/mol. reduced_* chemical potentials and energies are dimensionless quantities divided by R*T. Total quantities use the standard-state convention selected by phase_properties; the reduced residual quantities are reference-independent EoS departures.

Source code in src/torch_flash/types.py
@dataclass(frozen=True)
class PhaseProperties:
    """Thermodynamic properties of one homogeneous phase.

    Fugacities are in Pa and ``log_fugacities`` are the dimensionless
    ``ln(f_i / p_standard)`` values. Chemical potentials and molar free
    energies are in J/mol. ``reduced_*`` chemical potentials and energies are
    dimensionless quantities divided by ``R*T``. Total quantities use the
    standard-state convention selected by ``phase_properties``; the reduced
    residual quantities are reference-independent EoS departures.
    """

    kind: str
    composition: Tensor
    compressibility_factor: Tensor
    molar_volume: Tensor
    log_fugacity_coefficients: Tensor
    fugacities: Tensor
    log_fugacities: Tensor
    chemical_potentials: Tensor
    reduced_chemical_potentials: Tensor
    molar_gibbs_energy: Tensor
    molar_helmholtz_energy: Tensor
    reduced_gibbs_energy: Tensor
    reduced_helmholtz_energy: Tensor
    reduced_residual_gibbs_energy: Tensor
    reduced_residual_helmholtz_energy: Tensor
    residual_enthalpy: Tensor | None = None
    residual_entropy: Tensor | None = None
    phase_identification: PhaseIdentification | None = None

    @property
    def fugacity_coefficients(self) -> Tensor:
        """Fugacity coefficients, ``exp(log(phi))``."""
        return torch.exp(self.log_fugacity_coefficients)

fugacity_coefficients property

fugacity_coefficients

Fugacity coefficients, exp(log(phi)).

activity_model

activity_model(parameter_set, names=None, *, dtype=None, device=None, trainable=False, covolumes=None, group_assignments=None)

Construct NRTL, HV-NRTL, Wilson, or original UNIFAC from YAML.

The requested component order may differ from the stored order; vectors and matrices are permuted consistently. For custom fitting workflows, the model classes continue to accept explicit tensors directly. UNIFAC uses bundled fragmentations selected by names or explicit group_assignments.

Source code in src/torch_flash/activity/named.py
def activity_model(
    parameter_set: ParameterSource,
    names: tuple[str, ...] | None = None,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    trainable: bool = False,
    covolumes: Tensor | None = None,
    group_assignments: Sequence[GroupAssignment] | None = None,
) -> ActivityModel:
    """Construct NRTL, HV-NRTL, Wilson, or original UNIFAC from YAML.

    The requested component order may differ from the stored order; vectors
    and matrices are permuted consistently. For custom fitting workflows, the
    model classes continue to accept explicit tensors directly. UNIFAC uses
    bundled fragmentations selected by ``names`` or explicit
    ``group_assignments``.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "activity":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'activity'"
        )
    parameters = loaded.parameters
    model_name = loaded.model.strip().lower().replace("_", "-")
    if model_name in ("unifac", "original-unifac"):
        return _unifac_from_loaded(
            loaded,
            names=names,
            group_assignments=group_assignments,
            dtype=dtype,
            device=device,
            trainable=trainable,
        )
    if group_assignments is not None:
        raise ValueError("group_assignments are only valid for UNIFAC")
    stored_names = parameters.get("components")
    if not isinstance(stored_names, Sequence) or isinstance(stored_names, str):
        raise ParameterDatabaseError(f"{loaded.identifier!r} requires a components list")
    canonical_stored = tuple(
        canonical_component_name(str(name), strict=False) for name in stored_names
    )
    selected = (
        canonical_stored
        if names is None
        else tuple(canonical_component_name(name, strict=False) for name in names)
    )
    if not selected or len(set(selected)) != len(selected):
        raise ValueError("activity-model component names must be non-empty and unique")
    try:
        order = tuple(canonical_stored.index(name) for name in selected)
    except ValueError as exc:
        raise KeyError(
            f"{loaded.identifier} has no activity parameters for one or more of {selected!r}"
        ) from exc

    if model_name == "nrtl":
        return NRTL(
            _numeric_matrix(parameters, "interaction", order, dtype=dtype, device=device),
            _numeric_matrix(parameters, "nonrandomness", order, dtype=dtype, device=device),
            trainable=trainable,
        )
    if model_name == "wilson":
        return Wilson(
            _numeric_matrix(parameters, "interaction", order, dtype=dtype, device=device),
            _numeric_vector(parameters, "molar_volumes", order, dtype=dtype, device=device),
            trainable=trainable,
        )
    if model_name not in ("hv-nrtl", "huron-vidal-nrtl"):
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} has unsupported activity model {loaded.model!r}"
        )

    if covolumes is None and "covolumes" in parameters:
        covolumes = _numeric_vector(
            parameters,
            "covolumes",
            order,
            dtype=dtype,
            device=device,
        )
    if covolumes is None:
        cubic_source = parameters.get("covolume_cubic_parameter_set")
        if not isinstance(cubic_source, str):
            raise ParameterDatabaseError(
                f"{loaded.identifier!r} requires covolumes or 'covolume_cubic_parameter_set'"
            )
        cubic = load_model_parameters(cubic_source)
        omega_b = cubic.parameters.get("omega_b")
        if cubic.model_kind != "cubic" or not isinstance(omega_b, int | float):
            raise ParameterDatabaseError(f"{cubic.identifier!r} cannot provide cubic covolumes")
        components = component_set(selected, dtype=dtype, device=device)
        covolumes = (
            float(omega_b) * R * components.critical_temperature / components.critical_pressure
        )
    else:
        covolumes = covolumes.to(dtype=dtype, device=device)
    if covolumes.shape != (len(selected),):
        raise ValueError("one activity-model covolume is required per selected component")
    return HuronVidalNRTL(
        _numeric_matrix(parameters, "energy_over_r", order, dtype=dtype, device=device),
        _numeric_matrix(
            parameters,
            "temperature_coefficient",
            order,
            dtype=dtype,
            device=device,
        ),
        _numeric_matrix(parameters, "nonrandomness", order, dtype=dtype, device=device),
        covolumes,
        trainable=trainable,
    )

unifac_groups_from_identifiers

unifac_groups_from_identifiers(identifiers, *, identifier_type='smiles')

Fragment molecules with the optional MIT-licensed ugropy package.

SMILES inputs are recommended for reproducibility. Name lookup delegates to PubChem and therefore requires network access and may change outside torch-flash. Fragmentation is discrete; inspect every returned map before using it in regression or safety-critical calculations.

Source code in src/torch_flash/activity/unifac.py
def unifac_groups_from_identifiers(
    identifiers: Sequence[str],
    *,
    identifier_type: str = "smiles",
) -> tuple[dict[int, float], ...]:
    """Fragment molecules with the optional MIT-licensed ``ugropy`` package.

    SMILES inputs are recommended for reproducibility.  Name lookup delegates
    to PubChem and therefore requires network access and may change outside
    ``torch-flash``.  Fragmentation is discrete; inspect every returned map
    before using it in regression or safety-critical calculations.
    """
    try:
        from ugropy import unifac as ugropy_unifac
    except ImportError as exc:
        raise ImportError(
            "automatic UNIFAC fragmentation requires 'ugropy'; install torch-flash[groups]"
        ) from exc
    if identifier_type not in ("name", "smiles"):
        raise ValueError("identifier_type must be 'name' or 'smiles'")
    assignments: list[dict[int, float]] = []
    for identifier in identifiers:
        result = ugropy_unifac.get_groups(identifier, identifier_type)
        if isinstance(result, list):
            if len(result) != 1:
                raise ValueError(
                    f"ugropy returned {len(result)} fragmentations for {identifier!r}; "
                    "select a group assignment explicitly"
                )
            result = result[0]
        raw = getattr(result, "subgroups_num", None)
        if not isinstance(raw, Mapping) or not raw:
            raise ValueError(f"ugropy could not assign original-UNIFAC groups to {identifier!r}")
        assignments.append({int(key): float(value) for key, value in raw.items()})
    return tuple(assignments)

unifac_model

unifac_model(parameter_set='unifac-original', names=None, *, group_assignments=None, dtype=None, device=None, trainable=False)

Construct original UNIFAC from cached YAML or explicit parameters.

names select bundled, audited component fragmentations. Arbitrary molecules use group_assignments, where each mapping may be keyed by subgroup key, published subgroup number, or an unambiguous subgroup name. The optional :func:unifac_groups_from_identifiers adapter can generate these mappings with ugropy.

Source code in src/torch_flash/activity/unifac.py
def unifac_model(
    parameter_set: ParameterSource = "unifac-original",
    names: Sequence[str] | None = None,
    *,
    group_assignments: Sequence[GroupAssignment] | None = None,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    trainable: bool = False,
) -> UNIFAC:
    """Construct original UNIFAC from cached YAML or explicit parameters.

    ``names`` select bundled, audited component fragmentations.  Arbitrary
    molecules use ``group_assignments``, where each mapping may be keyed by
    subgroup key, published subgroup number, or an unambiguous subgroup name.
    The optional :func:`unifac_groups_from_identifiers` adapter can generate
    these mappings with ``ugropy``.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "activity":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'activity'"
        )
    if loaded.model.strip().lower().replace("_", "-") not in ("unifac", "original-unifac"):
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model!r}, not original UNIFAC"
        )
    return _unifac_from_loaded(
        loaded,
        names=names,
        group_assignments=group_assignments,
        dtype=dtype,
        device=device,
        trainable=trainable,
    )

equal_weight_lump

equal_weight_lump(distribution, groups, *, properties=None)

Lump contiguous SCN cuts into approximately equal-weight groups.

Critical properties supplied through properties use Pedersen's weight-average rule (2024, Eqs. 5.29-5.31). Molar mass is mole averaged; density, when present, preserves ideal mixed volume.

Source code in src/torch_flash/characterization/lumping.py
def equal_weight_lump(
    distribution: SCNDistribution,
    groups: int,
    *,
    properties: Mapping[str, Tensor] | None = None,
) -> LumpedDistribution:
    """Lump contiguous SCN cuts into approximately equal-weight groups.

    Critical properties supplied through ``properties`` use Pedersen's
    weight-average rule (2024, Eqs. 5.29-5.31). Molar mass is mole averaged;
    density, when present, preserves ideal mixed volume.
    """
    count = distribution.carbon_numbers.numel()
    if not isinstance(groups, int) or groups < 1 or groups > count:
        raise ValueError("groups must be an integer from one through the SCN count")
    supplied = {} if properties is None else dict(properties)
    for name, values in supplied.items():
        if not isinstance(name, str) or not name:
            raise ValueError("lumped property names must be non-empty strings")
        if values.shape != distribution.mole_fractions.shape:
            raise ValueError(f"lumped property {name!r} must match the SCN distribution")

    mass = distribution.mole_fractions * distribution.molar_masses
    cumulative = torch.cumsum(mass, dim=0)
    targets = (
        mass.sum()
        * torch.arange(
            1,
            groups,
            dtype=mass.dtype,
            device=mass.device,
        )
        / groups
    )
    boundaries = [0]
    for target in targets:
        candidate = int(torch.searchsorted(cumulative, target).detach()) + 1
        minimum = boundaries[-1] + 1
        maximum = count - (groups - len(boundaries))
        boundaries.append(min(max(candidate, minimum), maximum))
    boundaries.append(count)

    names: list[str] = []
    bounds: list[tuple[int, int]] = []
    fractions: list[Tensor] = []
    molar_masses: list[Tensor] = []
    densities: list[Tensor] = []
    lumped_properties: dict[str, list[Tensor]] = {name: [] for name in supplied}
    for start, stop in pairwise(boundaries):
        current_fraction = distribution.mole_fractions[start:stop]
        current_mass = mass[start:stop]
        total_fraction = current_fraction.sum()
        total_mass = current_mass.sum()
        lower = int(distribution.carbon_numbers[start])
        upper = int(distribution.carbon_numbers[stop - 1])
        names.append(f"C{lower}" if lower == upper else f"C{lower}-C{upper}")
        bounds.append((lower, upper))
        fractions.append(total_fraction)
        molar_masses.append(total_mass / total_fraction)
        if distribution.densities is not None:
            densities.append(
                total_mass / torch.sum(current_mass / distribution.densities[start:stop])
            )
        for name, values in supplied.items():
            lumped_properties[name].append(
                torch.sum(current_mass * values[start:stop]) / total_mass
            )
    return LumpedDistribution(
        tuple(names),
        tuple(bounds),
        torch.stack(fractions),
        torch.stack(molar_masses),
        None if distribution.densities is None else torch.stack(densities),
        {name: torch.stack(values) for name, values in lumped_properties.items()},
    )

pedersen_cubic_properties

pedersen_cubic_properties(distribution, eos, parameter_set='characterization.pedersen-2024')

Map characterized SCN cuts to SRK or PR properties.

Implements Pedersen et al. (2024), Eqs. 5.1-5.5 and Table 5.3. The correlation coefficients are EoS-specific; the input distribution and density split remain model-neutral.

Source code in src/torch_flash/characterization/cubic.py
def pedersen_cubic_properties(
    distribution: SCNDistribution,
    eos: CubicCharacterization,
    parameter_set: ParameterSource = "characterization.pedersen-2024",
) -> CubicFractionProperties:
    """Map characterized SCN cuts to SRK or PR properties.

    Implements Pedersen et al. (2024), Eqs. 5.1-5.5 and Table 5.3.
    The correlation coefficients are EoS-specific; the input distribution and
    density split remain model-neutral.
    """
    if eos not in ("SRK", "PR"):
        raise ValueError("eos must be 'SRK' or 'PR'")
    if distribution.densities is None:
        raise ValueError("Pedersen cubic properties require characterized SCN densities")
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "characterization":
        raise ParameterDatabaseError(f"{loaded.identifier!r} is not a characterization set")
    tables = loaded.parameters.get("cubic_properties")
    if not isinstance(tables, Mapping):
        raise ParameterDatabaseError(f"{loaded.identifier!r} requires cubic_properties")
    record = tables.get(eos)
    if not isinstance(record, Mapping):
        raise ParameterDatabaseError(f"{loaded.identifier!r} has no {eos} property table")
    c1, c2, c3, c4 = _coefficients(record, "critical_temperature", 4, loaded.identifier)
    d1, d2, d3, d4, d5 = _coefficients(record, "log_critical_pressure", 5, loaded.identifier)
    e1, e2, e3, e4 = _coefficients(record, "m", 4, loaded.identifier)
    w0, w1, w2 = _coefficients(record, "m_to_acentric_factor", 3, loaded.identifier)
    molar_mass = 1.0e3 * distribution.molar_masses
    density = 1.0e-3 * distribution.densities
    critical_temperature = c1 * density + c2 * torch.log(molar_mass) + c3 * molar_mass
    critical_temperature = critical_temperature + c4 / molar_mass
    log_pressure_atm = d1 + d2 * density**d5 + d3 / molar_mass + d4 / molar_mass.square()
    critical_pressure = 101_325.0 * torch.exp(log_pressure_atm)
    m = e1 + e2 * molar_mass + e3 * density + e4 * molar_mass.square()
    discriminant = w1 * w1 - 4.0 * w2 * (w0 - m)
    if bool((discriminant < 0.0).any()):
        raise ValueError("Pedersen cubic correlation produced no real acentric factor")
    root_first = (-w1 + torch.sqrt(discriminant)) / (2.0 * w2)
    root_second = (-w1 - torch.sqrt(discriminant)) / (2.0 * w2)
    acentric = torch.where(
        torch.abs(root_first) <= torch.abs(root_second),
        root_first,
        root_second,
    )
    return CubicFractionProperties(
        critical_temperature,
        critical_pressure,
        acentric,
        m,
    )

pedersen_density_split

pedersen_density_split(distribution, plus_density, *, anchor_density=None, anchor_carbon_number=None, parameter_set='characterization.pedersen-2024')

Assign rho_N = C + D ln(CN) while matching bulk plus density.

Density inputs and results are SI (kg/m3). If no measured anchor is provided, Pedersen's suggested C6 density ratio is applied to the carbon number immediately preceding the plus fraction.

Source code in src/torch_flash/characterization/distributions.py
def pedersen_density_split(
    distribution: SCNDistribution,
    plus_density: float | Tensor,
    *,
    anchor_density: float | Tensor | None = None,
    anchor_carbon_number: int | None = None,
    parameter_set: ParameterSource = "characterization.pedersen-2024",
) -> SCNDistribution:
    """Assign ``rho_N = C + D ln(CN)`` while matching bulk plus density.

    Density inputs and results are SI (kg/m3). If no measured anchor is
    provided, Pedersen's suggested C6 density ratio is applied to the carbon
    number immediately preceding the plus fraction.
    """
    parameters, source = _characterization_parameters(parameter_set)
    split = parameters.get("plus_split")
    if not isinstance(split, Mapping):
        raise ParameterDatabaseError(f"{source} requires a plus_split mapping")
    if split.get("density_log_carbon_number") is not True:
        raise ParameterDatabaseError(f"{source} does not define the logarithmic density rule")
    target = torch.as_tensor(
        plus_density,
        dtype=distribution.mole_fractions.dtype,
        device=distribution.mole_fractions.device,
    )
    if target.ndim or not bool(torch.isfinite(target) & (target > 0.0)):
        raise ValueError("plus density must be a finite positive scalar")
    if anchor_density is None:
        ratio = _positive_number(split, "default_anchor_density_ratio", source)
        anchor = ratio * target
    else:
        anchor = torch.as_tensor(
            anchor_density,
            dtype=target.dtype,
            device=target.device,
        )
    if anchor.ndim or not bool(torch.isfinite(anchor) & (anchor > 0.0)):
        raise ValueError("anchor density must be a finite positive scalar")
    if anchor_carbon_number is None:
        anchor_carbon_number = int(distribution.carbon_numbers[0]) - 1
    if anchor_carbon_number < 1:
        raise ValueError("anchor carbon number must be positive")

    log_delta = torch.log(distribution.carbon_numbers.to(target)) - torch.log(
        target.new_tensor(float(anchor_carbon_number))
    )
    mass = distribution.mole_fractions * distribution.molar_masses

    def residual(density_slope: Tensor) -> Tensor:
        densities = anchor + density_slope * log_delta
        bulk = mass.sum() / torch.sum(mass / densities)
        return bulk - target

    density_slope = (target - anchor) / torch.clamp(log_delta.mean(), min=1.0e-6)
    lower = -0.95 * anchor / torch.clamp(log_delta.max(), min=1.0e-6)
    upper = 10.0 * target
    for _ in range(30):
        value = residual(density_slope)
        derivative = torch.func.grad(residual)(density_slope)
        density_slope = torch.clamp(density_slope - value / derivative, lower, upper)
    densities = anchor + density_slope * log_delta
    if (
        bool((densities <= 0.0).any())
        or float(torch.abs(residual(density_slope) / target).detach()) > 1.0e-10
    ):
        raise ConvergenceError("Pedersen density split did not satisfy its volume balance")
    return SCNDistribution(
        distribution.carbon_numbers,
        distribution.mole_fractions,
        distribution.molar_masses,
        densities,
    )

pedersen_logarithmic_split

pedersen_logarithmic_split(plus_mole_fraction, plus_molar_mass, *, first_carbon_number=7, max_carbon_number=None, parameter_set='characterization.pedersen-2024', dtype=None, device=None)

Split a plus fraction with Pedersen's logarithmic molar distribution.

The finite distribution satisfies both plus-fraction mole and molar-mass balances (Pedersen et al., 2024, Eqs. 5.10-5.12). Molecular weights use Eq. 5.22. Measured extended compositions should be preferred whenever available, as emphasized by the source.

Source code in src/torch_flash/characterization/distributions.py
def pedersen_logarithmic_split(
    plus_mole_fraction: float | Tensor,
    plus_molar_mass: float | Tensor,
    *,
    first_carbon_number: int = 7,
    max_carbon_number: int | None = None,
    parameter_set: ParameterSource = "characterization.pedersen-2024",
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> SCNDistribution:
    """Split a plus fraction with Pedersen's logarithmic molar distribution.

    The finite distribution satisfies both plus-fraction mole and molar-mass
    balances (Pedersen et al., 2024, Eqs. 5.10-5.12). Molecular weights use
    Eq. 5.22. Measured extended compositions should be preferred whenever
    available, as emphasized by the source.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    parameters, source = _characterization_parameters(parameter_set)
    split = parameters.get("plus_split")
    if not isinstance(split, Mapping):
        raise ParameterDatabaseError(f"{source} requires a plus_split mapping")
    if max_carbon_number is None:
        raw_max = split.get("default_max_carbon_number")
        if not isinstance(raw_max, int):
            raise ParameterDatabaseError(f"{source} default_max_carbon_number must be an integer")
        max_carbon_number = raw_max
    if (
        not isinstance(first_carbon_number, int)
        or not isinstance(max_carbon_number, int)
        or first_carbon_number < 1
        or max_carbon_number < first_carbon_number
    ):
        raise ValueError("carbon-number bounds must be ordered positive integers")
    relation = split.get("molecular_weight")
    if not isinstance(relation, Mapping):
        raise ParameterDatabaseError(f"{source} requires a molecular_weight mapping")
    slope = _positive_number(relation, "slope", source)
    intercept_value = relation.get("intercept")
    if not isinstance(intercept_value, int | float) or not isfinite(intercept_value):
        raise ParameterDatabaseError(f"{source} molecular-weight intercept must be finite")
    intercept = float(intercept_value)

    fraction = torch.as_tensor(plus_mole_fraction, dtype=dtype, device=device)
    target_mass = torch.as_tensor(plus_molar_mass, dtype=dtype, device=device)
    if fraction.ndim or target_mass.ndim:
        raise ValueError("plus mole fraction and molar mass must be scalar")
    if not bool(torch.isfinite(fraction) & (fraction > 0.0)):
        raise ValueError("plus mole fraction must be finite and positive")
    if not bool(torch.isfinite(target_mass) & (target_mass > 0.0)):
        raise ValueError("plus molar mass must be finite and positive")
    carbon_numbers = torch.arange(
        first_carbon_number,
        max_carbon_number + 1,
        dtype=dtype,
        device=device,
    )
    molar_masses = (slope * carbon_numbers + intercept) * 1.0e-3
    if not bool(
        (target_mass >= molar_masses[0] - 1.0e-12) & (target_mass <= molar_masses[-1] + 1.0e-12)
    ):
        raise ValueError("plus molar mass lies outside the selected finite SCN range")

    # Fixed Newton iterations retain an autodiff path through the moment
    # constraint. d<E[M]>/d lambda is cov(M, carbon number).
    log_slope = target_mass.new_zeros(())
    centered_carbon = carbon_numbers - carbon_numbers[0]
    for _ in range(40):
        weights = torch.softmax(log_slope * centered_carbon, dim=0)
        mean_mass = torch.sum(weights * molar_masses)
        mean_carbon = torch.sum(weights * centered_carbon)
        covariance = torch.sum(
            weights * (molar_masses - mean_mass) * (centered_carbon - mean_carbon)
        )
        step = torch.clamp((mean_mass - target_mass) / covariance, -2.0, 2.0)
        log_slope = torch.clamp(log_slope - step, -50.0, 50.0)
    weights = torch.softmax(log_slope * centered_carbon, dim=0)
    mole_fractions = fraction * weights
    mean_mass = torch.sum(weights * molar_masses)
    mass_residual = torch.abs(mean_mass - target_mass)
    mass_scale = torch.maximum(torch.abs(mean_mass), torch.abs(target_mass))
    # The final moment is accumulated in the requested dtype. Permit several
    # ulps of reduction-order variation while retaining the historical
    # float64 absolute gate.
    precision_limit = 8.0 * torch.finfo(target_mass.dtype).eps * float(mass_scale.detach())
    if float(mass_residual.detach()) > max(1.0e-9, precision_limit):
        raise ConvergenceError("Pedersen logarithmic split did not satisfy its mass balance")
    return SCNDistribution(
        carbon_numbers.to(torch.int64),
        mole_fractions,
        molar_masses,
    )

whitson_gamma_split

whitson_gamma_split(plus_mole_fraction, plus_molar_mass, *, first_carbon_number=7, max_carbon_number=80, shape=None, minimum_molar_mass=None, parameter_set='characterization.whitson-2000', dtype=None, device=None)

Discretize Whitson's shifted gamma distribution into SCN-like bins.

The last requested bin contains the complete tail to infinite molecular weight, so total moles and average molar mass are preserved exactly apart from floating-point roundoff. shape=1 gives the exponential special case discussed by both Whitson and Pedersen.

Source code in src/torch_flash/characterization/distributions.py
def whitson_gamma_split(
    plus_mole_fraction: float | Tensor,
    plus_molar_mass: float | Tensor,
    *,
    first_carbon_number: int = 7,
    max_carbon_number: int = 80,
    shape: float | Tensor | None = None,
    minimum_molar_mass: float | Tensor | None = None,
    parameter_set: ParameterSource = "characterization.whitson-2000",
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> SCNDistribution:
    """Discretize Whitson's shifted gamma distribution into SCN-like bins.

    The last requested bin contains the complete tail to infinite molecular
    weight, so total moles and average molar mass are preserved exactly apart
    from floating-point roundoff. ``shape=1`` gives the exponential special
    case discussed by both Whitson and Pedersen.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    parameters, source = _characterization_parameters(parameter_set)
    gamma = parameters.get("gamma_distribution")
    if not isinstance(gamma, Mapping):
        raise ParameterDatabaseError(f"{source} requires a gamma_distribution mapping")
    if shape is None:
        shape = _positive_number(gamma, "default_shape", source)
    alpha = torch.as_tensor(shape, dtype=dtype, device=device)
    fraction = torch.as_tensor(plus_mole_fraction, dtype=dtype, device=device)
    average_mass = torch.as_tensor(plus_molar_mass, dtype=dtype, device=device)
    if any(value.ndim for value in (alpha, fraction, average_mass)):
        raise ValueError("gamma shape and plus-fraction inputs must be scalar")
    if not bool(
        torch.isfinite(alpha)
        & (alpha > 0.0)
        & torch.isfinite(fraction)
        & (fraction > 0.0)
        & torch.isfinite(average_mass)
        & (average_mass > 0.0)
    ):
        raise ValueError("gamma shape, mole fraction, and molar mass must be finite and positive")
    if first_carbon_number < 1 or max_carbon_number < first_carbon_number:
        raise ValueError("carbon-number bounds must be ordered positive integers")
    if minimum_molar_mass is None:
        relation = gamma.get("recommended_minimum_molecular_weight_relation")
        if not isinstance(relation, Mapping):
            raise ParameterDatabaseError(
                f"{source} requires recommended minimum-molecular-weight coefficients"
            )
        scale = _positive_number(relation, "scale", source)
        multiplier = _positive_number(relation, "multiplier", source)
        exponent = _positive_number(relation, "exponent", source)
        eta = 1.0e-3 * scale * (1.0 - 1.0 / (1.0 + multiplier / alpha**exponent))
    else:
        eta = torch.as_tensor(minimum_molar_mass, dtype=dtype, device=device)
    if eta.ndim or not bool(torch.isfinite(eta) & (eta > 0.0) & (eta < average_mass)):
        raise ValueError("minimum molar mass must be finite, positive, and below the average")
    beta = (average_mass - eta) / alpha

    carbon_numbers = torch.arange(
        first_carbon_number,
        max_carbon_number + 1,
        dtype=dtype,
        device=device,
    )
    boundary_increment = _positive_number(
        gamma,
        "molecular_weight_boundary_increment",
        source,
    )
    # Whitson and Brule (2000), Table 5.4 and program GAMSPL, place
    # molecular-weight boundaries at eta, eta + 14, eta + 28, ... g/mol.
    # The carbon numbers are therefore nominal labels for consecutive bins;
    # they do not define midpoints through the n-paraffin relation.
    offsets = torch.arange(
        carbon_numbers.numel(),
        dtype=dtype,
        device=device,
    )
    lower = eta + boundary_increment * offsets
    upper = torch.cat(
        (
            lower[1:],
            torch.full_like(eta.reshape(1), torch.inf),
        )
    )
    y_lower = torch.clamp((lower - eta) / beta, min=0.0)
    y_upper = torch.clamp((upper - eta) / beta, min=0.0)
    p_lower = torch.special.gammainc(alpha, y_lower)
    p_upper = torch.special.gammainc(alpha, y_upper)
    probabilities = p_upper - p_lower
    first_moment_probability = torch.special.gammainc(alpha + 1.0, y_upper)
    first_moment_probability = first_moment_probability - torch.special.gammainc(
        alpha + 1.0, y_lower
    )
    bin_masses = eta + alpha * beta * first_moment_probability / probabilities
    return SCNDistribution(
        carbon_numbers.to(torch.int64),
        fraction * probabilities,
        bin_masses,
    )

canonical_component_name

canonical_component_name(name, *, database=None, strict=True)

Return the canonical torch-flash name for a name or alias.

Source code in src/torch_flash/components.py
def canonical_component_name(
    name: str,
    *,
    database: str | Path | ComponentDatabase | None = None,
    strict: bool = True,
) -> str:
    """Return the canonical torch-flash name for a name or alias."""
    selected = load_component_database(database)
    try:
        return selected.lookup(name).name
    except KeyError:
        if strict:
            raise
        return _normalize_name(name)

clear_component_caches

clear_component_caches()

Clear cached component YAML documents.

Source code in src/torch_flash/components.py
def clear_component_caches() -> None:
    """Clear cached component YAML documents."""
    _default_component_database.cache_clear()
    _component_database_from_path.cache_clear()

component

component(name, *, database=None)

Look up a component by canonical name or alias.

Source code in src/torch_flash/components.py
def component(
    name: str,
    *,
    database: str | Path | ComponentDatabase | None = None,
) -> Component:
    """Look up a component by canonical name or alias."""
    return load_component_database(database).lookup(name)

component_set

component_set(names, *, dtype=None, device=None, database=None)

Build vectorized cubic-EoS constants from a component database.

Omitted tensor options follow the process-wide :mod:torch_flash.config policy. Explicit dtype or device arguments override that policy.

Source code in src/torch_flash/components.py
def component_set(
    names: Iterable[str],
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    database: str | Path | ComponentDatabase | None = None,
) -> ComponentSet:
    """Build vectorized cubic-EoS constants from a component database.

    Omitted tensor options follow the process-wide :mod:`torch_flash.config`
    policy. Explicit dtype or device arguments override that policy.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    items = tuple(component(name, database=database) for name in names)
    if not items:
        raise ValueError("at least one component is required")
    unavailable = tuple(item.name for item in items if item.acentric_factor is None)
    if unavailable:
        joined = ", ".join(unavailable)
        raise ParameterDatabaseError(
            f"cubic-EoS acentric factors are unavailable for: {joined}; "
            "supply a custom component database or ComponentSet"
        )

    def tensor(values: list[float]) -> Tensor:
        return torch.tensor(values, dtype=dtype, device=device)

    critical_volumes = [
        float("nan") if item.critical_volume is None else item.critical_volume for item in items
    ]
    critical_densities = [
        float("nan") if item.critical_density is None else item.critical_density for item in items
    ]
    return ComponentSet(
        tuple(item.name for item in items),
        tensor([item.critical_temperature for item in items]),
        tensor([item.critical_pressure for item in items]),
        tensor([cast(float, item.acentric_factor) for item in items]),
        tensor([item.molar_mass for item in items]),
        tensor(critical_volumes),
        tensor(critical_densities),
    )

load_component_database

load_component_database(source=None)

Load the bundled or a custom SI component YAML database once.

Source code in src/torch_flash/components.py
def load_component_database(
    source: str | Path | ComponentDatabase | None = None,
) -> ComponentDatabase:
    """Load the bundled or a custom SI component YAML database once."""
    if source is None or source in ("default", "components.default"):
        return _default_component_database()
    if isinstance(source, ComponentDatabase):
        return source
    return _component_database_from_path(str(Path(source).expanduser().absolute()))

configure

configure(*, device=None, dtype=None, num_threads=None, num_interop_threads=None, deterministic=None, deterministic_warn_only=None)

Set the process-wide torch-flash runtime policy before model creation.

device="auto" selects the first available CUDA, XPU, or MPS device that supports the requested dtype, falling back to CPU. device="gpu" performs the same search but raises if no accelerator supports the dtype.

The dtype becomes PyTorch's default floating dtype. The selected device is applied only by torch-flash factories and :class:RuntimeConfig tensor helpers; this function deliberately does not call :func:torch.set_default_device, which adds overhead to every Python-level PyTorch API call.

PyTorch thread counts and deterministic-algorithm flags are inherently process-wide. In particular, num_interop_threads can be changed only once and before inter-operation parallel work starts; PyTorch's failure is reported with an actionable error.

Source code in src/torch_flash/config.py
def configure(
    *,
    device: torch.device | str | None = None,
    dtype: torch.dtype | None = None,
    num_threads: int | None = None,
    num_interop_threads: int | None = None,
    deterministic: bool | None = None,
    deterministic_warn_only: bool | None = None,
) -> RuntimeConfig:
    """Set the process-wide torch-flash runtime policy before model creation.

    ``device="auto"`` selects the first available CUDA, XPU, or MPS device
    that supports the requested dtype, falling back to CPU. ``device="gpu"``
    performs the same search but raises if no accelerator supports the dtype.

    The dtype becomes PyTorch's default floating dtype. The selected device is
    applied only by torch-flash factories and :class:`RuntimeConfig` tensor
    helpers; this function deliberately does not call
    :func:`torch.set_default_device`, which adds overhead to every Python-level
    PyTorch API call.

    PyTorch thread counts and deterministic-algorithm flags are inherently
    process-wide. In particular, ``num_interop_threads`` can be changed only
    once and before inter-operation parallel work starts; PyTorch's failure is
    reported with an actionable error.
    """
    global _CONFIG

    with _CONFIG_LOCK:
        current = get_config()
        selected_dtype = current.dtype if dtype is None else dtype
        if selected_dtype not in _SUPPORTED_DTYPES:
            raise ValueError("torch-flash runtime dtype must be torch.float32 or torch.float64")
        selected_device = _resolve_device(device, selected_dtype, current.device)
        selected_threads = _positive_thread_count(
            num_threads,
            "num_threads",
            current.num_threads,
        )
        selected_interop_threads = _positive_thread_count(
            num_interop_threads,
            "num_interop_threads",
            current.num_interop_threads,
        )
        selected_deterministic = current.deterministic if deterministic is None else deterministic
        if not isinstance(selected_deterministic, bool):
            raise ValueError("deterministic must be a boolean")
        if deterministic is False and deterministic_warn_only is None:
            selected_warn_only = False
        else:
            selected_warn_only = (
                current.deterministic_warn_only
                if deterministic_warn_only is None
                else deterministic_warn_only
            )
        if not isinstance(selected_warn_only, bool):
            raise ValueError("deterministic_warn_only must be a boolean")
        if selected_warn_only and not selected_deterministic:
            raise ValueError("deterministic_warn_only requires deterministic=True")

        if selected_interop_threads != current.num_interop_threads:
            try:
                torch.set_num_interop_threads(selected_interop_threads)
            except RuntimeError as exc:
                raise RuntimeError(
                    "PyTorch inter-operation threads must be configured once, before "
                    "inter-operation parallel work starts"
                ) from exc
        if selected_threads != current.num_threads:
            torch.set_num_threads(selected_threads)
        if (
            selected_deterministic != current.deterministic
            or selected_warn_only != current.deterministic_warn_only
        ):
            torch.use_deterministic_algorithms(
                selected_deterministic,
                warn_only=selected_warn_only,
            )
        if selected_dtype != torch.get_default_dtype():
            torch.set_default_dtype(selected_dtype)

        _CONFIG = RuntimeConfig(
            selected_device,
            selected_dtype,
            torch.get_num_threads(),
            torch.get_num_interop_threads(),
            torch.are_deterministic_algorithms_enabled(),
            torch.is_deterministic_algorithms_warn_only_enabled(),
        )
        return _CONFIG

get_config

get_config()

Return the current immutable torch-flash runtime configuration.

Source code in src/torch_flash/config.py
def get_config() -> RuntimeConfig:
    """Return the current immutable torch-flash runtime configuration."""
    with _CONFIG_LOCK:
        return replace(
            _CONFIG,
            num_threads=torch.get_num_threads(),
            num_interop_threads=torch.get_num_interop_threads(),
            deterministic=torch.are_deterministic_algorithms_enabled(),
            deterministic_warn_only=torch.is_deterministic_algorithms_warn_only_enabled(),
        )

available_parameter_sets

available_parameter_sets(*, model_kind=None)

Return bundled parameter-set identifiers, optionally filtered by kind.

Source code in src/torch_flash/database.py
def available_parameter_sets(*, model_kind: str | None = None) -> tuple[str, ...]:
    """Return bundled parameter-set identifiers, optionally filtered by kind."""
    entries, _ = _index()
    identifiers = tuple(sorted(entries))
    if model_kind is None:
        return identifiers
    return tuple(
        identifier
        for identifier in identifiers
        if _load_builtin(identifier).model_kind == model_kind
    )

clear_parameter_caches

clear_parameter_caches()

Clear parsed YAML caches, primarily for custom-file development.

Source code in src/torch_flash/database.py
def clear_parameter_caches() -> None:
    """Clear parsed YAML caches, primarily for custom-file development."""
    _load_builtin.cache_clear()
    _load_path.cache_clear()
    _index.cache_clear()

load_model_parameters

load_model_parameters(source)

Load a bundled identifier, custom YAML path, or explicit parameter set.

Parsed bundled and custom files are cached by identifier or resolved path. Call :func:clear_parameter_caches after intentionally modifying a custom file in a long-running process.

Source code in src/torch_flash/database.py
def load_model_parameters(source: ParameterSource) -> ModelParameterSet:
    """Load a bundled identifier, custom YAML path, or explicit parameter set.

    Parsed bundled and custom files are cached by identifier or resolved path.
    Call :func:`clear_parameter_caches` after intentionally modifying a custom
    file in a long-running process.
    """
    if isinstance(source, ModelParameterSet):
        return source
    if isinstance(source, Path):
        return _load_path(str(source.expanduser().absolute()))
    _, aliases = _index()
    normalized = source.strip().lower()
    if normalized in aliases:
        return _load_builtin(aliases[normalized])
    candidate = Path(source).expanduser()
    if candidate.suffix.lower() in (".yaml", ".yml"):
        return _load_path(str(candidate.absolute()))
    available = ", ".join(available_parameter_sets())
    raise KeyError(f"unknown model parameter set {source!r}; available: {available}")

binary_bubble_point

binary_bubble_point(model, temperature, liquid_composition, *, initial_pressure=None, initial_vapor_composition=None, minimum_pressure=None, maximum_pressure=None, tolerance=1e-08, max_iterations=30)

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

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``.

    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_critical_point

binary_critical_point(model, composition, *, initial_temperature=None, initial_pressure=None, tolerance=1e-09, max_iterations=30)

Solve the binary mixture criticality conditions with autodiff.

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.

    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, temperature, pressure, initial_phase1_composition, initial_phase2_composition, *, phase_kinds=('stable', 'stable'), tolerance=1e-08, max_iterations=30, minimum_phase_separation=1e-06)

Solve binary phase coexistence at fixed T and P.

("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``.

    ``("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,
    )

binary_vle_point

binary_vle_point(model, temperature, pressure, initial_liquid_composition, initial_vapor_composition, *, tolerance=1e-08, max_iterations=30, minimum_phase_separation=1e-06)

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

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 binary liquid-vapor coexistence at fixed ``T`` and ``P``."""
    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,
    )

continue_saturation_branch

continue_saturation_branch(model, composition, initial_point, target_log_k_values, *, controlled_component=0, tolerance=1e-09, max_iterations=60, accelerated=True)

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

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.

    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)

phase_envelope

phase_envelope(model, temperatures, composition, *, kinds=('bubble', 'dew'), accelerated=True)

Trace bubble/dew branches over a specified temperature grid.

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.

    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

saturation_point

saturation_point(model, temperature, composition, kind, *, initial_pressure=None, initial_k_values=None, tolerance=1e-08, max_iterations=40, substitution_iterations=None)

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

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.

    ``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,
    )

cpa_components_from_cuts

cpa_components_from_cuts(cuts, *, parameter_set='cpa.yan-2009-reservoir-fluids', dtype=None, device=None)

Characterize analyzed C7+ cuts and normalize their internal fractions.

This function covers the EoS-parameter step of a plus-fraction workflow. It deliberately does not invent a carbon-number distribution when only a bulk C7+ molecular weight is known; splitting or lumping must be performed upstream with measured cuts or an explicitly selected Whitson/Pedersen distribution model.

Source code in src/torch_flash/eos/cpa_heavy_end.py
def cpa_components_from_cuts(
    cuts: tuple[PseudoComponentCut, ...],
    *,
    parameter_set: ParameterSource = "cpa.yan-2009-reservoir-fluids",
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> CPACharacterizedComponents:
    """Characterize analyzed C7+ cuts and normalize their internal fractions.

    This function covers the EoS-parameter step of a plus-fraction workflow.
    It deliberately does not invent a carbon-number distribution when only a
    bulk C7+ molecular weight is known; splitting or lumping must be performed
    upstream with measured cuts or an explicitly selected Whitson/Pedersen
    distribution model.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    if not cuts:
        raise ValueError("at least one pseudo-component cut is required")
    if any(not isfinite(cut.mole_fraction) or cut.mole_fraction < 0.0 for cut in cuts):
        raise ValueError("pseudo-component cut mole fractions must be finite and nonnegative")
    fractions = torch.tensor(
        [cut.mole_fraction for cut in cuts],
        dtype=dtype,
        device=device,
    )
    if not bool(fractions.sum() > 0.0):
        raise ValueError("pseudo-component cut mole fractions must have a positive sum")
    properties = tuple(
        cpa_monomer_properties(
            cut.normal_boiling_temperature,
            cut.specific_gravity,
            parameter_set,
        )
        for cut in cuts
    )
    components = tuple(
        CPAComponent(
            name=cut.name,
            critical_temperature=item.critical_temperature,
            a0=item.a0,
            b=item.b,
            c1=item.m,
            critical_pressure=item.critical_pressure,
            acentric_factor=item.acentric_factor,
            molar_mass=cut.molar_mass,
        )
        for cut, item in zip(cuts, properties, strict=True)
    )
    return CPACharacterizedComponents(
        components,
        fractions / fractions.sum(),
        properties,
    )

cpa_eos

cpa_eos(names, parameter_set='cpa.folas-2005', *, kij=None, kij_a=None, kij_b=None, lij=None, cross_association_energy=None, cross_association_volume=None, combining_rule=None, trainable=False, trainable_lij=False, association_iterations=10, association_newton_iterations=8, dtype=None, device=None)

Construct CPA from a bundled/custom YAML set or use :class:CPAEOS.

Direct construction with a tuple of :class:CPAComponent remains the explicit in-memory API for fitting new species or parameterizations. lij optionally modifies the physical SRK co-volume rule; trainable_lij controls it independently of the attraction parameters.

Source code in src/torch_flash/eos/cpa.py
def cpa_eos(
    names: tuple[str, ...],
    parameter_set: ParameterSource = "cpa.folas-2005",
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    cross_association_energy: Tensor | None = None,
    cross_association_volume: Tensor | None = None,
    combining_rule: CombiningRule | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    association_iterations: int = 10,
    association_newton_iterations: int = 8,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> CPAEOS:
    """Construct CPA from a bundled/custom YAML set or use :class:`CPAEOS`.

    Direct construction with a tuple of :class:`CPAComponent` remains the
    explicit in-memory API for fitting new species or parameterizations.
    ``lij`` optionally modifies the physical SRK co-volume rule;
    ``trainable_lij`` controls it independently of the attraction parameters.
    """
    supplied_tensors = tuple(
        value
        for value in (
            kij,
            kij_a,
            kij_b,
            lij,
            cross_association_energy,
            cross_association_volume,
        )
        if value is not None
    )
    runtime_dtype, runtime_device = resolve_tensor_options(dtype, device)
    dtype = supplied_tensors[0].dtype if dtype is None and supplied_tensors else runtime_dtype
    device = supplied_tensors[0].device if device is None and supplied_tensors else runtime_device
    selected = tuple(canonical_component_name(name) for name in names)
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "cpa":
        raise ParameterDatabaseError(f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'cpa'")
    records = loaded.parameters.get("components")
    if not isinstance(records, Mapping):
        raise ParameterDatabaseError(f"{loaded.identifier!r} requires a components mapping")
    parameters: list[CPAComponent] = []
    for name in selected:
        record = records.get(name)
        if not isinstance(record, Mapping):
            raise KeyError(f"{loaded.identifier} CPA parameters are unavailable for {name!r}")
        parameters.append(_cpa_component(name, record))
    default_rule = loaded.parameters.get("default_combining_rule", "ECR")
    resolved_rule = default_rule if combining_rule is None else combining_rule
    if resolved_rule not in ("CR1", "ECR"):
        raise ParameterDatabaseError(f"unsupported CPA combining rule {resolved_rule!r}")
    if kij is None and kij_a is None and kij_b is None:
        kij, kij_a, kij_b = _database_binary_interactions(
            loaded.parameters.get("binary_interactions"),
            selected,
            loaded.identifier,
            dtype=dtype,
            device=device,
        )
    if cross_association_energy is None and cross_association_volume is None:
        cross_association_energy, cross_association_volume = _database_cross_association(
            loaded.parameters.get("cross_association"),
            selected,
            loaded.identifier,
            dtype=dtype,
            device=device,
        )
    return CPAEOS(
        tuple(parameters),
        kij=kij,
        kij_a=kij_a,
        kij_b=kij_b,
        lij=lij,
        cross_association_energy=cross_association_energy,
        cross_association_volume=cross_association_volume,
        combining_rule=resolved_rule,
        trainable=trainable,
        trainable_lij=trainable_lij,
        association_iterations=association_iterations,
        association_newton_iterations=association_newton_iterations,
        dtype=dtype,
        device=device,
    )

cpa_folas_2005

cpa_folas_2005(names, *, kij=None, lij=None, combining_rule='ECR', trainable=False, trainable_lij=False, dtype=None, device=None)

Construct CPA with the Folas et al. (2005), Table 1, pure parameters.

Reference: doi:10.1021/ie048832j.

Source code in src/torch_flash/eos/cpa.py
def cpa_folas_2005(
    names: tuple[str, ...],
    *,
    kij: Tensor | None = None,
    lij: Tensor | None = None,
    combining_rule: CombiningRule = "ECR",
    trainable: bool = False,
    trainable_lij: bool = False,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> CPAEOS:
    """Construct CPA with the Folas et al. (2005), Table 1, pure parameters.

    Reference: doi:10.1021/ie048832j.
    """
    return cpa_eos(
        names,
        "cpa.folas-2005",
        kij=kij,
        lij=lij,
        combining_rule=combining_rule,
        trainable=trainable,
        trainable_lij=trainable_lij,
        dtype=dtype,
        device=device,
    )

cpa_heavy_end_correlations

cpa_heavy_end_correlations(parameter_set='cpa.yan-2009-reservoir-fluids')

Load and validate CPA heavy-end correlation coefficients.

Source code in src/torch_flash/eos/cpa_heavy_end.py
def cpa_heavy_end_correlations(
    parameter_set: ParameterSource = "cpa.yan-2009-reservoir-fluids",
) -> CPAHeavyEndCorrelations:
    """Load and validate CPA heavy-end correlation coefficients."""
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "cpa":
        raise ParameterDatabaseError(f"{loaded.identifier!r} is not a CPA parameter set")
    record = loaded.parameters.get("heavy_end_correlations")
    if not isinstance(record, Mapping):
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} requires a heavy_end_correlations mapping"
        )

    def positive(key: str) -> float:
        value = record.get(key)
        if not isinstance(value, int | float) or not isfinite(value) or value <= 0.0:
            raise ParameterDatabaseError(
                f"{loaded.identifier!r} heavy_end_correlations {key!r} must be finite and positive"
            )
        return float(value)

    temperature_ratio = record.get("critical_temperature_ratio")
    if not isinstance(temperature_ratio, Mapping):
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} requires critical_temperature_ratio coefficients"
        )
    return CPAHeavyEndCorrelations(
        positive("normal_boiling_pressure"),
        positive("srk_omega_a"),
        positive("srk_omega_b"),
        _numeric_tuple(record.get("srk_m"), 3, "srk_m", loaded.identifier),  # type: ignore[arg-type]
        _numeric_tuple(
            record.get("normal_alkane_temperature"),
            3,
            "normal_alkane_temperature",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            record.get("log_normal_alkane_pressure_bar"),
            5,
            "log_normal_alkane_pressure_bar",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            record.get("normal_alkane_acentric"),
            3,
            "normal_alkane_acentric",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            record.get("normal_alkane_specific_gravity"),
            5,
            "normal_alkane_specific_gravity",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            temperature_ratio.get("numerator"),
            3,
            "critical_temperature_ratio.numerator",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            temperature_ratio.get("denominator"),
            3,
            "critical_temperature_ratio.denominator",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            record.get("log_critical_pressure_ratio"),
            5,
            "log_critical_pressure_ratio",
            loaded.identifier,
        ),  # type: ignore[arg-type]
    )

cpa_monomer_properties

cpa_monomer_properties(normal_boiling_temperature, specific_gravity, parameter_set='cpa.yan-2009-reservoir-fluids')

Calculate CPA monomer properties for one narrow heavy-end cut.

Parameters:

Name Type Description Default
normal_boiling_temperature float

Atmospheric normal boiling temperature in K.

required
specific_gravity float

Dimensionless liquid specific gravity used by the source characterization.

required
Source code in src/torch_flash/eos/cpa_heavy_end.py
def cpa_monomer_properties(
    normal_boiling_temperature: float,
    specific_gravity: float,
    parameter_set: ParameterSource = "cpa.yan-2009-reservoir-fluids",
) -> CPAMonomerProperties:
    """Calculate CPA monomer properties for one narrow heavy-end cut.

    Parameters
    ----------
    normal_boiling_temperature
        Atmospheric normal boiling temperature in K.
    specific_gravity
        Dimensionless liquid specific gravity used by the source
        characterization.
    """
    tb = float(normal_boiling_temperature)
    sg = float(specific_gravity)
    if not isfinite(tb) or not isfinite(sg) or tb <= 0.0 or sg <= 0.0:
        raise ValueError("boiling temperature and specific gravity must be finite and positive")

    correlations = cpa_heavy_end_correlations(parameter_set)
    temperature_constant, temperature_slope, temperature_denominator = (
        correlations.normal_alkane_temperature
    )
    normal_temperature = (
        (temperature_constant + temperature_slope * tb) * tb / (temperature_denominator + tb)
    )
    p4, p3, p2, p1, p0 = correlations.log_normal_alkane_pressure_bar
    log_normal_pressure_bar = p4 * tb**4 + p3 * tb**3 + p2 * tb**2 + p1 * tb + p0
    normal_pressure = 1.0e5 * exp(log_normal_pressure_bar)
    sg_scale, sg_constant, sg_linear, sg_inverse, sg_inverse_square = (
        correlations.normal_alkane_specific_gravity
    )
    normal_specific_gravity = (sg_scale * tb) ** (1.0 / 3.0) / (
        sg_constant + sg_linear * tb + sg_inverse / tb + sg_inverse_square / tb**2
    )
    perturbation = sg - normal_specific_gravity
    tn1, tn2, tn3 = correlations.critical_temperature_numerator
    td1, td2, td3 = correlations.critical_temperature_denominator
    temperature_ratio = (
        1.0 + tn1 * perturbation + tn2 * perturbation**2 + tn3 * perturbation**3
    ) / (1.0 + td1 * perturbation + td2 * perturbation**2 + td3 * perturbation**3)
    critical_temperature = normal_temperature * temperature_ratio
    pr0, pr1, pr2, pr3, pr4 = correlations.log_critical_pressure_ratio
    log_pressure_ratio = (
        perturbation
        * (pr0 + (pr1 + pr2 / sg) * perturbation)
        / (1.0 + pr3 * perturbation + pr4 * perturbation**2)
    )
    critical_pressure = normal_pressure * exp(log_pressure_ratio)
    if (
        not isfinite(critical_temperature)
        or not isfinite(critical_pressure)
        or critical_temperature <= 0.0
        or critical_pressure <= 0.0
    ):
        raise ValueError("Yan CPA characterization is outside its physical correlation domain")

    if tb < critical_temperature:
        acentric_factor = _match_normal_boiling_point(
            tb,
            critical_temperature,
            critical_pressure,
            correlations,
        )
        used_match = True
    else:
        omega_constant, omega_slope, omega_denominator = correlations.normal_alkane_acentric
        acentric_factor = exp((omega_constant + omega_slope * tb) / (omega_denominator + tb))
        used_match = False
    return CPAMonomerProperties(
        tb,
        sg,
        normal_specific_gravity,
        perturbation,
        normal_temperature,
        normal_pressure,
        critical_temperature,
        critical_pressure,
        acentric_factor,
        used_match,
        correlations,
    )

cpa_oliveira_2007

cpa_oliveira_2007(names, *, kij=None, lij=None, cross_association_energy=None, cross_association_volume=None, trainable=False, trainable_lij=False, dtype=None, device=None)

Construct the hydrocarbon-water CPA parameterization of Oliveira et al.

Pure parameters and constant physical-term interactions are from Tables 1-3 of Oliveira, Coutinho, and Queimada, Fluid Phase Equilibria 258 (2007) 58-66, doi:10.1016/j.fluid.2007.05.023. Aromatic-water solvation uses the paper's modified CR-1 cross-association parameters.

Source code in src/torch_flash/eos/cpa.py
def cpa_oliveira_2007(
    names: tuple[str, ...],
    *,
    kij: Tensor | None = None,
    lij: Tensor | None = None,
    cross_association_energy: Tensor | None = None,
    cross_association_volume: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> CPAEOS:
    """Construct the hydrocarbon-water CPA parameterization of Oliveira et al.

    Pure parameters and constant physical-term interactions are from Tables
    1-3 of Oliveira, Coutinho, and Queimada, Fluid Phase Equilibria 258
    (2007) 58-66, doi:10.1016/j.fluid.2007.05.023. Aromatic-water solvation
    uses the paper's modified CR-1 cross-association parameters.
    """
    return cpa_eos(
        names,
        "cpa.oliveira-2007-hydrocarbon-water",
        kij=kij,
        lij=lij,
        cross_association_energy=cross_association_energy,
        cross_association_volume=cross_association_volume,
        combining_rule="CR1",
        trainable=trainable,
        trainable_lij=trainable_lij,
        dtype=dtype,
        device=device,
    )

cpa_pseudocomponent

cpa_pseudocomponent(name, normal_boiling_temperature, specific_gravity, molar_mass, parameter_set='cpa.yan-2009-reservoir-fluids')

Create a non-associating CPA pseudo-component for a heavy-end cut.

Source code in src/torch_flash/eos/cpa_heavy_end.py
def cpa_pseudocomponent(
    name: str,
    normal_boiling_temperature: float,
    specific_gravity: float,
    molar_mass: float,
    parameter_set: ParameterSource = "cpa.yan-2009-reservoir-fluids",
) -> CPAComponent:
    """Create a non-associating CPA pseudo-component for a heavy-end cut."""
    if not isinstance(name, str) or not name.strip():
        raise ValueError("pseudo-component name must be a non-empty string")
    mass = float(molar_mass)
    if not isfinite(mass) or mass <= 0.0:
        raise ValueError("pseudo-component molar mass must be finite and positive")
    properties = cpa_monomer_properties(
        normal_boiling_temperature,
        specific_gravity,
        parameter_set,
    )
    return CPAComponent(
        name=name,
        critical_temperature=properties.critical_temperature,
        a0=properties.a0,
        b=properties.b,
        c1=properties.m,
        critical_pressure=properties.critical_pressure,
        acentric_factor=properties.acentric_factor,
        molar_mass=mass,
    )

cpa_yan_2009

cpa_yan_2009(names, *, kij=None, kij_a=None, kij_b=None, lij=None, trainable=False, trainable_lij=False, dtype=None, device=None)

Construct reservoir-fluid CPA with Yan et al.'s A + B/T water BIPs.

Reference: Yan, Kontogeorgis, and Stenby, Fluid Phase Equilibria 276 (2009) 75-85, doi:10.1016/j.fluid.2008.10.007.

Source code in src/torch_flash/eos/cpa.py
def cpa_yan_2009(
    names: tuple[str, ...],
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> CPAEOS:
    """Construct reservoir-fluid CPA with Yan et al.'s ``A + B/T`` water BIPs.

    Reference: Yan, Kontogeorgis, and Stenby, Fluid Phase Equilibria 276
    (2009) 75-85, doi:10.1016/j.fluid.2008.10.007.
    """
    return cpa_eos(
        names,
        "cpa.yan-2009-reservoir-fluids",
        kij=kij,
        kij_a=kij_a,
        kij_b=kij_b,
        lij=lij,
        trainable=trainable,
        trainable_lij=trainable_lij,
        dtype=dtype,
        device=device,
    )

cubic_constants

cubic_constants(source)

Construct typed cubic constants from YAML or an explicit parameter set.

Source code in src/torch_flash/eos/cubic.py
def cubic_constants(source: ParameterSource) -> CubicConstants:
    """Construct typed cubic constants from YAML or an explicit parameter set."""
    parameter_set = load_model_parameters(source)
    if parameter_set.model_kind != "cubic":
        raise ParameterDatabaseError(
            f"{parameter_set.identifier!r} is {parameter_set.model_kind!r}, not 'cubic'"
        )
    parameters = parameter_set.parameters
    alpha = parameters.get("alpha")
    if not isinstance(alpha, Mapping):
        raise ParameterDatabaseError(f"{parameter_set.identifier!r} requires an alpha mapping")
    alpha_kind = alpha.get("kind")
    if alpha_kind not in ("srk", "pr76", "pr78"):
        raise ParameterDatabaseError(
            f"{parameter_set.identifier!r} has unsupported alpha kind {alpha_kind!r}"
        )

    def coefficients(key: str, size: int) -> tuple[float, ...]:
        block = alpha.get(key)
        if not isinstance(block, Mapping):
            raise ParameterDatabaseError(f"{parameter_set.identifier!r} requires alpha.{key}")
        values = block.get("coefficients")
        if not isinstance(values, tuple | list) or len(values) != size:
            raise ParameterDatabaseError(
                f"{parameter_set.identifier!r} alpha.{key}.coefficients must have length {size}"
            )
        if any(not isinstance(value, int | float) for value in values):
            raise ParameterDatabaseError("cubic alpha coefficients must be numeric")
        return tuple(float(value) for value in values)

    low = coefficients("low", 3)
    high = coefficients("high", 4) if alpha_kind == "pr78" else None
    switch = alpha.get("switch_acentric_factor")
    if alpha_kind == "pr78" and not isinstance(switch, int | float):
        raise ParameterDatabaseError(
            f"{parameter_set.identifier!r} requires alpha.switch_acentric_factor"
        )
    required = ("omega_a", "omega_b", "delta1", "delta2")
    if any(not isinstance(parameters.get(key), int | float) for key in required):
        raise ParameterDatabaseError(
            f"{parameter_set.identifier!r} cubic constants must be numeric"
        )
    return CubicConstants(
        parameter_set.model,
        float(parameters["omega_a"]),
        float(parameters["omega_b"]),
        float(parameters["delta1"]),
        float(parameters["delta2"]),
        alpha_kind,
        (low[0], low[1], low[2]),
        None if high is None else (high[0], high[1], high[2], high[3]),
        None if switch is None else float(switch),
        parameter_set.identifier,
    )

cubic_eos

cubic_eos(components, parameter_set, *, kij=None, kij_a=None, kij_b=None, lij=None, trainable=False, trainable_lij=False, mixing=None, volume_translation=None)

Construct a cubic EoS from bundled YAML or explicit typed constants.

Supply kij for a constant quadratic interaction matrix, or both kij_a and kij_b for kij(T) = kij_a + kij_b/T. The latter coefficients are dimensionless and kelvin, respectively. Supply lij for bij = 0.5*(bi+bj)*(1-lij); omitting it recovers the conventional linear covolume rule. trainable controls attraction interactions; trainable_lij independently enables co-volume fitting from the supplied matrix or from zero.

Source code in src/torch_flash/eos/cubic.py
def cubic_eos(
    components: ComponentSet,
    parameter_set: ParameterSource | CubicConstants,
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    mixing: CubicMixing | None = None,
    volume_translation: Tensor | VolumeTranslation | None = None,
) -> CubicEOS:
    """Construct a cubic EoS from bundled YAML or explicit typed constants.

    Supply ``kij`` for a constant quadratic interaction matrix, or both
    ``kij_a`` and ``kij_b`` for ``kij(T) = kij_a + kij_b/T``. The latter
    coefficients are dimensionless and kelvin, respectively. Supply ``lij``
    for ``bij = 0.5*(bi+bj)*(1-lij)``; omitting it recovers the conventional
    linear covolume rule. ``trainable`` controls attraction interactions;
    ``trainable_lij`` independently enables co-volume fitting from the
    supplied matrix or from zero.
    """
    constants = (
        parameter_set
        if isinstance(parameter_set, CubicConstants)
        else cubic_constants(parameter_set)
    )
    return CubicEOS(
        components,
        constants,
        mixing=_resolve_mixing(
            components,
            kij,
            kij_a,
            kij_b,
            lij,
            trainable,
            trainable_lij,
            mixing,
        ),
        volume_translation=volume_translation,
    )

density_matched_translation

density_matched_translation(eos_molar_volume, molar_mass, mass_density, *, source='density-match')

Match a reference density with an additive component translation.

All inputs use SI units. The returned shift is M/rho - v_eos, while the equivalent published Péneloux coefficient is its negative.

Source code in src/torch_flash/eos/volume_translation.py
def density_matched_translation(
    eos_molar_volume: Tensor,
    molar_mass: Tensor,
    mass_density: Tensor,
    *,
    source: str = "density-match",
) -> VolumeTranslation:
    """Match a reference density with an additive component translation.

    All inputs use SI units.  The returned shift is
    ``M/rho - v_eos``, while the equivalent published Péneloux coefficient is
    its negative.
    """
    if eos_molar_volume.shape != molar_mass.shape or mass_density.shape != molar_mass.shape:
        raise ValueError("volume, molar mass, and density vectors must have the same shape")
    if not bool(
        torch.isfinite(eos_molar_volume).all()
        & torch.isfinite(molar_mass).all()
        & torch.isfinite(mass_density).all()
    ):
        raise ValueError("density-matching inputs must be finite")
    if bool(
        (eos_molar_volume <= 0.0).any() | (molar_mass <= 0.0).any() | (mass_density <= 0.0).any()
    ):
        raise ValueError("density-matching inputs must be positive")
    target_volume = molar_mass / mass_density
    return VolumeTranslation.constant(target_volume - eos_molar_volume, source=source)

eoscg2021

eoscg2021(names=None, *, dtype=None, device=None, trainable=False, parameter_set='multifluid.eos-cg-2021')

Construct the complete native 16-component EOS-CG-2021 Helmholtz model.

Reference: Neumann et al. (2023), doi:10.1007/s10765-023-03263-6, including its supplementary tables.

Source code in src/torch_flash/eos/named.py
def eoscg2021(
    names: tuple[str, ...] | None = None,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    trainable: bool = False,
    parameter_set: ParameterSource = "multifluid.eos-cg-2021",
) -> MultiFluidEOS:
    """Construct the complete native 16-component EOS-CG-2021 Helmholtz model.

    Reference: Neumann et al. (2023),
    doi:10.1007/s10765-023-03263-6, including its supplementary tables.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if not loaded.model.upper().startswith("EOS-CG-2021"):
        raise ParameterDatabaseError(
            f"eoscg2021 requires an EOS-CG-2021 parameter set, got {loaded.model!r}"
        )
    return _eoscg_eos(
        loaded,
        names,
        dtype=dtype,
        device=device,
        trainable=trainable,
    )

gerg2008

gerg2008(names=None, *, dtype=None, device=None, trainable=False, parameter_set='multifluid.gerg-2008')

Construct the complete native 21-component GERG-2008 Helmholtz model.

Reference: Kunz and Wagner (2012), doi:10.1021/je300655b.

Source code in src/torch_flash/eos/named.py
def gerg2008(
    names: tuple[str, ...] | None = None,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    trainable: bool = False,
    parameter_set: ParameterSource = "multifluid.gerg-2008",
) -> MultiFluidEOS:
    """Construct the complete native 21-component GERG-2008 Helmholtz model.

    Reference: Kunz and Wagner (2012), doi:10.1021/je300655b.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if not loaded.model.upper().startswith("GERG-2008"):
        raise ParameterDatabaseError(
            f"gerg2008 requires a GERG-2008 parameter set, got {loaded.model!r}"
        )
    return _gerg_eos(
        loaded,
        names,
        dtype=dtype,
        device=device,
        trainable=trainable,
    )

multifluid_eos

multifluid_eos(parameter_set, names=None, *, dtype=None, device=None, trainable=False)

Construct a native multifluid EoS from YAML or explicit parameters.

Source code in src/torch_flash/eos/named.py
def multifluid_eos(
    parameter_set: ParameterSource,
    names: tuple[str, ...] | None = None,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    trainable: bool = False,
) -> MultiFluidEOS:
    """Construct a native multifluid EoS from YAML or explicit parameters."""
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "multifluid":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'multifluid'"
        )
    normalized = loaded.model.strip().lower().replace("_", "-")
    if normalized.startswith("gerg"):
        return _gerg_eos(
            loaded,
            names,
            dtype=dtype,
            device=device,
            trainable=trainable,
        )
    if normalized.startswith("eos-cg") or normalized.startswith("eoscg"):
        return _eoscg_eos(
            loaded,
            names,
            dtype=dtype,
            device=device,
            trainable=trainable,
        )
    raise ParameterDatabaseError(
        f"{loaded.identifier!r} has unsupported multifluid model {loaded.model!r}"
    )

pedersen_peneloux_translation

pedersen_peneloux_translation(components, eos, *, rackett_factor=None, source=_PEDERSEN_SOURCE)

Return the Pedersen light-component SRK/PR Péneloux translation.

SRK uses Pedersen Eq. 4.46 and PR uses the Jhaveri-Youngren Eq. 4.49. These correlations were fitted for nonhydrocarbons and hydrocarbons lighter than C7; heavier fractions should use density matching or a characterized parameter set.

Source code in src/torch_flash/eos/volume_translation.py
def pedersen_peneloux_translation(
    components: ComponentSet,
    eos: VolumeTranslationEOS,
    *,
    rackett_factor: Tensor | None = None,
    source: ParameterSource = _PEDERSEN_SOURCE,
) -> VolumeTranslation:
    """Return the Pedersen light-component SRK/PR Péneloux translation.

    SRK uses Pedersen Eq. 4.46 and PR uses the Jhaveri-Youngren Eq. 4.49.
    These correlations were fitted for nonhydrocarbons and hydrocarbons
    lighter than C7; heavier fractions should use density matching or a
    characterized parameter set.
    """
    parameters = _parameter_mapping(source, "Pedersen-Peneloux")
    correlations = _mapping(parameters, "correlations", str(source))
    correlation = _mapping(correlations, eos, str(source))
    z_ra = (
        rackett_compressibility_factor(components.acentric_factor, source=source)
        if rackett_factor is None
        else rackett_factor.to(
            dtype=components.critical_temperature.dtype,
            device=components.critical_temperature.device,
        )
    )
    if z_ra.shape != components.critical_temperature.shape:
        raise ValueError("rackett_factor must have one value per component")
    if not bool(torch.isfinite(z_ra).all()):
        raise ValueError("rackett_factor must be finite")
    scale = _number(correlation, "scale", str(source))
    target = _number(correlation, "target", str(source))
    published_c = (
        scale * R * components.critical_temperature / components.critical_pressure * (target - z_ra)
    )
    return VolumeTranslation.constant(
        -published_c,
        source=f"{load_model_parameters(source).identifier}:{eos}",
    )

pedersen_temperature_dependent_translation

pedersen_temperature_dependent_translation(model, reference_density, *, pressure=101325.0, source=_PEDERSEN_SOURCE)

Fit Pedersen's linear C7+ translation using Eqs. 5.7-5.9.

reference_density is in kg/m3 at 288.15 K by default. The ASTM correlation supplies the target density at 353.15 K, and the parent cubic EoS supplies the unshifted liquid volumes at both temperatures.

Source code in src/torch_flash/eos/volume_translation.py
def pedersen_temperature_dependent_translation(
    model: CubicEOS,
    reference_density: Tensor,
    *,
    pressure: float = 101_325.0,
    source: ParameterSource = _PEDERSEN_SOURCE,
) -> VolumeTranslation:
    """Fit Pedersen's linear C7+ translation using Eqs. 5.7-5.9.

    ``reference_density`` is in kg/m3 at 288.15 K by default.  The ASTM
    correlation supplies the target density at 353.15 K, and the parent cubic
    EoS supplies the unshifted liquid volumes at both temperatures.
    """
    parameters = _parameter_mapping(source, "Pedersen-Peneloux")
    temperature_parameters = _mapping(
        parameters,
        "temperature_dependent",
        str(source),
    )
    reference_temperature = _number(
        temperature_parameters,
        "reference_temperature",
        str(source),
    )
    target_temperature = _number(
        temperature_parameters,
        "target_temperature",
        str(source),
    )
    astm_constant = _number(
        temperature_parameters,
        "astm_density_constant",
        str(source),
    )
    nonlinear_factor = _number(
        temperature_parameters,
        "nonlinear_factor",
        str(source),
    )
    if not 0.0 < reference_temperature < target_temperature:
        raise ParameterDatabaseError(
            "temperature-dependent translation requires 0 < "
            "reference_temperature < target_temperature"
        )
    if astm_constant <= 0.0 or nonlinear_factor < 0.0:
        raise ParameterDatabaseError(
            "temperature-dependent translation requires a positive ASTM "
            "constant and non-negative nonlinear factor"
        )
    density = reference_density.to(
        dtype=model.critical_temperature.dtype,
        device=model.critical_temperature.device,
    )
    if density.shape != model.critical_temperature.shape:
        raise ValueError("reference_density must have one value per component")
    if not bool(torch.isfinite(density).all() & (density > 0.0).all()):
        raise ValueError("reference_density must be finite and positive")
    if not math.isfinite(pressure) or pressure <= 0.0:
        raise ValueError("pressure must be finite and positive")
    if bool(
        (model.volume_translation != 0.0).any() | (model.volume_translation_slope != 0.0).any()
    ):
        raise ValueError(
            "Pedersen temperature-dependent matching requires an untranslated parent EoS"
        )
    if bool((model.critical_temperature <= target_temperature).any()):
        raise InvalidStateError(
            "Pedersen temperature-dependent density matching requires "
            "target_temperature below every component critical temperature"
        )

    ncomponents = model.ncomponents
    identity = torch.eye(
        ncomponents,
        dtype=model.critical_temperature.dtype,
        device=model.critical_temperature.device,
    )
    pressure_vector = model.critical_temperature.new_full((ncomponents,), pressure)

    def parent_volume(temperature_value: float) -> Tensor:
        temperature_vector = model.critical_temperature.new_full(
            (ncomponents,),
            temperature_value,
        )
        z = model.select_z(
            temperature_vector,
            pressure_vector,
            identity,
            "liquid",
        )
        return z * R * temperature_vector / pressure_vector

    reference_parent_volume = parent_volume(reference_temperature)
    target_parent_volume = parent_volume(target_temperature)
    reference_shift = model.molar_mass / density - reference_parent_volume
    delta_temperature = target_temperature - reference_temperature
    expansion = astm_constant / density.square()
    target_density = density * torch.exp(
        -expansion * delta_temperature * (1.0 + nonlinear_factor * expansion * delta_temperature)
    )
    target_shift = model.molar_mass / target_density - target_parent_volume
    slope = (target_shift - reference_shift) / delta_temperature
    return VolumeTranslation(
        reference_shift,
        slope,
        reference_temperature,
        f"{load_model_parameters(source).identifier}:temperature-dependent",
    )

peng_robinson_1976

peng_robinson_1976(components, *, kij=None, kij_a=None, kij_b=None, lij=None, trainable=False, trainable_lij=False, mixing=None, volume_translation=None)

Construct the original 1976 Peng-Robinson equation of state.

Source code in src/torch_flash/eos/cubic.py
def peng_robinson_1976(
    components: ComponentSet,
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    mixing: CubicMixing | None = None,
    volume_translation: Tensor | VolumeTranslation | None = None,
) -> CubicEOS:
    """Construct the original 1976 Peng-Robinson equation of state."""
    return cubic_eos(
        components,
        PR76,
        kij=kij,
        kij_a=kij_a,
        kij_b=kij_b,
        lij=lij,
        trainable=trainable,
        trainable_lij=trainable_lij,
        mixing=mixing,
        volume_translation=volume_translation,
    )

peng_robinson_1978

peng_robinson_1978(components, *, kij=None, kij_a=None, kij_b=None, lij=None, trainable=False, trainable_lij=False, mixing=None, volume_translation=None)

Construct the 1978 Peng-Robinson acentric-factor variant.

Source code in src/torch_flash/eos/cubic.py
def peng_robinson_1978(
    components: ComponentSet,
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    mixing: CubicMixing | None = None,
    volume_translation: Tensor | VolumeTranslation | None = None,
) -> CubicEOS:
    """Construct the 1978 Peng-Robinson acentric-factor variant."""
    return cubic_eos(
        components,
        PR78,
        kij=kij,
        kij_a=kij_a,
        kij_b=kij_b,
        lij=lij,
        trainable=trainable,
        trainable_lij=trainable_lij,
        mixing=mixing,
        volume_translation=volume_translation,
    )

predictive_peng_robinson_1978

predictive_peng_robinson_1978(components, *, parameter_set=DEFAULT_PPR78_GROUP_CONTRIBUTION, group_counts=None, trainable=False, volume_translation=None)

Construct PPR78 with group-contribution kij(T).

The default parameter set is the original six-group saturated-hydrocarbon fit of Jaubert and Mutelet (2004), doi:10.1016/j.fluid.2004.06.059. Custom YAML parameter sets and explicit component group counts use the same path. trainable=True exposes the unique universal A/B group interactions as PyTorch parameters.

PPR78 was derived with the linear covolume rule, so co-volume interactions are intentionally not accepted by this named constructor.

Source code in src/torch_flash/eos/cubic.py
def predictive_peng_robinson_1978(
    components: ComponentSet,
    *,
    parameter_set: ParameterSource = DEFAULT_PPR78_GROUP_CONTRIBUTION,
    group_counts: Mapping[str, Mapping[str, float]] | Tensor | None = None,
    trainable: bool = False,
    volume_translation: Tensor | VolumeTranslation | None = None,
) -> CubicEOS:
    """Construct PPR78 with group-contribution ``kij(T)``.

    The default parameter set is the original six-group saturated-hydrocarbon
    fit of Jaubert and Mutelet (2004), doi:10.1016/j.fluid.2004.06.059.
    Custom YAML parameter sets and explicit component group counts use the
    same path. ``trainable=True`` exposes the unique universal A/B group
    interactions as PyTorch parameters.

    PPR78 was derived with the linear covolume rule, so co-volume interactions
    are intentionally not accepted by this named constructor.
    """
    return CubicEOS(
        components,
        PR78,
        mixing=ppr78_mixing(
            components,
            parameter_set,
            group_counts=group_counts,
            trainable=trainable,
        ),
        volume_translation=volume_translation,
    )

rackett_compressibility_factor

rackett_compressibility_factor(acentric_factor, *, source=_PEDERSEN_SOURCE)

Return Z_RA = 0.29056 - 0.08775 omega from Pedersen Eq. 4.47.

Source code in src/torch_flash/eos/volume_translation.py
def rackett_compressibility_factor(
    acentric_factor: Tensor,
    *,
    source: ParameterSource = _PEDERSEN_SOURCE,
) -> Tensor:
    """Return ``Z_RA = 0.29056 - 0.08775 omega`` from Pedersen Eq. 4.47."""
    parameters = _parameter_mapping(source, "Pedersen-Peneloux")
    rackett = _mapping(parameters, "rackett", str(source))
    intercept = _number(rackett, "intercept", str(source))
    slope = _number(rackett, "acentric_slope", str(source))
    return intercept + slope * acentric_factor

soave_redlich_kwong

soave_redlich_kwong(components, *, kij=None, kij_a=None, kij_b=None, lij=None, trainable=False, trainable_lij=False, mixing=None, volume_translation=None)

Construct the 1972 Soave-Redlich-Kwong equation of state.

Source code in src/torch_flash/eos/cubic.py
def soave_redlich_kwong(
    components: ComponentSet,
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    mixing: CubicMixing | None = None,
    volume_translation: Tensor | VolumeTranslation | None = None,
) -> CubicEOS:
    """Construct the 1972 Soave-Redlich-Kwong equation of state."""
    return cubic_eos(
        components,
        SRK,
        kij=kij,
        kij_a=kij_a,
        kij_b=kij_b,
        lij=lij,
        trainable=trainable,
        trainable_lij=trainable_lij,
        mixing=mixing,
        volume_translation=volume_translation,
    )

whitson_volume_translation

whitson_volume_translation(components, eos, *, heavy_families=None, source=_WHITSON_SOURCE)

Return Whitson Tables 4.2-4.3 translations.

Named light components and normal paraffins through n-decane use the tabulated s_i = c_i/b_i values. Any other component requires a family entry and uses s_i = 1 - A0/M_i**A1 with M in g/mol.

Source code in src/torch_flash/eos/volume_translation.py
def whitson_volume_translation(
    components: ComponentSet,
    eos: VolumeTranslationEOS,
    *,
    heavy_families: Mapping[str, HydrocarbonFamily] | None = None,
    source: ParameterSource = _WHITSON_SOURCE,
) -> VolumeTranslation:
    """Return Whitson Tables 4.2-4.3 translations.

    Named light components and normal paraffins through n-decane use the
    tabulated ``s_i = c_i/b_i`` values.  Any other component requires a family
    entry and uses ``s_i = 1 - A0/M_i**A1`` with ``M`` in g/mol.
    """
    parameters = _parameter_mapping(source, "Whitson-Peneloux")
    eos_parameters = _mapping(_mapping(parameters, "eos", str(source)), eos, str(source))
    factors = _mapping(eos_parameters, "pure_shift_factors", str(source))
    family_parameters = _mapping(parameters, "heavy_families", str(source))
    supplied_families = {} if heavy_families is None else dict(heavy_families)
    shift_factors: list[Tensor] = []
    for index, name in enumerate(components.names):
        tabulated = factors.get(name)
        if isinstance(tabulated, int | float):
            shift_factors.append(components.molar_mass.new_tensor(float(tabulated)))
            continue
        family = supplied_families.get(name)
        if family is None:
            raise ValueError(
                f"Whitson has no tabulated shift factor for {name!r}; provide heavy_families[name]"
            )
        family_values = _mapping(family_parameters, family, str(source))
        a0 = _number(family_values, "A0", str(source))
        a1 = _number(family_values, "A1", str(source))
        molar_mass_g = 1000.0 * components.molar_mass[index]
        shift_factors.append(1.0 - a0 / molar_mass_g.pow(a1))
    factor = torch.stack(shift_factors)
    omega_b = _number(eos_parameters, "covolume_factor", str(source))
    covolume = omega_b * R * components.critical_temperature / components.critical_pressure
    published_c = factor * covolume
    return VolumeTranslation.constant(
        -published_c,
        source=f"{load_model_parameters(source).identifier}:{eos}",
    )

batched_two_phase_flash

batched_two_phase_flash(model, state, *, initial_k_values=None, tolerance=1e-08, substitution_iterations=12, newton_iterations=16)

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. 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,
    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. ``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")
    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 = 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,
            "liquid",
        )
        log_phi_vapor = model.log_fugacity_coefficients(
            temperature,
            pressure,
            split.vapor_composition,
            "vapor",
        )
        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 = 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,
    )

multiphase_flash

multiphase_flash(model, state, *, initial_k_values=None, tolerance=1e-08, max_iterations=100)

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

The initial release supports arbitrary fixed phase counts but automated phase discovery is conservative. For VLL/VLW work, supplying physically informed initial K values remains recommended.

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.

    The initial release supports arbitrary fixed phase counts but automated
    phase discovery is conservative. For VLL/VLW work, supplying physically
    informed initial K values remains recommended.
    """
    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,
        },
    )

solve_generalized_rachford_rice

solve_generalized_rachford_rice(composition, k_values, *, tolerance=1e-12, max_iterations=100)

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

k_values has shape (nphases - 1, ncomponents). The returned phase fractions place the reference phase first.

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.

    ``k_values`` has shape ``(nphases - 1, ncomponents)``. The returned phase
    fractions place the reference phase first.
    """
    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, state, *, reference_phase='stable', initial_compositions=None, tolerance=1e-09, max_iterations=40)

Minimize normalized tangent-plane distance from several trial phases.

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,
) -> StabilityResult:
    """Minimize normalized tangent-plane distance from several trial phases."""
    if state.composition.ndim != 1:
        raise ValueError("stability analysis currently accepts one composition vector")
    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)
        trial = trial / trial.sum()
        coordinates = torch.log(trial[:-1]) - torch.log(trial[-1])

        def 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(
            objective,
            coordinates,
            tolerance=tolerance,
            max_iterations=max_iterations,
        )
        if float(value) < float(best_value):
            best_value = value
            best_composition = _independent_softmax(q)
            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, state, *, initial_k_values=None, check_stability=True, tolerance=1e-08, max_iterations=100, raise_on_failure=False)

Solve an isothermal two-phase flash by hybrid substitution/Newton steps.

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 an isothermal two-phase flash by hybrid substitution/Newton steps."""
    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},
    )

rachford_rice_numpy

rachford_rice_numpy(composition, k_values, *, tolerance=1e-15, max_iterations=100)

NumPy compatibility wrapper matching the Whitson contest signature.

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.

    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),
    )

binary_interaction

binary_interaction(components, parameter_set, eos='PR')

Return a symmetric kij matrix from YAML model parameters.

Source code in src/torch_flash/parameters/petroleum.py
def binary_interaction(
    components: ComponentSet,
    parameter_set: ParameterSource,
    eos: PetroleumEOS = "PR",
) -> Tensor:
    """Return a symmetric ``kij`` matrix from YAML model parameters."""
    if eos not in ("PR", "SRK"):
        raise ValueError("eos must be 'PR' or 'SRK'")
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "binary_interaction":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'binary_interaction'"
        )
    parameters = loaded.parameters
    hydrocarbons = _string_tuple(parameters.get("hydrocarbons"), "hydrocarbons")
    nonhydrocarbons = _string_tuple(parameters.get("nonhydrocarbons"), "nonhydrocarbons")
    supported = frozenset((*hydrocarbons, *nonhydrocarbons))
    unsupported = sorted(set(components.names) - supported)
    if unsupported:
        names = ", ".join(unsupported)
        raise KeyError(f"{loaded.identifier} has no parameters for: {names}")
    aggregate_from = parameters.get("aggregate_from")
    if not isinstance(aggregate_from, str) or aggregate_from not in hydrocarbons:
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} requires a valid aggregate_from component"
        )
    aggregate_index = hydrocarbons.index(aggregate_from)
    eos_tables = parameters.get("eos")
    if not isinstance(eos_tables, Mapping):
        raise ParameterDatabaseError(f"{loaded.identifier!r} requires an eos mapping")
    table = eos_tables.get(eos)
    if not isinstance(table, Mapping):
        raise ParameterDatabaseError(f"{loaded.identifier!r} has no {eos} table")
    hydrocarbon_rows = table.get("hydrocarbon_rows")
    pair_values = table.get("nonhydrocarbon_pairs")
    if not isinstance(hydrocarbon_rows, Mapping) or not isinstance(pair_values, Mapping):
        raise ParameterDatabaseError(f"{loaded.identifier!r} has incomplete {eos} tables")

    kij = torch.zeros(
        (components.ncomponents, components.ncomponents),
        dtype=components.critical_temperature.dtype,
        device=components.critical_temperature.device,
    )
    for row, first in enumerate(components.names):
        for column in range(row):
            second = components.names[column]
            if first in hydrocarbons and second in hydrocarbons:
                value = 0.0
            elif first in nonhydrocarbons and second in nonhydrocarbons:
                key = f"{first}|{second}"
                reverse_key = f"{second}|{first}"
                raw_value = pair_values.get(key, pair_values.get(reverse_key))
                if not isinstance(raw_value, int | float):
                    raise ParameterDatabaseError(
                        f"{loaded.identifier!r} lacks pair {first}|{second}"
                    )
                value = float(raw_value)
            else:
                nonhydrocarbon = first if first in nonhydrocarbons else second
                hydrocarbon = second if first in nonhydrocarbons else first
                values = hydrocarbon_rows.get(nonhydrocarbon)
                if not isinstance(values, Sequence) or isinstance(values, str):
                    raise ParameterDatabaseError(
                        f"{loaded.identifier!r} lacks row {nonhydrocarbon!r}"
                    )
                hydrocarbon_index = min(hydrocarbons.index(hydrocarbon), aggregate_index)
                raw_value = values[hydrocarbon_index]
                if not isinstance(raw_value, int | float):
                    raise ParameterDatabaseError("binary-interaction values must be numeric")
                value = float(raw_value)
            kij[row, column] = value
            kij[column, row] = value
    return kij

cubic_interaction_parameters

cubic_interaction_parameters(components, source)

Load general van der Waals one-fluid kij and lij matrices.

The YAML payload declares component_order, optional numeric defaults, and unordered first|second pair records. Missing pair fields use the declared defaults, which are zero when omitted.

Source code in src/torch_flash/parameters/cubic_interactions.py
def cubic_interaction_parameters(
    components: ComponentSet,
    source: ParameterSource,
) -> CubicInteractionParameters:
    """Load general van der Waals one-fluid ``kij`` and ``lij`` matrices.

    The YAML payload declares ``component_order``, optional numeric
    ``defaults``, and unordered ``first|second`` pair records. Missing pair
    fields use the declared defaults, which are zero when omitted.
    """
    loaded = load_model_parameters(source)
    if loaded.model_kind != "binary_interaction":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'binary_interaction'"
        )
    if loaded.model != "cubic-vdw-one-fluid":
        raise ParameterDatabaseError(f"{loaded.identifier!r} model must be 'cubic-vdw-one-fluid'")
    if loaded.units.get("kij") != "dimensionless" or loaded.units.get("lij") != "dimensionless":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} must declare dimensionless kij and lij units"
        )
    parameters = loaded.parameters
    order = _component_order(parameters.get("component_order"), loaded.identifier)
    unsupported = sorted(set(components.names) - set(order))
    if unsupported:
        raise KeyError(
            f"{loaded.identifier} has no interaction parameters for: {', '.join(unsupported)}"
        )
    defaults = parameters.get("defaults", {})
    if not isinstance(defaults, Mapping):
        raise ParameterDatabaseError(f"{loaded.identifier} defaults must be a mapping")
    default_kij = _finite_default(defaults, "kij", loaded.identifier)
    default_lij = _finite_default(defaults, "lij", loaded.identifier)
    shape = (components.ncomponents, components.ncomponents)
    kij = torch.full(
        shape,
        default_kij,
        dtype=components.critical_temperature.dtype,
        device=components.critical_temperature.device,
    )
    lij = torch.full_like(kij, default_lij)
    kij.fill_diagonal_(0.0)
    lij.fill_diagonal_(0.0)
    pairs = parameters.get("pairs", {})
    if not isinstance(pairs, Mapping):
        raise ParameterDatabaseError(f"{loaded.identifier} pairs must be a mapping")
    seen: set[frozenset[str]] = set()
    selected = {name: index for index, name in enumerate(components.names)}
    allowed = set(order)
    for key, record in pairs.items():
        if not isinstance(key, str) or key.count("|") != 1:
            raise ParameterDatabaseError(
                f"{loaded.identifier} pair keys must have the form 'first|second'"
            )
        first, second = key.split("|")
        if first == second:
            raise ParameterDatabaseError(f"{loaded.identifier} cannot contain self-pairs")
        if first not in allowed or second not in allowed:
            raise ParameterDatabaseError(
                f"{loaded.identifier} pair {key!r} names a component outside component_order"
            )
        unordered = frozenset((first, second))
        if unordered in seen:
            raise ParameterDatabaseError(
                f"{loaded.identifier} contains duplicate pair {first}|{second}"
            )
        seen.add(unordered)
        if not isinstance(record, Mapping):
            raise ParameterDatabaseError(f"{loaded.identifier} pair {key!r} must be a mapping")
        values = {}
        for parameter, default in (("kij", default_kij), ("lij", default_lij)):
            value = record.get(parameter, default)
            if not isinstance(value, int | float) or not math.isfinite(value):
                raise ParameterDatabaseError(
                    f"{loaded.identifier} pair {key!r} {parameter} must be finite and numeric"
                )
            values[parameter] = float(value)
        if first not in selected or second not in selected:
            continue
        first_index = selected[first]
        second_index = selected[second]
        kij[first_index, second_index] = kij[second_index, first_index] = values["kij"]
        lij[first_index, second_index] = lij[second_index, first_index] = values["lij"]
    return CubicInteractionParameters(kij, lij, loaded.identifier)

pedersen_binary_interaction

pedersen_binary_interaction(components, eos='PR')

Return Pedersen et al. (2024), Table 4.2, kij values.

This is a distinct parameterization from Whitson's Table A-3. The source's aggregate C7+ entries are used for n-heptane through n-decane.

Source code in src/torch_flash/parameters/petroleum.py
def pedersen_binary_interaction(
    components: ComponentSet,
    eos: PetroleumEOS = "PR",
) -> Tensor:
    """Return Pedersen et al. (2024), Table 4.2, ``kij`` values.

    This is a distinct parameterization from Whitson's Table A-3. The source's
    aggregate C7+ entries are used for n-heptane through n-decane.
    """
    return binary_interaction(components, "binary-interaction.pedersen-2024", eos)

ppr78_group_contribution_parameters

ppr78_group_contribution_parameters(components, source=DEFAULT_PPR78_GROUP_CONTRIBUTION, *, group_counts=None)

Load PPR78 group parameters for components.

source may be the bundled identifier, a custom YAML path, or an in-memory :class:~torch_flash.database.ModelParameterSet. Explicit group_counts override only the component decompositions; the source still supplies the group inventory and interaction matrices.

Source code in src/torch_flash/parameters/group_contribution.py
def ppr78_group_contribution_parameters(
    components: ComponentSet,
    source: ParameterSource = DEFAULT_PPR78_GROUP_CONTRIBUTION,
    *,
    group_counts: Mapping[str, Mapping[str, float]] | Tensor | None = None,
) -> PPR78GroupContributionParameters:
    """Load PPR78 group parameters for ``components``.

    ``source`` may be the bundled identifier, a custom YAML path, or an
    in-memory :class:`~torch_flash.database.ModelParameterSet`. Explicit
    ``group_counts`` override only the component decompositions; the source
    still supplies the group inventory and interaction matrices.
    """
    loaded = load_model_parameters(source)
    if loaded.model_kind != "group_contribution":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'group_contribution'"
        )
    if loaded.model != "PPR78":
        raise ParameterDatabaseError(f"{loaded.identifier!r} model must be 'PPR78'")
    expected_units = {
        "A": "Pa",
        "B": "Pa",
        "group_fraction": "dimensionless",
        "reference_temperature": "K",
    }
    if any(loaded.units.get(key) != unit for key, unit in expected_units.items()):
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} must declare PPR78 A, B, fraction, and temperature units"
        )
    parameters = loaded.parameters
    groups = _group_names(parameters.get("groups"), loaded.identifier)
    reference_temperature = _finite(
        parameters.get("reference_temperature"),
        f"{loaded.identifier} reference_temperature",
    )
    if reference_temperature <= 0.0:
        raise ParameterDatabaseError(f"{loaded.identifier} reference_temperature must be positive")
    group_a, group_b = _interaction_matrices(
        parameters.get("interactions"),
        groups,
        loaded.identifier,
        dtype=components.critical_temperature.dtype,
        device=components.critical_temperature.device,
    )
    decompositions = parameters.get("component_groups") if group_counts is None else group_counts
    fractions = _fraction_matrix(
        components,
        groups,
        decompositions,
        loaded.identifier if group_counts is None else "<api group_counts>",
    )
    return PPR78GroupContributionParameters(
        groups,
        fractions,
        group_a,
        group_b,
        reference_temperature,
        loaded.identifier,
    )

ppr78_mixing

ppr78_mixing(components, source=DEFAULT_PPR78_GROUP_CONTRIBUTION, *, group_counts=None, trainable=False)

Construct PPR78 mixing from bundled, custom-file, or API parameters.

Source code in src/torch_flash/parameters/group_contribution.py
def ppr78_mixing(
    components: ComponentSet,
    source: ParameterSource = DEFAULT_PPR78_GROUP_CONTRIBUTION,
    *,
    group_counts: Mapping[str, Mapping[str, float]] | Tensor | None = None,
    trainable: bool = False,
) -> PPR78Mixing:
    """Construct PPR78 mixing from bundled, custom-file, or API parameters."""
    parameters = ppr78_group_contribution_parameters(
        components,
        source,
        group_counts=group_counts,
    )
    return PPR78Mixing(
        parameters.group_fractions,
        parameters.group_a,
        parameters.group_b,
        reference_temperature=parameters.reference_temperature,
        trainable=trainable,
        parameter_set=parameters.parameter_set,
    )

whitson_binary_interaction

whitson_binary_interaction(components, eos='PR')

Return Whitson and Brule (2000), Table A-3, kij values.

The source's C7+ values are applied from n-heptane through n-decane. The printed H2S/C7+ value is not silently extrapolated by carbon number.

Source code in src/torch_flash/parameters/petroleum.py
def whitson_binary_interaction(
    components: ComponentSet,
    eos: PetroleumEOS = "PR",
) -> Tensor:
    """Return Whitson and Brule (2000), Table A-3, ``kij`` values.

    The source's C7+ values are applied from n-heptane through n-decane. The
    printed H2S/C7+ value is not silently extrapolated by carbon number.
    """
    return binary_interaction(components, "binary-interaction.whitson-2000", eos)

fugacities_tv

fugacities_tv(model, temperature, volume, moles)

Return component fugacities in Pa from an explicit T-V-n state.

Source code in src/torch_flash/properties/state.py
def fugacities_tv(
    model: HelmholtzStateModel,
    temperature: Tensor,
    volume: Tensor,
    moles: Tensor,
) -> Tensor:
    """Return component fugacities in Pa from an explicit ``T-V-n`` state."""
    return STANDARD_PRESSURE * torch.exp(log_fugacities_tv(model, temperature, volume, moles))

identify_flash_phases

identify_flash_phases(phases, *, ambiguity_relative_tolerance=DEFAULT_AMBIGUITY_RELATIVE_TOLERANCE)

Fill unavailable multiphase identities by molar-volume ordering.

Existing V/b identifications are preserved. If no phase has a cubic covolume diagnostic, the least-dense phase (largest molar volume) is labeled vapor and the remaining phases liquid. Near-equal leading volumes are returned as ambiguous unknown because density ordering cannot distinguish liquid-liquid equilibrium from a vapor-liquid split.

Source code in src/torch_flash/properties/phase_identification.py
def identify_flash_phases(
    phases: tuple[PhaseProperties, ...],
    *,
    ambiguity_relative_tolerance: float = DEFAULT_AMBIGUITY_RELATIVE_TOLERANCE,
) -> tuple[PhaseProperties, ...]:
    """Fill unavailable multiphase identities by molar-volume ordering.

    Existing ``V/b`` identifications are preserved. If no phase has a cubic
    covolume diagnostic, the least-dense phase (largest molar volume) is
    labeled vapor and the remaining phases liquid. Near-equal leading volumes
    are returned as ambiguous ``unknown`` because density ordering cannot
    distinguish liquid-liquid equilibrium from a vapor-liquid split.
    """
    _validate_options(DEFAULT_VOLUME_TO_COVOLUME_THRESHOLD, ambiguity_relative_tolerance)
    if not phases:
        return phases
    has_covolume_identification = tuple(
        phase.phase_identification is not None
        and phase.phase_identification.method != "unavailable"
        for phase in phases
    )
    if any(has_covolume_identification):
        return phases
    if len(phases) == 1:
        return phases

    volumes = torch.stack(tuple(phase.molar_volume for phase in phases))
    if volumes.ndim != 1 or not bool(torch.isfinite(volumes).all()) or bool((volumes <= 0.0).any()):
        raise ValueError("phase molar volumes must be finite positive scalars")
    sorted_volumes, _ = torch.sort(volumes)
    vapor_volume = sorted_volumes[-1]
    next_volume = sorted_volumes[-2]
    separator = torch.sqrt(vapor_volume * next_volume)
    separation = vapor_volume / next_volume
    ambiguous = bool(separation <= 1.0 + ambiguity_relative_tolerance)

    identified = []
    for phase, volume in zip(phases, volumes, strict=True):
        if ambiguous:
            kind: PhaseIdentityKind = "unknown"
        else:
            kind = "vapor" if bool(volume > separator) else "liquid"
        identification = PhaseIdentification(
            kind=kind,
            method="density-ordering",
            criterion_value=volume,
            threshold=separator,
            ambiguous=ambiguous,
        )
        identified.append(replace(phase, phase_identification=identification))
    return tuple(identified)

identify_phase

identify_phase(model, state, phase='stable', *, threshold=DEFAULT_VOLUME_TO_COVOLUME_THRESHOLD, ambiguity_relative_tolerance=DEFAULT_AMBIGUITY_RELATIVE_TOLERANCE)

Identify a scalar homogeneous state using the Pedersen V/b rule.

The default threshold of 1.75 is documented by Pedersen et al. for SRK and PR. Models that expose compatible cubic-family mixture_parameters can use the same machinery, but a non-SRK/PR threshold requires independent validation. If the model does not expose a covolume, unknown is returned rather than inferring physical identity from the selected root.

Source code in src/torch_flash/properties/phase_identification.py
def identify_phase(
    model: object,
    state: ChemicalState,
    phase: PhaseKind = "stable",
    *,
    threshold: float = DEFAULT_VOLUME_TO_COVOLUME_THRESHOLD,
    ambiguity_relative_tolerance: float = DEFAULT_AMBIGUITY_RELATIVE_TOLERANCE,
) -> PhaseIdentification:
    """Identify a scalar homogeneous state using the Pedersen ``V/b`` rule.

    The default threshold of 1.75 is documented by Pedersen et al. for SRK and
    PR. Models that expose compatible cubic-family ``mixture_parameters`` can
    use the same machinery, but a non-SRK/PR threshold requires independent
    validation. If the model does not expose a covolume, ``unknown`` is
    returned rather than inferring physical identity from the selected root.
    """
    _validate_options(threshold, ambiguity_relative_tolerance)
    if state.temperature.ndim != 0 or state.pressure.ndim != 0 or state.composition.ndim != 1:
        raise ValueError("phase identification currently accepts one scalar T-P state")
    select_z: Any = getattr(model, "select_z", None)
    if not callable(select_z):
        raise TypeError("phase-identification model must implement select_z")
    compressibility_factor = select_z(
        state.temperature,
        state.pressure,
        state.composition,
        phase,
    )
    return _from_state_values(
        model,
        state,
        compressibility_factor,
        threshold=threshold,
        ambiguity_relative_tolerance=ambiguity_relative_tolerance,
    )

log_fugacities_tv

log_fugacities_tv(model, temperature, volume, moles)

Return ln(f_i/p_standard) from an explicit T-V-n state.

volume is the total physical volume in m3 and moles are component amounts in mol. The identity

ln(f_i/p_standard) = ln(n_i*R*T/(V*p_standard)) + d(A^R/RT)/dn_i

provides an independent Helmholtz-route check of the usual TP fugacity calculation. The model's residual Helmholtz energy must be extensive and referenced to an ideal gas at the same physical volume.

Source code in src/torch_flash/properties/state.py
def log_fugacities_tv(
    model: HelmholtzStateModel,
    temperature: Tensor,
    volume: Tensor,
    moles: Tensor,
) -> Tensor:
    """Return ``ln(f_i/p_standard)`` from an explicit ``T-V-n`` state.

    ``volume`` is the total physical volume in m3 and ``moles`` are component
    amounts in mol. The identity

    ``ln(f_i/p_standard) = ln(n_i*R*T/(V*p_standard)) + d(A^R/RT)/dn_i``

    provides an independent Helmholtz-route check of the usual TP fugacity
    calculation. The model's residual Helmholtz energy must be extensive and
    referenced to an ideal gas at the same physical volume.
    """
    if temperature.ndim != 0 or volume.ndim != 0:
        raise ValueError("log_fugacities_tv currently accepts scalar temperature and volume")
    if moles.ndim != 1:
        raise ValueError("log_fugacities_tv currently accepts one mole-number vector")
    if bool((temperature <= 0.0) | (volume <= 0.0)):
        raise ValueError("temperature and volume must be positive")
    if bool((~torch.isfinite(moles)).any() | (moles <= 0.0).any()):
        raise ValueError("moles must be finite and strictly positive")

    residual_mu_rt: Tensor = torch.func.jacrev(
        lambda current_moles: model.residual_helmholtz_rt(
            temperature,
            volume,
            current_moles,
        )
    )(moles)
    ideal_log_fugacity = torch.log(moles * R * temperature / (volume * STANDARD_PRESSURE))
    return ideal_log_fugacity + residual_mu_rt

phase_properties

phase_properties(model, state, phase='stable', *, caloric=True, standard_state=None)

Evaluate a homogeneous state, without any equilibrium calculation.

Fugacities are returned in Pa and logarithmic fugacities are ln(f_i / p_standard) with p_standard = 1 bar. Chemical potentials and total free energies use a zero ideal-gas standard chemical potential at that pressure unless standard_state is supplied. The corresponding dimensionless chemical potential is mu_i / (R*T); it is the thermodynamically meaningful alternative to ln(mu_i), which is not generally defined for a dimensional, reference-dependent quantity that may be negative.

Absolute caloric reference terms are deliberately not invented; returned enthalpy and entropy are residual quantities.

The reduced molar free energies are reduced_gibbs_energy = g/(R*T) and reduced_helmholtz_energy = a/(R*T), with a = g - P*v. The reference-independent departures follow g^R/(R*T) = sum(x_i*ln(phi_i)) and a^R/(R*T) = g^R/(R*T) - Z + 1 + ln(Z), where Z = P*v/(R*T).

At an exactly zero mole fraction, the logarithmic component properties use the smallest positive value of the tensor dtype as a finite trace limit. state_derivatives instead requires a strictly positive, interior composition because logarithmic composition derivatives are singular on the simplex boundary.

Source code in src/torch_flash/properties/state.py
def phase_properties(
    model: StateModel,
    state: ChemicalState,
    phase: PhaseKind = "stable",
    *,
    caloric: bool = True,
    standard_state: StandardState | None = None,
) -> PhaseProperties:
    """Evaluate a homogeneous state, without any equilibrium calculation.

    Fugacities are returned in Pa and logarithmic fugacities are
    ``ln(f_i / p_standard)`` with ``p_standard = 1 bar``. Chemical potentials
    and total free energies use a zero ideal-gas standard chemical potential
    at that pressure unless ``standard_state`` is supplied. The corresponding
    dimensionless chemical potential is ``mu_i / (R*T)``; it is the
    thermodynamically meaningful alternative to ``ln(mu_i)``, which is not
    generally defined for a dimensional, reference-dependent quantity that
    may be negative.

    Absolute caloric reference terms are deliberately not invented; returned
    enthalpy and entropy are residual quantities.

    The reduced molar free energies are
    ``reduced_gibbs_energy = g/(R*T)`` and
    ``reduced_helmholtz_energy = a/(R*T)``, with ``a = g - P*v``. The
    reference-independent departures follow
    ``g^R/(R*T) = sum(x_i*ln(phi_i))`` and
    ``a^R/(R*T) = g^R/(R*T) - Z + 1 + ln(Z)``, where
    ``Z = P*v/(R*T)``.

    At an exactly zero mole fraction, the logarithmic component properties
    use the smallest positive value of the tensor dtype as a finite trace
    limit. ``state_derivatives`` instead requires a strictly positive,
    interior composition because logarithmic composition derivatives are
    singular on the simplex boundary.
    """
    temperature = state.temperature
    pressure = state.pressure
    composition = state.composition
    z_factor = model.select_z(temperature, pressure, composition, phase)
    volume = model.molar_volume(temperature, pressure, composition, phase)
    log_phi = model.log_fugacity_coefficients(temperature, pressure, composition, phase)
    log_fugacity = _log_fugacities_from_coefficients(log_phi, pressure, composition)
    fugacity = STANDARD_PRESSURE * torch.exp(log_fugacity)
    chemical_potentials = _chemical_potentials_from_log_fugacities(
        log_fugacity,
        temperature,
        standard_state,
    )
    reduced_chemical_potentials = chemical_potentials / (R * temperature[..., None])
    gibbs = torch.sum(composition * chemical_potentials, dim=-1)
    pressure_volume_over_rt = pressure * volume / (R * temperature)
    helmholtz = gibbs - pressure * volume
    reduced_gibbs = gibbs / (R * temperature)
    reduced_helmholtz = helmholtz / (R * temperature)
    reduced_residual_gibbs = torch.sum(composition * log_phi, dim=-1)
    reduced_residual_helmholtz = (
        reduced_residual_gibbs - pressure_volume_over_rt + 1.0 + torch.log(pressure_volume_over_rt)
    )

    residual_enthalpy: Tensor | None = None
    residual_entropy: Tensor | None = None
    if caloric and temperature.ndim == 0 and composition.ndim == 1:

        def residual_gibbs_over_t(current_temperature: Tensor) -> Tensor:
            current_log_phi = model.log_fugacity_coefficients(
                current_temperature, pressure, composition, phase
            )
            return R * torch.sum(composition * current_log_phi)

        derivative = torch.func.grad(residual_gibbs_over_t)(temperature)
        residual_enthalpy = -temperature.square() * derivative
        residual_gibbs = R * temperature * torch.sum(composition * log_phi)
        residual_entropy = (residual_enthalpy - residual_gibbs) / temperature

    properties = PhaseProperties(
        kind=phase,
        composition=composition,
        compressibility_factor=z_factor,
        molar_volume=volume,
        log_fugacity_coefficients=log_phi,
        fugacities=fugacity,
        log_fugacities=log_fugacity,
        chemical_potentials=chemical_potentials,
        reduced_chemical_potentials=reduced_chemical_potentials,
        molar_gibbs_energy=gibbs,
        molar_helmholtz_energy=helmholtz,
        reduced_gibbs_energy=reduced_gibbs,
        reduced_helmholtz_energy=reduced_helmholtz,
        reduced_residual_gibbs_energy=reduced_residual_gibbs,
        reduced_residual_helmholtz_energy=reduced_residual_helmholtz,
        residual_enthalpy=residual_enthalpy,
        residual_entropy=residual_entropy,
    )
    return replace(
        properties,
        phase_identification=identify_phase_from_properties(model, state, properties),
    )

state_derivatives

state_derivatives(model, state, phase='stable', *, standard_state=None)

Autodifferentiate TP state properties in several composition coordinates.

Source code in src/torch_flash/properties/state.py
def state_derivatives(
    model: StateModel,
    state: ChemicalState,
    phase: PhaseKind = "stable",
    *,
    standard_state: StandardState | None = None,
) -> ThermodynamicDerivatives:
    """Autodifferentiate TP state properties in several composition coordinates."""
    if state.temperature.ndim != 0 or state.pressure.ndim != 0:
        raise ValueError("state_derivatives currently accepts one scalar T-P state")
    if state.composition.ndim != 1:
        raise ValueError("state_derivatives currently accepts one composition vector")
    if bool((state.composition <= 0.0).any()):
        raise ValueError("state_derivatives requires strictly positive mole fractions")

    def composition_from_logits(logit_values: Tensor) -> Tensor:
        return torch.softmax(logit_values, dim=-1)

    def composition_from_independent(independent: Tensor) -> Tensor:
        return torch.cat((independent, (1.0 - independent.sum()).reshape(1)))

    def composition_from_moles(moles: Tensor) -> Tensor:
        return moles / moles.sum()

    def log_phi_at(composition: Tensor, temperature: Tensor, pressure: Tensor) -> Tensor:
        return model.log_fugacity_coefficients(
            temperature,
            pressure,
            composition,
            phase,
        )

    def log_fugacity_at(composition: Tensor, temperature: Tensor, pressure: Tensor) -> Tensor:
        return _log_fugacities(
            model,
            temperature,
            pressure,
            composition,
            phase,
        )

    def mu_at(composition: Tensor, temperature: Tensor, pressure: Tensor) -> Tensor:
        return _chemical_potentials(
            model,
            temperature,
            pressure,
            composition,
            phase,
            standard_state,
        )

    def volume_at(composition: Tensor, temperature: Tensor, pressure: Tensor) -> Tensor:
        return model.molar_volume(temperature, pressure, composition, phase)

    logits = torch.log(torch.clamp_min(state.composition, 1.0e-300))
    independent = state.composition[:-1]
    unit_moles = state.composition

    PropertyFunction = Callable[[Tensor, Tensor, Tensor], Tensor]

    def apply_logits(function: PropertyFunction, current: Tensor) -> Tensor:
        return function(
            composition_from_logits(current),
            state.temperature,
            state.pressure,
        )

    def apply_independent(function: PropertyFunction, current: Tensor) -> Tensor:
        return function(
            composition_from_independent(current),
            state.temperature,
            state.pressure,
        )

    def apply_moles(function: PropertyFunction, current: Tensor) -> Tensor:
        return function(
            composition_from_moles(current),
            state.temperature,
            state.pressure,
        )

    dlog_phi_dlogits = torch.func.jacrev(lambda value: apply_logits(log_phi_at, value))(logits)
    dlog_phi_dindependent = torch.func.jacrev(lambda value: apply_independent(log_phi_at, value))(
        independent
    )
    dlog_phi_dmoles = torch.func.jacrev(lambda value: apply_moles(log_phi_at, value))(unit_moles)
    dlog_phi_dt = torch.func.jacrev(lambda t: log_phi_at(state.composition, t, state.pressure))(
        state.temperature
    )
    dlog_phi_dp = torch.func.jacrev(lambda p: log_phi_at(state.composition, state.temperature, p))(
        state.pressure
    )

    dlog_fugacity_dlogits = torch.func.jacrev(lambda value: apply_logits(log_fugacity_at, value))(
        logits
    )
    dlog_fugacity_dindependent = torch.func.jacrev(
        lambda value: apply_independent(log_fugacity_at, value)
    )(independent)
    dlog_fugacity_dmoles = torch.func.jacrev(lambda value: apply_moles(log_fugacity_at, value))(
        unit_moles
    )
    dlog_fugacity_dt = torch.func.jacrev(
        lambda t: log_fugacity_at(state.composition, t, state.pressure)
    )(state.temperature)
    dlog_fugacity_dp = torch.func.jacrev(
        lambda p: log_fugacity_at(state.composition, state.temperature, p)
    )(state.pressure)

    dmu_dlogits = torch.func.jacrev(lambda value: apply_logits(mu_at, value))(logits)
    dmu_dindependent = torch.func.jacrev(lambda value: apply_independent(mu_at, value))(independent)
    dmu_dmoles = torch.func.jacrev(lambda value: apply_moles(mu_at, value))(unit_moles)
    dmu_dt = torch.func.jacrev(lambda t: mu_at(state.composition, t, state.pressure))(
        state.temperature
    )
    dmu_dp = torch.func.jacrev(lambda p: mu_at(state.composition, state.temperature, p))(
        state.pressure
    )

    dvolume_dlogits = torch.func.jacrev(lambda value: apply_logits(volume_at, value))(logits)
    dvolume_dindependent = torch.func.jacrev(lambda value: apply_independent(volume_at, value))(
        independent
    )
    dvolume_dmoles = torch.func.jacrev(lambda value: apply_moles(volume_at, value))(unit_moles)
    dvolume_dt = torch.func.jacrev(lambda t: volume_at(state.composition, t, state.pressure))(
        state.temperature
    )
    dvolume_dp = torch.func.jacrev(lambda p: volume_at(state.composition, state.temperature, p))(
        state.pressure
    )

    def gibbs(t: Tensor, p: Tensor) -> Tensor:
        mu = mu_at(state.composition, t, p)
        return torch.sum(state.composition * mu)

    dg_dt = torch.func.grad(lambda t: gibbs(t, state.pressure))(state.temperature)
    dg_dp = torch.func.grad(lambda p: gibbs(state.temperature, p))(state.pressure)

    log_fugacity = _log_fugacities(
        model,
        state.temperature,
        state.pressure,
        state.composition,
        phase,
    )
    fugacity = STANDARD_PRESSURE * torch.exp(log_fugacity)
    log_phi = log_phi_at(state.composition, state.temperature, state.pressure)
    phi = torch.exp(log_phi)
    dphi_dlogits = phi[:, None] * dlog_phi_dlogits
    dphi_dindependent = phi[:, None] * dlog_phi_dindependent
    dphi_dmoles = phi[:, None] * dlog_phi_dmoles
    dphi_dt = phi * dlog_phi_dt
    dphi_dp = phi * dlog_phi_dp
    dfugacity_dlogits = fugacity[:, None] * dlog_fugacity_dlogits
    dfugacity_dindependent = fugacity[:, None] * dlog_fugacity_dindependent
    dfugacity_dmoles = fugacity[:, None] * dlog_fugacity_dmoles
    dfugacity_dt = fugacity * dlog_fugacity_dt
    dfugacity_dp = fugacity * dlog_fugacity_dp

    chemical_potential = _chemical_potentials(
        model,
        state.temperature,
        state.pressure,
        state.composition,
        phase,
        standard_state,
    )
    rt = R * state.temperature
    dreduced_mu_dlogits = dmu_dlogits / rt
    dreduced_mu_dindependent = dmu_dindependent / rt
    dreduced_mu_dmoles = dmu_dmoles / rt
    dreduced_mu_dt = dmu_dt / rt - chemical_potential / (R * state.temperature.square())
    dreduced_mu_dp = dmu_dp / rt

    return ThermodynamicDerivatives(
        dfugacity_coefficient_dlogits=dphi_dlogits,
        dfugacity_coefficient_dindependent_composition=dphi_dindependent,
        dfugacity_coefficient_dtemperature=dphi_dt,
        dfugacity_coefficient_dpressure=dphi_dp,
        dfugacity_coefficient_dmoles=dphi_dmoles,
        dlog_fugacity_coefficient_dlogits=dlog_phi_dlogits,
        dlog_fugacity_coefficient_dindependent_composition=dlog_phi_dindependent,
        dlog_fugacity_coefficient_dtemperature=dlog_phi_dt,
        dlog_fugacity_coefficient_dpressure=dlog_phi_dp,
        dlog_fugacity_coefficient_dmoles=dlog_phi_dmoles,
        dfugacity_dlogits=dfugacity_dlogits,
        dfugacity_dindependent_composition=dfugacity_dindependent,
        dfugacity_dtemperature=dfugacity_dt,
        dfugacity_dpressure=dfugacity_dp,
        dfugacity_dmoles=dfugacity_dmoles,
        dlog_fugacity_dlogits=dlog_fugacity_dlogits,
        dlog_fugacity_dindependent_composition=dlog_fugacity_dindependent,
        dlog_fugacity_dtemperature=dlog_fugacity_dt,
        dlog_fugacity_dpressure=dlog_fugacity_dp,
        dlog_fugacity_dmoles=dlog_fugacity_dmoles,
        dchemical_potential_dlogits=dmu_dlogits,
        dchemical_potential_dindependent_composition=dmu_dindependent,
        dchemical_potential_dtemperature=dmu_dt,
        dchemical_potential_dpressure=dmu_dp,
        dchemical_potential_dmoles=dmu_dmoles,
        dreduced_chemical_potential_dlogits=dreduced_mu_dlogits,
        dreduced_chemical_potential_dindependent_composition=dreduced_mu_dindependent,
        dreduced_chemical_potential_dtemperature=dreduced_mu_dt,
        dreduced_chemical_potential_dpressure=dreduced_mu_dp,
        dreduced_chemical_potential_dmoles=dreduced_mu_dmoles,
        dmolar_volume_dlogits=dvolume_dlogits,
        dmolar_volume_dindependent_composition=dvolume_dindependent,
        dmolar_volume_dtemperature=dvolume_dt,
        dmolar_volume_dpressure=dvolume_dp,
        dmolar_volume_dmoles=dvolume_dmoles,
        dgibbs_dtemperature=dg_dt,
        dgibbs_dpressure=dg_dp,
    )

thermal_properties

thermal_properties(model, state, standard_state, phase='stable', *, reference_pressure=STANDARD_PRESSURE, molar_mass=None)

Evaluate Pedersen Chapter 8 properties at a specified state.

No phase-equilibrium calculation is performed. The requested phase root remains fixed while differentiating. A volume-translated cubic receives Pedersen's P*DeltaV enthalpy correction (Eq. 8.12).

The entropy pressure term in Eq. 8.17 is interpreted as -R*ln(P/Pref); the glyph in the printed 2024 edition can resemble T, but dimensional consistency and the ideal-gas identity require the gas constant. Helmholtz energy follows a = g - P*v. Reduced total free energies use the supplied caloric reference, while reduced residual free energies are reference-independent EoS departures.

Source code in src/torch_flash/properties/thermal.py
def thermal_properties(
    model: StateModel,
    state: ChemicalState,
    standard_state: CaloricStandardState,
    phase: PhaseKind = "stable",
    *,
    reference_pressure: float = STANDARD_PRESSURE,
    molar_mass: Tensor | None = None,
) -> ThermalProperties:
    """Evaluate Pedersen Chapter 8 properties at a specified state.

    No phase-equilibrium calculation is performed. The requested phase root
    remains fixed while differentiating. A volume-translated cubic receives
    Pedersen's ``P*DeltaV`` enthalpy correction (Eq. 8.12).

    The entropy pressure term in Eq. 8.17 is interpreted as
    ``-R*ln(P/Pref)``; the glyph in the printed 2024 edition can resemble
    ``T``, but dimensional consistency and the ideal-gas identity require
    the gas constant. Helmholtz energy follows ``a = g - P*v``. Reduced total
    free energies use the supplied caloric reference, while reduced residual
    free energies are reference-independent EoS departures.
    """
    if state.temperature.ndim != 0 or state.pressure.ndim != 0:
        raise ValueError("thermal_properties currently accepts one scalar T-P state")
    if state.composition.ndim != 1:
        raise ValueError("thermal_properties currently accepts one composition vector")
    if reference_pressure <= 0.0:
        raise ValueError("reference_pressure must be positive")

    temperature = state.temperature
    pressure = state.pressure
    composition = state.composition
    ideal_enthalpy = standard_state.enthalpy(temperature)
    ideal_entropy = standard_state.entropy(temperature)
    if ideal_enthalpy.shape != composition.shape or ideal_entropy.shape != composition.shape:
        raise ValueError("standard-state component count must match the composition")

    residual_enthalpy, residual_entropy = _residual_terms(
        model,
        temperature,
        pressure,
        composition,
        phase,
    )
    enthalpy = torch.sum(composition * ideal_enthalpy) + residual_enthalpy
    safe_composition = torch.clamp_min(composition, torch.finfo(composition.dtype).tiny)
    entropy = (
        torch.sum(composition * ideal_entropy)
        - R * torch.log(pressure / reference_pressure)
        - R * torch.sum(composition * torch.log(safe_composition))
        + residual_entropy
    )
    volume = model.molar_volume(temperature, pressure, composition, phase)
    internal_energy = enthalpy - pressure * volume
    gibbs = enthalpy - temperature * entropy
    helmholtz = gibbs - pressure * volume
    reduced_gibbs = gibbs / (R * temperature)
    reduced_helmholtz = helmholtz / (R * temperature)
    pressure_volume_over_rt = pressure * volume / (R * temperature)
    reduced_residual_gibbs = _weighted_log_fugacity(
        model,
        temperature,
        pressure,
        composition,
        phase,
    )
    reduced_residual_helmholtz = (
        reduced_residual_gibbs - pressure_volume_over_rt + 1.0 + torch.log(pressure_volume_over_rt)
    )

    enthalpy_at_temperature = lambda current_temperature: _enthalpy(  # noqa: E731
        model,
        standard_state,
        current_temperature,
        pressure,
        composition,
        phase,
    )
    enthalpy_at_pressure = lambda current_pressure: _enthalpy(  # noqa: E731
        model,
        standard_state,
        temperature,
        current_pressure,
        composition,
        phase,
    )
    cp = torch.func.grad(enthalpy_at_temperature)(temperature)
    dh_dp = torch.func.grad(enthalpy_at_pressure)(pressure)
    joule_thomson = -dh_dp / cp

    volume_at_temperature = lambda current_temperature: model.molar_volume(  # noqa: E731
        current_temperature,
        pressure,
        composition,
        phase,
    )
    volume_at_pressure = lambda current_pressure: model.molar_volume(  # noqa: E731
        temperature,
        current_pressure,
        composition,
        phase,
    )
    dv_dt = torch.func.grad(volume_at_temperature)(temperature)
    dv_dp = torch.func.grad(volume_at_pressure)(pressure)
    cv = cp + temperature * dv_dt.square() / dv_dp

    response_values = torch.stack((cp, cv, dv_dp))
    if bool(
        (
            (~torch.isfinite(response_values)).any() | (cp <= 0.0) | (cv <= 0.0) | (dv_dp >= 0.0)
        ).detach()
    ):
        raise InvalidStateError(
            "thermal response functions require positive Cp/Cv and "
            "negative isothermal compressibility"
        )

    mixture_molar_mass = _mixture_molar_mass(model, composition, molar_mass)
    speed_of_sound = None
    if mixture_molar_mass is not None:
        speed_squared = -volume.square() * cp / (mixture_molar_mass * cv * dv_dp)
        if bool((~torch.isfinite(speed_squared) | (speed_squared <= 0.0)).detach()):
            raise InvalidStateError("speed-of-sound expression is not positive and finite")
        speed_of_sound = torch.sqrt(speed_squared)

    return ThermalProperties(
        enthalpy,
        internal_energy,
        entropy,
        helmholtz,
        gibbs,
        reduced_helmholtz,
        reduced_gibbs,
        reduced_residual_helmholtz,
        reduced_residual_gibbs,
        cp,
        cv,
        joule_thomson,
        speed_of_sound,
        residual_enthalpy,
        residual_entropy,
    )

volume_to_covolume_ratio

volume_to_covolume_ratio(model, state, phase='stable')

Return the differentiable cubic-family V/b phase diagnostic.

Leading batch dimensions are supported. Cubic volume translations are excluded because Pedersen's criterion uses the volume entering the cubic repulsive term. A model without mixture_parameters has no defined covolume and raises TypeError.

Source code in src/torch_flash/properties/phase_identification.py
def volume_to_covolume_ratio(
    model: object,
    state: ChemicalState,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return the differentiable cubic-family ``V/b`` phase diagnostic.

    Leading batch dimensions are supported. Cubic volume translations are
    excluded because Pedersen's criterion uses the volume entering the cubic
    repulsive term. A model without ``mixture_parameters`` has no defined
    covolume and raises ``TypeError``.
    """
    select_z: Any = getattr(model, "select_z", None)
    if not callable(select_z):
        raise TypeError("phase-identification model must implement select_z")
    covolume = _covolume(model, state)
    if covolume is None:
        raise TypeError("phase-identification model does not expose a mixture covolume")
    compressibility_factor = cast(
        Tensor,
        select_z(
            state.temperature,
            state.pressure,
            state.composition,
            phase,
        ),
    )
    equation_of_state_volume = compressibility_factor * R * state.temperature / state.pressure
    ratio = equation_of_state_volume / covolume
    if not bool(torch.isfinite(ratio).all()) or bool((ratio <= 0.0).any()):
        raise ValueError("volume-to-covolume ratio must be finite and positive")
    return ratio

ideal_gas_polynomial

ideal_gas_polynomial(names, parameter_set, *, dtype=None, device=None, reference_temperature=None, trainable=False)

Construct an ideal-gas polynomial from YAML or explicit parameters.

Source code in src/torch_flash/standard_state.py
def ideal_gas_polynomial(
    names: tuple[str, ...] | list[str],
    parameter_set: ParameterSource,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    reference_temperature: float | None = None,
    trainable: bool = False,
) -> IdealGasPolynomial:
    """Construct an ideal-gas polynomial from YAML or explicit parameters."""
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "standard_state":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'standard_state'"
        )
    records = loaded.parameters.get("components")
    if not isinstance(records, Mapping):
        raise ParameterDatabaseError(f"{loaded.identifier!r} requires a components mapping")
    canonical = tuple(component(name).name for name in names)
    coefficients: list[list[float]] = []
    reference_enthalpy: list[float] = []
    reference_entropy: list[float] = []
    for name in canonical:
        record = records.get(name)
        if not isinstance(record, Mapping):
            raise KeyError(f"no {loaded.identifier} heat-capacity coefficients for: {name}")
        values = record.get("coefficients")
        if (
            not isinstance(values, Sequence)
            or isinstance(values, str)
            or not values
            or any(not isinstance(value, int | float) for value in values)
        ):
            raise ParameterDatabaseError(
                f"{loaded.identifier!r} coefficients for {name!r} must be numeric"
            )
        coefficients.append([float(value) for value in values])
        enthalpy = record.get("reference_enthalpy", 0.0)
        entropy = record.get("reference_entropy", 0.0)
        if not isinstance(enthalpy, int | float) or not isinstance(entropy, int | float):
            raise ParameterDatabaseError(
                f"{loaded.identifier!r} reference properties for {name!r} must be numeric"
            )
        reference_enthalpy.append(float(enthalpy))
        reference_entropy.append(float(entropy))
    if len({len(values) for values in coefficients}) != 1:
        raise ParameterDatabaseError("ideal-gas polynomial orders must match")
    resolved_reference = reference_temperature
    if resolved_reference is None:
        stored_reference = loaded.parameters.get("default_reference_temperature")
        if not isinstance(stored_reference, int | float):
            raise ParameterDatabaseError(
                f"{loaded.identifier!r} requires default_reference_temperature"
            )
        resolved_reference = float(stored_reference)
    dimensionless = torch.tensor(coefficients, dtype=dtype, device=device)
    return IdealGasPolynomial(
        R * dimensionless,
        torch.tensor(reference_enthalpy, dtype=dtype, device=device),
        torch.tensor(reference_entropy, dtype=dtype, device=device),
        reference_temperature=resolved_reference,
        trainable=trainable,
    )

poling_ideal_gas

poling_ideal_gas(names, *, dtype=None, device=None, reference_temperature=273.15, trainable=False)

Construct a named Poling ideal-gas standard state.

The tabulated fits cover 50-1000 K for gases through propane and 200-1000 K for C4-C10. Argon and helium are the monatomic ideal-gas limits. This function deliberately does not extrapolate or clip temperatures; callers remain responsible for checking those source ranges in their application.

Source code in src/torch_flash/standard_state.py
def poling_ideal_gas(
    names: tuple[str, ...] | list[str],
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    reference_temperature: float = 273.15,
    trainable: bool = False,
) -> IdealGasPolynomial:
    """Construct a named Poling ideal-gas standard state.

    The tabulated fits cover 50-1000 K for gases through propane and
    200-1000 K for C4-C10. Argon and helium are the monatomic ideal-gas
    limits. This function deliberately does not extrapolate or clip
    temperatures; callers remain responsible for checking those source
    ranges in their application.
    """
    try:
        return ideal_gas_polynomial(
            names,
            "standard-state.poling-2001",
            dtype=dtype,
            device=device,
            reference_temperature=reference_temperature,
            trainable=trainable,
        )
    except KeyError as exc:
        canonical = tuple(component(name).name for name in names)
        raise KeyError(
            f"no frozen Poling heat-capacity coefficients for: {', '.join(canonical)}"
        ) from exc

lbc_pseudocomponent_critical_volume

lbc_pseudocomponent_critical_volume(molar_mass, standard_liquid_density)

Estimate a C7+ critical molar volume from Pedersen Eq. 10.41.

Parameters are SI: molar mass in kg/mol and liquid density in kg/m3. The returned critical molar volume is in m3/mol. The underlying empirical equation uses g/mol, g/cm3, and ft3/lbmol.

Source code in src/torch_flash/transport/viscosity.py
def lbc_pseudocomponent_critical_volume(
    molar_mass: Tensor,
    standard_liquid_density: Tensor,
) -> Tensor:
    """Estimate a C7+ critical molar volume from Pedersen Eq. 10.41.

    Parameters are SI: molar mass in kg/mol and liquid density in kg/m3.
    The returned critical molar volume is in m3/mol. The underlying empirical
    equation uses g/mol, g/cm3, and ft3/lbmol.
    """
    if bool(
        (
            (~torch.isfinite(molar_mass))
            | (~torch.isfinite(standard_liquid_density))
            | (molar_mass <= 0.0)
            | (standard_liquid_density <= 0.0)
        ).any()
    ):
        raise ValueError("molar mass and standard liquid density must be finite and positive")
    molar_mass_g = 1000.0 * molar_mass
    density_g_cm3 = standard_liquid_density / 1000.0
    volume_ft3_lbmol = (
        21.573
        + 0.015122 * molar_mass_g
        - 27.656 * density_g_cm3
        + 0.070615 * molar_mass_g * density_g_cm3
    )
    return volume_ft3_lbmol * _FT3_PER_LBMOL_TO_M3_PER_MOL

lbc_viscosity

lbc_viscosity(temperature, molar_density, composition, components, *, critical_volume=None, coefficients=None)

Return Lohrenz-Bray-Clark mixture viscosity in Pa s.

Parameters:

Name Type Description Default
temperature Tensor

Temperature in K.

required
molar_density Tensor

Homogeneous-phase molar density in mol/m3, normally obtained from the same EoS used by the flash calculation.

required
composition Tensor

One mole-fraction vector.

required
components ComponentSet

Critical temperatures, pressures, molar masses, and volumes.

required
critical_volume Tensor | None

Optional m3/mol values overriding the component data. This is the principal LBC tuning parameter for petroleum pseudocomponents.

None
coefficients Tensor | None

Optional trainable or fitted a1...a5 tensor replacing the original LBC constants.

None
Notes

The Stiel-Thodos reducing parameters use K, atm, and g/mol, as required by the published numerical constants. LBC is empirical and often needs pseudocomponent critical-volume or coefficient tuning for heavy oils.

Source code in src/torch_flash/transport/viscosity.py
def lbc_viscosity(
    temperature: Tensor,
    molar_density: Tensor,
    composition: Tensor,
    components: ComponentSet,
    *,
    critical_volume: Tensor | None = None,
    coefficients: Tensor | None = None,
) -> Tensor:
    """Return Lohrenz-Bray-Clark mixture viscosity in Pa s.

    Parameters
    ----------
    temperature
        Temperature in K.
    molar_density
        Homogeneous-phase molar density in mol/m3, normally obtained from the
        same EoS used by the flash calculation.
    composition
        One mole-fraction vector.
    components
        Critical temperatures, pressures, molar masses, and volumes.
    critical_volume
        Optional m3/mol values overriding the component data. This is the
        principal LBC tuning parameter for petroleum pseudocomponents.
    coefficients
        Optional trainable or fitted ``a1...a5`` tensor replacing the original
        LBC constants.

    Notes
    -----
    The Stiel-Thodos reducing parameters use K, atm, and g/mol, as required by
    the published numerical constants. LBC is empirical and often needs
    pseudocomponent critical-volume or coefficient tuning for heavy oils.
    """
    if composition.ndim != 1:
        raise ValueError("LBC viscosity currently accepts one composition vector")
    if composition.numel() != components.ncomponents:
        raise ValueError("composition and component set sizes must match")
    if bool(
        (
            (~torch.isfinite(temperature))
            | (~torch.isfinite(molar_density))
            | (temperature <= 0.0)
            | (molar_density < 0.0)
        ).any()
    ):
        raise InvalidStateError("temperature must be positive and molar density non-negative")

    volumes = components.critical_volume if critical_volume is None else critical_volume
    if volumes is None:
        raise ValueError("critical molar volumes are required by the LBC correlation")
    if volumes.shape != components.critical_temperature.shape:
        raise ValueError("critical_volume must have one value per component")
    if bool(((~torch.isfinite(volumes)) | (volumes <= 0.0)).any()):
        raise ValueError("critical molar volumes must be finite and positive")

    parameters = (
        temperature.new_tensor(_LBC)
        if coefficients is None
        else coefficients.to(dtype=temperature.dtype, device=temperature.device)
    )
    if parameters.shape != (5,) or bool((~torch.isfinite(parameters)).any()):
        raise ValueError("LBC coefficients must be a finite five-element tensor")

    z = normalize_composition(composition)
    tc = components.critical_temperature
    pc_atm = components.critical_pressure / 101_325.0
    molar_mass_g = 1000.0 * components.molar_mass
    xi_components = tc.pow(1.0 / 6.0) / (molar_mass_g.sqrt() * pc_atm.pow(2.0 / 3.0))
    reduced_temperature = temperature[..., None] / tc
    dilute_components_cp = torch.where(
        reduced_temperature <= 1.5,
        34.0e-5 * reduced_temperature.pow(0.94) / xi_components,
        17.78e-5 * (4.58 * reduced_temperature - 1.67).pow(5.0 / 8.0) / xi_components,
    )
    square_root_mass = molar_mass_g.sqrt()
    dilute_mixture_cp = torch.sum(z * dilute_components_cp * square_root_mass, dim=-1) / torch.sum(
        z * square_root_mass
    )

    mixture_xi = torch.sum(z * tc).pow(1.0 / 6.0) / (
        torch.sum(z * molar_mass_g).sqrt() * torch.sum(z * pc_atm).pow(2.0 / 3.0)
    )
    reduced_density = molar_density * torch.sum(z * volumes)
    polynomial = parameters[0] + reduced_density * (
        parameters[1]
        + reduced_density
        * (parameters[2] + reduced_density * (parameters[3] + reduced_density * parameters[4]))
    )
    dense_increment_cp = (polynomial.pow(4) - 1.0e-4) / mixture_xi
    viscosity_cp = dilute_mixture_cp + dense_increment_cp
    if bool((~torch.isfinite(viscosity_cp) | (viscosity_cp <= 0.0)).any()):
        raise InvalidStateError("LBC correlation produced a non-positive viscosity")
    return viscosity_cp * 1.0e-3

pedersen_viscosity

pedersen_viscosity(temperature, pressure, composition, components, *, phase='vapor')

Return Pedersen CSP mixture viscosity in Pa s.

Source code in src/torch_flash/transport/viscosity.py
def pedersen_viscosity(
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    components: ComponentSet,
    *,
    phase: Literal["liquid", "vapor"] = "vapor",
) -> Tensor:
    """Return Pedersen CSP mixture viscosity in Pa s."""
    if composition.ndim != 1:
        raise ValueError("Pedersen viscosity currently accepts one composition vector")
    if composition.numel() != components.ncomponents:
        raise ValueError("composition and component set sizes must match")
    z = normalize_composition(composition)
    tc_mix, pc_mix = _mixture_pseudocritical(components, z)
    mapped_t = temperature * _METHANE_TC / tc_mix
    mapped_p = pressure * _METHANE_PC / pc_mix
    reference_density = methane_bwr_density(mapped_t, mapped_p, phase=phase)
    reference_mass_density = reference_density * _METHANE_MOLAR_MASS_G / 1000.0
    reduced_density = reference_mass_density / _METHANE_CRITICAL_MASS_DENSITY
    mixture_molar_mass = _pedersen_molecular_weight(components.molar_mass, z)
    alpha_mix = 1.0 + 7.378e-3 * reduced_density.pow(1.847) * mixture_molar_mass.pow(0.5173)
    alpha_reference = 1.0 + 7.378e-3 * reduced_density.pow(1.847) * temperature.new_tensor(
        _METHANE_MOLAR_MASS_G
    ).pow(0.5173)
    reference_pressure = pressure * _METHANE_PC * alpha_reference / (pc_mix * alpha_mix)
    reference_temperature = temperature * _METHANE_TC * alpha_reference / (tc_mix * alpha_mix)
    density = methane_bwr_density(reference_temperature, reference_pressure, phase=phase)
    reference_viscosity = methane_viscosity(reference_temperature, density)
    return (
        (tc_mix / _METHANE_TC).pow(-1.0 / 6.0)
        * (pc_mix / _METHANE_PC).pow(2.0 / 3.0)
        * (mixture_molar_mass / _METHANE_MOLAR_MASS_G).sqrt()
        * (alpha_mix / alpha_reference)
        * reference_viscosity
    )

torch_flash.config

Process-wide runtime configuration for native torch-flash models.

The configuration is intentionally read when model or data tensors are constructed, not inside thermodynamic kernels. Existing model instances are therefore unaffected by later configuration changes and hot-path calls do not pay for repeated policy lookups.

RuntimeConfig dataclass

Immutable snapshot of torch-flash's tensor and execution policy.

Parameters:

Name Type Description Default
device device

Device used by named model, component, standard-state, and characterization factories when no explicit device is supplied.

required
dtype dtype

Floating-point dtype used by those factories. Float64 is the scientific default; float32 is available for explicit lower-precision studies.

required
num_threads int

Current PyTorch CPU intra-operation thread count.

required
num_interop_threads int

Current PyTorch CPU inter-operation thread count.

required
deterministic bool

Whether PyTorch deterministic algorithms are enabled.

required
deterministic_warn_only bool

Whether unavailable deterministic algorithms warn instead of raising.

required
Source code in src/torch_flash/config.py
@dataclass(frozen=True, slots=True)
class RuntimeConfig:
    """Immutable snapshot of torch-flash's tensor and execution policy.

    Parameters
    ----------
    device:
        Device used by named model, component, standard-state, and
        characterization factories when no explicit device is supplied.
    dtype:
        Floating-point dtype used by those factories. Float64 is the
        scientific default; float32 is available for explicit lower-precision
        studies.
    num_threads:
        Current PyTorch CPU intra-operation thread count.
    num_interop_threads:
        Current PyTorch CPU inter-operation thread count.
    deterministic:
        Whether PyTorch deterministic algorithms are enabled.
    deterministic_warn_only:
        Whether unavailable deterministic algorithms warn instead of raising.
    """

    device: torch.device
    dtype: torch.dtype
    num_threads: int
    num_interop_threads: int
    deterministic: bool
    deterministic_warn_only: bool

    @property
    def accelerated(self) -> bool:
        """Whether tensors are configured for a non-CPU accelerator."""
        return bool(self.device.type != "cpu")

    @property
    def tensor_options(self) -> dict[str, torch.dtype | torch.device]:
        """Return keyword arguments for PyTorch tensor factory functions."""
        return {"dtype": self.dtype, "device": self.device}

    def tensor(
        self,
        data: Any,
        *,
        dtype: torch.dtype | None = None,
        device: torch.device | str | None = None,
        requires_grad: bool = False,
    ) -> Tensor:
        """Construct a copied tensor using this runtime policy by default."""
        return torch.tensor(
            data,
            dtype=self.dtype if dtype is None else dtype,
            device=self.device if device is None else device,
            requires_grad=requires_grad,
        )

    def as_tensor(
        self,
        data: Any,
        *,
        dtype: torch.dtype | None = None,
        device: torch.device | str | None = None,
    ) -> Tensor:
        """Convert data using this runtime policy without unnecessary copies."""
        return torch.as_tensor(
            data,
            dtype=self.dtype if dtype is None else dtype,
            device=self.device if device is None else device,
        )

accelerated property

accelerated

Whether tensors are configured for a non-CPU accelerator.

tensor_options property

tensor_options

Return keyword arguments for PyTorch tensor factory functions.

tensor

tensor(data, *, dtype=None, device=None, requires_grad=False)

Construct a copied tensor using this runtime policy by default.

Source code in src/torch_flash/config.py
def tensor(
    self,
    data: Any,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    requires_grad: bool = False,
) -> Tensor:
    """Construct a copied tensor using this runtime policy by default."""
    return torch.tensor(
        data,
        dtype=self.dtype if dtype is None else dtype,
        device=self.device if device is None else device,
        requires_grad=requires_grad,
    )

as_tensor

as_tensor(data, *, dtype=None, device=None)

Convert data using this runtime policy without unnecessary copies.

Source code in src/torch_flash/config.py
def as_tensor(
    self,
    data: Any,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> Tensor:
    """Convert data using this runtime policy without unnecessary copies."""
    return torch.as_tensor(
        data,
        dtype=self.dtype if dtype is None else dtype,
        device=self.device if device is None else device,
    )

get_config

get_config()

Return the current immutable torch-flash runtime configuration.

Source code in src/torch_flash/config.py
def get_config() -> RuntimeConfig:
    """Return the current immutable torch-flash runtime configuration."""
    with _CONFIG_LOCK:
        return replace(
            _CONFIG,
            num_threads=torch.get_num_threads(),
            num_interop_threads=torch.get_num_interop_threads(),
            deterministic=torch.are_deterministic_algorithms_enabled(),
            deterministic_warn_only=torch.is_deterministic_algorithms_warn_only_enabled(),
        )

configure

configure(*, device=None, dtype=None, num_threads=None, num_interop_threads=None, deterministic=None, deterministic_warn_only=None)

Set the process-wide torch-flash runtime policy before model creation.

device="auto" selects the first available CUDA, XPU, or MPS device that supports the requested dtype, falling back to CPU. device="gpu" performs the same search but raises if no accelerator supports the dtype.

The dtype becomes PyTorch's default floating dtype. The selected device is applied only by torch-flash factories and :class:RuntimeConfig tensor helpers; this function deliberately does not call :func:torch.set_default_device, which adds overhead to every Python-level PyTorch API call.

PyTorch thread counts and deterministic-algorithm flags are inherently process-wide. In particular, num_interop_threads can be changed only once and before inter-operation parallel work starts; PyTorch's failure is reported with an actionable error.

Source code in src/torch_flash/config.py
def configure(
    *,
    device: torch.device | str | None = None,
    dtype: torch.dtype | None = None,
    num_threads: int | None = None,
    num_interop_threads: int | None = None,
    deterministic: bool | None = None,
    deterministic_warn_only: bool | None = None,
) -> RuntimeConfig:
    """Set the process-wide torch-flash runtime policy before model creation.

    ``device="auto"`` selects the first available CUDA, XPU, or MPS device
    that supports the requested dtype, falling back to CPU. ``device="gpu"``
    performs the same search but raises if no accelerator supports the dtype.

    The dtype becomes PyTorch's default floating dtype. The selected device is
    applied only by torch-flash factories and :class:`RuntimeConfig` tensor
    helpers; this function deliberately does not call
    :func:`torch.set_default_device`, which adds overhead to every Python-level
    PyTorch API call.

    PyTorch thread counts and deterministic-algorithm flags are inherently
    process-wide. In particular, ``num_interop_threads`` can be changed only
    once and before inter-operation parallel work starts; PyTorch's failure is
    reported with an actionable error.
    """
    global _CONFIG

    with _CONFIG_LOCK:
        current = get_config()
        selected_dtype = current.dtype if dtype is None else dtype
        if selected_dtype not in _SUPPORTED_DTYPES:
            raise ValueError("torch-flash runtime dtype must be torch.float32 or torch.float64")
        selected_device = _resolve_device(device, selected_dtype, current.device)
        selected_threads = _positive_thread_count(
            num_threads,
            "num_threads",
            current.num_threads,
        )
        selected_interop_threads = _positive_thread_count(
            num_interop_threads,
            "num_interop_threads",
            current.num_interop_threads,
        )
        selected_deterministic = current.deterministic if deterministic is None else deterministic
        if not isinstance(selected_deterministic, bool):
            raise ValueError("deterministic must be a boolean")
        if deterministic is False and deterministic_warn_only is None:
            selected_warn_only = False
        else:
            selected_warn_only = (
                current.deterministic_warn_only
                if deterministic_warn_only is None
                else deterministic_warn_only
            )
        if not isinstance(selected_warn_only, bool):
            raise ValueError("deterministic_warn_only must be a boolean")
        if selected_warn_only and not selected_deterministic:
            raise ValueError("deterministic_warn_only requires deterministic=True")

        if selected_interop_threads != current.num_interop_threads:
            try:
                torch.set_num_interop_threads(selected_interop_threads)
            except RuntimeError as exc:
                raise RuntimeError(
                    "PyTorch inter-operation threads must be configured once, before "
                    "inter-operation parallel work starts"
                ) from exc
        if selected_threads != current.num_threads:
            torch.set_num_threads(selected_threads)
        if (
            selected_deterministic != current.deterministic
            or selected_warn_only != current.deterministic_warn_only
        ):
            torch.use_deterministic_algorithms(
                selected_deterministic,
                warn_only=selected_warn_only,
            )
        if selected_dtype != torch.get_default_dtype():
            torch.set_default_dtype(selected_dtype)

        _CONFIG = RuntimeConfig(
            selected_device,
            selected_dtype,
            torch.get_num_threads(),
            torch.get_num_interop_threads(),
            torch.are_deterministic_algorithms_enabled(),
            torch.is_deterministic_algorithms_warn_only_enabled(),
        )
        return _CONFIG

resolve_tensor_options

resolve_tensor_options(dtype, device)

Resolve optional factory arguments against the current runtime policy.

Source code in src/torch_flash/config.py
def resolve_tensor_options(
    dtype: torch.dtype | None,
    device: torch.device | str | None,
) -> tuple[torch.dtype, torch.device | str]:
    """Resolve optional factory arguments against the current runtime policy."""
    current = get_config()
    return (
        current.dtype if dtype is None else dtype,
        current.device if device is None else device,
    )

torch_flash.eos

Equation-of-state implementations.

CPAEOS

Bases: Module

SRK cubic-plus-association mixture model.

Source code in src/torch_flash/eos/cpa.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
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
class CPAEOS(nn.Module):
    """SRK cubic-plus-association mixture model."""

    critical_temperature: Tensor
    critical_pressure: Tensor
    acentric_factor: Tensor
    molar_mass: Tensor
    a0: Tensor
    b: Tensor
    c1: Tensor
    association_energy: Tensor
    association_volume: Tensor
    site_types: Tensor
    cross_association_mask: Tensor
    cross_association_energy: Tensor
    cross_association_volume: Tensor
    mixing: QuadraticMixing | TemperatureDependentQuadraticMixing

    def __init__(
        self,
        parameters: tuple[CPAComponent, ...],
        *,
        kij: Tensor | None = None,
        kij_a: Tensor | None = None,
        kij_b: Tensor | None = None,
        lij: Tensor | None = None,
        cross_association_energy: Tensor | None = None,
        cross_association_volume: Tensor | None = None,
        combining_rule: CombiningRule = "ECR",
        trainable: bool = False,
        trainable_lij: bool = False,
        association_iterations: int = 10,
        association_newton_iterations: int = 8,
        dtype: torch.dtype | None = None,
        device: torch.device | str | None = None,
    ) -> None:
        super().__init__()
        if not parameters:
            raise ValueError("CPA requires at least one component")
        if combining_rule not in ("CR1", "ECR"):
            raise ValueError(f"unknown CPA combining rule {combining_rule!r}")
        if association_iterations < 0 or association_newton_iterations < 0:
            raise ValueError("CPA association iteration counts must be nonnegative")
        if kij is not None and (kij_a is not None or kij_b is not None):
            raise ValueError("pass either fixed kij or temperature-dependent kij A/B, not both")
        if (kij_a is None) != (kij_b is None):
            raise ValueError("temperature-dependent CPA interactions require both kij_a and kij_b")
        if (cross_association_energy is None) != (cross_association_volume is None):
            raise ValueError("cross-association overrides require both energy and volume matrices")
        self.names = tuple(item.name for item in parameters)
        self.combining_rule = combining_rule
        self.association_iterations = association_iterations
        self.association_newton_iterations = association_newton_iterations
        supplied_tensors = tuple(
            value
            for value in (
                kij,
                kij_a,
                kij_b,
                lij,
                cross_association_energy,
                cross_association_volume,
            )
            if value is not None
        )
        runtime_dtype, runtime_device = resolve_tensor_options(dtype, device)
        dtype = supplied_tensors[0].dtype if dtype is None and supplied_tensors else runtime_dtype
        device = (
            supplied_tensors[0].device if device is None and supplied_tensors else runtime_device
        )

        def tensor(values: list[float]) -> Tensor:
            return torch.tensor(values, dtype=dtype, device=device)

        numeric_rows = (
            (
                item.critical_temperature,
                item.a0,
                item.b,
                item.c1,
                item.association_energy,
                item.association_volume,
            )
            for item in parameters
        )
        if any(
            not all(torch.isfinite(torch.tensor(row)))
            or row[0] <= 0.0
            or row[1] <= 0.0
            or row[2] <= 0.0
            or row[4] < 0.0
            or row[5] < 0.0
            for row in numeric_rows
        ):
            raise ValueError(
                "CPA temperatures, a0, and b must be positive; association parameters "
                "must be finite and nonnegative"
            )
        self.register_buffer(
            "critical_temperature",
            tensor([item.critical_temperature for item in parameters]),
        )
        fitted_parameters = {
            "a0": tensor([item.a0 for item in parameters]),
            "b": tensor([item.b for item in parameters]),
            "c1": tensor([item.c1 for item in parameters]),
            "association_energy": tensor([item.association_energy for item in parameters]),
            "association_volume": tensor([item.association_volume for item in parameters]),
        }
        for name, value in fitted_parameters.items():
            if trainable:
                setattr(self, name, nn.Parameter(value))
            else:
                self.register_buffer(name, value)
        self.register_buffer(
            "site_types",
            torch.tensor(
                [_site_types(item.scheme) for item in parameters],
                dtype=torch.int64,
                device=device,
            ),
        )

        critical_pressure: list[float] = []
        acentric_factor: list[float] = []
        molar_mass: list[float] = []
        for item in parameters:
            if (
                item.critical_pressure is None
                or item.acentric_factor is None
                or item.molar_mass is None
            ):
                try:
                    shared = component(item.name)
                except KeyError as exc:
                    raise ParameterDatabaseError(
                        f"custom CPA component {item.name!r} must provide critical_pressure, "
                        "acentric_factor, and molar_mass"
                    ) from exc
                shared_pressure = shared.critical_pressure
                shared_acentric = shared.acentric_factor
                shared_mass = shared.molar_mass
            else:
                shared_pressure = item.critical_pressure
                shared_acentric = item.acentric_factor
                shared_mass = item.molar_mass
            resolved_pressure = (
                item.critical_pressure if item.critical_pressure is not None else shared_pressure
            )
            resolved_acentric = (
                item.acentric_factor if item.acentric_factor is not None else shared_acentric
            )
            resolved_mass = item.molar_mass if item.molar_mass is not None else shared_mass
            if resolved_acentric is None:
                raise ParameterDatabaseError(
                    f"CPA component {item.name!r} requires an acentric factor"
                )
            if (
                not torch.isfinite(torch.tensor(resolved_pressure))
                or not torch.isfinite(torch.tensor(resolved_acentric))
                or not torch.isfinite(torch.tensor(resolved_mass))
                or resolved_pressure <= 0.0
                or resolved_mass <= 0.0
            ):
                raise ValueError("CPA critical pressure and molar mass must be finite and positive")
            critical_pressure.append(float(resolved_pressure))
            acentric_factor.append(float(resolved_acentric))
            molar_mass.append(float(resolved_mass))
        self.register_buffer("critical_pressure", tensor(critical_pressure))
        self.register_buffer("acentric_factor", tensor(acentric_factor))
        self.register_buffer("molar_mass", tensor(molar_mass))

        size = len(parameters)
        if kij_a is not None and kij_b is not None:
            self.mixing = TemperatureDependentQuadraticMixing(
                kij_a.to(dtype=dtype, device=device),
                kij_b.to(dtype=dtype, device=device),
                None if lij is None else lij.to(dtype=dtype, device=device),
                trainable=trainable,
                trainable_lij=trainable_lij,
            )
        else:
            fixed_kij = (
                torch.zeros((size, size), dtype=dtype, device=device)
                if kij is None
                else kij.to(dtype=dtype, device=device)
            )
            self.mixing = QuadraticMixing(
                fixed_kij,
                None if lij is None else lij.to(dtype=dtype, device=device),
                trainable=trainable,
                trainable_lij=trainable_lij,
            )

        if cross_association_energy is None or cross_association_volume is None:
            cross_mask = torch.zeros((size, size), dtype=torch.bool, device=device)
            cross_energy = torch.zeros((size, size), dtype=dtype, device=device)
            cross_volume = torch.zeros((size, size), dtype=dtype, device=device)
        else:
            supplied_energy = cross_association_energy.to(dtype=dtype, device=device)
            supplied_volume = cross_association_volume.to(dtype=dtype, device=device)
            if supplied_energy.shape != (size, size) or supplied_volume.shape != (size, size):
                raise ValueError("cross-association matrices must match the CPA component count")
            energy_mask = torch.isfinite(supplied_energy)
            volume_mask = torch.isfinite(supplied_volume)
            if not torch.equal(energy_mask, volume_mask):
                raise ValueError("cross-association energy and volume masks must match")
            if not torch.equal(energy_mask, energy_mask.mT):
                raise ValueError("cross-association overrides must be symmetric")
            if bool(torch.diagonal(energy_mask).any()):
                raise ValueError("cross-association overrides cannot replace pure-component pairs")
            cross_mask = energy_mask
            cross_energy = torch.where(
                energy_mask, supplied_energy, torch.zeros_like(supplied_energy)
            )
            cross_volume = torch.where(
                volume_mask, supplied_volume, torch.zeros_like(supplied_volume)
            )
            if (
                not torch.allclose(cross_energy, cross_energy.mT)
                or not torch.allclose(cross_volume, cross_volume.mT)
                or bool((cross_energy[cross_mask] <= 0.0).any())
                or bool((cross_volume[cross_mask] <= 0.0).any())
            ):
                raise ValueError(
                    "cross-association energies and volumes must be symmetric and positive"
                )
        self.register_buffer("cross_association_mask", cross_mask)
        if trainable:
            self.cross_association_energy = nn.Parameter(cross_energy)
            self.cross_association_volume = nn.Parameter(cross_volume)
        else:
            self.register_buffer("cross_association_energy", cross_energy)
            self.register_buffer("cross_association_volume", cross_volume)

    @property
    def ncomponents(self) -> int:
        """Number of mixture components."""
        return len(self.names)

    def pure_parameters(self, temperature: Tensor) -> tuple[Tensor, Tensor]:
        """Return CPA's fitted SRK energy and covolume parameters."""
        reduced = temperature[..., None] / self.critical_temperature
        a = self.a0 * (1.0 + self.c1 * (1.0 - torch.sqrt(reduced))).square()
        return a, self.b

    def mixture_parameters(self, temperature: Tensor, composition: Tensor) -> tuple[Tensor, Tensor]:
        """Return conventionally mixed physical-term parameters."""
        pure_a, pure_b = self.pure_parameters(temperature)
        result: tuple[Tensor, Tensor] = self.mixing(temperature, composition, pure_a, pure_b)
        return result

    def binary_interaction(self, temperature: Tensor) -> Tensor:
        """Return the symmetric physical-term ``kij`` matrix at ``temperature``."""
        if isinstance(self.mixing, TemperatureDependentQuadraticMixing):
            return self.mixing.kij(temperature)
        return self.mixing.kij

    def association_strength(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return ``Delta[i, A, j, B]`` association strengths in m3/mol."""
        x = normalize_composition(composition)
        _, bm = self.mixture_parameters(temperature, x)
        packing_fraction = 0.25 * bm * molar_density
        radial_distribution = 1.0 / (1.0 - 1.9 * packing_fraction)
        bij = 0.5 * (self.b[:, None] + self.b[None, :])
        temperature_pair = temperature[..., None, None]
        if self.combining_rule == "CR1":
            epsilon = 0.5 * (self.association_energy[:, None] + self.association_energy[None, :])
            beta = torch.sqrt(self.association_volume[:, None] * self.association_volume[None, :])
            pair_strength = (
                radial_distribution[..., None, None]
                * torch.expm1(epsilon / (R * temperature_pair))
                * bij
                * beta
            )
        elif self.combining_rule == "ECR":
            pure_factor = (
                torch.expm1(self.association_energy / (R * temperature[..., None]))
                * self.b
                * self.association_volume
            )
            pair_strength = radial_distribution[..., None, None] * torch.sqrt(
                pure_factor[..., :, None] * pure_factor[..., None, :]
            )
        else:  # pragma: no cover - constructor validation protects this branch
            raise ValueError(f"unknown CPA combining rule {self.combining_rule!r}")

        # Modified CR-1 solvation overrides use the published pair energy and
        # volume directly in Delta = g [exp(epsilon/RT)-1] bij beta.
        override_strength = (
            radial_distribution[..., None, None]
            * torch.expm1(self.cross_association_energy / (R * temperature_pair))
            * bij
            * self.cross_association_volume
        )
        pair_strength = torch.where(
            self.cross_association_mask,
            override_strength,
            pair_strength,
        )
        types_i = self.site_types[:, :, None, None]
        types_j = self.site_types[None, None, :, :]
        compatible = (types_i >= 0) & (types_j >= 0) & (types_i != types_j)
        return pair_strength[..., :, None, :, None] * compatible

    def site_fractions(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Solve the CPA mass-action equations for unbonded site fractions.

        Leading dimensions are broadcast as independent homogeneous states.
        The component axis is the final composition dimension.
        """
        x = normalize_composition(composition)
        if x.shape[-1] != self.ncomponents:
            raise ValueError("CPA composition has the wrong number of components")
        batch_shape = torch.broadcast_shapes(
            temperature.shape,
            molar_density.shape,
            x.shape[:-1],
        )
        temperature = torch.broadcast_to(temperature, batch_shape)
        molar_density = torch.broadcast_to(molar_density, batch_shape)
        x = torch.broadcast_to(x, (*batch_shape, self.ncomponents))
        strength = self.association_strength(temperature, molar_density, x)
        active = self.site_types >= 0
        site_fraction = torch.ones(
            (*batch_shape, self.ncomponents, self.site_types.shape[-1]),
            dtype=x.dtype,
            device=x.device,
        )
        for _ in range(self.association_iterations):
            bonded_sum = torch.einsum(
                "...j,...jb,...iajb->...ia",
                x,
                site_fraction,
                strength,
            )
            update = 1.0 / (1.0 + molar_density[..., None, None] * bonded_sum)
            update = torch.where(active, update, torch.ones_like(update))
            site_fraction = 0.5 * site_fraction + 0.5 * update

        # Newton refinement uses the analytic mass-action Jacobian. A
        # positivity-limited step replaces dozens of slowly convergent Picard
        # iterations in strongly associating liquids while retaining a fixed,
        # differentiable workload suitable for torch.compile and accelerators.
        nsites = self.site_types.shape[-1]
        system_size = self.ncomponents * nsites
        flattened_strength = strength.reshape(*batch_shape, system_size, system_size)
        site_weights = (
            x[..., :, None]
            .expand(*batch_shape, self.ncomponents, nsites)
            .reshape(*batch_shape, system_size)
        )
        flattened_active = active.reshape(system_size)
        identity = torch.eye(system_size, dtype=x.dtype, device=x.device)
        for _ in range(self.association_newton_iterations):
            flattened_sites = site_fraction.reshape(*batch_shape, system_size)
            bonded_sum = torch.einsum(
                "...j,...jb,...iajb->...ia",
                x,
                site_fraction,
                strength,
            ).reshape(*batch_shape, system_size)
            residual = flattened_sites.reciprocal() - 1.0 - molar_density[..., None] * bonded_sum
            jacobian = -torch.diag_embed(flattened_sites.reciprocal().square())
            jacobian = jacobian - (
                molar_density[..., None, None] * flattened_strength * site_weights[..., None, :]
            )
            residual = torch.where(flattened_active, residual, flattened_sites - 1.0)
            jacobian = torch.where(flattened_active[:, None], jacobian, identity)
            step = torch.linalg.solve(jacobian, -residual)
            decreases_site_fraction = (step < 0.0) & flattened_active
            safe_step = torch.where(
                decreases_site_fraction,
                step,
                -torch.ones_like(step),
            )
            positivity_limits = torch.where(
                decreases_site_fraction,
                -0.9 * flattened_sites / safe_step,
                torch.full_like(step, torch.inf),
            )
            step_scale = torch.minimum(
                torch.ones_like(positivity_limits[..., 0]),
                positivity_limits.amin(dim=-1),
            )
            site_fraction = (flattened_sites + step_scale[..., None] * step).reshape(
                *batch_shape, self.ncomponents, nsites
            )
            site_fraction = torch.where(active, site_fraction, torch.ones_like(site_fraction))
        return site_fraction

    def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
        """Return extensive ``Ares/(RT)`` for the combined CPA model."""
        if moles.ndim != 1 or volume.ndim != 0:
            raise ValueError("CPA Helmholtz kernel currently accepts one homogeneous state")
        total = moles.sum()
        x = moles / total
        molar_volume = volume / total
        am, bm = self.mixture_parameters(temperature, x)
        if bool(molar_volume <= bm):
            raise InvalidStateError("CPA molar volume must exceed mixture covolume")
        physical = -total * torch.log1p(-bm / molar_volume)
        physical = physical - total * am / (R * temperature * bm) * torch.log(
            (molar_volume + bm) / molar_volume
        )
        site_fraction = self.site_fractions(temperature, molar_volume.reciprocal(), x)
        active = self.site_types >= 0
        association_terms = torch.where(
            active,
            torch.log(site_fraction) - 0.5 * site_fraction + 0.5,
            torch.zeros_like(site_fraction),
        )
        association = torch.sum(moles[:, None] * association_terms)
        return physical + association

    def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
        """Evaluate the published CPA pressure equation."""
        x = normalize_composition(composition)
        am, bm = self.mixture_parameters(temperature, x)
        density = molar_volume.reciprocal()
        sites = self.site_fractions(temperature, density, x)
        active = self.site_types >= 0
        unbonded = torch.where(active, 1.0 - sites, torch.zeros_like(sites))
        association_sum = torch.sum(x[..., :, None] * unbonded, dim=(-2, -1))
        packing_fraction = 0.25 * bm * density
        density_dlogg = 1.9 * packing_fraction / (1.0 - 1.9 * packing_fraction)
        physical = R * temperature / (molar_volume - bm) - am / (molar_volume * (molar_volume + bm))
        association = (
            -0.5 * R * temperature / molar_volume * (1.0 + density_dlogg) * association_sum
        )
        return physical + association

    def _volume_roots(
        self, temperature: Tensor, pressure: Tensor, composition: Tensor
    ) -> tuple[Tensor, ...]:
        """Locate and polish all positive CPA pressure roots."""
        x = normalize_composition(composition)
        _, bm = self.mixture_parameters(temperature, x)
        minimum = bm * (1.0 + 1.0e-9)
        maximum = torch.maximum(100.0 * R * temperature / pressure, minimum * 1.0e6)
        grid = torch.logspace(
            float(torch.log10(minimum.detach())),
            float(torch.log10(maximum.detach())),
            96,
            dtype=temperature.dtype,
            device=temperature.device,
        )

        def residual(volume: Tensor) -> Tensor:
            return self.pressure(temperature, volume, x) - pressure

        # The scan is one batched pressure call. Association-site iterations
        # therefore operate on the entire grid instead of repeating Python and
        # dispatcher overhead for every candidate volume.
        values = residual(grid)
        finite = torch.isfinite(values[:-1]) & torch.isfinite(values[1:])
        changes_sign = torch.signbit(values[:-1]) != torch.signbit(values[1:])
        exact = (values[:-1] == 0.0) | (values[1:] == 0.0)
        bracket_indices = torch.nonzero(finite & (changes_sign | exact)).flatten().tolist()
        brackets = [(grid[index], grid[index + 1]) for index in bracket_indices]
        if not brackets:
            raise ConvergenceError("CPA volume scan found no pressure root")

        roots: list[Tensor] = []
        for left, right in brackets:
            left_value = residual(left)
            for _ in range(80):
                midpoint = 0.5 * (left + right)
                midpoint_value = residual(midpoint)
                if float((midpoint_value.abs() / pressure).detach()) <= 1.0e-12:
                    break
                if bool(torch.signbit(left_value) != torch.signbit(midpoint_value)):
                    right = midpoint
                else:
                    left = midpoint
                    left_value = midpoint_value

            # Bisection is used only for robust root selection. Newton polishing
            # restores the implicit parameter derivatives of p(T, v, x)=P.
            volume = midpoint
            for _ in range(4):
                value = residual(volume)
                slope = torch.func.grad(residual)(volume)
                volume = volume - value / slope
            roots.append(volume)
        return tuple(roots)

    def _phase_volume_newton(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: Literal["liquid", "vapor"],
    ) -> Tensor | None:
        """Try the fast differentiable phase-specific volume solve."""
        _, bm = self.mixture_parameters(temperature, composition)
        minimum = bm * (1.0 + 1.0e-9)
        ideal = R * temperature / pressure
        initial = minimum * 1.2 if phase == "liquid" else torch.maximum(ideal, minimum * 2.0)
        log_minimum = torch.log(minimum)
        log_volume = torch.log(initial)

        def residual(current: Tensor) -> Tensor:
            volume = torch.exp(current)
            return (self.pressure(temperature, volume, composition) - pressure) / pressure

        for _ in range(40):
            value = residual(log_volume)
            if float(value.detach().abs()) <= 1.0e-10:
                return torch.exp(log_volume)
            slope: Tensor = torch.func.grad(residual)(log_volume)
            step = torch.clamp(-value / slope, -0.5, 0.5)
            if not bool(torch.isfinite(step)):
                return None
            log_volume = torch.maximum(log_volume + step, log_minimum)
        return None

    def molar_volume(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Solve the CPA volume root for a specified phase."""
        if temperature.ndim != 0 or pressure.ndim != 0 or composition.ndim != 1:
            raise ValueError("CPA volume solver currently accepts one scalar T-P state")
        x = normalize_composition(composition)
        if phase in ("liquid", "vapor"):
            fast_volume = self._phase_volume_newton(
                temperature,
                pressure,
                x,
                phase,
            )
            if fast_volume is not None:
                return fast_volume
        roots = self._volume_roots(temperature, pressure, x)
        if phase == "liquid":
            return roots[0]
        if phase == "vapor":
            return roots[-1]
        if phase != "stable":
            raise ValueError(f"unknown phase root {phase!r}")

        gibbs: list[Tensor] = []
        for volume in roots:
            z = pressure * volume / (R * temperature)
            residual_helmholtz = self.residual_helmholtz_rt(temperature, volume, x)
            gibbs.append(residual_helmholtz + z - 1.0 - torch.log(z))
        index = int(torch.argmin(torch.stack(gibbs)).detach())
        return roots[index]

    def select_z(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return the CPA compressibility factor."""
        volume = self.molar_volume(temperature, pressure, composition, phase)
        return pressure * volume / (R * temperature)

    def log_fugacity_coefficients(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return CPA log fugacity coefficients from Helmholtz derivatives."""
        x = normalize_composition(composition)
        volume = self.molar_volume(temperature, pressure, x, phase)

        def at_fixed_volume(moles: Tensor) -> Tensor:
            return self.residual_helmholtz_rt(temperature, volume, moles)

        residual_mu_rt: Tensor = torch.func.grad(at_fixed_volume)(x)
        z = pressure * volume / (R * temperature)
        return residual_mu_rt - torch.log(z)

ncomponents property

ncomponents

Number of mixture components.

pure_parameters

pure_parameters(temperature)

Return CPA's fitted SRK energy and covolume parameters.

Source code in src/torch_flash/eos/cpa.py
def pure_parameters(self, temperature: Tensor) -> tuple[Tensor, Tensor]:
    """Return CPA's fitted SRK energy and covolume parameters."""
    reduced = temperature[..., None] / self.critical_temperature
    a = self.a0 * (1.0 + self.c1 * (1.0 - torch.sqrt(reduced))).square()
    return a, self.b

mixture_parameters

mixture_parameters(temperature, composition)

Return conventionally mixed physical-term parameters.

Source code in src/torch_flash/eos/cpa.py
def mixture_parameters(self, temperature: Tensor, composition: Tensor) -> tuple[Tensor, Tensor]:
    """Return conventionally mixed physical-term parameters."""
    pure_a, pure_b = self.pure_parameters(temperature)
    result: tuple[Tensor, Tensor] = self.mixing(temperature, composition, pure_a, pure_b)
    return result

binary_interaction

binary_interaction(temperature)

Return the symmetric physical-term kij matrix at temperature.

Source code in src/torch_flash/eos/cpa.py
def binary_interaction(self, temperature: Tensor) -> Tensor:
    """Return the symmetric physical-term ``kij`` matrix at ``temperature``."""
    if isinstance(self.mixing, TemperatureDependentQuadraticMixing):
        return self.mixing.kij(temperature)
    return self.mixing.kij

association_strength

association_strength(temperature, molar_density, composition)

Return Delta[i, A, j, B] association strengths in m3/mol.

Source code in src/torch_flash/eos/cpa.py
def association_strength(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return ``Delta[i, A, j, B]`` association strengths in m3/mol."""
    x = normalize_composition(composition)
    _, bm = self.mixture_parameters(temperature, x)
    packing_fraction = 0.25 * bm * molar_density
    radial_distribution = 1.0 / (1.0 - 1.9 * packing_fraction)
    bij = 0.5 * (self.b[:, None] + self.b[None, :])
    temperature_pair = temperature[..., None, None]
    if self.combining_rule == "CR1":
        epsilon = 0.5 * (self.association_energy[:, None] + self.association_energy[None, :])
        beta = torch.sqrt(self.association_volume[:, None] * self.association_volume[None, :])
        pair_strength = (
            radial_distribution[..., None, None]
            * torch.expm1(epsilon / (R * temperature_pair))
            * bij
            * beta
        )
    elif self.combining_rule == "ECR":
        pure_factor = (
            torch.expm1(self.association_energy / (R * temperature[..., None]))
            * self.b
            * self.association_volume
        )
        pair_strength = radial_distribution[..., None, None] * torch.sqrt(
            pure_factor[..., :, None] * pure_factor[..., None, :]
        )
    else:  # pragma: no cover - constructor validation protects this branch
        raise ValueError(f"unknown CPA combining rule {self.combining_rule!r}")

    # Modified CR-1 solvation overrides use the published pair energy and
    # volume directly in Delta = g [exp(epsilon/RT)-1] bij beta.
    override_strength = (
        radial_distribution[..., None, None]
        * torch.expm1(self.cross_association_energy / (R * temperature_pair))
        * bij
        * self.cross_association_volume
    )
    pair_strength = torch.where(
        self.cross_association_mask,
        override_strength,
        pair_strength,
    )
    types_i = self.site_types[:, :, None, None]
    types_j = self.site_types[None, None, :, :]
    compatible = (types_i >= 0) & (types_j >= 0) & (types_i != types_j)
    return pair_strength[..., :, None, :, None] * compatible

site_fractions

site_fractions(temperature, molar_density, composition)

Solve the CPA mass-action equations for unbonded site fractions.

Leading dimensions are broadcast as independent homogeneous states. The component axis is the final composition dimension.

Source code in src/torch_flash/eos/cpa.py
def site_fractions(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Solve the CPA mass-action equations for unbonded site fractions.

    Leading dimensions are broadcast as independent homogeneous states.
    The component axis is the final composition dimension.
    """
    x = normalize_composition(composition)
    if x.shape[-1] != self.ncomponents:
        raise ValueError("CPA composition has the wrong number of components")
    batch_shape = torch.broadcast_shapes(
        temperature.shape,
        molar_density.shape,
        x.shape[:-1],
    )
    temperature = torch.broadcast_to(temperature, batch_shape)
    molar_density = torch.broadcast_to(molar_density, batch_shape)
    x = torch.broadcast_to(x, (*batch_shape, self.ncomponents))
    strength = self.association_strength(temperature, molar_density, x)
    active = self.site_types >= 0
    site_fraction = torch.ones(
        (*batch_shape, self.ncomponents, self.site_types.shape[-1]),
        dtype=x.dtype,
        device=x.device,
    )
    for _ in range(self.association_iterations):
        bonded_sum = torch.einsum(
            "...j,...jb,...iajb->...ia",
            x,
            site_fraction,
            strength,
        )
        update = 1.0 / (1.0 + molar_density[..., None, None] * bonded_sum)
        update = torch.where(active, update, torch.ones_like(update))
        site_fraction = 0.5 * site_fraction + 0.5 * update

    # Newton refinement uses the analytic mass-action Jacobian. A
    # positivity-limited step replaces dozens of slowly convergent Picard
    # iterations in strongly associating liquids while retaining a fixed,
    # differentiable workload suitable for torch.compile and accelerators.
    nsites = self.site_types.shape[-1]
    system_size = self.ncomponents * nsites
    flattened_strength = strength.reshape(*batch_shape, system_size, system_size)
    site_weights = (
        x[..., :, None]
        .expand(*batch_shape, self.ncomponents, nsites)
        .reshape(*batch_shape, system_size)
    )
    flattened_active = active.reshape(system_size)
    identity = torch.eye(system_size, dtype=x.dtype, device=x.device)
    for _ in range(self.association_newton_iterations):
        flattened_sites = site_fraction.reshape(*batch_shape, system_size)
        bonded_sum = torch.einsum(
            "...j,...jb,...iajb->...ia",
            x,
            site_fraction,
            strength,
        ).reshape(*batch_shape, system_size)
        residual = flattened_sites.reciprocal() - 1.0 - molar_density[..., None] * bonded_sum
        jacobian = -torch.diag_embed(flattened_sites.reciprocal().square())
        jacobian = jacobian - (
            molar_density[..., None, None] * flattened_strength * site_weights[..., None, :]
        )
        residual = torch.where(flattened_active, residual, flattened_sites - 1.0)
        jacobian = torch.where(flattened_active[:, None], jacobian, identity)
        step = torch.linalg.solve(jacobian, -residual)
        decreases_site_fraction = (step < 0.0) & flattened_active
        safe_step = torch.where(
            decreases_site_fraction,
            step,
            -torch.ones_like(step),
        )
        positivity_limits = torch.where(
            decreases_site_fraction,
            -0.9 * flattened_sites / safe_step,
            torch.full_like(step, torch.inf),
        )
        step_scale = torch.minimum(
            torch.ones_like(positivity_limits[..., 0]),
            positivity_limits.amin(dim=-1),
        )
        site_fraction = (flattened_sites + step_scale[..., None] * step).reshape(
            *batch_shape, self.ncomponents, nsites
        )
        site_fraction = torch.where(active, site_fraction, torch.ones_like(site_fraction))
    return site_fraction

residual_helmholtz_rt

residual_helmholtz_rt(temperature, volume, moles)

Return extensive Ares/(RT) for the combined CPA model.

Source code in src/torch_flash/eos/cpa.py
def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
    """Return extensive ``Ares/(RT)`` for the combined CPA model."""
    if moles.ndim != 1 or volume.ndim != 0:
        raise ValueError("CPA Helmholtz kernel currently accepts one homogeneous state")
    total = moles.sum()
    x = moles / total
    molar_volume = volume / total
    am, bm = self.mixture_parameters(temperature, x)
    if bool(molar_volume <= bm):
        raise InvalidStateError("CPA molar volume must exceed mixture covolume")
    physical = -total * torch.log1p(-bm / molar_volume)
    physical = physical - total * am / (R * temperature * bm) * torch.log(
        (molar_volume + bm) / molar_volume
    )
    site_fraction = self.site_fractions(temperature, molar_volume.reciprocal(), x)
    active = self.site_types >= 0
    association_terms = torch.where(
        active,
        torch.log(site_fraction) - 0.5 * site_fraction + 0.5,
        torch.zeros_like(site_fraction),
    )
    association = torch.sum(moles[:, None] * association_terms)
    return physical + association

pressure

pressure(temperature, molar_volume, composition)

Evaluate the published CPA pressure equation.

Source code in src/torch_flash/eos/cpa.py
def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
    """Evaluate the published CPA pressure equation."""
    x = normalize_composition(composition)
    am, bm = self.mixture_parameters(temperature, x)
    density = molar_volume.reciprocal()
    sites = self.site_fractions(temperature, density, x)
    active = self.site_types >= 0
    unbonded = torch.where(active, 1.0 - sites, torch.zeros_like(sites))
    association_sum = torch.sum(x[..., :, None] * unbonded, dim=(-2, -1))
    packing_fraction = 0.25 * bm * density
    density_dlogg = 1.9 * packing_fraction / (1.0 - 1.9 * packing_fraction)
    physical = R * temperature / (molar_volume - bm) - am / (molar_volume * (molar_volume + bm))
    association = (
        -0.5 * R * temperature / molar_volume * (1.0 + density_dlogg) * association_sum
    )
    return physical + association

molar_volume

molar_volume(temperature, pressure, composition, phase='stable')

Solve the CPA volume root for a specified phase.

Source code in src/torch_flash/eos/cpa.py
def molar_volume(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Solve the CPA volume root for a specified phase."""
    if temperature.ndim != 0 or pressure.ndim != 0 or composition.ndim != 1:
        raise ValueError("CPA volume solver currently accepts one scalar T-P state")
    x = normalize_composition(composition)
    if phase in ("liquid", "vapor"):
        fast_volume = self._phase_volume_newton(
            temperature,
            pressure,
            x,
            phase,
        )
        if fast_volume is not None:
            return fast_volume
    roots = self._volume_roots(temperature, pressure, x)
    if phase == "liquid":
        return roots[0]
    if phase == "vapor":
        return roots[-1]
    if phase != "stable":
        raise ValueError(f"unknown phase root {phase!r}")

    gibbs: list[Tensor] = []
    for volume in roots:
        z = pressure * volume / (R * temperature)
        residual_helmholtz = self.residual_helmholtz_rt(temperature, volume, x)
        gibbs.append(residual_helmholtz + z - 1.0 - torch.log(z))
    index = int(torch.argmin(torch.stack(gibbs)).detach())
    return roots[index]

select_z

select_z(temperature, pressure, composition, phase='stable')

Return the CPA compressibility factor.

Source code in src/torch_flash/eos/cpa.py
def select_z(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return the CPA compressibility factor."""
    volume = self.molar_volume(temperature, pressure, composition, phase)
    return pressure * volume / (R * temperature)

log_fugacity_coefficients

log_fugacity_coefficients(temperature, pressure, composition, phase='stable')

Return CPA log fugacity coefficients from Helmholtz derivatives.

Source code in src/torch_flash/eos/cpa.py
def log_fugacity_coefficients(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return CPA log fugacity coefficients from Helmholtz derivatives."""
    x = normalize_composition(composition)
    volume = self.molar_volume(temperature, pressure, x, phase)

    def at_fixed_volume(moles: Tensor) -> Tensor:
        return self.residual_helmholtz_rt(temperature, volume, moles)

    residual_mu_rt: Tensor = torch.func.grad(at_fixed_volume)(x)
    z = pressure * volume / (R * temperature)
    return residual_mu_rt - torch.log(z)

CPAComponent dataclass

Pure-component CPA parameters and optional petroleum pseudo-properties.

critical_pressure, acentric_factor, and molar_mass normally come from the shared component database. Petroleum pseudo-components can instead carry those three initialization properties directly.

Source code in src/torch_flash/eos/cpa.py
@dataclass(frozen=True)
class CPAComponent:
    """Pure-component CPA parameters and optional petroleum pseudo-properties.

    ``critical_pressure``, ``acentric_factor``, and ``molar_mass`` normally
    come from the shared component database. Petroleum pseudo-components can
    instead carry those three initialization properties directly.
    """

    name: str
    critical_temperature: float
    a0: float
    b: float
    c1: float
    association_energy: float = 0.0
    association_volume: float = 0.0
    scheme: AssociationScheme = "none"
    critical_pressure: float | None = None
    acentric_factor: float | None = None
    molar_mass: float | None = None

CPACharacterizedComponents dataclass

Characterized pseudo-components and their normalized cut fractions.

Source code in src/torch_flash/eos/cpa_heavy_end.py
@dataclass(frozen=True)
class CPACharacterizedComponents:
    """Characterized pseudo-components and their normalized cut fractions."""

    components: tuple[CPAComponent, ...]
    mole_fractions: Tensor
    monomer_properties: tuple[CPAMonomerProperties, ...]

CPAHeavyEndCorrelations dataclass

Published coefficients used by the CPA C7+ monomer correlations.

Source code in src/torch_flash/eos/cpa_heavy_end.py
@dataclass(frozen=True)
class CPAHeavyEndCorrelations:
    """Published coefficients used by the CPA C7+ monomer correlations."""

    normal_boiling_pressure: float
    srk_omega_a: float
    srk_omega_b: float
    srk_m: tuple[float, float, float]
    normal_alkane_temperature: tuple[float, float, float]
    log_normal_alkane_pressure_bar: tuple[float, float, float, float, float]
    normal_alkane_acentric: tuple[float, float, float]
    normal_alkane_specific_gravity: tuple[float, float, float, float, float]
    critical_temperature_numerator: tuple[float, float, float]
    critical_temperature_denominator: tuple[float, float, float]
    log_critical_pressure_ratio: tuple[float, float, float, float, float]

CPAMonomerProperties dataclass

Intermediate and final CPA monomer characterization results.

Source code in src/torch_flash/eos/cpa_heavy_end.py
@dataclass(frozen=True)
class CPAMonomerProperties:
    """Intermediate and final CPA monomer characterization results."""

    normal_boiling_temperature: float
    specific_gravity: float
    normal_alkane_specific_gravity: float
    specific_gravity_perturbation: float
    normal_alkane_temperature: float
    normal_alkane_pressure: float
    critical_temperature: float
    critical_pressure: float
    acentric_factor: float
    used_boiling_point_match: bool
    correlations: CPAHeavyEndCorrelations

    @property
    def m(self) -> float:
        """Return the SRK ``m`` coefficient corresponding to ``omega``."""
        omega = self.acentric_factor
        first, second, third = self.correlations.srk_m
        return first + second * omega + third * omega * omega

    @property
    def a0(self) -> float:
        """Return CPA/SRK energy parameter in Pa m6 mol-2."""
        return (
            self.correlations.srk_omega_a
            * (R * self.critical_temperature) ** 2
            / self.critical_pressure
        )

    @property
    def b(self) -> float:
        """Return CPA/SRK covolume in m3 mol-1."""
        return (
            self.correlations.srk_omega_b * R * self.critical_temperature / self.critical_pressure
        )

m property

m

Return the SRK m coefficient corresponding to omega.

a0 property

a0

Return CPA/SRK energy parameter in Pa m6 mol-2.

b property

b

Return CPA/SRK covolume in m3 mol-1.

CubicConstants dataclass

Definition of a generalized cubic EoS.

Source code in src/torch_flash/eos/cubic.py
@dataclass(frozen=True)
class CubicConstants:
    """Definition of a generalized cubic EoS."""

    name: str
    omega_a: float
    omega_b: float
    delta1: float
    delta2: float
    alpha_kind: AlphaKind
    alpha_low: tuple[float, float, float]
    alpha_high: tuple[float, float, float, float] | None = None
    alpha_switch: float | None = None
    parameter_set: str | None = None

CubicEOS

Bases: Module

Generalized cubic equation of state with differentiable parameters.

Source code in src/torch_flash/eos/cubic.py
class CubicEOS(nn.Module):
    """Generalized cubic equation of state with differentiable parameters."""

    critical_temperature: Tensor
    critical_pressure: Tensor
    acentric_factor: Tensor
    molar_mass: Tensor
    volume_translation: Tensor
    volume_translation_slope: Tensor
    volume_translation_reference_temperature: Tensor
    mixing: CubicMixing

    def __init__(
        self,
        components: ComponentSet,
        constants: CubicConstants,
        *,
        mixing: CubicMixing | None = None,
        volume_translation: Tensor | VolumeTranslation | None = None,
    ) -> None:
        super().__init__()
        self.names = components.names
        self.constants = constants
        self.register_buffer("critical_temperature", components.critical_temperature.clone())
        self.register_buffer("critical_pressure", components.critical_pressure.clone())
        self.register_buffer("acentric_factor", components.acentric_factor.clone())
        self.register_buffer("molar_mass", components.molar_mass.clone())
        if mixing is None:
            zeros = torch.zeros(
                (components.ncomponents, components.ncomponents),
                dtype=components.critical_temperature.dtype,
                device=components.critical_temperature.device,
            )
            mixing = QuadraticMixing(zeros)
        self.mixing = mixing
        if isinstance(volume_translation, VolumeTranslation):
            translation = volume_translation.reference_shift.to(
                dtype=components.critical_temperature.dtype,
                device=components.critical_temperature.device,
            )
            translation_slope = volume_translation.temperature_slope.to(
                dtype=components.critical_temperature.dtype,
                device=components.critical_temperature.device,
            )
            reference_temperature = volume_translation.reference_temperature
        else:
            translation = (
                torch.zeros_like(components.critical_temperature)
                if volume_translation is None
                else volume_translation
            )
            translation_slope = torch.zeros_like(translation)
            reference_temperature = 288.15
        if translation.shape != components.critical_temperature.shape:
            raise ValueError("volume_translation must have one value per component")
        if translation_slope.shape != components.critical_temperature.shape:
            raise ValueError("volume_translation slope must have one value per component")
        self.register_buffer("volume_translation", translation.clone())
        self.register_buffer("volume_translation_slope", translation_slope.clone())
        self.register_buffer(
            "volume_translation_reference_temperature",
            components.critical_temperature.new_tensor(reference_temperature),
        )

    @property
    def ncomponents(self) -> int:
        """Number of modeled components."""
        return len(self.names)

    def component_volume_translation(self, temperature: Tensor) -> Tensor:
        """Return additive component volume shifts at ``temperature``."""
        return (
            self.volume_translation
            + (temperature[..., None] - self.volume_translation_reference_temperature)
            * self.volume_translation_slope
        )

    def _kappa(self) -> Tensor:
        omega = self.acentric_factor
        low_coefficients = self.constants.alpha_low
        low = (
            low_coefficients[0] + low_coefficients[1] * omega + low_coefficients[2] * omega.square()
        )
        if self.constants.alpha_kind != "pr78":
            return low
        if self.constants.alpha_high is None or self.constants.alpha_switch is None:
            raise ParameterDatabaseError("PR78 requires high-alpha coefficients and a switch")
        high_coefficients = self.constants.alpha_high
        high = (
            high_coefficients[0]
            + high_coefficients[1] * omega
            + high_coefficients[2] * omega.square()
            + high_coefficients[3] * omega.pow(3)
        )
        return torch.where(omega <= self.constants.alpha_switch, low, high)

    def pure_parameters(self, temperature: Tensor) -> tuple[Tensor, Tensor]:
        """Return temperature-dependent pure ``a_i`` and constant ``b_i``."""
        if bool((temperature <= 0.0).any()):
            raise InvalidStateError("temperature must be positive")
        reduced = temperature[..., None] / self.critical_temperature
        alpha = (1.0 + self._kappa() * (1.0 - torch.sqrt(reduced))).square()
        a = (
            self.constants.omega_a
            * R**2
            * self.critical_temperature.square()
            / self.critical_pressure
            * alpha
        )
        b = self.constants.omega_b * R * self.critical_temperature / self.critical_pressure
        return a, b

    def mixture_parameters(self, temperature: Tensor, composition: Tensor) -> tuple[Tensor, Tensor]:
        """Return mixed attraction and covolume parameters."""
        x = normalize_composition(composition)
        pure_a, pure_b = self.pure_parameters(temperature)
        result: tuple[Tensor, Tensor] = self.mixing(temperature, x, pure_a, pure_b)
        return result

    def dimensionless_parameters(
        self, temperature: Tensor, pressure: Tensor, composition: Tensor
    ) -> tuple[Tensor, Tensor]:
        """Return conventional cubic parameters ``A`` and ``B``."""
        if bool((pressure <= 0.0).any()):
            raise InvalidStateError("pressure must be positive")
        am, bm = self.mixture_parameters(temperature, composition)
        return am * pressure / (R * temperature).square(), bm * pressure / (R * temperature)

    def z_factors(self, temperature: Tensor, pressure: Tensor, composition: Tensor) -> Tensor:
        """Return sorted real compressibility-factor roots."""
        a, b = self.dimensionless_parameters(temperature, pressure, composition)
        u = self.constants.delta1 + self.constants.delta2
        w = self.constants.delta1 * self.constants.delta2
        c2 = (u - 1.0) * b - 1.0
        c1 = a - u * b + (w - u) * b.square()
        c0 = -(a * b + w * b.square() * (1.0 + b))
        return cubic_real_roots(c2, c1, c0)

    def _residual_gibbs_rt_from_z(self, z: Tensor, a: Tensor, b: Tensor) -> Tensor:
        d1 = self.constants.delta1
        d2 = self.constants.delta2
        safe_b = torch.clamp_min(b, torch.finfo(b.dtype).tiny)
        attraction = a / (safe_b * (d1 - d2))
        log_ratio = torch.log((z + d1 * b) / (z + d2 * b))
        value = z - 1.0 - torch.log(z - b) - attraction * log_ratio
        ideal_b_limit = z - 1.0 - torch.log(z) - a / z
        return torch.where(b.abs() > 10.0 * torch.finfo(b.dtype).eps, value, ideal_b_limit)

    def select_z(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Select a physical liquid, vapor, or minimum-Gibbs root."""
        roots = self.z_factors(temperature, pressure, composition)
        _, b = self.dimensionless_parameters(temperature, pressure, composition)
        valid = roots > b[..., None] * (1.0 + 32.0 * torch.finfo(roots.dtype).eps)
        if phase == "liquid":
            selected = torch.where(valid, roots, torch.inf).amin(dim=-1)
        elif phase == "vapor":
            selected = torch.where(valid, roots, -torch.inf).amax(dim=-1)
        elif phase == "stable":
            a, b = self.dimensionless_parameters(temperature, pressure, composition)
            gibbs = self._residual_gibbs_rt_from_z(roots, a[..., None], b[..., None])
            gibbs = torch.where(valid, gibbs, torch.inf)
            selected = torch.gather(roots, -1, gibbs.argmin(dim=-1, keepdim=True)).squeeze(-1)
        else:
            raise ValueError(f"unknown phase root {phase!r}")
        if not bool(torch.isfinite(selected).all()):
            raise InvalidStateError("cubic EoS produced no physical volume root")
        return selected

    def molar_volume(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return translated molar volume in m3/mol."""
        x = normalize_composition(composition)
        z = self.select_z(temperature, pressure, x, phase)
        unshifted = z * R * temperature / pressure
        translation = self.component_volume_translation(temperature)
        return unshifted + torch.sum(x * translation, dim=-1)

    def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
        """Evaluate pressure at a homogeneous ``T, v, x`` state."""
        x = normalize_composition(composition)
        shift = torch.sum(x * self.component_volume_translation(temperature), dim=-1)
        volume = molar_volume - shift
        am, bm = self.mixture_parameters(temperature, x)
        d1 = self.constants.delta1
        d2 = self.constants.delta2
        if bool((volume <= bm).any()):
            raise InvalidStateError("molar volume must exceed the mixture covolume")
        return R * temperature / (volume - bm) - am / ((volume + d1 * bm) * (volume + d2 * bm))

    def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
        """Return extensive residual Helmholtz energy divided by ``RT``."""
        total = moles.sum(dim=-1)
        x = moles / total[..., None]
        translation = self.component_volume_translation(temperature)
        shift = torch.sum(x * translation, dim=-1)
        unshifted_volume = volume - total * shift
        molar_volume = unshifted_volume / total
        am, bm = self.mixture_parameters(temperature, x)
        d1 = self.constants.delta1
        d2 = self.constants.delta2
        repulsive = -total * torch.log1p(-bm / molar_volume)
        logarithm = torch.log((molar_volume + d1 * bm) / (molar_volume + d2 * bm))
        attractive = -total * am / (R * temperature * bm * (d1 - d2)) * logarithm
        # A constant Peneloux-style translation maps the physical volume V to
        # the parent-EoS volume V0 = V - sum(n_i*c_i).  The final logarithm is
        # the ideal-gas reference-volume correction required when A^R is
        # defined relative to an ideal gas at the *physical* V.  Including it
        # makes -dA/dV, fugacity, and the TP/TV routes mutually consistent.
        reference_volume_correction = total * torch.log(volume / unshifted_volume)
        return repulsive + attractive + reference_volume_correction

    def log_fugacity_coefficients(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return component log fugacity coefficients.

        The quadratic rule uses the standard closed form. Non-quadratic mixing
        rules use an autodifferentiated residual Helmholtz energy, preserving
        the exact composition dependence of the selected rule.
        """
        x = normalize_composition(composition)
        z = self.select_z(temperature, pressure, x, phase)
        a, b = self.dimensionless_parameters(temperature, pressure, x)
        if isinstance(
            self.mixing,
            QuadraticMixing | TemperatureDependentQuadraticMixing | PPR78Mixing,
        ):
            pure_a, pure_b = self.pure_parameters(temperature)
            am, bm = self.mixture_parameters(temperature, x)
            if isinstance(self.mixing, PPR78Mixing):
                aij = self.mixing.cross_a(temperature, pure_a, pure_b)
            elif isinstance(self.mixing, TemperatureDependentQuadraticMixing):
                aij = self.mixing.cross_a(temperature, pure_a)
            else:
                aij = self.mixing.cross_a(pure_a)
            sum_aij = torch.einsum("...j,...ij->...i", x, aij)
            partial_b_over_b = self.mixing.partial_b(x, pure_b) / bm[..., None]
            composition_term = 2.0 * sum_aij / am[..., None] - partial_b_over_b
            d1 = self.constants.delta1
            d2 = self.constants.delta2
            log_ratio = torch.log((z + d1 * b) / (z + d2 * b))
            unshifted_log_phi = (
                partial_b_over_b * (z - 1.0)[..., None]
                - torch.log(z - b)[..., None]
                - (a / (b * (d1 - d2)) * log_ratio)[..., None] * composition_term
            )
            # For v = v0 + sum(x_i*c_i), thermodynamic consistency requires
            # ln(phi_i) = ln(phi_i,0) + P*c_i/(R*T).  A common sign error is
            # avoided here by defining ``volume_translation`` as the quantity
            # *added* to the parent-EoS volume in ``molar_volume``.
            return unshifted_log_phi + (pressure / (R * temperature))[
                ..., None
            ] * self.component_volume_translation(temperature)

        translation = self.component_volume_translation(temperature)
        volume = z * R * temperature / pressure + torch.sum(x * translation, dim=-1)

        def at_fixed_volume(moles: Tensor) -> Tensor:
            # Batched states are independent, so differentiating their sum
            # returns the same row-wise chemical potentials as evaluating
            # each scalar state separately. ``torch.func.grad`` requires a
            # scalar output and therefore cannot consume the unsummed batch.
            return self.residual_helmholtz_rt(temperature, volume, moles).sum()

        residual_mu_rt: Tensor = torch.func.grad(at_fixed_volume)(x)
        physical_z = pressure * volume / (R * temperature)
        return residual_mu_rt - torch.log(physical_z)[..., None]

ncomponents property

ncomponents

Number of modeled components.

component_volume_translation

component_volume_translation(temperature)

Return additive component volume shifts at temperature.

Source code in src/torch_flash/eos/cubic.py
def component_volume_translation(self, temperature: Tensor) -> Tensor:
    """Return additive component volume shifts at ``temperature``."""
    return (
        self.volume_translation
        + (temperature[..., None] - self.volume_translation_reference_temperature)
        * self.volume_translation_slope
    )

pure_parameters

pure_parameters(temperature)

Return temperature-dependent pure a_i and constant b_i.

Source code in src/torch_flash/eos/cubic.py
def pure_parameters(self, temperature: Tensor) -> tuple[Tensor, Tensor]:
    """Return temperature-dependent pure ``a_i`` and constant ``b_i``."""
    if bool((temperature <= 0.0).any()):
        raise InvalidStateError("temperature must be positive")
    reduced = temperature[..., None] / self.critical_temperature
    alpha = (1.0 + self._kappa() * (1.0 - torch.sqrt(reduced))).square()
    a = (
        self.constants.omega_a
        * R**2
        * self.critical_temperature.square()
        / self.critical_pressure
        * alpha
    )
    b = self.constants.omega_b * R * self.critical_temperature / self.critical_pressure
    return a, b

mixture_parameters

mixture_parameters(temperature, composition)

Return mixed attraction and covolume parameters.

Source code in src/torch_flash/eos/cubic.py
def mixture_parameters(self, temperature: Tensor, composition: Tensor) -> tuple[Tensor, Tensor]:
    """Return mixed attraction and covolume parameters."""
    x = normalize_composition(composition)
    pure_a, pure_b = self.pure_parameters(temperature)
    result: tuple[Tensor, Tensor] = self.mixing(temperature, x, pure_a, pure_b)
    return result

dimensionless_parameters

dimensionless_parameters(temperature, pressure, composition)

Return conventional cubic parameters A and B.

Source code in src/torch_flash/eos/cubic.py
def dimensionless_parameters(
    self, temperature: Tensor, pressure: Tensor, composition: Tensor
) -> tuple[Tensor, Tensor]:
    """Return conventional cubic parameters ``A`` and ``B``."""
    if bool((pressure <= 0.0).any()):
        raise InvalidStateError("pressure must be positive")
    am, bm = self.mixture_parameters(temperature, composition)
    return am * pressure / (R * temperature).square(), bm * pressure / (R * temperature)

z_factors

z_factors(temperature, pressure, composition)

Return sorted real compressibility-factor roots.

Source code in src/torch_flash/eos/cubic.py
def z_factors(self, temperature: Tensor, pressure: Tensor, composition: Tensor) -> Tensor:
    """Return sorted real compressibility-factor roots."""
    a, b = self.dimensionless_parameters(temperature, pressure, composition)
    u = self.constants.delta1 + self.constants.delta2
    w = self.constants.delta1 * self.constants.delta2
    c2 = (u - 1.0) * b - 1.0
    c1 = a - u * b + (w - u) * b.square()
    c0 = -(a * b + w * b.square() * (1.0 + b))
    return cubic_real_roots(c2, c1, c0)

select_z

select_z(temperature, pressure, composition, phase='stable')

Select a physical liquid, vapor, or minimum-Gibbs root.

Source code in src/torch_flash/eos/cubic.py
def select_z(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Select a physical liquid, vapor, or minimum-Gibbs root."""
    roots = self.z_factors(temperature, pressure, composition)
    _, b = self.dimensionless_parameters(temperature, pressure, composition)
    valid = roots > b[..., None] * (1.0 + 32.0 * torch.finfo(roots.dtype).eps)
    if phase == "liquid":
        selected = torch.where(valid, roots, torch.inf).amin(dim=-1)
    elif phase == "vapor":
        selected = torch.where(valid, roots, -torch.inf).amax(dim=-1)
    elif phase == "stable":
        a, b = self.dimensionless_parameters(temperature, pressure, composition)
        gibbs = self._residual_gibbs_rt_from_z(roots, a[..., None], b[..., None])
        gibbs = torch.where(valid, gibbs, torch.inf)
        selected = torch.gather(roots, -1, gibbs.argmin(dim=-1, keepdim=True)).squeeze(-1)
    else:
        raise ValueError(f"unknown phase root {phase!r}")
    if not bool(torch.isfinite(selected).all()):
        raise InvalidStateError("cubic EoS produced no physical volume root")
    return selected

molar_volume

molar_volume(temperature, pressure, composition, phase='stable')

Return translated molar volume in m3/mol.

Source code in src/torch_flash/eos/cubic.py
def molar_volume(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return translated molar volume in m3/mol."""
    x = normalize_composition(composition)
    z = self.select_z(temperature, pressure, x, phase)
    unshifted = z * R * temperature / pressure
    translation = self.component_volume_translation(temperature)
    return unshifted + torch.sum(x * translation, dim=-1)

pressure

pressure(temperature, molar_volume, composition)

Evaluate pressure at a homogeneous T, v, x state.

Source code in src/torch_flash/eos/cubic.py
def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
    """Evaluate pressure at a homogeneous ``T, v, x`` state."""
    x = normalize_composition(composition)
    shift = torch.sum(x * self.component_volume_translation(temperature), dim=-1)
    volume = molar_volume - shift
    am, bm = self.mixture_parameters(temperature, x)
    d1 = self.constants.delta1
    d2 = self.constants.delta2
    if bool((volume <= bm).any()):
        raise InvalidStateError("molar volume must exceed the mixture covolume")
    return R * temperature / (volume - bm) - am / ((volume + d1 * bm) * (volume + d2 * bm))

residual_helmholtz_rt

residual_helmholtz_rt(temperature, volume, moles)

Return extensive residual Helmholtz energy divided by RT.

Source code in src/torch_flash/eos/cubic.py
def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
    """Return extensive residual Helmholtz energy divided by ``RT``."""
    total = moles.sum(dim=-1)
    x = moles / total[..., None]
    translation = self.component_volume_translation(temperature)
    shift = torch.sum(x * translation, dim=-1)
    unshifted_volume = volume - total * shift
    molar_volume = unshifted_volume / total
    am, bm = self.mixture_parameters(temperature, x)
    d1 = self.constants.delta1
    d2 = self.constants.delta2
    repulsive = -total * torch.log1p(-bm / molar_volume)
    logarithm = torch.log((molar_volume + d1 * bm) / (molar_volume + d2 * bm))
    attractive = -total * am / (R * temperature * bm * (d1 - d2)) * logarithm
    # A constant Peneloux-style translation maps the physical volume V to
    # the parent-EoS volume V0 = V - sum(n_i*c_i).  The final logarithm is
    # the ideal-gas reference-volume correction required when A^R is
    # defined relative to an ideal gas at the *physical* V.  Including it
    # makes -dA/dV, fugacity, and the TP/TV routes mutually consistent.
    reference_volume_correction = total * torch.log(volume / unshifted_volume)
    return repulsive + attractive + reference_volume_correction

log_fugacity_coefficients

log_fugacity_coefficients(temperature, pressure, composition, phase='stable')

Return component log fugacity coefficients.

The quadratic rule uses the standard closed form. Non-quadratic mixing rules use an autodifferentiated residual Helmholtz energy, preserving the exact composition dependence of the selected rule.

Source code in src/torch_flash/eos/cubic.py
def log_fugacity_coefficients(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return component log fugacity coefficients.

    The quadratic rule uses the standard closed form. Non-quadratic mixing
    rules use an autodifferentiated residual Helmholtz energy, preserving
    the exact composition dependence of the selected rule.
    """
    x = normalize_composition(composition)
    z = self.select_z(temperature, pressure, x, phase)
    a, b = self.dimensionless_parameters(temperature, pressure, x)
    if isinstance(
        self.mixing,
        QuadraticMixing | TemperatureDependentQuadraticMixing | PPR78Mixing,
    ):
        pure_a, pure_b = self.pure_parameters(temperature)
        am, bm = self.mixture_parameters(temperature, x)
        if isinstance(self.mixing, PPR78Mixing):
            aij = self.mixing.cross_a(temperature, pure_a, pure_b)
        elif isinstance(self.mixing, TemperatureDependentQuadraticMixing):
            aij = self.mixing.cross_a(temperature, pure_a)
        else:
            aij = self.mixing.cross_a(pure_a)
        sum_aij = torch.einsum("...j,...ij->...i", x, aij)
        partial_b_over_b = self.mixing.partial_b(x, pure_b) / bm[..., None]
        composition_term = 2.0 * sum_aij / am[..., None] - partial_b_over_b
        d1 = self.constants.delta1
        d2 = self.constants.delta2
        log_ratio = torch.log((z + d1 * b) / (z + d2 * b))
        unshifted_log_phi = (
            partial_b_over_b * (z - 1.0)[..., None]
            - torch.log(z - b)[..., None]
            - (a / (b * (d1 - d2)) * log_ratio)[..., None] * composition_term
        )
        # For v = v0 + sum(x_i*c_i), thermodynamic consistency requires
        # ln(phi_i) = ln(phi_i,0) + P*c_i/(R*T).  A common sign error is
        # avoided here by defining ``volume_translation`` as the quantity
        # *added* to the parent-EoS volume in ``molar_volume``.
        return unshifted_log_phi + (pressure / (R * temperature))[
            ..., None
        ] * self.component_volume_translation(temperature)

    translation = self.component_volume_translation(temperature)
    volume = z * R * temperature / pressure + torch.sum(x * translation, dim=-1)

    def at_fixed_volume(moles: Tensor) -> Tensor:
        # Batched states are independent, so differentiating their sum
        # returns the same row-wise chemical potentials as evaluating
        # each scalar state separately. ``torch.func.grad`` requires a
        # scalar output and therefore cannot consume the unsummed batch.
        return self.residual_helmholtz_rt(temperature, volume, moles).sum()

    residual_mu_rt: Tensor = torch.func.grad(at_fixed_volume)(x)
    physical_z = pressure * volume / (R * temperature)
    return residual_mu_rt - torch.log(physical_z)[..., None]

GaoBTerms dataclass

Gao-B critical-region residual Helmholtz terms.

Source code in src/torch_flash/eos/multifluid.py
@dataclass(frozen=True)
class GaoBTerms:
    """Gao-B critical-region residual Helmholtz terms."""

    n: Tensor
    d: Tensor
    t: Tensor
    eta: Tensor
    epsilon: Tensor
    beta: Tensor
    gamma: Tensor
    b: Tensor

    def __post_init__(self) -> None:
        arrays = (self.d, self.t, self.eta, self.epsilon, self.beta, self.gamma, self.b)
        if not all(value.shape == self.n.shape for value in arrays):
            raise ValueError("all Gao-B term arrays must have equal shape")

HelmholtzTerms dataclass

Power, exponential, Gaussian, and GERG-special terms.

The full form is n*delta^d*tau^t*exp(-delta^l -eta*(delta-epsilon)^2-beta*(tau-gamma)^2 -linear_density*(delta-linear_shift)). Setting the optional arrays to zero recovers the power-exponential subset. The final linear-density factor is the binary departure form used by GERG-2008.

Source code in src/torch_flash/eos/multifluid.py
@dataclass(frozen=True)
class HelmholtzTerms:
    """Power, exponential, Gaussian, and GERG-special terms.

    The full form is ``n*delta^d*tau^t*exp(-delta^l
    -eta*(delta-epsilon)^2-beta*(tau-gamma)^2
    -linear_density*(delta-linear_shift))``. Setting the optional arrays to
    zero recovers the power-exponential subset. The final linear-density
    factor is the binary departure form used by GERG-2008.
    """

    n: Tensor
    d: Tensor
    t: Tensor
    decay: Tensor
    eta: Tensor | None = None
    epsilon: Tensor | None = None
    beta: Tensor | None = None
    gamma: Tensor | None = None
    linear_density: Tensor | None = None
    linear_shift: Tensor | None = None

    def __post_init__(self) -> None:
        if not (self.n.shape == self.d.shape == self.t.shape == self.decay.shape):
            raise ValueError("all Helmholtz term arrays must have equal shape")
        gaussian = (self.eta, self.epsilon, self.beta, self.gamma)
        if any(value is not None for value in gaussian) and not all(
            value is not None and value.shape == self.n.shape for value in gaussian
        ):
            raise ValueError("Gaussian arrays must all be present with the term-table shape")
        linear = (self.linear_density, self.linear_shift)
        if any(value is not None for value in linear) and not all(
            value is not None and value.shape == self.n.shape for value in linear
        ):
            raise ValueError("linear-density arrays must both have the term-table shape")

IdealHelmholtzTerms dataclass

Canonical pure-fluid ideal Helmholtz term tables.

Every component has lead, logarithmic, power, Planck--Einstein, and optional GERG sinh/cosh terms. gas_scale is the ratio between the gas constant of the original pure-fluid equation and the common mixture gas constant. It multiplies the density-independent part only, preserving the exact ideal-gas pressure limit.

Source code in src/torch_flash/eos/multifluid.py
@dataclass(frozen=True)
class IdealHelmholtzTerms:
    """Canonical pure-fluid ideal Helmholtz term tables.

    Every component has lead, logarithmic, power, Planck--Einstein, and
    optional GERG sinh/cosh terms. ``gas_scale`` is the ratio between the gas
    constant of the original pure-fluid equation and the common mixture gas
    constant. It multiplies the density-independent part only, preserving the
    exact ideal-gas pressure limit.
    """

    lead_constant: Tensor
    lead_tau: Tensor
    log_tau: Tensor
    tau_log_tau: Tensor
    power_n: Tensor
    power_t: Tensor
    planck_n: Tensor
    planck_theta: Tensor
    gerg_n: Tensor
    gerg_theta: Tensor
    gerg_sign: Tensor
    gas_scale: Tensor

    def __post_init__(self) -> None:
        component_shape = self.lead_constant.shape
        vectors = (
            self.lead_tau,
            self.log_tau,
            self.tau_log_tau,
            self.gas_scale,
        )
        if len(component_shape) != 1 or not all(
            value.shape == component_shape for value in vectors
        ):
            raise ValueError("all ideal Helmholtz lead arrays must have one value per component")
        paired = (
            (self.power_n, self.power_t),
            (self.planck_n, self.planck_theta),
            (self.gerg_n, self.gerg_theta, self.gerg_sign),
        )
        if not all(
            all(value.ndim == 2 and value.shape[0] == component_shape[0] for value in group)
            and all(value.shape == group[0].shape for value in group)
            for group in paired
        ):
            raise ValueError("ideal Helmholtz term tables must have one row per component")

MultiFluidEOS

Bases: Module

Autodifferentiable multifluid Helmholtz model.

Source code in src/torch_flash/eos/multifluid.py
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 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
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
class MultiFluidEOS(nn.Module):
    """Autodifferentiable multifluid Helmholtz model."""

    critical_temperature: Tensor
    critical_density: Tensor
    critical_pressure: Tensor
    acentric_factor: Tensor
    molar_mass: Tensor
    pure_n: Tensor
    pure_d: Tensor
    pure_t: Tensor
    pure_decay: Tensor
    pure_eta: Tensor
    pure_epsilon: Tensor
    pure_beta: Tensor
    pure_gamma: Tensor
    pure_linear_density: Tensor
    pure_linear_shift: Tensor
    departure_n: Tensor
    departure_d: Tensor
    departure_t: Tensor
    departure_decay: Tensor
    departure_eta: Tensor
    departure_epsilon: Tensor
    departure_beta: Tensor
    departure_gamma: Tensor
    departure_linear_density: Tensor
    departure_linear_shift: Tensor
    pure_gaob_n: Tensor
    pure_gaob_d: Tensor
    pure_gaob_t: Tensor
    pure_gaob_eta: Tensor
    pure_gaob_epsilon: Tensor
    pure_gaob_beta: Tensor
    pure_gaob_gamma: Tensor
    pure_gaob_b: Tensor
    pure_nonanalytic_n: Tensor
    pure_nonanalytic_capital_a: Tensor
    pure_nonanalytic_capital_b: Tensor
    pure_nonanalytic_capital_c: Tensor
    pure_nonanalytic_capital_d: Tensor
    pure_nonanalytic_a: Tensor
    pure_nonanalytic_b: Tensor
    pure_nonanalytic_beta: Tensor
    beta_temperature: Tensor
    gamma_temperature: Tensor
    beta_volume: Tensor
    gamma_volume: Tensor
    departure_scale: Tensor
    gas_constant: Tensor
    ideal_lead_constant: Tensor
    ideal_lead_tau: Tensor
    ideal_log_tau: Tensor
    ideal_tau_log_tau: Tensor
    ideal_power_n: Tensor
    ideal_power_t: Tensor
    ideal_planck_n: Tensor
    ideal_planck_theta: Tensor
    ideal_gerg_n: Tensor
    ideal_gerg_theta: Tensor
    ideal_gerg_sign: Tensor
    ideal_gas_scale: Tensor
    _upper_triangle: Tensor
    _critical_temperature_pair: Tensor
    _inverse_density_pair: Tensor

    def __init__(
        self,
        names: tuple[str, ...],
        critical_temperature: Tensor,
        critical_density: Tensor,
        molar_mass: Tensor,
        pure_terms: HelmholtzTerms,
        departure_terms: HelmholtzTerms,
        beta_temperature: Tensor,
        gamma_temperature: Tensor,
        beta_volume: Tensor,
        gamma_volume: Tensor,
        departure_scale: Tensor,
        metadata: MultifluidMetadata,
        *,
        trainable: bool = False,
        gas_constant: float = R,
        pure_gaob_terms: GaoBTerms | None = None,
        pure_nonanalytic_terms: NonAnalyticTerms | None = None,
        ideal_terms: IdealHelmholtzTerms | None = None,
        critical_pressure: Tensor | None = None,
        acentric_factor: Tensor | None = None,
    ) -> None:
        super().__init__()
        ncomponents = len(names)
        if pure_terms.n.shape[0] != ncomponents:
            raise ValueError("pure term table must have one row per component")
        expected_pair = (ncomponents, ncomponents)
        if departure_terms.n.shape[:2] != expected_pair:
            raise ValueError("departure term table must start with (component, component)")
        for matrix in (
            beta_temperature,
            gamma_temperature,
            beta_volume,
            gamma_volume,
            departure_scale,
        ):
            if matrix.shape != expected_pair:
                raise ValueError("all multifluid interaction matrices must be square")
        self.names = names
        self.metadata = metadata
        self.register_buffer(
            "gas_constant",
            critical_temperature.new_tensor(gas_constant),
        )
        self.register_buffer("critical_temperature", critical_temperature.clone())
        self.register_buffer("critical_density", critical_density.clone())
        self.register_buffer(
            "critical_pressure",
            (
                torch.full_like(critical_temperature, torch.nan)
                if critical_pressure is None
                else critical_pressure.clone()
            ),
        )
        self.register_buffer(
            "acentric_factor",
            (
                torch.full_like(critical_temperature, torch.nan)
                if acentric_factor is None
                else acentric_factor.clone()
            ),
        )
        self.register_buffer("molar_mass", molar_mass.clone())
        self._store_terms("pure", pure_terms, trainable)
        self._store_terms("departure", departure_terms, trainable)
        self._store_gaob_terms(pure_gaob_terms, ncomponents, critical_temperature, trainable)
        self._store_nonanalytic_terms(
            pure_nonanalytic_terms, ncomponents, critical_temperature, trainable
        )
        self._store_ideal_terms(ideal_terms, ncomponents, critical_temperature, trainable)
        self._store_parameter("beta_temperature", beta_temperature, trainable)
        self._store_parameter("gamma_temperature", gamma_temperature, trainable)
        self._store_parameter("beta_volume", beta_volume, trainable)
        self._store_parameter("gamma_volume", gamma_volume, trainable)
        self._store_parameter("departure_scale", departure_scale, trainable)
        self.register_buffer(
            "_upper_triangle",
            torch.triu(torch.ones_like(beta_temperature), diagonal=1),
        )
        self.register_buffer(
            "_critical_temperature_pair",
            torch.sqrt(critical_temperature[:, None] * critical_temperature[None, :]),
        )
        self.register_buffer(
            "_inverse_density_pair",
            0.125
            * (
                critical_density[:, None].pow(-1.0 / 3.0)
                + critical_density[None, :].pow(-1.0 / 3.0)
            ).pow(3),
        )

    def _store_parameter(self, name: str, value: Tensor, trainable: bool) -> None:
        if trainable:
            setattr(self, name, nn.Parameter(value.clone()))
        else:
            self.register_buffer(name, value.clone())

    def _store_terms(self, prefix: str, terms: HelmholtzTerms, trainable: bool) -> None:
        self._store_parameter(f"{prefix}_n", terms.n, trainable)
        self.register_buffer(f"{prefix}_d", terms.d.clone())
        self.register_buffer(f"{prefix}_t", terms.t.clone())
        self.register_buffer(f"{prefix}_decay", terms.decay.clone())
        for name in ("eta", "epsilon", "beta", "gamma", "linear_density", "linear_shift"):
            value = getattr(terms, name)
            self.register_buffer(
                f"{prefix}_{name}",
                torch.zeros_like(terms.n) if value is None else value.clone(),
            )

    def _store_gaob_terms(
        self,
        terms: GaoBTerms | None,
        ncomponents: int,
        like: Tensor,
        trainable: bool,
    ) -> None:
        if terms is None:
            zeros = like.new_zeros((ncomponents, 1))
            terms = GaoBTerms(zeros, zeros, zeros, zeros, zeros, zeros, zeros, zeros + 1.0)
        if terms.n.shape[0] != ncomponents:
            raise ValueError("Gao-B term table must have one row per component")
        self._store_parameter("pure_gaob_n", terms.n, trainable)
        for name in ("d", "t", "eta", "epsilon", "beta", "gamma", "b"):
            self.register_buffer(f"pure_gaob_{name}", getattr(terms, name).clone())

    def _store_nonanalytic_terms(
        self,
        terms: NonAnalyticTerms | None,
        ncomponents: int,
        like: Tensor,
        trainable: bool,
    ) -> None:
        if terms is None:
            zeros = like.new_zeros((ncomponents, 1))
            ones = zeros + 1.0
            terms = NonAnalyticTerms(zeros, zeros, zeros, zeros, zeros, ones, ones, ones)
        if terms.n.shape[0] != ncomponents:
            raise ValueError("non-analytic term table must have one row per component")
        self._store_parameter("pure_nonanalytic_n", terms.n, trainable)
        for name in ("capital_a", "capital_b", "capital_c", "capital_d", "a", "b", "beta"):
            self.register_buffer(f"pure_nonanalytic_{name}", getattr(terms, name).clone())

    def _store_ideal_terms(
        self,
        terms: IdealHelmholtzTerms | None,
        ncomponents: int,
        like: Tensor,
        trainable: bool,
    ) -> None:
        self.has_ideal_terms = terms is not None
        if terms is None:
            vector = like.new_zeros(ncomponents)
            table = like.new_zeros((ncomponents, 1))
            terms = IdealHelmholtzTerms(
                vector,
                vector,
                vector,
                vector,
                table,
                table,
                table,
                table + 1.0,
                table,
                table + 1.0,
                table,
                vector + 1.0,
            )
        if terms.lead_constant.shape != (ncomponents,):
            raise ValueError("ideal Helmholtz tables must have one row per component")
        for name in (
            "lead_constant",
            "lead_tau",
            "log_tau",
            "tau_log_tau",
            "power_n",
            "planck_n",
            "gerg_n",
        ):
            self._store_parameter(f"ideal_{name}", getattr(terms, name), trainable)
        for name in (
            "power_t",
            "planck_theta",
            "gerg_theta",
            "gerg_sign",
            "gas_scale",
        ):
            self.register_buffer(f"ideal_{name}", getattr(terms, name).clone())

    def reducing_functions(self, composition: Tensor) -> tuple[Tensor, Tensor]:
        """Return mixture reducing temperature and molar density."""
        x = normalize_composition(composition)
        xi = x[..., :, None]
        xj = x[..., None, :]
        pair_fraction_t = (
            2.0
            * xi
            * xj
            * self.beta_temperature
            * self.gamma_temperature
            * (xi + xj)
            / (self.beta_temperature.square() * xi + xj)
        )
        pair_fraction_v = (
            2.0
            * xi
            * xj
            * self.beta_volume
            * self.gamma_volume
            * (xi + xj)
            / (self.beta_volume.square() * xi + xj)
        )
        reducing_temperature = torch.sum(
            x.square() * self.critical_temperature,
            dim=-1,
        ) + torch.sum(
            self._upper_triangle * pair_fraction_t * self._critical_temperature_pair,
            dim=(-2, -1),
        )
        inverse_reducing_density = torch.sum(
            x.square() / self.critical_density,
            dim=-1,
        ) + torch.sum(
            self._upper_triangle * pair_fraction_v * self._inverse_density_pair,
            dim=(-2, -1),
        )
        return reducing_temperature, inverse_reducing_density.reciprocal()

    @staticmethod
    def _evaluate_terms(
        n: Tensor,
        d: Tensor,
        t: Tensor,
        decay: Tensor,
        eta: Tensor,
        epsilon: Tensor,
        beta: Tensor,
        gamma: Tensor,
        linear_density: Tensor,
        linear_shift: Tensor,
        delta: Tensor,
        tau: Tensor,
    ) -> Tensor:
        term_axes = (None,) * n.ndim
        delta = delta[(..., *term_axes)]
        tau = tau[(..., *term_axes)]
        exponent = (
            -delta.pow(decay) * (decay != 0.0)
            - eta * (delta - epsilon).square()
            - beta * (tau - gamma).square()
            - linear_density * (delta - linear_shift)
        )
        return torch.sum(
            n * delta.pow(d) * tau.pow(t) * torch.exp(exponent),
            dim=-1,
        )

    def _evaluate_gaob(self, delta: Tensor, tau: Tensor) -> Tensor:
        delta = delta[(..., None, None)]
        tau = tau[(..., None, None)]
        exponent = (
            self.pure_gaob_d * torch.log(delta)
            + self.pure_gaob_t * torch.log(tau)
            + self.pure_gaob_eta * (delta - self.pure_gaob_epsilon).square()
            + (
                self.pure_gaob_beta * (tau - self.pure_gaob_gamma).square() + self.pure_gaob_b
            ).reciprocal()
        )
        return torch.sum(self.pure_gaob_n * torch.exp(exponent), dim=-1)

    def _evaluate_nonanalytic(self, delta: Tensor, tau: Tensor) -> Tensor:
        delta = delta[(..., None, None)]
        tau = tau[(..., None, None)]
        delta_offset = delta - 1.0
        delta_squared = delta_offset.square().clamp_min(torch.finfo(delta.dtype).tiny)
        tau_offset = tau - 1.0
        psi = torch.exp(
            -self.pure_nonanalytic_capital_c * delta_squared
            - self.pure_nonanalytic_capital_d * tau_offset.square()
        )
        theta = -tau_offset + self.pure_nonanalytic_capital_a * delta_squared.pow(
            0.5 / self.pure_nonanalytic_beta
        )
        capital_delta = theta.square() + self.pure_nonanalytic_capital_b * delta_squared.pow(
            self.pure_nonanalytic_a
        )
        return torch.sum(
            self.pure_nonanalytic_n * delta * psi * capital_delta.pow(self.pure_nonanalytic_b),
            dim=-1,
        )

    def alpha_residual(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return dimensionless molar residual Helmholtz energy."""
        x = normalize_composition(composition)
        reducing_temperature, reducing_density = self.reducing_functions(x)
        tau = reducing_temperature / temperature
        delta = molar_density / reducing_density
        pure_values = self._evaluate_terms(
            self.pure_n,
            self.pure_d,
            self.pure_t,
            self.pure_decay,
            self.pure_eta,
            self.pure_epsilon,
            self.pure_beta,
            self.pure_gamma,
            self.pure_linear_density,
            self.pure_linear_shift,
            delta,
            tau,
        )
        pure_values = (
            pure_values + self._evaluate_gaob(delta, tau) + self._evaluate_nonanalytic(delta, tau)
        )
        departure_values = self._evaluate_terms(
            self.departure_n,
            self.departure_d,
            self.departure_t,
            self.departure_decay,
            self.departure_eta,
            self.departure_epsilon,
            self.departure_beta,
            self.departure_gamma,
            self.departure_linear_density,
            self.departure_linear_shift,
            delta,
            tau,
        )
        pure = torch.sum(x * pure_values, dim=-1)
        pair_weights = (
            x[..., :, None] * x[..., None, :] * self.departure_scale * self._upper_triangle
        )
        return pure + torch.sum(pair_weights * departure_values, dim=(-2, -1))

    def alpha_ideal(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return dimensionless molar ideal-gas Helmholtz energy."""
        if not self.has_ideal_terms:
            raise RuntimeError("this multifluid model has no ideal Helmholtz coefficient table")
        x = normalize_composition(composition)
        tau = self.critical_temperature / temperature[..., None]
        thermal = (
            self.ideal_lead_constant
            + self.ideal_lead_tau * tau
            + self.ideal_log_tau * torch.log(tau)
            + self.ideal_tau_log_tau * tau * torch.log(tau)
            + torch.sum(
                self.ideal_power_n * tau[..., :, None].pow(self.ideal_power_t),
                dim=-1,
            )
        )
        planck_argument = self.ideal_planck_theta * tau[..., :, None]
        thermal = thermal + torch.sum(
            self.ideal_planck_n * torch.log(-torch.expm1(-planck_argument)),
            dim=-1,
        )
        gerg_argument = (self.ideal_gerg_theta * tau[..., :, None]).abs()
        safe_argument = gerg_argument.clamp_min(torch.finfo(gerg_argument.dtype).tiny)
        log_sinh = (
            safe_argument
            + torch.log1p(-torch.exp(-2.0 * safe_argument))
            - torch.log(safe_argument.new_tensor(2.0))
        )
        log_cosh = torch.logaddexp(safe_argument, -safe_argument) - torch.log(
            safe_argument.new_tensor(2.0)
        )
        gerg_value = torch.where(self.ideal_gerg_sign > 0.0, log_sinh, -log_cosh)
        thermal = thermal + torch.sum(self.ideal_gerg_n * gerg_value, dim=-1)
        pure = (
            torch.log(molar_density[..., None] / self.critical_density)
            + self.ideal_gas_scale * thermal
        )
        return torch.sum(x * pure + torch.special.xlogy(x, x), dim=-1)

    def alpha_total(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return dimensionless total molar Helmholtz energy."""
        return self.alpha_ideal(temperature, molar_density, composition) + self.alpha_residual(
            temperature, molar_density, composition
        )

    def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
        """Return extensive residual Helmholtz energy divided by ``RT``."""
        total = moles.sum(dim=-1)
        x = moles / total[..., None]
        density = total / volume
        return total * self.alpha_residual(temperature, density, x)

    def helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
        """Return extensive total Helmholtz energy divided by ``RT``."""
        total = moles.sum(dim=-1)
        x = moles / total[..., None]
        return total * self.alpha_total(temperature, total / volume, x)

    def molar_helmholtz_energy(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total molar Helmholtz energy in J/mol."""
        return (
            self.gas_constant
            * temperature
            * self.alpha_total(temperature, molar_density, composition)
        )

    def molar_entropy(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total molar entropy in J/(mol K)."""
        derivative: Tensor = torch.func.grad(
            lambda current: self.molar_helmholtz_energy(
                current,
                molar_density,
                composition,
            ).sum()
        )(temperature)
        return -derivative

    def molar_internal_energy(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total molar internal energy in J/mol."""
        helmholtz = self.molar_helmholtz_energy(temperature, molar_density, composition)
        return helmholtz + temperature * self.molar_entropy(temperature, molar_density, composition)

    def molar_heat_capacity_cv(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return isochoric molar heat capacity in J/(mol K)."""
        derivative: Tensor = torch.func.grad(
            lambda current: self.molar_internal_energy(
                current,
                molar_density,
                composition,
            ).sum()
        )(temperature)
        return derivative

    def molar_heat_capacity_cp(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return isobaric molar heat capacity in J/(mol K)."""
        volume = molar_density.reciprocal()
        cv = self.molar_heat_capacity_cv(temperature, molar_density, composition)
        dp_dt: Tensor = torch.func.grad(
            lambda current: self.pressure(current, volume, composition).sum()
        )(temperature)
        dp_drho: Tensor = torch.func.grad(
            lambda density: self.pressure(
                temperature,
                density.reciprocal(),
                composition,
            ).sum()
        )(molar_density)
        return cv + temperature * dp_dt.square() / (molar_density.square() * dp_drho)

    def molar_enthalpy(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total molar enthalpy in J/mol."""
        volume = molar_density.reciprocal()
        return (
            self.molar_internal_energy(temperature, molar_density, composition)
            + self.pressure(temperature, volume, composition) * volume
        )

    def molar_gibbs_energy(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total molar Gibbs energy in J/mol."""
        volume = molar_density.reciprocal()
        return (
            self.molar_helmholtz_energy(temperature, molar_density, composition)
            + self.pressure(temperature, volume, composition) * volume
        )

    def chemical_potentials(
        self, temperature: Tensor, molar_volume: Tensor, composition: Tensor
    ) -> Tensor:
        """Return total component chemical potentials in J/mol."""
        x = normalize_composition(composition)
        reduced: Tensor = torch.func.grad(
            lambda moles: self.helmholtz_rt(
                temperature,
                molar_volume,
                moles,
            ).sum()
        )(x)
        return self.gas_constant * temperature * reduced

    def speed_of_sound(
        self, temperature: Tensor, molar_density: Tensor, composition: Tensor
    ) -> Tensor:
        """Return homogeneous-phase speed of sound in m/s."""
        x = normalize_composition(composition)
        dp_drho: Tensor = torch.func.grad(
            lambda density: self.pressure(
                temperature,
                density.reciprocal(),
                x,
            ).sum()
        )(molar_density)
        cv = self.molar_heat_capacity_cv(temperature, molar_density, x)
        cp = self.molar_heat_capacity_cp(temperature, molar_density, x)
        mixture_molar_mass = torch.sum(x * self.molar_mass, dim=-1)
        return torch.sqrt((cp / cv) * dp_drho / mixture_molar_mass)

    def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
        """Return pressure from a residual-Helmholtz density derivative.

        Leading state dimensions are broadcast. The summed scalar objective
        used by ``torch.func.grad`` differentiates independent batch entries
        elementwise because the Helmholtz kernel contains no cross-state terms.
        """
        x = normalize_composition(composition)
        molar_density = molar_volume.reciprocal()
        derivative: Tensor = torch.func.grad(
            lambda density: self.alpha_residual(temperature, density, x).sum()
        )(molar_density)
        return self.gas_constant * temperature * molar_density * (1.0 + molar_density * derivative)

    def molar_volume(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Solve and select a homogeneous density root.

        Phase-specific states first use a differentiable logarithmic-density
        Newton solve.  If it fails, a logarithmic scan brackets every
        mechanically admissible positive root before bisection and Newton
        polishing.  This conservative fallback matters because Helmholtz
        mixture models can have three density roots below the mixture critical
        locus, and an initial guess can cross a spinodal during phase-envelope
        calculations.
        """
        x = normalize_composition(composition)
        if x.shape[-1] != len(self.names):
            raise ValueError("multifluid composition has the wrong number of components")
        if phase not in ("vapor", "liquid", "stable"):
            raise ValueError(f"unknown phase root {phase!r}")
        batch_shape = torch.broadcast_shapes(
            temperature.shape,
            pressure.shape,
            x.shape[:-1],
        )
        if batch_shape:
            temperature = torch.broadcast_to(temperature, batch_shape)
            pressure = torch.broadcast_to(pressure, batch_shape)
            x = torch.broadcast_to(x, (*batch_shape, len(self.names)))
            if phase in ("vapor", "liquid"):
                return self._batched_phase_volume(temperature, pressure, x, phase)
            flattened_temperature = temperature.reshape(-1)
            flattened_pressure = pressure.reshape(-1)
            flattened_composition = x.reshape(-1, len(self.names))
            return torch.stack(
                [
                    self.molar_volume(current_temperature, current_pressure, current_x, phase)
                    for current_temperature, current_pressure, current_x in zip(
                        flattened_temperature,
                        flattened_pressure,
                        flattened_composition,
                        strict=True,
                    )
                ]
            ).reshape(batch_shape)

        ideal_density = pressure / (self.gas_constant * temperature)
        density_scale = torch.sum(x * self.critical_density)
        reducing_temperature, _ = self.reducing_functions(x)

        def residual(current: Tensor) -> Tensor:
            volume = torch.exp(-current)
            return (self.pressure(temperature, volume, x) - pressure) / pressure

        needs_implicit_gradient = (
            temperature.requires_grad
            or pressure.requires_grad
            or x.requires_grad
            or any(parameter.requires_grad for parameter in self.parameters())
        )

        def volume_with_implicit_gradient(current: Tensor) -> Tensor:
            """Restore root derivatives after detached numerical iteration."""
            if needs_implicit_gradient:
                value = residual(current)
                derivative: Tensor = torch.func.grad(residual)(current)
                current = current - value / derivative
            return torch.exp(-current)

        # Locate a phase-specific root with detached finite-difference Newton
        # steps. Differentiating every iteration nests reverse-mode AD through
        # the residual Helmholtz pressure derivative and makes saturation
        # calculations unnecessarily expensive. One exact correction at the
        # converged root restores the implicit state and parameter gradients.
        # The scan below remains the conservative fallback near spinodals or
        # when the conventional seed lies in the basin of another root.
        if phase != "stable":
            density = (
                ideal_density
                if phase == "vapor"
                else torch.maximum(ideal_density, 3.0 * density_scale)
            )
            log_density = torch.log(density)
            slope_offset = log_density.new_tensor(1.0e-4)
            for _ in range(20):
                value = residual(log_density)
                if float(value.detach().abs()) <= 1.0e-10:
                    solved_density = torch.exp(log_density)
                    phase_consistent = (
                        solved_density <= density_scale
                        if phase == "vapor"
                        else solved_density >= density_scale
                    )
                    if bool((temperature >= reducing_temperature) | phase_consistent):
                        return volume_with_implicit_gradient(log_density)
                    break
                derivative = (
                    residual(log_density + slope_offset) - residual(log_density - slope_offset)
                ) / (2.0 * slope_offset)
                step = torch.clamp(-value / derivative, -0.5, 0.5)
                if not bool(torch.isfinite(step)):
                    break
                log_density = (log_density + step).detach()

        minimum = torch.minimum(ideal_density * 1.0e-4, density_scale * 1.0e-8)
        maximum = torch.maximum(
            ideal_density * 10.0,
            100.0 * torch.max(self.critical_density),
        )
        grid = torch.logspace(
            float(torch.log10(minimum.detach())),
            float(torch.log10(maximum.detach())),
            96,
            dtype=temperature.dtype,
            device=temperature.device,
        )

        brackets: list[tuple[Tensor, Tensor]] = []
        left = torch.log(grid[0])
        left_value = residual(left)
        for density in grid[1:]:
            right = torch.log(density)
            right_value = residual(right)
            finite = bool(torch.isfinite(left_value) & torch.isfinite(right_value))
            changes_sign = bool(torch.signbit(left_value) != torch.signbit(right_value))
            if finite and (
                float(left_value.detach()) == 0.0
                or float(right_value.detach()) == 0.0
                or changes_sign
            ):
                brackets.append((left, right))
            left, left_value = right, right_value
        if not brackets:
            raise ConvergenceError("multifluid density solve did not converge")

        roots: list[Tensor] = []
        for left, right in brackets:
            left_value = residual(left)
            midpoint = 0.5 * (left + right)
            for _ in range(80):
                midpoint = 0.5 * (left + right)
                midpoint_value = residual(midpoint)
                if float(midpoint_value.detach().abs()) <= 1.0e-12:
                    break
                if bool(torch.signbit(left_value) != torch.signbit(midpoint_value)):
                    right = midpoint
                else:
                    left = midpoint
                    left_value = midpoint_value

            root = torch.exp(midpoint)
            if not roots or abs(float((root / roots[-1] - 1.0).detach())) > 1.0e-8:
                roots.append(root)

        if phase == "vapor":
            density = roots[0]
        elif phase == "liquid":
            density = roots[-1]
        elif phase == "stable":
            gibbs: list[Tensor] = []
            for density_root in roots:
                volume = density_root.reciprocal()
                z = pressure * volume / (self.gas_constant * temperature)
                residual_helmholtz = self.residual_helmholtz_rt(temperature, volume, x)
                gibbs.append(residual_helmholtz + z - 1.0 - torch.log(z))
            density = roots[int(torch.argmin(torch.stack(gibbs)).detach())]
        return volume_with_implicit_gradient(torch.log(density))

    def _batched_phase_volume(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: Literal["liquid", "vapor"],
    ) -> Tensor:
        """Solve independent phase-specific states with one tensor workload.

        A conventional phase-specific seed is advanced for the complete batch.
        Extra density seeds are evaluated only for the failed subset. Selecting
        its lowest or highest converged root handles cases in which the
        requested vapor-like branch is the sole, dense root above saturation
        pressure. Only states for which every seed fails use the conservative
        scalar root scan.
        """
        batch_shape = temperature.shape
        flat_temperature = temperature.reshape(-1)
        flat_pressure = pressure.reshape(-1)
        flat_composition = composition.reshape(-1, len(self.names))
        ideal_density = flat_pressure / (self.gas_constant * flat_temperature)
        density_scale = torch.sum(flat_composition * self.critical_density, dim=-1)
        reducing_temperature, _ = self.reducing_functions(flat_composition)
        density = (
            ideal_density if phase == "vapor" else torch.maximum(ideal_density, 3.0 * density_scale)
        )
        log_density = torch.log(density)

        def residual(current: Tensor) -> Tensor:
            volume = torch.exp(-current)
            return (
                self.pressure(
                    flat_temperature,
                    volume,
                    flat_composition,
                )
                - flat_pressure
            ) / flat_pressure

        # A fixed workload avoids per-state Python dispatch and device
        # synchronization. A centered log-density slope is much faster here
        # than nesting reverse-mode AD through the already differentiated
        # pressure kernel. It is used only to locate the root.
        slope_offset = log_density.new_tensor(1.0e-4)
        for _ in range(10):
            value = residual(log_density)
            derivative = (
                residual(log_density + slope_offset) - residual(log_density - slope_offset)
            ) / (2.0 * slope_offset)
            step = torch.clamp(-value / derivative, -0.5, 0.5)
            step = torch.where(torch.isfinite(step), step, torch.zeros_like(step))
            log_density = (log_density + step).detach()

        primary_residual = residual(log_density)
        primary_density = torch.exp(log_density)
        phase_consistent = (
            primary_density <= density_scale
            if phase == "vapor"
            else primary_density >= density_scale
        )
        primary_converged = (
            torch.isfinite(primary_residual)
            & (primary_residual.abs() <= 1.0e-8)
            & ((flat_temperature >= reducing_temperature) | phase_consistent)
        )
        preliminary_converged = primary_converged

        if not bool(primary_converged.all()):
            failed_indices = torch.nonzero(
                ~primary_converged,
                as_tuple=False,
            ).flatten()
            failed_temperature = flat_temperature[failed_indices]
            failed_pressure = flat_pressure[failed_indices]
            failed_composition = flat_composition[failed_indices]
            failed_ideal_density = ideal_density[failed_indices]
            failed_density_scale = density_scale[failed_indices]
            if phase == "vapor":
                candidate_density = torch.stack(
                    (
                        0.2 * failed_ideal_density,
                        failed_ideal_density,
                        torch.maximum(
                            2.0 * failed_ideal_density,
                            1.5 * failed_density_scale,
                        ),
                    ),
                    dim=-1,
                )
            else:
                candidate_density = torch.stack(
                    (
                        torch.maximum(
                            failed_ideal_density,
                            1.5 * failed_density_scale,
                        ),
                        torch.maximum(
                            failed_ideal_density,
                            3.0 * failed_density_scale,
                        ),
                        torch.maximum(
                            failed_ideal_density,
                            10.0 * failed_density_scale,
                        ),
                    ),
                    dim=-1,
                )
            candidate_log_density = torch.log(candidate_density)

            def candidate_residual(current: Tensor) -> Tensor:
                volume = torch.exp(-current)
                return (
                    self.pressure(
                        failed_temperature[:, None],
                        volume,
                        failed_composition[:, None, :],
                    )
                    - failed_pressure[:, None]
                ) / failed_pressure[:, None]

            for _ in range(10):
                value = candidate_residual(candidate_log_density)
                derivative = (
                    candidate_residual(candidate_log_density + slope_offset)
                    - candidate_residual(candidate_log_density - slope_offset)
                ) / (2.0 * slope_offset)
                step = torch.clamp(-value / derivative, -0.5, 0.5)
                step = torch.where(
                    torch.isfinite(step),
                    step,
                    torch.zeros_like(step),
                )
                candidate_log_density = (candidate_log_density + step).detach()

            candidate_value = candidate_residual(candidate_log_density)
            candidate_converged = torch.isfinite(candidate_value) & (
                candidate_value.abs() <= 1.0e-8
            )
            if phase == "vapor":
                ranked_density = torch.where(
                    candidate_converged,
                    candidate_log_density,
                    torch.full_like(candidate_log_density, torch.inf),
                )
                candidate_index = torch.argmin(
                    ranked_density,
                    dim=-1,
                    keepdim=True,
                )
            else:
                ranked_density = torch.where(
                    candidate_converged,
                    candidate_log_density,
                    torch.full_like(candidate_log_density, -torch.inf),
                )
                candidate_index = torch.argmax(
                    ranked_density,
                    dim=-1,
                    keepdim=True,
                )
            selected_candidate = torch.gather(
                candidate_log_density,
                -1,
                candidate_index,
            ).squeeze(-1)
            log_density = log_density.index_copy(
                0,
                failed_indices,
                selected_candidate,
            )
            preliminary_converged = primary_converged.index_copy(
                0,
                failed_indices,
                candidate_converged.any(dim=-1),
            )

        # Two detached corrections tighten the numerical root without growing
        # an optimization graph through the root-finding history.
        for _ in range(2):
            value = residual(log_density)
            derivative = (
                residual(log_density + slope_offset) - residual(log_density - slope_offset)
            ) / (2.0 * slope_offset)
            step = torch.where(
                torch.isfinite(derivative)
                & (derivative.abs() > torch.finfo(derivative.dtype).tiny),
                value / derivative,
                torch.zeros_like(value),
            )
            log_density = (log_density - step).detach()

        needs_implicit_gradient = (
            temperature.requires_grad
            or pressure.requires_grad
            or composition.requires_grad
            or any(parameter.requires_grad for parameter in self.parameters())
        )
        if needs_implicit_gradient:
            # One exact Newton correction at an already converged root restores
            # the implicit derivatives with respect to state and fitted model
            # parameters; the preceding numerical iterations remain detached.
            value = residual(log_density)
            derivative = torch.func.grad(lambda current: residual(current).sum())(log_density)
            log_density = log_density - value / derivative

        value = residual(log_density)
        solved_density = torch.exp(log_density)
        converged = preliminary_converged & torch.isfinite(value) & (value.abs() <= 1.0e-10)
        if bool(converged.all()):
            return solved_density.reciprocal().reshape(batch_shape)

        # The conservative scalar scan is retained for the uncommon states
        # for which every batched seed fails. Preserve the already solved
        # entries instead of sending the complete batch through Python.
        failed_indices = torch.nonzero(
            ~converged,
            as_tuple=False,
        ).flatten()
        fallback = torch.stack(
            [
                self.molar_volume(
                    flat_temperature[index],
                    flat_pressure[index],
                    flat_composition[index],
                    phase,
                )
                for index in failed_indices.tolist()
            ]
        )
        volumes = solved_density.reciprocal()
        return volumes.index_copy(0, failed_indices, fallback).reshape(batch_shape)

    def select_z(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return compressibility factor."""
        volume = self.molar_volume(temperature, pressure, composition, phase)
        return pressure * volume / (self.gas_constant * temperature)

    def log_fugacity_coefficients(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return log fugacity coefficients from composition derivatives."""
        x = normalize_composition(composition)
        volume = self.molar_volume(temperature, pressure, x, phase)
        residual_mu: Tensor = torch.func.grad(
            lambda moles: self.residual_helmholtz_rt(
                temperature,
                volume,
                moles,
            ).sum()
        )(x)
        z = pressure * volume / (self.gas_constant * temperature)
        return residual_mu - torch.log(z)[..., None]

reducing_functions

reducing_functions(composition)

Return mixture reducing temperature and molar density.

Source code in src/torch_flash/eos/multifluid.py
def reducing_functions(self, composition: Tensor) -> tuple[Tensor, Tensor]:
    """Return mixture reducing temperature and molar density."""
    x = normalize_composition(composition)
    xi = x[..., :, None]
    xj = x[..., None, :]
    pair_fraction_t = (
        2.0
        * xi
        * xj
        * self.beta_temperature
        * self.gamma_temperature
        * (xi + xj)
        / (self.beta_temperature.square() * xi + xj)
    )
    pair_fraction_v = (
        2.0
        * xi
        * xj
        * self.beta_volume
        * self.gamma_volume
        * (xi + xj)
        / (self.beta_volume.square() * xi + xj)
    )
    reducing_temperature = torch.sum(
        x.square() * self.critical_temperature,
        dim=-1,
    ) + torch.sum(
        self._upper_triangle * pair_fraction_t * self._critical_temperature_pair,
        dim=(-2, -1),
    )
    inverse_reducing_density = torch.sum(
        x.square() / self.critical_density,
        dim=-1,
    ) + torch.sum(
        self._upper_triangle * pair_fraction_v * self._inverse_density_pair,
        dim=(-2, -1),
    )
    return reducing_temperature, inverse_reducing_density.reciprocal()

alpha_residual

alpha_residual(temperature, molar_density, composition)

Return dimensionless molar residual Helmholtz energy.

Source code in src/torch_flash/eos/multifluid.py
def alpha_residual(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return dimensionless molar residual Helmholtz energy."""
    x = normalize_composition(composition)
    reducing_temperature, reducing_density = self.reducing_functions(x)
    tau = reducing_temperature / temperature
    delta = molar_density / reducing_density
    pure_values = self._evaluate_terms(
        self.pure_n,
        self.pure_d,
        self.pure_t,
        self.pure_decay,
        self.pure_eta,
        self.pure_epsilon,
        self.pure_beta,
        self.pure_gamma,
        self.pure_linear_density,
        self.pure_linear_shift,
        delta,
        tau,
    )
    pure_values = (
        pure_values + self._evaluate_gaob(delta, tau) + self._evaluate_nonanalytic(delta, tau)
    )
    departure_values = self._evaluate_terms(
        self.departure_n,
        self.departure_d,
        self.departure_t,
        self.departure_decay,
        self.departure_eta,
        self.departure_epsilon,
        self.departure_beta,
        self.departure_gamma,
        self.departure_linear_density,
        self.departure_linear_shift,
        delta,
        tau,
    )
    pure = torch.sum(x * pure_values, dim=-1)
    pair_weights = (
        x[..., :, None] * x[..., None, :] * self.departure_scale * self._upper_triangle
    )
    return pure + torch.sum(pair_weights * departure_values, dim=(-2, -1))

alpha_ideal

alpha_ideal(temperature, molar_density, composition)

Return dimensionless molar ideal-gas Helmholtz energy.

Source code in src/torch_flash/eos/multifluid.py
def alpha_ideal(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return dimensionless molar ideal-gas Helmholtz energy."""
    if not self.has_ideal_terms:
        raise RuntimeError("this multifluid model has no ideal Helmholtz coefficient table")
    x = normalize_composition(composition)
    tau = self.critical_temperature / temperature[..., None]
    thermal = (
        self.ideal_lead_constant
        + self.ideal_lead_tau * tau
        + self.ideal_log_tau * torch.log(tau)
        + self.ideal_tau_log_tau * tau * torch.log(tau)
        + torch.sum(
            self.ideal_power_n * tau[..., :, None].pow(self.ideal_power_t),
            dim=-1,
        )
    )
    planck_argument = self.ideal_planck_theta * tau[..., :, None]
    thermal = thermal + torch.sum(
        self.ideal_planck_n * torch.log(-torch.expm1(-planck_argument)),
        dim=-1,
    )
    gerg_argument = (self.ideal_gerg_theta * tau[..., :, None]).abs()
    safe_argument = gerg_argument.clamp_min(torch.finfo(gerg_argument.dtype).tiny)
    log_sinh = (
        safe_argument
        + torch.log1p(-torch.exp(-2.0 * safe_argument))
        - torch.log(safe_argument.new_tensor(2.0))
    )
    log_cosh = torch.logaddexp(safe_argument, -safe_argument) - torch.log(
        safe_argument.new_tensor(2.0)
    )
    gerg_value = torch.where(self.ideal_gerg_sign > 0.0, log_sinh, -log_cosh)
    thermal = thermal + torch.sum(self.ideal_gerg_n * gerg_value, dim=-1)
    pure = (
        torch.log(molar_density[..., None] / self.critical_density)
        + self.ideal_gas_scale * thermal
    )
    return torch.sum(x * pure + torch.special.xlogy(x, x), dim=-1)

alpha_total

alpha_total(temperature, molar_density, composition)

Return dimensionless total molar Helmholtz energy.

Source code in src/torch_flash/eos/multifluid.py
def alpha_total(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return dimensionless total molar Helmholtz energy."""
    return self.alpha_ideal(temperature, molar_density, composition) + self.alpha_residual(
        temperature, molar_density, composition
    )

residual_helmholtz_rt

residual_helmholtz_rt(temperature, volume, moles)

Return extensive residual Helmholtz energy divided by RT.

Source code in src/torch_flash/eos/multifluid.py
def residual_helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
    """Return extensive residual Helmholtz energy divided by ``RT``."""
    total = moles.sum(dim=-1)
    x = moles / total[..., None]
    density = total / volume
    return total * self.alpha_residual(temperature, density, x)

helmholtz_rt

helmholtz_rt(temperature, volume, moles)

Return extensive total Helmholtz energy divided by RT.

Source code in src/torch_flash/eos/multifluid.py
def helmholtz_rt(self, temperature: Tensor, volume: Tensor, moles: Tensor) -> Tensor:
    """Return extensive total Helmholtz energy divided by ``RT``."""
    total = moles.sum(dim=-1)
    x = moles / total[..., None]
    return total * self.alpha_total(temperature, total / volume, x)

molar_helmholtz_energy

molar_helmholtz_energy(temperature, molar_density, composition)

Return total molar Helmholtz energy in J/mol.

Source code in src/torch_flash/eos/multifluid.py
def molar_helmholtz_energy(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return total molar Helmholtz energy in J/mol."""
    return (
        self.gas_constant
        * temperature
        * self.alpha_total(temperature, molar_density, composition)
    )

molar_entropy

molar_entropy(temperature, molar_density, composition)

Return total molar entropy in J/(mol K).

Source code in src/torch_flash/eos/multifluid.py
def molar_entropy(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return total molar entropy in J/(mol K)."""
    derivative: Tensor = torch.func.grad(
        lambda current: self.molar_helmholtz_energy(
            current,
            molar_density,
            composition,
        ).sum()
    )(temperature)
    return -derivative

molar_internal_energy

molar_internal_energy(temperature, molar_density, composition)

Return total molar internal energy in J/mol.

Source code in src/torch_flash/eos/multifluid.py
def molar_internal_energy(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return total molar internal energy in J/mol."""
    helmholtz = self.molar_helmholtz_energy(temperature, molar_density, composition)
    return helmholtz + temperature * self.molar_entropy(temperature, molar_density, composition)

molar_heat_capacity_cv

molar_heat_capacity_cv(temperature, molar_density, composition)

Return isochoric molar heat capacity in J/(mol K).

Source code in src/torch_flash/eos/multifluid.py
def molar_heat_capacity_cv(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return isochoric molar heat capacity in J/(mol K)."""
    derivative: Tensor = torch.func.grad(
        lambda current: self.molar_internal_energy(
            current,
            molar_density,
            composition,
        ).sum()
    )(temperature)
    return derivative

molar_heat_capacity_cp

molar_heat_capacity_cp(temperature, molar_density, composition)

Return isobaric molar heat capacity in J/(mol K).

Source code in src/torch_flash/eos/multifluid.py
def molar_heat_capacity_cp(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return isobaric molar heat capacity in J/(mol K)."""
    volume = molar_density.reciprocal()
    cv = self.molar_heat_capacity_cv(temperature, molar_density, composition)
    dp_dt: Tensor = torch.func.grad(
        lambda current: self.pressure(current, volume, composition).sum()
    )(temperature)
    dp_drho: Tensor = torch.func.grad(
        lambda density: self.pressure(
            temperature,
            density.reciprocal(),
            composition,
        ).sum()
    )(molar_density)
    return cv + temperature * dp_dt.square() / (molar_density.square() * dp_drho)

molar_enthalpy

molar_enthalpy(temperature, molar_density, composition)

Return total molar enthalpy in J/mol.

Source code in src/torch_flash/eos/multifluid.py
def molar_enthalpy(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return total molar enthalpy in J/mol."""
    volume = molar_density.reciprocal()
    return (
        self.molar_internal_energy(temperature, molar_density, composition)
        + self.pressure(temperature, volume, composition) * volume
    )

molar_gibbs_energy

molar_gibbs_energy(temperature, molar_density, composition)

Return total molar Gibbs energy in J/mol.

Source code in src/torch_flash/eos/multifluid.py
def molar_gibbs_energy(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return total molar Gibbs energy in J/mol."""
    volume = molar_density.reciprocal()
    return (
        self.molar_helmholtz_energy(temperature, molar_density, composition)
        + self.pressure(temperature, volume, composition) * volume
    )

chemical_potentials

chemical_potentials(temperature, molar_volume, composition)

Return total component chemical potentials in J/mol.

Source code in src/torch_flash/eos/multifluid.py
def chemical_potentials(
    self, temperature: Tensor, molar_volume: Tensor, composition: Tensor
) -> Tensor:
    """Return total component chemical potentials in J/mol."""
    x = normalize_composition(composition)
    reduced: Tensor = torch.func.grad(
        lambda moles: self.helmholtz_rt(
            temperature,
            molar_volume,
            moles,
        ).sum()
    )(x)
    return self.gas_constant * temperature * reduced

speed_of_sound

speed_of_sound(temperature, molar_density, composition)

Return homogeneous-phase speed of sound in m/s.

Source code in src/torch_flash/eos/multifluid.py
def speed_of_sound(
    self, temperature: Tensor, molar_density: Tensor, composition: Tensor
) -> Tensor:
    """Return homogeneous-phase speed of sound in m/s."""
    x = normalize_composition(composition)
    dp_drho: Tensor = torch.func.grad(
        lambda density: self.pressure(
            temperature,
            density.reciprocal(),
            x,
        ).sum()
    )(molar_density)
    cv = self.molar_heat_capacity_cv(temperature, molar_density, x)
    cp = self.molar_heat_capacity_cp(temperature, molar_density, x)
    mixture_molar_mass = torch.sum(x * self.molar_mass, dim=-1)
    return torch.sqrt((cp / cv) * dp_drho / mixture_molar_mass)

pressure

pressure(temperature, molar_volume, composition)

Return pressure from a residual-Helmholtz density derivative.

Leading state dimensions are broadcast. The summed scalar objective used by torch.func.grad differentiates independent batch entries elementwise because the Helmholtz kernel contains no cross-state terms.

Source code in src/torch_flash/eos/multifluid.py
def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
    """Return pressure from a residual-Helmholtz density derivative.

    Leading state dimensions are broadcast. The summed scalar objective
    used by ``torch.func.grad`` differentiates independent batch entries
    elementwise because the Helmholtz kernel contains no cross-state terms.
    """
    x = normalize_composition(composition)
    molar_density = molar_volume.reciprocal()
    derivative: Tensor = torch.func.grad(
        lambda density: self.alpha_residual(temperature, density, x).sum()
    )(molar_density)
    return self.gas_constant * temperature * molar_density * (1.0 + molar_density * derivative)

molar_volume

molar_volume(temperature, pressure, composition, phase='stable')

Solve and select a homogeneous density root.

Phase-specific states first use a differentiable logarithmic-density Newton solve. If it fails, a logarithmic scan brackets every mechanically admissible positive root before bisection and Newton polishing. This conservative fallback matters because Helmholtz mixture models can have three density roots below the mixture critical locus, and an initial guess can cross a spinodal during phase-envelope calculations.

Source code in src/torch_flash/eos/multifluid.py
def molar_volume(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Solve and select a homogeneous density root.

    Phase-specific states first use a differentiable logarithmic-density
    Newton solve.  If it fails, a logarithmic scan brackets every
    mechanically admissible positive root before bisection and Newton
    polishing.  This conservative fallback matters because Helmholtz
    mixture models can have three density roots below the mixture critical
    locus, and an initial guess can cross a spinodal during phase-envelope
    calculations.
    """
    x = normalize_composition(composition)
    if x.shape[-1] != len(self.names):
        raise ValueError("multifluid composition has the wrong number of components")
    if phase not in ("vapor", "liquid", "stable"):
        raise ValueError(f"unknown phase root {phase!r}")
    batch_shape = torch.broadcast_shapes(
        temperature.shape,
        pressure.shape,
        x.shape[:-1],
    )
    if batch_shape:
        temperature = torch.broadcast_to(temperature, batch_shape)
        pressure = torch.broadcast_to(pressure, batch_shape)
        x = torch.broadcast_to(x, (*batch_shape, len(self.names)))
        if phase in ("vapor", "liquid"):
            return self._batched_phase_volume(temperature, pressure, x, phase)
        flattened_temperature = temperature.reshape(-1)
        flattened_pressure = pressure.reshape(-1)
        flattened_composition = x.reshape(-1, len(self.names))
        return torch.stack(
            [
                self.molar_volume(current_temperature, current_pressure, current_x, phase)
                for current_temperature, current_pressure, current_x in zip(
                    flattened_temperature,
                    flattened_pressure,
                    flattened_composition,
                    strict=True,
                )
            ]
        ).reshape(batch_shape)

    ideal_density = pressure / (self.gas_constant * temperature)
    density_scale = torch.sum(x * self.critical_density)
    reducing_temperature, _ = self.reducing_functions(x)

    def residual(current: Tensor) -> Tensor:
        volume = torch.exp(-current)
        return (self.pressure(temperature, volume, x) - pressure) / pressure

    needs_implicit_gradient = (
        temperature.requires_grad
        or pressure.requires_grad
        or x.requires_grad
        or any(parameter.requires_grad for parameter in self.parameters())
    )

    def volume_with_implicit_gradient(current: Tensor) -> Tensor:
        """Restore root derivatives after detached numerical iteration."""
        if needs_implicit_gradient:
            value = residual(current)
            derivative: Tensor = torch.func.grad(residual)(current)
            current = current - value / derivative
        return torch.exp(-current)

    # Locate a phase-specific root with detached finite-difference Newton
    # steps. Differentiating every iteration nests reverse-mode AD through
    # the residual Helmholtz pressure derivative and makes saturation
    # calculations unnecessarily expensive. One exact correction at the
    # converged root restores the implicit state and parameter gradients.
    # The scan below remains the conservative fallback near spinodals or
    # when the conventional seed lies in the basin of another root.
    if phase != "stable":
        density = (
            ideal_density
            if phase == "vapor"
            else torch.maximum(ideal_density, 3.0 * density_scale)
        )
        log_density = torch.log(density)
        slope_offset = log_density.new_tensor(1.0e-4)
        for _ in range(20):
            value = residual(log_density)
            if float(value.detach().abs()) <= 1.0e-10:
                solved_density = torch.exp(log_density)
                phase_consistent = (
                    solved_density <= density_scale
                    if phase == "vapor"
                    else solved_density >= density_scale
                )
                if bool((temperature >= reducing_temperature) | phase_consistent):
                    return volume_with_implicit_gradient(log_density)
                break
            derivative = (
                residual(log_density + slope_offset) - residual(log_density - slope_offset)
            ) / (2.0 * slope_offset)
            step = torch.clamp(-value / derivative, -0.5, 0.5)
            if not bool(torch.isfinite(step)):
                break
            log_density = (log_density + step).detach()

    minimum = torch.minimum(ideal_density * 1.0e-4, density_scale * 1.0e-8)
    maximum = torch.maximum(
        ideal_density * 10.0,
        100.0 * torch.max(self.critical_density),
    )
    grid = torch.logspace(
        float(torch.log10(minimum.detach())),
        float(torch.log10(maximum.detach())),
        96,
        dtype=temperature.dtype,
        device=temperature.device,
    )

    brackets: list[tuple[Tensor, Tensor]] = []
    left = torch.log(grid[0])
    left_value = residual(left)
    for density in grid[1:]:
        right = torch.log(density)
        right_value = residual(right)
        finite = bool(torch.isfinite(left_value) & torch.isfinite(right_value))
        changes_sign = bool(torch.signbit(left_value) != torch.signbit(right_value))
        if finite and (
            float(left_value.detach()) == 0.0
            or float(right_value.detach()) == 0.0
            or changes_sign
        ):
            brackets.append((left, right))
        left, left_value = right, right_value
    if not brackets:
        raise ConvergenceError("multifluid density solve did not converge")

    roots: list[Tensor] = []
    for left, right in brackets:
        left_value = residual(left)
        midpoint = 0.5 * (left + right)
        for _ in range(80):
            midpoint = 0.5 * (left + right)
            midpoint_value = residual(midpoint)
            if float(midpoint_value.detach().abs()) <= 1.0e-12:
                break
            if bool(torch.signbit(left_value) != torch.signbit(midpoint_value)):
                right = midpoint
            else:
                left = midpoint
                left_value = midpoint_value

        root = torch.exp(midpoint)
        if not roots or abs(float((root / roots[-1] - 1.0).detach())) > 1.0e-8:
            roots.append(root)

    if phase == "vapor":
        density = roots[0]
    elif phase == "liquid":
        density = roots[-1]
    elif phase == "stable":
        gibbs: list[Tensor] = []
        for density_root in roots:
            volume = density_root.reciprocal()
            z = pressure * volume / (self.gas_constant * temperature)
            residual_helmholtz = self.residual_helmholtz_rt(temperature, volume, x)
            gibbs.append(residual_helmholtz + z - 1.0 - torch.log(z))
        density = roots[int(torch.argmin(torch.stack(gibbs)).detach())]
    return volume_with_implicit_gradient(torch.log(density))

select_z

select_z(temperature, pressure, composition, phase='stable')

Return compressibility factor.

Source code in src/torch_flash/eos/multifluid.py
def select_z(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return compressibility factor."""
    volume = self.molar_volume(temperature, pressure, composition, phase)
    return pressure * volume / (self.gas_constant * temperature)

log_fugacity_coefficients

log_fugacity_coefficients(temperature, pressure, composition, phase='stable')

Return log fugacity coefficients from composition derivatives.

Source code in src/torch_flash/eos/multifluid.py
def log_fugacity_coefficients(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return log fugacity coefficients from composition derivatives."""
    x = normalize_composition(composition)
    volume = self.molar_volume(temperature, pressure, x, phase)
    residual_mu: Tensor = torch.func.grad(
        lambda moles: self.residual_helmholtz_rt(
            temperature,
            volume,
            moles,
        ).sum()
    )(x)
    z = pressure * volume / (self.gas_constant * temperature)
    return residual_mu - torch.log(z)[..., None]

MultifluidMetadata dataclass

Identity and validation scope of one coefficient set.

Source code in src/torch_flash/eos/multifluid.py
@dataclass(frozen=True)
class MultifluidMetadata:
    """Identity and validation scope of one coefficient set."""

    model: str
    reference: str
    version: str
    validated_components: tuple[str, ...]

NonAnalyticTerms dataclass

Span-Wagner non-analytic critical-region residual terms.

Source code in src/torch_flash/eos/multifluid.py
@dataclass(frozen=True)
class NonAnalyticTerms:
    """Span-Wagner non-analytic critical-region residual terms."""

    n: Tensor
    capital_a: Tensor
    capital_b: Tensor
    capital_c: Tensor
    capital_d: Tensor
    a: Tensor
    b: Tensor
    beta: Tensor

    def __post_init__(self) -> None:
        arrays = (
            self.capital_a,
            self.capital_b,
            self.capital_c,
            self.capital_d,
            self.a,
            self.b,
            self.beta,
        )
        if not all(value.shape == self.n.shape for value in arrays):
            raise ValueError("all non-analytic term arrays must have equal shape")

VolumeTranslation dataclass

Component translations in the additive torch-flash convention.

reference_shift is in m3/mol, temperature_slope in m3/(mol K), and reference_temperature in K. A published positive Péneloux c therefore appears as a negative reference_shift.

Source code in src/torch_flash/eos/volume_translation.py
@dataclass(frozen=True)
class VolumeTranslation:
    """Component translations in the additive torch-flash convention.

    ``reference_shift`` is in m3/mol, ``temperature_slope`` in
    m3/(mol K), and ``reference_temperature`` in K.  A published positive
    Péneloux ``c`` therefore appears as a negative ``reference_shift``.
    """

    reference_shift: Tensor
    temperature_slope: Tensor
    reference_temperature: float = 288.15
    source: str = "custom"

    def __post_init__(self) -> None:
        if self.reference_shift.ndim != 1:
            raise ValueError("reference_shift must be a one-dimensional component vector")
        if self.temperature_slope.shape != self.reference_shift.shape:
            raise ValueError("temperature_slope must match reference_shift")
        if not bool(
            torch.isfinite(self.reference_shift).all()
            & torch.isfinite(self.temperature_slope).all()
        ):
            raise ValueError("volume-translation coefficients must be finite")
        if not torch.is_floating_point(self.reference_shift):
            raise TypeError("volume-translation coefficients must use a floating dtype")
        if (
            self.temperature_slope.dtype != self.reference_shift.dtype
            or self.temperature_slope.device != self.reference_shift.device
        ):
            raise ValueError("volume-translation tensors must share dtype and device")
        if not torch.isfinite(torch.tensor(self.reference_temperature)):
            raise ValueError("reference_temperature must be finite")
        if self.reference_temperature <= 0.0:
            raise ValueError("reference_temperature must be positive")

    @classmethod
    def constant(cls, shift: Tensor, *, source: str = "custom") -> VolumeTranslation:
        """Construct a temperature-independent additive translation."""
        return cls(shift, torch.zeros_like(shift), source=source)

    def at_temperature(self, temperature: Tensor) -> Tensor:
        """Return each component's additive shift at ``temperature``."""
        return (
            self.reference_shift
            + (temperature[..., None] - self.reference_temperature) * self.temperature_slope
        )

    def to(
        self,
        *,
        dtype: torch.dtype | None = None,
        device: torch.device | str | None = None,
    ) -> VolumeTranslation:
        """Move translation coefficients to a common dtype and device."""
        return VolumeTranslation(
            self.reference_shift.to(dtype=dtype, device=device),
            self.temperature_slope.to(dtype=dtype, device=device),
            self.reference_temperature,
            self.source,
        )

constant classmethod

constant(shift, *, source='custom')

Construct a temperature-independent additive translation.

Source code in src/torch_flash/eos/volume_translation.py
@classmethod
def constant(cls, shift: Tensor, *, source: str = "custom") -> VolumeTranslation:
    """Construct a temperature-independent additive translation."""
    return cls(shift, torch.zeros_like(shift), source=source)

at_temperature

at_temperature(temperature)

Return each component's additive shift at temperature.

Source code in src/torch_flash/eos/volume_translation.py
def at_temperature(self, temperature: Tensor) -> Tensor:
    """Return each component's additive shift at ``temperature``."""
    return (
        self.reference_shift
        + (temperature[..., None] - self.reference_temperature) * self.temperature_slope
    )

to

to(*, dtype=None, device=None)

Move translation coefficients to a common dtype and device.

Source code in src/torch_flash/eos/volume_translation.py
def to(
    self,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> VolumeTranslation:
    """Move translation coefficients to a common dtype and device."""
    return VolumeTranslation(
        self.reference_shift.to(dtype=dtype, device=device),
        self.temperature_slope.to(dtype=dtype, device=device),
        self.reference_temperature,
        self.source,
    )

cpa_eos

cpa_eos(names, parameter_set='cpa.folas-2005', *, kij=None, kij_a=None, kij_b=None, lij=None, cross_association_energy=None, cross_association_volume=None, combining_rule=None, trainable=False, trainable_lij=False, association_iterations=10, association_newton_iterations=8, dtype=None, device=None)

Construct CPA from a bundled/custom YAML set or use :class:CPAEOS.

Direct construction with a tuple of :class:CPAComponent remains the explicit in-memory API for fitting new species or parameterizations. lij optionally modifies the physical SRK co-volume rule; trainable_lij controls it independently of the attraction parameters.

Source code in src/torch_flash/eos/cpa.py
def cpa_eos(
    names: tuple[str, ...],
    parameter_set: ParameterSource = "cpa.folas-2005",
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    cross_association_energy: Tensor | None = None,
    cross_association_volume: Tensor | None = None,
    combining_rule: CombiningRule | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    association_iterations: int = 10,
    association_newton_iterations: int = 8,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> CPAEOS:
    """Construct CPA from a bundled/custom YAML set or use :class:`CPAEOS`.

    Direct construction with a tuple of :class:`CPAComponent` remains the
    explicit in-memory API for fitting new species or parameterizations.
    ``lij`` optionally modifies the physical SRK co-volume rule;
    ``trainable_lij`` controls it independently of the attraction parameters.
    """
    supplied_tensors = tuple(
        value
        for value in (
            kij,
            kij_a,
            kij_b,
            lij,
            cross_association_energy,
            cross_association_volume,
        )
        if value is not None
    )
    runtime_dtype, runtime_device = resolve_tensor_options(dtype, device)
    dtype = supplied_tensors[0].dtype if dtype is None and supplied_tensors else runtime_dtype
    device = supplied_tensors[0].device if device is None and supplied_tensors else runtime_device
    selected = tuple(canonical_component_name(name) for name in names)
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "cpa":
        raise ParameterDatabaseError(f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'cpa'")
    records = loaded.parameters.get("components")
    if not isinstance(records, Mapping):
        raise ParameterDatabaseError(f"{loaded.identifier!r} requires a components mapping")
    parameters: list[CPAComponent] = []
    for name in selected:
        record = records.get(name)
        if not isinstance(record, Mapping):
            raise KeyError(f"{loaded.identifier} CPA parameters are unavailable for {name!r}")
        parameters.append(_cpa_component(name, record))
    default_rule = loaded.parameters.get("default_combining_rule", "ECR")
    resolved_rule = default_rule if combining_rule is None else combining_rule
    if resolved_rule not in ("CR1", "ECR"):
        raise ParameterDatabaseError(f"unsupported CPA combining rule {resolved_rule!r}")
    if kij is None and kij_a is None and kij_b is None:
        kij, kij_a, kij_b = _database_binary_interactions(
            loaded.parameters.get("binary_interactions"),
            selected,
            loaded.identifier,
            dtype=dtype,
            device=device,
        )
    if cross_association_energy is None and cross_association_volume is None:
        cross_association_energy, cross_association_volume = _database_cross_association(
            loaded.parameters.get("cross_association"),
            selected,
            loaded.identifier,
            dtype=dtype,
            device=device,
        )
    return CPAEOS(
        tuple(parameters),
        kij=kij,
        kij_a=kij_a,
        kij_b=kij_b,
        lij=lij,
        cross_association_energy=cross_association_energy,
        cross_association_volume=cross_association_volume,
        combining_rule=resolved_rule,
        trainable=trainable,
        trainable_lij=trainable_lij,
        association_iterations=association_iterations,
        association_newton_iterations=association_newton_iterations,
        dtype=dtype,
        device=device,
    )

cpa_folas_2005

cpa_folas_2005(names, *, kij=None, lij=None, combining_rule='ECR', trainable=False, trainable_lij=False, dtype=None, device=None)

Construct CPA with the Folas et al. (2005), Table 1, pure parameters.

Reference: doi:10.1021/ie048832j.

Source code in src/torch_flash/eos/cpa.py
def cpa_folas_2005(
    names: tuple[str, ...],
    *,
    kij: Tensor | None = None,
    lij: Tensor | None = None,
    combining_rule: CombiningRule = "ECR",
    trainable: bool = False,
    trainable_lij: bool = False,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> CPAEOS:
    """Construct CPA with the Folas et al. (2005), Table 1, pure parameters.

    Reference: doi:10.1021/ie048832j.
    """
    return cpa_eos(
        names,
        "cpa.folas-2005",
        kij=kij,
        lij=lij,
        combining_rule=combining_rule,
        trainable=trainable,
        trainable_lij=trainable_lij,
        dtype=dtype,
        device=device,
    )

cpa_oliveira_2007

cpa_oliveira_2007(names, *, kij=None, lij=None, cross_association_energy=None, cross_association_volume=None, trainable=False, trainable_lij=False, dtype=None, device=None)

Construct the hydrocarbon-water CPA parameterization of Oliveira et al.

Pure parameters and constant physical-term interactions are from Tables 1-3 of Oliveira, Coutinho, and Queimada, Fluid Phase Equilibria 258 (2007) 58-66, doi:10.1016/j.fluid.2007.05.023. Aromatic-water solvation uses the paper's modified CR-1 cross-association parameters.

Source code in src/torch_flash/eos/cpa.py
def cpa_oliveira_2007(
    names: tuple[str, ...],
    *,
    kij: Tensor | None = None,
    lij: Tensor | None = None,
    cross_association_energy: Tensor | None = None,
    cross_association_volume: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> CPAEOS:
    """Construct the hydrocarbon-water CPA parameterization of Oliveira et al.

    Pure parameters and constant physical-term interactions are from Tables
    1-3 of Oliveira, Coutinho, and Queimada, Fluid Phase Equilibria 258
    (2007) 58-66, doi:10.1016/j.fluid.2007.05.023. Aromatic-water solvation
    uses the paper's modified CR-1 cross-association parameters.
    """
    return cpa_eos(
        names,
        "cpa.oliveira-2007-hydrocarbon-water",
        kij=kij,
        lij=lij,
        cross_association_energy=cross_association_energy,
        cross_association_volume=cross_association_volume,
        combining_rule="CR1",
        trainable=trainable,
        trainable_lij=trainable_lij,
        dtype=dtype,
        device=device,
    )

cpa_yan_2009

cpa_yan_2009(names, *, kij=None, kij_a=None, kij_b=None, lij=None, trainable=False, trainable_lij=False, dtype=None, device=None)

Construct reservoir-fluid CPA with Yan et al.'s A + B/T water BIPs.

Reference: Yan, Kontogeorgis, and Stenby, Fluid Phase Equilibria 276 (2009) 75-85, doi:10.1016/j.fluid.2008.10.007.

Source code in src/torch_flash/eos/cpa.py
def cpa_yan_2009(
    names: tuple[str, ...],
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> CPAEOS:
    """Construct reservoir-fluid CPA with Yan et al.'s ``A + B/T`` water BIPs.

    Reference: Yan, Kontogeorgis, and Stenby, Fluid Phase Equilibria 276
    (2009) 75-85, doi:10.1016/j.fluid.2008.10.007.
    """
    return cpa_eos(
        names,
        "cpa.yan-2009-reservoir-fluids",
        kij=kij,
        kij_a=kij_a,
        kij_b=kij_b,
        lij=lij,
        trainable=trainable,
        trainable_lij=trainable_lij,
        dtype=dtype,
        device=device,
    )

cpa_components_from_cuts

cpa_components_from_cuts(cuts, *, parameter_set='cpa.yan-2009-reservoir-fluids', dtype=None, device=None)

Characterize analyzed C7+ cuts and normalize their internal fractions.

This function covers the EoS-parameter step of a plus-fraction workflow. It deliberately does not invent a carbon-number distribution when only a bulk C7+ molecular weight is known; splitting or lumping must be performed upstream with measured cuts or an explicitly selected Whitson/Pedersen distribution model.

Source code in src/torch_flash/eos/cpa_heavy_end.py
def cpa_components_from_cuts(
    cuts: tuple[PseudoComponentCut, ...],
    *,
    parameter_set: ParameterSource = "cpa.yan-2009-reservoir-fluids",
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
) -> CPACharacterizedComponents:
    """Characterize analyzed C7+ cuts and normalize their internal fractions.

    This function covers the EoS-parameter step of a plus-fraction workflow.
    It deliberately does not invent a carbon-number distribution when only a
    bulk C7+ molecular weight is known; splitting or lumping must be performed
    upstream with measured cuts or an explicitly selected Whitson/Pedersen
    distribution model.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    if not cuts:
        raise ValueError("at least one pseudo-component cut is required")
    if any(not isfinite(cut.mole_fraction) or cut.mole_fraction < 0.0 for cut in cuts):
        raise ValueError("pseudo-component cut mole fractions must be finite and nonnegative")
    fractions = torch.tensor(
        [cut.mole_fraction for cut in cuts],
        dtype=dtype,
        device=device,
    )
    if not bool(fractions.sum() > 0.0):
        raise ValueError("pseudo-component cut mole fractions must have a positive sum")
    properties = tuple(
        cpa_monomer_properties(
            cut.normal_boiling_temperature,
            cut.specific_gravity,
            parameter_set,
        )
        for cut in cuts
    )
    components = tuple(
        CPAComponent(
            name=cut.name,
            critical_temperature=item.critical_temperature,
            a0=item.a0,
            b=item.b,
            c1=item.m,
            critical_pressure=item.critical_pressure,
            acentric_factor=item.acentric_factor,
            molar_mass=cut.molar_mass,
        )
        for cut, item in zip(cuts, properties, strict=True)
    )
    return CPACharacterizedComponents(
        components,
        fractions / fractions.sum(),
        properties,
    )

cpa_heavy_end_correlations

cpa_heavy_end_correlations(parameter_set='cpa.yan-2009-reservoir-fluids')

Load and validate CPA heavy-end correlation coefficients.

Source code in src/torch_flash/eos/cpa_heavy_end.py
def cpa_heavy_end_correlations(
    parameter_set: ParameterSource = "cpa.yan-2009-reservoir-fluids",
) -> CPAHeavyEndCorrelations:
    """Load and validate CPA heavy-end correlation coefficients."""
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "cpa":
        raise ParameterDatabaseError(f"{loaded.identifier!r} is not a CPA parameter set")
    record = loaded.parameters.get("heavy_end_correlations")
    if not isinstance(record, Mapping):
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} requires a heavy_end_correlations mapping"
        )

    def positive(key: str) -> float:
        value = record.get(key)
        if not isinstance(value, int | float) or not isfinite(value) or value <= 0.0:
            raise ParameterDatabaseError(
                f"{loaded.identifier!r} heavy_end_correlations {key!r} must be finite and positive"
            )
        return float(value)

    temperature_ratio = record.get("critical_temperature_ratio")
    if not isinstance(temperature_ratio, Mapping):
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} requires critical_temperature_ratio coefficients"
        )
    return CPAHeavyEndCorrelations(
        positive("normal_boiling_pressure"),
        positive("srk_omega_a"),
        positive("srk_omega_b"),
        _numeric_tuple(record.get("srk_m"), 3, "srk_m", loaded.identifier),  # type: ignore[arg-type]
        _numeric_tuple(
            record.get("normal_alkane_temperature"),
            3,
            "normal_alkane_temperature",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            record.get("log_normal_alkane_pressure_bar"),
            5,
            "log_normal_alkane_pressure_bar",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            record.get("normal_alkane_acentric"),
            3,
            "normal_alkane_acentric",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            record.get("normal_alkane_specific_gravity"),
            5,
            "normal_alkane_specific_gravity",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            temperature_ratio.get("numerator"),
            3,
            "critical_temperature_ratio.numerator",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            temperature_ratio.get("denominator"),
            3,
            "critical_temperature_ratio.denominator",
            loaded.identifier,
        ),  # type: ignore[arg-type]
        _numeric_tuple(
            record.get("log_critical_pressure_ratio"),
            5,
            "log_critical_pressure_ratio",
            loaded.identifier,
        ),  # type: ignore[arg-type]
    )

cpa_monomer_properties

cpa_monomer_properties(normal_boiling_temperature, specific_gravity, parameter_set='cpa.yan-2009-reservoir-fluids')

Calculate CPA monomer properties for one narrow heavy-end cut.

Parameters:

Name Type Description Default
normal_boiling_temperature float

Atmospheric normal boiling temperature in K.

required
specific_gravity float

Dimensionless liquid specific gravity used by the source characterization.

required
Source code in src/torch_flash/eos/cpa_heavy_end.py
def cpa_monomer_properties(
    normal_boiling_temperature: float,
    specific_gravity: float,
    parameter_set: ParameterSource = "cpa.yan-2009-reservoir-fluids",
) -> CPAMonomerProperties:
    """Calculate CPA monomer properties for one narrow heavy-end cut.

    Parameters
    ----------
    normal_boiling_temperature
        Atmospheric normal boiling temperature in K.
    specific_gravity
        Dimensionless liquid specific gravity used by the source
        characterization.
    """
    tb = float(normal_boiling_temperature)
    sg = float(specific_gravity)
    if not isfinite(tb) or not isfinite(sg) or tb <= 0.0 or sg <= 0.0:
        raise ValueError("boiling temperature and specific gravity must be finite and positive")

    correlations = cpa_heavy_end_correlations(parameter_set)
    temperature_constant, temperature_slope, temperature_denominator = (
        correlations.normal_alkane_temperature
    )
    normal_temperature = (
        (temperature_constant + temperature_slope * tb) * tb / (temperature_denominator + tb)
    )
    p4, p3, p2, p1, p0 = correlations.log_normal_alkane_pressure_bar
    log_normal_pressure_bar = p4 * tb**4 + p3 * tb**3 + p2 * tb**2 + p1 * tb + p0
    normal_pressure = 1.0e5 * exp(log_normal_pressure_bar)
    sg_scale, sg_constant, sg_linear, sg_inverse, sg_inverse_square = (
        correlations.normal_alkane_specific_gravity
    )
    normal_specific_gravity = (sg_scale * tb) ** (1.0 / 3.0) / (
        sg_constant + sg_linear * tb + sg_inverse / tb + sg_inverse_square / tb**2
    )
    perturbation = sg - normal_specific_gravity
    tn1, tn2, tn3 = correlations.critical_temperature_numerator
    td1, td2, td3 = correlations.critical_temperature_denominator
    temperature_ratio = (
        1.0 + tn1 * perturbation + tn2 * perturbation**2 + tn3 * perturbation**3
    ) / (1.0 + td1 * perturbation + td2 * perturbation**2 + td3 * perturbation**3)
    critical_temperature = normal_temperature * temperature_ratio
    pr0, pr1, pr2, pr3, pr4 = correlations.log_critical_pressure_ratio
    log_pressure_ratio = (
        perturbation
        * (pr0 + (pr1 + pr2 / sg) * perturbation)
        / (1.0 + pr3 * perturbation + pr4 * perturbation**2)
    )
    critical_pressure = normal_pressure * exp(log_pressure_ratio)
    if (
        not isfinite(critical_temperature)
        or not isfinite(critical_pressure)
        or critical_temperature <= 0.0
        or critical_pressure <= 0.0
    ):
        raise ValueError("Yan CPA characterization is outside its physical correlation domain")

    if tb < critical_temperature:
        acentric_factor = _match_normal_boiling_point(
            tb,
            critical_temperature,
            critical_pressure,
            correlations,
        )
        used_match = True
    else:
        omega_constant, omega_slope, omega_denominator = correlations.normal_alkane_acentric
        acentric_factor = exp((omega_constant + omega_slope * tb) / (omega_denominator + tb))
        used_match = False
    return CPAMonomerProperties(
        tb,
        sg,
        normal_specific_gravity,
        perturbation,
        normal_temperature,
        normal_pressure,
        critical_temperature,
        critical_pressure,
        acentric_factor,
        used_match,
        correlations,
    )

cpa_pseudocomponent

cpa_pseudocomponent(name, normal_boiling_temperature, specific_gravity, molar_mass, parameter_set='cpa.yan-2009-reservoir-fluids')

Create a non-associating CPA pseudo-component for a heavy-end cut.

Source code in src/torch_flash/eos/cpa_heavy_end.py
def cpa_pseudocomponent(
    name: str,
    normal_boiling_temperature: float,
    specific_gravity: float,
    molar_mass: float,
    parameter_set: ParameterSource = "cpa.yan-2009-reservoir-fluids",
) -> CPAComponent:
    """Create a non-associating CPA pseudo-component for a heavy-end cut."""
    if not isinstance(name, str) or not name.strip():
        raise ValueError("pseudo-component name must be a non-empty string")
    mass = float(molar_mass)
    if not isfinite(mass) or mass <= 0.0:
        raise ValueError("pseudo-component molar mass must be finite and positive")
    properties = cpa_monomer_properties(
        normal_boiling_temperature,
        specific_gravity,
        parameter_set,
    )
    return CPAComponent(
        name=name,
        critical_temperature=properties.critical_temperature,
        a0=properties.a0,
        b=properties.b,
        c1=properties.m,
        critical_pressure=properties.critical_pressure,
        acentric_factor=properties.acentric_factor,
        molar_mass=mass,
    )

cubic_constants

cubic_constants(source)

Construct typed cubic constants from YAML or an explicit parameter set.

Source code in src/torch_flash/eos/cubic.py
def cubic_constants(source: ParameterSource) -> CubicConstants:
    """Construct typed cubic constants from YAML or an explicit parameter set."""
    parameter_set = load_model_parameters(source)
    if parameter_set.model_kind != "cubic":
        raise ParameterDatabaseError(
            f"{parameter_set.identifier!r} is {parameter_set.model_kind!r}, not 'cubic'"
        )
    parameters = parameter_set.parameters
    alpha = parameters.get("alpha")
    if not isinstance(alpha, Mapping):
        raise ParameterDatabaseError(f"{parameter_set.identifier!r} requires an alpha mapping")
    alpha_kind = alpha.get("kind")
    if alpha_kind not in ("srk", "pr76", "pr78"):
        raise ParameterDatabaseError(
            f"{parameter_set.identifier!r} has unsupported alpha kind {alpha_kind!r}"
        )

    def coefficients(key: str, size: int) -> tuple[float, ...]:
        block = alpha.get(key)
        if not isinstance(block, Mapping):
            raise ParameterDatabaseError(f"{parameter_set.identifier!r} requires alpha.{key}")
        values = block.get("coefficients")
        if not isinstance(values, tuple | list) or len(values) != size:
            raise ParameterDatabaseError(
                f"{parameter_set.identifier!r} alpha.{key}.coefficients must have length {size}"
            )
        if any(not isinstance(value, int | float) for value in values):
            raise ParameterDatabaseError("cubic alpha coefficients must be numeric")
        return tuple(float(value) for value in values)

    low = coefficients("low", 3)
    high = coefficients("high", 4) if alpha_kind == "pr78" else None
    switch = alpha.get("switch_acentric_factor")
    if alpha_kind == "pr78" and not isinstance(switch, int | float):
        raise ParameterDatabaseError(
            f"{parameter_set.identifier!r} requires alpha.switch_acentric_factor"
        )
    required = ("omega_a", "omega_b", "delta1", "delta2")
    if any(not isinstance(parameters.get(key), int | float) for key in required):
        raise ParameterDatabaseError(
            f"{parameter_set.identifier!r} cubic constants must be numeric"
        )
    return CubicConstants(
        parameter_set.model,
        float(parameters["omega_a"]),
        float(parameters["omega_b"]),
        float(parameters["delta1"]),
        float(parameters["delta2"]),
        alpha_kind,
        (low[0], low[1], low[2]),
        None if high is None else (high[0], high[1], high[2], high[3]),
        None if switch is None else float(switch),
        parameter_set.identifier,
    )

cubic_eos

cubic_eos(components, parameter_set, *, kij=None, kij_a=None, kij_b=None, lij=None, trainable=False, trainable_lij=False, mixing=None, volume_translation=None)

Construct a cubic EoS from bundled YAML or explicit typed constants.

Supply kij for a constant quadratic interaction matrix, or both kij_a and kij_b for kij(T) = kij_a + kij_b/T. The latter coefficients are dimensionless and kelvin, respectively. Supply lij for bij = 0.5*(bi+bj)*(1-lij); omitting it recovers the conventional linear covolume rule. trainable controls attraction interactions; trainable_lij independently enables co-volume fitting from the supplied matrix or from zero.

Source code in src/torch_flash/eos/cubic.py
def cubic_eos(
    components: ComponentSet,
    parameter_set: ParameterSource | CubicConstants,
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    mixing: CubicMixing | None = None,
    volume_translation: Tensor | VolumeTranslation | None = None,
) -> CubicEOS:
    """Construct a cubic EoS from bundled YAML or explicit typed constants.

    Supply ``kij`` for a constant quadratic interaction matrix, or both
    ``kij_a`` and ``kij_b`` for ``kij(T) = kij_a + kij_b/T``. The latter
    coefficients are dimensionless and kelvin, respectively. Supply ``lij``
    for ``bij = 0.5*(bi+bj)*(1-lij)``; omitting it recovers the conventional
    linear covolume rule. ``trainable`` controls attraction interactions;
    ``trainable_lij`` independently enables co-volume fitting from the
    supplied matrix or from zero.
    """
    constants = (
        parameter_set
        if isinstance(parameter_set, CubicConstants)
        else cubic_constants(parameter_set)
    )
    return CubicEOS(
        components,
        constants,
        mixing=_resolve_mixing(
            components,
            kij,
            kij_a,
            kij_b,
            lij,
            trainable,
            trainable_lij,
            mixing,
        ),
        volume_translation=volume_translation,
    )

peng_robinson_1976

peng_robinson_1976(components, *, kij=None, kij_a=None, kij_b=None, lij=None, trainable=False, trainable_lij=False, mixing=None, volume_translation=None)

Construct the original 1976 Peng-Robinson equation of state.

Source code in src/torch_flash/eos/cubic.py
def peng_robinson_1976(
    components: ComponentSet,
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    mixing: CubicMixing | None = None,
    volume_translation: Tensor | VolumeTranslation | None = None,
) -> CubicEOS:
    """Construct the original 1976 Peng-Robinson equation of state."""
    return cubic_eos(
        components,
        PR76,
        kij=kij,
        kij_a=kij_a,
        kij_b=kij_b,
        lij=lij,
        trainable=trainable,
        trainable_lij=trainable_lij,
        mixing=mixing,
        volume_translation=volume_translation,
    )

peng_robinson_1978

peng_robinson_1978(components, *, kij=None, kij_a=None, kij_b=None, lij=None, trainable=False, trainable_lij=False, mixing=None, volume_translation=None)

Construct the 1978 Peng-Robinson acentric-factor variant.

Source code in src/torch_flash/eos/cubic.py
def peng_robinson_1978(
    components: ComponentSet,
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    mixing: CubicMixing | None = None,
    volume_translation: Tensor | VolumeTranslation | None = None,
) -> CubicEOS:
    """Construct the 1978 Peng-Robinson acentric-factor variant."""
    return cubic_eos(
        components,
        PR78,
        kij=kij,
        kij_a=kij_a,
        kij_b=kij_b,
        lij=lij,
        trainable=trainable,
        trainable_lij=trainable_lij,
        mixing=mixing,
        volume_translation=volume_translation,
    )

predictive_peng_robinson_1978

predictive_peng_robinson_1978(components, *, parameter_set=DEFAULT_PPR78_GROUP_CONTRIBUTION, group_counts=None, trainable=False, volume_translation=None)

Construct PPR78 with group-contribution kij(T).

The default parameter set is the original six-group saturated-hydrocarbon fit of Jaubert and Mutelet (2004), doi:10.1016/j.fluid.2004.06.059. Custom YAML parameter sets and explicit component group counts use the same path. trainable=True exposes the unique universal A/B group interactions as PyTorch parameters.

PPR78 was derived with the linear covolume rule, so co-volume interactions are intentionally not accepted by this named constructor.

Source code in src/torch_flash/eos/cubic.py
def predictive_peng_robinson_1978(
    components: ComponentSet,
    *,
    parameter_set: ParameterSource = DEFAULT_PPR78_GROUP_CONTRIBUTION,
    group_counts: Mapping[str, Mapping[str, float]] | Tensor | None = None,
    trainable: bool = False,
    volume_translation: Tensor | VolumeTranslation | None = None,
) -> CubicEOS:
    """Construct PPR78 with group-contribution ``kij(T)``.

    The default parameter set is the original six-group saturated-hydrocarbon
    fit of Jaubert and Mutelet (2004), doi:10.1016/j.fluid.2004.06.059.
    Custom YAML parameter sets and explicit component group counts use the
    same path. ``trainable=True`` exposes the unique universal A/B group
    interactions as PyTorch parameters.

    PPR78 was derived with the linear covolume rule, so co-volume interactions
    are intentionally not accepted by this named constructor.
    """
    return CubicEOS(
        components,
        PR78,
        mixing=ppr78_mixing(
            components,
            parameter_set,
            group_counts=group_counts,
            trainable=trainable,
        ),
        volume_translation=volume_translation,
    )

soave_redlich_kwong

soave_redlich_kwong(components, *, kij=None, kij_a=None, kij_b=None, lij=None, trainable=False, trainable_lij=False, mixing=None, volume_translation=None)

Construct the 1972 Soave-Redlich-Kwong equation of state.

Source code in src/torch_flash/eos/cubic.py
def soave_redlich_kwong(
    components: ComponentSet,
    *,
    kij: Tensor | None = None,
    kij_a: Tensor | None = None,
    kij_b: Tensor | None = None,
    lij: Tensor | None = None,
    trainable: bool = False,
    trainable_lij: bool = False,
    mixing: CubicMixing | None = None,
    volume_translation: Tensor | VolumeTranslation | None = None,
) -> CubicEOS:
    """Construct the 1972 Soave-Redlich-Kwong equation of state."""
    return cubic_eos(
        components,
        SRK,
        kij=kij,
        kij_a=kij_a,
        kij_b=kij_b,
        lij=lij,
        trainable=trainable,
        trainable_lij=trainable_lij,
        mixing=mixing,
        volume_translation=volume_translation,
    )

eoscg2021

eoscg2021(names=None, *, dtype=None, device=None, trainable=False, parameter_set='multifluid.eos-cg-2021')

Construct the complete native 16-component EOS-CG-2021 Helmholtz model.

Reference: Neumann et al. (2023), doi:10.1007/s10765-023-03263-6, including its supplementary tables.

Source code in src/torch_flash/eos/named.py
def eoscg2021(
    names: tuple[str, ...] | None = None,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    trainable: bool = False,
    parameter_set: ParameterSource = "multifluid.eos-cg-2021",
) -> MultiFluidEOS:
    """Construct the complete native 16-component EOS-CG-2021 Helmholtz model.

    Reference: Neumann et al. (2023),
    doi:10.1007/s10765-023-03263-6, including its supplementary tables.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if not loaded.model.upper().startswith("EOS-CG-2021"):
        raise ParameterDatabaseError(
            f"eoscg2021 requires an EOS-CG-2021 parameter set, got {loaded.model!r}"
        )
    return _eoscg_eos(
        loaded,
        names,
        dtype=dtype,
        device=device,
        trainable=trainable,
    )

gerg2008

gerg2008(names=None, *, dtype=None, device=None, trainable=False, parameter_set='multifluid.gerg-2008')

Construct the complete native 21-component GERG-2008 Helmholtz model.

Reference: Kunz and Wagner (2012), doi:10.1021/je300655b.

Source code in src/torch_flash/eos/named.py
def gerg2008(
    names: tuple[str, ...] | None = None,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    trainable: bool = False,
    parameter_set: ParameterSource = "multifluid.gerg-2008",
) -> MultiFluidEOS:
    """Construct the complete native 21-component GERG-2008 Helmholtz model.

    Reference: Kunz and Wagner (2012), doi:10.1021/je300655b.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if not loaded.model.upper().startswith("GERG-2008"):
        raise ParameterDatabaseError(
            f"gerg2008 requires a GERG-2008 parameter set, got {loaded.model!r}"
        )
    return _gerg_eos(
        loaded,
        names,
        dtype=dtype,
        device=device,
        trainable=trainable,
    )

multifluid_eos

multifluid_eos(parameter_set, names=None, *, dtype=None, device=None, trainable=False)

Construct a native multifluid EoS from YAML or explicit parameters.

Source code in src/torch_flash/eos/named.py
def multifluid_eos(
    parameter_set: ParameterSource,
    names: tuple[str, ...] | None = None,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    trainable: bool = False,
) -> MultiFluidEOS:
    """Construct a native multifluid EoS from YAML or explicit parameters."""
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "multifluid":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'multifluid'"
        )
    normalized = loaded.model.strip().lower().replace("_", "-")
    if normalized.startswith("gerg"):
        return _gerg_eos(
            loaded,
            names,
            dtype=dtype,
            device=device,
            trainable=trainable,
        )
    if normalized.startswith("eos-cg") or normalized.startswith("eoscg"):
        return _eoscg_eos(
            loaded,
            names,
            dtype=dtype,
            device=device,
            trainable=trainable,
        )
    raise ParameterDatabaseError(
        f"{loaded.identifier!r} has unsupported multifluid model {loaded.model!r}"
    )

density_matched_translation

density_matched_translation(eos_molar_volume, molar_mass, mass_density, *, source='density-match')

Match a reference density with an additive component translation.

All inputs use SI units. The returned shift is M/rho - v_eos, while the equivalent published Péneloux coefficient is its negative.

Source code in src/torch_flash/eos/volume_translation.py
def density_matched_translation(
    eos_molar_volume: Tensor,
    molar_mass: Tensor,
    mass_density: Tensor,
    *,
    source: str = "density-match",
) -> VolumeTranslation:
    """Match a reference density with an additive component translation.

    All inputs use SI units.  The returned shift is
    ``M/rho - v_eos``, while the equivalent published Péneloux coefficient is
    its negative.
    """
    if eos_molar_volume.shape != molar_mass.shape or mass_density.shape != molar_mass.shape:
        raise ValueError("volume, molar mass, and density vectors must have the same shape")
    if not bool(
        torch.isfinite(eos_molar_volume).all()
        & torch.isfinite(molar_mass).all()
        & torch.isfinite(mass_density).all()
    ):
        raise ValueError("density-matching inputs must be finite")
    if bool(
        (eos_molar_volume <= 0.0).any() | (molar_mass <= 0.0).any() | (mass_density <= 0.0).any()
    ):
        raise ValueError("density-matching inputs must be positive")
    target_volume = molar_mass / mass_density
    return VolumeTranslation.constant(target_volume - eos_molar_volume, source=source)

pedersen_peneloux_translation

pedersen_peneloux_translation(components, eos, *, rackett_factor=None, source=_PEDERSEN_SOURCE)

Return the Pedersen light-component SRK/PR Péneloux translation.

SRK uses Pedersen Eq. 4.46 and PR uses the Jhaveri-Youngren Eq. 4.49. These correlations were fitted for nonhydrocarbons and hydrocarbons lighter than C7; heavier fractions should use density matching or a characterized parameter set.

Source code in src/torch_flash/eos/volume_translation.py
def pedersen_peneloux_translation(
    components: ComponentSet,
    eos: VolumeTranslationEOS,
    *,
    rackett_factor: Tensor | None = None,
    source: ParameterSource = _PEDERSEN_SOURCE,
) -> VolumeTranslation:
    """Return the Pedersen light-component SRK/PR Péneloux translation.

    SRK uses Pedersen Eq. 4.46 and PR uses the Jhaveri-Youngren Eq. 4.49.
    These correlations were fitted for nonhydrocarbons and hydrocarbons
    lighter than C7; heavier fractions should use density matching or a
    characterized parameter set.
    """
    parameters = _parameter_mapping(source, "Pedersen-Peneloux")
    correlations = _mapping(parameters, "correlations", str(source))
    correlation = _mapping(correlations, eos, str(source))
    z_ra = (
        rackett_compressibility_factor(components.acentric_factor, source=source)
        if rackett_factor is None
        else rackett_factor.to(
            dtype=components.critical_temperature.dtype,
            device=components.critical_temperature.device,
        )
    )
    if z_ra.shape != components.critical_temperature.shape:
        raise ValueError("rackett_factor must have one value per component")
    if not bool(torch.isfinite(z_ra).all()):
        raise ValueError("rackett_factor must be finite")
    scale = _number(correlation, "scale", str(source))
    target = _number(correlation, "target", str(source))
    published_c = (
        scale * R * components.critical_temperature / components.critical_pressure * (target - z_ra)
    )
    return VolumeTranslation.constant(
        -published_c,
        source=f"{load_model_parameters(source).identifier}:{eos}",
    )

pedersen_temperature_dependent_translation

pedersen_temperature_dependent_translation(model, reference_density, *, pressure=101325.0, source=_PEDERSEN_SOURCE)

Fit Pedersen's linear C7+ translation using Eqs. 5.7-5.9.

reference_density is in kg/m3 at 288.15 K by default. The ASTM correlation supplies the target density at 353.15 K, and the parent cubic EoS supplies the unshifted liquid volumes at both temperatures.

Source code in src/torch_flash/eos/volume_translation.py
def pedersen_temperature_dependent_translation(
    model: CubicEOS,
    reference_density: Tensor,
    *,
    pressure: float = 101_325.0,
    source: ParameterSource = _PEDERSEN_SOURCE,
) -> VolumeTranslation:
    """Fit Pedersen's linear C7+ translation using Eqs. 5.7-5.9.

    ``reference_density`` is in kg/m3 at 288.15 K by default.  The ASTM
    correlation supplies the target density at 353.15 K, and the parent cubic
    EoS supplies the unshifted liquid volumes at both temperatures.
    """
    parameters = _parameter_mapping(source, "Pedersen-Peneloux")
    temperature_parameters = _mapping(
        parameters,
        "temperature_dependent",
        str(source),
    )
    reference_temperature = _number(
        temperature_parameters,
        "reference_temperature",
        str(source),
    )
    target_temperature = _number(
        temperature_parameters,
        "target_temperature",
        str(source),
    )
    astm_constant = _number(
        temperature_parameters,
        "astm_density_constant",
        str(source),
    )
    nonlinear_factor = _number(
        temperature_parameters,
        "nonlinear_factor",
        str(source),
    )
    if not 0.0 < reference_temperature < target_temperature:
        raise ParameterDatabaseError(
            "temperature-dependent translation requires 0 < "
            "reference_temperature < target_temperature"
        )
    if astm_constant <= 0.0 or nonlinear_factor < 0.0:
        raise ParameterDatabaseError(
            "temperature-dependent translation requires a positive ASTM "
            "constant and non-negative nonlinear factor"
        )
    density = reference_density.to(
        dtype=model.critical_temperature.dtype,
        device=model.critical_temperature.device,
    )
    if density.shape != model.critical_temperature.shape:
        raise ValueError("reference_density must have one value per component")
    if not bool(torch.isfinite(density).all() & (density > 0.0).all()):
        raise ValueError("reference_density must be finite and positive")
    if not math.isfinite(pressure) or pressure <= 0.0:
        raise ValueError("pressure must be finite and positive")
    if bool(
        (model.volume_translation != 0.0).any() | (model.volume_translation_slope != 0.0).any()
    ):
        raise ValueError(
            "Pedersen temperature-dependent matching requires an untranslated parent EoS"
        )
    if bool((model.critical_temperature <= target_temperature).any()):
        raise InvalidStateError(
            "Pedersen temperature-dependent density matching requires "
            "target_temperature below every component critical temperature"
        )

    ncomponents = model.ncomponents
    identity = torch.eye(
        ncomponents,
        dtype=model.critical_temperature.dtype,
        device=model.critical_temperature.device,
    )
    pressure_vector = model.critical_temperature.new_full((ncomponents,), pressure)

    def parent_volume(temperature_value: float) -> Tensor:
        temperature_vector = model.critical_temperature.new_full(
            (ncomponents,),
            temperature_value,
        )
        z = model.select_z(
            temperature_vector,
            pressure_vector,
            identity,
            "liquid",
        )
        return z * R * temperature_vector / pressure_vector

    reference_parent_volume = parent_volume(reference_temperature)
    target_parent_volume = parent_volume(target_temperature)
    reference_shift = model.molar_mass / density - reference_parent_volume
    delta_temperature = target_temperature - reference_temperature
    expansion = astm_constant / density.square()
    target_density = density * torch.exp(
        -expansion * delta_temperature * (1.0 + nonlinear_factor * expansion * delta_temperature)
    )
    target_shift = model.molar_mass / target_density - target_parent_volume
    slope = (target_shift - reference_shift) / delta_temperature
    return VolumeTranslation(
        reference_shift,
        slope,
        reference_temperature,
        f"{load_model_parameters(source).identifier}:temperature-dependent",
    )

rackett_compressibility_factor

rackett_compressibility_factor(acentric_factor, *, source=_PEDERSEN_SOURCE)

Return Z_RA = 0.29056 - 0.08775 omega from Pedersen Eq. 4.47.

Source code in src/torch_flash/eos/volume_translation.py
def rackett_compressibility_factor(
    acentric_factor: Tensor,
    *,
    source: ParameterSource = _PEDERSEN_SOURCE,
) -> Tensor:
    """Return ``Z_RA = 0.29056 - 0.08775 omega`` from Pedersen Eq. 4.47."""
    parameters = _parameter_mapping(source, "Pedersen-Peneloux")
    rackett = _mapping(parameters, "rackett", str(source))
    intercept = _number(rackett, "intercept", str(source))
    slope = _number(rackett, "acentric_slope", str(source))
    return intercept + slope * acentric_factor

whitson_volume_translation

whitson_volume_translation(components, eos, *, heavy_families=None, source=_WHITSON_SOURCE)

Return Whitson Tables 4.2-4.3 translations.

Named light components and normal paraffins through n-decane use the tabulated s_i = c_i/b_i values. Any other component requires a family entry and uses s_i = 1 - A0/M_i**A1 with M in g/mol.

Source code in src/torch_flash/eos/volume_translation.py
def whitson_volume_translation(
    components: ComponentSet,
    eos: VolumeTranslationEOS,
    *,
    heavy_families: Mapping[str, HydrocarbonFamily] | None = None,
    source: ParameterSource = _WHITSON_SOURCE,
) -> VolumeTranslation:
    """Return Whitson Tables 4.2-4.3 translations.

    Named light components and normal paraffins through n-decane use the
    tabulated ``s_i = c_i/b_i`` values.  Any other component requires a family
    entry and uses ``s_i = 1 - A0/M_i**A1`` with ``M`` in g/mol.
    """
    parameters = _parameter_mapping(source, "Whitson-Peneloux")
    eos_parameters = _mapping(_mapping(parameters, "eos", str(source)), eos, str(source))
    factors = _mapping(eos_parameters, "pure_shift_factors", str(source))
    family_parameters = _mapping(parameters, "heavy_families", str(source))
    supplied_families = {} if heavy_families is None else dict(heavy_families)
    shift_factors: list[Tensor] = []
    for index, name in enumerate(components.names):
        tabulated = factors.get(name)
        if isinstance(tabulated, int | float):
            shift_factors.append(components.molar_mass.new_tensor(float(tabulated)))
            continue
        family = supplied_families.get(name)
        if family is None:
            raise ValueError(
                f"Whitson has no tabulated shift factor for {name!r}; provide heavy_families[name]"
            )
        family_values = _mapping(family_parameters, family, str(source))
        a0 = _number(family_values, "A0", str(source))
        a1 = _number(family_values, "A1", str(source))
        molar_mass_g = 1000.0 * components.molar_mass[index]
        shift_factors.append(1.0 - a0 / molar_mass_g.pow(a1))
    factor = torch.stack(shift_factors)
    omega_b = _number(eos_parameters, "covolume_factor", str(source))
    covolume = omega_b * R * components.critical_temperature / components.critical_pressure
    published_c = factor * covolume
    return VolumeTranslation.constant(
        -published_c,
        source=f"{load_model_parameters(source).identifier}:{eos}",
    )

torch_flash.activity

Excess-Gibbs-energy activity models.

NRTL

Bases: Module

Non-random two-liquid excess Gibbs energy model.

interaction stores :math:g_{ij}-g_{jj} in J/mol and may be made trainable. nonrandomness is the dimensionless :math:alpha_{ij}. See Renon and Prausnitz, AIChE Journal 14 (1968), 135-144, doi:10.1002/aic.690140124.

Source code in src/torch_flash/activity/models.py
class NRTL(nn.Module):
    """Non-random two-liquid excess Gibbs energy model.

    ``interaction`` stores :math:`g_{ij}-g_{jj}` in J/mol and may be made
    trainable. ``nonrandomness`` is the dimensionless :math:`alpha_{ij}`.
    See Renon and Prausnitz, *AIChE Journal* 14 (1968), 135-144,
    doi:10.1002/aic.690140124.
    """

    interaction: Tensor
    nonrandomness: Tensor

    def __init__(
        self,
        interaction: Tensor,
        nonrandomness: Tensor,
        *,
        trainable: bool = False,
    ) -> None:
        super().__init__()
        if interaction.shape != nonrandomness.shape or interaction.ndim != 2:
            raise ValueError("NRTL parameter arrays must be equally sized matrices")
        value = interaction.clone()
        if trainable:
            self.interaction = nn.Parameter(value)
        else:
            self.register_buffer("interaction", value)
        self.register_buffer("nonrandomness", nonrandomness.clone())

    def excess_gibbs_rt(self, temperature: Tensor, composition: Tensor) -> Tensor:
        """Return dimensionless molar excess Gibbs energy, ``gE/(RT)``."""
        x = normalize_composition(composition)
        tau = self.interaction / (R * temperature[..., None, None])
        weights = torch.exp(-self.nonrandomness * tau)
        denominator = torch.einsum("...k,...ki->...i", x, weights)
        numerator = torch.einsum("...j,...ji,...ji->...i", x, tau, weights)
        return torch.sum(x * numerator / denominator, dim=-1)

    def log_activity_coefficients(self, temperature: Tensor, composition: Tensor) -> Tensor:
        """Return ``log(gamma)`` from an autodifferentiated extensive ``gE``."""
        x = normalize_composition(composition)

        def extensive(moles: Tensor) -> Tensor:
            total = moles.sum(dim=-1)
            fractions = moles / total[..., None]
            return (total * self.excess_gibbs_rt(temperature, fractions)).sum()

        result: Tensor = torch.func.grad(extensive)(x)
        return result

excess_gibbs_rt

excess_gibbs_rt(temperature, composition)

Return dimensionless molar excess Gibbs energy, gE/(RT).

Source code in src/torch_flash/activity/models.py
def excess_gibbs_rt(self, temperature: Tensor, composition: Tensor) -> Tensor:
    """Return dimensionless molar excess Gibbs energy, ``gE/(RT)``."""
    x = normalize_composition(composition)
    tau = self.interaction / (R * temperature[..., None, None])
    weights = torch.exp(-self.nonrandomness * tau)
    denominator = torch.einsum("...k,...ki->...i", x, weights)
    numerator = torch.einsum("...j,...ji,...ji->...i", x, tau, weights)
    return torch.sum(x * numerator / denominator, dim=-1)

log_activity_coefficients

log_activity_coefficients(temperature, composition)

Return log(gamma) from an autodifferentiated extensive gE.

Source code in src/torch_flash/activity/models.py
def log_activity_coefficients(self, temperature: Tensor, composition: Tensor) -> Tensor:
    """Return ``log(gamma)`` from an autodifferentiated extensive ``gE``."""
    x = normalize_composition(composition)

    def extensive(moles: Tensor) -> Tensor:
        total = moles.sum(dim=-1)
        fractions = moles / total[..., None]
        return (total * self.excess_gibbs_rt(temperature, fractions)).sum()

    result: Tensor = torch.func.grad(extensive)(x)
    return result

AnchoredHuronVidalNRTL

Bases: Module

Fit-ready HV-NRTL model anchored at two temperatures.

Directly regressing :math:A_{ij} and :math:B_{ij} in :math:\tau_{ij}=A_{ij}/T+B_{ij} is poorly scaled and can be strongly correlated over a finite experimental temperature interval. This exactly equivalent parameterization stores trainable values of :math:\tau_{ij} at two user-selected temperatures and interpolates linearly in :math:1/T.

The optional trainable non-randomness matrix is kept symmetric and within explicit open bounds by a sigmoid transform. Diagonal interactions remain exactly zero. Call :meth:freeze after fitting to obtain the standard :class:HuronVidalNRTL representation used by parameter databases.

Source code in src/torch_flash/activity/models.py
class AnchoredHuronVidalNRTL(nn.Module):
    r"""Fit-ready HV-NRTL model anchored at two temperatures.

    Directly regressing :math:`A_{ij}` and :math:`B_{ij}` in
    :math:`\tau_{ij}=A_{ij}/T+B_{ij}` is poorly scaled and can be strongly
    correlated over a finite experimental temperature interval. This exactly
    equivalent parameterization stores trainable values of :math:`\tau_{ij}`
    at two user-selected temperatures and interpolates linearly in
    :math:`1/T`.

    The optional trainable non-randomness matrix is kept symmetric and within
    explicit open bounds by a sigmoid transform. Diagonal interactions remain
    exactly zero. Call :meth:`freeze` after fitting to obtain the standard
    :class:`HuronVidalNRTL` representation used by parameter databases.
    """

    raw_tau_at_lower_temperature: nn.Parameter
    raw_tau_at_upper_temperature: nn.Parameter
    raw_nonrandomness: nn.Parameter | None
    lower_temperature: Tensor
    upper_temperature: Tensor
    covolumes: Tensor
    _off_diagonal: Tensor
    _fixed_nonrandomness: Tensor

    def __init__(
        self,
        tau_at_lower_temperature: Tensor,
        tau_at_upper_temperature: Tensor,
        nonrandomness: Tensor,
        covolumes: Tensor,
        *,
        lower_temperature: Tensor | float,
        upper_temperature: Tensor | float,
        trainable_nonrandomness: bool = False,
        nonrandomness_bounds: tuple[float, float] = (0.05, 0.60),
    ) -> None:
        super().__init__()
        if (
            tau_at_lower_temperature.ndim != 2
            or tau_at_lower_temperature.shape[0] != tau_at_lower_temperature.shape[1]
            or tau_at_upper_temperature.shape != tau_at_lower_temperature.shape
            or nonrandomness.shape != tau_at_lower_temperature.shape
        ):
            raise ValueError("anchored HV-NRTL arrays must be equally sized square matrices")
        if covolumes.shape != (tau_at_lower_temperature.shape[0],):
            raise ValueError("one positive anchored HV-NRTL covolume is required per component")
        if bool((covolumes <= 0.0).any()):
            raise ValueError("anchored HV-NRTL covolumes must be positive")

        lower = torch.as_tensor(
            lower_temperature,
            dtype=tau_at_lower_temperature.dtype,
            device=tau_at_lower_temperature.device,
        )
        upper = torch.as_tensor(
            upper_temperature,
            dtype=tau_at_lower_temperature.dtype,
            device=tau_at_lower_temperature.device,
        )
        if (
            lower.ndim != 0
            or upper.ndim != 0
            or not bool(
                torch.isfinite(lower) & torch.isfinite(upper) & (lower > 0.0) & (upper > lower)
            )
        ):
            raise ValueError(
                "anchored HV-NRTL temperatures must be finite, positive, and increasing"
            )

        self.raw_tau_at_lower_temperature = nn.Parameter(tau_at_lower_temperature.clone())
        self.raw_tau_at_upper_temperature = nn.Parameter(tau_at_upper_temperature.clone())
        self.register_buffer("lower_temperature", lower.clone())
        self.register_buffer("upper_temperature", upper.clone())
        self.register_buffer("covolumes", covolumes.clone())
        size = tau_at_lower_temperature.shape[0]
        mask = torch.ones(
            (size, size),
            dtype=tau_at_lower_temperature.dtype,
            device=tau_at_lower_temperature.device,
        ) - torch.eye(
            size,
            dtype=tau_at_lower_temperature.dtype,
            device=tau_at_lower_temperature.device,
        )
        self.register_buffer("_off_diagonal", mask)

        if trainable_nonrandomness:
            lower_bound, upper_bound = nonrandomness_bounds
            if (
                not 0.0 <= lower_bound < upper_bound
                or not torch.isfinite(torch.tensor([lower_bound, upper_bound])).all()
            ):
                raise ValueError(
                    "anchored HV-NRTL nonrandomness bounds must be finite and increasing"
                )
            off_diagonal_values = nonrandomness[mask.bool()]
            if not bool(
                (off_diagonal_values > lower_bound).all()
                & (off_diagonal_values < upper_bound).all()
            ):
                raise ValueError(
                    "initial trainable HV-NRTL nonrandomness must lie inside its bounds"
                )
            if not torch.allclose(nonrandomness, nonrandomness.mT):
                raise ValueError("trainable HV-NRTL nonrandomness must be symmetric")
            scaled = (nonrandomness - lower_bound) / (upper_bound - lower_bound)
            scaled = torch.where(mask.bool(), scaled, torch.full_like(scaled, 0.5))
            raw_nonrandomness = torch.log(scaled) - torch.log1p(-scaled)
            self.raw_nonrandomness = nn.Parameter(raw_nonrandomness)
            self.register_buffer(
                "_fixed_nonrandomness",
                torch.empty(
                    0,
                    dtype=nonrandomness.dtype,
                    device=nonrandomness.device,
                ),
            )
        else:
            self.register_parameter("raw_nonrandomness", None)
            self.register_buffer("_fixed_nonrandomness", nonrandomness.clone())
        self.nonrandomness_bounds = (float(nonrandomness_bounds[0]), float(nonrandomness_bounds[1]))

    @property
    def tau_at_lower_temperature(self) -> Tensor:
        """Return the lower-anchor interaction matrix with an exact zero diagonal."""
        return self.raw_tau_at_lower_temperature * self._off_diagonal

    @property
    def tau_at_upper_temperature(self) -> Tensor:
        """Return the upper-anchor interaction matrix with an exact zero diagonal."""
        return self.raw_tau_at_upper_temperature * self._off_diagonal

    @property
    def nonrandomness(self) -> Tensor:
        """Return the fixed or bounded-symmetric non-randomness matrix."""
        if self.raw_nonrandomness is None:
            return self._fixed_nonrandomness
        lower, upper = self.nonrandomness_bounds
        transformed = lower + (upper - lower) * torch.sigmoid(self.raw_nonrandomness)
        return 0.5 * (transformed + transformed.mT) * self._off_diagonal

    @property
    def energy_over_r(self) -> Tensor:
        """Return :math:`A_{ij}` in kelvin for ``tau = A/T + B``."""
        inverse_interval = self.lower_temperature.reciprocal() - self.upper_temperature.reciprocal()
        return (self.tau_at_lower_temperature - self.tau_at_upper_temperature) / inverse_interval

    @property
    def temperature_coefficient(self) -> Tensor:
        """Return dimensionless :math:`B_{ij}` for ``tau = A/T + B``."""
        return self.tau_at_lower_temperature - self.energy_over_r / self.lower_temperature

    def tau_matrix(self, temperature: Tensor) -> Tensor:
        """Return the interaction matrix, interpolated linearly in inverse ``T``."""
        if bool((temperature <= 0.0).any()):
            raise ValueError("HV-NRTL temperature must be positive")
        inverse_fraction = (temperature.reciprocal() - self.upper_temperature.reciprocal()) / (
            self.lower_temperature.reciprocal() - self.upper_temperature.reciprocal()
        )
        return (
            inverse_fraction[..., None, None] * self.tau_at_lower_temperature
            + (1.0 - inverse_fraction[..., None, None]) * self.tau_at_upper_temperature
        )

    def excess_gibbs_rt(self, temperature: Tensor, composition: Tensor) -> Tensor:
        """Return the covolume-weighted HV excess Gibbs energy, ``gE/(RT)``."""
        x = normalize_composition(composition)
        return _huron_vidal_excess_gibbs_rt(
            x,
            self.tau_matrix(temperature),
            self.nonrandomness,
            self.covolumes,
        )

    def log_activity_coefficients(self, temperature: Tensor, composition: Tensor) -> Tensor:
        """Return ``log(gamma)`` by differentiating the extensive HV ``gE``."""
        x = normalize_composition(composition)

        def extensive(moles: Tensor) -> Tensor:
            total = moles.sum(dim=-1)
            fractions = moles / total[..., None]
            return (total * self.excess_gibbs_rt(temperature, fractions)).sum()

        result: Tensor = torch.func.grad(extensive)(x)
        return result

    def freeze(self) -> HuronVidalNRTL:
        """Return a detached standard HV-NRTL model for prediction or storage."""
        return HuronVidalNRTL(
            self.energy_over_r.detach(),
            self.temperature_coefficient.detach(),
            self.nonrandomness.detach(),
            self.covolumes.detach(),
        )

tau_at_lower_temperature property

tau_at_lower_temperature

Return the lower-anchor interaction matrix with an exact zero diagonal.

tau_at_upper_temperature property

tau_at_upper_temperature

Return the upper-anchor interaction matrix with an exact zero diagonal.

nonrandomness property

nonrandomness

Return the fixed or bounded-symmetric non-randomness matrix.

energy_over_r property

energy_over_r

Return :math:A_{ij} in kelvin for tau = A/T + B.

temperature_coefficient property

temperature_coefficient

Return dimensionless :math:B_{ij} for tau = A/T + B.

tau_matrix

tau_matrix(temperature)

Return the interaction matrix, interpolated linearly in inverse T.

Source code in src/torch_flash/activity/models.py
def tau_matrix(self, temperature: Tensor) -> Tensor:
    """Return the interaction matrix, interpolated linearly in inverse ``T``."""
    if bool((temperature <= 0.0).any()):
        raise ValueError("HV-NRTL temperature must be positive")
    inverse_fraction = (temperature.reciprocal() - self.upper_temperature.reciprocal()) / (
        self.lower_temperature.reciprocal() - self.upper_temperature.reciprocal()
    )
    return (
        inverse_fraction[..., None, None] * self.tau_at_lower_temperature
        + (1.0 - inverse_fraction[..., None, None]) * self.tau_at_upper_temperature
    )

excess_gibbs_rt

excess_gibbs_rt(temperature, composition)

Return the covolume-weighted HV excess Gibbs energy, gE/(RT).

Source code in src/torch_flash/activity/models.py
def excess_gibbs_rt(self, temperature: Tensor, composition: Tensor) -> Tensor:
    """Return the covolume-weighted HV excess Gibbs energy, ``gE/(RT)``."""
    x = normalize_composition(composition)
    return _huron_vidal_excess_gibbs_rt(
        x,
        self.tau_matrix(temperature),
        self.nonrandomness,
        self.covolumes,
    )

log_activity_coefficients

log_activity_coefficients(temperature, composition)

Return log(gamma) by differentiating the extensive HV gE.

Source code in src/torch_flash/activity/models.py
def log_activity_coefficients(self, temperature: Tensor, composition: Tensor) -> Tensor:
    """Return ``log(gamma)`` by differentiating the extensive HV ``gE``."""
    x = normalize_composition(composition)

    def extensive(moles: Tensor) -> Tensor:
        total = moles.sum(dim=-1)
        fractions = moles / total[..., None]
        return (total * self.excess_gibbs_rt(temperature, fractions)).sum()

    result: Tensor = torch.func.grad(extensive)(x)
    return result

freeze

freeze()

Return a detached standard HV-NRTL model for prediction or storage.

Source code in src/torch_flash/activity/models.py
def freeze(self) -> HuronVidalNRTL:
    """Return a detached standard HV-NRTL model for prediction or storage."""
    return HuronVidalNRTL(
        self.energy_over_r.detach(),
        self.temperature_coefficient.detach(),
        self.nonrandomness.detach(),
        self.covolumes.detach(),
    )

HuronVidalNRTL

Bases: Module

Covolume-weighted NRTL model used by the original HV formulation.

The dimensionless interaction energy is

.. math::

\tau_{ji}(T) = A_{ji}/T + B_{ji},

where energy_over_r stores :math:A in kelvin and temperature_coefficient stores the dimensionless linear coefficient :math:B. The excess Gibbs energy is the modified NRTL expression from Huron and Vidal (1979), with :math:x_j b_j replacing :math:x_j in the local-composition sums. Consequently its parameters must be fitted for the HV infinite-pressure reference; ordinary low-pressure NRTL parameters are not interchangeable.

Reference: Huron and Vidal, Fluid Phase Equilibria 3 (1979), 255-271, doi:10.1016/0378-3812(79)80001-1.

Source code in src/torch_flash/activity/models.py
class HuronVidalNRTL(nn.Module):
    r"""Covolume-weighted NRTL model used by the original HV formulation.

    The dimensionless interaction energy is

    .. math::

        \tau_{ji}(T) = A_{ji}/T + B_{ji},

    where ``energy_over_r`` stores :math:`A` in kelvin and
    ``temperature_coefficient`` stores the dimensionless linear coefficient
    :math:`B`.  The excess Gibbs energy is the modified NRTL expression from
    Huron and Vidal (1979), with :math:`x_j b_j` replacing :math:`x_j` in the
    local-composition sums.  Consequently its parameters must be fitted for
    the HV infinite-pressure reference; ordinary low-pressure NRTL parameters
    are not interchangeable.

    Reference: Huron and Vidal, *Fluid Phase Equilibria* 3 (1979), 255-271,
    doi:10.1016/0378-3812(79)80001-1.
    """

    energy_over_r: Tensor
    temperature_coefficient: Tensor
    nonrandomness: Tensor
    covolumes: Tensor

    def __init__(
        self,
        energy_over_r: Tensor,
        temperature_coefficient: Tensor,
        nonrandomness: Tensor,
        covolumes: Tensor,
        *,
        trainable: bool = False,
    ) -> None:
        super().__init__()
        if (
            energy_over_r.ndim != 2
            or energy_over_r.shape[0] != energy_over_r.shape[1]
            or temperature_coefficient.shape != energy_over_r.shape
            or nonrandomness.shape != energy_over_r.shape
        ):
            raise ValueError("HV-NRTL interaction arrays must be equally sized square matrices")
        if covolumes.shape != (energy_over_r.shape[0],):
            raise ValueError("one positive HV-NRTL covolume is required per component")
        if bool((covolumes <= 0.0).any()):
            raise ValueError("HV-NRTL covolumes must be positive")
        if trainable:
            self.energy_over_r = nn.Parameter(energy_over_r.clone())
            self.temperature_coefficient = nn.Parameter(temperature_coefficient.clone())
        else:
            self.register_buffer("energy_over_r", energy_over_r.clone())
            self.register_buffer("temperature_coefficient", temperature_coefficient.clone())
        self.register_buffer("nonrandomness", nonrandomness.clone())
        self.register_buffer("covolumes", covolumes.clone())

    def tau_matrix(self, temperature: Tensor) -> Tensor:
        """Return the dimensionless, temperature-dependent interaction matrix."""
        return self.energy_over_r / temperature[..., None, None] + self.temperature_coefficient

    def excess_gibbs_rt(self, temperature: Tensor, composition: Tensor) -> Tensor:
        """Return the covolume-weighted HV excess Gibbs energy, ``gE/(RT)``."""
        x = normalize_composition(composition)
        tau = self.tau_matrix(temperature)
        return _huron_vidal_excess_gibbs_rt(
            x,
            tau,
            self.nonrandomness,
            self.covolumes,
        )

    def log_activity_coefficients(self, temperature: Tensor, composition: Tensor) -> Tensor:
        """Return ``log(gamma)`` by differentiating the extensive HV ``gE``."""
        x = normalize_composition(composition)

        def extensive(moles: Tensor) -> Tensor:
            total = moles.sum(dim=-1)
            fractions = moles / total[..., None]
            return (total * self.excess_gibbs_rt(temperature, fractions)).sum()

        result: Tensor = torch.func.grad(extensive)(x)
        return result

tau_matrix

tau_matrix(temperature)

Return the dimensionless, temperature-dependent interaction matrix.

Source code in src/torch_flash/activity/models.py
def tau_matrix(self, temperature: Tensor) -> Tensor:
    """Return the dimensionless, temperature-dependent interaction matrix."""
    return self.energy_over_r / temperature[..., None, None] + self.temperature_coefficient

excess_gibbs_rt

excess_gibbs_rt(temperature, composition)

Return the covolume-weighted HV excess Gibbs energy, gE/(RT).

Source code in src/torch_flash/activity/models.py
def excess_gibbs_rt(self, temperature: Tensor, composition: Tensor) -> Tensor:
    """Return the covolume-weighted HV excess Gibbs energy, ``gE/(RT)``."""
    x = normalize_composition(composition)
    tau = self.tau_matrix(temperature)
    return _huron_vidal_excess_gibbs_rt(
        x,
        tau,
        self.nonrandomness,
        self.covolumes,
    )

log_activity_coefficients

log_activity_coefficients(temperature, composition)

Return log(gamma) by differentiating the extensive HV gE.

Source code in src/torch_flash/activity/models.py
def log_activity_coefficients(self, temperature: Tensor, composition: Tensor) -> Tensor:
    """Return ``log(gamma)`` by differentiating the extensive HV ``gE``."""
    x = normalize_composition(composition)

    def extensive(moles: Tensor) -> Tensor:
        total = moles.sum(dim=-1)
        fractions = moles / total[..., None]
        return (total * self.excess_gibbs_rt(temperature, fractions)).sum()

    result: Tensor = torch.func.grad(extensive)(x)
    return result

Wilson

Bases: Module

Wilson excess Gibbs model with energy and molar-volume parameters.

See Wilson, Journal of the American Chemical Society 86 (1964), 127-130, doi:10.1021/ja01056a002.

Source code in src/torch_flash/activity/models.py
class Wilson(nn.Module):
    """Wilson excess Gibbs model with energy and molar-volume parameters.

    See Wilson, *Journal of the American Chemical Society* 86 (1964), 127-130,
    doi:10.1021/ja01056a002.
    """

    interaction: Tensor
    molar_volumes: Tensor

    def __init__(
        self,
        interaction: Tensor,
        molar_volumes: Tensor,
        *,
        trainable: bool = False,
    ) -> None:
        super().__init__()
        if interaction.ndim != 2 or interaction.shape[0] != interaction.shape[1]:
            raise ValueError("Wilson interaction must be a square matrix")
        if molar_volumes.shape != (interaction.shape[0],):
            raise ValueError("one Wilson molar volume is required per component")
        if trainable:
            self.interaction = nn.Parameter(interaction.clone())
        else:
            self.register_buffer("interaction", interaction.clone())
        self.register_buffer("molar_volumes", molar_volumes.clone())

    def lambda_matrix(self, temperature: Tensor) -> Tensor:
        """Return the temperature-dependent Wilson Lambda matrix."""
        volume_ratio = self.molar_volumes[None, :] / self.molar_volumes[:, None]
        return volume_ratio * torch.exp(-self.interaction / (R * temperature[..., None, None]))

    def excess_gibbs_rt(self, temperature: Tensor, composition: Tensor) -> Tensor:
        """Return dimensionless molar excess Gibbs energy, ``gE/(RT)``."""
        x = normalize_composition(composition)
        lambda_matrix = self.lambda_matrix(temperature)
        sums = torch.einsum("...j,...ij->...i", x, lambda_matrix)
        return -torch.sum(x * torch.log(sums), dim=-1)

    def log_activity_coefficients(self, temperature: Tensor, composition: Tensor) -> Tensor:
        """Return the analytical Wilson ``log(gamma)`` expression."""
        x = normalize_composition(composition)
        lambda_matrix = self.lambda_matrix(temperature)
        row_sums = torch.einsum("...j,...ij->...i", x, lambda_matrix)
        correction = torch.einsum("...j,...ji,...j->...i", x, lambda_matrix, row_sums.reciprocal())
        return 1.0 - torch.log(row_sums) - correction

lambda_matrix

lambda_matrix(temperature)

Return the temperature-dependent Wilson Lambda matrix.

Source code in src/torch_flash/activity/models.py
def lambda_matrix(self, temperature: Tensor) -> Tensor:
    """Return the temperature-dependent Wilson Lambda matrix."""
    volume_ratio = self.molar_volumes[None, :] / self.molar_volumes[:, None]
    return volume_ratio * torch.exp(-self.interaction / (R * temperature[..., None, None]))

excess_gibbs_rt

excess_gibbs_rt(temperature, composition)

Return dimensionless molar excess Gibbs energy, gE/(RT).

Source code in src/torch_flash/activity/models.py
def excess_gibbs_rt(self, temperature: Tensor, composition: Tensor) -> Tensor:
    """Return dimensionless molar excess Gibbs energy, ``gE/(RT)``."""
    x = normalize_composition(composition)
    lambda_matrix = self.lambda_matrix(temperature)
    sums = torch.einsum("...j,...ij->...i", x, lambda_matrix)
    return -torch.sum(x * torch.log(sums), dim=-1)

log_activity_coefficients

log_activity_coefficients(temperature, composition)

Return the analytical Wilson log(gamma) expression.

Source code in src/torch_flash/activity/models.py
def log_activity_coefficients(self, temperature: Tensor, composition: Tensor) -> Tensor:
    """Return the analytical Wilson ``log(gamma)`` expression."""
    x = normalize_composition(composition)
    lambda_matrix = self.lambda_matrix(temperature)
    row_sums = torch.einsum("...j,...ij->...i", x, lambda_matrix)
    correction = torch.einsum("...j,...ji,...j->...i", x, lambda_matrix, row_sums.reciprocal())
    return 1.0 - torch.log(row_sums) - correction

UNIFAC

Bases: Module

Original UNIFAC excess-Gibbs-energy model.

Parameters:

Name Type Description Default
group_counts Tensor

Component-by-subgroup occurrence matrix :math:\nu_k^{(i)}.

required
relative_volume Tensor

Selected subgroup :math:R_k and :math:Q_k values.

required
relative_surface_area Tensor

Selected subgroup :math:R_k and :math:Q_k values.

required
subgroup_main_indices Tensor

Zero-based index of the selected main group for each subgroup.

required
interaction Tensor

Directed selected-main-group matrix :math:a_{mn} in kelvin. Every required off-diagonal value must be supplied.

required
coordination_number float

Lattice coordination number :math:z; original UNIFAC uses 10.

10.0
trainable bool

Store the main-group interaction matrix as an nn.Parameter.

False
Notes

The model is vectorized over all leading dimensions of temperature and composition. PyTorch autodiff can therefore differentiate activities, excess Gibbs energy, and temperature/composition derivatives on CPU or GPU. Group assignment is discrete and is intentionally not trainable.

Source code in src/torch_flash/activity/unifac.py
class UNIFAC(nn.Module):
    r"""Original UNIFAC excess-Gibbs-energy model.

    Parameters
    ----------
    group_counts:
        Component-by-subgroup occurrence matrix :math:`\nu_k^{(i)}`.
    relative_volume, relative_surface_area:
        Selected subgroup :math:`R_k` and :math:`Q_k` values.
    subgroup_main_indices:
        Zero-based index of the selected main group for each subgroup.
    interaction:
        Directed selected-main-group matrix :math:`a_{mn}` in kelvin.
        Every required off-diagonal value must be supplied.
    coordination_number:
        Lattice coordination number :math:`z`; original UNIFAC uses 10.
    trainable:
        Store the main-group interaction matrix as an ``nn.Parameter``.

    Notes
    -----
    The model is vectorized over all leading dimensions of temperature and
    composition.  PyTorch autodiff can therefore differentiate activities,
    excess Gibbs energy, and temperature/composition derivatives on CPU or
    GPU.  Group assignment is discrete and is intentionally not trainable.
    """

    group_counts: Tensor
    relative_volume: Tensor
    relative_surface_area: Tensor
    subgroup_main_indices: Tensor
    interaction: Tensor
    subgroup_keys: tuple[str, ...]
    main_group_ids: tuple[int, ...]

    def __init__(
        self,
        group_counts: Tensor,
        relative_volume: Tensor,
        relative_surface_area: Tensor,
        subgroup_main_indices: Tensor,
        interaction: Tensor,
        *,
        coordination_number: float = 10.0,
        trainable: bool = False,
    ) -> None:
        super().__init__()
        if group_counts.ndim != 2 or not group_counts.shape[0] or not group_counts.shape[1]:
            raise ValueError("UNIFAC group counts must be a nonempty component-by-group matrix")
        group_shape = (group_counts.shape[1],)
        if relative_volume.shape != group_shape or relative_surface_area.shape != group_shape:
            raise ValueError("UNIFAC requires one R and Q value per selected subgroup")
        if subgroup_main_indices.shape != group_shape:
            raise ValueError("UNIFAC requires one main-group index per selected subgroup")
        if subgroup_main_indices.dtype != torch.long:
            raise ValueError("UNIFAC main-group indices must have torch.long dtype")
        if interaction.ndim != 2 or interaction.shape[0] != interaction.shape[1]:
            raise ValueError("UNIFAC interactions must be a square directed matrix")
        if not interaction.shape[0]:
            raise ValueError("UNIFAC requires at least one selected main group")
        tensors = (group_counts, relative_volume, relative_surface_area, interaction)
        if not all(bool(torch.isfinite(value).all()) for value in tensors):
            raise ValueError("UNIFAC numerical inputs must be finite")
        if bool((group_counts < 0.0).any()) or bool((group_counts.sum(dim=-1) <= 0.0).any()):
            raise ValueError("each UNIFAC component needs nonnegative, nonempty group counts")
        if bool((relative_volume <= 0.0).any()) or bool((relative_surface_area < 0.0).any()):
            raise ValueError("UNIFAC R values must be positive and Q values nonnegative")
        if bool((subgroup_main_indices < 0).any()) or bool(
            (subgroup_main_indices >= interaction.shape[0]).any()
        ):
            raise ValueError("UNIFAC main-group indices are outside the interaction matrix")
        if not torch.allclose(
            torch.diagonal(interaction),
            torch.zeros(interaction.shape[0], dtype=interaction.dtype, device=interaction.device),
        ):
            raise ValueError("UNIFAC same-main-group interactions must be zero")
        if coordination_number <= 0.0:
            raise ValueError("UNIFAC coordination number must be positive")

        molecular_volume = group_counts @ relative_volume
        molecular_surface = group_counts @ relative_surface_area
        if bool((molecular_volume <= 0.0).any()) or bool((molecular_surface <= 0.0).any()):
            raise ValueError("each UNIFAC component must have positive molecular R and Q")

        self.coordination_number = float(coordination_number)
        self.subgroup_keys = ()
        self.main_group_ids = ()
        self.register_buffer("group_counts", group_counts.clone())
        self.register_buffer("relative_volume", relative_volume.clone())
        self.register_buffer("relative_surface_area", relative_surface_area.clone())
        self.register_buffer("subgroup_main_indices", subgroup_main_indices.clone())
        if trainable:
            self.interaction = nn.Parameter(interaction.clone())
        else:
            self.register_buffer("interaction", interaction.clone())

    @property
    def molecular_relative_volume(self) -> Tensor:
        """Return component molecular volume parameters :math:`r_i`."""
        return self.group_counts @ self.relative_volume

    @property
    def molecular_relative_surface_area(self) -> Tensor:
        """Return component molecular surface parameters :math:`q_i`."""
        return self.group_counts @ self.relative_surface_area

    def interaction_factors(self, temperature: Tensor) -> Tensor:
        r"""Return :math:`\Psi_{mn}=\exp(-a_{mn}/T)`."""
        if not torch.compiler.is_compiling() and bool((temperature <= 0.0).any()):
            raise ValueError("temperature must be positive")
        interaction = self.interaction - torch.diag_embed(torch.diagonal(self.interaction))
        return torch.exp(-interaction / temperature[..., None, None])

    def _state(self, temperature: Tensor, composition: Tensor) -> tuple[Tensor, Tensor]:
        x = normalize_composition(composition)
        if x.shape[-1] != self.group_counts.shape[0]:
            raise ValueError("UNIFAC composition size must match the component group matrix")
        if not torch.compiler.is_compiling() and bool((temperature <= 0.0).any()):
            raise ValueError("temperature must be positive")
        batch_shape = torch.broadcast_shapes(temperature.shape, x.shape[:-1])
        return temperature.expand(batch_shape), x.expand((*batch_shape, x.shape[-1]))

    def combinatorial_log_activity_coefficients(
        self,
        composition: Tensor,
    ) -> Tensor:
        r"""Return the original Flory-Huggins/Staverman-Guggenheim term."""
        x = normalize_composition(composition)
        if x.shape[-1] != self.group_counts.shape[0]:
            raise ValueError("UNIFAC composition size must match the component group matrix")
        r = self.molecular_relative_volume
        q = self.molecular_relative_surface_area
        r_bar = torch.sum(x * r, dim=-1, keepdim=True)
        q_bar = torch.sum(x * q, dim=-1, keepdim=True)
        phi_over_x = r / r_bar
        theta_over_phi = (q / r) * (r_bar / q_bar)
        return (
            torch.log(phi_over_x)
            + 1.0
            - phi_over_x
            + 0.5
            * self.coordination_number
            * q
            * (torch.log(theta_over_phi) - 1.0 + theta_over_phi.reciprocal())
        )

    def _group_log_activity_coefficients(
        self,
        surface_fractions: Tensor,
        interaction_factors: Tensor,
    ) -> Tensor:
        column_sum = torch.sum(
            surface_fractions[..., :, None] * interaction_factors,
            dim=-2,
        )
        correction = torch.sum(
            surface_fractions[..., None, :] * interaction_factors / column_sum[..., None, :],
            dim=-1,
        )
        return self.relative_surface_area * (1.0 - torch.log(column_sum) - correction)

    def residual_log_activity_coefficients(
        self,
        temperature: Tensor,
        composition: Tensor,
    ) -> Tensor:
        r"""Return the solution-of-groups residual contribution."""
        temperature, x = self._state(temperature, composition)
        group_abundance = torch.einsum("...i,ik->...k", x, self.group_counts)
        mixture_surface = self.relative_surface_area * group_abundance
        mixture_surface = mixture_surface / mixture_surface.sum(dim=-1, keepdim=True)

        main_factors = self.interaction_factors(temperature)
        subgroup_factors = main_factors[
            ...,
            self.subgroup_main_indices[:, None],
            self.subgroup_main_indices[None, :],
        ]
        mixture_log_group = self._group_log_activity_coefficients(
            mixture_surface,
            subgroup_factors,
        )

        pure_surface = self.group_counts * self.relative_surface_area
        pure_surface = pure_surface / pure_surface.sum(dim=-1, keepdim=True)
        batch_shape = x.shape[:-1]
        pure_surface = pure_surface.expand((*batch_shape, *pure_surface.shape))
        pure_log_group = self._group_log_activity_coefficients(
            pure_surface,
            subgroup_factors[..., None, :, :],
        )
        return torch.sum(
            self.group_counts * (mixture_log_group[..., None, :] - pure_log_group),
            dim=-1,
        )

    def log_activity_coefficients(self, temperature: Tensor, composition: Tensor) -> Tensor:
        """Return ``log(gamma)`` for original UNIFAC."""
        _, x = self._state(temperature, composition)
        return self.combinatorial_log_activity_coefficients(x) + (
            self.residual_log_activity_coefficients(temperature, x)
        )

    def excess_gibbs_rt(self, temperature: Tensor, composition: Tensor) -> Tensor:
        """Return dimensionless molar excess Gibbs energy, ``gE/(RT)``."""
        _, x = self._state(temperature, composition)
        return torch.sum(x * self.log_activity_coefficients(temperature, x), dim=-1)

molecular_relative_volume property

molecular_relative_volume

Return component molecular volume parameters :math:r_i.

molecular_relative_surface_area property

molecular_relative_surface_area

Return component molecular surface parameters :math:q_i.

interaction_factors

interaction_factors(temperature)

Return :math:\Psi_{mn}=\exp(-a_{mn}/T).

Source code in src/torch_flash/activity/unifac.py
def interaction_factors(self, temperature: Tensor) -> Tensor:
    r"""Return :math:`\Psi_{mn}=\exp(-a_{mn}/T)`."""
    if not torch.compiler.is_compiling() and bool((temperature <= 0.0).any()):
        raise ValueError("temperature must be positive")
    interaction = self.interaction - torch.diag_embed(torch.diagonal(self.interaction))
    return torch.exp(-interaction / temperature[..., None, None])

combinatorial_log_activity_coefficients

combinatorial_log_activity_coefficients(composition)

Return the original Flory-Huggins/Staverman-Guggenheim term.

Source code in src/torch_flash/activity/unifac.py
def combinatorial_log_activity_coefficients(
    self,
    composition: Tensor,
) -> Tensor:
    r"""Return the original Flory-Huggins/Staverman-Guggenheim term."""
    x = normalize_composition(composition)
    if x.shape[-1] != self.group_counts.shape[0]:
        raise ValueError("UNIFAC composition size must match the component group matrix")
    r = self.molecular_relative_volume
    q = self.molecular_relative_surface_area
    r_bar = torch.sum(x * r, dim=-1, keepdim=True)
    q_bar = torch.sum(x * q, dim=-1, keepdim=True)
    phi_over_x = r / r_bar
    theta_over_phi = (q / r) * (r_bar / q_bar)
    return (
        torch.log(phi_over_x)
        + 1.0
        - phi_over_x
        + 0.5
        * self.coordination_number
        * q
        * (torch.log(theta_over_phi) - 1.0 + theta_over_phi.reciprocal())
    )

residual_log_activity_coefficients

residual_log_activity_coefficients(temperature, composition)

Return the solution-of-groups residual contribution.

Source code in src/torch_flash/activity/unifac.py
def residual_log_activity_coefficients(
    self,
    temperature: Tensor,
    composition: Tensor,
) -> Tensor:
    r"""Return the solution-of-groups residual contribution."""
    temperature, x = self._state(temperature, composition)
    group_abundance = torch.einsum("...i,ik->...k", x, self.group_counts)
    mixture_surface = self.relative_surface_area * group_abundance
    mixture_surface = mixture_surface / mixture_surface.sum(dim=-1, keepdim=True)

    main_factors = self.interaction_factors(temperature)
    subgroup_factors = main_factors[
        ...,
        self.subgroup_main_indices[:, None],
        self.subgroup_main_indices[None, :],
    ]
    mixture_log_group = self._group_log_activity_coefficients(
        mixture_surface,
        subgroup_factors,
    )

    pure_surface = self.group_counts * self.relative_surface_area
    pure_surface = pure_surface / pure_surface.sum(dim=-1, keepdim=True)
    batch_shape = x.shape[:-1]
    pure_surface = pure_surface.expand((*batch_shape, *pure_surface.shape))
    pure_log_group = self._group_log_activity_coefficients(
        pure_surface,
        subgroup_factors[..., None, :, :],
    )
    return torch.sum(
        self.group_counts * (mixture_log_group[..., None, :] - pure_log_group),
        dim=-1,
    )

log_activity_coefficients

log_activity_coefficients(temperature, composition)

Return log(gamma) for original UNIFAC.

Source code in src/torch_flash/activity/unifac.py
def log_activity_coefficients(self, temperature: Tensor, composition: Tensor) -> Tensor:
    """Return ``log(gamma)`` for original UNIFAC."""
    _, x = self._state(temperature, composition)
    return self.combinatorial_log_activity_coefficients(x) + (
        self.residual_log_activity_coefficients(temperature, x)
    )

excess_gibbs_rt

excess_gibbs_rt(temperature, composition)

Return dimensionless molar excess Gibbs energy, gE/(RT).

Source code in src/torch_flash/activity/unifac.py
def excess_gibbs_rt(self, temperature: Tensor, composition: Tensor) -> Tensor:
    """Return dimensionless molar excess Gibbs energy, ``gE/(RT)``."""
    _, x = self._state(temperature, composition)
    return torch.sum(x * self.log_activity_coefficients(temperature, x), dim=-1)

activity_model

activity_model(parameter_set, names=None, *, dtype=None, device=None, trainable=False, covolumes=None, group_assignments=None)

Construct NRTL, HV-NRTL, Wilson, or original UNIFAC from YAML.

The requested component order may differ from the stored order; vectors and matrices are permuted consistently. For custom fitting workflows, the model classes continue to accept explicit tensors directly. UNIFAC uses bundled fragmentations selected by names or explicit group_assignments.

Source code in src/torch_flash/activity/named.py
def activity_model(
    parameter_set: ParameterSource,
    names: tuple[str, ...] | None = None,
    *,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    trainable: bool = False,
    covolumes: Tensor | None = None,
    group_assignments: Sequence[GroupAssignment] | None = None,
) -> ActivityModel:
    """Construct NRTL, HV-NRTL, Wilson, or original UNIFAC from YAML.

    The requested component order may differ from the stored order; vectors
    and matrices are permuted consistently. For custom fitting workflows, the
    model classes continue to accept explicit tensors directly. UNIFAC uses
    bundled fragmentations selected by ``names`` or explicit
    ``group_assignments``.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "activity":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'activity'"
        )
    parameters = loaded.parameters
    model_name = loaded.model.strip().lower().replace("_", "-")
    if model_name in ("unifac", "original-unifac"):
        return _unifac_from_loaded(
            loaded,
            names=names,
            group_assignments=group_assignments,
            dtype=dtype,
            device=device,
            trainable=trainable,
        )
    if group_assignments is not None:
        raise ValueError("group_assignments are only valid for UNIFAC")
    stored_names = parameters.get("components")
    if not isinstance(stored_names, Sequence) or isinstance(stored_names, str):
        raise ParameterDatabaseError(f"{loaded.identifier!r} requires a components list")
    canonical_stored = tuple(
        canonical_component_name(str(name), strict=False) for name in stored_names
    )
    selected = (
        canonical_stored
        if names is None
        else tuple(canonical_component_name(name, strict=False) for name in names)
    )
    if not selected or len(set(selected)) != len(selected):
        raise ValueError("activity-model component names must be non-empty and unique")
    try:
        order = tuple(canonical_stored.index(name) for name in selected)
    except ValueError as exc:
        raise KeyError(
            f"{loaded.identifier} has no activity parameters for one or more of {selected!r}"
        ) from exc

    if model_name == "nrtl":
        return NRTL(
            _numeric_matrix(parameters, "interaction", order, dtype=dtype, device=device),
            _numeric_matrix(parameters, "nonrandomness", order, dtype=dtype, device=device),
            trainable=trainable,
        )
    if model_name == "wilson":
        return Wilson(
            _numeric_matrix(parameters, "interaction", order, dtype=dtype, device=device),
            _numeric_vector(parameters, "molar_volumes", order, dtype=dtype, device=device),
            trainable=trainable,
        )
    if model_name not in ("hv-nrtl", "huron-vidal-nrtl"):
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} has unsupported activity model {loaded.model!r}"
        )

    if covolumes is None and "covolumes" in parameters:
        covolumes = _numeric_vector(
            parameters,
            "covolumes",
            order,
            dtype=dtype,
            device=device,
        )
    if covolumes is None:
        cubic_source = parameters.get("covolume_cubic_parameter_set")
        if not isinstance(cubic_source, str):
            raise ParameterDatabaseError(
                f"{loaded.identifier!r} requires covolumes or 'covolume_cubic_parameter_set'"
            )
        cubic = load_model_parameters(cubic_source)
        omega_b = cubic.parameters.get("omega_b")
        if cubic.model_kind != "cubic" or not isinstance(omega_b, int | float):
            raise ParameterDatabaseError(f"{cubic.identifier!r} cannot provide cubic covolumes")
        components = component_set(selected, dtype=dtype, device=device)
        covolumes = (
            float(omega_b) * R * components.critical_temperature / components.critical_pressure
        )
    else:
        covolumes = covolumes.to(dtype=dtype, device=device)
    if covolumes.shape != (len(selected),):
        raise ValueError("one activity-model covolume is required per selected component")
    return HuronVidalNRTL(
        _numeric_matrix(parameters, "energy_over_r", order, dtype=dtype, device=device),
        _numeric_matrix(
            parameters,
            "temperature_coefficient",
            order,
            dtype=dtype,
            device=device,
        ),
        _numeric_matrix(parameters, "nonrandomness", order, dtype=dtype, device=device),
        covolumes,
        trainable=trainable,
    )

unifac_groups_from_identifiers

unifac_groups_from_identifiers(identifiers, *, identifier_type='smiles')

Fragment molecules with the optional MIT-licensed ugropy package.

SMILES inputs are recommended for reproducibility. Name lookup delegates to PubChem and therefore requires network access and may change outside torch-flash. Fragmentation is discrete; inspect every returned map before using it in regression or safety-critical calculations.

Source code in src/torch_flash/activity/unifac.py
def unifac_groups_from_identifiers(
    identifiers: Sequence[str],
    *,
    identifier_type: str = "smiles",
) -> tuple[dict[int, float], ...]:
    """Fragment molecules with the optional MIT-licensed ``ugropy`` package.

    SMILES inputs are recommended for reproducibility.  Name lookup delegates
    to PubChem and therefore requires network access and may change outside
    ``torch-flash``.  Fragmentation is discrete; inspect every returned map
    before using it in regression or safety-critical calculations.
    """
    try:
        from ugropy import unifac as ugropy_unifac
    except ImportError as exc:
        raise ImportError(
            "automatic UNIFAC fragmentation requires 'ugropy'; install torch-flash[groups]"
        ) from exc
    if identifier_type not in ("name", "smiles"):
        raise ValueError("identifier_type must be 'name' or 'smiles'")
    assignments: list[dict[int, float]] = []
    for identifier in identifiers:
        result = ugropy_unifac.get_groups(identifier, identifier_type)
        if isinstance(result, list):
            if len(result) != 1:
                raise ValueError(
                    f"ugropy returned {len(result)} fragmentations for {identifier!r}; "
                    "select a group assignment explicitly"
                )
            result = result[0]
        raw = getattr(result, "subgroups_num", None)
        if not isinstance(raw, Mapping) or not raw:
            raise ValueError(f"ugropy could not assign original-UNIFAC groups to {identifier!r}")
        assignments.append({int(key): float(value) for key, value in raw.items()})
    return tuple(assignments)

unifac_model

unifac_model(parameter_set='unifac-original', names=None, *, group_assignments=None, dtype=None, device=None, trainable=False)

Construct original UNIFAC from cached YAML or explicit parameters.

names select bundled, audited component fragmentations. Arbitrary molecules use group_assignments, where each mapping may be keyed by subgroup key, published subgroup number, or an unambiguous subgroup name. The optional :func:unifac_groups_from_identifiers adapter can generate these mappings with ugropy.

Source code in src/torch_flash/activity/unifac.py
def unifac_model(
    parameter_set: ParameterSource = "unifac-original",
    names: Sequence[str] | None = None,
    *,
    group_assignments: Sequence[GroupAssignment] | None = None,
    dtype: torch.dtype | None = None,
    device: torch.device | str | None = None,
    trainable: bool = False,
) -> UNIFAC:
    """Construct original UNIFAC from cached YAML or explicit parameters.

    ``names`` select bundled, audited component fragmentations.  Arbitrary
    molecules use ``group_assignments``, where each mapping may be keyed by
    subgroup key, published subgroup number, or an unambiguous subgroup name.
    The optional :func:`unifac_groups_from_identifiers` adapter can generate
    these mappings with ``ugropy``.
    """
    dtype, device = resolve_tensor_options(dtype, device)
    loaded = load_model_parameters(parameter_set)
    if loaded.model_kind != "activity":
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model_kind!r}, not 'activity'"
        )
    if loaded.model.strip().lower().replace("_", "-") not in ("unifac", "original-unifac"):
        raise ParameterDatabaseError(
            f"{loaded.identifier!r} is {loaded.model!r}, not original UNIFAC"
        )
    return _unifac_from_loaded(
        loaded,
        names=names,
        group_assignments=group_assignments,
        dtype=dtype,
        device=device,
        trainable=trainable,
    )

torch_flash.mixing

Mixing rules for equations of state.

HuronVidalMixing

Bases: Module

Original infinite-pressure Huron-Vidal mixing rule.

The implementation follows Michelsen and Mollerup, chapter 7, Eq. 7:

a/(bRT) = sum(x_i a_i/(b_i RT)) - gE/(Delta RT).

Primary source: Huron and Vidal (1979), doi:10.1016/0378-3812(79)80001-1. The implementation convention follows Michelsen and Mollerup, Thermodynamic Models, 2nd ed. (2007), chapter 7, ISBN 978-87-989961-3-2.

Source code in src/torch_flash/mixing/rules.py
class HuronVidalMixing(nn.Module):
    """Original infinite-pressure Huron-Vidal mixing rule.

    The implementation follows Michelsen and Mollerup, chapter 7, Eq. 7:

    ``a/(bRT) = sum(x_i a_i/(b_i RT)) - gE/(Delta RT)``.

    Primary source: Huron and Vidal (1979),
    doi:10.1016/0378-3812(79)80001-1. The implementation convention follows
    Michelsen and Mollerup, *Thermodynamic Models*, 2nd ed. (2007),
    chapter 7, ISBN 978-87-989961-3-2.
    """

    def __init__(
        self,
        activity_model: ActivityModel,
        *,
        delta1: float,
        delta2: float,
    ) -> None:
        super().__init__()
        if delta1 == delta2:
            raise ValueError("cubic delta parameters must be distinct")
        self.activity_model = activity_model  # type: ignore[assignment]
        self.delta1 = float(delta1)
        self.delta2 = float(delta2)
        ratio = (1.0 + delta2) / (1.0 + delta1)
        self.delta = float(torch.log(torch.tensor(ratio)) / (delta2 - delta1))

    def forward(
        self,
        temperature: Tensor,
        composition: Tensor,
        pure_a: Tensor,
        pure_b: Tensor,
    ) -> tuple[Tensor, Tensor]:
        """Return mixture ``a`` and ``b`` parameters."""
        x = normalize_composition(composition)
        bm = torch.sum(x * pure_b, dim=-1)
        pure_alpha = pure_a / (pure_b * R * temperature[..., None])
        ge_rt = self.activity_model.excess_gibbs_rt(temperature, x)
        alpha = torch.sum(x * pure_alpha, dim=-1) - ge_rt / self.delta
        return alpha * bm * R * temperature, bm

forward

forward(temperature, composition, pure_a, pure_b)

Return mixture a and b parameters.

Source code in src/torch_flash/mixing/rules.py
def forward(
    self,
    temperature: Tensor,
    composition: Tensor,
    pure_a: Tensor,
    pure_b: Tensor,
) -> tuple[Tensor, Tensor]:
    """Return mixture ``a`` and ``b`` parameters."""
    x = normalize_composition(composition)
    bm = torch.sum(x * pure_b, dim=-1)
    pure_alpha = pure_a / (pure_b * R * temperature[..., None])
    ge_rt = self.activity_model.excess_gibbs_rt(temperature, x)
    alpha = torch.sum(x * pure_alpha, dim=-1) - ge_rt / self.delta
    return alpha * bm * R * temperature, bm

PPR78Mixing

Bases: Module

Predictive PR78 group-contribution attraction mixing.

This is Eq. (5) of Jaubert and Mutelet (2004), doi:10.1016/j.fluid.2004.06.059. For component group fractions :math:\\alpha_{ik}, the method evaluates

.. math::

k_{ij}(T) = \frac{ -\frac12\sum_{kl}\Delta\alpha_{ij,k}\Delta\alpha_{ij,l} A_{kl}(T_r/T)^{B_{kl}/A_{kl}-1} -(\sqrt{a_i}/b_i-\sqrt{a_j}/b_j)^2 }{ 2\sqrt{a_i a_j}/(b_i b_j) }.

group_a and group_b are symmetric pressure-valued group interaction matrices. Only their unique off-diagonal entries are stored, avoiding redundant degrees of freedom during fitting. The paper derives the correlation with the conventional linear covolume rule, which this class preserves.

Source code in src/torch_flash/mixing/rules.py
class PPR78Mixing(nn.Module):
    r"""Predictive PR78 group-contribution attraction mixing.

    This is Eq. (5) of Jaubert and Mutelet (2004),
    doi:10.1016/j.fluid.2004.06.059. For component group fractions
    :math:`\\alpha_{ik}`, the method evaluates

    .. math::

       k_{ij}(T) =
       \\frac{
       -\\frac12\\sum_{kl}\\Delta\\alpha_{ij,k}\\Delta\\alpha_{ij,l}
       A_{kl}(T_r/T)^{B_{kl}/A_{kl}-1}
       -(\\sqrt{a_i}/b_i-\\sqrt{a_j}/b_j)^2
       }{
       2\\sqrt{a_i a_j}/(b_i b_j)
       }.

    ``group_a`` and ``group_b`` are symmetric pressure-valued group
    interaction matrices. Only their unique off-diagonal entries are stored,
    avoiding redundant degrees of freedom during fitting. The paper derives
    the correlation with the conventional linear covolume rule, which this
    class preserves.
    """

    group_fractions: Tensor
    pair_indices: Tensor
    reference_temperature: Tensor
    raw_group_a: Tensor
    raw_group_b: Tensor

    def __init__(
        self,
        group_fractions: Tensor,
        group_a: Tensor,
        group_b: Tensor,
        *,
        reference_temperature: float = 298.15,
        trainable: bool = False,
        parameter_set: str | None = None,
    ) -> None:
        super().__init__()
        if group_fractions.ndim != 2:
            raise ValueError("PPR78 group_fractions must be a component-by-group matrix")
        component_count, group_count = group_fractions.shape
        if component_count == 0 or group_count < 2:
            raise ValueError("PPR78 requires at least one component and two groups")
        if group_a.shape != (group_count, group_count) or group_b.shape != group_a.shape:
            raise ValueError("PPR78 group A and B must be square matrices matching the groups")
        if not bool(
            torch.isfinite(group_fractions).all()
            & torch.isfinite(group_a).all()
            & torch.isfinite(group_b).all()
        ):
            raise ValueError("PPR78 fractions and group interactions must be finite")
        if bool((group_fractions < 0.0).any()):
            raise ValueError("PPR78 group fractions cannot be negative")
        row_sums = group_fractions.sum(dim=-1)
        if not torch.allclose(row_sums, torch.ones_like(row_sums)):
            raise ValueError("each PPR78 component group-fraction row must sum to one")
        if not torch.allclose(group_a, group_a.mT) or not torch.allclose(group_b, group_b.mT):
            raise ValueError("PPR78 group A and B matrices must be symmetric")
        if bool(torch.diagonal(group_a).count_nonzero()) or bool(
            torch.diagonal(group_b).count_nonzero()
        ):
            raise ValueError("PPR78 group A and B matrices must have zero diagonals")
        if not torch.isfinite(torch.tensor(reference_temperature)) or reference_temperature <= 0.0:
            raise ValueError("PPR78 reference_temperature must be finite and positive")
        undefined = (group_a == 0.0) & (group_b != 0.0)
        if bool(undefined.any()):
            raise ValueError("PPR78 group B must be zero wherever group A is zero")

        indices = torch.triu_indices(
            group_count,
            group_count,
            offset=1,
            device=group_a.device,
        )
        self.register_buffer("group_fractions", group_fractions.clone())
        self.register_buffer("pair_indices", indices)
        self.register_buffer(
            "reference_temperature",
            group_a.new_tensor(reference_temperature),
        )
        if trainable:
            self.raw_group_a = nn.Parameter(group_a[indices[0], indices[1]].clone())
            self.raw_group_b = nn.Parameter(group_b[indices[0], indices[1]].clone())
        else:
            self.register_buffer(
                "raw_group_a",
                group_a[indices[0], indices[1]].clone(),
            )
            self.register_buffer(
                "raw_group_b",
                group_b[indices[0], indices[1]].clone(),
            )
        self.parameter_set = parameter_set

    @property
    def ncomponents(self) -> int:
        """Number of component decompositions."""
        return int(self.group_fractions.shape[0])

    @property
    def ngroups(self) -> int:
        """Number of structural groups."""
        return int(self.group_fractions.shape[1])

    def _symmetric_matrix(self, values: Tensor) -> Tensor:
        matrix = values.new_zeros((self.ngroups, self.ngroups))
        matrix = matrix.index_put(
            (self.pair_indices[0], self.pair_indices[1]),
            values,
        )
        return matrix + matrix.mT

    @property
    def group_a(self) -> Tensor:
        """Return the symmetric :math:`A_{kl}` matrix in pascals."""
        return self._symmetric_matrix(self.raw_group_a)

    @property
    def group_b(self) -> Tensor:
        """Return the symmetric :math:`B_{kl}` matrix in pascals."""
        return self._symmetric_matrix(self.raw_group_b)

    def group_interaction_energy(self, temperature: Tensor) -> Tensor:
        """Return :math:`A_{kl}(298.15/T)^{B_{kl}/A_{kl}-1}` in pascals."""
        if bool((~torch.isfinite(temperature) | (temperature <= 0.0)).any()):
            raise ValueError("temperature must be finite and positive")
        group_a = self.group_a
        group_b = self.group_b
        is_zero = group_a == 0.0
        safe_a = torch.where(is_zero, torch.ones_like(group_a), group_a)
        exponent = group_b / safe_a - 1.0
        ratio = self.reference_temperature / temperature[..., None, None]
        value = group_a * ratio.pow(exponent)
        return torch.where(is_zero, torch.zeros_like(value), value)

    def kij(
        self,
        temperature: Tensor,
        pure_a: Tensor,
        pure_b: Tensor,
    ) -> Tensor:
        """Evaluate the symmetric component BIP matrix.

        ``pure_a`` and ``pure_b`` must be the PR78 pure-component parameters
        at ``temperature``. This dependence is essential: PPR78 is not a
        standalone ``A + B/T`` law.
        """
        if pure_a.shape[-1] != self.ncomponents or pure_b.shape[-1] != self.ncomponents:
            raise ValueError("PPR78 pure parameters must have one value per component")
        if not bool(
            torch.isfinite(pure_a).all()
            & torch.isfinite(pure_b).all()
            & (pure_a > 0.0).all()
            & (pure_b > 0.0).all()
        ):
            raise ValueError("PPR78 pure a and b parameters must be finite and positive")

        differences = self.group_fractions[:, None, :] - self.group_fractions[None, :, :]
        group_energy = self.group_interaction_energy(temperature)
        group_term = -0.5 * torch.einsum(
            "ijg,...gh,ijh->...ij",
            differences,
            group_energy,
            differences,
        )
        delta = torch.sqrt(pure_a) / pure_b
        pure_term = (delta[..., :, None] - delta[..., None, :]).square()
        denominator = (
            2.0
            * torch.sqrt(pure_a[..., :, None] * pure_a[..., None, :])
            / (pure_b[..., :, None] * pure_b[..., None, :])
        )
        result = (group_term - pure_term) / denominator
        return result - torch.diag_embed(torch.diagonal(result, dim1=-2, dim2=-1))

    def cross_a(
        self,
        temperature: Tensor,
        pure_a: Tensor,
        pure_b: Tensor,
    ) -> Tensor:
        """Return PPR78 unlike attraction parameters."""
        return torch.sqrt(pure_a[..., :, None] * pure_a[..., None, :]) * (
            1.0 - self.kij(temperature, pure_a, pure_b)
        )

    def partial_b(self, composition: Tensor, pure_b: Tensor) -> Tensor:
        """Return partial covolumes for the PPR78 linear covolume rule."""
        x = normalize_composition(composition)
        return pure_b + torch.zeros_like(x)

    def forward(
        self,
        temperature: Tensor,
        composition: Tensor,
        pure_a: Tensor,
        pure_b: Tensor,
    ) -> tuple[Tensor, Tensor]:
        """Return PPR78 mixture ``a`` and linear mixture ``b``."""
        x = normalize_composition(composition)
        aij = self.cross_a(temperature, pure_a, pure_b)
        am = torch.einsum("...i,...ij,...j->...", x, aij, x)
        bm = torch.sum(x * pure_b, dim=-1)
        return am, bm

ncomponents property

ncomponents

Number of component decompositions.

ngroups property

ngroups

Number of structural groups.

group_a property

group_a

Return the symmetric :math:A_{kl} matrix in pascals.

group_b property

group_b

Return the symmetric :math:B_{kl} matrix in pascals.

group_interaction_energy

group_interaction_energy(temperature)

Return :math:A_{kl}(298.15/T)^{B_{kl}/A_{kl}-1} in pascals.

Source code in src/torch_flash/mixing/rules.py
def group_interaction_energy(self, temperature: Tensor) -> Tensor:
    """Return :math:`A_{kl}(298.15/T)^{B_{kl}/A_{kl}-1}` in pascals."""
    if bool((~torch.isfinite(temperature) | (temperature <= 0.0)).any()):
        raise ValueError("temperature must be finite and positive")
    group_a = self.group_a
    group_b = self.group_b
    is_zero = group_a == 0.0
    safe_a = torch.where(is_zero, torch.ones_like(group_a), group_a)
    exponent = group_b / safe_a - 1.0
    ratio = self.reference_temperature / temperature[..., None, None]
    value = group_a * ratio.pow(exponent)
    return torch.where(is_zero, torch.zeros_like(value), value)

kij

kij(temperature, pure_a, pure_b)

Evaluate the symmetric component BIP matrix.

pure_a and pure_b must be the PR78 pure-component parameters at temperature. This dependence is essential: PPR78 is not a standalone A + B/T law.

Source code in src/torch_flash/mixing/rules.py
def kij(
    self,
    temperature: Tensor,
    pure_a: Tensor,
    pure_b: Tensor,
) -> Tensor:
    """Evaluate the symmetric component BIP matrix.

    ``pure_a`` and ``pure_b`` must be the PR78 pure-component parameters
    at ``temperature``. This dependence is essential: PPR78 is not a
    standalone ``A + B/T`` law.
    """
    if pure_a.shape[-1] != self.ncomponents or pure_b.shape[-1] != self.ncomponents:
        raise ValueError("PPR78 pure parameters must have one value per component")
    if not bool(
        torch.isfinite(pure_a).all()
        & torch.isfinite(pure_b).all()
        & (pure_a > 0.0).all()
        & (pure_b > 0.0).all()
    ):
        raise ValueError("PPR78 pure a and b parameters must be finite and positive")

    differences = self.group_fractions[:, None, :] - self.group_fractions[None, :, :]
    group_energy = self.group_interaction_energy(temperature)
    group_term = -0.5 * torch.einsum(
        "ijg,...gh,ijh->...ij",
        differences,
        group_energy,
        differences,
    )
    delta = torch.sqrt(pure_a) / pure_b
    pure_term = (delta[..., :, None] - delta[..., None, :]).square()
    denominator = (
        2.0
        * torch.sqrt(pure_a[..., :, None] * pure_a[..., None, :])
        / (pure_b[..., :, None] * pure_b[..., None, :])
    )
    result = (group_term - pure_term) / denominator
    return result - torch.diag_embed(torch.diagonal(result, dim1=-2, dim2=-1))

cross_a

cross_a(temperature, pure_a, pure_b)

Return PPR78 unlike attraction parameters.

Source code in src/torch_flash/mixing/rules.py
def cross_a(
    self,
    temperature: Tensor,
    pure_a: Tensor,
    pure_b: Tensor,
) -> Tensor:
    """Return PPR78 unlike attraction parameters."""
    return torch.sqrt(pure_a[..., :, None] * pure_a[..., None, :]) * (
        1.0 - self.kij(temperature, pure_a, pure_b)
    )

partial_b

partial_b(composition, pure_b)

Return partial covolumes for the PPR78 linear covolume rule.

Source code in src/torch_flash/mixing/rules.py
def partial_b(self, composition: Tensor, pure_b: Tensor) -> Tensor:
    """Return partial covolumes for the PPR78 linear covolume rule."""
    x = normalize_composition(composition)
    return pure_b + torch.zeros_like(x)

forward

forward(temperature, composition, pure_a, pure_b)

Return PPR78 mixture a and linear mixture b.

Source code in src/torch_flash/mixing/rules.py
def forward(
    self,
    temperature: Tensor,
    composition: Tensor,
    pure_a: Tensor,
    pure_b: Tensor,
) -> tuple[Tensor, Tensor]:
    """Return PPR78 mixture ``a`` and linear mixture ``b``."""
    x = normalize_composition(composition)
    aij = self.cross_a(temperature, pure_a, pure_b)
    am = torch.einsum("...i,...ij,...j->...", x, aij, x)
    bm = torch.sum(x * pure_b, dim=-1)
    return am, bm

QuadraticMixing

Bases: Module

van der Waals one-fluid mixing with kij and lij parameters.

The unlike attraction and covolume parameters are

aij = sqrt(ai*aj)*(1-kij) and bij = 0.5*(bi+bj)*(1-lij), respectively.

lij=0 recovers the conventional linear covolume rule exactly and uses a dedicated O(N) evaluation path. This convention is also exposed by ThermoPack's get_lij/set_lij cubic interface.

Source code in src/torch_flash/mixing/rules.py
class QuadraticMixing(nn.Module):
    """van der Waals one-fluid mixing with ``kij`` and ``lij`` parameters.

    The unlike attraction and covolume parameters are

    ``aij = sqrt(ai*aj)*(1-kij)`` and
    ``bij = 0.5*(bi+bj)*(1-lij)``, respectively.

    ``lij=0`` recovers the conventional linear covolume rule exactly and uses
    a dedicated O(N) evaluation path.
    This convention is also exposed by ThermoPack's ``get_lij``/``set_lij``
    cubic interface.
    """

    def __init__(
        self,
        kij: Tensor,
        lij: Tensor | None = None,
        *,
        trainable: bool = False,
        trainable_lij: bool = False,
    ) -> None:
        super().__init__()
        if kij.ndim != 2 or kij.shape[0] != kij.shape[1]:
            raise ValueError("kij must be a square matrix")
        if not bool(torch.isfinite(kij).all()):
            raise ValueError("kij must contain only finite values")
        if not torch.allclose(kij, kij.mT):
            raise ValueError("kij must be symmetric")
        if lij is None:
            lij = torch.zeros_like(kij)
        elif lij.shape != kij.shape:
            raise ValueError("lij must have the same square shape as kij")
        elif not bool(torch.isfinite(lij).all()):
            raise ValueError("lij must contain only finite values")
        elif not torch.allclose(lij, lij.mT):
            raise ValueError("lij must be symmetric")
        if trainable:
            self.raw_kij = nn.Parameter(kij.clone())
        else:
            self.register_buffer("raw_kij", kij.clone())
        if trainable_lij:
            self.raw_lij = nn.Parameter(lij.clone())
        else:
            self.register_buffer("raw_lij", lij.clone())
        self._linear_covolume = not trainable_lij and not bool(torch.count_nonzero(lij))

    @staticmethod
    def _symmetric_zero_diagonal(values: Tensor) -> Tensor:
        symmetric = 0.5 * (values + values.mT)
        return symmetric - torch.diag_embed(torch.diagonal(symmetric))

    @property
    def kij(self) -> Tensor:
        """Symmetrized interaction matrix with an exactly zero diagonal."""
        return self._symmetric_zero_diagonal(self.raw_kij)

    @property
    def lij(self) -> Tensor:
        """Symmetrized covolume interaction matrix with zero diagonal."""
        return self._symmetric_zero_diagonal(self.raw_lij)

    def forward(
        self,
        temperature: Tensor,
        composition: Tensor,
        pure_a: Tensor,
        pure_b: Tensor,
    ) -> tuple[Tensor, Tensor]:
        """Return mixture ``a`` and ``b`` parameters."""
        del temperature
        x = normalize_composition(composition)
        aij = self.cross_a(pure_a)
        am = torch.einsum("...i,...ij,...j->...", x, aij, x)
        if self._linear_covolume:
            bm = torch.sum(x * pure_b, dim=-1)
        else:
            bm = torch.einsum("...i,...ij,...j->...", x, self.cross_b(pure_b), x)
        return am, bm

    def cross_a(self, pure_a: Tensor) -> Tensor:
        """Return the matrix of unlike energy parameters ``aij``."""
        return torch.sqrt(pure_a[..., :, None] * pure_a[..., None, :]) * (1.0 - self.kij)

    def cross_b(self, pure_b: Tensor) -> Tensor:
        """Return unlike covolumes ``bij = (bi+bj)*(1-lij)/2``."""
        return 0.5 * (pure_b[..., :, None] + pure_b[..., None, :]) * (1.0 - self.lij)

    def partial_b(self, composition: Tensor, pure_b: Tensor) -> Tensor:
        """Return partial molar covolumes ``d(n*b_mix)/d(n_i)``."""
        x = normalize_composition(composition)
        if self._linear_covolume:
            return pure_b + torch.zeros_like(x)
        bij = self.cross_b(pure_b)
        bm = torch.einsum("...i,...ij,...j->...", x, bij, x)
        return 2.0 * torch.einsum("...j,...ij->...i", x, bij) - bm[..., None]

kij property

kij

Symmetrized interaction matrix with an exactly zero diagonal.

lij property

lij

Symmetrized covolume interaction matrix with zero diagonal.

forward

forward(temperature, composition, pure_a, pure_b)

Return mixture a and b parameters.

Source code in src/torch_flash/mixing/rules.py
def forward(
    self,
    temperature: Tensor,
    composition: Tensor,
    pure_a: Tensor,
    pure_b: Tensor,
) -> tuple[Tensor, Tensor]:
    """Return mixture ``a`` and ``b`` parameters."""
    del temperature
    x = normalize_composition(composition)
    aij = self.cross_a(pure_a)
    am = torch.einsum("...i,...ij,...j->...", x, aij, x)
    if self._linear_covolume:
        bm = torch.sum(x * pure_b, dim=-1)
    else:
        bm = torch.einsum("...i,...ij,...j->...", x, self.cross_b(pure_b), x)
    return am, bm

cross_a

cross_a(pure_a)

Return the matrix of unlike energy parameters aij.

Source code in src/torch_flash/mixing/rules.py
def cross_a(self, pure_a: Tensor) -> Tensor:
    """Return the matrix of unlike energy parameters ``aij``."""
    return torch.sqrt(pure_a[..., :, None] * pure_a[..., None, :]) * (1.0 - self.kij)

cross_b

cross_b(pure_b)

Return unlike covolumes bij = (bi+bj)*(1-lij)/2.

Source code in src/torch_flash/mixing/rules.py
def cross_b(self, pure_b: Tensor) -> Tensor:
    """Return unlike covolumes ``bij = (bi+bj)*(1-lij)/2``."""
    return 0.5 * (pure_b[..., :, None] + pure_b[..., None, :]) * (1.0 - self.lij)

partial_b

partial_b(composition, pure_b)

Return partial molar covolumes d(n*b_mix)/d(n_i).

Source code in src/torch_flash/mixing/rules.py
def partial_b(self, composition: Tensor, pure_b: Tensor) -> Tensor:
    """Return partial molar covolumes ``d(n*b_mix)/d(n_i)``."""
    x = normalize_composition(composition)
    if self._linear_covolume:
        return pure_b + torch.zeros_like(x)
    bij = self.cross_b(pure_b)
    bm = torch.einsum("...i,...ij,...j->...", x, bij, x)
    return 2.0 * torch.einsum("...j,...ij->...i", x, bij) - bm[..., None]

TemperatureDependentQuadraticMixing

Bases: Module

van der Waals mixing with kij(T) = Aij + Bij/T.

Aij is dimensionless and Bij is in kelvin. This form is used by Yan et al. for petroleum CPA calculations involving light hydrocarbons and water (Fluid Phase Equilibria 276, 2009, 75-85, doi:10.1016/j.fluid.2008.10.007).

Source code in src/torch_flash/mixing/rules.py
class TemperatureDependentQuadraticMixing(nn.Module):
    """van der Waals mixing with ``kij(T) = Aij + Bij/T``.

    ``Aij`` is dimensionless and ``Bij`` is in kelvin. This form is used by
    Yan et al. for petroleum CPA calculations involving light hydrocarbons
    and water (Fluid Phase Equilibria 276, 2009, 75-85,
    doi:10.1016/j.fluid.2008.10.007).
    """

    def __init__(
        self,
        a: Tensor,
        b: Tensor,
        lij: Tensor | None = None,
        *,
        trainable: bool = False,
        trainable_lij: bool = False,
    ) -> None:
        super().__init__()
        if a.ndim != 2 or a.shape[0] != a.shape[1]:
            raise ValueError("temperature-dependent kij A must be a square matrix")
        if b.shape != a.shape:
            raise ValueError("temperature-dependent kij A and B matrices must have equal shapes")
        if not bool(torch.isfinite(a).all()) or not bool(torch.isfinite(b).all()):
            raise ValueError("temperature-dependent kij A and B matrices must be finite")
        if not torch.allclose(a, a.mT) or not torch.allclose(b, b.mT):
            raise ValueError("temperature-dependent kij A and B matrices must be symmetric")
        if lij is None:
            lij = torch.zeros_like(a)
        elif lij.shape != a.shape:
            raise ValueError("lij must have the same square shape as temperature-dependent kij")
        elif not bool(torch.isfinite(lij).all()):
            raise ValueError("lij must contain only finite values")
        elif not torch.allclose(lij, lij.mT):
            raise ValueError("lij must be symmetric")
        if trainable:
            self.raw_a = nn.Parameter(a.clone())
            self.raw_b = nn.Parameter(b.clone())
        else:
            self.register_buffer("raw_a", a.clone())
            self.register_buffer("raw_b", b.clone())
        if trainable_lij:
            self.raw_lij = nn.Parameter(lij.clone())
        else:
            self.register_buffer("raw_lij", lij.clone())
        self._linear_covolume = not trainable_lij and not bool(torch.count_nonzero(lij))

    @staticmethod
    def _symmetric_zero_diagonal(values: Tensor) -> Tensor:
        symmetric = 0.5 * (values + values.mT)
        return symmetric - torch.diag_embed(torch.diagonal(symmetric))

    @property
    def a(self) -> Tensor:
        """Return the symmetric dimensionless ``Aij`` matrix."""
        return self._symmetric_zero_diagonal(self.raw_a)

    @property
    def b(self) -> Tensor:
        """Return the symmetric ``Bij`` matrix in kelvin."""
        return self._symmetric_zero_diagonal(self.raw_b)

    @property
    def lij(self) -> Tensor:
        """Return the symmetric dimensionless covolume interaction matrix."""
        return self._symmetric_zero_diagonal(self.raw_lij)

    def kij(self, temperature: Tensor) -> Tensor:
        """Evaluate the interaction matrix at temperature in kelvin."""
        if bool((~torch.isfinite(temperature) | (temperature <= 0.0)).any()):
            raise ValueError("temperature must be finite and positive")
        return self.a + self.b / temperature[..., None, None]

    def forward(
        self,
        temperature: Tensor,
        composition: Tensor,
        pure_a: Tensor,
        pure_b: Tensor,
    ) -> tuple[Tensor, Tensor]:
        """Return mixture ``a`` and ``b`` parameters."""
        x = normalize_composition(composition)
        aij = torch.sqrt(pure_a[..., :, None] * pure_a[..., None, :]) * (
            1.0 - self.kij(temperature)
        )
        am = torch.einsum("...i,...ij,...j->...", x, aij, x)
        if self._linear_covolume:
            bm = torch.sum(x * pure_b, dim=-1)
        else:
            bm = torch.einsum("...i,...ij,...j->...", x, self.cross_b(pure_b), x)
        return am, bm

    def cross_a(self, temperature: Tensor, pure_a: Tensor) -> Tensor:
        """Return temperature-dependent unlike energy parameters."""
        return torch.sqrt(pure_a[..., :, None] * pure_a[..., None, :]) * (
            1.0 - self.kij(temperature)
        )

    def cross_b(self, pure_b: Tensor) -> Tensor:
        """Return unlike covolumes ``bij = (bi+bj)*(1-lij)/2``."""
        return 0.5 * (pure_b[..., :, None] + pure_b[..., None, :]) * (1.0 - self.lij)

    def partial_b(self, composition: Tensor, pure_b: Tensor) -> Tensor:
        """Return partial molar covolumes ``d(n*b_mix)/d(n_i)``."""
        x = normalize_composition(composition)
        if self._linear_covolume:
            return pure_b + torch.zeros_like(x)
        bij = self.cross_b(pure_b)
        bm = torch.einsum("...i,...ij,...j->...", x, bij, x)
        return 2.0 * torch.einsum("...j,...ij->...i", x, bij) - bm[..., None]

a property

a

Return the symmetric dimensionless Aij matrix.

b property

b

Return the symmetric Bij matrix in kelvin.

lij property

lij

Return the symmetric dimensionless covolume interaction matrix.

kij

kij(temperature)

Evaluate the interaction matrix at temperature in kelvin.

Source code in src/torch_flash/mixing/rules.py
def kij(self, temperature: Tensor) -> Tensor:
    """Evaluate the interaction matrix at temperature in kelvin."""
    if bool((~torch.isfinite(temperature) | (temperature <= 0.0)).any()):
        raise ValueError("temperature must be finite and positive")
    return self.a + self.b / temperature[..., None, None]

forward

forward(temperature, composition, pure_a, pure_b)

Return mixture a and b parameters.

Source code in src/torch_flash/mixing/rules.py
def forward(
    self,
    temperature: Tensor,
    composition: Tensor,
    pure_a: Tensor,
    pure_b: Tensor,
) -> tuple[Tensor, Tensor]:
    """Return mixture ``a`` and ``b`` parameters."""
    x = normalize_composition(composition)
    aij = torch.sqrt(pure_a[..., :, None] * pure_a[..., None, :]) * (
        1.0 - self.kij(temperature)
    )
    am = torch.einsum("...i,...ij,...j->...", x, aij, x)
    if self._linear_covolume:
        bm = torch.sum(x * pure_b, dim=-1)
    else:
        bm = torch.einsum("...i,...ij,...j->...", x, self.cross_b(pure_b), x)
    return am, bm

cross_a

cross_a(temperature, pure_a)

Return temperature-dependent unlike energy parameters.

Source code in src/torch_flash/mixing/rules.py
def cross_a(self, temperature: Tensor, pure_a: Tensor) -> Tensor:
    """Return temperature-dependent unlike energy parameters."""
    return torch.sqrt(pure_a[..., :, None] * pure_a[..., None, :]) * (
        1.0 - self.kij(temperature)
    )

cross_b

cross_b(pure_b)

Return unlike covolumes bij = (bi+bj)*(1-lij)/2.

Source code in src/torch_flash/mixing/rules.py
def cross_b(self, pure_b: Tensor) -> Tensor:
    """Return unlike covolumes ``bij = (bi+bj)*(1-lij)/2``."""
    return 0.5 * (pure_b[..., :, None] + pure_b[..., None, :]) * (1.0 - self.lij)

partial_b

partial_b(composition, pure_b)

Return partial molar covolumes d(n*b_mix)/d(n_i).

Source code in src/torch_flash/mixing/rules.py
def partial_b(self, composition: Tensor, pure_b: Tensor) -> Tensor:
    """Return partial molar covolumes ``d(n*b_mix)/d(n_i)``."""
    x = normalize_composition(composition)
    if self._linear_covolume:
        return pure_b + torch.zeros_like(x)
    bij = self.cross_b(pure_b)
    bm = torch.einsum("...i,...ij,...j->...", x, bij, x)
    return 2.0 * torch.einsum("...j,...ij->...i", x, bij) - bm[..., None]

torch_flash.flash

Stability-tested phase-equilibrium flash calculations.

batched_two_phase_flash

batched_two_phase_flash(model, state, *, initial_k_values=None, tolerance=1e-08, substitution_iterations=12, newton_iterations=16)

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. 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,
    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. ``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")
    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 = 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,
            "liquid",
        )
        log_phi_vapor = model.log_fugacity_coefficients(
            temperature,
            pressure,
            split.vapor_composition,
            "vapor",
        )
        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 = 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,
    )

multiphase_flash

multiphase_flash(model, state, *, initial_k_values=None, tolerance=1e-08, max_iterations=100)

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

The initial release supports arbitrary fixed phase counts but automated phase discovery is conservative. For VLL/VLW work, supplying physically informed initial K values remains recommended.

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.

    The initial release supports arbitrary fixed phase counts but automated
    phase discovery is conservative. For VLL/VLW work, supplying physically
    informed initial K values remains recommended.
    """
    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,
        },
    )

solve_generalized_rachford_rice

solve_generalized_rachford_rice(composition, k_values, *, tolerance=1e-12, max_iterations=100)

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

k_values has shape (nphases - 1, ncomponents). The returned phase fractions place the reference phase first.

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.

    ``k_values`` has shape ``(nphases - 1, ncomponents)``. The returned phase
    fractions place the reference phase first.
    """
    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, state, *, reference_phase='stable', initial_compositions=None, tolerance=1e-09, max_iterations=40)

Minimize normalized tangent-plane distance from several trial phases.

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,
) -> StabilityResult:
    """Minimize normalized tangent-plane distance from several trial phases."""
    if state.composition.ndim != 1:
        raise ValueError("stability analysis currently accepts one composition vector")
    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)
        trial = trial / trial.sum()
        coordinates = torch.log(trial[:-1]) - torch.log(trial[-1])

        def 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(
            objective,
            coordinates,
            tolerance=tolerance,
            max_iterations=max_iterations,
        )
        if float(value) < float(best_value):
            best_value = value
            best_composition = _independent_softmax(q)
            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, state, *, initial_k_values=None, check_stability=True, tolerance=1e-08, max_iterations=100, raise_on_failure=False)

Solve an isothermal two-phase flash by hybrid substitution/Newton steps.

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 an isothermal two-phase flash by hybrid substitution/Newton steps."""
    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},
    )

torch_flash.material_balance

Material-balance equations.

rachford_rice_numpy

rachford_rice_numpy(composition, k_values, *, tolerance=1e-15, max_iterations=100)

NumPy compatibility wrapper matching the Whitson contest signature.

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.

    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),
    )

torch_flash.properties

Homogeneous-state thermodynamic property calculations.

ThermodynamicDerivatives dataclass

First homogeneous-state derivatives.

Component derivatives are provided in both unconstrained softmax-logit coordinates and n-1 independent mole fractions, where the last mole fraction is 1 - sum(x_independent). Temperature and pressure derivatives hold composition fixed.

Mole-number derivatives hold T and P fixed and are evaluated at n_i = x_i mol (a one-mole total basis). Because all returned properties are intensive, the corresponding derivative at another total amount is this value divided by that amount in mol.

Fugacity derivatives have Pa per coordinate units. dlog_fugacity_* differentiates the dimensionless ln(f_i / p_standard). Chemical-potential derivatives have J/mol per coordinate units; the reduced variants differentiate mu_i / (R*T). Molar-volume derivatives have m3/mol per coordinate units.

Source code in src/torch_flash/properties/state.py
@dataclass(frozen=True)
class ThermodynamicDerivatives:
    """First homogeneous-state derivatives.

    Component derivatives are provided in both unconstrained softmax-logit
    coordinates and ``n-1`` independent mole fractions, where the last mole
    fraction is ``1 - sum(x_independent)``. Temperature and pressure
    derivatives hold composition fixed.

    Mole-number derivatives hold ``T`` and ``P`` fixed and are evaluated at
    ``n_i = x_i mol`` (a one-mole total basis). Because all returned
    properties are intensive, the corresponding derivative at another total
    amount is this value divided by that amount in mol.

    Fugacity derivatives have Pa per coordinate units.
    ``dlog_fugacity_*`` differentiates the dimensionless
    ``ln(f_i / p_standard)``. Chemical-potential derivatives have J/mol per
    coordinate units; the reduced variants differentiate ``mu_i / (R*T)``.
    Molar-volume derivatives have m3/mol per coordinate units.
    """

    dfugacity_coefficient_dlogits: Tensor
    dfugacity_coefficient_dindependent_composition: Tensor
    dfugacity_coefficient_dtemperature: Tensor
    dfugacity_coefficient_dpressure: Tensor
    dfugacity_coefficient_dmoles: Tensor
    dlog_fugacity_coefficient_dlogits: Tensor
    dlog_fugacity_coefficient_dindependent_composition: Tensor
    dlog_fugacity_coefficient_dtemperature: Tensor
    dlog_fugacity_coefficient_dpressure: Tensor
    dlog_fugacity_coefficient_dmoles: Tensor
    dfugacity_dlogits: Tensor
    dfugacity_dindependent_composition: Tensor
    dfugacity_dtemperature: Tensor
    dfugacity_dpressure: Tensor
    dfugacity_dmoles: Tensor
    dlog_fugacity_dlogits: Tensor
    dlog_fugacity_dindependent_composition: Tensor
    dlog_fugacity_dtemperature: Tensor
    dlog_fugacity_dpressure: Tensor
    dlog_fugacity_dmoles: Tensor

    dchemical_potential_dlogits: Tensor
    dchemical_potential_dindependent_composition: Tensor
    dchemical_potential_dtemperature: Tensor
    dchemical_potential_dpressure: Tensor
    dchemical_potential_dmoles: Tensor
    dreduced_chemical_potential_dlogits: Tensor
    dreduced_chemical_potential_dindependent_composition: Tensor
    dreduced_chemical_potential_dtemperature: Tensor
    dreduced_chemical_potential_dpressure: Tensor
    dreduced_chemical_potential_dmoles: Tensor
    dmolar_volume_dlogits: Tensor
    dmolar_volume_dindependent_composition: Tensor
    dmolar_volume_dtemperature: Tensor
    dmolar_volume_dpressure: Tensor
    dmolar_volume_dmoles: Tensor
    dgibbs_dtemperature: Tensor
    dgibbs_dpressure: Tensor

ThermalProperties dataclass

Caloric and response properties of one homogeneous phase.

All quantities are molar SI except the Joule-Thomson coefficient (K/Pa) and speed of sound (m/s). reduced_* free energies are dimensionless molar quantities divided by R*T.

Source code in src/torch_flash/properties/thermal.py
@dataclass(frozen=True)
class ThermalProperties:
    """Caloric and response properties of one homogeneous phase.

    All quantities are molar SI except the Joule-Thomson coefficient (K/Pa)
    and speed of sound (m/s). ``reduced_*`` free energies are dimensionless
    molar quantities divided by ``R*T``.
    """

    molar_enthalpy: Tensor
    molar_internal_energy: Tensor
    molar_entropy: Tensor
    molar_helmholtz_energy: Tensor
    molar_gibbs_energy: Tensor
    reduced_helmholtz_energy: Tensor
    reduced_gibbs_energy: Tensor
    reduced_residual_helmholtz_energy: Tensor
    reduced_residual_gibbs_energy: Tensor
    isobaric_heat_capacity: Tensor
    isochoric_heat_capacity: Tensor
    joule_thomson_coefficient: Tensor
    speed_of_sound: Tensor | None
    residual_enthalpy: Tensor
    residual_entropy: Tensor

identify_flash_phases

identify_flash_phases(phases, *, ambiguity_relative_tolerance=DEFAULT_AMBIGUITY_RELATIVE_TOLERANCE)

Fill unavailable multiphase identities by molar-volume ordering.

Existing V/b identifications are preserved. If no phase has a cubic covolume diagnostic, the least-dense phase (largest molar volume) is labeled vapor and the remaining phases liquid. Near-equal leading volumes are returned as ambiguous unknown because density ordering cannot distinguish liquid-liquid equilibrium from a vapor-liquid split.

Source code in src/torch_flash/properties/phase_identification.py
def identify_flash_phases(
    phases: tuple[PhaseProperties, ...],
    *,
    ambiguity_relative_tolerance: float = DEFAULT_AMBIGUITY_RELATIVE_TOLERANCE,
) -> tuple[PhaseProperties, ...]:
    """Fill unavailable multiphase identities by molar-volume ordering.

    Existing ``V/b`` identifications are preserved. If no phase has a cubic
    covolume diagnostic, the least-dense phase (largest molar volume) is
    labeled vapor and the remaining phases liquid. Near-equal leading volumes
    are returned as ambiguous ``unknown`` because density ordering cannot
    distinguish liquid-liquid equilibrium from a vapor-liquid split.
    """
    _validate_options(DEFAULT_VOLUME_TO_COVOLUME_THRESHOLD, ambiguity_relative_tolerance)
    if not phases:
        return phases
    has_covolume_identification = tuple(
        phase.phase_identification is not None
        and phase.phase_identification.method != "unavailable"
        for phase in phases
    )
    if any(has_covolume_identification):
        return phases
    if len(phases) == 1:
        return phases

    volumes = torch.stack(tuple(phase.molar_volume for phase in phases))
    if volumes.ndim != 1 or not bool(torch.isfinite(volumes).all()) or bool((volumes <= 0.0).any()):
        raise ValueError("phase molar volumes must be finite positive scalars")
    sorted_volumes, _ = torch.sort(volumes)
    vapor_volume = sorted_volumes[-1]
    next_volume = sorted_volumes[-2]
    separator = torch.sqrt(vapor_volume * next_volume)
    separation = vapor_volume / next_volume
    ambiguous = bool(separation <= 1.0 + ambiguity_relative_tolerance)

    identified = []
    for phase, volume in zip(phases, volumes, strict=True):
        if ambiguous:
            kind: PhaseIdentityKind = "unknown"
        else:
            kind = "vapor" if bool(volume > separator) else "liquid"
        identification = PhaseIdentification(
            kind=kind,
            method="density-ordering",
            criterion_value=volume,
            threshold=separator,
            ambiguous=ambiguous,
        )
        identified.append(replace(phase, phase_identification=identification))
    return tuple(identified)

identify_phase

identify_phase(model, state, phase='stable', *, threshold=DEFAULT_VOLUME_TO_COVOLUME_THRESHOLD, ambiguity_relative_tolerance=DEFAULT_AMBIGUITY_RELATIVE_TOLERANCE)

Identify a scalar homogeneous state using the Pedersen V/b rule.

The default threshold of 1.75 is documented by Pedersen et al. for SRK and PR. Models that expose compatible cubic-family mixture_parameters can use the same machinery, but a non-SRK/PR threshold requires independent validation. If the model does not expose a covolume, unknown is returned rather than inferring physical identity from the selected root.

Source code in src/torch_flash/properties/phase_identification.py
def identify_phase(
    model: object,
    state: ChemicalState,
    phase: PhaseKind = "stable",
    *,
    threshold: float = DEFAULT_VOLUME_TO_COVOLUME_THRESHOLD,
    ambiguity_relative_tolerance: float = DEFAULT_AMBIGUITY_RELATIVE_TOLERANCE,
) -> PhaseIdentification:
    """Identify a scalar homogeneous state using the Pedersen ``V/b`` rule.

    The default threshold of 1.75 is documented by Pedersen et al. for SRK and
    PR. Models that expose compatible cubic-family ``mixture_parameters`` can
    use the same machinery, but a non-SRK/PR threshold requires independent
    validation. If the model does not expose a covolume, ``unknown`` is
    returned rather than inferring physical identity from the selected root.
    """
    _validate_options(threshold, ambiguity_relative_tolerance)
    if state.temperature.ndim != 0 or state.pressure.ndim != 0 or state.composition.ndim != 1:
        raise ValueError("phase identification currently accepts one scalar T-P state")
    select_z: Any = getattr(model, "select_z", None)
    if not callable(select_z):
        raise TypeError("phase-identification model must implement select_z")
    compressibility_factor = select_z(
        state.temperature,
        state.pressure,
        state.composition,
        phase,
    )
    return _from_state_values(
        model,
        state,
        compressibility_factor,
        threshold=threshold,
        ambiguity_relative_tolerance=ambiguity_relative_tolerance,
    )

volume_to_covolume_ratio

volume_to_covolume_ratio(model, state, phase='stable')

Return the differentiable cubic-family V/b phase diagnostic.

Leading batch dimensions are supported. Cubic volume translations are excluded because Pedersen's criterion uses the volume entering the cubic repulsive term. A model without mixture_parameters has no defined covolume and raises TypeError.

Source code in src/torch_flash/properties/phase_identification.py
def volume_to_covolume_ratio(
    model: object,
    state: ChemicalState,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return the differentiable cubic-family ``V/b`` phase diagnostic.

    Leading batch dimensions are supported. Cubic volume translations are
    excluded because Pedersen's criterion uses the volume entering the cubic
    repulsive term. A model without ``mixture_parameters`` has no defined
    covolume and raises ``TypeError``.
    """
    select_z: Any = getattr(model, "select_z", None)
    if not callable(select_z):
        raise TypeError("phase-identification model must implement select_z")
    covolume = _covolume(model, state)
    if covolume is None:
        raise TypeError("phase-identification model does not expose a mixture covolume")
    compressibility_factor = cast(
        Tensor,
        select_z(
            state.temperature,
            state.pressure,
            state.composition,
            phase,
        ),
    )
    equation_of_state_volume = compressibility_factor * R * state.temperature / state.pressure
    ratio = equation_of_state_volume / covolume
    if not bool(torch.isfinite(ratio).all()) or bool((ratio <= 0.0).any()):
        raise ValueError("volume-to-covolume ratio must be finite and positive")
    return ratio

fugacities_tv

fugacities_tv(model, temperature, volume, moles)

Return component fugacities in Pa from an explicit T-V-n state.

Source code in src/torch_flash/properties/state.py
def fugacities_tv(
    model: HelmholtzStateModel,
    temperature: Tensor,
    volume: Tensor,
    moles: Tensor,
) -> Tensor:
    """Return component fugacities in Pa from an explicit ``T-V-n`` state."""
    return STANDARD_PRESSURE * torch.exp(log_fugacities_tv(model, temperature, volume, moles))

log_fugacities_tv

log_fugacities_tv(model, temperature, volume, moles)

Return ln(f_i/p_standard) from an explicit T-V-n state.

volume is the total physical volume in m3 and moles are component amounts in mol. The identity

ln(f_i/p_standard) = ln(n_i*R*T/(V*p_standard)) + d(A^R/RT)/dn_i

provides an independent Helmholtz-route check of the usual TP fugacity calculation. The model's residual Helmholtz energy must be extensive and referenced to an ideal gas at the same physical volume.

Source code in src/torch_flash/properties/state.py
def log_fugacities_tv(
    model: HelmholtzStateModel,
    temperature: Tensor,
    volume: Tensor,
    moles: Tensor,
) -> Tensor:
    """Return ``ln(f_i/p_standard)`` from an explicit ``T-V-n`` state.

    ``volume`` is the total physical volume in m3 and ``moles`` are component
    amounts in mol. The identity

    ``ln(f_i/p_standard) = ln(n_i*R*T/(V*p_standard)) + d(A^R/RT)/dn_i``

    provides an independent Helmholtz-route check of the usual TP fugacity
    calculation. The model's residual Helmholtz energy must be extensive and
    referenced to an ideal gas at the same physical volume.
    """
    if temperature.ndim != 0 or volume.ndim != 0:
        raise ValueError("log_fugacities_tv currently accepts scalar temperature and volume")
    if moles.ndim != 1:
        raise ValueError("log_fugacities_tv currently accepts one mole-number vector")
    if bool((temperature <= 0.0) | (volume <= 0.0)):
        raise ValueError("temperature and volume must be positive")
    if bool((~torch.isfinite(moles)).any() | (moles <= 0.0).any()):
        raise ValueError("moles must be finite and strictly positive")

    residual_mu_rt: Tensor = torch.func.jacrev(
        lambda current_moles: model.residual_helmholtz_rt(
            temperature,
            volume,
            current_moles,
        )
    )(moles)
    ideal_log_fugacity = torch.log(moles * R * temperature / (volume * STANDARD_PRESSURE))
    return ideal_log_fugacity + residual_mu_rt

phase_properties

phase_properties(model, state, phase='stable', *, caloric=True, standard_state=None)

Evaluate a homogeneous state, without any equilibrium calculation.

Fugacities are returned in Pa and logarithmic fugacities are ln(f_i / p_standard) with p_standard = 1 bar. Chemical potentials and total free energies use a zero ideal-gas standard chemical potential at that pressure unless standard_state is supplied. The corresponding dimensionless chemical potential is mu_i / (R*T); it is the thermodynamically meaningful alternative to ln(mu_i), which is not generally defined for a dimensional, reference-dependent quantity that may be negative.

Absolute caloric reference terms are deliberately not invented; returned enthalpy and entropy are residual quantities.

The reduced molar free energies are reduced_gibbs_energy = g/(R*T) and reduced_helmholtz_energy = a/(R*T), with a = g - P*v. The reference-independent departures follow g^R/(R*T) = sum(x_i*ln(phi_i)) and a^R/(R*T) = g^R/(R*T) - Z + 1 + ln(Z), where Z = P*v/(R*T).

At an exactly zero mole fraction, the logarithmic component properties use the smallest positive value of the tensor dtype as a finite trace limit. state_derivatives instead requires a strictly positive, interior composition because logarithmic composition derivatives are singular on the simplex boundary.

Source code in src/torch_flash/properties/state.py
def phase_properties(
    model: StateModel,
    state: ChemicalState,
    phase: PhaseKind = "stable",
    *,
    caloric: bool = True,
    standard_state: StandardState | None = None,
) -> PhaseProperties:
    """Evaluate a homogeneous state, without any equilibrium calculation.

    Fugacities are returned in Pa and logarithmic fugacities are
    ``ln(f_i / p_standard)`` with ``p_standard = 1 bar``. Chemical potentials
    and total free energies use a zero ideal-gas standard chemical potential
    at that pressure unless ``standard_state`` is supplied. The corresponding
    dimensionless chemical potential is ``mu_i / (R*T)``; it is the
    thermodynamically meaningful alternative to ``ln(mu_i)``, which is not
    generally defined for a dimensional, reference-dependent quantity that
    may be negative.

    Absolute caloric reference terms are deliberately not invented; returned
    enthalpy and entropy are residual quantities.

    The reduced molar free energies are
    ``reduced_gibbs_energy = g/(R*T)`` and
    ``reduced_helmholtz_energy = a/(R*T)``, with ``a = g - P*v``. The
    reference-independent departures follow
    ``g^R/(R*T) = sum(x_i*ln(phi_i))`` and
    ``a^R/(R*T) = g^R/(R*T) - Z + 1 + ln(Z)``, where
    ``Z = P*v/(R*T)``.

    At an exactly zero mole fraction, the logarithmic component properties
    use the smallest positive value of the tensor dtype as a finite trace
    limit. ``state_derivatives`` instead requires a strictly positive,
    interior composition because logarithmic composition derivatives are
    singular on the simplex boundary.
    """
    temperature = state.temperature
    pressure = state.pressure
    composition = state.composition
    z_factor = model.select_z(temperature, pressure, composition, phase)
    volume = model.molar_volume(temperature, pressure, composition, phase)
    log_phi = model.log_fugacity_coefficients(temperature, pressure, composition, phase)
    log_fugacity = _log_fugacities_from_coefficients(log_phi, pressure, composition)
    fugacity = STANDARD_PRESSURE * torch.exp(log_fugacity)
    chemical_potentials = _chemical_potentials_from_log_fugacities(
        log_fugacity,
        temperature,
        standard_state,
    )
    reduced_chemical_potentials = chemical_potentials / (R * temperature[..., None])
    gibbs = torch.sum(composition * chemical_potentials, dim=-1)
    pressure_volume_over_rt = pressure * volume / (R * temperature)
    helmholtz = gibbs - pressure * volume
    reduced_gibbs = gibbs / (R * temperature)
    reduced_helmholtz = helmholtz / (R * temperature)
    reduced_residual_gibbs = torch.sum(composition * log_phi, dim=-1)
    reduced_residual_helmholtz = (
        reduced_residual_gibbs - pressure_volume_over_rt + 1.0 + torch.log(pressure_volume_over_rt)
    )

    residual_enthalpy: Tensor | None = None
    residual_entropy: Tensor | None = None
    if caloric and temperature.ndim == 0 and composition.ndim == 1:

        def residual_gibbs_over_t(current_temperature: Tensor) -> Tensor:
            current_log_phi = model.log_fugacity_coefficients(
                current_temperature, pressure, composition, phase
            )
            return R * torch.sum(composition * current_log_phi)

        derivative = torch.func.grad(residual_gibbs_over_t)(temperature)
        residual_enthalpy = -temperature.square() * derivative
        residual_gibbs = R * temperature * torch.sum(composition * log_phi)
        residual_entropy = (residual_enthalpy - residual_gibbs) / temperature

    properties = PhaseProperties(
        kind=phase,
        composition=composition,
        compressibility_factor=z_factor,
        molar_volume=volume,
        log_fugacity_coefficients=log_phi,
        fugacities=fugacity,
        log_fugacities=log_fugacity,
        chemical_potentials=chemical_potentials,
        reduced_chemical_potentials=reduced_chemical_potentials,
        molar_gibbs_energy=gibbs,
        molar_helmholtz_energy=helmholtz,
        reduced_gibbs_energy=reduced_gibbs,
        reduced_helmholtz_energy=reduced_helmholtz,
        reduced_residual_gibbs_energy=reduced_residual_gibbs,
        reduced_residual_helmholtz_energy=reduced_residual_helmholtz,
        residual_enthalpy=residual_enthalpy,
        residual_entropy=residual_entropy,
    )
    return replace(
        properties,
        phase_identification=identify_phase_from_properties(model, state, properties),
    )

state_derivatives

state_derivatives(model, state, phase='stable', *, standard_state=None)

Autodifferentiate TP state properties in several composition coordinates.

Source code in src/torch_flash/properties/state.py
def state_derivatives(
    model: StateModel,
    state: ChemicalState,
    phase: PhaseKind = "stable",
    *,
    standard_state: StandardState | None = None,
) -> ThermodynamicDerivatives:
    """Autodifferentiate TP state properties in several composition coordinates."""
    if state.temperature.ndim != 0 or state.pressure.ndim != 0:
        raise ValueError("state_derivatives currently accepts one scalar T-P state")
    if state.composition.ndim != 1:
        raise ValueError("state_derivatives currently accepts one composition vector")
    if bool((state.composition <= 0.0).any()):
        raise ValueError("state_derivatives requires strictly positive mole fractions")

    def composition_from_logits(logit_values: Tensor) -> Tensor:
        return torch.softmax(logit_values, dim=-1)

    def composition_from_independent(independent: Tensor) -> Tensor:
        return torch.cat((independent, (1.0 - independent.sum()).reshape(1)))

    def composition_from_moles(moles: Tensor) -> Tensor:
        return moles / moles.sum()

    def log_phi_at(composition: Tensor, temperature: Tensor, pressure: Tensor) -> Tensor:
        return model.log_fugacity_coefficients(
            temperature,
            pressure,
            composition,
            phase,
        )

    def log_fugacity_at(composition: Tensor, temperature: Tensor, pressure: Tensor) -> Tensor:
        return _log_fugacities(
            model,
            temperature,
            pressure,
            composition,
            phase,
        )

    def mu_at(composition: Tensor, temperature: Tensor, pressure: Tensor) -> Tensor:
        return _chemical_potentials(
            model,
            temperature,
            pressure,
            composition,
            phase,
            standard_state,
        )

    def volume_at(composition: Tensor, temperature: Tensor, pressure: Tensor) -> Tensor:
        return model.molar_volume(temperature, pressure, composition, phase)

    logits = torch.log(torch.clamp_min(state.composition, 1.0e-300))
    independent = state.composition[:-1]
    unit_moles = state.composition

    PropertyFunction = Callable[[Tensor, Tensor, Tensor], Tensor]

    def apply_logits(function: PropertyFunction, current: Tensor) -> Tensor:
        return function(
            composition_from_logits(current),
            state.temperature,
            state.pressure,
        )

    def apply_independent(function: PropertyFunction, current: Tensor) -> Tensor:
        return function(
            composition_from_independent(current),
            state.temperature,
            state.pressure,
        )

    def apply_moles(function: PropertyFunction, current: Tensor) -> Tensor:
        return function(
            composition_from_moles(current),
            state.temperature,
            state.pressure,
        )

    dlog_phi_dlogits = torch.func.jacrev(lambda value: apply_logits(log_phi_at, value))(logits)
    dlog_phi_dindependent = torch.func.jacrev(lambda value: apply_independent(log_phi_at, value))(
        independent
    )
    dlog_phi_dmoles = torch.func.jacrev(lambda value: apply_moles(log_phi_at, value))(unit_moles)
    dlog_phi_dt = torch.func.jacrev(lambda t: log_phi_at(state.composition, t, state.pressure))(
        state.temperature
    )
    dlog_phi_dp = torch.func.jacrev(lambda p: log_phi_at(state.composition, state.temperature, p))(
        state.pressure
    )

    dlog_fugacity_dlogits = torch.func.jacrev(lambda value: apply_logits(log_fugacity_at, value))(
        logits
    )
    dlog_fugacity_dindependent = torch.func.jacrev(
        lambda value: apply_independent(log_fugacity_at, value)
    )(independent)
    dlog_fugacity_dmoles = torch.func.jacrev(lambda value: apply_moles(log_fugacity_at, value))(
        unit_moles
    )
    dlog_fugacity_dt = torch.func.jacrev(
        lambda t: log_fugacity_at(state.composition, t, state.pressure)
    )(state.temperature)
    dlog_fugacity_dp = torch.func.jacrev(
        lambda p: log_fugacity_at(state.composition, state.temperature, p)
    )(state.pressure)

    dmu_dlogits = torch.func.jacrev(lambda value: apply_logits(mu_at, value))(logits)
    dmu_dindependent = torch.func.jacrev(lambda value: apply_independent(mu_at, value))(independent)
    dmu_dmoles = torch.func.jacrev(lambda value: apply_moles(mu_at, value))(unit_moles)
    dmu_dt = torch.func.jacrev(lambda t: mu_at(state.composition, t, state.pressure))(
        state.temperature
    )
    dmu_dp = torch.func.jacrev(lambda p: mu_at(state.composition, state.temperature, p))(
        state.pressure
    )

    dvolume_dlogits = torch.func.jacrev(lambda value: apply_logits(volume_at, value))(logits)
    dvolume_dindependent = torch.func.jacrev(lambda value: apply_independent(volume_at, value))(
        independent
    )
    dvolume_dmoles = torch.func.jacrev(lambda value: apply_moles(volume_at, value))(unit_moles)
    dvolume_dt = torch.func.jacrev(lambda t: volume_at(state.composition, t, state.pressure))(
        state.temperature
    )
    dvolume_dp = torch.func.jacrev(lambda p: volume_at(state.composition, state.temperature, p))(
        state.pressure
    )

    def gibbs(t: Tensor, p: Tensor) -> Tensor:
        mu = mu_at(state.composition, t, p)
        return torch.sum(state.composition * mu)

    dg_dt = torch.func.grad(lambda t: gibbs(t, state.pressure))(state.temperature)
    dg_dp = torch.func.grad(lambda p: gibbs(state.temperature, p))(state.pressure)

    log_fugacity = _log_fugacities(
        model,
        state.temperature,
        state.pressure,
        state.composition,
        phase,
    )
    fugacity = STANDARD_PRESSURE * torch.exp(log_fugacity)
    log_phi = log_phi_at(state.composition, state.temperature, state.pressure)
    phi = torch.exp(log_phi)
    dphi_dlogits = phi[:, None] * dlog_phi_dlogits
    dphi_dindependent = phi[:, None] * dlog_phi_dindependent
    dphi_dmoles = phi[:, None] * dlog_phi_dmoles
    dphi_dt = phi * dlog_phi_dt
    dphi_dp = phi * dlog_phi_dp
    dfugacity_dlogits = fugacity[:, None] * dlog_fugacity_dlogits
    dfugacity_dindependent = fugacity[:, None] * dlog_fugacity_dindependent
    dfugacity_dmoles = fugacity[:, None] * dlog_fugacity_dmoles
    dfugacity_dt = fugacity * dlog_fugacity_dt
    dfugacity_dp = fugacity * dlog_fugacity_dp

    chemical_potential = _chemical_potentials(
        model,
        state.temperature,
        state.pressure,
        state.composition,
        phase,
        standard_state,
    )
    rt = R * state.temperature
    dreduced_mu_dlogits = dmu_dlogits / rt
    dreduced_mu_dindependent = dmu_dindependent / rt
    dreduced_mu_dmoles = dmu_dmoles / rt
    dreduced_mu_dt = dmu_dt / rt - chemical_potential / (R * state.temperature.square())
    dreduced_mu_dp = dmu_dp / rt

    return ThermodynamicDerivatives(
        dfugacity_coefficient_dlogits=dphi_dlogits,
        dfugacity_coefficient_dindependent_composition=dphi_dindependent,
        dfugacity_coefficient_dtemperature=dphi_dt,
        dfugacity_coefficient_dpressure=dphi_dp,
        dfugacity_coefficient_dmoles=dphi_dmoles,
        dlog_fugacity_coefficient_dlogits=dlog_phi_dlogits,
        dlog_fugacity_coefficient_dindependent_composition=dlog_phi_dindependent,
        dlog_fugacity_coefficient_dtemperature=dlog_phi_dt,
        dlog_fugacity_coefficient_dpressure=dlog_phi_dp,
        dlog_fugacity_coefficient_dmoles=dlog_phi_dmoles,
        dfugacity_dlogits=dfugacity_dlogits,
        dfugacity_dindependent_composition=dfugacity_dindependent,
        dfugacity_dtemperature=dfugacity_dt,
        dfugacity_dpressure=dfugacity_dp,
        dfugacity_dmoles=dfugacity_dmoles,
        dlog_fugacity_dlogits=dlog_fugacity_dlogits,
        dlog_fugacity_dindependent_composition=dlog_fugacity_dindependent,
        dlog_fugacity_dtemperature=dlog_fugacity_dt,
        dlog_fugacity_dpressure=dlog_fugacity_dp,
        dlog_fugacity_dmoles=dlog_fugacity_dmoles,
        dchemical_potential_dlogits=dmu_dlogits,
        dchemical_potential_dindependent_composition=dmu_dindependent,
        dchemical_potential_dtemperature=dmu_dt,
        dchemical_potential_dpressure=dmu_dp,
        dchemical_potential_dmoles=dmu_dmoles,
        dreduced_chemical_potential_dlogits=dreduced_mu_dlogits,
        dreduced_chemical_potential_dindependent_composition=dreduced_mu_dindependent,
        dreduced_chemical_potential_dtemperature=dreduced_mu_dt,
        dreduced_chemical_potential_dpressure=dreduced_mu_dp,
        dreduced_chemical_potential_dmoles=dreduced_mu_dmoles,
        dmolar_volume_dlogits=dvolume_dlogits,
        dmolar_volume_dindependent_composition=dvolume_dindependent,
        dmolar_volume_dtemperature=dvolume_dt,
        dmolar_volume_dpressure=dvolume_dp,
        dmolar_volume_dmoles=dvolume_dmoles,
        dgibbs_dtemperature=dg_dt,
        dgibbs_dpressure=dg_dp,
    )

thermal_properties

thermal_properties(model, state, standard_state, phase='stable', *, reference_pressure=STANDARD_PRESSURE, molar_mass=None)

Evaluate Pedersen Chapter 8 properties at a specified state.

No phase-equilibrium calculation is performed. The requested phase root remains fixed while differentiating. A volume-translated cubic receives Pedersen's P*DeltaV enthalpy correction (Eq. 8.12).

The entropy pressure term in Eq. 8.17 is interpreted as -R*ln(P/Pref); the glyph in the printed 2024 edition can resemble T, but dimensional consistency and the ideal-gas identity require the gas constant. Helmholtz energy follows a = g - P*v. Reduced total free energies use the supplied caloric reference, while reduced residual free energies are reference-independent EoS departures.

Source code in src/torch_flash/properties/thermal.py
def thermal_properties(
    model: StateModel,
    state: ChemicalState,
    standard_state: CaloricStandardState,
    phase: PhaseKind = "stable",
    *,
    reference_pressure: float = STANDARD_PRESSURE,
    molar_mass: Tensor | None = None,
) -> ThermalProperties:
    """Evaluate Pedersen Chapter 8 properties at a specified state.

    No phase-equilibrium calculation is performed. The requested phase root
    remains fixed while differentiating. A volume-translated cubic receives
    Pedersen's ``P*DeltaV`` enthalpy correction (Eq. 8.12).

    The entropy pressure term in Eq. 8.17 is interpreted as
    ``-R*ln(P/Pref)``; the glyph in the printed 2024 edition can resemble
    ``T``, but dimensional consistency and the ideal-gas identity require
    the gas constant. Helmholtz energy follows ``a = g - P*v``. Reduced total
    free energies use the supplied caloric reference, while reduced residual
    free energies are reference-independent EoS departures.
    """
    if state.temperature.ndim != 0 or state.pressure.ndim != 0:
        raise ValueError("thermal_properties currently accepts one scalar T-P state")
    if state.composition.ndim != 1:
        raise ValueError("thermal_properties currently accepts one composition vector")
    if reference_pressure <= 0.0:
        raise ValueError("reference_pressure must be positive")

    temperature = state.temperature
    pressure = state.pressure
    composition = state.composition
    ideal_enthalpy = standard_state.enthalpy(temperature)
    ideal_entropy = standard_state.entropy(temperature)
    if ideal_enthalpy.shape != composition.shape or ideal_entropy.shape != composition.shape:
        raise ValueError("standard-state component count must match the composition")

    residual_enthalpy, residual_entropy = _residual_terms(
        model,
        temperature,
        pressure,
        composition,
        phase,
    )
    enthalpy = torch.sum(composition * ideal_enthalpy) + residual_enthalpy
    safe_composition = torch.clamp_min(composition, torch.finfo(composition.dtype).tiny)
    entropy = (
        torch.sum(composition * ideal_entropy)
        - R * torch.log(pressure / reference_pressure)
        - R * torch.sum(composition * torch.log(safe_composition))
        + residual_entropy
    )
    volume = model.molar_volume(temperature, pressure, composition, phase)
    internal_energy = enthalpy - pressure * volume
    gibbs = enthalpy - temperature * entropy
    helmholtz = gibbs - pressure * volume
    reduced_gibbs = gibbs / (R * temperature)
    reduced_helmholtz = helmholtz / (R * temperature)
    pressure_volume_over_rt = pressure * volume / (R * temperature)
    reduced_residual_gibbs = _weighted_log_fugacity(
        model,
        temperature,
        pressure,
        composition,
        phase,
    )
    reduced_residual_helmholtz = (
        reduced_residual_gibbs - pressure_volume_over_rt + 1.0 + torch.log(pressure_volume_over_rt)
    )

    enthalpy_at_temperature = lambda current_temperature: _enthalpy(  # noqa: E731
        model,
        standard_state,
        current_temperature,
        pressure,
        composition,
        phase,
    )
    enthalpy_at_pressure = lambda current_pressure: _enthalpy(  # noqa: E731
        model,
        standard_state,
        temperature,
        current_pressure,
        composition,
        phase,
    )
    cp = torch.func.grad(enthalpy_at_temperature)(temperature)
    dh_dp = torch.func.grad(enthalpy_at_pressure)(pressure)
    joule_thomson = -dh_dp / cp

    volume_at_temperature = lambda current_temperature: model.molar_volume(  # noqa: E731
        current_temperature,
        pressure,
        composition,
        phase,
    )
    volume_at_pressure = lambda current_pressure: model.molar_volume(  # noqa: E731
        temperature,
        current_pressure,
        composition,
        phase,
    )
    dv_dt = torch.func.grad(volume_at_temperature)(temperature)
    dv_dp = torch.func.grad(volume_at_pressure)(pressure)
    cv = cp + temperature * dv_dt.square() / dv_dp

    response_values = torch.stack((cp, cv, dv_dp))
    if bool(
        (
            (~torch.isfinite(response_values)).any() | (cp <= 0.0) | (cv <= 0.0) | (dv_dp >= 0.0)
        ).detach()
    ):
        raise InvalidStateError(
            "thermal response functions require positive Cp/Cv and "
            "negative isothermal compressibility"
        )

    mixture_molar_mass = _mixture_molar_mass(model, composition, molar_mass)
    speed_of_sound = None
    if mixture_molar_mass is not None:
        speed_squared = -volume.square() * cp / (mixture_molar_mass * cv * dv_dp)
        if bool((~torch.isfinite(speed_squared) | (speed_squared <= 0.0)).detach()):
            raise InvalidStateError("speed-of-sound expression is not positive and finite")
        speed_of_sound = torch.sqrt(speed_squared)

    return ThermalProperties(
        enthalpy,
        internal_energy,
        entropy,
        helmholtz,
        gibbs,
        reduced_helmholtz,
        reduced_gibbs,
        reduced_residual_helmholtz,
        reduced_residual_gibbs,
        cp,
        cv,
        joule_thomson,
        speed_of_sound,
        residual_enthalpy,
        residual_entropy,
    )

torch_flash.transport

Transport-property correlations.

lbc_pseudocomponent_critical_volume

lbc_pseudocomponent_critical_volume(molar_mass, standard_liquid_density)

Estimate a C7+ critical molar volume from Pedersen Eq. 10.41.

Parameters are SI: molar mass in kg/mol and liquid density in kg/m3. The returned critical molar volume is in m3/mol. The underlying empirical equation uses g/mol, g/cm3, and ft3/lbmol.

Source code in src/torch_flash/transport/viscosity.py
def lbc_pseudocomponent_critical_volume(
    molar_mass: Tensor,
    standard_liquid_density: Tensor,
) -> Tensor:
    """Estimate a C7+ critical molar volume from Pedersen Eq. 10.41.

    Parameters are SI: molar mass in kg/mol and liquid density in kg/m3.
    The returned critical molar volume is in m3/mol. The underlying empirical
    equation uses g/mol, g/cm3, and ft3/lbmol.
    """
    if bool(
        (
            (~torch.isfinite(molar_mass))
            | (~torch.isfinite(standard_liquid_density))
            | (molar_mass <= 0.0)
            | (standard_liquid_density <= 0.0)
        ).any()
    ):
        raise ValueError("molar mass and standard liquid density must be finite and positive")
    molar_mass_g = 1000.0 * molar_mass
    density_g_cm3 = standard_liquid_density / 1000.0
    volume_ft3_lbmol = (
        21.573
        + 0.015122 * molar_mass_g
        - 27.656 * density_g_cm3
        + 0.070615 * molar_mass_g * density_g_cm3
    )
    return volume_ft3_lbmol * _FT3_PER_LBMOL_TO_M3_PER_MOL

lbc_viscosity

lbc_viscosity(temperature, molar_density, composition, components, *, critical_volume=None, coefficients=None)

Return Lohrenz-Bray-Clark mixture viscosity in Pa s.

Parameters:

Name Type Description Default
temperature Tensor

Temperature in K.

required
molar_density Tensor

Homogeneous-phase molar density in mol/m3, normally obtained from the same EoS used by the flash calculation.

required
composition Tensor

One mole-fraction vector.

required
components ComponentSet

Critical temperatures, pressures, molar masses, and volumes.

required
critical_volume Tensor | None

Optional m3/mol values overriding the component data. This is the principal LBC tuning parameter for petroleum pseudocomponents.

None
coefficients Tensor | None

Optional trainable or fitted a1...a5 tensor replacing the original LBC constants.

None
Notes

The Stiel-Thodos reducing parameters use K, atm, and g/mol, as required by the published numerical constants. LBC is empirical and often needs pseudocomponent critical-volume or coefficient tuning for heavy oils.

Source code in src/torch_flash/transport/viscosity.py
def lbc_viscosity(
    temperature: Tensor,
    molar_density: Tensor,
    composition: Tensor,
    components: ComponentSet,
    *,
    critical_volume: Tensor | None = None,
    coefficients: Tensor | None = None,
) -> Tensor:
    """Return Lohrenz-Bray-Clark mixture viscosity in Pa s.

    Parameters
    ----------
    temperature
        Temperature in K.
    molar_density
        Homogeneous-phase molar density in mol/m3, normally obtained from the
        same EoS used by the flash calculation.
    composition
        One mole-fraction vector.
    components
        Critical temperatures, pressures, molar masses, and volumes.
    critical_volume
        Optional m3/mol values overriding the component data. This is the
        principal LBC tuning parameter for petroleum pseudocomponents.
    coefficients
        Optional trainable or fitted ``a1...a5`` tensor replacing the original
        LBC constants.

    Notes
    -----
    The Stiel-Thodos reducing parameters use K, atm, and g/mol, as required by
    the published numerical constants. LBC is empirical and often needs
    pseudocomponent critical-volume or coefficient tuning for heavy oils.
    """
    if composition.ndim != 1:
        raise ValueError("LBC viscosity currently accepts one composition vector")
    if composition.numel() != components.ncomponents:
        raise ValueError("composition and component set sizes must match")
    if bool(
        (
            (~torch.isfinite(temperature))
            | (~torch.isfinite(molar_density))
            | (temperature <= 0.0)
            | (molar_density < 0.0)
        ).any()
    ):
        raise InvalidStateError("temperature must be positive and molar density non-negative")

    volumes = components.critical_volume if critical_volume is None else critical_volume
    if volumes is None:
        raise ValueError("critical molar volumes are required by the LBC correlation")
    if volumes.shape != components.critical_temperature.shape:
        raise ValueError("critical_volume must have one value per component")
    if bool(((~torch.isfinite(volumes)) | (volumes <= 0.0)).any()):
        raise ValueError("critical molar volumes must be finite and positive")

    parameters = (
        temperature.new_tensor(_LBC)
        if coefficients is None
        else coefficients.to(dtype=temperature.dtype, device=temperature.device)
    )
    if parameters.shape != (5,) or bool((~torch.isfinite(parameters)).any()):
        raise ValueError("LBC coefficients must be a finite five-element tensor")

    z = normalize_composition(composition)
    tc = components.critical_temperature
    pc_atm = components.critical_pressure / 101_325.0
    molar_mass_g = 1000.0 * components.molar_mass
    xi_components = tc.pow(1.0 / 6.0) / (molar_mass_g.sqrt() * pc_atm.pow(2.0 / 3.0))
    reduced_temperature = temperature[..., None] / tc
    dilute_components_cp = torch.where(
        reduced_temperature <= 1.5,
        34.0e-5 * reduced_temperature.pow(0.94) / xi_components,
        17.78e-5 * (4.58 * reduced_temperature - 1.67).pow(5.0 / 8.0) / xi_components,
    )
    square_root_mass = molar_mass_g.sqrt()
    dilute_mixture_cp = torch.sum(z * dilute_components_cp * square_root_mass, dim=-1) / torch.sum(
        z * square_root_mass
    )

    mixture_xi = torch.sum(z * tc).pow(1.0 / 6.0) / (
        torch.sum(z * molar_mass_g).sqrt() * torch.sum(z * pc_atm).pow(2.0 / 3.0)
    )
    reduced_density = molar_density * torch.sum(z * volumes)
    polynomial = parameters[0] + reduced_density * (
        parameters[1]
        + reduced_density
        * (parameters[2] + reduced_density * (parameters[3] + reduced_density * parameters[4]))
    )
    dense_increment_cp = (polynomial.pow(4) - 1.0e-4) / mixture_xi
    viscosity_cp = dilute_mixture_cp + dense_increment_cp
    if bool((~torch.isfinite(viscosity_cp) | (viscosity_cp <= 0.0)).any()):
        raise InvalidStateError("LBC correlation produced a non-positive viscosity")
    return viscosity_cp * 1.0e-3

methane_viscosity

methane_viscosity(temperature, density_mol_l)

Return methane dynamic viscosity in Pa s from Eq. 10.6.

Although the 2024 textbook prose labels the correlation density as mol/L, the published Hanley coefficients require mass density in kg/L (numerically equal to g/cm3). Using molar density inside the fractional powers produces viscosities two orders of magnitude too large at reservoir pressures.

Source code in src/torch_flash/transport/viscosity.py
def methane_viscosity(
    temperature: Tensor,
    density_mol_l: Tensor,
) -> Tensor:
    """Return methane dynamic viscosity in Pa s from Eq. 10.6.

    Although the 2024 textbook prose labels the correlation density as mol/L,
    the published Hanley coefficients require mass density in kg/L (numerically
    equal to g/cm3). Using molar density inside the fractional powers produces
    viscosities two orders of magnitude too large at reservoir pressures.
    """
    gv = torch.tensor(_GV, dtype=temperature.dtype, device=temperature.device)
    powers = torch.arange(-3, 6, dtype=temperature.dtype, device=temperature.device) / 3.0
    dilute = torch.sum(gv * temperature.pow(powers))
    eta1 = 1.696985927 - 0.133372346 * (1.4 - torch.log(temperature / 168.0)).square()
    mass_density = density_mol_l * _METHANE_MOLAR_MASS_G / 1000.0
    theta = (mass_density - _METHANE_CRITICAL_MASS_DENSITY) / _METHANE_CRITICAL_MASS_DENSITY
    j = torch.tensor(_J, dtype=temperature.dtype, device=temperature.device)
    dense = torch.exp(j[0] + j[3] / temperature) * (
        torch.exp(
            mass_density.pow(0.1) * (j[1] + j[2] / temperature.pow(1.5))
            + theta
            * mass_density.sqrt()
            * (j[4] + j[5] / temperature + j[6] / temperature.square())
        )
        - 1.0
    )
    viscosity_cp = 1.0e-4 * (dilute + eta1 * mass_density + dense)
    return viscosity_cp * 1.0e-3

pedersen_viscosity

pedersen_viscosity(temperature, pressure, composition, components, *, phase='vapor')

Return Pedersen CSP mixture viscosity in Pa s.

Source code in src/torch_flash/transport/viscosity.py
def pedersen_viscosity(
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    components: ComponentSet,
    *,
    phase: Literal["liquid", "vapor"] = "vapor",
) -> Tensor:
    """Return Pedersen CSP mixture viscosity in Pa s."""
    if composition.ndim != 1:
        raise ValueError("Pedersen viscosity currently accepts one composition vector")
    if composition.numel() != components.ncomponents:
        raise ValueError("composition and component set sizes must match")
    z = normalize_composition(composition)
    tc_mix, pc_mix = _mixture_pseudocritical(components, z)
    mapped_t = temperature * _METHANE_TC / tc_mix
    mapped_p = pressure * _METHANE_PC / pc_mix
    reference_density = methane_bwr_density(mapped_t, mapped_p, phase=phase)
    reference_mass_density = reference_density * _METHANE_MOLAR_MASS_G / 1000.0
    reduced_density = reference_mass_density / _METHANE_CRITICAL_MASS_DENSITY
    mixture_molar_mass = _pedersen_molecular_weight(components.molar_mass, z)
    alpha_mix = 1.0 + 7.378e-3 * reduced_density.pow(1.847) * mixture_molar_mass.pow(0.5173)
    alpha_reference = 1.0 + 7.378e-3 * reduced_density.pow(1.847) * temperature.new_tensor(
        _METHANE_MOLAR_MASS_G
    ).pow(0.5173)
    reference_pressure = pressure * _METHANE_PC * alpha_reference / (pc_mix * alpha_mix)
    reference_temperature = temperature * _METHANE_TC * alpha_reference / (tc_mix * alpha_mix)
    density = methane_bwr_density(reference_temperature, reference_pressure, phase=phase)
    reference_viscosity = methane_viscosity(reference_temperature, density)
    return (
        (tc_mix / _METHANE_TC).pow(-1.0 / 6.0)
        * (pc_mix / _METHANE_PC).pow(2.0 / 3.0)
        * (mixture_molar_mass / _METHANE_MOLAR_MASS_G).sqrt()
        * (alpha_mix / alpha_reference)
        * reference_viscosity
    )

torch_flash.backends

Optional validation backends.

BackendCapabilities dataclass

Explicit numerical and model-identity capability flags.

Source code in src/torch_flash/backends/base.py
@dataclass(frozen=True)
class BackendCapabilities:
    """Explicit numerical and model-identity capability flags."""

    autodiff: bool
    gpu: bool
    fugacity_coefficients: bool
    exact_model: str

CoolPropBackend

CoolProp AbstractState wrapper.

This backend is intended for independent validation and frozen baselines. It is CPU-only and not differentiable. HEOS uses multifluid mixture machinery with GERG-style reducing/departure functions but is not labeled as the published GERG-2008 coefficient set. Exact GERG through REFPROP requires a licensed REFPROP installation configured by the user.

Source code in src/torch_flash/backends/coolprop.py
class CoolPropBackend:
    """CoolProp ``AbstractState`` wrapper.

    This backend is intended for independent validation and frozen baselines.
    It is CPU-only and not differentiable. ``HEOS`` uses multifluid mixture
    machinery with GERG-style reducing/departure functions but is not labeled
    as the published GERG-2008 coefficient set. Exact GERG through REFPROP
    requires a licensed REFPROP installation configured by the user.
    """

    _ALIASES: ClassVar[dict[str, str]] = {
        "carbon_dioxide": "CarbonDioxide",
        "n_butane": "n-Butane",
        "n_pentane": "n-Pentane",
        "n_hexane": "n-Hexane",
        "n_heptane": "n-Heptane",
        "n_octane": "n-Octane",
        "n_decane": "n-Decane",
    }

    def __init__(
        self,
        names: tuple[str, ...],
        *,
        backend: Literal["HEOS", "REFPROP"] = "HEOS",
    ) -> None:
        try:
            from CoolProp import CoolProp
        except ImportError as exc:
            raise ImportError(
                "CoolPropBackend requires the optional 'external' dependency"
            ) from exc
        self._cp = CoolProp
        self.names = names
        fluids = "&".join(self._ALIASES.get(name, name.title()) for name in names)
        self._state = CoolProp.AbstractState(backend, fluids)
        self.capabilities = BackendCapabilities(
            autodiff=False,
            gpu=False,
            fugacity_coefficients=True,
            exact_model="REFPROP-selected model" if backend == "REFPROP" else "CoolProp HEOS",
        )

    def _update(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind,
    ) -> None:
        if temperature.ndim or pressure.ndim or composition.ndim != 1:
            raise ValueError("CoolProp adapter accepts one scalar T-P state")
        x = normalize_composition(composition)
        self._state.set_mole_fractions(x.detach().cpu().tolist())
        if phase == "liquid":
            self._state.specify_phase(self._cp.iphase_liquid)
        elif phase == "vapor":
            self._state.specify_phase(self._cp.iphase_gas)
        else:
            self._state.unspecify_phase()
        self._state.update(
            self._cp.PT_INPUTS,
            float(pressure.detach()),
            float(temperature.detach()),
        )

    @staticmethod
    def _result(value: float, like: Tensor) -> Tensor:
        return torch.tensor(value, dtype=like.dtype, device=like.device)

    def select_z(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return CoolProp compressibility factor."""
        self._update(temperature, pressure, composition, phase)
        return self._result(self._state.compressibility_factor(), temperature)

    def molar_volume(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return CoolProp molar volume."""
        self._update(temperature, pressure, composition, phase)
        return self._result(1.0 / self._state.rhomolar(), temperature)

    def log_fugacity_coefficients(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return CoolProp log fugacity coefficients."""
        self._update(temperature, pressure, composition, phase)
        try:
            values = [self._state.fugacity_coefficient(index) for index in range(len(self.names))]
        except (AttributeError, ValueError) as exc:
            raise ModelCapabilityError(
                "selected CoolProp backend does not expose fugacity coefficients"
            ) from exc
        return torch.log(torch.tensor(values, dtype=temperature.dtype, device=temperature.device))

select_z

select_z(temperature, pressure, composition, phase='stable')

Return CoolProp compressibility factor.

Source code in src/torch_flash/backends/coolprop.py
def select_z(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return CoolProp compressibility factor."""
    self._update(temperature, pressure, composition, phase)
    return self._result(self._state.compressibility_factor(), temperature)

molar_volume

molar_volume(temperature, pressure, composition, phase='stable')

Return CoolProp molar volume.

Source code in src/torch_flash/backends/coolprop.py
def molar_volume(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return CoolProp molar volume."""
    self._update(temperature, pressure, composition, phase)
    return self._result(1.0 / self._state.rhomolar(), temperature)

log_fugacity_coefficients

log_fugacity_coefficients(temperature, pressure, composition, phase='stable')

Return CoolProp log fugacity coefficients.

Source code in src/torch_flash/backends/coolprop.py
def log_fugacity_coefficients(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return CoolProp log fugacity coefficients."""
    self._update(temperature, pressure, composition, phase)
    try:
        values = [self._state.fugacity_coefficient(index) for index in range(len(self.names))]
    except (AttributeError, ValueError) as exc:
        raise ModelCapabilityError(
            "selected CoolProp backend does not expose fugacity coefficients"
        ) from exc
    return torch.log(torch.tensor(values, dtype=temperature.dtype, device=temperature.device))

TeqpBackend

Wrap a teqp residual model as a homogeneous-state model.

teqp is deliberately optional and all values cross a CPU/NumPy boundary, so this adapter is for validation rather than differentiable production calculations. The GERG constructor uses the published hard-coded GERG-2008 residual coefficient set shipped by teqp.

Source code in src/torch_flash/backends/teqp.py
class TeqpBackend:
    """Wrap a teqp residual model as a homogeneous-state model.

    teqp is deliberately optional and all values cross a CPU/NumPy boundary,
    so this adapter is for validation rather than differentiable production
    calculations. The GERG constructor uses the published hard-coded
    GERG-2008 residual coefficient set shipped by teqp.
    """

    _GERG_NAMES: ClassVar[dict[str, str]] = {
        "carbon_dioxide": "carbondioxide",
        "n_butane": "n-butane",
        "n_pentane": "n-pentane",
        "n_hexane": "n-hexane",
        "n_heptane": "n-heptane",
        "n_octane": "n-octane",
        "n_decane": "n-decane",
    }
    _EOSCG_NAMES: ClassVar[dict[str, str]] = {
        "carbon_dioxide": "CarbonDioxide",
        "water": "Water",
        "nitrogen": "Nitrogen",
        "oxygen": "Oxygen",
        "argon": "Argon",
        "carbon_monoxide": "CarbonMonoxide",
    }

    def __init__(self, names: tuple[str, ...], model: Any, *, exact_model: str) -> None:
        self.names = names
        self._model = model
        self.capabilities = BackendCapabilities(
            autodiff=False,
            gpu=False,
            fugacity_coefficients=True,
            exact_model=exact_model,
        )

    @classmethod
    def canonical_peng_robinson(cls, components: ComponentSet) -> TeqpBackend:
        """Construct teqp's canonical Peng-Robinson model."""
        try:
            import teqp
        except ImportError as exc:
            raise ImportError("TeqpBackend requires the optional 'external' dependency") from exc
        model = teqp.canonical_PR(
            components.critical_temperature.detach().cpu().numpy(),
            components.critical_pressure.detach().cpu().numpy(),
            components.acentric_factor.detach().cpu().numpy(),
        )
        return cls(components.names, model, exact_model="teqp canonical Peng-Robinson")

    @classmethod
    def gerg2008(cls, names: tuple[str, ...]) -> TeqpBackend:
        """Construct teqp's exact GERG-2008 residual model."""
        try:
            import teqp
        except ImportError as exc:
            raise ImportError("TeqpBackend requires the optional 'external' dependency") from exc
        gerg_names = [cls._GERG_NAMES.get(name, name) for name in names]
        model = teqp.make_model({"kind": "GERG2008resid", "model": {"names": gerg_names}})
        return cls(names, model, exact_model="GERG-2008 residual (teqp)")

    @classmethod
    def eoscg_2015(cls, names: tuple[str, ...]) -> TeqpBackend:
        """Construct the complete 2015 EOS-CG multifluid model shipped by teqp.

        The six-component scope is CO2, water, nitrogen, oxygen, argon, and
        carbon monoxide.  teqp loads the pure-fluid Helmholtz equations plus
        the Gernert--Span binary reducing and departure parameters from its
        versioned CoolProp-format data files.
        """
        try:
            import teqp
        except ImportError as exc:
            raise ImportError("TeqpBackend requires the optional 'external' dependency") from exc
        try:
            component_names = [cls._EOSCG_NAMES[name] for name in names]
        except KeyError as exc:
            supported = ", ".join(cls._EOSCG_NAMES)
            raise ValueError(f"EOS-CG-2015 component must be one of: {supported}") from exc
        data_path = Path(teqp.get_datapath())
        model = teqp.build_multifluid_model(
            component_names,
            str(data_path),
            str(data_path / "dev" / "mixtures" / "mixture_binary_pairs.json"),
            departurepath=str(data_path / "dev" / "mixtures" / "mixture_departure_functions.json"),
        )
        return cls(names, model, exact_model="EOS-CG-2015 multifluid (teqp)")

    @staticmethod
    def _numpy_state(
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
    ) -> tuple[float, float, np.ndarray]:
        if temperature.ndim or pressure.ndim or composition.ndim != 1:
            raise ValueError("teqp adapter accepts one scalar T-P state")
        x = normalize_composition(composition)
        return (
            float(temperature.detach().cpu()),
            float(pressure.detach().cpu()),
            np.asarray(x.detach().cpu().numpy(), dtype=np.float64),
        )

    def _pressure(self, temperature: float, density: float, composition: np.ndarray) -> float:
        gas_constant = float(self._model.get_R(composition))
        return (
            density
            * gas_constant
            * temperature
            * (1.0 + float(self._model.get_Ar01(temperature, density, composition)))
        )

    def _density_roots(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
    ) -> tuple[float, ...]:
        t, p, x = self._numpy_state(temperature, pressure, composition)
        ideal_density = p / (R * t)
        grid = np.geomspace(max(ideal_density * 1.0e-5, 1.0e-8), 1.0e5, 400)

        def residual(density: float) -> float:
            return self._pressure(t, density, x) - p

        roots: list[float] = []
        left = float(grid[0])
        left_value = residual(left)
        for right_value_raw in grid[1:]:
            right = float(right_value_raw)
            right_value = residual(right)
            if (
                np.isfinite(left_value)
                and np.isfinite(right_value)
                and np.signbit(left_value) != np.signbit(right_value)
            ):
                low, high = left, right
                low_value = left_value
                for _ in range(100):
                    midpoint = 0.5 * (low + high)
                    midpoint_value = residual(midpoint)
                    if abs(midpoint_value) <= 1.0e-13 * max(p, 1.0):
                        break
                    if np.signbit(low_value) != np.signbit(midpoint_value):
                        high = midpoint
                    else:
                        low = midpoint
                        low_value = midpoint_value
                if abs(residual(midpoint)) <= 1.0e-10 * max(p, 1.0):
                    if not roots or abs(midpoint - roots[-1]) > 1.0e-7 * midpoint:
                        roots.append(midpoint)
            left, left_value = right, right_value
        if not roots:
            raise ConvergenceError("teqp density scan found no pressure root")
        return tuple(roots)

    def _select_density(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind,
    ) -> float:
        roots = self._density_roots(temperature, pressure, composition)
        if phase == "liquid":
            return roots[-1]
        if phase == "vapor":
            return roots[0]
        if phase != "stable":
            raise ValueError(f"unknown phase root {phase!r}")
        t, _, x = self._numpy_state(temperature, pressure, composition)
        with np.errstate(divide="ignore", invalid="ignore"):
            gibbs = [
                float(
                    np.dot(
                        x,
                        np.log(self._model.get_fugacity_coefficients(t, density * x)),
                    )
                )
                for density in roots
            ]
        return roots[int(np.argmin(gibbs))]

    def molar_volume(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return a teqp molar-volume root in m3/mol."""
        density = self._select_density(temperature, pressure, composition, phase)
        return torch.tensor(
            1.0 / density,
            dtype=temperature.dtype,
            device=temperature.device,
        )

    def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
        """Return pressure at a prescribed homogeneous density."""
        if temperature.ndim or molar_volume.ndim or composition.ndim != 1:
            raise ValueError("teqp adapter accepts one scalar T-volume state")
        x = normalize_composition(composition)
        value = self._pressure(
            float(temperature.detach().cpu()),
            1.0 / float(molar_volume.detach().cpu()),
            np.asarray(x.detach().cpu().numpy(), dtype=np.float64),
        )
        return torch.tensor(value, dtype=temperature.dtype, device=temperature.device)

    def select_z(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return compressibility factor."""
        volume = self.molar_volume(temperature, pressure, composition, phase)
        z_factor = pressure * volume / (R * temperature)
        if not bool(torch.isfinite(z_factor)):
            raise InvalidStateError("teqp produced a non-finite compressibility factor")
        return z_factor

    def log_fugacity_coefficients(
        self,
        temperature: Tensor,
        pressure: Tensor,
        composition: Tensor,
        phase: PhaseKind = "stable",
    ) -> Tensor:
        """Return teqp residual-model fugacity coefficients."""
        t, _, x = self._numpy_state(temperature, pressure, composition)
        density = self._select_density(temperature, pressure, composition, phase)
        values = np.asarray(self._model.get_fugacity_coefficients(t, density * x))
        if np.any(values <= 0.0) or not np.isfinite(values).all():
            raise InvalidStateError("teqp produced invalid fugacity coefficients")
        return torch.log(torch.tensor(values, dtype=temperature.dtype, device=temperature.device))

canonical_peng_robinson classmethod

canonical_peng_robinson(components)

Construct teqp's canonical Peng-Robinson model.

Source code in src/torch_flash/backends/teqp.py
@classmethod
def canonical_peng_robinson(cls, components: ComponentSet) -> TeqpBackend:
    """Construct teqp's canonical Peng-Robinson model."""
    try:
        import teqp
    except ImportError as exc:
        raise ImportError("TeqpBackend requires the optional 'external' dependency") from exc
    model = teqp.canonical_PR(
        components.critical_temperature.detach().cpu().numpy(),
        components.critical_pressure.detach().cpu().numpy(),
        components.acentric_factor.detach().cpu().numpy(),
    )
    return cls(components.names, model, exact_model="teqp canonical Peng-Robinson")

gerg2008 classmethod

gerg2008(names)

Construct teqp's exact GERG-2008 residual model.

Source code in src/torch_flash/backends/teqp.py
@classmethod
def gerg2008(cls, names: tuple[str, ...]) -> TeqpBackend:
    """Construct teqp's exact GERG-2008 residual model."""
    try:
        import teqp
    except ImportError as exc:
        raise ImportError("TeqpBackend requires the optional 'external' dependency") from exc
    gerg_names = [cls._GERG_NAMES.get(name, name) for name in names]
    model = teqp.make_model({"kind": "GERG2008resid", "model": {"names": gerg_names}})
    return cls(names, model, exact_model="GERG-2008 residual (teqp)")

eoscg_2015 classmethod

eoscg_2015(names)

Construct the complete 2015 EOS-CG multifluid model shipped by teqp.

The six-component scope is CO2, water, nitrogen, oxygen, argon, and carbon monoxide. teqp loads the pure-fluid Helmholtz equations plus the Gernert--Span binary reducing and departure parameters from its versioned CoolProp-format data files.

Source code in src/torch_flash/backends/teqp.py
@classmethod
def eoscg_2015(cls, names: tuple[str, ...]) -> TeqpBackend:
    """Construct the complete 2015 EOS-CG multifluid model shipped by teqp.

    The six-component scope is CO2, water, nitrogen, oxygen, argon, and
    carbon monoxide.  teqp loads the pure-fluid Helmholtz equations plus
    the Gernert--Span binary reducing and departure parameters from its
    versioned CoolProp-format data files.
    """
    try:
        import teqp
    except ImportError as exc:
        raise ImportError("TeqpBackend requires the optional 'external' dependency") from exc
    try:
        component_names = [cls._EOSCG_NAMES[name] for name in names]
    except KeyError as exc:
        supported = ", ".join(cls._EOSCG_NAMES)
        raise ValueError(f"EOS-CG-2015 component must be one of: {supported}") from exc
    data_path = Path(teqp.get_datapath())
    model = teqp.build_multifluid_model(
        component_names,
        str(data_path),
        str(data_path / "dev" / "mixtures" / "mixture_binary_pairs.json"),
        departurepath=str(data_path / "dev" / "mixtures" / "mixture_departure_functions.json"),
    )
    return cls(names, model, exact_model="EOS-CG-2015 multifluid (teqp)")

molar_volume

molar_volume(temperature, pressure, composition, phase='stable')

Return a teqp molar-volume root in m3/mol.

Source code in src/torch_flash/backends/teqp.py
def molar_volume(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return a teqp molar-volume root in m3/mol."""
    density = self._select_density(temperature, pressure, composition, phase)
    return torch.tensor(
        1.0 / density,
        dtype=temperature.dtype,
        device=temperature.device,
    )

pressure

pressure(temperature, molar_volume, composition)

Return pressure at a prescribed homogeneous density.

Source code in src/torch_flash/backends/teqp.py
def pressure(self, temperature: Tensor, molar_volume: Tensor, composition: Tensor) -> Tensor:
    """Return pressure at a prescribed homogeneous density."""
    if temperature.ndim or molar_volume.ndim or composition.ndim != 1:
        raise ValueError("teqp adapter accepts one scalar T-volume state")
    x = normalize_composition(composition)
    value = self._pressure(
        float(temperature.detach().cpu()),
        1.0 / float(molar_volume.detach().cpu()),
        np.asarray(x.detach().cpu().numpy(), dtype=np.float64),
    )
    return torch.tensor(value, dtype=temperature.dtype, device=temperature.device)

select_z

select_z(temperature, pressure, composition, phase='stable')

Return compressibility factor.

Source code in src/torch_flash/backends/teqp.py
def select_z(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return compressibility factor."""
    volume = self.molar_volume(temperature, pressure, composition, phase)
    z_factor = pressure * volume / (R * temperature)
    if not bool(torch.isfinite(z_factor)):
        raise InvalidStateError("teqp produced a non-finite compressibility factor")
    return z_factor

log_fugacity_coefficients

log_fugacity_coefficients(temperature, pressure, composition, phase='stable')

Return teqp residual-model fugacity coefficients.

Source code in src/torch_flash/backends/teqp.py
def log_fugacity_coefficients(
    self,
    temperature: Tensor,
    pressure: Tensor,
    composition: Tensor,
    phase: PhaseKind = "stable",
) -> Tensor:
    """Return teqp residual-model fugacity coefficients."""
    t, _, x = self._numpy_state(temperature, pressure, composition)
    density = self._select_density(temperature, pressure, composition, phase)
    values = np.asarray(self._model.get_fugacity_coefficients(t, density * x))
    if np.any(values <= 0.0) or not np.isfinite(values).all():
        raise InvalidStateError("teqp produced invalid fugacity coefficients")
    return torch.log(torch.tensor(values, dtype=temperature.dtype, device=temperature.device))