Source code for pyked.validation

"""Validation class for ChemKED schema."""

import re
from importlib import resources
from warnings import warn

import habanero
import httpx2 as httpx
import numpy as np
import pint
import yaml
from cerberus import SchemaError, Validator

from . import schemas
from .orcid import search_orcid

units = pint.UnitRegistry()
"""Unit registry to contain the units used in PyKED"""

units.define("cm3 = centimeter**3")
Q_ = units.Quantity

crossref_api = habanero.Crossref(mailto="prometheus@pr.omethe.us")

# Load the ChemKED schema definition file
schema_file = resources.files(schemas) / "chemked_schema.yaml"
with schema_file.open("r", encoding="utf-8") as f:
    schema_list = f.readlines()

inc_start = None
inc_end = None
inc_list = []
no_includes = False
for l_num, line in enumerate(schema_list):
    if line.startswith("!include"):
        if no_includes:  # pragma: no cover
            raise SchemaError("All included files must be first in the main schema")

        if inc_start is None:
            inc_start = l_num

        if inc_end is not None:  # pragma: no cover
            raise SchemaError("All included files must be first in the main schema")

        inc_fname = resources.files(schemas) / line.split("!include")[1].strip()
        with inc_fname.open("r") as f:
            inc_list.extend(f.readlines())
    else:
        if not line.strip() or line.startswith("#") or line.startswith("---"):
            continue

        if inc_start is None:  # pragma: no cover
            no_includes = True

        if inc_start is not None and inc_end is None:
            inc_end = l_num

schema_list[inc_start:inc_end] = inc_list
schema = yaml.safe_load("".join(schema_list))

# These top-level keys in the schema serve as references for lower-level keys.
# They are removed to prevent conflicts due to required variables, etc.
for key in [
    "author",
    "value-unit-required",
    "value-unit-optional",
    "composition",
    "ignition-type",
    "value-with-uncertainty",
    "value-without-uncertainty",
]:
    del schema[key]

# SI units for available value-type properties
property_units = {
    "temperature": "kelvin",
    "compressed-temperature": "kelvin",
    "pressure": "pascal",
    "compressed-pressure": "pascal",
    "ignition-delay": "second",
    "first-stage-ignition-delay": "second",
    "pressure-rise": "1.0 / second",
    "compression-time": "second",
    "volume": "meter**3",
    "time": "second",
    "piston position": "meter",
    "emission": "dimensionless",
    "absorption": "dimensionless",
    "concentration": "mole/meter**3",
    "stroke": "meter",
    "clearance": "meter",
    "compression-ratio": "dimensionless",
}


[docs] def compare_name(given_name, family_name, question_name): """Compares a name in question to a specified name separated into given and family. The name in question ``question_name`` can be of varying format, including "Kyle E. Niemeyer", "Kyle Niemeyer", "K. E. Niemeyer", "KE Niemeyer", and "K Niemeyer". Other possibilities include names with hyphens such as "Chih-Jen Sung", "C. J. Sung", "C-J Sung". Examples: >>> compare_name('Kyle', 'Niemeyer', 'Kyle E Niemeyer') True >>> compare_name('Chih-Jen', 'Sung', 'C-J Sung') True Args: given_name (`str`): Given (or first) name to be checked against. family_name (`str`): Family (or last) name to be checked against. question_name (`str`): The whole name in question. Returns: `bool`: The return value. True for successful comparison, False otherwise. """ # lowercase everything given_name = given_name.lower() family_name = family_name.lower() question_name = question_name.lower() # rearrange names given as "last, first middle" if "," in question_name: name_split = question_name.split(",") name_split.reverse() question_name = " ".join(name_split).strip() # remove periods question_name = question_name.replace(".", "") given_name = given_name.replace(".", "") family_name = family_name.replace(".", "") # split names by , <space> - . given_name = list(filter(None, re.split(r"[, \-.]+", given_name))) num_family_names = len(list(filter(None, re.split("[, .]+", family_name)))) # split name in question by , <space> - . name_split = list(filter(None, re.split(r"[, \-.]+", question_name))) first_name = [name_split[0]] if len(name_split) > 2: first_name += list(name_split[1:-num_family_names]) if len(first_name) > 1 and len(given_name) == len(first_name): # both have same number of first and middle names/initials for i in range(1, len(first_name)): first_name[i] = first_name[i][0] given_name[i] = given_name[i][0] elif len(given_name) != len(first_name): min_names = min(len(given_name), len(first_name)) first_name = first_name[:min_names] given_name = given_name[:min_names] # first initial if len(first_name[0]) == 1 or len(given_name[0]) == 1: given_name[0] = given_name[0][0] first_name[0] = first_name[0][0] # first and middle initials combined if len(first_name[0]) > 1 or len(given_name[0]) > 1: given_name[0] = given_name[0][0] first_name[0] = name_split[0][0] # Hyphenated last name may need to be reconnected if num_family_names == 1 and "-" in family_name: num_hyphen = family_name.count("-") family_name_compare = "-".join(name_split[-(num_hyphen + 1) :]) else: family_name_compare = " ".join(name_split[-num_family_names:]) return given_name == first_name and family_name == family_name_compare
[docs] class OurValidator(Validator): """Custom validator with rules for Quantities and references.""" def _validate_isvalid_t_range(self, isvalid_t_range, field, values): """Checks that the temperature ranges given for thermo data are valid Args: isvalid_t_range (`bool`): flag from schema indicating T range is to be checked field (`str`): T_range values (`list`): List of temperature values indicating low, middle, and high ranges The rule's arguments are validated against this schema: {'type': 'boolean'} """ if all(isinstance(v, (float, int)) for v in values): # If no units given, assume Kelvin T_low = Q_(values[0], "K") T_mid = Q_(values[1], "K") T_hi = Q_(values[2], "K") elif all(isinstance(v, str) for v in values): T_low = Q_(values[0]) T_mid = Q_(values[1]) T_hi = Q_(values[2]) else: self._error( field, "The temperatures in the range must all be either with units or " "without units, they cannot be mixed", ) return False if min([T_low, T_mid, T_hi]) != T_low: self._error(field, "The first element of the T_range must be the lower limit") if max([T_low, T_mid, T_hi]) != T_hi: self._error(field, "The last element of the T_range must be the upper limit") def _validate_isvalid_unit(self, isvalid_unit, field, value): """Checks for appropriate units using Pint unit registry. Args: isvalid_unit (`bool`): flag from schema indicating units to be checked. field (`str`): property associated with units in question. value (`dict`): dictionary of values from file associated with this property. The rule's arguments are validated against this schema: {'type': 'boolean'} """ quantity = 1.0 * units(value["units"]) try: quantity.to(property_units[field]) except pint.DimensionalityError: self._error( field, f"incompatible units; should be consistent with {property_units[field]}", ) def _validate_isvalid_history(self, isvalid_history, field, value): """Checks that the given time history is properly formatted. Args: isvalid_history (`bool`): flag from schema indicating history to be checked. field (`str`): property associated with history in question. value (`dict`): dictionary of values from file associated with this property. The rule's arguments are validated against this schema: {'type': 'boolean'} """ # Check the type has appropriate units history_type = value["type"] if history_type.endswith("emission"): history_type = "emission" elif history_type.endswith("absorption"): history_type = "absorption" quantity = 1.0 * (units(value["quantity"]["units"])) try: quantity.to(property_units[history_type]) except pint.DimensionalityError: self._error( field, f"incompatible units; should be consistent with {property_units[history_type]}", ) # Check that time has appropriate units time = 1.0 * (units(value["time"]["units"])) try: time.to(property_units["time"]) except pint.DimensionalityError: self._error( field, f"incompatible units; should be consistent with {property_units['time']}", ) # Check that the values have the right number of columns n_cols = len(value["values"][0]) max_cols = ( max( value["time"]["column"], value["quantity"]["column"], value.get("uncertainty", {}).get("column", 0), ) + 1 ) if n_cols > max_cols: self._error(field, "too many columns in the values") elif n_cols < max_cols: self._error(field, "not enough columns in the values") def _validate_isvalid_quantity(self, isvalid_quantity, field, value): """Checks for valid given value and appropriate units. Args: isvalid_quantity (`bool`): flag from schema indicating quantity to be checked. field (`str`): property associated with quantity in question. value (`list`): list whose first element is a string representing a value with units The rule's arguments are validated against this schema: {'type': 'boolean'} """ quantity = Q_(value[0]) low_lim = 0.0 * units(property_units[field]) try: if quantity <= low_lim: self._error( field, f"value must be greater than 0.0 {property_units[field]}", ) except pint.DimensionalityError: self._error( field, f"incompatible units; should be consistent with {property_units[field]}", ) def _validate_isvalid_uncertainty(self, isvalid_uncertainty, field, value): """Checks for valid given value and appropriate units with uncertainty. Args: isvalid_uncertainty (`bool`): flag from schema indicating uncertainty to be checked field (`str`): property associated with the quantity in question. value (`list`): list with the string of the value of the quantity and a dictionary of the uncertainty The rule's arguments are validated against this schema: {'type': 'boolean'} """ self._validate_isvalid_quantity(True, field, value) # This len check is necessary for reasons that aren't quite clear to me # Cerberus calls this validation method even when lists have only one element # and should therefore be validated only by isvalid_quantity if len(value) > 1 and value[1]["uncertainty-type"] != "relative": if value[1].get("uncertainty") is not None: self._validate_isvalid_quantity(True, field, [value[1]["uncertainty"]]) if value[1].get("upper-uncertainty") is not None: self._validate_isvalid_quantity(True, field, [value[1]["upper-uncertainty"]]) if value[1].get("lower-uncertainty") is not None: self._validate_isvalid_quantity(True, field, [value[1]["lower-uncertainty"]]) def _validate_isvalid_reference(self, isvalid_reference, field, value): """Checks valid reference metadata using DOI (if present). Args: isvalid_reference (`bool`): flag from schema indicating reference to be checked. field (`str`): 'reference' value (`dict`): dictionary of reference metadata. The rule's arguments are validated against this schema: {'type': 'boolean'} """ if "doi" in value: try: ref = crossref_api.works(ids=value["doi"])["message"] except (httpx.HTTPStatusError, habanero.RequestError): self._error(field, "DOI not found") return except httpx.ConnectError: warn("network not available, DOI not validated.") return # Assume that the reference returned by the DOI lookup always has a container-title ref_container = ref.get("container-title")[0] # TODO: Add other container types: value.get('journal') or value.get('report') or ... # note that there's a type field in the ref that is journal-article, proceedings-article container = value.get("journal") if container is None or container != ref_container: self._error(field, f"journal should be {ref_container}") # Assume that the reference returned by DOI lookup always has a year ref_year = ref.get("published-print") or ref.get("published-online") ref_year = ref_year["date-parts"][0][0] year = value.get("year") if year is None or year != ref_year: self._error(field, f"year should be {ref_year}") # Volume number might not be in the reference ref_volume = ref.get("volume") volume = value.get("volume") if ref_volume is None: if volume is not None: self._error( field, "Volume was specified in the YAML but is not present in the DOI reference.", ) else: if volume is None or int(volume) != int(ref_volume): self._error(field, f"volume should be {ref_volume}") # Pages might not be in the reference ref_pages = ref.get("page") pages = value.get("pages") if ref_pages is None: if pages is not None: self._error( field, "Pages were specified in the YAML but are not present in " "the DOI reference.", ) else: if pages is None or pages != ref_pages: self._error(field, f"pages should be {ref_pages}") # check that all authors present authors = value["authors"][:] author_names = [a["name"] for a in authors] for author in ref["author"]: # find using family name author_match = next( ( a for a in authors if compare_name(author["given"], author["family"], a["name"]) ), None, ) # error if missing author in given reference information if author_match is None: self._error( field, f"Missing author: {author['given']} {author['family']}", ) else: author_names.remove(author_match["name"]) # validate ORCID if given orcid = author.get("ORCID") if orcid: # Crossref may give ORCID as http://orcid.org/####-####-####-#### # so need to strip the leading URL orcid = orcid[orcid.rfind("/") + 1 :] if "ORCID" in author_match: if author_match["ORCID"] != orcid: self._error( field, f"{author_match['name']} ORCID does not match that in " f"reference. Reference: {orcid}. " f"Given: {author_match['ORCID']}", ) else: # ORCID not given, suggest adding it warn(f"ORCID {orcid} missing for {author_match['name']}") # check for extra names given if len(author_names) > 0: self._error(field, f"Extra author(s) given: {', '.join(author_names)}") def _validate_isvalid_orcid(self, isvalid_orcid, field, value): """Checks for valid ORCID if given. Args: isvalid_orcid (`bool`): flag from schema indicating ORCID to be checked. field (`str`): 'author' value (`dict`): dictionary of author metadata. The rule's arguments are validated against this schema: {'type': 'boolean'} """ if isvalid_orcid and "ORCID" in value: try: res = search_orcid(value["ORCID"]) except httpx.ConnectError: warn("network not available, ORCID not validated.") return except httpx.HTTPStatusError: self._error(field, f"ORCID incorrect or invalid for {value['name']}") return family_name = res["name"]["family-name"]["value"] given_name = res["name"]["given-names"]["value"] if not compare_name(given_name, family_name, value["name"]): self._error( field, f"Name and ORCID do not match. Name supplied: {value['name']}. " f"Name associated with ORCID: {given_name} {family_name}", ) def _validate_isvalid_composition(self, isvalid_composition, field, value): """Checks for valid specification of composition. Args: isvalid_composition (bool): flag from schema indicating composition to be checked. field (str): 'composition' value (dict): dictionary of composition The rule's arguments are validated against this schema: {'type': 'boolean'} """ sum_amount = 0.0 if value["kind"] in ["mass fraction", "mole fraction"]: low_lim = 0.0 up_lim = 1.0 total_amount = 1.0 elif value["kind"] in ["mole percent"]: low_lim = 0.0 up_lim = 100.0 total_amount = 100.0 else: self._error( field, 'composition kind must be "mole percent", "mass fraction", or "mole fraction"', ) return False for sp in value["species"]: amount = sp["amount"][0] sum_amount += amount # Check that amount within bounds, based on kind specified if amount < low_lim: self._error( field, f"Species {sp['species-name']} {value['kind']} " f"must be greater than {low_lim:.1f}", ) elif amount > up_lim: self._error( field, f"Species {sp['species-name']} {value['kind']} must be less than {up_lim:.1f}", ) # Make sure mole/mass fraction sum to 1 if not np.isclose(total_amount, sum_amount): self._error( field, f"Species {value['kind']}s do not sum to {total_amount:.1f}: {sum_amount:f}", )
# TODO: validate InChI, SMILES, or atomic-composition