enthalpy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- enthalpy/__init__.py +55 -0
- enthalpy/backend/__init__.py +78 -0
- enthalpy/backend/_base.py +144 -0
- enthalpy/backend/_jax.py +149 -0
- enthalpy/backend/_numpy.py +83 -0
- enthalpy/chemistry/__init__.py +34 -0
- enthalpy/chemistry/equilibrium.py +426 -0
- enthalpy/chemistry/reactions.py +185 -0
- enthalpy/chemistry/species.py +41 -0
- enthalpy/chemistry/system.py +471 -0
- enthalpy/chemistry/thermodynamics.py +144 -0
- enthalpy/cli.py +94 -0
- enthalpy/constants.py +21 -0
- enthalpy/exceptions.py +83 -0
- enthalpy/experiments/__init__.py +29 -0
- enthalpy/experiments/base.py +183 -0
- enthalpy/experiments/conditions.py +56 -0
- enthalpy/experiments/dataset.py +112 -0
- enthalpy/experiments/experiment.py +177 -0
- enthalpy/experiments/itc.py +90 -0
- enthalpy/experiments/nmr.py +115 -0
- enthalpy/experiments/protocols.py +221 -0
- enthalpy/experiments/stocks.py +126 -0
- enthalpy/experiments/synthetic.py +51 -0
- enthalpy/experiments/uvvis.py +124 -0
- enthalpy/inference/__init__.py +23 -0
- enthalpy/inference/fitting.py +321 -0
- enthalpy/inference/optimizers/__init__.py +6 -0
- enthalpy/inference/optimizers/base.py +63 -0
- enthalpy/inference/optimizers/scipy.py +71 -0
- enthalpy/inference/parameters.py +207 -0
- enthalpy/inference/problem.py +318 -0
- enthalpy/inference/result.py +320 -0
- enthalpy/inference/varpro.py +183 -0
- enthalpy/io/__init__.py +20 -0
- enthalpy/io/export.py +91 -0
- enthalpy/io/importers/__init__.py +5 -0
- enthalpy/io/importers/csv.py +89 -0
- enthalpy/io/records.py +215 -0
- enthalpy/io/schema.py +421 -0
- enthalpy/io/store.py +79 -0
- enthalpy/project.py +137 -0
- enthalpy/specs.py +148 -0
- enthalpy/statistics/__init__.py +30 -0
- enthalpy/statistics/bootstrap.py +98 -0
- enthalpy/statistics/comparison.py +134 -0
- enthalpy/statistics/covariance.py +102 -0
- enthalpy/statistics/profile.py +231 -0
- enthalpy/units.py +170 -0
- enthalpy/visualization/__init__.py +18 -0
- enthalpy/visualization/_axis.py +64 -0
- enthalpy/visualization/_style.py +150 -0
- enthalpy/visualization/diagnostics.py +120 -0
- enthalpy/visualization/fits.py +221 -0
- enthalpy/visualization/species.py +91 -0
- enthalpy-0.1.0.dist-info/METADATA +101 -0
- enthalpy-0.1.0.dist-info/RECORD +61 -0
- enthalpy-0.1.0.dist-info/WHEEL +5 -0
- enthalpy-0.1.0.dist-info/entry_points.txt +2 -0
- enthalpy-0.1.0.dist-info/licenses/LICENSE +674 -0
- enthalpy-0.1.0.dist-info/top_level.txt +1 -0
enthalpy/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""EnthalPy: global analysis of molecular binding equilibria from titration experiments."""
|
|
2
|
+
|
|
3
|
+
from enthalpy import io
|
|
4
|
+
from enthalpy.chemistry import ChemicalSystem, GibbsEnthalpy
|
|
5
|
+
from enthalpy.experiments import (
|
|
6
|
+
Conditions,
|
|
7
|
+
Experiment,
|
|
8
|
+
ITCHeat,
|
|
9
|
+
ITCInjectionProtocol,
|
|
10
|
+
NMRShift,
|
|
11
|
+
Stock,
|
|
12
|
+
TitrationProtocol,
|
|
13
|
+
UVVisAbsorbance,
|
|
14
|
+
synthesize,
|
|
15
|
+
)
|
|
16
|
+
from enthalpy.inference import FitResult, ScipyLeastSquares
|
|
17
|
+
from enthalpy.project import GlobalFit
|
|
18
|
+
from enthalpy.statistics import (
|
|
19
|
+
bootstrap,
|
|
20
|
+
compare_models,
|
|
21
|
+
information_criteria,
|
|
22
|
+
likelihood_ratio_test,
|
|
23
|
+
linear_standard_errors,
|
|
24
|
+
profile_likelihood,
|
|
25
|
+
)
|
|
26
|
+
from enthalpy.units import Q_, ureg
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ChemicalSystem",
|
|
32
|
+
"Conditions",
|
|
33
|
+
"Experiment",
|
|
34
|
+
"FitResult",
|
|
35
|
+
"GibbsEnthalpy",
|
|
36
|
+
"GlobalFit",
|
|
37
|
+
"ITCHeat",
|
|
38
|
+
"ITCInjectionProtocol",
|
|
39
|
+
"NMRShift",
|
|
40
|
+
"Q_",
|
|
41
|
+
"ScipyLeastSquares",
|
|
42
|
+
"Stock",
|
|
43
|
+
"TitrationProtocol",
|
|
44
|
+
"UVVisAbsorbance",
|
|
45
|
+
"__version__",
|
|
46
|
+
"bootstrap",
|
|
47
|
+
"io",
|
|
48
|
+
"compare_models",
|
|
49
|
+
"information_criteria",
|
|
50
|
+
"likelihood_ratio_test",
|
|
51
|
+
"linear_standard_errors",
|
|
52
|
+
"profile_likelihood",
|
|
53
|
+
"synthesize",
|
|
54
|
+
"ureg",
|
|
55
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Array backends for the numerical kernels (decision D3).
|
|
2
|
+
|
|
3
|
+
Examples
|
|
4
|
+
--------
|
|
5
|
+
>>> from enthalpy.backend import get_backend
|
|
6
|
+
>>> backend = get_backend("numpy")
|
|
7
|
+
>>> backend.xp.exp(backend.asarray([0.0]))
|
|
8
|
+
array([1.])
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
|
|
15
|
+
from enthalpy.backend._base import Array, Backend
|
|
16
|
+
from enthalpy.backend._jax import jax_unavailable_reason
|
|
17
|
+
from enthalpy.backend._numpy import NumpyBackend
|
|
18
|
+
from enthalpy.exceptions import BackendError
|
|
19
|
+
|
|
20
|
+
__all__ = ["Array", "Backend", "available_backends", "get_backend"]
|
|
21
|
+
|
|
22
|
+
_BACKEND_NAMES = ("numpy", "jax")
|
|
23
|
+
_instances: dict[str, Backend] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def available_backends() -> list[str]:
|
|
27
|
+
"""Return the names of backends that can be used on this machine.
|
|
28
|
+
|
|
29
|
+
JAX counts as available only if it is installed *and* imports successfully (jaxlib can fail at
|
|
30
|
+
import time, e.g. on CPUs without AVX). Checking this imports JAX, which takes about a second the
|
|
31
|
+
first time.
|
|
32
|
+
"""
|
|
33
|
+
names = ["numpy"]
|
|
34
|
+
if importlib.util.find_spec("jax") is not None and jax_unavailable_reason() is None:
|
|
35
|
+
names.append("jax")
|
|
36
|
+
return names
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_backend(backend: str | Backend = "numpy", *, enable_x64: bool = False) -> Backend:
|
|
40
|
+
"""Return a backend instance.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
backend
|
|
45
|
+
Backend name (``"numpy"`` or ``"jax"``) or an existing :class:`Backend`, which is
|
|
46
|
+
validated and returned unchanged.
|
|
47
|
+
enable_x64
|
|
48
|
+
JAX only: enable 64-bit floats globally if they are not enabled yet.
|
|
49
|
+
|
|
50
|
+
Raises
|
|
51
|
+
------
|
|
52
|
+
BackendError
|
|
53
|
+
If the backend is unknown, its dependencies are missing, or it is misconfigured.
|
|
54
|
+
"""
|
|
55
|
+
if isinstance(backend, Backend):
|
|
56
|
+
backend.validate()
|
|
57
|
+
return backend
|
|
58
|
+
|
|
59
|
+
name = backend.lower()
|
|
60
|
+
if name not in _BACKEND_NAMES:
|
|
61
|
+
raise BackendError(f"Unknown backend {backend!r}. Available backends: {', '.join(_BACKEND_NAMES)}.")
|
|
62
|
+
|
|
63
|
+
instance = _instances.get(name)
|
|
64
|
+
if instance is None:
|
|
65
|
+
if name == "numpy":
|
|
66
|
+
instance = NumpyBackend()
|
|
67
|
+
else:
|
|
68
|
+
from enthalpy.backend._jax import JaxBackend
|
|
69
|
+
|
|
70
|
+
instance = JaxBackend(enable_x64=enable_x64)
|
|
71
|
+
_instances[name] = instance
|
|
72
|
+
elif name == "jax" and enable_x64:
|
|
73
|
+
import jax
|
|
74
|
+
|
|
75
|
+
jax.config.update("jax_enable_x64", True)
|
|
76
|
+
|
|
77
|
+
instance.validate()
|
|
78
|
+
return instance
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Abstract array backend (decision D3).
|
|
2
|
+
|
|
3
|
+
Numerical kernels (θ → thermodynamics → equilibrium → observables → log-likelihood) are written
|
|
4
|
+
against a :class:`Backend`. It provides
|
|
5
|
+
|
|
6
|
+
- an Array-API namespace (:attr:`Backend.xp`) for elementwise operations and linear algebra,
|
|
7
|
+
- functional control flow (:meth:`Backend.while_loop`, :meth:`Backend.fori_loop`) that stays
|
|
8
|
+
traceable under ``jax.jit``,
|
|
9
|
+
- functional updates (:meth:`Backend.at_set`, :meth:`Backend.at_add`) because JAX arrays are
|
|
10
|
+
immutable,
|
|
11
|
+
- root finding with implicit differentiation (:meth:`Backend.custom_root`).
|
|
12
|
+
|
|
13
|
+
Kernel rules: no in-place assignment, no Python ``if``/``while`` on array values, and float64
|
|
14
|
+
throughout.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from types import ModuleType
|
|
22
|
+
from typing import Any, ClassVar, TypeAlias, TypeVar
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
from numpy.typing import NDArray
|
|
26
|
+
|
|
27
|
+
__all__ = ["Array", "Backend"]
|
|
28
|
+
|
|
29
|
+
Array: TypeAlias = Any
|
|
30
|
+
"""Backend array type (``numpy.ndarray`` or ``jax.Array``)."""
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Backend(ABC):
|
|
37
|
+
"""Interface that every array backend implements."""
|
|
38
|
+
|
|
39
|
+
name: ClassVar[str]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def xp(self) -> ModuleType:
|
|
44
|
+
"""Array-API compatible namespace (use like ``numpy``)."""
|
|
45
|
+
|
|
46
|
+
def validate(self) -> None: # noqa: B027 - optional hook, deliberately not abstract
|
|
47
|
+
"""Check that the backend is usable in its current configuration.
|
|
48
|
+
|
|
49
|
+
Raises
|
|
50
|
+
------
|
|
51
|
+
BackendError
|
|
52
|
+
If the backend is misconfigured.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def asarray(self, x: Any) -> Array:
|
|
56
|
+
"""Convert ``x`` to a ``float64`` array of this backend."""
|
|
57
|
+
return self.xp.asarray(x, dtype=self.xp.float64)
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def to_numpy(self, x: Array) -> NDArray[np.float64]:
|
|
61
|
+
"""Convert a backend array to a NumPy ``float64`` array."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def while_loop(self, cond_fun: Callable[[T], Any], body_fun: Callable[[T], T], init_val: T) -> T:
|
|
65
|
+
"""Run ``body_fun`` while ``cond_fun`` is true (``jax.lax.while_loop`` semantics).
|
|
66
|
+
|
|
67
|
+
Reverse-mode differentiation through this loop is not supported. Differentiate through
|
|
68
|
+
:meth:`custom_root` instead.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def fori_loop(self, lower: int, upper: int, body_fun: Callable[[int, T], T], init_val: T) -> T:
|
|
73
|
+
"""Apply ``val = body_fun(i, val)`` for ``i`` in ``range(lower, upper)``."""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def at_set(self, x: Array, index: Any, values: Any) -> Array:
|
|
77
|
+
"""Return a copy of ``x`` with ``x[index] = values`` (``x`` is not modified)."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def at_add(self, x: Array, index: Any, values: Any) -> Array:
|
|
81
|
+
"""Return a copy of ``x`` with ``values`` added at ``index``; repeated indices accumulate."""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def custom_root(
|
|
85
|
+
self,
|
|
86
|
+
f: Callable[[Array], Array],
|
|
87
|
+
initial_guess: Array,
|
|
88
|
+
solve: Callable[[Callable[[Array], Array], Array], Any],
|
|
89
|
+
tangent_solve: Callable[[Callable[[Array], Array], Array], Array],
|
|
90
|
+
*,
|
|
91
|
+
has_aux: bool = False,
|
|
92
|
+
) -> Any:
|
|
93
|
+
"""Find a root of ``f`` and make it differentiable by the implicit-function theorem.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
f
|
|
98
|
+
Residual function. Differentiable parameters are the variables ``f`` closes over.
|
|
99
|
+
initial_guess
|
|
100
|
+
Starting point passed to ``solve``.
|
|
101
|
+
solve
|
|
102
|
+
``solve(f, initial_guess) -> x`` with ``f(x) ≈ 0``.
|
|
103
|
+
tangent_solve
|
|
104
|
+
``tangent_solve(g, y) -> x`` solving the linear system ``g(x) = y``, where ``g`` is the
|
|
105
|
+
Jacobian-vector product of ``f`` at the root. :meth:`dense_tangent_solve` is a default.
|
|
106
|
+
has_aux
|
|
107
|
+
If true, ``solve`` returns ``(x, aux)``. The auxiliary output (e.g. convergence flags)
|
|
108
|
+
is passed through without being differentiated.
|
|
109
|
+
|
|
110
|
+
Notes
|
|
111
|
+
-----
|
|
112
|
+
On backends without automatic differentiation this simply returns
|
|
113
|
+
``solve(f, initial_guess)``. Derivatives are then computed explicitly by the caller with
|
|
114
|
+
the same implicit-function formula.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def dense_tangent_solve(self, g: Callable[[Array], Array], y: Array) -> Array:
|
|
119
|
+
"""Solve the linear system ``g(x) = y`` by materialising the matrix of the linear map ``g``."""
|
|
120
|
+
|
|
121
|
+
def block_tangent_solve(self, g: Callable[[Array], Array], y: Array) -> Array:
|
|
122
|
+
"""Solve ``g(x) = y`` for a linear map ``g`` that is block-diagonal over the leading axes.
|
|
123
|
+
|
|
124
|
+
``y`` has shape ``(..., n)``, and ``g`` must act independently on every leading index (e.g.
|
|
125
|
+
independent titration points). The ``n × n`` blocks are obtained from ``n`` evaluations
|
|
126
|
+
of ``g``, so the cost is linear in the number of blocks.
|
|
127
|
+
"""
|
|
128
|
+
xp = self.xp
|
|
129
|
+
n = y.shape[-1]
|
|
130
|
+
identity = xp.eye(n, dtype=xp.float64)
|
|
131
|
+
columns = [g(xp.zeros(y.shape, dtype=xp.float64) + identity[k]) for k in range(n)]
|
|
132
|
+
blocks = xp.stack(columns, axis=-1) # blocks[..., i, k] = g(e_k)[..., i]
|
|
133
|
+
return xp.linalg.solve(blocks, y[..., None])[..., 0]
|
|
134
|
+
|
|
135
|
+
@abstractmethod
|
|
136
|
+
def stop_gradient(self, x: T) -> T:
|
|
137
|
+
"""Treat ``x`` as a constant for differentiation."""
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
def jit(self, fun: F) -> F:
|
|
141
|
+
"""Compile ``fun`` if the backend supports it; otherwise return it unchanged."""
|
|
142
|
+
|
|
143
|
+
def __repr__(self) -> str:
|
|
144
|
+
return f"<{type(self).__name__} {self.name!r}>"
|
enthalpy/backend/_jax.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""JAX backend (automatic differentiation, JIT compilation, accelerators).
|
|
2
|
+
|
|
3
|
+
JAX is an optional dependency (``pip install enthalpy[jax]``). Equilibrium concentrations span
|
|
4
|
+
many orders of magnitude, so float64 is mandatory. The backend refuses to run while JAX is in its
|
|
5
|
+
default float32 mode.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
|
|
17
|
+
from enthalpy.backend._base import Array, Backend
|
|
18
|
+
from enthalpy.exceptions import BackendError
|
|
19
|
+
|
|
20
|
+
__all__ = ["JaxBackend", "import_jax", "jax_unavailable_reason"]
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
24
|
+
|
|
25
|
+
_X64_HELP = (
|
|
26
|
+
"The JAX backend requires 64-bit floats. Enable them before creating any arrays with "
|
|
27
|
+
"`jax.config.update('jax_enable_x64', True)`, by setting the environment variable "
|
|
28
|
+
"JAX_ENABLE_X64=1, or by calling `enthalpy.backend.get_backend('jax', enable_x64=True)`."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def import_jax() -> tuple[Any, Any]:
|
|
33
|
+
"""Import ``jax`` and ``jax.numpy``.
|
|
34
|
+
|
|
35
|
+
Raises
|
|
36
|
+
------
|
|
37
|
+
BackendError
|
|
38
|
+
If JAX is not installed, or if it is installed but cannot be imported. The latter happens,
|
|
39
|
+
for example, when jaxlib requires CPU instructions (AVX) that the machine does not provide.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
import jax
|
|
43
|
+
import jax.numpy as jnp
|
|
44
|
+
except ImportError as exc:
|
|
45
|
+
raise BackendError(f"The JAX backend requires the optional dependency 'jax' (enthalpy[jax]): {exc}") from exc
|
|
46
|
+
except Exception as exc: # e.g. RuntimeError from jaxlib's CPU feature check
|
|
47
|
+
raise BackendError(f"JAX is installed but cannot be imported: {exc}") from exc
|
|
48
|
+
return jax, jnp
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def jax_unavailable_reason() -> str | None:
|
|
52
|
+
"""Return why the JAX backend cannot be used, or ``None`` if JAX imports successfully."""
|
|
53
|
+
try:
|
|
54
|
+
import_jax()
|
|
55
|
+
except BackendError as exc:
|
|
56
|
+
return str(exc)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class JaxBackend(Backend):
|
|
61
|
+
"""Backend based on ``jax.numpy`` and ``jax.lax``.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
enable_x64
|
|
66
|
+
If true, switch JAX to 64-bit mode globally. Otherwise 64-bit mode must already be enabled.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
name = "jax"
|
|
70
|
+
|
|
71
|
+
def __init__(self, *, enable_x64: bool = False) -> None:
|
|
72
|
+
jax, jnp = import_jax()
|
|
73
|
+
self._jax: Any = jax
|
|
74
|
+
self._jnp: Any = jnp
|
|
75
|
+
if enable_x64:
|
|
76
|
+
jax.config.update("jax_enable_x64", True)
|
|
77
|
+
self.validate()
|
|
78
|
+
|
|
79
|
+
def validate(self) -> None:
|
|
80
|
+
if not bool(self._jax.config.jax_enable_x64):
|
|
81
|
+
raise BackendError(_X64_HELP)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def xp(self) -> ModuleType:
|
|
85
|
+
return self._jnp
|
|
86
|
+
|
|
87
|
+
def to_numpy(self, x: Array) -> NDArray[np.float64]:
|
|
88
|
+
return np.asarray(x, dtype=np.float64)
|
|
89
|
+
|
|
90
|
+
def while_loop(self, cond_fun: Callable[[T], Any], body_fun: Callable[[T], T], init_val: T) -> T:
|
|
91
|
+
return self._jax.lax.while_loop(cond_fun, body_fun, init_val)
|
|
92
|
+
|
|
93
|
+
def fori_loop(self, lower: int, upper: int, body_fun: Callable[[int, T], T], init_val: T) -> T:
|
|
94
|
+
return self._jax.lax.fori_loop(lower, upper, body_fun, init_val)
|
|
95
|
+
|
|
96
|
+
def at_set(self, x: Array, index: Any, values: Any) -> Array:
|
|
97
|
+
return self._jnp.asarray(x).at[index].set(values)
|
|
98
|
+
|
|
99
|
+
def at_add(self, x: Array, index: Any, values: Any) -> Array:
|
|
100
|
+
return self._jnp.asarray(x).at[index].add(values)
|
|
101
|
+
|
|
102
|
+
def custom_root(
|
|
103
|
+
self,
|
|
104
|
+
f: Callable[[Array], Array],
|
|
105
|
+
initial_guess: Array,
|
|
106
|
+
solve: Callable[[Callable[[Array], Array], Array], Any],
|
|
107
|
+
tangent_solve: Callable[[Callable[[Array], Array], Array], Array],
|
|
108
|
+
*,
|
|
109
|
+
has_aux: bool = False,
|
|
110
|
+
) -> Any:
|
|
111
|
+
lax = self._jax.lax
|
|
112
|
+
if not has_aux:
|
|
113
|
+
return lax.custom_root(f, initial_guess, solve, tangent_solve)
|
|
114
|
+
|
|
115
|
+
# Some JAX versions (e.g. 0.10.x) produce tangents of the wrong dtype for integer or boolean
|
|
116
|
+
# auxiliary outputs of custom_root. Carry all auxiliary leaves as float64 and restore their
|
|
117
|
+
# dtypes afterwards; the auxiliary output is never differentiated.
|
|
118
|
+
tree_util = self._jax.tree_util
|
|
119
|
+
jnp = self._jnp
|
|
120
|
+
meta: dict[str, Any] = {}
|
|
121
|
+
|
|
122
|
+
def solve_with_float_aux(fun: Callable[[Array], Array], guess: Array) -> Any:
|
|
123
|
+
solution, aux = solve(fun, guess)
|
|
124
|
+
leaves, treedef = tree_util.tree_flatten(aux)
|
|
125
|
+
meta["treedef"], meta["dtypes"] = treedef, [jnp.asarray(leaf).dtype for leaf in leaves]
|
|
126
|
+
return solution, tree_util.tree_unflatten(
|
|
127
|
+
treedef, [jnp.asarray(leaf, dtype=jnp.float64) for leaf in leaves]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
solution, aux = lax.custom_root(f, initial_guess, solve_with_float_aux, tangent_solve, has_aux=True)
|
|
131
|
+
leaves = [
|
|
132
|
+
lax.stop_gradient(leaf).astype(dtype)
|
|
133
|
+
for leaf, dtype in zip(tree_util.tree_leaves(aux), meta["dtypes"], strict=True)
|
|
134
|
+
]
|
|
135
|
+
return solution, tree_util.tree_unflatten(meta["treedef"], leaves)
|
|
136
|
+
|
|
137
|
+
def dense_tangent_solve(self, g: Callable[[Array], Array], y: Array) -> Array:
|
|
138
|
+
jnp = self._jnp
|
|
139
|
+
y = jnp.asarray(y)
|
|
140
|
+
if y.ndim == 0:
|
|
141
|
+
return y / g(jnp.ones_like(y))
|
|
142
|
+
matrix = self._jax.jacfwd(g)(y).reshape(y.size, y.size)
|
|
143
|
+
return jnp.linalg.solve(matrix, y.ravel()).reshape(y.shape)
|
|
144
|
+
|
|
145
|
+
def stop_gradient(self, x: T) -> T:
|
|
146
|
+
return self._jax.lax.stop_gradient(x)
|
|
147
|
+
|
|
148
|
+
def jit(self, fun: F) -> F:
|
|
149
|
+
return self._jax.jit(fun)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""NumPy backend (reference implementation, no automatic differentiation)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import array_api_compat.numpy as xp_numpy
|
|
10
|
+
import numpy as np
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
|
|
13
|
+
from enthalpy.backend._base import Array, Backend
|
|
14
|
+
|
|
15
|
+
__all__ = ["NumpyBackend"]
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NumpyBackend(Backend):
|
|
22
|
+
"""Backend based on NumPy, using the Array-API compatible ``array_api_compat.numpy`` namespace."""
|
|
23
|
+
|
|
24
|
+
name = "numpy"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def xp(self) -> ModuleType:
|
|
28
|
+
return xp_numpy
|
|
29
|
+
|
|
30
|
+
def to_numpy(self, x: Array) -> NDArray[np.float64]:
|
|
31
|
+
return np.asarray(x, dtype=np.float64)
|
|
32
|
+
|
|
33
|
+
def while_loop(self, cond_fun: Callable[[T], Any], body_fun: Callable[[T], T], init_val: T) -> T:
|
|
34
|
+
val = init_val
|
|
35
|
+
while bool(cond_fun(val)):
|
|
36
|
+
val = body_fun(val)
|
|
37
|
+
return val
|
|
38
|
+
|
|
39
|
+
def fori_loop(self, lower: int, upper: int, body_fun: Callable[[int, T], T], init_val: T) -> T:
|
|
40
|
+
val = init_val
|
|
41
|
+
for i in range(lower, upper):
|
|
42
|
+
val = body_fun(i, val)
|
|
43
|
+
return val
|
|
44
|
+
|
|
45
|
+
def at_set(self, x: Array, index: Any, values: Any) -> Array:
|
|
46
|
+
out = np.array(x, copy=True)
|
|
47
|
+
out[index] = values
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
def at_add(self, x: Array, index: Any, values: Any) -> Array:
|
|
51
|
+
out = np.array(x, copy=True)
|
|
52
|
+
np.add.at(out, index, values) # pyright: ignore[reportUnknownMemberType] (incomplete NumPy stub)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
def custom_root(
|
|
56
|
+
self,
|
|
57
|
+
f: Callable[[Array], Array],
|
|
58
|
+
initial_guess: Array,
|
|
59
|
+
solve: Callable[[Callable[[Array], Array], Array], Any],
|
|
60
|
+
tangent_solve: Callable[[Callable[[Array], Array], Array], Array],
|
|
61
|
+
*,
|
|
62
|
+
has_aux: bool = False,
|
|
63
|
+
) -> Any:
|
|
64
|
+
return solve(f, initial_guess)
|
|
65
|
+
|
|
66
|
+
def dense_tangent_solve(self, g: Callable[[Array], Array], y: Array) -> Array:
|
|
67
|
+
rhs: NDArray[np.float64] = np.asarray(y, dtype=np.float64)
|
|
68
|
+
if rhs.ndim == 0:
|
|
69
|
+
return rhs / np.asarray(g(np.ones_like(rhs)), dtype=np.float64)
|
|
70
|
+
# g is linear, so the columns of its matrix are the images of the unit vectors.
|
|
71
|
+
matrix = np.empty((rhs.size, rhs.size), dtype=np.float64)
|
|
72
|
+
for i in range(rhs.size):
|
|
73
|
+
unit = np.zeros(rhs.size, dtype=np.float64)
|
|
74
|
+
unit[i] = 1.0
|
|
75
|
+
matrix[:, i] = np.asarray(g(unit.reshape(rhs.shape)), dtype=np.float64).ravel()
|
|
76
|
+
solution = np.asarray(np.linalg.solve(matrix, rhs.ravel()), dtype=np.float64)
|
|
77
|
+
return solution.reshape(rhs.shape)
|
|
78
|
+
|
|
79
|
+
def stop_gradient(self, x: T) -> T:
|
|
80
|
+
return x
|
|
81
|
+
|
|
82
|
+
def jit(self, fun: F) -> F:
|
|
83
|
+
return fun
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Chemical model: species, reactions, thermodynamics and equilibrium (independent of techniques)."""
|
|
2
|
+
|
|
3
|
+
from enthalpy.chemistry.equilibrium import (
|
|
4
|
+
EquilibriumSensitivities,
|
|
5
|
+
EquilibriumSolver,
|
|
6
|
+
EquilibriumState,
|
|
7
|
+
NewtonSolver,
|
|
8
|
+
OneToOneSolver,
|
|
9
|
+
default_solver,
|
|
10
|
+
equilibrium_sensitivities,
|
|
11
|
+
)
|
|
12
|
+
from enthalpy.chemistry.reactions import Reaction, format_reaction, parse_reaction
|
|
13
|
+
from enthalpy.chemistry.species import Species
|
|
14
|
+
from enthalpy.chemistry.system import ChemicalSystem, FormationModel
|
|
15
|
+
from enthalpy.chemistry.thermodynamics import GibbsEnthalpy, ThermodynamicModel, ln_k
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ChemicalSystem",
|
|
19
|
+
"EquilibriumSensitivities",
|
|
20
|
+
"EquilibriumSolver",
|
|
21
|
+
"EquilibriumState",
|
|
22
|
+
"FormationModel",
|
|
23
|
+
"GibbsEnthalpy",
|
|
24
|
+
"NewtonSolver",
|
|
25
|
+
"OneToOneSolver",
|
|
26
|
+
"Reaction",
|
|
27
|
+
"Species",
|
|
28
|
+
"ThermodynamicModel",
|
|
29
|
+
"default_solver",
|
|
30
|
+
"equilibrium_sensitivities",
|
|
31
|
+
"format_reaction",
|
|
32
|
+
"ln_k",
|
|
33
|
+
"parse_reaction",
|
|
34
|
+
]
|