diff --git a/backend/src/ref_backend/api/deps.py b/backend/src/ref_backend/api/deps.py index a5847fc..a3089d5 100644 --- a/backend/src/ref_backend/api/deps.py +++ b/backend/src/ref_backend/api/deps.py @@ -8,6 +8,7 @@ from climate_ref.config import Config from climate_ref.database import Database from climate_ref.provider_registry import ProviderRegistry +from climate_ref.results import Reader from ref_backend.core.config import Settings, get_settings from ref_backend.core.ref import get_database, get_provider_registry, get_ref_config @@ -41,6 +42,16 @@ def get_database_session(database: DatabaseDep) -> Generator[Session, None, None SessionDep = Annotated[Session, Depends(get_database_session)] +def _get_reader_dependency(database: DatabaseDep, ref_config: REFConfigDep) -> Reader: + """ + Get the results reader + """ + return Reader(database, results=ref_config.paths.results) + + +ReaderDep = Annotated[Reader, Depends(_get_reader_dependency)] + + @dataclass class AppContext: """ @@ -52,6 +63,7 @@ class AppContext: """ session: Session + reader: Reader ref_config: Config settings: Settings provider_registry: ProviderRegistry @@ -69,6 +81,7 @@ def _provider_registry_dependency(settings: SettingsDep, ref_config: REFConfigDe def get_app_context( session: SessionDep, + reader: ReaderDep, ref_config: REFConfigDep, settings: SettingsDep, provider_registry: ProviderRegistryDep, @@ -78,6 +91,7 @@ def get_app_context( """ return AppContext( session=session, + reader=reader, ref_config=ref_config, settings=settings, provider_registry=provider_registry, diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index 5580098..ac4e1bd 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -7,17 +7,17 @@ from climate_ref import models from climate_ref.models.dataset import CMIP6Dataset +from climate_ref.results import MetricValueFilter, OutlierPolicy from ref_backend.api.deps import AppContextDep from ref_backend.core.filter_utils import build_filter_clause from ref_backend.core.metric_values import ( - METRIC_VALUES_NON_FILTER_PARAMS, MetricValueType, - apply_metric_filters, - collect_facets_from_query, + parse_id_list, +) +from ref_backend.core.reader_values import ( generate_csv_response_scalar, generate_csv_response_series, - paginate_annotated_values, - process_scalar_values, + parse_dimension_filters, ) from ref_backend.models import ( Collection, @@ -307,100 +307,57 @@ async def list_metric_values( # noqa: PLR0913 - `offset`: Number of items to skip (default 0) - `limit`: Maximum number of items to return (default 50, max 500) """ - diagnostic = await _get_diagnostic(app_context, provider_slug, diagnostic_slug) - - # Extract additional filters from query parameters - query_params = request.query_params - filter_params = {} - for key, value in query_params.items(): - if key in METRIC_VALUES_NON_FILTER_PARAMS: - continue - filter_params[key] = value + # Validates the provider/diagnostic exist and are not excluded (raises 404 otherwise). + await _get_diagnostic(app_context, provider_slug, diagnostic_slug) + + # Scope to this diagnostic/provider via exact-match slugs. ``promoted_only`` keeps only the + # promoted diagnostic version, so values from superseded versions are hidden. Exposing + # previous versions needs a separate design (TODO). Retracted executions are still included. + metric_filter = MetricValueFilter( + diagnostic_slug=diagnostic_slug, + provider_slug=provider_slug, + dimensions=parse_dimension_filters(request.query_params), + isolate_ids=parse_id_list(isolate_ids) if isolate_ids else None, + exclude_ids=parse_id_list(exclude_ids) if exclude_ids else None, + promoted_only=True, + include_retracted=True, + ) if value_type == MetricValueType.SCALAR: - scalar_query = ( - app_context.session.query(models.ScalarMetricValue) - .join(models.Execution) - .join(models.ExecutionGroup) - .filter(models.ExecutionGroup.diagnostic_id == diagnostic.id) - ) - - # Apply filtering - scalar_query = apply_metric_filters(scalar_query, filter_params, isolate_ids, exclude_ids) + detection_ran = detect_outliers == "iqr" + outlier_policy = OutlierPolicy(method=detect_outliers) if format == "csv": # CSV export returns all results without pagination - scalar_values = scalar_query.all() if scalar_query else [] - annotated_scalar_values, had_outliers, outlier_count, detection_ran = process_scalar_values( - scalar_values, detect_outliers, include_unverified + collection = app_context.reader.values.scalar_values( + metric_filter, + outliers=outlier_policy, + include_unverified=include_unverified, ) filename = f"metric_values_scalar_{provider_slug}_{diagnostic_slug}.csv" - return generate_csv_response_scalar( - annotated_scalar_values, - detection_ran, - had_outliers, - outlier_count, - filename, - ) - - facets = collect_facets_from_query(scalar_query) if scalar_query else [] - - # NOTE: We intentionally load ALL scalar values into memory here rather - # than using SQL-level OFFSET/LIMIT. Outlier detection (IQR) needs the - # full dataset to compute globally consistent bounds -- paginating at - # the DB level would produce different IQR thresholds per page. - all_scalar_values = scalar_query.all() if scalar_query else [] - - # Process scalar values with outlier detection (and optional filtering) - annotated_scalar_values, had_outliers, outlier_count, detection_ran = process_scalar_values( - all_scalar_values, detect_outliers, include_unverified - ) - - # total_count reflects the post-outlier-filter count so pagination math is correct - total_count = len(annotated_scalar_values) - page = paginate_annotated_values(annotated_scalar_values, offset, limit) - - return MetricValueCollection.build_scalar( - scalar_values=page, - total_count=total_count, - had_outliers=had_outliers if detection_ran else None, - outlier_count=outlier_count if detection_ran else None, - facets=facets, + return generate_csv_response_scalar(collection, detection_ran, filename) + + collection = app_context.reader.values.scalar_values( + metric_filter, + outliers=outlier_policy, + include_unverified=include_unverified, + offset=offset, + limit=limit, ) + return MetricValueCollection.build_scalar_from_reader(collection, detection_ran) elif value_type == MetricValueType.SERIES: - series_query = ( - app_context.session.query(models.SeriesMetricValue) - .join(models.Execution) - .join(models.ExecutionGroup) - .filter(models.ExecutionGroup.diagnostic_id == diagnostic.id) - ) - - # Apply filtering - series_query = apply_metric_filters(series_query, filter_params, isolate_ids, exclude_ids) - if format == "csv": # CSV export returns all results without pagination - series_values = series_query.all() if series_query else [] + series_collection = app_context.reader.values.series_values(metric_filter) filename = f"metric_values_series_{provider_slug}_{diagnostic_slug}.csv" - return generate_csv_response_series( - series_values, - detection_ran=False, - had_outliers=False, - outlier_count=0, - filename=filename, - ) - - total_count = series_query.count() if series_query else 0 - facets = collect_facets_from_query(series_query) if series_query else [] - series_values = series_query.offset(offset).limit(limit).all() if series_query else [] + return generate_csv_response_series(series_collection, filename) - return MetricValueCollection.build_series( - series_values=series_values, - total_count=total_count, - had_outliers=None, - outlier_count=None, - facets=facets, + series_collection = app_context.reader.values.series_values( + metric_filter, + offset=offset, + limit=limit, ) + return MetricValueCollection.build_series_from_reader(series_collection) else: raise HTTPException(status_code=500, detail="Unknown value_type") diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index aa306a8..ab1cfe9 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -14,20 +14,20 @@ from climate_ref import models from climate_ref.models.dataset import CMIP6Dataset, DatasetFile +from climate_ref.results import MetricValueFilter, OutlierPolicy from climate_ref_core.logging import EXECUTION_LOG_FILENAME from climate_ref_core.pycmec.metric import CMECMetric from ref_backend.api.deps import AppContextDep from ref_backend.core.file_handling import file_iterator from ref_backend.core.filter_utils import build_filter_clause from ref_backend.core.metric_values import ( - METRIC_VALUES_NON_FILTER_PARAMS, MetricValueType, - apply_metric_filters, - collect_facets_from_query, + parse_id_list, +) +from ref_backend.core.reader_values import ( generate_csv_response_scalar, generate_csv_response_series, - paginate_annotated_values, - process_scalar_values, + parse_dimension_filters, ) from ref_backend.models import ( Collection, @@ -298,9 +298,6 @@ async def metric_bundle( return CMECMetric.load_from_json(file_path) -_EXECUTION_NON_FILTER_PARAMS = METRIC_VALUES_NON_FILTER_PARAMS | {"execution_id"} - - @router.get("/{group_id}/values", response_model=MetricValueCollection) async def list_metric_values( # noqa: PLR0913 app_context: AppContextDep, @@ -327,87 +324,55 @@ async def list_metric_values( # noqa: PLR0913 - `limit`: Maximum number of items to return (default 50, max 500) """ execution = await _get_execution(group_id, execution_id, app_context.session) - # Extract additional filters from query parameters - query_params = request.query_params - filter_params = {} - for key, value in query_params.items(): - if key in _EXECUTION_NON_FILTER_PARAMS: - continue - filter_params[key] = value + + # Restrict to the selected execution's values; ``_get_execution`` already resolves the + # latest execution when no ``execution_id`` is supplied. ``promoted_only`` keeps only the + # promoted diagnostic version, so values from superseded versions are hidden. Exposing + # previous versions needs a separate design (TODO). Retracted executions are still included. + metric_filter = MetricValueFilter( + execution_ids=[execution.id], + dimensions=parse_dimension_filters(request.query_params), + isolate_ids=parse_id_list(isolate_ids) if isolate_ids else None, + exclude_ids=parse_id_list(exclude_ids) if exclude_ids else None, + promoted_only=True, + include_retracted=True, + ) + if value_type == MetricValueType.SCALAR: - scalar_query = app_context.session.query(models.ScalarMetricValue).filter( - models.ScalarMetricValue.execution_id == execution.id - ) - scalar_query = apply_metric_filters(scalar_query, filter_params, isolate_ids, exclude_ids) + detection_ran = detect_outliers == "iqr" + outlier_policy = OutlierPolicy(method=detect_outliers) if format == "csv": - scalar_values = scalar_query.all() if scalar_query else [] - annotated_scalar_values, had_outliers, outlier_count, detection_ran = process_scalar_values( - scalar_values, detect_outliers, include_unverified + # CSV export returns all results without pagination. + collection = app_context.reader.values.scalar_values( + metric_filter, + outliers=outlier_policy, + include_unverified=include_unverified, ) filename = f"metric_values_scalar_{group_id}_{execution.id}.csv" - return generate_csv_response_scalar( - annotated_scalar_values, - detection_ran, - had_outliers, - outlier_count, - filename, - ) - - facets = collect_facets_from_query(scalar_query) if scalar_query else [] - - # NOTE: We intentionally load ALL scalar values into memory here rather - # than using SQL-level OFFSET/LIMIT. Outlier detection (IQR) needs the - # full dataset to compute globally consistent bounds -- paginating at - # the DB level would produce different IQR thresholds per page. - all_scalar_values = scalar_query.all() if scalar_query else [] - - # Process scalar values with outlier detection (and optional filtering) - annotated_scalar_values, had_outliers, outlier_count, detection_ran = process_scalar_values( - all_scalar_values, detect_outliers, include_unverified - ) - - # total_count reflects the post-outlier-filter count so pagination math is correct - total_count = len(annotated_scalar_values) - page = paginate_annotated_values(annotated_scalar_values, offset, limit) - - return MetricValueCollection.build_scalar( - scalar_values=page, - total_count=total_count, - had_outliers=had_outliers if detection_ran else None, - outlier_count=outlier_count if detection_ran else None, - facets=facets, + return generate_csv_response_scalar(collection, detection_ran, filename) + + collection = app_context.reader.values.scalar_values( + metric_filter, + outliers=outlier_policy, + include_unverified=include_unverified, + offset=offset, + limit=limit, ) + return MetricValueCollection.build_scalar_from_reader(collection, detection_ran) elif value_type == MetricValueType.SERIES: - series_query = app_context.session.query(models.SeriesMetricValue).filter( - models.SeriesMetricValue.execution_id == execution.id - ) - - series_query = apply_metric_filters(series_query, filter_params, isolate_ids, exclude_ids) - if format == "csv": - series_values = series_query.all() if series_query else [] + series_collection = app_context.reader.values.series_values(metric_filter) filename = f"metric_values_series_{group_id}_{execution.id}.csv" - return generate_csv_response_series( - series_values, - detection_ran=False, - had_outliers=False, - outlier_count=0, - filename=filename, - ) - - total_count = series_query.count() if series_query else 0 - facets = collect_facets_from_query(series_query) if series_query else [] - series_values = series_query.offset(offset).limit(limit).all() if series_query else [] + return generate_csv_response_series(series_collection, filename) - return MetricValueCollection.build_series( - series_values=series_values, - total_count=total_count, - had_outliers=None, - outlier_count=None, - facets=facets, + series_collection = app_context.reader.values.series_values( + metric_filter, + offset=offset, + limit=limit, ) + return MetricValueCollection.build_series_from_reader(series_collection) else: raise HTTPException(status_code=500, detail="Unknown value_type") diff --git a/backend/src/ref_backend/core/reader_values.py b/backend/src/ref_backend/core/reader_values.py new file mode 100644 index 0000000..cb6ab0b --- /dev/null +++ b/backend/src/ref_backend/core/reader_values.py @@ -0,0 +1,118 @@ +"""Helpers for serving metric values through the ``climate_ref.results.Reader`` facade.""" + +import csv +import io +from collections.abc import Generator, Mapping + +from starlette.responses import StreamingResponse + +from climate_ref import models +from climate_ref.results.values import ScalarValueCollection, SeriesValueCollection +from ref_backend.core.json_utils import sanitize_float_value + + +def parse_dimension_filters(query_params: Mapping[str, str]) -> dict[str, str]: + """ + Extract CV-dimension filters from arbitrary query parameters. + + Only keys that are registered CV dimensions are kept so unknown parameters are + silently ignored (and never reach the reader, which would reject them). + """ + cv_dimensions = set(models.ScalarMetricValue._cv_dimensions) + return {key: value for key, value in query_params.items() if key in cv_dimensions} + + +def generate_csv_response_scalar( + collection: ScalarValueCollection, + detection_ran: bool, + filename: str, +) -> StreamingResponse: + """ + Generate a CSV streaming response from a reader scalar collection. + + Preserves the historical column layout: sorted dimension columns, then ``value`` and + ``type``, and (when detection ran) ``is_outlier`` and ``verification_status``. + """ + + def generate_csv() -> Generator[str]: + output = io.StringIO() + writer = csv.writer(output) + + items = collection.items + if not items: + yield "" + return + + dimensions = sorted(items[0].dimensions.keys()) + header = [*dimensions, "value", "type"] + if detection_ran: + header.extend(["is_outlier", "verification_status"]) + writer.writerow(header) + + for item in items: + row = [item.dimensions.get(d) for d in dimensions] + [ + sanitize_float_value(item.value), + "scalar", + ] + if detection_ran: + row.extend([item.is_outlier, item.verification_status]) + writer.writerow(row) + + output.seek(0) + yield output.read() + + headers = {"Content-Disposition": f"attachment; filename={filename}"} + if detection_ran: + headers["X-REF-Had-Outliers"] = "true" if collection.had_outliers else "false" + headers["X-REF-Outlier-Count"] = str(collection.outlier_count) + + return StreamingResponse( + generate_csv(), + media_type="text/csv", + headers=headers, + ) + + +def generate_csv_response_series( + collection: SeriesValueCollection, + filename: str, +) -> StreamingResponse: + """ + Generate a CSV streaming response from a reader series collection. + + Preserves the historical flattened layout: one header/data block per series, with a + row per index point. + """ + + def generate_csv() -> Generator[str]: + output = io.StringIO() + writer = csv.writer(output) + + items = collection.items + if not items: + yield "" + return + + for sv in items: + dimensions = sorted(sv.dimensions.keys()) + header = [*dimensions, "value", "index", "index_name", "type"] + writer.writerow(header) + + for i, value in enumerate(sv.values): + index_value = sv.index[i] if sv.index and i < len(sv.index) else i + row = [sv.dimensions.get(d) for d in dimensions] + [ + sanitize_float_value(value), + index_value, + sv.index_name or "index", + "series", + ] + writer.writerow(row) + + output.seek(0) + yield output.read() + + return StreamingResponse( + generate_csv(), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) diff --git a/backend/src/ref_backend/models.py b/backend/src/ref_backend/models.py index 0b81e8b..7f0b06d 100644 --- a/backend/src/ref_backend/models.py +++ b/backend/src/ref_backend/models.py @@ -1,6 +1,6 @@ from collections.abc import Sequence from datetime import datetime -from typing import TYPE_CHECKING, ClassVar, Generic, Literal, TypeVar, Union +from typing import TYPE_CHECKING, ClassVar, Generic, Literal, TypeVar, Union, cast from attr import define from loguru import logger @@ -19,6 +19,7 @@ from ref_backend.core.json_utils import sanitize_float_list, sanitize_float_value if TYPE_CHECKING: + from climate_ref.results.values import ScalarValueCollection, SeriesValueCollection from ref_backend.api.deps import AppContext @@ -560,18 +561,6 @@ 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" - - _PRESENTATION_ATTRIBUTE_FALLBACKS: dict[str, tuple[str, ...]] = { "value_units": ("value_units", "units"), "value_long_name": ("value_long_name", "long_name"), @@ -612,83 +601,74 @@ class MetricValueCollection(BaseModel): outlier_count: int | None = None @staticmethod - def build_scalar( - scalar_values: list[AnnotatedScalarValue], - total_count: int, - facets: list[Facet], - had_outliers: bool | None = None, - outlier_count: int | None = None, + def build_scalar_from_reader( + collection: "ScalarValueCollection", + detection_ran: bool, ) -> "MetricValueCollection": - """Build a MetricValueCollection from scalar values.""" - scalar_values = scalar_values or [] - - all_data: list[ScalarValue] = [] - - for item in scalar_values: - v = item.value - all_data.append( - ScalarValue( - id=v.id, - dimensions=v.dimensions, - attributes=v.attributes, - value=sanitize_float_value(float(v.value)), - execution_group_id=v.execution.execution_group_id, - execution_id=v.execution_id, - is_outlier=item.is_outlier, - verification_status=item.verification_status, - kind=_normalize_kind(v.dimensions), - ) + """Build a MetricValueCollection from a reader scalar collection.""" + all_data: list[ScalarValue] = [ + ScalarValue( + id=item.id, + dimensions=dict(item.dimensions), + attributes=dict(item.attributes) if item.attributes else None, + kind=cast('Literal["model", "reference"]', item.kind), + value=sanitize_float_value(float(cast(float, item.value))), + execution_group_id=item.execution_group_id, + execution_id=item.execution_id, + is_outlier=item.is_outlier, + verification_status=cast( + 'Literal["verified", "unverified"] | None', item.verification_status + ), ) + for item in collection.items + ] + + facets = [Facet(key=f.key, values=list(f.values)) for f in collection.facets] return MetricValueCollection( data=all_data, count=len(all_data), - total_count=total_count, + total_count=collection.total_count, facets=facets, types=["scalar"], - had_outliers=had_outliers, - outlier_count=outlier_count, + had_outliers=collection.had_outliers if detection_ran else None, + outlier_count=collection.outlier_count if detection_ran else None, ) @staticmethod - def build_series( - series_values: list[models.SeriesMetricValue], - total_count: int, - facets: list[Facet], - had_outliers: None = None, - outlier_count: None = None, + def build_series_from_reader( + collection: "SeriesValueCollection", ) -> "MetricValueCollection": - """Build a MetricValueCollection from series values.""" - series_values = series_values or [] - + """Build a MetricValueCollection from a reader series collection.""" all_data: list[ScalarValue | SeriesValue] = [] - - for series in series_values: - presentation = _normalize_presentation_attributes(series.attributes) + for item in collection.items: + attributes = dict(item.attributes) if item.attributes else None all_data.append( SeriesValue( - id=series.id, - dimensions=series.dimensions, - attributes=series.attributes, - values=sanitize_float_list(series.values or []), - index=series.index, - 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, + id=item.id, + dimensions=dict(item.dimensions), + attributes=attributes, + values=sanitize_float_list(list(item.values or [])), + index=list(item.index) if item.index is not None else None, + index_name=item.index_name, + execution_group_id=item.execution_group_id, + execution_id=item.execution_id, + kind=cast('Literal["model", "reference"]', item.kind), + reference_id=item.reference_id, + **_normalize_presentation_attributes(attributes), ) ) + facets = [Facet(key=f.key, values=list(f.values)) for f in collection.facets] + return MetricValueCollection( data=all_data, count=len(all_data), - total_count=total_count, + total_count=collection.total_count, facets=facets, types=["series"], - had_outliers=had_outliers, - outlier_count=outlier_count, + had_outliers=None, + outlier_count=None, ) diff --git a/backend/tests/test_core/test_metric_values.py b/backend/tests/test_core/test_metric_values.py index 89894bc..4ff28fb 100644 --- a/backend/tests/test_core/test_metric_values.py +++ b/backend/tests/test_core/test_metric_values.py @@ -14,7 +14,7 @@ parse_id_list, process_scalar_values, ) -from ref_backend.models import AnnotatedScalarValue, MetricValueCollection, _normalize_kind +from ref_backend.models import AnnotatedScalarValue, MetricValueCollection class TestParseIdList: @@ -401,63 +401,84 @@ def test_series_with_detection_headers(self): assert response.headers["Content-Disposition"] == "attachment; filename=test.csv" -class TestBuildScalar: - """Test MetricValueCollection.build_scalar sets kind from dimensions.""" +def _reader_collection(items): + """Build a stand-in reader collection (only the fields the builders read).""" + return Mock(items=items, total_count=len(items), facets=[], had_outliers=False, outlier_count=0) - def test_kind_read_from_dimensions(self): - """A scalar row with kind="reference" in its dimensions surfaces kind="reference".""" - mock_value = Mock( + +class TestBuildScalarFromReader: + """Test MetricValueCollection.build_scalar_from_reader carries the reader's fields through.""" + + def test_kind_and_outlier_annotations_pass_through(self): + """A reader scalar surfaces its resolved kind plus outlier annotations.""" + item = Mock( id=1, - dimensions={"metric": "rmse", "kind": "reference"}, + dimensions={"metric": "rmse"}, attributes=None, value=1.5, + execution_group_id=1, execution_id=2, + is_outlier=True, + verification_status="unverified", + kind="reference", ) - mock_value.execution = Mock(execution_group_id=1) - collection = MetricValueCollection.build_scalar( - [AnnotatedScalarValue(value=mock_value)], - total_count=1, - facets=[], + collection = MetricValueCollection.build_scalar_from_reader( + _reader_collection([item]), detection_ran=True ) - 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( + value = collection.data[0] + assert value.kind == "reference" + assert value.is_outlier is True + assert value.verification_status == "unverified" + assert collection.had_outliers is False + assert collection.outlier_count == 0 + + def test_no_detection_clears_outlier_summary(self): + """When detection did not run the outlier summary is None.""" + item = Mock( id=1, dimensions={"metric": "rmse"}, attributes=None, value=1.5, + execution_group_id=1, execution_id=2, + is_outlier=None, + verification_status=None, + kind="model", ) - mock_value.execution = Mock(execution_group_id=1) - collection = MetricValueCollection.build_scalar( - [AnnotatedScalarValue(value=mock_value)], - total_count=1, - facets=[], + collection = MetricValueCollection.build_scalar_from_reader( + _reader_collection([item]), detection_ran=False ) assert collection.data[0].kind == "model" + assert collection.had_outliers is None + assert collection.outlier_count is None - 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 TestBuildSeriesFromReader: + """Test build_series_from_reader surfaces kind, reference_id and presentation attrs.""" -class TestBuildSeries: - """Test MetricValueCollection.build_series surfaces kind, reference_id and presentation attrs.""" + def _series_item(self, **overrides): + defaults = dict( + id=1, + dimensions={"metric": "temp"}, + attributes=None, + values=[1.0, 2.0], + index=[2020, 2021], + index_name="time", + execution_group_id=1, + execution_id=2, + kind="model", + reference_id=None, + ) + defaults.update(overrides) + return Mock(**defaults) def test_esmvaltool_style_attributes(self): """ESMValTool-style attribute keys already match the target names.""" - mock_series = Mock( - id=1, - dimensions={"metric": "temp"}, + item = self._series_item( attributes={ "value_units": "K", "value_long_name": "Near-Surface Air Temperature", @@ -465,15 +486,9 @@ def test_esmvaltool_style_attributes(self): "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=[]) + collection = MetricValueCollection.build_series_from_reader(_reader_collection([item])) value = collection.data[0] assert value.value_units == "K" @@ -483,23 +498,15 @@ def test_esmvaltool_style_attributes(self): 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"}, + item = self._series_item( 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=[]) + collection = MetricValueCollection.build_series_from_reader(_reader_collection([item])) value = collection.data[0] assert value.value_units == "percent" @@ -509,19 +516,7 @@ def test_ilamb_style_attributes_fall_back(self): 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=[]) + collection = MetricValueCollection.build_series_from_reader(_reader_collection([self._series_item()])) value = collection.data[0] assert value.value_units is None @@ -531,39 +526,17 @@ def test_missing_attributes_are_none(self): 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) + item = self._series_item(kind="reference", reference_id="abc123") - collection = MetricValueCollection.build_series([mock_series], total_count=1, facets=[]) + collection = MetricValueCollection.build_series_from_reader(_reader_collection([item])) 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=[]) + """A model series surfaces kind="model" with no reference_id.""" + collection = MetricValueCollection.build_series_from_reader(_reader_collection([self._series_item()])) value = collection.data[0] assert value.kind == "model" diff --git a/changelog/39.trivial.md b/changelog/39.trivial.md new file mode 100644 index 0000000..3306de5 --- /dev/null +++ b/changelog/39.trivial.md @@ -0,0 +1 @@ +The backend now serves metric values through the shared `climate_ref.results` read layer instead of its own bespoke database queries. API responses are unchanged.