diff --git a/app/backend/api/services/project_service.py b/app/backend/api/services/project_service.py index 92413fd8..328697a3 100644 --- a/app/backend/api/services/project_service.py +++ b/app/backend/api/services/project_service.py @@ -11985,11 +11985,14 @@ def getFscRowsService( getattr(pgReader, "lastSkipReason", None), ) - self._ensureRuntimeProjectForFscRows( - mapper=mapper, - projectId=projectId, - currentUser=currentUser, - ) + if mapper is not None: + self._raisePostgresqlViewerUnavailable( + viewerName="FSC", + projectId=projectId, + protocolId=protocolId, + outputName=outputName, + reason=getattr(pgReader, "lastSkipReason", None) if pgReader is not None else "reader_not_available", + ) protocol = self._getScipionProtocolForRuntime( mapper=mapper, diff --git a/app/backend/viewers/postgresql_volume_reader.py b/app/backend/viewers/postgresql_volume_reader.py index 068e5ba1..df636ce9 100644 --- a/app/backend/viewers/postgresql_volume_reader.py +++ b/app/backend/viewers/postgresql_volume_reader.py @@ -1,5 +1,5 @@ import json -import os +import re from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple, Union @@ -544,6 +544,9 @@ def _extractDims(self, values: Dict[str, Any]) -> Optional[List[int]]: "dimensions", "volumeDim", "volumeDims", + "xyzDims", + "size", + "_dim", ], ) @@ -555,16 +558,18 @@ def _extractDims(self, values: Dict[str, Any]) -> Optional[List[int]]: parsed.get("z") or parsed.get("Z") or parsed.get("nz"), ] - if isinstance(parsed, (list, tuple)) and len(parsed) >= 3: - dims = [] - for value in parsed[:3]: - intValue = self._toOptionalInt(value) - if intValue is None: - return None - dims.append(intValue) - return dims + numbers = self._parseNumericSequence(parsed) + if numbers is None or len(numbers) < 3: + return None - return None + dims: List[int] = [] + for value in numbers[:3]: + intValue = self._toOptionalInt(value) + if intValue is None or intValue <= 0: + return None + dims.append(intValue) + + return dims def _extractSamplingRate(self, values: Any) -> Optional[float]: if isinstance(values, dict): @@ -576,6 +581,7 @@ def _extractSamplingRate(self, values: Any) -> Optional[float]: "voxelSize", "sampling", "apix", + "_samplingRate", ], ) else: @@ -591,8 +597,9 @@ def _extractSamplingRate(self, values: Any) -> Optional[float]: return number return None - if isinstance(parsed, (list, tuple)) and parsed: - return self._toOptionalFloat(parsed[0]) + numbers = self._parseNumericSequence(parsed) + if numbers: + return self._toOptionalFloat(numbers[0]) return self._toOptionalFloat(parsed) @@ -707,6 +714,53 @@ def _normalizeJsonObject(self, value: Any) -> Dict[str, Any]: return parsed return {} + def _parseNumericSequence(self, value: Any) -> Optional[List[float]]: + parsed = self._parseJsonValue(value) + + if parsed is None or parsed == "": + return None + + if isinstance(parsed, np.ndarray): + rawValues = parsed.ravel().tolist() + elif isinstance(parsed, (list, tuple)): + rawValues = list(parsed) + elif isinstance(parsed, str): + text = parsed.strip() + if not text: + return None + + cleaned = text.strip("[]()") + if not cleaned: + return None + + # Accept: + # "128,128,64" + # "128 128 64" + # "128;128;64" + # "128x128x64" + tokens = [ + token.strip() + for token in re.split(r"[\s,;xX]+", cleaned) + if token.strip() + ] + rawValues = tokens + else: + rawValues = [parsed] + + values: List[float] = [] + for rawValue in rawValues: + try: + number = float(rawValue) + except Exception: + return None + + if not np.isfinite(number): + return None + + values.append(number) + + return values or None + def _parseJsonValue(self, value: Any) -> Any: if isinstance(value, (dict, list, tuple)): return value diff --git a/tests/unit/backend/api/services/test_project_service_fsc.py b/tests/unit/backend/api/services/test_project_service_fsc.py index ba7cded3..006885ef 100644 --- a/tests/unit/backend/api/services/test_project_service_fsc.py +++ b/tests/unit/backend/api/services/test_project_service_fsc.py @@ -136,7 +136,7 @@ def failRuntime(**kwargs): } -def test_GetFscRowsServiceFallsBackToRuntimeWhenPostgresqlReaderHasNoRows( +def test_GetFscRowsServiceRaisesWhenPostgresqlReaderHasNoRowsAndMapperIsPresent( service, monkeypatch, ): @@ -146,32 +146,39 @@ class FakePgReader: def getFscRows(self): return None - fscOutput = FakeFscOutput(items=[FakeFsc(label="Half maps")]) - protocol = FakeProtocol("outputFsc", fscOutput) - mapper = object() - calls = {} - monkeypatch.setattr( service, "_getPostgresqlFscReaderIfAvailable", lambda **kwargs: FakePgReader(), ) - def fakeRuntimeProtocol(**kwargs): - calls.update(kwargs) - return protocol + def failRuntime(**kwargs): + raise AssertionError("runtime should not be used when mapper is present") - monkeypatch.setattr( - service, - "_getScipionProtocolForRuntime", - fakeRuntimeProtocol, - ) + monkeypatch.setattr(service, "_getScipionProtocolForRuntime", failRuntime) + + with pytest.raises(HTTPException) as exc: + service.getFscRowsService( + projectId=1, + protocolId=10, + outputName="outputFsc", + mapper=object(), + ) + + assert exc.value.status_code == 404 + assert "FSC output is not available in PostgreSQL metadata" in exc.value.detail + assert "fsc_rows_not_found" in exc.value.detail + + +def test_GetFscRowsServiceBuildsRowsWithoutMapper(service): + fscOutput = FakeFscOutput(items=[FakeFsc(label="Half maps")]) + protocol = FakeProtocol("outputFsc", fscOutput) + service.currentProject = FakeCurrentProject(protocol) result = service.getFscRowsService( projectId=1, protocolId=10, outputName="outputFsc", - mapper=mapper, ) assert result == { @@ -186,32 +193,30 @@ def fakeRuntimeProtocol(**kwargs): ], } - assert calls == { - "mapper": mapper, - "projectId": 1, - "protocolId": 10, - } - -def test_GetFscRowsServiceBuildsRowsWithoutMapper(service): +def test_GetFscRowsServiceUsesRuntimeWhenMapperIsMissing( + service, + monkeypatch, +): fscOutput = FakeFscOutput(items=[FakeFsc(label="Half maps")]) protocol = FakeProtocol("outputFsc", fscOutput) - service.currentProject = FakeCurrentProject(protocol) + + monkeypatch.setattr( + service, + "_getPostgresqlFscReaderIfAvailable", + lambda **kwargs: None, + ) + monkeypatch.setattr( + service, + "_getScipionProtocolForRuntime", + lambda **kwargs: protocol, + ) result = service.getFscRowsService( projectId=1, protocolId=10, outputName="outputFsc", + mapper=None, ) - assert result == { - "threshold": 0.143, - "rows": [ - { - "label": "Half maps", - "resolution": 3.5, - "x": [0.01, 0.02, 0.03], - "y": [0.9, 0.5, 0.1], - } - ], - } \ No newline at end of file + assert result["rows"][0]["label"] == "Half maps" \ No newline at end of file diff --git a/tests/unit/backend/viewers/test_postgresql_fsc_reader.py b/tests/unit/backend/viewers/test_postgresql_fsc_reader.py index 2d24ffed..6d8d7236 100644 --- a/tests/unit/backend/viewers/test_postgresql_fsc_reader.py +++ b/tests/unit/backend/viewers/test_postgresql_fsc_reader.py @@ -24,7 +24,7 @@ # * # ****************************************************************************** import importlib - +import pytest class FakeSetMapper: def __init__(self, storedSet): @@ -57,9 +57,10 @@ def test_PostgresqlFscReaderBuildsRowsFromExplicitXY(authTestEnv): "scipionItemId": 101, "label": "Half maps", "values": { - "x": [0.01, 0.02, 0.03], - "y": [0.9, 0.5, 0.1], - }, + "x": [0.01, 0.02, 0.03], + "y": [0.9, 0.5, 0.1], + "resolution": 3.5, + }, } ], } @@ -178,6 +179,7 @@ def test_PostgresqlFscReaderBuildsRowsFromCommaSeparatedXYStrings(authTestEnv): "values": { "x": "0.01,0.02,0.03", "y": "0.9,0.5,0.1", + "resolution": 3.5, }, } ], @@ -239,4 +241,38 @@ def test_PostgresqlFscReaderBuildsRowsFromDelimitedDataText(authTestEnv): assert result["rows"][0]["label"] == "FSC text" assert result["rows"][0]["x"] == [0.01, 0.02, 0.03] assert result["rows"][0]["y"] == [0.9, 0.5, 0.1] - assert result["rows"][0]["resolution"] == 3.5 \ No newline at end of file + assert result["rows"][0]["resolution"] == 3.5 + + +def test_PostgresqlFscReaderEstimatesResolutionWhenMissing(authTestEnv): + module = importlib.import_module("app.backend.viewers.postgresql_fsc_reader") + + storedSet = { + "id": 1, + "setClassName": "SetOfFSCs", + "itemClassName": "FSC", + "items": [ + { + "id": 10, + "scipionItemId": 101, + "label": "Half maps", + "values": { + "x": "0.01,0.02,0.03", + "y": "0.9,0.5,0.1", + }, + } + ], + } + + reader = module.PostgresqlFscReader( + db=object(), + projectId=1, + protocolId=500, + outputName="outputFsc", + ) + reader.setMapper = FakeSetMapper(storedSet) + + result = reader.getFscRows() + + assert result is not None + assert result["rows"][0]["resolution"] == pytest.approx(34.57216940363008) \ No newline at end of file diff --git a/tests/unit/backend/viewers/test_postgresql_volume_reader.py b/tests/unit/backend/viewers/test_postgresql_volume_reader.py new file mode 100644 index 00000000..e5ebca33 --- /dev/null +++ b/tests/unit/backend/viewers/test_postgresql_volume_reader.py @@ -0,0 +1,84 @@ +# ****************************************************************************** +# * +# * Authors: Yunior C. Fonseca Reyna +# * +# * Unidad de Bioinformatica of Centro Nacional de Biotecnologia , CSIC +# * +# * This program is free software; you can redistribute it and/or modify +# * it under the terms of the GNU General Public License as published by +# * the Free Software Foundation; either version 3 of the License, or +# * (at your option) any later version. +# * +# * This program is distributed in the hope that it will be useful, +# * but WITHOUT ANY WARRANTY; without even the implied warranty of +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# * GNU General Public License for more details. +# * +# * You should have received a copy of the GNU General Public License +# * along with this program; if not, write to the Free Software +# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA +# * 02111-1307 USA +# * +# * All comments concerning this program package may be sent to the +# * e-mail address 'scipion@cnb.csic.es' +# * +# ****************************************************************************** +import importlib + +def test_PostgresqlVolumeReaderExtractsDimsFromCommaSeparatedString(authTestEnv): + module = importlib.import_module("app.backend.viewers.postgresql_volume_reader") + + reader = module.PostgresqlVolumeReader( + db=object(), + projectId=1, + protocolId=500, + outputName="outputVolumes", + ) + + assert reader._extractDims({"_dim": "128,128,64"}) == [128, 128, 64] + assert reader._extractDims({"dimensions": "128 128 64"}) == [128, 128, 64] + assert reader._extractDims({"volumeDims": "128x128x64"}) == [128, 128, 64] + + +def test_PostgresqlVolumeReaderExtractsDimsFromJsonString(authTestEnv): + module = importlib.import_module("app.backend.viewers.postgresql_volume_reader") + + reader = module.PostgresqlVolumeReader( + db=object(), + projectId=1, + protocolId=500, + outputName="outputVolumes", + ) + + assert reader._extractDims({"dims": "[32, 48, 64]"}) == [32, 48, 64] + assert reader._extractDims({"dims": '{"x": 32, "y": 48, "z": 64}'}) == [32, 48, 64] + + +def test_PostgresqlVolumeReaderExtractsSamplingRateFromFlatStrings(authTestEnv): + module = importlib.import_module("app.backend.viewers.postgresql_volume_reader") + + reader = module.PostgresqlVolumeReader( + db=object(), + projectId=1, + protocolId=500, + outputName="outputVolumes", + ) + + assert reader._extractSamplingRate({"samplingRate": "1.25"}) == 1.25 + assert reader._extractSamplingRate({"voxelSize": "1.5,1.5,1.5"}) == 1.5 + assert reader._extractSamplingRate({"pixelSize": "2.0 2.0 2.0"}) == 2.0 + + +def test_PostgresqlVolumeReaderRejectsInvalidDims(authTestEnv): + module = importlib.import_module("app.backend.viewers.postgresql_volume_reader") + + reader = module.PostgresqlVolumeReader( + db=object(), + projectId=1, + protocolId=500, + outputName="outputVolumes", + ) + + assert reader._extractDims({"dims": "128,foo,64"}) is None + assert reader._extractDims({"dims": "128,64"}) is None + assert reader._extractDims({"dims": "128,0,64"}) is None \ No newline at end of file