From 760288037f2778e052961a9d36dca119871489e6 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:37:03 +0545 Subject: [PATCH] =?UTF-8?q?fix(gui):=20GUI-S2=20=E2=80=94=20DataRecorder?= =?UTF-8?q?=20snapshots=20arrays=20before=20HDF5=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same RadarFrame is enqueued for the display consumer and handed to DataRecorder.record_frame on the producer thread. h5py releases the GIL during gzip compression, so any in-place mutation by the consumer (or a future scaling/normalisation step) would tear the on-disk frame. record_frame now copies all five numpy arrays into local snapshots before passing them to h5py.create_dataset. Disk integrity no longer depends on consumer behaviour. New test test_record_frame_isolates_from_post_call_mutation asserts that mutating every array in place after record_frame returns leaves the HDF5 contents untouched. Tests: GUI 93/93 (was 92), ruff clean repo-wide. --- 9_Firmware/9_3_GUI/radar_protocol.py | 20 +++++++++---- 9_Firmware/9_3_GUI/test_GUI_V65_Tk.py | 43 +++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/9_Firmware/9_3_GUI/radar_protocol.py b/9_Firmware/9_3_GUI/radar_protocol.py index a3f71b1..76c7342 100644 --- a/9_Firmware/9_3_GUI/radar_protocol.py +++ b/9_Firmware/9_3_GUI/radar_protocol.py @@ -679,16 +679,26 @@ class DataRecorder: def record_frame(self, frame: RadarFrame): if not self._recording or self._file is None: return + # GUI-S2: snapshot the arrays before handing them to h5py. The same + # frame object is also queued for the display consumer, and h5py + # releases the GIL during gzip compression — without this copy, any + # in-place mutation by the consumer (or a future scaling/normalization + # step) would tear the on-disk frame. try: + mag = np.asarray(frame.magnitude).copy() + rdi = np.asarray(frame.range_doppler_i).copy() + rdq = np.asarray(frame.range_doppler_q).copy() + det = np.asarray(frame.detections).copy() + rprf = np.asarray(frame.range_profile).copy() fg = self._grp.create_group(f"frame_{self._frame_count:06d}") fg.attrs["timestamp"] = frame.timestamp fg.attrs["frame_number"] = frame.frame_number fg.attrs["detection_count"] = frame.detection_count - fg.create_dataset("magnitude", data=frame.magnitude, compression="gzip") - fg.create_dataset("range_doppler_i", data=frame.range_doppler_i, compression="gzip") - fg.create_dataset("range_doppler_q", data=frame.range_doppler_q, compression="gzip") - fg.create_dataset("detections", data=frame.detections, compression="gzip") - fg.create_dataset("range_profile", data=frame.range_profile, compression="gzip") + fg.create_dataset("magnitude", data=mag, compression="gzip") + fg.create_dataset("range_doppler_i", data=rdi, compression="gzip") + fg.create_dataset("range_doppler_q", data=rdq, compression="gzip") + fg.create_dataset("detections", data=det, compression="gzip") + fg.create_dataset("range_profile", data=rprf, compression="gzip") self._frame_count += 1 except (OSError, ValueError, TypeError) as e: log.error(f"Recording error: {e}") diff --git a/9_Firmware/9_3_GUI/test_GUI_V65_Tk.py b/9_Firmware/9_3_GUI/test_GUI_V65_Tk.py index beee48b..8e1d0cc 100644 --- a/9_Firmware/9_3_GUI/test_GUI_V65_Tk.py +++ b/9_Firmware/9_3_GUI/test_GUI_V65_Tk.py @@ -425,6 +425,49 @@ class TestDataRecorder(unittest.TestCase): mag = f["frames/frame_000001/magnitude"][:] self.assertEqual(mag.shape, (NUM_RANGE_BINS, NUM_DOPPLER_BINS)) + @unittest.skipUnless( + (lambda: ( + __import__("importlib.util") + and __import__("importlib").util.find_spec("h5py") is not None + ))(), + "h5py not installed" + ) + def test_record_frame_isolates_from_post_call_mutation(self): + # GUI-S2: the recorder must snapshot the frame's arrays so a + # consumer (or future in-place scaling) mutating the same RadarFrame + # after record_frame returns cannot tear the on-disk record. + import h5py + rec = DataRecorder() + rec.start(self.filepath) + + frame = RadarFrame() + frame.frame_number = 42 + frame.timestamp = 123.456 + frame.magnitude = np.full((NUM_RANGE_BINS, NUM_DOPPLER_BINS), 7.0) + frame.range_profile = np.full(NUM_RANGE_BINS, 3.0) + frame.detections[0, 0] = 1 + frame.range_doppler_i[1, 1] = 100 + frame.range_doppler_q[2, 2] = -50 + + rec.record_frame(frame) + + # Mutate every array in place AFTER recording — must not bleed into HDF5. + frame.magnitude.fill(0.0) + frame.range_profile.fill(0.0) + frame.detections.fill(0) + frame.range_doppler_i.fill(0) + frame.range_doppler_q.fill(0) + + rec.stop() + + with h5py.File(self.filepath, "r") as f: + fg = f["frames/frame_000000"] + self.assertTrue(np.all(fg["magnitude"][:] == 7.0)) + self.assertTrue(np.all(fg["range_profile"][:] == 3.0)) + self.assertEqual(fg["detections"][0, 0], 1) + self.assertEqual(fg["range_doppler_i"][1, 1], 100) + self.assertEqual(fg["range_doppler_q"][2, 2], -50) + class TestRadarAcquisition(unittest.TestCase): """Test acquisition thread with mock connection."""