Module:SMILES2IUPAC/Functional
Appearance
-- Module:SMILES2IUPAC/Functional
--
-- Functional-group naming for simple acyclic saturated compounds.
--
-- Supported:
-- alcohols
-- ethers
-- haloalkanes
-- alkyl hypochlorites
local Chain = require('Module:SMILES2IUPAC/Chain')
local Data = require('Module:SMILES2IUPAC/Data')
local p = {}
local halogens = {
Cl = 'chloro',
Br = 'bromo',
F = 'fluoro',
I = 'iodo'
}
local halogenElements = {
chloro = true,
bromo = true,
fluoro = true,
iodo = true
}
local function isHalogen(element)
return halogens[element] ~= nil
end
local function getAtom(graph, index)
return graph.atoms[index]
end
local function neighbours(graph, atomIndex)
local result = {}
for _, bond in ipairs(graph.bonds) do
if bond.a == atomIndex then
table.insert(result, bond.b)
elseif bond.b == atomIndex then
table.insert(result, bond.a)
end
end
return result
end
local function heavyNeighbours(graph, atomIndex)
local result = {}
for _, node in ipairs(neighbours(graph, atomIndex)) do
if getAtom(graph, node).element ~= 'H' then
table.insert(result, node)
end
end
return result
end
local function getCarbons(graph)
local result = {}
for i, atom in ipairs(graph.atoms) do
if atom.element == 'C' then
table.insert(result, i)
end
end
return result
end
local function pathSet(path)
local result = {}
for _, node in ipairs(path) do
result[node] = true
end
return result
end
local function inPath(path, node)
for _, n in ipairs(path) do
if n == node then
return true
end
end
return false
end
local function getRoot(n)
if Data.chainRoot and Data.chainRoot[n] then
return Data.chainRoot[n]
end
if Data.chainExceptions and Data.chainExceptions[n] then
return Data.chainExceptions[n]
end
return nil
end
----------------------------------------------------------------------
-- Find a longest chain containing a required carbon
----------------------------------------------------------------------
local function longestChainContaining(carbons, adj, required)
local candidates = Chain.findLongestChains(carbons, adj)
local best = nil
for _, chain in ipairs(candidates) do
if inPath(chain, required) then
if not best or #chain > #best then
best = chain
end
end
end
return best
end
----------------------------------------------------------------------
-- Orient a chain so the functional group gets the lowest locant
----------------------------------------------------------------------
local function orientPathForNode(path, node)
local position = nil
for i, n in ipairs(path) do
if n == node then
position = i
break
end
end
if not position then
return path, nil
end
local reversePosition = #path - position + 1
if reversePosition < position then
local reversed = {}
for i = #path, 1, -1 do
table.insert(reversed, path[i])
end
return reversed, reversePosition
end
return path, position
end
----------------------------------------------------------------------
-- Prefix helpers
----------------------------------------------------------------------
local function multiplierForCount(count)
if count == 2 then
return 'di'
elseif count == 3 then
return 'tri'
elseif count == 4 then
return 'tetra'
end
return ''
end
local function buildSubstituentPrefix(name, locants)
if not locants or #locants == 0 then
return nil
end
table.sort(locants)
local values = {}
for _, locant in ipairs(locants) do
table.insert(values, tostring(locant))
end
return table.concat(values, ',')
.. '-'
.. multiplierForCount(#locants)
.. name
end
local function buildSubstituentPrefixList(substituents)
local names = {}
for name in pairs(substituents) do
table.insert(names, name)
end
table.sort(names)
local parts = {}
for _, name in ipairs(names) do
local part = buildSubstituentPrefix(
name,
substituents[name]
)
if part then
table.insert(parts, part)
end
end
return parts
end
----------------------------------------------------------------------
-- Carbon substituents
----------------------------------------------------------------------
local function collectCarbonSubstituents(graph, path, adj)
local result = {}
local pathNodes = pathSet(path)
for index, carbon in ipairs(path) do
for _, neighbour in ipairs(adj[carbon] or {}) do
if not pathNodes[neighbour] then
if getAtom(graph, neighbour).element == 'C' then
local subtree = Chain.substituentSubtree(
adj,
neighbour,
pathNodes
)
if #subtree == 1 then
if not result.methyl then
result.methyl = {}
end
table.insert(result.methyl, index)
end
end
end
end
end
return result
end
----------------------------------------------------------------------
-- Halogen substituents
----------------------------------------------------------------------
local function collectHalogenSubstituents(graph, path)
local result = {}
for index, carbon in ipairs(path) do
for _, atomIndex in ipairs(neighbours(graph, carbon)) do
local atom = getAtom(graph, atomIndex)
if isHalogen(atom.element) then
local name = halogens[atom.element]
if not result[name] then
result[name] = {}
end
table.insert(result[name], index)
end
end
end
return result
end
local function mergeSubstituents(target, source)
for name, locants in pairs(source) do
if not target[name] then
target[name] = {}
end
for _, locant in ipairs(locants) do
table.insert(target[name], locant)
end
end
end
----------------------------------------------------------------------
-- ALCOHOL
----------------------------------------------------------------------
local function nameAlcohol(graph, carbons, adj)
local oxygen = nil
for i, atom in ipairs(graph.atoms) do
if atom.element == 'O' then
oxygen = i
break
end
end
if not oxygen then
return nil
end
local oxygenNeighbours = heavyNeighbours(graph, oxygen)
-- Alcohol: C-O-H
if #oxygenNeighbours ~= 1 then
return nil
end
local alcoholCarbon = oxygenNeighbours[1]
if getAtom(graph, alcoholCarbon).element ~= 'C' then
return nil
end
local chain = longestChainContaining(
carbons,
adj,
alcoholCarbon
)
if not chain then
return nil
end
chain, alcoholLocant = orientPathForNode(
chain,
alcoholCarbon
)
local length = #chain
local root = getRoot(length)
if not root then
return nil
end
local substituents = collectCarbonSubstituents(
graph,
chain,
adj
)
mergeSubstituents(
substituents,
collectHalogenSubstituents(
graph,
chain
)
)
local parts = buildSubstituentPrefixList(substituents)
if length == 1 and #parts == 0 then
return 'methanol'
end
if length == 2 and #parts == 0 then
return 'ethanol'
end
local name = root
.. 'an-'
.. tostring(alcoholLocant)
.. '-ol'
if #parts > 0 then
name = table.concat(parts, '-') .. name
end
return name
end
----------------------------------------------------------------------
-- HALOALKANE
----------------------------------------------------------------------
local function nameHaloalkane(graph, carbons, adj)
local hasHalogen = false
for _, atom in ipairs(graph.atoms) do
if isHalogen(atom.element) then
hasHalogen = true
elseif atom.element ~= 'C'
and atom.element ~= 'H'
then
return nil
end
end
if not hasHalogen then
return nil
end
local halogenCarbons = {}
for i, atom in ipairs(graph.atoms) do
if isHalogen(atom.element) then
for _, neighbour in ipairs(neighbours(graph, i)) do
if getAtom(graph, neighbour).element == 'C' then
halogenCarbons[neighbour] = true
end
end
end
end
if not next(halogenCarbons) then
return nil
end
local candidates = Chain.findLongestChains(carbons, adj)
if #candidates == 0 then
return nil
end
local best = nil
local bestHalogenCount = -1
local bestLocants = nil
for _, candidate in ipairs(candidates) do
local halogenCount = 0
for _, carbon in ipairs(candidate) do
if halogenCarbons[carbon] then
halogenCount = halogenCount + 1
end
end
-- Scribunto Lua has no "continue", so simply skip
-- candidates with no halogen by wrapping the rest.
if halogenCount > 0 then
local function halogenLocants(reverse)
local locants = {}
for i, carbon in ipairs(candidate) do
local locant = i
if reverse then
locant = #candidate - i + 1
end
for _, atomIndex in ipairs(neighbours(graph, carbon)) do
if isHalogen(
getAtom(graph, atomIndex).element
) then
table.insert(locants, locant)
end
end
end
table.sort(locants)
return locants
end
local forward = halogenLocants(false)
local backward = halogenLocants(true)
local locants = forward
if Chain.compareLocantLists(
backward,
forward
) < 0 then
locants = backward
end
if halogenCount > bestHalogenCount
or (
halogenCount == bestHalogenCount
and (
not bestLocants
or Chain.compareLocantLists(
locants,
bestLocants
) < 0
)
)
then
best = candidate
bestHalogenCount = halogenCount
bestLocants = locants
end
end
end
if not best then
return nil
end
local chain = best
local length = #chain
local root = getRoot(length)
if not root then
return nil
end
------------------------------------------------------------------
-- Choose numbering direction.
------------------------------------------------------------------
local function getHalogenLocants(reverse)
local locants = {}
for i, carbon in ipairs(chain) do
local locant = i
if reverse then
locant = length - i + 1
end
for _, atomIndex in ipairs(neighbours(graph, carbon)) do
if isHalogen(
getAtom(graph, atomIndex).element
) then
table.insert(locants, locant)
end
end
end
table.sort(locants)
return locants
end
local forwardHalogens = getHalogenLocants(false)
local reverseHalogens = getHalogenLocants(true)
local reversed = false
if Chain.compareLocantLists(
reverseHalogens,
forwardHalogens
) < 0 then
reversed = true
end
------------------------------------------------------------------
-- Collect substituents.
------------------------------------------------------------------
local substituents = {}
for i, carbon in ipairs(chain) do
local locant = i
if reversed then
locant = length - i + 1
end
for _, atomIndex in ipairs(neighbours(graph, carbon)) do
local atom = getAtom(graph, atomIndex)
if isHalogen(atom.element) then
local name = halogens[atom.element]
if not substituents[name] then
substituents[name] = {}
end
table.insert(
substituents[name],
locant
)
end
end
for _, branch in ipairs(adj[carbon] or {}) do
if not inPath(chain, branch) then
local subtree = Chain.substituentSubtree(
adj,
branch,
pathSet(chain)
)
if #subtree == 1 then
if not substituents.methyl then
substituents.methyl = {}
end
table.insert(
substituents.methyl,
locant
)
end
end
end
end
------------------------------------------------------------------
-- Methane/ethane halogen names do not require locants.
----------------------------------------------------------------------
if length <= 2 then
local parts = {}
for name, locants in pairs(substituents) do
if halogenElements[name]
and #locants == 1
then
table.insert(parts, name)
else
local part = buildSubstituentPrefix(
name,
locants
)
if part then
table.insert(parts, part)
end
end
end
table.sort(parts)
return table.concat(parts, '-')
.. root
.. 'ane'
end
local parts = buildSubstituentPrefixList(
substituents
)
if #parts == 0 then
return root .. 'ane'
end
return table.concat(parts, '-')
.. root
.. 'ane'
end
----------------------------------------------------------------------
-- ETHER
----------------------------------------------------------------------
local function nameEther(graph)
local oxygen = nil
for i, atom in ipairs(graph.atoms) do
if atom.element == 'O' then
oxygen = i
break
end
end
if not oxygen then
return nil
end
local oxygenNeighbours = heavyNeighbours(graph, oxygen)
-- Ether: C-O-C
if #oxygenNeighbours ~= 2 then
return nil
end
if getAtom(
graph,
oxygenNeighbours[1]
).element ~= 'C'
or getAtom(
graph,
oxygenNeighbours[2]
).element ~= 'C'
then
return nil
end
local function countSide(start)
local visited = {
[oxygen] = true
}
local queue = { start }
local qi = 1
local count = 0
while qi <= #queue do
local current = queue[qi]
qi = qi + 1
if not visited[current] then
visited[current] = true
if getAtom(
graph,
current
).element ~= 'C'
then
return nil
end
count = count + 1
for _, neighbour in ipairs(
neighbours(graph, current)
) do
if not visited[neighbour]
and neighbour ~= oxygen
then
local element =
getAtom(
graph,
neighbour
).element
if element == 'C' then
table.insert(
queue,
neighbour
)
elseif element ~= 'H' then
return nil
end
end
end
end
end
return count
end
local left = countSide(
oxygenNeighbours[1]
)
local right = countSide(
oxygenNeighbours[2]
)
if not left or not right then
return nil
end
local larger = math.max(left, right)
local smaller = math.min(left, right)
local largerRoot = getRoot(larger)
local smallerRoot = getRoot(smaller)
if not largerRoot or not smallerRoot then
return nil
end
return smallerRoot
.. 'oxy'
.. largerRoot
.. 'ane'
end
----------------------------------------------------------------------
-- ALKYL HYPOCHLORITE
----------------------------------------------------------------------
local function nameHypochlorite(graph)
local oxygen = nil
for i, atom in ipairs(graph.atoms) do
if atom.element == 'O' then
oxygen = i
break
end
end
if not oxygen then
return nil
end
local oxygenNeighbours = heavyNeighbours(
graph,
oxygen
)
-- R-O-Cl
if #oxygenNeighbours ~= 2 then
return nil
end
local carbon = nil
local chlorine = nil
for _, neighbour in ipairs(oxygenNeighbours) do
local element =
getAtom(
graph,
neighbour
).element
if element == 'C' then
if carbon then
return nil
end
carbon = neighbour
elseif element == 'Cl' then
if chlorine then
return nil
end
chlorine = neighbour
else
return nil
end
end
if not carbon or not chlorine then
return nil
end
local visited = {
[oxygen] = true
}
local queue = { carbon }
local qi = 1
local count = 0
while qi <= #queue do
local current = queue[qi]
qi = qi + 1
if not visited[current] then
visited[current] = true
if getAtom(
graph,
current
).element ~= 'C'
then
return nil
end
count = count + 1
for _, neighbour in ipairs(
neighbours(graph, current)
) do
if not visited[neighbour]
and neighbour ~= oxygen
then
local element =
getAtom(
graph,
neighbour
).element
if element == 'C' then
table.insert(
queue,
neighbour
)
elseif element ~= 'H' then
return nil
end
end
end
end
end
local root = getRoot(count)
if not root then
return nil
end
return root .. 'yl hypochlorite'
end
----------------------------------------------------------------------
-- MAIN
----------------------------------------------------------------------
function p.name(graph)
-- Functional module only handles single bonds.
for _, bond in ipairs(graph.bonds) do
if bond.order ~= 1 then
return nil,
'double/triple bonds present'
end
end
local carbons = getCarbons(graph)
if #carbons == 0 then
return nil, 'no carbon skeleton'
end
local adjacency = {}
for _, carbon in ipairs(carbons) do
adjacency[carbon] = {}
end
for _, bond in ipairs(graph.bonds) do
if adjacency[bond.a]
and adjacency[bond.b]
then
table.insert(
adjacency[bond.a],
bond.b
)
table.insert(
adjacency[bond.b],
bond.a
)
end
end
------------------------------------------------------------------
-- Functional groups are checked BEFORE carbon-tree validation.
--
-- This is important for ethers such as CCOCC because the oxygen
-- separates the carbon graph into two components.
----------------------------------------------------------------------
local alcohol = nameAlcohol(
graph,
carbons,
adjacency
)
if alcohol then
return alcohol
end
local hypochlorite = nameHypochlorite(graph)
if hypochlorite then
return hypochlorite
end
local ether = nameEther(graph)
if ether then
return ether
end
------------------------------------------------------------------
-- Ordinary carbon skeleton.
----------------------------------------------------------------------
local isTree, err = Chain.isTree(
carbons,
adjacency
)
if not isTree then
return nil,
err .. ' — rings not supported'
end
local halo = nameHaloalkane(
graph,
carbons,
adjacency
)
if halo then
return halo
end
return nil,
'functional group not supported yet'
end
return p