"""
An API for importing a ThermoML archive.
"""
import copy
import logging
import re
import traceback
from enum import unique, Enum
from urllib.error import HTTPError
from urllib.request import urlopen
from xml.etree import ElementTree

import numpy as np
from propertyestimator import unit

from propertyestimator.properties import PropertyPhase, MeasurementSource
from propertyestimator.substances import Substance
from propertyestimator.thermodynamics import ThermodynamicState
from propertyestimator.utils.openmm import openmm_quantity_to_pint
from .datasets import PhysicalPropertyDataSet
from .plugins import registered_thermoml_properties


def unit_from_thermoml_string(full_string):
    """A non-ideal way to convert a string to a propertyestimator.unit.Unit

    Parameters
    ----------
    full_string : str
        The string to convert to a simtk Unit

    Returns
    ----------
    propertyestimator.unit.Unit
        The parsed unit.
    """

    full_string_split = full_string.split(',')

    unit_string = full_string_split[1] if len(full_string_split) > 1 else ''
    unit_string = unit_string.strip()

    if unit_string == 'K':
        return unit.kelvin
    elif unit_string == '1/K':
        return (1.0 / unit.kelvin).units
    elif unit_string == 'kPa':
        return unit.kilopascal
    elif unit_string == 'kPa*dm3/mol':
        return unit.kilopascal * unit.decimeter**3 / unit.mole
    elif unit_string == 'kPa*kg/mol':
        return unit.kilopascal * unit.kilogram / unit.mole
    elif unit_string == 'kg/m3':
        return unit.kilogram / unit.meter**3
    elif unit_string == 'mol/kg':
        return unit.mole / unit.kilogram
    elif unit_string == 'mol/dm3':
        return unit.mole / unit.decimeter ** 3
    elif unit_string == 'kJ/mol':
        return unit.kilojoule / unit.mole
    elif unit_string == 'm3/kg':
        return unit.meter ** 3 / unit.kilogram
    elif unit_string == 'mol/m3':
        return unit.mole / unit.meter ** 3
    elif unit_string == 'm3/mol':
        return unit.meter**3 / unit.mole
    elif unit_string == 'J/K/mol':
        return unit.joule / unit.kelvin / unit.mole
    elif unit_string == 'J/K/kg':
        return unit.joule / unit.kelvin / unit.kilogram
    elif unit_string == 'J/K/m3':
        return unit.joule / unit.kelvin / unit.meter ** 3
    elif unit_string == '1/kPa':
        return (1.0 / unit.kilopascal).units
    elif unit_string == 'm/s':
        return unit.meter / unit.second
    elif unit_string == 'MHz':
        return (1.0 / unit.megasecond).units
    elif unit_string == 'N/m':
        return unit.newton / unit.meter
    elif len(unit_string) == 0:
        return unit.dimensionless
    else:
        raise NotImplementedError('The unit (' + unit_string + ') is not currently supported')


def phase_from_thermoml_string(string):
    """Converts a ThermoML string to a PropertyPhase

        Parameters
        ----------
        string : str
            The string to convert to a PropertyPhase

        Returns
        ----------
        PropertyPhase
            The converted PropertyPhase
        """
    phase_string = string.lower().strip()
    phase = PropertyPhase.Undefined

    if (phase_string == 'liquid' or
        phase_string.find('fluid') >= 0 or phase_string.find('solution') >= 0):

        phase = PropertyPhase.Liquid

    elif phase_string.find('crystal') >= 0 and not phase_string.find('liquid') >= 0:

        phase = PropertyPhase.Solid

    elif phase_string.find('gas') >= 0:

        phase = PropertyPhase.Gas

    return phase


@unique
class ThermoMLConstraintType(Enum):
    """An enum containing the supported types of ThermoML constraints
    """

    Undefined = 'Undefined'
    Temperature = 'Temperature, K'
    Pressure = 'Pressure, kPa'
    ComponentMoleFraction = 'Mole fraction'
    ComponentMassFraction = 'Mass fraction'
    ComponentMolality = 'Molality, mol/kg'
    SolventMoleFraction = 'Solvent: Mole fraction'
    SolventMassFraction = 'Solvent: Mass fraction'
    SolventMolality = 'Solvent: Molality, mol/kg'

    @staticmethod
    def from_node(node):
        """Converts either a ConstraintType or VariableType xml node to a ThermoMLConstraintType.

        Parameters
        ----------
        node : xml.etree.Element
            The xml node to convert.

        Returns
        ----------
        ThermoMLConstraintType
            The converted constraint type.
        """

        try:
            constraint_type = ThermoMLConstraintType(node.text)
        except (KeyError, ValueError):
            constraint_type = ThermoMLConstraintType.Undefined

        if constraint_type == ThermoMLConstraintType.Undefined:
            logging.debug(f'{node.tag}->{node.text} is an unsupported constraint type.')

        return constraint_type

    def is_composition_constraint(self):
        """Checks whether the purpose of this constraint is
        to constrain the substance composition.

        Returns
        -------
        bool
            True if the constraint type is either a

            - `ThermoMLConstraintType.ComponentMoleFraction`
            - `ThermoMLConstraintType.ComponentMassFraction`
            - `ThermoMLConstraintType.ComponentMolality`
            - `ThermoMLConstraintType.SolventMoleFraction`
            - `ThermoMLConstraintType.SolventMassFraction`
            - `ThermoMLConstraintType.SolventMolality`
        """
        return (self == ThermoMLConstraintType.ComponentMoleFraction or
                self == ThermoMLConstraintType.ComponentMassFraction or
                self == ThermoMLConstraintType.ComponentMolality or
                self == ThermoMLConstraintType.SolventMoleFraction or
                self == ThermoMLConstraintType.SolventMassFraction or
                self == ThermoMLConstraintType.SolventMolality)


class ThermoMLConstraint:
    """A wrapper around a ThermoML Constraint node.
    """
    def __init__(self):

        self.type = ThermoMLConstraintType.Undefined
        self.value = 0.0

        self.solvents = []

        # Describes which compound the variable acts upon.
        self.compound_index = None

    @classmethod
    def from_node(cls, constraint_node, namespace):
        """Creates a ThermoMLConstraint from an xml node.

        Parameters
        ----------
        constraint_node : Element
            The xml node to convert.
        namespace : dict of str and str
            The xml namespace.

        Returns
        ----------
        ThermoMLConstraint
            The created constraint.
        """
        # Extract the xml nodes.
        type_node = constraint_node.find('.//ThermoML:ConstraintType/*', namespace)
        value_node = constraint_node.find('./ThermoML:nConstraintValue', namespace)

        solvent_index_nodes = constraint_node.find('./ThermoML:Solvent//ThermoML:nOrgNum', namespace)
        compound_index_node = constraint_node.find('./ThermoML:ConstraintID/ThermoML:RegNum/*', namespace)

        value = float(value_node.text)
        # Determine what the default unit for this variable should be.
        unit_type = unit_from_thermoml_string(type_node.text)

        return_value = cls()

        return_value.type = ThermoMLConstraintType.from_node(type_node)
        return_value.value = value * unit_type

        if compound_index_node is not None:
            return_value.compound_index = int(compound_index_node.text)

        if solvent_index_nodes is not None:
            for solvent_index_node in solvent_index_nodes:
                return_value.solvents.append(int(solvent_index_node))

        return None if return_value.type is ThermoMLConstraintType.Undefined else return_value

    @classmethod
    def from_variable(cls, variable, value):
        """Creates a ThermoMLConstraint from an existing ThermoML variable definition.

        Parameters
        ----------
        variable : ThermoMLVariableDefinition
            The variable to convert.
        value : propertyestimator.unit.Quantity
            The value of the constant.

        Returns
        ----------
        ThermoMLConstraint
            The created constraint.
        """
        return_value = cls()

        return_value.type = variable.type
        return_value.compound_index = variable.compound_index

        return_value.solvents.extend(variable.solvents)

        return_value.value = value

        return return_value


class ThermoMLVariableDefinition:
    """A wrapper around a ThermoML Variable node.
    """
    def __init__(self):

        self.index = -1
        self.type = ThermoMLConstraintType.Undefined

        self.solvents = []
        self.default_unit = None

        # Describes which compound the variable acts upon.
        self.compound_index = None

    @classmethod
    def from_node(cls, variable_node, namespace):
        """Creates a ThermoMLVariableDefinition from an xml node.

        Parameters
        ----------
        variable_node : xml.etree.Element
            The xml node to convert.
        namespace : dict of str and str
            The xml namespace.

        Returns
        ----------
        ThermoMLVariableDefinition
            The created variable definition.
        """
        # Extract the xml nodes.
        type_node = variable_node.find('.//ThermoML:VariableType/*', namespace)
        index_node = variable_node.find('ThermoML:nVarNumber', namespace)

        solvent_index_nodes = variable_node.find('./ThermoML:Solvent//ThermoML:nOrgNum', namespace)
        compound_index_node = variable_node.find('./ThermoML:VariableID/ThermoML:RegNum/*', namespace)

        return_value = cls()

        return_value.default_unit = unit_from_thermoml_string(type_node.text)

        return_value.index = int(index_node.text)
        return_value.type = ThermoMLConstraintType.from_node(type_node)

        if compound_index_node is not None:
            return_value.compound_index = int(compound_index_node.text)

        if solvent_index_nodes is not None:
            for solvent_index_node in solvent_index_nodes:
                return_value.solvents.append(int(solvent_index_node))

        return None if return_value.type is ThermoMLConstraintType.Undefined else return_value


class ThermoMLPropertyUncertainty:
    """A wrapper around a ThermoML PropUncertainty node.
    """

    # Reduce code redundancy by reusing this class for
    # both property and combined uncertainties.
    prefix = ''

    def __init__(self):

        self.index = -1
        self.coverage_factor = None

    @classmethod
    def from_xml(cls, node, namespace):
        """Creates a ThermoMLPropertyUncertainty from an xml node.

        Parameters
        ----------
        node : Element
            The xml node to convert.
        namespace : dict of str and str
            The xml namespace.

        Returns
        ----------
        ThermoMLCompound
            The created property uncertainty.
        """

        coverage_factor_node = node.find('ThermoML:n' + cls.prefix + 'CoverageFactor', namespace)
        confidence_node = node.find('ThermoML:n' + cls.prefix + 'UncertLevOfConfid', namespace)

        # As defined by https://www.nist.gov/pml/nist-technical-note-1297/nist-tn-1297-7-reporting-uncertainty
        if coverage_factor_node is not None:
            coverage_factor = float(coverage_factor_node.text)
        elif confidence_node is not None and confidence_node.text == '95':
            coverage_factor = 2
        else:
            return None

        index_node = node.find('ThermoML:n' + cls.prefix + 'UncertAssessNum', namespace)
        index = int(index_node.text)

        return_value = cls()

        return_value.coverage_factor = coverage_factor
        return_value.index = index

        return return_value


class ThermoMLCombinedUncertainty(ThermoMLPropertyUncertainty):
    """A wrapper around a ThermoML CombPropUncertainty node.
    """
    prefix = 'Comb'


class ThermoMLCompound:
    """A wrapper around a ThermoML Compound node.
    """
    def __init__(self):

        self.smiles = None
        self.index = -1

    @staticmethod
    def smiles_from_inchi_string(inchi_string):
        """Attempts to create a SMILES pattern from an inchi string.

        Parameters
        ----------
        inchi_string : str
            The InChI string to convert.

        Returns
        ----------
        str, optional
            None if the identifier cannot be converted, otherwise the converted SMILES pattern.
        """
        from openeye import oechem

        if inchi_string is None:
            raise ValueError('The InChI string cannot be `None`.')

        temp_molecule = oechem.OEMol()

        if oechem.OEParseInChI(temp_molecule, inchi_string) is False:
            raise ValueError('All InChI strings in ThermoML files must be valid.')

        return oechem.OEMolToSmiles(temp_molecule)

    @staticmethod
    def smiles_from_thermoml_smiles_string(thermoml_string):
        """Attempts to create a SMILES pattern from a thermoml smiles string.

        Parameters
        ----------
        thermoml_string : str
            The string to convert.

        Returns
        ----------
        str, optional
            None if the identifier cannot be converted, otherwise the converted SMILES pattern.
        """
        from openeye import oechem

        if thermoml_string is None:
            raise ValueError('The string cannot be `None`.')

        temp_molecule = oechem.OEMol()

        parse_smiles_options = oechem.OEParseSmilesOptions(quiet=True)

        # Should make sure all smiles are OEChem consistent.
        if oechem.OEParseSmiles(temp_molecule, thermoml_string, parse_smiles_options) is False:
            raise ValueError('All SMILES strings in ThermoML files must be valid.')

        return oechem.OEMolToSmiles(temp_molecule)

    @staticmethod
    def smiles_from_common_name(common_name):
        """Attempts to create a SMILES pattern from a common molecular name using
        the `oechem.OEParseIUPACName` method.

        Parameters
        ----------
        common_name : str
            The common name to convert.
        Returns
        ----------
        str, None
            None if the identifier cannot be converted, otherwise the converted SMILES pattern.
        """
        from openeye import oechem
        from openeye import oeiupac

        if common_name is None:
            return None

        temp_molecule = oechem.OEMol()
        smiles = None

        if oeiupac.OEParseIUPACName(temp_molecule, common_name) is True:
            smiles = oechem.OEMolToSmiles(temp_molecule)

        return smiles

    @classmethod
    def from_xml_node(cls, node, namespace):
        """Creates a ThermoMLCompound from an xml node.

        Parameters
        ----------
        node : Element
            The xml node to convert.
        namespace : dict of str and str
            The xml namespace.

        Returns
        ----------
        ThermoMLCompound
            The created compound wrapper.
        """
        # Gather up all possible identifiers
        inchi_identifier_nodes = node.findall('ThermoML:sStandardInChI', namespace)
        smiles_identifier_nodes = node.findall('ThermoML:sSmiles', namespace)
        common_identifier_nodes = node.findall('ThermoML:sCommonName', namespace)

        if len(inchi_identifier_nodes) > 0 and inchi_identifier_nodes[0].text is not None:
            # Convert InChI key to smiles.
            smiles = cls.smiles_from_inchi_string(inchi_identifier_nodes[0].text)

        elif len(smiles_identifier_nodes) > 0 and smiles_identifier_nodes[0].text is not None:
            # Standardise the smiles pattern using OE.
            smiles = cls.smiles_from_thermoml_smiles_string(smiles_identifier_nodes[0].text)

        elif len(common_identifier_nodes) > 0 and common_identifier_nodes[0].text is not None:
            # Standardise the smiles pattern using OE.
            smiles = cls.smiles_from_common_name(common_identifier_nodes[0].text)

        else:

            logging.debug('A ThermoML:Compound node does not have a valid InChI identifier, '
                          'a valid SMILES pattern, or an understandable common name.')

            return None

        index_node = node.find('./ThermoML:RegNum/*', namespace)

        if index_node is None:
            raise ValueError('A ThermoML:Compound has a non-existent index')

        compound_index = int(index_node.text)

        return_value = cls()

        return_value.smiles = smiles
        return_value.index = compound_index

        return return_value


class ThermoMLProperty:
    """A wrapper around a ThermoML Property node.
    """
    class SoluteStandardState(Enum):
        """Describes the standard state of a solute.
        """

        Undefined = 'Undefined',
        InfiniteDilutionSolute = 'Infinite dilution solute',
        PureCompound = 'Pure compound',
        PureLiquidSolute = 'Pure liquid solute',
        StandardMolality = 'Standard molality (1 mol/kg) solute',

        @staticmethod
        def from_node(node):
            """Converts an `eStandardState` node a `ThermoMLProperty.SoluteStandardState`.

            Parameters
            ----------
            node : xml.etree.Element
                The xml node to convert.

            Returns
            ----------
            ThermoMLProperty.SoluteStandardState
                The converted state type.
            """

            try:
                standard_state = ThermoMLProperty.SoluteStandardState(node.text)
            except (KeyError, ValueError):
                standard_state = ThermoMLProperty.SoluteStandardState.Undefined

            if standard_state == ThermoMLConstraintType.Undefined:

                logging.debug(f'{node.tag}->{node.text} is an unsupported '
                              f'solute standard state type.')

            return standard_state

    def __init__(self, base_type):

        self.thermodynamic_state = None
        self.phase = PropertyPhase.Undefined

        self.substance = None

        self.value = None
        self.uncertainty = None

        self.source = None

        self.index = None

        self.type = base_type

        self.solute_standard_state = ThermoMLProperty.SoluteStandardState.Undefined
        self.solvents = []

        self.target_compound_index = None

        self.property_uncertainty_definitions = {}
        self.combined_uncertainty_definitions = {}

        self.default_unit = None

        self.target_compound_index = None

    @property
    def temperature(self):
        """propertyestimator.unit.Quantity or None: The temperature at which the property was collected."""
        return None if self.thermodynamic_state is None else self.thermodynamic_state.temperature

    @property
    def pressure(self):
        """propertyestimator.unit.Quantity or None: The pressure at which the property was collected."""
        return None if self.thermodynamic_state is None else self.thermodynamic_state.pressure

    @staticmethod
    def extract_uncertainty_definitions(node, namespace,
                                        property_uncertainty_definitions,
                                        combined_uncertainty_definitions):

        """Extract any property or combined uncertainties from a property xml node.

        Parameters
        ----------
        node : Element
            The xml node to convert.
        namespace : dict of str and str
            The xml namespace.
        property_uncertainty_definitions : list(ThermoMLPropertyUncertainty)
            A list of the extracted property uncertainties.
        combined_uncertainty_definitions : list(ThermoMLPropertyUncertainty)
            A list of the extracted combined property uncertainties.
        """

        property_nodes = node.findall('ThermoML:CombinedUncertainty', namespace)

        for property_node in property_nodes:

            if property_node is None:
                continue

            uncertainty_definition = ThermoMLCombinedUncertainty.from_xml(property_node, namespace)

            if uncertainty_definition is None:
                continue

            combined_uncertainty_definitions[uncertainty_definition.index] = uncertainty_definition

        property_nodes = node.findall('ThermoML:PropUncertainty', namespace)

        for property_node in property_nodes:

            if property_node is None:
                continue

            uncertainty_definition = ThermoMLPropertyUncertainty.from_xml(property_node, namespace)

            if uncertainty_definition is None:
                continue

            property_uncertainty_definitions[uncertainty_definition.index] = uncertainty_definition

    @classmethod
    def from_xml_node(cls, node, namespace, parent_phases):
        """Creates a ThermoMLProperty from an xml node.

        Parameters
        ----------
        node : Element
            The xml node to convert.
        namespace : dict of str and str
            The xml namespace.
        parent_phases: PropertyPhase
            The phases specfied in the parent PureOrMixtureData node.

        Returns
        ----------
        ThermoMLCompound
            The created property.
        """

        # Gather up all possible identifiers
        index_node = node.find('ThermoML:nPropNumber', namespace)

        property_index = int(index_node.text)

        phase_node = node.find('./ThermoML:PropPhaseID//ThermoML:ePropPhase', namespace)
        phase = PropertyPhase.Undefined | parent_phases

        if phase_node is not None:
            phase |= phase_from_thermoml_string(phase_node.text)

        reference_phase_node = node.find('./ThermoML:RefPhaseID//ThermoML:eRefPhase', namespace)

        if reference_phase_node is not None:
            phase |= phase_from_thermoml_string(reference_phase_node.text)

        if phase == PropertyPhase.Undefined:

            logging.debug(f'A property was measured in an unsupported phase '
                          f'({phase_node.text}) and will be skipped.')

            return None

        property_group_node = node.find('./ThermoML:Property-MethodID//ThermoML:PropertyGroup//*', namespace)

        property_name_node = property_group_node.find('./ThermoML:ePropName', namespace)
        method_name_node = property_group_node.find('./ThermoML:eMethodName', namespace)

        if method_name_node is None:
            method_name_node = property_group_node.find('./ThermoML:sMethodName', namespace)

        if method_name_node is None or property_name_node is None:
            raise RuntimeError('A property does not have a name / method entry.')

        if property_name_node.text not in registered_thermoml_properties:

            logging.debug(f'An unsupported property was found '
                          f'({property_name_node.text}) and will be skipped.')

            return None

        registered_plugin = registered_thermoml_properties[property_name_node.text]

        if (registered_plugin.supported_phases & phase) != phase:

            logging.debug(f'The {property_name_node.text} property is currently only supported '
                          f'when measured in the {str(registered_plugin.supported_phases)} phase, '
                          f'and not the {str(phase)} phase.')

            return None

        return_value = cls(registered_plugin.class_type)

        return_value.index = property_index
        return_value.phase = phase

        return_value.default_unit = unit_from_thermoml_string(property_name_node.text)

        return_value.type = registered_plugin.class_type
        return_value.method_name = method_name_node.text

        property_uncertainty_definitions = {}
        combined_uncertainty_definitions = {}

        cls.extract_uncertainty_definitions(node, namespace,
                                            property_uncertainty_definitions,
                                            combined_uncertainty_definitions)

        return_value.combined_uncertainty_definitions = combined_uncertainty_definitions
        return_value.property_uncertainty_definitions = property_uncertainty_definitions

        solvent_index_nodes = node.findall('./ThermoML:Solvent//ThermoML:nOrgNum', namespace)

        if solvent_index_nodes is not None:
            for solvent_index_node in solvent_index_nodes:
                return_value.solvents.append(int(solvent_index_node.text))

        # The solute standard state appears to describe which a solute should
        # be present in only trace amounts. It only seems to be relevant for
        # activity based properties.
        standard_state_node = node.find('./ThermoML:eStandardState', namespace)

        if standard_state_node is not None:
            return_value.solute_standard_state = ThermoMLProperty.SoluteStandardState.from_node(standard_state_node)

        # Property->Property-MethodID->RegNum describes which compound is referred
        # to if the property is based on one of the compounds e.g. the activity
        # coefficient of compound 2.
        target_compound_node = node.find('./ThermoML:Property-MethodID/ThermoML:RegNum/ThermoML:nOrgNum', namespace)

        if target_compound_node is not None:
            return_value.target_compound_index = int(target_compound_node.text)

        return return_value

    def set_value(self, value, uncertainty):
        """Set the value and uncertainty of this property, adding units if necessary.

        Parameters
        ----------
        value : float, unit.Quantity
            The value of the property
        uncertainty : float
            The uncertainty in the value.
        """
        value_quantity = value
        uncertainty_quantity = uncertainty

        if not isinstance(value_quantity, unit.Quantity):
            value_quantity = value * self.default_unit
        if not isinstance(uncertainty_quantity, unit.Quantity):
            uncertainty_quantity = uncertainty * self.default_unit

        self.value = value_quantity
        self.uncertainty = uncertainty_quantity


class ThermoMLPureOrMixtureData:
    """A wrapper around a ThermoML PureOrMixtureData node.
    """

    @staticmethod
    def extract_compound_indices(node, namespace, compounds):
        """Extract a list of ThermoMLCompounds from a PureOrMixtureData node.

        Parameters
        ----------
        node : Element
            The xml node to read.
        namespace : dict of str and str
            The xml namespace.
        compounds :
            The extracted compounds.
        """

        component_nodes = node.findall('ThermoML:Component', namespace)
        compound_indices = []

        # Figure out which compounds are going to be associated with
        # the property entries.
        for component_node in component_nodes:

            index_node = component_node.find('./ThermoML:RegNum/*', namespace)

            compound_index = int(index_node.text)

            if compound_index not in compounds:

                logging.debug('A PureOrMixtureData entry depends on an '
                              'unsupported compound and has been ignored')

                return None

            if compound_index in compound_indices:

                raise RuntimeError('A ThermoML:PureOrMixtureData entry defines the same compound twice')

            compound_indices.append(compound_index)

        return compound_indices

    @staticmethod
    def extract_property_definitions(node, namespace, parent_phases):
        """Extract a list of ThermoMLProperty from a PureOrMixtureData node.

        Parameters
        ----------
        node : Element
            The xml node to read.
        namespace : dict of str and str
            The xml namespace.
        parent_phases: PropertyPhase
            The phases specified by the parent PureOrMixtureData node.

        Returns
        ----------
        dict(int, ThermoMLProperty)
            The extracted properties.
        """

        property_nodes = node.findall('ThermoML:Property', namespace)
        properties = {}

        for property_node in property_nodes:

            property_definition = ThermoMLProperty.from_xml_node(property_node, namespace, parent_phases)

            if property_definition is None:
                continue

            if property_definition.index in properties:

                raise RuntimeError('A ThermoML data set contains two '
                                   'properties with the same index')

            properties[property_definition.index] = property_definition

        return properties

    @staticmethod
    def extract_global_constraints(node, namespace, compounds):
        """Extract a list of ThermoMLConstraint from a PureOrMixtureData node.

        Parameters
        ----------
        node : Element
            The xml node to read.
        namespace : dict of str and str
            The xml namespace.
        compounds : dict(int, ThermoMLCompound)
            A dictionary of the compounds this PureOrMixtureData was calculated for.

        Returns
        ----------
        dict(int, ThermoMLConstraint)
            The extracted constraints.
        """

        constraint_nodes = node.findall('ThermoML:Constraint', namespace)
        constraints = []

        for constraint_node in constraint_nodes:

            constraint = ThermoMLConstraint.from_node(constraint_node, namespace)

            if constraint is None or constraint.type is ThermoMLConstraintType.Undefined:
                logging.debug('An unsupported constraint has been ignored.')
                return None

            if constraint.compound_index is not None and \
               constraint.compound_index not in compounds:

                logging.debug('A constraint exists upon a non-existent compound and will be ignored.')
                return None

            if constraint.type.is_composition_constraint() and constraint.compound_index is None:

                logging.debug('An unsupported constraint has been ignored - composition constraints'
                              'need to have a corresponding compound_index.')
                return None

            constraints.append(constraint)

        return constraints

    @staticmethod
    def extract_variable_definitions(node, namespace, compounds):
        """Extract a list of ThermoMLVariableDefinition from a PureOrMixtureData node.

        Parameters
        ----------
        node : Element
            The xml node to read.
        namespace : dict of str and str
            The xml namespace.
        compounds : dict(int, ThermoMLCompound)
            A dictionary of the compounds this PureOrMixtureData was calculated for.

        Returns
        ----------
        dict(int, ThermoMLVariableDefinition)
            The extracted constraints.
        """
        variable_nodes = node.findall('ThermoML:Variable', namespace)
        variables = {}

        for variable_node in variable_nodes:

            variable = ThermoMLVariableDefinition.from_node(variable_node, namespace)

            if variable is None or variable.type is ThermoMLConstraintType.Undefined:

                logging.debug('An unsupported variable has been ignored.')
                continue

            if variable.compound_index is not None and \
               variable.compound_index not in compounds:

                logging.debug('A constraint exists upon a non-existent compound and will be ignored.')
                continue

            if variable.type.is_composition_constraint() and variable.compound_index is None:

                logging.debug('An unsupported variable has been ignored - composition variables'
                              'need to have a corresponding compound_index.')

                continue

            variables[variable.index] = variable

        return variables

    @staticmethod
    def calculate_uncertainty(node, namespace, property_definition):

        # Look for a standard uncertainty..
        uncertainty_node = node.find('.//ThermoML:nCombStdUncertValue', namespace)

        if uncertainty_node is None:
            uncertainty_node = node.find('.//ThermoML:nStdUncertValue', namespace)

        # We have found a std. uncertainty
        if uncertainty_node is not None:
            return float(uncertainty_node.text)

        # Try to calculate uncertainty from a coverage factor if present
        if len(property_definition.combined_uncertainty_definitions) == 0 and \
           len(property_definition.property_uncertainty_definitions) == 0:

            return None

        combined = len(property_definition.combined_uncertainty_definitions) > 0

        prefix = ThermoMLCombinedUncertainty.prefix if combined \
            else ThermoMLPropertyUncertainty.prefix

        if combined:
            index_node = node.find('./ThermoML:CombinedUncertainty/ThermoML:nCombUncertAssessNum', namespace)
        else:
            index_node = node.find('./ThermoML:PropUncertainty/ThermoML:nUncertAssessNum', namespace)

        expanded_uncertainty_node = node.find('.//ThermoML:n' + prefix + 'ExpandUncertValue', namespace)

        if index_node is None or expanded_uncertainty_node is None:
            return None

        expanded_uncertainty = float(expanded_uncertainty_node.text)
        index = int(index_node.text)

        if combined and index not in property_definition.combined_uncertainty_definitions:
            return None

        if not combined and index not in property_definition.property_uncertainty_definitions:
            return None

        divisor = property_definition.combined_uncertainty_definitions[index].coverage_factor if combined \
            else property_definition.property_uncertainty_definitions[index].coverage_factor

        return expanded_uncertainty / divisor

    @staticmethod
    def _smiles_to_molecular_weight(smiles):
        """Calculates the molecular weight of a substance specified
        by a smiles string.

        Parameters
        ----------
        smiles: str
            The smiles string to calculate the molecular weight of.

        Returns
        -------
        propertyestimator.unit.Quantity
            The molecular weight.
        """

        from simtk import unit as simtk_unit
        from openforcefield.topology import Molecule

        try:
            molecule = Molecule.from_smiles(smiles)
        except Exception as e:

            formatted_exception = traceback.format_exception(None, e, e.__traceback__)
            raise ValueError(f'The toolkit raised an exception for the {smiles} smiles pattern: {formatted_exception}')

        molecular_weight = 0.0 * simtk_unit.dalton

        for atom in molecule.atoms:
            molecular_weight += atom.mass

        return openmm_quantity_to_pint(molecular_weight)

    @staticmethod
    def _solvent_mole_fractions_to_moles(solvent_mass, solvent_mole_fractions, solvent_compounds):
        """Converts a set of solvent mole fractions to moles for a
        given mass of solvent.

        Parameters
        ----------
        solvent_mass: propertyestimator.unit.Quantity
            The total mass of the solvent in units compatible with kg.
        solvent_mole_fractions: dict of int and float
            The mole fractions of any solvent compounds in the system.
        solvent_compounds: dict of int and float
            A dictionary of any solvent compounds in the system.

        Returns
        -------
        dict of int and unit.Quantity
            A dictionary of the moles of each solvent compound.
        """
        weighted_molecular_weights = 0.0 * unit.gram / unit.mole
        number_of_moles = {}

        for solvent_index in solvent_compounds:

            solvent_smiles = solvent_compounds[solvent_index].smiles

            solvent_fraction = solvent_mole_fractions[solvent_index]
            solvent_weight = ThermoMLPureOrMixtureData._smiles_to_molecular_weight(solvent_smiles)

            weighted_molecular_weights += solvent_weight * solvent_fraction

        total_solvent_moles = solvent_mass / weighted_molecular_weights

        for solvent_index in solvent_compounds:

            moles = solvent_mole_fractions[solvent_index] * total_solvent_moles
            number_of_moles[solvent_index] = moles

        return number_of_moles

    @staticmethod
    def _convert_mole_fractions(constraints, compounds, solvent_mole_fractions=None):
        """Converts a set of `ThermoMLConstraint` to mole fractions.

        Parameters
        ----------
        constraints: list of ThermoMLConstraint
            The constraints to convert.
        compounds: dict of int and ThermoMLCompound
            The compounds in the system.
        solvent_mole_fractions: dict of int and float
            The mole fractions of any solvent compounds in the system,
            where the total mole fraction of all solvents must be equal
            to one.

        Returns
        -------
        dict of int and float
            A dictionary of compound indices and mole fractions.
        """

        # noinspection PyTypeChecker
        number_of_constraints = len(constraints)

        mole_fractions = {}
        total_mol_fraction = 0.0

        for constraint in constraints:

            mole_fraction = constraint.value

            if isinstance(mole_fraction, unit.Quantity):
                mole_fraction = mole_fraction.to(unit.dimensionless).magnitude

            mole_fractions[constraint.compound_index] = mole_fraction
            total_mol_fraction += mole_fractions[constraint.compound_index]

        if ((number_of_constraints != len(compounds) and solvent_mole_fractions is not None) or
            (number_of_constraints != len(compounds) - 1 and number_of_constraints != len(compounds) and
             solvent_mole_fractions is None)):

            raise ValueError(f'The number of mole fraction constraints ({number_of_constraints}) must be one '
                             f'less than or equal to the number of compounds being constrained ({len(compounds)}) '
                             f'if a solvent list is not present, otherwise there must be an equal number.')

        # Handle the case were a single mole fraction constraint is missing.
        if number_of_constraints == len(compounds) - 1:

            for compound_index in compounds:

                if compound_index in mole_fractions:
                    continue

                mole_fractions[compound_index] = 1.0 - total_mol_fraction

        # Recompute the total mole fraction to be safe.
        total_mol_fraction = 0.0

        for compound_index in mole_fractions:
            total_mol_fraction += mole_fractions[compound_index]

        # Account for any solvent present.
        if solvent_mole_fractions is not None:

            # Assume the remainder of the mole fraction is the solvent.
            remaining_mole_fraction = 1.0 - total_mol_fraction

            for solvent_index in solvent_mole_fractions:
                mole_fractions[solvent_index] = solvent_mole_fractions[solvent_index] * remaining_mole_fraction

        return mole_fractions

    @staticmethod
    def _convert_mass_fractions(constraints, compounds, solvent_mole_fractions=None, solvent_compounds=None):
        """Converts a set of `ThermoMLConstraint` to mole fractions.

        Parameters
        ----------
        constraints: list of ThermoMLConstraint
            The constraints to convert.
        compounds: dict of int and ThermoMLCompound
            The compounds in the system.
        solvent_mole_fractions: dict of int and float
            The mole fractions of any solvent compounds in the system,
            where the total mole fraction of all solvents must be equal
            to one.
        solvent_compounds: dict of int and float
            A dictionary of any explicitly defined solvent compounds in the
            system.

        Returns
        -------
        dict of int and float
            A dictionary of compound indices and mole fractions.
        """

        # noinspection PyTypeChecker
        number_of_constraints = len(constraints)

        mass_fractions = {}
        total_mass_fraction = 0.0

        for constraint in constraints:

            mass_fraction = constraint.value

            if isinstance(mass_fraction, unit.Quantity):
                mass_fraction = mass_fraction.to(unit.dimensionless).magnitude

            mass_fractions[constraint.compound_index] = mass_fraction
            total_mass_fraction += mass_fraction

        if ((number_of_constraints != len(compounds) and solvent_mole_fractions is not None) or
            (number_of_constraints != len(compounds) - 1 and number_of_constraints != len(compounds) and
             solvent_mole_fractions is None)):

            raise ValueError(f'The number of mass fraction constraints ({number_of_constraints}) must be one '
                             f'less than or equal to the number of compounds being constrained ({len(compounds)}) '
                             f'if a solvent list is not present, otherwise there must be an equal number.')

        # Handle the case were a single mass fraction constraint is missing.
        if number_of_constraints == len(compounds) - 1:

            for compound_index in compounds:

                if compound_index in mass_fractions:
                    continue

                mass_fractions[compound_index] = (1.0 - total_mass_fraction)\

                if isinstance(mass_fractions[compound_index], unit.Quantity):
                    mass_fractions[compound_index] = mass_fractions[compound_index].to(unit.dimensionless).magnitude

        total_mass = 1 * unit.gram
        total_solvent_mass = total_mass

        moles = {}
        total_moles = 0.0 * unit.mole

        for compound_index in compounds:

            compound_smiles = compounds[compound_index].smiles
            compound_weight = ThermoMLPureOrMixtureData._smiles_to_molecular_weight(compound_smiles)

            moles[compound_index] = total_mass * mass_fractions[compound_index] / compound_weight
            total_moles += moles[compound_index]

            total_solvent_mass -= total_mass * mass_fractions[compound_index]

        if number_of_constraints == len(compounds) and solvent_mole_fractions is not None:

            solvent_moles = ThermoMLPureOrMixtureData._solvent_mole_fractions_to_moles(total_solvent_mass,
                                                                                       solvent_mole_fractions,
                                                                                       solvent_compounds)

            for solvent_index in solvent_moles:

                moles[solvent_index] = solvent_moles[solvent_index]
                total_moles += solvent_moles[solvent_index]

        mole_fractions = {}

        for compound_index in moles:

            mole_fraction = moles[compound_index] / total_moles
            mole_fractions[compound_index] = mole_fraction

        return mole_fractions

    @staticmethod
    def _convert_molality(constraints, compounds, solvent_mole_fractions=None, solvent_compounds=None):
        """Converts a set of `ThermoMLConstraint` to mole fractions.

        Parameters
        ----------
        constraints: list of ThermoMLConstraint
            The constraints to convert.
        compounds: dict of int and ThermoMLCompound
            The compounds in the system.
        solvent_mole_fractions: dict of int and float
            The mole fractions of any solvent compounds in the system,
            where the total mole fraction of all solvents must be equal
            to one.
        solvent_compounds: dict of int and float
            A dictionary of any explicitly defined solvent compounds in the
            system.

        Returns
        -------
        dict of int and float
            A dictionary of compound indices and mole fractions.
        """
        number_of_moles = {}
        # noinspection PyTypeChecker
        number_of_constraints = len(constraints)

        mole_fractions = {}

        total_number_of_moles = 0.0 * unit.moles
        total_solvent_mass = 1.0 * unit.kilograms

        for constraint in constraints:

            molality = constraint.value
            moles = molality * total_solvent_mass

            number_of_moles[constraint.compound_index] = moles
            total_number_of_moles += moles

        if ((number_of_constraints != len(compounds) - 1 and solvent_mole_fractions is None) or
            (number_of_constraints != len(compounds) and solvent_mole_fractions is not None)):

            raise ValueError(f'The number of molality constraints ({number_of_constraints}) must be one '
                             f'less than the number of compounds being constrained ({len(compounds)}) if a '
                             f'solvent list is not present, otherwise there must be an equal number.')

        if number_of_constraints == len(compounds) - 1 and solvent_mole_fractions is None:

            # In this case, there is no explicit solvent entry and the last component
            # whose molality has not been constrained is considered to be the 'solvent'
            for compound_index in compounds:

                if compound_index in number_of_moles:
                    continue

                compound_smiles = compounds[compound_index].smiles
                compound_weight = ThermoMLPureOrMixtureData._smiles_to_molecular_weight(compound_smiles)

                moles = total_solvent_mass / compound_weight

                number_of_moles[compound_index] = moles
                total_number_of_moles += moles

        elif number_of_constraints == len(compounds) and solvent_mole_fractions is not None:

            solvent_moles = ThermoMLPureOrMixtureData._solvent_mole_fractions_to_moles(total_solvent_mass,
                                                                                       solvent_mole_fractions,
                                                                                       solvent_compounds)

            for solvent_index in solvent_moles:

                number_of_moles[solvent_index] = solvent_moles[solvent_index]
                total_number_of_moles += solvent_moles[solvent_index]

        for compound_index in number_of_moles:

            mole_fraction = number_of_moles[compound_index] / total_number_of_moles
            mole_fractions[compound_index] = mole_fraction

        return mole_fractions

    @staticmethod
    def build_mixture(thermoml_property, constraints, compounds):
        """Build a Substance object from the extracted constraints and compounds.

        Parameters
        ----------
        thermoml_property: ThermoMLProperty
            The property to which this mixture belongs.
        constraints : list of ThermoMLConstraint
            The ThermoML constraints.
        compounds : dict of int and ThermoMLCompound
            A dictionary of the compounds this PureOrMixtureData was calculated for.

        Returns
        ----------
        Substance
            The constructed mixture.
        """

        # TODO: We need to take into account `thermoml_property.target_compound_index` and
        #       `thermoml_property.solute_standard_state` to properly identify infinitely
        #       diluted solutes in the system (if any). Otherwise the solute will be
        #       assigned a mole fraction of zero.

        solvent_constraint_type = ThermoMLConstraintType.Undefined
        component_constraint_type = ThermoMLConstraintType.Undefined

        solvent_indices = set()

        for solvent_index in thermoml_property.solvents:

            if solvent_index in solvent_indices:
                continue

            solvent_indices.add(solvent_index)

        # Determine which types of solvent and component constraints are
        # being applied.
        for constraint in constraints:

            # Make sure we hunt down solvent indices.
            for solvent_index in constraint.solvents:

                if solvent_index in solvent_indices:
                    continue

                solvent_indices.add(solvent_index)

            # Only composition type restraints apply here, skip
            # the rest.
            if not constraint.type.is_composition_constraint():
                continue

            if (constraint.type == ThermoMLConstraintType.SolventMassFraction or
                constraint.type == ThermoMLConstraintType.SolventMoleFraction or
                constraint.type == ThermoMLConstraintType.SolventMolality):

                if solvent_constraint_type == ThermoMLConstraintType.Undefined:
                    solvent_constraint_type = constraint.type

                if solvent_constraint_type != constraint.type:

                    logging.debug(f'A property with different types of solvent composition constraints '
                                  f'was found - {solvent_constraint_type} vs {constraint.type}). This '
                                  f'is likely a bug in the ThermoML file and so this property will be '
                                  f'skipped.')

                    return None

            else:

                if component_constraint_type == ThermoMLConstraintType.Undefined:
                    component_constraint_type = constraint.type

                if component_constraint_type != constraint.type:

                    logging.debug(f'A property with different types of composition constraints '
                                  f'was found - {component_constraint_type} vs {constraint.type}). This '
                                  f'is likely a bug in the ThermoML file and so this property will be '
                                  f'skipped.')

                    return None

        # If no constraint was applied, this very likely means a pure substance
        # was found.
        if (component_constraint_type == ThermoMLConstraintType.Undefined and
            solvent_constraint_type == ThermoMLConstraintType.Undefined):

            component_constraint_type = ThermoMLConstraintType.ComponentMoleFraction

        elif (component_constraint_type == ThermoMLConstraintType.Undefined and
              solvent_constraint_type != ThermoMLConstraintType.Undefined):

            logging.debug(f'A property with only solvent composition '
                          f'constraints {solvent_constraint_type} was found.')

            return None

        solvent_mole_fractions = {}

        solvent_constraints = [constraint for constraint in constraints if
                               constraint.type == solvent_constraint_type]

        solvent_compounds = {}

        for solvent_index in solvent_indices:

            if solvent_index in compounds:

                solvent_compounds[solvent_index] = compounds[solvent_index]
                continue

            logging.debug('The composition of a non-existent solvent was '
                          'found. This usually only occurs in cases were '
                          'the solvent component could not be understood '
                          'by the framework.')

            return None

        # Make sure all of the solvents have not been removed.
        if solvent_constraint_type != ThermoMLConstraintType.Undefined and len(solvent_indices) == 0:

            logging.debug('The composition of a solvent was found, however the '
                          'solvent list is empty. This usually only occurs in '
                          'cases were the solvent component could not be understood '
                          'by the framework.')

            return None

        remaining_constraints = [constraint for constraint in constraints if
                                 constraint.type == component_constraint_type]

        remaining_compounds = {}

        for compound_index in compounds:

            if compound_index in solvent_indices:
                continue

            remaining_compounds[compound_index] = compounds[compound_index]

        # Determine the mole fractions of the solvent species, if any.
        if solvent_constraint_type == ThermoMLConstraintType.SolventMoleFraction:

            solvent_mole_fractions = ThermoMLPureOrMixtureData._convert_mole_fractions(solvent_constraints,
                                                                                       solvent_compounds)

        elif solvent_constraint_type == ThermoMLConstraintType.SolventMassFraction:

            solvent_mole_fractions = ThermoMLPureOrMixtureData._convert_mass_fractions(solvent_constraints,
                                                                                       solvent_compounds)

        elif solvent_constraint_type == ThermoMLConstraintType.SolventMolality:

            solvent_mole_fractions = ThermoMLPureOrMixtureData._convert_molality(solvent_constraints,
                                                                                 solvent_compounds)

        elif solvent_constraint_type == ThermoMLConstraintType.Undefined:

            solvent_mole_fractions = None
            solvent_compounds = None

            remaining_compounds = compounds

        # Determine the mole fractions of the remaining compounds.
        mole_fractions = {}

        if component_constraint_type == ThermoMLConstraintType.ComponentMoleFraction:

            mole_fractions = ThermoMLPureOrMixtureData._convert_mole_fractions(remaining_constraints,
                                                                               remaining_compounds,
                                                                               solvent_mole_fractions)

        elif component_constraint_type == ThermoMLConstraintType.ComponentMassFraction:

            mole_fractions = ThermoMLPureOrMixtureData._convert_mass_fractions(remaining_constraints,
                                                                               remaining_compounds,
                                                                               solvent_mole_fractions,
                                                                               solvent_compounds)

        elif component_constraint_type == ThermoMLConstraintType.ComponentMolality:

            mole_fractions = ThermoMLPureOrMixtureData._convert_molality(remaining_constraints,
                                                                         remaining_compounds,
                                                                         solvent_mole_fractions,
                                                                         solvent_compounds)

        if len(mole_fractions) != len(compounds):

            raise ValueError(f'The number of mole fractions ({len(mole_fractions)}) does not '
                             f'equal the total number of compounds ({len(compounds)})')

        # Make sure we haven't picked up a dimensionless unit be accident.
        for compound_index in mole_fractions:

            if isinstance(mole_fractions[compound_index], unit.Quantity):
                mole_fractions[compound_index] = mole_fractions[compound_index].to(unit.dimensionless).magnitude

        total_mol_fraction = sum([value for value in mole_fractions.values()])

        if not np.isclose(total_mol_fraction, 1.0):
            raise ValueError(f'The total mole fraction {total_mol_fraction} is not equal to 1.0')

        substance = Substance()

        for compound_index in compounds:

            compound = compounds[compound_index]

            if np.isclose(mole_fractions[compound_index], 0.0):
                continue

            substance.add_component(component=Substance.Component(smiles=compound.smiles),
                                    amount=Substance.MoleFraction(mole_fractions[compound_index]))

        return substance

    @staticmethod
    def extract_measured_properties(node, namespace,
                                    property_definitions,
                                    global_constraints,
                                    variable_definitions,
                                    compounds):

        """Extract the measured properties defined by a ThermoML PureOrMixtureData node.

        Parameters
        ----------
        node : Element
            The xml node to read.
        namespace : dict of str and str
            The xml namespace.
        property_definitions
            The extracted property definitions.
        global_constraints
            The extracted constraints.
        variable_definitions
            The extracted variable definitions.
        compounds
            The extracted compounds.

        Returns
        ----------
        list(MeasuredPhysicalProperty)
            The extracted measured properties.
        """

        value_nodes = node.findall('ThermoML:NumValues', namespace)

        measured_properties = []

        # Each value_node corresponds to one MeasuredProperty
        for value_node in value_nodes:

            constraints = []

            temperature_constraint = None
            pressure_constraint = None

            for global_constraint in global_constraints:

                constraint = copy.deepcopy(global_constraint)
                constraints.append(constraint)

                if constraint.type == ThermoMLConstraintType.Temperature:
                    temperature_constraint = constraint
                elif constraint.type == ThermoMLConstraintType.Pressure:
                    pressure_constraint = constraint

            # First extract the values of any variable constraints
            variable_nodes = value_node.findall('ThermoML:VariableValue', namespace)

            skip_entry = False

            for variable_node in variable_nodes:

                variable_index = int(variable_node.find('./ThermoML:nVarNumber', namespace).text)

                if variable_index not in variable_definitions:

                    # The property was constrained by an unsupported variable and
                    # so will be skipped for now.
                    skip_entry = True
                    break

                variable_definition = variable_definitions[variable_index]

                variable_value = float(variable_node.find('ThermoML:nVarValue', namespace).text)
                value_as_quantity = unit.Quantity(variable_value, variable_definition.default_unit)

                "Convert the 'variable' into a full constraint entry"
                constraint = ThermoMLConstraint.from_variable(variable_definition, value_as_quantity)
                constraints.append(constraint)

                if constraint.type == ThermoMLConstraintType.Temperature:
                    temperature_constraint = constraint
                elif constraint.type == ThermoMLConstraintType.Pressure:
                    pressure_constraint = constraint

            if skip_entry:
                continue

            # Extract the thermodynamic state that the property was measured at.
            if temperature_constraint is None:

                logging.debug('A property did not report the temperature or the pressure it '
                              'was measured at and will be ignored.')
                continue

            temperature = temperature_constraint.value
            pressure = None if pressure_constraint is None else pressure_constraint.value

            thermodynamic_state = ThermodynamicState(temperature=temperature, pressure=pressure)

            # Now extract the actual values of the measured properties, and their
            # uncertainties
            property_nodes = value_node.findall('ThermoML:PropertyValue', namespace)

            for property_node in property_nodes:

                property_index = int(property_node.find('./ThermoML:nPropNumber', namespace).text)

                if property_index not in property_definitions:

                    # Most likely the property was dropped earlier due to an unsupported phase / type
                    continue

                property_definition = property_definitions[property_index]

                uncertainty = ThermoMLPureOrMixtureData.calculate_uncertainty(property_node,
                                                                              namespace,
                                                                              property_definition)

                if uncertainty is None:

                    logging.debug('A property ({str(property_definition.type)}) '
                                  'without uncertainties was ignored')

                    continue

                measured_property = copy.deepcopy(property_definition)
                # measured_property = json.loads(json.dumps(property_definition))
                measured_property.thermodynamic_state = thermodynamic_state

                property_value_node = property_node.find('.//ThermoML:nPropValue', namespace)

                measured_property.set_value(float(property_value_node.text),
                                            float(uncertainty))

                mixture = ThermoMLPureOrMixtureData.build_mixture(measured_property,
                                                                  constraints,
                                                                  compounds)

                if mixture is None:
                    continue

                measured_property.substance = mixture
                measured_properties.append(measured_property)

        # By this point we now have the measured properties and the thermodynamic state
        # they were measured at converted to standardised classes.
        return measured_properties

    @staticmethod
    def from_xml_node(node, namespace, compounds):
        """Extracts all of the data in a ThermoML PureOrMixtureData node.

        Parameters
        ----------
        node : Element
            The xml node to read.
        namespace : dict of str and str
            The xml namespace.
        compounds : dict of int and ThermoMLCompound
            A list of the already extracted `ThermoMLCompound`'s.

        Returns
        ----------
        list(MeasuredPhysicalProperty)
            A list of extracted properties.
        """

        # Figure out which compounds are going to be associated with
        # the property entries.
        compound_indices = ThermoMLPureOrMixtureData.extract_compound_indices(node, namespace, compounds)

        if compound_indices is None:
            # Most likely this entry depended on a non-parsable compound
            # and will be skipped entirely
            return None

        if len(compound_indices) == 0:

            logging.debug('A PureOrMixtureData entry with no compounds was ignored.')
            return None

        phase_nodes = node.findall('./ThermoML:PhaseID/ThermoML:ePhase', namespace)

        all_phases = None

        for phase_node in phase_nodes:

            phase = phase_from_thermoml_string(phase_node.text)

            if phase == PropertyPhase.Undefined:

                logging.debug(f'A property was measured in an unsupported phase '
                              f'({phase_node.text}) and will be skipped.')

                return None

            all_phases = phase if all_phases is None else all_phases | phase

        # Extract property definitions - values come later!
        property_definitions = ThermoMLPureOrMixtureData.extract_property_definitions(node, namespace, all_phases)

        if len(property_definitions) == 0:
            return None

        for property_index in property_definitions:

            all_phases |= property_definitions[property_index].phase
            property_definitions[property_index].phase |= all_phases

        # Extract any constraints on the system e.g pressure, temperature
        global_constraints = ThermoMLPureOrMixtureData.extract_global_constraints(node, namespace, compounds)

        if global_constraints is None:
            return None

        # Extract any variables set on the system e.g pressure, temperature
        # Only the definition entry and not the value of the variable is extracted
        variable_definitions = ThermoMLPureOrMixtureData.extract_variable_definitions(node, namespace, compounds)

        if len(global_constraints) == 0 and len(variable_definitions) == 0:

            logging.debug('A PureOrMixtureData entry with no constraints was ignored.')
            return None

        used_compounds = {}

        for compound_index in compounds:

            if compound_index not in compound_indices:
                continue

            used_compounds[compound_index] = compounds[compound_index]

        measured_properties = ThermoMLPureOrMixtureData.extract_measured_properties(node, namespace,
                                                                                    property_definitions,
                                                                                    global_constraints,
                                                                                    variable_definitions,
                                                                                    used_compounds)

        return measured_properties


class ThermoMLDataSet(PhysicalPropertyDataSet):
    """A dataset of physical property measurements created from a ThermoML dataset.

    Examples
    --------

    For example, we can use the DOI `10.1016/j.jct.2005.03.012` as a key
    for retrieving the dataset from the ThermoML Archive:

    >>> dataset = ThermoMLDataSet.from_doi('10.1016/j.jct.2005.03.012')

    You can also specify multiple ThermoML Archive keys to create a dataset from multiple ThermoML files:

    >>> thermoml_keys = ['10.1021/acs.jced.5b00365', '10.1021/acs.jced.5b00474']
    >>> dataset = ThermoMLDataSet.from_doi(*thermoml_keys)

    """

    def __init__(self):
        """Constructs a new ThermoMLDataSet object."""
        super().__init__()

    @classmethod
    def from_doi(cls, *doi_list):
        """Load a ThermoML data set from a list of DOIs

        Parameters
        ----------
        doi_list : str
            The list of DOIs to pull data from

        Returns
        -------
        ThermoMLDataSet
            The loaded data set.
        """
        return_value = None

        for doi in doi_list:

            # E.g https://trc.nist.gov/ThermoML/10.1016/j.jct.2016.12.009.xml
            doi_url = 'https://trc.nist.gov/ThermoML/' + doi + '.xml'

            data_set = cls._from_url(doi_url, MeasurementSource(doi=doi))

            if data_set is None or len(data_set.properties) == 0:
                continue

            if return_value is None:
                return_value = data_set
            else:
                return_value.merge(data_set)

        return return_value

    @classmethod
    def from_url(cls, *url_list):
        """Load a ThermoML data set from a list of URLs

        Parameters
        ----------
        url_list : str
            The list of URLs to pull data from

        Returns
        -------
        ThermoMLDataSet
            The loaded data set.
        """

        return_value = None

        for url in url_list:

            data_set = cls._from_url(url)

            if data_set is None or len(data_set.properties) == 0:
                continue

            if return_value is None:
                return_value = data_set
            else:
                return_value.merge(data_set)

        return return_value

    @classmethod
    def _from_url(cls, url, source=None):
        """Load a ThermoML data set from a given URL

        Parameters
        ----------
        url : str
            The URL to pull data from
        source : Source, optional
            An optional source which gives more information (e.g DOIs) for the url.

        Returns
        ----------
        ThermoMLDataSet
            The loaded data set.
        """
        if source is None:
            source = MeasurementSource(reference=url)

        return_value = None

        try:

            with urlopen(url) as response:
                return_value = cls.from_xml(response.read(), source)

        except HTTPError:
            logging.warning('No ThermoML file could not be found at ' + url)

        return return_value

    @classmethod
    def from_file(cls, *file_list):
        """Load a ThermoML data set from a list of files

        Parameters
        ----------
        file_list : str
            The list of files to pull data from

        Returns
        -------
        ThermoMLDataSet
            The loaded data set.
        """
        return_value = None
        counter = 0

        for file in file_list:

            data_set = cls._from_file(file)

            counter += 1

            if data_set is None or len(data_set.properties) == 0:
                continue

            if return_value is None:
                return_value = data_set
            else:
                return_value.merge(data_set)

        return return_value

    @classmethod
    def _from_file(cls, path):
        """Load a ThermoML data set from a given file

        Parameters
        ----------
        path : str
            The file path to pull data from

        Returns
        -------
        ThermoMLDataSet
            The loaded data set.
        """
        source = MeasurementSource(reference=path)
        return_value = None

        try:

            with open(path) as file:
                return_value = ThermoMLDataSet.from_xml(file.read(), source)

        except FileNotFoundError:
            logging.warning('No ThermoML file could not be found at ' + path)

        return return_value

    @classmethod
    def from_xml(cls, xml, source):
        """Load a ThermoML data set from an xml object.

        Parameters
        ----------
        xml : str
            The xml string to parse.
        source : Source
            The source of the xml object.

        Returns
        -------
        ThermoMLDataSet
            The loaded ThermoML data set.
        """
        root_node = ElementTree.fromstring(xml)

        if root_node is None:
            logging.warning('The ThermoML XML document could not be parsed.')
            return None

        if root_node.tag.find('DataReport') < 0:
            logging.warning('The ThermoML XML document does not contain the expected root node.')
            return None

        # Extract the namespace that will prefix all type names
        namespace_string = re.search(r'{.*\}', root_node.tag).group(0)[1:-1]
        namespace = {'ThermoML': namespace_string}

        return_value = ThermoMLDataSet()
        compounds = {}

        # Extract the base compounds present in the xml file
        for node in root_node.findall('ThermoML:Compound', namespace):

            compound = ThermoMLCompound.from_xml_node(node, namespace)

            if compound is None:
                continue

            if compound.index in compounds:
                raise RuntimeError('A ThermoML data set contains two '
                                   'compounds with the same index')

            compounds[compound.index] = compound

        # Pull out any and all properties in the file.
        for node in root_node.findall('ThermoML:PureOrMixtureData', namespace):

            properties = ThermoMLPureOrMixtureData.from_xml_node(node,
                                                                 namespace,
                                                                 compounds)

            if properties is None or len(properties) == 0:
                continue

            for measured_property in properties:

                substance_id = measured_property.substance.identifier

                if substance_id not in return_value._properties:
                    return_value._properties[substance_id] = []

                if measured_property.type is None:
                    raise ValueError('An unexepected property type managed to slip through the cracks.')

                final_property = measured_property.type()

                final_property.value = measured_property.value
                final_property.uncertainty = measured_property.uncertainty

                final_property.phase = measured_property.phase

                final_property.thermodynamic_state = measured_property.thermodynamic_state
                final_property.substance = measured_property.substance

                final_property.source = source

                return_value._properties[substance_id].append(final_property)

        return_value._sources.append(source)

        return return_value
