Files
calibration/tools/audit_rtk_imu_factor_consistency.py
T

314 lines
17 KiB
Python

#!/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 rtk_imu.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 rtk_imu.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())