"""Task 03 - Simulated CSV data generation (~150 MB). Generates the canonical raw corpus under ./out/csv/ exactly as the instruments would write it, driven entirely by out/config/lab_config.yaml (volumes, physical-model coefficients, seed). Also writes ./out/csv/MANIFEST.csv (path, rows, bytes, sha256) and the ./out/.done/03.ok marker. Spec: docs/specs/03_csv_data_generation.md. """ from __future__ import annotations import hashlib import logging import math import shutil import sys from datetime import datetime, timedelta from pathlib import Path import numpy as np from common.pipeline import ( check_dependencies, load_lab_config, parse_task_args, remove_stale_marker, write_marker, ) logger = logging.getLogger(__name__) TASK_ID = "03" DEPENDS_ON = ["01"] SIZE_GATE_MIB = (120, 200) # abort bounds on the generated tree, spec 03 # One float format per column family (docs/rules/data-determinism.md): # repr drift between runs must never change file bytes. Precision of the two # bulk families also sets corpus size; chosen to land in the 140-170 MB # target band of spec 03. FMT_COF = "%.16g" FMT_POSITION = "%.14g" FMT_FORCE = "%.14g" FMT_MISC = "%.10g" # wt%, um, nm, GPa, simtra, wear # RNG stream tags for per-entity derived generators. S_XRF, S_PROF, S_NANO, S_AFM, S_COF, S_LOOP, S_WEAR, S_SIMTRA = range(1, 9) class ValidationError(RuntimeError): pass class CorpusGenerator: def __init__(self, cfg: dict, csv_root: Path) -> None: self.cfg = cfg self.csv_root = csv_root self.seed = cfg["seed"] self.manifest: list[tuple[str, int, int, str]] = [] self.total_rows = 0 self.total_bytes = 0 self.counts = {"coupons": 0, "tracks": 0, "cycle_rows": 0, "loop_rows": 0, "xrf_rows": 0} self.runs_by_batch = {r["batch_code"]: r for r in cfg["runs"]} def rng(self, *tags: int) -> np.random.Generator: return np.random.default_rng([self.seed, *tags]) def write_csv(self, relpath: str, header: str, lines: list[str]) -> None: text = header + "\n" + "\n".join(lines) + "\n" data = text.encode("ascii") path = self.csv_root / relpath path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) sha = hashlib.sha256(data).hexdigest() self.manifest.append((relpath, len(lines), len(data), sha)) self.total_rows += len(lines) self.total_bytes += len(data) # --- top-level flat files --- def gen_batches(self) -> None: sched = self.cfg["schedule"] start = datetime.fromisoformat(sched["deposition_start_date"]) lines = [] for i, b in enumerate(self.cfg["deposition_matrix"]): date = (start + timedelta(days=i * sched["batch_interval_days"])).date().isoformat() lines.append( f"{b['batch_code']},{b['pt_gun_tilt_deg']},{b['au_gun_tilt_deg']}," f"{b['pt_power_W']},{b['au_power_W']},{b['pt_discharge_V']},{b['au_discharge_V']},{date}" ) self.write_csv( "batches.csv", "batch_code,pt_gun_tilt_deg,au_gun_tilt_deg,pt_power_W,au_power_W,pt_discharge_V,au_discharge_V,deposition_date", lines, ) def gen_runs(self) -> None: plates = self.cfg["friction_assignment"]["plates_per_run"] lines = [ f"{r['run_code']},{r['environment']},{r['date']},{plates},{r['operator']}" for r in self.cfg["runs"] ] self.write_csv("runs.csv", "run_code,environment,date,plates,operator", lines) def gen_simtra(self) -> None: pm = self.cfg["physical_models"]["simtra"] n = self.cfg["volumes"]["simtra_rows_per_batch"] us_pt = pm["surface_binding_energy_eV"]["pt"] us_au = pm["surface_binding_energy_eV"]["au"] for b_idx, b in enumerate(self.cfg["deposition_matrix"], 1): rng = self.rng(b_idx, S_SIMTRA) angle = np.linspace(pm["angle_deg_range"][0], pm["angle_deg_range"][1], n) pt_flux = pm["flux_per_W"] * b["pt_power_W"] * np.cos(np.radians(angle - b["pt_gun_tilt_deg"])) ** 2 au_flux = pm["flux_per_W"] * b["au_power_W"] * np.cos(np.radians(angle - b["au_gun_tilt_deg"])) ** 2 pt_flux = np.maximum(pt_flux * (1 + rng.normal(0, 0.02, n)), 0) au_flux = np.maximum(au_flux * (1 + rng.normal(0, 0.02, n)), 0) # Thompson mean emission energy ~ 2 * surface binding energy, # flux-weighted across the two species. energy = (pt_flux * 2 * us_pt + au_flux * 2 * us_au) / (pt_flux + au_flux + 1e-12) energy = energy + rng.normal(0, 0.2, n) lines = [ f"{FMT_MISC % a},{FMT_MISC % e},{FMT_MISC % p},{FMT_MISC % g}" for a, e, p, g in zip(angle, energy, pt_flux, au_flux) ] self.write_csv(f"simtra/simtra_profile_{b['batch_code']}.csv", "angle_deg,energy_eV,pt_flux,au_flux", lines) # --- per-coupon measurements --- def gen_xrf(self, rel_dir: str, b_idx: int, w: int, c: int, au_nominal: float) -> float: gx_n, gy_n = self.cfg["volumes"]["xrf_grid"] noise_sd = self.cfg["physical_models"]["au_gradient"]["xrf_noise_sd_wtpct"] rng = self.rng(b_idx, w, c, S_XRF) au = au_nominal + rng.normal(0, noise_sd, gx_n * gy_n) lines = [] i = 0 for gy in range(1, gy_n + 1): for gx in range(1, gx_n + 1): lines.append(f"{gx},{gy},{FMT_MISC % (100.0 - au[i])},{FMT_MISC % au[i]}") i += 1 self.write_csv(f"{rel_dir}/xrf_map.csv", "grid_x,grid_y,pt_wtpct,au_wtpct", lines) self.counts["xrf_rows"] += len(lines) return float(au.mean()) def gen_profilometry(self, rel_dir: str, b_idx: int, w: int, c: int, row: int, col: int) -> float: pm = self.cfg["physical_models"]["thickness_um"] gx_n, gy_n = self.cfg["volumes"]["profilometry_grid"] # Normalized squared wafer radius: corner coupon (r2=1) is thinnest. r2 = (((col - 4) / 3.0) ** 2 + ((row - 4) / 3.0) ** 2) / 2.0 nominal = pm["center_um"] - (pm["center_um"] - pm["edge_um"]) * r2 rng = self.rng(b_idx, w, c, S_PROF) t = nominal + rng.normal(0, pm["noise_sd_um"], gx_n * gy_n) lines = [] i = 0 for gy in range(1, gy_n + 1): for gx in range(1, gx_n + 1): lines.append(f"{gx},{gy},{FMT_MISC % t[i]}") i += 1 self.write_csv(f"{rel_dir}/profilometry.csv", "grid_x,grid_y,thickness_um", lines) return float(t.mean()) def gen_nanoindentation(self, rel_dir: str, b_idx: int, w: int, c: int, au_mean: float) -> None: pm = self.cfg["physical_models"] n = self.cfg["volumes"]["nanoindentation_indents_per_coupon"] grid_w = pm["nanoindentation"]["indent_grid"][0] pitch = pm["nanoindentation"]["grid_pitch_um"] max_load = pm["nanoindentation"]["max_load_mN"] rng = self.rng(b_idx, w, c, S_NANO) h = pm["hardness_GPa"]["intercept"] + pm["hardness_GPa"]["slope_per_au_wtpct"] * au_mean \ + rng.normal(0, pm["hardness_GPa"]["noise_sd"], n) er = pm["reduced_modulus_GPa"]["intercept"] + pm["reduced_modulus_GPa"]["slope_per_au_wtpct"] * au_mean \ + rng.normal(0, pm["reduced_modulus_GPa"]["noise_sd"], n) lines = [ f"{i + 1},{(i % grid_w) * pitch},{(i // grid_w) * pitch},{FMT_MISC % h[i]},{FMT_MISC % er[i]},{max_load}" for i in range(n) ] self.write_csv( f"{rel_dir}/nanoindentation.csv", "indent_id,x_um,y_um,hardness_GPa,reduced_modulus_GPa,max_load_mN", lines, ) def gen_afm(self, rel_dir: str, b_idx: int, w: int, c: int, coupon_code: str) -> float: pm = self.cfg["physical_models"]["roughness_ra_nm"] rng = self.rng(b_idx, w, c, S_AFM) ra = float(np.exp(rng.normal(math.log(pm["median_nm"]), pm["sigma_log"]))) rq = ra * pm["rq_over_ra"] self.write_csv( f"{rel_dir}/afm.csv", "ra_nm,rq_nm,image_file", [f"{FMT_MISC % ra},{FMT_MISC % rq},images/afm/{coupon_code}.tiff"], ) return ra # --- per-track friction data --- def cof_ss(self, au_mean: float, environment: str) -> float: pm = self.cfg["physical_models"]["cof_steady_state"] value = pm[environment]["intercept"] + pm[environment]["slope_per_au_wtpct"] * au_mean return max(value, pm["floor"]) # deliberate model floor, spec 01 def gen_track( self, rel_dir: str, b_idx: int, w: int, c: int, t: int, track_code: str, run: dict, au_mean: float, plate: int, probe: int, square: int, ) -> None: tc = self.cfg["test_conditions"] pm = self.cfg["physical_models"] v = self.cfg["volumes"] sched = self.cfg["schedule"] env = run["environment"] load_mn = tc["normal_load_mN"] started = datetime.fromisoformat(f"{run['date']}T{sched['run_start_time']}+00:00") + timedelta( seconds=((plate - 1) * 12 + (square - 1) * 3 + (t - 1)) * sched["track_interval_s"] ) counterface_id = f"{run['run_code']}-P{plate}-PR{probe}-B{t}" self.write_csv( f"{rel_dir}/track_info.csv", "track_code,run_code,environment,load_mN,stroke_mm,speed_mm_s,counterface_id,started_at", [ f"{track_code},{run['run_code']},{env},{load_mn},{FMT_MISC % tc['stroke_mm']}," f"{FMT_MISC % tc['speed_mm_s']},{counterface_id},{started.strftime('%Y-%m-%dT%H:%M:%SZ')}" ], ) cof_ss = self.cof_ss(au_mean, env) cof_rng = self.rng(b_idx, w, c, t, S_COF) ri = pm["cof_run_in"] cof_0 = cof_rng.uniform(*ri["cof_0_range"]) tau = cof_rng.uniform(*ri["tau_cycles_range"]) n_cycles = v["cycles_per_track"] cycles = np.arange(1, n_cycles + 1) decay = (cof_0 - cof_ss) * np.exp(-cycles / tau) cof = cof_ss + decay + cof_rng.normal(0, ri["noise_sd"], n_cycles) self.write_csv( f"{rel_dir}/cof_vs_cycle.csv", "cycle,cof", [f"{cyc},{FMT_COF % val}" for cyc, val in zip(cycles, cof)], ) self.counts["cycle_rows"] += n_cycles loop_rng = self.rng(b_idx, w, c, t, S_LOOP) half_um = tc["stroke_mm"] * 1000.0 / 2.0 pts_half = v["loop_points_per_loop"] // 2 fwd = np.linspace(-half_um, half_um, pts_half) rev = np.linspace(half_um, -half_um, pts_half) force_sd = pm["friction_loop"]["force_noise_sd_mN"] lines: list[str] = [] for k in range(1, v["loops_per_track"] + 1): cyc = k * v["loop_every_n_cycles"] mu = cof_ss + (cof_0 - cof_ss) * math.exp(-cyc / tau) f_fwd = mu * load_mn + loop_rng.normal(0, force_sd, pts_half) f_rev = -mu * load_mn + loop_rng.normal(0, force_sd, pts_half) lines.extend(f"{cyc},{FMT_POSITION % p},{FMT_FORCE % f}" for p, f in zip(fwd, f_fwd)) lines.extend(f"{cyc},{FMT_POSITION % p},{FMT_FORCE % f}" for p, f in zip(rev, f_rev)) self.write_csv(f"{rel_dir}/friction_loops.csv", "cycle,position_um,friction_force_mN", lines) self.counts["loop_rows"] += len(lines) wm = pm["wear_archard"] wear_rng = self.rng(b_idx, w, c, t, S_WEAR) k_arch = wm["k0_mm3_per_N_m"][env] * (1 + wm["slope_per_au_wtpct"][env] * au_mean) k_arch *= math.exp(wear_rng.normal(0, wm["noise_sigma_log"])) k_arch = max(k_arch, wm["k_floor_mm3_per_N_m"]) # deliberate model floor, spec 01 sliding_m = n_cycles * 2 * tc["stroke_mm"] / 1000.0 volume_um3 = k_arch * (load_mn / 1000.0) * sliding_m * 1e9 self.write_csv( f"{rel_dir}/wear.csv", "wear_volume_um3,k_archard,sliding_distance_m", [f"{FMT_MISC % volume_um3},{FMT_MISC % k_arch},{FMT_MISC % sliding_m}"], ) self.counts["tracks"] += 1 # --- hierarchy walk --- def generate(self) -> None: self.gen_batches() self.gen_runs() self.gen_simtra() h = self.cfg["hierarchy"] fa = self.cfg["friction_assignment"] ag = self.cfg["physical_models"]["au_gradient"] sched = self.cfg["schedule"] dep_start = datetime.fromisoformat(sched["deposition_start_date"]) reserve = set(fa["reserve_grid_positions"]) grid_w = h["coupon_grid_per_wafer"][0] for b_idx, batch in enumerate(self.cfg["deposition_matrix"], 1): b_code = batch["batch_code"] run = self.runs_by_batch[b_code] au_center = ag["batch_center_wtpct"][b_code] dep_date = (dep_start + timedelta(days=(b_idx - 1) * sched["batch_interval_days"])).date().isoformat() friction_idx = 0 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}" self.write_csv( f"{wafer_dir}/wafer_info.csv", "wafer_code,batch_code,wafer_index,deposition_date,coupons,friction_coupons,reserve_coupons", [ f"{wafer_code},{b_code},{w},{dep_date},{h['coupons_per_wafer']}," f"{h['coupons_per_wafer'] - len(reserve)},{len(reserve)}" ], ) for c in range(1, h["coupons_per_wafer"] + 1): row = (c - 1) // grid_w + 1 col = (c - 1) % grid_w + 1 coupon_code = f"{wafer_code}-C{c:02d}" rel_dir = f"{wafer_dir}/coupon_C{c:02d}" au_nominal = au_center + ag["wafer_span_wtpct"] * (col - 4) / 3.0 au_mean = self.gen_xrf(rel_dir, b_idx, w, c, au_nominal) thickness = self.gen_profilometry(rel_dir, b_idx, w, c, row, col) self.gen_nanoindentation(rel_dir, b_idx, w, c, au_mean) ra = self.gen_afm(rel_dir, b_idx, w, c, coupon_code) if c in reserve: assignment = "RESERVE,,," else: plate = friction_idx // 24 + 1 probe = (friction_idx % 24) // 4 + 1 square = friction_idx % 4 + 1 friction_idx += 1 assignment = f"{run['run_code']},{plate},{probe},{square}" self.write_csv( f"{rel_dir}/coupon_info.csv", "coupon_code,batch_code,wafer_code,grid_row,grid_col,thickness_um,ra_nm,au_wtpct_mean,run_code,plate,probe,square", [ f"{coupon_code},{b_code},{wafer_code},{row},{col},{FMT_MISC % thickness}," f"{FMT_MISC % ra},{FMT_MISC % au_mean},{assignment}" ], ) self.counts["coupons"] += 1 if c not in reserve: plate, probe, square = (int(x) for x in assignment.split(",")[1:]) for t in range(1, fa["tracks_per_friction_coupon"] + 1): self.gen_track( f"{rel_dir}/track_T{t}", b_idx, w, c, t, f"{coupon_code}-T{t}", run, au_mean, plate, probe, square, ) logger.debug("wafer %s done", wafer_code) logger.info( "batch %s done: %d files, %.1f MiB cumulative", b_code, len(self.manifest), self.total_bytes / (1024 * 1024), ) 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}: generated {self.counts[key]} != expected {exp}") logger.info("all entity counts match lab_config volumes: %s", self.counts) def write_manifest(self) -> int: lines = [f"{p},{r},{b},{s}" for p, r, b, s in self.manifest] text = "path,rows,bytes,sha256\n" + "\n".join(lines) + "\n" data = text.encode("ascii") (self.csv_root / "MANIFEST.csv").write_bytes(data) return len(data) def main() -> int: args = parse_task_args("Task 03: generate the CSV corpus") out_root: Path = args.out_root csv_root = out_root / "csv" try: check_dependencies(out_root, DEPENDS_ON) remove_stale_marker(out_root, TASK_ID) if csv_root.exists(): logger.info("re-run: removing previous corpus %s", csv_root) shutil.rmtree(csv_root) cfg = load_lab_config(out_root) gen = CorpusGenerator(cfg, csv_root) gen.generate() gen.validate_counts() manifest_bytes = gen.write_manifest() tree_bytes = gen.total_bytes + manifest_bytes tree_mib = tree_bytes / (1024 * 1024) print(f"generated tree size: {tree_mib:.1f} MiB ({tree_bytes} bytes, {len(gen.manifest) + 1} files)") if not SIZE_GATE_MIB[0] <= tree_mib <= SIZE_GATE_MIB[1]: raise ValidationError( f"tree size {tree_mib:.1f} MiB outside gate {SIZE_GATE_MIB[0]}-{SIZE_GATE_MIB[1]} MiB" ) marker_path = write_marker(out_root, TASK_ID, { "csv_root": gen.csv_root.as_posix(), "files": len(gen.manifest), "total_rows": gen.total_rows, "total_bytes": tree_bytes, "coupons": gen.counts["coupons"], "tracks": gen.counts["tracks"], "cycle_rows": gen.counts["cycle_rows"], "loop_points": gen.counts["loop_rows"], "xrf_points": gen.counts["xrf_rows"], }) except Exception: logger.critical("task %s failed", TASK_ID, exc_info=True) return 1 c = gen.counts print( f"rows: total={gen.total_rows} cycles={c['cycle_rows']} loops={c['loop_rows']} " f"xrf={c['xrf_rows']} coupons={c['coupons']} tracks={c['tracks']}" ) print(f"manifest: {csv_root / 'MANIFEST.csv'} ({len(gen.manifest)} entries)") print(f"marker: {marker_path}") return 0 if __name__ == "__main__": sys.exit(main())