aerosolprops 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.
@@ -0,0 +1,56 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Structure-derived pure-component properties for atmospheric organic
3
+ aerosol.
4
+
5
+ Successor to the pure-component property methods of UManSysProp
6
+ (Topping et al., Geosci. Model Dev. 9, 899-911, 2016), rebuilt on RDKit as
7
+ the structure engine and following the conventions of the ``thermo``
8
+ package family: strict SI units (Pa, K, mol), estimators as pure functions
9
+ of group counts where possible, and failures returned as ``None``.
10
+
11
+ Method coverage for v0.1:
12
+
13
+ - SIMPOL.1 vapour pressure (Pankow & Asher, ACP 8, 2773, 2008) —
14
+ implemented (see :class:`aerosolprops.vapour_pressure.SIMPOL1`)
15
+ - EVAPORATION vapour pressure (Compernolle et al., ACP 11, 9431, 2011) —
16
+ implemented (see :class:`aerosolprops.vapour_pressure.EVAPORATION`)
17
+ - Nannoolal Part 1 normal boiling point (Nannoolal et al., FPE 226, 45,
18
+ 2004), including the SOA extension groups of Compernolle et al. (ACP 10,
19
+ 6271, 2010) — implemented (see
20
+ :class:`aerosolprops.boiling_point.NannoolalBP`)
21
+ - Nannoolal Part 3 vapour pressure (Nannoolal et al., FPE 269, 117, 2008) —
22
+ implemented (see :class:`aerosolprops.vapour_pressure.NannoolalVP`)
23
+ - Myrdal-Yalkowsky vapour pressure (Myrdal & Yalkowsky, IECR 36, 2494,
24
+ 1997) with the hydrogen-bonding extension of Compernolle et al. (2010) —
25
+ implemented (see :class:`aerosolprops.vapour_pressure.MyrdalYalkowsky`)
26
+
27
+ Boiling-point inputs may instead come from ``thermo``'s built-in Joback
28
+ method or from experimental data; aerosolprops does not reimplement Joback.
29
+
30
+ RDKit is a required dependency but is imported lazily: importing this
31
+ package pulls in neither RDKit nor ``thermo``'s group-contribution
32
+ machinery. Estimators consume plain group-count dictionaries — the same
33
+ interface ``thermo`` uses for its own RDKit-optional group-contribution
34
+ methods — so pieces remain extractable upstream.
35
+ """
36
+
37
+ __version__ = "0.1.0.dev0"
38
+
39
+ from . import (
40
+ boiling_point,
41
+ fragmentation,
42
+ methods,
43
+ structure,
44
+ topology,
45
+ vapour_pressure,
46
+ )
47
+
48
+ __all__ = [
49
+ "__version__",
50
+ "boiling_point",
51
+ "fragmentation",
52
+ "methods",
53
+ "structure",
54
+ "topology",
55
+ "vapour_pressure",
56
+ ]
@@ -0,0 +1,637 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Normal boiling point estimation (object-oriented wrapper layer).
3
+
4
+ Nannoolal Part 1 (Nannoolal, Rarey, Ramjugernath & Cordes, Fluid Phase
5
+ Equilibria 226, 45-63, 2004) via the priority-claim fragmenter of
6
+ :mod:`aerosolprops.fragmentation` and the pure coefficient catalogue and
7
+ estimator of :mod:`aerosolprops.methods.nannoolal_bp`.
8
+
9
+ This module owns everything RDKit: the SMARTS catalogue (authored
10
+ clean-room from the paper's group *definitions*; no third-party pattern
11
+ files consulted), the claimed-atom sets of every group, and the
12
+ second-order corrections 118-133 that no single SMARTS pattern can
13
+ express (ring sizes, ortho/meta/para pairs, steric bond counts,
14
+ molecular hydrogen count). Functional groups claim every atom of the
15
+ functional pattern except their flanking carbon skeleton (paper Table
16
+ 17d: the ester group covers exactly the C(=O)O atoms); the skeleton
17
+ groups claim single carbons; overlaps are adjudicated by the paper's
18
+ priority numbers (Table 1, footnote a: lower number wins).
19
+
20
+ The aerosolprops catalogue parameterises the C/N/O/S/F/Cl/Br/I subset of
21
+ the paper's groups. Molecules containing other elements the paper
22
+ covers (Si, Ge, Sn, P, As, Se, B, Al - groups 70-93, 113, 116, 117)
23
+ fail cleanly with a status naming the missing family: atmospheric SOA
24
+ chemistry is outside their scope and transcribing them without test
25
+ compounds would be guesswork.
26
+
27
+ Examples
28
+ --------
29
+ Paper Table 17a (3,3,4,4-tetramethylhexane; experimental Tb 444.0 K):
30
+
31
+ >>> nnp = NannoolalBP(smi="CCC(C)(C)C(C)(C)CC")
32
+ >>> (nnp.success, nnp.status, nnp.n, nnp.counts)
33
+ (True, 'OK', 10, {1: 6, 4: 2, 6: 2, 133: 1})
34
+ >>> round(nnp.boiling_point(), 1)
35
+ 429.5
36
+
37
+ Ethanol (paper Table 1, group 36 "OH short chain (<C5)"; experimental
38
+ Tb 351.5 K):
39
+
40
+ >>> nnp = NannoolalBP(smi="CCO")
41
+ >>> (nnp.success, nnp.counts, nnp.n)
42
+ (True, {1: 1, 7: 1, 36: 1}, 3)
43
+ >>> round(nnp.boiling_point(), 1)
44
+ 333.1
45
+ """
46
+
47
+ from .fragmentation import PriorityFragmenter
48
+ from .methods.nannoolal_bp import (
49
+ NANNOOLAL_BP_GROUPS,
50
+ nannoolal_bp_boiling_point,
51
+ nannoolal_bp_gi,
52
+ )
53
+ from .structure import load_rdkit, mol_from_smiles, normalize_mol
54
+ from .topology import ring_arc_distance
55
+
56
+ __all__ = ["NannoolalBP", "nannoolal_fragmentation"]
57
+
58
+ #: SMARTS catalogue: group ID -> (pattern, claim). The pattern is
59
+ #: authored from the paper's group definition; ``claim`` lists the
60
+ #: pattern-atom indices the group instance owns (its anchor first is not
61
+ #: required - the engine only needs the set). Every non-hydrogen atom
62
+ #: must end up claimed by exactly one instance.
63
+ NANNOOLAL_BP_SMARTS = {
64
+ # --- Carbon skeleton, chain (paper Table 1) ----------------------
65
+ # CH3 by neighbour type; (e) = N/O/F/Cl, (a) = aromatic atom of any
66
+ # element. 2 < 3 < 1 by priority; a methyl on an aromatic N or O is
67
+ # both (e) and (a) and goes to the lower number, 2.
68
+ 1: ("[CH3;!R;!$([CH3]~[a]);!$([CH3]~[#7,#8,#9,#17])]", (0,)),
69
+ 2: ("[CH3;!R;$([CH3]~[#7,#8,#9,#17])]", (0,)),
70
+ 3: ("[CH3;!R;$([CH3]~[a])]", (0,)),
71
+ # Chain sp3 carbons of any hydrogen count; the qualified variants 7
72
+ # (electronegative neighbour) and 8 (aromatic carbon neighbour) have
73
+ # lower priority numbers and claim first.
74
+ 4: ("[CH2;!R]", (0,)),
75
+ 5: ("[CH;!R]", (0,)),
76
+ 6: ("[C;H0;!R]", (0,)),
77
+ 7: ("[C;!R;$([C]~[#7,#8,#9,#17])]", (0,)),
78
+ 8: ("[C;!R;$([C]~[c])]", (0,)),
79
+ # Ring sp3 carbons; 12/13/14 (heteroatom or aromatic neighbours)
80
+ # have lower priority numbers than the plain 9/10/11.
81
+ 9: ("[CH2;R]", (0,)),
82
+ 10: ("[CH;R]", (0,)),
83
+ 11: ("[C;H0;R]", (0,)),
84
+ # 12: ring C with an external (non-ring-bond) N/O, or any F/Cl.
85
+ 12: ("[$([C;R]-!@[#7,#8]),$([C;R]~[#9,#17])]", (0,)),
86
+ # 13: ring C with the N/O inside the same ring (ring bond).
87
+ 13: ("[$([C;R]-@[#7,#8])]", (0,)),
88
+ 14: ("[C;R;$([C]~[c])]", (0,)),
89
+ # Aromatic carbons. 18 (fused, three aromatic neighbours) beats 16
90
+ # and 17 by priority; 17/16 split on electronegative neighbours.
91
+ 15: ("[cH]", (0,)),
92
+ 16: ("[cH0;!$([cH0]~[#7,#8,#9,#17])]", (0,)),
93
+ 17: ("[cH0;$([cH0]~[#7,#8,#9,#17])]", (0,)),
94
+ 18: ("[cH0](:[c])(:[c])~[c]", (0,)),
95
+ # --- Halogens ----------------------------------------------------
96
+ # Fluorine: the specific families (20-23, 102) have lower priority
97
+ # numbers than the generic 19, so the patterns can stay positive -
98
+ # whatever no specific family claims falls to 19 (paper: "F
99
+ # connected to C" as the remainder class). The neighbour-hydrogen
100
+ # conditions follow Table 1: H2 carbon "a" variants take exactly one
101
+ # further halogen, H1/H0 "b" variants take at least two/one.
102
+ 19: ("[F][C]", (0,)),
103
+ 20: ("[F;$([F][C]=[C])]", (0,)),
104
+ 21: ("[F;$([F][C;H0]~[F])]", (0,)),
105
+ 102: ("[F;$([F][C;H0]~[Cl])]", (0,)),
106
+ 22: ("[F;$([F][C;H1]([F,Cl])[!F,!Cl])]", (0,)),
107
+ 23: ("[F;$([F][C;H1]([F,Cl])[F,Cl])]", (0,)),
108
+ # Chlorine: 27 (two or more co-halogens) then 26 (one) then 25
109
+ # (none), descending priority; aromatic and vinyl handled first.
110
+ 25: ("[Cl][C]", (0,)),
111
+ 26: ("[Cl;$([Cl][C]~[F,Cl])]", (0,)),
112
+ 27: ("[Cl;$([Cl][C]([F,Cl])[F,Cl])]", (0,)),
113
+ 28: ("[Cl][c]", (0,)),
114
+ 29: ("[Cl;$([Cl][C]=[C])]", (0,)),
115
+ 30: ("[Br][C]", (0,)),
116
+ 31: ("[Br][c]", (0,)),
117
+ 32: ("[I][#6]", (0,)),
118
+ # --- Oxygen ------------------------------------------------------
119
+ # Alcohols 33/34/35 (carbon neighbour count; paper Table 1); 36 is
120
+ # algorithmic (molecules with fewer than five C: post-processed from
121
+ # 33/34/35); 37 is the phenol O. 44-52 carbonyl families claim the
122
+ # C, the Oxo O and the O/N single-bonded heteroatom, but never the
123
+ # flanking carbon skeleton (paper Table 17d atom lists).
124
+ 33: ("[OX2H;$([OX2H][C;H0])]", (0,)),
125
+ 34: ("[OX2H;$([OX2H][C;H1]([#6])[#6])]", (0,)),
126
+ 35: ("[OX2H;$([OX2H][C;H2][#6])]", (0,)),
127
+ # 36 claims any remaining alcohol O (e.g. methanol, whose carbon has
128
+ # no C neighbour at all); the <C5 reclassification below moves every
129
+ # 33/34/35/36 instance into 36 for short-chain molecules (paper
130
+ # Table 1, note ruling: the short-chain gate precedes the neighbour
131
+ # count - di-isopropanolamine, Table 17b, keeps 34 at six C).
132
+ 36: ("[OX2H;$([OX2H][C])]", (0,)),
133
+ 37: ("[OX2H;$([OX2H][c])]", (0,)),
134
+ # 38: plain ether O (two C neighbours, any ring/aromatic status);
135
+ # furan-type O is aromatic ([o], group 65) and never matches [OX2].
136
+ 38: ("[OX2;$([OX2]([#6])[#6])]", (0,)),
137
+ # 39: epoxide - the 3-ring O plus both its ring carbons (priority
138
+ # beats the plain ether 38).
139
+ 39: ("[OX2;r3]([C;r3])[C;r3]", (0, 1, 2)),
140
+ 44: ("[CX3](=[OX1])([OX2H1])[#6]", (0, 1, 2)),
141
+ # 45/47 split acyclic/cyclic on the ester single-bond O; 47 demands
142
+ # a non-carbonyl C neighbour so cyclic carbonates stay whole for 103.
143
+ 45: ("[CX3](=[OX1])([OX2;!R][#6])[#6]", (0, 1, 2)),
144
+ 47: ("[CX3](=[OX1])([OX2;R;$([OX2]~[#6;!$([#6]=[OX1])])])[#6]",
145
+ (0, 1, 2)),
146
+ 46: ("[CX3;H1](=[OX1])([OX2][#6])", (0, 1, 2)),
147
+ 48: ("[NX3;H0]([#6])([#6])[CX3](=[OX1])", (0, 3, 4)),
148
+ 49: ("[NX3;H1]([#6])[CX3](=[OX1])", (0, 2, 3)),
149
+ 50: ("[NX3;H2][CX3](=[OX1])", (0, 1, 2)),
150
+ 99: ("[CX3](=[OX1])([NX3])[OX2]", (0, 1, 2, 3)),
151
+ 100: ("[CX3](=[OX1])([NX3])[NX3]", (0, 1, 2, 3)),
152
+ 51: ("[CX3](=[OX1])([C])[C]", (0, 1)),
153
+ 92: ("[CX3](=[OX1])([c])[#6]", (0, 1)),
154
+ 52: ("[CX3;H1](=[OX1])([C])", (0, 1)),
155
+ 90: ("[CX3;H1](=[OX1])([c])", (0, 1)),
156
+ 94: ("[#6][OX2][OX2][#6]", (1, 2)),
157
+ 75: ("[N;X2]([OX2H1])=[#6]", (0, 1)),
158
+ 77: ("[CX3](=[OX1])([Cl])[#6]", (0, 1)),
159
+ 76: ("[CX3](=[OX1])([OX2;!R][CX3](=[OX1]))", (0, 1, 2, 3, 4)),
160
+ 96: ("[CX3](=[OX1])([OX2;R][CX3](=[OX1]))", (0, 1, 2, 3, 4)),
161
+ 79: ("[CX3](=[OX1])([OX2;!R][#6])([OX2][#6])", (0, 1, 2, 4)),
162
+ 103: ("[CX3](=[OX1])([OX2;R])([OX2;R])", (0, 1, 2, 3)),
163
+ 72: ("[#6][OX2][N+](=[OX1])([OX1-])", (1, 2, 3, 4)),
164
+ 74: ("[#6][OX2][N;X2](=[OX1])", (1, 2, 3)),
165
+ 80: ("[#6][N;X2]=[CX2]=[OX1]", (1, 2, 3)),
166
+ 65: ("[o](:[c]):[c]", (0,)),
167
+ 115: ("[o;r5]~[n;r5]", (0, 1)),
168
+ # Compernolle (2010) Table 3 extensions; the paper has no priority
169
+ # numbers for them, so aerosolprops assigns engine-local ordering (a
170
+ # separate constant below) - 302 before 301 because the peroxy-acid
171
+ # OH-O also matches the hydroperoxide pattern. Nitro/PAN nitrogen is
172
+ # written in the charge-separated form aerosolprops' normaliser emits.
173
+ 301: ("[OX2H1][OX2][#6]", (0, 1)),
174
+ 302: ("[OX2H1][OX2][CX3](=[OX1])", (0, 1, 2, 3)),
175
+ 303: ("[#6][CX3](=[OX1])([OX2][OX2][N+](=[OX1])([OX1-]))",
176
+ (1, 2, 3, 4, 5, 6, 7)),
177
+ # --- Nitrogen ----------------------------------------------------
178
+ # Amines split on hydrogen count; secondary amines split on ring
179
+ # membership (42 acyclic / 97 cyclic). The Part 1 Table 1 text for
180
+ # 97 ("at least one aromatic neighbour") contradicts its own example
181
+ # molecules (morpholine, pyrrolidine); the Part 3 revision (FPE 354
182
+ # (2008) Table 1) defines 97 explicitly as "in a ring (cyclic
183
+ # secondary amine)" with the same examples, so the cyclic reading is
184
+ # adopted. 66/67 are the "free electron pair" (H0) aromatic N only -
185
+ # a pyrrole-type ring NH is 97, and an NH bonded to a ring N (e.g.
186
+ # pyrazole) matches no group and refuses. 106 claims whole imidazole
187
+ # rings first.
188
+ 40: ("[NX3;H2][#6]", (0,)),
189
+ 41: ("[NX3;H2][c]", (0,)),
190
+ 42: ("[#7X3;H1;!R]([#6])[#6]", (0,)),
191
+ 97: ("[#7X3;H1;R]([#6;R])[#6;R]", (0,)),
192
+ 43: ("[NX3;H0]([#6])([#6])[#6]", (0,)),
193
+ 101: ("[N+;X4;H0]([#6])([#6])([#6])[#6]", (0,)),
194
+ 66: ("[n;r5;H0]", (0,)),
195
+ 67: ("[n;r6;H0]", (0,)),
196
+ 57: ("[#6][CX2]#[NX1]", (1, 2)),
197
+ 108: ("[#16][CX2]#[NX1]", (1, 2)),
198
+ 111: ("[#7][CX2]#[NX1]", (1, 2)),
199
+ 91: ("[NX2]=[#6]", (0,)),
200
+ # 68/69 split on the nitro-bearing carbon: aliphatic ([C]) vs
201
+ # aromatic ([c]); a shared [#6] context would let the lower-priority
202
+ # number 68 steal aromatic nitros from 69.
203
+ 68: ("[C][N+](=[OX1])([OX1-])", (1, 2, 3)),
204
+ 69: ("[c][N+](=[OX1])([OX1-])", (1, 2, 3)),
205
+ 106: ("[n;r5]1[c;r5][n;r5][c;r5][c;r5]1", (0, 1, 2, 3, 4)),
206
+ # --- Sulfur ------------------------------------------------------
207
+ 53: ("[SX2H1][#6]", (0,)),
208
+ 54: ("[SX2]([#6])[#6]", (0,)),
209
+ 55: ("[#6][SX2][SX2][#6]", (1, 2)),
210
+ 56: ("[s]", (0,)),
211
+ 82: ("[SX4](=[OX1])(=[OX1])([#6])[#6]", (0, 1, 2)),
212
+ 104: ("[SX4](=[OX1])(=[OX1])([OX2][#6])([OX2][#6])", (0, 1, 2, 3, 5)),
213
+ 105: ("[SX4](=[OX1])(=[OX1])([NX3])[#6]", (0, 1, 2)),
214
+ # Sulfoxide: RDKit's normaliser renders S=O sulfoxides charge-
215
+ # separated (CS(C)=O -> C[S+](C)[O-]); sulfones keep their double
216
+ # bonds (82 above), so 107 is written against the charge form only.
217
+ 107: ("[S+;X3]([OX1-])([#6])[#6]", (0, 1)),
218
+ # --- Unsaturated carbon (bond-anchored; claims every sp2/sp C of
219
+ # the unit). The C=C families are written so the substitution
220
+ # condition sits on the sp2 carbon itself (59/60: the OR recursion
221
+ # checks the atom's own substituent or its double-bond partner's),
222
+ # which resolves the paper's cinnamic-alcohol example in favour of
223
+ # 59, not 60.
224
+ 87: ("[C]=[C]=[C]", (0, 1, 2)),
225
+ 88: ("[C;R]=[C;R][C;R]=[C;R]", (0, 1, 2, 3)),
226
+ 89: ("[C;!R]=[C;!R][C;!R]=[C;!R]", (0, 1, 2, 3)),
227
+ 95: ("[C;X2]#[C;X2][C;X2]#[C;X2]", (0, 1, 2, 3)),
228
+ 64: ("[CH1]#[C]", (0, 1)),
229
+ 63: ("[C]#[C]", (0, 1)),
230
+ 61: ("[CH2]=[C]", (0, 1)),
231
+ 60: ("[$([C]~[#7,#8,#9,#17]),$([C]=[C]~[#7,#8,#9,#17]);!R]"
232
+ "=[C;!R]", (0, 1)),
233
+ 59: ("[$([C]~[c]),$([C]=[C]~[c]);!R]=[C;!R]", (0, 1)),
234
+ 58: ("[C;!R;X2,X3]=[C;!R;X2,X3]", (0, 1)),
235
+ 62: ("[C;R]=[C;R]", (0, 1)),
236
+ }
237
+
238
+ #: Engine-local claiming order for the extension groups 301-303 (the
239
+ #: paper and Compernolle (2010) give no priority numbers for them).
240
+ #: Values are chosen below every competing native group; only the
241
+ #: 302-before-301 relation is load-bearing (a peroxy acid otherwise
242
+ #: matches the hydroperoxide pattern first).
243
+ NANNOOLAL_BP_EXTENSION_PRIORITIES = {303: 1, 302: 2, 301: 3}
244
+
245
+ #: Elements the aerosolprops catalogue parameterises (paper groups outside
246
+ #: this set - Si, Ge, Sn, P, As, Se, B, Al families 70-93, 113, 116,
247
+ #: 117 - are deliberately not transcribed; see module docstring).
248
+ NANNOOLAL_BP_ELEMENTS = frozenset("C N O S F Cl Br I".split())
249
+
250
+ #: Paper group 109 ("CO connected to N", example "methyl thioacetate"
251
+ #: which contains no nitrogen) has no implementable definition and is
252
+ #: excluded from the catalogue; real amides are covered by 48-50, 99,
253
+ #: 100. Unclaimed C(=O)-N fragments of exotic acyl species therefore
254
+ #: fail the atom balance cleanly.
255
+ _ENGINE = None
256
+
257
+
258
+ def _nannoolal_engine():
259
+ """Build (once) the priority fragmenter for the Nannoolal catalogue."""
260
+ global _ENGINE
261
+ if _ENGINE is None:
262
+ catalogue = {}
263
+ for gid, (smarts, claim) in NANNOOLAL_BP_SMARTS.items():
264
+ priority = NANNOOLAL_BP_GROUPS[gid][2]
265
+ if priority is None:
266
+ priority = NANNOOLAL_BP_EXTENSION_PRIORITIES[gid]
267
+ catalogue[gid] = {
268
+ "smarts": smarts, "priority": priority, "claim": claim,
269
+ }
270
+ _ENGINE = PriorityFragmenter(catalogue)
271
+ return _ENGINE
272
+
273
+
274
+ #: Carbonyl groups whose acyl neighbours decide corrections 118-120.
275
+ _HO_CONJUGATION_GIDS = (44, 45, 46, 47, 51, 52, 90, 92)
276
+ _HALO_CONJUGATION_GIDS = (44, 45, 46, 47, 48, 49, 50, 51, 52, 77, 90, 92)
277
+
278
+
279
+ def _carbonyl_carbon(mol, claim):
280
+ """Index of the C=O carbon inside a carbonyl group's claim tuple."""
281
+ for idx in claim:
282
+ atom = mol.GetAtomWithIdx(idx)
283
+ if atom.GetSymbol() != "C":
284
+ continue
285
+ for bond in atom.GetBonds():
286
+ other = bond.GetOtherAtom(atom)
287
+ if other.GetSymbol() == "O" and bond.GetBondTypeAsDouble() == 2.0:
288
+ return idx
289
+ return None
290
+
291
+
292
+ def _halogen_count(atom):
293
+ """Number of directly bonded fluorine/chlorine atoms."""
294
+ return sum(
295
+ 1 for nb in atom.GetNeighbors() if nb.GetSymbol() in ("F", "Cl")
296
+ )
297
+
298
+
299
+ def _carbon_neighbours(atom):
300
+ return [nb for nb in atom.GetNeighbors() if nb.GetSymbol() == "C"]
301
+
302
+
303
+ def _is_sp3(atom):
304
+ """True when every bond of `atom` is a single non-aromatic bond."""
305
+ return not atom.GetIsAromatic() and all(
306
+ bond.GetBondTypeAsDouble() == 1.0 for bond in atom.GetBonds()
307
+ )
308
+
309
+
310
+ def _carbonyl_corrections(mol, assignments, corrections):
311
+ """Corrections 118-120: conjugated and halo-substituted carbonyls.
312
+
313
+ 118 - a carbonyl (acid, ester, lactone, ketone, aldehyde) whose acyl
314
+ carbon neighbour is sp2/aromatic (paper Table 11); aryl
315
+ ketones and benzaldehyde always carry it by definition.
316
+ 119 - a carbonyl with one acyl neighbour carrying two or more F/Cl.
317
+ 120 - a carbonyl with two or more such neighbours (per paper Table
318
+ 17c, 119 and 120 are mutually exclusive).
319
+ """
320
+ for gid in _HO_CONJUGATION_GIDS:
321
+ for claim in assignments.get(gid, ()):
322
+ cx = _carbonyl_carbon(mol, claim)
323
+ if cx is None:
324
+ continue
325
+ for nb in mol.GetAtomWithIdx(cx).GetNeighbors():
326
+ if nb.GetIdx() in claim and nb.GetSymbol() == "O":
327
+ continue
328
+ if nb.GetIsAromatic() or any(
329
+ b.GetBondTypeAsDouble() == 2.0 for b in nb.GetBonds()
330
+ ):
331
+ corrections[118] = corrections.get(118, 0) + 1
332
+ break
333
+ for gid in _HALO_CONJUGATION_GIDS:
334
+ for claim in assignments.get(gid, ()):
335
+ cx = _carbonyl_carbon(mol, claim)
336
+ if cx is None:
337
+ continue
338
+ halo_carbons = sum(
339
+ 1
340
+ for nb in mol.GetAtomWithIdx(cx).GetNeighbors()
341
+ if nb.GetSymbol() == "C" and _halogen_count(nb) >= 2
342
+ )
343
+ if halo_carbons >= 2:
344
+ corrections[120] = corrections.get(120, 0) + 1
345
+ elif halo_carbons == 1:
346
+ corrections[119] = corrections.get(119, 0) + 1
347
+
348
+
349
+ def _halogen_carbon_corrections(mol, corrections):
350
+ """Corrections 121/122: heavily halogenated carbon atoms.
351
+
352
+ 121 - a carbon with three or more F/Cl neighbours (per carbon).
353
+ 122 - a carbon with exactly two F/Cl and two carbon neighbours.
354
+ """
355
+ for atom in mol.GetAtoms():
356
+ if atom.GetSymbol() != "C":
357
+ continue
358
+ halo = _halogen_count(atom)
359
+ if halo >= 3:
360
+ corrections[121] = corrections.get(121, 0) + 1
361
+ elif halo == 2 and len(_carbon_neighbours(atom)) == 2:
362
+ corrections[122] = corrections.get(122, 0) + 1
363
+
364
+
365
+ def _steric_corrections(mol, corrections):
366
+ """Corrections 130-133: crowded quaternary carbons and C-C bonds.
367
+
368
+ 130 - a carbon with four carbon neighbours at least one of which is
369
+ aromatic or double-bonded (tert-butylbenzene crowding; the
370
+ bond from an sp3 carbon to a ring atom is single, so the
371
+ condition sits on the neighbour, not the bond).
372
+ 131/132/133 - per single C(sp3)-C(sp3) bond, by the number of
373
+ further carbon neighbours of each endpoint: 2-2, 3-2, 3-3
374
+ (paper Table 2 names C2C-CC2 / C3C-CC2 / C3C-CC3). Ring bonds
375
+ qualify: the paper's own example for 132 (ethyl bornyl ether)
376
+ only reaches 3+2 on the bornane bridge bond C7-C4, and the
377
+ 131 example (bicyclohexyl) is the acyclic analogue of decalin's
378
+ fused (2,2) bond. Ordinary small-ring crowding never reaches
379
+ (2,2) per bond (cyclohexane bonds are 1-1), so 125/126 stay
380
+ the sole small-ring corrections.
381
+ """
382
+ for atom in mol.GetAtoms():
383
+ if atom.GetSymbol() != "C":
384
+ continue
385
+ nbs = _carbon_neighbours(atom)
386
+ if len(nbs) != 4:
387
+ continue
388
+ if any(
389
+ nb.GetIsAromatic()
390
+ or any(b.GetBondTypeAsDouble() == 2.0 for b in nb.GetBonds())
391
+ for nb in nbs
392
+ ):
393
+ corrections[130] = corrections.get(130, 0) + 1
394
+ seen_bonds = set()
395
+ for bond in mol.GetBonds():
396
+ a1, a2 = bond.GetBeginAtom(), bond.GetEndAtom()
397
+ key = tuple(sorted((a1.GetIdx(), a2.GetIdx())))
398
+ if key in seen_bonds:
399
+ continue
400
+ seen_bonds.add(key)
401
+ if (
402
+ a1.GetSymbol() != "C"
403
+ or a2.GetSymbol() != "C"
404
+ or bond.GetBondTypeAsDouble() != 1.0
405
+ or not (_is_sp3(a1) and _is_sp3(a2))
406
+ ):
407
+ continue
408
+ d1 = sum(
409
+ 1
410
+ for nb in a1.GetNeighbors()
411
+ if nb.GetSymbol() == "C" and nb.GetIdx() != a2.GetIdx()
412
+ )
413
+ d2 = sum(
414
+ 1
415
+ for nb in a2.GetNeighbors()
416
+ if nb.GetSymbol() == "C" and nb.GetIdx() != a1.GetIdx()
417
+ )
418
+ pair = tuple(sorted((d1, d2)))
419
+ gid = {(2, 2): 131, (2, 3): 132, (3, 3): 133}.get(pair)
420
+ if gid is not None:
421
+ corrections[gid] = corrections.get(gid, 0) + 1
422
+
423
+
424
+ def _ring_corrections(mol, corrections):
425
+ """Corrections 125-129: small rings and aromatic substitution patterns.
426
+
427
+ 125/126 - one per SSSR ring of size 3-4 / 5 that is not fully
428
+ aromatic (cyclopropane/butane/pentane skeleton strain;
429
+ furan-style aromatic rings are excluded).
430
+ 127/128/129 - ortho/meta/para: once per molecule, by the closest
431
+ pair of substituted, non-fused carbons of any aromatic
432
+ ring (ring-arc distance 1/2/3). Fused ring atoms carry
433
+ no substituent (naphthalene gets no o/m/p correction);
434
+ 1,2,4-trimethylbenzene counts as ortho only.
435
+ """
436
+ loaded = load_rdkit()
437
+ if loaded is None:
438
+ return
439
+ rings = [list(ring) for ring in loaded[0].GetSymmSSSR(mol)]
440
+ for ring in rings:
441
+ size = len(ring)
442
+ if size not in (3, 4, 5):
443
+ continue
444
+ if all(mol.GetAtomWithIdx(i).GetIsAromatic() for i in ring):
445
+ continue
446
+ gid = 125 if size <= 4 else 126
447
+ corrections[gid] = corrections.get(gid, 0) + 1
448
+ ring_count = {}
449
+ for ring in rings:
450
+ for idx in ring:
451
+ ring_count[idx] = ring_count.get(idx, 0) + 1
452
+ closest = None
453
+ for ring in rings:
454
+ if not all(mol.GetAtomWithIdx(i).GetIsAromatic() for i in ring):
455
+ continue
456
+ members = set(ring)
457
+ substituted = [
458
+ idx
459
+ for idx in ring
460
+ if ring_count[idx] == 1
461
+ and any(
462
+ nb.GetIdx() not in members
463
+ for nb in mol.GetAtomWithIdx(idx).GetNeighbors()
464
+ )
465
+ ]
466
+ for pos, i1 in enumerate(substituted):
467
+ for i2 in substituted[pos + 1:]:
468
+ d = ring_arc_distance(mol, i1, i2)
469
+ if d in (1, 2, 3) and (closest is None or d < closest):
470
+ closest = d
471
+ if closest is not None:
472
+ corrections[{1: 127, 2: 128, 3: 129}[closest]] = 1
473
+
474
+
475
+ def _hydrogen_count_corrections(mol, corrections):
476
+ """Corrections 123/124: molecules with zero/one hydrogen atom."""
477
+ n_h = sum(atom.GetTotalNumHs() for atom in mol.GetAtoms())
478
+ if n_h == 0:
479
+ corrections[123] = 1
480
+ elif n_h == 1:
481
+ corrections[124] = 1
482
+
483
+
484
+ def nannoolal_fragmentation(mol):
485
+ """Fragment `mol` into Nannoolal Part 1 groups (paper Tables 1-2).
486
+
487
+ Parameters
488
+ ----------
489
+ mol : Mol
490
+ Normalised RDKit molecule.
491
+
492
+ Returns
493
+ -------
494
+ counts : dict
495
+ Group ID -> count, main groups plus second-order corrections
496
+ 118-133 plus extension groups 301-303.
497
+ n : int
498
+ Number of non-hydrogen atoms (paper Eq. (1) normalisation; note
499
+ the paper counts *every* non-H atom including sulfur - this is
500
+ a deliberate difference from UManSysProp, which omits S).
501
+ success : bool
502
+ status : str
503
+ ``"OK"``, or the reason the molecule cannot be parameterised
504
+ (unsupported element, unclaimed atom, failed parse).
505
+
506
+ Examples
507
+ --------
508
+ Paper Table 17b (di-isopropanolamine):
509
+
510
+ >>> counts, n, ok, status = nannoolal_fragmentation(
511
+ ... mol_from_smiles("CC(O)CNCC(O)C"))
512
+ >>> (ok, status, n, counts)
513
+ (True, 'OK', 9, {1: 2, 7: 4, 34: 2, 42: 1})
514
+ """
515
+ if mol is None:
516
+ return {}, 0, False, "Failed to construct mol"
517
+ unsupported = sorted(
518
+ {
519
+ atom.GetSymbol()
520
+ for atom in mol.GetAtoms()
521
+ if atom.GetSymbol() not in NANNOOLAL_BP_ELEMENTS
522
+ }
523
+ )
524
+ if unsupported:
525
+ return {}, mol.GetNumAtoms(), False, (
526
+ "unsupported element(s) %s: outside the C/N/O/S/F/Cl/Br/I "
527
+ "families aerosolprops parameterises" % ", ".join(unsupported)
528
+ )
529
+ counts, assignments, success, status = _nannoolal_engine().fragment(mol)
530
+ n = mol.GetNumAtoms()
531
+ counts = dict(sorted(counts.items()))
532
+ if not success:
533
+ return counts, n, False, status
534
+
535
+ # Group 36: paper Table 1 reclassifies every alcohol OH of a
536
+ # molecule with fewer than five carbon atoms; Table 17b keeps 34 for
537
+ # the six-carbon di-isopropanolamine. The claimed atoms are
538
+ # unchanged.
539
+ n_carbon = sum(
540
+ 1 for atom in mol.GetAtoms() if atom.GetSymbol() == "C"
541
+ )
542
+ if n_carbon < 5:
543
+ n_oh = sum(counts.pop(gid, 0) for gid in (33, 34, 35, 36))
544
+ if n_oh:
545
+ counts[36] = n_oh
546
+
547
+ corrections = {}
548
+ _carbonyl_corrections(mol, assignments, corrections)
549
+ _halogen_carbon_corrections(mol, corrections)
550
+ _steric_corrections(mol, corrections)
551
+ _ring_corrections(mol, corrections)
552
+ _hydrogen_count_corrections(mol, corrections)
553
+ counts.update(corrections)
554
+ return counts, n, True, "OK"
555
+
556
+
557
+ class NannoolalBP:
558
+ """Normal boiling point of pure components (Nannoolal Part 1, 2004).
559
+
560
+ Wraps :func:`nannoolal_fragmentation` and the pure estimators of
561
+ :mod:`aerosolprops.methods.nannoolal_bp` in the thermo ``Joback``
562
+ idiom: fragmentation runs once in the constructor, results are
563
+ stored on the instance, and the estimation method returns ``None``
564
+ if fragmentation failed or a required group interaction has no
565
+ published value (paper Table 5 rows 153/211) - never zero.
566
+
567
+ Parameters
568
+ ----------
569
+ mol : Mol, optional
570
+ Normalisation is applied internally; pass any parsed RDKit
571
+ molecule.
572
+ smi : str, optional
573
+ SMILES string, used when `mol` is not given.
574
+
575
+ Attributes
576
+ ----------
577
+ counts : dict
578
+ Non-zero group counts (empty on failure); IDs follow paper
579
+ Tables 1-2, extensions 301-303 follow Compernolle (2010).
580
+ n : int
581
+ Number of non-hydrogen atoms (0 on failure).
582
+ success : bool
583
+ status : str
584
+ ``"OK"`` on success, otherwise the failure reason.
585
+ gi_status : str
586
+ Status of the group-interaction term: ``"OK"`` or
587
+ ``"no-GI (extension groups present)"`` when extension groups
588
+ force the reduced-confidence branch (Compernolle 2010: no
589
+ interaction parameters derivable).
590
+
591
+ Examples
592
+ --------
593
+ Paper Table 17c (hexafluoroacetone; experimental Tb 245.9 K):
594
+
595
+ >>> nnp = NannoolalBP(smi="FC(F)(F)C(=O)C(F)(F)F")
596
+ >>> (nnp.success, nnp.status, nnp.n)
597
+ (True, 'OK', 10)
598
+ >>> sorted(nnp.counts.items())
599
+ [(7, 2), (21, 6), (51, 1), (120, 1), (121, 2), (123, 1)]
600
+ >>> round(nnp.boiling_point(), 1)
601
+ 246.3
602
+ """
603
+
604
+ def __init__(self, mol=None, smi=None):
605
+ if mol is None:
606
+ mol = None if smi is None else mol_from_smiles(smi)
607
+ else:
608
+ mol = normalize_mol(mol)
609
+ if mol is None:
610
+ self.counts, self.n = {}, 0
611
+ self.success, self.status = False, "Failed to construct mol"
612
+ else:
613
+ self.counts, self.n, self.success, self.status = (
614
+ nannoolal_fragmentation(mol)
615
+ )
616
+ self.gi_status = None
617
+
618
+ def boiling_point(self):
619
+ """Normal boiling point [K], or ``None`` on any failure.
620
+
621
+ Refuses (returns ``None`` and records the reason in
622
+ ``self.status``) when the molecule needs an interaction the
623
+ paper prints without a value. Molecules with Compernolle
624
+ extension groups are estimated without the GI term and flagged
625
+ in ``self.gi_status`` instead.
626
+ """
627
+ if not self.success:
628
+ return None
629
+ gi, self.gi_status = nannoolal_bp_gi(self.counts, self.n)
630
+ tb = nannoolal_bp_boiling_point(self.counts, self.n)
631
+ if tb is None:
632
+ self.status = (
633
+ "refused: missing interaction parameter"
634
+ if gi is None
635
+ else "refused: invalid group counts"
636
+ )
637
+ return tb