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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 137 additions & 53 deletions backend/app/api/analysis.py

Large diffs are not rendered by default.

27 changes: 25 additions & 2 deletions backend/app/schemas/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down
85 changes: 85 additions & 0 deletions backend/tests/test_analysis_stats.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading