diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fa59b366..75a78040 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "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.19.0,<0.20", + "climate-ref[aft-providers,postgres]>=0.20.0,<0.21", "loguru", "pyyaml>=6.0", "fastapi-sqlalchemy-monitor>=1.1.3", diff --git a/backend/src/ref_backend/api/routes/datasets.py b/backend/src/ref_backend/api/routes/datasets.py index d7847906..785f8b44 100644 --- a/backend/src/ref_backend/api/routes/datasets.py +++ b/backend/src/ref_backend/api/routes/datasets.py @@ -2,20 +2,17 @@ from typing import Any from fastapi import APIRouter, HTTPException, Query -from sqlalchemy import func, select -from climate_ref import models -from climate_ref.datasets import get_dataset_adapter -from climate_ref.results import DatasetFilter -from climate_ref.results.datasets import DatasetView, select_datasets +from climate_ref.results import DatasetFilter, ExecutionGroupFilter, InputDatasetFilter +from climate_ref.results.datasets import DatasetView from climate_ref_core.datasets import SourceDatasetType -from ref_backend.api.deps import AppContextDep, ReaderDep, SessionDep +from ref_backend.api.deps import AppContextDep, ReaderDep +from ref_backend.core.execution_groups import load_execution_groups from ref_backend.models import ( Collection, Dataset, ExecutionGroup, ) -from ref_backend.models.executions import EXECUTION_GROUP_LOAD_OPTIONS router = APIRouter(prefix="/datasets", tags=["datasets"]) @@ -39,7 +36,7 @@ def _parse_source_type(dataset_type: str) -> SourceDatasetType: @router.get("/", name="list") async def _list( # noqa: PLR0913, PLR0917 - session: SessionDep, + reader: ReaderDep, offset: int = Query(0, ge=0), limit: int = Query(10, ge=1, le=100), name_contains: str = Query(None, description="Filter datasets by name"), @@ -61,27 +58,21 @@ async def _list( # noqa: PLR0913, PLR0917 ) dataset_type = SourceDatasetType.CMIP6.value - source_type = _parse_source_type(dataset_type) - adapter = get_dataset_adapter(source_type.value) - dataset_filter = DatasetFilter( - source_type=source_type, + source_type=_parse_source_type(dataset_type), facets=_parse_facets(facets) if facets else None, + slug_contains=name_contains or None, # Retracted datasets stay visible: this is an inspection view, not a solve-time query. include_retracted=True, ) try: - statement = select_datasets(dataset_filter, latest_group_by=adapter.dataset_id_metadata) + datasets = reader.datasets.list(dataset_filter, offset=offset, limit=limit) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from None - if name_contains: - statement = statement.where(models.Dataset.slug.ilike(f"%{name_contains}%")) - - total_count = session.execute(select(func.count()).select_from(statement.subquery())).scalar_one() - datasets = session.execute(statement.offset(offset).limit(limit)).scalars().unique().all() - - return Collection(data=[Dataset.build(ds) for ds in datasets], total_count=total_count) + return Collection( + data=[Dataset.build_from_view(ds) for ds in datasets.items], total_count=datasets.total_count + ) @router.get("/{slug}", name="get") @@ -108,20 +99,18 @@ async def executions( limit: int = Query(10, ge=1, le=100), ) -> Collection[ExecutionGroup]: """ - List the currently registered diagnostics - """ - execution_groups_query = ( - app_context.session.query(models.ExecutionGroup) - .options(*EXECUTION_GROUP_LOAD_OPTIONS) - .join(models.ExecutionGroup.executions) - .join(models.Execution.datasets) - .filter(models.Dataset.id == dataset_id) - .distinct() - ) - total_count = execution_groups_query.count() - _execution_groups = execution_groups_query.offset(offset).limit(limit).all() + List the execution groups whose latest execution used this dataset - return Collection( - data=[ExecutionGroup.build(eg, app_context) for eg in _execution_groups], - total_count=total_count, + Groups from every diagnostic version are returned, retracted ones included. + """ + groups, total_count = load_execution_groups( + app_context.session, + ExecutionGroupFilter( + input_datasets=InputDatasetFilter(dataset_ids=dataset_id), + include_superseded=True, + include_retracted=True, + ), + offset=offset, + limit=limit, ) + return Collection(data=[ExecutionGroup.build(g, app_context) for g in groups], total_count=total_count) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index f516fb1f..82d2c576 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -1,37 +1,35 @@ from typing import Literal, NamedTuple from fastapi import APIRouter, HTTPException, Query, Request -from sqlalchemy import ColumnElement, Integer, func, select from starlette.responses import StreamingResponse from climate_ref import models -from climate_ref.results import MetricValueFilter +from climate_ref.models.metric_value import MetricValueType as StoredMetricValueType +from climate_ref.query import select_group_counts +from climate_ref.results import DiagnosticFilter, ExecutionGroupFilter, MetricValueFilter, ResourceFilter from ref_backend.api.deps import AppContextDep +from ref_backend.core.execution_groups import load_execution_groups from ref_backend.core.metric_values import ( MetricValueType, parse_id_list, ) -from ref_backend.core.mip_eras import ( - cmip_dataset_filter, - execution_group_filter, - executions_in_mip_era, -) +from ref_backend.core.mip_eras import MipEra, era_scope, source_id_datasets from ref_backend.core.reader_values import ( + fetch_facets, fetch_metric_values, parse_dimension_filters, ) -from ref_backend.core.resource_usage import RESOURCE_AGGREGATES, summary_from_row +from ref_backend.core.resource_usage import resource_usage_by_diagnostic from ref_backend.models import ( Collection, DiagnosticCatalogEntry, DiagnosticSummary, DiagnosticValueFlags, - Execution, ExecutionGroup, + Facet, + GroupCounts, MetricValueCollection, - MetricValueFacetSummary, ) -from ref_backend.models.executions import EXECUTION_GROUP_LOAD_OPTIONS router = APIRouter(prefix="/diagnostics", tags=["diagnostics"]) @@ -72,227 +70,138 @@ def _listed_diagnostics(app_context: AppContextDep) -> list[models.Diagnostic]: return query.all() -def _group_scope( - diagnostic_ids: list[int], mip_era: str | None, source_id: str | None = None -) -> list[ColumnElement[bool]]: - promoted_version = ( - select(models.Diagnostic.promoted_version) - .where(models.Diagnostic.id == models.ExecutionGroup.diagnostic_id) - .scalar_subquery() - ) - group_scope: list[ColumnElement[bool]] = [ - models.ExecutionGroup.diagnostic_id.in_(diagnostic_ids), - models.ExecutionGroup.diagnostic_version == promoted_version, - ] - # Selectors do not record source_id, so match on the datasets used, as the executions list does. - facets = {key: value for key, value in {"mip_era": mip_era, "source_id": source_id}.items() if value} - if facets: - matching_groups = select(models.ExecutionGroup.id).where(execution_group_filter(facets)) - group_scope.append(models.ExecutionGroup.id.in_(matching_groups)) - return group_scope +class _Scope(NamedTuple): + """The listed diagnostics, and the filter that picks which of their execution groups count""" + diagnostics: list[models.Diagnostic] + filters: DiagnosticFilter -def _scoped_diagnostics( - app_context: AppContextDep, mip_era: str | None, source_id: str | None -) -> tuple[list[models.Diagnostic], list[ColumnElement[bool]]]: - """ - Get the listed diagnostics, with the scope that counts their groups for `mip_era` and `source_id` - Without any listed diagnostic the scope matches no group, so the counts come out empty. - """ - diagnostics = _listed_diagnostics(app_context) - return diagnostics, _group_scope([d.id for d in diagnostics], mip_era, source_id) - - -def _diagnostics_with_values( - app_context: AppContextDep, value_class: type[models.MetricValue], group_scope: list[ColumnElement[bool]] -) -> set[int]: - rows = ( - app_context.session.query(models.ExecutionGroup.diagnostic_id) - .join(models.Execution) - .join(value_class) - .filter(*group_scope) - .distinct() +def _scoped_diagnostics(app_context: AppContextDep, mip_era: MipEra | None, source_id: str | None) -> _Scope: + """Get the listed diagnostics, counting only the groups that draw on `mip_era` and ran `source_id`""" + return _Scope( + _listed_diagnostics(app_context), + DiagnosticFilter( + include_superseded=False, + include_retracted=False, + **era_scope(mip_era), + input_datasets=source_id_datasets(source_id), + ), ) - return {row[0] for row in rows} class _ValueTypes(NamedTuple): - """The ids of the diagnostics with scalar values and of those with series values""" + """The (provider, diagnostic) slugs of the diagnostics with scalar values and of those with series""" - scalar: set[int] - series: set[int] + scalar: frozenset[tuple[str, str]] + series: frozenset[tuple[str, str]] -def _value_types(app_context: AppContextDep, group_scope: list[ColumnElement[bool]]) -> _ValueTypes: +def _value_types(app_context: AppContextDep, scope: _Scope) -> _ValueTypes: + with_values = app_context.reader.diagnostics.with_values return _ValueTypes( - scalar=_diagnostics_with_values(app_context, models.ScalarMetricValue, group_scope), - series=_diagnostics_with_values(app_context, models.SeriesMetricValue, group_scope), + scalar=with_values(StoredMetricValueType.SCALAR, scope.filters), + series=with_values(StoredMetricValueType.SERIES, scope.filters), ) -def _catalog_entries( - app_context: AppContextDep, diagnostics: list[models.Diagnostic], group_scope: list[ColumnElement[bool]] -) -> list[DiagnosticCatalogEntry]: - # Count executions and roll up their resource usage per diagnostic - execution_counts = ( - app_context.session.query( - models.ExecutionGroup.diagnostic_id, - func.count(models.Execution.id).label("total_count"), - func.sum(func.cast(models.Execution.successful, Integer)).label("successful_count"), - *RESOURCE_AGGREGATES, - ) - .join(models.Execution) - .filter(*group_scope) - .group_by(models.ExecutionGroup.diagnostic_id) - .all() - ) - execution_stats = { - row.diagnostic_id: {"total": row.total_count, "successful": row.successful_count or 0} - for row in execution_counts - } - resource_usage = {row.diagnostic_id: summary_from_row(row) for row in execution_counts} +def _key(diagnostic: models.Diagnostic | DiagnosticCatalogEntry) -> tuple[str, str]: + return diagnostic.provider.slug, diagnostic.slug - # Count execution groups per diagnostic - execution_group_counts = ( - app_context.session.query( - models.ExecutionGroup.diagnostic_id, func.count(models.ExecutionGroup.id).label("group_count") - ) - .filter(*group_scope) - .group_by(models.ExecutionGroup.diagnostic_id) - .all() - ) - group_counts = {row[0]: row[1] for row in execution_group_counts} - - # Count successful execution groups (latest execution successful) - # Subquery: latest execution id per group - latest_exec_per_group = ( - app_context.session.query( - models.Execution.execution_group_id.label("egid"), - func.max(models.Execution.id).label("latest_exec_id"), - ) - .join(models.ExecutionGroup, models.Execution.execution_group_id == models.ExecutionGroup.id) - .filter(*group_scope) - .group_by(models.Execution.execution_group_id) - .subquery() - ) - # Join back to executions to check success of latest - successful_group_counts = ( - app_context.session.query( - models.ExecutionGroup.diagnostic_id, - func.count(latest_exec_per_group.c.egid).label("successful_count"), - ) - .join(models.Execution, models.Execution.id == latest_exec_per_group.c.latest_exec_id) - .join(models.ExecutionGroup, models.ExecutionGroup.id == latest_exec_per_group.c.egid) - .filter(models.Execution.successful.is_(True)) - .group_by(models.ExecutionGroup.diagnostic_id) - .all() +def _catalog_entries(app_context: AppContextDep, scope: _Scope) -> list[DiagnosticCatalogEntry]: + resource_usage = resource_usage_by_diagnostic( + app_context.reader, + ResourceFilter( + include_superseded=False, + include_retracted=True, + source_types=scope.filters.source_types, + or_no_model_input=scope.filters.or_no_model_input, + input_datasets=scope.filters.input_datasets, + ), ) - successful_group_counts_dict = {row[0]: row[1] for row in successful_group_counts} - + counts = { + (row.provider_slug, row.diagnostic_slug): row + for row in app_context.session.execute(select_group_counts(scope.filters)) + } return [ DiagnosticCatalogEntry.build_with_stats( m, app_context, - execution_stats=execution_stats.get(m.id, {"total": 0, "successful": 0}), - execution_group_count=group_counts.get(m.id, 0), - successful_execution_group_count=successful_group_counts_dict.get(m.id, 0), - resource_usage=resource_usage.get(m.id), + groups=GroupCounts.of(counts.get(_key(m))), + resource_usage=resource_usage.get(_key(m)), ) - for m in diagnostics + for m in scope.diagnostics ] @router.get("/", name="list") async def _list( - app_context: AppContextDep, mip_era: str | None = None, source_id: str | None = None + app_context: AppContextDep, + mip_era: MipEra | None = Query(None, description="Count only the execution groups that draw on this era"), + source_id: str | None = Query(None, description="Count only the execution groups that ran this model"), ) -> Collection[DiagnosticSummary]: """ List the currently registered diagnostics - Pass `mip_era` to count only the execution groups that ran against that era, - and `source_id` to count only those that ran against that model. + Counts cover the promoted version of each diagnostic and leave out retracted executions. """ - diagnostics, group_scope = _scoped_diagnostics(app_context, mip_era, source_id) - value_types = _value_types(app_context, group_scope) + scope = _scoped_diagnostics(app_context, mip_era, source_id) + value_types = _value_types(app_context, scope) return Collection( data=[ DiagnosticSummary.from_catalog_entry( entry, - has_scalar_values=entry.id in value_types.scalar, - has_series_values=entry.id in value_types.series, + has_scalar_values=_key(entry) in value_types.scalar, + has_series_values=_key(entry) in value_types.series, ) - for entry in _catalog_entries(app_context, diagnostics, group_scope) + for entry in _catalog_entries(app_context, scope) ] ) @router.get("/catalog", name="catalog") async def catalog( - app_context: AppContextDep, mip_era: str | None = None, source_id: str | None = None + app_context: AppContextDep, + mip_era: MipEra | None = Query(None, description="Count only the execution groups that draw on this era"), + source_id: str | None = Query(None, description="Count only the execution groups that ran this model"), ) -> Collection[DiagnosticCatalogEntry]: """ List the diagnostics like `/diagnostics/`, without whether each has metric values Checking for metric values is most of the cost of the full listing. Fetch those from `/diagnostics/value-flags`. - - Pass `mip_era` and `source_id` to count only the execution groups that ran against that era or model. """ - diagnostics, group_scope = _scoped_diagnostics(app_context, mip_era, source_id) - return Collection(data=_catalog_entries(app_context, diagnostics, group_scope)) + scope = _scoped_diagnostics(app_context, mip_era, source_id) + return Collection(data=_catalog_entries(app_context, scope)) @router.get("/value-flags", name="value_flags") async def value_flags( - app_context: AppContextDep, mip_era: str | None = None, source_id: str | None = None + app_context: AppContextDep, + mip_era: MipEra | None = Query(None, description="Check only the execution groups that draw on this era"), + source_id: str | None = Query(None, description="Check only the execution groups that ran this model"), ) -> Collection[DiagnosticValueFlags]: """ Whether each listed diagnostic has scalar and series values These are the flags `/diagnostics/catalog` leaves out. """ - diagnostics, group_scope = _scoped_diagnostics(app_context, mip_era, source_id) - value_types = _value_types(app_context, group_scope) + scope = _scoped_diagnostics(app_context, mip_era, source_id) + value_types = _value_types(app_context, scope) return Collection( data=[ DiagnosticValueFlags( id=d.id, - has_metric_values=d.id in value_types.scalar or d.id in value_types.series, - has_scalar_values=d.id in value_types.scalar, - has_series_values=d.id in value_types.series, + has_metric_values=_key(d) in value_types.scalar or _key(d) in value_types.series, + has_scalar_values=_key(d) in value_types.scalar, + has_series_values=_key(d) in value_types.series, ) - for d in diagnostics + for d in scope.diagnostics ] ) -@router.get("/facets", name="facets") -async def facets(app_context: AppContextDep) -> MetricValueFacetSummary: - """ - Query the unique dimensions and metrics for all diagnostics (both scalar and series) - """ - # Query the base class so no type filter is added. - # With one, SQLite walks the type index instead of the covering index on each dimension. - session = app_context.session - dimension_summary = {} - for dimension_name in models.MetricValue._cv_dimensions: - if not hasattr(models.MetricValue, dimension_name): - continue - - column = getattr(models.MetricValue, dimension_name) - values = session.scalars(select(column).where(column.isnot(None)).distinct()) - dimension_summary[dimension_name] = sorted(values) - - count = session.scalar(select(func.count(models.MetricValue.id))) or 0 - - return MetricValueFacetSummary( - dimensions=dimension_summary, - count=count, - ) - - @router.get("/{provider_slug}/{diagnostic_slug}") async def get(app_context: AppContextDep, provider_slug: str, diagnostic_slug: str) -> DiagnosticSummary: """ @@ -305,56 +214,77 @@ async def get(app_context: AppContextDep, provider_slug: str, diagnostic_slug: s @router.get("/{provider_slug}/{diagnostic_slug}/execution_groups") async def list_execution_groups( - app_context: AppContextDep, provider_slug: str, diagnostic_slug: str, mip_era: str | None = None + app_context: AppContextDep, + provider_slug: str, + diagnostic_slug: str, + mip_era: MipEra | None = Query(None, description="Keep only the execution groups that draw on this era"), ) -> Collection[ExecutionGroup]: """ - Fetch execution groups for a diagnostic. + Fetch the execution groups of a diagnostic, at its promoted version - Pass `mip_era` to keep only the groups that ran against that era. + Groups whose latest execution is retracted are left out, matching the counts. """ - diagnostic = await _get_diagnostic(app_context, provider_slug, diagnostic_slug) - group_filters = [models.ExecutionGroup.diagnostic_id == diagnostic.id] - if mip_era: - group_filters.append(execution_group_filter({"mip_era": mip_era})) - - # Eager-load relationships to avoid per-item queries - execution_groups = ( - app_context.session.query(models.ExecutionGroup) - .options(*EXECUTION_GROUP_LOAD_OPTIONS) - .filter(*group_filters) - .all() + await _get_diagnostic(app_context, provider_slug, diagnostic_slug) + groups, _ = load_execution_groups( + app_context.session, + ExecutionGroupFilter( + provider_slug=provider_slug, + diagnostic_slug=diagnostic_slug, + include_superseded=False, + include_retracted=False, + **era_scope(mip_era), + ), + with_count=False, ) + return Collection(data=[ExecutionGroup.build(g, app_context) for g in groups], total_count=len(groups)) - return Collection(data=[ExecutionGroup.build(e, app_context) for e in execution_groups]) +async def _metric_filter( # noqa: PLR0913, PLR0917 + app_context: AppContextDep, + provider_slug: str, + diagnostic_slug: str, + request: Request, + mip_era: MipEra | None, + isolate_ids: str | None, + exclude_ids: str | None, +) -> MetricValueFilter: + # Validates the provider/diagnostic exist and are not excluded (raises 404 otherwise). + await _get_diagnostic(app_context, provider_slug, diagnostic_slug) + return MetricValueFilter( + diagnostic_slug=diagnostic_slug, + provider_slug=provider_slug, + include_superseded=False, + include_retracted=False, + latest_only=False, + **era_scope(mip_era), + dimensions=parse_dimension_filters(request.query_params), + isolate_ids=parse_id_list(isolate_ids), + exclude_ids=parse_id_list(exclude_ids), + ) -@router.get( - "/{provider_slug}/{diagnostic_slug}/executions", - response_model=Collection[Execution], -) -async def list_executions( + +@router.get("/{provider_slug}/{diagnostic_slug}/values/facets") +async def list_metric_value_facets( # noqa: PLR0913, PLR0917 app_context: AppContextDep, provider_slug: str, diagnostic_slug: str, request: Request, -) -> Collection[Execution]: + value_type: MetricValueType = Query(..., description="Type of metric values to read the facets of"), + isolate_ids: str | None = Query(None, description="Comma-separated list of metric value IDs to isolate"), + exclude_ids: str | None = Query(None, description="Comma-separated list of metric value IDs to exclude"), + mip_era: MipEra | None = Query( + None, description="Keep only the execution groups that draw on this era, or on no model data" + ), +) -> list[Facet]: """ - Fetch executions for a specific diagnostic, with arbitrary filters on the dataset. + Get the facets of the values `/values` returns for the same filters, without the values. - e.g. `?source_id=MIROC6&experiment_id=ssp585`. Pass `mip_era` to restrict to one era. + They cover every page and the outliers too, so a client can keep them while it pages. """ - diagnostic = await _get_diagnostic(app_context, provider_slug, diagnostic_slug) - - executions_query = ( - app_context.session.query(models.Execution) - .join(models.ExecutionGroup) - .filter(models.ExecutionGroup.diagnostic_id == diagnostic.id) - .filter(cmip_dataset_filter(dict(request.query_params))) + metric_filter = await _metric_filter( + app_context, provider_slug, diagnostic_slug, request, mip_era, isolate_ids, exclude_ids ) - - executions = executions_query.all() - - return Collection(data=[Execution.build(e, app_context) for e in executions]) + return fetch_facets(app_context, metric_filter, value_type) @router.get("/{provider_slug}/{diagnostic_slug}/values", response_model=MetricValueCollection) @@ -373,7 +303,10 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 include_unverified: bool = Query(False, description="Include unverified (outlier) values"), isolate_ids: str | None = Query(None, description="Comma-separated list of metric value IDs to isolate"), exclude_ids: str | None = Query(None, description="Comma-separated list of metric value IDs to exclude"), - mip_era: str | None = Query(None, description="Restrict to one MIP era, CMIP6 or CMIP7"), + mip_era: MipEra | None = Query( + None, description="Keep only the execution groups that draw on this era, or on no model data" + ), + with_facets: bool = Query(True, description="Fill `facets`, which `/values/facets` also returns"), ) -> MetricValueCollection | StreamingResponse: """ Get all the diagnostic values for a given diagnostic (both scalar and series) @@ -382,35 +315,20 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 - `format`: Return format - 'json' (default) or 'csv' - `offset`: Number of items to skip (default 0) - `limit`: Maximum number of items to return (default 50, max 500) - - `mip_era`: Restrict to the executions of one era, so outlier detection and pagination - see only the values the caller is charting - """ - # Validates the provider/diagnostic exist and are not excluded (raises 404 otherwise). - diagnostic = await _get_diagnostic(app_context, provider_slug, diagnostic_slug) - - execution_ids = None - if mip_era: - # The reader reads an empty id list as unconstrained, so an era that matched no execution - # needs an id no value can carry rather than no filter at all. - execution_ids = executions_in_mip_era(app_context.session, mip_era, diagnostic.id) or [0] + - `mip_era`: Restrict to the groups of one era, so outlier detection and pagination + see only the values the caller is charting. + Groups that use no model data, such as observation-only ones, are kept. - # 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, - execution_ids=execution_ids, - 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, + Values come from the promoted diagnostic version, and retracted executions are left out. + """ + metric_filter = await _metric_filter( + app_context, provider_slug, diagnostic_slug, request, mip_era, isolate_ids, exclude_ids ) return fetch_metric_values( app_context, metric_filter, + with_facets=with_facets, value_type=value_type, format=format, offset=offset, diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 9ba4132e..050a24a1 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -2,44 +2,44 @@ import os import tarfile import tempfile -from collections.abc import Generator, Sequence +from collections.abc import Generator from pathlib import Path -from typing import Literal +from typing import Literal, get_args from fastapi import APIRouter, HTTPException, Query, Request from loguru import logger -from sqlalchemy import select -from sqlalchemy.orm import Session from starlette.responses import Response, StreamingResponse from climate_ref import models from climate_ref.models.dataset import DatasetFile -from climate_ref.results import ExecutionGroupFilter, MetricValueFilter +from climate_ref.models.execution import ExecutionFailureReason +from climate_ref.query import select_group_counts +from climate_ref.results import DiagnosticFilter, ExecutionGroupFilter, MetricValueFilter, ResourceFilter from climate_ref_core.pycmec.metric import CMECMetric from ref_backend.api.deps import AppContext, AppContextDep +from ref_backend.core.execution_groups import load_execution_groups from ref_backend.core.file_handling import file_iterator, resolve_artifact, tail_lines from ref_backend.core.metric_values import ( MetricValueType, parse_id_list, ) -from ref_backend.core.mip_eras import execution_group_filter, execution_groups_per_era +from ref_backend.core.mip_eras import MipEra, era_scope, source_id_datasets from ref_backend.core.reader_values import ( + fetch_facets, fetch_metric_values, parse_dimension_filters, ) -from ref_backend.core.resource_usage import resource_usage_overall +from ref_backend.core.resource_usage import resource_usage from ref_backend.models import ( Collection, Dataset, Execution, ExecutionGroup, ExecutionStats, + Facet, + GroupCounts, MetricValueCollection, ) -from ref_backend.models.executions import ( - EXECUTION_GROUP_LOAD_OPTIONS, - EXECUTION_GROUP_LOAD_OPTIONS_WITHOUT_OUTPUTS, -) router = APIRouter(prefix="/executions", tags=["executions"]) @@ -51,77 +51,58 @@ def _parse_int_id(value: str, resource: str) -> int: raise HTTPException(status_code=404, detail=f"{resource} not found") from None -def _load_execution_groups( - session: Session, group_ids: Sequence[int], include_outputs: bool = True -) -> list[models.ExecutionGroup]: - """ - Load the ORM rows for execution groups the reader has already selected - - The response builders need ORM objects, so the reader decides which groups to show - and this hydrates them, preserving the order of ``group_ids``. - """ - if not group_ids: - return [] - - load_options = ( - EXECUTION_GROUP_LOAD_OPTIONS if include_outputs else EXECUTION_GROUP_LOAD_OPTIONS_WITHOUT_OUTPUTS - ) - rows = ( - session.query(models.ExecutionGroup) - .options(*load_options) - .filter(models.ExecutionGroup.id.in_(group_ids)) - .all() - ) - by_id = {row.id: row for row in rows} - return [by_id[group_id] for group_id in group_ids if group_id in by_id] - - @router.get("/statistics") async def get_execution_statistics(app_context: AppContextDep) -> ExecutionStats: """ Get execution statistics for the dashboard. Execution groups are counted at the promoted version of each diagnostic, - and classified by the outcome of their latest execution. + by the outcome of their latest execution, leaving out retracted executions. + Metric values, datasets and files are counted as stored, whatever their version or retraction. """ - session = app_context.session - stats = app_context.reader.executions.statistics() + session = app_context.reader.session + + def group_counts(mip_era: MipEra | None = None) -> GroupCounts: + filters = DiagnosticFilter(include_superseded=False, include_retracted=False, **era_scope(mip_era)) + return GroupCounts.summed(session.execute(select_group_counts(filters)).all()) return ExecutionStats( - total_execution_groups=sum(s.total for s in stats), - successful_execution_groups=sum(s.successful for s in stats), - failed_execution_groups=sum(s.failed for s in stats), - running_execution_groups=sum(s.running for s in stats), - not_started_execution_groups=sum(s.not_started for s in stats), - execution_groups_by_mip_era=execution_groups_per_era(session), + groups=group_counts(), + groups_by_mip_era={era: group_counts(era) for era in get_args(MipEra)}, scalar_value_count=session.query(models.ScalarMetricValue).count(), series_value_count=session.query(models.SeriesMetricValue).count(), total_datasets=session.query(models.Dataset).count(), total_files=session.query(DatasetFile).count(), - resource_usage=resource_usage_overall(session), + resource_usage=resource_usage( + app_context.reader, + ResourceFilter(include_superseded=False, include_retracted=True, latest_only=False), + ), ) @router.get("/") async def list_recent_execution_groups( # noqa: PLR0913, PLR0917 app_context: AppContextDep, - limit: int = 10, - offset: int = 0, + limit: int = Query(10, ge=0), + offset: int = Query(0, ge=0), diagnostic_name_contains: str | None = None, provider_name_contains: str | None = None, dirty: bool | None = None, successful: bool | None = None, - failure_reason: str | None = None, - source_id: str | None = None, - mip_era: str | None = None, + failure_reason: ExecutionFailureReason | None = None, + source_id: str | None = Query( + None, description="Keep only the groups whose latest execution ran this model" + ), + mip_era: MipEra | None = Query(None, description="Keep only the groups that draw on this era"), include_outputs: bool = Query( False, description="Include the files produced by each execution; they are left out by default" ), ) -> Collection[ExecutionGroup]: """ - List the most recent execution groups + List the most recently updated execution groups - Only groups at the promoted version of their diagnostic are returned. + Only groups at the promoted version of their diagnostic are returned, + and groups whose latest execution is retracted are left out. Supports filtering by: - diagnostic_name_contains (case-insensitive substring of the diagnostic slug) @@ -129,66 +110,56 @@ async def list_recent_execution_groups( # noqa: PLR0913, PLR0917 - dirty - successful (filters by latest execution success) - failure_reason (filters by why the latest execution failed, e.g. `system_error`) - - source_id (filters groups that include an execution whose datasets - include a CMIP6 or CMIP7 dataset with this source_id) - - mip_era (restricts the above to a single era, CMIP6 or CMIP7) + - source_id (the latest execution ran against a dataset of this model) + - mip_era (the group draws on this era) Each execution's `outputs` are null unless `include_outputs` is set, because the file lists make up most of the response. Fetch one group's files from `/executions/{group_id}`. """ - session = app_context.session - filters = ExecutionGroupFilter( - diagnostic_contains=[diagnostic_name_contains] if diagnostic_name_contains else None, - provider_contains=[provider_name_contains] if provider_name_contains else None, + diagnostic_contains=diagnostic_name_contains or None, + provider_contains=provider_name_contains or None, + include_superseded=False, + include_retracted=False, dirty=dirty, successful=successful, + failure_reasons=failure_reason, + **era_scope(mip_era), + input_datasets=source_id_datasets(source_id), ) - # The reader orders by id, so sort here to keep the most recently updated groups first. - groups = sorted( - app_context.reader.executions.groups(filters), - key=lambda group: (group.updated_at, group.id), - reverse=True, + groups, total_count = load_execution_groups( + app_context.session, + filters, + order="recently_updated", + offset=offset, + limit=limit, + include_outputs=include_outputs, ) - - if failure_reason is not None: - groups = [group for group in groups if group.failure_reason == failure_reason] - - # Selectors do not record source_id, so match on the datasets used. - if source_id or mip_era: - facets = {key: value for key, value in {"source_id": source_id, "mip_era": mip_era}.items() if value} - matching_ids = set( - session.scalars(select(models.ExecutionGroup.id).where(execution_group_filter(facets))) - ) - groups = [group for group in groups if group.id in matching_ids] - - total_count = len(groups) - page_ids = [group.id for group in groups[offset : offset + limit]] - data = [] - for eg in _load_execution_groups(session, page_ids, include_outputs=include_outputs): + for eg in groups: try: data.append(ExecutionGroup.build(eg, app_context, include_outputs=include_outputs)) except Exception as e: logger.error(f"Error building execution group ID {eg.id}: {e}") - continue - - return Collection( - total_count=total_count, - data=data, - ) + return Collection(total_count=total_count, data=data) @router.get("/{group_id}") async def get(app_context: AppContextDep, group_id: str) -> ExecutionGroup: """ - Inspect a specific execution + Inspect a specific execution group, whatever its version or retraction """ group_id_int = _parse_int_id(group_id, "Execution group") - execution_groups = _load_execution_groups(app_context.session, [group_id_int]) - if not execution_groups: + groups, _ = load_execution_groups( + app_context.session, + ExecutionGroupFilter( + execution_group_ids=group_id_int, include_superseded=True, include_retracted=True + ), + with_count=False, + ) + if not groups: raise HTTPException(status_code=404, detail="Execution not found") - return ExecutionGroup.build(execution_groups[0], app_context) + return ExecutionGroup.build(groups[0], app_context) async def _get_execution( @@ -300,6 +271,39 @@ async def metric_bundle( return CMECMetric.load_from_json(file_path) +def _metric_filter( + execution: models.Execution, request: Request, isolate_ids: str | None, exclude_ids: str | None +) -> MetricValueFilter: + return MetricValueFilter( + execution_ids=execution.id, + include_superseded=True, + include_retracted=True, + latest_only=False, + dimensions=parse_dimension_filters(request.query_params), + isolate_ids=parse_id_list(isolate_ids), + exclude_ids=parse_id_list(exclude_ids), + ) + + +@router.get("/{group_id}/values/facets") +async def list_metric_value_facets( # noqa: PLR0913, PLR0917 + app_context: AppContextDep, + request: Request, + group_id: str, + execution_id: str | None = None, + value_type: MetricValueType = Query(..., description="Type of metric values to read the facets of"), + isolate_ids: str | None = Query(None, description="Comma-separated list of metric value IDs to isolate"), + exclude_ids: str | None = Query(None, description="Comma-separated list of metric value IDs to exclude"), +) -> list[Facet]: + """ + Get the facets of the values `/values` returns for the same filters, without the values. + + They cover every page and the outliers too, so a client can keep them while it pages. + """ + execution = await _get_execution(group_id, execution_id, app_context) + return fetch_facets(app_context, _metric_filter(execution, request, isolate_ids, exclude_ids), value_type) + + @router.get("/{group_id}/values", response_model=MetricValueCollection) async def list_metric_values( # noqa: PLR0913, PLR0917 app_context: AppContextDep, @@ -316,33 +320,24 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 include_unverified: bool = Query(False, description="Include unverified (outlier) values"), isolate_ids: str | None = Query(None, description="Comma-separated list of metric value IDs to isolate"), exclude_ids: str | None = Query(None, description="Comma-separated list of metric value IDs to exclude"), + with_facets: bool = Query(True, description="Fill `facets`, which `/values/facets` also returns"), ) -> MetricValueCollection | StreamingResponse: """ Fetch metric values for a specific execution (both scalar and series) - - `value_type`: Type of metric values - 'scalar', 'series', or 'all' (required) + - `value_type`: Type of metric values - 'scalar' or 'series' (required) - `format`: Return format - 'json' (default) or 'csv' - `offset`: Number of items to skip (default 0) - `limit`: Maximum number of items to return (default 50, max 500) + + The values of the named execution are returned, whatever its version or retraction. """ execution = await _get_execution(group_id, execution_id, app_context) - # 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, - ) - return fetch_metric_values( app_context, - metric_filter, + _metric_filter(execution, request, isolate_ids, exclude_ids), + with_facets=with_facets, value_type=value_type, format=format, offset=offset, diff --git a/backend/src/ref_backend/api/routes/models.py b/backend/src/ref_backend/api/routes/models.py index 06d3d1e3..74e63f0d 100644 --- a/backend/src/ref_backend/api/routes/models.py +++ b/backend/src/ref_backend/api/routes/models.py @@ -5,6 +5,7 @@ from climate_ref import models from ref_backend.api.deps import SessionDep +from ref_backend.core.mip_eras import MipEra from ref_backend.core.model_ensemble import ensemble_comparisons from ref_backend.core.model_runs import ( ModelFacets, @@ -52,7 +53,7 @@ def _summary_fields(source_id: str, rows: list[ModelRunRow], facets: ModelFacets @router.get("/", name="list") async def _list( session: SessionDep, - mip_era: str | None = Query(None, description="Restrict to one MIP era, CMIP6 or CMIP7"), + mip_era: MipEra | None = Query(None, description="Restrict to one MIP era"), ) -> Collection[ModelSummary]: """ List the models that have been run, with a tally of how their runs went. @@ -74,7 +75,7 @@ async def _list( async def get( session: SessionDep, source_id: str, - mip_era: str | None = Query(None, description="Restrict to one MIP era, CMIP6 or CMIP7"), + mip_era: MipEra | None = Query(None, description="Restrict to one MIP era"), ) -> ModelDetail: """ Summarise the runs a single model took part in, broken down by diagnostic. @@ -131,7 +132,7 @@ async def get( async def ensemble( session: SessionDep, source_id: str, - mip_era: str | None = Query(None, description="Restrict to one MIP era, CMIP6 or CMIP7"), + mip_era: MipEra | None = Query(None, description="Restrict to one MIP era"), diagnostic_slug: str | None = Query(None, description="Restrict to a single diagnostic"), ) -> Collection[EnsembleComparison]: """ diff --git a/backend/src/ref_backend/api/routes/results.py b/backend/src/ref_backend/api/routes/results.py index 99404923..d6c6a727 100644 --- a/backend/src/ref_backend/api/routes/results.py +++ b/backend/src/ref_backend/api/routes/results.py @@ -15,7 +15,7 @@ async def get_result(session: SessionDep, reader: ReaderDep, result_id: int) -> """ Fetch a result """ - result = session.query(ExecutionOutput).get(result_id) + result = session.get(ExecutionOutput, result_id) if result is None: raise HTTPException(status_code=404, detail="Result not found") diff --git a/backend/src/ref_backend/core/execution_groups.py b/backend/src/ref_backend/core/execution_groups.py new file mode 100644 index 00000000..bfcb0f47 --- /dev/null +++ b/backend/src/ref_backend/core/execution_groups.py @@ -0,0 +1,44 @@ +"""Load pages of execution groups through the climate-ref query builders.""" + +from typing import Literal + +from sqlalchemy.orm import Session, selectinload + +from climate_ref import models +from climate_ref.query import count, paginate, select_execution_group_ids, select_execution_groups +from climate_ref.results import ExecutionGroupFilter + +# What ExecutionGroup.build touches beyond the diagnostic and provider the builder already joins, +# so a page of groups loads in a fixed number of queries. +_WITHOUT_OUTPUTS = (selectinload(models.ExecutionGroup.executions).selectinload(models.Execution.datasets),) +_WITH_OUTPUTS = ( + selectinload(models.ExecutionGroup.executions).selectinload(models.Execution.outputs), + *_WITHOUT_OUTPUTS, +) + + +def load_execution_groups( # noqa: PLR0913 + session: Session, + filters: ExecutionGroupFilter, + *, + order: Literal["id", "recently_updated"] = "id", + offset: int = 0, + limit: int | None = None, + include_outputs: bool = True, + with_count: bool = True, +) -> tuple[list[models.ExecutionGroup], int | None]: + """ + Load a page of the groups `filters` keeps, ready for `ExecutionGroup.build`, and how many match. + + Pass `with_count=False` to skip counting, which leaves the count `None`. + + `recently_updated` puts the most recently updated groups first. + """ + stmt = select_execution_groups(filters).options(*(_WITH_OUTPUTS if include_outputs else _WITHOUT_OUTPUTS)) + if order == "recently_updated": + stmt = stmt.order_by(None).order_by( + models.ExecutionGroup.updated_at.desc(), models.ExecutionGroup.id.desc() + ) + total_count = count(session, select_execution_group_ids(filters)) if with_count else None + rows = session.execute(paginate(stmt, offset, limit)).tuples().all() + return [group for group, _latest in rows], total_count diff --git a/backend/src/ref_backend/core/filter_utils.py b/backend/src/ref_backend/core/filter_utils.py deleted file mode 100644 index 71511137..00000000 --- a/backend/src/ref_backend/core/filter_utils.py +++ /dev/null @@ -1,49 +0,0 @@ -# mypy: disable-error-code="no-any-return" -""" -Shared utilities for parsing multi-value query parameters -""" - -from typing import Any - -from sqlalchemy.sql.elements import BinaryExpression - - -def _normalize_list_from_value(value: Any) -> list[str] | None: - """ - Convert a value that may be a list or a comma-separated string into a cleaned list of strings. - - Returns None if the input should be treated as a single value (no list semantics). - """ - if isinstance(value, list): - # e.g. from parsed JSON - items = [str(v) for v in value if v is not None] - elif isinstance(value, str) and "," in value: - items = value.split(",") - else: - return None - - vals = [item.strip() for item in items if item.strip()] - return vals or None - - -def build_filter_clause(column: Any, value: Any) -> BinaryExpression[Any]: - """ - Build a SQLAlchemy binary expression for a filter on `column` given `value`. - - Behavior: - - If `value` is a list (or a comma-separated string with multiple items), - an `IN` clause is returned. - - If `value` contains exactly one normalized item, a simple equality - (`column == item`) is returned. - - Otherwise, fall back to equality with the original `value`, - including for an empty list. - - This function is intended to be used in route handlers where query values - may be single values, comma-separated strings, or parsed JSON lists. - """ - vals = _normalize_list_from_value(value) - if vals is None: - return column == value - if len(vals) == 1: - return column == vals[0] - return column.in_(vals) diff --git a/backend/src/ref_backend/core/metric_values.py b/backend/src/ref_backend/core/metric_values.py index 207eaf4f..61be3c0a 100644 --- a/backend/src/ref_backend/core/metric_values.py +++ b/backend/src/ref_backend/core/metric_values.py @@ -3,10 +3,6 @@ from enum import StrEnum from fastapi import HTTPException -from sqlalchemy import ColumnElement, String, cast - -from climate_ref import models -from climate_ref.models.metric_value import MetricValueType as StoredMetricValueType class MetricValueType(StrEnum): @@ -16,19 +12,14 @@ class MetricValueType(StrEnum): SERIES = "series" -def parse_id_list(id_str: str) -> list[int]: - """Parse comma-separated list of IDs into integers.""" - try: - return [int(i.strip()) for i in id_str.split(",") if i.strip()] - except ValueError as e: - raise HTTPException(status_code=400, detail=f"Invalid id in list: {e}") from e - - -def metric_value_type_is(value_type: StoredMetricValueType) -> ColumnElement[bool]: +def parse_id_list(id_str: str | None) -> list[int] | None: """ - Match metric values of one type without letting SQLite use `ix_metric_value_type` + Parse comma-separated list of IDs into integers. - That index splits every row into two values, but with no planner stats - SQLite prefers it over the selective index on the other side of a join. + A missing or blank list is `None`, because the reader reads an empty list as matching nothing. """ - return cast(models.MetricValue.type, String) == value_type.name + try: + ids = [int(i.strip()) for i in (id_str or "").split(",") if i.strip()] + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid id in list: {e}") from e + return ids or None diff --git a/backend/src/ref_backend/core/mip_eras.py b/backend/src/ref_backend/core/mip_eras.py index b6a0b5df..ae4fa4a5 100644 --- a/backend/src/ref_backend/core/mip_eras.py +++ b/backend/src/ref_backend/core/mip_eras.py @@ -1,18 +1,26 @@ """Helpers for telling the CMIP6 and CMIP7 eras apart.""" -from collections.abc import Collection, Mapping -from typing import Any +from collections.abc import Collection +from typing import Any, Literal -from sqlalchemy import ColumnElement, and_, distinct, false, func, or_, select +from sqlalchemy import ColumnElement, select from sqlalchemy.orm import Session -from sqlalchemy.sql.expression import ColumnCollection from climate_ref import models +from climate_ref.models.execution import ExecutionGroupSourceType +from climate_ref.query.filters import ExecutionScope +from climate_ref.results import InputDatasetFilter from climate_ref_core.datasets import SourceDatasetType -from ref_backend.core.filter_utils import build_filter_clause + +MipEra = Literal["CMIP6", "CMIP7"] #: The MIP eras the app presents. Data from the two is never combined on a chart. -CMIP_ERAS: tuple[SourceDatasetType, ...] = (SourceDatasetType.CMIP6, SourceDatasetType.CMIP7) +_ERA_TYPES: dict[MipEra, SourceDatasetType] = { + "CMIP6": SourceDatasetType.CMIP6, + "CMIP7": SourceDatasetType.CMIP7, +} +_TYPE_ERAS: dict[SourceDatasetType, MipEra] = {source_type: era for era, source_type in _ERA_TYPES.items()} +CMIP_ERAS: tuple[SourceDatasetType, ...] = tuple(_ERA_TYPES.values()) def dataset_model_for(source_type: SourceDatasetType) -> type[models.Dataset]: @@ -29,129 +37,68 @@ def dataset_type_label(source_type: SourceDatasetType) -> str: return str(source_type.value) -def mip_era_for(source_type: SourceDatasetType) -> str | None: +def mip_era_for(source_type: SourceDatasetType) -> MipEra | None: """Map a source dataset type onto the MIP era label used to keep the eras apart.""" - if source_type not in CMIP_ERAS: - return None - return dataset_type_label(source_type).upper() - - -def _mapped_columns(source_type: SourceDatasetType) -> ColumnCollection[str, Any]: - """Return the columns an era's dataset table actually carries.""" - return dataset_model_for(source_type).__mapper__.columns - + return _TYPE_ERAS.get(source_type) -def cv_column(mapped: Any, name: str) -> Any: - """Read a column the CV registers at runtime, which the mapped class does not declare.""" - return getattr(mapped, name) +def source_types_for(mip_era: MipEra | None) -> tuple[SourceDatasetType, ...] | None: + """Resolve a `mip_era` query value into the source types it keeps, or `None` to keep every group.""" + return None if mip_era is None else (_ERA_TYPES[mip_era],) -def cmip_dataset_filter(facets: Mapping[str, str]) -> ColumnElement[bool]: - """ - Match executions holding a CMIP6 or CMIP7 dataset that satisfies every facet in `facets`. - - A facet no CMIP era carries is ignored, which keeps unknown query parameters harmless. - A facet only one era carries excludes the other, so a CMIP7-only facet does not pull in - all of CMIP6. - """ - requested_era = facets.get("mip_era") - columns_by_era = {era: _mapped_columns(era) for era in CMIP_ERAS} - known = { - key: value - for key, value in facets.items() - if key != "mip_era" and any(key in columns for columns in columns_by_era.values()) - } - - branches = [] - for source_type in CMIP_ERAS: - if requested_era and requested_era.upper() != mip_era_for(source_type): - continue - - dataset_model = dataset_model_for(source_type) - if any(key not in columns_by_era[source_type] for key in known): - continue - - conditions = [build_filter_clause(getattr(dataset_model, key), value) for key, value in known.items()] - datasets = models.Execution.datasets.of_type(dataset_model) - branches.append(datasets.any(and_(*conditions)) if conditions else datasets.any()) - - if not branches: - # Nothing can satisfy the request, so match no rows rather than every row. - return false() - return or_(*branches) +def era_scope(mip_era: MipEra | None) -> dict[str, Any]: + """Filter fields keeping the groups that draw on `mip_era`, or on no model data, or every group.""" + if mip_era is None: + return {} + return {"source_types": source_types_for(mip_era), "or_no_model_input": True} -def execution_group_filter(facets: Mapping[str, str]) -> ColumnElement[bool]: - """Match execution groups holding an execution that satisfies `cmip_dataset_filter`.""" - return models.ExecutionGroup.executions.any(cmip_dataset_filter(facets)) +def eras_in_scope(mip_era: MipEra | None) -> tuple[SourceDatasetType, ...]: + """Resolve a `mip_era` query value into the CMIP eras it keeps.""" + return source_types_for(mip_era) or CMIP_ERAS -def execution_groups_per_era(session: Session) -> dict[str, int]: - """ - Count the execution groups that ran against each MIP era, keyed by era label. - A group holding both eras counts once for each, so the counts do not partition the total. - Groups are counted at the promoted version of each diagnostic, matching the overall statistics. - """ - rows = session.execute( - select(models.Dataset.dataset_type, func.count(distinct(models.ExecutionGroup.id))) - .join(models.Diagnostic, models.ExecutionGroup.diagnostic_id == models.Diagnostic.id) - .join(models.ExecutionGroup.executions) - .join(models.Execution.datasets) - .where( - models.ExecutionGroup.diagnostic_version == models.Diagnostic.promoted_version, - models.Dataset.dataset_type.in_(CMIP_ERAS), - ) - .group_by(models.Dataset.dataset_type) - ).all() +def era_group_clauses(mip_era: MipEra) -> list[ColumnElement[bool]]: + """Clauses over `ExecutionGroup` keeping the groups that draw on `mip_era`, or on no model data.""" + return ExecutionScope(**era_scope(mip_era)).group_clauses() - counts = {mip_era_for(source_type): count for source_type, count in rows} - return {label: counts.get(label, 0) for era in CMIP_ERAS if (label := mip_era_for(era))} +def source_id_datasets(source_id: str | None) -> InputDatasetFilter | None: + """Match executions that ran against a model, which selectors do not always record.""" + return InputDatasetFilter(facets={"source_id": source_id}) if source_id else None -def executions_in_mip_era(session: Session, mip_era: str, diagnostic_id: int) -> list[int]: - """ - Find the executions of a diagnostic that ran against `mip_era`, plus any with no era at all. - An execution with no CMIP input carries no era, so dropping it would lose values rather than - label them. Scoping the values query this way is what keeps outlier detection and pagination - inside a single era. - """ - no_cmip_input = ~cmip_dataset_filter({}) - rows = session.execute( - select(models.Execution.id) - .join(models.Execution.execution_group) - .where( - models.ExecutionGroup.diagnostic_id == diagnostic_id, - or_(cmip_dataset_filter({"mip_era": mip_era}), no_cmip_input), - ) - ).scalars() - return list(rows) +def cv_column(mapped: Any, name: str) -> Any: + """Read a column the CV registers at runtime, which the mapped class does not declare.""" + return getattr(mapped, name) -def mip_eras_for_executions(session: Session, execution_ids: Collection[int]) -> dict[int, str]: +def mip_eras_for_executions(session: Session, execution_ids: Collection[int]) -> dict[int, MipEra]: """ - Map each execution onto the MIP era of the model datasets it ran against. + Map each execution onto the MIP era its group draws on. - Most diagnostics never record an era on their own values, so this recovers it from the inputs. - An execution mixing eras is left out, because no single era describes it. + Most diagnostics never record an era on their own values, so this recovers it from the group. + A group drawing on both eras is left out, because no single era describes it. """ if not execution_ids: return {} rows = session.execute( - select(models.Execution.id, models.Dataset.dataset_type) - .join(models.Execution.datasets) + select(models.Execution.id, ExecutionGroupSourceType.source_type) + .join( + ExecutionGroupSourceType, + ExecutionGroupSourceType.execution_group_id == models.Execution.execution_group_id, + ) .where( models.Execution.id.in_(execution_ids), - models.Dataset.dataset_type.in_(CMIP_ERAS), + ExecutionGroupSourceType.source_type.in_(CMIP_ERAS), ) - .distinct() ).all() - eras: dict[int, str | None] = {} - for execution_id, dataset_type in rows: - era = mip_era_for(dataset_type) + eras: dict[int, MipEra | None] = {} + for execution_id, source_type in rows: + era = mip_era_for(source_type) eras[execution_id] = era if execution_id not in eras else None return {execution_id: era for execution_id, era in eras.items() if era is not None} diff --git a/backend/src/ref_backend/core/model_ensemble.py b/backend/src/ref_backend/core/model_ensemble.py index fde4bcea..98b91333 100644 --- a/backend/src/ref_backend/core/model_ensemble.py +++ b/backend/src/ref_backend/core/model_ensemble.py @@ -5,12 +5,12 @@ from statistics import fmean, pstdev, quantiles from typing import Any, NamedTuple -from sqlalchemy import or_, select +from sqlalchemy import select from sqlalchemy.orm import Session from climate_ref import models -from ref_backend.core.mip_eras import cmip_dataset_filter, cv_column -from ref_backend.core.model_runs import latest_executions +from climate_ref.query import select_latest_executions +from ref_backend.core.mip_eras import MipEra, cv_column, era_group_clauses from ref_backend.models.model_runs import EnsembleComparison, EnsembleStatistics #: Dimensions that say which run produced a value rather than what the value measures. @@ -86,12 +86,12 @@ def _scalar_rows( *, diagnostic_ids: Sequence[int], dimensions: Sequence[str], - mip_era: str | None, + mip_era: MipEra | None, ) -> list[ScalarRow]: value = models.ScalarMetricValue columns = [cv_column(value, name) for name in dimensions] - # A retried group holds a value per attempt, so only the execution that decided it counts. - latest = latest_executions() + # A retried group holds a value per attempt, so only the unretracted execution that decided it counts. + latest = select_latest_executions().subquery() statement = ( select( @@ -108,14 +108,14 @@ def _scalar_rows( .where( models.ExecutionGroup.diagnostic_version == models.Diagnostic.promoted_version, models.ExecutionGroup.diagnostic_id.in_(diagnostic_ids), + models.Execution.retracted.is_(False), cv_column(value, "kind") == "model", cv_column(value, "source_id").isnot(None), value.value.isnot(None), ) ) if mip_era: - # An execution with no CMIP input carries no era, so it answers to whichever is asked for. - statement = statement.where(or_(cmip_dataset_filter({"mip_era": mip_era}), ~cmip_dataset_filter({}))) + statement = statement.where(*era_group_clauses(mip_era)) return [ ScalarRow(row[0], row[1], row[2], _units(row[3]), tuple(row[4:])) @@ -144,7 +144,7 @@ def ensemble_comparisons( session: Session, *, source_id: str, - mip_era: str | None = None, + mip_era: MipEra | None = None, diagnostic_slug: str | None = None, ) -> list[EnsembleComparison]: """ diff --git a/backend/src/ref_backend/core/model_runs.py b/backend/src/ref_backend/core/model_runs.py index afc106f4..3e5bb65f 100644 --- a/backend/src/ref_backend/core/model_runs.py +++ b/backend/src/ref_backend/core/model_runs.py @@ -8,7 +8,8 @@ from sqlalchemy.orm import Session, aliased from climate_ref import models -from ref_backend.core.mip_eras import CMIP_ERAS, cv_column, dataset_model_for, mip_era_for +from climate_ref.query import select_latest_executions +from ref_backend.core.mip_eras import MipEra, cv_column, dataset_model_for, eras_in_scope, mip_era_for from ref_backend.models.model_runs import RunCounts @@ -29,13 +30,7 @@ class ModelFacets(NamedTuple): dataset_count: int -def _eras_in_scope(mip_era: str | None) -> tuple[Any, ...]: - if mip_era is None: - return CMIP_ERAS - return tuple(era for era in CMIP_ERAS if mip_era_for(era) == mip_era.upper()) - - -def _model_groups(mip_era: str | None, source_id: str | None) -> CompoundSelect[Any] | Select[Any] | None: +def _model_groups(mip_era: MipEra | None, source_id: str | None) -> CompoundSelect[Any] | Select[Any] | None: """ Select every (execution group, model) pairing at the promoted version of each diagnostic. @@ -45,12 +40,13 @@ def _model_groups(mip_era: str | None, source_id: str | None) -> CompoundSelect[ Participation is read from the execution that decides the group, the same one the outcome comes from. A retry run against different datasets would otherwise report the latest outcome for a model that took no part in it. + A group whose deciding execution is retracted is left out, rather than falling back to an older run. Only `source_id` is carried alongside, so a group naming one model across two eras or two institutions still yields one row and is counted once. """ - latest = latest_executions() + latest = select_latest_executions().subquery() statements = [] - for era in _eras_in_scope(mip_era): + for era in eras_in_scope(mip_era): dataset_model = dataset_model_for(era) model_source_id = cv_column(dataset_model, "source_id") statement = ( @@ -61,11 +57,12 @@ def _model_groups(mip_era: str | None, source_id: str | None) -> CompoundSelect[ models.Execution.id.label("execution_id"), ) .join(models.Diagnostic, models.ExecutionGroup.diagnostic_id == models.Diagnostic.id) - .join(latest, latest.c.group_id == models.ExecutionGroup.id) + .join(latest, latest.c.execution_group_id == models.ExecutionGroup.id) .join(models.Execution, models.Execution.id == latest.c.execution_id) .join(models.Execution.datasets.of_type(dataset_model)) .where( models.ExecutionGroup.diagnostic_version == models.Diagnostic.promoted_version, + models.Execution.retracted.is_(False), model_source_id.isnot(None), ) .distinct() @@ -77,37 +74,6 @@ def _model_groups(mip_era: str | None, source_id: str | None) -> CompoundSelect[ return union(*statements) if statements else None -def latest_executions() -> Any: - """ - Select the execution that decides each group, the way `climate_ref` picks the latest. - - That is newest `created_at`, with the highest id breaking a tie. Ranking on id alone parts - company with `created_at` whenever rows land out of order, and these counts would then - disagree with the statistics endpoint and the CLI for the same group. - """ - newest = ( - select( - models.Execution.execution_group_id.label("group_id"), - func.max(models.Execution.created_at).label("created_at"), - ) - .group_by(models.Execution.execution_group_id) - .subquery() - ) - return ( - select( - models.Execution.execution_group_id.label("group_id"), - func.max(models.Execution.id).label("execution_id"), - ) - .join( - newest, - (models.Execution.execution_group_id == newest.c.group_id) - & (models.Execution.created_at == newest.c.created_at), - ) - .group_by(models.Execution.execution_group_id) - .subquery() - ) - - def _outcome(execution: Any) -> Any: return case( (execution.successful.is_(True), "successful"), @@ -117,7 +83,7 @@ def _outcome(execution: Any) -> Any: def model_run_rows( - session: Session, *, mip_era: str | None = None, source_id: str | None = None + session: Session, *, mip_era: MipEra | None = None, source_id: str | None = None ) -> list[ModelRunRow]: """Tally execution groups per model, diagnostic and outcome.""" groups = _model_groups(mip_era, source_id) @@ -143,14 +109,14 @@ def model_run_rows( def model_facets( - session: Session, *, mip_era: str | None = None, source_id: str | None = None + session: Session, *, mip_era: MipEra | None = None, source_id: str | None = None ) -> dict[str, ModelFacets]: """Describe each model from its ingested datasets, counting every version.""" eras: dict[str, set[str]] = defaultdict(set) institutions: dict[str, set[str]] = defaultdict(set) counts: dict[str, int] = defaultdict(int) - for era in _eras_in_scope(mip_era): + for era in eras_in_scope(mip_era): dataset_model = dataset_model_for(era) model_source_id = cv_column(dataset_model, "source_id") institution_id = cv_column(dataset_model, "institution_id") @@ -194,7 +160,7 @@ def tally(rows: Sequence[ModelRunRow]) -> RunCounts: def failed_runs( - session: Session, *, source_id: str, mip_era: str | None = None + session: Session, *, source_id: str, mip_era: MipEra | None = None ) -> list[tuple[models.ExecutionGroup, int, str]]: """ Find the groups this model ran in whose latest execution did not succeed. diff --git a/backend/src/ref_backend/core/reader_values.py b/backend/src/ref_backend/core/reader_values.py index cdab9798..8e6b61bd 100644 --- a/backend/src/ref_backend/core/reader_values.py +++ b/backend/src/ref_backend/core/reader_values.py @@ -14,7 +14,8 @@ from ref_backend.core.json_utils import sanitize_float_value from ref_backend.core.metric_values import MetricValueType from ref_backend.core.mip_eras import mip_eras_for_executions -from ref_backend.models import MetricValueCollection +from ref_backend.models import Facet, MetricValueCollection +from ref_backend.models.values import facets_of if TYPE_CHECKING: from ref_backend.api.deps import AppContext @@ -160,6 +161,7 @@ def fetch_metric_values( # noqa: PLR0913, PLR0917 detect_outliers: Literal["off", "iqr"], include_unverified: bool, filename_stem: str, + with_facets: bool = True, ) -> MetricValueCollection | StreamingResponse: """ Read metric values for an already-scoped filter and render them as JSON or CSV. @@ -167,6 +169,7 @@ def fetch_metric_values( # noqa: PLR0913, PLR0917 `filename_stem` names the CSV download, which is the only thing that varies between the diagnostic-scoped and execution-scoped endpoints. CSV exports return every matching value, so `offset` and `limit` are ignored there. + Pass `with_facets=False` to leave `facets` empty, for a caller that reads them from `fetch_facets`. """ if value_type == MetricValueType.SCALAR: detection_ran = detect_outliers == "iqr" @@ -177,6 +180,7 @@ def fetch_metric_values( # noqa: PLR0913, PLR0917 metric_filter, outliers=outlier_policy, include_unverified=include_unverified, + with_facets=False, ) return generate_csv_response_scalar( collection, detection_ran, f"metric_values_scalar_{filename_stem}.csv" @@ -188,6 +192,7 @@ def fetch_metric_values( # noqa: PLR0913, PLR0917 include_unverified=include_unverified, offset=offset, limit=limit, + with_facets=with_facets, ) return annotate_mip_eras( app_context, MetricValueCollection.build_scalar_from_reader(collection, detection_ran) @@ -195,7 +200,7 @@ def fetch_metric_values( # noqa: PLR0913, PLR0917 if value_type == MetricValueType.SERIES: if format == "csv": - series_collection = app_context.reader.values.series_values(metric_filter) + series_collection = app_context.reader.values.series_values(metric_filter, with_facets=False) return generate_csv_response_series( series_collection, f"metric_values_series_{filename_stem}.csv" ) @@ -204,9 +209,23 @@ def fetch_metric_values( # noqa: PLR0913, PLR0917 metric_filter, offset=offset, limit=limit, + with_facets=with_facets, ) return annotate_mip_eras( app_context, MetricValueCollection.build_series_from_reader(series_collection) ) raise HTTPException(status_code=500, detail="Unknown value_type") + + +def fetch_facets( + app_context: "AppContext", metric_filter: MetricValueFilter, value_type: MetricValueType +) -> list[Facet]: + """ + Read the facets of every value `metric_filter` keeps, without loading the values. + + These match the `facets` of `fetch_metric_values`, which cover outliers too. + """ + values = app_context.reader.values + read = values.scalar_values if value_type == MetricValueType.SCALAR else values.series_values + return facets_of(read(metric_filter, limit=0, with_count=False)) diff --git a/backend/src/ref_backend/core/resource_usage.py b/backend/src/ref_backend/core/resource_usage.py index 61b3118b..bc050862 100644 --- a/backend/src/ref_backend/core/resource_usage.py +++ b/backend/src/ref_backend/core/resource_usage.py @@ -1,12 +1,10 @@ """Aggregates of the wall, CPU and memory usage recorded against executions.""" -from typing import Any - +import attrs from pydantic import BaseModel -from sqlalchemy import Row, func -from sqlalchemy.orm import Session -from climate_ref import models +from climate_ref.results import Reader, ResourceFilter +from climate_ref.results.resources import ResourceSummary class ExecutionResourceSummary(BaseModel): @@ -53,42 +51,21 @@ class ExecutionResourceSummary(BaseModel): """ -RESOURCE_AGGREGATES = ( - func.count(models.Execution.wall_seconds).label("timed_execution_count"), - func.sum(models.Execution.wall_seconds).label("wall_seconds_total"), - func.avg(models.Execution.wall_seconds).label("wall_seconds_mean"), - func.max(models.Execution.wall_seconds).label("wall_seconds_max"), - func.sum(models.Execution.cpu_seconds).label("cpu_seconds_total"), - func.avg(models.Execution.cpu_seconds).label("cpu_seconds_mean"), - func.min(models.Execution.peak_memory_bytes).label("peak_memory_bytes_min"), - func.max(models.Execution.peak_memory_bytes).label("peak_memory_bytes_max"), -) - - -def summary_from_row(row: Row[Any]) -> ExecutionResourceSummary | None: - """ - Build a summary from a row that carries the ``RESOURCE_AGGREGATES`` labels. - - Returns ``None`` when the row holds no timed executions. - """ - values = row._mapping - if not values["timed_execution_count"]: - return None - return ExecutionResourceSummary.model_validate(dict(values)) +def _summary(summary: ResourceSummary) -> ExecutionResourceSummary: + return ExecutionResourceSummary.model_validate(attrs.asdict(summary)) -def resource_usage_for_diagnostic(session: Session, diagnostic_id: int) -> ExecutionResourceSummary | None: - """Roll up the resource usage of every execution of one diagnostic.""" - row = ( - session.query(*RESOURCE_AGGREGATES) - .select_from(models.Execution) - .join(models.ExecutionGroup) - .filter(models.ExecutionGroup.diagnostic_id == diagnostic_id) - .one() - ) - return summary_from_row(row) +def resource_usage(reader: Reader, filters: ResourceFilter) -> ExecutionResourceSummary | None: + """Roll up the resource usage of the executions matching ``filters``.""" + summary = reader.resources.summary(filters) + return _summary(summary) if summary else None -def resource_usage_overall(session: Session) -> ExecutionResourceSummary | None: - """Roll up the resource usage of every execution in the database.""" - return summary_from_row(session.query(*RESOURCE_AGGREGATES).one()) +def resource_usage_by_diagnostic( + reader: Reader, filters: ResourceFilter +) -> dict[tuple[str | None, str | None], ExecutionResourceSummary]: + """Roll up the resource usage of the executions matching ``filters``, per provider and diagnostic slug.""" + return { + (summary.provider_slug, summary.diagnostic_slug): _summary(summary) + for summary in reader.resources.summaries(filters, group_by="diagnostic") + } diff --git a/backend/src/ref_backend/models/__init__.py b/backend/src/ref_backend/models/__init__.py index d4c6d3e4..62d53a03 100644 --- a/backend/src/ref_backend/models/__init__.py +++ b/backend/src/ref_backend/models/__init__.py @@ -12,7 +12,7 @@ AFTDiagnosticSummary, RefDiagnosticLink, ) -from ref_backend.models.common import Collection, GroupBy, ProviderSummary, T +from ref_backend.models.common import Collection, GroupBy, GroupCounts, ProviderSummary, T from ref_backend.models.datasets import CMIPDatasetMetadata, Dataset from ref_backend.models.diagnostics import DiagnosticCatalogEntry, DiagnosticSummary, DiagnosticValueFlags from ref_backend.models.executions import ( @@ -34,7 +34,6 @@ NON_FACET_DIMENSIONS, Facet, MetricValueCollection, - MetricValueFacetSummary, ScalarValue, SeriesValue, ) @@ -61,8 +60,8 @@ "Facet", "FailedRun", "GroupBy", + "GroupCounts", "MetricValueCollection", - "MetricValueFacetSummary", "ModelDetail", "ModelSummary", "ProviderSummary", diff --git a/backend/src/ref_backend/models/common.py b/backend/src/ref_backend/models/common.py index 27fc92fe..64dc7568 100644 --- a/backend/src/ref_backend/models/common.py +++ b/backend/src/ref_backend/models/common.py @@ -1,6 +1,7 @@ """Shared response wrappers and small summaries used across the API.""" -from typing import Generic, TypeVar +from collections.abc import Sequence +from typing import Any, Generic, TypeVar from pydantic import BaseModel, computed_field @@ -43,3 +44,39 @@ def build(provider: models.Provider) -> "ProviderSummary": class GroupBy(BaseModel): source_type: str group_by: list[str] | None + + +class GroupCounts(BaseModel): + """ + How many execution groups are running, failed, succeeded or not started. + + Each group is counted once, by the outcome of its latest execution, + so `successful + failed + running + not_started == total`. + `dirty` overlaps the others. + """ + + total: int + successful: int + failed: int + running: int + not_started: int + dirty: int + """ + Groups whose inputs have changed, so they need to be run again + """ + + @staticmethod + def of(row: Any | None) -> "GroupCounts": + """Convert a row of `select_group_counts`, with `None` for a diagnostic that has no groups.""" + return GroupCounts.summed([row] if row is not None else []) + + @staticmethod + def summed(rows: Sequence[Any]) -> "GroupCounts": + """Add up the per-diagnostic rows of `select_group_counts`.""" + return GroupCounts( + total=sum(row.group_count for row in rows), + **{name: sum(getattr(row, name) for row in rows) for name in _OUTCOMES}, + ) + + +_OUTCOMES = ("successful", "failed", "running", "not_started", "dirty") diff --git a/backend/src/ref_backend/models/datasets.py b/backend/src/ref_backend/models/datasets.py index c1c52195..51de3094 100644 --- a/backend/src/ref_backend/models/datasets.py +++ b/backend/src/ref_backend/models/datasets.py @@ -5,7 +5,7 @@ from climate_ref import models from climate_ref.models.dataset import CMIP6Dataset, CMIP7Dataset from climate_ref.results.datasets import DatasetView -from ref_backend.core.mip_eras import CMIP_ERAS, dataset_type_label, mip_era_for +from ref_backend.core.mip_eras import CMIP_ERAS, MipEra, dataset_type_label, mip_era_for class CMIPDatasetMetadata(BaseModel): @@ -21,7 +21,7 @@ class Dataset(BaseModel): dataset_type: str metadata: CMIPDatasetMetadata | None #: The MIP era this dataset belongs to, or None for non-CMIP sources. - mip_era: str | None = None + mip_era: MipEra | None = None @computed_field # type: ignore @property diff --git a/backend/src/ref_backend/models/diagnostics.py b/backend/src/ref_backend/models/diagnostics.py index 75fc411f..a44db7c1 100644 --- a/backend/src/ref_backend/models/diagnostics.py +++ b/backend/src/ref_backend/models/diagnostics.py @@ -5,19 +5,19 @@ from loguru import logger from pydantic import BaseModel -from sqlalchemy import func, select from climate_ref import models from climate_ref.models.metric_value import MetricValueType as StoredMetricValueType +from climate_ref.query import select_group_counts +from climate_ref.results import DiagnosticFilter, ResourceFilter from ref_backend.core.diagnostic_metadata import ( DiagnosticMetadata, ReferenceDatasetLink, load_diagnostic_metadata_cached, ) -from ref_backend.core.metric_values import metric_value_type_is -from ref_backend.core.resource_usage import ExecutionResourceSummary, resource_usage_for_diagnostic +from ref_backend.core.resource_usage import ExecutionResourceSummary, resource_usage from ref_backend.models.aft import AFTDiagnosticDetail -from ref_backend.models.common import GroupBy, ProviderSummary +from ref_backend.models.common import GroupBy, GroupCounts, ProviderSummary if TYPE_CHECKING: from climate_ref_core.diagnostics import Diagnostic @@ -68,21 +68,9 @@ class DiagnosticCatalogEntry(BaseModel): """ List of IDs for the provider executions associated with this provider """ - execution_count: int + groups: GroupCounts """ - Total number of executions across all execution groups for this diagnostic - """ - successful_execution_count: int - """ - Number of successful executions across all execution groups for this diagnostic - """ - execution_group_count: int - """ - Number of execution groups for this diagnostic - """ - successful_execution_group_count: int - """ - Number of execution groups whose latest execution is successful + Execution groups at the promoted version, by the outcome of their latest execution """ promoted_version: int """ @@ -186,13 +174,11 @@ def _apply_metadata_overrides( logger.debug(f"Applied metadata overrides for diagnostic {diagnostic_key}") @staticmethod - def build_with_stats( # noqa: PLR0913 + def build_with_stats( diagnostic: models.Diagnostic, app_context: "AppContext", *, - execution_stats: dict[str, int], - execution_group_count: int, - successful_execution_group_count: int, + groups: GroupCounts, resource_usage: ExecutionResourceSummary | None = None, ) -> "DiagnosticCatalogEntry": """Build a DiagnosticCatalogEntry with pre-computed statistics to avoid N+1 queries.""" @@ -211,10 +197,7 @@ def build_with_stats( # noqa: PLR0913 name=diagnostic.name, description=description, execution_groups=[e.id for e in diagnostic.execution_groups], - execution_count=execution_stats["total"], - successful_execution_count=execution_stats["successful"], - execution_group_count=execution_group_count, - successful_execution_group_count=successful_execution_group_count, + groups=groups, promoted_version=diagnostic.promoted_version, group_by=group_by_summary, aft_link=aft, @@ -251,110 +234,38 @@ class DiagnosticSummary(DiagnosticCatalogEntry): @staticmethod def build(diagnostic: models.Diagnostic, app_context: "AppContext") -> "DiagnosticSummary": """Build a DiagnosticSummary with individual database queries.""" - metadata_cache = DiagnosticSummary._ensure_metadata_cache(app_context) - group_by_summary = DiagnosticSummary._build_group_by_summary(diagnostic, app_context) - aft = DiagnosticSummary._get_aft_link(diagnostic) - - def has_values(value_type: StoredMetricValueType) -> bool: - query = ( - select(models.MetricValue.id) - .join(models.Execution) - .join(models.ExecutionGroup) - .where(models.ExecutionGroup.diagnostic_id == diagnostic.id, metric_value_type_is(value_type)) - .limit(1) - ) - return app_context.session.scalar(query) is not None - - has_scalar_values = has_values(StoredMetricValueType.SCALAR) - has_series_values = has_values(StoredMetricValueType.SERIES) - - has_metric_values = has_scalar_values or has_series_values - - # Every count below is scoped to the promoted version, matching the values endpoints. - # Without that scoping a page can report thousands of successful groups and still show an - # empty table, because the values of a superseded version are hidden. - promoted_scope = ( - models.ExecutionGroup.diagnostic_id == diagnostic.id, - models.ExecutionGroup.diagnostic_version == diagnostic.promoted_version, + this_diagnostic = DiagnosticFilter( + provider_slug=diagnostic.provider.slug, + diagnostic_slug=diagnostic.slug, + include_superseded=False, + include_retracted=False, ) - # Execution counts for this diagnostic - execution_count = ( - app_context.session.query(models.Execution) - .join(models.ExecutionGroup) - .filter(*promoted_scope) - .count() - ) - successful_execution_count = ( - app_context.session.query(models.Execution) - .join(models.ExecutionGroup) - .filter( - *promoted_scope, - models.Execution.successful.is_(True), - ) - .count() - ) - - # Execution group counts for this diagnostic - execution_group_count = ( - app_context.session.query(models.ExecutionGroup).filter(*promoted_scope).count() - ) - - # Count execution groups whose latest execution is successful - ExecutionGroup = models.ExecutionGroup - Execution = models.Execution - - # Subquery: latest execution id per group - latest_exec_per_group = ( - app_context.session.query( - Execution.execution_group_id.label("egid"), - func.max(Execution.id).label("latest_exec_id"), - ) - .join(ExecutionGroup, Execution.execution_group_id == ExecutionGroup.id) - .filter(*promoted_scope) - .group_by(Execution.execution_group_id) - .subquery() - ) - - # Join back to executions to check success of latest - successful_execution_group_count = ( - app_context.session.query(latest_exec_per_group.c.egid) - .join(Execution, Execution.id == latest_exec_per_group.c.latest_exec_id) - .filter(Execution.successful.is_(True)) - .count() + def has_values(value_type: StoredMetricValueType) -> bool: + return bool(app_context.reader.diagnostics.with_values(value_type, this_diagnostic)) + + entry = DiagnosticCatalogEntry.build_with_stats( + diagnostic, + app_context, + groups=GroupCounts.summed( + app_context.session.execute(select_group_counts(this_diagnostic)).all() + ), + resource_usage=resource_usage( + app_context.reader, + ResourceFilter( + provider_slug=diagnostic.provider.slug, + diagnostic_slug=diagnostic.slug, + include_superseded=False, + include_retracted=True, + ), + ), ) - - concrete_diagnostic = _concrete_diagnostic(diagnostic, app_context) - description = (concrete_diagnostic.__doc__ or "") if concrete_diagnostic else "" - - resource_usage = resource_usage_for_diagnostic(app_context.session, diagnostic.id) - - # Build the base diagnostic summary - summary = DiagnosticSummary( - id=diagnostic.id, - provider=ProviderSummary.build(diagnostic.provider), - slug=diagnostic.slug, - name=diagnostic.name, - description=description, - execution_groups=[e.id for e in diagnostic.execution_groups], - has_metric_values=has_metric_values, - has_scalar_values=has_scalar_values, - has_series_values=has_series_values, - execution_count=execution_count, - successful_execution_count=successful_execution_count, - execution_group_count=execution_group_count, - successful_execution_group_count=successful_execution_group_count, - promoted_version=diagnostic.promoted_version, - group_by=group_by_summary, - aft_link=aft, - resource_usage=resource_usage, + return DiagnosticSummary.from_catalog_entry( + entry, + has_scalar_values=has_values(StoredMetricValueType.SCALAR), + has_series_values=has_values(StoredMetricValueType.SERIES), ) - # Apply metadata overrides from YAML if available - DiagnosticSummary._apply_metadata_overrides(summary, diagnostic, metadata_cache) - - return summary - @staticmethod def from_catalog_entry( entry: DiagnosticCatalogEntry, *, has_scalar_values: bool, has_series_values: bool diff --git a/backend/src/ref_backend/models/executions.py b/backend/src/ref_backend/models/executions.py index e9cb55e9..4cfdbc97 100644 --- a/backend/src/ref_backend/models/executions.py +++ b/backend/src/ref_backend/models/executions.py @@ -4,11 +4,12 @@ from typing import TYPE_CHECKING from pydantic import BaseModel, computed_field -from sqlalchemy.orm import selectinload from climate_ref import models from climate_ref.models.execution import ResultOutputType +from ref_backend.core.mip_eras import MipEra from ref_backend.core.resource_usage import ExecutionResourceSummary +from ref_backend.models.common import GroupCounts if TYPE_CHECKING: from ref_backend.api.deps import AppContext @@ -42,19 +43,6 @@ def build(output: models.ExecutionOutput, app_context: "AppContext") -> "Executi ) -# Everything ExecutionGroup.build touches without outputs, -# so a page of groups loads in a fixed number of queries. -EXECUTION_GROUP_LOAD_OPTIONS_WITHOUT_OUTPUTS = ( - selectinload(models.ExecutionGroup.executions).selectinload(models.Execution.datasets), - selectinload(models.ExecutionGroup.diagnostic).selectinload(models.Diagnostic.provider), -) -# Everything ExecutionGroup.build touches, including each execution's outputs. -EXECUTION_GROUP_LOAD_OPTIONS = ( - selectinload(models.ExecutionGroup.executions).selectinload(models.Execution.outputs), - *EXECUTION_GROUP_LOAD_OPTIONS_WITHOUT_OUTPUTS, -) - - class ExecutionGroup(BaseModel): id: int key: str @@ -160,37 +148,23 @@ class ExecutionStats(BaseModel): Statistics for execution groups and their success rates. """ - total_execution_groups: int - """ - Total number of execution groups in the database. - """ - successful_execution_groups: int - """ - Number of execution groups whose latest execution was successful. - """ - failed_execution_groups: int + groups: GroupCounts """ - Number of execution groups whose latest execution failed. + Execution groups at the promoted version of each diagnostic, by the outcome of their latest execution """ - running_execution_groups: int + groups_by_mip_era: dict[MipEra, GroupCounts] """ - Number of execution groups whose latest execution is still running. - """ - not_started_execution_groups: int - """ - Number of execution groups that have not been executed yet. - """ - execution_groups_by_mip_era: dict[str, int] - """ - Number of execution groups that ran against each MIP era, keyed by era label. + The same counts for the groups that draw on each MIP era. + + A group drawing on both eras counts in each, so these do not add up to `groups`. """ scalar_value_count: int """ - Number of scalar metric values available. + Number of scalar metric values stored, from every version. """ series_value_count: int """ - Number of series metric values available. + Number of series metric values stored, from every version. """ total_datasets: int """ @@ -202,7 +176,7 @@ class ExecutionStats(BaseModel): """ resource_usage: ExecutionResourceSummary | None """ - Wall, CPU and memory usage rolled up across every execution. + Wall, CPU and memory usage rolled up across every execution at the promoted versions. Absent when no execution has recorded a wall time. """ @@ -211,8 +185,8 @@ class ExecutionStats(BaseModel): @property def success_rate_percentage(self) -> float: """ - Success rate as a percentage (0-100). + Share of the groups whose latest execution succeeded, as a percentage (0-100). """ - if self.total_execution_groups == 0: + if self.groups.total == 0: return 0.0 - return round((self.successful_execution_groups / self.total_execution_groups) * 100, 1) + return round(self.groups.successful / self.groups.total * 100, 1) diff --git a/backend/src/ref_backend/models/values.py b/backend/src/ref_backend/models/values.py index c4455400..5a179e33 100644 --- a/backend/src/ref_backend/models/values.py +++ b/backend/src/ref_backend/models/values.py @@ -61,6 +61,15 @@ class Facet(BaseModel): NON_FACET_DIMENSIONS = frozenset({"kind"}) +def facets_of(collection: "ScalarValueCollection | SeriesValueCollection") -> list[Facet]: + """Return the facets of a reader collection that callers can filter on.""" + return [ + Facet(key=f.key, values=list(f.values)) + for f in collection.facets + if f.key not in NON_FACET_DIMENSIONS + ] + + _PRESENTATION_ATTRIBUTE_FALLBACKS: dict[str, tuple[str, ...]] = { "value_units": ("value_units", "units"), "value_long_name": ("value_long_name", "long_name"), @@ -91,6 +100,12 @@ def _normalize_presentation_attributes( return normalized +def _counted(total_count: int | None) -> int: + if total_count is None: + raise ValueError("Read the values with with_count=True to report total_count") + return total_count + + class MetricValueCollection(BaseModel): data: Sequence[ScalarValue | SeriesValue] count: int @@ -123,17 +138,11 @@ def build_scalar_from_reader( for item in collection.items ] - facets = [ - Facet(key=f.key, values=list(f.values)) - for f in collection.facets - if f.key not in NON_FACET_DIMENSIONS - ] - return MetricValueCollection( data=all_data, count=len(all_data), - total_count=collection.total_count, - facets=facets, + total_count=_counted(collection.total_count), + facets=facets_of(collection), types=["scalar"], had_outliers=collection.had_outliers if detection_ran else None, outlier_count=collection.outlier_count if detection_ran else None, @@ -163,33 +172,12 @@ def build_series_from_reader( ) ) - facets = [ - Facet(key=f.key, values=list(f.values)) - for f in collection.facets - if f.key not in NON_FACET_DIMENSIONS - ] - return MetricValueCollection( data=all_data, count=len(all_data), - total_count=collection.total_count, - facets=facets, + total_count=_counted(collection.total_count), + facets=facets_of(collection), types=["series"], had_outliers=None, outlier_count=None, ) - - -class MetricValueFacetSummary(BaseModel): - """ - Summary of the dimensions used in a metric value collection. - """ - - dimensions: dict[str, list[str]] - """ - Dimensions and their unique values for the current filter - """ - count: int - """ - Number of metric values with the current filter - """ diff --git a/backend/src/ref_backend/testing.py b/backend/src/ref_backend/testing.py index 276147b6..74d77b71 100644 --- a/backend/src/ref_backend/testing.py +++ b/backend/src/ref_backend/testing.py @@ -46,3 +46,8 @@ def test_ref_config() -> Config: config.ignore_datasets_file = EXAMPLE_DIR / "ignore_datasets.yaml" return config + + +# Test modules import these by name, so stop pytest collecting them as tests. +test_settings.__test__ = False # type: ignore[attr-defined] +test_ref_config.__test__ = False # type: ignore[attr-defined] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 421a6043..7009c707 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,4 +1,7 @@ +import copy +import shutil from collections.abc import Generator +from pathlib import Path import pytest from fastapi import FastAPI @@ -39,3 +42,27 @@ def app() -> FastAPI: def client(app) -> Generator[TestClient, None, None]: with TestClient(app) as c: yield c + + +@pytest.fixture +def writable_client(tmp_path: Path) -> Generator[TestClient, None, None]: + """A client over a copy of the fixture database, for tests that have to write to it.""" + settings = test_settings() + config = copy.deepcopy(test_ref_config()) + + source = Path(config.db.database_url.removeprefix("sqlite:///")) + database_copy = tmp_path / source.name + shutil.copy(source, database_copy) + config.db.database_url = f"sqlite:///{database_copy}" + + app = build_app( + settings=settings, + ref_config=config, + database=deps._get_database_dependency(settings, config), + ) + app.dependency_overrides[get_settings] = lambda: settings + app.dependency_overrides[deps._ref_config_dependency] = lambda: config + + with TestClient(app) as client: + client.ref_config = config # type: ignore[attr-defined] + yield client diff --git a/backend/tests/perf/test_endpoints.py b/backend/tests/perf/test_endpoints.py index 1123d8ea..9cc2397c 100644 --- a/backend/tests/perf/test_endpoints.py +++ b/backend/tests/perf/test_endpoints.py @@ -27,19 +27,20 @@ class Endpoint: _DIAGNOSTIC = "diagnostics/{provider_slug}/{diagnostic_slug}" _GROUP = "executions/{execution_group_id}" -# The allowed scans record what each endpoint read when this suite was added, not what it should read. +# The allowed scans record the full reads each endpoint is known to make, not what it should read. ENDPOINTS = [ # diagnostics: the landing page and a diagnostic's page - Endpoint(id="diagnostics.list", path="diagnostics/"), + Endpoint(id="diagnostics.list", path="diagnostics/", allowed_scans=frozenset({"execution_group"})), Endpoint( id="diagnostics.list.cmip6", path="diagnostics/", params={"mip_era": "CMIP6"}, allowed_scans=frozenset({"execution_group"}), ), - Endpoint(id="diagnostics.catalog", path="diagnostics/catalog"), + Endpoint( + id="diagnostics.catalog", path="diagnostics/catalog", allowed_scans=frozenset({"execution_group"}) + ), Endpoint(id="diagnostics.value-flags", path="diagnostics/value-flags"), - Endpoint(id="diagnostics.facets", path="diagnostics/facets", allowed_scans=frozenset({"metric_value"})), Endpoint(id="diagnostics.get", path=_DIAGNOSTIC), Endpoint(id="diagnostics.execution-groups", path=f"{_DIAGNOSTIC}/execution_groups"), Endpoint(id="diagnostics.values.scalar", path=f"{_DIAGNOSTIC}/values", params={"value_type": "scalar"}), @@ -49,6 +50,26 @@ class Endpoint: params={"value_type": "scalar", "source_id": "{source_id}"}, ), Endpoint(id="diagnostics.values.series", path=f"{_DIAGNOSTIC}/values", params={"value_type": "series"}), + Endpoint( + id="diagnostics.values.scalar.page", + path=f"{_DIAGNOSTIC}/values", + params={"value_type": "scalar", "with_facets": "false"}, + ), + Endpoint( + id="diagnostics.values.series.page", + path=f"{_DIAGNOSTIC}/values", + params={"value_type": "series", "with_facets": "false"}, + ), + Endpoint( + id="diagnostics.values.facets.scalar", + path=f"{_DIAGNOSTIC}/values/facets", + params={"value_type": "scalar"}, + ), + Endpoint( + id="diagnostics.values.facets.series", + path=f"{_DIAGNOSTIC}/values/facets", + params={"value_type": "series"}, + ), # executions: the recent list and an execution group's page Endpoint(id="executions.list", path="executions/", allowed_scans=frozenset({"execution_group"})), Endpoint( @@ -60,11 +81,12 @@ class Endpoint: Endpoint( id="executions.statistics", path="executions/statistics", - allowed_scans=frozenset({"execution", "dataset", "dataset_file"}), + allowed_scans=frozenset({"execution_group", "dataset", "dataset_file"}), ), Endpoint(id="executions.get", path=_GROUP), Endpoint(id="executions.values.scalar", path=f"{_GROUP}/values", params={"value_type": "scalar"}), Endpoint(id="executions.values.series", path=f"{_GROUP}/values", params={"value_type": "series"}), + Endpoint(id="executions.values.facets", path=f"{_GROUP}/values/facets", params={"value_type": "scalar"}), # models and datasets Endpoint( id="models.list", diff --git a/backend/tests/test-data/tests.integration.test_cmip7_aft/test_solve_cmip7_aft/db/climate_ref.db b/backend/tests/test-data/tests.integration.test_cmip7_aft/test_solve_cmip7_aft/db/climate_ref.db index ab4c0960..c758d1dd 100644 Binary files a/backend/tests/test-data/tests.integration.test_cmip7_aft/test_solve_cmip7_aft/db/climate_ref.db and b/backend/tests/test-data/tests.integration.test_cmip7_aft/test_solve_cmip7_aft/db/climate_ref.db differ diff --git a/backend/tests/test_api/test_routes/test_datasets.py b/backend/tests/test_api/test_routes/test_datasets.py index 6ddcc110..6f9c5435 100644 --- a/backend/tests/test_api/test_routes/test_datasets.py +++ b/backend/tests/test_api/test_routes/test_datasets.py @@ -130,3 +130,24 @@ def test_dataset_carries_its_mip_era(client: TestClient, settings) -> None: assert r.status_code == 200 assert r.json()["data"][0]["mip_era"] == "CMIP6" + + +def test_dataset_list_is_ordered_by_slug_and_pages_without_overlap(client: TestClient, settings): + everything = client.get(f"{settings.API_V1_STR}/datasets/?limit=100").json() + slugs = [d["slug"] for d in everything["data"]] + assert len(slugs) > 2 + assert slugs == sorted(slugs) + + first = client.get(f"{settings.API_V1_STR}/datasets/?limit=2&offset=0").json() + second = client.get(f"{settings.API_V1_STR}/datasets/?limit=2&offset=2").json() + assert [d["slug"] for d in first["data"] + second["data"]] == slugs[:4] + assert first["total_count"] == second["total_count"] == everything["total_count"] + + +def test_dataset_list_name_contains_ignores_case_and_counts_matches(client: TestClient, settings): + slug = client.get(f"{settings.API_V1_STR}/datasets/?limit=1").json()["data"][0]["slug"] + part = slug.split(".")[1].upper() + r = client.get(f"{settings.API_V1_STR}/datasets/?limit=100&name_contains={part}").json() + assert r["data"] + assert all(part.lower() in d["slug"].lower() for d in r["data"]) + assert r["total_count"] == len(r["data"]) diff --git a/backend/tests/test_api/test_routes/test_diagnostics.py b/backend/tests/test_api/test_routes/test_diagnostics.py index 1101a6a4..768de038 100644 --- a/backend/tests/test_api/test_routes/test_diagnostics.py +++ b/backend/tests/test_api/test_routes/test_diagnostics.py @@ -1,18 +1,14 @@ -import copy -import shutil -from collections.abc import Generator -from pathlib import Path +from datetime import timedelta import pytest from fastapi.testclient import TestClient +from sqlalchemy import select from climate_ref import models from climate_ref.database import Database from climate_ref.provider_registry import ProviderRegistry from ref_backend.api import deps -from ref_backend.builder import build_app -from ref_backend.core.config import get_settings -from ref_backend.testing import test_ref_config, test_settings +from ref_backend.testing import test_ref_config def get_diagnostic(client: TestClient, settings) -> dict: @@ -93,24 +89,6 @@ def test_diagnostic_comparison(client: TestClient, settings): assert "ensemble" in data -def test_diagnostic_executions(client: TestClient, settings): - """Test the executions endpoint for a diagnostic.""" - diagnostic = get_diagnostic(client, settings) - provider_slug = diagnostic["provider"]["slug"] - diagnostic_slug = diagnostic["slug"] - - source_id = "ACCESS-ESM1-5" - - r = client.get( - f"{settings.API_V1_STR}/diagnostics/{provider_slug}/{diagnostic_slug}/executions?source_id={source_id}" - ) - - assert r.status_code == 200 - data = r.json() - assert data["count"] > 0 - assert len(data["data"]) > 0 - - def test_diagnostic_values_outlier_detection_off(client: TestClient, settings): """Test diagnostic values endpoint with outlier detection disabled.""" diagnostic = get_diagnostic_with_scalar_values(client, settings) @@ -587,39 +565,17 @@ def test_diagnostic_values_pagination_with_filter(client: TestClient, settings): assert item["dimensions"].get(facet["key"]) == filter_value -def test_diagnostics_facets_dimensions_are_sorted_and_non_null(client: TestClient, settings) -> None: - """Test that facets dimensions contain only sorted, non-null values. - - Validates the ORM-based facets query correctly filters NULLs and sorts results. - """ - r = client.get(f"{settings.API_V1_STR}/diagnostics/facets") - assert r.status_code == 200 - data = r.json() - - for dimension_name, values in data["dimensions"].items(): - assert isinstance(values, list), f"Dimension '{dimension_name}' should be a list" - assert None not in values, f"Dimension '{dimension_name}' should not contain null values" - assert values == sorted(values), f"Dimension '{dimension_name}' should be sorted" - assert len(values) == len(set(values)), f"Dimension '{dimension_name}' should have unique values" - - -def test_diagnostics_facets_count_is_non_negative(client: TestClient, settings) -> None: - """Test that facets count reflects total metric values.""" - r = client.get(f"{settings.API_V1_STR}/diagnostics/facets") - assert r.status_code == 200 - data = r.json() - assert data["count"] >= 0 - - -def _diagnostics_with_values(session, value_class, *, promoted_only: bool) -> set[int]: +def _diagnostics_with_values(session, value_class) -> set[int]: query = ( session.query(models.ExecutionGroup.diagnostic_id) .join(models.Execution) .join(value_class) .join(models.Diagnostic, models.Diagnostic.id == models.ExecutionGroup.diagnostic_id) + .filter( + models.ExecutionGroup.diagnostic_version == models.Diagnostic.promoted_version, + models.Execution.retracted.is_(False), + ) ) - if promoted_only: - query = query.filter(models.ExecutionGroup.diagnostic_version == models.Diagnostic.promoted_version) return {row[0] for row in query.distinct()} @@ -627,84 +583,46 @@ def test_diagnostics_has_values_flags_match_each_value_type(client: TestClient, """The list and detail flags agree with a query against each metric value subclass.""" session = Database.from_config(test_ref_config(), run_migrations=False, read_only=True).session listing = client.get(f"{settings.API_V1_STR}/diagnostics/").json()["data"] + listed = {d["id"] for d in listing} for value_class, flag in [ (models.ScalarMetricValue, "has_scalar_values"), (models.SeriesMetricValue, "has_series_values"), ]: - promoted = _diagnostics_with_values(session, value_class, promoted_only=True) - assert promoted, flag - assert {d["id"] for d in listing if d[flag]} == promoted + expected = _diagnostics_with_values(session, value_class) & listed + assert expected, flag + assert {d["id"] for d in listing if d[flag]} == expected - any_version = _diagnostics_with_values(session, value_class, promoted_only=False) for diagnostic in listing: url = f"{settings.API_V1_STR}/diagnostics/{diagnostic['provider']['slug']}/{diagnostic['slug']}" - expected = diagnostic["id"] in any_version - assert client.get(url).json()[flag] == expected, (flag, diagnostic["slug"]) - - -def test_diagnostics_facets_cover_scalar_and_series_values(client: TestClient, settings) -> None: - """Facets are the union of the scalar and series dimension values, and count both.""" - database = Database.from_config(test_ref_config(), run_migrations=False, read_only=True) - session = database.session - scalars = session.query(models.ScalarMetricValue).all() - series = session.query(models.SeriesMetricValue).all() - assert scalars - assert series - - expected = {} - for dimension_name in models.MetricValue._cv_dimensions: - values = {getattr(value, dimension_name) for value in [*scalars, *series]} - {None} - if values: - expected[dimension_name] = sorted(values) - - data = client.get(f"{settings.API_V1_STR}/diagnostics/facets").json() - assert {key: values for key, values in data["dimensions"].items() if values} == expected - assert data["count"] == len(scalars) + len(series) - - -def test_diagnostic_executions_ignores_truly_unknown_query_params(client: TestClient, settings) -> None: - """Test that query params not matching any model attribute are silently ignored. - - Only params matching a real CMIP6Dataset attribute should affect filtering. - Completely nonexistent param names must not cause errors. - """ - diagnostic = get_diagnostic(client, settings) - provider_slug = diagnostic["provider"]["slug"] - diagnostic_slug = diagnostic["slug"] + assert client.get(url).json()[flag] == (diagnostic["id"] in expected), (flag, diagnostic["slug"]) - r = client.get( - f"{settings.API_V1_STR}/diagnostics/{provider_slug}/{diagnostic_slug}/executions" - "?nonexistent_column=test&fake_param=evil&zzz_not_real=bad" - ) - assert r.status_code == 200 - # Truly unknown params should be ignored, yielding the same results as unfiltered - r_unfiltered = client.get( - f"{settings.API_V1_STR}/diagnostics/{provider_slug}/{diagnostic_slug}/executions" - ) - assert r_unfiltered.status_code == 200 - assert r.json()["count"] == r_unfiltered.json()["count"] - - -def test_diagnostic_executions_dunder_attrs_do_not_crash(client: TestClient, settings) -> None: - """Test that dunder attribute names in query params don't cause 500 errors. +def test_retracted_values_are_hidden(writable_client: TestClient) -> None: + """Retracting every execution behind a diagnostic's values clears its flags and its values.""" + listing = writable_client.get("/api/v1/diagnostics/").json()["data"] + diagnostic = next(d for d in listing if d["has_scalar_values"]) + detail_url = f"/api/v1/diagnostics/{diagnostic['provider']['slug']}/{diagnostic['slug']}" + scalars = {"value_type": "scalar", "detect_outliers": "off"} + assert writable_client.get(f"{detail_url}/values", params=scalars).json()["data"] - NOTE: hasattr(CMIP6Dataset, '__tablename__') is True because it's a - SQLAlchemy class attribute. This means dunder names pass the hasattr check - and may produce unexpected filter behavior (returning 0 results). This test - verifies the server doesn't crash -- the empty result is a known limitation - of the unfiltered hasattr approach (tracked as MEDIUM priority). - """ - diagnostic = get_diagnostic(client, settings) - provider_slug = diagnostic["provider"]["slug"] - diagnostic_slug = diagnostic["slug"] + database = Database.from_config(writable_client.ref_config) # type: ignore[attr-defined] + with database.session.begin(): + executions = database.session.scalars( + select(models.Execution) + .join(models.ExecutionGroup) + .where(models.ExecutionGroup.diagnostic_id == diagnostic["id"]) + ).all() + for execution in executions: + execution.retracted = True - r = client.get( - f"{settings.API_V1_STR}/diagnostics/{provider_slug}/{diagnostic_slug}/executions?__tablename__=evil" + relisted = next( + d for d in writable_client.get("/api/v1/diagnostics/").json()["data"] if d["id"] == diagnostic["id"] ) - # Must not crash with a 500 - assert r.status_code == 200 + assert not relisted["has_metric_values"] + assert not writable_client.get(detail_url).json()["has_metric_values"] + values = writable_client.get(f"{detail_url}/values", params=scalars).json() + assert values["data"] == [] def test_diagnostic_values_ignores_unknown_filter_params(client: TestClient, settings) -> None: @@ -756,37 +674,6 @@ def test_diagnostic_404_invalid_provider(client: TestClient, settings) -> None: assert r.status_code == 404 -def test_diagnostics_facets(client: TestClient, settings) -> None: - """Test that diagnostics facets endpoint returns dimension summary.""" - r = client.get(f"{settings.API_V1_STR}/diagnostics/facets") - assert r.status_code == 200 - data = r.json() - assert "dimensions" in data - assert "count" in data - assert isinstance(data["dimensions"], dict) - assert isinstance(data["count"], int) - - -def test_diagnostic_executions_filtered_by_mip_era(client: TestClient, settings) -> None: - """A `mip_era` filter restricts executions to a single MIP era.""" - diagnostic = get_diagnostic(client, settings) - base = ( - f"{settings.API_V1_STR}/diagnostics/{diagnostic['provider']['slug']}/{diagnostic['slug']}/executions" - ) - - unfiltered = client.get(base) - cmip6 = client.get(f"{base}?mip_era=CMIP6") - cmip7 = client.get(f"{base}?mip_era=CMIP7") - - assert unfiltered.status_code == 200 - assert cmip6.status_code == 200 - assert cmip7.status_code == 200 - - # The test fixtures are CMIP6 only, so CMIP7 is empty and CMIP6 accounts for everything. - assert cmip7.json()["count"] == 0 - assert cmip6.json()["count"] == unfiltered.json()["count"] - - def test_diagnostic_values_carry_an_era(client: TestClient, settings) -> None: """Model values are stamped with the era of their inputs, even when the diagnostic omits it.""" diagnostic = get_diagnostic_with_scalar_values(client, settings) @@ -822,22 +709,6 @@ def test_diagnostic_values_filtered_by_mip_era(client: TestClient, settings) -> assert cmip6.json()["total_count"] == unfiltered.json()["total_count"] -def test_diagnostic_executions_keeps_known_filters_beside_unknown_ones(client: TestClient, settings) -> None: - """An unrecognised query parameter is ignored without dropping the filters beside it.""" - diagnostic = get_diagnostic(client, settings) - base = ( - f"{settings.API_V1_STR}/diagnostics/{diagnostic['provider']['slug']}/{diagnostic['slug']}/executions" - ) - - matching = client.get(f"{base}?source_id=ACCESS-ESM1-5¬_a_facet=zzz") - missing = client.get(f"{base}?source_id=NOT-A-MODEL¬_a_facet=zzz") - - assert matching.status_code == 200 - assert missing.status_code == 200 - assert matching.json()["count"] > 0 - assert missing.json()["count"] == 0 - - def test_diagnostic_resource_usage_matches_between_list_and_get(client: TestClient, settings) -> None: r = client.get(f"{settings.API_V1_STR}/diagnostics/") assert r.status_code == 200 @@ -854,7 +725,7 @@ def test_diagnostic_resource_usage_matches_between_list_and_get(client: TestClie assert rd.json()["resource_usage"] == diagnostic["resource_usage"] usage = timed[0]["resource_usage"] - assert usage["timed_execution_count"] <= timed[0]["execution_count"] + assert usage["timed_execution_count"] > 0 assert usage["wall_seconds_total"] >= usage["wall_seconds_max"] >= usage["wall_seconds_mean"] > 0 @@ -869,19 +740,16 @@ def test_diagnostics_list_counts_only_the_requested_era(client: TestClient) -> N # Every diagnostic is still listed, only its statistics change with the era. assert set(cmip6) == set(everything) == set(cmip7) - assert sum(d["execution_group_count"] for d in cmip6.values()) > 0 + assert sum(d["groups"]["total"] for d in cmip6.values()) > 0 # The fixture database only holds CMIP6 datasets. - assert all(d["execution_group_count"] == 0 for d in cmip7.values()) + assert all(d["groups"]["total"] == 0 for d in cmip7.values()) assert all(not d["has_metric_values"] for d in cmip7.values()) - assert all( - cmip6[slug]["execution_group_count"] <= everything[slug]["execution_group_count"] - for slug in everything - ) + assert all(cmip6[slug]["groups"]["total"] <= everything[slug]["groups"]["total"] for slug in everything) def test_execution_groups_filter_by_era(client: TestClient) -> None: listing = client.get("/api/v1/diagnostics/").json()["data"] - diagnostic = next(d for d in listing if d["execution_group_count"] > 0) + diagnostic = next(d for d in listing if d["groups"]["total"] > 0) base = f"/api/v1/diagnostics/{diagnostic['provider']['slug']}/{diagnostic['slug']}/execution_groups" everything = client.get(base).json()["data"] @@ -903,34 +771,10 @@ def test_diagnostic_group_by_lists_each_pairing_once(client: TestClient) -> None assert len(pairings) == len(set(pairings)), diagnostic["slug"] -@pytest.fixture -def writable_client(tmp_path: Path) -> Generator[TestClient, None, None]: - """A client over a copy of the fixture database, for tests that have to write to it.""" - settings = test_settings() - config = copy.deepcopy(test_ref_config()) - - source = Path(config.db.database_url.removeprefix("sqlite:///")) - database_copy = tmp_path / source.name - shutil.copy(source, database_copy) - config.db.database_url = f"sqlite:///{database_copy}" - - app = build_app( - settings=settings, - ref_config=config, - database=deps._get_database_dependency(settings, config), - ) - app.dependency_overrides[get_settings] = lambda: settings - app.dependency_overrides[deps._ref_config_dependency] = lambda: config - - with TestClient(app) as client: - client.ref_config = config # type: ignore[attr-defined] - yield client - - def test_diagnostic_counts_are_scoped_to_the_promoted_version(writable_client: TestClient) -> None: """Counts follow the same promoted-version gate as the values, so they cannot disagree.""" listing = writable_client.get("/api/v1/diagnostics/").json()["data"] - diagnostic = next(d for d in listing if d["execution_group_count"] > 0) + diagnostic = next(d for d in listing if d["groups"]["total"] > 0) detail_url = f"/api/v1/diagnostics/{diagnostic['provider']['slug']}/{diagnostic['slug']}" database = Database.from_config(writable_client.ref_config) # type: ignore[attr-defined] @@ -942,14 +786,79 @@ def test_diagnostic_counts_are_scoped_to_the_promoted_version(writable_client: T # Every group now sits on a superseded version, so nothing is left to count. detail = writable_client.get(detail_url).json() assert detail["promoted_version"] == diagnostic["promoted_version"] + 1 - assert detail["execution_group_count"] == 0 - assert detail["successful_execution_group_count"] == 0 - assert detail["execution_count"] == 0 + assert detail["groups"]["total"] == 0 + assert detail["groups"]["successful"] == 0 + + relisted = next( + d for d in writable_client.get("/api/v1/diagnostics/").json()["data"] if d["id"] == diagnostic["id"] + ) + assert relisted["groups"]["total"] == 0 + + +def _catalog_usage(client: TestClient, diagnostic_id: int) -> dict | None: + entries = client.get("/api/v1/diagnostics/catalog").json()["data"] + return next(d for d in entries if d["id"] == diagnostic_id)["resource_usage"] + + +def test_catalog_resource_usage_counts_retracted_runs_at_the_promoted_version( + writable_client: TestClient, +) -> None: + entries = writable_client.get("/api/v1/diagnostics/catalog").json()["data"] + diagnostic = next(d for d in entries if d["resource_usage"] is not None) + before = diagnostic["resource_usage"] + + database = Database.from_config(writable_client.ref_config) # type: ignore[attr-defined] + with database.session.begin(): + for execution in database.session.scalars( + select(models.Execution) + .join(models.ExecutionGroup) + .where(models.ExecutionGroup.diagnostic_id == diagnostic["id"]) + ): + execution.retracted = True + assert _catalog_usage(writable_client, diagnostic["id"]) == before + + with database.session.begin(): + record = database.session.get(models.Diagnostic, diagnostic["id"]) + assert record is not None + record.promoted_version = record.promoted_version + 1 + assert _catalog_usage(writable_client, diagnostic["id"]) is None + +def test_a_backfilled_failure_does_not_decide_a_group(writable_client: TestClient) -> None: + """The latest execution is the newest by created_at, not the highest id.""" + listing = writable_client.get("/api/v1/diagnostics/").json()["data"] + diagnostic = next(d for d in listing if d["groups"]["successful"] > 0) + detail_url = f"/api/v1/diagnostics/{diagnostic['provider']['slug']}/{diagnostic['slug']}" + before = writable_client.get(detail_url).json() + + database = Database.from_config(writable_client.ref_config) # type: ignore[attr-defined] + with database.session.begin(): + group = database.session.scalars( + select(models.ExecutionGroup).where( + models.ExecutionGroup.diagnostic_id == diagnostic["id"], + models.ExecutionGroup.diagnostic_version == diagnostic["promoted_version"], + models.ExecutionGroup.executions.any(models.Execution.successful.is_(True)), + ) + ).first() + assert group is not None + newest = max(group.executions, key=lambda e: (e.created_at, e.id)) + assert newest.successful + database.session.add( + models.Execution( + execution_group_id=group.id, + dataset_hash="backfill", + output_fragment=newest.output_fragment, + successful=False, + created_at=newest.created_at - timedelta(days=1), + ) + ) + + after = writable_client.get(detail_url).json() + assert after["groups"]["successful"] == before["groups"]["successful"] relisted = next( d for d in writable_client.get("/api/v1/diagnostics/").json()["data"] if d["id"] == diagnostic["id"] ) - assert relisted["execution_group_count"] == 0 + assert relisted["groups"]["successful"] == before["groups"]["successful"] FLAGS = ("has_metric_values", "has_scalar_values", "has_series_values") @@ -980,8 +889,8 @@ def counts(query: str = "") -> dict[str, tuple[int, int]]: assert r.status_code == 200 return { f"{d['provider']['slug']}/{d['slug']}": ( - d["execution_group_count"], - d["successful_execution_group_count"], + d["groups"]["total"], + d["groups"]["successful"], ) for d in r.json()["data"] } @@ -1009,3 +918,77 @@ def test_diagnostic_without_provider(app, client: TestClient, settings): del app.dependency_overrides[deps._provider_registry_dependency] assert r.status_code == 200 + + +@pytest.mark.parametrize("param", ["isolate_ids", "exclude_ids"]) +def test_a_blank_id_list_is_no_filter(client: TestClient, param) -> None: + url = "/api/v1/diagnostics/esmvaltool/climate-at-global-warming-levels/values" + params = {"value_type": "scalar", "detect_outliers": "off"} + everything = client.get(url, params=params).json() + + assert everything["total_count"] > 0 + assert client.get(url, params={**params, param: ","}).json() == everything + + +@pytest.mark.parametrize("value_type", ["scalar", "series"]) +def test_value_facets_match_the_values(client: TestClient, settings, value_type) -> None: + diagnostic = next( + d + for d in client.get(f"{settings.API_V1_STR}/diagnostics/").json()["data"] + if d[f"has_{value_type}_values"] + ) + url = f"{settings.API_V1_STR}/diagnostics/{diagnostic['provider']['slug']}/{diagnostic['slug']}/values" + params = {"value_type": value_type, "limit": 1} + + facets = client.get(f"{url}/facets", params=params) + values = client.get(url, params=params).json() + + assert facets.status_code == 200 + assert values["facets"] + assert facets.json() == values["facets"] + assert client.get(url, params={**params, "with_facets": "false"}).json()["facets"] == [] + + +def test_value_facets_follow_the_dimension_filters(client: TestClient) -> None: + url = "/api/v1/diagnostics/esmvaltool/climate-at-global-warming-levels/values" + everything = { + f["key"]: f["values"] for f in client.get(f"{url}/facets", params={"value_type": "scalar"}).json() + } + key, (value, *_) = next((k, v) for k, v in everything.items() if len(v) > 1) + + narrowed = client.get(f"{url}/facets", params={"value_type": "scalar", key: value}).json() + + assert {f["key"]: f["values"] for f in narrowed}[key] == [value] + + +def test_value_facets_follow_the_isolated_ids(client: TestClient) -> None: + url = "/api/v1/diagnostics/esmvaltool/climate-at-global-warming-levels/values" + params = {"value_type": "scalar", "detect_outliers": "off"} + value_id = client.get(url, params={**params, "limit": 1}).json()["data"][0]["id"] + + for ids in ({"isolate_ids": str(value_id)}, {"exclude_ids": str(value_id)}): + facets = client.get(f"{url}/facets", params={**params, **ids}).json() + assert facets == client.get(url, params={**params, **ids}).json()["facets"] + + +@pytest.mark.parametrize( + ("params", "status"), + [ + ({}, 422), + ({"value_type": "all"}, 422), + ({"value_type": "scalar", "isolate_ids": "1,x"}, 400), + ({"value_type": "scalar", "exclude_ids": "x"}, 400), + ], +) +def test_value_facets_reject_bad_input(client: TestClient, params, status) -> None: + r = client.get( + "/api/v1/diagnostics/esmvaltool/climate-at-global-warming-levels/values/facets", params=params + ) + + assert r.status_code == status + + +def test_value_facets_of_an_unknown_diagnostic(client: TestClient) -> None: + r = client.get("/api/v1/diagnostics/nope/nope/values/facets", params={"value_type": "scalar"}) + + assert r.status_code == 404 diff --git a/backend/tests/test_api/test_routes/test_executions.py b/backend/tests/test_api/test_routes/test_executions.py index d20a04c5..1bad4923 100644 --- a/backend/tests/test_api/test_routes/test_executions.py +++ b/backend/tests/test_api/test_routes/test_executions.py @@ -15,17 +15,17 @@ def test_execution_list_count(client: TestClient, settings) -> None: assert len(data["data"]) > 0 -def get_execution_group_id(client: TestClient, settings) -> str: - """Helper to get an execution group ID that has scalar metric values.""" +def get_execution_group_id(client: TestClient, settings, value_type: str = "scalar") -> str: + """Helper to get an execution group ID that has metric values of `value_type`.""" r = client.get(f"{settings.API_V1_STR}/executions") assert r.status_code == 200 data = r.json() assert len(data["data"]) > 0 for group in data["data"]: - rv = client.get(f"{settings.API_V1_STR}/executions/{group['id']}/values?value_type=scalar") + rv = client.get(f"{settings.API_V1_STR}/executions/{group['id']}/values?value_type={value_type}") if rv.status_code == 200 and rv.json()["count"] > 0: return group["id"] - pytest.skip("No execution group with scalar values found in test data") + pytest.skip(f"No execution group with {value_type} values found in test data") def test_execution_values_outlier_detection_off(client: TestClient, settings): @@ -414,33 +414,24 @@ def test_execution_statistics(client: TestClient, settings) -> None: r = client.get(f"{settings.API_V1_STR}/executions/statistics") assert r.status_code == 200 data = r.json() - assert "total_execution_groups" in data - assert "successful_execution_groups" in data - assert "failed_execution_groups" in data - assert "scalar_value_count" in data - assert "series_value_count" in data - assert "total_datasets" in data - assert "total_files" in data - assert isinstance(data["total_execution_groups"], int) - assert isinstance(data["successful_execution_groups"], int) - assert isinstance(data["failed_execution_groups"], int) - assert data["total_execution_groups"] == ( - data["successful_execution_groups"] - + data["failed_execution_groups"] - + data["running_execution_groups"] - + data["not_started_execution_groups"] + groups = data["groups"] + assert ( + groups["total"] == groups["successful"] + groups["failed"] + groups["running"] + groups["not_started"] ) + assert groups["total"] > 0 + for key in ("scalar_value_count", "series_value_count", "total_datasets", "total_files"): + assert isinstance(data[key], int) def test_execution_statistics_match_list(client: TestClient, settings) -> None: """The list and the statistics count the same execution groups.""" - stats = client.get(f"{settings.API_V1_STR}/executions/statistics").json() + stats = client.get(f"{settings.API_V1_STR}/executions/statistics").json()["groups"] listed = client.get(f"{settings.API_V1_STR}/executions?limit=1").json() successful = client.get(f"{settings.API_V1_STR}/executions?limit=1&successful=true").json() unsuccessful = client.get(f"{settings.API_V1_STR}/executions?limit=1&successful=false").json() - assert listed["total_count"] == stats["total_execution_groups"] - assert successful["total_count"] == stats["successful_execution_groups"] + assert listed["total_count"] == stats["total"] + assert successful["total_count"] == stats["successful"] # Not successful covers failed, running and never-run groups. assert unsuccessful["total_count"] == listed["total_count"] - successful["total_count"] @@ -591,3 +582,60 @@ def test_execution_list_filters_by_failure_reason(client: TestClient, settings) assert unknown["total_count"] == 1 assert unknown["data"][0]["executions"][-1]["failure_reason"] == "unknown" assert other["total_count"] == 0 + + +@pytest.mark.parametrize("query", ["failure_reason=not-a-reason", "mip_era=CMIP5"]) +def test_execution_list_rejects_an_unknown_filter_value(client: TestClient, settings, query) -> None: + r = client.get(f"{settings.API_V1_STR}/executions/?{query}") + assert r.status_code == 422 + + +def test_execution_list_filters_by_source_id(client: TestClient, settings) -> None: + everything = client.get(f"{settings.API_V1_STR}/executions/?limit=500&include_outputs=false").json() + by_era = client.get(f"{settings.API_V1_STR}/executions/?limit=500&mip_era=CMIP6").json() + assert 0 < by_era["total_count"] <= everything["total_count"] + + nothing = client.get(f"{settings.API_V1_STR}/executions/?source_id=not-a-model").json() + assert nothing["total_count"] == 0 + + +def test_execution_statistics_count_groups_per_era(client: TestClient, settings) -> None: + stats = client.get(f"{settings.API_V1_STR}/executions/statistics").json() + by_era = stats["groups_by_mip_era"] + assert set(by_era) == {"CMIP6", "CMIP7"} + assert all(0 <= era["total"] <= stats["groups"]["total"] for era in by_era.values()) + + listed = client.get(f"{settings.API_V1_STR}/executions/?mip_era=CMIP6").json() + assert by_era["CMIP6"]["total"] == listed["total_count"] > 0 + + +@pytest.mark.parametrize("value_type", ["scalar", "series"]) +def test_execution_value_facets_match_the_values(client: TestClient, settings, value_type): + group_id = get_execution_group_id(client, settings, value_type) + url = f"{settings.API_V1_STR}/executions/{group_id}/values" + params = {"value_type": value_type, "limit": 1} + + facets = client.get(f"{url}/facets", params=params) + values = client.get(url, params=params).json() + + assert facets.status_code == 200 + assert values["facets"] + assert facets.json() == values["facets"] + assert client.get(url, params={**params, "with_facets": "false"}).json()["facets"] == [] + + +@pytest.mark.parametrize( + ("params", "status"), + [({}, 422), ({"value_type": "all"}, 422), ({"value_type": "scalar", "isolate_ids": "x"}, 400)], +) +def test_execution_value_facets_reject_bad_input(client: TestClient, settings, params, status): + group_id = get_execution_group_id(client, settings) + r = client.get(f"{settings.API_V1_STR}/executions/{group_id}/values/facets", params=params) + + assert r.status_code == status + + +def test_execution_value_facets_of_an_unknown_group(client: TestClient, settings): + r = client.get(f"{settings.API_V1_STR}/executions/999999/values/facets", params={"value_type": "scalar"}) + + assert r.status_code == 404 diff --git a/backend/tests/test_api/test_routes/test_mip_era_params.py b/backend/tests/test_api/test_routes/test_mip_era_params.py new file mode 100644 index 00000000..965a7b1f --- /dev/null +++ b/backend/tests/test_api/test_routes/test_mip_era_params.py @@ -0,0 +1,139 @@ +"""The `mip_era` query parameter on every route that takes it.""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from climate_ref import models +from climate_ref.database import Database +from climate_ref_core.datasets import SourceDatasetType + +MODEL = "ACCESS-ESM1-5" +DIAGNOSTIC = "esmvaltool/climate-at-global-warming-levels" + + +def _group_total(body) -> int: + return sum(d["groups"]["total"] for d in body["data"]) + + +def _with_values(body) -> int: + return sum(d["has_metric_values"] for d in body["data"]) + + +def _rows(body) -> int: + return len(body["data"]) + + +def _facet_values(body) -> int: + return sum(len(f["values"]) for f in body) + + +ROUTES = [ + ("/diagnostics/", {}, _group_total), + ("/diagnostics/catalog", {}, _group_total), + ("/diagnostics/value-flags", {}, _with_values), + (f"/diagnostics/{DIAGNOSTIC}/execution_groups", {}, _rows), + (f"/diagnostics/{DIAGNOSTIC}/values", {"value_type": "scalar"}, _rows), + (f"/diagnostics/{DIAGNOSTIC}/values/facets", {"value_type": "scalar"}, _facet_values), + ("/executions/", {}, _rows), + ("/models/", {}, _rows), + (f"/models/{MODEL}/ensemble", {}, _rows), +] + + +@pytest.mark.parametrize(("path", "params", "amount"), ROUTES) +def test_the_era_keeps_only_its_groups(client: TestClient, settings, path, params, amount) -> None: + """The fixture only holds CMIP6 runs.""" + url = f"{settings.API_V1_STR}{path}" + everything = client.get(url, params=params) + cmip6 = client.get(url, params={**params, "mip_era": "CMIP6"}) + cmip7 = client.get(url, params={**params, "mip_era": "CMIP7"}) + + assert everything.status_code == cmip6.status_code == cmip7.status_code == 200 + assert amount(cmip6.json()) == amount(everything.json()) > 0 + assert amount(cmip7.json()) == 0 + + +@pytest.mark.parametrize( + ("path", "params"), [*((path, params) for path, params, _ in ROUTES), (f"/models/{MODEL}", {})] +) +@pytest.mark.parametrize("mip_era", ["CMIP5", "cmip6", ""]) +def test_an_era_other_than_cmip6_or_cmip7_is_rejected( + client: TestClient, settings, path, params, mip_era +) -> None: + r = client.get(f"{settings.API_V1_STR}{path}", params={**params, "mip_era": mip_era}) + assert r.status_code == 422 + + +@pytest.mark.parametrize(("source_id", "mip_era"), [("NOT-A-MODEL", None), (MODEL, "CMIP7")]) +def test_a_missing_model_or_an_era_without_runs_is_not_found( + client: TestClient, settings, source_id, mip_era +) -> None: + params = {"mip_era": mip_era} if mip_era else {} + assert client.get(f"{settings.API_V1_STR}/models/{source_id}", params=params).status_code == 404 + + +def _add_obs_only_value(config) -> int: + """Add a scalar value from a group of `DIAGNOSTIC` that draws on observations alone.""" + provider_slug, diagnostic_slug = DIAGNOSTIC.split("/") + with Database.from_config(config, run_migrations=False).session_scope() as session: + diagnostic = session.scalars( + select(models.Diagnostic) + .join(models.Provider) + .where(models.Diagnostic.slug == diagnostic_slug, models.Provider.slug == provider_slug) + ).one() + group = models.ExecutionGroup( + diagnostic_id=diagnostic.id, + key="obs-only", + diagnostic_version=diagnostic.promoted_version, + selectors={SourceDatasetType.obs4MIPs.value: [["source_id", "obs"]]}, + ) + session.add(group) + session.flush() + execution = models.Execution( + execution_group_id=group.id, dataset_hash="obs-only", output_fragment="obs-only", successful=True + ) + session.add(execution) + session.flush() + value = models.ScalarMetricValue.build( + execution_id=execution.id, value=1.0, dimensions={"source_id": "obs"}, attributes=None + ) + session.add(value) + session.flush() + value_id = value.id + session.commit() + return value_id + + +@pytest.mark.parametrize("mip_era", ["CMIP6", "CMIP7"]) +def test_values_keep_obs_only_runs_for_either_era(writable_client: TestClient, mip_era) -> None: + value_id = _add_obs_only_value(writable_client.ref_config) # type: ignore[attr-defined] + r = writable_client.get( + f"/api/v1/diagnostics/{DIAGNOSTIC}/values", + params={"value_type": "scalar", "mip_era": mip_era, "limit": 500, "detect_outliers": "off"}, + ) + + assert r.status_code == 200 + assert value_id in {v["id"] for v in r.json()["data"]} + + +def _cmip7_groups(body) -> int: + return body["groups_by_mip_era"]["CMIP7"]["total"] + + +@pytest.mark.parametrize( + ("path", "params", "amount"), + [ + *((path, params, amount) for path, params, amount in ROUTES if not path.startswith("/models/")), + ("/executions/statistics", {}, _cmip7_groups), + ], +) +def test_an_obs_only_group_counts_for_an_era_without_runs( + writable_client: TestClient, path, params, amount +) -> None: + """The fixture holds no CMIP7 runs, so with CMIP7 only the obs-only group is left.""" + _add_obs_only_value(writable_client.ref_config) # type: ignore[attr-defined] + r = writable_client.get(f"/api/v1{path}", params={**params, "mip_era": "CMIP7"}) + + assert r.status_code == 200 + assert amount(r.json()) > 0 diff --git a/backend/tests/test_core/conftest.py b/backend/tests/test_core/conftest.py index 4203beb2..42c5a5d4 100644 --- a/backend/tests/test_core/conftest.py +++ b/backend/tests/test_core/conftest.py @@ -4,17 +4,27 @@ import pytest from climate_ref.database import Database +from climate_ref.results import Reader from ref_backend.testing import test_ref_config as fixture_ref_config @pytest.fixture -def writable_session(tmp_path): +def writable_database(tmp_path): """A throwaway copy of the fixture database, so a test may insert into it.""" config = copy.deepcopy(fixture_ref_config()) source = str(config.db.database_url).removeprefix("sqlite:///") destination = tmp_path / "climate_ref.db" shutil.copy(source, destination) config.db.database_url = f"sqlite:///{destination}" + return Database.from_config(config) - with Database.from_config(config).session_scope() as session: + +@pytest.fixture +def writable_session(writable_database): + with writable_database.session_scope() as session: yield session + + +@pytest.fixture +def writable_reader(writable_database, writable_session): + return Reader(writable_database, session=writable_session) diff --git a/backend/tests/test_core/test_filter_utils.py b/backend/tests/test_core/test_filter_utils.py deleted file mode 100644 index 09d92937..00000000 --- a/backend/tests/test_core/test_filter_utils.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Tests for shared utilities for parsing multi-value query parameters.""" - -from sqlalchemy import Column, MetaData, String, Table - -from ref_backend.core.filter_utils import _normalize_list_from_value, build_filter_clause - - -class TestNormalizeListFromValue: - """Test the _normalize_list_from_value function.""" - - def test_none_returns_none(self): - """Test that None input returns None.""" - result = _normalize_list_from_value(None) - assert result is None - - def test_list_input_returns_list(self): - """Test that list input returns cleaned list.""" - result = _normalize_list_from_value(["a", "b"]) - assert result == ["a", "b"] - - def test_comma_separated_string_returns_list(self): - """Test that comma-separated string is split into list.""" - result = _normalize_list_from_value("a,b,c") - assert result == ["a", "b", "c"] - - def test_single_string_without_comma_returns_none(self): - """Test that single string without comma returns None for equality fallback.""" - result = _normalize_list_from_value("abc") - assert result is None - - def test_empty_list_returns_none(self): - """Test that empty list returns None.""" - result = _normalize_list_from_value([]) - assert result is None - - def test_list_with_none_elements_filters_them_out(self): - """Test that None elements in list are filtered out.""" - result = _normalize_list_from_value([None, "a", None]) - assert result == ["a"] - - def test_list_with_whitespace_trims_values(self): - """Test that whitespace in list values is trimmed.""" - result = _normalize_list_from_value([" a ", " b "]) - assert result == ["a", "b"] - - def test_comma_separated_with_whitespace(self): - """Test that comma-separated string with whitespace is cleaned.""" - result = _normalize_list_from_value(" a , b , c ") - assert result == ["a", "b", "c"] - - def test_list_with_all_none_returns_none(self): - """Test that list with all None elements returns None.""" - result = _normalize_list_from_value([None, None]) - assert result is None - - def test_list_with_empty_strings_after_strip_returns_none(self): - """Test that list with only whitespace elements returns None.""" - result = _normalize_list_from_value([" ", " "]) - assert result is None - - def test_string_of_only_commas_returns_none(self): - """Test that a string of commas and whitespace returns None.""" - result = _normalize_list_from_value(" , ,") - assert result is None - - def test_non_string_scalar_returns_none(self): - """Test that a scalar other than a string returns None.""" - result = _normalize_list_from_value(42) - assert result is None - - -class TestBuildFilterClause: - """Test the build_filter_clause function.""" - - def setup_method(self): - """Set up test table and column for SQLAlchemy expressions.""" - self.metadata = MetaData() - self.test_table = Table("test", self.metadata, Column("name", String)) - self.column = self.test_table.c.name - - def _compile_clause(self, clause): - """Helper to compile SQLAlchemy clause to SQL string.""" - return str(clause.compile(compile_kwargs={"literal_binds": True})) - - def test_single_scalar_value_returns_equality(self): - """Test that single scalar value returns equality expression.""" - clause = build_filter_clause(self.column, "a") - sql = self._compile_clause(clause) - assert "test.name = 'a'" in sql - - def test_comma_separated_string_returns_in_clause(self): - """Test that comma-separated string returns IN clause.""" - clause = build_filter_clause(self.column, "a,b") - sql = self._compile_clause(clause) - assert "test.name IN ('a', 'b')" in sql - - def test_list_returns_in_clause(self): - """Test that list with multiple values returns IN clause.""" - clause = build_filter_clause(self.column, ["a", "b"]) - sql = self._compile_clause(clause) - assert "test.name IN ('a', 'b')" in sql - - def test_single_item_list_returns_equality(self): - """Test that single-item list returns equality expression.""" - clause = build_filter_clause(self.column, ["a"]) - sql = self._compile_clause(clause) - assert "test.name = 'a'" in sql - - def test_empty_list_returns_equality_fallback(self): - """Test that empty list falls back to equality with original value.""" - clause = build_filter_clause(self.column, []) - # Empty list creates an equality clause with the list itself - # We can't compile this with literal_binds, so just verify the clause type - assert clause.operator.__name__ == "eq" - - def test_list_with_three_values_returns_in_clause(self): - """Test that list with three values returns IN clause.""" - clause = build_filter_clause(self.column, ["x", "y", "z"]) - sql = self._compile_clause(clause) - assert "test.name IN ('x', 'y', 'z')" in sql - - def test_numeric_scalar_returns_equality(self): - """Test that numeric scalar value returns equality expression.""" - clause = build_filter_clause(self.column, 42) - sql = self._compile_clause(clause) - assert "test.name = 42" in sql - - def test_comma_separated_string_with_one_item_returns_equality(self): - """Test that a comma-separated string with one non-empty item returns equality.""" - clause = build_filter_clause(self.column, "a, ") - sql = self._compile_clause(clause) - assert "test.name = 'a'" in sql - - def test_none_value_returns_equality(self): - """Test that None value returns equality expression.""" - clause = build_filter_clause(self.column, None) - sql = self._compile_clause(clause) - assert "test.name IS NULL" in sql or "test.name = NULL" in sql diff --git a/backend/tests/test_core/test_metric_values.py b/backend/tests/test_core/test_metric_values.py index 1164ffb8..12d18a1b 100644 --- a/backend/tests/test_core/test_metric_values.py +++ b/backend/tests/test_core/test_metric_values.py @@ -32,6 +32,10 @@ def test_extra_commas(self): result = parse_id_list("1,,2,,,3") assert result == [1, 2, 3] + @pytest.mark.parametrize("id_str", [None, "", ",", " , "]) + def test_a_blank_list_is_no_filter(self, id_str): + assert parse_id_list(id_str) is None + def test_invalid_input_raises_http_exception(self): """Test that invalid input raises HTTPException with status_code=400.""" with pytest.raises(HTTPException) as exc_info: diff --git a/backend/tests/test_core/test_mip_era_edges.py b/backend/tests/test_core/test_mip_era_edges.py new file mode 100644 index 00000000..ce273b1d --- /dev/null +++ b/backend/tests/test_core/test_mip_era_edges.py @@ -0,0 +1,112 @@ +"""How executions whose group does not draw on exactly one MIP era are labelled.""" + +from types import SimpleNamespace + +import pytest +from sqlalchemy import select + +from climate_ref import models +from climate_ref.models.execution import ExecutionGroupSourceType +from climate_ref_core.datasets import SourceDatasetType +from ref_backend.core.mip_eras import CMIP_ERAS, eras_in_scope, mip_eras_for_executions, source_types_for +from ref_backend.core.reader_values import annotate_mip_eras +from ref_backend.models.values import MetricValueCollection, SeriesValue + + +@pytest.mark.parametrize( + ("mip_era", "expected"), + [ + (None, CMIP_ERAS), + ("CMIP7", (SourceDatasetType.CMIP7,)), + ("CMIP6", (SourceDatasetType.CMIP6,)), + ], +) +def test_eras_in_scope(mip_era, expected): + assert eras_in_scope(mip_era) == expected + + +def test_no_era_keeps_every_source_type(): + assert source_types_for(None) is None + + +def _cmip6_execution(session) -> models.Execution: + return session.scalars( + select(models.Execution) + .join( + ExecutionGroupSourceType, + ExecutionGroupSourceType.execution_group_id == models.Execution.execution_group_id, + ) + .where(ExecutionGroupSourceType.source_type == SourceDatasetType.CMIP6) + .limit(1) + ).one() + + +def _mixed_era_execution(session) -> models.Execution: + execution = _cmip6_execution(session) + execution.execution_group.source_types.append( + ExecutionGroupSourceType(source_type=SourceDatasetType.CMIP7) + ) + session.flush() + return execution + + +def _obs_only_execution(session) -> models.Execution: + execution = _cmip6_execution(session) + group = execution.execution_group + group.source_types = [t for t in group.source_types if t.source_type not in CMIP_ERAS] + session.flush() + return execution + + +def _values(*items: SeriesValue) -> MetricValueCollection: + return MetricValueCollection( + data=list(items), count=len(items), total_count=len(items), facets=[], types=[] + ) + + +def _value(execution_id: int, **dimensions: str) -> SeriesValue: + return SeriesValue( + id=1, dimensions=dimensions, values=[], execution_group_id=1, execution_id=execution_id + ) + + +def test_a_group_drawing_on_one_era_labels_its_executions(writable_session): + execution = _cmip6_execution(writable_session) + + assert mip_eras_for_executions(writable_session, [execution.id]) == {execution.id: "CMIP6"} + + +def test_a_mixed_era_group_has_no_era(writable_session): + execution = _mixed_era_execution(writable_session) + + assert mip_eras_for_executions(writable_session, [execution.id]) == {} + + +def test_a_group_without_cmip_input_has_no_era(writable_session): + execution = _obs_only_execution(writable_session) + + assert mip_eras_for_executions(writable_session, [execution.id]) == {} + + +def test_mip_id_labels_an_execution_the_group_does_not_settle(writable_session): + mixed = _mixed_era_execution(writable_session) + app_context = SimpleNamespace(session=writable_session) + + collection = annotate_mip_eras( + app_context, + _values(_value(mixed.id, mip_id="cmip7"), _value(mixed.id)), + ) + + assert [v.dimensions.get("mip_era") for v in collection.data] == ["CMIP7", None] + + +def test_the_group_outranks_mip_id_and_a_recorded_era_is_kept(writable_session): + cmip6 = _cmip6_execution(writable_session) + app_context = SimpleNamespace(session=writable_session) + + collection = annotate_mip_eras( + app_context, + _values(_value(cmip6.id, mip_id="CMIP7"), _value(cmip6.id, mip_era="CMIP7")), + ) + + assert [v.dimensions["mip_era"] for v in collection.data] == ["CMIP6", "CMIP7"] diff --git a/backend/tests/test_core/test_model_ensemble_retries.py b/backend/tests/test_core/test_model_ensemble_retries.py index 33e534b7..021b19e8 100644 --- a/backend/tests/test_core/test_model_ensemble_retries.py +++ b/backend/tests/test_core/test_model_ensemble_retries.py @@ -94,3 +94,25 @@ def test_writes_do_not_reach_the_checked_in_fixture(writable_session): assert writable_session.scalar(select(func.count(models.Execution.id))) == before + 1 original.expire_all() assert original.scalar(select(func.count(models.Execution.id))) == before + + +def test_a_retracted_latest_execution_leaves_the_ensemble(writable_session): + """A retracted run must not fall back to the attempt it replaced either.""" + values = writable_session.scalars( + select(models.ScalarMetricValue).where(cv_column(models.ScalarMetricValue, "source_id") == MODEL) + ).all() + for candidate in values: + deciding = writable_session.get(models.Execution, candidate.execution_id) + diagnostic_id = deciding.execution_group.diagnostic_id + before = _comparisons_for(writable_session, diagnostic_id) + if before: + break + else: + pytest.skip("No metric of this model is reported by enough others in the fixture") + + deciding.retracted = True + writable_session.flush() + + after = _comparisons_for(writable_session, diagnostic_id) + members = sum(c.ensemble.count for c in after.values()) + assert members < sum(c.ensemble.count for c in before.values()) diff --git a/backend/tests/test_core/test_model_runs.py b/backend/tests/test_core/test_model_runs.py index eac90a13..b0c5e56c 100644 --- a/backend/tests/test_core/test_model_runs.py +++ b/backend/tests/test_core/test_model_runs.py @@ -10,7 +10,6 @@ from ref_backend.core.model_runs import ( _model_groups, failed_runs, - latest_executions, model_run_rows, tally, ) @@ -173,10 +172,10 @@ def test_a_backfilled_execution_does_not_become_the_latest(writable_session): A row can be inserted after one it predates, such as a backfill. Ranking on id alone would hand the group that older run's outcome, and disagree with the statistics endpoint. """ - group = writable_session.scalars( - select(models.ExecutionGroup).join(models.ExecutionGroup.executions).limit(1) - ).first() - deciding = group.executions[-1] + groups = _model_groups(None, None).subquery() + group_id, deciding_id = writable_session.execute(select(groups.c.group_id, groups.c.execution_id)).first() + group = writable_session.get(models.ExecutionGroup, group_id) + deciding = writable_session.get(models.Execution, deciding_id) writable_session.add( models.Execution( @@ -189,8 +188,16 @@ def test_a_backfilled_execution_does_not_become_the_latest(writable_session): ) writable_session.commit() - latest = latest_executions() - picked = writable_session.execute( - select(latest.c.execution_id).where(latest.c.group_id == group.id) - ).scalar_one() - assert picked == deciding.id + picked = writable_session.scalars( + select(groups.c.execution_id).where(groups.c.group_id == group.id).distinct() + ).all() + assert picked == [deciding.id] + + +def test_a_retracted_latest_execution_drops_its_group(writable_session): + groups = _model_groups(None, None).subquery() + group_id, deciding_id = writable_session.execute(select(groups.c.group_id, groups.c.execution_id)).first() + writable_session.get(models.Execution, deciding_id).retracted = True + writable_session.commit() + + assert group_id not in writable_session.scalars(select(groups.c.group_id)).all() diff --git a/backend/tests/test_core/test_resource_usage.py b/backend/tests/test_core/test_resource_usage.py new file mode 100644 index 00000000..81b3d516 --- /dev/null +++ b/backend/tests/test_core/test_resource_usage.py @@ -0,0 +1,24 @@ +from sqlalchemy import select + +from climate_ref import models +from climate_ref.results import ResourceFilter +from ref_backend.core.resource_usage import resource_usage + + +def test_resource_usage_counts_retracted_executions(writable_session, writable_reader): + """A retracted execution still used the resources it recorded.""" + everything = ResourceFilter(include_retracted=True) + before = resource_usage(writable_reader, everything) + assert before is not None + + timed = writable_session.scalars( + select(models.Execution).where(models.Execution.wall_seconds.isnot(None)).limit(1) + ).one() + timed.retracted = True + writable_session.commit() + + assert resource_usage(writable_reader, everything) == before + + +def test_resource_usage_is_none_without_a_timed_execution(writable_reader): + assert resource_usage(writable_reader, ResourceFilter(diagnostic_slug="no-such-diagnostic")) is None diff --git a/backend/uv.lock b/backend/uv.lock index c3dac2f4..95ac593e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -649,7 +649,7 @@ wheels = [ [[package]] name = "climate-ref" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alembic" }, @@ -670,9 +670,9 @@ dependencies = [ { name = "typer" }, { name = "xarray" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/19/8495912bde7baed252f6f61d002075d4b52d143c89e9422337f9fd2125b1/climate_ref-0.19.2.tar.gz", hash = "sha256:c686f8f3e013f4aafc49a89ae3c138a9be27e626b2216f8401e1c236a4b736d7", size = 589340, upload-time = "2026-10-20T04:12:31.61Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/6a/8dea166923be6ed91aa1462f4909fcc20ed9e1c752aa227736350412933a/climate_ref-0.20.0.tar.gz", hash = "sha256:cc699f8dd646f81f0a6fec0a54b5473ff69d706d0d39e6f0c54ec1117a087e3a", size = 649749, upload-time = "2026-10-23T23:50:18.03Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/0d/70528884523e618e7c362d3f95c7dd00964db02b63d52acb074c3b98d70b/climate_ref-0.19.2-py3-none-any.whl", hash = "sha256:7ab61c35ffbbc7c874a35dde9c5ccf9b1597fa297006b464fb01c1c81541e216", size = 367493, upload-time = "2026-10-20T04:12:22.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/e80f37ba0cb7e83e0cffd1545a6a57fb0d27a4202a10a0699d0dd1276cdc/climate_ref-0.20.0-py3-none-any.whl", hash = "sha256:b0baaa05e1193c10d6fd3e387f32ec0ce8ba894c40b23ed841714af080738101", size = 405969, upload-time = "2026-10-23T23:50:10.553Z" }, ] [package.optional-dependencies] @@ -688,7 +688,7 @@ postgres = [ [[package]] name = "climate-ref-core" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -710,14 +710,14 @@ dependencies = [ { name = "setuptools" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/18/ee78a71f058eb6a605482b9cdee048e083a74507d405e846f29dd4c0410a/climate_ref_core-0.19.2.tar.gz", hash = "sha256:5de226d5f3a8a8a0a08a6fa3a8b2ce13a48dbe7029966fe62e7bbb350c2d1f3a", size = 239558, upload-time = "2026-10-20T04:12:33.838Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/9b/29b1f011faf4ecb2b004d97b8e133470168e1ef58f5bc10b1b43c4d5db57/climate_ref_core-0.20.0.tar.gz", hash = "sha256:3b95ee4aba60aa484685155bb2f2acf24744d355bf4dcd100d88e984e0d934ac", size = 242866, upload-time = "2026-10-23T23:50:19.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/be/a29fe7c453a9c4180c658248fe87ee055d38c3d9634dbf0f588d8c3472d7/climate_ref_core-0.19.2-py3-none-any.whl", hash = "sha256:04df2afbf31965f875a18311815de7d7e7ad046fc1af582bbd2f7d41e264afea", size = 159477, upload-time = "2026-10-20T04:12:25.24Z" }, + { url = "https://files.pythonhosted.org/packages/09/5b/5c4b584584fd9552df832b9642a1983aac0dc5224aa49bca247de2ca0183/climate_ref_core-0.20.0-py3-none-any.whl", hash = "sha256:f8f04abfbaa5f9c3d468db540cb309e947e6a2341b432d8beea7ce0d4c4b7293", size = 161500, upload-time = "2026-10-23T23:50:12.858Z" }, ] [[package]] name = "climate-ref-esmvaltool" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cftime" }, @@ -727,34 +727,34 @@ dependencies = [ { name = "pyyaml" }, { name = "xarray" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/9b/e5801f1144f7d9c6eb2edcf7edbebc729922ae34ce14c80ee271ac885c0b/climate_ref_esmvaltool-0.19.2.tar.gz", hash = "sha256:3b988d9967de4f3bc33888d40d1547c69e508b489611929582c978024d16296a", size = 524721, upload-time = "2026-10-20T04:12:35.187Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/95/17148dffdee6b125c1707707c8f0b6ce031d7a8f7cad7e7dae094ea4bc77/climate_ref_esmvaltool-0.20.0.tar.gz", hash = "sha256:cb3083c6907aec0ef17fcc9a9f7c69492b2e16d47322dd55fd1cbbca218dcd02", size = 544315, upload-time = "2026-10-23T23:50:20.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/1f/3f86ba3053469af33a84699dbe7db5cbb550e0a6b2633909e60eee77f6a3/climate_ref_esmvaltool-0.19.2-py3-none-any.whl", hash = "sha256:429d115b1c9ae0ce62dd87f9176b63e2dab6e0508272833be4b3e1202958886c", size = 170773, upload-time = "2026-10-20T04:12:26.618Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6f/58ff5743107843200c263abf6096fa861dfc52c35df871be73c5250e5045/climate_ref_esmvaltool-0.20.0-py3-none-any.whl", hash = "sha256:338b82b3ae1b6f64359d2b89cc41c4c0a90441bcc8280e64a53057cfc0ac7c54", size = 162536, upload-time = "2026-10-23T23:50:14.066Z" }, ] [[package]] name = "climate-ref-ilamb" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "climate-ref-core" }, { name = "ilamb3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/15/3eb0cf1aa7ed7f2dadcafe7e75f01ff9ee3085575185bf2fb282baddff76/climate_ref_ilamb-0.19.2.tar.gz", hash = "sha256:f306cfe90caf8685aa736e3ff65f33578436508b2db1915f09f237334cb22781", size = 259109, upload-time = "2026-10-20T04:12:36.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/5c/728443255e80076bb74478ea5f70628f47d4cb570384c973728103b95fec/climate_ref_ilamb-0.20.0.tar.gz", hash = "sha256:d6d215b152b922bb605df0d58f0da477874c35e35e35134b07d44b07cc921f75", size = 259039, upload-time = "2026-10-23T23:50:22.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/77/756538bb130d9d620c300c3d81abfb613509d70f7abb2306ef28417d942d/climate_ref_ilamb-0.19.2-py3-none-any.whl", hash = "sha256:9246337ef6840f7e7877653e8fa799990555df9160d684573c687415273cc0de", size = 26937, upload-time = "2026-10-20T04:12:28.84Z" }, + { url = "https://files.pythonhosted.org/packages/11/02/9962f7db782ec453d3fbbbc2e50a98596714299c0fcbd87ccd6f6ba81e23/climate_ref_ilamb-0.20.0-py3-none-any.whl", hash = "sha256:1d48ff80aa6bc866d8720a6e21148cb26a642562a0bd6729998c0e6b95ff9c03", size = 26900, upload-time = "2026-10-23T23:50:15.994Z" }, ] [[package]] name = "climate-ref-pmp" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "climate-ref-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/a8/1304cde653a241b94a3296ddccc8d90c0528448421090412819327847ae0/climate_ref_pmp-0.19.2.tar.gz", hash = "sha256:bce0cef40743c300c0cac6145d42e81fd0c43e87b140a61b7bb85ea80bc3085e", size = 1345616, upload-time = "2026-10-20T04:12:38.295Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/65/8b7fb55e0aa9a1189bb494e511f6dc6393b5069baa57e79123f170e9beaf/climate_ref_pmp-0.20.0.tar.gz", hash = "sha256:ed4423836cfa8a4a4248188265d9e2045dc17ff75e7502d5f57becb876a7416a", size = 1344937, upload-time = "2026-10-23T23:50:23.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/40/1fe3676e60d1ade5ae63c72a0041ac623645d6924a25748b981e4f8be848/climate_ref_pmp-0.19.2-py3-none-any.whl", hash = "sha256:3571254b6c0996693fc7be0b19bb4242f4abdc2be25a45a0477bc71989477fad", size = 116352, upload-time = "2026-10-20T04:12:30.01Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/fa3b325c09c36a8e8d70178828eb84fa6bc6549ce124f69b2016260601ce/climate_ref_pmp-0.20.0-py3-none-any.whl", hash = "sha256:fa00805bc2aa93bc373718fc0baa7751fc45987d7588b54e72cfcb61b6427066", size = 116110, upload-time = "2026-10-23T23:50:16.898Z" }, ] [[package]] @@ -4146,7 +4146,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "climate-ref", extras = ["aft-providers", "postgres"], specifier = ">=0.19.0,<0.20" }, + { name = "climate-ref", extras = ["aft-providers", "postgres"], specifier = ">=0.20.0,<0.21" }, { name = "cr-service", extras = ["tracing"], specifier = ">=0.6.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.162.0,<1.0.0" }, { name = "fastapi-sqlalchemy-monitor", specifier = ">=1.1.3" }, diff --git a/changelog/147.breaking.md b/changelog/147.breaking.md new file mode 100644 index 00000000..9a0c3612 --- /dev/null +++ b/changelog/147.breaking.md @@ -0,0 +1,11 @@ +* The `mip_era` parameter now only accepts `CMIP6` or `CMIP7`, and any other value returns a 422. An era now keeps the execution groups that draw on that era or on no model data, such as observation-only groups. +* Diagnostics and `/executions/statistics` now report execution group counts under `groups`, and `groups_by_mip_era` replaced `execution_groups_by_mip_era`. +* Removed `execution_count` and `successful_execution_count` from diagnostics. +* Group counts, diagnostic values, value flags and the executions list now leave out retracted executions. +* A diagnostic's execution groups now only cover its promoted version. +* An execution group from a superseded diagnostic version now returns its values instead of none. +* Filtering by `source_id`, and listing a dataset's executions, now look at each group's latest execution only. +* Resource usage now only covers the promoted version of each diagnostic, and still includes retracted executions. +* `/executions` now returns a 422 for an unknown `failure_reason`, or a negative `limit` or `offset`. +* `/datasets` is now ordered by slug. +* Removed the unused `/diagnostics/facets` and `/diagnostics/{provider}/{diagnostic}/executions` endpoints. diff --git a/changelog/147.feature.md b/changelog/147.feature.md new file mode 100644 index 00000000..41fef222 --- /dev/null +++ b/changelog/147.feature.md @@ -0,0 +1,2 @@ +* Added `/diagnostics/{provider}/{diagnostic}/values/facets` and `/executions/{group_id}/values/facets`, which return the facets of the metric values `/values` returns for the same filters. +* Added a `with_facets` argument to `/values`. Set it to `false` to skip the facets. diff --git a/changelog/147.improvement.md b/changelog/147.improvement.md new file mode 100644 index 00000000..5f438ee5 --- /dev/null +++ b/changelog/147.improvement.md @@ -0,0 +1,2 @@ +* Sped up the diagnostic, execution and metric value endpoints, most by five to ten times on a large database. +* The values tables and explorer filters now load facets once per filter instead of with every page. diff --git a/frontend/src/client/@tanstack/react-query.gen.ts b/frontend/src/client/@tanstack/react-query.gen.ts index 5ca6a7e4..ff1466c7 100644 --- a/frontend/src/client/@tanstack/react-query.gen.ts +++ b/frontend/src/client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { cmip7AssessmentFastTrackAftGetAftDiagnostic, cmip7AssessmentFastTrackAftListAftDiagnostics, datasetsExecutions, datasetsGet, datasetsList, diagnosticsCatalog, diagnosticsFacets, diagnosticsGet, diagnosticsList, diagnosticsListExecutionGroups, diagnosticsListExecutions, diagnosticsListMetricValues, diagnosticsValueFlags, executionsExecution, executionsExecutionArchive, executionsExecutionDatasets, executionsExecutionLogs, executionsGet, executionsGetExecutionStatistics, executionsListMetricValues, executionsListRecentExecutionGroups, executionsMetricBundle, explorerGetCollection, explorerGetTheme, explorerListCollections, explorerListThemes, modelsEnsemble, modelsGet, modelsList, type Options, resultsGetResult, utilsAbout, utilsHealthCheck } from '../sdk.gen'; -import type { Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticError, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponse, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponse, DatasetsExecutionsData, DatasetsExecutionsError, DatasetsExecutionsResponse, DatasetsGetData, DatasetsGetError, DatasetsGetResponse, DatasetsListData, DatasetsListError, DatasetsListResponse, DiagnosticsCatalogData, DiagnosticsCatalogError, DiagnosticsCatalogResponse, DiagnosticsFacetsData, DiagnosticsFacetsResponse, DiagnosticsGetData, DiagnosticsGetError, DiagnosticsGetResponse, DiagnosticsListData, DiagnosticsListError, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsError, DiagnosticsListExecutionGroupsResponse, DiagnosticsListExecutionsData, DiagnosticsListExecutionsError, DiagnosticsListExecutionsResponse, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesError, DiagnosticsListMetricValuesResponse, DiagnosticsListResponse, DiagnosticsValueFlagsData, DiagnosticsValueFlagsError, DiagnosticsValueFlagsResponse, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveError, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsError, ExecutionsExecutionDatasetsResponse, ExecutionsExecutionError, ExecutionsExecutionLogsData, ExecutionsExecutionLogsError, ExecutionsExecutionResponse, ExecutionsGetData, ExecutionsGetError, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponse, ExecutionsGetResponse, ExecutionsListMetricValuesData, ExecutionsListMetricValuesError, ExecutionsListMetricValuesResponse, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsError, ExecutionsListRecentExecutionGroupsResponse, ExecutionsMetricBundleData, ExecutionsMetricBundleError, ExecutionsMetricBundleResponse, ExplorerGetCollectionData, ExplorerGetCollectionError, ExplorerGetCollectionResponse, ExplorerGetThemeData, ExplorerGetThemeError, ExplorerGetThemeResponse, ExplorerListCollectionsData, ExplorerListCollectionsResponse, ExplorerListThemesData, ExplorerListThemesResponse, ModelsEnsembleData, ModelsEnsembleError, ModelsEnsembleResponse, ModelsGetData, ModelsGetError, ModelsGetResponse, ModelsListData, ModelsListError, ModelsListResponse, ResultsGetResultData, ResultsGetResultError, UtilsAboutData, UtilsAboutResponse, UtilsHealthCheckData, UtilsHealthCheckResponse } from '../types.gen'; +import { cmip7AssessmentFastTrackAftGetAftDiagnostic, cmip7AssessmentFastTrackAftListAftDiagnostics, datasetsExecutions, datasetsGet, datasetsList, diagnosticsCatalog, diagnosticsGet, diagnosticsList, diagnosticsListExecutionGroups, diagnosticsListMetricValueFacets, diagnosticsListMetricValues, diagnosticsValueFlags, executionsExecution, executionsExecutionArchive, executionsExecutionDatasets, executionsExecutionLogs, executionsGet, executionsGetExecutionStatistics, executionsListMetricValueFacets, executionsListMetricValues, executionsListRecentExecutionGroups, executionsMetricBundle, explorerGetCollection, explorerGetTheme, explorerListCollections, explorerListThemes, modelsEnsemble, modelsGet, modelsList, type Options, resultsGetResult, utilsAbout, utilsHealthCheck } from '../sdk.gen'; +import type { Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticError, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponse, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponse, DatasetsExecutionsData, DatasetsExecutionsError, DatasetsExecutionsResponse, DatasetsGetData, DatasetsGetError, DatasetsGetResponse, DatasetsListData, DatasetsListError, DatasetsListResponse, DiagnosticsCatalogData, DiagnosticsCatalogError, DiagnosticsCatalogResponse, DiagnosticsGetData, DiagnosticsGetError, DiagnosticsGetResponse, DiagnosticsListData, DiagnosticsListError, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsError, DiagnosticsListExecutionGroupsResponse, DiagnosticsListMetricValueFacetsData, DiagnosticsListMetricValueFacetsError, DiagnosticsListMetricValueFacetsResponse, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesError, DiagnosticsListMetricValuesResponse, DiagnosticsListResponse, DiagnosticsValueFlagsData, DiagnosticsValueFlagsError, DiagnosticsValueFlagsResponse, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveError, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsError, ExecutionsExecutionDatasetsResponse, ExecutionsExecutionError, ExecutionsExecutionLogsData, ExecutionsExecutionLogsError, ExecutionsExecutionResponse, ExecutionsGetData, ExecutionsGetError, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponse, ExecutionsGetResponse, ExecutionsListMetricValueFacetsData, ExecutionsListMetricValueFacetsError, ExecutionsListMetricValueFacetsResponse, ExecutionsListMetricValuesData, ExecutionsListMetricValuesError, ExecutionsListMetricValuesResponse, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsError, ExecutionsListRecentExecutionGroupsResponse, ExecutionsMetricBundleData, ExecutionsMetricBundleError, ExecutionsMetricBundleResponse, ExplorerGetCollectionData, ExplorerGetCollectionError, ExplorerGetCollectionResponse, ExplorerGetThemeData, ExplorerGetThemeError, ExplorerGetThemeResponse, ExplorerListCollectionsData, ExplorerListCollectionsResponse, ExplorerListThemesData, ExplorerListThemesResponse, ModelsEnsembleData, ModelsEnsembleError, ModelsEnsembleResponse, ModelsGetData, ModelsGetError, ModelsGetResponse, ModelsListData, ModelsListError, ModelsListResponse, ResultsGetResultData, ResultsGetResultError, UtilsAboutData, UtilsAboutResponse, UtilsHealthCheckData, UtilsHealthCheckResponse } from '../types.gen'; export type QueryKey = [ Pick & { @@ -191,7 +191,9 @@ export const datasetsExecutionsQueryKey = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -211,7 +213,9 @@ export const datasetsExecutionsInfiniteQueryKey = (options: Options) => { const opts = infiniteQueryOptions, QueryKey>, number | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>( @@ -245,8 +249,7 @@ export const diagnosticsListQueryKey = (options?: Options) * * List the currently registered diagnostics * - * Pass `mip_era` to count only the execution groups that ran against that era, - * and `source_id` to count only those that ran against that model. + * Counts cover the promoted version of each diagnostic and leave out retracted executions. */ export const diagnosticsListOptions = (options?: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -270,8 +273,6 @@ export const diagnosticsCatalogQueryKey = (options?: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -308,26 +309,6 @@ export const diagnosticsValueFlagsOptions = (options?: Options) => createQueryKey('diagnosticsFacets', options); - -/** - * Facets - * - * Query the unique dimensions and metrics for all diagnostics (both scalar and series) - */ -export const diagnosticsFacetsOptions = (options?: Options) => queryOptions>({ - queryFn: async ({ queryKey, signal }) => { - const { data } = await diagnosticsFacets({ - ...options, - ...queryKey[0], - signal, - throwOnError: true - }); - return data; - }, - queryKey: diagnosticsFacetsQueryKey(options) -}); - export const diagnosticsGetQueryKey = (options: Options) => createQueryKey('diagnosticsGet', options); /** @@ -353,9 +334,9 @@ export const diagnosticsListExecutionGroupsQueryKey = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -370,18 +351,18 @@ export const diagnosticsListExecutionGroupsOptions = (options: Options) => createQueryKey('diagnosticsListExecutions', options); +export const diagnosticsListMetricValueFacetsQueryKey = (options: Options) => createQueryKey('diagnosticsListMetricValueFacets', options); /** - * List Executions + * List Metric Value Facets * - * Fetch executions for a specific diagnostic, with arbitrary filters on the dataset. + * Get the facets of the values `/values` returns for the same filters, without the values. * - * e.g. `?source_id=MIROC6&experiment_id=ssp585`. Pass `mip_era` to restrict to one era. + * They cover every page and the outliers too, so a client can keep them while it pages. */ -export const diagnosticsListExecutionsOptions = (options: Options) => queryOptions>({ +export const diagnosticsListMetricValueFacetsOptions = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { - const { data } = await diagnosticsListExecutions({ + const { data } = await diagnosticsListMetricValueFacets({ ...options, ...queryKey[0], signal, @@ -389,7 +370,7 @@ export const diagnosticsListExecutionsOptions = (options: Options) => createQueryKey('diagnosticsListMetricValues', options); @@ -403,8 +384,11 @@ export const diagnosticsListMetricValuesQueryKey = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -430,8 +414,11 @@ export const diagnosticsListMetricValuesInfiniteQueryKey = (options: Options) => { const opts = infiniteQueryOptions, QueryKey>, number | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>( @@ -466,7 +453,8 @@ export const executionsGetExecutionStatisticsQueryKey = (options?: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -486,9 +474,10 @@ export const executionsListRecentExecutionGroupsQueryKey = (options?: Options) => cr /** * Get * - * Inspect a specific execution + * Inspect a specific execution group, whatever its version or retraction */ export const executionsGetOptions = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -667,6 +655,28 @@ export const executionsMetricBundleOptions = (options: Options) => createQueryKey('executionsListMetricValueFacets', options); + +/** + * List Metric Value Facets + * + * Get the facets of the values `/values` returns for the same filters, without the values. + * + * They cover every page and the outliers too, so a client can keep them while it pages. + */ +export const executionsListMetricValueFacetsOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await executionsListMetricValueFacets({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: executionsListMetricValueFacetsQueryKey(options) +}); + export const executionsListMetricValuesQueryKey = (options: Options) => createQueryKey('executionsListMetricValues', options); /** @@ -678,6 +688,8 @@ export const executionsListMetricValuesQueryKey = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -703,6 +715,8 @@ export const executionsListMetricValuesInfiniteQueryKey = (options: Options) => { const opts = infiniteQueryOptions, QueryKey>, number | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>( diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index b09315af..691075bc 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { cmip7AssessmentFastTrackAftGetAftDiagnostic, cmip7AssessmentFastTrackAftListAftDiagnostics, datasetsExecutions, datasetsGet, datasetsList, diagnosticsCatalog, diagnosticsFacets, diagnosticsGet, diagnosticsList, diagnosticsListExecutionGroups, diagnosticsListExecutions, diagnosticsListMetricValues, diagnosticsValueFlags, executionsExecution, executionsExecutionArchive, executionsExecutionDatasets, executionsExecutionLogs, executionsGet, executionsGetExecutionStatistics, executionsListMetricValues, executionsListRecentExecutionGroups, executionsMetricBundle, explorerGetCollection, explorerGetTheme, explorerListCollections, explorerListThemes, modelsEnsemble, modelsGet, modelsList, type Options, resultsGetResult, utilsAbout, utilsHealthCheck } from './sdk.gen'; -export type { About, AftCollectionCard, AftCollectionCardContent, AftCollectionContent, AftCollectionDetail, AftCollectionDiagnosticLink, AftCollectionFilterControl, AftCollectionGroupingConfig, AftCollectionPlainLanguage, AftCollectionSummary, AftDiagnosticDetail, AftDiagnosticSummary, ClientOptions, CmecMetric, Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticError, Cmip7AssessmentFastTrackAftGetAftDiagnosticErrors, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponse, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponses, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponse, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponses, CmipDatasetMetadata, CollectionDataset, CollectionDatasetWritable, CollectionDiagnosticCatalogEntry, CollectionDiagnosticCatalogEntryWritable, CollectionDiagnosticSummary, CollectionDiagnosticSummaryWritable, CollectionDiagnosticValueFlags, CollectionDiagnosticValueFlagsWritable, CollectionEnsembleComparison, CollectionEnsembleComparisonWritable, CollectionExecution, CollectionExecutionGroup, CollectionExecutionGroupWritable, CollectionExecutionWritable, CollectionModelSummary, CollectionModelSummaryWritable, Dataset, DatasetsExecutionsData, DatasetsExecutionsError, DatasetsExecutionsErrors, DatasetsExecutionsResponse, DatasetsExecutionsResponses, DatasetsGetData, DatasetsGetError, DatasetsGetErrors, DatasetsGetResponse, DatasetsGetResponses, DatasetsListData, DatasetsListError, DatasetsListErrors, DatasetsListResponse, DatasetsListResponses, DatasetWritable, DiagnosticCatalogEntry, DiagnosticRuns, DiagnosticRunsWritable, DiagnosticsCatalogData, DiagnosticsCatalogError, DiagnosticsCatalogErrors, DiagnosticsCatalogResponse, DiagnosticsCatalogResponses, DiagnosticsFacetsData, DiagnosticsFacetsResponse, DiagnosticsFacetsResponses, DiagnosticsGetData, DiagnosticsGetError, DiagnosticsGetErrors, DiagnosticsGetResponse, DiagnosticsGetResponses, DiagnosticsListData, DiagnosticsListError, DiagnosticsListErrors, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsError, DiagnosticsListExecutionGroupsErrors, DiagnosticsListExecutionGroupsResponse, DiagnosticsListExecutionGroupsResponses, DiagnosticsListExecutionsData, DiagnosticsListExecutionsError, DiagnosticsListExecutionsErrors, DiagnosticsListExecutionsResponse, DiagnosticsListExecutionsResponses, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesError, DiagnosticsListMetricValuesErrors, DiagnosticsListMetricValuesResponse, DiagnosticsListMetricValuesResponses, DiagnosticsListResponse, DiagnosticsListResponses, DiagnosticSummary, DiagnosticsValueFlagsData, DiagnosticsValueFlagsError, DiagnosticsValueFlagsErrors, DiagnosticsValueFlagsResponse, DiagnosticsValueFlagsResponses, DiagnosticValueFlags, EnsembleComparison, EnsembleComparisonWritable, EnsembleStatistics, Execution, ExecutionGroup, ExecutionOutput, ExecutionResourceSummary, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveError, ExecutionsExecutionArchiveErrors, ExecutionsExecutionArchiveResponses, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsError, ExecutionsExecutionDatasetsErrors, ExecutionsExecutionDatasetsResponse, ExecutionsExecutionDatasetsResponses, ExecutionsExecutionError, ExecutionsExecutionErrors, ExecutionsExecutionLogsData, ExecutionsExecutionLogsError, ExecutionsExecutionLogsErrors, ExecutionsExecutionLogsResponses, ExecutionsExecutionResponse, ExecutionsExecutionResponses, ExecutionsGetData, ExecutionsGetError, ExecutionsGetErrors, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponse, ExecutionsGetExecutionStatisticsResponses, ExecutionsGetResponse, ExecutionsGetResponses, ExecutionsListMetricValuesData, ExecutionsListMetricValuesError, ExecutionsListMetricValuesErrors, ExecutionsListMetricValuesResponse, ExecutionsListMetricValuesResponses, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsError, ExecutionsListRecentExecutionGroupsErrors, ExecutionsListRecentExecutionGroupsResponse, ExecutionsListRecentExecutionGroupsResponses, ExecutionsMetricBundleData, ExecutionsMetricBundleError, ExecutionsMetricBundleErrors, ExecutionsMetricBundleResponse, ExecutionsMetricBundleResponses, ExecutionStats, ExecutionStatsWritable, ExplorerGetCollectionData, ExplorerGetCollectionError, ExplorerGetCollectionErrors, ExplorerGetCollectionResponse, ExplorerGetCollectionResponses, ExplorerGetThemeData, ExplorerGetThemeError, ExplorerGetThemeErrors, ExplorerGetThemeResponse, ExplorerGetThemeResponses, ExplorerListCollectionsData, ExplorerListCollectionsResponse, ExplorerListCollectionsResponses, ExplorerListThemesData, ExplorerListThemesResponse, ExplorerListThemesResponses, Facet, FailedRun, GroupBy, HttpValidationError, MetricDimensions, MetricValueCollection, MetricValueFacetSummary, MetricValueType, ModelDetail, ModelDetailWritable, ModelsEnsembleData, ModelsEnsembleError, ModelsEnsembleErrors, ModelsEnsembleResponse, ModelsEnsembleResponses, ModelsGetData, ModelsGetError, ModelsGetErrors, ModelsGetResponse, ModelsGetResponses, ModelsListData, ModelsListError, ModelsListErrors, ModelsListResponse, ModelsListResponses, ModelSummary, ModelSummaryWritable, ProviderSummary, RefDiagnosticLink, ReferenceDatasetLink, ResultOutputType, ResultsGetResultData, ResultsGetResultError, ResultsGetResultErrors, ResultsGetResultResponses, RunCounts, RunCountsWritable, ScalarValue, SeriesValue, ThemeDetail, ThemeSummary, UtilsAboutData, UtilsAboutResponse, UtilsAboutResponses, UtilsHealthCheckData, UtilsHealthCheckResponse, UtilsHealthCheckResponses, ValidationError } from './types.gen'; +export { cmip7AssessmentFastTrackAftGetAftDiagnostic, cmip7AssessmentFastTrackAftListAftDiagnostics, datasetsExecutions, datasetsGet, datasetsList, diagnosticsCatalog, diagnosticsGet, diagnosticsList, diagnosticsListExecutionGroups, diagnosticsListMetricValueFacets, diagnosticsListMetricValues, diagnosticsValueFlags, executionsExecution, executionsExecutionArchive, executionsExecutionDatasets, executionsExecutionLogs, executionsGet, executionsGetExecutionStatistics, executionsListMetricValueFacets, executionsListMetricValues, executionsListRecentExecutionGroups, executionsMetricBundle, explorerGetCollection, explorerGetTheme, explorerListCollections, explorerListThemes, modelsEnsemble, modelsGet, modelsList, type Options, resultsGetResult, utilsAbout, utilsHealthCheck } from './sdk.gen'; +export type { About, AftCollectionCard, AftCollectionCardContent, AftCollectionContent, AftCollectionDetail, AftCollectionDiagnosticLink, AftCollectionFilterControl, AftCollectionGroupingConfig, AftCollectionPlainLanguage, AftCollectionSummary, AftDiagnosticDetail, AftDiagnosticSummary, ClientOptions, CmecMetric, Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticError, Cmip7AssessmentFastTrackAftGetAftDiagnosticErrors, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponse, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponses, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponse, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponses, CmipDatasetMetadata, CollectionDataset, CollectionDatasetWritable, CollectionDiagnosticCatalogEntry, CollectionDiagnosticCatalogEntryWritable, CollectionDiagnosticSummary, CollectionDiagnosticSummaryWritable, CollectionDiagnosticValueFlags, CollectionDiagnosticValueFlagsWritable, CollectionEnsembleComparison, CollectionEnsembleComparisonWritable, CollectionExecutionGroup, CollectionExecutionGroupWritable, CollectionModelSummary, CollectionModelSummaryWritable, Dataset, DatasetsExecutionsData, DatasetsExecutionsError, DatasetsExecutionsErrors, DatasetsExecutionsResponse, DatasetsExecutionsResponses, DatasetsGetData, DatasetsGetError, DatasetsGetErrors, DatasetsGetResponse, DatasetsGetResponses, DatasetsListData, DatasetsListError, DatasetsListErrors, DatasetsListResponse, DatasetsListResponses, DatasetWritable, DiagnosticCatalogEntry, DiagnosticRuns, DiagnosticRunsWritable, DiagnosticsCatalogData, DiagnosticsCatalogError, DiagnosticsCatalogErrors, DiagnosticsCatalogResponse, DiagnosticsCatalogResponses, DiagnosticsGetData, DiagnosticsGetError, DiagnosticsGetErrors, DiagnosticsGetResponse, DiagnosticsGetResponses, DiagnosticsListData, DiagnosticsListError, DiagnosticsListErrors, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsError, DiagnosticsListExecutionGroupsErrors, DiagnosticsListExecutionGroupsResponse, DiagnosticsListExecutionGroupsResponses, DiagnosticsListMetricValueFacetsData, DiagnosticsListMetricValueFacetsError, DiagnosticsListMetricValueFacetsErrors, DiagnosticsListMetricValueFacetsResponse, DiagnosticsListMetricValueFacetsResponses, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesError, DiagnosticsListMetricValuesErrors, DiagnosticsListMetricValuesResponse, DiagnosticsListMetricValuesResponses, DiagnosticsListResponse, DiagnosticsListResponses, DiagnosticSummary, DiagnosticsValueFlagsData, DiagnosticsValueFlagsError, DiagnosticsValueFlagsErrors, DiagnosticsValueFlagsResponse, DiagnosticsValueFlagsResponses, DiagnosticValueFlags, EnsembleComparison, EnsembleComparisonWritable, EnsembleStatistics, Execution, ExecutionFailureReason, ExecutionGroup, ExecutionOutput, ExecutionResourceSummary, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveError, ExecutionsExecutionArchiveErrors, ExecutionsExecutionArchiveResponses, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsError, ExecutionsExecutionDatasetsErrors, ExecutionsExecutionDatasetsResponse, ExecutionsExecutionDatasetsResponses, ExecutionsExecutionError, ExecutionsExecutionErrors, ExecutionsExecutionLogsData, ExecutionsExecutionLogsError, ExecutionsExecutionLogsErrors, ExecutionsExecutionLogsResponses, ExecutionsExecutionResponse, ExecutionsExecutionResponses, ExecutionsGetData, ExecutionsGetError, ExecutionsGetErrors, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponse, ExecutionsGetExecutionStatisticsResponses, ExecutionsGetResponse, ExecutionsGetResponses, ExecutionsListMetricValueFacetsData, ExecutionsListMetricValueFacetsError, ExecutionsListMetricValueFacetsErrors, ExecutionsListMetricValueFacetsResponse, ExecutionsListMetricValueFacetsResponses, ExecutionsListMetricValuesData, ExecutionsListMetricValuesError, ExecutionsListMetricValuesErrors, ExecutionsListMetricValuesResponse, ExecutionsListMetricValuesResponses, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsError, ExecutionsListRecentExecutionGroupsErrors, ExecutionsListRecentExecutionGroupsResponse, ExecutionsListRecentExecutionGroupsResponses, ExecutionsMetricBundleData, ExecutionsMetricBundleError, ExecutionsMetricBundleErrors, ExecutionsMetricBundleResponse, ExecutionsMetricBundleResponses, ExecutionStats, ExecutionStatsWritable, ExplorerGetCollectionData, ExplorerGetCollectionError, ExplorerGetCollectionErrors, ExplorerGetCollectionResponse, ExplorerGetCollectionResponses, ExplorerGetThemeData, ExplorerGetThemeError, ExplorerGetThemeErrors, ExplorerGetThemeResponse, ExplorerGetThemeResponses, ExplorerListCollectionsData, ExplorerListCollectionsResponse, ExplorerListCollectionsResponses, ExplorerListThemesData, ExplorerListThemesResponse, ExplorerListThemesResponses, Facet, FailedRun, GroupBy, GroupCounts, HttpValidationError, MetricDimensions, MetricValueCollection, MetricValueType, ModelDetail, ModelDetailWritable, ModelsEnsembleData, ModelsEnsembleError, ModelsEnsembleErrors, ModelsEnsembleResponse, ModelsEnsembleResponses, ModelsGetData, ModelsGetError, ModelsGetErrors, ModelsGetResponse, ModelsGetResponses, ModelsListData, ModelsListError, ModelsListErrors, ModelsListResponse, ModelsListResponses, ModelSummary, ModelSummaryWritable, ProviderSummary, RefDiagnosticLink, ReferenceDatasetLink, ResultOutputType, ResultsGetResultData, ResultsGetResultError, ResultsGetResultErrors, ResultsGetResultResponses, RunCounts, RunCountsWritable, ScalarValue, SeriesValue, ThemeDetail, ThemeSummary, UtilsAboutData, UtilsAboutResponse, UtilsAboutResponses, UtilsHealthCheckData, UtilsHealthCheckResponse, UtilsHealthCheckResponses, ValidationError } from './types.gen'; diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index f52725bd..4a95b86b 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticErrors, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponses, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponses, DatasetsExecutionsData, DatasetsExecutionsErrors, DatasetsExecutionsResponses, DatasetsGetData, DatasetsGetErrors, DatasetsGetResponses, DatasetsListData, DatasetsListErrors, DatasetsListResponses, DiagnosticsCatalogData, DiagnosticsCatalogErrors, DiagnosticsCatalogResponses, DiagnosticsFacetsData, DiagnosticsFacetsResponses, DiagnosticsGetData, DiagnosticsGetErrors, DiagnosticsGetResponses, DiagnosticsListData, DiagnosticsListErrors, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsErrors, DiagnosticsListExecutionGroupsResponses, DiagnosticsListExecutionsData, DiagnosticsListExecutionsErrors, DiagnosticsListExecutionsResponses, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesErrors, DiagnosticsListMetricValuesResponses, DiagnosticsListResponses, DiagnosticsValueFlagsData, DiagnosticsValueFlagsErrors, DiagnosticsValueFlagsResponses, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveErrors, ExecutionsExecutionArchiveResponses, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsErrors, ExecutionsExecutionDatasetsResponses, ExecutionsExecutionErrors, ExecutionsExecutionLogsData, ExecutionsExecutionLogsErrors, ExecutionsExecutionLogsResponses, ExecutionsExecutionResponses, ExecutionsGetData, ExecutionsGetErrors, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponses, ExecutionsGetResponses, ExecutionsListMetricValuesData, ExecutionsListMetricValuesErrors, ExecutionsListMetricValuesResponses, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsErrors, ExecutionsListRecentExecutionGroupsResponses, ExecutionsMetricBundleData, ExecutionsMetricBundleErrors, ExecutionsMetricBundleResponses, ExplorerGetCollectionData, ExplorerGetCollectionErrors, ExplorerGetCollectionResponses, ExplorerGetThemeData, ExplorerGetThemeErrors, ExplorerGetThemeResponses, ExplorerListCollectionsData, ExplorerListCollectionsResponses, ExplorerListThemesData, ExplorerListThemesResponses, ModelsEnsembleData, ModelsEnsembleErrors, ModelsEnsembleResponses, ModelsGetData, ModelsGetErrors, ModelsGetResponses, ModelsListData, ModelsListErrors, ModelsListResponses, ResultsGetResultData, ResultsGetResultErrors, ResultsGetResultResponses, UtilsAboutData, UtilsAboutResponses, UtilsHealthCheckData, UtilsHealthCheckResponses } from './types.gen'; +import type { Cmip7AssessmentFastTrackAftGetAftDiagnosticData, Cmip7AssessmentFastTrackAftGetAftDiagnosticErrors, Cmip7AssessmentFastTrackAftGetAftDiagnosticResponses, Cmip7AssessmentFastTrackAftListAftDiagnosticsData, Cmip7AssessmentFastTrackAftListAftDiagnosticsResponses, DatasetsExecutionsData, DatasetsExecutionsErrors, DatasetsExecutionsResponses, DatasetsGetData, DatasetsGetErrors, DatasetsGetResponses, DatasetsListData, DatasetsListErrors, DatasetsListResponses, DiagnosticsCatalogData, DiagnosticsCatalogErrors, DiagnosticsCatalogResponses, DiagnosticsGetData, DiagnosticsGetErrors, DiagnosticsGetResponses, DiagnosticsListData, DiagnosticsListErrors, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsErrors, DiagnosticsListExecutionGroupsResponses, DiagnosticsListMetricValueFacetsData, DiagnosticsListMetricValueFacetsErrors, DiagnosticsListMetricValueFacetsResponses, DiagnosticsListMetricValuesData, DiagnosticsListMetricValuesErrors, DiagnosticsListMetricValuesResponses, DiagnosticsListResponses, DiagnosticsValueFlagsData, DiagnosticsValueFlagsErrors, DiagnosticsValueFlagsResponses, ExecutionsExecutionArchiveData, ExecutionsExecutionArchiveErrors, ExecutionsExecutionArchiveResponses, ExecutionsExecutionData, ExecutionsExecutionDatasetsData, ExecutionsExecutionDatasetsErrors, ExecutionsExecutionDatasetsResponses, ExecutionsExecutionErrors, ExecutionsExecutionLogsData, ExecutionsExecutionLogsErrors, ExecutionsExecutionLogsResponses, ExecutionsExecutionResponses, ExecutionsGetData, ExecutionsGetErrors, ExecutionsGetExecutionStatisticsData, ExecutionsGetExecutionStatisticsResponses, ExecutionsGetResponses, ExecutionsListMetricValueFacetsData, ExecutionsListMetricValueFacetsErrors, ExecutionsListMetricValueFacetsResponses, ExecutionsListMetricValuesData, ExecutionsListMetricValuesErrors, ExecutionsListMetricValuesResponses, ExecutionsListRecentExecutionGroupsData, ExecutionsListRecentExecutionGroupsErrors, ExecutionsListRecentExecutionGroupsResponses, ExecutionsMetricBundleData, ExecutionsMetricBundleErrors, ExecutionsMetricBundleResponses, ExplorerGetCollectionData, ExplorerGetCollectionErrors, ExplorerGetCollectionResponses, ExplorerGetThemeData, ExplorerGetThemeErrors, ExplorerGetThemeResponses, ExplorerListCollectionsData, ExplorerListCollectionsResponses, ExplorerListThemesData, ExplorerListThemesResponses, ModelsEnsembleData, ModelsEnsembleErrors, ModelsEnsembleResponses, ModelsGetData, ModelsGetErrors, ModelsGetResponses, ModelsListData, ModelsListErrors, ModelsListResponses, ResultsGetResultData, ResultsGetResultErrors, ResultsGetResultResponses, UtilsAboutData, UtilsAboutResponses, UtilsHealthCheckData, UtilsHealthCheckResponses } from './types.gen'; export type Options = Options2 & { /** @@ -53,7 +53,9 @@ export const datasetsGet = (options: Optio /** * Executions * - * List the currently registered diagnostics + * List the execution groups whose latest execution used this dataset + * + * Groups from every diagnostic version are returned, retracted ones included. */ export const datasetsExecutions = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/datasets/{dataset_id}/executions', ...options }); @@ -62,8 +64,7 @@ export const datasetsExecutions = (options * * List the currently registered diagnostics * - * Pass `mip_era` to count only the execution groups that ran against that era, - * and `source_id` to count only those that ran against that model. + * Counts cover the promoted version of each diagnostic and leave out retracted executions. */ export const diagnosticsList = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/diagnostics/', ...options }); @@ -74,8 +75,6 @@ export const diagnosticsList = (options?: * * Checking for metric values is most of the cost of the full listing. * Fetch those from `/diagnostics/value-flags`. - * - * Pass `mip_era` and `source_id` to count only the execution groups that ran against that era or model. */ export const diagnosticsCatalog = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/diagnostics/catalog', ...options }); @@ -88,13 +87,6 @@ export const diagnosticsCatalog = (options */ export const diagnosticsValueFlags = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/diagnostics/value-flags', ...options }); -/** - * Facets - * - * Query the unique dimensions and metrics for all diagnostics (both scalar and series) - */ -export const diagnosticsFacets = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/diagnostics/facets', ...options }); - /** * Get * @@ -105,20 +97,20 @@ export const diagnosticsGet = (options: Op /** * List Execution Groups * - * Fetch execution groups for a diagnostic. + * Fetch the execution groups of a diagnostic, at its promoted version * - * Pass `mip_era` to keep only the groups that ran against that era. + * Groups whose latest execution is retracted are left out, matching the counts. */ export const diagnosticsListExecutionGroups = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/execution_groups', ...options }); /** - * List Executions + * List Metric Value Facets * - * Fetch executions for a specific diagnostic, with arbitrary filters on the dataset. + * Get the facets of the values `/values` returns for the same filters, without the values. * - * e.g. `?source_id=MIROC6&experiment_id=ssp585`. Pass `mip_era` to restrict to one era. + * They cover every page and the outliers too, so a client can keep them while it pages. */ -export const diagnosticsListExecutions = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/executions', ...options }); +export const diagnosticsListMetricValueFacets = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/values/facets', ...options }); /** * List Metric Values @@ -129,8 +121,11 @@ export const diagnosticsListExecutions = ( * - `format`: Return format - 'json' (default) or 'csv' * - `offset`: Number of items to skip (default 0) * - `limit`: Maximum number of items to return (default 50, max 500) - * - `mip_era`: Restrict to the executions of one era, so outlier detection and pagination - * see only the values the caller is charting + * - `mip_era`: Restrict to the groups of one era, so outlier detection and pagination + * see only the values the caller is charting. + * Groups that use no model data, such as observation-only ones, are kept. + * + * Values come from the promoted diagnostic version, and retracted executions are left out. */ export const diagnosticsListMetricValues = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/values', ...options }); @@ -140,16 +135,18 @@ export const diagnosticsListMetricValues = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/executions/statistics', ...options }); /** * List Recent Execution Groups * - * List the most recent execution groups + * List the most recently updated execution groups * - * Only groups at the promoted version of their diagnostic are returned. + * Only groups at the promoted version of their diagnostic are returned, + * and groups whose latest execution is retracted are left out. * * Supports filtering by: * - diagnostic_name_contains (case-insensitive substring of the diagnostic slug) @@ -157,9 +154,8 @@ export const executionsGetExecutionStatistics = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/executions/{group_id}', ...options }); @@ -205,6 +201,15 @@ export const executionsExecutionLogs = (op */ export const executionsMetricBundle = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/executions/{group_id}/metric_bundle', ...options }); +/** + * List Metric Value Facets + * + * Get the facets of the values `/values` returns for the same filters, without the values. + * + * They cover every page and the outliers too, so a client can keep them while it pages. + */ +export const executionsListMetricValueFacets = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/executions/{group_id}/values/facets', ...options }); + /** * List Metric Values * @@ -214,6 +219,8 @@ export const executionsMetricBundle = (opt * - `format`: Return format - 'json' (default) or 'csv' * - `offset`: Number of items to skip (default 0) * - `limit`: Maximum number of items to return (default 50, max 500) + * + * The values of the named execution are returned, whatever its version or retraction. */ export const executionsListMetricValues = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/executions/{group_id}/values', ...options }); diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 7ad35540..f01e53c3 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -581,26 +581,6 @@ export type CollectionExecutionGroup = { readonly count: number; }; -/** - * Collection[Execution] - */ -export type CollectionExecution = { - /** - * Data - */ - data: Array; - /** - * Total Count - */ - total_count?: number | null; - /** - * Count - * - * Number of data items present - */ - readonly count: number; -}; - /** * Collection[ModelSummary] */ @@ -641,7 +621,7 @@ export type Dataset = { /** * Mip Era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; /** * More Info Url */ @@ -681,22 +661,7 @@ export type DiagnosticCatalogEntry = { * Execution Groups */ execution_groups: Array; - /** - * Execution Count - */ - execution_count: number; - /** - * Successful Execution Count - */ - successful_execution_count: number; - /** - * Execution Group Count - */ - execution_group_count: number; - /** - * Successful Execution Group Count - */ - successful_execution_group_count: number; + groups: GroupCounts; /** * Promoted Version */ @@ -776,22 +741,7 @@ export type DiagnosticSummary = { * Execution Groups */ execution_groups: Array; - /** - * Execution Count - */ - execution_count: number; - /** - * Successful Execution Count - */ - successful_execution_count: number; - /** - * Execution Group Count - */ - execution_group_count: number; - /** - * Successful Execution Group Count - */ - successful_execution_group_count: number; + groups: GroupCounts; /** * Promoted Version */ @@ -1001,6 +951,13 @@ export type Execution = { peak_memory_bytes: number | null; }; +/** + * ExecutionFailureReason + * + * Why an execution failed + */ +export type ExecutionFailureReason = 'diagnostic_error' | 'system_error' | 'result_error' | 'abandoned' | 'not_started' | 'cancelled' | 'unknown'; + /** * ExecutionGroup */ @@ -1140,31 +1097,12 @@ export type ExecutionResourceSummary = { * Statistics for execution groups and their success rates. */ export type ExecutionStats = { + groups: GroupCounts; /** - * Total Execution Groups - */ - total_execution_groups: number; - /** - * Successful Execution Groups + * Groups By Mip Era */ - successful_execution_groups: number; - /** - * Failed Execution Groups - */ - failed_execution_groups: number; - /** - * Running Execution Groups - */ - running_execution_groups: number; - /** - * Not Started Execution Groups - */ - not_started_execution_groups: number; - /** - * Execution Groups By Mip Era - */ - execution_groups_by_mip_era: { - [key: string]: number; + groups_by_mip_era: { + [key: string]: GroupCounts; }; /** * Scalar Value Count @@ -1186,7 +1124,7 @@ export type ExecutionStats = { /** * Success Rate Percentage * - * Success rate as a percentage (0-100). + * Share of the groups whose latest execution succeeded, as a percentage (0-100). */ readonly success_rate_percentage: number; }; @@ -1259,6 +1197,42 @@ export type GroupBy = { group_by: Array | null; }; +/** + * GroupCounts + * + * How many execution groups are running, failed, succeeded or not started. + * + * Each group is counted once, by the outcome of its latest execution, + * so `successful + failed + running + not_started == total`. + * `dirty` overlaps the others. + */ +export type GroupCounts = { + /** + * Total + */ + total: number; + /** + * Successful + */ + successful: number; + /** + * Failed + */ + failed: number; + /** + * Running + */ + running: number; + /** + * Not Started + */ + not_started: number; + /** + * Dirty + */ + dirty: number; +}; + /** * HTTPValidationError */ @@ -1315,24 +1289,6 @@ export type MetricValueCollection = { outlier_count?: number | null; }; -/** - * MetricValueFacetSummary - * - * Summary of the dimensions used in a metric value collection. - */ -export type MetricValueFacetSummary = { - /** - * Dimensions - */ - dimensions: { - [key: string]: Array; - }; - /** - * Count - */ - count: number; -}; - /** * MetricValueType * @@ -1797,20 +1753,6 @@ export type CollectionExecutionGroupWritable = { total_count?: number | null; }; -/** - * Collection[Execution] - */ -export type CollectionExecutionWritable = { - /** - * Data - */ - data: Array; - /** - * Total Count - */ - total_count?: number | null; -}; - /** * Collection[ModelSummary] */ @@ -1845,7 +1787,7 @@ export type DatasetWritable = { /** * Mip Era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; }; /** @@ -1934,31 +1876,12 @@ export type EnsembleComparisonWritable = { * Statistics for execution groups and their success rates. */ export type ExecutionStatsWritable = { + groups: GroupCounts; /** - * Total Execution Groups - */ - total_execution_groups: number; - /** - * Successful Execution Groups + * Groups By Mip Era */ - successful_execution_groups: number; - /** - * Failed Execution Groups - */ - failed_execution_groups: number; - /** - * Running Execution Groups - */ - running_execution_groups: number; - /** - * Not Started Execution Groups - */ - not_started_execution_groups: number; - /** - * Execution Groups By Mip Era - */ - execution_groups_by_mip_era: { - [key: string]: number; + groups_by_mip_era: { + [key: string]: GroupCounts; }; /** * Scalar Value Count @@ -2246,10 +2169,14 @@ export type DiagnosticsListData = { query?: { /** * Mip Era + * + * Count only the execution groups that draw on this era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; /** * Source Id + * + * Count only the execution groups that ran this model */ source_id?: string | null; }; @@ -2280,10 +2207,14 @@ export type DiagnosticsCatalogData = { query?: { /** * Mip Era + * + * Count only the execution groups that draw on this era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; /** * Source Id + * + * Count only the execution groups that ran this model */ source_id?: string | null; }; @@ -2314,10 +2245,14 @@ export type DiagnosticsValueFlagsData = { query?: { /** * Mip Era + * + * Check only the execution groups that draw on this era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; /** * Source Id + * + * Check only the execution groups that ran this model */ source_id?: string | null; }; @@ -2342,22 +2277,6 @@ export type DiagnosticsValueFlagsResponses = { export type DiagnosticsValueFlagsResponse = DiagnosticsValueFlagsResponses[keyof DiagnosticsValueFlagsResponses]; -export type DiagnosticsFacetsData = { - body?: never; - path?: never; - query?: never; - url: '/api/v1/diagnostics/facets'; -}; - -export type DiagnosticsFacetsResponses = { - /** - * Successful Response - */ - 200: MetricValueFacetSummary; -}; - -export type DiagnosticsFacetsResponse = DiagnosticsFacetsResponses[keyof DiagnosticsFacetsResponses]; - export type DiagnosticsGetData = { body?: never; path: { @@ -2407,8 +2326,10 @@ export type DiagnosticsListExecutionGroupsData = { query?: { /** * Mip Era + * + * Keep only the execution groups that draw on this era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; }; url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/execution_groups'; }; @@ -2431,7 +2352,7 @@ export type DiagnosticsListExecutionGroupsResponses = { export type DiagnosticsListExecutionGroupsResponse = DiagnosticsListExecutionGroupsResponses[keyof DiagnosticsListExecutionGroupsResponses]; -export type DiagnosticsListExecutionsData = { +export type DiagnosticsListMetricValueFacetsData = { body?: never; path: { /** @@ -2443,27 +2364,52 @@ export type DiagnosticsListExecutionsData = { */ diagnostic_slug: string; }; - query?: never; - url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/executions'; + query: { + /** + * Type of metric values to read the facets of + */ + value_type: MetricValueType; + /** + * Isolate Ids + * + * Comma-separated list of metric value IDs to isolate + */ + isolate_ids?: string | null; + /** + * Exclude Ids + * + * Comma-separated list of metric value IDs to exclude + */ + exclude_ids?: string | null; + /** + * Mip Era + * + * Keep only the execution groups that draw on this era, or on no model data + */ + mip_era?: 'CMIP6' | 'CMIP7' | null; + }; + url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/values/facets'; }; -export type DiagnosticsListExecutionsErrors = { +export type DiagnosticsListMetricValueFacetsErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type DiagnosticsListExecutionsError = DiagnosticsListExecutionsErrors[keyof DiagnosticsListExecutionsErrors]; +export type DiagnosticsListMetricValueFacetsError = DiagnosticsListMetricValueFacetsErrors[keyof DiagnosticsListMetricValueFacetsErrors]; -export type DiagnosticsListExecutionsResponses = { +export type DiagnosticsListMetricValueFacetsResponses = { /** + * Response Diagnostics-List Metric Value Facets + * * Successful Response */ - 200: CollectionExecution; + 200: Array; }; -export type DiagnosticsListExecutionsResponse = DiagnosticsListExecutionsResponses[keyof DiagnosticsListExecutionsResponses]; +export type DiagnosticsListMetricValueFacetsResponse = DiagnosticsListMetricValueFacetsResponses[keyof DiagnosticsListMetricValueFacetsResponses]; export type DiagnosticsListMetricValuesData = { body?: never; @@ -2525,9 +2471,15 @@ export type DiagnosticsListMetricValuesData = { /** * Mip Era * - * Restrict to one MIP era, CMIP6 or CMIP7 + * Keep only the execution groups that draw on this era, or on no model data */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; + /** + * With Facets + * + * Fill `facets`, which `/values/facets` also returns + */ + with_facets?: boolean; }; url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/values'; }; @@ -2597,15 +2549,19 @@ export type ExecutionsListRecentExecutionGroupsData = { /** * Failure Reason */ - failure_reason?: string | null; + failure_reason?: ExecutionFailureReason | null; /** * Source Id + * + * Keep only the groups whose latest execution ran this model */ source_id?: string | null; /** * Mip Era + * + * Keep only the groups that draw on this era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; /** * Include Outputs * @@ -2808,6 +2764,59 @@ export type ExecutionsMetricBundleResponses = { export type ExecutionsMetricBundleResponse = ExecutionsMetricBundleResponses[keyof ExecutionsMetricBundleResponses]; +export type ExecutionsListMetricValueFacetsData = { + body?: never; + path: { + /** + * Group Id + */ + group_id: string; + }; + query: { + /** + * Execution Id + */ + execution_id?: string | null; + /** + * Type of metric values to read the facets of + */ + value_type: MetricValueType; + /** + * Isolate Ids + * + * Comma-separated list of metric value IDs to isolate + */ + isolate_ids?: string | null; + /** + * Exclude Ids + * + * Comma-separated list of metric value IDs to exclude + */ + exclude_ids?: string | null; + }; + url: '/api/v1/executions/{group_id}/values/facets'; +}; + +export type ExecutionsListMetricValueFacetsErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ExecutionsListMetricValueFacetsError = ExecutionsListMetricValueFacetsErrors[keyof ExecutionsListMetricValueFacetsErrors]; + +export type ExecutionsListMetricValueFacetsResponses = { + /** + * Response Executions-List Metric Value Facets + * + * Successful Response + */ + 200: Array; +}; + +export type ExecutionsListMetricValueFacetsResponse = ExecutionsListMetricValueFacetsResponses[keyof ExecutionsListMetricValueFacetsResponses]; + export type ExecutionsListMetricValuesData = { body?: never; path: { @@ -2865,6 +2874,12 @@ export type ExecutionsListMetricValuesData = { * Comma-separated list of metric value IDs to exclude */ exclude_ids?: string | null; + /** + * With Facets + * + * Fill `facets`, which `/values/facets` also returns + */ + with_facets?: boolean; }; url: '/api/v1/executions/{group_id}/values'; }; @@ -3023,9 +3038,9 @@ export type ModelsListData = { /** * Mip Era * - * Restrict to one MIP era, CMIP6 or CMIP7 + * Restrict to one MIP era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; }; url: '/api/v1/models/'; }; @@ -3060,9 +3075,9 @@ export type ModelsGetData = { /** * Mip Era * - * Restrict to one MIP era, CMIP6 or CMIP7 + * Restrict to one MIP era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; }; url: '/api/v1/models/{source_id}'; }; @@ -3097,9 +3112,9 @@ export type ModelsEnsembleData = { /** * Mip Era * - * Restrict to one MIP era, CMIP6 or CMIP7 + * Restrict to one MIP era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; /** * Diagnostic Slug * diff --git a/frontend/src/components/dashboard/executionStats.test.tsx b/frontend/src/components/dashboard/executionStats.test.tsx index bba76b10..447b1bb7 100644 --- a/frontend/src/components/dashboard/executionStats.test.tsx +++ b/frontend/src/components/dashboard/executionStats.test.tsx @@ -11,14 +11,28 @@ vi.mock("@tanstack/react-query", async () => { const mockedUseQuery = vi.mocked(useQuery); +const groupCounts = ( + total: number, + successful: number, + failed: number, + running: number, + not_started: number, +) => ({ + total, + successful, + failed, + running, + not_started, + dirty: 0, +}); + const STATS = { - total_execution_groups: 1200, - successful_execution_groups: 1000, - failed_execution_groups: 150, - running_execution_groups: 30, - not_started_execution_groups: 20, + groups: groupCounts(1200, 1000, 150, 30, 20), success_rate_percentage: 83.3, - execution_groups_by_mip_era: { CMIP6: 900, CMIP7: 300 }, + groups_by_mip_era: { + CMIP6: groupCounts(900, 800, 70, 20, 10), + CMIP7: groupCounts(300, 200, 80, 10, 10), + }, scalar_value_count: 5000, series_value_count: 250, total_datasets: 42, diff --git a/frontend/src/components/dashboard/executionStats.tsx b/frontend/src/components/dashboard/executionStats.tsx index 287e7b6c..6c570bc4 100644 --- a/frontend/src/components/dashboard/executionStats.tsx +++ b/frontend/src/components/dashboard/executionStats.tsx @@ -25,16 +25,16 @@ export function ExecutionStats() { ); } - const perEra = data?.execution_groups_by_mip_era; + const perEra = data?.groups_by_mip_era; return ( <> @@ -44,7 +44,7 @@ export function ExecutionStats() { value={`${data?.success_rate_percentage}%`} hint={ data - ? `${formatCount(data.successful_execution_groups)} successful, ${formatCount(data.failed_execution_groups)} failed` + ? `${formatCount(data.groups.successful)} successful, ${formatCount(data.groups.failed)} failed` : "..." } /> diff --git a/frontend/src/components/datasets/diagnosticSummaryTable.tsx b/frontend/src/components/datasets/diagnosticSummaryTable.tsx index 6ed0057b..d7e9fd07 100644 --- a/frontend/src/components/datasets/diagnosticSummaryTable.tsx +++ b/frontend/src/components/datasets/diagnosticSummaryTable.tsx @@ -61,14 +61,14 @@ const diagnosticColumns = ( ), }, { - accessorKey: "successful_execution_group_count", + accessorKey: "groups.successful", header: ({ column }) => ( ), enableSorting: true, cell: (cell) => { const successful = cell.getValue(); - const total = cell.row.original.execution_group_count ?? 0; + const total = cell.row.original.groups.total; const allSuccessful = total > 0 && successful === total; const className = allSuccessful ? "text-emerald-600 dark:text-emerald-400 font-medium" diff --git a/frontend/src/components/diagnostics/diagnosticCard.tsx b/frontend/src/components/diagnostics/diagnosticCard.tsx index 8a6c6728..d937e741 100644 --- a/frontend/src/components/diagnostics/diagnosticCard.tsx +++ b/frontend/src/components/diagnostics/diagnosticCard.tsx @@ -82,8 +82,7 @@ export function DiagnosticCard({ /> - {diagnostic.successful_execution_group_count}/ - {diagnostic.execution_group_count} + {diagnostic.groups.successful}/{diagnostic.groups.total} {note && ( diff --git a/frontend/src/components/execution/values/valuesPanel.test.tsx b/frontend/src/components/execution/values/valuesPanel.test.tsx index d8f518b9..7c305521 100644 --- a/frontend/src/components/execution/values/valuesPanel.test.tsx +++ b/frontend/src/components/execution/values/valuesPanel.test.tsx @@ -10,17 +10,20 @@ const valuesQuery = vi.fn(); vi.mock("@tanstack/react-query", async () => ({ ...(await vi.importActual("@tanstack/react-query")), useSuspenseQuery: () => ({ data: { executions: groupExecutions } }), - useQuery: () => ({ - data: { - data: [], - count: 0, - total_count: 0, - facets: [{ key: "source_id", values: ["A", "B"] }], - types: ["scalar"], - }, - isLoading: false, - ...valuesQuery(), - }), + useQuery: ({ queryKey }: { queryKey: [{ _id: string }] }) => + queryKey[0]._id.endsWith("Facets") + ? { data: [{ key: "source_id", values: ["A", "B"] }] } + : { + data: { + data: [], + count: 0, + total_count: 0, + facets: [], + types: ["scalar"], + }, + isLoading: false, + ...valuesQuery(), + }, })); describe("ExecutionValuesPanel", () => { diff --git a/frontend/src/components/execution/values/valuesPanel.tsx b/frontend/src/components/execution/values/valuesPanel.tsx index e9d6233b..c6273360 100644 --- a/frontend/src/components/execution/values/valuesPanel.tsx +++ b/frontend/src/components/execution/values/valuesPanel.tsx @@ -23,7 +23,14 @@ interface ValuesPanelProps { function MetricValuesTable({ valueType, search, - result: { metricValues, isLoading, initialFilters, pagination, handlers }, + result: { + metricValues, + facets, + isLoading, + initialFilters, + pagination, + handlers, + }, }: { valueType: ValueType; search: Pick; @@ -31,7 +38,7 @@ function MetricValuesTable({ }) { return ( { }); }); - it("leaves out the value type, paging and unset filters", () => { + it("leaves out the value type, paging, facets and unset filters", () => { const search = chartValuesSearch({ value_type: "series", limit: 500, offset: 0, + with_facets: false, mip_era: undefined, isolate_ids: null, }); diff --git a/frontend/src/components/explorer/chartValues.ts b/frontend/src/components/explorer/chartValues.ts index 4150c089..eede6c98 100644 --- a/frontend/src/components/explorer/chartValues.ts +++ b/frontend/src/components/explorer/chartValues.ts @@ -5,7 +5,7 @@ import type { MetricValuesQuery } from "./content/useExplorerMetricValues"; /** * The search of the values page listing the values a chart card shows * - * The page pages through the values itself, so the chart's limit is left out. + * The page pages through the values and reads the facets itself, so the chart's limit is left out. * Outliers are detected and unverified values hidden unless the chart says otherwise. */ export function chartValuesSearch( @@ -16,6 +16,7 @@ export function chartValuesSearch( limit: _limit, offset: _offset, format: _format, + with_facets: _withFacets, detect_outliers, include_unverified, ...filters diff --git a/frontend/src/components/explorer/content/filterControls.tsx b/frontend/src/components/explorer/content/filterControls.tsx index b8b572f5..73351565 100644 --- a/frontend/src/components/explorer/content/filterControls.tsx +++ b/frontend/src/components/explorer/content/filterControls.tsx @@ -1,8 +1,7 @@ import { useSuspenseQueries } from "@tanstack/react-query"; import { useMemo, useState } from "react"; -import { diagnosticsListMetricValuesOptions } from "@/client/@tanstack/react-query.gen"; +import { diagnosticsListMetricValueFacetsOptions } from "@/client/@tanstack/react-query.gen"; import { useSelectedMipEra } from "@/components/charts/mipEraContext"; -import type { MetricValueCollection } from "@/components/execution/values/types"; import { Select, SelectContent, @@ -89,12 +88,11 @@ export function useFilterControls( } } - const facetQuery = diagnosticsListMetricValuesOptions({ + const facetQuery = diagnosticsListMetricValueFacetsOptions({ path: { provider_slug: provider, diagnostic_slug: diagnostic }, query: { ...facetQueryFilters, value_type: valueType, - limit: 1, mip_era: selectedMipEra ?? undefined, }, }); @@ -106,8 +104,7 @@ export function useFilterControls( const facetMap = useMemo(() => { const map = new Map(); - const facets = (facetData as MetricValueCollection | undefined)?.facets; - for (const facet of facets ?? []) { + for (const facet of facetData ?? []) { map.set(facet.key, facet.values); } return map; diff --git a/frontend/src/components/explorer/content/useExplorerMetricValues.ts b/frontend/src/components/explorer/content/useExplorerMetricValues.ts index 406e523e..262bf964 100644 --- a/frontend/src/components/explorer/content/useExplorerMetricValues.ts +++ b/frontend/src/components/explorer/content/useExplorerMetricValues.ts @@ -48,7 +48,8 @@ export function useExplorerMetricValues( provider_slug: contentItem.provider, diagnostic_slug: contentItem.diagnostic, }, - query, + // The filter controls read the facets on their own + query: { ...query, with_facets: false }, }), ); diff --git a/frontend/src/hooks/useDiagnosticMetricValues.ts b/frontend/src/hooks/useDiagnosticMetricValues.ts index 7e2ee213..cc1763f9 100644 --- a/frontend/src/hooks/useDiagnosticMetricValues.ts +++ b/frontend/src/hooks/useDiagnosticMetricValues.ts @@ -1,6 +1,9 @@ import type { useNavigate } from "@tanstack/react-router"; import { diagnosticsListMetricValues } from "@/client"; -import { diagnosticsListMetricValuesOptions } from "@/client/@tanstack/react-query.gen.ts"; +import { + diagnosticsListMetricValueFacetsOptions, + diagnosticsListMetricValuesOptions, +} from "@/client/@tanstack/react-query.gen.ts"; import { useMetricValues } from "./useMetricValues"; @@ -28,6 +31,7 @@ export function useDiagnosticMetricValues({ valueType, navigate, fetchQueryOptions: diagnosticsListMetricValuesOptions, + fetchFacetsOptions: diagnosticsListMetricValueFacetsOptions, fetchDownload: diagnosticsListMetricValues, getDownloadFilename: (params, type) => `metric-values-${params.provider_slug}-${params.diagnostic_slug}-${type}.csv`, diff --git a/frontend/src/hooks/useExecutionMetricValues.ts b/frontend/src/hooks/useExecutionMetricValues.ts index 7894df79..2b1b54cd 100644 --- a/frontend/src/hooks/useExecutionMetricValues.ts +++ b/frontend/src/hooks/useExecutionMetricValues.ts @@ -1,6 +1,9 @@ import type { useNavigate } from "@tanstack/react-router"; import { executionsListMetricValues } from "@/client"; -import { executionsListMetricValuesOptions } from "@/client/@tanstack/react-query.gen.ts"; +import { + executionsListMetricValueFacetsOptions, + executionsListMetricValuesOptions, +} from "@/client/@tanstack/react-query.gen.ts"; import { useMetricValues } from "./useMetricValues"; interface UseExecutionMetricValuesOptions { @@ -27,6 +30,7 @@ export function useExecutionMetricValues({ valueType, navigate, fetchQueryOptions: executionsListMetricValuesOptions, + fetchFacetsOptions: executionsListMetricValueFacetsOptions, fetchDownload: executionsListMetricValues, getDownloadFilename: (params, type) => `metric-values-${params.group_id}-${type}.csv`, diff --git a/frontend/src/hooks/useMetricValues.test.ts b/frontend/src/hooks/useMetricValues.test.ts index 61621664..c0126146 100644 --- a/frontend/src/hooks/useMetricValues.test.ts +++ b/frontend/src/hooks/useMetricValues.test.ts @@ -44,6 +44,10 @@ function createDefaultOptions(overrides: Record = {}) { queryKey: ["test"], queryFn: () => Promise.resolve(null), }), + fetchFacetsOptions: vi.fn().mockReturnValue({ + queryKey: ["facets"], + queryFn: () => Promise.resolve(null), + }), fetchDownload: vi.fn().mockResolvedValue({ data: "csv-data" }), getDownloadFilename: vi.fn().mockReturnValue("test.csv"), }; @@ -351,6 +355,63 @@ describe("useMetricValues", () => { }); }); + describe("facets", () => { + it("reads the values without facets, and the facets without paging or outliers", () => { + const options = createDefaultOptions({ + offset: "50", + limit: "25", + source_id: "A", + }); + renderHook(() => useMetricValues(options), { wrapper: createWrapper() }); + + expect(options.fetchQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ with_facets: false, offset: "50" }), + }), + ); + expect(options.fetchFacetsOptions).toHaveBeenCalledWith({ + path: options.pathParams, + query: { source_id: "A", value_type: "scalar" }, + }); + }); + + it("reads the facets with the page when the facets query fails", () => { + mockedUseQuery.mockImplementation( + (options: any) => + (options.queryKey[0] === "facets" + ? { data: undefined, isLoading: false, isError: true } + : { data: MOCK_METRIC_VALUES, isLoading: false }) as any, + ); + const options = createDefaultOptions(); + const { result } = renderHook(() => useMetricValues(options), { + wrapper: createWrapper(), + }); + + expect(options.fetchQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ with_facets: true }), + }), + ); + expect(result.current.facets).toEqual(MOCK_METRIC_VALUES.facets); + }); + + it("is loading until the facets are in", () => { + mockedUseQuery.mockImplementation( + (options: any) => + (options.queryKey[0] === "facets" + ? { data: undefined, isLoading: true } + : { data: MOCK_METRIC_VALUES, isLoading: false }) as any, + ); + const options = createDefaultOptions(); + const { result } = renderHook(() => useMetricValues(options), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.facets).toEqual([]); + }); + }); + describe("CSV download", () => { it("calls fetchDownload with format=csv and all search params", async () => { const options = createDefaultOptions({ model: "cesm2", offset: 50 }); diff --git a/frontend/src/hooks/useMetricValues.ts b/frontend/src/hooks/useMetricValues.ts index d559dac4..7f65c055 100644 --- a/frontend/src/hooks/useMetricValues.ts +++ b/frontend/src/hooks/useMetricValues.ts @@ -1,7 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { useNavigate } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { diagnosticsListMetricValues } from "@/client"; +import type { Facet } from "@/client/types.gen"; import type { MetricValueCollection } from "@/components/execution/values/types.ts"; import type { MetricFilter } from "@/components/explorer/types"; import { @@ -24,6 +25,7 @@ interface UseValuesTabOptions< navigate: ReturnType; fetchQueryOptions: FetchQueryOptions; + fetchFacetsOptions: FetchQueryOptions; fetchDownload: FetchDownloadFunction; getDownloadFilename: (pathParams: TPath, valueType: string) => string; @@ -33,6 +35,14 @@ interface UseValuesTabOptions< // (offset and limit ARE passed to the API, so they are not excluded here) const EXCLUDED_FROM_API = ["tab", "groupBy", "hue", "style"]; +// Facets cover every page and the outliers, so these do not change them +const EXCLUDED_FROM_FACETS = [ + "offset", + "limit", + "detect_outliers", + "include_unverified", +]; + /** The search params passed on to the API, without the view params and unset params. */ function apiQuery(search: Record): Record { return Object.fromEntries( @@ -52,16 +62,39 @@ export function useMetricValues>({ valueType, navigate, fetchQueryOptions, + fetchFacetsOptions, fetchDownload = diagnosticsListMetricValues, getDownloadFilename, }: UseValuesTabOptions) { const query = apiQuery(search); - // Fetch metric values using the provided query options function + // Fetched apart from the values, so paging does not read them again + const facetsQuery = Object.fromEntries( + Object.entries(query).filter( + ([key]) => !EXCLUDED_FROM_FACETS.includes(key), + ), + ); + const { + data: facets, + isLoading: facetsLoading, + isError: facetsFailed, + } = useQuery({ + ...fetchFacetsOptions({ + path: pathParams, + query: { ...facetsQuery, value_type: valueType }, + }), + placeholderData: keepPreviousData, + }); + + // The page carries its own facets only when the facets query has failed const { data: metricValues, isLoading } = useQuery( fetchQueryOptions({ path: pathParams, - query: { ...query, value_type: valueType }, + query: { + ...query, + value_type: valueType, + with_facets: facetsFailed === true, + }, }), ); @@ -151,7 +184,11 @@ export function useMetricValues>({ return { metricValues: metricValues as MetricValueCollection | undefined, - isLoading, + facets: + (facetsFailed + ? (metricValues as MetricValueCollection | undefined)?.facets + : (facets as Facet[] | undefined)) ?? [], + isLoading: isLoading || facetsLoading, initialFilters, pagination: { offset: Number(search.offset) || 0, diff --git a/frontend/src/lib/failures.test.ts b/frontend/src/lib/failures.test.ts index 3e167e21..76d3effe 100644 --- a/frontend/src/lib/failures.test.ts +++ b/frontend/src/lib/failures.test.ts @@ -29,8 +29,7 @@ const diagnostic = ( provider: { slug: provider, name: provider }, slug, name: slug, - execution_group_count: total, - successful_execution_group_count: successful, + groups: { total, successful }, }) as DiagnosticCatalogEntry; let nextId = 1; diff --git a/frontend/src/lib/failures.ts b/frontend/src/lib/failures.ts index 55659896..448e115c 100644 --- a/frontend/src/lib/failures.ts +++ b/frontend/src/lib/failures.ts @@ -76,11 +76,11 @@ export function summariseFailures( const failedGroups = groups.filter( (group) => group.executions.at(-1)?.successful === false, ); - const successful = diagnostic.successful_execution_group_count; + const successful = diagnostic.groups.successful; return { diagnostic, health: diagnosticHealth(successful, failedGroups.length), - total: diagnostic.execution_group_count, + total: diagnostic.groups.total, successful, running: groups.filter( (group) => group.executions.at(-1)?.successful === null, diff --git a/frontend/src/lib/resourceUsageCsv.test.ts b/frontend/src/lib/resourceUsageCsv.test.ts index a8d58735..23a11181 100644 --- a/frontend/src/lib/resourceUsageCsv.test.ts +++ b/frontend/src/lib/resourceUsageCsv.test.ts @@ -9,10 +9,14 @@ const base = { has_metric_values: false, has_scalar_values: false, has_series_values: false, - execution_count: 1, - successful_execution_count: 1, - execution_group_count: 1, - successful_execution_group_count: 1, + groups: { + total: 1, + successful: 1, + failed: 0, + running: 0, + not_started: 0, + dirty: 0, + }, promoted_version: 1, group_by: [], aft_link: null, diff --git a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/route.tsx b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/route.tsx index 7f617af8..d919f51b 100644 --- a/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/route.tsx +++ b/frontend/src/routes/_app/diagnostics.$providerSlug.$diagnosticSlug/route.tsx @@ -231,8 +231,7 @@ const DiagnosticInfoLayout = () => { help='A collection of diagnostic runs that share the same combination of dataset grouping values. For example, if grouped by model and experiment, all runs for "CESM2" and "historical" form one execution group. The counter shows how many groups completed successfully versus the total number of groups.' >

- {data.successful_execution_group_count}/ - {data.execution_group_count} + {data.groups.successful}/{data.groups.total}

{ + const { data } = useQuery({ + ...executionsListMetricValuesOptions({ + path: { group_id: groupId }, + query: { + value_type: valueType, + limit: 1, + detect_outliers: "off", + with_facets: false, + }, + }), + enabled, + }); + return (data?.total_count ?? 0) > 0; +}; + const ExecutionInfoLayout = () => { const { groupId } = Route.useParams(); const location = useLocation(); @@ -71,6 +97,9 @@ const ExecutionInfoLayout = () => { }), ); + const hasScalarValues = useHasValues(groupId, "scalar", !!execution); + const hasSeriesValues = useHasValues(groupId, "series", !!execution); + // Determine the active tab based on the current URL pathname const activeTab = location.pathname.split("/").pop() || "datasets"; @@ -182,12 +211,12 @@ const ExecutionInfoLayout = () => {