"""Task 04 - Convert the CSV corpus to JSON-LD. Reads ./out/csv/ (validated file-by-file against MANIFEST.csv) and writes two ontology-annotated variants: - FULL: one compact JSON-LD file per coupon under ./out/json/full/, bulk points embedded. Like spec 07's RDF modeling, bulk points (cycles, loop points, map points) carry no @type - the containing predicate types them; this keeps FULL lean, so its size lands below the 0.7-1.2 GB the spec sketched for a fully typed serialization. - HYBRID: single ./out/json/hybrid/dataset.jsonld holding every metadata entity; every per-point array (cycles, loops, xrf, profilometry, indents) is replaced by a {sourceFile, rows, sha256} reference into the CSV tree. Appends both sizes to ./out/bench/storage_sizes.csv and writes ./out/.done/04.ok. Spec: docs/specs/04_convert_json.md. """ from __future__ import annotations import argparse import hashlib import json import logging import re import shutil import sys from pathlib import Path import yaml from rdflib import Graph from common.storage_sizes import update_storage_sizes logger = logging.getLogger(__name__) REPO_ROOT = Path(__file__).resolve().parent TASK_ID = "04" DEPENDS_ON = ["01", "03"] VOCAB = "https://sandia.gov/ontology/tribology#" ID_BASE = "https://sandia.gov/ontology/tribology/id/" CONTEXT = { "@vocab": VOCAB, "tribo": VOCAB, "qudt": "http://qudt.org/schema/qudt/", "unit": "http://qudt.org/vocab/unit/", "prov": "http://www.w3.org/ns/prov#", "batch": ID_BASE + "batch/", "wafer": ID_BASE + "wafer/", "coupon": ID_BASE + "coupon/", "track": ID_BASE + "track/", "run": ID_BASE + "run/", "instrument": ID_BASE + "instrument/", "partOf": {"@id": "tribo:partOf", "@type": "@id"}, "cutFrom": {"@id": "tribo:cutFrom", "@type": "@id"}, "performedOn": {"@id": "tribo:performedOn", "@type": "@id"}, "performedBy": {"@id": "tribo:performedBy", "@type": "@id"}, "duringRun": {"@id": "tribo:duringRun", "@type": "@id"}, "started_at": {"@id": "prov:startedAtTime"}, } INT_RE = re.compile(r"^-?[0-9]+$") FLOAT_RE = re.compile(r"^-?([0-9]+\.[0-9]*|\.[0-9]+|[0-9]+)([eE][+-]?[0-9]+)?$") MEASUREMENTS = [ # (csv file, json key, @type, instrument id, points key) ("xrf_map.csv", "xrf", "CompositionMeasurement", "m4_tornado", "points"), ("profilometry.csv", "profilometry", "ProfilometryMeasurement", "profilometer", "points"), ("nanoindentation.csv", "nanoindentation", "NanoindentationMeasurement", "ti980", "indents"), ] class ValidationError(RuntimeError): pass def coerce(s: str) -> int | float | str: if INT_RE.match(s): return int(s) if FLOAT_RE.match(s): return float(s) return s class JsonConverter: def __init__(self, cfg: dict, csv_root: Path, json_root: Path) -> None: self.cfg = cfg self.csv_root = csv_root self.json_root = json_root self.manifest = self._load_manifest() self.graph: list[dict] = [] # hybrid metadata entities self.full_bytes = 0 self.full_files = 0 self.counts = {"coupons": 0, "tracks": 0, "cycle_rows": 0, "loop_rows": 0, "xrf_rows": 0} def _load_manifest(self) -> dict[str, tuple[int, int, str]]: manifest: dict[str, tuple[int, int, str]] = {} with open(self.csv_root / "MANIFEST.csv", encoding="ascii") as fh: header = fh.readline().strip() if header != "path,rows,bytes,sha256": raise ValidationError(f"MANIFEST.csv: unexpected header {header!r}") for line in fh: path, rows, nbytes, sha = line.strip().split(",") manifest[path] = (int(rows), int(nbytes), sha) logger.info("manifest loaded: %d entries", len(manifest)) return manifest def read_csv(self, relpath: str) -> tuple[list[str], list[list[str]]]: """Read one corpus file, verifying bytes and row count against MANIFEST.""" data = (self.csv_root / relpath).read_bytes() exp_rows, exp_bytes, exp_sha = self.manifest[relpath] if len(data) != exp_bytes or hashlib.sha256(data).hexdigest() != exp_sha: raise ValidationError(f"{relpath}: bytes/sha256 mismatch vs MANIFEST") lines = data.decode("ascii").split("\n") header = lines[0].split(",") rows = [ln.split(",") for ln in lines[1:] if ln] if len(rows) != exp_rows: raise ValidationError(f"{relpath}: {len(rows)} rows != manifest {exp_rows}") return header, rows def rows_as_dicts(self, relpath: str) -> list[dict]: header, rows = self.read_csv(relpath) keys = [h.lower() for h in header] return [ {k: coerce(v) for k, v in zip(keys, row) if v != ""} for row in rows ] def source_ref(self, relpath: str) -> dict: rows, _, sha = self.manifest[relpath] return {"sourceFile": relpath, "rows": rows, "sha256": sha} # --- flat entities (hybrid graph) --- def convert_flat(self) -> None: for inst in self.cfg["instruments"]: self.graph.append({ "@id": f"instrument:{inst['instrument_id']}", "@type": "Instrument", "name": inst["name"], "role": inst["role"], }) for row in self.rows_as_dicts("batches.csv"): self.graph.append({"@id": f"batch:{row['batch_code']}", "@type": "Batch", **row}) self.graph.append({ "@id": f"batch:{row['batch_code']}-simtra", "@type": "SimtraProfile", "performedOn": f"batch:{row['batch_code']}", "performedBy": "instrument:simtra", **self.source_ref(f"simtra/simtra_profile_{row['batch_code']}.csv"), }) for row in self.rows_as_dicts("runs.csv"): self.graph.append({"@id": f"run:{row['run_code']}", "@type": "Run", **row}) # --- per-coupon conversion --- def convert_coupons(self) -> None: h = self.cfg["hierarchy"] full_dir = self.json_root / "full" full_dir.mkdir(parents=True, exist_ok=True) for batch in self.cfg["deposition_matrix"]: b_code = batch["batch_code"] for w in range(1, h["wafers_per_batch"] + 1): wafer_code = f"{b_code}-W{w}" wafer_dir = f"batch_{b_code}/wafer_W{w}" info = self.rows_as_dicts(f"{wafer_dir}/wafer_info.csv")[0] self.graph.append({ "@id": f"wafer:{wafer_code}", "@type": "Wafer", "partOf": f"batch:{b_code}", **info, }) for c in range(1, h["coupons_per_wafer"] + 1): self.convert_coupon(f"{wafer_dir}/coupon_C{c:02d}", full_dir) logger.debug("wafer %s converted", wafer_code) logger.info("batch %s converted: %.1f MiB full so far", b_code, self.full_bytes / (1024 * 1024)) def convert_coupon(self, rel_dir: str, full_dir: Path) -> None: info = self.rows_as_dicts(f"{rel_dir}/coupon_info.csv")[0] code = info["coupon_code"] coupon_id = f"coupon:{code}" is_friction = info["run_code"] != "RESERVE" full_node: dict = { "@context": CONTEXT, "@id": coupon_id, "@type": "Coupon", "cutFrom": f"wafer:{info['wafer_code']}", **info, } hybrid_node: dict = { "@id": coupon_id, "@type": "Coupon", "cutFrom": f"wafer:{info['wafer_code']}", **info, } for csv_name, key, cls, inst, points_key in MEASUREMENTS: relpath = f"{rel_dir}/{csv_name}" meta = {"@type": cls, "performedOn": coupon_id, "performedBy": f"instrument:{inst}"} full_node[key] = {**meta, points_key: self.rows_as_dicts(relpath)} hybrid_node[key] = {**meta, **self.source_ref(relpath)} self.counts["xrf_rows"] += self.manifest[f"{rel_dir}/xrf_map.csv"][0] afm = self.rows_as_dicts(f"{rel_dir}/afm.csv")[0] afm_node = {"@type": "AFMMeasurement", "performedOn": coupon_id, "performedBy": "instrument:afm", **afm} full_node["afm"] = afm_node hybrid_node["afm"] = afm_node if is_friction: tracks_full = [] for t in range(1, self.cfg["friction_assignment"]["tracks_per_friction_coupon"] + 1): full_track, hybrid_track = self.convert_track(f"{rel_dir}/track_T{t}", coupon_id, info["run_code"]) tracks_full.append(full_track) self.graph.append(hybrid_track) full_node["tracks"] = tracks_full out_path = full_dir / f"{code}.jsonld" text = json.dumps(full_node, separators=(",", ":")) data = text.encode("ascii") out_path.write_bytes(data) self.full_bytes += len(data) self.full_files += 1 self.graph.append(hybrid_node) self.counts["coupons"] += 1 def convert_track(self, rel_dir: str, coupon_id: str, run_code: str) -> tuple[dict, dict]: info = self.rows_as_dicts(f"{rel_dir}/track_info.csv")[0] track_id = f"track:{info['track_code']}" base = { "@id": track_id, "@type": "FrictionTest", "partOf": coupon_id, "duringRun": f"run:{run_code}", "performedBy": "instrument:rapid", **info, } wear = self.rows_as_dicts(f"{rel_dir}/wear.csv")[0] wear_node = {"@type": "WearMeasurement", **wear} cycles_path = f"{rel_dir}/cof_vs_cycle.csv" loops_path = f"{rel_dir}/friction_loops.csv" full_track = { **base, "cycles": self.rows_as_dicts(cycles_path), "loop_points": self.rows_as_dicts(loops_path), "wear": wear_node, } hybrid_track = { **base, "cycles": self.source_ref(cycles_path), "loop_points": self.source_ref(loops_path), "wear": wear_node, } self.counts["cycle_rows"] += len(full_track["cycles"]) self.counts["loop_rows"] += len(full_track["loop_points"]) self.counts["tracks"] += 1 return full_track, hybrid_track # --- outputs --- def write_hybrid(self) -> int: hybrid_dir = self.json_root / "hybrid" hybrid_dir.mkdir(parents=True, exist_ok=True) path = hybrid_dir / "dataset.jsonld" with open(path, "w", encoding="ascii", newline="\n") as fh: json.dump({"@context": CONTEXT, "@graph": self.graph}, fh, separators=(",", ":")) return path.stat().st_size def validate_counts(self) -> None: v = self.cfg["volumes"] expected = { "coupons": v["coupons_total"], "tracks": v["tracks_total"], "cycle_rows": v["cycle_rows_total"], "loop_rows": v["loop_points_total"], "xrf_rows": v["xrf_points_total"], } for key, exp in expected.items(): if self.counts[key] != exp: raise ValidationError(f"count mismatch: {key}: converted {self.counts[key]} != expected {exp}") logger.info("all converted counts match lab_config volumes: %s", self.counts) def validate_sample(paths: list[Path]) -> None: """Smoke test (spec 04 + test-pipeline-validation): json.load + rdflib parse.""" for path in paths: with open(path, encoding="ascii") as fh: json.load(fh) g = Graph() g.parse(path, format="json-ld") if len(g) == 0: raise ValidationError(f"{path}: rdflib parsed 0 triples") logger.info("sample %s: valid JSON, %d triples via rdflib", path.name, len(g)) def check_dependencies(out_root: Path) -> None: for dep in DEPENDS_ON: marker = out_root / ".done" / f"{dep}.ok" if not marker.exists(): raise FileNotFoundError(f"dependency marker missing: {marker} - run task {dep} first") def write_marker(marker_path: Path, conv: JsonConverter, hybrid_bytes: int) -> None: lines = [ f"task: '{TASK_ID}'", "status: ok", f"full_files: {conv.full_files}", f"full_bytes: {conv.full_bytes}", f"hybrid_bytes: {hybrid_bytes}", f"coupons: {conv.counts['coupons']}", f"tracks: {conv.counts['tracks']}", f"cycle_rows: {conv.counts['cycle_rows']}", f"loop_points: {conv.counts['loop_rows']}", ] marker_path.parent.mkdir(parents=True, exist_ok=True) with open(marker_path, "w", encoding="utf-8", newline="\n") as fh: fh.write("\n".join(lines) + "\n") def main() -> int: parser = argparse.ArgumentParser(description="Task 04: convert the CSV corpus to JSON-LD") parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]) parser.add_argument("--out-root", type=Path, default=REPO_ROOT / "out", help="artifact tree root (default: ./out)") args = parser.parse_args() logging.basicConfig( level=args.log_level, stream=sys.stderr, format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", ) out_root: Path = args.out_root json_root = out_root / "json" marker_path = out_root / ".done" / f"{TASK_ID}.ok" try: check_dependencies(out_root) if marker_path.exists(): logger.info("re-run: removing stale marker %s", marker_path) marker_path.unlink() if json_root.exists(): logger.info("re-run: removing previous output %s", json_root) shutil.rmtree(json_root) with open(out_root / "config" / "lab_config.yaml", encoding="utf-8") as fh: cfg = yaml.safe_load(fh) conv = JsonConverter(cfg, out_root / "csv", json_root) conv.convert_flat() conv.convert_coupons() conv.validate_counts() hybrid_bytes = conv.write_hybrid() validate_sample([ json_root / "full" / "B722-W2-C13.jsonld", json_root / "hybrid" / "dataset.jsonld", ]) update_storage_sizes(out_root / "bench", "json", [("full", conv.full_bytes), ("hybrid", hybrid_bytes)]) write_marker(marker_path, conv, hybrid_bytes) except Exception: logger.critical("task %s failed", TASK_ID, exc_info=True) return 1 print(f"task 04 ok: full {conv.full_files} files, {conv.full_bytes / (1024 * 1024):.1f} MiB; " f"hybrid {hybrid_bytes / (1024 * 1024):.1f} MiB") print(f"entities: coupons={conv.counts['coupons']} tracks={conv.counts['tracks']} " f"cycles={conv.counts['cycle_rows']} loop_points={conv.counts['loop_rows']}") print(f"marker: {marker_path}") return 0 if __name__ == "__main__": sys.exit(main())