"""Offline first-loop experiment on an immutable K1 recording, never an API action. Run with the project's map-correction extra. All outputs are private derivatives; the caller supplies a fresh output directory, source session identity and digest. No raw overwrites, scene publication, hardware commands or planner mutations. """ from __future__ import annotations import argparse import hashlib import json import time from dataclasses import asdict from datetime import UTC, datetime from importlib.metadata import version from pathlib import Path import numpy as np from scipy.spatial import cKDTree from scipy.spatial.transform import Rotation from k1link.device_plugins.xgrids_k1.protocol.streams import decode_lio_pcl, decode_lio_pose from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages from k1link.missions.registration import POLICY, PreparedReference, transform from k1link.reconstruction.closure import ClosurePolicy, ClosureUnavailable, acquire_closure from k1link.reconstruction.smooth_correction import ( CorrectionField, CorrectionPolicy, SurfaceLink, fit_correction, ) PROFILE = dict( version="recorded-ring-experiment/v2", sample_stride=8, holdout_period_s=10.0, holdout_start_s=4.0, holdout_duration_s=2.0, seam_reference_s=20.0, seam_query_s=5.0, seam_radius_m=25.0, local_validation_radius_m=40.0, neighbor_radius_m=30.0, voxel_m=0.25, seam_translation_weight_m=0.03, seam_rotation_weight_deg=0.05, neighbor_translation_weight_m=0.2, neighbor_rotation_weight_deg=0.5, ) # A separate first-fit policy, never a mutation of the live tracking policy. # Quality/shape/information/correspondence gates remain identical. CLOSURE_REGISTRATION_POLICY = { **POLICY, "version": "offline-closure-gicp/v1", "maximum_correction_m": 25.0, "maximum_correction_deg": 180.0, } DECODE_KEYS = ("sample_stride", "holdout_period_s", "holdout_start_s", "holdout_duration_s") def compatible_cache_profile(profile): # Earlier sealed caches include fitting settings, though decoding never uses # them. Reuse is safe only when every actual decode/split setting agrees. return all(profile.get(key) == PROFILE[key] for key in DECODE_KEYS) def digest(path): with path.open("rb") as stream: return hashlib.file_digest(stream, "sha256").hexdigest() def write_json(path, value): with path.open("x") as stream: json.dump(value, stream, indent=2, allow_nan=False) def extract(raw, output, expected): if digest(raw) != expected: raise ValueError("Source digest mismatch before decoding.") output.mkdir() # Exclusive new directory, never replace an earlier experiment. started = time.monotonic() poses, frames, samples, sample_ids = [], [], [], [] first = None total = 0 sequences = {"lio_pose": [], "lio_pcl": []} with ( (output / "source-points.f32").open("xb") as points_file, (output / "source-intensity.u8").open("xb") as intensity_file, ): for message in iter_replay_messages(raw): if message.received_monotonic_ns is None: raise ValueError("Source has no monotonic receipt timestamp.") clock = message.received_monotonic_ns / 1e9 if first is None: first = clock t = clock - first if message.topic.endswith("/lio_pose"): pose = decode_lio_pose(message.payload) sequences["lio_pose"].append(pose.header.seq) poses.append( [ t, *pose.position_xyz, *pose.orientation_xyzw, pose.pose_stamp, pose.header.seq, ] ) elif message.topic.endswith("/lio_pcl"): cloud = decode_lio_pcl(message.payload) sequences["lio_pcl"].append(cloud.header.seq) data = np.asarray(cloud.points, dtype=np.int64).reshape(-1, 4) xyz = (data[:, :3] / cloud.header.scaler).astype("= PROFILE["holdout_start_s"]) & ( phase < PROFILE["holdout_start_s"] + PROFILE["holdout_duration_s"] ) nearest = np.clip(np.searchsorted(p[:, 0], f[:, 0]), 1, len(p) - 1) gap = np.minimum(abs(p[nearest, 0] - f[:, 0]), abs(p[nearest - 1, 0] - f[:, 0])) if digest(raw) != expected: raise ValueError("Source changed while decoding; derivative is not admissible.") np.savez( output / "index.npz", poses=p, frames=f, distance=distance, frame_distance=frame_distance, heldout=held, sample_points=np.concatenate(samples), sample_frame=np.concatenate(sample_ids), ) meta = dict( source_sha256=expected, profile=PROFILE, frames=len(f), poses=len(p), points=total, seconds=time.monotonic() - started, path_m=float(distance[-1]), receipt_pose_nearest_gap_p95_s=float(np.quantile(gap, 0.95)), receipt_pose_nearest_gap_max_s=float(gap.max()), clock_binding="host-monotonic interpolation; NOT hardware synchronization", mapped_increment_not_native_sweep=True, heldout_frames=int(held.sum()), training_frames=int((~held).sum()), ) write_json(output / "source.json", meta) return meta def voxel(points): if not len(points): return points _, idx = np.unique( np.floor(points / PROFILE["voxel_m"]).astype(np.int64), axis=0, return_index=True ) return points[np.sort(idx)] def register(reference, query, seed, *, acquisition=False): try: result = PreparedReference(reference).register( query, seed, policy=CLOSURE_REGISTRATION_POLICY if acquisition else POLICY ) result.pop("matched_query_indices", None) return result except ValueError as exc: return dict(status="unavailable", reasons=[str(exc)]) def links_for(data): p, s = data["poses"], data["frame_distance"] pts, ids, held = data["sample_points"], data["sample_frame"], data["heldout"] training = ~held[ids] def take(frame_mask, center, radius): mask = training & frame_mask[ids] chunk = pts[mask] return chunk[np.linalg.norm(chunk - center, axis=1) <= radius] link, search = acquire_closure(data, register) selected = search["attempts"][search["selected_attempt"]] links = [link] audits = [dict(kind="seam", **selected, search=search)] # Disjoint time/distance windows: no shared frame can self-match across an edge. centers = np.linspace(0, data["distance"][-1], int(np.ceil(data["distance"][-1] / 20)) + 1) for i, (sa, sb) in enumerate(zip(centers[:-1], centers[1:], strict=True)): width = (sb - sa) / 3 amask, bmask = abs(s - sa) <= width, abs(s - sb) <= width assert not np.any(amask & bmask) pivot = np.array([np.interp((sa + sb) / 2, data["distance"], p[:, j]) for j in range(1, 4)]) a = take(amask, pivot, PROFILE["neighbor_radius_m"]) b = take(bmask, pivot, PROFILE["neighbor_radius_m"]) fit = register(a, b, np.eye(4)) audits.append(dict(kind="neighbor", distances_m=[float(sa), float(sb)], fit=fit)) if fit["status"] == "candidate": links.append( SurfaceLink( float(np.mean(s[amask & ~held])), float(np.mean(s[bmask & ~held])), np.asarray(fit["T_reference_query"]), np.median(b, axis=0), PROFILE["neighbor_translation_weight_m"], PROFILE["neighbor_rotation_weight_deg"], f"neighbor-{i}", ) ) print(f"neighbor {i + 1}/{len(centers) - 1}: {fit['status']}", flush=True) return links, audits def evaluate(data, field, label): pts, ids = data["sample_points"], data["sample_frame"] s, f, p = data["frame_distance"], data["frames"], data["poses"] train = ~data["heldout"][ids] corrected = np.empty_like(pts) offsets = np.searchsorted(ids, np.arange(len(f) + 1)) for i, distance in enumerate(s): start, end = offsets[i : i + 2] corrected[start:end] = field.points(pts[start:end], distance) target = voxel(corrected[train]) tree = cKDTree(target) groups = np.floor((f[:, 0] - f[0, 0]) / PROFILE["holdout_period_s"]).astype(int) seed, rows = np.eye(4), [] for group in np.unique(groups[data["heldout"]]): frames = data["heldout"] & (groups == group) seconds, distance = float(np.mean(f[frames, 0])), float(np.mean(s[frames])) position = np.array([np.interp(seconds, p[:, 0], p[:, j]) for j in range(1, 4)]) query = pts[frames[ids]] # Uncorrected, held-out scanner output. radius = PROFILE["local_validation_radius_m"] query = query[np.linalg.norm(query - position, axis=1) <= radius] estimated = transform(position[None], seed)[0] reference = target[tree.query_ball_point(estimated, radius + 5)] fit = register(reference, query, seed) row = dict( group=int(group), distance_m=distance, source_frames=int(frames.sum()), source_query_points=len(query), fit=fit, ) if "T_reference_query" in fit: fitted = np.asarray(fit["T_reference_query"]) implied = field.matrices(distance)[0] row["model_consistency_m"] = float( np.linalg.norm( transform(position[None], fitted) - transform(position[None], implied) ) ) row["model_consistency_deg"] = float( np.rad2deg( np.linalg.norm( Rotation.from_matrix(fitted[:3, :3].T @ implied[:3, :3]).as_rotvec() ) ) ) # All-point tails remain visible, not just accepted correspondences. dist, _ = tree.query(transform(query, fitted), workers=1) row["all_point_distance_p95_m"] = float(np.quantile(dist, 0.95)) if fit["status"] == "candidate": seed = fitted # causal last accepted transform, never field oracle. rows.append(row) if len(rows) % 10 == 0: print(f"validation {label}: {len(rows)} windows", flush=True) return dict( label=label, training_map_points=len(target), windows=rows, candidate_count=sum(r["fit"]["status"] == "candidate" for r in rows), total=len(rows), interpretation="same-source held-out-frame local matching, not independent truth", seed="identity then previous accepted transform; no current-field seed", ) def materialize(cache, data, field, output): frames = data["frames"] count = int(frames[-1, 2] + frames[-1, 3]) source = np.memmap(cache / "source-points.f32", dtype="