Merge branch 'ongoing-scan-data-warning' into 'v3'

Stop reading scan_data.json for ongoing scan.

Closes #583

See merge request openflexure/openflexure-microscope-server!426
This commit is contained in:
Julian Stirling 2025-11-04 22:15:59 +00:00
commit 40b3d993ee
4 changed files with 90 additions and 25 deletions

View file

@ -276,11 +276,15 @@ class ScanDirectoryManager:
return [f.name for f in os.scandir(self._base_scan_dir) if f.is_dir()] return [f.name for f in os.scandir(self._base_scan_dir) if f.is_dir()]
@requires_lock @requires_lock
def all_scans_info(self) -> list[ScanInfo]: def all_scans_info(self, ongoing: Optional[str] = None) -> list[ScanInfo]:
"""Return a lists of ScanInfo objects for each scan.""" """Return a lists of ScanInfo objects for each scan."""
all_info: list[ScanInfo] = [] all_info: list[ScanInfo] = []
for scan_name in self.all_scans: for scan_name in self.all_scans:
all_info.append(ScanDirectory(scan_name, self.base_dir).scan_info()) # If the scan is ongoing send flag to skip reading the json data
skip_json = scan_name == ongoing
scan_dir = ScanDirectory(scan_name, self.base_dir)
info = scan_dir.scan_info(skip_json=skip_json)
all_info.append(info)
return all_info return all_info
@requires_lock @requires_lock
@ -504,7 +508,7 @@ class ScanDirectory:
LOGGER.warning(f"Could not validate scan data for {self.name}.") LOGGER.warning(f"Could not validate scan data for {self.name}.")
return None return None
def scan_info(self) -> ScanInfo: def scan_info(self, skip_json: bool = False) -> ScanInfo:
"""Return the information to be used in the UI for the scan.""" """Return the information to be used in the UI for the scan."""
scan_files = self.get_scan_files() scan_files = self.get_scan_files()
scan_images = self._extract_scan_images(scan_files) scan_images = self._extract_scan_images(scan_files)
@ -514,7 +518,7 @@ class ScanDirectory:
stitch_available = len(stitches) > 0 stitch_available = len(stitches) > 0
dzi = None if not dzi_files else str(dzi_files[0]) dzi = None if not dzi_files else str(dzi_files[0])
scan_data = self.get_scan_data() scan_data = None if skip_json else self.get_scan_data()
duration = ( duration = (
None None
if scan_data is None or scan_data.duration is None if scan_data is None or scan_data.duration is None

View file

@ -49,8 +49,8 @@ CSMDep = lt.deps.direct_thing_client_dependency(
AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/")
class ScanListData(BaseModel): class ScanListInfo(BaseModel):
"""The data to be sent to the Scan List tab.""" """The information to be sent to the Scan List tab."""
scans: list[scan_directories.ScanInfo] scans: list[scan_directories.ScanInfo]
"""The list of scans as ScanInfo objects""" """The list of scans as ScanInfo objects"""
@ -588,8 +588,18 @@ class SmartScanThing(lt.Thing):
) )
"""Whether to run a final stitch at the end of a successful scan.""" """Whether to run a final stitch at the end of a successful scan."""
def _get_all_scan_info(self) -> list[scan_directories.ScanInfo]:
"""Return all the information from the scan directories.
It is preferable to use the method rather than calling
_scan_dir_manager.all_scans_info() directly as it will handle stopping the json
in any ongoing scans being read.
"""
ongoing_name = None if self._ongoing_scan is None else self._ongoing_scan.name
return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name)
@lt.thing_property @lt.thing_property
def scans(self) -> ScanListData: def scans(self) -> ScanListInfo:
"""All the available scans. """All the available scans.
Each scan has a name (which can be used to access it), along with Each scan has a name (which can be used to access it), along with
@ -598,8 +608,8 @@ class SmartScanThing(lt.Thing):
uses a regular expression, and changes to the naming scheme will uses a regular expression, and changes to the naming scheme will
break it. break it.
""" """
return ScanListData( return ScanListInfo(
scans=self._scan_dir_manager.all_scans_info(), scans=self._get_all_scan_info(),
ongoing=None if self._ongoing_scan is None else self._ongoing_scan.name, ongoing=None if self._ongoing_scan is None else self._ongoing_scan.name,
) )
@ -667,7 +677,7 @@ class SmartScanThing(lt.Thing):
def purge_empty_scans(self, logger: lt.deps.InvocationLogger) -> None: def purge_empty_scans(self, logger: lt.deps.InvocationLogger) -> None:
"""Delete all scan folders containing no images at the top level.""" """Delete all scan folders containing no images at the top level."""
# JSON is ignored as it's created before any images are captured # JSON is ignored as it's created before any images are captured
for scan_info in self._scan_dir_manager.all_scans_info(): for scan_info in self._get_all_scan_info():
if scan_info.number_of_images == 0: if scan_info.number_of_images == 0:
self._delete_scan(scan_info.name, logger) self._delete_scan(scan_info.name, logger)
@ -791,6 +801,6 @@ class SmartScanThing(lt.Thing):
""" """
if self._scan_logger is not None: if self._scan_logger is not None:
raise RuntimeError("Can't stitch previous scans while a scan is ongoing") raise RuntimeError("Can't stitch previous scans while a scan is ongoing")
for scan in self._scan_dir_manager.all_scans_info(): for scan in self._get_all_scan_info():
if scan.dzi is None: if scan.dzi is None:
self.stitch_scan(logger=logger, cancel=cancel, scan_name=scan.name) self.stitch_scan(logger=logger, cancel=cancel, scan_name=scan.name)

View file

@ -170,15 +170,23 @@ def test_bad_scan_names():
assert scan_dir.name == "fake_scan_rm_-rf____0001" assert scan_dir.name == "fake_scan_rm_-rf____0001"
def test_scan_sequence_and_listing(): def test_scan_sequence_and_listing(caplog):
"""Check created scans are added in order and listed correctly.""" """Check created scans are added in order and listed correctly."""
_clear_scan_dir() _clear_scan_dir()
scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR) scan_dir_manager = ScanDirectoryManager(BASE_SCAN_DIR)
# Create some scan data and mark it as successful to get an end date.
scan_data = _fake_scan_data()
scan_data.set_final_data(result="Success")
# Make 4 scans # Make 4 scans
scan_dir_manager.new_scan_dir("fake_scan") scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
scan_dir_manager.new_scan_dir("fake_scan") scan_dir.save_scan_data(scan_data)
scan_dir_manager.new_scan_dir("fake_scan") scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
scan_dir_manager.new_scan_dir("fake_scan") scan_dir.save_scan_data(scan_data)
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
scan_dir.save_scan_data(scan_data)
scan_dir = scan_dir_manager.new_scan_dir("fake_scan")
scan_dir.save_scan_data(scan_data)
# Check they exist and are numbered sequentially # Check they exist and are numbered sequentially
all_scans = scan_dir_manager.all_scans all_scans = scan_dir_manager.all_scans
@ -188,13 +196,55 @@ def test_scan_sequence_and_listing():
assert "fake_scan_0003" in all_scans assert "fake_scan_0003" in all_scans
assert "fake_scan_0004" in all_scans assert "fake_scan_0004" in all_scans
# Check scan data can be read for all of them # Start capturing warnings
# (more detailed scan_info tests below) with caplog.at_level(logging.WARNING):
all_scan_info = scan_dir_manager.all_scans_info() # Check scan data can be read for all of them
for scan_info in all_scan_info: # (more detailed scan_info tests below)
assert isinstance(scan_info, ScanInfo) all_scan_info = scan_dir_manager.all_scans_info()
assert scan_info.name.startswith("fake_scan_000") for scan_info in all_scan_info:
assert scan_info.number_of_images == 0 assert isinstance(scan_info, ScanInfo)
assert scan_info.name.startswith("fake_scan_000")
assert scan_info.number_of_images == 0
assert scan_info.duration is not None
# There are no warnings or errors
assert len(caplog.records) == 0
# Mess up the JSON of the final scan
with open(scan_dir.scan_data_path, "w", encoding="utf-8") as f:
f.write("Mock JSON")
# Re-read the data with bad data for fake_scan_0004
all_scan_info = scan_dir_manager.all_scans_info()
for scan_info in all_scan_info:
assert isinstance(scan_info, ScanInfo)
assert scan_info.name.startswith("fake_scan_000")
assert scan_info.number_of_images == 0
# Bad data for scan 0004. So duration is None.
if scan_info.name == "fake_scan_0004":
assert scan_info.duration is None
else:
assert scan_info.duration is not None
# This should have warned about the bad data
assert len(caplog.records) == 1
assert (
caplog.records[0].message == "Could not load scan data for fake_scan_0004."
)
# Clear the warning logs
caplog.clear()
# Re-read the data again claiming fake_scan_0004 is ongoing
all_scan_info = scan_dir_manager.all_scans_info(ongoing="fake_scan_0004")
for scan_info in all_scan_info:
assert isinstance(scan_info, ScanInfo)
assert scan_info.name.startswith("fake_scan_000")
assert scan_info.number_of_images == 0
# scan 0004 is marked as ongoing, so duration is None.
if scan_info.name == "fake_scan_0004":
assert scan_info.duration is None
else:
assert scan_info.duration is not None
# This time there is no warning
assert len(caplog.records) == 0
def test_scan_name_non_sequential(): def test_scan_name_non_sequential():

View file

@ -58,8 +58,9 @@
<div class="scan-info"> <div class="scan-info">
<ul> <ul>
<li>{{ scanData.number_of_images }} images</li> <li>{{ scanData.number_of_images }} images</li>
<li>created: {{ formatDate(scanData.created) }}</li> <li>Created: {{ formatDate(scanData.created) }}</li>
<li>duration: {{ formatDuration(scanData.duration) }}</li> <li v-if="!ongoing">Duration: {{ formatDuration(scanData.duration) }}</li>
<li v-if="ongoing">Duration: <i>Ongoing</i></li>
</ul> </ul>
<ul v-if="!ongoing"> <ul v-if="!ongoing">
<li v-if="scanData.number_of_images < 3" class="warning-msg"> <li v-if="scanData.number_of_images < 3" class="warning-msg">