Validation Package ontology-terms

Language: Python

ontology; controlled vocabulary; FAIR; annotation; quality control

v1.0.0 released on 07/07/2026

by Mohamed Abouzid

51 Downloads

Validates that ontology/CV annotations in ARC ISA tables are complete, well-formed, declared, and resolvable.


Install with arc-validate

 arc-validate package install ontology-terms --version 1.0.0

Include in a PLANTDataHUB CQC pipeline

validation_packages:
  - name: ontology-terms
    version: 1.0.0

Description

Checks the quality of ontology / controlled-vocabulary annotations on

Characteristic/Parameter/Factor/Component building blocks across all study and assay

tables. It verifies BOTH that used annotations are well-formed and structurally sound

(source/accession pairing, accession well-formedness) AND that, when ontology terms are

used, they resolve to real terms (online accession resolution and label match against

the TIB Terminology Service, with EBI OLS fallback). It does not require every value to

be annotated - free text is permitted, so coverage is only a warning. source_declared

and prefix_consistency are reported as non-critical warnings, since the Investigation

ONTOLOGY SOURCE REFERENCE section is not a settled convention and an ontology may host

terms whose id prefix differs from its source name.


Release notes


Browse code for v1.0.0 (python)
PACKAGE_METADATA = """
---
Name: ontology-terms
MajorVersion: 1
MinorVersion: 0
PatchVersion: 0
Publish: true
Summary: Validates that ontology/CV annotations in ARC ISA tables are complete, well-formed, declared, and resolvable.
Description: |
  Checks the quality of ontology / controlled-vocabulary annotations on
  Characteristic/Parameter/Factor/Component building blocks across all study and assay
  tables. It verifies BOTH that used annotations are well-formed and structurally sound
  (source/accession pairing, accession well-formedness) AND that, when ontology terms are
  used, they resolve to real terms (online accession resolution and label match against
  the TIB Terminology Service, with EBI OLS fallback). It does not require every value to
  be annotated - free text is permitted, so coverage is only a warning. source_declared
  and prefix_consistency are reported as non-critical warnings, since the Investigation
  ONTOLOGY SOURCE REFERENCE section is not a settled convention and an ontology may host
  terms whose id prefix differs from its source name.
Authors:
  - FullName: Mohamed Abouzid
    Email: m.abouzid@fz-juelich.de
    Affiliation: Forschungszentrum Juelich
    AffiliationLink: https://www.fz-juelich.de
Tags:
  - Name: ontology
  - Name: controlled vocabulary
  - Name: FAIR
  - Name: annotation
  - Name: quality control
---
"""

# /// script
# dependencies = [
#   "arctrl",
#   "requests",
# ]
# ///

NAME = "ontology-terms"
VERSION = "1.0.0"
TIB_API = "https://api.terminology.tib.eu/api/search"
OLS_API = "https://www.ebi.ac.uk/ols4/api/search"


import json
import os
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from xml.dom import minidom

_PREFIXED_RE = re.compile(r"^[A-Za-z][A-Za-z0-9.\-_]*:[A-Za-z0-9._\-]+$")
_IRI_RE = re.compile(r"^https?://\S+$")


@dataclass(frozen=True)
class TermOccurrence:
    name: str
    source: str       # Term Source REF, "" if absent
    accession: str    # Term Accession Number, "" if absent
    location: str     # human-readable: "<container>/<table>/<where>"


@dataclass
class Finding:
    check_id: str
    severity: str     # "error" | "warning"
    status: str       # "passed" | "failed" | "skipped"
    message: str
    location: str


def load_arc(arc_dir):
    from arctrl import ARC
    return ARC.load(arc_dir)


def declared_sources(arc):
    out = set()
    for ref in arc.OntologySourceReferences:
        name = getattr(ref, "Name", None)
        if name:
            out.add(str(name).strip())
    return out


def _to_occurrence(oa, location):
    return TermOccurrence(
        name=(oa.Name or "").strip(),
        source=(oa.TermSourceREF or "").strip(),
        accession=(oa.TermAccessionNumber or "").strip(),
        location=location,
    )


def collect_terms(arc):
    occs = []
    containers = list(arc.Studies) + list(arc.Assays)
    for container in containers:
        cid = getattr(container, "Identifier", "?")
        for table in container.Tables:
            tname = table.Name
            for col in table.Columns:
                header = col.Header
                hterm = None
                try:
                    hterm = header.TryGetTerm()
                except Exception:
                    hterm = None
                if hterm is not None and not hterm.is_empty():
                    occs.append(_to_occurrence(hterm, f"{cid}/{tname}/header[{header}]"))
                for i, cell in enumerate(col.Cells):
                    oa = None
                    try:
                        if cell.is_term:
                            oa = cell.AsTerm
                        elif cell.is_unitized:
                            _, oa = cell.AsUnitized
                    except Exception:
                        oa = None
                    if oa is not None and not oa.is_empty():
                        occs.append(_to_occurrence(oa, f"{cid}/{tname}/{header}/row{i + 1}"))
    return occs


def _is_iri(accession):
    return bool(_IRI_RE.match(accession))


def _pass(check_id, severity, occ):
    return Finding(check_id=check_id, severity=severity, status="passed",
                   message="ok", location=occ.location)


def check_pairing(occ):
    has_s, has_a = bool(occ.source), bool(occ.accession)
    if has_s == has_a:
        return _pass("pairing", "error", occ)
    missing = "Term Accession Number" if has_s else "Term Source REF"
    return Finding("pairing", "error", "failed",
                   f"'{occ.name}' has only one of source/accession; missing {missing}.",
                   occ.location)


def check_wellformed(occ):
    if not occ.accession:
        return Finding("wellformed", "error", "skipped", "no accession", occ.location)
    if _PREFIXED_RE.match(occ.accession) or _IRI_RE.match(occ.accession):
        return _pass("wellformed", "error", occ)
    return Finding("wellformed", "error", "failed",
                   f"Accession '{occ.accession}' is not a valid PREFIX:LOCAL id or IRI.",
                   occ.location)


def check_source_declared(occ, declared):
    # Non-critical (warning): the Investigation ONTOLOGY SOURCE REFERENCE section is not a
    # reliably-populated convention across the ARC community (it is functionally an ISA
    # artifact), so a missing declaration must not gate an ARC. See README friction note.
    if not occ.source:
        return Finding("source_declared", "warning", "skipped", "no source", occ.location)
    declared_lower = {d.lower() for d in declared}
    if occ.source.lower() in declared_lower:
        return _pass("source_declared", "warning", occ)
    return Finding("source_declared", "warning", "failed",
                   f"Term Source REF '{occ.source}' is not declared in the "
                   f"investigation ONTOLOGY SOURCE REFERENCE section.",
                   occ.location)


def check_prefix_consistency(occ):
    # Non-critical (warning): an ontology may legitimately host terms whose id prefix
    # differs from its Term Source REF (e.g. the MS / psi-ms CV hosts PEFF: ids, so
    # source=MS with accession=PEFF:0000002 is a real, valid term). A mismatch is a useful
    # signal for genuine typos, but must not gate an ARC.
    if not (occ.source and occ.accession):
        return Finding("prefix_consistency", "warning", "skipped", "needs both", occ.location)
    if _is_iri(occ.accession):
        return Finding("prefix_consistency", "warning", "skipped", "iri accession", occ.location)
    prefix = occ.accession.split(":", 1)[0]
    if prefix.lower() == occ.source.lower():
        return _pass("prefix_consistency", "warning", occ)
    return Finding("prefix_consistency", "warning", "failed",
                   f"Accession prefix '{prefix}' does not match Term Source REF '{occ.source}' "
                   f"(may be legitimate for ontologies that host foreign-prefix terms).",
                   occ.location)


def check_coverage(occ):
    if occ.name and not occ.source and not occ.accession:
        return Finding("coverage", "warning", "failed",
                       f"'{occ.name}' is free text with no ontology annotation.",
                       occ.location)
    return _pass("coverage", "warning", occ)


def run_offline(occurrences, declared):
    findings = []
    for occ in occurrences:
        findings.append(check_pairing(occ))
        findings.append(check_wellformed(occ))
        findings.append(check_source_declared(occ, declared))
        findings.append(check_prefix_consistency(occ))
        findings.append(check_coverage(occ))
    return findings


def _query_id(accession):
    """Convert an OBO-style IRI to a prefixed obo_id; pass prefixed ids through unchanged."""
    if accession.startswith("http"):
        segment = accession.rstrip("/").rsplit("/", 1)[-1]
        if "_" in segment:
            prefix, local = segment.rsplit("_", 1)
            return f"{prefix}:{local}"
        return segment
    return accession


def _try_search(api, query_id, session):
    """Return (doc_or_None, responded) where responded is False only on transient error."""
    for _attempt in range(2):
        try:
            r = session.get(api,
                            params={"q": query_id, "queryFields": "obo_id",
                                    "exact": "true", "rows": 1},
                            timeout=5)
            r.raise_for_status()
            docs = r.json().get("response", {}).get("docs", [])
            return (docs[0] if docs else None), True
        except Exception:
            continue
    return None, False


def resolve(accession, session, cache):
    if accession in cache:
        return cache[accession]
    query_id = _query_id(accession)
    found_doc = None
    any_response = False
    for api in (TIB_API, OLS_API):
        doc, responded = _try_search(api, query_id, session)
        any_response = any_response or responded
        if doc is not None:
            found_doc = doc
            break
    if found_doc is not None:
        result = {"found": True, "label": found_doc.get("label")}
    elif any_response:
        result = {"found": False, "label": None}   # a service answered; term genuinely absent
    else:
        result = None                              # every service errored -> skip
    cache[accession] = result
    return result


def check_resolves(occ, resolved):
    if resolved is None:
        return Finding("resolves", "error", "skipped",
                       "terminology service unreachable", occ.location)
    if resolved["found"]:
        return _pass("resolves", "error", occ)
    return Finding("resolves", "error", "failed",
                   f"Accession '{occ.accession}' does not resolve in the terminology service.",
                   occ.location)


def check_label(occ, resolved):
    if resolved is None or not resolved.get("found"):
        return Finding("label_match", "warning", "skipped",
                       "not resolved", occ.location)
    canonical = (resolved.get("label") or "").strip().casefold()
    given = (occ.name or "").strip().casefold()
    if not canonical or canonical == given:
        return _pass("label_match", "warning", occ)
    return Finding("label_match", "warning", "failed",
                   f"Annotation name '{occ.name}' differs from canonical label "
                   f"'{resolved.get('label')}' for {occ.accession}.",
                   occ.location)


def run_online(occurrences, session):
    findings = []
    cache = {}
    for occ in occurrences:
        if not occ.accession:
            continue
        resolved = resolve(occ.accession, session, cache)
        findings.append(check_resolves(occ, resolved))
        findings.append(check_label(occ, resolved))
    return findings


def _counts(findings):
    passed = sum(1 for f in findings if f.status == "passed")
    skipped = sum(1 for f in findings if f.status == "skipped")
    failed_errors = sum(1 for f in findings if f.status == "failed" and f.severity == "error")
    warnings = sum(1 for f in findings if f.status == "failed" and f.severity == "warning")
    return passed, failed_errors, warnings, skipped


def write_junit(findings, path):
    suites = ET.Element("testsuites", name=NAME)
    for severity, suite_name in (("error", "errors"), ("warning", "warnings")):
        group = [f for f in findings if f.severity == severity]
        suite = ET.SubElement(suites, "testsuite", name=suite_name,
                              tests=str(len(group)),
                              failures=str(sum(1 for f in group if f.status == "failed")),
                              skipped=str(sum(1 for f in group if f.status == "skipped")))
        for f in group:
            case = ET.SubElement(suite, "testcase", classname=f.check_id, name=f.location)
            if f.status == "failed":
                fail = ET.SubElement(case, "failure", type=f.severity, message=f.message)
                fail.text = f.message
            elif f.status == "skipped":
                sk = ET.SubElement(case, "skipped", message=f.message)
                sk.text = f.message
    xml = minidom.parseString(ET.tostring(suites)).toprettyxml(indent="  ")
    Path(path).write_text(xml, encoding="utf-8")


def write_summary(findings, path):
    passed, failed_errors, warnings, skipped = _counts(findings)
    data = {
        "package": NAME,
        "version": VERSION,
        "total": len(findings),
        "passed": passed,
        "failed_errors": failed_errors,
        "warnings": warnings,
        "skipped": skipped,
        "critical": failed_errors > 0,
    }
    Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8")


def write_badge(findings, path):
    _, failed_errors, warnings, _ = _counts(findings)
    if failed_errors:
        color, status = "#e05d44", f"{failed_errors} errors"
    elif warnings:
        color, status = "#dfb317", f"{warnings} warnings"
    else:
        color, status = "#4c1", "passed"
    label = "ontology-terms"
    svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="220" height="20" role="img" aria-label="{label}: {status}">
  <rect width="130" height="20" fill="#555"/>
  <rect x="130" width="90" height="20" fill="{color}"/>
  <g fill="#fff" font-family="Verdana,Geneva,sans-serif" font-size="11">
    <text x="8" y="14">{label}</text>
    <text x="138" y="14">{status}</text>
  </g>
</svg>'''
    Path(path).write_text(svg, encoding="utf-8")


def _offline_forced():
    return os.environ.get("ONTOLOGY_TERMS_OFFLINE", "").strip() in ("1", "true", "True")


def main(argv=None):
    import argparse
    parser = argparse.ArgumentParser(
        description="Validate ontology term annotations in an ARC.")
    parser.add_argument("-i", "--input", type=Path, default=None,
                        help="Path to the ARC directory (default: $ARC_PATH or cwd)")
    parser.add_argument("-o", "--output", type=Path, default=None,
                        help="Directory to write results into (default: the input directory)")
    args, _ = parser.parse_known_args(argv)

    arc_dir = args.input or Path(os.environ.get("ARC_PATH") or os.getcwd())
    out_root = args.output or arc_dir
    out_dir = Path(out_root) / ".arc-validate-results" / f"{NAME}@{VERSION}"
    out_dir.mkdir(parents=True, exist_ok=True)

    try:
        arc = load_arc(str(arc_dir))
    except Exception as e:
        # The input is not a readable ARC: report it as a finding, never crash the
        # runner. (The registry CI smoke-tests every package against a non-ARC dir and
        # only requires a clean exit.)
        findings = [Finding("arc_loadable", "error", "failed",
                            f"Could not load an ARC at '{arc_dir}': {e}", str(arc_dir))]
        write_junit(findings, out_dir / "validation_report.xml")
        write_summary(findings, out_dir / "validation_summary.json")
        write_badge(findings, out_dir / "badge.svg")
        return 0

    declared = declared_sources(arc)
    occurrences = collect_terms(arc)

    findings = run_offline(occurrences, declared)
    if _offline_forced():
        for occ in occurrences:
            if occ.accession:
                findings.append(Finding("resolves", "error", "skipped", "offline mode", occ.location))
                findings.append(Finding("label_match", "warning", "skipped", "offline mode", occ.location))
    else:
        import requests
        session = requests.Session()
        findings += run_online(occurrences, session)

    write_junit(findings, out_dir / "validation_report.xml")
    write_summary(findings, out_dir / "validation_summary.json")
    write_badge(findings, out_dir / "badge.svg")
    return 0


if __name__ == "__main__":
    import sys
    sys.exit(main(sys.argv[1:]))

Available versions
Version Released on
1.0.0 07/07/2026