Coverage for cosmolayer / cosmolayer.py: 93%

74 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-11 14:25 +0000

1""" 

2.. module:: cosmolayer.cosmolayer 

3 :synopsis: Differentiable COSMO-type activity coefficient layer. 

4""" 

5 

6from collections.abc import Sequence 

7from typing import cast 

8 

9import numpy as np 

10import torch 

11from numpy.typing import NDArray 

12 

13from .cosmosolver import CosmoSolver 

14 

15AREA_PER_CONTACT = 79.53 # Ų 

16COORDINATION_NUMBER = 10 

17 

18 

19class CosmoLayer(torch.nn.Module): 

20 r"""Differentiable COSMO-type activity coefficient layer. 

21 

22 This class assumes that the interaction energy matrix :math:`\mathbf{U}` can depend 

23 on the temperature :math:`T` through the following relationship: 

24 

25 .. math:: 

26 

27 \frac{\mathbf{U}}{RT} = \sum_{n=1}^N \frac{\mathbf{U}_n}{RT^{\alpha_n}}, 

28 

29 

30 where each :math:`\mathbf{U}_n` is a constant interaction energy matrix, and 

31 :math:`\alpha_n` is a constant exponent. 

32 

33 To instantiate the class, the user must provide a reference temperature 

34 :math:`T_{\rm ref}`, a tuple of exponents :math:`(\alpha_1, \ldots, \alpha_N)`, and 

35 a tuple of scaled interaction energy matrices 

36 :math:`(\hat{\mathbf{U}}_1, \ldots, \hat{\mathbf{U}}_N)`, defined as: 

37 

38 .. math:: 

39 

40 \hat{\mathbf{U}}_n = \frac{\mathbf{U}_n}{RT_{\rm ref}^{\alpha_n}} 

41 

42 Parameters 

43 ---------- 

44 interaction_matrices : Sequence[NDArray[np.float64]] 

45 The scaled interaction energy matrices at the reference temperature 

46 (:math:`\hat{\mathbf{U}}_1, \ldots, \hat{\mathbf{U}}_N`). 

47 Must be square matrices, all with the same shape. 

48 exponents : Sequence[int] 

49 The temperature exponents :math:`(\alpha_1, \ldots, \alpha_N)`. Must have the 

50 same length as the number of interaction energy matrices. 

51 area_per_segment : float 

52 Area of each surface segment. 

53 reference_temperature : float, optional 

54 Reference temperature :math:`T_{\rm ref}`. Default is 298.15 K. 

55 learn_matrices : bool, optional 

56 Whether to register all scaled interaction energy matrices as trainable 

57 parameters. Default is False. 

58 

59 Examples 

60 -------- 

61 >>> from importlib.resources import files 

62 >>> from cosmolayer import CosmoLayer 

63 >>> from cosmolayer.cosmosac import CosmoSac2002Model 

64 >>> import torch 

65 >>> T_ref = 298.15 # K 

66 >>> source = files("cosmolayer.data") 

67 >>> components = { 

68 ... "fluoromethane": (source / "CF.cosmo").read_text(), 

69 ... "water": (source / "O.cosmo").read_text(), 

70 ... } 

71 >>> mixture = CosmoSac2002Model.create_mixture(components) 

72 >>> interaction_matrices = mixture.interaction_matrices(T_ref) 

73 >>> exponents = mixture.temperature_exponents 

74 >>> area_per_segment = mixture.area_per_segment 

75 >>> cosmo_layer = CosmoLayer(interaction_matrices, exponents, area_per_segment) 

76 >>> cosmo_layer 

77 CosmoLayer( 

78 reference_temperature=298.15 

79 area_per_segment=7.50 

80 exponents=(1,) 

81 num_segment_types=51 

82 ) 

83 >>> T = torch.tensor(373.15) 

84 >>> x = torch.tensor([0.5, 0.5], requires_grad=True) 

85 >>> a = torch.tensor(mixture.areas) 

86 >>> v = torch.tensor(mixture.volumes) 

87 >>> P = torch.tensor(mixture.probabilities) 

88 >>> ln_gamma = cosmo_layer(T, x, a, v, P) 

89 >>> ln_gamma.tolist() 

90 [0.805809..., 0.648071...] 

91 >>> gE_RT = (x * ln_gamma).sum() 

92 >>> gE_RT.item() 

93 0.726940... 

94 >>> gE_RT.backward() 

95 >>> x.grad.tolist() 

96 [0.805809..., 0.648071...] 

97 """ 

98 

99 def __init__( # noqa: PLR0913 

100 self, 

101 interaction_matrices: Sequence[NDArray[np.float64]], 

102 exponents: Sequence[int], 

103 area_per_segment: float, 

104 *, 

105 reference_temperature: float = 298.15, # K 

106 learn_matrices: bool = False, 

107 max_iter: int = 100, 

108 ): 

109 super().__init__() 

110 

111 num_matrices = len(interaction_matrices) 

112 if len(exponents) != num_matrices: 

113 raise ValueError( 

114 f"Number of exponents ({len(exponents)}) must match " 

115 f"number of interaction matrices ({num_matrices})" 

116 ) 

117 

118 self._num_matrices = num_matrices 

119 

120 shapes = {matrix.shape for matrix in interaction_matrices} 

121 if len(shapes) != 1: 

122 raise ValueError("All interaction matrices must have the same shape") 

123 rows, cols = shapes.pop() 

124 if rows != cols: 

125 raise ValueError("Interaction matrices must be square") 

126 self._n_types = rows 

127 

128 self._matrix_names_and_exponents: list[tuple[str, int]] = [] 

129 for idx, input_matrix in enumerate(interaction_matrices): 

130 matrix = torch.as_tensor(input_matrix) 

131 name = f"interaction_matrix_{idx}" 

132 if learn_matrices: 

133 self.register_parameter(name, torch.nn.Parameter(matrix)) 

134 else: 

135 self.register_buffer(name, matrix) 

136 self._matrix_names_and_exponents.append((name, exponents[idx])) 

137 

138 self._exponents = tuple(exponents) 

139 self._ref_temp = reference_temperature 

140 self._area_per_segment = area_per_segment 

141 self._kappa = COORDINATION_NUMBER / (2 * AREA_PER_CONTACT) 

142 self._max_iter = max_iter 

143 

144 def extra_repr(self) -> str: 

145 """Return a string representation of the CosmoLayer.""" 

146 return ( 

147 f"reference_temperature={self._ref_temp:.2f}\n" 

148 f"area_per_segment={self._area_per_segment:.2f}\n" 

149 f"exponents={self._exponents}\n" 

150 f"num_segment_types={self._n_types}" 

151 ) 

152 

153 def log_combinatorial_activity_coefficients( 

154 self, 

155 fracs: torch.Tensor, 

156 areas: torch.Tensor, 

157 volumes: torch.Tensor, 

158 ) -> torch.Tensor: 

159 r"""Compute the logarithms of the combinatorial activity coefficients. 

160 

161 Parameters 

162 ---------- 

163 fracs : torch.Tensor 

164 Mole fractions of the mixture components. 

165 Must sum to 1. Shape: (..., num_components). 

166 areas : torch.Tensor 

167 Surface areas of the mixture components, all in the same units. 

168 Shape: (..., num_components). 

169 volumes : torch.Tensor 

170 Volumes of the mixture components, all in the same units. 

171 Shape: (..., num_components). 

172 

173 Returns 

174 ------- 

175 torch.Tensor 

176 Logarithms of the combinatorial activity coefficients. 

177 Shape: (..., num_components). 

178 """ 

179 v_hat = volumes / (fracs * volumes).sum(dim=-1, keepdim=True) 

180 a_hat = areas / (fracs * areas).sum(dim=-1, keepdim=True) 

181 w_hat = v_hat / a_hat 

182 ln_gamma_c: torch.Tensor = ( 

183 1 - v_hat + v_hat.log() - self._kappa * areas * (1 - w_hat + w_hat.log()) 

184 ) 

185 return ln_gamma_c 

186 

187 def mixture_probabilities( 

188 self, 

189 fracs: torch.Tensor, 

190 areas: torch.Tensor, 

191 probs: torch.Tensor, 

192 ) -> torch.Tensor: 

193 """Compute the probabilities of segment types in the mixture. 

194 

195 Parameters 

196 ---------- 

197 fracs : torch.Tensor 

198 Mole fractions of the components. Must sum to 1. 

199 Shape: (..., num_components). 

200 areas : torch.Tensor 

201 Surface areas of the components. 

202 Shape: (..., num_components). 

203 probs : torch.Tensor 

204 Probabilities of segment types per component. Must sum to 1 along the 

205 segment type dimension. 

206 Shape: (..., num_components, num_segment_types). 

207 

208 Returns 

209 ------- 

210 torch.Tensor 

211 Probabilities of segment types in the mixture. 

212 Shape: (..., num_segment_types). 

213 """ 

214 xa = fracs * areas 

215 theta = xa / xa.sum(dim=-1, keepdim=True) 

216 return (theta.unsqueeze(-1) * probs).sum(dim=-2) 

217 

218 def scaled_interactions(self, temp: torch.Tensor) -> torch.Tensor: 

219 """Compute the scaled interactions at a given temperature. 

220 

221 Parameters 

222 ---------- 

223 temp : torch.Tensor 

224 Temperature in the same units as the reference temperature. 

225 Shape: (...,). 

226 

227 Returns 

228 ------- 

229 torch.Tensor 

230 The scaled interactions at the given temperature. 

231 Shape: (..., num_segment_types, num_segment_types). 

232 """ 

233 beta = (self._ref_temp / temp).unsqueeze(-1).unsqueeze(-1) 

234 matrices = [ 

235 getattr(self, name) * beta**exponent 

236 for name, exponent in self._matrix_names_and_exponents 

237 ] 

238 return torch.stack(matrices).sum(dim=0) 

239 

240 def log_pure_segment_activity_coefficients( 

241 self, 

242 scaled_interactions: torch.Tensor, 

243 probs: torch.Tensor, 

244 ) -> torch.Tensor: 

245 """Compute the log-activity coefficients of segment types in pure compounds. 

246 

247 Parameters 

248 ---------- 

249 scaled_interactions : torch.Tensor 

250 Scaled interaction energy matrix. 

251 Shape: (..., num_segment_types, num_segment_types). 

252 probs : torch.Tensor 

253 Probabilities of segment types per component. Must sum to 1 along the 

254 segment type dimension. 

255 Shape: (..., num_components, num_segment_types). 

256 

257 Returns 

258 ------- 

259 torch.Tensor 

260 Log-activity coefficients of segment types in pure compounds. 

261 Shape: (..., num_components, num_segment_types). 

262 """ 

263 log_gamma_pure, converged = CosmoSolver.apply( 

264 probs, scaled_interactions.unsqueeze(-3), self._max_iter 

265 ) 

266 if not bool(converged.all()): 

267 raise RuntimeError( 

268 f"Newton solver did not converge in {self._max_iter} iterations" 

269 ) 

270 return cast(torch.Tensor, log_gamma_pure) 

271 

272 def log_mixture_segment_activity_coefficients( 

273 self, 

274 scaled_interactions: torch.Tensor, 

275 fracs: torch.Tensor, 

276 areas: torch.Tensor, 

277 probs: torch.Tensor, 

278 ) -> torch.Tensor: 

279 """Compute the log-activity coefficients of segment types in the mixture. 

280 

281 Parameters 

282 ---------- 

283 scaled_interactions : torch.Tensor 

284 Scaled interaction energy matrix. 

285 Shape: (..., num_segment_types, num_segment_types). 

286 fracs : torch.Tensor 

287 Mole fractions of the components. Must sum to 1. 

288 Shape: (..., num_components). 

289 areas : torch.Tensor 

290 Surface areas of the components. 

291 Shape: (..., num_components). 

292 probs : torch.Tensor 

293 Probabilities of segment types per component. Must sum to 1 along the 

294 segment type dimension. 

295 Shape: (..., num_components, num_segment_types). 

296 

297 Returns 

298 ------- 

299 torch.Tensor 

300 Log-activity coefficients of segment types in the mixture. 

301 Shape: (..., num_segment_types). 

302 """ 

303 log_gamma_mix, converged = CosmoSolver.apply( 

304 self.mixture_probabilities(fracs, areas, probs), 

305 scaled_interactions, 

306 self._max_iter, 

307 ) 

308 if not bool(converged.all()): 

309 raise RuntimeError( 

310 f"Newton solver did not converge in {self._max_iter} iterations" 

311 ) 

312 return cast(torch.Tensor, log_gamma_mix) 

313 

314 def log_residual_activity_coefficients( 

315 self, 

316 temperature: torch.Tensor, 

317 fracs: torch.Tensor, 

318 areas: torch.Tensor, 

319 probs: torch.Tensor, 

320 ) -> torch.Tensor: 

321 """Compute the logarithms of the residual activity coefficients. 

322 

323 Parameters 

324 ---------- 

325 temperature : torch.Tensor 

326 Temperature in the same units as the reference temperature. 

327 Shape: (...,). 

328 fracs : torch.Tensor 

329 Mole fractions of the components. Must sum to 1. 

330 Shape: (..., num_components). 

331 areas : torch.Tensor 

332 Surface areas of the components. 

333 Shape: (..., num_components). 

334 probs : torch.Tensor 

335 Probabilities of segment types per component. Must sum to 1 along the 

336 segment type dimension. 

337 Shape: (..., num_components, num_segment_types). 

338 

339 Returns 

340 ------- 

341 torch.Tensor 

342 Logarithms of the residual activity coefficients. 

343 Shape: (..., num_components). 

344 """ 

345 scaled_interactions = self.scaled_interactions(temperature) 

346 log_gamma_pure = self.log_pure_segment_activity_coefficients( 

347 scaled_interactions, probs 

348 ) 

349 log_gamma_mix = self.log_mixture_segment_activity_coefficients( 

350 scaled_interactions, fracs, areas, probs 

351 ) 

352 num_segments = areas / self._area_per_segment 

353 log_gamma_res: torch.Tensor = num_segments * ( 

354 probs * (log_gamma_mix.unsqueeze(-2) - log_gamma_pure) 

355 ).sum(dim=-1) 

356 return log_gamma_res 

357 

358 def log_activity_coefficients( 

359 self, 

360 temperature: torch.Tensor, 

361 fracs: torch.Tensor, 

362 areas: torch.Tensor, 

363 volumes: torch.Tensor, 

364 probs: torch.Tensor, 

365 ) -> torch.Tensor: 

366 """Compute the logarithms of the activity coefficients. 

367 

368 Parameters 

369 ---------- 

370 temperature : torch.Tensor 

371 Temperature in the same units as the reference temperature. 

372 Shape: (...,). 

373 fracs : torch.Tensor 

374 Mole fractions of the components. Must sum to 1. 

375 Shape: (..., num_components). 

376 areas : torch.Tensor 

377 Surface areas of the components. 

378 Shape: (..., num_components). 

379 volumes : torch.Tensor 

380 Volumes of the components. 

381 Shape: (..., num_components). 

382 probs : torch.Tensor 

383 Probabilities of segment types per component. Must sum to 1 along the 

384 segment type dimension. 

385 Shape: (..., num_components, num_segment_types). 

386 

387 Returns 

388 ------- 

389 torch.Tensor 

390 Logarithms of the activity coefficients. 

391 Shape: (..., num_components). 

392 """ 

393 log_gamma_c = self.log_combinatorial_activity_coefficients( 

394 fracs, areas, volumes 

395 ) 

396 log_gamma_r = self.log_residual_activity_coefficients( 

397 temperature, fracs, areas, probs 

398 ) 

399 return log_gamma_c + log_gamma_r 

400 

401 def forward( 

402 self, 

403 temp: torch.Tensor, 

404 fracs: torch.Tensor, 

405 areas: torch.Tensor, 

406 volumes: torch.Tensor, 

407 probs: torch.Tensor, 

408 ) -> torch.Tensor: 

409 """Forward pass of the CosmoLayer. 

410 

411 Parameters 

412 ---------- 

413 temp : torch.Tensor 

414 Temperature in the same units as the reference temperature. 

415 Shape: (...,). 

416 fracs : torch.Tensor 

417 Mole fractions of the components. Must sum to 1. 

418 Shape: (..., num_components). 

419 areas : torch.Tensor 

420 Surface areas of the components. 

421 Shape: (..., num_components). 

422 volumes : torch.Tensor 

423 Volumes of the components. 

424 Shape: (..., num_components). 

425 probs : torch.Tensor 

426 Probabilities of segment types per component. Must sum to 1 along the 

427 segment type dimension. 

428 Shape: (..., num_components, num_segment_types). 

429 

430 Returns 

431 ------- 

432 torch.Tensor 

433 Logarithms of the activity coefficients. 

434 Shape: (..., num_components). 

435 """ 

436 return self.log_activity_coefficients(temp, fracs, areas, volumes, probs)