Edge Rewrite
// HTMLRewriter · presentation

This page was redesigned at the edge.

Cloudflare fetched the original article and streamed it through HTMLRewriter to apply an entirely new visual system without rebuilding the source page.

Jump to content

Module:SMILES2IUPAC/Functional

From Wikipedia, the free encyclopedia
-- 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