diff --git a/src/snapred/backend/dao/request/RunFeedbackRequest.py b/src/snapred/backend/dao/request/RunFeedbackRequest.py new file mode 100644 index 000000000..a795fa728 --- /dev/null +++ b/src/snapred/backend/dao/request/RunFeedbackRequest.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class RunFeedbackRequest(BaseModel): + runId: str diff --git a/src/snapred/backend/dao/state/DetectorState.py b/src/snapred/backend/dao/state/DetectorState.py index f2a0f743d..cb19450db 100644 --- a/src/snapred/backend/dao/state/DetectorState.py +++ b/src/snapred/backend/dao/state/DetectorState.py @@ -1,6 +1,6 @@ from enum import IntEnum from numbers import Number -from typing import Dict, Literal, Tuple +from typing import Dict, Literal, Optional, Tuple from pydantic import BaseModel, field_validator @@ -17,6 +17,7 @@ class DetectorState(BaseModel): guideStat: Literal[1, 2] # two additional values that don't define state, but are useful lin: Tuple[float, float] + title: Optional[str] = None @field_validator("guideStat", mode="before") @classmethod @@ -31,13 +32,21 @@ def validate_int(cls, v): def fromLogs(cls, logs: Dict[str, Number]): # NeXus/HDF5 and `mantid.api.Run` logs are time-series => # here we take only the first entry in each series. - return DetectorState( + ds = DetectorState( arc=(logs["det_arc1"][0], logs["det_arc2"][0]), lin=(logs["det_lin1"][0], logs["det_lin2"][0]), wav=logs.get("BL3:Chop:Skf1:WavelengthUserReq", logs.get("BL3:Chop:Gbl:WavelengthReq"))[0], freq=logs["BL3:Det:TH:BL:Frequency"][0], guideStat=int(logs["BL3:Mot:OpticsPos:Pos"][0]), ) + if "title" in logs: + raw = logs["title"] + val = raw[0] if hasattr(raw, "__getitem__") else raw + if hasattr(val, "decode"): + val = val.decode("utf-8") + ds.title = str(val) + + return ds def toLogs(self) -> Dict[str, Number]: # NeXus/HDF5 and `mantid.api.Run` logs are time-series => diff --git a/src/snapred/backend/data/util/PV_logs_util.py b/src/snapred/backend/data/util/PV_logs_util.py index 23a712c9f..02fcf1482 100644 --- a/src/snapred/backend/data/util/PV_logs_util.py +++ b/src/snapred/backend/data/util/PV_logs_util.py @@ -118,23 +118,33 @@ def mappingFromNeXusLogs(h5: h5py.File) -> Mapping: class _Mapping(Mapping): def __init__(self, h5: h5py.File): - self._logs = h5[Config["instrument.PVLogs.rootGroup"]] + self._logGroup = h5[Config["instrument.PVLogs.rootGroup"]] + self._h5 = h5 def __getitem__(self, key: str) -> Any: - return self._logs[key + "/value"] + if key == "title": + return self._h5["/entry/title"][...] + else: + return self._logGroup[f"{key}/value"][...] - def __iter__(self): - return self.keys().__iter__() + def __contains__(self, key: str) -> bool: + if key == "title": + return "/entry/title" in self._h5 + return f"{key}/value" in self._logGroup - def __len__( - self, - ): - return len(self._logs.keys()) + def keys(self): + baseKeys = [] + for fullKey in self._logGroup.keys(): + if fullKey.endswith("/value"): + baseKeys.append(fullKey[: fullKey.rfind("/value")]) + if "/entry/title" in self._h5: + baseKeys.append("title") + return baseKeys - def __contains__(self, key: str): - return self._logs.__contains__(key + "/value") + def __iter__(self): + return iter(self.keys()) - def keys(self): - return [k[0 : k.rfind("/value")] for k in self._logs.keys()] + def __len__(self): + return len(self.keys()) return _Mapping(h5) diff --git a/src/snapred/backend/service/CalibrationService.py b/src/snapred/backend/service/CalibrationService.py index 434b24b37..61550e024 100644 --- a/src/snapred/backend/service/CalibrationService.py +++ b/src/snapred/backend/service/CalibrationService.py @@ -32,6 +32,7 @@ LoadCalibrationRecordRequest, MatchRunsRequest, OverrideRequest, + RunFeedbackRequest, SimpleDiffCalRequest, ) from snapred.backend.dao.response.CalibrationAssessmentResponse import CalibrationAssessmentResponse @@ -105,6 +106,7 @@ def __init__(self): self.registerPath("residual", self.calculateResidual) self.registerPath("fetchMatches", self.fetchMatchingCalibrations) self.registerPath("override", self.handleOverrides) + self.registerPath("runFeedback", self.runFeedback) return @staticmethod @@ -603,3 +605,8 @@ def handleOverrides(self, request: OverrideRequest): return sample.overrides else: return None + + @FromString + def runFeedback(self, request: RunFeedbackRequest): + stateId = self.dataFactoryService.constructStateId(request.runId) + return stateId diff --git a/src/snapred/ui/view/DiffCalRequestView.py b/src/snapred/ui/view/DiffCalRequestView.py index 28092a48e..e0d06084f 100644 --- a/src/snapred/ui/view/DiffCalRequestView.py +++ b/src/snapred/ui/view/DiffCalRequestView.py @@ -1,3 +1,5 @@ +from qtpy.QtCore import Signal, Slot + from snapred.meta.decorators.Resettable import Resettable from snapred.meta.mantid.AllowedPeakTypes import SymmetricPeakEnum from snapred.ui.view.BackendRequestView import BackendRequestView @@ -18,19 +20,27 @@ class DiffCalRequestView(BackendRequestView): """ + signalUpdateRunFeedback = Signal(str, str) + def __init__(self, samples=[], groups=[], parent=None): super().__init__(parent=parent) + self.signalUpdateRunFeedback.connect(self._setRunFeedback) + # input fields self.runNumberField = self._labeledField("Run Number") + self.runNumberField.setToolTip("Run number to be calibrated.") self.liteModeToggle = self._labeledToggle("Lite Mode", True) self.fieldConvergenceThreshold = self._labeledField("Convergence Threshold") self.fieldNBinsAcrossPeakWidth = self._labeledField("Bins Across Peak Width") # drop downs self.sampleDropdown = self._sampleDropDown("Sample", samples) + self.sampleDropdown.setToolTip("Samples available for this run number.") self.groupingFileDropdown = self._sampleDropDown("Grouping File", groups) + self.groupingFileDropdown.setToolTip("Grouping schemas available for this sample run number.") self.peakFunctionDropdown = self._sampleDropDown("Peak Function", [p.value for p in SymmetricPeakEnum]) + self.peakFunctionDropdown.setToolTip("Peak function to be used for calibration.") # checkbox for removing background self.removeBackgroundToggle = self._labeledToggle("RemoveBackground", False) @@ -43,23 +53,36 @@ def __init__(self, samples=[], groups=[], parent=None): # skip pixel calibration toggle self.skipPixelCalToggle = self._labeledToggle("Skip Pixel Calibration", False) + # run number feedback fields + self.runFeedbackStateId = self._labeledField("State ID") + self.runFeedbackStateId.setToolTip("State ID of the run number.") + self.runFeedbackRunTitle = self._labeledField("Run Title") + self.runFeedbackRunTitle.setToolTip("Title of the run from PV file.") + + # run feedback fields are read only + self.runFeedbackStateId.field.setReadOnly(True) + self.runFeedbackRunTitle.field.setReadOnly(True) + # add all widgets to layout layout_ = self.layout() layout_.addWidget(self.runNumberField, 0, 0) - layout_.addWidget(self.liteModeToggle, 0, 1) - layout_.addWidget(self.skipPixelCalToggle, 0, 2) - layout_.addWidget(self.fieldConvergenceThreshold, 1, 0) - layout_.addWidget(self.fieldNBinsAcrossPeakWidth, 1, 1) - layout_.addWidget(self.removeBackgroundToggle, 1, 2) - layout_.addWidget(self.sampleDropdown, 2, 0) - layout_.addWidget(self.groupingFileDropdown, 2, 1) - layout_.addWidget(self.peakFunctionDropdown, 2, 2) + layout_.addWidget(self.liteModeToggle, 0, 2) + layout_.addWidget(self.runFeedbackStateId, 1, 0) + layout_.addWidget(self.runFeedbackRunTitle, 1, 1) + layout_.addWidget(self.skipPixelCalToggle, 1, 2) + layout_.addWidget(self.fieldConvergenceThreshold, 2, 0) + layout_.addWidget(self.fieldNBinsAcrossPeakWidth, 2, 1) + layout_.addWidget(self.removeBackgroundToggle, 2, 2) + layout_.addWidget(self.sampleDropdown, 3, 0) + layout_.addWidget(self.groupingFileDropdown, 3, 1) + layout_.addWidget(self.peakFunctionDropdown, 3, 2) def populateGroupingDropdown(self, groups): self.groupingFileDropdown.setItems(groups) def verify(self): if not self.runNumberField.text().isdigit(): + self._setRunFeedback("", "") raise ValueError("Please enter a valid run number") if self.sampleDropdown.currentIndex() < 0: raise ValueError("Please select a sample") @@ -99,3 +122,11 @@ def disablePeakFunction(self): def enablePeakFunction(self): self.peakFunctionDropdown.setEnabled(True) + + def updateRunFeedback(self, stateId: str, runTitle: str): + self.signalUpdateRunFeedback.emit(stateId, runTitle) + + @Slot(str, str) + def _setRunFeedback(self, stateId: str, runTitle: str): + self.runFeedbackStateId.setText(stateId if stateId else "") + self.runFeedbackRunTitle.setText(runTitle if runTitle else "") diff --git a/src/snapred/ui/view/NormalizationRequestView.py b/src/snapred/ui/view/NormalizationRequestView.py index 859d068c2..0348f849f 100644 --- a/src/snapred/ui/view/NormalizationRequestView.py +++ b/src/snapred/ui/view/NormalizationRequestView.py @@ -1,3 +1,5 @@ +from qtpy.QtCore import Signal, Slot + from snapred.meta.decorators.Resettable import Resettable from snapred.ui.view.BackendRequestView import BackendRequestView @@ -15,26 +17,45 @@ class NormalizationRequestView(BackendRequestView): """ + signalUpdateRunFeedback = Signal(str, str) + def __init__(self, samplePaths=[], groups=[], parent=None): super(NormalizationRequestView, self).__init__(parent=parent) + self.signalUpdateRunFeedback.connect(self._setRunFeedback) + # input fields self.runNumberField = self._labeledLineEdit("Run Number:") + self.runNumberField.setToolTip("Vanadium sample run number to be normalized.") self.liteModeToggle = self._labeledToggle("Lite Mode", True) self.backgroundRunNumberField = self._labeledLineEdit("Background Run Number:") - + self.backgroundRunNumberField.setToolTip("Background run number to be subtracted from the sample run.") # drop downs self.sampleDropdown = self._sampleDropDown("Select Sample", samplePaths) + self.sampleDropdown.setToolTip("Samples available for this run number.") self.groupingFileDropdown = self._sampleDropDown("Select Grouping File", groups) + self.groupingFileDropdown.setToolTip("Grouping schemas available for this sample run number.") # set field properties self.liteModeToggle.setEnabled(False) + # run number feedback fields + self.runFeedbackStateId = self._labeledField("State ID") + self.runFeedbackStateId.setToolTip("State ID of the run number.") + self.runFeedbackRunTitle = self._labeledField("Run Title") + self.runFeedbackRunTitle.setToolTip("Title of the run from PV file.") + + # run feedback fields are read only + self.runFeedbackStateId.field.setReadOnly(True) + self.runFeedbackRunTitle.field.setReadOnly(True) + # add all widgets to layout _layout = self.layout() _layout.addWidget(self.runNumberField, 0, 0) - _layout.addWidget(self.liteModeToggle, 0, 1) - _layout.addWidget(self.backgroundRunNumberField, 1, 0) + _layout.addWidget(self.backgroundRunNumberField, 0, 1) + _layout.addWidget(self.liteModeToggle, 0, 2) + _layout.addWidget(self.runFeedbackStateId, 1, 0) + _layout.addWidget(self.runFeedbackRunTitle, 1, 1) _layout.addWidget(self.sampleDropdown, 2, 0) _layout.addWidget(self.groupingFileDropdown, 2, 1) @@ -43,6 +64,7 @@ def populateGroupingDropdown(self, groups): def verify(self): if not self.runNumberField.text().isdigit(): + self._setRunFeedback("", "") raise ValueError("Please enter a valid run number") if not self.backgroundRunNumberField.text().isdigit(): raise ValueError("Please enter a valid background run number") @@ -62,3 +84,11 @@ def setInteractive(self, flag: bool): self.liteModeToggle.setEnabled(flag) self.sampleDropdown.setEnabled(flag) self.groupingFileDropdown.setEnabled(flag) + + def updateRunFeedback(self, stateId: str, runTitle: str): + self.signalUpdateRunFeedback.emit(stateId, runTitle) + + @Slot(str, str) + def _setRunFeedback(self, stateId: str, runTitle: str): + self.runFeedbackStateId.setText(stateId if stateId else "") + self.runFeedbackRunTitle.setText(runTitle if runTitle else "") diff --git a/src/snapred/ui/view/reduction/ReductionRequestView.py b/src/snapred/ui/view/reduction/ReductionRequestView.py index dd1c9fb83..2dbe34472 100644 --- a/src/snapred/ui/view/reduction/ReductionRequestView.py +++ b/src/snapred/ui/view/reduction/ReductionRequestView.py @@ -1,6 +1,6 @@ from abc import abstractmethod from datetime import datetime, timedelta -from typing import Callable, List, Optional +from typing import Callable, List, Optional, Tuple from qtpy.QtCore import Qt, QTimer, Signal, Slot from qtpy.QtGui import QColor @@ -46,6 +46,8 @@ def __init__( self.validateRunNumbers = validateRunNumbers self.getLiveMetadata = getLiveMetadata + self.retrieveRunFeedbackCallback: Optional[Callable[[str], Tuple[str, str]]] = None + self.runNumbers = [] self.pixelMaskDropdown = self._multiSelectDropDown("Select Pixel Mask(s)", []) @@ -78,6 +80,9 @@ def __init__( self.liteModeToggle.stateChanged.connect(self._populatePixelMaskDropdown) self.liveDataToggle.stateChanged.connect(self.liveDataModeChange) + def setRetrieveRunFeedbackCallback(self, cb: Callable[[str], Tuple[str, str]]): + self.retrieveRunFeedbackCallback = cb + @ExceptionToErrLog @Slot(bool) def _populatePixelMaskDropdown(self, useLiteMode: bool): @@ -145,6 +150,8 @@ def getRunNumbers(self): class _RequestView(_RequestViewBase): + signalUpdateRunFeedback = Signal(str, str) + def __init__( self, parent=None, @@ -189,6 +196,8 @@ def __init__( self.enterRunNumberButton.clicked.connect(self.addRunNumber) self.clearButton.clicked.connect(self.clearRunNumbers) + self.signalUpdateRunFeedback.connect(self._setRunFeedback) + @Slot() def addRunNumber(self): try: @@ -201,7 +210,11 @@ def addRunNumber(self): if self.validateRunNumbers is not None: self.validateRunNumbers(noDuplicates) self.runNumbers = noDuplicates - self.updateRunNumberList() + if self.retrieveRunFeedbackCallback: + for rn in runNumberList: + stateId, runTitle = self.retrieveRunFeedbackCallback(rn) + lineText = f"{rn}, StateId = {stateId}, Title = {runTitle}" + self.runNumberDisplay.addItem(lineText) self.runNumberInput.clear() self._populatePixelMaskDropdown(self.useLiteMode()) except ValueError as e: @@ -235,13 +248,22 @@ def parseInputRunNumbers(self) -> List[str]: def updateRunNumberList(self): self.runNumberDisplay.clear() - self.runNumberDisplay.addItems(self.runNumbers) def clearRunNumbers(self): self.runNumbers.clear() self.runNumberDisplay.clear() self.pixelMaskDropdown.setItems([]) + def updateRunFeedback(self, stateId: str, runTitle: str): + self.signalUpdateRunFeedback.emit(stateId, runTitle) + + @Slot(str, str) + def _setRunFeedback(self, stateId: str, runTitle: str): + if not stateId and not runTitle: + return + itemText = f"StateId={stateId}, Title={runTitle}" + self.runNumberDisplay.addItem(itemText) + ### ### Abstract methods: ### @@ -251,12 +273,10 @@ def verify(self): if not runNumbers: raise ValueError("Please enter at least one run number.") if runNumbers != self.runNumbers: - raise ValueError("Unexpected issue verifying run numbers. Please clear and re-enter.") + raise ValueError("Unexpected issue verifying run numbers. Please clear and re-enter.") for runNumber in runNumbers: if not runNumber.isdigit(): - raise ValueError( - "Please enter a valid run number or list of run numbers. (e.g. 46680, 46685, 46686, etc...)" - ) + pass if self.keepUnfocused(): if self.convertUnitsDropdown.currentIndex() < 0: raise ValueError("Please select units to convert to") @@ -628,6 +648,10 @@ def _setLiveDataMode(self, flag: bool): view.liveDataToggle.toggle.setEnabled(True) + def updateRunFeedback(self, stateId: str, runTitle: str): + if not self.liveDataMode(): + self._requestView.updateRunFeedback(stateId, runTitle) + @Slot(LiveMetadata) def updateLiveMetadata(self, data: LiveMetadata): if self.liveDataMode(): diff --git a/src/snapred/ui/workflow/DiffCalWorkflow.py b/src/snapred/ui/workflow/DiffCalWorkflow.py index 75d01fb49..ffd1383ed 100644 --- a/src/snapred/ui/workflow/DiffCalWorkflow.py +++ b/src/snapred/ui/workflow/DiffCalWorkflow.py @@ -17,6 +17,7 @@ FocusSpectraRequest, HasStateRequest, OverrideRequest, + RunFeedbackRequest, SimpleDiffCalRequest, ) from snapred.backend.dao.SNAPResponse import ResponseCode, SNAPResponse @@ -159,10 +160,27 @@ def _populateGroupingDropdown(self): useLiteMode = self._requestView.liteModeToggle.getState() self.__setInteraction(False, "populateGroupDropdown") + + def _onDropdownSuccess(): + self.__setInteraction(True, "populateGroupDropdown") + self._populateRunFeedback(runNumber) + self.workflow.presenter.handleAction( self.handleDropdown, args=(runNumber, useLiteMode), - onSuccess=lambda: self.__setInteraction(True, "populateGroupDropdown"), + onSuccess=_onDropdownSuccess, + ) + + @ExceptionToErrLog + @Slot() + def _populateRunFeedback(self, runNumber: str): + if not runNumber: + self._requestView.updateRunFeedback("", "") + return + self.workflow.presenter.handleAction( + self.handleRunFeedback, + args=(runNumber), + onSuccess=lambda: self.__setInteraction(False, "populateRunFeedback"), ) @ExceptionToErrLog @@ -195,6 +213,16 @@ def handleDropdown(self, runNumber, useLiteMode): self._requestView.populateGroupingDropdown(list(self.focusGroups.keys())) return SNAPResponse(code=ResponseCode.OK) + def handleRunFeedback(self, runNumber): + payload = RunFeedbackRequest( + runId=runNumber, + ) + response = self.request(path="calibration/runFeedback", payload=payload.json()).data + stateId, detectorState = response + runTitle = detectorState.title + self._requestView.updateRunFeedback(stateId, runTitle) + return SNAPResponse(code=ResponseCode.OK) + def handleOverride(self, sampleFile): payload = OverrideRequest(calibrantSamplePath=sampleFile) overrides = self.request(path="calibration/override", payload=payload.json()).data diff --git a/src/snapred/ui/workflow/NormalizationWorkflow.py b/src/snapred/ui/workflow/NormalizationWorkflow.py index 1cfe650d3..4a72f803b 100644 --- a/src/snapred/ui/workflow/NormalizationWorkflow.py +++ b/src/snapred/ui/workflow/NormalizationWorkflow.py @@ -10,6 +10,7 @@ HasStateRequest, NormalizationExportRequest, NormalizationRequest, + RunFeedbackRequest, ) from snapred.backend.dao.request.SmoothDataExcludingPeaksRequest import SmoothDataExcludingPeaksRequest from snapred.backend.dao.SNAPResponse import ResponseCode, SNAPResponse @@ -100,9 +101,26 @@ def _populateGroupingDropdown(self): self.useLiteMode = self._requestView.liteModeToggle.getState() self._setInteractive(False) + + def _onDropdownSuccess(): + self._setInteractive(True) + self._populateRunFeedback(runNumber) + self.workflow.presenter.handleAction( self.handleDropdown, args=(runNumber, self.useLiteMode), + onSuccess=_onDropdownSuccess, + ) + + @ExceptionToErrLog + @Slot() + def _populateRunFeedback(self, runNumber: str): + if not runNumber: + self._requestView.updateRunFeedback("", "") + return + self.workflow.presenter.handleAction( + self.handleRunFeedback, + args=(runNumber), onSuccess=lambda: self._setInteractive(True), ) @@ -123,6 +141,18 @@ def handleDropdown(self, runNumber, useLiteMode): self._requestView.populateGroupingDropdown(list(self.focusGroups.keys())) return SNAPResponse(code=ResponseCode.OK) + def handleRunFeedback(self, runNumber): + if not RunNumberValidator.validateRunNumber(runNumber): + return SNAPResponse(code=ResponseCode.OK) + payload = RunFeedbackRequest( + runId=runNumber, + ) + response = self.request(path="calibration/runFeedback", payload=payload.json()).data + stateId, detectorState = response + runTitle = detectorState.title + self._requestView.updateRunFeedback(stateId, runTitle) + return SNAPResponse(code=ResponseCode.OK) + @EntryExitLogger(logger=logger) @ExceptionToErrLog @Slot() diff --git a/src/snapred/ui/workflow/ReductionWorkflow.py b/src/snapred/ui/workflow/ReductionWorkflow.py index b1e1559a9..61e651419 100644 --- a/src/snapred/ui/workflow/ReductionWorkflow.py +++ b/src/snapred/ui/workflow/ReductionWorkflow.py @@ -10,6 +10,7 @@ MatchRunsRequest, ReductionExportRequest, ReductionRequest, + RunFeedbackRequest, ) from snapred.backend.dao.response.ReductionResponse import ReductionResponse from snapred.backend.dao.SNAPResponse import ResponseCode, SNAPResponse @@ -66,6 +67,9 @@ def __init__(self, parent=None): validateRunNumbers=self._validateRunNumbers, getLiveMetadata=self._getLiveMetadata, ) + + self._reductionRequestView._requestView.setRetrieveRunFeedbackCallback(self.handleRunFeedback) + if not self._hasLiveDataConnection(): # Only enable live-data mode if there is a connection to the listener. self._reductionRequestView.setLiveDataToggleEnabled(False) @@ -174,6 +178,20 @@ def setStatus(self, status: ReductionStatus): self._status = status self._statusUpdate.emit(status) + def handleRunFeedback(self, runNumber: str) -> tuple: + payload = RunFeedbackRequest(runId=runNumber) + response = self.request(path="calibration/runFeedback", payload=payload.json()) + stateId = "" + runTitle = "" + if response.code == ResponseCode.OK: + data = response.data + if isinstance(data, tuple) and len(data) == 2: + stateId = data[0] + detectorState = data[1] + if hasattr(detectorState, "title"): + runTitle = detectorState.title + return (stateId, runTitle) + def _nothing(self, workflowPresenter: WorkflowPresenter): # noqa: ARG002 return SNAPResponse(code=200) diff --git a/tests/integration/test_reduction.py b/tests/integration/test_reduction.py index 8fd1a714e..ca2fbc940 100644 --- a/tests/integration/test_reduction.py +++ b/tests/integration/test_reduction.py @@ -258,7 +258,7 @@ def completionMessageBoxAssert(*args, **kwargs): # noqa: ARG001 _count = requestView._requestView.runNumberDisplay.count() _runNumbers = [requestView._requestView.runNumberDisplay.item(x).text() for x in range(_count)] - assert reductionRunNumber in _runNumbers + assert any(reductionRunNumber in item for item in _runNumbers) self.testSummary.SUCCESS() """ diff --git a/tests/integration/test_workflow_panels_happy_path.py b/tests/integration/test_workflow_panels_happy_path.py index 5566332e4..c53e584e0 100644 --- a/tests/integration/test_workflow_panels_happy_path.py +++ b/tests/integration/test_workflow_panels_happy_path.py @@ -440,7 +440,7 @@ def test_calibration_and_reduction_panels_happy_path( _count = requestView.runNumberDisplay.count() _runNumbers = [requestView.runNumberDisplay.item(x).text() for x in range(_count)] - assert reductionRunNumber in _runNumbers + assert any(reductionRunNumber in item for item in _runNumbers) """ request.liteModeToggle.setState(True); @@ -1012,7 +1012,7 @@ def completionMessageBoxAssert(*args, **kwargs): # noqa: ARG001 _count = requestView._requestView.runNumberDisplay.count() _runNumbers = [requestView._requestView.runNumberDisplay.item(x).text() for x in range(_count)] - assert reductionRunNumber in _runNumbers + assert any(reductionRunNumber in item for item in _runNumbers) self.testSummary.SUCCESS() """ request.liteModeToggle.setState(True); diff --git a/tests/unit/backend/data/test_LocalDataService.py b/tests/unit/backend/data/test_LocalDataService.py index e5c8370f1..123307d20 100644 --- a/tests/unit/backend/data/test_LocalDataService.py +++ b/tests/unit/backend/data/test_LocalDataService.py @@ -102,6 +102,12 @@ def mockDetectorState(runId: str) -> DetectorState: return None +def mockH5Dataset(value): + dset = mock.MagicMock(spec=h5py.Dataset) + dset.__getitem__.side_effect = lambda x: value # noqa: ARG005 + return dset + + def mockPVFile(detectorState: DetectorState) -> mock.Mock: # See also: `tests/unit/backend/data/util/test_PV_logs_util.py`. @@ -110,32 +116,26 @@ def mockPVFile(detectorState: DetectorState) -> mock.Mock: # For the HDF5-file, each key requires the "/value" suffix. dict_ = { - "run_number/value": "123456", - "start_time/value": "2023-06-14T14:06:40.429048667", - "end_time/value": "2023-06-14T14:07:56.123123123", - "BL3:Chop:Skf1:WavelengthUserReq/value": [detectorState.wav], - "det_arc1/value": [detectorState.arc[0]], - "det_arc2/value": [detectorState.arc[1]], - "BL3:Det:TH:BL:Frequency/value": [detectorState.freq], - "BL3:Mot:OpticsPos:Pos/value": [detectorState.guideStat], - "det_lin1/value": [detectorState.lin[0]], - "det_lin2/value": [detectorState.lin[1]], + "run_number/value": np.array([123456], dtype=int), + "start_time/value": np.array(["2023-06-14T14:06:40.429048667"]), + "end_time/value": np.array(["2023-06-14T14:07:56.123123123"]), + "BL3:Chop:Skf1:WavelengthUserReq/value": mockH5Dataset(np.array([detectorState.wav])), + "det_arc1/value": np.array([detectorState.arc[0]]), + "det_arc2/value": np.array([detectorState.arc[1]]), + "BL3:Det:TH:BL:Frequency/value": np.array([detectorState.freq]), + "BL3:Mot:OpticsPos:Pos/value": np.array([detectorState.guideStat]), + "det_lin1/value": np.array([detectorState.lin[0]]), + "det_lin2/value": np.array([detectorState.lin[1]]), } - def del_item(key: str): - # bypass .__delitem__ - del dict_[key] - mock_ = mock.MagicMock(spec=h5py.Group) - mock_.get = lambda key, default=None: dict_.get(key, default) - mock_.del_item = del_item - - # Use of the h5py.File starts with access to the "entry/DASlogs" group: - mock_.__getitem__.side_effect = lambda key: mock_ if key == "entry/DASlogs" else dict_[key] + def side_effect_getitem(key: str): + if key == "entry/DASlogs": + return mock_ + return dict_[key] - mock_.__contains__.side_effect = dict_.__contains__ - mock_.keys.side_effect = dict_.keys + mock_.__getitem__.side_effect = side_effect_getitem return mock_ diff --git a/tests/unit/backend/data/util/test_PV_logs_util.py b/tests/unit/backend/data/util/test_PV_logs_util.py index 42bd38ab4..06ee5eecb 100644 --- a/tests/unit/backend/data/util/test_PV_logs_util.py +++ b/tests/unit/backend/data/util/test_PV_logs_util.py @@ -216,32 +216,34 @@ def test_keys(self, mockKeys): class TestMappingFromNeXusLogs(unittest.TestCase): - def _mockPVFile(self, detectorState: DetectorState) -> mock.Mock: - # Note: `PV_logs_util.mappingFromNeXusLogs` will open the 'entry/DASlogs' group, - # so this `dict` mocks the HDF5 group, not the PV-file itself. + def _mockPVFile(self, detectorState: DetectorState, hasTitle: bool) -> mock.Mock: + def _mockH5Dataset(array_value): + ds = mock.MagicMock(spec=h5py.Dataset) + ds.__getitem__.side_effect = lambda x: array_value # noqa: ARG005 + ds.shape = array_value.shape + ds.dtype = array_value.dtype + return ds dict_ = { - "BL3:Chop:Skf1:WavelengthUserReq/value": [detectorState.wav], - "det_arc1/value": [detectorState.arc[0]], - "det_arc2/value": [detectorState.arc[1]], - "BL3:Det:TH:BL:Frequency/value": [detectorState.freq], - "BL3:Mot:OpticsPos:Pos/value": [detectorState.guideStat], - "det_lin1/value": [detectorState.lin[0]], - "det_lin2/value": [detectorState.lin[1]], + "BL3:Chop:Skf1:WavelengthUserReq/value": _mockH5Dataset(np.array([detectorState.wav])), + "det_arc1/value": np.array([detectorState.arc[0]]), + "det_arc2/value": np.array([detectorState.arc[1]]), + "BL3:Det:TH:BL:Frequency/value": np.array([detectorState.freq]), + "BL3:Mot:OpticsPos:Pos/value": np.array([detectorState.guideStat]), + "det_lin1/value": np.array([detectorState.lin[0]]), + "det_lin2/value": np.array([detectorState.lin[1]]), } - - def del_item(key: str): - # bypass .__delitem__ - del dict_[key] - + if hasTitle: + dict_["/entry/title"] = _mockH5Dataset(np.array([b"MyTestTitle"])) mock_ = mock.MagicMock(spec=h5py.Group) - mock_.get = lambda key, default=None: dict_.get(key, default) - mock_.del_item = del_item - - # Use of the h5py.File starts with access to the "entry/DASlogs" group: - mock_.__getitem__.side_effect = lambda key: mock_ if key == "entry/DASlogs" else dict_[key] + def side_effect_getitem(key: str): + if key == "entry/DASlogs": + return mock_ + else: + return dict_[key] + mock_.__getitem__.side_effect = side_effect_getitem mock_.__setitem__.side_effect = dict_.__setitem__ mock_.__contains__.side_effect = dict_.__contains__ mock_.keys.side_effect = dict_.keys @@ -249,18 +251,40 @@ def del_item(key: str): def setUp(self): self.detectorState = DetectorState(arc=(1.0, 2.0), wav=1.1, freq=1.2, guideStat=1, lin=(1.0, 2.0)) - self.mockPVFile = self._mockPVFile(self.detectorState) + self.mockPVFile = self._mockPVFile(self.detectorState, hasTitle=False) def tearDown(self): pass def test_init(self): - map_ = mappingFromNeXusLogs(self.mockPVFile) # noqa: F841 + mockFile = self._mockPVFile(self.detectorState, hasTitle=False) + map_ = mappingFromNeXusLogs(mockFile) # noqa: F841 def test_get_item(self): - map_ = mappingFromNeXusLogs(self.mockPVFile) + mockFile = self._mockPVFile(self.detectorState, hasTitle=False) + map_ = mappingFromNeXusLogs(mockFile) assert map_["BL3:Chop:Skf1:WavelengthUserReq"][0] == 1.1 + def test_get_item_title(self): + mockFile = self._mockPVFile(self.detectorState, hasTitle=True) + map_ = mappingFromNeXusLogs(mockFile) + assert "title" in map_ + titleValue = map_["title"] + assert titleValue[0] == b"MyTestTitle" + + def test_keys_includes_title(self): + mockFile = self._mockPVFile(self.detectorState, hasTitle=True) + map_ = mappingFromNeXusLogs(mockFile) + keys_ = map_.keys() + assert "title" in keys_ + for expected in [ + "BL3:Chop:Skf1:WavelengthUserReq", + "det_arc1", + "det_arc2", + # etc + ]: + assert expected in keys_ + def test_get_item_key_error(self): map_ = mappingFromNeXusLogs(self.mockPVFile) with pytest.raises(KeyError, match="something else"): diff --git a/tests/util_tests/test_state_helpers.py b/tests/util_tests/test_state_helpers.py index 75475a32c..a842ba702 100644 --- a/tests/util_tests/test_state_helpers.py +++ b/tests/util_tests/test_state_helpers.py @@ -5,6 +5,7 @@ from shutil import rmtree import h5py +import numpy as np import pytest ## @@ -31,39 +32,32 @@ def _cleanup_directories(): def mockPVFile(detectorState: DetectorState) -> mock.Mock: - # See also: `tests/unit/backend/data/util/test_PV_logs_util.py`. - - # Note: `PV_logs_util.mappingFromNeXusLogs` will open the 'entry/DASlogs' group, - # so this `dict` mocks the HDF5 group, not the PV-file itself. - - # For the HDF5-file, each key requires the "/value" suffix. dict_ = { - "run_number/value": "123456", - "start_time/value": "2023-06-14T14:06:40.429048667", - "end_time/value": "2023-06-14T14:07:56.123123123", - "BL3:Chop:Skf1:WavelengthUserReq/value": [detectorState.wav], - "det_arc1/value": [detectorState.arc[0]], - "det_arc2/value": [detectorState.arc[1]], - "BL3:Det:TH:BL:Frequency/value": [detectorState.freq], - "BL3:Mot:OpticsPos:Pos/value": [detectorState.guideStat], - "det_lin1/value": [detectorState.lin[0]], - "det_lin2/value": [detectorState.lin[1]], + "run_number/value": np.array(["123456"], dtype="S6"), + "start_time/value": np.array(["2023-06-14T14:06:40.429048667"], dtype="S30"), + "end_time/value": np.array(["2023-06-14T14:07:56.123123123"], dtype="S30"), + "BL3:Chop:Skf1:WavelengthUserReq/value": np.array([detectorState.wav]), + "det_arc1/value": np.array([detectorState.arc[0]]), + "det_arc2/value": np.array([detectorState.arc[1]]), + "BL3:Det:TH:BL:Frequency/value": np.array([detectorState.freq]), + "BL3:Mot:OpticsPos:Pos/value": np.array([detectorState.guideStat]), + "det_lin1/value": np.array([detectorState.lin[0]]), + "det_lin2/value": np.array([detectorState.lin[1]]), } - def del_item(key: str): - # bypass .__delitem__ - del dict_[key] - mock_ = mock.MagicMock(spec=h5py.Group) - mock_.get = lambda key, default=None: dict_.get(key, default) - mock_.del_item = del_item - - # Use of the h5py.File starts with access to the "entry/DASlogs" group: - mock_.__getitem__.side_effect = lambda key: mock_ if key == "entry/DASlogs" else dict_[key] + # Return either the mock group itself or the relevant dataset + def side_effect_getitem(key: str): + if key == "entry/DASlogs": + return mock_ + return dict_[key] + mock_.__getitem__.side_effect = side_effect_getitem + # Typically you don’t need to override __setitem__ or .get unless your test calls them mock_.__contains__.side_effect = dict_.__contains__ mock_.keys.side_effect = dict_.keys + return mock_