@@ -159,10 +159,15 @@ def extract_ekf_z(ulog):
159159 d = get_topic (ulog , "vehicle_local_position" )
160160 if d is None :
161161 return None
162- return {
162+ result = {
163163 "time_s" : us_to_s (d .data ["timestamp" ], ulog .start_timestamp ),
164164 "z" : d .data ["z" ],
165165 }
166+ if "vz" in d .data :
167+ result ["vz" ] = d .data ["vz" ]
168+ if "vx" in d .data and "vy" in d .data :
169+ result ["vxy" ] = np .sqrt (d .data ["vx" ]** 2 + d .data ["vy" ]** 2 )
170+ return result
166171
167172
168173def extract_ekf_baro_obs (ulog ):
@@ -311,9 +316,47 @@ def thrust_std(self):
311316 return float (np .sqrt (max (self ._thrust_var , 0.0 )))
312317
313318
319+ def build_estimation_mask (baro_t , armed_start , armed_end , landed ,
320+ ekf_z = None , range_data = None ):
321+ """Build sample mask matching firmware hard + soft guards.
322+
323+ Hard gates: armed AND not landed.
324+ Soft gates (skip RLS but keep CF in firmware — here we skip both since
325+ the offline replay doesn't separate CF from RLS per-sample):
326+ - |vz| > 2 m/s
327+ - vxy > 5 m/s
328+ - range sensor < 0.5 m (ground effect proximity)
329+ """
330+ armed = (baro_t >= armed_start ) & (baro_t <= armed_end )
331+
332+ if landed is not None :
333+ is_landed = (np .interp (baro_t , landed ["time_s" ],
334+ landed ["landed" ].astype (float )) > 0.5 )
335+ else :
336+ is_landed = np .zeros (len (baro_t ), dtype = bool )
337+
338+ mask = armed & ~ is_landed
339+
340+ if ekf_z is not None and "vz" in ekf_z :
341+ vz = np .interp (baro_t , ekf_z ["time_s" ], ekf_z ["vz" ])
342+ mask &= np .abs (vz ) <= 2.0
343+
344+ if ekf_z is not None and "vxy" in ekf_z :
345+ vxy = np .interp (baro_t , ekf_z ["time_s" ], ekf_z ["vxy" ])
346+ mask &= vxy <= 5.0
347+
348+ if range_data is not None :
349+ rng = np .interp (baro_t , range_data ["time_s" ],
350+ range_data ["distance_m" ])
351+ mask &= rng > 0.5
352+
353+ return mask
354+
355+
314356def run_offline_cf_rls (baro , accel , attitude , thrust , landed ,
315357 armed_start , armed_end ,
316- cf_bandwidth = None , rls_lambda = None ):
358+ cf_bandwidth = None , rls_lambda = None ,
359+ ekf_z = None , range_data = None ):
317360 """Replay CF+RLS on logged sensor data. Returns K trace and residuals."""
318361 baro_t = baro ["time_s" ]
319362 baro_alt = baro ["alt_m" ]
@@ -329,14 +372,8 @@ def run_offline_cf_rls(baro, accel, attitude, thrust, landed,
329372 accel_up = np .interp (baro_t , accel ["time_s" ], accel_up_all )
330373 thrust_interp = np .interp (baro_t , thrust ["time_s" ], thrust ["thrust" ])
331374
332- # Airborne mask: armed AND not landed (matches firmware hard gates)
333- armed = (baro_t >= armed_start ) & (baro_t <= armed_end )
334- if landed is not None :
335- is_landed = (np .interp (baro_t , landed ["time_s" ],
336- landed ["landed" ].astype (float )) > 0.5 )
337- else :
338- is_landed = np .zeros (len (baro_t ), dtype = bool )
339- airborne = armed & ~ is_landed
375+ airborne = build_estimation_mask (baro_t , armed_start , armed_end , landed ,
376+ ekf_z , range_data )
340377
341378 est = CfRls (cf_bandwidth = cf_bandwidth , rls_lambda = rls_lambda )
342379 n = len (baro_t )
@@ -464,7 +501,8 @@ def run_range_calibration(baro, range_data, thrust,
464501# ---------------------------------------------------------------------------
465502
466503def sweep_cf_params (baro , accel , attitude , thrust , landed ,
467- armed_start , armed_end , range_cal , existing_pcoef ):
504+ armed_start , armed_end , range_cal , existing_pcoef ,
505+ ekf_z = None , range_data = None ):
468506 """Sweep CF bandwidth at two lambda values, evaluate against range truth."""
469507 bandwidths = np .logspace (np .log10 (0.01 ), np .log10 (1.0 ), 30 )
470508 lambdas = [("lambda_0.998" , 0.998 ), ("lambda_1.0" , 1.0 )]
@@ -482,13 +520,9 @@ def sweep_cf_params(baro, accel, attitude, thrust, landed,
482520 accel_up = np .interp (baro_t , accel ["time_s" ], accel_up_all )
483521 thrust_interp = np .interp (baro_t , thrust ["time_s" ], thrust ["thrust" ])
484522
485- armed = (baro_t >= armed_start ) & (baro_t <= armed_end )
486- if landed is not None :
487- is_landed = (np .interp (baro_t , landed ["time_s" ],
488- landed ["landed" ].astype (float )) > 0.5 )
489- else :
490- is_landed = np .zeros (len (baro_t ), dtype = bool )
491- airborne_idx = np .where (armed & ~ is_landed )[0 ]
523+ mask = build_estimation_mask (baro_t , armed_start , armed_end , landed ,
524+ ekf_z , range_data )
525+ airborne_idx = np .where (mask )[0 ]
492526
493527 raw_err = range_cal ["raw_err_fit" ]
494528 thr_fit = range_cal ["thrust_fit" ]
@@ -1009,7 +1043,8 @@ def main():
10091043 offline = None
10101044 if accel is not None and attitude is not None :
10111045 offline = run_offline_cf_rls (baro , accel , attitude , thrust , landed ,
1012- armed_start , armed_end )
1046+ armed_start , armed_end ,
1047+ ekf_z = ekf_z , range_data = range_data )
10131048 summary .append (f"Offline CF+RLS:" )
10141049 summary .append (f" Final K = { offline ['final_k' ]:.3f} " )
10151050 summary .append (f" Total PCOEF: { existing_pcoef :+.2f} "
@@ -1099,7 +1134,8 @@ def main():
10991134 print ("\n Sweeping CF bandwidth..." )
11001135 sweep = sweep_cf_params (baro , accel , attitude , thrust , landed ,
11011136 armed_start , armed_end , range_cal ,
1102- existing_pcoef )
1137+ existing_pcoef ,
1138+ ekf_z = ekf_z , range_data = range_data )
11031139 fig , best_bw , min_bw , min_std = plot_cf_tuning (
11041140 sweep , range_cal , existing_pcoef )
11051141 figures .append (fig )
0 commit comments