peritheos 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.
peritheos/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Peritheos: thermodynamic equations of state calculations."""
2
+
3
+ __version__ = "0.1.0"
peritheos/constants.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ Physical constants used in thermodynamic calculations
3
+ """
4
+
5
+ from scipy import constants
6
+
7
+ # Universal gas constant (J/(mol·K))
8
+ R = constants.R
9
+
10
+ # Boltzmann constant (J/K)
11
+ k_B = constants.Boltzmann
12
+
13
+ # Avogadro's number (1/mol)
14
+ N_A = constants.Avogadro
15
+
16
+ # Standard temperature and pressure
17
+ STP_TEMPERATURE = constants.zero_Celsius # K (273.15)
18
+ STP_PRESSURE = constants.atm # Pa (101325.0)
19
+
20
+ # Other useful constants
21
+ STANDARD_GRAVITY = constants.g # m/s²
22
+ STEFAN_BOLTZMANN = constants.Stefan_Boltzmann # W/(m²·K⁴)
@@ -0,0 +1,234 @@
1
+ """
2
+ Equations of state module for Peritheos
3
+ """
4
+
5
+ from typing import Callable, Union
6
+ import numpy as np
7
+ from numpy.typing import NDArray
8
+ from scipy import optimize
9
+
10
+ # Type alias for numeric values (scalar or array)
11
+ NumericType = Union[float, NDArray[np.float64]]
12
+
13
+
14
+ def validate_finite_scalar(value: float, name: str) -> float:
15
+ """Return *value* as a finite float or raise a descriptive error."""
16
+ value = float(value)
17
+ if not np.isfinite(value):
18
+ raise ValueError(f"{name} must be finite")
19
+ return value
20
+
21
+
22
+ def validate_positive_scalar(value: float, name: str) -> float:
23
+ """Return *value* as a positive finite float."""
24
+ value = validate_finite_scalar(value, name)
25
+ if value <= 0:
26
+ raise ValueError(f"{name} must be greater than zero")
27
+ return value
28
+
29
+
30
+ def validate_volume(V: NumericType) -> NumericType:
31
+ """Validate volume input while preserving scalar or array behaviour."""
32
+ values = np.asarray(V, dtype=float)
33
+ if not np.all(np.isfinite(values)):
34
+ raise ValueError("Volume must be finite")
35
+ if np.any(values <= 0):
36
+ raise ValueError("Volume must be greater than zero")
37
+ if values.ndim == 0:
38
+ return float(values)
39
+ return values
40
+
41
+
42
+ def _scalar_pressure(pressure_function: Callable[[float], NumericType], V: float) -> float:
43
+ """Evaluate a pressure callable and require a finite scalar result."""
44
+ pressure = np.asarray(pressure_function(V), dtype=float)
45
+ if pressure.ndim != 0:
46
+ raise TypeError("The pressure function must return a scalar for scalar volume")
47
+ result = float(pressure)
48
+ if not np.isfinite(result):
49
+ raise ArithmeticError(f"EOS returned a non-finite pressure at V={V}")
50
+ return result
51
+
52
+
53
+ def solve_volume(
54
+ pressure_function: Callable[[float], NumericType],
55
+ pressure: float,
56
+ reference_volume: float,
57
+ ) -> float:
58
+ """Solve ``pressure_function(V) == pressure`` on the branch nearest V0.
59
+
60
+ The EOS is assumed to be locally monotonic around its reference volume:
61
+ pressure increases on compression and decreases on expansion. Starting at
62
+ ``V0``, the search geometrically decreases volume for a higher target
63
+ pressure or increases it for a lower target pressure until the pressure
64
+ residual changes sign. ``scipy.optimize.brentq`` then solves inside that
65
+ positive-volume bracket. If no sign change is found, the requested state is
66
+ reported as outside the model's invertible range.
67
+ """
68
+ target = validate_finite_scalar(pressure, "Pressure")
69
+ V0 = validate_positive_scalar(reference_volume, "Reference volume")
70
+ p0 = _scalar_pressure(pressure_function, V0)
71
+ f0 = p0 - target
72
+ pressure_tolerance = 1e-10 * max(1.0, abs(target), abs(p0))
73
+ if abs(f0) <= pressure_tolerance:
74
+ return V0
75
+
76
+ if f0 < 0:
77
+ # The target is above P(V0), so search towards compression.
78
+ upper, f_upper = V0, f0
79
+ lower = V0
80
+ f_lower = f0
81
+ for _ in range(160):
82
+ lower *= 0.8
83
+ if lower <= V0 * 1e-14:
84
+ break
85
+ f_lower = _scalar_pressure(pressure_function, lower) - target
86
+ if f_lower >= 0:
87
+ break
88
+ else:
89
+ f_lower = np.nan
90
+ if not np.isfinite(f_lower) or f_lower < 0:
91
+ raise ValueError(
92
+ f"Could not bracket a positive volume for pressure {target}"
93
+ )
94
+ else:
95
+ # The target is below P(V0), so search along the first expansion branch.
96
+ lower, f_lower = V0, f0
97
+ upper = V0
98
+ f_upper = f0
99
+ for _ in range(160):
100
+ upper *= 1.05
101
+ if upper >= V0 * 1e4:
102
+ break
103
+ f_upper = _scalar_pressure(pressure_function, upper) - target
104
+ if f_upper <= 0:
105
+ break
106
+ else:
107
+ f_upper = np.nan
108
+ if not np.isfinite(f_upper) or f_upper > 0:
109
+ raise ValueError(
110
+ f"Pressure {target} is outside the invertible expansion range"
111
+ )
112
+
113
+ result = optimize.brentq(
114
+ lambda volume: _scalar_pressure(pressure_function, volume) - target,
115
+ lower,
116
+ upper,
117
+ xtol=max(np.finfo(float).eps * V0, 1e-14),
118
+ rtol=1e-12,
119
+ )
120
+ residual = abs(_scalar_pressure(pressure_function, result) - target)
121
+ if residual > 1e-8 * max(1.0, abs(target)):
122
+ raise ArithmeticError(
123
+ f"Volume inversion did not converge to the requested pressure; residual={residual}"
124
+ )
125
+ return float(result)
126
+
127
+
128
+ class EosBase:
129
+ """
130
+ Base class for equation of state implementations.
131
+
132
+ This abstract class defines the interface that all equation of state
133
+ implementations should follow.
134
+ """
135
+
136
+ def pressure(self, V: NumericType) -> NumericType:
137
+ """
138
+ Calculate pressure at a given volume.
139
+
140
+ Parameters
141
+ ----------
142
+ V : float or numpy.ndarray
143
+ Volume (in cubic angstroms or any consistent unit)
144
+
145
+ Returns
146
+ -------
147
+ float or numpy.ndarray
148
+ Pressure (in the same units as K0)
149
+ """
150
+ raise NotImplementedError("Subclasses must implement the pressure method.")
151
+
152
+ def bulk_modulus(self, V: NumericType) -> NumericType:
153
+ """
154
+ Calculate the bulk modulus.
155
+
156
+ Parameters
157
+ ----------
158
+ V : float or numpy.ndarray
159
+ Volume (in cubic angstroms or any consistent unit)
160
+
161
+ Returns
162
+ -------
163
+ float or numpy.ndarray
164
+ Bulk modulus (in the same units as K0)
165
+ """
166
+ raise NotImplementedError("Subclasses must implement the bulk_modulus method.")
167
+
168
+ def calculate_volume(self, P: NumericType) -> NumericType:
169
+ """
170
+ Calculate volume at a given pressure using a bracketed root solver.
171
+
172
+ Parameters
173
+ ----------
174
+ P : float or numpy.ndarray
175
+ Pressure (in the same units as K0)
176
+
177
+ Returns
178
+ -------
179
+ float or numpy.ndarray
180
+ Volume (in the same units as V0)
181
+ """
182
+ pressures = np.asarray(P, dtype=float)
183
+ if not np.all(np.isfinite(pressures)):
184
+ raise ValueError("Pressure must be finite")
185
+ if pressures.ndim == 0:
186
+ return solve_volume(self.pressure, float(pressures), self.V0)
187
+ return np.array(
188
+ [solve_volume(self.pressure, value, self.V0) for value in pressures.flat]
189
+ ).reshape(pressures.shape)
190
+
191
+ def volume(self, P: NumericType) -> NumericType:
192
+ """Alias for :meth:`calculate_volume` using the P-to-V terminology."""
193
+ return self.calculate_volume(P)
194
+
195
+
196
+ class ThermalEOS(EosBase):
197
+ def __init__(self, rt_eos: EosBase):
198
+ self.rt_eos = rt_eos
199
+
200
+ def thermal_pressure(self, V: NumericType, T: NumericType) -> NumericType:
201
+ raise NotImplementedError("This method should be implemented by the subclass")
202
+
203
+ def pressure(self, V: NumericType, T: NumericType) -> NumericType:
204
+ return self.thermal_pressure(V, T) + self.rt_eos.pressure(V)
205
+
206
+ def calculate_volume(self, P: NumericType, T: NumericType) -> NumericType:
207
+ """Calculate volume at pressure and temperature using bracketed roots."""
208
+ pressures, temperatures = np.broadcast_arrays(
209
+ np.asarray(P, dtype=float), np.asarray(T, dtype=float)
210
+ )
211
+ if not np.all(np.isfinite(pressures)):
212
+ raise ValueError("Pressure must be finite")
213
+ if not np.all(np.isfinite(temperatures)) or np.any(temperatures <= 0):
214
+ raise ValueError("Temperature must be finite and greater than zero")
215
+
216
+ volumes = np.array(
217
+ [
218
+ solve_volume(
219
+ lambda volume, temperature=float(temperature): self.pressure(
220
+ volume, temperature
221
+ ),
222
+ float(pressure),
223
+ self.rt_eos.V0,
224
+ )
225
+ for pressure, temperature in zip(pressures.flat, temperatures.flat)
226
+ ]
227
+ ).reshape(pressures.shape)
228
+ if volumes.ndim == 0:
229
+ return float(volumes)
230
+ return volumes
231
+
232
+ def volume(self, P: NumericType, T: NumericType) -> NumericType:
233
+ """Alias for :meth:`calculate_volume` for a thermal EOS."""
234
+ return self.calculate_volume(P, T)
File without changes