diff --git a/backend/alembic/versions/0020_user_activity_seen.py b/backend/alembic/versions/0020_user_activity_seen.py
new file mode 100644
index 00000000..1865d23d
--- /dev/null
+++ b/backend/alembic/versions/0020_user_activity_seen.py
@@ -0,0 +1,33 @@
+"""Add users.activity_seen_at for the job monitor's unseen-error badge.
+
+Nullable timezone-aware timestamp: when this user last marked the activity
+error feed as seen (POST /api/activity/seen). NULL means never marked, which
+the frontend treats as "every fetched error is unseen".
+
+Plain add_column with no existence guard: guards are reserved for revisions
+with a specific stated reason (see 0018's autocommit backfill), and installs
+bootstrapped by create_all-then-stamp are no longer supported
+(MIN_UPGRADE_FROM_DATA_VERSION = 13).
+
+No index: the column is only ever read for the requesting user's own row,
+which the primary key already serves.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = "0020"
+down_revision = "0019"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "users",
+ sa.Column("activity_seen_at", sa.DateTime(timezone=True), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("users", "activity_seen_at")
diff --git a/backend/alembic/versions/0021_arcsec_per_pixel.py b/backend/alembic/versions/0021_arcsec_per_pixel.py
new file mode 100644
index 00000000..93e06f64
--- /dev/null
+++ b/backend/alembic/versions/0021_arcsec_per_pixel.py
@@ -0,0 +1,41 @@
+"""Add images.arcsec_per_pixel, the materialized plate scale.
+
+Nullable float: 206.265 * XPIXSZ(um) / FOCALLEN(mm), computed at ingest by the
+scanner via units.arcsec_per_pixel. NULL when the headers carry no usable pixel
+size or focal length. Materialized because deriving the plate scale per-row
+from raw_headers JSONB (regex-guarded text casts, units.sql_arcsec_per_pixel)
+made cold /api/stats aggregations take tens of seconds.
+
+Plain add_column with no existence guard: guards are reserved for revisions
+with a specific stated reason (see 0018's autocommit backfill), and installs
+bootstrapped by create_all-then-stamp are no longer supported
+(MIN_UPGRADE_FROM_DATA_VERSION = 13).
+
+Schema only. Backfilling existing rows is inference from raw_headers, not a
+structural DDL step, so it runs through app.services.data_migrations as data
+migration v15, where raw-headers re-derivation already lives and where the
+version gate makes it run exactly once (same split as 0019 / v14).
+
+No index: the column is aggregated over full scans (multiplied into HFR/FWHM
+conversions), never used as a filter, so a btree would cost every ingest write
+for no read. Add one when a query needs it.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = "0021"
+down_revision = "0020"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "images",
+ sa.Column("arcsec_per_pixel", sa.Float(), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("images", "arcsec_per_pixel")
diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py
index 274535ae..72f4e0d8 100644
--- a/backend/app/api/activity.py
+++ b/backend/app/api/activity.py
@@ -3,7 +3,7 @@
import base64
import json
import logging
-from datetime import datetime
+from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import delete, func, select
@@ -13,7 +13,11 @@
from app.database import get_session
from app.models.activity_event import ActivityEvent
from app.models.user import User
-from app.schemas.activity import ActivityItem, PaginatedActivityResponse
+from app.schemas.activity import (
+ ActivityItem,
+ ActivitySeenResponse,
+ PaginatedActivityResponse,
+)
from app.schemas.common import StatusResponse
logger = logging.getLogger(__name__)
@@ -138,3 +142,21 @@ async def clear_activity(
await session.execute(delete(ActivityEvent))
await session.commit()
return {"status": "cleared"}
+
+
+@router.post("/seen", response_model=ActivitySeenResponse)
+async def mark_activity_seen(
+ session: AsyncSession = Depends(get_session),
+ user: User = Depends(get_current_user),
+):
+ """Record that the current user has seen the activity error feed.
+
+ Any authenticated role may call this: it mutates only the caller's own
+ row. get_current_user and this endpoint share the request's get_session
+ dependency (FastAPI caches per-request), so mutating the loaded user and
+ committing persists it, the same pattern change_password uses in auth.py.
+ """
+ now = datetime.now(timezone.utc)
+ user.activity_seen_at = now
+ await session.commit()
+ return ActivitySeenResponse(activity_seen_at=now)
diff --git a/backend/app/api/analysis.py b/backend/app/api/analysis.py
index 6ae59a2c..800d8b28 100644
--- a/backend/app/api/analysis.py
+++ b/backend/app/api/analysis.py
@@ -30,7 +30,6 @@
TrendLine,
)
from app.services.normalization import load_alias_maps, expand_canonical, normalize_equipment, normalize_filter
-from app.services.units import sql_arcsec_per_pixel
from app.api.auth import get_current_user
router = APIRouter(prefix="/analysis", tags=["analysis"])
@@ -764,11 +763,12 @@ async def get_compare(
# Pixel-domain metrics cannot be compared across optical trains directly:
# the same star measures a different pixel HFR on a different focal length
# or pixel size. For these, each frame is also converted to arcsec via its
- # plate scale (XPIXSZ/FOCALLEN raw headers) and the verdict runs on the
- # arcsec medians. Frames without a derivable plate scale contribute to the
- # px box plots (backward compatible) but not to the arcsec comparison.
+ # materialized plate scale (arcsec_per_pixel, NULL when the headers lacked
+ # a derivable XPIXSZ/FOCALLEN) and the verdict runs on the arcsec medians.
+ # Frames without a plate scale contribute to the px box plots (backward
+ # compatible) but not to the arcsec comparison.
is_pixel_metric = metric in _PIXEL_METRICS
- scale_expr = sql_arcsec_per_pixel(Image.raw_headers)
+ scale_expr = Image.arcsec_per_pixel
async def _fetch_values(group: str) -> tuple[list[float], list[float]]:
q = (
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index 42fa9f30..2f40485d 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -235,7 +235,12 @@ async def logout(
@router.get("/me", response_model=MeResponse)
async def me(user: User = Depends(get_current_user)):
- return MeResponse(id=user.id, username=user.username, role=user.role.value)
+ return MeResponse(
+ id=user.id,
+ username=user.username,
+ role=user.role.value,
+ activity_seen_at=user.activity_seen_at,
+ )
@router.put("/password", response_model=StatusResponse)
diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py
index 80bb0a2a..119c5537 100644
--- a/backend/app/api/stats.py
+++ b/backend/app/api/stats.py
@@ -15,7 +15,6 @@
from app.models.user import User
from app.models import Image, Target
from app.services.normalization import load_alias_maps, normalize_filter, normalize_equipment
-from app.services.units import sql_arcsec_per_pixel
from app.schemas.stats import (
StatsResponse, OverviewStats, EquipmentStats, EquipmentItem,
@@ -278,12 +277,12 @@ async def _query_overview():
async with async_session() as s:
return (await s.execute(q)).one()
- # Plate scale (arcsec/px) derived per row from the XPIXSZ/FOCALLEN raw
- # headers; NULL when underivable, so multiplying a pixel metric by it
- # yields NULL and percentile_cont skips the frame. This keeps the pooled
- # arcsec medians unit-consistent across optical trains, unlike the raw
- # pixel medians beside them.
- _scale = sql_arcsec_per_pixel(Image.raw_headers)
+ # Plate scale (arcsec/px) materialized at scan time from the
+ # XPIXSZ/FOCALLEN raw headers; NULL when underivable, so multiplying a
+ # pixel metric by it yields NULL and percentile_cont skips the frame.
+ # This keeps the pooled arcsec medians unit-consistent across optical
+ # trains, unlike the raw pixel medians beside them.
+ _scale = Image.arcsec_per_pixel
async def _query_cameras():
q = select(
diff --git a/backend/app/models/image.py b/backend/app/models/image.py
index 7b61f2d6..925ecef4 100644
--- a/backend/app/models/image.py
+++ b/backend/app/models/image.py
@@ -70,6 +70,14 @@ class Image(Base):
# derived from RA/Dec + time + site.
altitude_deg: Mapped[float | None] = mapped_column(Float, nullable=True)
+ # Plate scale in arcseconds per pixel, materialized at ingest from the
+ # XPIXSZ/FOCALLEN headers via units.arcsec_per_pixel (206.265 * um / mm).
+ # Stored as a column because deriving it per-row from raw_headers JSONB
+ # (regex-guarded text casts, see units.sql_arcsec_per_pixel) is what made
+ # cold /api/stats aggregations take tens of seconds. NULL when the headers
+ # carry no usable pixel size or focal length.
+ arcsec_per_pixel: Mapped[float | None] = mapped_column(Float, nullable=True)
+
# --- CSV metrics (N.I.N.A. ImageMetaData) ---
# Quality
hfr_stdev: Mapped[float | None] = mapped_column(Float, nullable=True)
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 6802c204..8ee107a0 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -22,5 +22,9 @@ class User(Base):
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ # When this user last marked the activity/error feed as seen (job-monitor
+ # unseen badge). NULL means never marked; the frontend then treats every
+ # fetched error event as unseen.
+ activity_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
diff --git a/backend/app/schemas/activity.py b/backend/app/schemas/activity.py
index d1a380d6..84c7b315 100644
--- a/backend/app/schemas/activity.py
+++ b/backend/app/schemas/activity.py
@@ -41,3 +41,7 @@ class ActivityFilterParams(BaseModel):
@classmethod
def cap_limit(cls, v: int) -> int:
return min(int(v), 200)
+
+
+class ActivitySeenResponse(BaseModel):
+ activity_seen_at: datetime
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index 0dfc2b30..d2a47d96 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -19,6 +19,7 @@ class MeResponse(BaseModel):
id: uuid.UUID
username: str
role: str
+ activity_seen_at: datetime | None = None
class PasswordChangeRequest(BaseModel):
diff --git a/backend/app/services/data_migrations.py b/backend/app/services/data_migrations.py
index fec60071..1875353d 100644
--- a/backend/app/services/data_migrations.py
+++ b/backend/app/services/data_migrations.py
@@ -23,7 +23,7 @@
# Current data version - bump this and add a migration function when
# code changes affect how stored target data is derived.
-DATA_VERSION = 14
+DATA_VERSION = 15
# Migrations whose per-target loops make external network calls (VizieR, Gaia,
# SAC, HyperLEDA) with pacing sleeps commit their work every this many queried
@@ -812,6 +812,51 @@ def _migrate_v14_eccentricity_provenance(session: Session) -> str:
return "; ".join(parts) if parts else "No changes needed"
+def _migrate_v15_arcsec_per_pixel(session: Session) -> str:
+ """Backfill the materialized plate scale (arcsec_per_pixel) from raw_headers.
+
+ Re-derives an image-level value from raw_headers after a schema change,
+ exactly as v12/v14 did: alembic 0021 added images.arcsec_per_pixel so
+ /api/stats aggregations no longer have to derive the plate scale per-row
+ from JSONB (regex-guarded text casts, units.sql_arcsec_per_pixel), which
+ is what made the cold path take tens of seconds. The scanner now sets the
+ column at ingest; existing rows carry XPIXSZ/FOCALLEN in raw_headers, so
+ the value is backfilled here rather than left to wait on a full re-ingest.
+
+ Uses units.plate_scale_from_headers, the Python counterpart of the SQL
+ expression, so the backfilled values match what the scanner writes and
+ what the SQL derivation produced (same keyword semantics, None for
+ missing/non-numeric/non-positive inputs).
+
+ Idempotent: only rows still NULL in arcsec_per_pixel are selected, and the
+ derivation is a pure function of raw_headers, so replaying is a no-op.
+ """
+ from app.models import Image
+ from app.services.units import plate_scale_from_headers
+
+ rows = session.execute(
+ select(Image.id, Image.raw_headers).where(
+ Image.arcsec_per_pixel.is_(None),
+ Image.raw_headers.has_key("XPIXSZ"), # noqa: W601 (JSONB ?)
+ Image.raw_headers.has_key("FOCALLEN"), # noqa: W601
+ )
+ ).all()
+
+ updates: list[dict] = []
+ for img_id, headers in rows:
+ scale = plate_scale_from_headers(headers)
+ if scale is not None:
+ updates.append({"id": img_id, "arcsec_per_pixel": scale})
+
+ if updates:
+ session.bulk_update_mappings(Image, updates)
+ session.flush()
+
+ if updates:
+ return f"{len(updates)} plate scales backfilled from raw_headers"
+ return "No changes needed"
+
+
def reference_catalogs_are_empty(session: Session) -> bool:
"""Return True if the static OpenNGC catalog table has no rows.
@@ -879,6 +924,7 @@ def load_reference_catalogs(session: Session) -> str:
12: ("Backfill exposure_time from EXPOSURE keyword and split FWHM out of median_hfr", _migrate_v12_exposure_and_metric_split),
13: ("Backfill per-target enrichment (constellation, memberships, Gaia, HyperLEDA) for targets created since v11", _migrate_v13_enrich_created_targets),
14: ("Recover eccentricity provenance and backfill frame altitude from raw_headers", _migrate_v14_eccentricity_provenance),
+ 15: ("Backfill materialized plate scale (arcsec_per_pixel) from raw_headers", _migrate_v15_arcsec_per_pixel),
}
diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py
index b41631b5..c21d82cb 100644
--- a/backend/app/services/scanner.py
+++ b/backend/app/services/scanner.py
@@ -10,6 +10,7 @@
from app.services.csv_metadata import get_csv_metrics, merge_csv_metrics
from app.services.scan_filters import ScanFilterConfig
+from app.services.units import arcsec_per_pixel
logger = logging.getLogger(__name__)
@@ -236,6 +237,13 @@ def extract_metadata(fits_path: Path, header=None) -> dict[str, Any]:
# OBJCTALT (MaxIm-style) first, CENTALT second; both are written by
# common capture software and agree when both are present.
"altitude_deg": _first_float(header, "OBJCTALT", "CENTALT"),
+ # Plate scale materialized at ingest (206.265 * XPIXSZ / FOCALLEN) so
+ # aggregations never have to re-derive it per-row from raw_headers
+ # JSONB. Same helper semantics as units.sql_arcsec_per_pixel: None
+ # when either keyword is missing, non-numeric, or non-positive.
+ "arcsec_per_pixel": arcsec_per_pixel(
+ header.get("XPIXSZ"), header.get("FOCALLEN")
+ ),
"capture_date": capture_date,
"raw_headers": raw_headers,
}
diff --git a/backend/app/services/xisf_parser.py b/backend/app/services/xisf_parser.py
index a9564191..f45f366e 100644
--- a/backend/app/services/xisf_parser.py
+++ b/backend/app/services/xisf_parser.py
@@ -21,6 +21,7 @@
from app.services.csv_metadata import get_csv_metrics, merge_csv_metrics
from app.services.stretch import normalize_to_unit, resize_array, stretch_channel
+from app.services.units import arcsec_per_pixel
logger = logging.getLogger(__name__)
@@ -213,6 +214,11 @@ def extract_xisf_metadata(xisf_path: Path) -> dict[str, Any]:
# Frame-centre altitude, for the same atmospheric-dispersion reason as
# the FITS path (see scanner.extract_metadata).
"altitude_deg": _first_float(merged.get("OBJCTALT"), merged.get("CENTALT")),
+ # Plate scale materialized at ingest, same as the FITS path (see
+ # scanner.extract_metadata).
+ "arcsec_per_pixel": arcsec_per_pixel(
+ merged.get("XPIXSZ"), merged.get("FOCALLEN")
+ ),
"capture_date": _parse_capture_date(merged.get("DATE-OBS"), xisf_path),
"raw_headers": raw_headers,
}
diff --git a/backend/app/worker/tasks_scan.py b/backend/app/worker/tasks_scan.py
index 59c43a1c..f5bf2d0b 100644
--- a/backend/app/worker/tasks_scan.py
+++ b/backend/app/worker/tasks_scan.py
@@ -476,6 +476,7 @@ def _do_ingest(fits_path: str, include_calibration: bool = True) -> dict:
eccentricity=meta.get("eccentricity"),
eccentricity_source=meta.get("eccentricity_source"),
altitude_deg=meta.get("altitude_deg"),
+ arcsec_per_pixel=meta.get("arcsec_per_pixel"),
raw_headers=meta.get("raw_headers", {}),
# CSV metrics (N.I.N.A. Session Metadata)
hfr_stdev=meta.get("hfr_stdev"),
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 71bfe641..4fa61d1d 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -50,6 +50,7 @@ def admin_user():
user.role = UserRole.admin
user.is_active = True
user.password_hash = "hashed"
+ user.activity_seen_at = None
return user
@@ -62,4 +63,5 @@ def viewer_user():
user.role = UserRole.viewer
user.is_active = True
user.password_hash = "hashed"
+ user.activity_seen_at = None
return user
diff --git a/backend/tests/test_api_activity_seen.py b/backend/tests/test_api_activity_seen.py
new file mode 100644
index 00000000..08f2cec5
--- /dev/null
+++ b/backend/tests/test_api_activity_seen.py
@@ -0,0 +1,92 @@
+import uuid
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from httpx import AsyncClient, ASGITransport
+
+from app.main import app
+from app.database import get_session
+from app.api.deps import get_current_user
+from app.models.user import User, UserRole
+
+
+def _user(role=UserRole.viewer):
+ u = MagicMock(spec=User)
+ u.id = uuid.uuid4()
+ u.username = "viewer"
+ u.role = role
+ u.is_active = True
+ u.activity_seen_at = None
+ return u
+
+
+def _session_gen(mock_session):
+ async def _gen():
+ yield mock_session
+ return _gen
+
+
+def test_user_model_has_nullable_activity_seen_at():
+ assert "activity_seen_at" in User.__table__.columns
+ col = User.__table__.columns["activity_seen_at"]
+ assert col.nullable is True
+
+
+@pytest.mark.asyncio
+async def test_mark_activity_seen_sets_timestamp_and_commits():
+ user = _user()
+ mock_session = AsyncMock()
+ app.dependency_overrides[get_session] = _session_gen(mock_session)
+ app.dependency_overrides[get_current_user] = lambda: user
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ resp = await c.post("/api/activity/seen")
+ app.dependency_overrides.clear()
+ assert resp.status_code == 200
+ returned = datetime.fromisoformat(resp.json()["activity_seen_at"])
+ assert returned.tzinfo is not None
+ assert user.activity_seen_at == returned
+ mock_session.commit.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_mark_activity_seen_allowed_for_viewer_role():
+ user = _user(UserRole.viewer)
+ mock_session = AsyncMock()
+ app.dependency_overrides[get_session] = _session_gen(mock_session)
+ app.dependency_overrides[get_current_user] = lambda: user
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ resp = await c.post("/api/activity/seen")
+ app.dependency_overrides.clear()
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_mark_activity_seen_requires_auth():
+ app.dependency_overrides.clear()
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ resp = await c.post("/api/activity/seen")
+ assert resp.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_me_includes_activity_seen_at():
+ user = _user()
+ user.activity_seen_at = datetime(2026, 7, 18, 12, 0, 0, tzinfo=timezone.utc)
+ app.dependency_overrides[get_current_user] = lambda: user
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ resp = await c.get("/api/auth/me")
+ app.dependency_overrides.clear()
+ assert resp.status_code == 200
+ assert datetime.fromisoformat(resp.json()["activity_seen_at"]) == user.activity_seen_at
+
+
+@pytest.mark.asyncio
+async def test_me_activity_seen_at_null_when_never_marked():
+ user = _user()
+ app.dependency_overrides[get_current_user] = lambda: user
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ resp = await c.get("/api/auth/me")
+ app.dependency_overrides.clear()
+ assert resp.status_code == 200
+ assert resp.json()["activity_seen_at"] is None
diff --git a/backend/tests/test_api_analysis.py b/backend/tests/test_api_analysis.py
index 4d029144..07955017 100644
--- a/backend/tests/test_api_analysis.py
+++ b/backend/tests/test_api_analysis.py
@@ -603,7 +603,7 @@ async def test_get_compare_pixel_metric_not_comparable_without_scale():
of returning a bogus % figure."""
user = _admin_user()
rows_a = [_row(v, 1.5) for v in [2.0, 2.0, 2.2, 2.4, 2.6]]
- rows_b = [_row(4.0, None) for _ in range(5)] # no XPIXSZ/FOCALLEN
+ rows_b = [_row(4.0, None) for _ in range(5)] # arcsec_per_pixel NULL
session = _compare_session(rows_a, rows_b)
app.dependency_overrides[get_current_user] = _override_user(user)
diff --git a/backend/tests/test_data_migrations.py b/backend/tests/test_data_migrations.py
index b591a736..3f7190c5 100644
--- a/backend/tests/test_data_migrations.py
+++ b/backend/tests/test_data_migrations.py
@@ -22,6 +22,7 @@
_migrate_v12_exposure_and_metric_split,
_migrate_v13_enrich_created_targets,
_migrate_v14_eccentricity_provenance,
+ _migrate_v15_arcsec_per_pixel,
)
@@ -77,7 +78,13 @@ def test_v14_is_eccentricity_provenance(self):
desc, func = MIGRATIONS[14]
assert func is _migrate_v14_eccentricity_provenance
assert "eccentricity" in desc.lower()
- assert DATA_VERSION == 14
+
+ def test_v15_is_arcsec_per_pixel_backfill(self):
+ assert 15 in MIGRATIONS
+ desc, func = MIGRATIONS[15]
+ assert func is _migrate_v15_arcsec_per_pixel
+ assert "arcsec_per_pixel" in desc
+ assert DATA_VERSION == 15
def _sync_session_factory():
@@ -378,6 +385,98 @@ def test_backfills_source_and_altitude(self):
engine.dispose()
+class TestV15ArcsecPerPixel:
+ """End-to-end backfill of the materialized plate scale from raw_headers."""
+
+ def test_backfills_plate_scale(self):
+ from sqlalchemy import text
+ from app.models import Image
+
+ Session, engine = _sync_session_factory()
+ session = Session()
+
+ tag = uuid.uuid4().hex[:8]
+ p_full = f"/fits/{tag}/full_headers.fits"
+ p_strings = f"/fits/{tag}/string_headers.fits"
+ p_missing = f"/fits/{tag}/no_focallen.fits"
+ p_bad = f"/fits/{tag}/bad_values.fits"
+ p_set = f"/fits/{tag}/already_set.fits"
+ ids = {}
+ try:
+ # Row 1: numeric XPIXSZ + FOCALLEN -- backfilled.
+ r1 = Image(
+ file_path=p_full, file_name="full_headers.fits",
+ arcsec_per_pixel=None,
+ raw_headers={"XPIXSZ": 3.76, "FOCALLEN": 530.0, "OBJECT": "M31"},
+ )
+ # Row 2: string values (XISF FITSKeywords land as text) -- backfilled.
+ r2 = Image(
+ file_path=p_strings, file_name="string_headers.fits",
+ arcsec_per_pixel=None,
+ raw_headers={"XPIXSZ": "3.76", "FOCALLEN": "530", "OBJECT": "M31"},
+ )
+ # Row 3: FOCALLEN missing -- stays NULL (JSONB key filter skips it).
+ r3 = Image(
+ file_path=p_missing, file_name="no_focallen.fits",
+ arcsec_per_pixel=None,
+ raw_headers={"XPIXSZ": 3.76, "OBJECT": "M31"},
+ )
+ # Row 4: non-positive focal length -- selected but underivable, NULL.
+ r4 = Image(
+ file_path=p_bad, file_name="bad_values.fits",
+ arcsec_per_pixel=None,
+ raw_headers={"XPIXSZ": 3.76, "FOCALLEN": 0, "OBJECT": "M31"},
+ )
+ # Row 5: already materialized -- left untouched (idempotency filter).
+ r5 = Image(
+ file_path=p_set, file_name="already_set.fits",
+ arcsec_per_pixel=2.5,
+ raw_headers={"XPIXSZ": 3.76, "FOCALLEN": 530.0, "OBJECT": "M31"},
+ )
+ session.add_all([r1, r2, r3, r4, r5])
+ session.commit()
+ for r in (r1, r2, r3, r4, r5):
+ ids[r.file_path] = r.id
+
+ summary = _migrate_v15_arcsec_per_pixel(session)
+ session.commit()
+
+ expected = 206.265 * 3.76 / 530.0
+
+ got1 = session.get(Image, ids[p_full])
+ assert got1.arcsec_per_pixel == pytest.approx(expected)
+
+ got2 = session.get(Image, ids[p_strings])
+ assert got2.arcsec_per_pixel == pytest.approx(expected)
+
+ got3 = session.get(Image, ids[p_missing])
+ assert got3.arcsec_per_pixel is None
+
+ got4 = session.get(Image, ids[p_bad])
+ assert got4.arcsec_per_pixel is None
+
+ got5 = session.get(Image, ids[p_set])
+ assert got5.arcsec_per_pixel == pytest.approx(2.5)
+
+ assert "plate scales backfilled" in summary
+
+ # Replaying the migration must not change anything (idempotent).
+ session.expire_all()
+ _migrate_v15_arcsec_per_pixel(session)
+ session.commit()
+ assert session.get(Image, ids[p_full]).arcsec_per_pixel == pytest.approx(expected)
+ assert session.get(Image, ids[p_set]).arcsec_per_pixel == pytest.approx(2.5)
+ finally:
+ session.rollback()
+ for fp in (p_full, p_strings, p_missing, p_bad, p_set):
+ session.execute(
+ text("DELETE FROM images WHERE file_path = :fp"), {"fp": fp}
+ )
+ session.commit()
+ session.close()
+ engine.dispose()
+
+
class TestLoadReferenceCatalogs:
"""H1: static catalogs must load independently of the data-version gate.
diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py
index a6ec27ec..6094416d 100644
--- a/backend/tests/test_scanner.py
+++ b/backend/tests/test_scanner.py
@@ -318,6 +318,49 @@ def test_eccentricity_keyword_preferred(mock_fitsio, mock_get_csv):
assert result["eccentricity"] == 0.42
+# ---------------------------------------------------------------------------
+# Materialized plate scale: arcsec_per_pixel from XPIXSZ + FOCALLEN
+# ---------------------------------------------------------------------------
+
+@patch("app.services.scanner.get_csv_metrics")
+@patch("app.services.scanner.fitsio")
+def test_arcsec_per_pixel_from_headers(mock_fitsio, mock_get_csv):
+ """XPIXSZ + FOCALLEN materialize the plate scale (206.265 * um / mm)."""
+ header = _make_fake_header({"XPIXSZ": 3.76, "FOCALLEN": 530.0})
+ mock_fitsio.read_header.return_value = header
+ mock_get_csv.return_value = {}
+
+ result = extract_metadata(Path("/data/Light.fits"))
+
+ assert result["arcsec_per_pixel"] == pytest.approx(206.265 * 3.76 / 530.0)
+
+
+@patch("app.services.scanner.get_csv_metrics")
+@patch("app.services.scanner.fitsio")
+def test_arcsec_per_pixel_none_when_headers_missing(mock_fitsio, mock_get_csv):
+ """Missing pixel size or focal length yields a NULL plate scale."""
+ header = _make_fake_header() # no XPIXSZ/FOCALLEN in defaults
+ mock_fitsio.read_header.return_value = header
+ mock_get_csv.return_value = {}
+
+ result = extract_metadata(Path("/data/Light.fits"))
+
+ assert result["arcsec_per_pixel"] is None
+
+
+@patch("app.services.scanner.get_csv_metrics")
+@patch("app.services.scanner.fitsio")
+def test_arcsec_per_pixel_none_for_invalid_values(mock_fitsio, mock_get_csv):
+ """Non-positive or non-numeric header values yield a NULL plate scale."""
+ header = _make_fake_header({"XPIXSZ": 3.76, "FOCALLEN": 0})
+ mock_fitsio.read_header.return_value = header
+ mock_get_csv.return_value = {}
+
+ result = extract_metadata(Path("/data/Light.fits"))
+
+ assert result["arcsec_per_pixel"] is None
+
+
# ---------------------------------------------------------------------------
# AUD-020: unparseable DATE-OBS logs a warning instead of vanishing silently
# ---------------------------------------------------------------------------
diff --git a/frontend/openapi.json b/frontend/openapi.json
index c87e936d..a7fbd0ef 100644
--- a/frontend/openapi.json
+++ b/frontend/openapi.json
@@ -154,6 +154,20 @@
"title": "ActivityItem",
"type": "object"
},
+ "ActivitySeenResponse": {
+ "properties": {
+ "activity_seen_at": {
+ "format": "date-time",
+ "title": "Activity Seen At",
+ "type": "string"
+ }
+ },
+ "required": [
+ "activity_seen_at"
+ ],
+ "title": "ActivitySeenResponse",
+ "type": "object"
+ },
"ActivitySettingsResponse": {
"properties": {
"activity_retention_days": {
@@ -921,12 +935,39 @@
},
"CompareResponse": {
"properties": {
+ "comparable": {
+ "default": true,
+ "title": "Comparable",
+ "type": "boolean"
+ },
"group_a": {
"$ref": "#/components/schemas/CompareGroupStats"
},
"group_b": {
"$ref": "#/components/schemas/CompareGroupStats"
},
+ "median_hfr_arcsec_a": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Median Hfr Arcsec A"
+ },
+ "median_hfr_arcsec_b": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Median Hfr Arcsec B"
+ },
"metric": {
"title": "Metric",
"type": "string"
@@ -1416,6 +1457,17 @@
],
"title": "Avg Hfr"
},
+ "avg_hfr_arcsec": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Avg Hfr Arcsec"
+ },
"best_hfr": {
"anyOf": [
{
@@ -1427,12 +1479,53 @@
],
"title": "Best Hfr"
},
+ "best_hfr_arcsec": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Best Hfr Arcsec"
+ },
+ "ecc_excluded_count": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ecc Excluded Count"
+ },
+ "hfr_arcsec_hist": {
+ "default": [],
+ "items": {
+ "$ref": "#/components/schemas/HfrBucket"
+ },
+ "title": "Hfr Arcsec Hist",
+ "type": "array"
+ },
"hfr_distribution": {
"items": {
"$ref": "#/components/schemas/HfrBucket"
},
"title": "Hfr Distribution",
"type": "array"
+ },
+ "unscaled_frame_count": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Unscaled Frame Count"
}
},
"required": [
@@ -1980,6 +2073,17 @@
],
"title": "Median Fwhm"
},
+ "median_fwhm_arcsec": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Median Fwhm Arcsec"
+ },
"median_guiding_rms": {
"anyOf": [
{
@@ -3483,6 +3587,18 @@
},
"MeResponse": {
"properties": {
+ "activity_seen_at": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Activity Seen At"
+ },
"id": {
"format": "uuid",
"title": "Id",
@@ -6159,6 +6275,17 @@
"title": "Frames",
"type": "array"
},
+ "fwhm_arcsec": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Fwhm Arcsec"
+ },
"gain": {
"anyOf": [
{
@@ -6170,6 +6297,17 @@
],
"title": "Gain"
},
+ "hfr_arcsec": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Hfr Arcsec"
+ },
"insights": {
"default": [],
"items": {
@@ -6595,11 +6733,33 @@
"title": "Frame Count",
"type": "integer"
},
+ "fwhm_arcsec": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Fwhm Arcsec"
+ },
"has_notes": {
"default": false,
"title": "Has Notes",
"type": "boolean"
},
+ "hfr_arcsec": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Hfr Arcsec"
+ },
"integration_seconds": {
"title": "Integration Seconds",
"type": "number"
@@ -7469,6 +7629,17 @@
],
"title": "Avg Hfr"
},
+ "avg_hfr_arcsec": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Avg Hfr Arcsec"
+ },
"catalog_memberships": {
"default": [],
"items": {
@@ -7510,6 +7681,17 @@
],
"title": "Distance Pc"
},
+ "ecc_excluded_count": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ecc Excluded Count"
+ },
"equipment": {
"items": {
"type": "string"
@@ -8968,6 +9150,56 @@
]
}
},
+ "/api/activity/seen": {
+ "post": {
+ "description": "Record that the current user has seen the activity error feed.\n\nAny authenticated role may call this: it mutates only the caller's own\nrow. get_current_user and this endpoint share the request's get_session\ndependency (FastAPI caches per-request), so mutating the loaded user and\ncommitting persists it, the same pattern change_password uses in auth.py.",
+ "operationId": "mark_activity_seen_api_activity_seen_post",
+ "parameters": [
+ {
+ "in": "cookie",
+ "name": "access_token",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Access Token"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ActivitySeenResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Mark Activity Seen",
+ "tags": [
+ "activity"
+ ]
+ }
+ },
"/api/analysis/boxplot": {
"get": {
"operationId": "get_boxplot_api_analysis_boxplot_get",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index fca4929c..594d01f0 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,18 +4,18 @@ import NavBar from "./components/NavBar";
import { Toast } from "./components/Toast";
import { useAuth } from "./components/AuthProvider";
import {
- startErrorToastPoller,
- stopErrorToastPoller,
-} from "./store/errorToastPoller";
+ startActivityErrorsPoller,
+ stopActivityErrorsPoller,
+} from "./store/activityErrors";
const ErrorPollerMount: Component = () => {
const { user } = useAuth();
createEffect(() => {
if (user() !== null) {
- startErrorToastPoller();
+ startActivityErrorsPoller();
} else {
- stopErrorToastPoller();
+ stopActivityErrorsPoller();
}
});
diff --git a/frontend/src/api/generated/schema.d.ts b/frontend/src/api/generated/schema.d.ts
index 79a99dfb..b69b2687 100644
--- a/frontend/src/api/generated/schema.d.ts
+++ b/frontend/src/api/generated/schema.d.ts
@@ -31,6 +31,31 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/activity/seen": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Mark Activity Seen
+ * @description Record that the current user has seen the activity error feed.
+ *
+ * Any authenticated role may call this: it mutates only the caller's own
+ * row. get_current_user and this endpoint share the request's get_session
+ * dependency (FastAPI caches per-request), so mutating the loaded user and
+ * committing persists it, the same pattern change_password uses in auth.py.
+ */
+ post: operations["mark_activity_seen_api_activity_seen_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/analysis/boxplot": {
parameters: {
query?: never;
@@ -2262,6 +2287,14 @@ export interface components {
*/
timestamp: string;
};
+ /** ActivitySeenResponse */
+ ActivitySeenResponse: {
+ /**
+ * Activity Seen At
+ * Format: date-time
+ */
+ activity_seen_at: string;
+ };
/** ActivitySettingsResponse */
ActivitySettingsResponse: {
/** Activity Retention Days */
@@ -2569,8 +2602,17 @@ export interface components {
};
/** CompareResponse */
CompareResponse: {
+ /**
+ * Comparable
+ * @default true
+ */
+ comparable: boolean;
group_a: components["schemas"]["CompareGroupStats"];
group_b: components["schemas"]["CompareGroupStats"];
+ /** Median Hfr Arcsec A */
+ median_hfr_arcsec_a?: number | null;
+ /** Median Hfr Arcsec B */
+ median_hfr_arcsec_b?: number | null;
/** Metric */
metric: string;
/** Mode */
@@ -2735,10 +2777,23 @@ export interface components {
avg_eccentricity: number | null;
/** Avg Hfr */
avg_hfr: number | null;
+ /** Avg Hfr Arcsec */
+ avg_hfr_arcsec?: number | null;
/** Best Hfr */
best_hfr: number | null;
+ /** Best Hfr Arcsec */
+ best_hfr_arcsec?: number | null;
+ /** Ecc Excluded Count */
+ ecc_excluded_count?: number | null;
+ /**
+ * Hfr Arcsec Hist
+ * @default []
+ */
+ hfr_arcsec_hist: components["schemas"]["HfrBucket"][];
/** Hfr Distribution */
hfr_distribution: components["schemas"]["HfrBucket"][];
+ /** Unscaled Frame Count */
+ unscaled_frame_count?: number | null;
};
/** DbSummaryResponse */
DbSummaryResponse: {
@@ -2926,6 +2981,8 @@ export interface components {
integration_seconds: number;
/** Median Fwhm */
median_fwhm?: number | null;
+ /** Median Fwhm Arcsec */
+ median_fwhm_arcsec?: number | null;
/** Median Guiding Rms */
median_guiding_rms?: number | null;
/** Name */
@@ -3447,6 +3504,8 @@ export interface components {
};
/** MeResponse */
MeResponse: {
+ /** Activity Seen At */
+ activity_seen_at?: string | null;
/**
* Id
* Format: uuid
@@ -4368,8 +4427,12 @@ export interface components {
* @default []
*/
frames: components["schemas"]["FrameRecord"][];
+ /** Fwhm Arcsec */
+ fwhm_arcsec?: number | null;
/** Gain */
gain?: number | null;
+ /** Hfr Arcsec */
+ hfr_arcsec?: number | null;
/**
* Insights
* @default []
@@ -4487,11 +4550,15 @@ export interface components {
filters_used: string[];
/** Frame Count */
frame_count: number;
+ /** Fwhm Arcsec */
+ fwhm_arcsec?: number | null;
/**
* Has Notes
* @default false
*/
has_notes: boolean;
+ /** Hfr Arcsec */
+ hfr_arcsec?: number | null;
/** Integration Seconds */
integration_seconds: number;
/** Median Detected Stars */
@@ -4769,6 +4836,8 @@ export interface components {
avg_guiding_rms_arcsec?: number | null;
/** Avg Hfr */
avg_hfr?: number | null;
+ /** Avg Hfr Arcsec */
+ avg_hfr_arcsec?: number | null;
/**
* Catalog Memberships
* @default []
@@ -4780,6 +4849,8 @@ export interface components {
dec?: number | null;
/** Distance Pc */
distance_pc?: number | null;
+ /** Ecc Excluded Count */
+ ecc_excluded_count?: number | null;
/** Equipment */
equipment: string[];
/** Filters Used */
@@ -5303,6 +5374,37 @@ export interface operations {
};
};
};
+ mark_activity_seen_api_activity_seen_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: {
+ access_token?: string | null;
+ };
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ActivitySeenResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_boxplot_api_analysis_boxplot_get: {
parameters: {
query: {
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 823573c7..d13e053a 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -727,7 +727,7 @@ export type ActivityCategory =
// widget); not a server response shape.
export interface ActiveJob {
id: string;
- category: "scan" | "rebuild" | "thumbnail" | "enrichment" | "mosaic";
+ category: "scan" | "rebuild" | "thumbnail" | "enrichment" | "mosaic" | "wbpp_copy";
label: string;
subLabel?: string;
progress?: number;
diff --git a/frontend/src/components/FilePreviewModal.tsx b/frontend/src/components/FilePreviewModal.tsx
index 33cfaab0..fd746dec 100644
--- a/frontend/src/components/FilePreviewModal.tsx
+++ b/frontend/src/components/FilePreviewModal.tsx
@@ -1,12 +1,28 @@
-import { createSignal, Show, onCleanup, onMount, createEffect, createMemo } from "solid-js";
+import { createSignal, Show, For, onCleanup, onMount, createEffect, createMemo } from "solid-js";
import { useSettingsContext } from "./SettingsProvider";
import HelpPopover from "./HelpPopover";
import Dialog from "./Dialog";
+// One pill in the optional per-file metadata strip. The modal renders whatever
+// it is handed and knows nothing about where the values came from; callers own
+// the semantics (and any color classes) entirely.
+export type PreviewMetaEntry = {
+ label: string;
+ value: string;
+ /** Color class for the value; defaults to the modal's neutral text. */
+ class?: string;
+ /** Renders a trailing ✕ after the value (e.g. a failed quality gate). */
+ marked?: boolean;
+ /** Tooltip on the pill. */
+ title?: string;
+};
+
export type PreviewFile = {
imageId: string;
filePath: string;
thumbnailUrl?: string | null;
+ /** Optional metadata strip shown under the file path; updates on navigation. */
+ meta?: PreviewMetaEntry[];
};
type Props = {
@@ -254,6 +270,27 @@ export function FilePreviewModal(props: Props) {
{current().filePath}
+