重构RTK-IMU标定链路并完成机械先验工程验证
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Audit RTK/IMU factor conventions without running an optimizer.'''
|
||||
from __future__ import annotations
|
||||
import argparse, json, math, sys
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT))
|
||||
from imu_lidar.imu_preintegration import preintegrate_imu
|
||||
from imu_lidar.rtk_imu_engineering import (
|
||||
G_ENU, MIN_SEGMENT_DURATION_S, MIN_SEGMENT_NODE_COUNT,
|
||||
NUISANCE_DOF_PER_SEGMENT, _Segment, _all_hpr, _audit,
|
||||
_dense_colored_jacobian, _enu, _height_reference,
|
||||
_hpr_factor_observation, _initial_parameters, _marginal_lever_information,
|
||||
_motion_flags, _nodes, _position_valid, _residual,
|
||||
_segment_residual_size, _world_rtk)
|
||||
from imu_lidar.rtk_imu_multisource import _f, _truth, load_unified_sessions
|
||||
MECHANICAL_L_I_M = np.array([-0.45072, -0.25682, 0.73208])
|
||||
|
||||
def _jsonable(value):
|
||||
if isinstance(value, np.ndarray): return _jsonable(value.tolist())
|
||||
if isinstance(value, np.generic): return _jsonable(value.item())
|
||||
if isinstance(value, float): return value if math.isfinite(value) else None
|
||||
if is_dataclass(value): return _jsonable(asdict(value))
|
||||
if isinstance(value, dict): return {str(k): _jsonable(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)): return [_jsonable(v) for v in value]
|
||||
return value
|
||||
|
||||
def _summary(error):
|
||||
a = np.asarray(error, dtype=float)
|
||||
return _jsonable(_audit(list(a.reshape(-1, 3)), 3)) if a.size else _jsonable(_audit([], 3))
|
||||
|
||||
def _corr(a, b):
|
||||
out = np.full(3, np.nan)
|
||||
for axis in range(3):
|
||||
if len(a) >= 3 and np.std(a[:, axis]) > 1e-10 and np.std(b[:, axis]) > 1e-10:
|
||||
out[axis] = np.corrcoef(a[:, axis], b[:, axis])[0, 1]
|
||||
return out
|
||||
|
||||
def _best_arrays(session, reference):
|
||||
rows = []
|
||||
for row in session.rtk_by_type.get('BESTNAVA', []):
|
||||
v = np.array([_f(row, 'velocity_east_m_s'), _f(row, 'velocity_north_m_s'),
|
||||
_f(row, 'vertical_speed_m_s')])
|
||||
if _position_valid(row, 'BESTNAVA') and _truth(row, 'doppler_velocity_valid') and np.all(np.isfinite(v)):
|
||||
rows.append(row)
|
||||
rows.sort(key=lambda row: _f(row, 't_device_s'))
|
||||
rows = [row for i, row in enumerate(rows) if i == 0 or _f(row, 't_device_s') > _f(rows[i-1], 't_device_s')]
|
||||
t = np.asarray([_f(row, 't_device_s') for row in rows])
|
||||
p = np.asarray([_enu(row, 'BESTNAVA', reference)[0] for row in rows]).reshape(-1, 3)
|
||||
v = np.asarray([[_f(row, 'velocity_east_m_s'), _f(row, 'velocity_north_m_s'),
|
||||
_f(row, 'vertical_speed_m_s')] for row in rows]).reshape(-1, 3)
|
||||
return rows, t, p, v
|
||||
|
||||
def _gnss_audit(sessions, reference, max_dt):
|
||||
all_dpdt, all_v, per_session = [], [], {}
|
||||
for session in sessions:
|
||||
_, t, p, v = _best_arrays(session, reference)
|
||||
dt = np.diff(t); keep = (dt > 0) & (dt <= max_dt)
|
||||
dpdt = np.diff(p, axis=0)[keep] / dt[keep, None]
|
||||
v_avg = .5 * (v[:-1] + v[1:])[keep]
|
||||
all_dpdt.append(dpdt); all_v.append(v_avg)
|
||||
per_session[session.session_id] = {
|
||||
'interval_count': len(dpdt), 'nominal_correlation_xyz': _corr(dpdt, v_avg),
|
||||
'nominal_error_m_s': _summary(dpdt-v_avg)}
|
||||
dpdt, velocity = np.vstack(all_dpdt), np.vstack(all_v)
|
||||
hypotheses = []
|
||||
for swap in (False, True):
|
||||
base = velocity[:, [1,0,2]] if swap else velocity
|
||||
for sx in (-1.,1.):
|
||||
for sy in (-1.,1.):
|
||||
for sz in (-1.,1.):
|
||||
transformed = base * [sx,sy,sz]
|
||||
hypotheses.append({
|
||||
'mapping': ('[N,E,Z]' if swap else '[E,N,Z]')+f'*[{sx:+.0f},{sy:+.0f},{sz:+.0f}]',
|
||||
'correlation_xyz': _corr(dpdt, transformed),
|
||||
'error_m_s': _summary(dpdt-transformed)})
|
||||
hypotheses.sort(key=lambda item: item['error_m_s']['vector_rms'])
|
||||
nominal = next(x for x in hypotheses if x['mapping'] == '[E,N,Z]*[+1,+1,+1]')
|
||||
return {'interval_count': len(dpdt), 'nominal': nominal, 'best_mapping': hypotheses[0],
|
||||
'all_hypotheses': hypotheses, 'per_session': per_session}
|
||||
|
||||
def _nearest_imu(session, t, tolerance=.03):
|
||||
right = int(np.searchsorted(session.imu.t_s, t))
|
||||
candidates = [i for i in (right-1,right) if 0 <= i < len(session.imu.t_s)]
|
||||
if not candidates: return None
|
||||
index = min(candidates, key=lambda i: abs(session.imu.t_s[i]-t))
|
||||
return index if abs(session.imu.t_s[index]-t) <= tolerance else None
|
||||
|
||||
def _static_audit(sessions, rotation):
|
||||
current, opposite, per_session = [], [], {}
|
||||
for session in sessions:
|
||||
hpr, local = _all_hpr(session), []
|
||||
last_t = -np.inf
|
||||
for hpr_index in hpr.valid_indices:
|
||||
t = float(hpr.t_s[hpr_index])
|
||||
if t-last_t < 1.: continue
|
||||
last_t = t
|
||||
gravity, _ = _motion_flags(session,t,np.zeros(0),np.zeros((0,3)))
|
||||
baseline, _, valid, _, _ = _hpr_factor_observation(hpr,t)
|
||||
imu_index = _nearest_imu(session,t)
|
||||
if not (gravity and valid and imu_index is not None):
|
||||
continue
|
||||
R_WI = _world_rtk(baseline) @ rotation
|
||||
accel = session.imu.acc_m_s2[imu_index]
|
||||
value = R_WI @ accel + G_ENU
|
||||
local.append(value); current.append(value); opposite.append(R_WI @ accel-G_ENU)
|
||||
per_session[session.session_id] = {'count':len(local),'current_formula_m_s2':_summary(local)}
|
||||
return {'formula':'a_W_linear = R_WI @ specific_force_I + G_ENU',
|
||||
'current_formula_m_s2':_summary(current),
|
||||
'opposite_gravity_sign_m_s2':_summary(opposite),'per_session':per_session}
|
||||
|
||||
def _closure_audit(sessions, reference, rotation, lever, max_dt):
|
||||
all_p, all_v, per_session = [], [], {}
|
||||
for session in sessions:
|
||||
_, t, p_ant, v_ant = _best_arrays(session, reference)
|
||||
hpr, local_p, local_v = _all_hpr(session), [], []
|
||||
for index, dt in enumerate(np.diff(t)):
|
||||
if not .5 <= dt <= max_dt: continue
|
||||
baseline, _, valid, _, _ = _hpr_factor_observation(hpr, t[index])
|
||||
i0, i1 = _nearest_imu(session, t[index]), _nearest_imu(session, t[index+1])
|
||||
if not valid or i0 is None or i1 is None: continue
|
||||
pre = preintegrate_imu(session.imu.t_s, session.imu.gyro_rad_s,
|
||||
session.imu.acc_m_s2, t[index], t[index+1])
|
||||
if pre.duration_s <= 0 or abs(pre.duration_s-dt) > 1e-6: continue
|
||||
R0 = _world_rtk(baseline) @ rotation
|
||||
p_i0 = p_ant[index] - R0 @ lever
|
||||
v_i0 = v_ant[index] - R0 @ np.cross(session.imu.gyro_rad_s[i0], lever)
|
||||
p_i1 = p_i0 + v_i0*dt + .5*G_ENU*dt**2 + R0@pre.delta_p
|
||||
v_i1 = v_i0 + G_ENU*dt + R0@pre.delta_v
|
||||
R1 = R0 @ pre.delta_R
|
||||
local_p.append(p_i1 + R1@lever - p_ant[index+1])
|
||||
local_v.append(v_i1 + R1@np.cross(session.imu.gyro_rad_s[i1],lever) - v_ant[index+1])
|
||||
all_p.extend(local_p); all_v.extend(local_v)
|
||||
per_session[session.session_id] = {
|
||||
'interval_count':len(local_p),'position_m':_summary(local_p),
|
||||
'velocity_m_s':_summary(local_v)}
|
||||
return {'method':'single-step forward closure; fixed R2G/mechanical lever/BEST p-v; no least_squares',
|
||||
'position_m':_summary(all_p),'velocity_m_s':_summary(all_v),
|
||||
'per_session':per_session}
|
||||
|
||||
def _qualified_runs(sessions, reference, period):
|
||||
result = []
|
||||
for session in sessions:
|
||||
nodes, start, number = _nodes(session, reference, period), 0, 0
|
||||
for end in range(1,len(nodes)+1):
|
||||
if end != len(nodes) and nodes[end].continuity_id == nodes[end-1].continuity_id:
|
||||
continue
|
||||
run, start = tuple(nodes[start:end]), end
|
||||
if (len(run) >= MIN_SEGMENT_NODE_COUNT
|
||||
and run[-1].t_s-run[0].t_s >= MIN_SEGMENT_DURATION_S
|
||||
and any(node.hpr_factor_valid for node in run)):
|
||||
result.append((session,run,f'{session.session_id}:{number:02d}'))
|
||||
number += 1
|
||||
return result
|
||||
|
||||
def _first_node_audit(runs, rotation, lever):
|
||||
records, ranges = [], {}
|
||||
for session, run, segment_id in runs:
|
||||
node = run[0]
|
||||
hpr_node = next(item for item in run if item.hpr_factor_valid)
|
||||
R_WI = _world_rtk(hpr_node.baseline_enu) @ rotation
|
||||
velocity = node.velocity_enu_m_s
|
||||
v = np.full(3,np.nan) if velocity is None else velocity
|
||||
records.append({
|
||||
'segment_id':segment_id,'source':node.source,'t_s':node.t_s,
|
||||
'first_position_global_enu_m':node.p_enu_m,'first_velocity_enu_m_s':v,
|
||||
'free_x0_l0_position_residual_m':np.zeros(3),
|
||||
'free_x0_l0_velocity_residual_m_s':-v,
|
||||
'mechanical_l_unshifted_x0_position_residual_m':R_WI@lever,
|
||||
'mechanical_l_unshifted_x0_velocity_residual_m_s':R_WI@np.cross(node.gyro_rad_s,lever)-v,
|
||||
'observation_seeded_mechanical_position_residual_m':np.zeros(3),
|
||||
'observation_seeded_mechanical_velocity_residual_m_s':
|
||||
np.zeros(3) if velocity is not None else np.full(3,np.nan)})
|
||||
ranges.setdefault(session.session_id,[]).append(node.p_enu_m)
|
||||
ranges = {key:{'count':len(value),'min_global_enu_m':np.min(value,axis=0),
|
||||
'max_global_enu_m':np.max(value,axis=0)} for key,value in ranges.items()}
|
||||
return {'common_global_enu_reference':True,'segment_state_is_global_imu_position':True,
|
||||
'per_session_first_node_ranges':ranges,'segments':records}
|
||||
|
||||
def _gyro_score(session, run, category):
|
||||
keep = (session.imu.t_s >= run[0].t_s) & (session.imu.t_s <= run[-1].t_s)
|
||||
t, gyro = session.imu.t_s[keep], session.imu.gyro_rad_s[keep]
|
||||
if len(t) < 2: return 0.
|
||||
value = np.trapezoid(np.abs(gyro),t,axis=0)
|
||||
return float(value[2] if category != 'slope' else np.hypot(value[0],value[1]))
|
||||
|
||||
def _build_segment(session, run, segment_id):
|
||||
pre = tuple(preintegrate_imu(session.imu.t_s,session.imu.gyro_rad_s,
|
||||
session.imu.acc_m_s2,a.t_s,b.t_s)
|
||||
for a,b in zip(run[:-1],run[1:]))
|
||||
hpr = next(node for node in run if node.hpr_factor_valid)
|
||||
return _Segment(segment_id,session.session_id,run,pre,_world_rtk(hpr.baseline_enu))
|
||||
|
||||
def _subset_marginal(jacobian,residual,segments,indices):
|
||||
rows, row0 = [], 0
|
||||
for index,segment in enumerate(segments):
|
||||
count = _segment_residual_size(segment)
|
||||
if index in indices: rows.extend(range(row0,row0+count))
|
||||
row0 += count
|
||||
columns = [0,1,2]
|
||||
for index in indices:
|
||||
start = 3 + NUISANCE_DOF_PER_SEGMENT*index
|
||||
columns.extend(range(start,start+NUISANCE_DOF_PER_SEGMENT))
|
||||
rows, columns = np.asarray(rows), np.asarray(columns)
|
||||
return _marginal_lever_information(
|
||||
jacobian[np.ix_(rows,columns)],residual[rows])[0]
|
||||
|
||||
def _jacobian_schur_audit(runs,categories,rotation,lever):
|
||||
segments, indices = [], {}
|
||||
for category in ('circle','left_right','slope'):
|
||||
choices = [item for item in runs if categories[item[0].session_id] == category]
|
||||
if not choices:
|
||||
indices[category] = []; continue
|
||||
best = max(choices,key=lambda item:_gyro_score(item[0],item[1],category))
|
||||
indices[category] = [len(segments)]
|
||||
segments.append(_build_segment(*best))
|
||||
x = _initial_parameters(segments); x[:3] = lever
|
||||
residual = _residual(x,segments,rotation)
|
||||
jacobian = _dense_colored_jacobian(x,segments,rotation)
|
||||
comparisons = []
|
||||
for axis in range(3):
|
||||
plus, minus = x.copy(), x.copy()
|
||||
plus[axis] += .001; minus[axis] -= .001
|
||||
numeric = (_residual(plus,segments,rotation)-_residual(minus,segments,rotation))/.002
|
||||
current = jacobian[:,axis]; delta = numeric-current
|
||||
comparisons.append({
|
||||
'axis':'XYZ'[axis],'perturbation_m':.001,
|
||||
'relative_difference':float(np.linalg.norm(delta)/max(np.linalg.norm(numeric),1e-12)),
|
||||
'difference_norm':float(np.linalg.norm(delta)),
|
||||
'max_absolute_difference':float(np.max(np.abs(delta))),
|
||||
'correlation':float(np.corrcoef(numeric,current)[0,1])})
|
||||
full = _marginal_lever_information(jacobian,residual)[0]
|
||||
parts = {key:_subset_marginal(jacobian,residual,segments,value)
|
||||
for key,value in indices.items() if value}
|
||||
summed = sum(parts.values(),np.zeros((3,3)))
|
||||
error = np.linalg.norm(summed-full,ord='fro')
|
||||
return {'linearization':'same observation-seeded nuisance state and mechanical lever; no optimizer',
|
||||
'selected_segments':[segment.segment_id for segment in segments],
|
||||
'finite_difference_vs_solver_jacobian':comparisons,
|
||||
'full_marginal_information':full,'category_marginal_information':parts,
|
||||
'category_sum':summed,'additivity_error_fro':float(error),
|
||||
'additivity_relative_error':float(error/max(np.linalg.norm(full,ord='fro'),1e-12))}
|
||||
|
||||
def _legacy_diagnostics(path):
|
||||
if path is None or not path.exists():
|
||||
return {'available':False,'reason':'no prior artifact supplied'}
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
final_l = np.asarray(payload.get('free_solution',{}).get('l_I_m',[np.nan]*3))
|
||||
return {'available':False,'source':str(path),'initial_cost':None,'final_cost':None,
|
||||
'cost_reduction':None,'nfev':None,'optimality':None,'gradient_norm':None,
|
||||
'initial_l_I_m':[0.,0.,0.],'final_l_I_m':final_l,
|
||||
'l_step_norm_m':float(np.linalg.norm(final_l)) if np.all(np.isfinite(final_l)) else None,
|
||||
'reason':'legacy artifact did not persist optimizer state; prohibited solve was not rerun'}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--circle-session',required=True)
|
||||
parser.add_argument('--left-right-session',required=True)
|
||||
parser.add_argument('--slope-session',required=True)
|
||||
parser.add_argument('--sample-period-s',type=float,default=1.)
|
||||
parser.add_argument('--max-best-interval-s',type=float,default=1.75)
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
parser.add_argument('--mechanical-l-I-m',nargs=3,type=float,
|
||||
default=MECHANICAL_L_I_M.tolist())
|
||||
parser.add_argument('--previous-free-result',type=Path)
|
||||
parser.add_argument('--level-static-session',action='append',
|
||||
default=['0819_20260819_072130','0819_20260819_073045'])
|
||||
args = parser.parse_args()
|
||||
categories = {args.circle_session:'circle',args.left_right_session:'left_right',
|
||||
args.slope_session:'slope'}
|
||||
selected_ids = set(categories) | set(args.level_static_session)
|
||||
all_sessions = load_unified_sessions(args.manifest,selected_session_ids=selected_ids)
|
||||
sessions = [session for session in all_sessions if session.session_id in categories]
|
||||
static_sessions = [session for session in all_sessions
|
||||
if session.session_id in set(args.level_static_session)]
|
||||
reference = _height_reference(sessions)
|
||||
if reference is None: raise RuntimeError('no valid BESTNAVA height reference')
|
||||
rotation = Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
lever = np.asarray(args.mechanical_l_I_m)
|
||||
runs = _qualified_runs(sessions,reference,args.sample_period_s)
|
||||
payload = {
|
||||
'scope':'factor consistency only; no free solve/LOO/prior/bootstrap/sensitivity',
|
||||
'least_squares_called':False,'rotation_source':'R2G_gravity_level_prior',
|
||||
'rotation_rpy_deg':args.rotation_rpy_deg,'mechanical_l_I_m':lever,
|
||||
'common_enu_reference':reference,
|
||||
'gnss_position_difference_vs_doppler':_gnss_audit(
|
||||
sessions,reference,args.max_best_interval_s),
|
||||
'static_specific_force_gravity_sign':_static_audit(static_sessions,rotation),
|
||||
'one_step_imu_preintegration_closure':_closure_audit(
|
||||
sessions,reference,rotation,lever,args.max_best_interval_s),
|
||||
'first_node_origin_and_initial_residual':_first_node_audit(runs,rotation,lever),
|
||||
'lever_jacobian_and_category_schur':_jacobian_schur_audit(
|
||||
runs,categories,rotation,lever),
|
||||
'previous_free_fit_solver_diagnostics':_legacy_diagnostics(args.previous_free_result)}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable({
|
||||
'gnss':payload['gnss_position_difference_vs_doppler'],
|
||||
'static':payload['static_specific_force_gravity_sign'],
|
||||
'closure':payload['one_step_imu_preintegration_closure'],
|
||||
'jacobian_schur':payload['lever_jacobian_and_category_schur']}),
|
||||
ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit BESTNAVA/Doppler factor yield for every unified RTK--IMU session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from imu_lidar.rtk_imu_engineering import (
|
||||
_all_hpr,
|
||||
_height_reference,
|
||||
_nodes,
|
||||
_position_valid,
|
||||
)
|
||||
from imu_lidar.rtk_imu_multisource import _f, _nearest_index, _truth, load_unified_sessions
|
||||
|
||||
|
||||
def _finite_doppler(row: dict[str, str]) -> bool:
|
||||
value = np.asarray([
|
||||
_f(row, "velocity_east_m_s"),
|
||||
_f(row, "velocity_north_m_s"),
|
||||
_f(row, "vertical_speed_m_s"),
|
||||
])
|
||||
return bool(_truth(row, "doppler_velocity_valid") and np.all(np.isfinite(value)))
|
||||
|
||||
|
||||
def _source_only_nodes(session, source: str, period_s: float):
|
||||
rows = {source: session.rtk_by_type.get(source, [])}
|
||||
if "GNHPR" in session.rtk_by_type:
|
||||
rows["GNHPR"] = session.rtk_by_type["GNHPR"]
|
||||
return _nodes(replace(session, rtk_by_type=rows), _height_reference([session]), period_s)
|
||||
|
||||
|
||||
def _audit_session(session, period_s: float) -> dict[str, object]:
|
||||
best = session.rtk_by_type.get("BESTNAVA", [])
|
||||
hpr = _all_hpr(session)
|
||||
counts = {
|
||||
"raw_bestnava": len(best),
|
||||
"checksum_valid": 0,
|
||||
"fixed": 0,
|
||||
"finite_position": 0,
|
||||
"finite_doppler": 0,
|
||||
"hpr_near": 0,
|
||||
"hpr_q4": 0,
|
||||
"imu_near": 0,
|
||||
"raw_bestnava_candidate": 0,
|
||||
}
|
||||
for row in best:
|
||||
if not _truth(row, "checksum_valid"):
|
||||
continue
|
||||
counts["checksum_valid"] += 1
|
||||
if not _truth(row, "position_fixed"):
|
||||
continue
|
||||
counts["fixed"] += 1
|
||||
if not _position_valid(row, "BESTNAVA"):
|
||||
continue
|
||||
counts["finite_position"] += 1
|
||||
if not _finite_doppler(row):
|
||||
continue
|
||||
counts["finite_doppler"] += 1
|
||||
t_s = _f(row, "t_device_s")
|
||||
hpr_index = _nearest_index(hpr.t_s, t_s, 0.12)
|
||||
if hpr_index is None:
|
||||
continue
|
||||
counts["hpr_near"] += 1
|
||||
if not hpr.valid[hpr_index]:
|
||||
continue
|
||||
counts["hpr_q4"] += 1
|
||||
if _nearest_index(session.imu.t_s, t_s, 0.03) is None:
|
||||
continue
|
||||
counts["imu_near"] += 1
|
||||
counts["raw_bestnava_candidate"] += 1
|
||||
|
||||
reference = _height_reference([session])
|
||||
combined_nodes = _nodes(session, reference, period_s) if reference is not None else []
|
||||
best_nodes = _source_only_nodes(session, "BESTNAVA", period_s) if reference is not None else []
|
||||
combined_best = [node for node in combined_nodes if node.source == "BESTNAVA"]
|
||||
best_velocity_nodes = [node for node in combined_best if node.velocity_enu_m_s is not None]
|
||||
source_best_velocity = [node for node in best_nodes if node.velocity_enu_m_s is not None]
|
||||
run_nodes: dict[int, list] = {}
|
||||
for node in combined_nodes:
|
||||
run_nodes.setdefault(node.continuity_id, []).append(node)
|
||||
qualifying_runs = [
|
||||
run for run in run_nodes.values()
|
||||
if len(run) >= 6 and run[-1].t_s - run[0].t_s >= 5.0
|
||||
]
|
||||
qualifying_best = [
|
||||
node for run in qualifying_runs for node in run if node.source == "BESTNAVA"
|
||||
]
|
||||
qualifying_doppler = [node for node in qualifying_best if node.velocity_enu_m_s is not None]
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"batch_id": session.batch_id,
|
||||
"counts": counts,
|
||||
"combined_selected_nodes": len(combined_nodes),
|
||||
"combined_selected_bestnava": len(combined_best),
|
||||
"combined_selected_doppler": len(best_velocity_nodes),
|
||||
"best_only_selected_nodes": len(best_nodes),
|
||||
"best_only_selected_doppler": len(source_best_velocity),
|
||||
"best_suppressed_by_mixed_selection": max(0, len(best_nodes) - len(combined_best)),
|
||||
"doppler_suppressed_by_mixed_selection": max(0, len(source_best_velocity) - len(best_velocity_nodes)),
|
||||
"continuous_run_count": len(run_nodes),
|
||||
"qualified_run_count": len(qualifying_runs),
|
||||
"qualified_bestnava_factors": len(qualifying_best),
|
||||
"qualified_doppler_factors": len(qualifying_doppler),
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--sample-period-s", type=float, default=1.0)
|
||||
args = parser.parse_args(argv)
|
||||
sessions = load_unified_sessions(args.manifest)
|
||||
audit = [_audit_session(session, args.sample_period_s) for session in sessions]
|
||||
totals: dict[str, int] = {}
|
||||
for item in audit:
|
||||
for key, value in item["counts"].items():
|
||||
totals[key] = totals.get(key, 0) + int(value)
|
||||
for key in (
|
||||
"combined_selected_nodes", "combined_selected_bestnava", "combined_selected_doppler",
|
||||
"best_only_selected_nodes", "best_only_selected_doppler",
|
||||
"best_suppressed_by_mixed_selection", "doppler_suppressed_by_mixed_selection",
|
||||
"continuous_run_count", "qualified_run_count", "qualified_bestnava_factors",
|
||||
"qualified_doppler_factors",
|
||||
):
|
||||
totals[key] = totals.get(key, 0) + int(item[key])
|
||||
payload = {
|
||||
"sample_period_s": args.sample_period_s,
|
||||
"session_count": len(audit),
|
||||
"totals": totals,
|
||||
"sessions": audit,
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"session_count": len(audit), "totals": totals}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Independent prediction innovations on the frozen 267 held-out windows.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import build_problem
|
||||
from tools.audit_rtk_imu_factor_consistency import _jsonable
|
||||
from tools.audit_rtk_imu_innovation_noise import (
|
||||
_factor_report,_hpr_innovations,_interval_innovations)
|
||||
from tools.run_rtk_imu_node_graph_free_selected import _restore_segments
|
||||
|
||||
LIMITS={
|
||||
'best_position':{'bias_norm':.10,'vector_p95':.50},
|
||||
'doppler':{'bias_norm':.10,'vector_p95':.50},
|
||||
'hpr':{'bias_norm':.01,'vector_p95':.05},
|
||||
}
|
||||
|
||||
def _finite_max_abs(values):
|
||||
a=np.asarray(values,dtype=float)
|
||||
return float(np.max(np.abs(a[np.isfinite(a)]))) if np.any(np.isfinite(a)) else np.inf
|
||||
|
||||
def _summary_gate(summary,limits,min_samples=20):
|
||||
if summary.get('sample_count',0)<min_samples:
|
||||
return {'evaluated':False,'passed':True,'reason':'insufficient_samples'}
|
||||
checks={'bias_norm':float(np.linalg.norm(summary['innovation_bias']))<=limits['bias_norm'],
|
||||
'vector_p95':summary['vector_p95']<=limits['vector_p95'],
|
||||
'lag1_autocorrelation_abs':_finite_max_abs(
|
||||
summary['temporal_autocorrelation_lag1'])<=.95}
|
||||
return {'evaluated':True,'thresholds':{**limits,'lag1_abs_max':.95},
|
||||
'checks':checks,'passed':bool(all(checks.values()))}
|
||||
|
||||
def _factor_gate(report,key):
|
||||
limits=LIMITS[key]; overall=_summary_gate(report['overall'],limits,1)
|
||||
grouped={}
|
||||
for group_name in ('by_session','by_motion','by_speed','by_gyro_norm'):
|
||||
grouped[group_name]={name:_summary_gate(value,limits)
|
||||
for name,value in report[group_name].items()}
|
||||
evaluated=[gate for group in grouped.values() for gate in group.values()
|
||||
if gate['evaluated']]
|
||||
passed=overall['passed'] and all(gate['passed'] for gate in evaluated)
|
||||
return {'overall':overall,'grouped':grouped,'passed':bool(passed)}
|
||||
|
||||
def _calibration_biases(engineering,problems):
|
||||
source=engineering['prior_constrained_solution'].get(
|
||||
'calibration_only_frozen_bias_by_session')
|
||||
if not source:
|
||||
raise RuntimeError('engineering result lacks calibration-only frozen bias')
|
||||
by_date={}
|
||||
for session_id,value in source.items():
|
||||
by_date.setdefault(session_id.split('_')[1],[]).append(value)
|
||||
result={}
|
||||
for problem in problems:
|
||||
session_id=problem.segment.session_id
|
||||
if session_id in result: continue
|
||||
if session_id in source:
|
||||
value=source[session_id]; origin='same_calibration_session'
|
||||
else:
|
||||
values=by_date.get(session_id.split('_')[1],list(source.values()))
|
||||
weights=np.asarray([x['node_count'] for x in values],dtype=float)
|
||||
value={'gyro_bias_rad_s':np.average(
|
||||
[x['gyro_bias_rad_s'] for x in values],axis=0,weights=weights),
|
||||
'accel_bias_m_s2':np.average(
|
||||
[x['accel_bias_m_s2'] for x in values],axis=0,weights=weights)}
|
||||
origin=('calibration_date_weighted_mean' if
|
||||
session_id.split('_')[1] in by_date else 'calibration_global_weighted_mean')
|
||||
result[session_id]={'gyro_bias_rad_s':value['gyro_bias_rad_s'],
|
||||
'accel_bias_m_s2':value['accel_bias_m_s2'],'source':origin}
|
||||
return result
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--manifest',type=Path,required=True)
|
||||
p.add_argument('--calibration-selection',type=Path,required=True)
|
||||
p.add_argument('--all-selection',type=Path,required=True)
|
||||
p.add_argument('--engineering-result',type=Path,required=True)
|
||||
p.add_argument('--output',type=Path,required=True)
|
||||
p.add_argument('--sample-period-s',type=float,default=1.)
|
||||
p.add_argument('--hpr-direct-sigma-rad',type=float,default=.006)
|
||||
p.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
p.add_argument('--circle-session',default='0808_20260808_092827')
|
||||
p.add_argument('--left-right-session',default='0808_20260808_082148')
|
||||
p.add_argument('--slope-session',default='0815_20260812_123424')
|
||||
args=p.parse_args()
|
||||
engineering=json.loads(args.engineering_result.read_text(encoding='utf-8'))
|
||||
calibration=json.loads(args.calibration_selection.read_text(encoding='utf-8'))
|
||||
selected=json.loads(args.all_selection.read_text(encoding='utf-8'))
|
||||
calibration_ids={x['candidate_id'] for x in calibration['selected_windows']}
|
||||
heldout=[x for x in selected['selected_windows']
|
||||
if x['candidate_id'] not in calibration_ids]
|
||||
if len(calibration_ids)!=47 or len(heldout)!=267:
|
||||
raise RuntimeError(f'expected 47+267 windows, got {len(calibration_ids)}+{len(heldout)}')
|
||||
shared=sum(x.get('shared_sample_count_with_previous',{}).get(k,0)
|
||||
for x in selected['selected_windows'] for k in ('imu','gnss','hpr'))
|
||||
if shared: raise RuntimeError(f'selection contains {shared} shared samples')
|
||||
lever=np.asarray(engineering.get('engineering_l_I_m',
|
||||
engineering['prior_constrained_solution']['result']['final_l_I_m']),dtype=float)
|
||||
sessions=load_unified_sessions(
|
||||
args.manifest,selected_session_ids={x['session_id'] for x in heldout})
|
||||
reference=_height_reference(sessions)
|
||||
segments=_restore_segments(sessions,reference,heldout,args.sample_period_s)
|
||||
rotation=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
problems=[build_problem(segment,rotation,lever,args.hpr_direct_sigma_rad)
|
||||
for segment in segments]
|
||||
session_by_id={session.session_id:session for session in sessions}
|
||||
motion_by_session={args.circle_session:'circle',
|
||||
args.left_right_session:'left_right',args.slope_session:'slope'}
|
||||
records={key:[] for key in ('best_position','doppler','hpr','imu_preintegration')}
|
||||
biases=_calibration_biases(engineering,problems)
|
||||
for problem in problems:
|
||||
motion=motion_by_session.get(problem.segment.session_id,'other_recovered_dynamic')
|
||||
bias=biases[problem.segment.session_id]
|
||||
interval=_interval_innovations(problem,motion,
|
||||
bias['gyro_bias_rad_s'],bias['accel_bias_m_s2'])
|
||||
for key,value in interval.items(): records[key].extend(value)
|
||||
records['hpr'].extend(_hpr_innovations(
|
||||
session_by_id[problem.segment.session_id],problem,motion))
|
||||
factors={key:_factor_report(value) for key,value in records.items()}
|
||||
gates={key:_factor_gate(factors[key],key)
|
||||
for key in ('best_position','doppler','hpr')}
|
||||
passed=bool(all(value['passed'] for value in gates.values()))
|
||||
payload={'scope':'267-window independent held-out prediction innovations',
|
||||
'least_squares_called':False,'target_observation_used_by_predictor':False,
|
||||
'lever_reoptimized':False,'rotation_reoptimized':False,
|
||||
'covariance_parameters_modified':False,
|
||||
'fixed_l_I_m':lever,'fixed_rotation_rpy_deg':args.rotation_rpy_deg,
|
||||
'calibration_window_count':47,'heldout_window_count':267,
|
||||
'calibration_heldout_overlap_count':0,
|
||||
'motion_class_mapping':motion_by_session,
|
||||
'prediction_definition':{
|
||||
'BEST_position':'previous GNSS p/v + IMU preintegration + fixed lever',
|
||||
'Doppler':'previous GNSS velocity + IMU preintegration + omega-cross-lever',
|
||||
'HPR':'withheld direct HPR predicted by LOO interpolation and gyro propagation',
|
||||
'IMU_preintegration':'two independently GNSS/HPR-anchored endpoints'},
|
||||
'bias_source':('frozen prior-node bias from the disjoint 47-window calibration set; '
|
||||
'same-session when available, otherwise calibration date-weighted mean; '
|
||||
'no held-out target observation and no covariance writeback'),
|
||||
'per_session_frozen_bias':biases,
|
||||
'factors':factors,'validation_limits_are_physical_not_covariance_retuning':LIMITS,
|
||||
'factor_gates':gates,'independent_heldout_innovation_passed':passed}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
compact={key:{'samples':value['overall'].get('sample_count',0),
|
||||
'bias':value['overall'].get('innovation_bias'),
|
||||
'vector_p95':value['overall'].get('vector_p95'),
|
||||
'nis_per_dof':value['overall'].get('nis_per_dof'),
|
||||
'gate':gates.get(key)} for key,value in factors.items()}
|
||||
print(json.dumps(_jsonable({'factors':compact,
|
||||
'independent_heldout_innovation_passed':passed}),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Independent prediction/innovation noise audit for node-state RTK/IMU factors.'''
|
||||
from __future__ import annotations
|
||||
import argparse, json, math, sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.geometry import so3_log
|
||||
from imu_lidar.imu_preintegration import apply_bias_correction_imu,preintegrate_gyro
|
||||
from imu_lidar.rtk_imu_engineering import (
|
||||
G_ENU,HPR_DIRECT_ANGULAR_SIGMA_RAD,_all_hpr,_height_reference,
|
||||
_world_rtk)
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import build_problem
|
||||
from tools.audit_rtk_imu_factor_consistency import MECHANICAL_L_I_M,_jsonable
|
||||
from tools.run_rtk_imu_node_graph_fixed_lever import _select_window
|
||||
|
||||
BEST_SIGMA = np.array([.06,.06,.12])
|
||||
DOPPLER_SIGMA = np.array([.15,.15,.30])
|
||||
|
||||
def _whiten(value,covariance):
|
||||
covariance = .5*(covariance+covariance.T)+np.eye(len(value))*1e-12
|
||||
return np.linalg.solve(np.linalg.cholesky(covariance),value)
|
||||
|
||||
def _bin_speed(value):
|
||||
if value < .2: return 'speed_lt_0p2'
|
||||
if value < 1.: return 'speed_0p2_to_1'
|
||||
return 'speed_ge_1'
|
||||
|
||||
def _bin_gyro(value):
|
||||
if value < .02: return 'gyro_lt_0p02'
|
||||
if value < .10: return 'gyro_0p02_to_0p10'
|
||||
return 'gyro_ge_0p10'
|
||||
|
||||
def _bin_gap(value):
|
||||
if not np.isfinite(value): return 'gap_unavailable'
|
||||
if value <= .12: return 'gap_direct'
|
||||
if value <= .3: return 'gap_0p12_to_0p3'
|
||||
if value <= .5: return 'gap_0p3_to_0p5'
|
||||
return 'gap_gt_0p5'
|
||||
|
||||
def _distribution(values):
|
||||
a = np.asarray(values,dtype=float).reshape(-1)
|
||||
if not a.size:
|
||||
return {'count':0,'rms':np.nan,'p50_abs':np.nan,'p95_abs':np.nan,'p99_abs':np.nan}
|
||||
return {'count':len(a),'rms':float(np.sqrt(np.mean(a*a))),
|
||||
'p50_abs':float(np.percentile(np.abs(a),50.)),
|
||||
'p95_abs':float(np.percentile(np.abs(a),95.)),
|
||||
'p99_abs':float(np.percentile(np.abs(a),99.))}
|
||||
|
||||
def _autocorrelation(vectors):
|
||||
a = np.asarray(vectors,dtype=float)
|
||||
result = np.full(a.shape[1] if a.ndim == 2 else 0,np.nan)
|
||||
if a.ndim != 2 or len(a) < 3: return result
|
||||
for axis in range(a.shape[1]):
|
||||
left,right = a[:-1,axis],a[1:,axis]
|
||||
if np.std(left)>1e-12 and np.std(right)>1e-12:
|
||||
result[axis] = np.corrcoef(left,right)[0,1]
|
||||
return result
|
||||
|
||||
def _summarize(records):
|
||||
if not records: return {'sample_count':0}
|
||||
residual = np.asarray([item['residual'] for item in records])
|
||||
normalized = np.asarray([item['normalized'] for item in records])
|
||||
factor_only = np.asarray([item['normalized_factor_only'] for item in records])
|
||||
nis = np.sum(normalized*normalized,axis=1)
|
||||
dimension = residual.shape[1]
|
||||
effective_dimension = int(records[0].get('effective_dimension',dimension))
|
||||
total_dof = len(records)*effective_dimension
|
||||
norm = np.linalg.norm(residual,axis=1)
|
||||
centered_t = np.asarray([item['t_s'] for item in records],dtype=float)
|
||||
centered_t -= np.mean(centered_t)
|
||||
temporal_drift = np.zeros(dimension)
|
||||
if len(records)>=3 and np.ptp(centered_t)>1e-9:
|
||||
temporal_drift = np.asarray([
|
||||
np.polyfit(centered_t,residual[:,axis],1)[0]
|
||||
for axis in range(dimension)])
|
||||
return {'sample_count':len(records),'dimension':dimension,
|
||||
'effective_dof_per_sample':effective_dimension,
|
||||
'innovation_bias':np.mean(residual,axis=0),
|
||||
'innovation_distribution':_distribution(residual),
|
||||
'axis_rms':np.sqrt(np.mean(residual*residual,axis=0)),
|
||||
'axis_p50_abs':np.percentile(np.abs(residual),50.,axis=0),
|
||||
'axis_p95_abs':np.percentile(np.abs(residual),95.,axis=0),
|
||||
'axis_p99_abs':np.percentile(np.abs(residual),99.,axis=0),
|
||||
'vector_rms':float(np.sqrt(np.mean(norm*norm))),
|
||||
'vector_p50':float(np.percentile(norm,50.)),
|
||||
'vector_p95':float(np.percentile(norm,95.)),
|
||||
'vector_p99':float(np.percentile(norm,99.)),
|
||||
'normalized_distribution':_distribution(normalized),
|
||||
'empirical_covariance':np.cov(residual,rowvar=False),
|
||||
'nis_distribution':_distribution(nis),
|
||||
'nis_total':float(np.sum(nis)),'nis_per_dof':float(np.sum(nis)/total_dof),
|
||||
'alpha_factor':float(np.sum(nis)/total_dof),
|
||||
'alpha_composite_prediction':float(np.sum(nis)/total_dof),
|
||||
'alpha_factor_only_upper_bound':float(np.sum(factor_only*factor_only)/total_dof),
|
||||
'temporal_autocorrelation_lag1':_autocorrelation(residual),
|
||||
'temporal_linear_drift_per_s':temporal_drift}
|
||||
|
||||
def _group(records,key):
|
||||
values = {}
|
||||
for item in records: values.setdefault(str(item[key]),[]).append(item)
|
||||
return {name:_summarize(items) for name,items in values.items()}
|
||||
|
||||
def _base_record(session_id,motion,t,speed,gyro_norm,gap):
|
||||
return {'session':session_id,'motion':motion,'t_s':float(t),
|
||||
'speed_bin':_bin_speed(speed),'gyro_bin':_bin_gyro(gyro_norm),
|
||||
'hpr_gap_bin':_bin_gap(gap)}
|
||||
|
||||
def _interval_innovations(problem,motion,bg=None,ba=None):
|
||||
records = {'best_position':[],'doppler':[],'imu_preintegration':[]}
|
||||
lever = problem.fixed_l_I_m
|
||||
bg=np.zeros(3) if bg is None else np.asarray(bg,dtype=float)
|
||||
ba=np.zeros(3) if ba is None else np.asarray(ba,dtype=float)
|
||||
nodes = problem.segment.nodes
|
||||
for index,(left,right,pre) in enumerate(zip(nodes[:-1],nodes[1:],problem.segment.preintegrations)):
|
||||
if not (left.hpr_factor_valid and right.hpr_factor_valid): continue
|
||||
if left.velocity_enu_m_s is None or right.velocity_enu_m_s is None: continue
|
||||
R0 = _world_rtk(left.baseline_enu)@problem.R_RTK_IMU
|
||||
R1 = _world_rtk(right.baseline_enu)@problem.R_RTK_IMU
|
||||
p_i0 = left.p_enu_m-R0@lever
|
||||
p_i1 = right.p_enu_m-R1@lever
|
||||
v_i0 = left.velocity_enu_m_s-R0@np.cross(left.gyro_rad_s-bg,lever)
|
||||
v_i1 = right.velocity_enu_m_s-R1@np.cross(right.gyro_rad_s-bg,lever)
|
||||
dt = pre.duration_s
|
||||
delta_R,delta_v,delta_p=apply_bias_correction_imu(pre,bg,ba)
|
||||
pred_p_i1 = p_i0+v_i0*dt+.5*G_ENU*dt*dt+R0@delta_p
|
||||
pred_v_i1 = v_i0+G_ENU*dt+R0@delta_v
|
||||
pred_R1 = R0@delta_R
|
||||
p_innovation = pred_p_i1+pred_R1@lever-right.p_enu_m
|
||||
v_innovation = pred_v_i1+pred_R1@np.cross(
|
||||
right.gyro_rad_s-bg,lever)-right.velocity_enu_m_s
|
||||
speed = float(np.linalg.norm(left.velocity_enu_m_s))
|
||||
gyro_norm = float(pre.mean_gyro_norm)
|
||||
gap = max(left.hpr_support_gap_s,right.hpr_support_gap_s)
|
||||
base = _base_record(problem.segment.session_id,motion,right.t_s,speed,gyro_norm,gap)
|
||||
base.update({'interval_id':f'{problem.segment.segment_id}:{index}',
|
||||
'dt_s':float(dt),'R0_WI':R0})
|
||||
p_cov = (np.diag(BEST_SIGMA**2)+dt*dt*np.diag(DOPPLER_SIGMA**2)
|
||||
+R0@pre.cov[6:9,6:9]@R0.T+np.diag(BEST_SIGMA**2))
|
||||
v_cov = (np.diag(DOPPLER_SIGMA**2)+R0@pre.cov[3:6,3:6]@R0.T
|
||||
+np.diag(DOPPLER_SIGMA**2))
|
||||
records['best_position'].append({**base,'residual':p_innovation,
|
||||
'normalized':_whiten(p_innovation,p_cov),
|
||||
'normalized_factor_only':p_innovation/BEST_SIGMA})
|
||||
records['doppler'].append({**base,'residual':v_innovation,
|
||||
'normalized':_whiten(v_innovation,v_cov),
|
||||
'normalized_factor_only':v_innovation/DOPPLER_SIGMA})
|
||||
imu_error = np.concatenate([
|
||||
so3_log(delta_R.T@R0.T@R1),
|
||||
R0.T@(v_i1-v_i0-G_ENU*dt)-delta_v,
|
||||
R0.T@(p_i1-p_i0-v_i0*dt-.5*G_ENU*dt*dt)-delta_p])
|
||||
anchored_cov = pre.cov.copy()
|
||||
hpr_var = left.hpr_angular_sigma_rad**2+right.hpr_angular_sigma_rad**2
|
||||
anchored_cov[:3,:3] += np.eye(3)*hpr_var
|
||||
anchored_cov[3:6,3:6] += R0.T@np.diag(2.*DOPPLER_SIGMA**2)@R0
|
||||
anchored_cov[6:9,6:9] += R0.T@np.diag(
|
||||
2.*BEST_SIGMA**2+dt*dt*DOPPLER_SIGMA**2)@R0
|
||||
records['imu_preintegration'].append({**base,'residual':imu_error,
|
||||
'normalized':_whiten(imu_error,anchored_cov),
|
||||
'normalized_factor_only':_whiten(imu_error,pre.cov)})
|
||||
return records
|
||||
|
||||
def _hpr_innovations(session,problem,motion):
|
||||
hpr = _all_hpr(session)
|
||||
indices = [int(i) for i in hpr.valid_indices
|
||||
if problem.segment.nodes[0].t_s <= hpr.t_s[i] <= problem.segment.nodes[-1].t_s]
|
||||
records = []
|
||||
baseline_I = problem.R_RTK_IMU.T[:,0]
|
||||
node_times = np.asarray([node.t_s for node in problem.segment.nodes])
|
||||
for position in range(1,len(indices)-1):
|
||||
left,index,right = indices[position-1],indices[position],indices[position+1]
|
||||
t0,t,t1 = hpr.t_s[left],hpr.t_s[index],hpr.t_s[right]
|
||||
total_gap = float(t1-t0)
|
||||
if not 0. < total_gap <= .5: continue
|
||||
fraction = float((t-t0)/total_gap)
|
||||
predicted = (1.-fraction)*hpr.baseline_enu[left]+fraction*hpr.baseline_enu[right]
|
||||
predicted /= np.linalg.norm(predicted)
|
||||
innovation = np.cross(predicted,hpr.baseline_enu[index])
|
||||
sigma = HPR_DIRECT_ANGULAR_SIGMA_RAD*np.sqrt(
|
||||
1.+(1.-fraction)**2+fraction**2)
|
||||
nearest = int(np.argmin(np.abs(node_times-t)))
|
||||
node = problem.segment.nodes[nearest]
|
||||
speed = float(np.linalg.norm(node.velocity_enu_m_s)) if node.velocity_enu_m_s is not None else 0.
|
||||
gyro_norm = float(np.linalg.norm(node.gyro_rad_s))
|
||||
base = _base_record(session.session_id,motion,t,speed,gyro_norm,total_gap)
|
||||
records.append({**base,'method':'leave_one_out_interpolation',
|
||||
'effective_dimension':2,'residual':innovation,
|
||||
'normalized':innovation/sigma,
|
||||
'normalized_factor_only':innovation/HPR_DIRECT_ANGULAR_SIGMA_RAD})
|
||||
gyro_pre = preintegrate_gyro(session.imu.t_s,session.imu.gyro_rad_s,float(t0),float(t))
|
||||
R0 = _world_rtk(hpr.baseline_enu[left])@problem.R_RTK_IMU
|
||||
propagated = R0@gyro_pre.delta_R@baseline_I
|
||||
gyro_innovation = np.cross(propagated,hpr.baseline_enu[index])
|
||||
gyro_sigma = np.sqrt(2.*HPR_DIRECT_ANGULAR_SIGMA_RAD**2
|
||||
+float(np.trace(gyro_pre.cov))/3.)
|
||||
records.append({**base,'method':'gyro_propagation',
|
||||
'effective_dimension':2,'residual':gyro_innovation,
|
||||
'normalized':gyro_innovation/gyro_sigma,
|
||||
'normalized_factor_only':gyro_innovation/HPR_DIRECT_ANGULAR_SIGMA_RAD})
|
||||
return records
|
||||
|
||||
def _factor_report(records):
|
||||
return {'overall':_summarize(records),
|
||||
'by_session':_group(records,'session'),'by_motion':_group(records,'motion'),
|
||||
'by_speed':_group(records,'speed_bin'),'by_gyro_norm':_group(records,'gyro_bin'),
|
||||
'by_hpr_bridge_gap':_group(records,'hpr_gap_bin'),
|
||||
'by_method':_group(records,'method') if records and 'method' in records[0] else {}}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--circle-session',required=True)
|
||||
parser.add_argument('--left-right-session',required=True)
|
||||
parser.add_argument('--slope-session',required=True)
|
||||
parser.add_argument('--sample-period-s',type=float,default=1.)
|
||||
parser.add_argument('--target-duration-s',type=float,default=15.)
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
parser.add_argument('--mechanical-l-I-m',nargs=3,type=float,
|
||||
default=MECHANICAL_L_I_M.tolist())
|
||||
args = parser.parse_args()
|
||||
categories = {'circle':args.circle_session,'left_right':args.left_right_session,
|
||||
'slope':args.slope_session}
|
||||
sessions = load_unified_sessions(args.manifest,selected_session_ids=set(categories.values()))
|
||||
reference = _height_reference(sessions)
|
||||
if reference is None: raise RuntimeError('no BEST reference')
|
||||
rotation = Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
lever = np.asarray(args.mechanical_l_I_m)
|
||||
selections, all_records = {}, {
|
||||
'best_position':[],'doppler':[],'hpr':[],'imu_preintegration':[]}
|
||||
for motion,session_id in categories.items():
|
||||
session = next(item for item in sessions if item.session_id == session_id)
|
||||
segment,selection = _select_window(
|
||||
[session],reference,args.sample_period_s,args.target_duration_s)
|
||||
selections[motion] = selection
|
||||
problem = build_problem(segment,rotation,lever)
|
||||
interval = _interval_innovations(problem,motion)
|
||||
for key,records in interval.items(): all_records[key].extend(records)
|
||||
all_records['hpr'].extend(_hpr_innovations(session,problem,motion))
|
||||
factors = {key:_factor_report(records) for key,records in all_records.items()}
|
||||
payload = {
|
||||
'scope':'independent prediction innovations; no node optimization/covariance writeback',
|
||||
'least_squares_called':False,'covariance_parameters_modified':False,
|
||||
'fixed_l_I_m':lever,'selections':selections,
|
||||
'current_physical_sigma':{
|
||||
'best_position_xyz_m':BEST_SIGMA,
|
||||
'doppler_xyz_m_s':DOPPLER_SIGMA,
|
||||
'hpr_direct_angular_rad':HPR_DIRECT_ANGULAR_SIGMA_RAD,
|
||||
'bias_random_walk_source':'unchanged device/static/Allan noise model'},
|
||||
'alpha_semantics':{
|
||||
'alpha_factor':'innovation NIS/dof using composite prediction covariance',
|
||||
'alpha_factor_only_upper_bound':(
|
||||
'innovation divided only by current factor covariance; includes predictor and '
|
||||
'endpoint-anchor noise and must not be written back directly')},
|
||||
'factors':factors,
|
||||
'recommended_covariance_writeback':False,
|
||||
'covariance_freeze_assessment':{
|
||||
'best_position':'predictor-confounded; unchanged',
|
||||
'doppler':'predictor-confounded; unchanged',
|
||||
'imu_preintegration':'endpoint-anchor dominated; unchanged',
|
||||
'bias_random_walk':'static/Allan/device-model based; unchanged',
|
||||
'hpr':{
|
||||
'identifiable':True,
|
||||
'evidence':'264 withheld samples; LOO and gyro predictions agree',
|
||||
'frozen_direct_sigma_rad':.006,
|
||||
'writeback_policy':'explicit audit decision, not automatic alpha'}}}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
compact = {key:{'count':value['overall'].get('sample_count',0),
|
||||
'bias':value['overall'].get('innovation_bias'),
|
||||
'rms':value['overall'].get('innovation_distribution',{}).get('rms'),
|
||||
'p95':value['overall'].get('innovation_distribution',{}).get('p95_abs'),
|
||||
'alpha_composite':value['overall'].get('alpha_composite_prediction'),
|
||||
'alpha_factor_only_upper_bound':value['overall'].get('alpha_factor_only_upper_bound'),
|
||||
'autocorrelation':value['overall'].get('temporal_autocorrelation_lag1')}
|
||||
for key,value in factors.items()}
|
||||
print(json.dumps(_jsonable(compact),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit strict RTK--IMU segments for lever-arm excitation and marginal information."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from imu_lidar.rtk_imu_engineering import _fit_segments, _fit_summary, _segments
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
|
||||
|
||||
def _jsonable(value):
|
||||
if isinstance(value, np.ndarray):
|
||||
return _jsonable(value.tolist())
|
||||
if isinstance(value, np.generic):
|
||||
return _jsonable(value.item())
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else None
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {key: _jsonable(item) for key, item in asdict(value).items()}
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _orientation_spans_deg(segment, rotation_rtk_imu: np.ndarray) -> np.ndarray:
|
||||
matrices = [segment.R_WRTK_initial @ rotation_rtk_imu]
|
||||
for pre in segment.preintegrations:
|
||||
matrices.append(matrices[-1] @ pre.delta_R)
|
||||
euler = Rotation.from_matrix(np.asarray(matrices)).as_euler("xyz", degrees=False)
|
||||
return np.degrees(np.ptp(np.unwrap(euler, axis=0), axis=0))
|
||||
|
||||
|
||||
def _audit_segment(segment, rotation_rtk_imu: np.ndarray, max_nfev: int) -> dict[str, object]:
|
||||
gyro = np.asarray([node.gyro_rad_s for node in segment.nodes])
|
||||
gyro_norm = np.linalg.norm(gyro, axis=1)
|
||||
best_count = sum(node.source == "BESTNAVA" for node in segment.nodes)
|
||||
doppler_count = sum(
|
||||
node.source == "BESTNAVA" and node.velocity_enu_m_s is not None
|
||||
for node in segment.nodes
|
||||
)
|
||||
fit, residual, detail = _fit_segments([segment], rotation_rtk_imu, max_nfev=max_nfev)
|
||||
summary = _fit_summary(fit, residual, detail)
|
||||
item: dict[str, object] = {
|
||||
"segment_id": segment.segment_id,
|
||||
"session_id": segment.session_id,
|
||||
"node_count": len(segment.nodes),
|
||||
"duration_s": float(segment.nodes[-1].t_s - segment.nodes[0].t_s),
|
||||
"yaw_pitch_roll_span_deg": _orientation_spans_deg(segment, rotation_rtk_imu)[[2, 1, 0]],
|
||||
"gyro_rms_deg_s": np.degrees(np.sqrt(np.mean(gyro ** 2, axis=0))),
|
||||
"gyro_peak_deg_s": np.degrees(np.max(np.abs(gyro), axis=0)),
|
||||
"gyro_norm_rms_deg_s": float(np.degrees(np.sqrt(np.mean(gyro_norm ** 2)))),
|
||||
"gyro_norm_peak_deg_s": float(np.degrees(np.max(gyro_norm))),
|
||||
"bestnava_count": best_count,
|
||||
"doppler_count": doppler_count,
|
||||
"fit": None,
|
||||
}
|
||||
if summary is not None:
|
||||
item["fit"] = {
|
||||
"optimizer_converged": summary.optimizer_converged,
|
||||
"lever_information_singular_values": summary.lever_information_singular_values,
|
||||
"lever_information_condition_number": summary.lever_information_condition_number,
|
||||
"lever_precision_rank": summary.lever_precision_rank,
|
||||
"weakest_lever_direction_I": summary.weakest_lever_direction_I,
|
||||
"lever_std_m": summary.l_I_std_m,
|
||||
}
|
||||
singular = summary.lever_information_singular_values
|
||||
item["information_score"] = float(singular[-1]) if summary.lever_precision_rank == 3 else 0.0
|
||||
else:
|
||||
item["information_score"] = 0.0
|
||||
spans = np.asarray(item["yaw_pitch_roll_span_deg"])
|
||||
# Short strict runs rarely accumulate a full vehicle turn; retain clearly non-straight motion.
|
||||
item["turn_or_slope"] = bool(spans[0] >= 3.0 or abs(spans[1]) >= 0.5 or abs(spans[2]) >= 0.5)
|
||||
return item
|
||||
|
||||
|
||||
def _recommended(items: list[dict[str, object]]) -> list[str]:
|
||||
candidates = [
|
||||
item for item in items
|
||||
if item["turn_or_slope"] and int(item["bestnava_count"]) >= 6
|
||||
and int(item["doppler_count"]) >= 6
|
||||
and item["fit"] is not None
|
||||
and int(item["fit"]["lever_precision_rank"]) == 3
|
||||
]
|
||||
candidates.sort(key=lambda item: float(item["information_score"]), reverse=True)
|
||||
selected: list[str] = []
|
||||
per_session: dict[str, int] = {}
|
||||
for item in candidates:
|
||||
session_id = str(item["session_id"])
|
||||
if per_session.get(session_id, 0) >= 2:
|
||||
continue
|
||||
selected.append(str(item["segment_id"]))
|
||||
per_session[session_id] = per_session.get(session_id, 0) + 1
|
||||
return selected
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--sample-period-s", type=float, default=1.0)
|
||||
parser.add_argument("--rotation-rpy-deg", nargs=3, type=float,
|
||||
default=[0.4543066225, -0.0026392019, 0.0122384129])
|
||||
parser.add_argument("--max-nfev", type=int, default=80)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
sessions = load_unified_sessions(args.manifest)
|
||||
rotation = Rotation.from_euler("xyz", args.rotation_rpy_deg, degrees=True).as_matrix()
|
||||
items = [
|
||||
_audit_segment(segment, rotation, args.max_nfev)
|
||||
for segment in _segments(sessions, args.sample_period_s)
|
||||
]
|
||||
payload = {
|
||||
"session_count": len(sessions),
|
||||
"strict_segment_count": len(items),
|
||||
"sample_period_s": args.sample_period_s,
|
||||
"rotation_rpy_deg": args.rotation_rpy_deg,
|
||||
"recommended_segment_ids": _recommended(items),
|
||||
"segments": items,
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload), ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
print(json.dumps({
|
||||
"strict_segment_count": len(items),
|
||||
"recommended_segment_ids": payload["recommended_segment_ids"],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,668 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Penetration audit for RTK--IMU motion excitation.
|
||||
|
||||
This is read-only diagnostics. It never applies a lever prior, solves a lever arm,
|
||||
or changes continuity/acceptance thresholds. Gyro trajectory integrals are the
|
||||
primary excitation metrics; start/end Euler differences are deliberately absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from imu_lidar.imu_audit import audit_imu
|
||||
from imu_lidar.rtk_imu_engineering import (
|
||||
MIN_SEGMENT_DURATION_S,
|
||||
MIN_SEGMENT_NODE_COUNT,
|
||||
_all_hpr,
|
||||
_height_reference,
|
||||
_hpr_factor_observation,
|
||||
_node_interval_threshold_s,
|
||||
_trajectory_continuity_reasons,
|
||||
_nearest_index,
|
||||
_nodes,
|
||||
_position_valid,
|
||||
_segments,
|
||||
_source_nodes,
|
||||
)
|
||||
from imu_lidar.rtk_imu_multisource import _f, _truth, load_unified_sessions
|
||||
|
||||
|
||||
RAW_GAP_S = 1.5
|
||||
|
||||
|
||||
def _jsonable(value):
|
||||
if isinstance(value, np.ndarray):
|
||||
return _jsonable(value.tolist())
|
||||
if isinstance(value, np.generic):
|
||||
return _jsonable(value.item())
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else None
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {key: _jsonable(item) for key, item in asdict(value).items()}
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _trapz(values: np.ndarray, t_s: np.ndarray) -> np.ndarray:
|
||||
if t_s.size < 2:
|
||||
return np.zeros(values.shape[1], dtype=float)
|
||||
return np.trapezoid(values, t_s, axis=0)
|
||||
|
||||
|
||||
def _interval_imu(session, start_s: float, end_s: float) -> tuple[np.ndarray, np.ndarray]:
|
||||
mask = (session.imu.t_s >= start_s) & (session.imu.t_s <= end_s)
|
||||
return session.imu.t_s[mask], session.imu.gyro_rad_s[mask]
|
||||
|
||||
|
||||
def _gyro_metrics(session, start_s: float, end_s: float, gyro_offset_rad_s: np.ndarray | None = None) -> dict[str, object]:
|
||||
t_s, gyro = _interval_imu(session, start_s, end_s)
|
||||
if gyro_offset_rad_s is not None:
|
||||
gyro = gyro - np.asarray(gyro_offset_rad_s, dtype=float).reshape(1, 3)
|
||||
if t_s.size < 2:
|
||||
nan = np.full(3, np.nan)
|
||||
return {
|
||||
"sample_count": int(t_s.size), "net_rotation_xyz_deg": nan,
|
||||
"unwrap_rotation_range_xyz_deg": nan,
|
||||
"cumulative_absolute_rotation_xyz_deg": nan,
|
||||
"gyro_integral_squared_xyz_rad2_s": nan,
|
||||
"gyro_rms_xyz_deg_s": nan, "gyro_peak_xyz_deg_s": nan,
|
||||
}
|
||||
dt = np.diff(t_s)
|
||||
midpoint = 0.5 * (gyro[:-1] + gyro[1:])
|
||||
trajectory = np.vstack([np.zeros(3), np.cumsum(midpoint * dt[:, None], axis=0)])
|
||||
# The integrated trajectory is continuous. Explicit unwrap documents that
|
||||
# the yaw range is never inferred from a wrapped heading/Euler endpoint.
|
||||
trajectory[:, 2] = np.unwrap(trajectory[:, 2])
|
||||
duration = float(t_s[-1] - t_s[0])
|
||||
return {
|
||||
"sample_count": int(t_s.size),
|
||||
"net_rotation_xyz_deg": np.degrees(trajectory[-1]),
|
||||
"unwrap_rotation_range_xyz_deg": np.degrees(np.ptp(trajectory, axis=0)),
|
||||
"cumulative_absolute_rotation_xyz_deg": np.degrees(_trapz(np.abs(gyro), t_s)),
|
||||
"gyro_integral_squared_xyz_rad2_s": _trapz(gyro * gyro, t_s),
|
||||
"gyro_rms_xyz_deg_s": np.degrees(np.sqrt(_trapz(gyro * gyro, t_s) / duration)),
|
||||
"gyro_peak_xyz_deg_s": np.degrees(np.max(np.abs(gyro), axis=0)),
|
||||
}
|
||||
|
||||
|
||||
def _interval_summary(session, start_s: float, end_s: float, *, label: str,
|
||||
best_rows: Iterable[dict[str, str]], hpr) -> dict[str, object]:
|
||||
best = list(best_rows)
|
||||
in_range = [row for row in best if start_s <= _f(row, "t_device_s") <= end_s]
|
||||
doppler = [
|
||||
row for row in in_range
|
||||
if _truth(row, "doppler_velocity_valid") and np.all(np.isfinite([
|
||||
_f(row, "velocity_east_m_s"), _f(row, "velocity_north_m_s"),
|
||||
_f(row, "vertical_speed_m_s"),
|
||||
]))
|
||||
]
|
||||
q4 = hpr.valid & (hpr.t_s >= start_s) & (hpr.t_s <= end_s)
|
||||
return {
|
||||
"label": label,
|
||||
"start_s": float(start_s), "end_s": float(end_s),
|
||||
"duration_s": float(max(0.0, end_s - start_s)),
|
||||
"bestnava_count": len(in_range), "doppler_count": len(doppler),
|
||||
"q4_hpr_count": int(np.count_nonzero(q4)),
|
||||
"gyro": _gyro_metrics(session, start_s, end_s),
|
||||
}
|
||||
|
||||
|
||||
def _coalesce(records: list[dict[str, object]], *, include: bool, label: str,
|
||||
session, best_rows, hpr) -> list[dict[str, object]]:
|
||||
result: list[dict[str, object]] = []
|
||||
current: list[dict[str, object]] = []
|
||||
key: tuple[str, ...] | None = None
|
||||
for record in records:
|
||||
active = bool(record["accepted"]) == include
|
||||
reasons = tuple(record["reasons"])
|
||||
same = (
|
||||
current and active and key == reasons
|
||||
and float(record["t_s"]) - float(current[-1]["t_s"]) <= RAW_GAP_S
|
||||
)
|
||||
if active and (not current or same):
|
||||
current.append(record)
|
||||
key = reasons
|
||||
continue
|
||||
if current:
|
||||
summary = _interval_summary(
|
||||
session, float(current[0]["t_s"]), float(current[-1]["t_s"]),
|
||||
label=label, best_rows=best_rows, hpr=hpr,
|
||||
)
|
||||
if not include:
|
||||
summary["cut_reason"] = list(key or ())
|
||||
result.append(summary)
|
||||
current = [record] if active else []
|
||||
key = reasons if active else None
|
||||
if current:
|
||||
summary = _interval_summary(
|
||||
session, float(current[0]["t_s"]), float(current[-1]["t_s"]),
|
||||
label=label, best_rows=best_rows, hpr=hpr,
|
||||
)
|
||||
if not include:
|
||||
summary["cut_reason"] = list(key or ())
|
||||
result.append(summary)
|
||||
return result
|
||||
|
||||
|
||||
def _raw_records(session) -> list[dict[str, object]]:
|
||||
"""Raw-valid is position/IMU validity; HPR support remains a separate factor audit."""
|
||||
hpr = _all_hpr(session)
|
||||
rows = session.rtk_by_type.get("BESTNAVA", [])
|
||||
ordered = sorted(rows, key=lambda row: _f(row, "t_device_s"))
|
||||
records: list[dict[str, object]] = []
|
||||
last_t = -np.inf
|
||||
for row in ordered:
|
||||
t_s = _f(row, "t_device_s")
|
||||
reasons: list[str] = []
|
||||
if not np.isfinite(t_s):
|
||||
reasons.append("position_device_time_invalid")
|
||||
elif t_s <= last_t:
|
||||
reasons.append("position_device_time_nonmonotonic")
|
||||
if np.isfinite(t_s):
|
||||
last_t = max(last_t, t_s)
|
||||
if not _truth(row, "checksum_valid"):
|
||||
reasons.append("position_checksum_invalid")
|
||||
if not _truth(row, "position_fixed"):
|
||||
reasons.append("position_not_fixed")
|
||||
if not _position_valid(row, "BESTNAVA"):
|
||||
reasons.append("position_required_field_invalid")
|
||||
imu_index = _nearest_index(session.imu.t_s, t_s, 0.03) if np.isfinite(t_s) else None
|
||||
if imu_index is None:
|
||||
reasons.append("imu_missing_near")
|
||||
_, _, hpr_factor_valid, hpr_method, hpr_gap = _hpr_factor_observation(hpr, t_s)
|
||||
doppler_ok = bool(
|
||||
_truth(row, "doppler_velocity_valid") and np.all(np.isfinite([
|
||||
_f(row, "velocity_east_m_s"), _f(row, "velocity_north_m_s"),
|
||||
_f(row, "vertical_speed_m_s"),
|
||||
]))
|
||||
)
|
||||
records.append({
|
||||
"t_s": t_s, "row": row, "accepted": not reasons,
|
||||
"reasons": sorted(set(reasons)), "doppler_valid": doppler_ok,
|
||||
"hpr_factor_valid": hpr_factor_valid, "hpr_factor_method": hpr_method,
|
||||
"hpr_support_gap_s": hpr_gap,
|
||||
})
|
||||
return records
|
||||
def _r0_runs_and_cuts(session, nodes, hpr, best_rows, period_s: float) -> tuple[list[dict[str, object]], list[dict[str, object]], list[dict[str, object]]]:
|
||||
runs: list[list] = []
|
||||
cuts: list[dict[str, object]] = []
|
||||
intervals: list[dict[str, object]] = []
|
||||
if not nodes:
|
||||
return [], [], []
|
||||
current = [nodes[0]]
|
||||
threshold = _node_interval_threshold_s(period_s)
|
||||
for previous, node in zip(nodes[:-1], nodes[1:]):
|
||||
dt = float(node.t_s - previous.t_s)
|
||||
structural_reasons = list(_trajectory_continuity_reasons(session, previous.t_s, node.t_s, period_s))
|
||||
continuity_break = node.continuity_id != previous.continuity_id
|
||||
reasons = structural_reasons or (["position_source_quality_or_merge_break"] if continuity_break else [])
|
||||
intervals.append({
|
||||
"left_t_s": float(previous.t_s), "right_t_s": float(node.t_s),
|
||||
"dt_s": dt, "threshold_s": threshold,
|
||||
"trajectory_continuous": not structural_reasons,
|
||||
"continuity_id_changed": continuity_break,
|
||||
"cut_reason": reasons,
|
||||
"left_hpr_factor": {"valid": previous.hpr_factor_valid, "method": previous.hpr_factor_method,
|
||||
"support_gap_s": previous.hpr_support_gap_s},
|
||||
"right_hpr_factor": {"valid": node.hpr_factor_valid, "method": node.hpr_factor_method,
|
||||
"support_gap_s": node.hpr_support_gap_s},
|
||||
})
|
||||
if not continuity_break:
|
||||
current.append(node)
|
||||
continue
|
||||
runs.append(current)
|
||||
cuts.append({
|
||||
**_interval_summary(session, previous.t_s, node.t_s, label="r0_cut", best_rows=best_rows, hpr=hpr),
|
||||
"dt_s": dt, "threshold_s": threshold, "cut_reason": reasons,
|
||||
})
|
||||
current = [node]
|
||||
runs.append(current)
|
||||
summaries = [
|
||||
_interval_summary(session, run[0].t_s, run[-1].t_s, label="R0_after_cuts", best_rows=best_rows, hpr=hpr)
|
||||
| {
|
||||
"node_count": len(run), "continuity_id": int(run[0].continuity_id),
|
||||
"bestnava_count": sum(node.source == "BESTNAVA" for node in run),
|
||||
"doppler_count": sum(node.velocity_enu_m_s is not None for node in run),
|
||||
"hpr_factor_count": sum(node.hpr_factor_valid for node in run),
|
||||
"hpr_factor_rejected_count": sum(not node.hpr_factor_valid for node in run),
|
||||
}
|
||||
for run in runs
|
||||
]
|
||||
return summaries, cuts, intervals
|
||||
def _qualified_summary(session, segments, hpr, best_rows) -> list[dict[str, object]]:
|
||||
return [
|
||||
_interval_summary(session, segment.nodes[0].t_s, segment.nodes[-1].t_s,
|
||||
label="qualified_segment", best_rows=best_rows, hpr=hpr)
|
||||
| {
|
||||
"segment_id": segment.segment_id, "node_count": len(segment.nodes),
|
||||
"bestnava_count": sum(node.source == "BESTNAVA" for node in segment.nodes),
|
||||
"doppler_count": sum(node.velocity_enu_m_s is not None for node in segment.nodes),
|
||||
"hpr_factor_count": sum(node.hpr_factor_valid for node in segment.nodes),
|
||||
"hpr_factor_rejected_count": sum(not node.hpr_factor_valid for node in segment.nodes),
|
||||
}
|
||||
for segment in segments
|
||||
]
|
||||
|
||||
def _dropped_r0_runs(session, r0_nodes, qualified, hpr, best_rows) -> list[dict[str, object]]:
|
||||
qualified_ranges = [(s.nodes[0].t_s, s.nodes[-1].t_s) for s in qualified]
|
||||
result: list[dict[str, object]] = []
|
||||
by_id: dict[int, list] = {}
|
||||
for node in r0_nodes:
|
||||
by_id.setdefault(node.continuity_id, []).append(node)
|
||||
for run in by_id.values():
|
||||
start_s, end_s = run[0].t_s, run[-1].t_s
|
||||
retained = any(abs(start_s - left) < 1e-6 and abs(end_s - right) < 1e-6 for left, right in qualified_ranges)
|
||||
if retained:
|
||||
continue
|
||||
reasons = []
|
||||
if len(run) < MIN_SEGMENT_NODE_COUNT:
|
||||
reasons.append("qualified_min_node_count")
|
||||
if end_s - start_s < MIN_SEGMENT_DURATION_S:
|
||||
reasons.append("qualified_min_duration")
|
||||
if not reasons:
|
||||
reasons.append("preintegration_or_segment_validation")
|
||||
result.append({
|
||||
**_interval_summary(session, start_s, end_s, label="dropped_before_qualified",
|
||||
best_rows=best_rows, hpr=hpr),
|
||||
"node_count": len(run), "cut_reason": reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _interval_overlap(left: dict[str, object], right: dict[str, object]) -> float:
|
||||
return max(0.0, min(float(left["end_s"]), float(right["end_s"])) - max(float(left["start_s"]), float(right["start_s"])))
|
||||
|
||||
|
||||
def _interval_penetration(raw_intervals, r0_intervals, qualified_intervals, cut_intervals):
|
||||
"""Link every raw-valid dynamic interval to its downstream R0/qualified survivors."""
|
||||
result = []
|
||||
for raw in raw_intervals:
|
||||
r0 = [item for item in r0_intervals if _interval_overlap(raw, item) > 0.0 or (
|
||||
item["start_s"] == item["end_s"] and raw["start_s"] <= item["start_s"] <= raw["end_s"]
|
||||
)]
|
||||
qualified = [item for item in qualified_intervals if _interval_overlap(raw, item) > 0.0]
|
||||
cuts = [item for item in cut_intervals if _interval_overlap(raw, item) > 0.0]
|
||||
raw_best = max(int(raw["bestnava_count"]), 1)
|
||||
raw_doppler = max(int(raw["doppler_count"]), 1)
|
||||
r0_duration = sum(_interval_overlap(raw, item) for item in r0)
|
||||
qualified_duration = sum(_interval_overlap(raw, item) for item in qualified)
|
||||
cut_reasons = sorted({reason for item in cuts for reason in item.get("cut_reason", [])})
|
||||
result.append({
|
||||
"raw_start_s": raw["start_s"], "raw_end_s": raw["end_s"],
|
||||
"raw_duration_s": raw["duration_s"], "raw_bestnava_count": raw["bestnava_count"],
|
||||
"raw_doppler_count": raw["doppler_count"], "raw_gyro": raw["gyro"],
|
||||
"R0_overlap_duration_s": r0_duration,
|
||||
"qualified_overlap_duration_s": qualified_duration,
|
||||
"R0_bestnava_count": sum(int(item["bestnava_count"]) for item in r0),
|
||||
"R0_doppler_count": sum(int(item["doppler_count"]) for item in r0),
|
||||
"qualified_bestnava_count": sum(int(item["bestnava_count"]) for item in qualified),
|
||||
"qualified_doppler_count": sum(int(item["doppler_count"]) for item in qualified),
|
||||
"retention": {
|
||||
"raw_to_R0_bestnava": sum(int(item["bestnava_count"]) for item in r0) / raw_best,
|
||||
"raw_to_R0_doppler": sum(int(item["doppler_count"]) for item in r0) / raw_doppler,
|
||||
"raw_to_R0_duration": r0_duration / max(float(raw["duration_s"]), 1e-9),
|
||||
"raw_to_qualified_bestnava": sum(int(item["bestnava_count"]) for item in qualified) / raw_best,
|
||||
"raw_to_qualified_doppler": sum(int(item["doppler_count"]) for item in qualified) / raw_doppler,
|
||||
"raw_to_qualified_duration": qualified_duration / max(float(raw["duration_s"]), 1e-9),
|
||||
},
|
||||
"cut_reason": cut_reasons,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _union_time_intervals(intervals: list[dict[str, object]]) -> list[tuple[float, float]]:
|
||||
ordered = sorted(
|
||||
(float(item["start_s"]), float(item["end_s"])) for item in intervals
|
||||
if np.isfinite(float(item["start_s"])) and np.isfinite(float(item["end_s"]))
|
||||
)
|
||||
merged: list[list[float]] = []
|
||||
for start_s, end_s in ordered:
|
||||
if end_s < start_s:
|
||||
continue
|
||||
if not merged or start_s > merged[-1][1]:
|
||||
merged.append([start_s, end_s])
|
||||
else:
|
||||
merged[-1][1] = max(merged[-1][1], end_s)
|
||||
return [(start_s, end_s) for start_s, end_s in merged]
|
||||
|
||||
|
||||
def _stage_statistics(session, intervals: list[dict[str, object]]) -> dict[str, object]:
|
||||
"""Audit each stage on unique IMU samples over the union of its time ranges."""
|
||||
total = _stage_total(intervals)
|
||||
union = _union_time_intervals(intervals)
|
||||
selected_count = 0
|
||||
unique_mask = np.zeros(session.imu.t_s.size, dtype=bool)
|
||||
for item in intervals:
|
||||
mask = (session.imu.t_s >= float(item["start_s"])) & (session.imu.t_s <= float(item["end_s"]))
|
||||
selected_count += int(np.count_nonzero(mask))
|
||||
unique_mask |= mask
|
||||
unique_count = int(np.count_nonzero(unique_mask))
|
||||
input_duration = float(sum(max(0.0, float(item["end_s"]) - float(item["start_s"])) for item in intervals))
|
||||
union_duration = float(sum(end_s - start_s for start_s, end_s in union))
|
||||
net = np.zeros(3)
|
||||
unwrap_range_sum = np.zeros(3)
|
||||
cumulative_abs = np.zeros(3)
|
||||
energy = np.zeros(3)
|
||||
peak = np.zeros(3)
|
||||
metric_duration = 0.0
|
||||
for start_s, end_s in union:
|
||||
gyro = _gyro_metrics(session, start_s, end_s)
|
||||
current_net = np.asarray(gyro["net_rotation_xyz_deg"], dtype=float)
|
||||
if not np.all(np.isfinite(current_net)):
|
||||
continue
|
||||
net += current_net
|
||||
unwrap_range_sum += np.asarray(gyro["unwrap_rotation_range_xyz_deg"], dtype=float)
|
||||
cumulative_abs += np.asarray(gyro["cumulative_absolute_rotation_xyz_deg"], dtype=float)
|
||||
energy += np.asarray(gyro["gyro_integral_squared_xyz_rad2_s"], dtype=float)
|
||||
peak = np.maximum(peak, np.asarray(gyro["gyro_peak_xyz_deg_s"], dtype=float))
|
||||
metric_duration += max(0.0, end_s - start_s)
|
||||
total["unique_imu_coverage"] = {
|
||||
"input_interval_count": len(intervals),
|
||||
"union_interval_count": len(union),
|
||||
"input_duration_s": input_duration,
|
||||
"union_duration_s": union_duration,
|
||||
"overlap_duration_s": max(0.0, input_duration - union_duration),
|
||||
"selected_imu_sample_count_before_dedup": selected_count,
|
||||
"unique_imu_sample_count": unique_count,
|
||||
"duplicate_imu_sample_count": selected_count - unique_count,
|
||||
}
|
||||
total["gyro"] = {
|
||||
"net_rotation_xyz_deg": net,
|
||||
"sum_interval_unwrap_rotation_range_xyz_deg": unwrap_range_sum,
|
||||
"cumulative_absolute_rotation_xyz_deg": cumulative_abs,
|
||||
"gyro_integral_squared_xyz_rad2_s": energy,
|
||||
"gyro_rms_xyz_deg_s": np.degrees(np.sqrt(energy / max(metric_duration, 1e-9))),
|
||||
"gyro_peak_xyz_deg_s": peak,
|
||||
}
|
||||
return total
|
||||
|
||||
def _stage_total(intervals: list[dict[str, object]]) -> dict[str, object]:
|
||||
total = {"interval_count": len(intervals), "duration_s": 0.0, "bestnava_count": 0,
|
||||
"doppler_count": 0, "q4_hpr_count": 0}
|
||||
for item in intervals:
|
||||
for key in ("duration_s", "bestnava_count", "doppler_count", "q4_hpr_count"):
|
||||
total[key] += item[key]
|
||||
return total
|
||||
|
||||
|
||||
def _hpr_chain_diagnostics(hpr) -> dict[str, object]:
|
||||
if hpr.t_s.size < 2:
|
||||
return {"sample_count": int(hpr.t_s.size), "pair_count": 0}
|
||||
dt = np.diff(hpr.t_s)
|
||||
finite_vector = np.all(np.isfinite(hpr.baseline_enu), axis=1)
|
||||
dot = np.sum(hpr.baseline_enu[:-1] * hpr.baseline_enu[1:], axis=1)
|
||||
jump_deg = np.degrees(np.arccos(np.clip(dot, -1.0, 1.0)))
|
||||
rate = jump_deg / np.maximum(dt, 1e-12)
|
||||
return {
|
||||
"sample_count": int(hpr.t_s.size),
|
||||
"q4_valid_sample_count": int(np.count_nonzero(hpr.valid)),
|
||||
"pair_count": int(dt.size),
|
||||
"pair_with_invalid_endpoint_count": int(np.count_nonzero(~(hpr.valid[:-1] & hpr.valid[1:]))),
|
||||
"dt_s_p50_p95_max": np.percentile(dt[np.isfinite(dt)], [50.0, 95.0, 100.0]),
|
||||
"dt_too_short_count": int(np.count_nonzero(dt < 0.03)),
|
||||
"dt_too_long_count": int(np.count_nonzero(dt > 0.25)),
|
||||
"baseline_jump_rate_over_45deg_s_count": int(np.count_nonzero(
|
||||
finite_vector[:-1] & finite_vector[1:] & (rate > 45.0)
|
||||
)),
|
||||
}
|
||||
|
||||
|
||||
def _hpr_axis_mapping(session, hpr) -> dict[str, object]:
|
||||
valid = hpr.valid & np.isfinite(hpr.t_s)
|
||||
if np.count_nonzero(valid) < 8:
|
||||
return {"available": False, "reason": "fewer_than_8_q4_hpr_samples"}
|
||||
t = hpr.t_s[valid]
|
||||
dt = np.diff(t)
|
||||
keep = np.r_[True, (dt > 0.03) & (dt <= 0.25)]
|
||||
t = t[keep]
|
||||
# hpr arrays preserve GNHPR order after time sorting; heading must unwrap.
|
||||
hpr_rows = sorted(session.rtk_by_type.get("GNHPR", []), key=lambda row: _f(row, "t_device_s"))
|
||||
heading = np.unwrap(np.deg2rad(np.asarray([_f(row, "heading_deg") for row in hpr_rows])))[valid][keep]
|
||||
pitch = np.asarray([_f(row, "pitch_deg") for row in hpr_rows])[valid][keep]
|
||||
if t.size < 8:
|
||||
return {"available": False, "reason": "insufficient_contiguous_q4_hpr"}
|
||||
heading_rate = np.gradient(heading, t)
|
||||
gyro = np.column_stack([np.interp(t, session.imu.t_s, session.imu.gyro_rad_s[:, axis]) for axis in range(3)])
|
||||
correlation = []
|
||||
for axis in range(3):
|
||||
value = np.corrcoef(heading_rate, gyro[:, axis])[0, 1]
|
||||
correlation.append(float(value) if np.isfinite(value) else np.nan)
|
||||
best_axis = int(np.nanargmax(np.abs(correlation))) if np.any(np.isfinite(correlation)) else None
|
||||
return {
|
||||
"available": best_axis is not None,
|
||||
"hpr_heading_unwrapped_range_deg": float(np.degrees(np.ptp(heading))),
|
||||
"hpr_heading_net_rotation_deg": float(np.degrees(heading[-1] - heading[0])),
|
||||
"hpr_heading_cumulative_absolute_rotation_deg": float(np.degrees(np.sum(np.abs(np.diff(heading))))),
|
||||
"hpr_pitch_range_deg": float(np.ptp(pitch)),
|
||||
"hpr_pitch_cumulative_absolute_change_deg": float(np.sum(np.abs(np.diff(pitch)))),
|
||||
"heading_rate_to_imu_gyro_correlation_xyz": np.asarray(correlation),
|
||||
"best_correlated_imu_axis": best_axis,
|
||||
"expected_z_axis_correlation": correlation[2],
|
||||
"note": "heading is unwrapped; sign depends on GNHPR clockwise-from-north convention",
|
||||
}
|
||||
|
||||
|
||||
def _bias_absorption_check(session, intervals: list[dict[str, object]]) -> dict[str, object]:
|
||||
report = audit_imu(session.imu)
|
||||
checks = []
|
||||
for item in intervals:
|
||||
if item["duration_s"] < 1.0:
|
||||
continue
|
||||
start_s, end_s = item["start_s"], item["end_s"]
|
||||
raw = _gyro_metrics(session, start_s, end_s)
|
||||
static_corrected = _gyro_metrics(session, start_s, end_s, report.gyro_bias_rad_s)
|
||||
t, gyro = _interval_imu(session, start_s, end_s)
|
||||
mean = np.mean(gyro, axis=0) if gyro.size else np.zeros(3)
|
||||
mean_removed = _gyro_metrics(session, start_s, end_s, mean)
|
||||
raw_abs = np.asarray(raw["cumulative_absolute_rotation_xyz_deg"])
|
||||
removed_abs = np.asarray(mean_removed["cumulative_absolute_rotation_xyz_deg"])
|
||||
ratio = removed_abs / np.maximum(raw_abs, 1e-9)
|
||||
checks.append({
|
||||
"start_s": start_s, "end_s": end_s,
|
||||
"static_bias_rad_s": report.gyro_bias_rad_s,
|
||||
"segment_mean_gyro_rad_s": mean,
|
||||
"segment_mean_removed_to_raw_abs_rotation_ratio_xyz": ratio,
|
||||
"static_bias_corrected": static_corrected,
|
||||
"mean_removal_would_absorb_motion": bool(np.any(ratio < 0.5)),
|
||||
})
|
||||
return {"imu_static_audit": report, "interval_checks": checks,
|
||||
"note": "Engineering audit integrates raw gyro; it does not subtract a segment mean."}
|
||||
|
||||
|
||||
def _unit_check(session) -> dict[str, object]:
|
||||
gyro = session.imu.gyro_rad_s
|
||||
norm = np.linalg.norm(gyro, axis=1)
|
||||
p99 = float(np.percentile(norm, 99.0)) if norm.size else np.nan
|
||||
return {
|
||||
"gyro_p99_norm_rad_s": p99,
|
||||
"gyro_p99_norm_deg_s": float(np.degrees(p99)),
|
||||
"gyro_peak_norm_rad_s": float(np.max(norm)) if norm.size else np.nan,
|
||||
"suspect_deg_per_second_stored_as_rad_per_second": bool(np.isfinite(p99) and p99 > 20.0),
|
||||
"suspect_near_zero_gyro_scale": bool(np.isfinite(p99) and p99 < 1e-4),
|
||||
"unit_contract": "unified imu.npz gyro_rad_s is radians per second",
|
||||
}
|
||||
|
||||
|
||||
def _candidate_scores(session_audit: dict[str, object]) -> dict[str, float]:
|
||||
raw = session_audit["raw_valid_intervals"]
|
||||
if not raw:
|
||||
return {"circle": 0.0, "left_right": 0.0, "slope": 0.0}
|
||||
cumulative = np.zeros(3)
|
||||
net = np.zeros(3)
|
||||
for interval in raw:
|
||||
gyro = interval["gyro"]
|
||||
value = np.asarray(gyro["cumulative_absolute_rotation_xyz_deg"], dtype=float)
|
||||
signed = np.asarray(gyro["net_rotation_xyz_deg"], dtype=float)
|
||||
if np.all(np.isfinite(value)):
|
||||
cumulative += value
|
||||
if np.all(np.isfinite(signed)):
|
||||
net += signed
|
||||
axis = session_audit["axis_mapping"]
|
||||
heading_range = abs(float(axis.get("hpr_heading_unwrapped_range_deg", 0.0) or 0.0))
|
||||
heading_abs = abs(float(axis.get("hpr_heading_cumulative_absolute_rotation_deg", 0.0) or 0.0))
|
||||
heading_net = abs(float(axis.get("hpr_heading_net_rotation_deg", 0.0) or 0.0))
|
||||
pitch_range = abs(float(axis.get("hpr_pitch_range_deg", 0.0) or 0.0))
|
||||
pitch_abs = abs(float(axis.get("hpr_pitch_cumulative_absolute_change_deg", 0.0) or 0.0))
|
||||
return {
|
||||
"circle": max(heading_range, cumulative[2]),
|
||||
"left_right": max(0.0, heading_abs - heading_net, cumulative[2] - abs(net[2])),
|
||||
"slope": max(cumulative[0], cumulative[1]) ** 2 / max(1.0, cumulative[2]),
|
||||
}
|
||||
|
||||
|
||||
def _select_candidates(audits: list[dict[str, object]]) -> dict[str, dict[str, object] | None]:
|
||||
remaining = list(audits)
|
||||
chosen: dict[str, dict[str, object] | None] = {}
|
||||
for kind in ("circle", "left_right", "slope"):
|
||||
ranked = sorted(remaining, key=lambda item: item["candidate_scores"][kind], reverse=True)
|
||||
choice = ranked[0] if ranked and ranked[0]["candidate_scores"][kind] > 0.0 else None
|
||||
chosen[kind] = None if choice is None else {
|
||||
"session_id": choice["session_id"], "score_deg": choice["candidate_scores"][kind],
|
||||
"selection_metric": {
|
||||
"circle": "max(unwrapped HPR heading range, raw gyro-z net/absolute rotation)",
|
||||
"left_right": "unwrapped HPR heading cumulative change minus net change, cross-checked with gyro-z",
|
||||
"slope": "tilt-dominance: max(raw gyro-x/y cumulative rotation)^2 / raw gyro-z cumulative rotation",
|
||||
}[kind],
|
||||
}
|
||||
if choice is not None:
|
||||
remaining.remove(choice)
|
||||
return chosen
|
||||
|
||||
|
||||
def _audit_session(session, period_s: float) -> dict[str, object]:
|
||||
reference = _height_reference([session])
|
||||
hpr = _all_hpr(session)
|
||||
best_rows = session.rtk_by_type.get("BESTNAVA", [])
|
||||
records = _raw_records(session)
|
||||
raw_valid = _coalesce(records, include=True, label="raw_valid", session=session,
|
||||
best_rows=best_rows, hpr=hpr)
|
||||
raw_rejected = _coalesce(records, include=False, label="dropped_before_raw_valid", session=session,
|
||||
best_rows=best_rows, hpr=hpr)
|
||||
r0_nodes = [] if reference is None else _nodes(session, reference, period_s)
|
||||
r0_intervals, r0_cuts, r0_node_intervals = _r0_runs_and_cuts(
|
||||
session, r0_nodes, hpr, best_rows, period_s
|
||||
)
|
||||
all_qualified = _segments([session], period_s)
|
||||
qualified = [segment for segment in all_qualified if segment.session_id == session.session_id]
|
||||
qualified_intervals = _qualified_summary(session, qualified, hpr, best_rows)
|
||||
dropped_r0 = _dropped_r0_runs(session, r0_nodes, qualified, hpr, best_rows)
|
||||
r0_selected_times = np.asarray([node.t_s for node in r0_nodes])
|
||||
decimated = []
|
||||
for record in records:
|
||||
if not record["accepted"]:
|
||||
continue
|
||||
t_s = float(record["t_s"])
|
||||
selected = r0_selected_times.size and np.min(np.abs(r0_selected_times - t_s)) < 1e-8
|
||||
if not selected:
|
||||
decimated.append({**record, "accepted": False, "reasons": ["sample_period_decimation"]})
|
||||
decimation_cuts = _coalesce(decimated, include=False, label="dropped_raw_to_R0", session=session,
|
||||
best_rows=best_rows, hpr=hpr)
|
||||
raw_total, r0_total, qualified_total = map(_stage_total, (raw_valid, r0_intervals, qualified_intervals))
|
||||
retention = {
|
||||
"raw_to_R0": {
|
||||
"bestnava_count_ratio": r0_total["bestnava_count"] / max(raw_total["bestnava_count"], 1),
|
||||
"doppler_count_ratio": r0_total["doppler_count"] / max(raw_total["doppler_count"], 1),
|
||||
"duration_ratio": r0_total["duration_s"] / max(raw_total["duration_s"], 1e-9),
|
||||
},
|
||||
"R0_to_qualified": {
|
||||
"bestnava_count_ratio": qualified_total["bestnava_count"] / max(r0_total["bestnava_count"], 1),
|
||||
"doppler_count_ratio": qualified_total["doppler_count"] / max(r0_total["doppler_count"], 1),
|
||||
"duration_ratio": qualified_total["duration_s"] / max(r0_total["duration_s"], 1e-9),
|
||||
},
|
||||
}
|
||||
audit = {
|
||||
"session_id": session.session_id, "batch_id": session.batch_id,
|
||||
"raw_valid_intervals": raw_valid, "R0_after_cuts_intervals": r0_intervals,
|
||||
"qualified_segments": qualified_intervals,
|
||||
"stage_statistics": {
|
||||
"raw_valid": _stage_statistics(session, raw_valid),
|
||||
"R0_after_cuts": _stage_statistics(session, r0_intervals),
|
||||
"qualified": _stage_statistics(session, qualified_intervals),
|
||||
},
|
||||
"retention": retention,
|
||||
"cut_intervals": [*raw_rejected, *decimation_cuts, *r0_cuts, *dropped_r0],
|
||||
"r0_consecutive_node_intervals": r0_node_intervals,
|
||||
"interval_penetration": _interval_penetration(
|
||||
raw_valid, r0_intervals, qualified_intervals,
|
||||
[*raw_rejected, *decimation_cuts, *r0_cuts, *dropped_r0],
|
||||
),
|
||||
"hpr_chain_diagnostics": _hpr_chain_diagnostics(hpr),
|
||||
"axis_mapping": _hpr_axis_mapping(session, hpr),
|
||||
"unit_check": _unit_check(session),
|
||||
"bias_absorption_check": _bias_absorption_check(session, raw_valid),
|
||||
}
|
||||
audit["candidate_scores"] = _candidate_scores(audit)
|
||||
return audit
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--sample-period-s", type=float, default=1.0)
|
||||
parser.add_argument("--session", action="append", help="Optional session id; may repeat.")
|
||||
parser.add_argument(
|
||||
"--inventory-only", action="store_true",
|
||||
help="Only scan raw-valid gyro/HPR excitation; skip R0 and qualified-segment work.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
sessions = load_unified_sessions(
|
||||
args.manifest,
|
||||
selected_session_ids=None if args.session is None else set(args.session),
|
||||
)
|
||||
if args.inventory_only:
|
||||
audits = []
|
||||
for session in sessions:
|
||||
hpr = _all_hpr(session)
|
||||
best_rows = session.rtk_by_type.get("BESTNAVA", [])
|
||||
records = _raw_records(session)
|
||||
raw_valid = _coalesce(records, include=True, label="raw_valid", session=session,
|
||||
best_rows=best_rows, hpr=hpr)
|
||||
audit = {
|
||||
"session_id": session.session_id,
|
||||
"batch_id": session.batch_id,
|
||||
"raw_valid_intervals": raw_valid,
|
||||
"hpr_chain_diagnostics": _hpr_chain_diagnostics(hpr),
|
||||
"axis_mapping": _hpr_axis_mapping(session, hpr),
|
||||
"unit_check": _unit_check(session),
|
||||
}
|
||||
audit["candidate_scores"] = _candidate_scores(audit)
|
||||
audits.append(audit)
|
||||
scope = "raw-valid motion inventory only; no R0/qualified work or optimisation"
|
||||
else:
|
||||
audits = [_audit_session(session, args.sample_period_s) for session in sessions]
|
||||
scope = "motion-excitation penetration audit only; no lever fit/prior/bootstrap/sensitivity"
|
||||
payload = {
|
||||
"scope": scope,
|
||||
"sample_period_s": args.sample_period_s,
|
||||
"session_count": len(audits),
|
||||
"selected_dynamic_candidates": _select_candidates(audits),
|
||||
"sessions": audits,
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload), ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
print(json.dumps({
|
||||
"session_count": len(audits),
|
||||
"selected_dynamic_candidates": payload["selected_dynamic_candidates"],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Multi-horizon open-loop RTK/IMU propagation audit without optimization.'''
|
||||
from __future__ import annotations
|
||||
import argparse, json, sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.imu_preintegration import preintegrate_imu
|
||||
from imu_lidar.rtk_imu_engineering import (
|
||||
G_ENU, _all_hpr, _height_reference, _hpr_factor_observation, _world_rtk)
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from tools.audit_rtk_imu_factor_consistency import (
|
||||
MECHANICAL_L_I_M, _best_arrays, _jsonable, _nearest_imu, _summary)
|
||||
|
||||
HORIZONS_S = (1.,2.,5.,10.,20.)
|
||||
|
||||
def _angle_deg(left,right):
|
||||
return float(np.degrees(np.arccos(np.clip(np.dot(left,right),-1.,1.))))
|
||||
|
||||
def _scalar_summary(values):
|
||||
a = np.asarray(values,dtype=float)
|
||||
if not a.size:
|
||||
return {'count':0,'rms':np.nan,'p95_abs':np.nan}
|
||||
return {'count':len(a),'rms':float(np.sqrt(np.mean(a*a))),
|
||||
'p95_abs':float(np.percentile(np.abs(a),95.))}
|
||||
|
||||
def _evaluate_session(session,reference,rotation,lever,bg,ba):
|
||||
_,times,p_ant,v_ant = _best_arrays(session,reference)
|
||||
hpr = _all_hpr(session)
|
||||
baseline_I = rotation.T[:,0]
|
||||
values = {h:{'position':[],'velocity':[],'attitude':[],'baseline':[]}
|
||||
for h in HORIZONS_S}
|
||||
pair_pre = []
|
||||
for left,right in zip(times[:-1],times[1:]):
|
||||
if not .5 <= right-left <= 1.75:
|
||||
pair_pre.append(None)
|
||||
else:
|
||||
pair_pre.append(preintegrate_imu(
|
||||
session.imu.t_s,session.imu.gyro_rad_s,session.imu.acc_m_s2,
|
||||
float(left),float(right),bg,ba))
|
||||
for start,t0 in enumerate(times):
|
||||
baseline0,_,valid0,_,_ = _hpr_factor_observation(hpr,float(t0))
|
||||
imu0 = _nearest_imu(session,float(t0))
|
||||
if not valid0 or imu0 is None: continue
|
||||
R0 = _world_rtk(baseline0) @ rotation
|
||||
p_i0 = p_ant[start]-R0@lever
|
||||
v_i0 = v_ant[start]-R0@np.cross(session.imu.gyro_rad_s[imu0]-bg,lever)
|
||||
delta_R, delta_v, delta_p = np.eye(3), np.zeros(3), np.zeros(3)
|
||||
elapsed, matched = 0., set()
|
||||
for end in range(start+1,len(times)):
|
||||
pre = pair_pre[end-1]
|
||||
if pre is None: break
|
||||
delta_p = delta_p + delta_v*pre.duration_s + delta_R@pre.delta_p
|
||||
delta_v = delta_v + delta_R@pre.delta_v
|
||||
delta_R = delta_R@pre.delta_R
|
||||
elapsed = float(times[end]-t0)
|
||||
if elapsed > max(HORIZONS_S)+.25: break
|
||||
candidates = [h for h in HORIZONS_S if h not in matched and abs(elapsed-h) <= .25]
|
||||
if not candidates: continue
|
||||
horizon = min(candidates,key=lambda h:abs(elapsed-h))
|
||||
matched.add(horizon)
|
||||
baseline1,_,valid1,_,_ = _hpr_factor_observation(hpr,float(times[end]))
|
||||
imu1 = _nearest_imu(session,float(times[end]))
|
||||
if not valid1 or imu1 is None: continue
|
||||
p_i1 = p_i0+v_i0*elapsed+.5*G_ENU*elapsed**2+R0@delta_p
|
||||
v_i1 = v_i0+G_ENU*elapsed+R0@delta_v
|
||||
R1 = R0@delta_R
|
||||
predicted_p = p_i1+R1@lever
|
||||
predicted_v = v_i1+R1@np.cross(session.imu.gyro_rad_s[imu1]-bg,lever)
|
||||
observed_R1 = _world_rtk(baseline1)@rotation
|
||||
values[horizon]['position'].append(predicted_p-p_ant[end])
|
||||
values[horizon]['velocity'].append(predicted_v-v_ant[end])
|
||||
values[horizon]['attitude'].append(
|
||||
np.degrees(Rotation.from_matrix(observed_R1.T@R1).magnitude()))
|
||||
values[horizon]['baseline'].append(_angle_deg(R1@baseline_I,baseline1))
|
||||
summary = {str(int(h)):{
|
||||
'position_error_m':_summary(values[h]['position']),
|
||||
'velocity_error_m_s':_summary(values[h]['velocity']),
|
||||
'attitude_level_completed_error_deg':_scalar_summary(values[h]['attitude']),
|
||||
'observable_baseline_angular_error_deg':_scalar_summary(values[h]['baseline'])}
|
||||
for h in HORIZONS_S}
|
||||
return summary, values
|
||||
|
||||
def _summarize_values(values):
|
||||
return {str(int(h)):{
|
||||
'position_error_m':_summary(values[h]['position']),
|
||||
'velocity_error_m_s':_summary(values[h]['velocity']),
|
||||
'attitude_level_completed_error_deg':_scalar_summary(values[h]['attitude']),
|
||||
'observable_baseline_angular_error_deg':_scalar_summary(values[h]['baseline'])}
|
||||
for h in HORIZONS_S}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--session',action='append',required=True)
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
parser.add_argument('--mechanical-l-I-m',nargs=3,type=float,
|
||||
default=MECHANICAL_L_I_M.tolist())
|
||||
parser.add_argument('--gyro-bias-rad-s',nargs=3,type=float,default=[0.,0.,0.])
|
||||
parser.add_argument('--accel-bias-m-s2',nargs=3,type=float,default=[0.,0.,0.])
|
||||
args = parser.parse_args()
|
||||
sessions = load_unified_sessions(args.manifest,selected_session_ids=set(args.session))
|
||||
reference = _height_reference(sessions)
|
||||
if reference is None: raise RuntimeError('no BEST height reference')
|
||||
rotation = Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
lever = np.asarray(args.mechanical_l_I_m)
|
||||
bg, ba = np.asarray(args.gyro_bias_rad_s), np.asarray(args.accel_bias_m_s2)
|
||||
combined = {h:{'position':[],'velocity':[],'attitude':[],'baseline':[]}
|
||||
for h in HORIZONS_S}
|
||||
per_session = {}
|
||||
for session in sessions:
|
||||
per_session[session.session_id], raw = _evaluate_session(
|
||||
session,reference,rotation,lever,bg,ba)
|
||||
for h in HORIZONS_S:
|
||||
for key in combined[h]: combined[h][key].extend(raw[h][key])
|
||||
aggregate = _summarize_values(combined)
|
||||
one, twenty = aggregate['1'], aggregate['20']
|
||||
growth = {
|
||||
'position_p95_ratio_20s_over_1s':twenty['position_error_m']['vector_p95']/one['position_error_m']['vector_p95'],
|
||||
'velocity_p95_ratio_20s_over_1s':twenty['velocity_error_m_s']['vector_p95']/one['velocity_error_m_s']['vector_p95'],
|
||||
'attitude_p95_ratio_20s_over_1s':twenty['observable_baseline_angular_error_deg']['p95_abs']/one['observable_baseline_angular_error_deg']['p95_abs']}
|
||||
significant = bool(
|
||||
growth['position_p95_ratio_20s_over_1s'] >= 3.
|
||||
and twenty['position_error_m']['vector_p95']-one['position_error_m']['vector_p95'] >= .5)
|
||||
payload = {
|
||||
'scope':'open-loop only; no least_squares/free/prior/LOO/bootstrap/sensitivity',
|
||||
'least_squares_called':False,'rotation_source':'R2G_gravity_level_prior',
|
||||
'mechanical_l_I_m':lever,'gyro_bias_rad_s':bg,'accel_bias_m_s2':ba,
|
||||
'bias_source':'current nominal engineering bias; configurable CLI; no fit artifact bias was persisted',
|
||||
'aggregate_by_horizon_s':aggregate,'per_session_by_horizon_s':per_session,
|
||||
'one_second_reanchored_control':{
|
||||
'definition':'each interval restarts from observed BEST p/v and HPR-completed R',
|
||||
'result':one},
|
||||
'growth_20s_over_1s':growth,
|
||||
'significant_accumulated_drift':significant,
|
||||
'legacy_long_segment_deterministic_model_deprecated':significant}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable({'aggregate':aggregate,'growth':growth,
|
||||
'legacy_deprecated':significant}),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Root-cause audit for paired BEST/Doppler propagation innovations.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import G0,G_ENU,_height_reference,_nodes,_world_rtk
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import build_problem
|
||||
from tools.audit_rtk_imu_factor_consistency import _jsonable
|
||||
from tools.audit_rtk_imu_heldout_innovation import _calibration_biases
|
||||
from tools.audit_rtk_imu_innovation_noise import _factor_report,_interval_innovations
|
||||
from tools.run_rtk_imu_node_graph_free_selected import _restore_segments
|
||||
|
||||
def _vector_summary(values):
|
||||
a=np.asarray(values,dtype=float).reshape(-1,3)
|
||||
if not len(a): return {'count':0}
|
||||
norm=np.linalg.norm(a,axis=1)
|
||||
return {'count':len(a),'bias':np.mean(a,axis=0),
|
||||
'axis_rms':np.sqrt(np.mean(a*a,axis=0)),
|
||||
'axis_p95_abs':np.percentile(np.abs(a),95,axis=0),
|
||||
'vector_rms':float(np.sqrt(np.mean(norm*norm))),
|
||||
'vector_p95':float(np.percentile(norm,95)),
|
||||
'empirical_covariance':np.cov(a,rowvar=False)}
|
||||
|
||||
def _correlation(left,right):
|
||||
a,b=np.asarray(left),np.asarray(right); result=np.full(3,np.nan)
|
||||
for axis in range(3):
|
||||
if len(a)>2 and np.std(a[:,axis])>1e-12 and np.std(b[:,axis])>1e-12:
|
||||
result[axis]=np.corrcoef(a[:,axis],b[:,axis])[0,1]
|
||||
return result
|
||||
|
||||
def _root_report(position,velocity):
|
||||
p={x['interval_id']:x for x in position}; v={x['interval_id']:x for x in velocity}
|
||||
ids=sorted(set(p)&set(v)); records=[]
|
||||
for key in ids:
|
||||
left,right=p[key],v[key]; dt=float(left['dt_s'])
|
||||
a_p=2.*np.asarray(left['residual'])/(dt*dt)
|
||||
a_v=np.asarray(right['residual'])/dt
|
||||
R=np.asarray(left['R0_WI'])
|
||||
records.append({**{k:left[k] for k in (
|
||||
'interval_id','session','motion','t_s','speed_bin','gyro_bin')},
|
||||
'dt_s':dt,'a_position':a_p,'a_velocity':a_v,
|
||||
'difference':a_v-a_p,'a_common':.5*(a_p+a_v),
|
||||
'a_common_body':R.T@(.5*(a_p+a_v))})
|
||||
def summarize(items):
|
||||
if not items:
|
||||
return {'interval_count':0,
|
||||
'a_err_from_position_m_s2':{'count':0},
|
||||
'a_err_from_velocity_m_s2':{'count':0},
|
||||
'per_axis_correlation':[np.nan]*3,
|
||||
'mean_direction_cosine':np.nan,
|
||||
'mean_magnitude_ratio_velocity_over_position':np.nan,
|
||||
'difference_velocity_minus_position_m_s2':{'count':0},
|
||||
'common_acceleration_world_m_s2':{'count':0},
|
||||
'common_acceleration_body_m_s2':{'count':0},
|
||||
'equivalent_horizontal_tilt_rad':np.nan,
|
||||
'equivalent_horizontal_tilt_deg':np.nan}
|
||||
ap=[x['a_position'] for x in items]; av=[x['a_velocity'] for x in items]
|
||||
diff=[x['difference'] for x in items]; common=[x['a_common'] for x in items]
|
||||
body=[x['a_common_body'] for x in items]
|
||||
mean_p=np.mean(ap,axis=0); mean_v=np.mean(av,axis=0)
|
||||
denom=np.linalg.norm(mean_p)*np.linalg.norm(mean_v)
|
||||
cosine=float(mean_p@mean_v/denom) if denom>1e-12 else np.nan
|
||||
ratio=float(np.linalg.norm(mean_v)/max(np.linalg.norm(mean_p),1e-12))
|
||||
body_bias=np.mean(body,axis=0)
|
||||
body_std=np.std(body,axis=0)
|
||||
horizontal=float(np.linalg.norm(body_bias[:2]))
|
||||
return {'interval_count':len(items),
|
||||
'a_err_from_position_m_s2':_vector_summary(ap),
|
||||
'a_err_from_velocity_m_s2':_vector_summary(av),
|
||||
'per_axis_correlation':_correlation(ap,av),
|
||||
'mean_direction_cosine':cosine,'mean_magnitude_ratio_velocity_over_position':ratio,
|
||||
'difference_velocity_minus_position_m_s2':_vector_summary(diff),
|
||||
'common_acceleration_world_m_s2':_vector_summary(common),
|
||||
'common_acceleration_body_m_s2':_vector_summary(body),
|
||||
'body_bias_stability_std_over_bias_norm':float(
|
||||
np.linalg.norm(body_std)/max(np.linalg.norm(body_bias),1e-12)),
|
||||
'constant_body_accelerometer_bias_direction_stable':bool(
|
||||
np.linalg.norm(body_std)<=np.linalg.norm(body_bias)),
|
||||
'equivalent_horizontal_tilt_rad':horizontal/G0,
|
||||
'equivalent_horizontal_tilt_deg':float(np.degrees(horizontal/G0)),
|
||||
'gravity_tilt_leakage_magnitude_le_0p5deg':bool(
|
||||
np.degrees(horizontal/G0)<=.5)}
|
||||
def grouped(key):
|
||||
groups={}
|
||||
for record in records: groups.setdefault(record[key],[]).append(record)
|
||||
return {name:summarize(items) for name,items in groups.items()}
|
||||
overall=summarize(records)
|
||||
detection_checks={}
|
||||
if records:
|
||||
mp=np.asarray(overall['a_err_from_position_m_s2']['bias'])
|
||||
mv=np.asarray(overall['a_err_from_velocity_m_s2']['bias'])
|
||||
detection_checks={'mean_direction_cosine_ge_0p95':
|
||||
overall['mean_direction_cosine']>=.95,
|
||||
'mean_magnitude_ratio_in_0p75_1p25':
|
||||
.75<=overall['mean_magnitude_ratio_velocity_over_position']<=1.25,
|
||||
'mean_acceleration_difference_norm_le_0p05_m_s2':
|
||||
np.linalg.norm(mv-mp)<=.05}
|
||||
detected=bool(all(detection_checks.values()))
|
||||
else:
|
||||
detected=False
|
||||
return {'overall':overall,'per_session':grouped('session'),
|
||||
'by_motion_class':grouped('motion'),'by_speed':grouped('speed_bin'),
|
||||
'by_gyro_norm':grouped('gyro_bin'),
|
||||
'common_acceleration_detection_gate':{
|
||||
'thresholds':{'direction_cosine_min':.95,
|
||||
'magnitude_ratio_range':[.75,1.25],
|
||||
'mean_difference_norm_max_m_s2':.05},
|
||||
'checks':detection_checks,'passed':detected},
|
||||
'common_constant_acceleration_error_detected':detected},records
|
||||
|
||||
def _outside_targets(t,intervals):
|
||||
return all(not (start-1.<=t<=end+1.) for start,end in intervals)
|
||||
|
||||
def _physical_static_biases(sessions,reference,R,heldout):
|
||||
ranges={}
|
||||
for item in heldout:
|
||||
ranges.setdefault(item['session_id'],[]).append((item['start_s'],item['end_s']))
|
||||
result={}
|
||||
for session in sessions:
|
||||
estimates=[]; times=[]
|
||||
for node in _nodes(session,reference,1.):
|
||||
if (node.zupt_static and node.hpr_factor_valid and
|
||||
_outside_targets(node.t_s,ranges.get(session.session_id,[]))):
|
||||
R_WI=_world_rtk(node.baseline_enu)@R
|
||||
estimates.append(node.accel_m_s2-R_WI.T@(-G_ENU)); times.append(node.t_s)
|
||||
if len(estimates)>=3:
|
||||
a=np.asarray(estimates)
|
||||
result[session.session_id]={'available':True,'sample_count':len(a),
|
||||
'time_min_s':min(times),'time_max_s':max(times),
|
||||
'physical_accel_bias_m_s2':np.median(a,axis=0),
|
||||
'sample_axis_std_m_s2':np.std(a,axis=0),
|
||||
'method':('target-excluded zupt_static + fixed R2G/HPR gravity; '
|
||||
'horizontal components remain gravity-tilt confounded')}
|
||||
else:
|
||||
result[session.session_id]={'available':False,'sample_count':len(estimates),
|
||||
'reason':'fewer than 3 target-excluded independent static nodes'}
|
||||
return result
|
||||
|
||||
def _bias_distribution(values,key):
|
||||
a=np.asarray([x[key] for x in values.values()],dtype=float)
|
||||
return {'session_count':len(a),'mean':np.mean(a,axis=0),'std':np.std(a,axis=0),
|
||||
'min':np.min(a,axis=0),'max':np.max(a,axis=0),
|
||||
'peak_to_peak':np.ptp(a,axis=0)}
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--manifest',type=Path,required=True)
|
||||
p.add_argument('--calibration-selection',type=Path,required=True)
|
||||
p.add_argument('--all-selection',type=Path,required=True)
|
||||
p.add_argument('--engineering-result',type=Path,required=True)
|
||||
p.add_argument('--output',type=Path,required=True)
|
||||
p.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
args=p.parse_args()
|
||||
engineering=json.loads(args.engineering_result.read_text(encoding='utf-8'))
|
||||
calibration=json.loads(args.calibration_selection.read_text(encoding='utf-8'))
|
||||
selected=json.loads(args.all_selection.read_text(encoding='utf-8'))
|
||||
calibration_ids={x['candidate_id'] for x in calibration['selected_windows']}
|
||||
heldout=[x for x in selected['selected_windows']
|
||||
if x['candidate_id'] not in calibration_ids]
|
||||
if len(calibration_ids)!=47 or len(heldout)!=267:
|
||||
raise RuntimeError(f'expected 47+267 windows, got {len(calibration_ids)}+{len(heldout)}')
|
||||
lever=np.asarray(engineering['prior_constrained_solution']['result']['final_l_I_m'])
|
||||
frozen=np.array([-.4518015159,-.2644749820,.7314656115])
|
||||
if not np.allclose(lever,frozen,atol=1e-10):
|
||||
raise RuntimeError('candidate lever differs from frozen root-cause value')
|
||||
sessions=load_unified_sessions(
|
||||
args.manifest,selected_session_ids={x['session_id'] for x in heldout})
|
||||
reference=_height_reference(sessions)
|
||||
segments=_restore_segments(sessions,reference,heldout,1.)
|
||||
R=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
problems=[build_problem(segment,R,lever,.006) for segment in segments]
|
||||
calibration_bias=_calibration_biases(engineering,problems)
|
||||
physical_bias=_physical_static_biases(sessions,reference,R,heldout)
|
||||
motion_map={'0808_20260808_092827':'circle',
|
||||
'0808_20260808_082148':'left_right',
|
||||
'0815_20260812_123424':'slope'}
|
||||
|
||||
def evaluate(mode):
|
||||
output={'best_position':[],'doppler':[],'imu_preintegration':[]}
|
||||
for problem in problems:
|
||||
session_id=problem.segment.session_id
|
||||
if mode=='calibration_frozen':
|
||||
bg=calibration_bias[session_id]['gyro_bias_rad_s']
|
||||
ba=calibration_bias[session_id]['accel_bias_m_s2']
|
||||
elif mode=='zero':
|
||||
bg=np.zeros(3); ba=np.zeros(3)
|
||||
else:
|
||||
source=physical_bias[session_id]
|
||||
if not source['available']: continue
|
||||
bg=np.zeros(3); ba=source['physical_accel_bias_m_s2']
|
||||
motion=motion_map.get(session_id,'other_recovered_dynamic')
|
||||
records=_interval_innovations(problem,motion,bg,ba)
|
||||
for key,value in records.items(): output[key].extend(value)
|
||||
root,pairs=_root_report(output['best_position'],output['doppler'])
|
||||
return {'bias_source':mode,
|
||||
'BEST_position_innovation':_factor_report(output['best_position']),
|
||||
'Doppler_innovation':_factor_report(output['doppler']),
|
||||
'IMU_preintegration_innovation':_factor_report(output['imu_preintegration']),
|
||||
'propagation_acceleration_consistency':root},pairs,output
|
||||
|
||||
modes={}; pairs={}; raw={}
|
||||
for mode in ('calibration_frozen','zero','session_static_physical'):
|
||||
modes[mode],pairs[mode],raw[mode]=evaluate(mode)
|
||||
static_ids={x['interval_id'] for x in pairs['session_static_physical']}
|
||||
common_subset={}
|
||||
for mode in ('calibration_frozen','zero','session_static_physical'):
|
||||
pos=[x for x in raw[mode]['best_position'] if x['interval_id'] in static_ids]
|
||||
vel=[x for x in raw[mode]['doppler'] if x['interval_id'] in static_ids]
|
||||
root,_=_root_report(pos,vel)
|
||||
common_subset[mode]={'BEST_position_innovation':_factor_report(pos),
|
||||
'Doppler_innovation':_factor_report(vel),
|
||||
'propagation_acceleration_consistency':root}
|
||||
def bias_norm(report,factor):
|
||||
return float(np.linalg.norm(report[factor]['overall']['innovation_bias']))
|
||||
A,C=common_subset['calibration_frozen'],common_subset['session_static_physical']
|
||||
static_available=bool(static_ids)
|
||||
best_reduction=(bias_norm(C,'BEST_position_innovation')/
|
||||
max(bias_norm(A,'BEST_position_innovation'),1e-12)
|
||||
if static_available else np.inf)
|
||||
doppler_reduction=(bias_norm(C,'Doppler_innovation')/
|
||||
max(bias_norm(A,'Doppler_innovation'),1e-12)
|
||||
if static_available else np.inf)
|
||||
nuisance_transfer=bool(static_available and best_reduction<=.5 and doppler_reduction<=.5)
|
||||
high_gyro={}
|
||||
for factor in ('BEST_position_innovation','Doppler_innovation'):
|
||||
summary=modes['calibration_frozen'][factor]['by_gyro_norm'].get('gyro_ge_0p10',{})
|
||||
limit=.5
|
||||
high_gyro[factor]={'sample_count':summary.get('sample_count',0),
|
||||
'bias_norm':float(np.linalg.norm(summary.get('innovation_bias',[np.inf]*3))),
|
||||
'vector_p95':summary.get('vector_p95',np.inf),
|
||||
'passed':bool(summary.get('sample_count',0)>=20 and
|
||||
np.linalg.norm(summary['innovation_bias'])<=.10 and
|
||||
summary['vector_p95']<=limit)}
|
||||
common_detected=modes['calibration_frozen'][
|
||||
'propagation_acceleration_consistency'][
|
||||
'common_constant_acceleration_error_detected']
|
||||
extrinsic_sensitive=bool(common_detected and
|
||||
all(x['passed'] for x in high_gyro.values()))
|
||||
propagation_passed=bool(nuisance_transfer)
|
||||
graph_bias=engineering['prior_constrained_solution'][
|
||||
'calibration_only_frozen_bias_by_session']
|
||||
graph_distribution=_bias_distribution(graph_bias,'accel_bias_m_s2')
|
||||
physical_available={k:v for k,v in physical_bias.items() if v['available']}
|
||||
physical_values={k:{'accel_bias_m_s2':v['physical_accel_bias_m_s2']}
|
||||
for k,v in physical_available.items()}
|
||||
payload={'scope':'propagation bias root-cause only; frozen extrinsic and covariance',
|
||||
'lever_reoptimized':False,'R2G_refit':False,'covariance_retuned':False,
|
||||
'R0_parser_modified':False,'new_window_selection':False,
|
||||
'data_only_free_bootstrap_LOO_called':False,
|
||||
'fixed_l_I_m':lever,'fixed_rotation_rpy_deg':args.rotation_rpy_deg,
|
||||
'calibration_window_count':47,'heldout_window_count':267,
|
||||
'target_GNSS_observation_used_for_bias_estimation':False,
|
||||
'bias_semantics':{
|
||||
'graph_nuisance_accel_bias':(
|
||||
'node-graph nuisance absorbing IMU/model/attitude effects; '
|
||||
'not assumed transferable physical sensor zero bias'),
|
||||
'physical_IMU_accel_bias':(
|
||||
'target-excluded session static estimate; horizontal components '
|
||||
'remain gravity-tilt confounded')},
|
||||
'graph_nuisance_accel_bias_distribution_m_s2':graph_distribution,
|
||||
'per_session_graph_nuisance_bias':graph_bias,
|
||||
'per_session_static_physical_bias':physical_bias,
|
||||
'static_physical_bias_distribution_m_s2':(
|
||||
_bias_distribution(physical_values,'accel_bias_m_s2')
|
||||
if physical_values else {'session_count':0}),
|
||||
'bias_source_ablation':modes,
|
||||
'common_static_interval_subset_ablation':common_subset,
|
||||
'static_common_subset_interval_count':len(static_ids),
|
||||
'physical_over_calibration_bias_norm_ratio':{
|
||||
'BEST_position':best_reduction,'Doppler':doppler_reduction},
|
||||
'common_constant_acceleration_error_detected':common_detected,
|
||||
'nuisance_bias_transfer_failure_detected':nuisance_transfer,
|
||||
'physical_ba_ablation_available':static_available,
|
||||
'nuisance_bias_transfer_assessment':(
|
||||
'confirmed' if nuisance_transfer else
|
||||
'not_testable_no_independent_static_segments' if not static_available
|
||||
else 'not_confirmed_by_static_ablation'),
|
||||
'lever_sensitive_high_gyro_validation':high_gyro,
|
||||
'independent_propagation_validation_passed':propagation_passed,
|
||||
'independent_extrinsic_sensitive_validation_passed':extrinsic_sensitive,
|
||||
'engineering_translation_accepted':False,
|
||||
'acceptance_modified_by_this_audit':False}
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
compact={'common_constant_acceleration_error_detected':common_detected,
|
||||
'static_session_count':len(physical_available),
|
||||
'static_common_subset_interval_count':len(static_ids),
|
||||
'bias_norm_ratio':payload['physical_over_calibration_bias_norm_ratio'],
|
||||
'nuisance_bias_transfer_failure_detected':nuisance_transfer,
|
||||
'high_gyro':high_gyro,
|
||||
'independent_propagation_validation_passed':propagation_passed,
|
||||
'independent_extrinsic_sensitive_validation_passed':extrinsic_sensitive,
|
||||
'engineering_translation_accepted':False,
|
||||
'calibration_frozen_acceleration':
|
||||
modes['calibration_frozen']['propagation_acceleration_consistency']['overall']}
|
||||
print(json.dumps(_jsonable(compact),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
@@ -46,6 +46,9 @@ CSV_FIELDS = [
|
||||
"pitch_deg",
|
||||
"roll_deg",
|
||||
"heading_quality",
|
||||
"heading_satellites",
|
||||
"heading_age_s",
|
||||
"heading_station_id",
|
||||
"heading_valid",
|
||||
"gga_utc",
|
||||
"hpr_utc",
|
||||
@@ -170,6 +173,9 @@ def merged_row(
|
||||
"pitch_deg": hpr_fields.get("pitch_deg"),
|
||||
"roll_deg": hpr_fields.get("roll_deg"),
|
||||
"heading_quality": hpr_fields.get("heading_quality"),
|
||||
"heading_satellites": hpr_fields.get("heading_satellites"),
|
||||
"heading_age_s": hpr_fields.get("heading_age_s"),
|
||||
"heading_station_id": hpr_fields.get("heading_station_id"),
|
||||
"heading_valid": hpr_fields.get("heading_valid"),
|
||||
"gga_utc": fields.get("position_time_utc"),
|
||||
"hpr_utc": hpr_fields.get("position_time_utc"),
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export paired G90/HI13 captures without replacing sensor time by host time."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tools.h32_dlog.timeutil import utc_dotnet_ticks_to_unix_s
|
||||
from tools.rscap_v2.capture_format_v2 import file_summary, read_capture
|
||||
from tools.rscap_v2.g90_rtk import RtkSentence, iter_g90_sentences
|
||||
from tools.rscap_v2.hi13_imu import Hi13Sample, iter_hi13_imu_samples
|
||||
from tools.time_alignment import AffineClockModel, fit_affine_clock
|
||||
|
||||
GPS_EPOCH_UNIX_S = datetime(1980, 1, 6, tzinfo=timezone.utc).timestamp()
|
||||
DEFAULT_SOURCES = (
|
||||
("0808", Path("D:/data/raw_serial_capture_v2"), "20260808"),
|
||||
("0815", Path("D:/data/0815/raw_serial_capture_v2"), None),
|
||||
("0819", Path("D:/data/0819/raw_serial_capture_v2"), None),
|
||||
)
|
||||
RTK_FIELDS = [
|
||||
"message_type", "t_device_s", "measurement_utc_s", "gnss_time_s",
|
||||
"gnss_week", "gnss_tow_ms", "leap_seconds", "host_receive_utc_s",
|
||||
"host_minus_measurement_s", "receive_utc_ticks", "checksum_valid",
|
||||
"position_time_utc", "lat_deg", "lon_deg", "altitude_m",
|
||||
"position_status", "position_type", "position_fixed", "fix_quality",
|
||||
"satellites", "solution_satellites", "hdop", "undulation_m",
|
||||
"lat_std_m", "lon_std_m", "altitude_std_m", "differential_age_s",
|
||||
"solution_age_s", "station_id", "heading_deg", "pitch_deg", "roll_deg",
|
||||
"heading_quality", "heading_satellites", "heading_age_s",
|
||||
"heading_station_id", "heading_valid", "baseline_length_m",
|
||||
"heading_type", "heading_solution_satellites", "velocity_status",
|
||||
"velocity_type", "doppler_velocity_valid", "velocity_latency_s",
|
||||
"velocity_age_s", "horizontal_speed_m_s", "track_ground_deg",
|
||||
"velocity_east_m_s", "velocity_north_m_s", "vertical_speed_m_s",
|
||||
"horizontal_speed_std_m_s", "vertical_speed_std_m_s", "raw_line",
|
||||
]
|
||||
|
||||
|
||||
def _capture_time(path: Path) -> datetime:
|
||||
match = re.search(r"_(\d{8}-\d{6}\.\d+)_", path.name)
|
||||
if not match:
|
||||
raise ValueError(f"capture filename has no timestamp: {path}")
|
||||
return datetime.strptime(match.group(1), "%Y%m%d-%H%M%S.%f")
|
||||
|
||||
|
||||
def discover_pairs(
|
||||
sources: tuple[tuple[str, Path, str | None], ...],
|
||||
*,
|
||||
max_start_delta_s: float = 1.5,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
pairs: list[dict] = []
|
||||
unmatched: list[dict] = []
|
||||
for batch, folder, date_prefix in sources:
|
||||
rtk = sorted(folder.glob("wheeltec-g90*.rscap"))
|
||||
imu = sorted(folder.glob("hi13*.rscap"))
|
||||
if date_prefix:
|
||||
rtk = [path for path in rtk if date_prefix in path.name]
|
||||
imu = [path for path in imu if date_prefix in path.name]
|
||||
available = set(imu)
|
||||
for rtk_path in rtk:
|
||||
candidates = sorted(
|
||||
(
|
||||
(abs((_capture_time(path) - _capture_time(rtk_path)).total_seconds()), path)
|
||||
for path in available
|
||||
),
|
||||
key=lambda item: item[0],
|
||||
)
|
||||
if not candidates or candidates[0][0] > max_start_delta_s:
|
||||
unmatched.append({"batch": batch, "rtk_rscap": str(rtk_path)})
|
||||
continue
|
||||
delta, imu_path = candidates[0]
|
||||
available.remove(imu_path)
|
||||
stamp = _capture_time(rtk_path).strftime("%Y%m%d_%H%M%S")
|
||||
pairs.append(
|
||||
{
|
||||
"session_id": f"{batch}_{stamp}",
|
||||
"batch_id": batch,
|
||||
"rtk_rscap": rtk_path,
|
||||
"imu_rscap": imu_path,
|
||||
"capture_start_delta_s": delta,
|
||||
}
|
||||
)
|
||||
return pairs, unmatched
|
||||
|
||||
|
||||
def _fit_clock(samples: list[Hi13Sample]) -> AffineClockModel:
|
||||
stride = max(1, len(samples) // 20000)
|
||||
selected = samples[::stride]
|
||||
device = np.asarray([sample.t_s for sample in selected], dtype=float)
|
||||
host = np.asarray(
|
||||
[utc_dotnet_ticks_to_unix_s(sample.host_receive_utc_ticks) for sample in selected],
|
||||
dtype=float,
|
||||
)
|
||||
return fit_affine_clock(device, host)
|
||||
|
||||
|
||||
def _nmea_utc_to_unix_s(value: str, host_s: float) -> float:
|
||||
packed = float(value)
|
||||
hour = int(packed // 10000)
|
||||
minute = int((packed - hour * 10000) // 100)
|
||||
second = packed - hour * 10000 - minute * 100
|
||||
receive = datetime.fromtimestamp(host_s, tz=timezone.utc)
|
||||
midnight = datetime(receive.year, receive.month, receive.day, tzinfo=timezone.utc).timestamp()
|
||||
same_day = midnight + hour * 3600 + minute * 60 + second
|
||||
return min((same_day - 86400.0, same_day, same_day + 86400.0),
|
||||
key=lambda candidate: abs(candidate - host_s))
|
||||
|
||||
|
||||
def _measurement_time(row: RtkSentence) -> tuple[float, float | None]:
|
||||
fields = row.fields
|
||||
week = fields.get("gnss_week")
|
||||
tow_ms = fields.get("gnss_tow_ms")
|
||||
leap = fields.get("leap_seconds")
|
||||
if week is not None and tow_ms is not None:
|
||||
gnss_s = float(week) * 604800.0 + float(tow_ms) * 1e-3
|
||||
utc_s = GPS_EPOCH_UNIX_S + gnss_s - float(leap or 0)
|
||||
return utc_s, gnss_s
|
||||
value = fields.get("position_time_utc")
|
||||
if value is None:
|
||||
raise ValueError(f"{row.sentence_type} has no sensor measurement time")
|
||||
host_s = utc_dotnet_ticks_to_unix_s(row.receive_utc_ticks)
|
||||
return _nmea_utc_to_unix_s(str(value), host_s), None
|
||||
|
||||
|
||||
def _format(value: object) -> object:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def _write_imu_npz(path: Path, samples: list[Hi13Sample]) -> None:
|
||||
np.savez_compressed(
|
||||
path,
|
||||
system_time_s=np.asarray([row.t_s for row in samples], dtype=np.float64),
|
||||
system_time_ms=np.asarray([row.system_time_ms for row in samples], dtype=np.uint32),
|
||||
host_receive_utc_s=np.asarray(
|
||||
[utc_dotnet_ticks_to_unix_s(row.host_receive_utc_ticks) for row in samples],
|
||||
dtype=np.float64,
|
||||
),
|
||||
gyro_rad_s=np.asarray([row.gyro_rad_s for row in samples], dtype=np.float64),
|
||||
accel_m_s2=np.asarray([row.accel_m_s2 for row in samples], dtype=np.float64),
|
||||
rpy_deg=np.asarray([row.rpy_deg for row in samples], dtype=np.float64),
|
||||
quaternion_wxyz=np.asarray([row.quaternion_wxyz for row in samples], dtype=np.float64),
|
||||
mag_ut=np.asarray([row.mag_ut for row in samples], dtype=np.float64),
|
||||
pps_sync_stamp_ms=np.asarray([row.pps_sync_stamp_ms for row in samples], dtype=np.uint16),
|
||||
temperature_c=np.asarray([row.temperature_c for row in samples], dtype=np.int16),
|
||||
air_pressure_pa=np.asarray([row.air_pressure_pa for row in samples], dtype=np.float64),
|
||||
frame_tag=np.asarray([row.frame_tag for row in samples], dtype=np.uint8),
|
||||
)
|
||||
|
||||
|
||||
def _write_rtk_csv(
|
||||
path: Path,
|
||||
rows: list[RtkSentence],
|
||||
clock: AffineClockModel,
|
||||
) -> Counter:
|
||||
counts: Counter = Counter()
|
||||
with path.open("w", encoding="utf-8", newline="") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=RTK_FIELDS)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
host_s = utc_dotnet_ticks_to_unix_s(row.receive_utc_ticks)
|
||||
try:
|
||||
measurement_s, gnss_s = _measurement_time(row)
|
||||
t_device_s = clock.inverse(measurement_s)
|
||||
host_minus_measurement_s = host_s - measurement_s
|
||||
except (TypeError, ValueError):
|
||||
measurement_s = None
|
||||
gnss_s = None
|
||||
t_device_s = None
|
||||
host_minus_measurement_s = None
|
||||
counts[f"{row.sentence_type}_invalid_measurement_time"] += 1
|
||||
fields = dict(row.fields)
|
||||
fields.update(
|
||||
{
|
||||
"message_type": row.sentence_type,
|
||||
"t_device_s": t_device_s,
|
||||
"measurement_utc_s": measurement_s,
|
||||
"gnss_time_s": gnss_s,
|
||||
"host_receive_utc_s": host_s,
|
||||
"host_minus_measurement_s": host_minus_measurement_s,
|
||||
"receive_utc_ticks": row.receive_utc_ticks,
|
||||
"checksum_valid": row.checksum_valid,
|
||||
"raw_line": row.raw_line,
|
||||
}
|
||||
)
|
||||
writer.writerow({name: _format(fields.get(name)) for name in RTK_FIELDS})
|
||||
counts[row.sentence_type] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def export_pair(pair: dict, output_root: Path, *, overwrite: bool) -> dict:
|
||||
destination = output_root / str(pair["session_id"])
|
||||
if destination.exists() and not overwrite:
|
||||
raise FileExistsError(f"{destination} exists; pass --overwrite")
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
imu_capture = read_capture(pair["imu_rscap"])
|
||||
rtk_capture = read_capture(pair["rtk_rscap"])
|
||||
imu_rows = iter_hi13_imu_samples(imu_capture)
|
||||
if len(imu_rows) < 2:
|
||||
raise ValueError(f"not enough CRC-valid HI91 rows: {pair['imu_rscap']}")
|
||||
if np.any(np.diff(np.asarray([row.t_s for row in imu_rows])) <= 0):
|
||||
raise ValueError(f"HI13 system_time is not strictly increasing: {pair['imu_rscap']}")
|
||||
clock = _fit_clock(imu_rows)
|
||||
rtk_rows = iter_g90_sentences(rtk_capture)
|
||||
_write_imu_npz(destination / "imu.npz", imu_rows)
|
||||
counts = _write_rtk_csv(destination / "rtk.csv", rtk_rows, clock)
|
||||
quaternion = np.asarray([row.quaternion_wxyz for row in imu_rows], dtype=float)
|
||||
quaternion_norm = np.linalg.norm(quaternion, axis=1)
|
||||
summary = {
|
||||
**{key: str(value) if isinstance(value, Path) else value for key, value in pair.items()},
|
||||
"time_policy": {
|
||||
"master": "HI13 system_time; RTK GNSS measurement time mapped into that clock",
|
||||
"host_receive_time": "diagnostic and affine cross-clock bridge only",
|
||||
},
|
||||
"imu_capture": file_summary(imu_capture),
|
||||
"rtk_capture": file_summary(rtk_capture),
|
||||
"imu_valid_rows": len(imu_rows),
|
||||
"imu_time_span_s": float(imu_rows[-1].t_s - imu_rows[0].t_s),
|
||||
"quaternion_norm_p01_p50_p99": [
|
||||
float(np.percentile(quaternion_norm, percentile)) for percentile in (1, 50, 99)
|
||||
],
|
||||
"rtk_records": dict(counts),
|
||||
"clock_model_device_to_host": clock.to_dict(),
|
||||
}
|
||||
(destination / "export_summary.json").write_text(
|
||||
json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
parser.add_argument("--overwrite", action="store_true")
|
||||
parser.add_argument("--resume", action="store_true")
|
||||
parser.add_argument("--session", action="append")
|
||||
args = parser.parse_args(argv)
|
||||
pairs, unmatched = discover_pairs(DEFAULT_SOURCES)
|
||||
if args.session:
|
||||
selected = set(args.session)
|
||||
pairs = [pair for pair in pairs if pair["session_id"] in selected]
|
||||
args.output_root.mkdir(parents=True, exist_ok=True)
|
||||
summaries = []
|
||||
for index, pair in enumerate(pairs, 1):
|
||||
print(f"[{index}/{len(pairs)}] {pair['session_id']}", flush=True)
|
||||
summary_path = args.output_root / pair["session_id"] / "export_summary.json"
|
||||
if args.resume and summary_path.is_file():
|
||||
summaries.append(json.loads(summary_path.read_text(encoding="utf-8")))
|
||||
continue
|
||||
summaries.append(export_pair(pair, args.output_root, overwrite=args.overwrite))
|
||||
manifest = {
|
||||
"schema_version": 3,
|
||||
"session_count": len(summaries),
|
||||
"unmatched_rtk": unmatched,
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": item["session_id"],
|
||||
"batch_id": item["batch_id"],
|
||||
"directory": str((args.output_root / item["session_id"]).resolve()),
|
||||
"rtk_records": item["rtk_records"],
|
||||
"imu_valid_rows": item["imu_valid_rows"],
|
||||
}
|
||||
for item in summaries
|
||||
],
|
||||
}
|
||||
(args.output_root / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
print(json.dumps({"output_root": str(args.output_root), **manifest}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Combine final mechanical-prior engineering release gates without refitting.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.geometry import make_transform
|
||||
from tools.audit_rtk_imu_factor_consistency import _jsonable
|
||||
|
||||
SUMMARY=('Translation is mechanically anchored and dynamically validated. '
|
||||
'The current dataset does not independently observe translation accurately '
|
||||
'enough for data-only calibration, and does not provide meaningful refinement '
|
||||
'beyond the mechanical prior.')
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--calibration',type=Path,required=True)
|
||||
p.add_argument('--heldout-postfit',type=Path,required=True)
|
||||
p.add_argument('--innovation',type=Path,required=True)
|
||||
p.add_argument('--sensitivity',type=Path,required=True)
|
||||
p.add_argument('--convergence-retry',type=Path,required=True)
|
||||
p.add_argument('--propagation-root-cause',type=Path)
|
||||
p.add_argument('--output',type=Path,required=True)
|
||||
args=p.parse_args()
|
||||
calibration=json.loads(args.calibration.read_text(encoding='utf-8'))
|
||||
heldout=json.loads(args.heldout_postfit.read_text(encoding='utf-8'))
|
||||
innovation=json.loads(args.innovation.read_text(encoding='utf-8'))
|
||||
sensitivity=json.loads(args.sensitivity.read_text(encoding='utf-8'))
|
||||
retry=json.loads(args.convergence_retry.read_text(encoding='utf-8'))
|
||||
root_cause=(None if args.propagation_root_cause is None else
|
||||
json.loads(args.propagation_root_cause.read_text(encoding='utf-8')))
|
||||
lever=np.asarray(calibration['prior_constrained_solution']['result']['final_l_I_m'])
|
||||
R=Rotation.from_euler('xyz',[.4543066225,-.0026392019,.0122384129],
|
||||
degrees=True).as_matrix()
|
||||
candidate_T=make_transform(-R@lever,R)
|
||||
candidate_inverse=np.linalg.inv(candidate_T)
|
||||
inverse_error=float(np.linalg.norm(candidate_T@candidate_inverse-np.eye(4)))
|
||||
if inverse_error>=1e-10: raise RuntimeError('candidate transforms are not inverse')
|
||||
comparison=calibration['comparisons']
|
||||
def nonconflicting(name):
|
||||
value=comparison[name]
|
||||
return (value['relative_cost_delta']<=.05 and
|
||||
all(x['p95_abs_delta']<=.25
|
||||
for x in value['factor_residual_delta'].values()))
|
||||
mechanical_consistent=bool(
|
||||
nonconflicting('fixed_vs_free') and nonconflicting('prior_data_vs_free'))
|
||||
overall=heldout['heldout_validation']['overall']
|
||||
physical_checks={
|
||||
'all_267_converged_after_retry':
|
||||
retry['heldout_convergence_after_retry']==1.,
|
||||
'BEST_position_vector_p95_le_0p20_m':
|
||||
overall['best_position_physical_m']['vector_p95']<=.20,
|
||||
'Doppler_vector_p95_le_0p50_m_s':
|
||||
overall['doppler_physical_m_s']['vector_p95']<=.50,
|
||||
'HPR_normalized_p95_le_4':
|
||||
overall['residual_by_factor']['hpr']['p95_abs']<=4.,
|
||||
'preintegration_normalized_p95_le_3':
|
||||
overall['residual_by_factor']['imu_preintegration']['p95_abs']<=3.}
|
||||
physical_passed=bool(all(physical_checks.values()))
|
||||
statistical_passed=bool(.25<=overall['global_chi_square_per_dof']<=4.)
|
||||
underdispersion=bool(physical_passed and not statistical_passed and
|
||||
overall['global_chi_square_per_dof']<.25)
|
||||
independent=bool(innovation['independent_heldout_innovation_passed'])
|
||||
rotation=bool(sensitivity['rotation_sensitivity_passed'])
|
||||
accepted=bool(mechanical_consistent and physical_passed and independent and rotation)
|
||||
payload={'scope':'final mechanical-prior RTK-IMU engineering release decision',
|
||||
'no_refit_performed':True,'data_only_full_free_called':False,
|
||||
'bootstrap_called':False,'loo_called':False,
|
||||
'covariance_retuned':False,'new_window_selection_called':False,
|
||||
'parser_R0_modified':False,
|
||||
'data_only_translation_accepted':False,
|
||||
'translation_refined_by_data':False,
|
||||
'mechanical_prior_consistent_with_calibration':mechanical_consistent,
|
||||
'heldout_physical_validation_passed':physical_passed,
|
||||
'heldout_physical_gate_checks':physical_checks,
|
||||
'heldout_statistical_scale_passed':statistical_passed,
|
||||
'heldout_postfit_chi_square_per_dof':
|
||||
overall['global_chi_square_per_dof'],
|
||||
'heldout_covariance_underdispersion_warning':underdispersion,
|
||||
'independent_heldout_innovation_passed':independent,
|
||||
'common_constant_acceleration_error_detected':(
|
||||
None if root_cause is None else
|
||||
root_cause['common_constant_acceleration_error_detected']),
|
||||
'independent_propagation_validation_passed':(
|
||||
None if root_cause is None else
|
||||
root_cause['independent_propagation_validation_passed']),
|
||||
'independent_extrinsic_sensitive_validation_passed':(
|
||||
None if root_cause is None else
|
||||
root_cause['independent_extrinsic_sensitive_validation_passed']),
|
||||
'rotation_sensitivity_passed':rotation,
|
||||
'engineering_translation_acceptance_formula':(
|
||||
'mechanical_prior_consistent_with_calibration AND '
|
||||
'heldout_physical_validation_passed AND '
|
||||
'independent_heldout_innovation_passed AND rotation_sensitivity_passed'),
|
||||
'engineering_translation_accepted':accepted,
|
||||
'result_nature':'mechanically anchored + dynamically validated',
|
||||
'summary':SUMMARY,'forbidden_descriptions':[
|
||||
'data-only calibrated translation','dynamically refined mechanical lever'],
|
||||
'candidate_l_I_engineering_m':lever,
|
||||
'candidate_T_RTK_IMU':candidate_T,
|
||||
'candidate_T_IMU_RTK':candidate_inverse,
|
||||
'candidate_transform_inverse_error_norm':inverse_error,
|
||||
'l_I_engineering_m':lever if accepted else None,
|
||||
'T_RTK_IMU':candidate_T if accepted else None,
|
||||
'T_IMU_RTK':candidate_inverse if accepted else None,
|
||||
'transform_convention':{
|
||||
'equation':'p_RTK = R_RTK_IMU * p_IMU + t_RTK_IMU',
|
||||
'translation':'t_RTK_IMU = -R_RTK_IMU * l_I',
|
||||
'RTK_origin':'ANT1 phase center'},
|
||||
'rotation_source':'R2G_gravity_level_prior',
|
||||
'translation_conditional_on_rotation':True,
|
||||
'evidence':{
|
||||
'calibration_path':str(args.calibration),
|
||||
'heldout_postfit_path':str(args.heldout_postfit),
|
||||
'innovation_path':str(args.innovation),
|
||||
'sensitivity_path':str(args.sensitivity),
|
||||
'convergence_retry_path':str(args.convergence_retry),
|
||||
'propagation_root_cause_path':(
|
||||
None if args.propagation_root_cause is None
|
||||
else str(args.propagation_root_cause)),
|
||||
'posterior_prior_variance_ratio':
|
||||
calibration['prior_constrained_solution']['posterior_prior_variance_ratio'],
|
||||
'heldout_convergence_after_retry':
|
||||
retry['heldout_convergence_after_retry'],
|
||||
'rotation_sensitivity_summary':sensitivity['summary']}}
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable({key:payload[key] for key in (
|
||||
'data_only_translation_accepted','translation_refined_by_data',
|
||||
'mechanical_prior_consistent_with_calibration',
|
||||
'heldout_physical_validation_passed','heldout_statistical_scale_passed',
|
||||
'heldout_covariance_underdispersion_warning',
|
||||
'independent_heldout_innovation_passed','rotation_sensitivity_passed',
|
||||
'engineering_translation_accepted')}),indent=2))
|
||||
return 0
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Refine non-overlapping windows after comparing predicted and actual lever information.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import build_problem,linearized_lever_information
|
||||
from tools.audit_rtk_imu_factor_consistency import _jsonable
|
||||
from tools.run_rtk_imu_node_graph_free_selected import _restore_segments
|
||||
from tools.select_rtk_imu_windows_by_lever_information import (
|
||||
STD_GATE,_overlap,_sample_keys,_summary,_window_candidates)
|
||||
|
||||
|
||||
def _sqrt_psd(matrix,inverse=False):
|
||||
values,vectors=np.linalg.eigh(.5*(matrix+matrix.T))
|
||||
values=np.maximum(values,1e-12)
|
||||
scale=1./np.sqrt(values) if inverse else np.sqrt(values)
|
||||
return (vectors*scale)@vectors.T
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--base-selection',type=Path,required=True)
|
||||
parser.add_argument('--actual-result',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--sample-period-s',type=float,default=1.)
|
||||
parser.add_argument('--window-duration-s',type=float,default=15.)
|
||||
parser.add_argument('--max-additional-windows',type=int,default=400)
|
||||
parser.add_argument('--hpr-direct-sigma-rad',type=float,default=.006)
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
args=parser.parse_args()
|
||||
base=json.loads(args.base_selection.read_text(encoding='utf-8'))
|
||||
actual=json.loads(args.actual_result.read_text(encoding='utf-8'))
|
||||
actual_solution=next(iter(actual['solutions'].values()))
|
||||
lever=np.asarray(actual_solution['final_l_I_m'],dtype=float)
|
||||
actual_cov=np.asarray(actual_solution['lever_covariance_m2'],dtype=float)
|
||||
actual_information=np.linalg.pinv(actual_cov,rcond=1e-9)
|
||||
predicted_information=sum((np.asarray(item['single_window_information'])
|
||||
for item in base['selected_windows']),np.zeros((3,3)))
|
||||
correction=_sqrt_psd(actual_information)@_sqrt_psd(predicted_information,True)
|
||||
sessions=load_unified_sessions(args.manifest)
|
||||
reference=_height_reference(sessions)
|
||||
rotation=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
restored=_restore_segments(sessions,reference,base['selected_windows'],
|
||||
args.sample_period_s)
|
||||
session_by_id={session.session_id:session for session in sessions}
|
||||
selected=[]
|
||||
for item,segment in zip(base['selected_windows'],restored):
|
||||
selected.append({**item,'segment':segment,
|
||||
'session':session_by_id[item['session_id']]})
|
||||
candidates=[entry for entry in _window_candidates(
|
||||
sessions,reference,args.sample_period_s,args.window_duration_s)
|
||||
if not any(_overlap(entry,item) for item in selected)]
|
||||
for entry in candidates:
|
||||
problem=build_problem(entry['segment'],rotation,lever,args.hpr_direct_sigma_rad)
|
||||
raw,_,singular,weak=linearized_lever_information(problem,lever)
|
||||
entry['raw_information']=raw
|
||||
entry['information']=correction@raw@correction.T
|
||||
entry['single_window_singular_values']=singular
|
||||
entry['single_window_weakest_direction_I']=weak
|
||||
total=actual_information.copy()
|
||||
curve=[{'window_count':len(selected),'added_candidate_id':'actual_base',
|
||||
'information_source':'actual_joint_free_schur',**_summary(total)}]
|
||||
remaining=list(candidates); saturation_count=0
|
||||
stop_reason='candidate_exhausted'
|
||||
while remaining and len(selected)<len(base['selected_windows'])+args.max_additional_windows:
|
||||
feasible=[entry for entry in remaining
|
||||
if not any(_overlap(entry,item) for item in selected)]
|
||||
if not feasible: break
|
||||
ranked=[]
|
||||
for entry in feasible:
|
||||
summary=_summary(total+entry['information'])
|
||||
ranked.append((summary['lambda_min'],summary['logdet'],entry,summary))
|
||||
_,_,choice,summary=max(ranked,key=lambda item:(item[0],item[1]))
|
||||
gain=summary['lambda_min']-curve[-1]['lambda_min']
|
||||
selected.append(choice); remaining.remove(choice); total+=choice['information']
|
||||
curve.append({'window_count':len(selected),'added_candidate_id':choice['candidate_id'],
|
||||
'delta_lambda_min':gain,'information_source':'actual-calibrated prediction',
|
||||
**summary})
|
||||
saturation_count=saturation_count+1 if gain<.01 else 0
|
||||
if np.all(np.asarray(summary['std_m'])<=STD_GATE):
|
||||
stop_reason='actual_calibrated_observability_gate_reached'; break
|
||||
if saturation_count>=3:
|
||||
stop_reason='incremental_lambda_min_gain_saturated'; break
|
||||
else:
|
||||
if len(selected)>=len(base['selected_windows'])+args.max_additional_windows:
|
||||
stop_reason='max_additional_windows_reached'
|
||||
seen={'imu':set(),'gnss':set(),'hpr':set()}; duplicate={key:0 for key in seen}
|
||||
output=[]
|
||||
for order,entry in enumerate(selected):
|
||||
keys=_sample_keys(entry); shared={key:len(value&seen[key]) for key,value in keys.items()}
|
||||
for key,value in keys.items(): duplicate[key]+=shared[key]; seen[key].update(value)
|
||||
information=np.asarray(entry['information'] if 'information' in entry
|
||||
else entry['single_window_information'])
|
||||
output.append({'selection_order':order,'candidate_id':entry['candidate_id'],
|
||||
'session_id':entry['session_id'],'start_s':entry['start_s'],
|
||||
'end_s':entry['end_s'],'duration_s':entry['duration_s'],
|
||||
'node_count':entry['node_count'],'seed_window':order<len(base['selected_windows']),
|
||||
'single_window_information':information,
|
||||
'raw_single_window_information':entry.get('raw_information'),
|
||||
'sample_count':{key:len(value) for key,value in keys.items()},
|
||||
'shared_sample_count_with_previous':shared})
|
||||
overlap={'duplicate_sample_count':duplicate,
|
||||
'all_selected_windows_time_nonoverlapping_within_session':not any(
|
||||
_overlap(a,b) for i,a in enumerate(selected) for b in selected[i+1:]),
|
||||
'unique_sample_count':{key:len(value) for key,value in seen.items()}}
|
||||
payload={'scope':'actual-H calibrated incremental lever-information selection',
|
||||
'manual_prior_used':False,'base_window_count':len(base['selected_windows']),
|
||||
'candidate_count':len(candidates),'selected_window_count':len(selected),
|
||||
'actual_base_l_I_m':lever,'actual_base_information':actual_information,
|
||||
'predicted_base_information':predicted_information,
|
||||
'information_congruence_correction':correction,
|
||||
'selection_objective':'maximize lambda_min(H_l); break ties by logdet(H_l)',
|
||||
'std_gate_m':STD_GATE,'stop_reason':stop_reason,
|
||||
'selected_windows':output,'lever_std_vs_information_curve':curve,
|
||||
'sample_overlap_audit':overlap,
|
||||
'final_calibrated_linearized_observable':bool(
|
||||
np.all(np.asarray(curve[-1]['std_m'])<=STD_GATE))}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable({'selected_window_count':len(selected),
|
||||
'additional_window_count':len(selected)-len(base['selected_windows']),
|
||||
'stop_reason':stop_reason,'final_curve':curve[-1],
|
||||
'sample_overlap_audit':overlap}),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Retry only held-out fixed-lever windows that ended at max_nfev.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import (
|
||||
build_problem,fit_states_at_fixed_lever,summarize_fixed_state_values)
|
||||
from tools.audit_rtk_imu_factor_consistency import _jsonable
|
||||
from tools.run_rtk_imu_node_graph_free_selected import _restore_segments
|
||||
|
||||
def _retry(task):
|
||||
index,problem,lever,state,max_nfev=task
|
||||
value,meta=fit_states_at_fixed_lever(
|
||||
problem,lever,max_nfev,initial_state_values=state)
|
||||
return index,value,meta
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--manifest',type=Path,required=True)
|
||||
p.add_argument('--calibration-selection',type=Path,required=True)
|
||||
p.add_argument('--all-selection',type=Path,required=True)
|
||||
p.add_argument('--engineering-result',type=Path,required=True)
|
||||
p.add_argument('--checkpoint-dir',type=Path,required=True)
|
||||
p.add_argument('--output',type=Path,required=True)
|
||||
p.add_argument('--retry-checkpoint-dir',type=Path,required=True)
|
||||
p.add_argument('--max-nfev',type=int,default=120)
|
||||
p.add_argument('--workers',type=int,default=4)
|
||||
p.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
args=p.parse_args()
|
||||
engineering=json.loads(args.engineering_result.read_text(encoding='utf-8'))
|
||||
calibration=json.loads(args.calibration_selection.read_text(encoding='utf-8'))
|
||||
selected=json.loads(args.all_selection.read_text(encoding='utf-8'))
|
||||
ids={x['candidate_id'] for x in calibration['selected_windows']}
|
||||
heldout=[x for x in selected['selected_windows'] if x['candidate_id'] not in ids]
|
||||
lever=np.asarray(engineering['engineering_l_I_m'],dtype=float)
|
||||
bad=[]
|
||||
for index,item in enumerate(heldout):
|
||||
data=np.load(args.checkpoint_dir/f'{index:04d}.npz',allow_pickle=False)
|
||||
meta=json.loads(str(data['optimizer']))
|
||||
if not meta['success']:
|
||||
bad.append((index,item,data['state'].copy(),meta))
|
||||
if len(bad)!=10: raise RuntimeError(f'expected 10 non-converged windows, got {len(bad)}')
|
||||
sessions=load_unified_sessions(
|
||||
args.manifest,selected_session_ids={item['session_id'] for _,item,_,_ in bad})
|
||||
reference=_height_reference(sessions)
|
||||
selections=[item for _,item,_,_ in bad]
|
||||
segments=_restore_segments(sessions,reference,selections,1.)
|
||||
R=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
problems=[build_problem(segment,R,lever,.006) for segment in segments]
|
||||
tasks=[(local,problem,lever,bad[local][2],args.max_nfev)
|
||||
for local,problem in enumerate(problems)]
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as executor:
|
||||
retried=list(executor.map(_retry,tasks))
|
||||
retried.sort(key=lambda x:x[0])
|
||||
args.retry_checkpoint_dir.mkdir(parents=True,exist_ok=True)
|
||||
mapping={'0808_20260808_092827':'circle',
|
||||
'0808_20260808_082148':'left_right',
|
||||
'0815_20260812_123424':'slope'}
|
||||
results=[]
|
||||
for local,state,meta in retried:
|
||||
original_index,item,_,previous=bad[local]
|
||||
summary=summarize_fixed_state_values([problems[local]],[state],lever)
|
||||
np.savez_compressed(args.retry_checkpoint_dir/f'{original_index:04d}.npz',
|
||||
state=state,optimizer=json.dumps(meta))
|
||||
results.append({'heldout_index':original_index,
|
||||
'candidate_id':item['candidate_id'],'session_id':item['session_id'],
|
||||
'time_range_s':[item['start_s'],item['end_s']],
|
||||
'motion_class':mapping.get(item['session_id'],'other_recovered_dynamic'),
|
||||
'original_termination_reason':previous['message'],
|
||||
'original_nfev':previous['nfev'],'original_final_cost':previous['cost'],
|
||||
'retry_started_from_original_final_state':True,
|
||||
'retry_termination_reason':meta['message'],'retry_nfev':meta['nfev'],
|
||||
'retry_initial_cost':meta['initial_cost'],'retry_final_cost':meta['cost'],
|
||||
'retry_hit_max_nfev':bool(not meta['success'] and meta['nfev']>=args.max_nfev),
|
||||
'retry_success':meta['success'],'residual':summary})
|
||||
converged=sum(x['retry_success'] for x in results)
|
||||
payload={'scope':'10 held-out max_nfev windows; exact-model continuation retry',
|
||||
'lever_reoptimized':False,'covariance_parameters_modified':False,
|
||||
'parser_R0_modified':False,'windows_removed':False,
|
||||
'fixed_l_I_m':lever,'retry_max_nfev':args.max_nfev,
|
||||
'initial_nonconverged_count':len(results),
|
||||
'converged_after_retry_count':converged,
|
||||
'heldout_convergence_after_retry':(257+converged)/267.,
|
||||
'remaining_nonconverged_count':len(results)-converged,
|
||||
'windows':results}
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable({'converged_after_retry':converged,
|
||||
'remaining':len(results)-converged,
|
||||
'heldout_convergence_after_retry':payload['heldout_convergence_after_retry'],
|
||||
'windows':[{'index':x['heldout_index'],'session':x['session_id'],
|
||||
'motion':x['motion_class'],'success':x['retry_success'],
|
||||
'nfev':x['retry_nfev'],'cost':x['retry_final_cost']}
|
||||
for x in results]}),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
+163
-10
@@ -1,4 +1,9 @@
|
||||
"""Decode Wheeltec G90 NMEA (GGA / GNHPR) from a V2 .rscap capture."""
|
||||
"""Decode calibration-relevant Wheeltec G90 logs from a V2 capture.
|
||||
|
||||
GNSS-owned measurement time is preserved for every record. Host receive time
|
||||
only identifies the chunk that completed the line and must not be substituted
|
||||
for the measurement timestamp.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,6 +28,32 @@ def nmea_checksum_valid(line: str) -> bool:
|
||||
return value == expected
|
||||
|
||||
|
||||
def unicore_checksum_valid(line: str) -> bool:
|
||||
"""Validate the CRC32 suffix used by Unicore hash-prefixed logs."""
|
||||
|
||||
star = line.rfind("*")
|
||||
if star < 0:
|
||||
return False
|
||||
try:
|
||||
expected = int(line[star + 1 : star + 9], 16)
|
||||
except ValueError:
|
||||
return False
|
||||
crc = 0
|
||||
for value in line[1:star].encode("ascii", "replace"):
|
||||
crc ^= value
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ (0xEDB88320 if crc & 1 else 0)
|
||||
return (crc & 0xFFFFFFFF) == expected
|
||||
|
||||
|
||||
def g90_checksum_valid(line: str) -> bool:
|
||||
if line.startswith("$"):
|
||||
return nmea_checksum_valid(line)
|
||||
if line.startswith("#"):
|
||||
return unicore_checksum_valid(line)
|
||||
return False
|
||||
|
||||
|
||||
def _safe_float(value: str):
|
||||
try:
|
||||
return float(value)
|
||||
@@ -76,8 +107,124 @@ def parse_gnhpr(line: str) -> dict:
|
||||
"pitch_deg": _safe_float(fields[3]),
|
||||
"roll_deg": _safe_float(fields[4]),
|
||||
"heading_quality": quality,
|
||||
"satellites": _safe_int(fields[6]),
|
||||
"heading_valid": quality in {4, 5},
|
||||
"heading_satellites": _safe_int(fields[6]),
|
||||
"heading_age_s": _safe_float(fields[7]) if len(fields) > 7 else None,
|
||||
"heading_station_id": fields[8] if len(fields) > 8 else None,
|
||||
"heading_valid": quality == 4,
|
||||
}
|
||||
|
||||
|
||||
def _split_unicore(line: str) -> tuple[list[str], list[str]]:
|
||||
before_checksum = line[: line.rfind("*")]
|
||||
header, payload = before_checksum.split(";", 1)
|
||||
return header[1:].split(","), payload.split(",")
|
||||
|
||||
|
||||
def _parse_unicore_header(fields: list[str]) -> dict:
|
||||
if len(fields) < 9:
|
||||
raise ValueError("Unicore ASCII header is incomplete")
|
||||
return {
|
||||
"gnss_week": _safe_int(fields[4]),
|
||||
"gnss_tow_ms": _safe_int(fields[5]),
|
||||
"leap_seconds": _safe_int(fields[8]),
|
||||
}
|
||||
|
||||
|
||||
def parse_bestnava(line: str) -> dict:
|
||||
"""Parse BESTNAVA position and Doppler-velocity fields."""
|
||||
|
||||
header, fields = _split_unicore(line)
|
||||
if len(fields) < 30:
|
||||
raise ValueError("BESTNAVA has too few fields")
|
||||
result = {
|
||||
"type": "BESTNAVA",
|
||||
**_parse_unicore_header(header),
|
||||
"position_status": fields[0],
|
||||
"position_type": fields[1],
|
||||
"lat_deg": _safe_float(fields[2]),
|
||||
"lon_deg": _safe_float(fields[3]),
|
||||
"altitude_m": _safe_float(fields[4]),
|
||||
"undulation_m": _safe_float(fields[5]),
|
||||
"lat_std_m": _safe_float(fields[7]),
|
||||
"lon_std_m": _safe_float(fields[8]),
|
||||
"altitude_std_m": _safe_float(fields[9]),
|
||||
"station_id": fields[10].strip('"'),
|
||||
"differential_age_s": _safe_float(fields[11]),
|
||||
"solution_age_s": _safe_float(fields[12]),
|
||||
"satellites": _safe_int(fields[13]),
|
||||
"solution_satellites": _safe_int(fields[14]),
|
||||
"velocity_status": fields[21],
|
||||
"velocity_type": fields[22],
|
||||
"velocity_latency_s": _safe_float(fields[23]),
|
||||
"velocity_age_s": _safe_float(fields[24]),
|
||||
"horizontal_speed_m_s": _safe_float(fields[25]),
|
||||
"track_ground_deg": _safe_float(fields[26]),
|
||||
"vertical_speed_m_s": _safe_float(fields[27]),
|
||||
"vertical_speed_std_m_s": _safe_float(fields[28]),
|
||||
"horizontal_speed_std_m_s": _safe_float(fields[29]),
|
||||
}
|
||||
speed = result["horizontal_speed_m_s"]
|
||||
track = result["track_ground_deg"]
|
||||
if speed is not None and track is not None:
|
||||
angle = math.radians(track)
|
||||
result["velocity_east_m_s"] = speed * math.sin(angle)
|
||||
result["velocity_north_m_s"] = speed * math.cos(angle)
|
||||
else:
|
||||
result["velocity_east_m_s"] = None
|
||||
result["velocity_north_m_s"] = None
|
||||
result["position_fixed"] = (
|
||||
result["position_status"] == "SOL_COMPUTED"
|
||||
and result["position_type"] == "NARROW_INT"
|
||||
)
|
||||
result["doppler_velocity_valid"] = (
|
||||
result["velocity_status"] == "SOL_COMPUTED"
|
||||
and result["velocity_type"] == "DOPPLER_VELOCITY"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def parse_pvtslna(line: str) -> dict:
|
||||
"""Parse PVTSLNA as a quality-rich fallback/diagnostic record."""
|
||||
|
||||
header, fields = _split_unicore(line)
|
||||
if len(fields) < 34:
|
||||
raise ValueError("PVTSLNA has too few fields")
|
||||
speed_north = _safe_float(fields[17])
|
||||
speed_east = _safe_float(fields[18])
|
||||
return {
|
||||
"type": "PVTSLNA",
|
||||
**_parse_unicore_header(header),
|
||||
"position_type": fields[0],
|
||||
"altitude_m": _safe_float(fields[1]),
|
||||
"lat_deg": _safe_float(fields[2]),
|
||||
"lon_deg": _safe_float(fields[3]),
|
||||
"altitude_std_m": _safe_float(fields[4]),
|
||||
"lat_std_m": _safe_float(fields[5]),
|
||||
"lon_std_m": _safe_float(fields[6]),
|
||||
"differential_age_s": _safe_float(fields[7]),
|
||||
"psr_position_type": fields[8],
|
||||
"undulation_m": _safe_float(fields[12]),
|
||||
"satellites": _safe_int(fields[13]),
|
||||
"solution_satellites": _safe_int(fields[14]),
|
||||
"velocity_north_m_s": speed_north,
|
||||
"velocity_east_m_s": speed_east,
|
||||
"horizontal_speed_m_s": (
|
||||
None if speed_north is None or speed_east is None
|
||||
else math.hypot(speed_north, speed_east)
|
||||
),
|
||||
"vertical_speed_m_s": _safe_float(fields[19]),
|
||||
"heading_type": fields[20],
|
||||
"baseline_length_m": _safe_float(fields[21]),
|
||||
"heading_deg": _safe_float(fields[22]),
|
||||
"pitch_deg": _safe_float(fields[23]),
|
||||
"heading_satellites": _safe_int(fields[24]),
|
||||
"heading_solution_satellites": _safe_int(fields[25]),
|
||||
"gdop": _safe_float(fields[28]),
|
||||
"pdop": _safe_float(fields[29]),
|
||||
"hdop": _safe_float(fields[30]),
|
||||
"htdop": _safe_float(fields[31]),
|
||||
"tdop": _safe_float(fields[32]),
|
||||
"position_fixed": fields[0] == "NARROW_INT",
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +252,7 @@ class RtkSentence:
|
||||
|
||||
|
||||
def iter_g90_sentences(capture: CaptureFile) -> list[RtkSentence]:
|
||||
"""Parse GGA/GNHPR lines; host time comes from the containing serial chunk."""
|
||||
"""Parse native asynchronous GGA/GNHPR/BESTNAVA/PVTSLNA records."""
|
||||
|
||||
rows: list[RtkSentence] = []
|
||||
for _segment_id, chunks in iter_contiguous_segments(capture.chunks):
|
||||
@@ -122,21 +269,27 @@ def iter_g90_sentences(capture: CaptureFile) -> list[RtkSentence]:
|
||||
if not raw_line:
|
||||
continue
|
||||
line = raw_line.decode("ascii", "replace")
|
||||
if not (line.startswith("$GNGGA") or line.startswith("$GPGGA") or line.startswith("$GNHPR")):
|
||||
parser = None
|
||||
if line.startswith("$GNGGA") or line.startswith("$GPGGA"):
|
||||
parser = parse_gga
|
||||
elif line.startswith("$GNHPR"):
|
||||
parser = parse_gnhpr
|
||||
elif line.startswith("#BESTNAVA"):
|
||||
parser = parse_bestnava
|
||||
elif line.startswith("#PVTSLNA"):
|
||||
parser = parse_pvtslna
|
||||
if parser is None:
|
||||
continue
|
||||
ticks = _host_ticks_for_span(chunks, starts, end)
|
||||
try:
|
||||
if line.startswith("$GNGGA") or line.startswith("$GPGGA"):
|
||||
fields = parse_gga(line)
|
||||
else:
|
||||
fields = parse_gnhpr(line)
|
||||
fields = parser(line)
|
||||
except ValueError:
|
||||
continue
|
||||
rows.append(
|
||||
RtkSentence(
|
||||
sentence_type=str(fields["type"]),
|
||||
receive_utc_ticks=int(ticks),
|
||||
checksum_valid=nmea_checksum_valid(line),
|
||||
checksum_valid=g90_checksum_valid(line),
|
||||
fields=fields,
|
||||
raw_line=line,
|
||||
)
|
||||
|
||||
+57
-18
@@ -12,6 +12,7 @@ HI91 (preferred for calibration):
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -22,6 +23,29 @@ G0 = 9.80665
|
||||
DEG2RAD = np.pi / 180.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hi13Sample:
|
||||
"""One CRC-valid native HI91 record.
|
||||
|
||||
t_s/system_time_ms are sensor-owned. Host receive ticks are retained only
|
||||
for clock diagnostics and cross-clock fitting.
|
||||
"""
|
||||
|
||||
t_s: float
|
||||
system_time_ms: int
|
||||
device_timestamp_us: int
|
||||
host_receive_utc_ticks: int
|
||||
gyro_rad_s: tuple[float, float, float]
|
||||
accel_m_s2: tuple[float, float, float]
|
||||
rpy_deg: tuple[float, float, float]
|
||||
quaternion_wxyz: tuple[float, float, float, float]
|
||||
mag_ut: tuple[float, float, float]
|
||||
pps_sync_stamp_ms: int
|
||||
temperature_c: int
|
||||
air_pressure_pa: float
|
||||
frame_tag: int = 0x91
|
||||
|
||||
|
||||
def crc16_hi13(frame: bytes, payload_length: int) -> int:
|
||||
crc = 0
|
||||
for value in frame[:4]:
|
||||
@@ -41,8 +65,8 @@ def _update_crc16(crc: int, value: int) -> int:
|
||||
return crc
|
||||
|
||||
|
||||
def parse_hi91_frame(raw: bytes) -> tuple[tuple[float, float, float], tuple[float, float, float], int] | None:
|
||||
"""Return (gyro_rad_s, accel_m_s2, device_timestamp_ms) for a CRC-valid HI91 frame."""
|
||||
def parse_hi91_sample(raw: bytes, host_receive_utc_ticks: int = 0) -> Hi13Sample | None:
|
||||
"""Decode every calibration-relevant field from a CRC-valid HI91 frame."""
|
||||
|
||||
if len(raw) < 6 + 76:
|
||||
return None
|
||||
@@ -54,12 +78,34 @@ def parse_hi91_frame(raw: bytes) -> tuple[tuple[float, float, float], tuple[floa
|
||||
expected = raw[4] | (raw[5] << 8)
|
||||
if crc16_hi13(raw, payload_length) != expected:
|
||||
return None
|
||||
device_ms = struct.unpack_from("<I", raw, 14)[0]
|
||||
device_ms = int(struct.unpack_from("<I", raw, 14)[0])
|
||||
ax, ay, az = struct.unpack_from("<fff", raw, 18)
|
||||
gx, gy, gz = struct.unpack_from("<fff", raw, 30)
|
||||
gyro = (gx * DEG2RAD, gy * DEG2RAD, gz * DEG2RAD)
|
||||
accel = (ax * G0, ay * G0, az * G0)
|
||||
return gyro, accel, int(device_ms)
|
||||
return Hi13Sample(
|
||||
t_s=float(device_ms) * 1e-3,
|
||||
system_time_ms=device_ms,
|
||||
device_timestamp_us=device_ms * 1000,
|
||||
host_receive_utc_ticks=int(host_receive_utc_ticks),
|
||||
gyro_rad_s=gyro,
|
||||
accel_m_s2=accel,
|
||||
rpy_deg=struct.unpack_from("<fff", raw, 54),
|
||||
quaternion_wxyz=struct.unpack_from("<ffff", raw, 66),
|
||||
mag_ut=struct.unpack_from("<fff", raw, 42),
|
||||
pps_sync_stamp_ms=int(struct.unpack_from("<H", raw, 7)[0]),
|
||||
temperature_c=int(struct.unpack_from("<b", raw, 9)[0]),
|
||||
air_pressure_pa=float(struct.unpack_from("<f", raw, 10)[0]),
|
||||
)
|
||||
|
||||
|
||||
def parse_hi91_frame(raw: bytes) -> tuple[tuple[float, float, float], tuple[float, float, float], int] | None:
|
||||
"""Backward-compatible compact HI91 decoder."""
|
||||
|
||||
sample = parse_hi91_sample(raw)
|
||||
if sample is None:
|
||||
return None
|
||||
return sample.gyro_rad_s, sample.accel_m_s2, sample.system_time_ms
|
||||
|
||||
|
||||
def iter_hi13_imu_samples(
|
||||
@@ -67,14 +113,14 @@ def iter_hi13_imu_samples(
|
||||
*,
|
||||
host_utc_ticks_min: int | None = None,
|
||||
host_utc_ticks_max: int | None = None,
|
||||
) -> list[ImuSample]:
|
||||
) -> list[Hi13Sample]:
|
||||
"""Return CRC-valid HI91 samples sorted by device timestamp.
|
||||
|
||||
Streams chunk-by-chunk (no giant join) and can skip whole chunks outside the
|
||||
host UTC receive window before parsing.
|
||||
"""
|
||||
|
||||
samples: list[ImuSample] = []
|
||||
samples: list[Hi13Sample] = []
|
||||
carry = b""
|
||||
for chunk in capture.chunks:
|
||||
if host_utc_ticks_min is not None and chunk.receive_utc_ticks < host_utc_ticks_min:
|
||||
@@ -102,25 +148,16 @@ def iter_hi13_imu_samples(
|
||||
if end > len(stream):
|
||||
carry = stream[sync:]
|
||||
break
|
||||
parsed = parse_hi91_frame(stream[sync:end])
|
||||
host_ticks = chunk.receive_utc_ticks
|
||||
parsed = parse_hi91_sample(stream[sync:end], host_ticks)
|
||||
cursor = end
|
||||
if parsed is None:
|
||||
continue
|
||||
gyro, accel, device_ms = parsed
|
||||
host_ticks = chunk.receive_utc_ticks
|
||||
if host_utc_ticks_min is not None and host_ticks < host_utc_ticks_min:
|
||||
continue
|
||||
if host_utc_ticks_max is not None and host_ticks > host_utc_ticks_max:
|
||||
continue
|
||||
samples.append(
|
||||
ImuSample(
|
||||
t_s=float(device_ms) * 1e-3,
|
||||
gyro_rad_s=gyro,
|
||||
accel_m_s2=accel,
|
||||
host_receive_utc_ticks=host_ticks,
|
||||
device_timestamp_us=int(device_ms) * 1000,
|
||||
)
|
||||
)
|
||||
samples.append(parsed)
|
||||
else:
|
||||
carry = b""
|
||||
samples.sort(key=lambda sample: (sample.t_s, sample.device_timestamp_us))
|
||||
@@ -129,8 +166,10 @@ def iter_hi13_imu_samples(
|
||||
|
||||
__all__ = [
|
||||
"ImuSample",
|
||||
"Hi13Sample",
|
||||
"crc16_hi13",
|
||||
"iter_hi13_imu_samples",
|
||||
"parse_hi91_frame",
|
||||
"parse_hi91_sample",
|
||||
"samples_to_arrays",
|
||||
]
|
||||
|
||||
@@ -12,7 +12,13 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from imu_lidar.rtk_imu_replay import load_inventory, load_sessions, run_calibration
|
||||
from imu_lidar.rtk_imu_replay import (
|
||||
DEFAULT_RTK_FRAME_DEFINITION,
|
||||
DEFAULT_RTK_REFERENCE_POINT,
|
||||
load_inventory,
|
||||
load_sessions,
|
||||
run_calibration,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -33,8 +39,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("--no-loo", action="store_true")
|
||||
parser.add_argument("--knot-step-s", type=float, default=2.0)
|
||||
parser.add_argument("--per-batch", action="store_true")
|
||||
parser.add_argument("--rtk-frame-definition", default='')
|
||||
parser.add_argument("--rtk-reference-point", default='')
|
||||
parser.add_argument("--rtk-frame-definition", default=DEFAULT_RTK_FRAME_DEFINITION)
|
||||
parser.add_argument("--rtk-reference-point", default=DEFAULT_RTK_REFERENCE_POINT)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
entries = load_inventory(args.inventory)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the conditional engineering RTK--IMU 6DoF lever-arm branch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from imu_lidar.rtk_imu_engineering import (
|
||||
DEFAULT_MANUAL_L_I_M,
|
||||
DEFAULT_MANUAL_L_I_COVARIANCE_M2,
|
||||
solve_engineering_6dof,
|
||||
)
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
|
||||
|
||||
def _jsonable(value):
|
||||
if isinstance(value, np.ndarray):
|
||||
return _jsonable(value.tolist())
|
||||
if isinstance(value, np.generic):
|
||||
return _jsonable(value.item())
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else None
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {key: _jsonable(item) for key, item in asdict(value).items()}
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--session", action="append", required=True,
|
||||
help="Dynamic/static sessions intentionally admitted to this engineering run.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rotation-rpy-deg", nargs=3, type=float,
|
||||
default=[0.4543066225, -0.0026392019, 0.0122384129],
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manual-l-i-m", nargs=3, type=float, default=DEFAULT_MANUAL_L_I_M.tolist(),
|
||||
help="Mechanical ANT1 phase-centre lever arm p_ANT1^I in metres.",
|
||||
)
|
||||
prior_group = parser.add_mutually_exclusive_group()
|
||||
prior_group.add_argument(
|
||||
"--manual-l-i-std-m", nargs=3, type=float,
|
||||
default=np.sqrt(np.diag(DEFAULT_MANUAL_L_I_COVARIANCE_M2)).tolist(),
|
||||
help="Mechanical 1-sigma prior standard deviation for lx, ly, lz in metres.",
|
||||
)
|
||||
prior_group.add_argument(
|
||||
"--manual-l-i-covariance-m2", nargs=9, type=float,
|
||||
help="Row-major 3x3 mechanical prior covariance in m^2.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-manual-prior", action="store_true",
|
||||
help="Run a free solve only; retain the mechanical reference in no optimisation factor.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--segment-id", action="append",
|
||||
help="Strict-continuity segment id from the excitation audit; may be repeated.",
|
||||
)
|
||||
parser.add_argument("--sample-period-s", type=float, default=0.5)
|
||||
parser.add_argument("--skip-loo", action="store_true")
|
||||
parser.add_argument("--run-bootstrap", action="store_true")
|
||||
parser.add_argument("--bootstrap-repetitions", type=int, default=40)
|
||||
parser.add_argument("--bootstrap-seed", type=int, default=0)
|
||||
parser.add_argument("--run-rotation-sensitivity", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
manual_l_i_m = None if args.no_manual_prior else args.manual_l_i_m
|
||||
prior_covariance = None
|
||||
if not args.no_manual_prior:
|
||||
if args.manual_l_i_std_m is not None:
|
||||
prior_covariance = np.diag(np.square(args.manual_l_i_std_m))
|
||||
elif args.manual_l_i_covariance_m2 is not None:
|
||||
prior_covariance = np.asarray(args.manual_l_i_covariance_m2, dtype=float).reshape(3, 3)
|
||||
else:
|
||||
parser.error("mechanical prior requires --manual-l-i-std-m or --manual-l-i-covariance-m2")
|
||||
|
||||
sessions = load_unified_sessions(args.manifest, selected_session_ids=set(args.session))
|
||||
rotation = Rotation.from_euler("xyz", args.rotation_rpy_deg, degrees=True).as_matrix()
|
||||
result = solve_engineering_6dof(
|
||||
sessions,
|
||||
R_RTK_IMU=rotation,
|
||||
manual_l_I_m=manual_l_i_m,
|
||||
manual_l_I_covariance_m2=prior_covariance,
|
||||
sample_period_s=args.sample_period_s,
|
||||
run_loo=not args.skip_loo,
|
||||
run_bootstrap=args.run_bootstrap,
|
||||
bootstrap_repetitions=args.bootstrap_repetitions,
|
||||
bootstrap_seed=args.bootstrap_seed,
|
||||
run_rotation_sensitivity=args.run_rotation_sensitivity,
|
||||
selected_segment_ids=None if args.segment_id is None else set(args.segment_id),
|
||||
)
|
||||
payload = {
|
||||
"data_only_6dof_accepted": False,
|
||||
"data_only_translation_accepted": result.data_only_translation_accepted,
|
||||
"engineering_6dof": _jsonable(result),
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run one prior-free engineering base fit on high-excitation qualified segments.
|
||||
|
||||
This diagnostic never adds a mechanical lever factor, never profiles the
|
||||
mechanical reference, and never runs LOO/bootstrap/rotation sensitivity.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from imu_lidar.rtk_imu_engineering import (
|
||||
MIN_SEGMENT_DURATION_S,
|
||||
MIN_SEGMENT_NODE_COUNT,
|
||||
NUISANCE_DOF_PER_SEGMENT,
|
||||
_Segment,
|
||||
_fit_segments,
|
||||
_fit_summary,
|
||||
_height_reference,
|
||||
_initial_parameters,
|
||||
_marginal_lever_information,
|
||||
_nodes,
|
||||
_residual,
|
||||
_segment_residual_size,
|
||||
_world_rtk,
|
||||
)
|
||||
from imu_lidar.imu_preintegration import preintegrate_imu
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
|
||||
MANUAL_REFERENCE_L_I_M = np.array([-0.45072, -0.25682, 0.73208], dtype=float)
|
||||
|
||||
|
||||
def _jsonable(value):
|
||||
if isinstance(value, np.ndarray):
|
||||
return _jsonable(value.tolist())
|
||||
if isinstance(value, np.generic):
|
||||
return _jsonable(value.item())
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else None
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {key: _jsonable(item) for key, item in asdict(value).items()}
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _r0_segment_candidates(sessions, period_s: float) -> list[tuple[object, tuple, str]]:
|
||||
"""Split R0 first; defer expensive preintegration until a candidate is selected."""
|
||||
reference = _height_reference(sessions)
|
||||
if reference is None:
|
||||
return []
|
||||
candidates: list[tuple[object, tuple, str]] = []
|
||||
for session in sessions:
|
||||
nodes = _nodes(session, reference, period_s)
|
||||
start = 0
|
||||
qualifying_index = 0
|
||||
for end in range(1, len(nodes) + 1):
|
||||
if end != len(nodes) and nodes[end].continuity_id == nodes[end - 1].continuity_id:
|
||||
continue
|
||||
run = tuple(nodes[start:end])
|
||||
start = end
|
||||
if len(run) < MIN_SEGMENT_NODE_COUNT or run[-1].t_s - run[0].t_s < MIN_SEGMENT_DURATION_S:
|
||||
continue
|
||||
if not any(node.hpr_factor_valid for node in run):
|
||||
continue
|
||||
candidates.append((session, run, f"{session.session_id}:{qualifying_index:02d}"))
|
||||
qualifying_index += 1
|
||||
return candidates
|
||||
|
||||
|
||||
def _build_segment(session, run: tuple, segment_id: str) -> _Segment | None:
|
||||
pre = tuple(preintegrate_imu(
|
||||
session.imu.t_s, session.imu.gyro_rad_s, session.imu.acc_m_s2, left.t_s, right.t_s,
|
||||
) for left, right in zip(run[:-1], run[1:]))
|
||||
if any(item.duration_s <= 0.0 for item in pre):
|
||||
return None
|
||||
initial_hpr = next((node for node in run if node.hpr_factor_valid), None)
|
||||
if initial_hpr is None:
|
||||
return None
|
||||
return _Segment(segment_id, session.session_id, run, pre, _world_rtk(initial_hpr.baseline_enu))
|
||||
|
||||
def _gyro_abs_rotation_deg(session, segment) -> np.ndarray:
|
||||
start_s, end_s = segment.nodes[0].t_s, segment.nodes[-1].t_s
|
||||
mask = (session.imu.t_s >= start_s) & (session.imu.t_s <= end_s)
|
||||
t_s = session.imu.t_s[mask]
|
||||
gyro = session.imu.gyro_rad_s[mask]
|
||||
if t_s.size < 2:
|
||||
return np.zeros(3)
|
||||
return np.degrees(np.trapezoid(np.abs(gyro), t_s, axis=0))
|
||||
|
||||
|
||||
def _category_score(category: str, gyro_abs_deg: np.ndarray) -> float:
|
||||
if category in {"circle", "left_right"}:
|
||||
return float(gyro_abs_deg[2])
|
||||
return float(np.hypot(gyro_abs_deg[0], gyro_abs_deg[1]))
|
||||
|
||||
|
||||
def _marginal_for_segment_indices(jacobian: np.ndarray, residual: np.ndarray,
|
||||
segments, indices: list[int]) -> tuple[np.ndarray, np.ndarray, float, int, np.ndarray]:
|
||||
row = 0
|
||||
rows: list[np.ndarray] = []
|
||||
for index, segment in enumerate(segments):
|
||||
count = _segment_residual_size(segment)
|
||||
if index in indices:
|
||||
rows.append(np.arange(row, row + count))
|
||||
row += count
|
||||
selected_rows = np.concatenate(rows) if rows else np.empty(0, dtype=int)
|
||||
columns = [0, 1, 2]
|
||||
for index in indices:
|
||||
offset = 3 + NUISANCE_DOF_PER_SEGMENT * index
|
||||
columns.extend(range(offset, offset + NUISANCE_DOF_PER_SEGMENT))
|
||||
marginal, singular, condition, rank, weakest, _ = _marginal_lever_information(
|
||||
jacobian[np.ix_(selected_rows, columns)], residual[selected_rows]
|
||||
)
|
||||
return marginal, singular, condition, rank, weakest
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--circle-session", required=True)
|
||||
parser.add_argument("--left-right-session", required=True)
|
||||
parser.add_argument("--slope-session", required=True)
|
||||
parser.add_argument("--top-per-category", type=int, default=10)
|
||||
parser.add_argument("--sample-period-s", type=float, default=1.0)
|
||||
parser.add_argument("--rotation-rpy-deg", nargs=3, type=float,
|
||||
default=[0.4543066225, -0.0026392019, 0.0122384129])
|
||||
args = parser.parse_args()
|
||||
category_by_session = {
|
||||
args.circle_session: "circle",
|
||||
args.left_right_session: "left_right",
|
||||
args.slope_session: "slope",
|
||||
}
|
||||
sessions = load_unified_sessions(args.manifest, selected_session_ids=set(category_by_session))
|
||||
candidates: dict[str, list[tuple[float, object, tuple, str, np.ndarray]]] = {
|
||||
key: [] for key in category_by_session.values()
|
||||
}
|
||||
all_candidates = _r0_segment_candidates(sessions, args.sample_period_s)
|
||||
for session, run, segment_id in all_candidates:
|
||||
category = category_by_session[session.session_id]
|
||||
gyro_abs = _gyro_abs_rotation_deg(session, type("Run", (), {"nodes": run})())
|
||||
candidates[category].append((_category_score(category, gyro_abs), session, run, segment_id, gyro_abs))
|
||||
selected: list[object] = []
|
||||
selected_entries: list[dict[str, object]] = []
|
||||
category_indices: dict[str, list[int]] = {}
|
||||
for category, entries in candidates.items():
|
||||
selected_before = len(selected)
|
||||
for score, session, run, segment_id, gyro_abs in sorted(entries, key=lambda item: item[0], reverse=True):
|
||||
segment = _build_segment(session, run, segment_id)
|
||||
if segment is None:
|
||||
continue
|
||||
selected.append(segment)
|
||||
selected_entries.append({
|
||||
"category": category, "segment_id": segment.segment_id,
|
||||
"session_id": segment.session_id, "duration_s": segment.nodes[-1].t_s - segment.nodes[0].t_s,
|
||||
"node_count": len(segment.nodes), "excitation_score_deg": score,
|
||||
"cumulative_absolute_gyro_rotation_xyz_deg": gyro_abs,
|
||||
})
|
||||
if len(selected) - selected_before >= args.top_per_category:
|
||||
break
|
||||
category_indices[category] = list(range(selected_before, len(selected)))
|
||||
rotation = Rotation.from_euler("xyz", args.rotation_rpy_deg, degrees=True).as_matrix()
|
||||
initial_parameters = _initial_parameters(selected)
|
||||
initial_residual = _residual(initial_parameters, selected, rotation)
|
||||
fit, residual, detail = _fit_segments(selected, rotation)
|
||||
summary = _fit_summary(fit, residual, detail)
|
||||
if fit is None or summary is None:
|
||||
raise RuntimeError("no selected qualified segment could be fit")
|
||||
contributions: dict[str, dict[str, object]] = {}
|
||||
contribution_sum = np.zeros((3, 3))
|
||||
for category, indices in category_indices.items():
|
||||
marginal, singular, condition, rank, weakest = _marginal_for_segment_indices(
|
||||
fit.jac, residual, selected, indices
|
||||
)
|
||||
contribution_sum += marginal
|
||||
contributions[category] = {
|
||||
"selected_segment_count": len(indices),
|
||||
"lever_marginal_information": marginal,
|
||||
"lever_information_singular_values": singular,
|
||||
"condition_number": condition,
|
||||
"precision_rank": rank,
|
||||
"weakest_direction_I": weakest,
|
||||
"axis_information_diagonal_I": np.diag(marginal),
|
||||
}
|
||||
total_diag = np.diag(summary.lever_marginal_information)
|
||||
for category, item in contributions.items():
|
||||
item["axis_information_fraction_of_total_I"] = np.divide(
|
||||
item["axis_information_diagonal_I"], total_diag,
|
||||
out=np.full(3, np.nan), where=np.abs(total_diag) > 1e-12,
|
||||
)
|
||||
payload = {
|
||||
'solver_diagnostics': {
|
||||
'initial_cost': 0.5 * float(np.dot(initial_residual, initial_residual)),
|
||||
'final_cost': 0.5 * float(np.dot(residual, residual)),
|
||||
'cost_reduction': 0.5 * float(
|
||||
np.dot(initial_residual, initial_residual) - np.dot(residual, residual)
|
||||
),
|
||||
'cost_definition': '0.5 * unmodified residual squared norm, comparable initial/final',
|
||||
'scipy_final_huber_cost': float(fit.cost),
|
||||
'nfev': int(fit.nfev), 'optimality': float(fit.optimality),
|
||||
'gradient_norm': float(np.linalg.norm(fit.grad)),
|
||||
'initial_l_I_m': initial_parameters[:3], 'final_l_I_m': fit.x[:3],
|
||||
'l_step_norm_m': float(np.linalg.norm(fit.x[:3] - initial_parameters[:3])),
|
||||
},
|
||||
"scope": "prior-free free base fit only; no mechanical factor/LOO/bootstrap/rotation sensitivity",
|
||||
"rotation_source": "R2G_gravity_level_prior",
|
||||
"translation_conditional_on_rotation": True,
|
||||
"manual_reference_comparison_only": {
|
||||
"manual_l_I_m": MANUAL_REFERENCE_L_I_M,
|
||||
"free_minus_manual_l_I_m": summary.l_I_m - MANUAL_REFERENCE_L_I_M,
|
||||
"euclidean_delta_m": float(np.linalg.norm(summary.l_I_m - MANUAL_REFERENCE_L_I_M)),
|
||||
},
|
||||
"sample_period_s": args.sample_period_s,
|
||||
"all_qualified_segment_count": len(all_candidates),
|
||||
"selected_segment_count": len(selected),
|
||||
"selected_segments": selected_entries,
|
||||
"free_solution": _jsonable(summary),
|
||||
"motion_category_information_contributions": _jsonable(contributions),
|
||||
"axis_information_total_I": np.diag(summary.lever_marginal_information),
|
||||
"marginal_additivity_error_fro": float(np.linalg.norm(
|
||||
contribution_sum - summary.lever_marginal_information, ord="fro"
|
||||
)),
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload), ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
print(json.dumps({
|
||||
"selected_segment_count": len(selected),
|
||||
"free_l_I_m": _jsonable(summary.l_I_m),
|
||||
"free_l_I_std_m": _jsonable(summary.l_I_std_m),
|
||||
"lever_information_singular_values": _jsonable(summary.lever_information_singular_values),
|
||||
"condition_number": summary.lever_information_condition_number,
|
||||
"precision_rank": summary.lever_precision_rank,
|
||||
"bestnava_xyz_vector_rms_p95_m": [summary.bestnava_xyz_residual.vector_rms, summary.bestnava_xyz_residual.vector_p95],
|
||||
"doppler_vector_rms_p95_m_s": [summary.doppler_velocity_residual.vector_rms, summary.doppler_velocity_residual.vector_p95],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Run fixed-mechanical and soft-prior solutions on immutable 47-window baseline.'''
|
||||
from __future__ import annotations
|
||||
import argparse,hashlib,json,sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import (
|
||||
NODE_DOF,build_problem,fit_states_at_fixed_lever,solve_free_lever_many,
|
||||
summarize_fixed_state_values)
|
||||
from tools.audit_rtk_imu_factor_consistency import MECHANICAL_L_I_M,_jsonable
|
||||
from tools.run_rtk_imu_node_graph_free_selected import _restore_segments
|
||||
|
||||
MANUAL_STD_M=np.array([.02,.02,.03])
|
||||
MANUAL_COVARIANCE_M2=np.diag(MANUAL_STD_M**2)
|
||||
|
||||
|
||||
def _factor_delta(candidate,baseline):
|
||||
output={}
|
||||
for key in ('best_position','doppler','hpr','imu_preintegration'):
|
||||
left,right=candidate['residual_by_factor'][key],baseline['residual_by_factor'][key]
|
||||
output[key]={'rms_delta':left['rms']-right['rms'],
|
||||
'p95_abs_delta':left['p95_abs']-right['p95_abs'],
|
||||
'nis_per_dof_delta':left['chi_square_per_dof']-right['chi_square_per_dof']}
|
||||
return output
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--selection',type=Path,required=True)
|
||||
parser.add_argument('--free-baseline',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--state-output',type=Path)
|
||||
parser.add_argument('--sample-period-s',type=float,default=1.)
|
||||
parser.add_argument('--fixed-max-nfev',type=int,default=120)
|
||||
parser.add_argument('--prior-max-nfev',type=int,default=120)
|
||||
parser.add_argument('--workers',type=int,default=4)
|
||||
parser.add_argument('--hpr-direct-sigma-rad',type=float,default=.006)
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
args=parser.parse_args()
|
||||
selection=json.loads(args.selection.read_text(encoding='utf-8'))
|
||||
baseline=json.loads(args.free_baseline.read_text(encoding='utf-8'))
|
||||
if len(selection['selected_windows'])!=47:
|
||||
raise RuntimeError('engineering branch requires immutable 47-window selection')
|
||||
ids={item['session_id'] for item in selection['selected_windows']}
|
||||
sessions=load_unified_sessions(args.manifest,selected_session_ids=ids)
|
||||
reference=_height_reference(sessions)
|
||||
segments=_restore_segments(sessions,reference,selection['selected_windows'],
|
||||
args.sample_period_s)
|
||||
rotation=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
mechanical=MECHANICAL_L_I_M.copy()
|
||||
problems=[build_problem(segment,rotation,mechanical,args.hpr_direct_sigma_rad)
|
||||
for segment in segments]
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||
fitted=list(executor.map(lambda problem:fit_states_at_fixed_lever(
|
||||
problem,mechanical,args.fixed_max_nfev),problems))
|
||||
fixed_states=[item[0] for item in fitted]
|
||||
fixed_optimizer=[item[1] for item in fitted]
|
||||
fixed_summary=summarize_fixed_state_values(problems,fixed_states,mechanical)
|
||||
prior_result,prior_states=solve_free_lever_many(
|
||||
problems,mechanical,args.prior_max_nfev,fixed_states,
|
||||
lever_prior_mean_m=mechanical,
|
||||
lever_prior_covariance_m2=MANUAL_COVARIANCE_M2,
|
||||
return_state_values=True)
|
||||
prior_result=asdict(prior_result)
|
||||
prior_data=summarize_fixed_state_values(
|
||||
problems,prior_states,np.asarray(prior_result['final_l_I_m']))
|
||||
bias_values={}
|
||||
for segment,state in zip(segments,prior_states):
|
||||
states=np.asarray(state).reshape(-1,NODE_DOF)
|
||||
values=bias_values.setdefault(segment.session_id,{'bg':[],'ba':[]})
|
||||
values['bg'].extend(states[:,9:12])
|
||||
values['ba'].extend(states[:,12:15])
|
||||
calibration_bias={session_id:{
|
||||
'gyro_bias_rad_s':np.median(values['bg'],axis=0),
|
||||
'accel_bias_m_s2':np.median(values['ba'],axis=0),
|
||||
'node_count':len(values['bg'])}
|
||||
for session_id,values in bias_values.items()}
|
||||
free=baseline['solutions']['mechanical']
|
||||
posterior_cov=np.asarray(prior_result['lever_covariance_m2'])
|
||||
variance_ratio=np.diag(posterior_cov)/np.diag(MANUAL_COVARIANCE_M2)
|
||||
prior_pull=(np.asarray(prior_result['final_l_I_m'])-mechanical)/MANUAL_STD_M
|
||||
translation_refined=bool(np.all(variance_ratio<=.90))
|
||||
comparisons={'fixed_vs_free':{
|
||||
'cost_delta':fixed_summary['cost']-free['final_cost'],
|
||||
'relative_cost_delta':fixed_summary['cost']/free['final_cost']-1.,
|
||||
'factor_residual_delta':_factor_delta(fixed_summary,free)},
|
||||
'prior_data_vs_free':{
|
||||
'cost_delta':prior_data['cost']-free['final_cost'],
|
||||
'relative_cost_delta':prior_data['cost']/free['final_cost']-1.,
|
||||
'factor_residual_delta':_factor_delta(prior_data,free)}}
|
||||
baseline_hash=hashlib.sha256(args.free_baseline.read_bytes()).hexdigest()
|
||||
payload={'scope':'47-window mechanical-prior engineering branch',
|
||||
'data_only_translation_accepted':False,
|
||||
'immutable_free_baseline':{'path':str(args.free_baseline),
|
||||
'sha256':baseline_hash,'solution':free},
|
||||
'selected_window_count':len(segments),'selection_path':str(args.selection),
|
||||
'manual_l_I_m':mechanical,'manual_l_I_std_m':MANUAL_STD_M,
|
||||
'manual_l_I_covariance_m2':MANUAL_COVARIANCE_M2,
|
||||
'fixed_mechanical_solution':{'l_I_m':mechanical,
|
||||
'window_optimizer':fixed_optimizer,'data_summary':fixed_summary},
|
||||
'prior_constrained_solution':{'result':prior_result,
|
||||
'data_only_summary_excluding_prior_factor':prior_data,
|
||||
'posterior_covariance_m2':posterior_cov,
|
||||
'posterior_prior_variance_ratio':variance_ratio,
|
||||
'prior_pull_sigma':prior_pull,
|
||||
'calibration_only_frozen_bias_by_session':calibration_bias},
|
||||
'comparisons':comparisons,
|
||||
'translation_refinement_gate':{
|
||||
'required_max_axis_variance_ratio':.90,
|
||||
'passed':translation_refined},
|
||||
'translation_refined_by_data':translation_refined,
|
||||
'heldout_validation_called':False,
|
||||
'engineering_translation_accepted':False}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
if args.state_output is not None:
|
||||
sizes=np.asarray([len(value) for value in prior_states],dtype=int)
|
||||
args.state_output.parent.mkdir(parents=True,exist_ok=True)
|
||||
np.savez_compressed(args.state_output,
|
||||
states=np.concatenate(prior_states),offsets=np.cumsum(np.r_[0,sizes]),
|
||||
candidate_ids=np.asarray(
|
||||
[item['candidate_id'] for item in selection['selected_windows']]))
|
||||
print(json.dumps(_jsonable({'fixed':fixed_summary,
|
||||
'prior_l_I_m':prior_result['final_l_I_m'],
|
||||
'prior_data_cost':prior_data['cost'],'prior_map_cost':prior_result['final_cost'],
|
||||
'variance_ratio':variance_ratio,'prior_pull_sigma':prior_pull,
|
||||
'translation_refined_by_data':translation_refined,
|
||||
'fixed_optimizer_failed_count':sum(not item['success'] for item in fixed_optimizer)}),
|
||||
ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Validate an engineering lever on disjoint held-out windows.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from concurrent.futures import ProcessPoolExecutor,as_completed
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.geometry import make_transform
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import build_problem,fit_states_at_fixed_lever,residual
|
||||
from tools.audit_rtk_imu_factor_consistency import _jsonable
|
||||
from tools.run_rtk_imu_node_graph_free_selected import _restore_segments
|
||||
|
||||
FACTORS=('best_position','doppler','hpr','imu_preintegration')
|
||||
PHYSICAL=('best_position_physical','doppler_physical','hpr_physical')
|
||||
|
||||
def _fit_checkpoint(task):
|
||||
index,problem,lever,max_nfev,path_text=task
|
||||
path=Path(path_text)
|
||||
if path.exists():
|
||||
data=np.load(path,allow_pickle=False)
|
||||
return index,data['state'],json.loads(str(data['optimizer']))
|
||||
state,meta=fit_states_at_fixed_lever(problem,lever,max_nfev)
|
||||
np.savez_compressed(path,state=state,optimizer=json.dumps(meta))
|
||||
return index,state,meta
|
||||
|
||||
def _stats(values,dof=None):
|
||||
a=np.asarray(values,dtype=float).reshape(-1)
|
||||
if not a.size:
|
||||
return {'count':0,'dof':0,'rms':np.nan,'p50_abs':np.nan,
|
||||
'p95_abs':np.nan,'p99_abs':np.nan,'nis':np.nan,
|
||||
'chi_square_per_dof':np.nan}
|
||||
dof=len(a) if dof is None else max(int(dof),1); nis=float(a@a)
|
||||
return {'count':len(a),'dof':dof,'rms':float(np.sqrt(np.mean(a*a))),
|
||||
'p50_abs':float(np.percentile(np.abs(a),50)),
|
||||
'p95_abs':float(np.percentile(np.abs(a),95)),
|
||||
'p99_abs':float(np.percentile(np.abs(a),99)),
|
||||
'nis':nis,'chi_square_per_dof':nis/dof}
|
||||
|
||||
def _vectors(values):
|
||||
a=np.asarray(values,dtype=float).reshape(-1,3)
|
||||
if not a.size:
|
||||
return {'count':0,'axis_rms':[np.nan]*3,'axis_p95_abs':[np.nan]*3,
|
||||
'vector_rms':np.nan,'vector_p95':np.nan}
|
||||
norm=np.linalg.norm(a,axis=1)
|
||||
return {'count':len(a),'axis_rms':np.sqrt(np.mean(a*a,axis=0)),
|
||||
'axis_p95_abs':np.percentile(np.abs(a),95,axis=0),
|
||||
'vector_rms':float(np.sqrt(np.mean(norm*norm))),
|
||||
'vector_p95':float(np.percentile(norm,95))}
|
||||
|
||||
def _summarize(records):
|
||||
factor={key:[] for key in FACTORS}; physical={key:[] for key in PHYSICAL}
|
||||
joined=[]; state_dimension=0; other_effective=0
|
||||
for record in records:
|
||||
joined.extend(record['all']); state_dimension+=record['state_dimension']
|
||||
other_effective+=record['other_effective']
|
||||
for key in FACTORS: factor[key].extend(record['factor'].get(key,[]))
|
||||
for key in PHYSICAL: physical[key].extend(record['physical'].get(key,[]))
|
||||
effective=sum(2*len(v)//3 if k=='hpr' else len(v)
|
||||
for k,v in factor.items())+other_effective
|
||||
dof=max(effective-state_dimension,1); a=np.asarray(joined,dtype=float)
|
||||
converged=sum(bool(x['optimizer']['success']) for x in records)
|
||||
return {'window_count':len(records),'optimizer_converged_count':converged,
|
||||
'optimizer_converged_fraction':converged/max(len(records),1),
|
||||
'total_residual_dimension':len(a),'state_dimension':state_dimension,
|
||||
'statistical_dof':dof,'total_nis':float(a@a),
|
||||
'global_chi_square_per_dof':float((a@a)/dof),
|
||||
'residual_by_factor':{key:_stats(value,2*len(value)//3 if key=='hpr' else None)
|
||||
for key,value in factor.items()},
|
||||
'best_position_physical_m':_vectors(physical['best_position_physical']),
|
||||
'doppler_physical_m_s':_vectors(physical['doppler_physical']),
|
||||
'hpr_physical_rad':_vectors(physical['hpr_physical'])}
|
||||
|
||||
def _gate(summary):
|
||||
factor=summary['residual_by_factor']
|
||||
checks={'optimizer_converged_fraction_ge_0p90':
|
||||
summary['optimizer_converged_fraction']>=.90,
|
||||
'global_chi_square_per_dof_in_0p25_4':
|
||||
.25<=summary['global_chi_square_per_dof']<=4.,
|
||||
'best_vector_p95_le_0p20_m':
|
||||
summary['best_position_physical_m']['vector_p95']<=.20,
|
||||
'doppler_vector_p95_le_0p50_m_s':
|
||||
summary['doppler_physical_m_s']['vector_p95']<=.50,
|
||||
'hpr_normalized_p95_le_4':factor['hpr']['p95_abs']<=4.,
|
||||
'preintegration_normalized_p95_le_3':
|
||||
factor['imu_preintegration']['p95_abs']<=3.}
|
||||
return {'uses_existing_P0p5_physical_and_statistical_health_gates':True,
|
||||
'checks':checks,'passed':bool(all(checks.values()))}
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--manifest',type=Path,required=True)
|
||||
p.add_argument('--calibration-selection',type=Path,required=True)
|
||||
p.add_argument('--all-selection',type=Path,required=True)
|
||||
p.add_argument('--engineering-result',type=Path,required=True)
|
||||
p.add_argument('--output',type=Path,required=True)
|
||||
p.add_argument('--checkpoint-dir',type=Path,required=True)
|
||||
p.add_argument('--sample-period-s',type=float,default=1.)
|
||||
p.add_argument('--max-nfev',type=int,default=120)
|
||||
p.add_argument('--workers',type=int,default=4)
|
||||
p.add_argument('--hpr-direct-sigma-rad',type=float,default=.006)
|
||||
p.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
p.add_argument('--circle-session',default='0808_20260808_092827')
|
||||
p.add_argument('--left-right-session',default='0808_20260808_082148')
|
||||
p.add_argument('--slope-session',default='0815_20260812_123424')
|
||||
args=p.parse_args()
|
||||
engineering=json.loads(args.engineering_result.read_text(encoding='utf-8'))
|
||||
calibration=json.loads(args.calibration_selection.read_text(encoding='utf-8'))
|
||||
all_windows=json.loads(args.all_selection.read_text(encoding='utf-8'))
|
||||
calibration_ids={x['candidate_id'] for x in calibration['selected_windows']}
|
||||
heldout=[x for x in all_windows['selected_windows']
|
||||
if x['candidate_id'] not in calibration_ids]
|
||||
if len(calibration_ids)!=47 or len(heldout)!=267:
|
||||
raise RuntimeError(f'expected 47+267 windows, got {len(calibration_ids)}+{len(heldout)}')
|
||||
shared=sum(x.get('shared_sample_count_with_previous',{}).get(k,0)
|
||||
for x in all_windows['selected_windows'] for k in ('imu','gnss','hpr'))
|
||||
if shared: raise RuntimeError(f'selection contains {shared} shared samples')
|
||||
lever=np.asarray(engineering['prior_constrained_solution']['result']['final_l_I_m'])
|
||||
sessions=load_unified_sessions(
|
||||
args.manifest,selected_session_ids={x['session_id'] for x in heldout})
|
||||
reference=_height_reference(sessions)
|
||||
segments=_restore_segments(sessions,reference,heldout,args.sample_period_s)
|
||||
rotation=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
problems=[build_problem(x,rotation,lever,args.hpr_direct_sigma_rad) for x in segments]
|
||||
args.checkpoint_dir.mkdir(parents=True,exist_ok=True)
|
||||
fitted=[None]*len(problems)
|
||||
tasks=[(i,problem,lever,args.max_nfev,
|
||||
str(args.checkpoint_dir/f'{i:04d}.npz'))
|
||||
for i,problem in enumerate(problems)]
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as executor:
|
||||
futures=[executor.submit(_fit_checkpoint,task) for task in tasks]
|
||||
completed=0
|
||||
for future in as_completed(futures):
|
||||
index,state,meta=future.result(); fitted[index]=(state,meta); completed+=1
|
||||
if completed%10==0: print(f'held-out checkpoint {completed}/{len(problems)}',flush=True)
|
||||
mapping={args.circle_session:'circle',args.left_right_session:'left_right',
|
||||
args.slope_session:'slope'}
|
||||
records=[]
|
||||
for item,problem,fit in zip(heldout,problems,fitted):
|
||||
state,optimizer=fit; detail={}
|
||||
all_residual=residual(problem,state,details=detail,lever_override=lever)
|
||||
known=sum(len(detail.get(k,[])) for k in FACTORS)
|
||||
records.append({'candidate_id':item['candidate_id'],
|
||||
'session_id':item['session_id'],
|
||||
'motion_class':mapping.get(item['session_id'],'other_recovered_dynamic'),
|
||||
'start_s':item['start_s'],'end_s':item['end_s'],
|
||||
'state_dimension':len(state),'optimizer':optimizer,
|
||||
'all':all_residual.tolist(),'other_effective':len(all_residual)-known,
|
||||
'factor':{k:detail.get(k,[]) for k in FACTORS},
|
||||
'physical':{k:detail.get(k,[]) for k in PHYSICAL}})
|
||||
overall=_summarize(records)
|
||||
by_session={key:_summarize([x for x in records if x['session_id']==key])
|
||||
for key in sorted({x['session_id'] for x in records})}
|
||||
by_motion={key:_summarize([x for x in records if x['motion_class']==key])
|
||||
for key in sorted({x['motion_class'] for x in records})}
|
||||
overall_gate=_gate(overall)
|
||||
session_gates={k:_gate(v) for k,v in by_session.items()}
|
||||
motion_gates={k:_gate(v) for k,v in by_motion.items()}
|
||||
heldout_passed=bool(overall_gate['passed'] and
|
||||
all(x['passed'] for x in session_gates.values()) and
|
||||
all(x['passed'] for x in motion_gates.values()))
|
||||
comparisons=engineering['comparisons']
|
||||
def nonconflicting(name):
|
||||
c=comparisons[name]
|
||||
return (c['relative_cost_delta']<=.05 and
|
||||
all(x['p95_abs_delta']<=.25 for x in c['factor_residual_delta'].values()))
|
||||
fit_nonconflict=nonconflicting('fixed_vs_free') and nonconflicting('prior_data_vs_free')
|
||||
T_rtk_imu=make_transform(-rotation@lever,rotation)
|
||||
T_imu_rtk=np.linalg.inv(T_rtk_imu)
|
||||
accepted=bool(fit_nonconflict and heldout_passed)
|
||||
payload={**engineering,
|
||||
'scope':'47-window mechanical-prior engineering branch + disjoint held-out validation',
|
||||
'heldout_validation_called':True,
|
||||
'engineering_lever_source':'prior_constrained_solution',
|
||||
'engineering_l_I_m':lever,'calibration_heldout_overlap_count':0,
|
||||
'heldout_window_count':len(heldout),
|
||||
'heldout_validation':{'lever_reoptimized':False,
|
||||
'nuisance_states_optimized_per_window':True,
|
||||
'motion_class_mapping':mapping,
|
||||
'other_sessions_class':'other_recovered_dynamic',
|
||||
'overall':overall,'by_session':by_session,'by_motion_class':by_motion,
|
||||
'overall_gate':overall_gate,'session_gates':session_gates,
|
||||
'motion_class_gates':motion_gates,'passed':heldout_passed},
|
||||
'calibration_fit_nonconflict_gate':{'relative_cost_increase_max':.05,
|
||||
'per_factor_normalized_p95_increase_max':.25,
|
||||
'fixed_passed':nonconflicting('fixed_vs_free'),
|
||||
'prior_passed':nonconflicting('prior_data_vs_free'),
|
||||
'passed':fit_nonconflict},
|
||||
'engineering_translation_accepted':accepted,
|
||||
'data_only_translation_accepted':False,
|
||||
'result_nature':'mechanical lever + dynamic-data consistency validation; not data-only translation calibration',
|
||||
'rotation_source':'R2G_gravity_level_prior',
|
||||
'translation_conditional_on_rotation':True,
|
||||
'candidate_T_RTK_IMU':T_rtk_imu,'candidate_T_IMU_RTK':T_imu_rtk,
|
||||
'T_RTK_IMU':T_rtk_imu if accepted else None,
|
||||
'T_IMU_RTK':T_imu_rtk if accepted else None,
|
||||
'transform_convention':('T_RTK_IMU maps IMU coordinates into ANT1 RTK frame; '
|
||||
'l_I=p_ANT1^I; T_IMU_RTK translation equals l_I')}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable({'engineering_l_I_m':lever,
|
||||
'fit_nonconflict':fit_nonconflict,'heldout_overall':overall,
|
||||
'heldout_passed':heldout_passed,
|
||||
'engineering_translation_accepted':accepted}),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Run 18 fixed-R2G perturbation prior-constrained node-graph solves.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from concurrent.futures import ProcessPoolExecutor,as_completed
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.geometry import make_transform
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import (
|
||||
build_problem,solve_free_lever_many,summarize_fixed_state_values)
|
||||
from tools.audit_rtk_imu_factor_consistency import _jsonable
|
||||
from tools.run_rtk_imu_node_graph_free_selected import _restore_segments
|
||||
|
||||
_CONTEXT={}
|
||||
MANUAL=np.array([-.45072,-.25682,.73208])
|
||||
STD=np.array([.02,.02,.03])
|
||||
COV=np.diag(STD**2)
|
||||
|
||||
def _initialize_worker(segments,states,rpy,max_nfev,sigma,checkpoint_dir):
|
||||
_CONTEXT.update(segments=segments,states=states,rpy=np.asarray(rpy),
|
||||
max_nfev=max_nfev,sigma=sigma,checkpoint_dir=Path(checkpoint_dir))
|
||||
|
||||
def _solve(task):
|
||||
axis,sign,angle=task
|
||||
name=f'{axis}_{sign*angle:+.1f}deg'
|
||||
path=_CONTEXT['checkpoint_dir']/f'{name}.json'
|
||||
if path.exists(): return json.loads(path.read_text(encoding='utf-8'))
|
||||
vector=np.zeros(3); vector['xyz'.index(axis)]=np.deg2rad(sign*angle)
|
||||
nominal_R=Rotation.from_euler('xyz',_CONTEXT['rpy'],degrees=True).as_matrix()
|
||||
perturbed_R=Rotation.from_rotvec(vector).as_matrix()@nominal_R
|
||||
problems=[build_problem(segment,perturbed_R,MANUAL,_CONTEXT['sigma'])
|
||||
for segment in _CONTEXT['segments']]
|
||||
result,states=solve_free_lever_many(problems,MANUAL,_CONTEXT['max_nfev'],
|
||||
_CONTEXT['states'],lever_prior_mean_m=MANUAL,
|
||||
lever_prior_covariance_m2=COV,return_state_values=True)
|
||||
result=asdict(result); lever=np.asarray(result['final_l_I_m'])
|
||||
data=summarize_fixed_state_values(problems,states,lever)
|
||||
transform=make_transform(-perturbed_R@lever,perturbed_R)
|
||||
payload={'name':name,'axis':axis,'signed_angle_deg':sign*angle,
|
||||
'success':result['success'],'message':result['message'],'nfev':result['nfev'],
|
||||
'final_l_I_m':lever,'delta_l_from_nominal_m':None,
|
||||
'map_cost':result['final_cost'],'data_cost':data['cost'],
|
||||
'global_chi_square_per_dof':data['chi_square_per_dof'],
|
||||
'residual_by_factor':data['residual_by_factor'],
|
||||
'physical_residual':{
|
||||
'BEST_position_m':data['best_position_physical_m'],
|
||||
'Doppler_m_s':data['doppler_physical_m_s'],
|
||||
'HPR_rad':data['hpr_physical_rad']},
|
||||
'posterior_covariance_m2':result['lever_covariance_m2'],
|
||||
'posterior_std_m':np.sqrt(np.diag(result['lever_covariance_m2'])),
|
||||
'prior_pull_sigma':(lever-MANUAL)/STD,
|
||||
'T_RTK_IMU':transform}
|
||||
path.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
return _jsonable(payload)
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--manifest',type=Path,required=True)
|
||||
p.add_argument('--selection',type=Path,required=True)
|
||||
p.add_argument('--engineering-result',type=Path,required=True)
|
||||
p.add_argument('--nominal-states',type=Path,required=True)
|
||||
p.add_argument('--output',type=Path,required=True)
|
||||
p.add_argument('--checkpoint-dir',type=Path,required=True)
|
||||
p.add_argument('--workers',type=int,default=3)
|
||||
p.add_argument('--max-nfev',type=int,default=120)
|
||||
p.add_argument('--hpr-direct-sigma-rad',type=float,default=.006)
|
||||
p.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
args=p.parse_args()
|
||||
engineering=json.loads(args.engineering_result.read_text(encoding='utf-8'))
|
||||
selection=json.loads(args.selection.read_text(encoding='utf-8'))
|
||||
if len(selection['selected_windows'])!=47:
|
||||
raise RuntimeError('sensitivity requires immutable 47-window selection')
|
||||
sessions=load_unified_sessions(args.manifest,
|
||||
selected_session_ids={x['session_id'] for x in selection['selected_windows']})
|
||||
segments=_restore_segments(sessions,_height_reference(sessions),
|
||||
selection['selected_windows'],1.)
|
||||
saved=np.load(args.nominal_states,allow_pickle=False)
|
||||
offsets=saved['offsets']; flat=saved['states']
|
||||
states=[flat[offsets[i]:offsets[i+1]] for i in range(len(offsets)-1)]
|
||||
if list(saved['candidate_ids'])!=[x['candidate_id'] for x in selection['selected_windows']]:
|
||||
raise RuntimeError('nominal state checkpoint does not match selection order')
|
||||
args.checkpoint_dir.mkdir(parents=True,exist_ok=True)
|
||||
tasks=[(axis,sign,angle) for axis in 'xyz'
|
||||
for angle in (.1,.3,.5) for sign in (-1.,1.)]
|
||||
results=[]
|
||||
with ProcessPoolExecutor(max_workers=args.workers,initializer=_initialize_worker,
|
||||
initargs=(segments,states,args.rotation_rpy_deg,args.max_nfev,
|
||||
args.hpr_direct_sigma_rad,str(args.checkpoint_dir))) as executor:
|
||||
futures=[executor.submit(_solve,task) for task in tasks]
|
||||
for future in as_completed(futures):
|
||||
result=future.result(); results.append(result)
|
||||
print('completed',result['name'],flush=True)
|
||||
results.sort(key=lambda x:('xyz'.index(x['axis']),x['signed_angle_deg']))
|
||||
nominal=engineering['prior_constrained_solution']
|
||||
nominal_l=np.asarray(nominal['result']['final_l_I_m'])
|
||||
nominal_data=nominal['data_only_summary_excluding_prior_factor']
|
||||
nominal_R=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
nominal_T=make_transform(-nominal_R@nominal_l,nominal_R)
|
||||
for result in results:
|
||||
result['delta_l_from_nominal_m']=np.asarray(result['final_l_I_m'])-nominal_l
|
||||
result['delta_l_norm_m']=float(np.linalg.norm(result['delta_l_from_nominal_m']))
|
||||
result['transform_translation_delta_m']=(
|
||||
np.asarray(result['T_RTK_IMU'])[:3,3]-nominal_T[:3,3])
|
||||
result['transform_translation_delta_norm_m']=float(np.linalg.norm(
|
||||
result['transform_translation_delta_m']))
|
||||
result['transform_rotation_delta_deg']=abs(result['signed_angle_deg'])
|
||||
result['relative_data_cost_delta']=result['data_cost']/nominal_data['cost']-1.
|
||||
result['factor_p95_delta_sigma']={key:
|
||||
result['residual_by_factor'][key]['p95_abs']-
|
||||
nominal_data['residual_by_factor'][key]['p95_abs']
|
||||
for key in ('best_position','doppler','hpr','imu_preintegration')}
|
||||
delta=np.asarray([x['delta_l_from_nominal_m'] for x in results])
|
||||
at_point3=[x for x in results if abs(x['signed_angle_deg'])==.3]
|
||||
checks={'all_18_complete_and_converged':
|
||||
len(results)==18 and all(x['success'] for x in results),
|
||||
'max_delta_norm_at_0p3deg_le_0p10m':
|
||||
max(x['delta_l_norm_m'] for x in at_point3)<=.10,
|
||||
'max_delta_norm_all_le_0p15m':
|
||||
max(x['delta_l_norm_m'] for x in results)<=.15,
|
||||
'relative_data_cost_increase_all_le_0p05':
|
||||
max(x['relative_data_cost_delta'] for x in results)<=.05,
|
||||
'factor_normalized_p95_increase_all_le_0p5sigma':
|
||||
max(v for x in results for v in x['factor_p95_delta_sigma'].values())<=.5}
|
||||
passed=bool(all(checks.values()))
|
||||
payload={'scope':'18 prior-constrained fixed-R2G perturbation solves',
|
||||
'data_only_free_called':False,'bootstrap_called':False,'loo_called':False,
|
||||
'parser_R0_covariance_modified':False,'calibration_window_count':47,
|
||||
'nominal_rotation_rpy_deg':args.rotation_rpy_deg,
|
||||
'nominal_l_I_m':nominal_l,'nominal_T_RTK_IMU':nominal_T,
|
||||
'perturbations':results,'summary':{
|
||||
'max_abs_delta_l_xyz_m':np.max(np.abs(delta),axis=0),
|
||||
'max_delta_l_norm_m':float(np.max(np.linalg.norm(delta,axis=1))),
|
||||
'max_transform_translation_delta_norm_m':max(
|
||||
x['transform_translation_delta_norm_m'] for x in results)},
|
||||
'diagnostic_gate_thresholds_frozen_before_run':{
|
||||
'max_delta_norm_at_0p3deg_m':.10,'max_delta_norm_all_m':.15,
|
||||
'relative_data_cost_increase':.05,
|
||||
'factor_normalized_p95_increase_sigma':.5},
|
||||
'gate_checks':checks,'rotation_sensitivity_passed':passed}
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable({'summary':payload['summary'],
|
||||
'checks':checks,'rotation_sensitivity_passed':passed}),indent=2))
|
||||
return 0
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run R1b/R2V/R2G/R3 on unified native-time G90/HI13 exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from imu_lidar.rtk_imu_multisource import (
|
||||
load_unified_sessions,
|
||||
result_to_jsonable,
|
||||
solve_r3,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--session", action="append")
|
||||
parser.add_argument("--level-static", action="append", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
selected = None if not args.session else set(args.session) | set(args.level_static)
|
||||
sessions = load_unified_sessions(args.manifest, selected_session_ids=selected)
|
||||
result = solve_r3(sessions, level_static_session_ids=set(args.level_static))
|
||||
payload = result_to_jsonable(result)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Run one 10-20 s per-node state graph with a fixed mechanical lever.'''
|
||||
from __future__ import annotations
|
||||
import argparse, json, sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import build_problem, solve_fixed_lever
|
||||
from tools.audit_rtk_imu_factor_consistency import (
|
||||
MECHANICAL_L_I_M, _build_segment, _jsonable, _qualified_runs)
|
||||
|
||||
def _select_window(sessions,reference,period,target_duration):
|
||||
best = None
|
||||
for session,run,segment_id in _qualified_runs(sessions,reference,period):
|
||||
for start in range(len(run)):
|
||||
target_t = run[start].t_s+target_duration
|
||||
end = int(np.searchsorted([node.t_s for node in run],target_t))
|
||||
if end >= len(run): continue
|
||||
window = tuple(run[start:end+1])
|
||||
duration = window[-1].t_s-window[0].t_s
|
||||
if not 10. <= duration <= 20.: continue
|
||||
best_count = sum(node.source == 'BESTNAVA' for node in window)
|
||||
doppler_count = sum(node.velocity_enu_m_s is not None for node in window)
|
||||
hpr_count = sum(node.hpr_factor_valid for node in window)
|
||||
gyro_score = sum(np.linalg.norm(node.gyro_rad_s) for node in window)
|
||||
score = 10.*best_count+10.*doppler_count+2.*hpr_count+gyro_score
|
||||
candidate = (score,session,window,f'{segment_id}:window_{start:03d}',
|
||||
best_count,doppler_count,hpr_count)
|
||||
if best is None or candidate[0] > best[0]: best = candidate
|
||||
if best is None: raise RuntimeError('no 10-20 s qualified window')
|
||||
score,session,window,segment_id,best_count,doppler_count,hpr_count = best
|
||||
segment = _build_segment(session,window,segment_id)
|
||||
if segment is None: raise RuntimeError('selected window preintegration failed')
|
||||
return segment,{'score':score,'session_id':session.session_id,
|
||||
'segment_id':segment_id,'start_s':window[0].t_s,'end_s':window[-1].t_s,
|
||||
'duration_s':window[-1].t_s-window[0].t_s,'node_count':len(window),
|
||||
'best_count':best_count,'doppler_count':doppler_count,'hpr_factor_count':hpr_count}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--session',required=True)
|
||||
parser.add_argument('--sample-period-s',type=float,default=1.)
|
||||
parser.add_argument('--target-duration-s',type=float,default=15.)
|
||||
parser.add_argument('--max-nfev',type=int,default=30)
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
parser.add_argument('--mechanical-l-I-m',nargs=3,type=float,
|
||||
default=MECHANICAL_L_I_M.tolist())
|
||||
args = parser.parse_args()
|
||||
sessions = load_unified_sessions(args.manifest,selected_session_ids={args.session})
|
||||
reference = _height_reference(sessions)
|
||||
if reference is None: raise RuntimeError('no BEST reference')
|
||||
segment,selection = _select_window(
|
||||
sessions,reference,args.sample_period_s,args.target_duration_s)
|
||||
rotation = Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
problem = build_problem(segment,rotation,np.asarray(args.mechanical_l_I_m))
|
||||
result = solve_fixed_lever(problem,args.max_nfev)
|
||||
payload = {'scope':'single 10-20 s node-state graph; fixed mechanical lever',
|
||||
'translation_variable_enabled':False,'free_prior_loo_bootstrap_sensitivity_called':False,
|
||||
'selection':selection,'result':asdict(result)}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Three-start, no-prior free-lever solve on the frozen P0.5 circle window.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import build_problem,solve_free_lever
|
||||
from tools.audit_rtk_imu_factor_consistency import MECHANICAL_L_I_M,_jsonable
|
||||
from tools.run_rtk_imu_node_graph_fixed_lever import _select_window
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--circle-session',required=True)
|
||||
parser.add_argument('--sample-period-s',type=float,default=1.)
|
||||
parser.add_argument('--target-duration-s',type=float,default=15.)
|
||||
parser.add_argument('--max-nfev',type=int,default=120)
|
||||
parser.add_argument('--hpr-direct-sigma-rad',type=float,default=.006)
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
parser.add_argument('--mechanical-l-I-m',nargs=3,type=float,
|
||||
default=MECHANICAL_L_I_M.tolist())
|
||||
parser.add_argument('--large-perturbation-m',nargs=3,type=float,
|
||||
default=[.5,-.5,.5])
|
||||
args=parser.parse_args()
|
||||
sessions=load_unified_sessions(
|
||||
args.manifest,selected_session_ids={args.circle_session})
|
||||
reference=_height_reference(sessions)
|
||||
segment,selection=_select_window(
|
||||
sessions,reference,args.sample_period_s,args.target_duration_s)
|
||||
rotation=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
mechanical=np.asarray(args.mechanical_l_I_m,dtype=float)
|
||||
problem=build_problem(segment,rotation,mechanical,args.hpr_direct_sigma_rad)
|
||||
starts={'zero':np.zeros(3),'mechanical':mechanical,
|
||||
'mechanical_large_perturbation':mechanical+args.large_perturbation_m}
|
||||
results={name:asdict(solve_free_lever(problem,value,args.max_nfev))
|
||||
for name,value in starts.items()}
|
||||
for result in results.values():
|
||||
covariance=np.asarray(result['lever_covariance_m2'])
|
||||
result['lever_std_m']=np.sqrt(np.maximum(np.diag(covariance),0.))
|
||||
result['delta_to_mechanical_m']=np.asarray(result['final_l_I_m'])-mechanical
|
||||
result['xy_marginal_covariance_m2']=covariance[:2,:2]
|
||||
result['xy_information_singular_values']=np.linalg.svd(
|
||||
np.linalg.pinv(covariance[:2,:2],rcond=1e-9),compute_uv=False)
|
||||
solutions=np.asarray([value['final_l_I_m'] for value in results.values()])
|
||||
spread=float(max(np.linalg.norm(a-b) for a in solutions for b in solutions))
|
||||
xy_spread=float(max(np.linalg.norm(a[:2]-b[:2]) for a in solutions for b in solutions))
|
||||
circle_xy_observable=bool(all(value['success'] and
|
||||
np.all(np.asarray(value['lever_std_m'])[:2]<=.15) for value in results.values()))
|
||||
payload={'scope':'circle single-segment no-prior three-start free lever',
|
||||
'translation_variable_enabled':True,'manual_prior_used':False,
|
||||
'loo_bootstrap_sensitivity_called':False,'selection':selection,
|
||||
'fixed_rotation_rpy_deg':args.rotation_rpy_deg,
|
||||
'hpr_direct_sigma_rad':args.hpr_direct_sigma_rad,
|
||||
'mechanical_reference_m':mechanical,'large_perturbation_m':args.large_perturbation_m,
|
||||
'solutions':results,'maximum_solution_spread_m':spread,
|
||||
'maximum_xy_solution_spread_m':xy_spread,
|
||||
'circle_only_observability_gate':{
|
||||
'xy_marginal_std_max_m':.15,'passed':circle_xy_observable},
|
||||
'circle_only_data_only_accepted':circle_xy_observable,
|
||||
'joint_free_blocked_by_circle_gate':False,
|
||||
'covariance_postfit_scaled':False}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Three-start, no-prior joint free-lever solve on fixed P0.5 motion windows.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import build_problem,solve_free_lever_many
|
||||
from tools.audit_rtk_imu_factor_consistency import MECHANICAL_L_I_M,_jsonable
|
||||
from tools.run_rtk_imu_node_graph_fixed_lever import _select_window
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--circle-session',required=True)
|
||||
parser.add_argument('--left-right-session',required=True)
|
||||
parser.add_argument('--slope-session',required=True)
|
||||
parser.add_argument('--sample-period-s',type=float,default=1.)
|
||||
parser.add_argument('--target-duration-s',type=float,default=15.)
|
||||
parser.add_argument('--max-nfev',type=int,default=120)
|
||||
parser.add_argument('--hpr-direct-sigma-rad',type=float,default=.006)
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
parser.add_argument('--mechanical-l-I-m',nargs=3,type=float,
|
||||
default=MECHANICAL_L_I_M.tolist())
|
||||
parser.add_argument('--large-perturbation-m',nargs=3,type=float,
|
||||
default=[.5,-.5,.5])
|
||||
args=parser.parse_args()
|
||||
categories={'circle':args.circle_session,'left_right':args.left_right_session,
|
||||
'slope':args.slope_session}
|
||||
sessions=load_unified_sessions(
|
||||
args.manifest,selected_session_ids=set(categories.values()))
|
||||
reference=_height_reference(sessions)
|
||||
rotation=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
mechanical=np.asarray(args.mechanical_l_I_m,dtype=float)
|
||||
problems,selections=[],{}
|
||||
for motion,session_id in categories.items():
|
||||
segment,selection=_select_window(
|
||||
[s for s in sessions if s.session_id==session_id],reference,
|
||||
args.sample_period_s,args.target_duration_s)
|
||||
problems.append(build_problem(
|
||||
segment,rotation,mechanical,args.hpr_direct_sigma_rad))
|
||||
selections[motion]=selection
|
||||
starts={'zero':np.zeros(3),'mechanical':mechanical,
|
||||
'mechanical_large_perturbation':mechanical+args.large_perturbation_m}
|
||||
results={name:asdict(solve_free_lever_many(problems,value,args.max_nfev))
|
||||
for name,value in starts.items()}
|
||||
for result in results.values():
|
||||
covariance=np.asarray(result['lever_covariance_m2'])
|
||||
result['lever_std_m']=np.sqrt(np.maximum(np.diag(covariance),0.))
|
||||
result['delta_to_mechanical_m']=np.asarray(result['final_l_I_m'])-mechanical
|
||||
solutions=np.asarray([value['final_l_I_m'] for value in results.values()])
|
||||
spread=float(max(np.linalg.norm(a-b) for a in solutions for b in solutions))
|
||||
gates={name:{'optimizer_converged':value['success'],
|
||||
'lever_marginal_std':bool(np.all(np.asarray(value['lever_std_m'])<=[.15,.15,.20])),
|
||||
'lever_information_rank':value['lever_precision_rank']==3,
|
||||
'lever_information_condition':value['lever_information_condition_number']<=1e6,
|
||||
'lever_min_information':min(value['lever_information_singular_values'])>=1e-3}
|
||||
for name,value in results.items()}
|
||||
observable=bool(all(all(gate.values()) for gate in gates.values()))
|
||||
payload={'scope':'three-motion joint no-prior three-start free lever',
|
||||
'translation_variable_enabled':True,'manual_prior_used':False,
|
||||
'loo_bootstrap_sensitivity_called':False,'selections':selections,
|
||||
'fixed_rotation_rpy_deg':args.rotation_rpy_deg,
|
||||
'hpr_direct_sigma_rad':args.hpr_direct_sigma_rad,
|
||||
'mechanical_reference_m':mechanical,'solutions':results,
|
||||
'maximum_solution_spread_m':spread,'observability_gates':gates,
|
||||
'joint_data_only_translation_observable':observable,
|
||||
'manual_prior_started':False,'covariance_postfit_scaled':False}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Three-start joint free solve on information-selected windows.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import (
|
||||
build_problem,fit_states_at_fixed_lever,solve_free_lever_many)
|
||||
from tools.audit_rtk_imu_factor_consistency import MECHANICAL_L_I_M,_build_segment,_jsonable,_qualified_runs
|
||||
|
||||
|
||||
def _restore_segments(sessions,reference,selections,period_s):
|
||||
runs=list(_qualified_runs(sessions,reference,period_s)); restored=[]
|
||||
for item in selections:
|
||||
match=None
|
||||
for session,run,_ in runs:
|
||||
if session.session_id!=item['session_id']: continue
|
||||
contained=(run[0].t_s<=item['start_s']+1e-6 and
|
||||
run[-1].t_s>=item['end_s']-1e-6)
|
||||
if not contained: continue
|
||||
nodes=tuple(node for node in run
|
||||
if item['start_s']-1e-6<=node.t_s<=item['end_s']+1e-6)
|
||||
if len(nodes)==item['node_count']:
|
||||
match=_build_segment(session,nodes,item['candidate_id']); break
|
||||
if match is None: raise RuntimeError('cannot restore '+item['candidate_id'])
|
||||
restored.append(match)
|
||||
return restored
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--selection',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--sample-period-s',type=float,default=1.)
|
||||
parser.add_argument('--max-nfev',type=int,default=120)
|
||||
parser.add_argument('--prefit-max-nfev',type=int,default=50)
|
||||
parser.add_argument('--prefit-workers',type=int,default=4)
|
||||
parser.add_argument('--start-name',choices=['all','zero','mechanical',
|
||||
'mechanical_large_perturbation'],default='all')
|
||||
parser.add_argument('--hpr-direct-sigma-rad',type=float,default=.006)
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
parser.add_argument('--mechanical-l-I-m',nargs=3,type=float,
|
||||
default=MECHANICAL_L_I_M.tolist())
|
||||
parser.add_argument('--large-perturbation-m',nargs=3,type=float,
|
||||
default=[.5,-.5,.5])
|
||||
args=parser.parse_args()
|
||||
selection=json.loads(args.selection.read_text(encoding='utf-8'))
|
||||
ids={item['session_id'] for item in selection['selected_windows']}
|
||||
sessions=load_unified_sessions(args.manifest,selected_session_ids=ids)
|
||||
reference=_height_reference(sessions)
|
||||
segments=_restore_segments(sessions,reference,selection['selected_windows'],
|
||||
args.sample_period_s)
|
||||
rotation=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
mechanical=np.asarray(args.mechanical_l_I_m,dtype=float)
|
||||
problems=[build_problem(segment,rotation,mechanical,args.hpr_direct_sigma_rad)
|
||||
for segment in segments]
|
||||
starts={'zero':np.zeros(3),'mechanical':mechanical,
|
||||
'mechanical_large_perturbation':mechanical+args.large_perturbation_m}
|
||||
if args.start_name!='all': starts={args.start_name:starts[args.start_name]}
|
||||
results={}; prefit={}
|
||||
for name,value in starts.items():
|
||||
with ThreadPoolExecutor(max_workers=args.prefit_workers) as executor:
|
||||
fitted=list(executor.map(
|
||||
lambda problem:fit_states_at_fixed_lever(
|
||||
problem,value,args.prefit_max_nfev),problems))
|
||||
state_values=[item[0] for item in fitted]
|
||||
prefit[name]=[item[1] for item in fitted]
|
||||
results[name]=asdict(solve_free_lever_many(
|
||||
problems,value,args.max_nfev,state_values))
|
||||
for result in results.values():
|
||||
covariance=np.asarray(result['lever_covariance_m2'])
|
||||
result['lever_std_m']=np.sqrt(np.maximum(np.diag(covariance),0.))
|
||||
result['delta_to_mechanical_m']=np.asarray(result['final_l_I_m'])-mechanical
|
||||
solutions=np.asarray([value['final_l_I_m'] for value in results.values()])
|
||||
spread=float(max(np.linalg.norm(a-b) for a in solutions for b in solutions))
|
||||
gates={name:{'optimizer_converged':value['success'],
|
||||
'lever_marginal_std':bool(np.all(np.asarray(value['lever_std_m'])<=[.15,.15,.20])),
|
||||
'lever_information_rank':value['lever_precision_rank']==3,
|
||||
'lever_information_condition':value['lever_information_condition_number']<=1e6,
|
||||
'lever_min_information':min(value['lever_information_singular_values'])>=1e-3}
|
||||
for name,value in results.items()}
|
||||
observable=bool(all(all(gate.values()) for gate in gates.values()))
|
||||
payload={'scope':'information-selected multi-window no-prior three-start free lever',
|
||||
'translation_variable_enabled':True,'manual_prior_used':False,
|
||||
'loo_bootstrap_sensitivity_called':False,
|
||||
'selection_artifact':str(args.selection),'selected_window_count':len(segments),
|
||||
'start_name':args.start_name,'nuisance_prefit':prefit,
|
||||
'nuisance_prefit_is_not_lever_prior':True,
|
||||
'sample_overlap_audit':selection['sample_overlap_audit'],
|
||||
'information_curve':selection['lever_std_vs_information_curve'],
|
||||
'fixed_rotation_rpy_deg':args.rotation_rpy_deg,
|
||||
'hpr_direct_sigma_rad':args.hpr_direct_sigma_rad,
|
||||
'mechanical_reference_m':mechanical,'solutions':results,
|
||||
'maximum_solution_spread_m':spread,'observability_gates':gates,
|
||||
'data_only_translation_observable':observable,
|
||||
'manual_prior_started':False,'covariance_postfit_scaled':False}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable({'selected_window_count':len(segments),
|
||||
'solutions':{name:{'success':value['success'],'l_I_m':value['final_l_I_m'],
|
||||
'std_m':value['lever_std_m'],'cost':value['final_cost'],
|
||||
'chi_square_per_dof':value['chi_square_per_dof'],
|
||||
'singular_values':value['lever_information_singular_values']}
|
||||
for name,value in results.items()},'maximum_solution_spread_m':spread,
|
||||
'data_only_translation_observable':observable}),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
'''P0.5 fixed-lever node-graph covariance and motion-window audit.'''
|
||||
from __future__ import annotations
|
||||
import argparse, json, sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import _height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import (
|
||||
build_problem,solve_fixed_lever,solve_fixed_lever_many)
|
||||
from tools.audit_rtk_imu_factor_consistency import MECHANICAL_L_I_M,_jsonable
|
||||
from tools.run_rtk_imu_node_graph_fixed_lever import _select_window
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--circle-session',required=True)
|
||||
parser.add_argument('--left-right-session',required=True)
|
||||
parser.add_argument('--slope-session',required=True)
|
||||
parser.add_argument('--sample-period-s',type=float,default=1.)
|
||||
parser.add_argument('--target-duration-s',type=float,default=15.)
|
||||
parser.add_argument('--max-nfev',type=int,default=50)
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
parser.add_argument('--mechanical-l-I-m',nargs=3,type=float,
|
||||
default=MECHANICAL_L_I_M.tolist())
|
||||
parser.add_argument('--hpr-direct-sigma-rad',type=float,default=.006)
|
||||
args = parser.parse_args()
|
||||
categories = {'circle':args.circle_session,'left_right':args.left_right_session,
|
||||
'slope':args.slope_session}
|
||||
sessions = load_unified_sessions(args.manifest,selected_session_ids=set(categories.values()))
|
||||
reference = _height_reference(sessions)
|
||||
rotation = Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
lever = np.asarray(args.mechanical_l_I_m)
|
||||
problems, selections, separate = [], {}, {}
|
||||
for category,session_id in categories.items():
|
||||
local = [session for session in sessions if session.session_id == session_id]
|
||||
segment,selection = _select_window(
|
||||
local,reference,args.sample_period_s,args.target_duration_s)
|
||||
problem = build_problem(segment,rotation,lever,args.hpr_direct_sigma_rad)
|
||||
problems.append(problem); selections[category] = selection
|
||||
separate[category] = asdict(solve_fixed_lever(problem,args.max_nfev))
|
||||
joint = asdict(solve_fixed_lever_many(problems,args.max_nfev))
|
||||
fixed_pass = bool(
|
||||
all(value['success'] for value in separate.values()) and joint['success']
|
||||
and joint['chi_square_per_dof'] >= .25
|
||||
and joint['chi_square_per_dof'] <= 4.
|
||||
and joint['final_position_residual_m']['vector_p95'] <= .20
|
||||
and joint['final_velocity_residual_m_s']['vector_p95'] <= .50)
|
||||
payload = {'scope':'P0.5 three-motion fixed-lever only',
|
||||
'translation_variable_enabled':False,
|
||||
'manual_prior_loo_bootstrap_sensitivity_called':False,
|
||||
'covariance_model':{
|
||||
'source':'independent_innovation_audit_three_motion',
|
||||
'best_position_xyz_m':[.06,.06,.12],
|
||||
'doppler_xyz_m_s':[.15,.15,.30],
|
||||
'hpr_direct_angular_rad':args.hpr_direct_sigma_rad,
|
||||
'hpr_bridge_rule':'sqrt(direct_sigma^2 + dropout_extra_variance)',
|
||||
'imu_preintegration':'unchanged physical covariance',
|
||||
'bias_random_walk':'unchanged static/Allan/device model',
|
||||
'postfit_global_scale_applied':False},
|
||||
'fixed_l_I_m':lever,'selections':selections,
|
||||
'separate_fixed_lever':separate,'joint_fixed_lever':joint,
|
||||
'fixed_lever_covariance_gate':{
|
||||
'chi_square_per_dof_range':[.25,4.],
|
||||
'position_vector_p95_max_m':.20,
|
||||
'velocity_vector_p95_max_m_s':.50,
|
||||
'passed':fixed_pass},
|
||||
'whitening_diagnosis':{
|
||||
'normalized_scale_consistent':joint['chi_square_per_dof'] >= .25,
|
||||
'joint_preintegration_nis_per_dof':
|
||||
joint['final_residual_by_factor']['imu_preintegration']['chi_square_per_dof'],
|
||||
'joint_preintegration_normalized_p95':
|
||||
joint['final_residual_by_factor']['imu_preintegration']['p95_abs'],
|
||||
'preintegration_sigma_distribution':joint['preintegration_covariance_sigma'],
|
||||
'interpretation':(
|
||||
'absolute preintegration sigma is small, but per-node states satisfy process '
|
||||
'factors almost exactly while BEST/Doppler/HPR normalized residuals are also '
|
||||
'well below one; current factor covariance set is collectively overconservative'
|
||||
)},
|
||||
'free_lever_unlocked':fixed_pass}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
compact = {'selections':selections,
|
||||
'separate':{key:{'success':value['success'],
|
||||
'chi_square_per_dof':value['chi_square_per_dof'],
|
||||
'position_p95_m':value['final_position_residual_m']['vector_p95'],
|
||||
'velocity_p95_m_s':value['final_velocity_residual_m_s']['vector_p95'],
|
||||
'factor_stats':value['final_residual_by_factor']}
|
||||
for key,value in separate.items()},
|
||||
'joint':{'success':joint['success'],'chi_square_per_dof':joint['chi_square_per_dof'],
|
||||
'position_p95_m':joint['final_position_residual_m']['vector_p95'],
|
||||
'velocity_p95_m_s':joint['final_velocity_residual_m_s']['vector_p95'],
|
||||
'factor_stats':joint['final_residual_by_factor']},
|
||||
'free_lever_unlocked':fixed_pass}
|
||||
print(json.dumps(_jsonable(compact),ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
'''Select non-overlapping qualified windows by incremental lever information.'''
|
||||
from __future__ import annotations
|
||||
import argparse,json,sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
|
||||
from imu_lidar.rtk_imu_engineering import _all_hpr,_height_reference
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
from imu_lidar.rtk_imu_node_graph import build_problem,linearized_lever_information
|
||||
from tools.audit_rtk_imu_factor_consistency import _build_segment,_jsonable,_qualified_runs
|
||||
from tools.run_rtk_imu_node_graph_fixed_lever import _select_window
|
||||
|
||||
|
||||
STD_GATE=np.array([.15,.15,.20])
|
||||
|
||||
|
||||
def _overlap(left,right):
|
||||
return left['session_id']==right['session_id'] and not (
|
||||
left['end_s']<right['start_s'] or right['end_s']<left['start_s'])
|
||||
|
||||
|
||||
def _summary(information):
|
||||
covariance=np.linalg.pinv(information,rcond=1e-9)
|
||||
singular=np.linalg.svd(information,compute_uv=False)
|
||||
sign,logdet=np.linalg.slogdet(information)
|
||||
return {'std_m':np.sqrt(np.maximum(np.diag(covariance),0.)),
|
||||
'singular_values':singular,'lambda_min':singular[-1],
|
||||
'logdet':float(logdet) if sign>0 else -np.inf}
|
||||
|
||||
|
||||
def _window_candidates(sessions,reference,period_s,duration_s):
|
||||
candidates=[]
|
||||
for session,run,segment_id in _qualified_runs(sessions,reference,period_s):
|
||||
times=np.asarray([node.t_s for node in run])
|
||||
start=0
|
||||
while start<len(run):
|
||||
end=int(np.searchsorted(times,times[start]+duration_s))
|
||||
if end>=len(run): break
|
||||
window=tuple(run[start:end+1])
|
||||
if 10.<=window[-1].t_s-window[0].t_s<=20.:
|
||||
candidate_id=f'{segment_id}:info_window_{start:04d}'
|
||||
segment=_build_segment(session,window,candidate_id)
|
||||
if segment is not None:
|
||||
candidates.append({'candidate_id':candidate_id,
|
||||
'session_id':session.session_id,'start_s':window[0].t_s,
|
||||
'end_s':window[-1].t_s,'duration_s':window[-1].t_s-window[0].t_s,
|
||||
'node_count':len(window),'segment':segment,'session':session})
|
||||
start=end+1
|
||||
return candidates
|
||||
|
||||
|
||||
def _sample_keys(entry):
|
||||
session=entry['session']; start,end=entry['start_s'],entry['end_s']
|
||||
imu=set((session.session_id,int(round(t*1e6))) for t in session.imu.t_s
|
||||
if start<=t<=end)
|
||||
gnss=set((session.session_id,int(round(node.t_s*1e6)),node.source)
|
||||
for node in entry['segment'].nodes)
|
||||
hpr=_all_hpr(session)
|
||||
hpr_keys=set((session.session_id,int(round(hpr.t_s[i]*1e6)))
|
||||
for i in hpr.valid_indices if start<=hpr.t_s[i]<=end)
|
||||
return {'imu':imu,'gnss':gnss,'hpr':hpr_keys}
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,required=True)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--circle-session',required=True)
|
||||
parser.add_argument('--left-right-session',required=True)
|
||||
parser.add_argument('--slope-session',required=True)
|
||||
parser.add_argument('--sample-period-s',type=float,default=1.)
|
||||
parser.add_argument('--window-duration-s',type=float,default=15.)
|
||||
parser.add_argument('--max-additional-windows',type=int,default=100)
|
||||
parser.add_argument('--hpr-direct-sigma-rad',type=float,default=.006)
|
||||
parser.add_argument('--linearization-l-I-m',nargs=3,type=float,
|
||||
default=[-.53243,-.42662,.74660])
|
||||
parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
|
||||
default=[.4543066225,-.0026392019,.0122384129])
|
||||
args=parser.parse_args()
|
||||
sessions=load_unified_sessions(args.manifest)
|
||||
reference=_height_reference(sessions)
|
||||
rotation=Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
|
||||
lever=np.asarray(args.linearization_l_I_m,dtype=float)
|
||||
categories={'circle':args.circle_session,'left_right':args.left_right_session,
|
||||
'slope':args.slope_session}
|
||||
selected=[]
|
||||
for motion,session_id in categories.items():
|
||||
session=[s for s in sessions if s.session_id==session_id]
|
||||
segment,meta=_select_window(
|
||||
session,reference,args.sample_period_s,args.window_duration_s)
|
||||
selected.append({**meta,'candidate_id':meta['segment_id'],'motion':motion,
|
||||
'segment':segment,'session':session[0],'seed_window':True})
|
||||
candidates=[entry for entry in _window_candidates(
|
||||
sessions,reference,args.sample_period_s,args.window_duration_s)
|
||||
if not any(_overlap(entry,seed) for seed in selected)]
|
||||
for entry in [*selected,*candidates]:
|
||||
problem=build_problem(entry['segment'],rotation,lever,args.hpr_direct_sigma_rad)
|
||||
information,_,singular,weak=linearized_lever_information(problem,lever)
|
||||
entry['problem']=problem; entry['information']=information
|
||||
entry['single_window_singular_values']=singular
|
||||
entry['single_window_weakest_direction_I']=weak
|
||||
total=sum((entry['information'] for entry in selected),np.zeros((3,3)))
|
||||
curve=[{'window_count':len(selected),'added_candidate_id':'seed_three_motion',
|
||||
**_summary(total)}]
|
||||
remaining=list(candidates)
|
||||
saturation_count=0
|
||||
stop_reason='candidate_exhausted'
|
||||
while remaining and len(selected)<3+args.max_additional_windows:
|
||||
feasible=[entry for entry in remaining
|
||||
if not any(_overlap(entry,item) for item in selected)]
|
||||
if not feasible: break
|
||||
ranked=[]
|
||||
for entry in feasible:
|
||||
summary=_summary(total+entry['information'])
|
||||
ranked.append((summary['lambda_min'],summary['logdet'],entry,summary))
|
||||
_,_,choice,summary=max(ranked,key=lambda item:(item[0],item[1]))
|
||||
gain=summary['lambda_min']-curve[-1]['lambda_min']
|
||||
selected.append(choice); remaining.remove(choice); total+=choice['information']
|
||||
curve.append({'window_count':len(selected),'added_candidate_id':choice['candidate_id'],
|
||||
'delta_lambda_min':gain,**summary})
|
||||
saturation_count=saturation_count+1 if gain<.01 else 0
|
||||
if np.all(np.asarray(summary['std_m'])<=STD_GATE):
|
||||
stop_reason='linearized_observability_gate_reached'; break
|
||||
if saturation_count>=3:
|
||||
stop_reason='incremental_lambda_min_gain_saturated'; break
|
||||
else:
|
||||
if len(selected)>=3+args.max_additional_windows:
|
||||
stop_reason='max_additional_windows_reached'
|
||||
seen={'imu':set(),'gnss':set(),'hpr':set()}
|
||||
duplicate={'imu':0,'gnss':0,'hpr':0}
|
||||
selected_output=[]
|
||||
for order,entry in enumerate(selected):
|
||||
keys=_sample_keys(entry)
|
||||
shared={name:len(value&seen[name]) for name,value in keys.items()}
|
||||
for name,value in keys.items():
|
||||
duplicate[name]+=shared[name]; seen[name].update(value)
|
||||
selected_output.append({'selection_order':order,
|
||||
'candidate_id':entry['candidate_id'],'session_id':entry['session_id'],
|
||||
'start_s':entry['start_s'],'end_s':entry['end_s'],
|
||||
'duration_s':entry['duration_s'],'node_count':entry['node_count'],
|
||||
'seed_window':entry.get('seed_window',False),
|
||||
'single_window_information':entry['information'],
|
||||
'single_window_singular_values':entry['single_window_singular_values'],
|
||||
'single_window_weakest_direction_I':entry['single_window_weakest_direction_I'],
|
||||
'sample_count':{name:len(value) for name,value in keys.items()},
|
||||
'shared_sample_count_with_previous':shared})
|
||||
payload={'scope':'all recovered qualified dynamics; information-based window selection',
|
||||
'manual_prior_used':False,'linearization_l_I_m':lever,
|
||||
'linearization_point_is_not_prior_factor':True,
|
||||
'candidate_count':len(candidates),'selected_window_count':len(selected),
|
||||
'selection_objective':'maximize lambda_min(H_l); break ties by logdet(H_l)',
|
||||
'std_gate_m':STD_GATE,'stop_reason':stop_reason,
|
||||
'selected_windows':selected_output,'lever_std_vs_information_curve':curve,
|
||||
'sample_overlap_audit':{'duplicate_sample_count':duplicate,
|
||||
'all_selected_windows_time_nonoverlapping_within_session':not any(
|
||||
_overlap(a,b) for i,a in enumerate(selected) for b in selected[i+1:]),
|
||||
'unique_sample_count':{name:len(value) for name,value in seen.items()}},
|
||||
'final_linearized_observable':bool(np.all(
|
||||
np.asarray(curve[-1]['std_m'])<=STD_GATE))}
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload),ensure_ascii=False,indent=2,
|
||||
allow_nan=False)+'\n',encoding='utf-8')
|
||||
print(json.dumps(_jsonable({'candidate_count':len(candidates),
|
||||
'selected_window_count':len(selected),'stop_reason':stop_reason,
|
||||
'final_curve':curve[-1],'sample_overlap_audit':payload['sample_overlap_audit']}),
|
||||
ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only artificial GNHPR dropout validation for the engineering R0 bridge.
|
||||
|
||||
For each requested gap, Q4 HPR samples inside an otherwise 0.1 s contiguous
|
||||
run are withheld. Their true baseline directions are compared with normalized
|
||||
linear interpolation and short-horizon HI13 gyro propagation. No lever-arm
|
||||
solve, prior, bootstrap, sensitivity run, or threshold update is performed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from imu_lidar.rtk_imu_engineering import _all_hpr, _baseline_angle_deg, _interpolate_baseline, _world_rtk
|
||||
from imu_lidar.rtk_imu_multisource import load_unified_sessions
|
||||
|
||||
GAPS_S = (0.2, 0.3, 0.4, 0.5, 0.6, 0.8)
|
||||
R2G_RPY_DEG = (0.4543066225, -0.0026392019, 0.0122384129)
|
||||
BASELINE_FACTOR_SIGMA_DEG = float(np.degrees(0.035))
|
||||
|
||||
|
||||
def _jsonable(value):
|
||||
if isinstance(value, np.ndarray):
|
||||
return _jsonable(value.tolist())
|
||||
if isinstance(value, np.generic):
|
||||
return _jsonable(value.item())
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else None
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _jsonable(v) for k, v in value.items()}
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [_jsonable(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _contiguous_runs(times: np.ndarray, valid: np.ndarray) -> list[np.ndarray]:
|
||||
indices = np.flatnonzero(valid)
|
||||
if indices.size == 0:
|
||||
return []
|
||||
runs: list[list[int]] = [[int(indices[0])]]
|
||||
for previous, index in zip(indices[:-1], indices[1:]):
|
||||
dt = float(times[index] - times[previous])
|
||||
if 0.075 <= dt <= 0.125:
|
||||
runs[-1].append(int(index))
|
||||
else:
|
||||
runs.append([int(index)])
|
||||
return [np.asarray(run, dtype=int) for run in runs if len(run) >= 3]
|
||||
|
||||
|
||||
def _propagate_baseline(session, R_WI_start: np.ndarray, baseline_I: np.ndarray,
|
||||
start_s: float, targets_s: np.ndarray) -> list[np.ndarray]:
|
||||
"""Propagate with vectorized gyro interpolation over the short withheld gap."""
|
||||
targets = np.asarray(targets_s, dtype=float)
|
||||
if targets.size == 0:
|
||||
return []
|
||||
grid = np.unique(np.concatenate(([start_s], session.imu.t_s[
|
||||
(session.imu.t_s > start_s) & (session.imu.t_s < targets[-1])], targets)))
|
||||
gyro = np.column_stack([
|
||||
np.interp(grid, session.imu.t_s, session.imu.gyro_rad_s[:, axis]) for axis in range(3)
|
||||
])
|
||||
R_WI = np.asarray(R_WI_start, dtype=float).copy()
|
||||
predicted: list[np.ndarray] = []
|
||||
target_index = 0
|
||||
for index, (left, right) in enumerate(zip(grid[:-1], grid[1:])):
|
||||
omega = 0.5 * (gyro[index] + gyro[index + 1])
|
||||
R_WI = R_WI @ Rotation.from_rotvec(omega * float(right - left)).as_matrix()
|
||||
while target_index < targets.size and abs(targets[target_index] - right) < 1e-9:
|
||||
predicted.append(R_WI @ baseline_I)
|
||||
target_index += 1
|
||||
return predicted
|
||||
|
||||
def _session_validation(session, rotation: np.ndarray, max_windows: int) -> dict[str, object]:
|
||||
hpr = _all_hpr(session)
|
||||
runs = _contiguous_runs(hpr.t_s, hpr.valid)
|
||||
baseline_I = rotation.T[:, 0]
|
||||
result: dict[str, object] = {"session_id": session.session_id, "q4_runs": len(runs), "gaps": {}}
|
||||
for requested_gap in GAPS_S:
|
||||
interpolation_errors: list[float] = []
|
||||
propagation_errors: list[float] = []
|
||||
actual_gaps: list[float] = []
|
||||
windows = 0
|
||||
for run in runs:
|
||||
nominal_dt = float(np.median(np.diff(hpr.t_s[run])))
|
||||
step_count = max(2, int(round(requested_gap / nominal_dt)))
|
||||
# The endpoints remain observed, every internal Q4 point is withheld.
|
||||
for start in range(0, run.size - step_count, max(1, step_count)):
|
||||
subset = run[start:start + step_count + 1]
|
||||
if subset.size != step_count + 1:
|
||||
continue
|
||||
left, right = int(subset[0]), int(subset[-1])
|
||||
actual_gap = float(hpr.t_s[right] - hpr.t_s[left])
|
||||
if abs(actual_gap - requested_gap) > 0.08:
|
||||
continue
|
||||
withheld = subset[1:-1]
|
||||
if withheld.size == 0:
|
||||
continue
|
||||
left_b, right_b = hpr.baseline_enu[left], hpr.baseline_enu[right]
|
||||
targets = hpr.t_s[withheld]
|
||||
predicted_prop = _propagate_baseline(
|
||||
session, _world_rtk(left_b) @ rotation, baseline_I, float(hpr.t_s[left]), targets
|
||||
)
|
||||
for index, predicted in zip(withheld, predicted_prop):
|
||||
fraction = float((hpr.t_s[index] - hpr.t_s[left]) / actual_gap)
|
||||
interpolation_errors.append(_baseline_angle_deg(
|
||||
_interpolate_baseline(left_b, right_b, fraction), hpr.baseline_enu[index]
|
||||
))
|
||||
propagation_errors.append(_baseline_angle_deg(predicted, hpr.baseline_enu[index]))
|
||||
actual_gaps.append(actual_gap)
|
||||
windows += 1
|
||||
if windows >= max_windows:
|
||||
break
|
||||
if windows >= max_windows:
|
||||
break
|
||||
def summary(errors: list[float]) -> dict[str, float | int | None]:
|
||||
finite = np.asarray([e for e in errors if np.isfinite(e)], dtype=float)
|
||||
if finite.size == 0:
|
||||
return {"count": 0, "p50_deg": None, "p95_deg": None, "max_deg": None}
|
||||
return {"count": int(finite.size), "p50_deg": float(np.percentile(finite, 50)),
|
||||
"p95_deg": float(np.percentile(finite, 95)), "max_deg": float(np.max(finite))}
|
||||
interp, prop = summary(interpolation_errors), summary(propagation_errors)
|
||||
choice = "linear_baseline_interpolation" if (interp["p95_deg"] or np.inf) <= (prop["p95_deg"] or np.inf) else "imu_gyro_propagation"
|
||||
selected_p95 = interp["p95_deg"] if choice.startswith("linear") else prop["p95_deg"]
|
||||
result["gaps"][f"{requested_gap:.1f}"] = {
|
||||
"requested_gap_s": requested_gap,
|
||||
"actual_gap_p50_s": float(np.median(actual_gaps)) if actual_gaps else None,
|
||||
"window_count": windows,
|
||||
"linear_baseline_interpolation": interp,
|
||||
"imu_gyro_propagation": prop,
|
||||
"preferred_method_by_p95": choice,
|
||||
"selected_p95_deg": selected_p95,
|
||||
"within_baseline_factor_sigma": bool(selected_p95 is not None and selected_p95 <= BASELINE_FACTOR_SIGMA_DEG),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--session", action="append")
|
||||
parser.add_argument("--max-windows", type=int, default=500)
|
||||
args = parser.parse_args()
|
||||
sessions = load_unified_sessions(args.manifest, selected_session_ids=None if args.session is None else set(args.session))
|
||||
rotation = Rotation.from_euler("xyz", R2G_RPY_DEG, degrees=True).as_matrix()
|
||||
session_results = [_session_validation(session, rotation, args.max_windows) for session in sessions]
|
||||
recommendation: dict[str, object] = {}
|
||||
for gap in GAPS_S:
|
||||
entries = [item["gaps"][f"{gap:.1f}"] for item in session_results if item["gaps"].get(f"{gap:.1f}")]
|
||||
p95 = [entry["selected_p95_deg"] for entry in entries if entry["selected_p95_deg"] is not None]
|
||||
recommendation[f"{gap:.1f}"] = {
|
||||
"sessions_with_samples": len(p95),
|
||||
"worst_session_selected_p95_deg": float(max(p95)) if p95 else None,
|
||||
"passes_all_sessions_factor_sigma": bool(p95 and max(p95) <= BASELINE_FACTOR_SIGMA_DEG),
|
||||
}
|
||||
passing = [float(key) for key, value in recommendation.items() if value["passes_all_sessions_factor_sigma"]]
|
||||
payload = {
|
||||
"scope": "artificial HPR dropout validation only; no solve/prior/bootstrap/sensitivity/threshold change",
|
||||
"r2g_rotation_rpy_deg": R2G_RPY_DEG,
|
||||
"baseline_factor_sigma_deg": BASELINE_FACTOR_SIGMA_DEG,
|
||||
"sessions": session_results,
|
||||
"bridge_recommendation": recommendation,
|
||||
"largest_gap_passing_all_sessions_s": max(passing) if passing else None,
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(_jsonable(payload), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"largest_gap_passing_all_sessions_s": payload["largest_gap_passing_all_sessions_s"],
|
||||
"bridge_recommendation": recommendation}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user