Skip to content

Commit 0379bd1

Browse files
AdrianM0claude
andcommitted
Add sulfamic and azinic acid as functional parents
Two acids had no parent to be named on, so a weaker group became the suffix and the acid was demoted to a prefix. `sulfamic acid`: the sulfonic-acid perception accepts any single non-oxygen ligand, so H2N-SO3H was perceived -- but "...sulfonic acid" needs a carbon skeleton to attach to, and there is none when the sulfur is bonded to nitrogen. NS(=O)(=O)O produced no name at all, and NC(=O)CNS(=O)(=O)O came out as 1-hydroxysulfonylaminomethanamide, naming the amide as the parent. A ring nitrogen keeps its ring, so 1H-imidazole-1-sulfonic acid is not dissolved into prefixes. `azinic acid`: HN(=O)OH differs from a nitro group by one hydrogen, and the nitro detector only checked that the *nitrogen* carried none -- in C=[N+]([O-])O the hydrogen is on an oxygen. Two trinitro names were therefore naming compounds with one nitro group too many. The nitrogen's remaining bond is single, giving N-phenylazinic acid, or double, giving the ylidene form. Both were checked against OPSIN: every name the change touches round-trips to its input, and of the 28 names that changed across 200k molecules, three go from wrong to correct and none get worse. Two that stay wrong were already wrong under their previous names -- pre-existing substituent defects these parents merely surface. Refutations 27 -> 24. zinc22 unchanged at 97.63%; pubchem 96.22% -> 96.21%, the 8 lost confirmations all being the audit not yet modelling either parent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 20287f6 commit 0379bd1

3 files changed

Lines changed: 204 additions & 1 deletion

File tree

src/openclatura/perception.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,17 @@ def _builtin_perceive_groups(mol: Molecule) -> list[PerceivedGroup]:
126126
# acid tautomer — ``[O-][NH+](O)Ph`` is N-phenylazinic acid, which
127127
# differs from nitrobenzene by two hydrogens, so calling it nitro
128128
# would name a different compound.
129-
if len(oxygens) == 2 and len(adj_atoms) == 1 and atom.total_h_count == 0:
129+
# Nor does either of its oxygens: an N-OH is the acid, so
130+
# ``C=[N+]([O-])O`` is an azinic acid ylidene rather than a nitro
131+
# group, and differs from it by a hydrogen.
132+
hydroxyl_oxygen = any(
133+
mol.atoms[o].charge == 0
134+
and (bond := mol.get_bond(atom.idx, o)) is not None
135+
and bond.order == 1
136+
and mol.atoms[o].total_h_count > 0
137+
for o in oxygens
138+
)
139+
if len(oxygens) == 2 and len(adj_atoms) == 1 and atom.total_h_count == 0 and not hydroxyl_oxygen:
130140
has_double_o = any(mol.get_bond(atom.idx, o).order == 2 for o in oxygens)
131141
if atom.charge == 1 or has_double_o:
132142
groups.append(PerceivedGroup("nitro", False, adj_atoms[0], {atom.idx} | set(oxygens)))

src/openclatura/special_cases.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from .assembly_parts import NameAtomBinding, NameTokenBinding
88
from .assembly_prefixes import substituent_sort_key
9+
from .chains import get_cyclic_atoms
910
from .charge_pair_roles import charge_pair_roles
1011
from .formatting import format_counted_prefixes, format_multiplier, oxy_prefix_from_branch, strip_outer_parentheses
1112
from .molecule import Molecule
@@ -118,6 +119,8 @@ def structural_replacement_parent_result(
118119
("phosphane_borane_zwitterion", lambda: phosphane_borane_zwitterion_result(mol, component_atoms, branch_namer)),
119120
("sulfonium_ylide", lambda: sulfonium_ylide_result(mol, component_atoms, branch_namer)),
120121
("hydroxyurea_parent", lambda: hydroxyurea_parent_result(mol, component_atoms, branch_namer)),
122+
("sulfamic_acid", lambda: sulfamic_acid_result(mol, component_atoms, branch_namer)),
123+
("azinic_acid", lambda: azinic_acid_result(mol, component_atoms, branch_namer)),
121124
("oxoacid_ester", lambda: oxoacid_ester_result(mol, component_atoms, branch_namer)),
122125
("oxoacid_parent", lambda: oxoacid_parent_result(mol, component_atoms)),
123126
("organophosphinic_acid", lambda: organophosphinic_acid_result(mol, component_atoms)),
@@ -1137,6 +1140,175 @@ def hydroxyurea_parent_result(
11371140
return _component_name_result(mol, component_atoms, name, "hydroxyurea_parent", bindings=tuple(bindings))
11381141

11391142

1143+
def sulfamic_acid_result(
1144+
mol: Molecule,
1145+
component_atoms: set[int],
1146+
branch_namer: RecursiveSubgraphNamer | None = None,
1147+
) -> SpecialComponentName | None:
1148+
"""Name sulfamic acid and its N-substituted derivatives.
1149+
1150+
A sulfonic sulfur bonded to nitrogen rather than carbon has no carbon
1151+
skeleton to hang ``…sulfonic acid`` on, so the acid used to be demoted to a
1152+
``hydroxysulfonyl`` prefix and some weaker group became the suffix.
1153+
``H2N-SO3H`` is the retained functional parent, and its nitrogen carries the
1154+
substituents.
1155+
"""
1156+
1157+
# A ring nitrogen keeps its ring as the parent -- 1H-imidazole-1-sulfonic
1158+
# acid is not a sulfamic acid whose ring has been dissolved into prefixes.
1159+
cyclic_atoms = get_cyclic_atoms(mol)
1160+
centers = []
1161+
for idx in component_atoms:
1162+
atom = mol.atoms[idx]
1163+
if atom.symbol != "S" or atom.charge != 0:
1164+
continue
1165+
neighbors = [n for n in mol.get_neighbors(idx) if n in component_atoms]
1166+
oxygens = [n for n in neighbors if mol.atoms[n].symbol == "O"]
1167+
nitrogens = [n for n in neighbors if mol.atoms[n].symbol == "N"]
1168+
if len(neighbors) != 4 or len(oxygens) != 3 or len(nitrogens) != 1:
1169+
continue
1170+
if nitrogens[0] in cyclic_atoms:
1171+
continue
1172+
double_o = [o for o in oxygens if (bond := mol.get_bond(idx, o)) is not None and bond.order == 2]
1173+
single_o = [o for o in oxygens if o not in double_o]
1174+
if len(double_o) != 2 or len(single_o) != 1:
1175+
continue
1176+
hydroxyl = single_o[0]
1177+
if mol.atoms[hydroxyl].charge != 0 or mol.degree(hydroxyl) != 1:
1178+
continue
1179+
centers.append((idx, nitrogens[0], oxygens))
1180+
if len(centers) != 1:
1181+
return None
1182+
sulfur, nitrogen, oxygens = centers[0]
1183+
if mol.atoms[nitrogen].charge != 0:
1184+
return None
1185+
1186+
core_atoms = {sulfur, nitrogen, *oxygens}
1187+
ligands: list[tuple[set[int], str]] = []
1188+
for root in mol.get_neighbors(nitrogen):
1189+
if root in core_atoms or root not in component_atoms or mol.atoms[root].symbol == "H":
1190+
continue
1191+
if branch_namer is None:
1192+
return None
1193+
name = branch_namer(mol, root, (set(mol.atoms) - component_atoms) | core_atoms, upstream_atom=nitrogen)
1194+
if isinstance(name, tuple):
1195+
name = name[0]
1196+
if not name:
1197+
return None
1198+
ligand_atoms = _component_atoms_until_blocked(mol, component_atoms, root, core_atoms)
1199+
if not ligand_atoms:
1200+
return None
1201+
ligands.append((set(ligand_atoms), strip_outer_parentheses(name)))
1202+
1203+
represented = set(core_atoms)
1204+
for ligand_atoms, _name in ligands:
1205+
represented.update(ligand_atoms)
1206+
if represented != component_atoms:
1207+
return None
1208+
1209+
prefix = format_counted_prefixes([name for _atoms, name in ligands]) if ligands else ""
1210+
name = f"{prefix}sulfamic acid"
1211+
bindings = [
1212+
NameAtomBinding(
1213+
stage="shortcut",
1214+
role="sulfamic_acid_core",
1215+
term="sulfamic acid",
1216+
atom_ids=set(core_atoms),
1217+
bond_ids=_bond_ids_within_atoms(mol, set(core_atoms)),
1218+
)
1219+
]
1220+
bindings.extend(
1221+
NameAtomBinding(
1222+
stage="shortcut",
1223+
role="sulfamic_acid_n_ligand",
1224+
term=ligand_name,
1225+
atom_ids=set(ligand_atoms),
1226+
bond_ids=_bond_ids_within_atoms(mol, set(ligand_atoms) | {nitrogen}),
1227+
locants=("N",),
1228+
)
1229+
for ligand_atoms, ligand_name in ligands
1230+
)
1231+
return _component_name_result(mol, component_atoms, name, "sulfamic_acid", bindings=tuple(bindings))
1232+
1233+
1234+
def azinic_acid_result(
1235+
mol: Molecule,
1236+
component_atoms: set[int],
1237+
branch_namer: RecursiveSubgraphNamer | None = None,
1238+
) -> SpecialComponentName | None:
1239+
"""Name azinic acid derivatives, ``HN(=O)OH`` substituted at nitrogen.
1240+
1241+
The nitrogen carries an oxido and a hydroxy oxygen, which distinguishes it
1242+
from a nitro group -- the two differ by a hydrogen. Its remaining bond is
1243+
either single, giving ``N-phenylazinic acid``, or double, giving the
1244+
ylidene form.
1245+
"""
1246+
1247+
centers = []
1248+
for idx in component_atoms:
1249+
atom = mol.atoms[idx]
1250+
if atom.symbol != "N" or atom.charge != 1:
1251+
continue
1252+
neighbors = [n for n in mol.get_neighbors(idx) if n in component_atoms]
1253+
oxygens = [n for n in neighbors if mol.atoms[n].symbol == "O"]
1254+
others = [n for n in neighbors if n not in oxygens]
1255+
if len(oxygens) != 2 or len(others) != 1:
1256+
continue
1257+
oxido = [o for o in oxygens if mol.atoms[o].charge == -1 and mol.degree(o) == 1]
1258+
hydroxy = [
1259+
o
1260+
for o in oxygens
1261+
if mol.atoms[o].charge == 0 and mol.degree(o) == 1 and mol.atoms[o].total_h_count > 0
1262+
]
1263+
if len(oxido) != 1 or len(hydroxy) != 1:
1264+
continue
1265+
centers.append((idx, others[0], oxygens))
1266+
if len(centers) != 1:
1267+
return None
1268+
nitrogen, ligand_root, oxygens = centers[0]
1269+
bond = mol.get_bond(nitrogen, ligand_root)
1270+
if bond is None or bond.order not in {1, 2}:
1271+
return None
1272+
if branch_namer is None:
1273+
return None
1274+
1275+
core_atoms = {nitrogen, *oxygens}
1276+
ligand_name = branch_namer(
1277+
mol, ligand_root, (set(mol.atoms) - component_atoms) | core_atoms, upstream_atom=nitrogen
1278+
)
1279+
if isinstance(ligand_name, tuple):
1280+
ligand_name = ligand_name[0]
1281+
if not ligand_name:
1282+
return None
1283+
ligand_atoms = _component_atoms_until_blocked(mol, component_atoms, ligand_root, core_atoms)
1284+
if not ligand_atoms or set(ligand_atoms) | core_atoms != component_atoms:
1285+
return None
1286+
1287+
ligand_name = strip_outer_parentheses(ligand_name)
1288+
if bond.order == 2:
1289+
name = f"{format_multiplier(ligand_name, 1, safe_enclose=True)}azinic acid"
1290+
else:
1291+
name = f"N-{format_multiplier(ligand_name, 1)}azinic acid"
1292+
bindings = (
1293+
NameAtomBinding(
1294+
stage="shortcut",
1295+
role="azinic_acid_core",
1296+
term="azinic acid",
1297+
atom_ids=set(core_atoms),
1298+
bond_ids=_bond_ids_within_atoms(mol, set(core_atoms)),
1299+
),
1300+
NameAtomBinding(
1301+
stage="shortcut",
1302+
role="azinic_acid_ligand",
1303+
term=ligand_name,
1304+
atom_ids=set(ligand_atoms),
1305+
bond_ids=_bond_ids_within_atoms(mol, set(ligand_atoms) | {nitrogen}),
1306+
locants=("N",),
1307+
),
1308+
)
1309+
return _component_name_result(mol, component_atoms, name, "azinic_acid", bindings=bindings)
1310+
1311+
11401312
def _urea_n_ligand_names(
11411313
mol: Molecule,
11421314
component_atoms: set[int],

src/openclatura/tests/test_analysis.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5656,3 +5656,24 @@ def test_analysis_exposes_operation_ledger():
56565656
assert any(operation.operation_class == OperationClass.SUBSTITUTIVE for operation in acid.operations)
56575657
assert any(operation.detail == "principal_group_and_substituent_assembly" for operation in acid.operations)
56585658
assert any(operation.operation_class == OperationClass.SUBTRACTIVE for operation in alkene.operations)
5659+
5660+
5661+
def test_sulfamic_acid_is_a_functional_parent():
5662+
# A sulfonic sulfur bonded to nitrogen has no carbon skeleton to hang
5663+
# "...sulfonic acid" on, so the acid used to be demoted to a prefix.
5664+
assert name_smiles("NS(=O)(=O)O") == "sulfamic acid"
5665+
assert name_smiles("NC(=O)CNS(=O)(=O)O") == "(2-amino-2-oxoethyl)sulfamic acid"
5666+
assert name_smiles("CNS(=O)(=O)O") == "methylsulfamic acid"
5667+
assert name_smiles("c1ccccc1NS(=O)(=O)O") == "phenylsulfamic acid"
5668+
# A carbon-bonded sulfonic acid is untouched.
5669+
assert name_smiles("CS(=O)(=O)O") == "methanesulfonic acid"
5670+
5671+
5672+
def test_azinic_acid_is_distinguished_from_a_nitro_group():
5673+
# Azinic acid carries a hydroxy oxygen, so it differs from nitro by a
5674+
# hydrogen; naming it nitro would name a different compound.
5675+
assert name_smiles("[O-][NH+](O)c1ccccc1") == "N-phenylazinic acid"
5676+
assert name_smiles("C=[N+]([O-])O") == "(methylidene)azinic acid"
5677+
assert name_smiles("CC=[N+]([O-])O") == "(ethylidene)azinic acid"
5678+
assert name_smiles("c1ccccc1[N+](=O)[O-]") == "nitrobenzene"
5679+
assert name_smiles("C[N+](=O)[O-]") == "nitromethane"

0 commit comments

Comments
 (0)