mirror of
https://github.com/NawfalMotii79/PLFM_RADAR.git
synced 2026-06-08 22:47:16 +00:00
fix(gui): GUI-S2 — DataRecorder snapshots arrays before HDF5 write
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.
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user