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
1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type HistoryEntry = {
inputs: Record<string, unknown>;
status: "success" | "error" | "cancelled";
message: string;
details?: string | null;
};

export async function fetchHistory(): Promise<HistoryEntry[]> {
Expand Down
36 changes: 28 additions & 8 deletions frontend/src/components/HistoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,34 @@ export function HistoryPage() {
<TableRow>
<TableCell colSpan={5} sx={{ py: 0, borderBottom: 0 }}>
<Collapse in={isOpen} timeout="auto" unmountOnExit>
<Box
sx={{
py: 1.5,
whiteSpace: "pre-wrap",
fontFamily: "monospace",
}}
>
{entry.message}
<Box sx={{ py: 1.5 }}>
<Box
sx={{
whiteSpace: "pre-wrap",
fontFamily: "monospace",
}}
>
{entry.message}
</Box>
{entry.details && (
<Paper
variant="outlined"
sx={{
p: 2,
mt: 1.5,
maxHeight: 360,
overflow: "auto",
fontFamily: "monospace",
fontSize: 13,
whiteSpace: "pre-wrap",
}}
>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Details
</Typography>
{entry.details}
</Paper>
)}
</Box>
</Collapse>
</TableCell>
Expand Down
25 changes: 23 additions & 2 deletions frontend/src/components/ProgressView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type StreamEvent =
caption: string | null;
timestamp: string;
}
| { type: "error"; message: string }
| { type: "error"; message: string; details: string }
| { type: "done"; message: string }
| { type: "cancelled"; message: string };

Expand Down Expand Up @@ -53,6 +53,7 @@ export function ProgressView({
const [done, setDone] = useState<string | null>(null);
const [cancelled, setCancelled] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorDetails, setErrorDetails] = useState<string | null>(null);
const [cancelPending, setCancelPending] = useState(false);
const [cancelError, setCancelError] = useState<string | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -98,6 +99,7 @@ export function ProgressView({
});
} else if (ev.type === "error") {
setError(ev.message);
setErrorDetails(ev.details);
es.close();
} else if (ev.type === "cancelled") {
setCancelled(ev.message);
Expand Down Expand Up @@ -137,10 +139,29 @@ export function ProgressView({
{percent}%
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
<Alert severity="error" sx={{ mb: 2, whiteSpace: "pre-wrap" }}>
{error}
</Alert>
)}
{errorDetails && (
<Paper
variant="outlined"
sx={{
p: 2,
mb: 2,
maxHeight: 360,
overflow: "auto",
fontFamily: "monospace",
fontSize: 13,
whiteSpace: "pre-wrap",
}}
>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Details
</Typography>
{errorDetails}
</Paper>
)}
{cancelled && (
<Alert
severity="warning"
Expand Down
11 changes: 1 addition & 10 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,6 @@ if [ -z "$WHEEL_URL" ]; then
exit 1
fi

TMPDIR="$(mktemp -d)"
cleanup() {
rm -rf "$TMPDIR"
}
trap cleanup EXIT

WHEEL_PATH="${TMPDIR}/$(basename "$WHEEL_URL")"
curl -fsSL "$WHEEL_URL" -o "$WHEEL_PATH"

uv tool install --force --from "$WHEEL_PATH" "$TOOL_NAME"
uv tool install --force --from "$WHEEL_URL" "$TOOL_NAME"

echo "${TOOL_NAME} installed from ${TAG}"
127 changes: 127 additions & 0 deletions tests/test_geometry_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from unittest.mock import Mock, patch

import pytest

from uploader.app.structured.geometry.upload import upload_geometry_isophotal
from uploader.clients.gen.client import adminapi


def _mock_storage(total: int = 1) -> Mock:
storage = Mock()
storage.query.return_value = [{"cnt": total}]
return storage


def _mock_client() -> Mock:
return Mock(spec=adminapi.AuthenticatedClient)


def _base_expressions() -> dict[str, str]:
return {
"a": '3 * 10 ** col("logd25") * arcsec',
"e_a": '3 * 10 ** col("logd25") * 2.302585093 * e_logd25 * arcsec',
"b": '3 * 10 ** (col("logd25") - col("logr25")) * arcsec',
"e_b": (
'3 * 10 ** (col("logd25") - col("logr25")) * 2.302585093 '
'* (col("e_logd25") ** 2 + col("e_logr25") ** 2) ** 0.5 * arcsec'
),
"isophote": 'col("bri25")',
}


@patch("uploader.app.structured.geometry.upload.rawdata_batches")
@patch("uploader.app.structured.geometry.upload._fetch_column_units")
def test_isophote_unit_conversion_error_includes_field_details(
mock_fetch_column_units: Mock,
mock_rawdata_batches: Mock,
) -> None:
mock_fetch_column_units.return_value = (
{"logd25", "logr25", "e_logd25", "e_logr25", "bri25"},
{
"logd25": "",
"logr25": "",
"e_logd25": "",
"e_logr25": "",
"bri25": "",
},
)
mock_rawdata_batches.return_value = iter(
[
[
{
"hyperleda_internal_id": "000079ce-5ffd-82c6-3f75-3a083f0fde80",
"logd25": 1.5,
"logr25": 0.3,
"e_logd25": 0.05,
"e_logr25": 0.04,
"bri25": 25.0,
},
],
],
)

with pytest.raises(RuntimeError, match="failed to evaluate expressions for row") as exc_info:
upload_geometry_isophotal(
_mock_storage(),
"test_table",
"B",
_base_expressions(),
100,
_mock_client(),
report_func=lambda _: None,
)

message = str(exc_info.value)
assert "isophote" in message
assert 'col("bri25")' in message
assert "mag/arcmin2" in message
assert "columns: bri25=''" in message


@patch("uploader.app.structured.geometry.upload.rawdata_batches")
@patch("uploader.app.structured.geometry.upload._fetch_column_units")
def test_constant_isophote_unit_error_omits_empty_columns(
mock_fetch_column_units: Mock,
mock_rawdata_batches: Mock,
) -> None:
mock_fetch_column_units.return_value = (
{"logd25", "logr25", "e_logd25", "e_logr25"},
{
"logd25": "",
"logr25": "",
"e_logd25": "",
"e_logr25": "",
},
)
mock_rawdata_batches.return_value = iter(
[
[
{
"hyperleda_internal_id": "000079ce-5ffd-82c6-3f75-3a083f0fde80",
"logd25": 1.5,
"logr25": 0.3,
"e_logd25": 0.05,
"e_logr25": 0.04,
},
],
],
)
expressions = _base_expressions()
expressions["isophote"] = "22"

with pytest.raises(RuntimeError, match="failed to evaluate expressions for row") as exc_info:
upload_geometry_isophotal(
_mock_storage(),
"test_table",
"B",
expressions,
100,
_mock_client(),
report_func=lambda _: None,
)

message = str(exc_info.value)
assert "isophote" in message
assert "('22')" in message
assert "from dimensionless to mag/arcmin2" in message
assert "columns:" not in message
1 change: 1 addition & 0 deletions uploader/app/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class DoneEvent:
@dataclass(frozen=True)
class ErrorEvent:
message: str
details: str


@dataclass(frozen=True)
Expand Down
35 changes: 31 additions & 4 deletions uploader/app/structured/geometry/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,40 @@ def _validate_columns(
raise RuntimeError(f"Table {table_name} has no column(s): {missing}")


def _format_unit(unit: u.UnitBase) -> str:
text = f"{unit:s}".strip()
return text if text else "dimensionless"


def _eval_context_suffix(expr: Expression, column_units: dict[str, str]) -> str:
if not expr.referenced_columns:
return ""
parts = [f"{col}={column_units.get(col, '')!r}" for col in sorted(expr.referenced_columns)]
return f"; columns: {', '.join(parts)}"


def _evaluate_field(
expr: Expression,
values: dict[str, float],
column_units: dict[str, str],
field: str,
source: str,
) -> float:
quantity = expr.evaluate(values, column_units).to(u.Unit(TARGET_UNITS[field]))
return float(quantity.value)
target = TARGET_UNITS[field]
try:
quantity = expr.evaluate(values, column_units)
except (ValueError, u.UnitConversionError, u.UnitTypeError) as e:
raise RuntimeError(
f"failed to evaluate {field!r} ({source!r}){_eval_context_suffix(expr, column_units)}: {e}",
) from e
try:
return float(quantity.to(u.Unit(target)).value)
except (u.UnitConversionError, u.UnitTypeError) as e:
raise RuntimeError(
f"failed to convert {field!r} ({source!r}) "
f"from {_format_unit(quantity.unit)} to {target}"
f"{_eval_context_suffix(expr, column_units)}: {e}",
) from e


def upload_geometry_isophotal(
Expand Down Expand Up @@ -216,9 +242,10 @@ def upload_geometry_isophotal(
values = {col: float(row[col]) for col in needed_cols}
try:
evaluated = {
field: _evaluate_field(expr, values, column_units, field) for field, expr in parsed.items()
field: _evaluate_field(expr, values, column_units, field, expressions[field])
for field, expr in parsed.items()
}
except (ValueError, u.UnitConversionError, u.UnitTypeError) as e:
except RuntimeError as e:
raise RuntimeError(
f"failed to evaluate expressions for row {row['hyperleda_internal_id']}: {e}",
) from e
Expand Down
48 changes: 43 additions & 5 deletions uploader/app/structured/icrs/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,40 @@ def _parse_expressions(expressions: dict[str, str]) -> dict[str, Expression]:
return {field: parse(source) for field, source in expressions.items()}


def _format_unit(unit: u.UnitBase) -> str:
text = f"{unit:s}".strip()
return text if text else "dimensionless"


def _eval_context_suffix(expr: Expression, column_units: dict[str, str]) -> str:
if not expr.referenced_columns:
return ""
parts = [f"{col}={column_units.get(col, '')!r}" for col in sorted(expr.referenced_columns)]
return f"; columns: {', '.join(parts)}"


def _evaluate_error_field(
expr: Expression,
values: dict[str, float],
column_units: dict[str, str],
field: str,
source: str,
) -> float:
quantity = expr.evaluate(values, column_units).to(u.Unit(TARGET_ERROR_UNITS[field]))
return float(quantity.value)
target = TARGET_ERROR_UNITS[field]
try:
quantity = expr.evaluate(values, column_units)
except (ValueError, u.UnitConversionError, u.UnitTypeError) as e:
raise RuntimeError(
f"failed to evaluate {field!r} ({source!r}){_eval_context_suffix(expr, column_units)}: {e}",
) from e
try:
return float(quantity.to(u.Unit(target)).value)
except (u.UnitConversionError, u.UnitTypeError) as e:
raise RuntimeError(
f"failed to convert {field!r} ({source!r}) "
f"from {_format_unit(quantity.unit)} to {target}"
f"{_eval_context_suffix(expr, column_units)}: {e}",
) from e


def _fetch_column_units(
Expand Down Expand Up @@ -169,9 +195,21 @@ def upload_icrs(

values = {col: float(row[col]) for col in error_cols}
try:
e_ra_val = _evaluate_error_field(parsed["e_ra"], values, column_units, "e_ra")
e_dec_val = _evaluate_error_field(parsed["e_dec"], values, column_units, "e_dec")
except (ValueError, u.UnitConversionError, u.UnitTypeError) as e:
e_ra_val = _evaluate_error_field(
parsed["e_ra"],
values,
column_units,
"e_ra",
expressions["e_ra"],
)
e_dec_val = _evaluate_error_field(
parsed["e_dec"],
values,
column_units,
"e_dec",
expressions["e_dec"],
)
except RuntimeError as e:
raise RuntimeError(
f"failed to evaluate expressions for row {row['hyperleda_internal_id']}: {e}",
) from e
Expand Down
1 change: 1 addition & 0 deletions uploader/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class HistoryEntry(BaseModel):
inputs: dict[str, object]
status: HistoryStatus
message: str
details: str | None = None


HISTORY_PATH = Path("history.jsonl")
Expand Down
Loading
Loading