From f1f367da8a651ee7465a6786e5bc146d284a726c Mon Sep 17 00:00:00 2001 From: gnss-gpu contributors Date: Mon, 15 Jun 2026 02:01:00 +0900 Subject: [PATCH] feat(gsdc2023): opt-in DGNSS base correction for FGO-only rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a BridgeConfig flag base_correction_fgo_only_rows (default False) and CLI --base-correction-fgo-only-rows. When set, the DGNSS base correction is also applied to FGO-only rows — rows whose WLS weight was masked out while the FGO weight (weights_fgo) was kept — so the FGO objective sees corrected pseudoranges on those rows too. The legacy behaviour (correct only WLS-active rows) is preserved by default. apply_base_correction_to_pseudorange gains weights_fgo / include_fgo_only parameters; the flag is threaded through build_trip_arrays, the post-observation stages and the submission CLI. Motivation: a 26-trip A/B (off vs on) on the pixel=FGO preset improves the FGO standalone score from 1.6386 m to 1.5177 m (-12.1 cm aggregate, 12 wins / 5 regressions / 9 wash). The wins are bias-dominated rows recovered by the DGNSS correction (mtv-pe1 -131 cm, lax-t -81 cm, mtv-e -42 cm, lax-o -23 cm); the main regression is sjc-q (+39 cm), an NLOS-heavy trip whose rows are then kept at full weight in FGO. High variance, hence opt-in and default off. --- .../build_gsdc2023_bridge_submission.py | 8 +++ experiments/gsdc2023_bridge_config.py | 7 +++ experiments/gsdc2023_raw_bridge.py | 4 ++ experiments/gsdc2023_trip_stages.py | 20 ++++++- tests/test_gsdc2023_bridge_config.py | 6 +++ tests/test_gsdc2023_trip_stages.py | 54 +++++++++++++++++++ 6 files changed, 98 insertions(+), 1 deletion(-) diff --git a/experiments/build_gsdc2023_bridge_submission.py b/experiments/build_gsdc2023_bridge_submission.py index 724d1dfe..df4ed43e 100644 --- a/experiments/build_gsdc2023_bridge_submission.py +++ b/experiments/build_gsdc2023_bridge_submission.py @@ -299,6 +299,7 @@ def build_config(args: argparse.Namespace) -> BridgeConfig: hatch_smoothing_n=int(getattr(args, "hatch_smoothing_n", 100)), use_rtklib_tropo=bool(getattr(args, "use_rtklib_tropo", False)), apply_base_correction=bool(getattr(args, "base_correction", False)), + base_correction_fgo_only_rows=bool(getattr(args, "base_correction_fgo_only_rows", False)), position_source=args.position_source, chunk_epochs=args.chunk_epochs, gated_baseline_threshold=args.gated_threshold, @@ -521,6 +522,7 @@ def run_bridge_submission(args: argparse.Namespace) -> tuple[pd.DataFrame, dict[ "max_epochs": args.max_epochs, "dual_frequency": bool(args.dual_frequency), "base_correction": bool(config.apply_base_correction), + "base_correction_fgo_only_rows": bool(config.base_correction_fgo_only_rows), "ct_rbpf_fgo": bool(config.ct_rbpf_fgo_enabled), "taroz_marupaku": taroz_marupaku, "taroz_phone_aware": taroz_phone_aware, @@ -589,6 +591,12 @@ def main(argv: list[str] | None = None) -> int: default=False, help="Mirror the post-fill pseudorange residual mask into the FGO weights (weights_fgo), so residual-rejected PR rows are dropped from the FGO objective too.", ) + parser.add_argument( + "--base-correction-fgo-only-rows", + action=argparse.BooleanOptionalAction, + default=False, + help="Also apply the DGNSS base correction to FGO-only rows (weights==0 but weights_fgo>0) — rows whose WLS weight was masked while the FGO weight was kept. Off by default keeps the legacy WLS-active-only behaviour.", + ) parser.add_argument("--stop-attitude-sigma-rad", type=float, default=0.0) parser.add_argument( "--taroz-fgo", diff --git a/experiments/gsdc2023_bridge_config.py b/experiments/gsdc2023_bridge_config.py index 561b24d1..e8509aae 100644 --- a/experiments/gsdc2023_bridge_config.py +++ b/experiments/gsdc2023_bridge_config.py @@ -332,6 +332,11 @@ class BridgeConfig: apply_relative_height: bool = False apply_position_offset: bool = False apply_base_correction: bool = False + # When True, the DGNSS base correction is also applied to FGO-only rows + # (weights==0 but weights_fgo>0): FGO-only constellations and rows whose WLS + # weight was masked out while the FGO weight was kept. Off by default keeps + # the legacy WLS-active-only behaviour. + base_correction_fgo_only_rows: bool = False graph_relative_height: bool = False relative_height_sigma_m: float = 0.5 relative_height_huber_k: float = 0.0 @@ -385,6 +390,8 @@ def __post_init__(self) -> None: raise ValueError("fgo_lm_damping must be >= 0") if not isinstance(self.fgo_extra_constellations, bool): raise ValueError("fgo_extra_constellations must be a bool") + if not isinstance(self.base_correction_fgo_only_rows, bool): + raise ValueError("base_correction_fgo_only_rows must be a bool") for name in ( "stop_velocity_huber_k", "stop_position_huber_k", diff --git a/experiments/gsdc2023_raw_bridge.py b/experiments/gsdc2023_raw_bridge.py index 8b563d7d..74b3c736 100644 --- a/experiments/gsdc2023_raw_bridge.py +++ b/experiments/gsdc2023_raw_bridge.py @@ -673,6 +673,7 @@ def build_trip_arrays( tdcp_cycle_jump_mask_cycles: float = 0.0, tdcp_doppler_endpoint_mask: bool = True, apply_base_correction: bool = False, + base_correction_fgo_only_rows: bool = False, data_root: Path | None = None, trip: str | None = None, apply_observation_mask: bool = False, @@ -801,6 +802,7 @@ def build_trip_arrays( fgo_extra_constellations=fgo_extra_constellations, factor_dt_max_s=factor_dt_max_s, apply_base_correction=apply_base_correction, + base_correction_fgo_only_rows=base_correction_fgo_only_rows, data_root=data_root, trip=trip, doppler_residual_mask_mps=doppler_residual_mask_mps, @@ -3374,6 +3376,7 @@ def _build_taroz_fgo_candidate_batch( tdcp_cycle_jump_mask_cycles=config.tdcp_cycle_jump_mask_cycles, tdcp_doppler_endpoint_mask=config.tdcp_doppler_endpoint_mask, apply_base_correction=config.apply_base_correction, + base_correction_fgo_only_rows=bool(getattr(config, "base_correction_fgo_only_rows", False)), data_root=data_root, trip=trip, apply_observation_mask=config.apply_observation_mask, @@ -4060,6 +4063,7 @@ def validate_raw_gsdc2023_trip( tdcp_cycle_jump_mask_cycles=cfg.tdcp_cycle_jump_mask_cycles, tdcp_doppler_endpoint_mask=cfg.tdcp_doppler_endpoint_mask, apply_base_correction=cfg.apply_base_correction, + base_correction_fgo_only_rows=bool(getattr(cfg, "base_correction_fgo_only_rows", False)), data_root=data_root, trip=trip, apply_observation_mask=cfg.apply_observation_mask, diff --git a/experiments/gsdc2023_trip_stages.py b/experiments/gsdc2023_trip_stages.py index 6ba36319..4ef9d6be 100644 --- a/experiments/gsdc2023_trip_stages.py +++ b/experiments/gsdc2023_trip_stages.py @@ -298,6 +298,7 @@ class PostObservationStageConfig: default_pr_l1_threshold_m: float default_pr_l5_threshold_m: float fgo_extra_constellations: bool = False + base_correction_fgo_only_rows: bool = False tdcp_cycle_jump_mask_cycles: float = 0.0 tdcp_doppler_endpoint_mask: bool = True tdcp_ddl_sign_fixed: bool = False @@ -492,6 +493,8 @@ def apply_base_correction_to_pseudorange( signal_type: str, pseudorange: np.ndarray, weights: np.ndarray, + weights_fgo: np.ndarray | None = None, + include_fgo_only: bool = False, correction_matrix_fn: BaseCorrectionMatrixFn, ) -> int: if data_root is None or trip is None: @@ -503,7 +506,16 @@ def apply_base_correction_to_pseudorange( slot_keys, signal_type, ) - valid_base_correction = np.isfinite(base_correction) & (weights > 0.0) + # The shared pseudorange feeds both WLS (weights) and FGO (weights_fgo). + # By default only WLS-active rows are corrected (legacy behaviour). When + # ``include_fgo_only`` is set, rows that are FGO-only (weights==0 but + # weights_fgo>0 — FGO-only constellations, or rows whose WLS weight was + # masked while the FGO weight was kept) also receive the DGNSS correction + # so the FGO objective sees corrected pseudoranges. + active = weights > 0.0 + if include_fgo_only and weights_fgo is not None: + active = active | (weights_fgo > 0.0) + valid_base_correction = np.isfinite(base_correction) & active pseudorange[valid_base_correction] -= base_correction[valid_base_correction] return int(np.count_nonzero(valid_base_correction)) @@ -1649,6 +1661,7 @@ def build_observation_mask_base_correction_stage( *, apply_observation_mask: bool, apply_base_correction: bool, + base_correction_fgo_only_rows: bool = False, data_root: Path | None, trip: str | None, times_ms: np.ndarray, @@ -1837,6 +1850,8 @@ def build_observation_mask_base_correction_stage( signal_type=signal_type, pseudorange=pseudorange, weights=weights, + weights_fgo=weights_fgo, + include_fgo_only=base_correction_fgo_only_rows, correction_matrix_fn=correction_matrix_fn, ) @@ -1901,6 +1916,7 @@ def build_post_observation_stages( clock_drift_context_mps: np.ndarray | None, build_trip_arrays_fn: BuildTripArraysFn, apply_base_correction: bool, + base_correction_fgo_only_rows: bool = False, data_root: Path | None, trip: str | None, n_clock: int, @@ -2028,6 +2044,7 @@ def build_post_observation_stages( mask_base_stage = build_observation_mask_base_correction_stage( apply_observation_mask=apply_observation_mask, apply_base_correction=apply_base_correction, + base_correction_fgo_only_rows=base_correction_fgo_only_rows, data_root=data_root, trip=trip, times_ms=times_ms, @@ -2191,6 +2208,7 @@ def build_configured_post_observation_stages( clock_drift_context_mps=observation_products.clock_drift_context_mps, build_trip_arrays_fn=dependencies.build_trip_arrays_fn, apply_base_correction=config.apply_base_correction, + base_correction_fgo_only_rows=config.base_correction_fgo_only_rows, data_root=config.data_root, trip=config.trip, n_clock=observation_products.n_clock, diff --git a/tests/test_gsdc2023_bridge_config.py b/tests/test_gsdc2023_bridge_config.py index 67da1cf2..c6b50483 100644 --- a/tests/test_gsdc2023_bridge_config.py +++ b/tests/test_gsdc2023_bridge_config.py @@ -41,6 +41,7 @@ def test_bridge_config_defaults_match_public_factor_dt() -> None: assert cfg.taroz_imu_factor_mask_csv is None assert cfg.taroz_stop_mask_from_seed_velocity is False assert cfg.fgo_extra_constellations is False + assert cfg.base_correction_fgo_only_rows is False def test_bridge_config_rejects_invalid_position_source() -> None: @@ -53,6 +54,11 @@ def test_bridge_config_rejects_non_bool_fgo_extra_constellations() -> None: BridgeConfig(fgo_extra_constellations=1) # type: ignore[arg-type] +def test_bridge_config_rejects_non_bool_base_correction_fgo_only_rows() -> None: + with pytest.raises(ValueError, match="base_correction_fgo_only_rows must be a bool"): + BridgeConfig(base_correction_fgo_only_rows=1) # type: ignore[arg-type] + + def test_taroz_presets_enable_base_correction_and_unscaled_tdcp_weights() -> None: fgo = apply_taroz_fgo_preset(BridgeConfig()) gnss_only = apply_taroz_gnss_only_preset(BridgeConfig()) diff --git a/tests/test_gsdc2023_trip_stages.py b/tests/test_gsdc2023_trip_stages.py index bf68eafd..de5a4ca1 100644 --- a/tests/test_gsdc2023_trip_stages.py +++ b/tests/test_gsdc2023_trip_stages.py @@ -449,6 +449,60 @@ def correction_matrix_fn( assert calls == [(tmp_path, "train/course/phone", ("G01", "G02"), "GPS_L1_CA")] +def test_apply_base_correction_to_pseudorange_skips_fgo_only_rows_by_default(tmp_path: Path) -> None: + # Row [0, 1] is FGO-only: WLS weight masked (0) but FGO weight kept (>0). + pseudorange = np.array([[10.0, 20.0]], dtype=np.float64) + weights = np.array([[1.0, 0.0]], dtype=np.float64) + weights_fgo = np.array([[1.0, 5.0]], dtype=np.float64) + correction = np.array([[1.5, 2.0]], dtype=np.float64) + + def correction_matrix_fn(*_args: object) -> np.ndarray: + return correction + + count = apply_base_correction_to_pseudorange( + data_root=tmp_path, + trip="train/course/phone", + times_ms=np.array([1000.0], dtype=np.float64), + slot_keys=["G01", "G02"], + signal_type="GPS_L1_CA", + pseudorange=pseudorange, + weights=weights, + weights_fgo=weights_fgo, + correction_matrix_fn=correction_matrix_fn, + ) + + # Legacy default: only the WLS-active row [0, 0] is corrected. + assert count == 1 + np.testing.assert_allclose(pseudorange, [[8.5, 20.0]]) + + +def test_apply_base_correction_to_pseudorange_corrects_fgo_only_rows_when_enabled(tmp_path: Path) -> None: + pseudorange = np.array([[10.0, 20.0]], dtype=np.float64) + weights = np.array([[1.0, 0.0]], dtype=np.float64) + weights_fgo = np.array([[1.0, 5.0]], dtype=np.float64) + correction = np.array([[1.5, 2.0]], dtype=np.float64) + + def correction_matrix_fn(*_args: object) -> np.ndarray: + return correction + + count = apply_base_correction_to_pseudorange( + data_root=tmp_path, + trip="train/course/phone", + times_ms=np.array([1000.0], dtype=np.float64), + slot_keys=["G01", "G02"], + signal_type="GPS_L1_CA", + pseudorange=pseudorange, + weights=weights, + weights_fgo=weights_fgo, + include_fgo_only=True, + correction_matrix_fn=correction_matrix_fn, + ) + + # The FGO-only row [0, 1] now also receives the DGNSS correction. + assert count == 2 + np.testing.assert_allclose(pseudorange, [[8.5, 18.0]]) + + def test_apply_base_correction_to_pseudorange_requires_trip_context() -> None: with pytest.raises(RuntimeError, match="data_root and trip"): apply_base_correction_to_pseudorange(