From 84885fc6fa4e05827ca87c94ec0ec07b5d8f9211 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Mon, 7 Sep 2026 10:47:04 +0100 Subject: [PATCH] 20260907 - Default the Doppler span to +/-300 Hz and let it reach deployed nodes owl was widened to +/-1000 Hz on 2026-08-11 to catch a 402 m/s target that sat at -674 Hz when the node ran at fc 503 MHz. At 213 MHz (lambda 1.407 m) that same target appears at 286 Hz, so the window that justified +/-1000 now costs 265 ms/CPI to cover speeds nothing flies at. +/-300 Hz reaches ~+/-422 m/s for +42 ms/CPI span-attributable, measured over 74 CPIs on owl on 2026-09-07. Doppler span costs no resolution: bin spacing is pinned at 1/CPI and the span sets bin count, paid for out of nCorr = fs/dopplerSpan. What it does cost, at +/-300 against +/-200, is ~0.4 dB more far-range integration loss at delay bin 400 (the linear-correlation taper deepens as nCorr falls from 4975 to 3322) and 1.5x the raw CFAR false alarms, before centroiding and the minDoppler gate. Changing default.yml alone would have reached nothing. The merger seeds user.yml with a whole copy of default.yml on first boot, so every node that has ever booted holds the shipped span in its own overlay, which wins over defaults. owl's overlay carries all six top-level sections for exactly this reason. migrate_doppler_span drops the pair from the overlay when it is exactly the +/-200 that used to ship, so the default shows through. It follows migrate_gain_reduction in kind, and differs in three ways: - It tests both bounds together. -200/1000 or -200/400 is somebody's decision and is kept whole. owl's -1000/1000 survives untouched. - It deletes rather than rewrites, so the value lives in default.yml alone and the next span change needs no merger edit. - It runs before the user merge rather than after the forced one, so forced.yml keeps the last word. A node that deliberately chose +/-200 cannot be told apart from a first-boot copy, so it moves too and has to be set again. That is the cost of the overlay never recording who wrote a value. user.yml on disk is left alone, which makes this function load-bearing rather than one-shot: remove it and those nodes fall back to the +/-200 their overlay still holds. test_doppler_span_migration_does_not_rewrite_user_yml pins that so it is not deleted as spent. Co-Authored-By: Claude Opus 5 (1M context) --- config-merger/script/merge_config.py | 42 ++++++++++ config-merger/test/test_merge_config.py | 101 ++++++++++++++++++++++++ config/default.yml | 4 +- 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/config-merger/script/merge_config.py b/config-merger/script/merge_config.py index 1372d36..322bad0 100755 --- a/config-merger/script/merge_config.py +++ b/config-merger/script/merge_config.py @@ -32,6 +32,12 @@ import yaml from mergedeep import merge +# The Doppler span default.yml shipped up to and including v0.4.5.0. A user.yml +# holding exactly this pair is assumed to be the merger's own first-boot copy +# rather than anyone's choice. See migrate_doppler_span(). +LEGACY_DOPPLER_MIN = -200 +LEGACY_DOPPLER_MAX = 200 + def get_node_id_from_mender(): """Read node_id from Mender device identity file (generated by mender-device-identity)""" @@ -157,6 +163,41 @@ def migrate_gain_reduction(config): config['capture']['device']['gainReduction'] = [gain, gain] +def migrate_doppler_span(user): + """Drop a Doppler span the user never chose, so default.yml can change it. + + On first boot the merger seeds user.yml with a whole copy of default.yml, so + every deployed node persists the shipped Doppler span whether or not anyone + selected it. Without this, a change to default.yml could never reach a node + that has already booted once. + + Only the exact legacy pair is dropped, and only when both bounds match: any + other value is a deliberate setting and is left alone. owl runs +/-1000 Hz, + which must survive. A node that genuinely wants +/-200 Hz is indistinguishable + from a first-boot copy, so it moves to the new default and has to be set + again if that is not wanted. + + Dropping the keys rather than rewriting them means whatever default.yml ships + takes effect, including a later change to some other value. user.yml on disk + is untouched, so this stays load-bearing: remove it and nodes fall back to + the +/-200 Hz their overlay still holds. + """ + try: + ambiguity = user['process']['ambiguity'] + except (KeyError, TypeError): + return + + if not isinstance(ambiguity, dict): + return + + if (ambiguity.get('dopplerMin') == LEGACY_DOPPLER_MIN + and ambiguity.get('dopplerMax') == LEGACY_DOPPLER_MAX): + print(f"Dropping first-boot Doppler span ({LEGACY_DOPPLER_MIN}/{LEGACY_DOPPLER_MAX} Hz) " + "from user config so the shipped default applies") + del ambiguity['dopplerMin'] + del ambiguity['dopplerMax'] + + def ensure_node_id(user_config_path): """Add/update node_id in user config from Mender device identity""" try: @@ -253,6 +294,7 @@ def main(): if user: # Only merge if user.yml has actual content print("Applying user overrides...") + migrate_doppler_span(user) merge(config, user) else: print("User config is empty, using defaults") diff --git a/config-merger/test/test_merge_config.py b/config-merger/test/test_merge_config.py index e507ae1..f0d0aba 100755 --- a/config-merger/test/test_merge_config.py +++ b/config-merger/test/test_merge_config.py @@ -617,5 +617,106 @@ def test_retina_tracker_yaml_user_override(self): self.assertEqual(output['tracker']['min_snr'], 4.5) + # --- Doppler span migration ------------------------------------------- + # The merger seeds user.yml from default.yml on first boot, so every node + # persists the shipped span. These cover which of those persisted values + # the merger is allowed to move. + + def default_with_span(self, doppler_min=-300, doppler_max=300): + """A default.yml carrying the shipped ambiguity block.""" + return { + 'process': { + 'ambiguity': { + 'delayMin': -10, + 'delayMax': 400, + 'dopplerMin': doppler_min, + 'dopplerMax': doppler_max, + } + } + } + + def write_span_configs(self, user_ambiguity, forced_config=None): + """Write a default/user/forced set differing only in the ambiguity block.""" + self.write_yaml(os.path.join(self.defaults_dir, 'default.yml'), self.default_with_span()) + self.write_yaml(os.path.join(self.defaults_dir, 'forced.yml'), forced_config or {}) + self.write_yaml(os.path.join(self.config_dir, 'user.yml'), + {'process': {'ambiguity': user_ambiguity}}) + + def test_doppler_span_first_boot_copy_migrated(self): + """A user.yml holding the old shipped span gives way to the new default""" + self.write_span_configs({'delayMin': -10, 'delayMax': 400, + 'dopplerMin': -200, 'dopplerMax': 200}) + + output = self.read_yaml(self.run_merge()) + ambiguity = output['process']['ambiguity'] + + self.assertEqual(ambiguity['dopplerMin'], -300) + self.assertEqual(ambiguity['dopplerMax'], 300) + # Only the Doppler bounds move; the rest of the block is the user's. + self.assertEqual(ambiguity['delayMin'], -10) + self.assertEqual(ambiguity['delayMax'], 400) + + def test_doppler_span_deliberate_override_kept(self): + """A span nobody could have got from a first-boot copy survives""" + self.write_span_configs({'dopplerMin': -1000, 'dopplerMax': 1000}) + + output = self.read_yaml(self.run_merge()) + + self.assertEqual(output['process']['ambiguity']['dopplerMin'], -1000) + self.assertEqual(output['process']['ambiguity']['dopplerMax'], 1000) + + def test_doppler_span_partial_legacy_match_kept(self): + """One legacy bound is not the legacy pair, so neither bound moves""" + self.write_span_configs({'dopplerMin': -200, 'dopplerMax': 1000}) + + output = self.read_yaml(self.run_merge()) + + self.assertEqual(output['process']['ambiguity']['dopplerMin'], -200) + self.assertEqual(output['process']['ambiguity']['dopplerMax'], 1000) + + def test_doppler_span_asymmetric_legacy_value_kept(self): + """An asymmetric span that happens to touch 200 is still deliberate""" + self.write_span_configs({'dopplerMin': -200, 'dopplerMax': 400}) + + output = self.read_yaml(self.run_merge()) + + self.assertEqual(output['process']['ambiguity']['dopplerMin'], -200) + self.assertEqual(output['process']['ambiguity']['dopplerMax'], 400) + + def test_doppler_span_forced_still_wins(self): + """forced.yml keeps the last word over a migrated span""" + self.write_span_configs( + {'dopplerMin': -200, 'dopplerMax': 200}, + forced_config={'process': {'ambiguity': {'dopplerMin': -250, 'dopplerMax': 250}}}, + ) + + output = self.read_yaml(self.run_merge()) + + self.assertEqual(output['process']['ambiguity']['dopplerMin'], -250) + self.assertEqual(output['process']['ambiguity']['dopplerMax'], 250) + + def test_doppler_span_migration_does_not_rewrite_user_yml(self): + """The overlay on disk is untouched, so the migration has to stay in place""" + self.write_span_configs({'dopplerMin': -200, 'dopplerMax': 200}) + + self.run_merge() + + user = self.read_yaml(os.path.join(self.config_dir, 'user.yml')) + self.assertEqual(user['process']['ambiguity']['dopplerMin'], -200) + self.assertEqual(user['process']['ambiguity']['dopplerMax'], 200) + + def test_doppler_span_absent_from_user_config(self): + """A user.yml with no ambiguity block just takes the default""" + self.write_yaml(os.path.join(self.defaults_dir, 'default.yml'), self.default_with_span()) + self.write_yaml(os.path.join(self.defaults_dir, 'forced.yml'), {}) + self.write_yaml(os.path.join(self.config_dir, 'user.yml'), + {'network': {'node_id': 'test-node'}}) + + output = self.read_yaml(self.run_merge()) + + self.assertEqual(output['process']['ambiguity']['dopplerMin'], -300) + self.assertEqual(output['process']['ambiguity']['dopplerMax'], 300) + + if __name__ == '__main__': unittest.main() diff --git a/config/default.yml b/config/default.yml index 50c10b9..798d8e0 100644 --- a/config/default.yml +++ b/config/default.yml @@ -22,8 +22,8 @@ process: ambiguity: delayMin: -10 delayMax: 400 - dopplerMin: -200 - dopplerMax: 200 + dopplerMin: -300 + dopplerMax: 300 clutter: enable: true delayMin: -10