Source code for seamm_thermochemistry.store_tools

# -*- coding: utf-8 -*-

"""Tools for the two-layer fragment store (see :mod:`.layered_store`):

* :func:`migrate_store` brings an older store up to date (the ``meta`` table, the
  model chemistries stored bare with their program), after a dated backup;
* :func:`export_local` writes the local rows that the official store lacks, or
  has with other values, to a contribution file for the next official version;
* :func:`merge_stores` adds the rows of other stores (copies from other
  installations, contributions, campaign stores) to a store;
* :func:`build_official` builds a new official store from the previous one and
  contributions;
* :func:`layer_conflicts` lists the keys whose values differ between the local
  and the official store.

All writes are insert-or-ignore with provenance: a row whose key is already
there with values differing by more than 0.1 kJ/mol is logged, never
overwritten, and superseded values travel as history.
"""

import datetime
import hashlib
import json
import logging
from pathlib import Path
import shutil
import sqlite3

from .fragment_store import FragmentRecord, FragmentStore
from .geometry_store import (
    _ENERGY_COLUMNS,
    _GEOMETRY_COLUMNS,
    _GEOMETRY_KEY,
    DUPLICATE_TOLERANCE,
    SCHEMA_VERSION,
    EnergyRecord,
    GeometryRecord,
    _decode,
    is_unusable,
    split_model_chemistry,
)

logger = logging.getLogger(__name__)

__all__ = [
    "ground_state_multiplicity",
    "sanity_check",
    "load_exclusions",
    "job_id_only",
    "migrate_store",
    "export_local",
    "merge_stores",
    "build_official",
    "layer_conflicts",
]

#: The key columns of each table
KEYS = {
    "fragment": (
        "species",
        "charge",
        "multiplicity",
        "energy_fingerprint",
        "frequency_fingerprint",
    ),
    "geometry": _GEOMETRY_COLUMNS[:_GEOMETRY_KEY],
    "energy": _ENERGY_COLUMNS[:2],
}
VALUES = {
    "fragment": ("E", "ZPE", "H"),
    "geometry": ("E", "ZPE", "H"),
    "energy": ("E",),
}


def _now():
    return datetime.datetime.now().isoformat(timespec="seconds")


def _package_version():
    from . import __version__

    return __version__


def _sha256(path):
    h = hashlib.sha256()
    with open(path, "rb") as fd:
        for chunk in iter(lambda: fd.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def _rows(store, table):
    """All rows of a table as records (JSON columns decoded); [] if none."""
    try:
        db = store._connect()
    except FileNotFoundError:
        return []
    try:
        rows = db.execute(f"SELECT * FROM {table} ORDER BY id").fetchall()
    except sqlite3.OperationalError:  # a store from before the table existed
        return []
    if table == "fragment":
        out = []
        for r in rows:
            record = FragmentRecord(dict(r))
            record["verified"] = bool(record["verified"])
            out.append(record)
        return out
    return [
        _decode(r, GeometryRecord if table == "geometry" else EnergyRecord)
        for r in rows
    ]


def _row_by_key(store, table, record):
    """The row of `table` with `record`'s key, or None."""
    columns = KEYS[table]
    where = " AND ".join(f"{c} = ?" for c in columns)
    try:
        row = (
            store._connect()
            .execute(
                f"SELECT * FROM {table} WHERE {where}", [record[c] for c in columns]
            )
            .fetchone()
        )
    except (FileNotFoundError, sqlite3.OperationalError):
        return None
    if row is None:
        return None
    return dict(row)


def _difference(a, b, fields):
    d = 0.0
    for f in fields:
        x, y = a.get(f), b.get(f)
        if x is None or y is None:
            continue
        d = max(d, abs(float(x) - float(y)))
    return d


def _put(target, table, record):
    """Insert-or-ignore one row into `target`; returns the outcome."""
    record = dict(record)
    record.pop("id", None)
    if table == "fragment":
        return target.put(FragmentRecord(record))
    if table == "geometry":
        return target.put_geometry(GeometryRecord(record))[0]
    record["geometry"] = None  # relinked to the target's geometry row
    return target.put_energy(EnergyRecord(record))[0]


def _copy_history(source, target, table, record, label):
    """Copy the superseded values of `record`'s row in `source` to the same key's
    row in `target`, as history (the date and source in the reason)."""
    old_values = source.superseded_values(table, record["id"])
    if not old_values:
        return 0
    row = _row_by_key(target, table, record)
    if row is None:
        return 0
    db = target._writer()
    n = 0
    for old in old_values:
        reason = f"{old.get('reason')} (superseded {old.get('date')} in {label})"
        if table == "fragment":
            db.execute(
                "INSERT INTO superseded (fragment, E, ZPE, H, superseded_on, reason) "
                "VALUES (?, ?, ?, ?, ?, ?)",
                (row["id"], old.get("E"), old.get("ZPE"), old.get("H"), _now(), reason),
            )
        else:
            target._history(
                db, table, row["id"], "superseded", old, None, reason=reason
            )
        n += 1
    db.commit()
    return n


# -- migrate -------------------------------------------------------------------


[docs] def migrate_store(path=None, *, backup=True): """Bring a store up to date: the ``meta`` and ``writeback_log`` tables (schema 4), and the model chemistries stored bare with the program filled in (``ORCA:CMP@R2SCAN-3C`` -> ``CMP@R2SCAN-3C`` + ``ORCA``). A dated copy of the file is kept first. Running it again changes nothing (and makes no copy). Keys do not change: verified rows are keyed by fingerprints, and the keys of unverified rows keep the prefixed spelling. Returns {"backup": path or None, "changed": {table: n}, "schema": (old, new)}. """ store = FragmentStore(path) if not store.path.exists(): raise FileNotFoundError(f"No fragment store at {store.path}") old_schema = store.schema_version() plan = {} for table, columns in ( ("fragment", ("model_chemistry", "frequency_model_chemistry")), ("geometry", ("model_chemistry",)), ("energy", ("model_chemistry",)), ): try: rows = ( store._connect() .execute(f"SELECT id, program, {', '.join(columns)} FROM {table}") .fetchall() ) except sqlite3.OperationalError: continue for row in rows: program = row["program"] new = {} for c in columns: model, prefix = split_model_chemistry(row[c], program) if model != row[c]: new[c] = model program = program or prefix if program != row["program"]: new["program"] = program if new: plan.setdefault(table, []).append((row["id"], new)) needs_meta = old_schema < SCHEMA_VERSION or "layer" not in store.meta() store.close() result = {"backup": None, "changed": {}, "schema": (old_schema, old_schema)} if not plan and not needs_meta: return result if backup: stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") copy = store.path.with_name(f"{store.path.name}.pre-migrate-{stamp}") shutil.copy2(store.path, copy) result["backup"] = copy with FragmentStore( store.path, create=True ) as writer: # adds meta, writeback_log: schema 4 db = writer._connect(create=True) for table, changes in plan.items(): for row_id, new in changes: assignments = ", ".join(f"{c} = ?" for c in new) db.execute( f"UPDATE {table} SET {assignments} WHERE id = ?", list(new.values()) + [row_id], ) result["changed"][table] = len(changes) db.commit() result["schema"] = (old_schema, writer.schema_version()) return result
# -- merge, export, official -----------------------------------------------------
[docs] def merge_stores(target, sources, *, label=None, allow_official=False): """Add the rows of `sources` (paths or stores) to `target` (a path or a writable store): insert-or-ignore, differing duplicates logged, superseded values copied as history. An official target is refused unless `allow_official` (for :func:`build_official`). Returns {table: {outcome: n}} plus "history": n. """ own = not isinstance(target, FragmentStore) store = FragmentStore(target, create=True) if own else target try: if store.layer == "official" and not allow_official: raise ValueError( f"{store.path} is an official store; merge into a local store, or " "build a new official one." ) counts = {"fragment": {}, "geometry": {}, "energy": {}, "history": 0} for source in sources: src = source if isinstance(source, FragmentStore) else FragmentStore(source) name = label or str(src.path) try: for table in ("fragment", "geometry", "energy"): for record in _rows(src, table): outcome = _put(store, table, record) tally = counts[table] tally[outcome] = tally.get(outcome, 0) + 1 counts["history"] += _copy_history( src, store, table, record, name ) store.set_meta(**{f"merged {_now()}": name}) finally: if src is not source: src.close() return counts finally: if own: store.close()
[docs] def export_local(out, local=None, official=None, *, label=None): """Write the local store's rows that the official store lacks, or has with values differing by more than 0.1 kJ/mol (flagged in the history as "differs from official", with the official values), to the contribution file `out`, with their provenance and superseded values. `label` is free text recorded in the file; nothing about the host or user is added. Returns {table: n rows exported}, plus "differing": n. """ from .fragment_store import official_store_path out = Path(out).expanduser() if out.exists(): raise FileExistsError(f"{out} exists; export to a new file.") local_store = FragmentStore(local) official_path = official_store_path(official) official_store = ( FragmentStore(official_path, immutable=True) if official_path.exists() else None ) counts = {"fragment": 0, "geometry": 0, "energy": 0, "differing": 0} try: with FragmentStore(out, create=True, layer="contribution") as target: target.set_meta( label=label or "", exported=_now(), official_version=( official_store.meta().get("version") if official_store else "" ), ) for table in ("fragment", "geometry", "energy"): for record in _rows(local_store, table): if is_unusable(record.get("model_chemistry")): continue # quarantined rows are not contributed theirs = ( _row_by_key(official_store, table, record) if official_store else None ) if theirs is not None and ( _difference(record, theirs, VALUES[table]) <= DUPLICATE_TOLERANCE ): continue _put(target, table, record) counts[table] += 1 _copy_history(local_store, target, table, record, "the local store") if theirs is not None: counts["differing"] += 1 row = _row_by_key(target, table, record) db = target._writer() target._history( db, table, row["id"], "differs from official", {k: theirs.get(k) for k in VALUES[table]}, {k: record.get(k) for k in VALUES[table]}, difference=_difference(record, theirs, VALUES[table]), ) db.commit() finally: local_store.close() if official_store is not None: official_store.close() return counts
[docs] def ground_state_multiplicity(species, charge=0, atct=None): """(multiplicity, source) of a species' ground state where it is known, or (None, None). Singlet for the species of :data:`.cbh.SINGLET_GROUND_STATES` (silylenes, dihalocarbenes and BH, which a SMILES writes as triplets), triplet for those of :data:`.cbh.TRIPLET_GROUND_STATES` (O2, S2, SO, OCCO, cyclic C3, which a SMILES writes closed-shell); else from ATcT when it lists the species in labelled spin states: the plain entry is the ground state, and the labelled entry with the same enthalpy of formation names its multiplicity (CH2: plain = triplet; SiH2: plain = singlet). Unlike :func:`.cbh.ground_state_multiplicity` this does not fall back on the radical count, which is not knowledge of the ground state (O2 would be a singlet). """ from .atct import canonical_smiles from .cbh import SINGLET_GROUND_STATES try: from .cbh import TRIPLET_GROUND_STATES except ImportError: # pragma: no cover TRIPLET_GROUND_STATES = () try: can = canonical_smiles(species, stereo=False) or species except Exception: can = species for states, multiplicity, name in ( (SINGLET_GROUND_STATES, 1, "singlet ground state (cbh.SINGLET_GROUND_STATES)"), (TRIPLET_GROUND_STATES, 3, "triplet ground state (cbh.TRIPLET_GROUND_STATES)"), ): for smiles in states: try: if canonical_smiles(smiles, stereo=False) == can: return multiplicity, name except Exception: continue if atct is None: return None, None try: candidates = [e for e in atct.candidates(can, charge=charge) if e.is_gas] except Exception: return None, None plain = [e for e in candidates if e.is_plain] labelled = [e for e in candidates if e.multiplicity is not None] if len(plain) != 1 or not labelled: return None, None value = plain[0].get("dfH0_298K") for e in labelled: if ( value is not None and e.get("dfH0_298K") is not None and (abs(float(e["dfH0_298K"]) - float(value)) < 1e-6) ): return e.multiplicity, f"ATcT {atct.version} ({e['id']})" return None, None
[docs] def sanity_check(store, *, atct=None): """The problems that keep rows out of an official store, by species state: {(species, charge, multiplicity): [reason, ...]}. * a geometry with an imaginary mode beyond 20i (a saddle point); * a geometry whose bonds, perceived again from its coordinates, are not its species', whose charge or multiplicity do not fit its SMILES, or with atoms too close together (:func:`.fragment_store.check_structure`); * a multiplicity that is not the species' ground state, where that is known (:func:`ground_state_multiplicity`). Ions and open-shell species are checked like the rest, not excluded. """ from .fragment_store import check_structure from .geometry_store import IMAGINARY_TOLERANCE problems = {} def add(key, reason): problems.setdefault(key, []) if reason not in problems[key]: problems[key].append(reason) states = set() for g in _rows(store, "geometry"): key = (g["species"], g["charge"], g["multiplicity"]) states.add(key) label = f"conformer '{g['conformer']}'" if g["conformer"] else "geometry" if g.get("n_imaginary"): add( key, f"{label}: {g['n_imaginary']} imaginary mode(s), the largest " f"{g['max_imaginary']:.0f}i cm^-1 (> {IMAGINARY_TOLERANCE:.0f}i)", ) problem = check_structure( g["symbols"], g["coordinates"], g["species"], g["charge"], g["multiplicity"] ) if problem: add(key, f"{label}: {problem}") for f in _rows(store, "fragment"): states.add((f["species"], f["charge"], f["multiplicity"])) for species, charge, multiplicity in sorted(states, key=str): ground, source = ground_state_multiplicity(species, charge, atct) if ground is not None and ground != multiplicity: add( (species, charge, multiplicity), f"multiplicity {multiplicity} is not the ground state's ({ground}, " f"{source})", ) return problems
def _select(store, frequency_fingerprints=None, energy_fingerprints=None): """Delete the rows not reachable at the given levels: geometry rows of other frequency fingerprints, energy rows of other energy fingerprints or not on a remaining geometry, and fragment rows of neither. Returns {table: n deleted}.""" db = store._connect(create=True) deleted = {} freq = list(frequency_fingerprints or []) energy = list(energy_fingerprints or []) if freq: marks = ", ".join("?" * len(freq)) deleted["geometry"] = db.execute( f"DELETE FROM geometry WHERE frequency_fingerprint NOT IN ({marks})", freq ).rowcount sql = ( "DELETE FROM energy WHERE geometry_hash NOT IN " "(SELECT geometry_hash FROM geometry)" ) args = [] if energy: marks = ", ".join("?" * len(energy)) sql += f" OR energy_fingerprint NOT IN ({marks})" args = energy deleted["energy"] = db.execute(sql, args).rowcount if freq or energy: allowed_e = freq + energy marks_e = ", ".join("?" * len(allowed_e)) sql = f"DELETE FROM fragment WHERE energy_fingerprint NOT IN ({marks_e})" args = list(allowed_e) if freq: marks_f = ", ".join("?" * len(freq)) sql += ( f" OR (frequency_fingerprint != '' AND frequency_fingerprint NOT IN " f"({marks_f}))" ) args += freq deleted["fragment"] = db.execute(sql, args).rowcount db.commit() return deleted
[docs] def job_id_only(job): """A job's provenance without its directory: "5689:/D3/.../Job_005689" gives "5689"; a directory alone gives its last component ("Job_005689"); anything else is returned as it is.""" if job is None: return None text = str(job) head, sep, tail = text.partition(":") if sep and head.strip().isdigit() and ("/" in tail or "\\" in tail): return head.strip() if "/" in text or "\\" in text: return text.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1] return text
def _reduce_job_paths(store): """Replace every job's provenance in the store by :func:`job_id_only`: the job column of the rows, the superseded and duplicate logs, and the old and new values in the history. Returns the number of values changed.""" db = store._connect(create=True) n = 0 for table in ("fragment", "geometry", "energy", "superseded", "duplicate_log"): try: rows = db.execute(f"SELECT id, job FROM {table}").fetchall() except sqlite3.OperationalError: continue for row_id, job in rows: short = job_id_only(job) if short != job: db.execute(f"UPDATE {table} SET job = ? WHERE id = ?", (short, row_id)) n += 1 try: history = db.execute("SELECT id, old, new FROM row_history").fetchall() except sqlite3.OperationalError: history = [] for row_id, old, new in history: values = [] for text in (old, new): try: data = json.loads(text) if text else None except ValueError: data = None if isinstance(data, dict) and data.get("job") is not None: short = job_id_only(data["job"]) if short != data["job"]: data["job"] = short n += 1 text = json.dumps(data, default=str) values.append(text) db.execute( "UPDATE row_history SET old = ?, new = ? WHERE id = ?", values + [row_id], ) try: rows = db.execute("SELECT id, job FROM writeback_log").fetchall() except sqlite3.OperationalError: rows = [] for row_id, job in rows: short = job_id_only(job) if short != job: db.execute("UPDATE writeback_log SET job = ? WHERE id = ?", (short, row_id)) n += 1 db.commit() return n def _drop_unusable(store): """Delete the rows quarantined by hand (model chemistry "UNUSABLE: <reason>") and return [{table, species or geometry, reason}] for the report.""" db = store._connect(create=True) dropped = [] for table, who in ( ("geometry", "species || ' ' || conformer"), ("energy", "geometry_hash"), ("fragment", "species"), ): rows = db.execute( f"SELECT id, {who}, model_chemistry FROM {table} " "WHERE UPPER(TRIM(model_chemistry)) LIKE 'UNUSABLE:%'" ).fetchall() for row in rows: dropped.append( { "table": table, "row": row[1], "reason": row[2].split(":", 1)[1].strip(), } ) db.execute( f"DELETE FROM {table} WHERE UPPER(TRIM(model_chemistry)) LIKE 'UNUSABLE:%'" ) db.commit() return dropped def _exclude(store, excluded): """Delete the excluded rows: a whole species state, or one conformer of it (the energies on a deleted geometry, and the fragment-table records made from it, go too). `excluded` is {(species, charge, multiplicity, conformer or None): reason}; returns {key: rows deleted}.""" from .fragment_store import geometry_hash as fragment_geometry_hash db = store._connect(create=True) removed = {} for key in excluded: species, charge, multiplicity, conformer = key where = "species = ? AND charge = ? AND multiplicity = ?" args = [species, int(charge), int(multiplicity)] if conformer is not None: where_g = where + " AND conformer = ?" args_g = args + [conformer] else: where_g, args_g = where, args geometries = db.execute( f"SELECT geometry_hash, symbols, coordinates FROM geometry WHERE {where_g}", args_g, ).fetchall() n = db.execute(f"DELETE FROM geometry WHERE {where_g}", args_g).rowcount if conformer is None: n += db.execute(f"DELETE FROM fragment WHERE {where}", args).rowcount else: # The fragment-table records made from this conformer's geometry for g in geometries: old_hash = fragment_geometry_hash( json.loads(g["symbols"]), json.loads(g["coordinates"]) ) n += db.execute( f"DELETE FROM fragment WHERE {where} AND geometry_hash = ?", args + [old_hash], ).rowcount for g in geometries: still = db.execute( "SELECT COUNT(*) FROM geometry WHERE geometry_hash = ?", (g["geometry_hash"],), ).fetchone()[0] if not still: n += db.execute( "DELETE FROM energy WHERE geometry_hash = ?", (g["geometry_hash"],) ).rowcount removed[key] = n db.commit() return removed
[docs] def load_exclusions(path): """Exclusions from a CSV file with the columns species, charge, multiplicity, reason and, optionally, conformer (to leave out one conformer instead of the whole species state): {(canonical species, charge, multiplicity, conformer or None): reason}.""" import csv from .atct import canonical_smiles out = {} with open(Path(path).expanduser(), newline="") as fd: for row in csv.DictReader(fd): species = (row.get("species") or "").strip() reason = (row.get("reason") or "").strip() if not species: continue if not reason: raise ValueError(f"{path}: excluding {species} needs a reason") try: species = canonical_smiles(species) or species except Exception: pass key = ( species, int(row.get("charge") or 0), int(row.get("multiplicity") or 1), (row.get("conformer") or "").strip() or None, ) out[key] = reason return out
[docs] def build_official( out, version, sources, *, previous=None, source=None, frequency_fingerprints=None, energy_fingerprints=None, exclude=None, atct=None, check=True, job_ids_only=True, ): """Build an official store: the previous official one (if given) plus the contributions `sources`, marked official with `version` (and `source`, e.g. its Zenodo DOI), compacted, with a ``.sha256`` file and a ``.report.json`` beside it. `frequency_fingerprints` and `energy_fingerprints`, when given, keep only the rows reachable at those levels (e.g. the presets' r2SCAN-3c geometries and the revDSD energies on them). `exclude` ({(species, charge, multiplicity): reason}, or with a fourth element, a conformer label, to leave out one conformer; see :func:`load_exclusions`) leaves rows out, with the reason in the report. With `job_ids_only` (the default: an official store is published) the provenance keeps each job's id and drops its directory (see :func:`job_id_only`), in every row and in the history; the full paths stay in the stores the rows came from. Nothing is written when a contribution's values differ from a row already there (resolve it with ``supersede`` and a reason first), or when the sanity pass (:func:`sanity_check`; `atct` for the multiplicities) finds a problem in a species state that is not excluded. Returns (path, sha256, counts). """ out = Path(out).expanduser() if out.exists(): raise FileExistsError(f"{out} exists; build to a new file.") # Older three-element keys exclude the whole species state exclude = { (tuple(k) + (None,) if len(k) == 3 else tuple(k)): v for k, v in (exclude or {}).items() } if previous is not None: with FragmentStore(previous, immutable=True) as old: if old.layer != "official": raise ValueError(f"{previous} is not an official store.") shutil.copy2(Path(previous).expanduser(), out) # Rows quarantined by hand ("UNUSABLE: ...") are dropped first and listed report = { "version": version, "built": _now(), "previous": str(previous) if previous else None, "sources": [str(s) for s in sources], "frequency fingerprints": list(frequency_fingerprints or []), "energy fingerprints": list(energy_fingerprints or []), "atct": getattr(atct, "version", None), "generator": f"seamm-thermochemistry {_package_version()}", } try: with FragmentStore(out, create=True, layer="official") as store: counts = merge_stores(store, sources, allow_official=True) logged = sum(counts[t].get("logged", 0) for t in VALUES) if logged: conflicts = [ f"{h['table_name']} row {h['row']}: {h['difference']:.2f} kJ/mol" for h in store.history() if h["kind"] == "duplicate" ] conflicts += [ f"fragment {d['species']}: {d['difference']:.2f} kJ/mol" for d in store.duplicates() ] raise ValueError( f"{logged} contributed values differ from those already in the " "store; resolve them with 'supersede' first:\n " + "\n ".join(conflicts[:50]) ) report["quarantined"] = _drop_unusable(store) report["deselected"] = _select( store, frequency_fingerprints, energy_fingerprints ) removed = _exclude(store, exclude) report["job paths"] = "kept" if not job_ids_only else "reduced to job ids" if job_ids_only: _reduce_job_paths(store) report["excluded"] = [ { "species": k[0], "charge": k[1], "multiplicity": k[2], "conformer": k[3], "reason": reason, "rows": removed.get(k, 0), } for k, reason in exclude.items() ] if check: problems = sanity_check(store, atct=atct) if problems: lines = [ f"{s} (charge {q}, multiplicity {m}): " + "; ".join(r) for (s, q, m), r in sorted(problems.items(), key=str) ] raise ValueError( f"{len(problems)} species states fail the sanity checks; fix " "them, or exclude them with a reason (--exclude):\n " + "\n ".join(lines[:100]) ) db = store._connect(create=True) report["rows"] = { t: db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0] for t in ("fragment", "geometry", "energy") } report["species"] = db.execute( "SELECT COUNT(*) FROM (SELECT DISTINCT species, charge, " "multiplicity FROM geometry)" ).fetchone()[0] report["checked"] = bool(check) store.set_meta( layer="official", version=version, created=_now(), source=source or "", schema=SCHEMA_VERSION, ) db.execute("VACUUM") except Exception: out.unlink(missing_ok=True) raise digest = _sha256(out) report["sha256"] = digest out.with_name(out.name + ".sha256").write_text(f"{digest} {out.name}\n") out.with_name(out.name + ".report.json").write_text(json.dumps(report, indent=1)) return out, digest, counts
[docs] def layer_conflicts(local=None, official=None): """The keys in both the local and the official store whose values differ by more than 0.1 kJ/mol: [{"table", "key", "local", "official", "difference", "superseded"}], "superseded" when the local value is one the official store superseded (the official value is then used).""" from .fragment_store import official_store_path official_path = official_store_path(official) if not official_path.exists(): return [] out = [] with ( FragmentStore(local) as mine, FragmentStore(official_path, immutable=True) as theirs, ): for table in ("fragment", "geometry", "energy"): for record in _rows(mine, table): other = _row_by_key(theirs, table, record) if other is None: continue d = _difference(record, other, VALUES[table]) if d <= DUPLICATE_TOLERANCE: continue superseded = any( _difference(record, old, VALUES[table]) <= DUPLICATE_TOLERANCE for old in theirs.superseded_values(table, other["id"]) ) out.append( { "table": table, "key": tuple(record[c] for c in KEYS[table]), "local": {k: record.get(k) for k in VALUES[table]}, "official": {k: other.get(k) for k in VALUES[table]}, "difference": d, "superseded": superseded, "job": record.get("job"), } ) return out