Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions app/backend/api/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
78 changes: 66 additions & 12 deletions app/backend/viewers/postgresql_volume_reader.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import json
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple, Union

Expand Down Expand Up @@ -544,6 +544,9 @@ def _extractDims(self, values: Dict[str, Any]) -> Optional[List[int]]:
"dimensions",
"volumeDim",
"volumeDims",
"xyzDims",
"size",
"_dim",
],
)

Expand All @@ -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):
Expand All @@ -576,6 +581,7 @@ def _extractSamplingRate(self, values: Any) -> Optional[float]:
"voxelSize",
"sampling",
"apix",
"_samplingRate",
],
)
else:
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand Down
73 changes: 39 additions & 34 deletions tests/unit/backend/api/services/test_project_service_fsc.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def failRuntime(**kwargs):
}


def test_GetFscRowsServiceFallsBackToRuntimeWhenPostgresqlReaderHasNoRows(
def test_GetFscRowsServiceRaisesWhenPostgresqlReaderHasNoRowsAndMapperIsPresent(
service,
monkeypatch,
):
Expand All @@ -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 == {
Expand All @@ -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],
}
],
}
assert result["rows"][0]["label"] == "Half maps"
46 changes: 41 additions & 5 deletions tests/unit/backend/viewers/test_postgresql_fsc_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
# *
# ******************************************************************************
import importlib

import pytest

class FakeSetMapper:
def __init__(self, storedSet):
Expand Down Expand Up @@ -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,
},
}
],
}
Expand Down Expand Up @@ -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,
},
}
],
Expand Down Expand Up @@ -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
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)
Loading
Loading