Skip to content
Open
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
10 changes: 6 additions & 4 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
name = "ref-backend"
version = "0.3.0"
description = "Backend for the Climate Rapid Evaluation Framework"
requires-python = ">=3.11"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]<1.0.0,>=0.114.2",
"pydantic>2.0",
"psycopg[binary]<4.0.0,>=3.1.13",
"pydantic-settings<3.0.0,>=2.13.1",
"sentry-sdk[fastapi]>=2.0.0",
"climate-ref[aft-providers,postgres]>=0.13.1,<0.14",
"climate-ref[aft-providers,postgres]>=0.13.1",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -HI '(Dockerfile|pyproject\.toml|requirements.*|.*\.ya?ml)$' . -x sh -c '
  echo "== $1 ==";
  rg -n "uv sync|uv pip|pip install|poetry install|hatch build" "$1" || true
' sh {}

Repository: Climate-REF/ref-app

Length of output: 2336


🏁 Script executed:

sed -n '1,120p' backend/pyproject.toml

Repository: Climate-REF/ref-app

Length of output: 2739


Pin climate-ref in the published dependency metadata too.

[tool.uv.sources] only affects uv resolution; project.dependencies still advertises climate-ref[aft-providers,postgres]>=0.13.1, so pip/wheel/sdist installs can resolve a PyPI release that may not include the new contract this backend expects.

"loguru",
"pyyaml>=6.0",
"fastapi-sqlalchemy-monitor>=1.1.3",
Expand All @@ -30,8 +30,10 @@ dev = [
]

[tool.uv.sources]
# Temporary pin for testing
# climate-ref = { git = "https://github.com/Climate-REF/climate-ref", subdirectory = "packages/climate-ref", tag="v0.7.0" }
# Pinned to an unreleased commit on climate-ref main that carries the shared
# metric-value contract (kind, reference_id, typed presentation attributes).
# Switch back to a plain PyPI version constraint once this ships in a release.
climate-ref = { git = "https://github.com/Climate-REF/climate-ref", subdirectory = "packages/climate-ref", rev = "383bd3f81e4af8c40a3f162eb529b5e6c2e05a1f" }
# climate-ref-example = { git = "https://github.com/Climate-REF/climate-ref", subdirectory = "packages/climate-ref-example", tag="v0.7.0" }
# Uncomment the following line to use a local version of climate-ref
#climate-ref = { path = "../../climate-ref/packages/climate-ref", editable = true }
Expand Down
14 changes: 10 additions & 4 deletions backend/src/ref_backend/core/outliers.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,14 @@ def calculate_iqr_bounds_by_source_id(
if "source_id" not in df.columns:
return None

# Separate Reference values (exclude from IQR calculation)
reference_mask = df["source_id"] == "Reference"
# Separate reference values (exclude from IQR calculation) using the
# first-class `kind` signal. A missing/None/empty kind is treated as a
# model. If the `kind` column is absent entirely (older data), fall back
# to treating all rows as models (no reference exclusion).
if "kind" in df.columns:
reference_mask = df["kind"] == "reference"
else:
reference_mask = pd.Series(False, index=df.index)
non_reference_df = df[~reference_mask]

# Group by source_id and calculate mean for each
Expand Down Expand Up @@ -138,11 +144,11 @@ def detect_outliers_in_scalar_values(

if iqr_bounds is not None:
lower_bound, upper_bound = iqr_bounds
# Apply bounds to individual values (Reference values always non-outlier)
# Apply bounds to individual values (reference values always non-outlier)
source_id_flags = group_values.apply(
lambda row: (
(row["value"] < lower_bound or row["value"] > upper_bound)
if row["source_id"] != "Reference"
if row.get("kind") != "reference"
else False
),
axis=1,
Expand Down
53 changes: 53 additions & 0 deletions backend/src/ref_backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,12 @@ class SeriesValue(BaseModel):
attributes: dict[str, Union[str, float]] | None = None
execution_group_id: int
execution_id: int
kind: Literal["model", "reference"] = "model"
reference_id: str | None = None
value_units: str | None = None
value_long_name: str | None = None
index_units: str | None = None
calendar: str | None = None


class Facet(BaseModel):
Expand All @@ -554,6 +560,48 @@ class AnnotatedScalarValue:
verification_status: Literal["verified", "unverified"] | None = None


def _normalize_kind(dimensions: dict[str, str]) -> Literal["model", "reference"]:
"""
Normalise the ``kind`` CV dimension to the model/reference role.

``kind`` is absent from ``dimensions`` for model rows (the committed
default is omitted at serialisation), so a missing or empty value is
treated as ``"model"``.
"""
kind = dimensions.get("kind")
return "reference" if kind == "reference" else "model"
Comment on lines +563 to +572

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Normalise dimensions as well when blank/null kind is meant to be supported.

The new helper maps missing/empty kind to "model", but both builders still pass the raw dimensions dict straight into the DTOs. If an older row literally carries {"kind": null}, the derived top-level kind is fine while the DTO payload is still invalid. Strip or rewrite invalid kind entries before constructing ScalarValue/SeriesValue, otherwise this path can still 500 on the data shape the helper claims to accept.

Also applies to: 639-639, 678-680



_PRESENTATION_ATTRIBUTE_FALLBACKS: dict[str, tuple[str, ...]] = {
"value_units": ("value_units", "units"),
"value_long_name": ("value_long_name", "long_name"),
"index_units": ("index_units",),
"calendar": ("calendar",),
}


def _normalize_presentation_attributes(
attributes: dict[str, Union[str, float]] | None,
) -> dict[str, str | None]:
"""
Normalise provider-specific presentation attribute keys to the shared names.

Providers disagree on attribute keys (ESMValTool already uses the target
names, ILAMB uses ``units``/``long_name``), so the first present key in
each fallback chain wins; a series with no matching key surfaces ``None``.
"""
attributes = attributes or {}
normalized: dict[str, str | None] = {}
for target, fallback_keys in _PRESENTATION_ATTRIBUTE_FALLBACKS.items():
value: str | None = None
for key in fallback_keys:
if key in attributes and attributes[key] is not None:
value = str(attributes[key])
break
normalized[target] = value
return normalized


class MetricValueCollection(BaseModel):
data: Sequence[ScalarValue | SeriesValue]
count: int
Expand Down Expand Up @@ -588,6 +636,7 @@ def build_scalar(
execution_id=v.execution_id,
is_outlier=item.is_outlier,
verification_status=item.verification_status,
kind=_normalize_kind(v.dimensions),
)
)

Expand Down Expand Up @@ -615,6 +664,7 @@ def build_series(
all_data: list[ScalarValue | SeriesValue] = []

for series in series_values:
presentation = _normalize_presentation_attributes(series.attributes)
all_data.append(
SeriesValue(
id=series.id,
Expand All @@ -625,6 +675,9 @@ def build_series(
index_name=series.index_name,
execution_group_id=series.execution.execution_group_id,
execution_id=series.execution_id,
kind=_normalize_kind(series.dimensions),
reference_id=series.reference_id,
**presentation,
)
)

Expand Down
Binary file not shown.
171 changes: 170 additions & 1 deletion backend/tests/test_core/test_metric_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
parse_id_list,
process_scalar_values,
)
from ref_backend.models import AnnotatedScalarValue
from ref_backend.models import AnnotatedScalarValue, MetricValueCollection, _normalize_kind


class TestParseIdList:
Expand Down Expand Up @@ -399,3 +399,172 @@ def test_series_with_detection_headers(self):
assert response.headers["X-REF-Had-Outliers"] == "true"
assert response.headers["X-REF-Outlier-Count"] == "5"
assert response.headers["Content-Disposition"] == "attachment; filename=test.csv"


class TestBuildScalar:
"""Test MetricValueCollection.build_scalar sets kind from dimensions."""

def test_kind_read_from_dimensions(self):
"""A scalar row with kind="reference" in its dimensions surfaces kind="reference"."""
mock_value = Mock(
id=1,
dimensions={"metric": "rmse", "kind": "reference"},
attributes=None,
value=1.5,
execution_id=2,
)
mock_value.execution = Mock(execution_group_id=1)

collection = MetricValueCollection.build_scalar(
[AnnotatedScalarValue(value=mock_value)],
total_count=1,
facets=[],
)

assert collection.data[0].kind == "reference"

def test_missing_kind_defaults_to_model(self):
"""A scalar row without kind in its dimensions defaults to kind="model"."""
mock_value = Mock(
id=1,
dimensions={"metric": "rmse"},
attributes=None,
value=1.5,
execution_id=2,
)
mock_value.execution = Mock(execution_group_id=1)

collection = MetricValueCollection.build_scalar(
[AnnotatedScalarValue(value=mock_value)],
total_count=1,
facets=[],
)

assert collection.data[0].kind == "model"

def test_normalize_kind_treats_none_and_empty_as_model(self):
"""The kind-normalisation helper treats missing, None and empty kind as model."""
assert _normalize_kind({"metric": "rmse"}) == "model"
assert _normalize_kind({"metric": "rmse", "kind": None}) == "model"
assert _normalize_kind({"metric": "rmse", "kind": ""}) == "model"
assert _normalize_kind({"metric": "rmse", "kind": "reference"}) == "reference"


class TestBuildSeries:
"""Test MetricValueCollection.build_series surfaces kind, reference_id and presentation attrs."""

def test_esmvaltool_style_attributes(self):
"""ESMValTool-style attribute keys already match the target names."""
mock_series = Mock(
id=1,
dimensions={"metric": "temp"},
attributes={
"value_units": "K",
"value_long_name": "Near-Surface Air Temperature",
"index_units": "days since 1850-01-01",
"calendar": "standard",
"index_long_name": "time",
},
values=[1.0, 2.0],
index=[2020, 2021],
index_name="time",
reference_id=None,
execution_id=2,
)
mock_series.execution = Mock(execution_group_id=1)

collection = MetricValueCollection.build_series([mock_series], total_count=1, facets=[])

value = collection.data[0]
assert value.value_units == "K"
assert value.value_long_name == "Near-Surface Air Temperature"
assert value.index_units == "days since 1850-01-01"
assert value.calendar == "standard"

def test_ilamb_style_attributes_fall_back(self):
"""ILAMB-style keys (units, long_name) fall back onto the target names."""
mock_series = Mock(
id=1,
dimensions={"metric": "temp"},
attributes={
"units": "percent",
"long_name": "Bias",
"standard_name": "bias",
},
values=[1.0, 2.0],
index=[2020, 2021],
index_name="time",
reference_id=None,
execution_id=2,
)
mock_series.execution = Mock(execution_group_id=1)

collection = MetricValueCollection.build_series([mock_series], total_count=1, facets=[])

value = collection.data[0]
assert value.value_units == "percent"
assert value.value_long_name == "Bias"
assert value.index_units is None
assert value.calendar is None

def test_missing_attributes_are_none(self):
"""A series with attributes=None surfaces None presentation fields, not an error."""
mock_series = Mock(
id=1,
dimensions={"metric": "temp"},
attributes=None,
values=[1.0, 2.0],
index=[2020, 2021],
index_name="time",
reference_id=None,
execution_id=2,
)
mock_series.execution = Mock(execution_group_id=1)

collection = MetricValueCollection.build_series([mock_series], total_count=1, facets=[])

value = collection.data[0]
assert value.value_units is None
assert value.value_long_name is None
assert value.index_units is None
assert value.calendar is None

def test_reference_series_sets_kind_and_reference_id(self):
"""A reference series surfaces kind="reference" and its reference_id."""
mock_series = Mock(
id=1,
dimensions={"metric": "temp", "kind": "reference"},
attributes=None,
values=[1.0, 2.0],
index=[2020, 2021],
index_name="time",
reference_id="abc123",
execution_id=2,
)
mock_series.execution = Mock(execution_group_id=1)

collection = MetricValueCollection.build_series([mock_series], total_count=1, facets=[])

value = collection.data[0]
assert value.kind == "reference"
assert value.reference_id == "abc123"

def test_model_series_defaults_kind_and_no_reference_id(self):
"""A model series (kind absent from dimensions) defaults to kind="model" with no reference_id."""
mock_series = Mock(
id=1,
dimensions={"metric": "temp"},
attributes=None,
values=[1.0, 2.0],
index=[2020, 2021],
index_name="time",
reference_id=None,
execution_id=2,
)
mock_series.execution = Mock(execution_group_id=1)

collection = MetricValueCollection.build_series([mock_series], total_count=1, facets=[])

value = collection.data[0]
assert value.kind == "model"
assert value.reference_id is None
Loading
Loading