Coverage for cosmolayer / cosmolayer.py: 95%
76 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 19:10 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 19:10 +0000
1"""
2.. module:: cosmolayer.cosmolayer
3 :synopsis: Differentiable COSMO-type activity coefficient layer.
4"""
6import warnings
7from collections.abc import Sequence
8from typing import cast
10import numpy as np
11import torch
12from numpy.typing import NDArray
14from .cosmosolver import CosmoSolver
16AREA_PER_CONTACT = 79.53 # Ų
17COORDINATION_NUMBER = 10
20class CosmoLayer(torch.nn.Module):
21 r"""Differentiable COSMO-type activity coefficient layer.
23 This class assumes that the interaction energy matrix :math:`\mathbf{U}` can depend
24 on the temperature :math:`T` through the following relationship:
26 .. math::
28 \frac{\mathbf{U}}{RT} = \sum_{n=1}^N \frac{\mathbf{U}_n}{RT^{\alpha_n}},
31 where each :math:`\mathbf{U}_n` is a constant interaction energy matrix, and
32 :math:`\alpha_n` is a constant exponent.
34 To instantiate the class, the user must provide a reference temperature
35 :math:`T_{\rm ref}`, a tuple of exponents :math:`(\alpha_1, \ldots, \alpha_N)`, and
36 a tuple of scaled interaction energy matrices
37 :math:`(\hat{\mathbf{U}}_1, \ldots, \hat{\mathbf{U}}_N)`, defined as:
39 .. math::
41 \hat{\mathbf{U}}_n = \frac{\mathbf{U}_n}{RT_{\rm ref}^{\alpha_n}}
43 Parameters
44 ----------
45 interaction_matrices : Sequence[NDArray[np.float64]]
46 The scaled interaction energy matrices at the reference temperature
47 (:math:`\hat{\mathbf{U}}_1, \ldots, \hat{\mathbf{U}}_N`).
48 Must be square matrices, all with the same shape.
49 exponents : Sequence[int]
50 The temperature exponents :math:`(\alpha_1, \ldots, \alpha_N)`. Must have the
51 same length as the number of interaction energy matrices.
52 area_per_segment : float
53 Area of each surface segment.
54 reference_temperature : float, optional
55 Reference temperature :math:`T_{\rm ref}`. Default is 298.15 K.
56 learn_matrices : bool, optional
57 Whether to register all scaled interaction energy matrices as trainable
58 parameters. Default is False.
60 Examples
61 --------
62 >>> from importlib.resources import files
63 >>> from cosmolayer import CosmoLayer
64 >>> from cosmolayer.cosmosac import CosmoSac2002Model
65 >>> import torch
66 >>> T_ref = 298.15 # K
67 >>> source = files("cosmolayer.data")
68 >>> components = {
69 ... "fluoromethane": (source / "CF.cosmo").read_text(),
70 ... "water": (source / "O.cosmo").read_text(),
71 ... }
72 >>> mixture = CosmoSac2002Model.create_mixture(components)
73 >>> interaction_matrices = mixture.interaction_matrices(T_ref)
74 >>> exponents = mixture.temperature_exponents
75 >>> area_per_segment = mixture.area_per_segment
76 >>> cosmo_layer = CosmoLayer(interaction_matrices, exponents, area_per_segment)
77 >>> cosmo_layer
78 CosmoLayer(
79 reference_temperature=298.15
80 area_per_segment=7.50
81 exponents=(1,)
82 num_segment_types=51
83 )
84 >>> T = torch.tensor(373.15)
85 >>> x = torch.tensor([0.5, 0.5], requires_grad=True)
86 >>> a = torch.tensor(mixture.areas)
87 >>> v = torch.tensor(mixture.volumes)
88 >>> P = torch.tensor(mixture.probabilities)
89 >>> ln_gamma = cosmo_layer(T, x, a, v, P)
90 >>> ln_gamma.tolist()
91 [0.805809..., 0.648071...]
92 >>> gE_RT = (x * ln_gamma).sum()
93 >>> gE_RT.item()
94 0.726940...
95 >>> gE_RT.backward()
96 >>> x.grad.tolist()
97 [0.805809..., 0.648071...]
98 """
100 def __init__( # noqa: PLR0913
101 self,
102 interaction_matrices: Sequence[NDArray[np.float64]],
103 exponents: Sequence[int],
104 area_per_segment: float,
105 *,
106 reference_temperature: float = 298.15, # K
107 learn_matrices: bool = False,
108 max_iter: int = 100,
109 ):
110 super().__init__()
112 num_matrices = len(interaction_matrices)
113 if len(exponents) != num_matrices:
114 raise ValueError(
115 f"Number of exponents ({len(exponents)}) must match "
116 f"number of interaction matrices ({num_matrices})"
117 )
119 self._num_matrices = num_matrices
121 shapes = {matrix.shape for matrix in interaction_matrices}
122 if len(shapes) != 1:
123 raise ValueError("All interaction matrices must have the same shape")
124 rows, cols = shapes.pop()
125 if rows != cols:
126 raise ValueError("Interaction matrices must be square")
127 self._n_types = rows
129 self._matrix_names_and_exponents: list[tuple[str, int]] = []
130 for idx, input_matrix in enumerate(interaction_matrices):
131 matrix = torch.as_tensor(input_matrix)
132 name = f"interaction_matrix_{idx}"
133 if learn_matrices:
134 self.register_parameter(name, torch.nn.Parameter(matrix))
135 else:
136 self.register_buffer(name, matrix)
137 self._matrix_names_and_exponents.append((name, exponents[idx]))
139 self._exponents = tuple(exponents)
140 self._ref_temp = reference_temperature
141 self._area_per_segment = area_per_segment
142 self._kappa = COORDINATION_NUMBER / (2 * AREA_PER_CONTACT)
143 self._max_iter = max_iter
145 def _check_convergence(self, converged: torch.Tensor) -> None:
146 if not bool(converged.all()):
147 warnings.warn(
148 f"COSMO solver did not converge in {self._max_iter} iterations",
149 RuntimeWarning,
150 stacklevel=2,
151 )
153 def extra_repr(self) -> str:
154 """Return a string representation of the CosmoLayer."""
155 return (
156 f"reference_temperature={self._ref_temp:.2f}\n"
157 f"area_per_segment={self._area_per_segment:.2f}\n"
158 f"exponents={self._exponents}\n"
159 f"num_segment_types={self._n_types}"
160 )
162 def log_combinatorial_activity_coefficients(
163 self,
164 fracs: torch.Tensor,
165 areas: torch.Tensor,
166 volumes: torch.Tensor,
167 ) -> torch.Tensor:
168 r"""Compute the logarithms of the combinatorial activity coefficients.
170 Parameters
171 ----------
172 fracs : torch.Tensor
173 Mole fractions of the mixture components.
174 Must sum to 1. Shape: (..., num_components).
175 areas : torch.Tensor
176 Surface areas of the mixture components, all in the same units.
177 Shape: (..., num_components).
178 volumes : torch.Tensor
179 Volumes of the mixture components, all in the same units.
180 Shape: (..., num_components).
182 Returns
183 -------
184 torch.Tensor
185 Logarithms of the combinatorial activity coefficients.
186 Shape: (..., num_components).
187 """
188 v_hat = volumes / (fracs * volumes).sum(dim=-1, keepdim=True)
189 a_hat = areas / (fracs * areas).sum(dim=-1, keepdim=True)
190 w_hat = v_hat / a_hat
191 ln_gamma_c: torch.Tensor = (
192 1 - v_hat + v_hat.log() - self._kappa * areas * (1 - w_hat + w_hat.log())
193 )
194 return ln_gamma_c
196 def mixture_probabilities(
197 self,
198 fracs: torch.Tensor,
199 areas: torch.Tensor,
200 probs: torch.Tensor,
201 ) -> torch.Tensor:
202 """Compute the probabilities of segment types in the mixture.
204 Parameters
205 ----------
206 fracs : torch.Tensor
207 Mole fractions of the components. Must sum to 1.
208 Shape: (..., num_components).
209 areas : torch.Tensor
210 Surface areas of the components.
211 Shape: (..., num_components).
212 probs : torch.Tensor
213 Probabilities of segment types per component. Must sum to 1 along the
214 segment type dimension.
215 Shape: (..., num_components, num_segment_types).
217 Returns
218 -------
219 torch.Tensor
220 Probabilities of segment types in the mixture.
221 Shape: (..., num_segment_types).
222 """
223 xa = fracs * areas
224 theta = xa / xa.sum(dim=-1, keepdim=True)
225 return (theta.unsqueeze(-1) * probs).sum(dim=-2)
227 def scaled_interactions(self, temp: torch.Tensor) -> torch.Tensor:
228 """Compute the scaled interactions at a given temperature.
230 Parameters
231 ----------
232 temp : torch.Tensor
233 Temperature in the same units as the reference temperature.
234 Shape: (...,).
236 Returns
237 -------
238 torch.Tensor
239 The scaled interactions at the given temperature.
240 Shape: (..., num_segment_types, num_segment_types).
241 """
242 beta = (self._ref_temp / temp).unsqueeze(-1).unsqueeze(-1)
243 matrices = [
244 getattr(self, name) * beta**exponent
245 for name, exponent in self._matrix_names_and_exponents
246 ]
247 return torch.stack(matrices).sum(dim=0)
249 def log_pure_segment_activity_coefficients(
250 self,
251 scaled_interactions: torch.Tensor,
252 probs: torch.Tensor,
253 ) -> torch.Tensor:
254 """Compute the log-activity coefficients of segment types in pure compounds.
256 Parameters
257 ----------
258 scaled_interactions : torch.Tensor
259 Scaled interaction energy matrix.
260 Shape: (..., num_segment_types, num_segment_types).
261 probs : torch.Tensor
262 Probabilities of segment types per component. Must sum to 1 along the
263 segment type dimension.
264 Shape: (..., num_components, num_segment_types).
266 Returns
267 -------
268 torch.Tensor
269 Log-activity coefficients of segment types in pure compounds.
270 Shape: (..., num_components, num_segment_types).
271 """
272 log_gamma_pure, converged = CosmoSolver.apply(
273 probs, scaled_interactions.unsqueeze(-3), self._max_iter
274 )
275 self._check_convergence(converged)
276 return cast(torch.Tensor, log_gamma_pure)
278 def log_mixture_segment_activity_coefficients(
279 self,
280 scaled_interactions: torch.Tensor,
281 fracs: torch.Tensor,
282 areas: torch.Tensor,
283 probs: torch.Tensor,
284 ) -> torch.Tensor:
285 """Compute the log-activity coefficients of segment types in the mixture.
287 Parameters
288 ----------
289 scaled_interactions : torch.Tensor
290 Scaled interaction energy matrix.
291 Shape: (..., num_segment_types, num_segment_types).
292 fracs : torch.Tensor
293 Mole fractions of the components. Must sum to 1.
294 Shape: (..., num_components).
295 areas : torch.Tensor
296 Surface areas of the components.
297 Shape: (..., num_components).
298 probs : torch.Tensor
299 Probabilities of segment types per component. Must sum to 1 along the
300 segment type dimension.
301 Shape: (..., num_components, num_segment_types).
303 Returns
304 -------
305 torch.Tensor
306 Log-activity coefficients of segment types in the mixture.
307 Shape: (..., num_segment_types).
308 """
309 log_gamma_mix, converged = CosmoSolver.apply(
310 self.mixture_probabilities(fracs, areas, probs),
311 scaled_interactions,
312 self._max_iter,
313 )
314 self._check_convergence(converged)
315 return cast(torch.Tensor, log_gamma_mix)
317 def log_residual_activity_coefficients(
318 self,
319 temperature: torch.Tensor,
320 fracs: torch.Tensor,
321 areas: torch.Tensor,
322 probs: torch.Tensor,
323 ) -> torch.Tensor:
324 """Compute the logarithms of the residual activity coefficients.
326 Parameters
327 ----------
328 temperature : torch.Tensor
329 Temperature in the same units as the reference temperature.
330 Shape: (...,).
331 fracs : torch.Tensor
332 Mole fractions of the components. Must sum to 1.
333 Shape: (..., num_components).
334 areas : torch.Tensor
335 Surface areas of the components.
336 Shape: (..., num_components).
337 probs : torch.Tensor
338 Probabilities of segment types per component. Must sum to 1 along the
339 segment type dimension.
340 Shape: (..., num_components, num_segment_types).
342 Returns
343 -------
344 torch.Tensor
345 Logarithms of the residual activity coefficients.
346 Shape: (..., num_components).
347 """
348 scaled_interactions = self.scaled_interactions(temperature)
349 log_gamma_pure = self.log_pure_segment_activity_coefficients(
350 scaled_interactions, probs
351 )
352 log_gamma_mix = self.log_mixture_segment_activity_coefficients(
353 scaled_interactions, fracs, areas, probs
354 )
355 num_segments = areas / self._area_per_segment
356 log_gamma_res: torch.Tensor = num_segments * (
357 probs * (log_gamma_mix.unsqueeze(-2) - log_gamma_pure)
358 ).sum(dim=-1)
359 return log_gamma_res
361 def log_activity_coefficients(
362 self,
363 temperature: torch.Tensor,
364 fracs: torch.Tensor,
365 areas: torch.Tensor,
366 volumes: torch.Tensor,
367 probs: torch.Tensor,
368 ) -> torch.Tensor:
369 """Compute the logarithms of the activity coefficients.
371 Parameters
372 ----------
373 temperature : torch.Tensor
374 Temperature in the same units as the reference temperature.
375 Shape: (...,).
376 fracs : torch.Tensor
377 Mole fractions of the components. Must sum to 1.
378 Shape: (..., num_components).
379 areas : torch.Tensor
380 Surface areas of the components.
381 Shape: (..., num_components).
382 volumes : torch.Tensor
383 Volumes of the components.
384 Shape: (..., num_components).
385 probs : torch.Tensor
386 Probabilities of segment types per component. Must sum to 1 along the
387 segment type dimension.
388 Shape: (..., num_components, num_segment_types).
390 Returns
391 -------
392 torch.Tensor
393 Logarithms of the activity coefficients.
394 Shape: (..., num_components).
395 """
396 log_gamma_c = self.log_combinatorial_activity_coefficients(
397 fracs, areas, volumes
398 )
399 log_gamma_r = self.log_residual_activity_coefficients(
400 temperature, fracs, areas, probs
401 )
402 return log_gamma_c + log_gamma_r
404 def forward(
405 self,
406 temp: torch.Tensor,
407 fracs: torch.Tensor,
408 areas: torch.Tensor,
409 volumes: torch.Tensor,
410 probs: torch.Tensor,
411 ) -> torch.Tensor:
412 """Forward pass of the CosmoLayer.
414 Parameters
415 ----------
416 temp : torch.Tensor
417 Temperature in the same units as the reference temperature.
418 Shape: (...,).
419 fracs : torch.Tensor
420 Mole fractions of the components. Must sum to 1.
421 Shape: (..., num_components).
422 areas : torch.Tensor
423 Surface areas of the components.
424 Shape: (..., num_components).
425 volumes : torch.Tensor
426 Volumes of the components.
427 Shape: (..., num_components).
428 probs : torch.Tensor
429 Probabilities of segment types per component. Must sum to 1 along the
430 segment type dimension.
431 Shape: (..., num_components, num_segment_types).
433 Returns
434 -------
435 torch.Tensor
436 Logarithms of the activity coefficients.
437 Shape: (..., num_components).
438 """
439 return self.log_activity_coefficients(temp, fracs, areas, volumes, probs)