diff --git a/backend/app/api/analysis.py b/backend/app/api/analysis.py index 34f38d3f..ab2efd6b 100644 --- a/backend/app/api/analysis.py +++ b/backend/app/api/analysis.py @@ -5,7 +5,7 @@ from datetime import date as date_type from fastapi import APIRouter, Depends, Query, HTTPException -from sqlalchemy import select, func, distinct, and_ +from sqlalchemy import select, func, distinct, and_, Numeric, cast from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_session @@ -25,6 +25,7 @@ MatrixCell, MatrixResponse, MovingAveragePoint, + SkippedGroup, SummaryStats, TimeSeriesPoint, TimeSeriesResponse, @@ -46,6 +47,10 @@ # response payload while trend/stats stay computed over the full set. _CORRELATION_POINT_CAP = 5000 +# Deterministic row order for every select that feeds a figure, so float sums, +# the downsample stride and group emission order repeat between identical requests. +_ROW_ORDER = (Image.session_date, Image.id) + # ── Metric map ────────────────────────────────────────────────────────── METRIC_MAP = { @@ -170,6 +175,26 @@ async def _apply_filters( return q +async def _has_mixed_plate_scales( + session: AsyncSession, + telescope: str | None, + camera: str | None, + filter_used: str | None, + date_from: str | None, + date_to: str | None, +) -> bool: + """True when the filtered LIGHT frames span more than one plate scale.""" + # Rounded to 2 decimals so header jitter within one optical train does not + # count as a second plate scale. NULL plate scales are ignored. + q = ( + select(distinct(func.round(cast(Image.arcsec_per_pixel, Numeric), 2))) + .where(Image.image_type == "LIGHT") + .where(Image.arcsec_per_pixel.is_not(None)) + ) + q = await _apply_filters(q, session, telescope, camera, filter_used, date_from, date_to) + return len((await session.execute(q)).scalars().all()) > 1 + + async def _resolve_target_names( session: AsyncSession, target_ids: set, ) -> dict: @@ -223,37 +248,35 @@ def _compute_box_plot(values: list[float], group_name: str) -> BoxPlotGroup | No ) -def _is_outlier_iqr(x: float, y: float, xs: list[float], ys: list[float]) -> bool: - """Check if a point is an outlier on either axis using IQR method.""" - for vals, val in [(xs, x), (ys, y)]: - s = sorted(vals) - n = len(s) - q1 = statistics.median(s[: n // 2]) - q3 = statistics.median(s[(n + 1) // 2 :]) - iqr = q3 - q1 - if val < q1 - 1.5 * iqr or val > q3 + 1.5 * iqr: - return True - return False +def _iqr_fences(vals: list[float]) -> tuple[float, float]: + """Lower and upper 1.5*IQR outlier fences for one axis.""" + s = sorted(vals) + n = len(s) + q1 = statistics.median(s[: n // 2]) + q3 = statistics.median(s[(n + 1) // 2 :]) + iqr = q3 - q1 + return q1 - 1.5 * iqr, q3 + 1.5 * iqr -def _pearson_r(xs: list[float], ys: list[float]) -> float: +def _pearson_r(xs: list[float], ys: list[float]) -> float | None: + """Pearson r, or None when undefined (n < 3 or a constant axis).""" n = len(xs) if n < 3: - return 0.0 + return None mx = sum(xs) / n my = sum(ys) / n num = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) dx = math.sqrt(sum((x - mx) ** 2 for x in xs)) dy = math.sqrt(sum((y - my) ** 2 for y in ys)) if dx < 1e-12 or dy < 1e-12: - return 0.0 + return None return num / (dx * dy) -def _spearman_rho(xs: list[float], ys: list[float]) -> float: +def _spearman_rho(xs: list[float], ys: list[float]) -> float | None: n = len(xs) if n < 3: - return 0.0 + return None def _rank(vals): indexed = sorted(range(n), key=lambda i: vals[i]) @@ -274,6 +297,50 @@ def _rank(vals): return _pearson_r(rx, ry) +# Two-sided 95% Student t critical values for df 1..30; 1.96 above that. +_T_CRIT_95 = [ + 12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, + 2.201, 2.179, 2.160, 2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086, + 2.080, 2.074, 2.069, 2.064, 2.060, 2.056, 2.052, 2.048, 2.045, 2.042, +] + + +def _t_crit_95(df: int) -> float: + return _T_CRIT_95[df - 1] if 1 <= df <= 30 else 1.96 + + +def _pct_lower(med_a: float, med_b: float) -> float: + """Percent by which the smaller median is lower than the larger one.""" + denom = max(abs(med_a), abs(med_b)) + return abs(med_a - med_b) / denom * 100 if denom else 0.0 + + +def _histogram_bins(values: list[float], n_bins: int) -> list[tuple[float, float, int]]: + """Equal-width (start, end, count) bins. The last bin ends exactly at the + maximum and is closed on the right, so the counts always sum to len(values).""" + v_min, v_max = min(values), max(values) + bin_width = (v_max - v_min) / n_bins if v_max > v_min else 1.0 + edges = [v_min + i * bin_width for i in range(n_bins + 1)] + if v_max > v_min: + edges[-1] = v_max + bins = [] + for i in range(n_bins): + b_start, b_end = edges[i], edges[i + 1] + last = i == n_bins - 1 + count = sum(1 for v in values if b_start <= v < b_end or (last and v == b_end)) + bins.append((b_start, b_end, count)) + return bins + + +def _join_target_names(names: list[str]) -> str | None: + """Tooltip label for a night: sorted names, at most three, then "+N more".""" + names = sorted(names) + if not names: + return None + label = ", ".join(names[:3]) + return f"{label} +{len(names) - 3} more" if len(names) > 3 else label + + def _compute_trend(points: list[CorrelationPoint]) -> TrendLine | None: """Linear regression with Pearson r, Spearman rho, and 95% confidence band.""" if len(points) < 3: @@ -302,7 +369,7 @@ def _compute_trend(points: list[CorrelationPoint]) -> TrendLine | None: # Confidence band (95%) at evenly spaced x points mean_x = sum_x / n se = math.sqrt(ss_res / (n - 2)) if n > 2 and ss_res > 0 else 0 - t_val = 1.96 if n > 30 else 2.0 + t_val = _t_crit_95(n - 2) x_sorted = sorted(xs) x_min, x_max = x_sorted[0], x_sorted[-1] @@ -319,12 +386,14 @@ def _compute_trend(points: list[CorrelationPoint]) -> TrendLine | None: upper.append(ConfidenceBandPoint(x=round(bx, 6), y=round(y_hat + margin, 6))) lower.append(ConfidenceBandPoint(x=round(bx, 6), y=round(y_hat - margin, 6))) + pearson = _pearson_r(xs, ys) + spearman = _spearman_rho(xs, ys) return TrendLine( slope=round(slope, 6), intercept=round(intercept, 6), r_squared=round(r_squared, 4), - pearson_r=round(_pearson_r(xs, ys), 4), - spearman_rho=round(_spearman_rho(xs, ys), 4), + pearson_r=round(pearson, 4) if pearson is not None else None, + spearman_rho=round(spearman, 4) if spearman is not None else None, confidence_upper=upper, confidence_lower=lower, ) @@ -406,7 +475,7 @@ async def _compute(): .where(Image.capture_date.is_not(None)) ) q = await _apply_filters(q, session, telescope, camera, filter_used, date_from, date_to) - rows = (await session.execute(q)).all() + rows = (await session.execute(q.order_by(*_ROW_ORDER))).all() if granularity == "frame": target_ids = {r.resolved_target_id for r in rows if r.resolved_target_id} @@ -438,12 +507,15 @@ async def _compute(): # Outlier detection all_xs = [p[0] for p in raw_points] all_ys = [p[1] for p in raw_points] + if len(raw_points) >= 4: + x_lo, x_hi = _iqr_fences(all_xs) + y_lo, y_hi = _iqr_fences(all_ys) points = [ CorrelationPoint( x=x, y=y, date=d, target_id=str(tid) if tid is not None else None, - outlier=_is_outlier_iqr(x, y, all_xs, all_ys) if len(raw_points) >= 4 else False, + outlier=(x < x_lo or x > x_hi or y < y_lo or y > y_hi) if len(raw_points) >= 4 else False, ) for x, y, d, tid in raw_points ] @@ -460,6 +532,11 @@ async def _compute(): # stride, which preserves the overall shape and keeps outliers spread # across the range. sampled_count/total_count let the client note that # it is showing a subset. + mixed = ( + (x_metric in _PIXEL_METRICS or y_metric in _PIXEL_METRICS) + and await _has_mixed_plate_scales(session, telescope, camera, filter_used, date_from, date_to) + ) + total_count = len(points) if total_count > _CORRELATION_POINT_CAP: step = total_count / _CORRELATION_POINT_CAP @@ -478,6 +555,7 @@ async def _compute(): target_names={str(tid): name for tid, name in target_names.items()}, total_count=total_count, sampled_count=len(returned_points), + mixed_plate_scales=mixed, ).model_dump() data = await cached_json(cache_key, _ANALYSIS_CACHE_TTL, _compute) @@ -515,7 +593,7 @@ async def _compute(): .where(Image.capture_date.is_not(None)) ) q = await _apply_filters(q, session, telescope, camera, filter_used, date_from, date_to) - rows = (await session.execute(q)).all() + rows = (await session.execute(q.order_by(*_ROW_ORDER))).all() if granularity == "session": groups: dict[tuple, list[float]] = defaultdict(list) @@ -532,19 +610,14 @@ async def _compute(): # Sturges' rule for bin count n_bins = max(1, int(math.ceil(math.log2(len(values)) + 1))) - v_min, v_max = min(values), max(values) - bin_width = (v_max - v_min) / n_bins if v_max > v_min else 1.0 - - bins = [] - for i in range(n_bins): - b_start = v_min + i * bin_width - b_end = b_start + bin_width - count = sum(1 for v in values if (b_start <= v < b_end) or (i == n_bins - 1 and v == b_end)) - bins.append(HistogramBin(bin_start=round(b_start, 6), bin_end=round(b_end, 6), count=count)) - - # Skewness (Fisher) - mean = stats.mean - std = stats.std_dev + bins = [ + HistogramBin(bin_start=round(b_start, 6), bin_end=round(b_end, 6), count=count) + for b_start, b_end, count in _histogram_bins(values, n_bins) + ] + + # Skewness (Fisher), from the full-precision mean and sample std dev + mean = statistics.fmean(values) + std = statistics.stdev(values) n = len(values) if std > 0 and n > 2: skewness = (n / ((n - 1) * (n - 2))) * sum(((v - mean) / std) ** 3 for v in values) @@ -556,11 +629,10 @@ async def _compute(): stats=stats, metric=metric, skewness=round(skewness, 4), + mixed_plate_scales=metric in _PIXEL_METRICS and await _has_mixed_plate_scales(session, telescope, camera, filter_used, date_from, date_to), ).model_dump() data = await cached_json(cache_key, _ANALYSIS_CACHE_TTL, _compute) - if data is None: - raise HTTPException(400, "Not enough data points for distribution") return DistributionResponse(**data) @@ -594,7 +666,7 @@ async def _compute(): if group_by in ("equipment", "filter"): extra_cols = [Image.telescope, Image.camera, Image.filter_used] elif group_by == "month": - extra_cols = [func.to_char(Image.capture_date, "YYYY-MM").label("month_grp")] + extra_cols = [func.to_char(Image.session_date, "YYYY-MM").label("month_grp")] else: # target extra_cols = [Image.resolved_target_id] @@ -605,7 +677,7 @@ async def _compute(): .where(Image.capture_date.is_not(None)) ) q = await _apply_filters(q, session, telescope, camera, filter_used, date_from, date_to) - rows = (await session.execute(q)).all() + rows = (await session.execute(q.order_by(*_ROW_ORDER))).all() # Load alias maps for normalization filter_map, cam_map, tel_map = await load_alias_maps(session) @@ -646,12 +718,21 @@ async def _compute(): grouped = resolved_groups groups = [] + skipped = [] for name, vals in sorted(grouped.items()): bp = _compute_box_plot(vals, name) if bp: groups.append(bp) + else: + skipped.append(SkippedGroup(group_name=name, count=len(vals))) - return BoxPlotResponse(groups=groups, metric=metric, group_by=group_by).model_dump() + return BoxPlotResponse( + groups=groups, + metric=metric, + group_by=group_by, + skipped_groups=skipped, + mixed_plate_scales=metric in _PIXEL_METRICS and await _has_mixed_plate_scales(session, telescope, camera, filter_used, date_from, date_to), + ).model_dump() data = await cached_json(cache_key, _ANALYSIS_CACHE_TTL, _compute) return BoxPlotResponse(**data) @@ -691,7 +772,7 @@ async def _compute(): .where(Image.capture_date.is_not(None)) ) q = await _apply_filters(q, session, telescope, camera, filter_used, date_from, date_to) - rows = (await session.execute(q)).all() + rows = (await session.execute(q.order_by(*_ROW_ORDER))).all() nightly: dict[str, dict] = defaultdict(lambda: {"vals": [], "target_ids": set()}) for r in rows: @@ -709,8 +790,9 @@ async def _compute(): points = [] for night in sorted_nights: g = nightly[night] - tid_list = list(g["target_ids"]) - target_name = tnames.get(tid_list[0]) if tid_list else None + target_name = _join_target_names( + [tnames[tid] for tid in g["target_ids"] if tnames.get(tid)] + ) points.append(TimeSeriesPoint( date=night, value=round(statistics.median(g["vals"]), 6), @@ -749,6 +831,7 @@ def _moving_avg(vals, window): ma_30=ma_30, metric=metric, month_boundaries=month_boundaries, + mixed_plate_scales=metric in _PIXEL_METRICS and await _has_mixed_plate_scales(session, telescope, camera, filter_used, date_from, date_to), ).model_dump() data = await cached_json(cache_key, _ANALYSIS_CACHE_TTL, _compute) @@ -812,7 +895,13 @@ async def _compute(): else: cells.append(MatrixCell(x_metric=xm, y_metric=ym, pearson_r=None, n_points=n_points)) - return MatrixResponse(cells=cells, x_metrics=X_METRICS, y_metrics=Y_METRICS).model_dump() + # Y_METRICS always includes the pixel-domain hfr, so always evaluate. + return MatrixResponse( + cells=cells, + x_metrics=X_METRICS, + y_metrics=Y_METRICS, + mixed_plate_scales=await _has_mixed_plate_scales(session, telescope, camera, filter_used, date_from, date_to), + ).model_dump() data = await cached_json(cache_key, _MATRIX_CACHE_TTL, _compute) return MatrixResponse(**data) @@ -867,7 +956,7 @@ async def _fetch_values(group: str) -> tuple[list[float], list[float]]: else: q = q.where(Image.filter_used == group) - rows = (await session.execute(q)).all() + rows = (await session.execute(q.order_by(*_ROW_ORDER))).all() vals = [float(r.val) for r in rows] arcsec_vals = [ float(r.val) * float(r.scale) @@ -888,12 +977,7 @@ async def _fetch_values(group: str) -> tuple[list[float], list[float]]: stats_b = _compute_summary_stats(vals_b) def _pct_verdict(med_a: float, med_b: float, unit: str = "") -> str: - if med_a != 0: - pct_diff = abs(med_a - med_b) / abs(med_a) * 100 - elif med_b != 0: - pct_diff = abs(med_a - med_b) / abs(med_b) * 100 - else: - pct_diff = 0 + pct_diff = _pct_lower(med_a, med_b) if med_a < med_b: return f"{group_a} has {pct_diff:.0f}% lower median{unit} than {group_b} (N={stats_a.count} vs N={stats_b.count})" elif med_b < med_a: @@ -916,7 +1000,7 @@ def _pct_verdict(med_a: float, med_b: float, unit: str = "") -> str: verdict = ( f"{group_a} and {group_b} cannot be compared: {metric} is measured in pixels " "and one or both groups lack the plate-scale headers (XPIXSZ/FOCALLEN) " - "needed to convert to arcseconds" + "needed to convert to arcseconds." ) else: verdict = _pct_verdict(stats_a.median, stats_b.median) diff --git a/backend/app/schemas/analysis.py b/backend/app/schemas/analysis.py index ae1dce63..81f047f3 100644 --- a/backend/app/schemas/analysis.py +++ b/backend/app/schemas/analysis.py @@ -27,8 +27,9 @@ class TrendLine(BaseModel): slope: float intercept: float r_squared: float - pearson_r: float - spearman_rho: float + # None when undefined: fewer than 3 points or a constant axis. + pearson_r: float | None + spearman_rho: float | None confidence_upper: list[ConfidenceBandPoint] confidence_lower: list[ConfidenceBandPoint] @@ -48,6 +49,9 @@ class CorrelationResponse(BaseModel): # and x/y stats are still computed over the full set. Equal otherwise. total_count: int = 0 sampled_count: int = 0 + # True when a pixel-domain metric (hfr) is involved and the filtered frames + # span more than one plate scale, so raw pixel values are not comparable. + mixed_plate_scales: bool = False class HistogramBin(BaseModel): @@ -61,6 +65,9 @@ class DistributionResponse(BaseModel): stats: SummaryStats metric: str skewness: float + # True when a pixel-domain metric (hfr) is involved and the filtered frames + # span more than one plate scale, so raw pixel values are not comparable. + mixed_plate_scales: bool = False class BoxPlotGroup(BaseModel): @@ -74,10 +81,20 @@ class BoxPlotGroup(BaseModel): count: int +class SkippedGroup(BaseModel): + group_name: str + count: int + + class BoxPlotResponse(BaseModel): groups: list[BoxPlotGroup] metric: str group_by: str + # Groups dropped for having fewer than 4 values, sorted by group_name. + skipped_groups: list[SkippedGroup] = [] + # True when a pixel-domain metric (hfr) is involved and the filtered frames + # span more than one plate scale, so raw pixel values are not comparable. + mixed_plate_scales: bool = False class TimeSeriesPoint(BaseModel): @@ -98,6 +115,9 @@ class TimeSeriesResponse(BaseModel): ma_30: list[MovingAveragePoint] metric: str month_boundaries: list[str] + # True when a pixel-domain metric (hfr) is involved and the filtered frames + # span more than one plate scale, so raw pixel values are not comparable. + mixed_plate_scales: bool = False class MatrixCell(BaseModel): @@ -111,6 +131,9 @@ class MatrixResponse(BaseModel): cells: list[MatrixCell] x_metrics: list[str] y_metrics: list[str] + # True when a pixel-domain metric (hfr) is involved and the filtered frames + # span more than one plate scale, so raw pixel values are not comparable. + mixed_plate_scales: bool = False class CompareGroupStats(BaseModel): diff --git a/backend/tests/test_analysis_stats.py b/backend/tests/test_analysis_stats.py new file mode 100644 index 00000000..83053416 --- /dev/null +++ b/backend/tests/test_analysis_stats.py @@ -0,0 +1,85 @@ +"""Pure-logic tests for the analysis statistics helpers (issue #311).""" +import statistics + +import pytest + +from app.api.analysis import ( + _histogram_bins, + _iqr_fences, + _join_target_names, + _pct_lower, + _pearson_r, + _spearman_rho, + _t_crit_95, +) + + +def test_histogram_counts_sum_to_n_and_keep_the_maximum(): + # v_min + 10 * ((v_max - v_min) / 10) lands below v_max in floating point. + values = [0.0, 2.6245] + [0.1 * i for i in range(1, 26)] + bins = _histogram_bins(values, 10) + assert len(bins) == 10 + assert sum(count for _, _, count in bins) == len(values) + assert bins[-1][1] == 2.6245 + assert bins[-1][2] >= 1 + + +def test_histogram_constant_values(): + bins = _histogram_bins([3.0, 3.0, 3.0], 3) + assert sum(count for _, _, count in bins) == 3 + + +def test_pct_lower_uses_the_larger_median_and_is_symmetric(): + assert round(_pct_lower(1.772, 3.997)) == 56 + assert _pct_lower(1.772, 3.997) == _pct_lower(3.997, 1.772) + assert _pct_lower(0.0, 0.0) == 0.0 + assert _pct_lower(0.0, 2.0) == 100.0 + + +def test_pearson_r_none_when_undefined(): + assert _pearson_r([1.0, 2.0], [1.0, 2.0]) is None + assert _pearson_r([1.0, 1.0, 1.0, 1.0], [1.0, 2.0, 3.0, 4.0]) is None + assert _pearson_r([1.0, 2.0, 3.0, 4.0], [5.0, 5.0, 5.0, 5.0]) is None + assert _spearman_rho([1.0, 2.0], [1.0, 2.0]) is None + assert _spearman_rho([1.0, 1.0, 1.0], [1.0, 2.0, 3.0]) is None + + +def test_pearson_r_perfect_correlation(): + xs = [1.0, 2.0, 3.0, 4.0, 5.0] + assert _pearson_r(xs, [2 * x + 1 for x in xs]) == pytest.approx(1.0) + assert _pearson_r(xs, [-x for x in xs]) == pytest.approx(-1.0) + + +def test_t_critical_values(): + assert _t_crit_95(2) == pytest.approx(4.303, abs=0.001) + assert _t_crit_95(30) == pytest.approx(2.042, abs=0.001) + assert _t_crit_95(31) == 1.96 + + +def _brute_force_outlier(x, y, xs, ys): + for vals, val in [(xs, x), (ys, y)]: + s = sorted(vals) + n = len(s) + q1 = statistics.median(s[: n // 2]) + q3 = statistics.median(s[(n + 1) // 2 :]) + iqr = q3 - q1 + if val < q1 - 1.5 * iqr or val > q3 + 1.5 * iqr: + return True + return False + + +def test_iqr_fences_match_per_point_computation(): + xs = [1.0, 1.2, 0.9, 1.1, 1.05, 9.0, 1.15, 0.95, -6.0] + ys = [2.0, 2.1, 1.9, 40.0, 2.05, 2.0, 1.95, 2.2, 2.1] + x_lo, x_hi = _iqr_fences(xs) + y_lo, y_hi = _iqr_fences(ys) + flags = [x < x_lo or x > x_hi or y < y_lo or y > y_hi for x, y in zip(xs, ys)] + assert flags == [_brute_force_outlier(x, y, xs, ys) for x, y in zip(xs, ys)] + assert flags.count(True) == 3 + + +def test_join_target_names(): + assert _join_target_names([]) is None + assert _join_target_names(["M 31"]) == "M 31" + assert _join_target_names(["NGC 7000", "M 31"]) == "M 31, NGC 7000" + assert _join_target_names(["d", "b", "a", "c", "e"]) == "a, b, c +2 more" diff --git a/frontend/openapi.json b/frontend/openapi.json index 568d04fd..e0f64a12 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -316,6 +316,126 @@ "title": "AggregateStats", "type": "object" }, + "ApiKeyCreateRequest": { + "properties": { + "can_write": { + "default": false, + "title": "Can Write", + "type": "boolean" + }, + "name": { + "maxLength": 100, + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ApiKeyCreateRequest", + "type": "object" + }, + "ApiKeyCreateResponse": { + "description": "Creation is the only response that ever carries the raw key.", + "properties": { + "can_write": { + "title": "Can Write", + "type": "boolean" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "key": { + "title": "Key", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "prefix": { + "title": "Prefix", + "type": "string" + } + }, + "required": [ + "key", + "id", + "name", + "prefix", + "can_write", + "created_at" + ], + "title": "ApiKeyCreateResponse", + "type": "object" + }, + "ApiKeyResponse": { + "properties": { + "can_write": { + "title": "Can Write", + "type": "boolean" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "last_used_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + }, + "name": { + "title": "Name", + "type": "string" + }, + "prefix": { + "title": "Prefix", + "type": "string" + }, + "revoked_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revoked At" + } + }, + "required": [ + "id", + "name", + "prefix", + "can_write", + "created_at" + ], + "title": "ApiKeyResponse", + "type": "object" + }, "AppLogItem": { "properties": { "id": { @@ -461,7 +581,7 @@ "Body_restore_backup_endpoint_api_backup_restore_post": { "properties": { "file": { - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "File", "type": "string" }, @@ -485,7 +605,7 @@ "Body_validate_backup_endpoint_api_backup_validate_post": { "properties": { "file": { - "format": "binary", + "contentMediaType": "application/octet-stream", "title": "File", "type": "string" }, @@ -816,6 +936,19 @@ "metric": { "title": "Metric", "type": "string" + }, + "mixed_plate_scales": { + "default": false, + "title": "Mixed Plate Scales", + "type": "boolean" + }, + "skipped_groups": { + "default": [], + "items": { + "$ref": "#/components/schemas/SkippedGroup" + }, + "title": "Skipped Groups", + "type": "array" } }, "required": [ @@ -1096,6 +1229,11 @@ "title": "Granularity", "type": "string" }, + "mixed_plate_scales": { + "default": false, + "title": "Mixed Plate Scales", + "type": "boolean" + }, "points": { "items": { "$ref": "#/components/schemas/CorrelationPoint" @@ -1797,6 +1935,11 @@ "title": "Metric", "type": "string" }, + "mixed_plate_scales": { + "default": false, + "title": "Mixed Plate Scales", + "type": "boolean" + }, "skewness": { "title": "Skewness", "type": "number" @@ -3130,80 +3273,135 @@ "title": "FrameRecord", "type": "object" }, - "GeneralSettings": { + "FromSessionsCreateRequest": { "properties": { - "activity_retention_days": { - "default": 90, - "maximum": 3650.0, - "minimum": 1.0, - "title": "Activity Retention Days", - "type": "integer" - }, - "app_log_capture_level": { - "default": "warning", - "pattern": "^(debug|info|warning|error)$", - "title": "App Log Capture Level", + "mode": { + "enum": [ + "new", + "existing" + ], + "title": "Mode", "type": "string" }, - "app_log_max_rows": { - "default": 50000, - "maximum": 5000000.0, - "minimum": 1000.0, - "title": "App Log Max Rows", - "type": "integer" - }, - "app_log_retention_days": { - "default": 14, - "maximum": 3650.0, - "minimum": 1.0, - "title": "App Log Retention Days", - "type": "integer" + "mosaic_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mosaic Id" }, - "astrobin_bortle": { + "name": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Astrobin Bortle" + "title": "Name" }, - "astrobin_filter_ids": { - "additionalProperties": { - "type": "integer" + "panels": { + "items": { + "$ref": "#/components/schemas/FromSessionsPanelEntry" }, - "default": {}, - "title": "Astrobin Filter Ids", - "type": "object" - }, - "auto_scan_enabled": { - "default": true, - "title": "Auto Scan Enabled", - "type": "boolean" + "minItems": 1, + "title": "Panels", + "type": "array" }, - "auto_scan_interval": { - "default": 240, - "title": "Auto Scan Interval", + "target_id": { + "format": "uuid", + "title": "Target Id", + "type": "string" + } + }, + "required": [ + "mode", + "target_id", + "panels" + ], + "title": "FromSessionsCreateRequest", + "type": "object" + }, + "FromSessionsCreateResponse": { + "properties": { + "claimed_frames": { + "title": "Claimed Frames", "type": "integer" }, - "content_width": { - "default": "extra-wide", - "title": "Content Width", + "id": { + "title": "Id", "type": "string" }, - "default_page_size": { - "default": 50, - "title": "Default Page Size", + "name": { + "title": "Name", + "type": "string" + }, + "panel_count": { + "title": "Panel Count", "type": "integer" + } + }, + "required": [ + "id", + "name", + "panel_count", + "claimed_frames" + ], + "title": "FromSessionsCreateResponse", + "type": "object" + }, + "FromSessionsMosaicOption": { + "description": "A mosaic that already has at least one panel on the prefill target,\noffered as an add-to-existing destination.", + "properties": { + "id": { + "title": "Id", + "type": "string" }, - "filter_style": { - "default": "text-only", - "title": "Filter Style", + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "title": "FromSessionsMosaicOption", + "type": "object" + }, + "FromSessionsPanelEntry": { + "properties": { + "panel_label": { + "minLength": 1, + "title": "Panel Label", "type": "string" }, - "frame_list_base_folder": { + "rows": { + "items": { + "$ref": "#/components/schemas/FromSessionsPanelRow" + }, + "minItems": 1, + "title": "Rows", + "type": "array" + } + }, + "required": [ + "panel_label", + "rows" + ], + "title": "FromSessionsPanelEntry", + "type": "object" + }, + "FromSessionsPanelRow": { + "description": "One (session_date, original_panel_label) selection for a panel entry.\n\noriginal_panel_label is the label as parsed from OBJECT at ingest (what\nImage.panel_label actually carries), or None for unlabeled frames; the\nentry's panel_label may be a user-edited final name that no frame\ncarries, so the claim must match on the original label.", + "properties": { + "original_panel_label": { "anyOf": [ { "type": "string" @@ -3212,21 +3410,181 @@ "type": "null" } ], - "title": "Frame List Base Folder" - }, - "include_calibration": { - "default": false, - "title": "Include Calibration", - "type": "boolean" + "title": "Original Panel Label" }, - "mosaic_campaign_gap_days": { - "default": 0, - "title": "Mosaic Campaign Gap Days", - "type": "integer" - }, - "mosaic_keywords": { - "default": [ - "Panel", + "session_date": { + "title": "Session Date", + "type": "string" + } + }, + "required": [ + "session_date" + ], + "title": "FromSessionsPanelRow", + "type": "object" + }, + "FromSessionsPrefillResponse": { + "properties": { + "base_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Name" + }, + "mosaics": { + "items": { + "$ref": "#/components/schemas/FromSessionsMosaicOption" + }, + "title": "Mosaics", + "type": "array" + }, + "rows": { + "items": { + "$ref": "#/components/schemas/FromSessionsPrefillRow" + }, + "title": "Rows", + "type": "array" + } + }, + "required": [ + "rows", + "mosaics" + ], + "title": "FromSessionsPrefillResponse", + "type": "object" + }, + "FromSessionsPrefillRow": { + "description": "One distinct (session_date, panel_label) pair over a target's LIGHT\nframes on the requested dates. panel_label is None for frames whose\nOBJECT header carried no panel token at ingest.", + "properties": { + "frame_count": { + "title": "Frame Count", + "type": "integer" + }, + "panel_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Panel Label" + }, + "session_date": { + "title": "Session Date", + "type": "string" + } + }, + "required": [ + "session_date", + "frame_count" + ], + "title": "FromSessionsPrefillRow", + "type": "object" + }, + "GeneralSettings": { + "properties": { + "activity_retention_days": { + "default": 90, + "maximum": 3650.0, + "minimum": 1.0, + "title": "Activity Retention Days", + "type": "integer" + }, + "app_log_capture_level": { + "default": "warning", + "pattern": "^(debug|info|warning|error)$", + "title": "App Log Capture Level", + "type": "string" + }, + "app_log_max_rows": { + "default": 50000, + "maximum": 5000000.0, + "minimum": 1000.0, + "title": "App Log Max Rows", + "type": "integer" + }, + "app_log_retention_days": { + "default": 14, + "maximum": 3650.0, + "minimum": 1.0, + "title": "App Log Retention Days", + "type": "integer" + }, + "astrobin_bortle": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Astrobin Bortle" + }, + "astrobin_filter_ids": { + "additionalProperties": { + "type": "integer" + }, + "default": {}, + "title": "Astrobin Filter Ids", + "type": "object" + }, + "auto_scan_enabled": { + "default": true, + "title": "Auto Scan Enabled", + "type": "boolean" + }, + "auto_scan_interval": { + "default": 240, + "title": "Auto Scan Interval", + "type": "integer" + }, + "content_width": { + "default": "extra-wide", + "title": "Content Width", + "type": "string" + }, + "default_page_size": { + "default": 50, + "title": "Default Page Size", + "type": "integer" + }, + "filter_style": { + "default": "text-only", + "title": "Filter Style", + "type": "string" + }, + "frame_list_base_folder": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Frame List Base Folder" + }, + "include_calibration": { + "default": false, + "title": "Include Calibration", + "type": "boolean" + }, + "mosaic_campaign_gap_days": { + "default": 0, + "title": "Mosaic Campaign Gap Days", + "type": "integer" + }, + "mosaic_keywords": { + "default": [ + "Panel", "P" ], "items": { @@ -3934,6 +4292,11 @@ "title": "Cells", "type": "array" }, + "mixed_plate_scales": { + "default": false, + "title": "Mixed Plate Scales", + "type": "boolean" + }, "x_metrics": { "items": { "type": "string" @@ -6918,6 +7281,17 @@ ], "title": "New Files" }, + "pending_rescan": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Pending Rescan" + }, "percent": { "anyOf": [ { @@ -7122,6 +7496,11 @@ "title": "New Files", "type": "integer" }, + "pending_rescan": { + "default": false, + "title": "Pending Rescan", + "type": "boolean" + }, "percent": { "default": 0.0, "title": "Percent", @@ -8094,6 +8473,24 @@ "title": "SiteCoords", "type": "object" }, + "SkippedGroup": { + "properties": { + "count": { + "title": "Count", + "type": "integer" + }, + "group_name": { + "title": "Group Name", + "type": "string" + } + }, + "required": [ + "group_name", + "count" + ], + "title": "SkippedGroup", + "type": "object" + }, "StatsResponse": { "properties": { "data_quality": { @@ -9329,6 +9726,11 @@ "title": "Metric", "type": "string" }, + "mixed_plate_scales": { + "default": false, + "title": "Mixed Plate Scales", + "type": "boolean" + }, "month_boundaries": { "items": { "type": "string" @@ -9440,8 +9842,15 @@ "type": "number" }, "pearson_r": { - "title": "Pearson R", - "type": "number" + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Pearson R" }, "r_squared": { "title": "R Squared", @@ -9452,8 +9861,15 @@ "type": "number" }, "spearman_rho": { - "title": "Spearman Rho", - "type": "number" + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Spearman Rho" } }, "required": [ @@ -9655,6 +10071,13 @@ }, "ValidationError": { "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, "loc": { "items": { "anyOf": [ @@ -11199,25 +11622,37 @@ ] } }, - "/api/auth/login": { - "post": { - "operationId": "login_api_auth_login_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } + "/api/apikeys": { + "get": { + "operationId": "list_api_keys_api_apikeys_get", + "parameters": [ + { + "in": "cookie", + "name": "access_token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Access Token" } - }, - "required": true - }, + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LoginResponse" + "items": { + "$ref": "#/components/schemas/ApiKeyResponse" + }, + "title": "Response List Api Keys Api Apikeys Get", + "type": "array" } } }, @@ -11234,13 +11669,216 @@ "description": "Validation Error" } }, - "summary": "Login", + "summary": "List Api Keys", "tags": [ - "auth" + "apikeys" ] - } - }, - "/api/auth/logout": { + }, + "post": { + "operationId": "create_key_api_apikeys_post", + "parameters": [ + { + "in": "cookie", + "name": "access_token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Access Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyCreateResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Key", + "tags": [ + "apikeys" + ] + } + }, + "/api/apikeys/{key_id}": { + "delete": { + "operationId": "delete_key_api_apikeys__key_id__delete", + "parameters": [ + { + "in": "path", + "name": "key_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Key Id", + "type": "string" + } + }, + { + "in": "cookie", + "name": "access_token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Access Token" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Key", + "tags": [ + "apikeys" + ] + } + }, + "/api/apikeys/{key_id}/permanent": { + "delete": { + "description": "Hard-delete a revoked key. Active keys must be revoked first (409).", + "operationId": "delete_key_permanently_api_apikeys__key_id__permanent_delete", + "parameters": [ + { + "in": "path", + "name": "key_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Key Id", + "type": "string" + } + }, + { + "in": "cookie", + "name": "access_token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Access Token" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Key Permanently", + "tags": [ + "apikeys" + ] + } + }, + "/api/auth/login": { + "post": { + "operationId": "login_api_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Login", + "tags": [ + "auth" + ] + } + }, + "/api/auth/logout": { "post": { "operationId": "logout_api_auth_logout_post", "parameters": [ @@ -13286,6 +13924,135 @@ ] } }, + "/api/mosaics/from-sessions": { + "post": { + "description": "Create a mosaic (or extend an existing one) from explicit sessions.\n\nOne transaction: mosaic, panels, session membership, and frame claiming\ncommit together.\n\nFrames are claimed here rather than left to a later (target,\npanel_label) lookup because that lookup is ambiguous when several\ncampaign mosaics share both the target and the label; the caller's\nexplicit per-row (session_date, original_panel_label) selection is what\nmakes the claim deterministic. Each row claims frames carrying its\nORIGINAL parsed label (entry.panel_label may be a user-edited final name\nno frame carries) or, for a None row, frames with no label at all, with\nno image_type filter (mirroring retro_link_panel_images). Previously\nunlabeled claimed rows are stamped with the entry's label; a real parsed\nlabel is never overwritten with an edited name -- panel_id is the\nauthoritative membership and panel_label stays OBJECT-derived.", + "operationId": "create_mosaic_from_sessions_api_mosaics_from_sessions_post", + "parameters": [ + { + "in": "cookie", + "name": "access_token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Access Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FromSessionsCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FromSessionsCreateResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Mosaic From Sessions", + "tags": [ + "mosaics" + ] + } + }, + "/api/mosaics/from-sessions/prefill": { + "get": { + "description": "Prefill data for the create-mosaic-from-sessions dialog.\n\nFor the given target and session dates, returns the most common\npanel-token base name parsed from the LIGHT frames' OBJECT headers (to\nseed the new-mosaic name), one row per distinct (session_date,\npanel_label) pair (to seed the panel/date selection), and the mosaics\nthat already contain a panel on this target (for the add-to-existing\ndropdown).", + "operationId": "from_sessions_prefill_api_mosaics_from_sessions_prefill_get", + "parameters": [ + { + "in": "query", + "name": "target_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Target Id", + "type": "string" + } + }, + { + "in": "query", + "name": "dates", + "required": true, + "schema": { + "title": "Dates", + "type": "string" + } + }, + { + "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/FromSessionsPrefillResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "From Sessions Prefill", + "tags": [ + "mosaics" + ] + } + }, "/api/mosaics/suggestions": { "get": { "operationId": "get_suggestions_api_mosaics_suggestions_get", @@ -14494,7 +15261,7 @@ }, "/api/scan": { "post": { - "description": "Walk the FITS directory, queue new files for ingestion.\n\nThe heavy directory scan runs inside a Celery task so this endpoint\nreturns immediately - no nginx timeout issues on large data sets.", + "description": "Walk the FITS directory, queue new files for ingestion.\n\nThe heavy directory scan runs inside a Celery task so this endpoint\nreturns immediately - no nginx timeout issues on large data sets.\n\nTriggering while a scan is running no longer bounces: it queues a single\nfollow-up run (202, ``status: \"queued\"``) that starts when the current one\nfinishes. The walk is single-pass, so this is the only way files copied in\nafter it started get seen without someone watching for the scan to end.", "operationId": "trigger_scan_api_scan_post", "parameters": [ { diff --git a/frontend/src/api/generated/schema.d.ts b/frontend/src/api/generated/schema.d.ts index 177b684b..d7cb24c4 100644 --- a/frontend/src/api/generated/schema.d.ts +++ b/frontend/src/api/generated/schema.d.ts @@ -186,6 +186,61 @@ export interface paths { patch?: never; trace?: never; }; + "/api/apikeys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Api Keys */ + get: operations["list_api_keys_api_apikeys_get"]; + put?: never; + /** Create Key */ + post: operations["create_key_api_apikeys_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/apikeys/{key_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete Key */ + delete: operations["delete_key_api_apikeys__key_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/apikeys/{key_id}/permanent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete Key Permanently + * @description Hard-delete a revoked key. Active keys must be revoked first (409). + */ + delete: operations["delete_key_permanently_api_apikeys__key_id__permanent_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/login": { parameters: { query?: never; @@ -739,6 +794,68 @@ export interface paths { patch?: never; trace?: never; }; + "/api/mosaics/from-sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Mosaic From Sessions + * @description Create a mosaic (or extend an existing one) from explicit sessions. + * + * One transaction: mosaic, panels, session membership, and frame claiming + * commit together. + * + * Frames are claimed here rather than left to a later (target, + * panel_label) lookup because that lookup is ambiguous when several + * campaign mosaics share both the target and the label; the caller's + * explicit per-row (session_date, original_panel_label) selection is what + * makes the claim deterministic. Each row claims frames carrying its + * ORIGINAL parsed label (entry.panel_label may be a user-edited final name + * no frame carries) or, for a None row, frames with no label at all, with + * no image_type filter (mirroring retro_link_panel_images). Previously + * unlabeled claimed rows are stamped with the entry's label; a real parsed + * label is never overwritten with an edited name -- panel_id is the + * authoritative membership and panel_label stays OBJECT-derived. + */ + post: operations["create_mosaic_from_sessions_api_mosaics_from_sessions_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/mosaics/from-sessions/prefill": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * From Sessions Prefill + * @description Prefill data for the create-mosaic-from-sessions dialog. + * + * For the given target and session dates, returns the most common + * panel-token base name parsed from the LIGHT frames' OBJECT headers (to + * seed the new-mosaic name), one row per distinct (session_date, + * panel_label) pair (to seed the panel/date selection), and the mosaics + * that already contain a panel on this target (for the add-to-existing + * dropdown). + */ + get: operations["from_sessions_prefill_api_mosaics_from_sessions_prefill_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/mosaics/suggestions": { parameters: { query?: never; @@ -1086,6 +1203,11 @@ export interface paths { * * The heavy directory scan runs inside a Celery task so this endpoint * returns immediately - no nginx timeout issues on large data sets. + * + * Triggering while a scan is running no longer bounces: it queues a single + * follow-up run (202, ``status: "queued"``) that starts when the current one + * finishes. The walk is single-pass, so this is the only way files copied in + * after it started get seen without someone watching for the scan to end. */ post: operations["trigger_scan_api_scan_post"]; delete?: never; @@ -2485,6 +2607,63 @@ export interface components { /** Total Integration Seconds */ total_integration_seconds: number; }; + /** ApiKeyCreateRequest */ + ApiKeyCreateRequest: { + /** + * Can Write + * @default false + */ + can_write: boolean; + /** Name */ + name: string; + }; + /** + * ApiKeyCreateResponse + * @description Creation is the only response that ever carries the raw key. + */ + ApiKeyCreateResponse: { + /** Can Write */ + can_write: boolean; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Id + * Format: uuid + */ + id: string; + /** Key */ + key: string; + /** Name */ + name: string; + /** Prefix */ + prefix: string; + }; + /** ApiKeyResponse */ + ApiKeyResponse: { + /** Can Write */ + can_write: boolean; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Id + * Format: uuid + */ + id: string; + /** Last Used At */ + last_used_at?: string | null; + /** Name */ + name: string; + /** Prefix */ + prefix: string; + /** Revoked At */ + revoked_at?: string | null; + }; /** AppLogItem */ AppLogItem: { /** Id */ @@ -2549,10 +2728,7 @@ export interface components { }; /** Body_restore_backup_endpoint_api_backup_restore_post */ Body_restore_backup_endpoint_api_backup_restore_post: { - /** - * File - * Format: binary - */ + /** File */ file: string; /** * Mode @@ -2567,10 +2743,7 @@ export interface components { }; /** Body_validate_backup_endpoint_api_backup_validate_post */ Body_validate_backup_endpoint_api_backup_validate_post: { - /** - * File - * Format: binary - */ + /** File */ file: string; /** * Mode @@ -2692,6 +2865,16 @@ export interface components { groups: components["schemas"]["BoxPlotGroup"][]; /** Metric */ metric: string; + /** + * Mixed Plate Scales + * @default false + */ + mixed_plate_scales: boolean; + /** + * Skipped Groups + * @default [] + */ + skipped_groups: components["schemas"]["SkippedGroup"][]; }; /** BrowseEntry */ BrowseEntry: { @@ -2814,6 +2997,11 @@ export interface components { CorrelationResponse: { /** Granularity */ granularity: string; + /** + * Mixed Plate Scales + * @default false + */ + mixed_plate_scales: boolean; /** Points */ points: components["schemas"]["CorrelationPoint"][]; /** @@ -3053,6 +3241,11 @@ export interface components { bins: components["schemas"]["HistogramBin"][]; /** Metric */ metric: string; + /** + * Mixed Plate Scales + * @default false + */ + mixed_plate_scales: boolean; /** Skewness */ skewness: number; stats: components["schemas"]["SummaryStats"]; @@ -3419,6 +3612,92 @@ export interface components { /** Wind Speed */ wind_speed?: number | null; }; + /** FromSessionsCreateRequest */ + FromSessionsCreateRequest: { + /** + * Mode + * @enum {string} + */ + mode: "new" | "existing"; + /** Mosaic Id */ + mosaic_id?: string | null; + /** Name */ + name?: string | null; + /** Panels */ + panels: components["schemas"]["FromSessionsPanelEntry"][]; + /** + * Target Id + * Format: uuid + */ + target_id: string; + }; + /** FromSessionsCreateResponse */ + FromSessionsCreateResponse: { + /** Claimed Frames */ + claimed_frames: number; + /** Id */ + id: string; + /** Name */ + name: string; + /** Panel Count */ + panel_count: number; + }; + /** + * FromSessionsMosaicOption + * @description A mosaic that already has at least one panel on the prefill target, + * offered as an add-to-existing destination. + */ + FromSessionsMosaicOption: { + /** Id */ + id: string; + /** Name */ + name: string; + }; + /** FromSessionsPanelEntry */ + FromSessionsPanelEntry: { + /** Panel Label */ + panel_label: string; + /** Rows */ + rows: components["schemas"]["FromSessionsPanelRow"][]; + }; + /** + * FromSessionsPanelRow + * @description One (session_date, original_panel_label) selection for a panel entry. + * + * original_panel_label is the label as parsed from OBJECT at ingest (what + * Image.panel_label actually carries), or None for unlabeled frames; the + * entry's panel_label may be a user-edited final name that no frame + * carries, so the claim must match on the original label. + */ + FromSessionsPanelRow: { + /** Original Panel Label */ + original_panel_label?: string | null; + /** Session Date */ + session_date: string; + }; + /** FromSessionsPrefillResponse */ + FromSessionsPrefillResponse: { + /** Base Name */ + base_name?: string | null; + /** Mosaics */ + mosaics: components["schemas"]["FromSessionsMosaicOption"][]; + /** Rows */ + rows: components["schemas"]["FromSessionsPrefillRow"][]; + }; + /** + * FromSessionsPrefillRow + * @description One distinct (session_date, panel_label) pair over a target's LIGHT + * frames on the requested dates. panel_label is None for frames whose + * OBJECT header carried no panel token at ingest. + */ + FromSessionsPrefillRow: { + /** Frame Count */ + frame_count: number; + /** Panel Label */ + panel_label?: string | null; + /** Session Date */ + session_date: string; + }; /** GeneralSettings */ GeneralSettings: { /** @@ -3774,6 +4053,11 @@ export interface components { MatrixResponse: { /** Cells */ cells: components["schemas"]["MatrixCell"][]; + /** + * Mixed Plate Scales + * @default false + */ + mixed_plate_scales: boolean; /** X Metrics */ x_metrics: string[]; /** Y Metrics */ @@ -4795,6 +5079,8 @@ export interface components { message?: string | null; /** New Files */ new_files?: number | null; + /** Pending Rescan */ + pending_rescan?: boolean | null; /** Percent */ percent?: number | null; /** Removed */ @@ -4867,6 +5153,11 @@ export interface components { * @default 0 */ new_files: number; + /** + * Pending Rescan + * @default false + */ + pending_rescan: boolean; /** * Percent * @default 0 @@ -5203,6 +5494,13 @@ export interface components { /** Longitude */ longitude: number; }; + /** SkippedGroup */ + SkippedGroup: { + /** Count */ + count: number; + /** Group Name */ + group_name: string; + }; /** StatsResponse */ StatsResponse: { data_quality: components["schemas"]["DataQualityStats"]; @@ -5601,6 +5899,11 @@ export interface components { ma_7: components["schemas"]["MovingAveragePoint"][]; /** Metric */ metric: string; + /** + * Mixed Plate Scales + * @default false + */ + mixed_plate_scales: boolean; /** Month Boundaries */ month_boundaries: string[]; /** Points */ @@ -5638,13 +5941,13 @@ export interface components { /** Intercept */ intercept: number; /** Pearson R */ - pearson_r: number; + pearson_r: number | null; /** R Squared */ r_squared: number; /** Slope */ slope: number; /** Spearman Rho */ - spearman_rho: number; + spearman_rho: number | null; }; /** UserCreateRequest */ UserCreateRequest: { @@ -5714,6 +6017,10 @@ export interface components { }; /** ValidationError */ ValidationError: { + /** Context */ + ctx?: Record; + /** Input */ + input?: unknown; /** Location */ loc: (string | number)[]; /** Message */ @@ -6247,6 +6554,134 @@ export interface operations { }; }; }; + list_api_keys_api_apikeys_get: { + 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"]["ApiKeyResponse"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_key_api_apikeys_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + access_token?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ApiKeyCreateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiKeyCreateResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_key_api_apikeys__key_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + key_id: string; + }; + cookie?: { + access_token?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_key_permanently_api_apikeys__key_id__permanent_delete: { + parameters: { + query?: never; + header?: never; + path: { + key_id: string; + }; + cookie?: { + access_token?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; login_api_auth_login_post: { parameters: { query?: never; @@ -7429,6 +7864,75 @@ export interface operations { }; }; }; + create_mosaic_from_sessions_api_mosaics_from_sessions_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + access_token?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FromSessionsCreateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FromSessionsCreateResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + from_sessions_prefill_api_mosaics_from_sessions_prefill_get: { + parameters: { + query: { + target_id: string; + dates: string; + }; + 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"]["FromSessionsPrefillResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_suggestions_api_mosaics_suggestions_get: { parameters: { query?: never; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 990eb0d5..c5c90f51 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -717,8 +717,9 @@ export interface TrendLine { slope: number; intercept: number; r_squared: number; - pearson_r: number; - spearman_rho: number; + // null when fewer than 3 points or an axis is constant + pearson_r: number | null; + spearman_rho: number | null; confidence_upper: ConfidenceBandPoint[]; confidence_lower: ConfidenceBandPoint[]; } @@ -734,6 +735,7 @@ export interface CorrelationResponse { target_names: Record; total_count?: number; sampled_count?: number; + mixed_plate_scales?: boolean; } export type HistogramBin = Schemas["HistogramBin"]; @@ -759,6 +761,7 @@ export interface TimeSeriesResponse { ma_30: MovingAveragePoint[]; metric: string; month_boundaries: string[]; + mixed_plate_scales?: boolean; } export type MatrixCell = Schemas["MatrixCell"]; diff --git a/frontend/src/components/analysis/BoxPlotChart.tsx b/frontend/src/components/analysis/BoxPlotChart.tsx index 60544115..fe85049e 100644 --- a/frontend/src/components/analysis/BoxPlotChart.tsx +++ b/frontend/src/components/analysis/BoxPlotChart.tsx @@ -15,8 +15,10 @@ const BoxPlotChart: Component = (props) => { let chartInstance: Chart | undefined; const renderChart = () => { - if (!canvasRef || props.groups.length === 0) return; + // Destroy first so a result with zero groups clears the previous chart. chartInstance?.destroy(); + chartInstance = undefined; + if (!canvasRef || props.groups.length === 0) return; const labels = props.groups.map((g) => g.group_name); const iqrData = props.groups.map((g) => [g.q1, g.q3] as [number, number]); diff --git a/frontend/src/components/analysis/CompareTab.tsx b/frontend/src/components/analysis/CompareTab.tsx index 00c40b6c..51c3c33a 100644 --- a/frontend/src/components/analysis/CompareTab.tsx +++ b/frontend/src/components/analysis/CompareTab.tsx @@ -11,7 +11,7 @@ import StatsCard from "./StatsCard"; // backend ships them; cast at the fetch boundary, same precedent as CorrelationTab. import type { CompareResponse } from "../../api/types"; import { metricOptions, METRIC_UNITS } from "../../utils/metricLabels"; -import { formatArcsec } from "../../utils/format"; +import { getErrorMessage } from "../../utils/errors"; const Y_METRICS = metricOptions([ "hfr", "fwhm", "eccentricity", "guiding_rms", "guiding_rms_ra", @@ -50,21 +50,6 @@ const CompareTab: Component = (props) => { placeholderData: keepPreviousData, })); - // When the backend flags the two sides as different optical trains for a - // pixel-domain metric, the % improvement verdict is meaningless: suppress it - // and either fall back to the arcsec medians (cross-train comparable) or say - // why no comparison is shown. - const crossTrainVerdict = (): string | null => { - const d = dataQuery.data; - if (!d || d.comparable !== false) return null; - const a = d.median_hfr_arcsec_a; - const b = d.median_hfr_arcsec_b; - if (a != null && b != null) { - return `Different optical trains: pixel HFR values are not directly comparable. Comparing in arcseconds instead: ${d.group_a.name} median ${formatArcsec(a)} vs ${d.group_b.name} median ${formatArcsec(b)}.`; - } - return `Different optical trains: pixel HFR is not comparable between these groups, and arcsecond data is unavailable (plate scale unknown). No improvement figure is shown.`; - }; - const selectClass = "text-sm bg-theme-elevated border border-theme-border rounded px-2.5 py-1.5 text-theme-text-primary"; const toggleClass = (active: boolean) => `text-sm px-3 py-1.5 rounded-[var(--radius-sm)] transition-colors ${ @@ -130,6 +115,10 @@ const CompareTab: Component = (props) => { + +
{getErrorMessage(dataQuery.error, "Failed to load comparison")}
+
+
= (props) => { />
- {dataQuery.data!.verdict}} - > -
{crossTrainVerdict()}
-
+ {/* The backend authors the verdict in every case. When comparable is + false (a pixel-domain metric where one or both groups lack plate-scale + headers) its verdict explains why no improvement figure is given, so + it is shown in secondary text rather than as a headline result. */} +
+ {dataQuery.data!.verdict} +
diff --git a/frontend/src/components/analysis/CorrelationChart.test.ts b/frontend/src/components/analysis/CorrelationChart.test.ts new file mode 100644 index 00000000..a4590531 --- /dev/null +++ b/frontend/src/components/analysis/CorrelationChart.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { describeCorrelation } from "./CorrelationChart"; +import type { CorrelationResponse } from "../../api/types"; + +const resp = (y_metric: string, slope: number, r: number | null = 0.8): CorrelationResponse => ({ + points: [1, 2, 3].map((x) => ({ x, y: x, date: "2026-01-01", target_id: null, outlier: false })), + trend: { + slope, intercept: 0, r_squared: 0.64, pearson_r: r, spearman_rho: r, + confidence_upper: [], confidence_lower: [], + }, + x_metric: "humidity", y_metric, granularity: "frame", + x_stats: null, y_stats: null, target_names: {}, +}); + +describe("describeCorrelation", () => { + it("treats a rising lower-is-better metric as worse", () => { + expect(describeCorrelation(resp("hfr", 1))).toContain("negative impact"); + expect(describeCorrelation(resp("hfr", -1))).toContain("improves"); + }); + + it("flips polarity for higher-is-better metrics", () => { + expect(describeCorrelation(resp("detected_stars", 1))).toContain("improves"); + expect(describeCorrelation(resp("detected_stars", -1))).toContain("negative impact"); + }); + + it("uses neutral wording for ADU metrics", () => { + const text = describeCorrelation(resp("adu_mean", 1)); + expect(text).toContain("increases"); + expect(text).not.toMatch(/improves|negative impact/); + }); + + it("renders null coefficients as n/a", () => { + expect(describeCorrelation(resp("hfr", 1, null))).toContain("Pearson r=n/a"); + }); +}); diff --git a/frontend/src/components/analysis/CorrelationChart.tsx b/frontend/src/components/analysis/CorrelationChart.tsx index d247b641..64651449 100644 --- a/frontend/src/components/analysis/CorrelationChart.tsx +++ b/frontend/src/components/analysis/CorrelationChart.tsx @@ -7,6 +7,7 @@ import { chartFontSize } from "../../utils/chartConfig"; // and stats-card labels everywhere. import { METRIC_LABELS } from "../../utils/metricLabels"; import { ARCSEC } from "../../utils/format"; +import { HIGHER_IS_BETTER, NO_POLARITY } from "./metricPolarity"; const METRIC_SHORT: Record = { humidity: "humidity", @@ -31,7 +32,10 @@ const METRIC_SHORT: Record = { adu_stdev: "ADU noise", }; -function describeCorrelation(data: CorrelationResponse): string { +// Coefficients are null when there are fewer than 3 points or an axis is constant. +const fmtCoef = (v: number | null | undefined): string => (v == null ? "n/a" : v.toFixed(2)); + +export function describeCorrelation(data: CorrelationResponse): string { const { trend, x_metric, y_metric, points } = data; if (!trend || points.length < 3) return `Not enough data to determine a pattern (${points.length} points).`; @@ -39,6 +43,10 @@ function describeCorrelation(data: CorrelationResponse): string { const yName = METRIC_SHORT[y_metric] || y_metric; const r2 = trend.r_squared; const rising = trend.slope > 0; + // A rising Y is worse for lower-is-better metrics and better for + // higher-is-better ones; metrics with no polarity get neutral wording. + const neutral = NO_POLARITY.has(y_metric); + const worse = rising !== HIGHER_IS_BETTER.has(y_metric); let strength: string; let verdict: string; @@ -52,17 +60,25 @@ function describeCorrelation(data: CorrelationResponse): string { : `${yName} tends to decrease slightly with higher ${xName}, but the effect is minor.`; } else if (r2 < 0.4) { strength = "Moderate correlation"; - verdict = rising - ? `Higher ${xName} is associated with worse ${yName}. Consider this a factor in your imaging conditions.` - : `Higher ${xName} is associated with better ${yName}. This is a meaningful pattern in your data.`; + if (neutral) { + verdict = `${yName} tends to ${rising ? "increase" : "decrease"} with higher ${xName}. This is a meaningful pattern in your data.`; + } else { + verdict = worse + ? `Higher ${xName} is associated with worse ${yName}. Consider this a factor in your imaging conditions.` + : `Higher ${xName} is associated with better ${yName}. This is a meaningful pattern in your data.`; + } } else { strength = "Strong correlation"; - verdict = rising - ? `${xName} has a strong negative impact on your ${yName}. This is a key factor at your site.` - : `${xName} strongly improves your ${yName}. This is a key factor at your site.`; + if (neutral) { + verdict = `${yName} ${rising ? "increases" : "decreases"} strongly with higher ${xName}. This is a key factor at your site.`; + } else { + verdict = worse + ? `${xName} has a strong negative impact on your ${yName}. This is a key factor at your site.` + : `${xName} strongly improves your ${yName}. This is a key factor at your site.`; + } } - const statsLine = `Pearson r=${trend.pearson_r.toFixed(2)}, Spearman \u03c1=${trend.spearman_rho.toFixed(2)}`; + const statsLine = `Pearson r=${fmtCoef(trend.pearson_r)}, Spearman \u03c1=${fmtCoef(trend.spearman_rho)}`; return `${strength} (R\u00b2=${r2.toFixed(2)}, ${statsLine}). ${verdict}`; } diff --git a/frontend/src/components/analysis/CorrelationTab.tsx b/frontend/src/components/analysis/CorrelationTab.tsx index d21016ce..92b8c01f 100644 --- a/frontend/src/components/analysis/CorrelationTab.tsx +++ b/frontend/src/components/analysis/CorrelationTab.tsx @@ -16,6 +16,7 @@ import type { SharedFilters } from "../../pages/AnalysisPage"; import type { CorrelationResponse } from "../../api/types"; import CorrelationChart from "./CorrelationChart"; import StatsCard from "./StatsCard"; +import PlateScaleWarning from "./PlateScaleWarning"; import { metricOptions, metricLabel, METRIC_UNITS, PHD2_X_METRICS, PHD2_METRIC_NOTE } from "../../utils/metricLabels"; const X_OPTIONS = metricOptions([ @@ -184,6 +185,8 @@ const CorrelationTab: Component = (props) => {

{samplingNote()}

+ +
diff --git a/frontend/src/components/analysis/DistributionsTab.tsx b/frontend/src/components/analysis/DistributionsTab.tsx index e1126d4c..c5a57c8c 100644 --- a/frontend/src/components/analysis/DistributionsTab.tsx +++ b/frontend/src/components/analysis/DistributionsTab.tsx @@ -7,7 +7,9 @@ import type { SharedFilters } from "../../pages/AnalysisPage"; import HistogramChart from "./HistogramChart"; import BoxPlotChart from "./BoxPlotChart"; import StatsCard from "./StatsCard"; -import { metricOptions, METRIC_UNITS, PIXEL_METRIC_NOTE } from "../../utils/metricLabels"; +import PlateScaleWarning from "./PlateScaleWarning"; +import { metricOptions, METRIC_UNITS } from "../../utils/metricLabels"; +import { getErrorMessage } from "../../utils/errors"; const ALL_METRICS = metricOptions([ "humidity", "wind_speed", "ambient_temp", "dew_point", "pressure", @@ -75,15 +77,12 @@ const DistributionsTab: Component = (props) => { placeholderData: keepPreviousData, })); - // HFR is pixel-domain, so values mixed across optical trains are not - // cross-comparable. Shown whenever the current scope can span more than one - // train (no telescope/camera filter, or the box plot is explicitly grouped by - // equipment). FWHM is always arcseconds and needs no such note. - const crossTrainNote = (metric: string, groupedByEquipment: boolean): string | null => { - if (metric !== "hfr") return null; - const singleTrain = props.filters.telescope !== undefined && props.filters.camera !== undefined; - if (singleTrain && !groupedByEquipment) return null; - return `Values are per-train pixel-domain units, not comparable across optical trains. ${PIXEL_METRIC_NOTE}`; + // Groups the backend dropped for having fewer than 4 values. When every + // group was dropped this line is the only explanation for the empty chart. + const skippedNote = (): string | null => { + const skipped = boxQuery.data?.skipped_groups; + if (!skipped || skipped.length === 0) return null; + return `Not shown (fewer than 4 frames): ${skipped.map((g) => `${g.group_name} (${g.count})`).join(", ")}`; }; const selectClass = "text-sm bg-theme-elevated border border-theme-border rounded px-2.5 py-1.5 text-theme-text-primary"; @@ -111,8 +110,9 @@ const DistributionsTab: Component = (props) => { {ALL_METRICS.map((o) => )}
- -

{crossTrainNote(histMetric(), false)}

+ + +
{getErrorMessage(histQuery.error, "Failed to load distribution")}
= (props) => { {GROUP_OPTIONS.map((o) => )}
- -

{crossTrainNote(boxMetric(), groupBy() === "equipment")}

+ + +
{getErrorMessage(boxQuery.error, "Failed to load box plot")}
= (props) => { metricLabel={Y_METRICS.find((m) => m.value === boxMetric())?.label} />
+ +

{skippedNote()}

+
); diff --git a/frontend/src/components/analysis/MatrixTab.tsx b/frontend/src/components/analysis/MatrixTab.tsx index 3a7bc9d7..97833dd2 100644 --- a/frontend/src/components/analysis/MatrixTab.tsx +++ b/frontend/src/components/analysis/MatrixTab.tsx @@ -5,6 +5,7 @@ import { unwrap } from "../../api/unwrap"; import { queryKeys } from "../../api/queryKeys"; import type { SharedFilters } from "../../pages/AnalysisPage"; import { ARCSEC } from "../../utils/format"; +import PlateScaleWarning from "./PlateScaleWarning"; const X_LABELS: Record = { humidity: "Humid.", wind_speed: "Wind", ambient_temp: "Temp", @@ -66,7 +67,9 @@ const MatrixTab: Component = (props) => { return (

Correlation Matrix

-

Pearson r for all metric pairs. Click a cell to explore in the Correlation tab.

+

Pearson r for each environment metric (columns) against each quality metric (rows). Cells with fewer than 10 paired frames show n/a. Click a cell to explore in the Correlation tab.

+ + {dataQuery.isFetching && !dataQuery.data && (
Computing correlations...
@@ -99,17 +102,17 @@ const MatrixTab: Component = (props) => { { - if (cell()?.pearson_r !== null) { + if (cell()?.pearson_r != null) { window.dispatchEvent(new CustomEvent("analysis-navigate", { detail: { tab: "correlation", x: xm, y: ym } })); } }} > - {cell()?.pearson_r !== null ? cell()!.pearson_r!.toFixed(2) : "\u2014"} + {cell()?.pearson_r != null ? cell()!.pearson_r!.toFixed(2) : "n/a"} ); diff --git a/frontend/src/components/analysis/PlateScaleWarning.tsx b/frontend/src/components/analysis/PlateScaleWarning.tsx new file mode 100644 index 00000000..a64db9ee --- /dev/null +++ b/frontend/src/components/analysis/PlateScaleWarning.tsx @@ -0,0 +1,14 @@ +import { Component, Show } from "solid-js"; + +// Rendered when an analysis response reports mixed_plate_scales. The flag is +// absent on older cached payloads, so undefined counts as false. +const PlateScaleWarning: Component<{ show: boolean | undefined }> = (props) => ( + + + +); + +export default PlateScaleWarning; diff --git a/frontend/src/components/analysis/TimeSeriesTab.tsx b/frontend/src/components/analysis/TimeSeriesTab.tsx index 5856d46d..1f1b36ee 100644 --- a/frontend/src/components/analysis/TimeSeriesTab.tsx +++ b/frontend/src/components/analysis/TimeSeriesTab.tsx @@ -13,9 +13,9 @@ import type { TimeSeriesResponse } from "../../api/types"; import TimeSeriesChart from "./TimeSeriesChart"; import { MIN_GROUP, type MetricBaseline } from "../../utils/frameQuality"; import { metricOptions } from "../../utils/metricLabels"; - // Higher-is-better metrics flip z-score polarity so "worse than baseline" stays positive. -const HIGHER_IS_BETTER = new Set(["detected_stars", "sky_quality"]); +import { HIGHER_IS_BETTER } from "./metricPolarity"; +import PlateScaleWarning from "./PlateScaleWarning"; // Robust median + MAD computed over the displayed values, matching the util's // definition (MAD = median(|x - median|)). Returns null when too sparse or uniform. @@ -94,6 +94,7 @@ const TimeSeriesTab: Component = (props) => {
+
{
  • Correlation: scatter plot of two metrics.
  • Distributions: histogram of one metric, optionally grouped.
  • -
  • Time Series: a metric plotted over time.
  • -
  • Matrix: heatmap of one metric across two categorical axes.
  • +
  • Time Series: nightly median of a metric plotted over time.
  • +
  • Matrix: grid of Pearson correlation coefficients for every environment metric against every quality metric.
  • Compare: side-by-side distributions across groups.

Each section has its own info icon with details and examples.

@@ -152,13 +152,13 @@ const AnalysisPage: Component = () => {

Shared Filters

- Scope controls that apply to every tab on this page at the same time. Switching tabs preserves your selection. + Scope controls shared by the tabs on this page. Switching tabs preserves your selection. The Compare tab uses only the date range, because it selects its own equipment or filter groups.

  • Equipment: telescope and camera combination.
  • Filter: restrict to a single optical filter (e.g. Ha, OIII, L).
  • -
  • Granularity: per frame uses each individual sub, per session aggregates by imaging session.
  • -
  • Date range: restrict by capture date.
  • +
  • Granularity: per frame uses each individual sub, per session aggregates by imaging session. Applies to the Correlation tab and the Distributions histogram only; the box plot and the other tabs ignore it.
  • +
  • Date range: restrict by imaging night (session date).

Example: pick your main scope, Ha filter, per session, last 12 months, then flip through tabs to see that slice every way.

@@ -293,11 +293,11 @@ const AnalysisPage: Component = () => {

Time Series

- A metric plotted over time, either per frame or aggregated by session or night. Useful for spotting drift, degradation, and seasonal patterns. + The nightly median of a metric plotted over time, one point per imaging night. Useful for spotting drift, degradation, and seasonal patterns.

    -
  • Camera temperature across a single night to verify cooling stability.
  • -
  • Median HFR per session across months (within one rig) to watch for focus or collimation drift.
  • +
  • Nightly median sensor temperature across a season to verify cooling stability.
  • +
  • Nightly median HFR across months (within one rig) to watch for focus or collimation drift.
@@ -314,13 +314,13 @@ const AnalysisPage: Component = () => {

Matrix

- Heatmap grid of one metric across two categorical axes. Good for spotting gaps in coverage and comparing aggregate values at a glance. + Grid of Pearson correlation coefficients for every environment metric (columns) against every quality metric (rows). Red cells are positive correlations, blue cells are negative, and stronger color means a stronger correlation. Cells with fewer than 10 paired frames show n/a.

    -
  • Filter by target shows integration time per channel for each object.
  • -
  • Telescope by filter highlights which rigs have imaged which bands.
  • +
  • A strong positive cell for humidity against HFR means stars get larger on humid nights.
  • +
  • A row of near-zero cells means none of the recorded conditions explain that quality metric.
-

Click a cell to jump to the Correlation tab pre-filtered to that combination.

+

Click a cell to open that metric pair in the Correlation tab.