From 53b486ccaff141cf8a8e523d8f2da445583c11b6 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Sat, 24 Oct 2026 11:17:01 +1100 Subject: [PATCH 01/28] chore(deps): build against climate-ref 0.20.0 Migrates the fixture database for the new migrations. Drops the deprecated MetricValueFilter(promoted_only=True), which is now the default. Handles the reader's total_count now being optional. --- backend/pyproject.toml | 2 +- .../src/ref_backend/api/routes/diagnostics.py | 3 +- .../src/ref_backend/api/routes/executions.py | 3 +- backend/src/ref_backend/models/values.py | 10 ++++-- .../test_solve_cmip7_aft/db/climate_ref.db | Bin 1060864 -> 1085440 bytes backend/uv.lock | 32 +++++++++--------- 6 files changed, 27 insertions(+), 23 deletions(-) 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/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index f516fb1f..2bb45f8b 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -394,7 +394,7 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 # 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] - # Scope to this diagnostic/provider via exact-match slugs. ``promoted_only`` keeps only the + # Scope to this diagnostic/provider via exact-match slugs. The filter 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( @@ -404,7 +404,6 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 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, ) diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 9ba4132e..1554b707 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -328,7 +328,7 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 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 + # latest execution when no ``execution_id`` is supplied. The filter 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( @@ -336,7 +336,6 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 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, ) diff --git a/backend/src/ref_backend/models/values.py b/backend/src/ref_backend/models/values.py index c4455400..48b3bfbc 100644 --- a/backend/src/ref_backend/models/values.py +++ b/backend/src/ref_backend/models/values.py @@ -91,6 +91,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 @@ -132,7 +138,7 @@ def build_scalar_from_reader( return MetricValueCollection( data=all_data, count=len(all_data), - total_count=collection.total_count, + total_count=_counted(collection.total_count), facets=facets, types=["scalar"], had_outliers=collection.had_outliers if detection_ran else None, @@ -172,7 +178,7 @@ def build_series_from_reader( return MetricValueCollection( data=all_data, count=len(all_data), - total_count=collection.total_count, + total_count=_counted(collection.total_count), facets=facets, types=["series"], had_outliers=None, 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 ab4c0960e379afbb2b20c0adc49d6c6b4be5e492..c758d1dd11861f85eee2c64e75744706b30a6759 100644 GIT binary patch delta 15233 zcmeI(cYIV;+6VBtGn2XfoFqds2_Y~*0tqBAsX%Bcv`|ArZy`Wb5J4#lwksBNS=U0) zC@KQF*jN!^Er`fs$A*npu_EqbTP!cKV!?gpJaZ@K8U4I}y#5QH&+vJ^`JE}5^4xRI zom}>D*q}H!W+P*9iZuXr8-#+5E+e6uq_TOJDosGnXu0er{vifYt@4l+Ke2!fih( z2+yCFe?@td8mg)BUUlBGra23n<2TcY*I&AL`I5QKjmuV^`+r4BOT@?s{^lCtyp|U? z<=@<(sI8aGQY>Fyd1=deJhSD4yR5;#z5Q@qeeIa4`l;bjL#KxG5+BI?c?DtV!L+Q} z6>PcQFj9VVr>&h&DZ%8%gzLBTvMXDT>}sb@niB}kukp6*<)N0w$CC$0x7k>faHU2m zR5W;PqZiLAHd_si%k{1*X1m)1J#gklB7GjvY#vL*B9HlE+KaCx)hZ*4m4%NA+t z_V1NYuTS;&Uz(s|N1{WPJ23$Gp0 zIeV>#t-Gz8t!u0ct%cSstIn#nO08~IhGm#Po5#$L%{RM8Xb^%M0i^*Qx1^?vns^*Z%3b%{DpourOXE7StDqng}$OBerjjxX#> z_4&Ogyq|dwd0+PK@jm3;>|N(w?Oowr;GN+e>#g(_dvm<$Ud{F^nO?i0rfPImy|S2S z5z#qB3yIDqT3|c!jm{_GSwv?Nok7%0G>>R5(Hx>C+wVxf(`*tp63rr-Ni>6KI?*(u zsYFviPTNyDnS_&wCK62`Y9Oj78c$S5G%hM9{)&zz;TWRPM5BmC64eroAR10IOvrKH zP(w*Lgs6t7ny8AXl4vl|AfkaNC-#0GK*Ih+6-51r`V#daDkmx86%h4w<%myn50^suM0rHriMkQx66FweCF&BF6Ky7)7W zK&FyJ zz9u?G^lzfCh`uDpKhGB={G8}BqN7CrBKjxMr$k4HJ|V|H&tVdNO!N`aheRI`y-)NW z(IKLDL2buB&pRaiAEJXqZxg*m^d`|8M6VMah$?>k^SnmFSBYLBdYR}YqWwfK61_n5 zypVhR^E^kwXNjI6dYWh-(Ladx5J^cc~jM2`^tJtjQ< zd3KU;2hqbs+ld|`+D7ys(cg%+x)R4f&jT)_$3M^gME4QhOLPy>7NWlr-A!~?T+!p7 zXLFoUZ6ex8bSKdUqC1FgC%TPjeVgF;=ef0wpj(JqiEbvkiD(^B3(;B&K5B~IFtomQ zXhXJuhH!?&B<4ZGWDFHIp~fa?c!RFilI%vJ8;Gtax{l~C*#83fP?T#$=<3fzYl!|t zbPdtfM5~FeBDxX;{m;F_D@b@b(JG?Li0}~R-pZvUyM*XsqKl$(;&*=`30D%WF#UR_ z(g17kB=K{1(!SV$_4u#ebPqqyKjVk~AFFcbk1fmp@T%PTW6SbCyedyQy)1`0_Z;%B zY9iATWi|;LiDnVaB$`1qooE`-RH7-MwrPnnnS_&wCK62`Y9Oj78c$S5G%l+6v_u(8 z!ZAdniAE8PB&sDEK{T9bn2~~nPxKzqA)&vrm#_L5G!Ii zEW`}^7yBFgh<(t05zd%zvp3r}+1JRn4g>PnXi~nncK~~%$v-s&6Vca=1g;( zS>>1|X091Bb>k=F-^NGA>&DZ@-;H~WTa7;(ml%tUCZoX^YVxFtJJw^9v-)l#;ceR(aC$(+bCap!gO1nUtug%cL zY1LX8oHozWn5L-5)uZYmwe}Ttuew9sqTZ@rt2%Jzyjh*1j#3AyMQV;3QjNebfo}pw z0tW*x26hLw1vUq63S1MoD6lBd6qpbg9;gTu2Eu`0Adqyj_2DLeH~0cewa*c)Jrnn{ zJh*4&3iqsB;hvQ%+_Q28dsc8ja)nz~uJE(r3b(9W{RvMsxWqjxSGZ^83iqsBT}AFN zx@XmP_qb){j=Y>?tB5YMqNm!G*ic98^Tlwfj7wx(EaM^>7s^;EV}*eDSma#w}1K%A@GG@zYlrc-jOc^s|Oh?4#6w~Br zs*EWzCd-&4W1@@+G8$ym%NP%EGL_h`S0_i~WQ>(DM#g9vqhySfQ7dDFjN$nCOONd* zzbW3HuJAmOtJg_(fao=%SBYLBdYR}Y{QS9hv7dx561^al7JH!26FoZPZPPq(?n?vv9G$U z6CcVTPUkcks*He)BpH4gJ{evUUS>UZ)3HEIh&n{!C_(&4Wyw){8JRLNWQ1g-%Se;a zPDW5hsulgJoBI$_x^pR06qfADrfD?P%C2~|O zqewnyM=i0D#nQl(;@Duz?{xN@>@8?hPo%}vn zzqIgc_$7P^KND6kuhVbRc{Tq`dPiKu$5-1;tRh=%(u<`=5Dcexy4*#I_7!i8Rit2>aRB(Yr0R# zu30>1Y3Z2Sx~2Lwp90@AhqBz73PE)|m;ymH29qJEiU*S*sKQ_(1eNh%0tA&9G(a#o z9@IlH7=!T+B!l8f9VCM=83)0@crX@%ff$T|U_d+=4Z#2mMnTX&9*l&bKL)iBRK$Z3 z5L93=+^0C%{o=_mNcv$i6t2=Y9t?q?F9tOb^oa-65cCm2YL!pPFRvR@SF>>bIZewJ zFI;@)O1%=|a*PKL2*IPglRmO&m#$V(xQ;MGeY?wt@9L);tVB8Yn>#1V*lVcZ+y!i2aN#DyYu zQVSt3NXQEyFTkt!gt%uy+ymmC80SOWBO%U%xCh4FATyFr|fajwr%^79h%9LV!9 z?+RD%o)C9|xI4z3A?}tChav7JV!acTxmb3DG6&0SD7#|W0p5S1E?8&5k2_=89?CG5 znNW7ZG6TwvScag?#xfns4p^o^nT2ILDBEKhgffU_DwL^Mra+kzEuC^b8R}&GF^7_4 z$)IFd+ECh9T2NY8noycp8c-To>QL%fYEWues!*y}2A~XJnFOVigtZ@PKbAfyeOP*- z^omkbe0oS!(H*1d#;Iu6{epkU-{8-|p3#Hw2NpN;HT+WOxo7Zc@Fy14yqx#o9eFAb zz#my0W1p~tY(Lw>9)^9R_3S$6xaY7YHrip8tS9RPdq$`1&+QMO-|n{Wv2U|i+ZWo+ z_EdWa^xLj>$o5(%ti#sZ)?RC;b*I&0U1}|XU83>urxoQ^cPq=XERXrE`A_p8blNA( zZRSREop}}P5-l)i!7fpaS!(8)nWhbooBrDojuaUV^DEg(ya`W-$ze#rE<8;{@ishF zMA(O?h$!BOCyNL>@gxz&Tk%8@VK1H_qIffIh(?Zr?YLfK@pe33Tn+niorvNMd7Ox_ zBaanPyd{qj5%%QKB8oTVQ6j>wJW@p1mZ1qPtrZzI<`E)`H|F6Y!p=NQMDf-3-1xj)IN4uNaZgsr!hVfL0Bix?BuMXw_w60QTxqF(9E?mxuw_ zt&7Eggm&!|i4oYaBVt5C$L=i#V9V|$1|+oXLNNe)c7Yg>(6oDs0ob*BhyiVF+YyJ$ zd@%wWcb*uL(7C&d0ob~`i2(_%J68I-!#!&5rY#S96G7OY1r0Xb6NYhZN ukQ#d)144lSW=TRxNhrUN-;eS|rTKiQmc!o$^}b$jMv`8l>#fQW7W^;N&FCZm delta 147 zcmZoz;MDNIVS=>a1qKF2W(EYfF;T~u@xsQ0De^pN7KxTg2FZpgMybY|6u$7I2s9bA z88EgPFtr&lw;8Z(Ghk(0zy>r$jAhbv_6w}Kn*|NxSlTCDVFh9~AZ7<*4j|?PVlE)& T24Wr{<^^KD?USzX=S2emQX4T- 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" }, From c9c1b354fc25e76af2d89f13eb8652be847b4a2f Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 21 Oct 2026 22:24:22 +1100 Subject: [PATCH 02/28] refactor: use select_latest_executions from climate-ref Removes the app's own latest-execution subquery. climate-ref now picks the latest execution with the same created_at then id rule. --- .../src/ref_backend/core/model_ensemble.py | 4 +-- backend/src/ref_backend/core/model_runs.py | 36 ++----------------- backend/tests/test_core/test_model_runs.py | 18 +++++----- 3 files changed, 13 insertions(+), 45 deletions(-) diff --git a/backend/src/ref_backend/core/model_ensemble.py b/backend/src/ref_backend/core/model_ensemble.py index fde4bcea..ae5483c4 100644 --- a/backend/src/ref_backend/core/model_ensemble.py +++ b/backend/src/ref_backend/core/model_ensemble.py @@ -9,8 +9,8 @@ from sqlalchemy.orm import Session from climate_ref import models +from climate_ref.models.execution import select_latest_executions from ref_backend.core.mip_eras import cmip_dataset_filter, cv_column -from ref_backend.core.model_runs import latest_executions from ref_backend.models.model_runs import EnsembleComparison, EnsembleStatistics #: Dimensions that say which run produced a value rather than what the value measures. @@ -91,7 +91,7 @@ def _scalar_rows( 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() + latest = select_latest_executions().subquery() statement = ( select( diff --git a/backend/src/ref_backend/core/model_runs.py b/backend/src/ref_backend/core/model_runs.py index afc106f4..64f487ab 100644 --- a/backend/src/ref_backend/core/model_runs.py +++ b/backend/src/ref_backend/core/model_runs.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session, aliased from climate_ref import models +from climate_ref.models.execution import select_latest_executions from ref_backend.core.mip_eras import CMIP_ERAS, cv_column, dataset_model_for, mip_era_for from ref_backend.models.model_runs import RunCounts @@ -48,7 +49,7 @@ def _model_groups(mip_era: str | None, source_id: str | None) -> CompoundSelect[ 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): dataset_model = dataset_model_for(era) @@ -61,7 +62,7 @@ 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( @@ -77,37 +78,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"), diff --git a/backend/tests/test_core/test_model_runs.py b/backend/tests/test_core/test_model_runs.py index eac90a13..5dbff030 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,7 @@ 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] From 09623d996cfd96651b032d741840d3aeac5c95d4 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 21 Oct 2026 22:26:18 +1100 Subject: [PATCH 03/28] refactor: read resource summaries from ResourcesReader Uses ResourcesReader.summary for the overall and per-diagnostic resource usage. Passes include_retracted=True, so retracted executions still count as they did before. The roll-ups now keep the promoted version only. --- .../src/ref_backend/api/routes/executions.py | 6 ++--- .../src/ref_backend/core/resource_usage.py | 24 +++++++------------ backend/src/ref_backend/models/diagnostics.py | 14 ++++++++--- backend/tests/test_core/conftest.py | 14 +++++++++-- .../tests/test_core/test_resource_usage.py | 24 +++++++++++++++++++ 5 files changed, 58 insertions(+), 24 deletions(-) create mode 100644 backend/tests/test_core/test_resource_usage.py diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 1554b707..4b76a1b0 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -14,7 +14,7 @@ from climate_ref import models from climate_ref.models.dataset import DatasetFile -from climate_ref.results import ExecutionGroupFilter, MetricValueFilter +from climate_ref.results import ExecutionGroupFilter, MetricValueFilter, ResourceFilter from climate_ref_core.pycmec.metric import CMECMetric from ref_backend.api.deps import AppContext, AppContextDep from ref_backend.core.file_handling import file_iterator, resolve_artifact, tail_lines @@ -27,7 +27,7 @@ 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, @@ -98,7 +98,7 @@ async def get_execution_statistics(app_context: AppContextDep) -> ExecutionStats 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_retracted=True)), ) diff --git a/backend/src/ref_backend/core/resource_usage.py b/backend/src/ref_backend/core/resource_usage.py index 61b3118b..fc2cccfd 100644 --- a/backend/src/ref_backend/core/resource_usage.py +++ b/backend/src/ref_backend/core/resource_usage.py @@ -2,11 +2,12 @@ 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 class ExecutionResourceSummary(BaseModel): @@ -77,18 +78,9 @@ def summary_from_row(row: Row[Any]) -> ExecutionResourceSummary | None: return ExecutionResourceSummary.model_validate(dict(values)) -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_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(reader: Reader, filters: ResourceFilter) -> ExecutionResourceSummary | None: + """Roll up the resource usage of the executions matching ``filters``.""" + summary = reader.resources.summary(filters) + if summary is None: + return None + return ExecutionResourceSummary.model_validate(attrs.asdict(summary)) diff --git a/backend/src/ref_backend/models/diagnostics.py b/backend/src/ref_backend/models/diagnostics.py index 75fc411f..f87c1776 100644 --- a/backend/src/ref_backend/models/diagnostics.py +++ b/backend/src/ref_backend/models/diagnostics.py @@ -9,13 +9,14 @@ from climate_ref import models from climate_ref.models.metric_value import MetricValueType as StoredMetricValueType +from climate_ref.results import 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 @@ -327,7 +328,14 @@ def has_values(value_type: StoredMetricValueType) -> bool: 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) + diagnostic_resource_usage = resource_usage( + app_context.reader, + ResourceFilter( + provider_slug=diagnostic.provider.slug, + diagnostic_slug=diagnostic.slug, + include_retracted=True, + ), + ) # Build the base diagnostic summary summary = DiagnosticSummary( @@ -347,7 +355,7 @@ def has_values(value_type: StoredMetricValueType) -> bool: promoted_version=diagnostic.promoted_version, group_by=group_by_summary, aft_link=aft, - resource_usage=resource_usage, + resource_usage=diagnostic_resource_usage, ) # Apply metadata overrides from YAML if available 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_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 From 5c299e9dc5dc69f638923f20e385ea63e07a245a Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 06:18:51 +1100 Subject: [PATCH 04/28] refactor: build era and source filters on InputDatasetFilter Moves the per-era dataset matching onto climate-ref's InputDatasetFilter. Removes filter_utils, whose only user was the era filter. Adds tests for an unknown era, an unknown facet, a facet only one era carries and a comma list. The no-facet case trips an empty and_() deprecation upstream, which a test pins. --- backend/src/ref_backend/core/filter_utils.py | 49 ------- backend/src/ref_backend/core/mip_eras.py | 57 ++++---- backend/tests/test_core/test_filter_utils.py | 138 ------------------- backend/tests/test_core/test_mip_eras.py | 65 +++++++++ 4 files changed, 92 insertions(+), 217 deletions(-) delete mode 100644 backend/src/ref_backend/core/filter_utils.py delete mode 100644 backend/tests/test_core/test_filter_utils.py create mode 100644 backend/tests/test_core/test_mip_eras.py 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/mip_eras.py b/backend/src/ref_backend/core/mip_eras.py index b6a0b5df..143e506f 100644 --- a/backend/src/ref_backend/core/mip_eras.py +++ b/backend/src/ref_backend/core/mip_eras.py @@ -3,13 +3,12 @@ from collections.abc import Collection, Mapping from typing import Any -from sqlalchemy import ColumnElement, and_, distinct, false, func, or_, select +from sqlalchemy import ColumnElement, distinct, false, func, or_, select from sqlalchemy.orm import Session -from sqlalchemy.sql.expression import ColumnCollection from climate_ref import models +from climate_ref.results import InputDatasetFilter from climate_ref_core.datasets import SourceDatasetType -from ref_backend.core.filter_utils import build_filter_clause #: The MIP eras the app presents. Data from the two is never combined on a chart. CMIP_ERAS: tuple[SourceDatasetType, ...] = (SourceDatasetType.CMIP6, SourceDatasetType.CMIP7) @@ -36,54 +35,52 @@ def mip_era_for(source_type: SourceDatasetType) -> str | 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 - - 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 cmip_dataset_filter(facets: Mapping[str, str]) -> ColumnElement[bool]: +def _carries(source_type: SourceDatasetType, facet: str) -> bool: + return facet in dataset_model_for(source_type).__mapper__.columns + + +def _facet_values(value: str) -> tuple[str, ...]: + if "," not in value: + return (value,) + return tuple(item.strip() for item in value.split(",") if item.strip()) + + +def cmip_input_filter(facets: Mapping[str, str]) -> InputDatasetFilter | None: """ 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. + all of CMIP6. Returns `None` when nothing can satisfy the request. """ requested_era = facets.get("mip_era") - columns_by_era = {era: _mapped_columns(era) for era in CMIP_ERAS} + eras = tuple(era for era in CMIP_ERAS if not requested_era or requested_era.upper() == mip_era_for(era)) known = { - key: value + key: _facet_values(value) for key, value in facets.items() - if key != "mip_era" and any(key in columns for columns in columns_by_era.values()) + if key != "mip_era" and any(_carries(era, key) for era in CMIP_ERAS) } + if not eras or any(not any(_carries(era, key) for era in eras) for key in known): + return None + # The attrs converter leaves mypy with an unbound Iterable[_T_co] for source_types. + return InputDatasetFilter(source_types=eras, facets=known or None) # type: ignore[arg-type] - 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 cmip_dataset_filter(facets: Mapping[str, str]) -> ColumnElement[bool]: + """Match executions as `cmip_input_filter` does, or no execution when nothing can match.""" + input_filter = cmip_input_filter(facets) + return input_filter.execution_clause() if input_filter else false() 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)) + input_filter = cmip_input_filter(facets) + return input_filter.execution_group_clause() if input_filter else false() def execution_groups_per_era(session: Session) -> dict[str, int]: 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_mip_eras.py b/backend/tests/test_core/test_mip_eras.py new file mode 100644 index 00000000..83c51a16 --- /dev/null +++ b/backend/tests/test_core/test_mip_eras.py @@ -0,0 +1,65 @@ +import pytest +from sqlalchemy import func, select + +from climate_ref import models +from climate_ref.database import Database +from ref_backend.core.mip_eras import ( + CMIP_ERAS, + cmip_dataset_filter, + cmip_input_filter, + cv_column, + dataset_model_for, +) +from ref_backend.testing import test_ref_config as fixture_ref_config + + +@pytest.fixture(scope="module") +def session(): + return Database.from_config(fixture_ref_config(), run_migrations=False, read_only=True).session + + +def _count(session, facets) -> int: + return session.scalar(select(func.count(models.Execution.id)).where(cmip_dataset_filter(facets))) + + +def _a_source_id(session) -> str: + cmip6 = dataset_model_for(CMIP_ERAS[0]) + return session.scalar(select(cv_column(cmip6, "source_id")).limit(1)) + + +def test_no_facets_matches_every_execution_with_a_cmip_input(session): + assert _count(session, {}) > 0 + + +def test_an_unknown_era_matches_nothing(session): + assert cmip_input_filter({"mip_era": "CMIP5"}) is None + assert _count(session, {"mip_era": "CMIP5"}) == 0 + + +def test_the_era_is_case_insensitive(session): + assert _count(session, {"mip_era": "cmip6"}) == _count(session, {"mip_era": "CMIP6"}) > 0 + + +def test_an_unknown_facet_is_ignored(session): + assert _count(session, {"not_a_facet": "x"}) == _count(session, {}) + + +def test_a_facet_only_the_other_era_carries_matches_nothing(session): + assert cmip_input_filter({"mip_era": "CMIP6", "branding_suffix": "x"}) is None + + +def test_a_facet_one_era_carries_drops_the_other_era(session): + cmip6 = dataset_model_for(CMIP_ERAS[0]) + table_id = session.scalar(select(cmip6.table_id).limit(1)) + assert _count(session, {"table_id": table_id}) == _count( + session, {"mip_era": "CMIP6", "table_id": table_id} + ) + assert _count(session, {"mip_era": "CMIP7", "table_id": table_id}) == 0 + + +def test_a_comma_list_matches_any_of_its_values(session): + source_id = _a_source_id(session) + one = _count(session, {"source_id": source_id}) + assert one > 0 + assert _count(session, {"source_id": f"{source_id}, not-a-model"}) == one + assert _count(session, {"source_id": "not-a-model,"}) == 0 From c72b0e146d4d3ccfc25f61bece95f0720d0fd39f Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 07:23:34 +1100 Subject: [PATCH 05/28] refactor: filter the executions list in ExecutionGroupFilter Moves the failure_reason, source_id and mip_era filters into the reader query. Skips the reader's count, because the route counts after its own sort. An unknown failure_reason or mip_era still returns an empty list. --- .../src/ref_backend/api/routes/executions.py | 29 ++++++++++--------- .../test_api/test_routes/test_executions.py | 23 +++++++++++++++ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 4b76a1b0..732ce006 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -8,12 +8,12 @@ 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.models.execution import ExecutionFailureReason from climate_ref.results import ExecutionGroupFilter, MetricValueFilter, ResourceFilter from climate_ref_core.pycmec.metric import CMECMetric from ref_backend.api.deps import AppContext, AppContextDep @@ -22,7 +22,7 @@ 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 cmip_input_filter, execution_groups_per_era from ref_backend.core.reader_values import ( fetch_metric_values, parse_dimension_filters, @@ -138,30 +138,31 @@ async def list_recent_execution_groups( # noqa: PLR0913, PLR0917 """ session = app_context.session + # Selectors do not record source_id, so match on the datasets used. + facets = {key: value for key, value in {"source_id": source_id, "mip_era": mip_era}.items() if value} + input_datasets = cmip_input_filter(facets) if facets else None + try: + failure_reasons = [ExecutionFailureReason(failure_reason)] if failure_reason is not None else None + except ValueError: + return Collection(total_count=0, data=[]) + if facets and input_datasets is None: + return Collection(total_count=0, data=[]) + filters = ExecutionGroupFilter( diagnostic_contains=[diagnostic_name_contains] if diagnostic_name_contains else None, provider_contains=[provider_name_contains] if provider_name_contains else None, dirty=dirty, successful=successful, + failure_reasons=failure_reasons, + input_datasets=input_datasets, ) # The reader orders by id, so sort here to keep the most recently updated groups first. groups = sorted( - app_context.reader.executions.groups(filters), + app_context.reader.executions.groups(filters, with_count=False), key=lambda group: (group.updated_at, group.id), reverse=True, ) - 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]] diff --git a/backend/tests/test_api/test_routes/test_executions.py b/backend/tests/test_api/test_routes/test_executions.py index d20a04c5..b09c82f8 100644 --- a/backend/tests/test_api/test_routes/test_executions.py +++ b/backend/tests/test_api/test_routes/test_executions.py @@ -591,3 +591,26 @@ 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 + + +def test_execution_list_with_an_unknown_failure_reason_is_empty(client: TestClient, settings) -> None: + r = client.get(f"{settings.API_V1_STR}/executions/?failure_reason=not-a-reason") + assert r.status_code == 200 + assert r.json()["total_count"] == 0 + assert r.json()["data"] == [] + + +def test_execution_list_with_an_unknown_mip_era_is_empty(client: TestClient, settings) -> None: + r = client.get(f"{settings.API_V1_STR}/executions/?mip_era=CMIP5") + assert r.status_code == 200 + assert r.json()["total_count"] == 0 + assert r.json()["data"] == [] + + +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 From 3887e753c1b67752ef457ee5e99a4b24190161c5 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 07:24:38 +1100 Subject: [PATCH 06/28] refactor: count groups per era with ExecutionsReader.statistics Counts the groups per MIP era with one reader aggregate per era. Adds a test that the CMIP6 count matches the executions list for that era. --- .../src/ref_backend/api/routes/executions.py | 2 +- backend/src/ref_backend/core/mip_eras.py | 34 +++++++++---------- .../test_api/test_routes/test_executions.py | 10 ++++++ 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 732ce006..618b8403 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -93,7 +93,7 @@ async def get_execution_statistics(app_context: AppContextDep) -> ExecutionStats 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), + execution_groups_by_mip_era=execution_groups_per_era(app_context.reader), scalar_value_count=session.query(models.ScalarMetricValue).count(), series_value_count=session.query(models.SeriesMetricValue).count(), total_datasets=session.query(models.Dataset).count(), diff --git a/backend/src/ref_backend/core/mip_eras.py b/backend/src/ref_backend/core/mip_eras.py index 143e506f..7b662ce4 100644 --- a/backend/src/ref_backend/core/mip_eras.py +++ b/backend/src/ref_backend/core/mip_eras.py @@ -3,11 +3,11 @@ from collections.abc import Collection, Mapping from typing import Any -from sqlalchemy import ColumnElement, distinct, false, func, or_, select +from sqlalchemy import ColumnElement, false, or_, select from sqlalchemy.orm import Session from climate_ref import models -from climate_ref.results import InputDatasetFilter +from climate_ref.results import InputDatasetFilter, Reader from climate_ref_core.datasets import SourceDatasetType #: The MIP eras the app presents. Data from the two is never combined on a chart. @@ -67,8 +67,14 @@ def cmip_input_filter(facets: Mapping[str, str]) -> InputDatasetFilter | None: } if not eras or any(not any(_carries(era, key) for era in eras) for key in known): return None + return _input_filter(eras, known or None) + + +def _input_filter( + eras: tuple[SourceDatasetType, ...], facets: Mapping[str, tuple[str, ...]] | None = None +) -> InputDatasetFilter: # The attrs converter leaves mypy with an unbound Iterable[_T_co] for source_types. - return InputDatasetFilter(source_types=eras, facets=known or None) # type: ignore[arg-type] + return InputDatasetFilter(source_types=eras, facets=facets) # type: ignore[arg-type] def cmip_dataset_filter(facets: Mapping[str, str]) -> ColumnElement[bool]: @@ -83,27 +89,19 @@ def execution_group_filter(facets: Mapping[str, str]) -> ColumnElement[bool]: return input_filter.execution_group_clause() if input_filter else false() -def execution_groups_per_era(session: Session) -> dict[str, int]: +def execution_groups_per_era(reader: Reader) -> 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() - - 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))} + counts = {} + for era in CMIP_ERAS: + if label := mip_era_for(era): + stats = reader.executions.statistics(input_datasets=_input_filter((era,))) + counts[label] = sum(s.total for s in stats) + return counts def executions_in_mip_era(session: Session, mip_era: str, diagnostic_id: int) -> list[int]: diff --git a/backend/tests/test_api/test_routes/test_executions.py b/backend/tests/test_api/test_routes/test_executions.py index b09c82f8..13506fb6 100644 --- a/backend/tests/test_api/test_routes/test_executions.py +++ b/backend/tests/test_api/test_routes/test_executions.py @@ -614,3 +614,13 @@ def test_execution_list_filters_by_source_id(client: TestClient, settings) -> No 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["execution_groups_by_mip_era"] + assert set(by_era) == {"CMIP6", "CMIP7"} + assert all(0 <= count <= stats["total_execution_groups"] for count in by_era.values()) + + listed = client.get(f"{settings.API_V1_STR}/executions/?mip_era=CMIP6").json() + assert by_era["CMIP6"] == listed["total_count"] > 0 From 85ab7b32cf992dcce68fd38e8fddea6e272cf9f4 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 07:27:21 +1100 Subject: [PATCH 07/28] refactor: count diagnostic groups with DiagnosticsReader.stats Takes the catalog and detail group counts from the reader instead of two hand-written queries each. The latest execution of a group is now the newest by created_at, not the highest id. This matches the executions statistics and the CLI. Adds tests for a backfilled failure and for an unknown era in the catalog. --- .../src/ref_backend/api/routes/diagnostics.py | 141 ++++++++---------- backend/src/ref_backend/models/diagnostics.py | 41 ++--- .../test_api/test_routes/test_diagnostics.py | 47 ++++++ 3 files changed, 120 insertions(+), 109 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index 2bb45f8b..b5fd73fc 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -1,11 +1,11 @@ from typing import Literal, NamedTuple from fastapi import APIRouter, HTTPException, Query, Request -from sqlalchemy import ColumnElement, Integer, func, select +from sqlalchemy import ColumnElement, Integer, false, func, select from starlette.responses import StreamingResponse from climate_ref import models -from climate_ref.results import MetricValueFilter +from climate_ref.results import InputDatasetFilter, MetricValueFilter from ref_backend.api.deps import AppContextDep from ref_backend.core.metric_values import ( MetricValueType, @@ -13,6 +13,7 @@ ) from ref_backend.core.mip_eras import ( cmip_dataset_filter, + cmip_input_filter, execution_group_filter, executions_in_mip_era, ) @@ -72,36 +73,41 @@ 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 which of their execution groups count towards them""" + diagnostics: list[models.Diagnostic] + input_datasets: InputDatasetFilter | None + matches_nothing: bool + group_scope: list[ColumnElement[bool]] -def _scoped_diagnostics( - app_context: AppContextDep, mip_era: str | None, source_id: str | None -) -> tuple[list[models.Diagnostic], list[ColumnElement[bool]]]: + +def _scoped_diagnostics(app_context: AppContextDep, mip_era: str | None, source_id: str | None) -> _Scope: """ 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) + # 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} + input_datasets = cmip_input_filter(facets) if facets else None + matches_nothing = not diagnostics or (bool(facets) and input_datasets is None) + + 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_([d.id for d in diagnostics]), + models.ExecutionGroup.diagnostic_version == promoted_version, + ] + if input_datasets: + group_scope.append(input_datasets.execution_group_clause()) + if matches_nothing: + group_scope.append(false()) + return _Scope(diagnostics, input_datasets, matches_nothing, group_scope) def _diagnostics_with_values( @@ -131,9 +137,8 @@ def _value_types(app_context: AppContextDep, group_scope: list[ColumnElement[boo ) -def _catalog_entries( - app_context: AppContextDep, diagnostics: list[models.Diagnostic], group_scope: list[ColumnElement[bool]] -) -> list[DiagnosticCatalogEntry]: +def _catalog_entries(app_context: AppContextDep, scope: _Scope) -> list[DiagnosticCatalogEntry]: + group_scope = scope.group_scope # Count executions and roll up their resource usage per diagnostic execution_counts = ( app_context.session.query( @@ -153,55 +158,29 @@ def _catalog_entries( } resource_usage = {row.diagnostic_id: summary_from_row(row) for row in execution_counts} - # 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() + group_stats = ( + {} + if scope.matches_nothing + else { + (stats.provider, stats.diagnostic): stats + for stats in app_context.reader.diagnostics.stats(input_datasets=scope.input_datasets) + } ) - # 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() - ) - successful_group_counts_dict = {row[0]: row[1] for row in successful_group_counts} - - 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), + entries = [] + for m in scope.diagnostics: + stats = group_stats.get((m.provider.slug, m.slug)) + entries.append( + DiagnosticCatalogEntry.build_with_stats( + m, + app_context, + execution_stats=execution_stats.get(m.id, {"total": 0, "successful": 0}), + execution_group_count=stats.total if stats else 0, + successful_execution_group_count=stats.successful if stats else 0, + resource_usage=resource_usage.get(m.id), + ) ) - for m in diagnostics - ] + return entries @router.get("/", name="list") @@ -214,8 +193,8 @@ async def _list( 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. """ - 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.group_scope) return Collection( data=[ DiagnosticSummary.from_catalog_entry( @@ -223,7 +202,7 @@ async def _list( has_scalar_values=entry.id in value_types.scalar, has_series_values=entry.id in value_types.series, ) - for entry in _catalog_entries(app_context, diagnostics, group_scope) + for entry in _catalog_entries(app_context, scope) ] ) @@ -240,8 +219,8 @@ async def catalog( 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") @@ -253,8 +232,8 @@ async def value_flags( 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.group_scope) return Collection( data=[ DiagnosticValueFlags( @@ -263,7 +242,7 @@ async def value_flags( has_scalar_values=d.id in value_types.scalar, has_series_values=d.id in value_types.series, ) - for d in diagnostics + for d in scope.diagnostics ] ) diff --git a/backend/src/ref_backend/models/diagnostics.py b/backend/src/ref_backend/models/diagnostics.py index f87c1776..76f536a6 100644 --- a/backend/src/ref_backend/models/diagnostics.py +++ b/backend/src/ref_backend/models/diagnostics.py @@ -5,7 +5,7 @@ from loguru import logger from pydantic import BaseModel -from sqlalchemy import func, select +from sqlalchemy import select from climate_ref import models from climate_ref.models.metric_value import MetricValueType as StoredMetricValueType @@ -296,34 +296,19 @@ def has_values(value_type: StoredMetricValueType) -> bool: .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() + # The reader only matches slugs as substrings, so pick the exact one out. + group_stats = next( + ( + stats + for stats in app_context.reader.diagnostics.stats( + provider_contains=[diagnostic.provider.slug], diagnostic_contains=[diagnostic.slug] + ) + if (stats.provider, stats.diagnostic) == (diagnostic.provider.slug, diagnostic.slug) + ), + None, ) + execution_group_count = group_stats.total if group_stats else 0 + successful_execution_group_count = group_stats.successful if group_stats else 0 concrete_diagnostic = _concrete_diagnostic(diagnostic, app_context) description = (concrete_diagnostic.__doc__ or "") if concrete_diagnostic else "" diff --git a/backend/tests/test_api/test_routes/test_diagnostics.py b/backend/tests/test_api/test_routes/test_diagnostics.py index 1101a6a4..a6a43329 100644 --- a/backend/tests/test_api/test_routes/test_diagnostics.py +++ b/backend/tests/test_api/test_routes/test_diagnostics.py @@ -1,10 +1,12 @@ import copy import shutil from collections.abc import Generator +from datetime import timedelta from pathlib import Path import pytest from fastapi.testclient import TestClient +from sqlalchemy import select from climate_ref import models from climate_ref.database import Database @@ -952,6 +954,51 @@ def test_diagnostic_counts_are_scoped_to_the_promoted_version(writable_client: T assert relisted["execution_group_count"] == 0 +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["successful_execution_group_count"] > 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["successful_execution_group_count"] == before["successful_execution_group_count"] + relisted = next( + d for d in writable_client.get("/api/v1/diagnostics/").json()["data"] if d["id"] == diagnostic["id"] + ) + assert relisted["successful_execution_group_count"] == before["successful_execution_group_count"] + + +def test_diagnostics_catalog_with_an_unknown_era_counts_nothing(client: TestClient, settings) -> None: + catalog = client.get(f"{settings.API_V1_STR}/diagnostics/catalog", params={"mip_era": "CMIP5"}).json() + assert catalog["data"] + assert all(d["execution_group_count"] == 0 for d in catalog["data"]) + assert all(d["successful_execution_group_count"] == 0 for d in catalog["data"]) + assert all(d["execution_count"] == 0 for d in catalog["data"]) + + FLAGS = ("has_metric_values", "has_scalar_values", "has_series_values") From f6bc7c9eed12c6b54a48928e797af719ab93d574 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 07:29:21 +1100 Subject: [PATCH 08/28] refactor: read value flags from DiagnosticsReader.with_values Takes has_scalar_values and has_series_values from the reader for the list, catalog flags and detail. Hides retracted executions from both values endpoints, so the flags and the values follow one rule. The detail flags now count the promoted version only, like the list. Removes metric_value_type_is, which the reader replaces. The flag test now checks only listed diagnostics, so a local DIAGNOSTIC_EXCLUDE no longer breaks it. --- .../src/ref_backend/api/routes/diagnostics.py | 30 ++++++------ .../src/ref_backend/api/routes/executions.py | 3 +- backend/src/ref_backend/core/metric_values.py | 14 ------ backend/src/ref_backend/models/diagnostics.py | 16 ++----- .../test_api/test_routes/test_diagnostics.py | 46 +++++++++++++++---- 5 files changed, 57 insertions(+), 52 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index b5fd73fc..22912822 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -5,7 +5,8 @@ from starlette.responses import StreamingResponse from climate_ref import models -from climate_ref.results import InputDatasetFilter, MetricValueFilter +from climate_ref.models.metric_value import MetricValueType as StoredMetricValueType +from climate_ref.results import DiagnosticFilter, InputDatasetFilter, MetricValueFilter from ref_backend.api.deps import AppContextDep from ref_backend.core.metric_values import ( MetricValueType, @@ -111,16 +112,14 @@ def _scoped_diagnostics(app_context: AppContextDep, mip_era: str | None, source_ def _diagnostics_with_values( - app_context: AppContextDep, value_class: type[models.MetricValue], group_scope: list[ColumnElement[bool]] + app_context: AppContextDep, value_type: StoredMetricValueType, scope: _Scope ) -> set[int]: - rows = ( - app_context.session.query(models.ExecutionGroup.diagnostic_id) - .join(models.Execution) - .join(value_class) - .filter(*group_scope) - .distinct() + if scope.matches_nothing: + return set() + pairs = app_context.reader.diagnostics.with_values( + value_type, DiagnosticFilter(input_datasets=scope.input_datasets) ) - return {row[0] for row in rows} + return {d.id for d in scope.diagnostics if (d.provider.slug, d.slug) in pairs} class _ValueTypes(NamedTuple): @@ -130,10 +129,10 @@ class _ValueTypes(NamedTuple): series: set[int] -def _value_types(app_context: AppContextDep, group_scope: list[ColumnElement[bool]]) -> _ValueTypes: +def _value_types(app_context: AppContextDep, scope: _Scope) -> _ValueTypes: return _ValueTypes( - scalar=_diagnostics_with_values(app_context, models.ScalarMetricValue, group_scope), - series=_diagnostics_with_values(app_context, models.SeriesMetricValue, group_scope), + scalar=_diagnostics_with_values(app_context, StoredMetricValueType.SCALAR, scope), + series=_diagnostics_with_values(app_context, StoredMetricValueType.SERIES, scope), ) @@ -194,7 +193,7 @@ async def _list( and `source_id` to count only those that ran against that model. """ scope = _scoped_diagnostics(app_context, mip_era, source_id) - value_types = _value_types(app_context, scope.group_scope) + value_types = _value_types(app_context, scope) return Collection( data=[ DiagnosticSummary.from_catalog_entry( @@ -233,7 +232,7 @@ async def value_flags( These are the flags `/diagnostics/catalog` leaves out. """ scope = _scoped_diagnostics(app_context, mip_era, source_id) - value_types = _value_types(app_context, scope.group_scope) + value_types = _value_types(app_context, scope) return Collection( data=[ DiagnosticValueFlags( @@ -375,7 +374,7 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 # Scope to this diagnostic/provider via exact-match slugs. The filter 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. + # previous versions needs a separate design (TODO). Retracted executions are hidden. metric_filter = MetricValueFilter( diagnostic_slug=diagnostic_slug, provider_slug=provider_slug, @@ -383,7 +382,6 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 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, - include_retracted=True, ) return fetch_metric_values( diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 618b8403..87719608 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -331,13 +331,12 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 # Restrict to the selected execution's values; ``_get_execution`` already resolves the # latest execution when no ``execution_id`` is supplied. The filter 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. + # previous versions needs a separate design (TODO). Retracted executions are hidden. 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, - include_retracted=True, ) return fetch_metric_values( diff --git a/backend/src/ref_backend/core/metric_values.py b/backend/src/ref_backend/core/metric_values.py index 207eaf4f..8f902778 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): @@ -22,13 +18,3 @@ def parse_id_list(id_str: str) -> list[int]: 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]: - """ - Match metric values of one type without letting SQLite use `ix_metric_value_type` - - 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. - """ - return cast(models.MetricValue.type, String) == value_type.name diff --git a/backend/src/ref_backend/models/diagnostics.py b/backend/src/ref_backend/models/diagnostics.py index 76f536a6..b5dad6fe 100644 --- a/backend/src/ref_backend/models/diagnostics.py +++ b/backend/src/ref_backend/models/diagnostics.py @@ -5,17 +5,15 @@ from loguru import logger from pydantic import BaseModel -from sqlalchemy import select from climate_ref import models from climate_ref.models.metric_value import MetricValueType as StoredMetricValueType -from climate_ref.results import ResourceFilter +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 from ref_backend.models.aft import AFTDiagnosticDetail from ref_backend.models.common import GroupBy, ProviderSummary @@ -256,15 +254,11 @@ def build(diagnostic: models.Diagnostic, app_context: "AppContext") -> "Diagnost group_by_summary = DiagnosticSummary._build_group_by_summary(diagnostic, app_context) aft = DiagnosticSummary._get_aft_link(diagnostic) + key = (diagnostic.provider.slug, diagnostic.slug) + slug_filter = DiagnosticFilter(provider_contains=[key[0]], diagnostic_contains=[key[1]]) + 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 + return key in app_context.reader.diagnostics.with_values(value_type, slug_filter) has_scalar_values = has_values(StoredMetricValueType.SCALAR) has_series_values = has_values(StoredMetricValueType.SERIES) diff --git a/backend/tests/test_api/test_routes/test_diagnostics.py b/backend/tests/test_api/test_routes/test_diagnostics.py index a6a43329..1adc075c 100644 --- a/backend/tests/test_api/test_routes/test_diagnostics.py +++ b/backend/tests/test_api/test_routes/test_diagnostics.py @@ -613,15 +613,17 @@ def test_diagnostics_facets_count_is_non_negative(client: TestClient, settings) 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()} @@ -629,20 +631,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"]) + assert client.get(url).json()[flag] == (diagnostic["id"] in expected), (flag, diagnostic["slug"]) + + +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"] + + 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 + + relisted = next( + d for d in writable_client.get("/api/v1/diagnostics/").json()["data"] if d["id"] == diagnostic["id"] + ) + 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_diagnostics_facets_cover_scalar_and_series_values(client: TestClient, settings) -> None: From 3996ffbb594af2816dd7f408579cc8511e225c18 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 07:30:17 +1100 Subject: [PATCH 09/28] refactor: list datasets with DatasetsReader.list Uses the reader for the dataset list, with slug_contains for name_contains. The list is now ordered by slug rather than by most recently updated, so paging is stable. Adds tests for the order, paging and a case-insensitive name match. --- .../src/ref_backend/api/routes/datasets.py | 26 +++++++------------ .../test_api/test_routes/test_datasets.py | 21 +++++++++++++++ 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/backend/src/ref_backend/api/routes/datasets.py b/backend/src/ref_backend/api/routes/datasets.py index d7847906..4cd69306 100644 --- a/backend/src/ref_backend/api/routes/datasets.py +++ b/backend/src/ref_backend/api/routes/datasets.py @@ -2,14 +2,12 @@ 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.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.models import ( Collection, Dataset, @@ -39,7 +37,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 +59,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") 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"]) From 67d92275d8bc6c5aae2662edf8f3395bf3cebb5b Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 07:30:48 +1100 Subject: [PATCH 10/28] chore: use Session.get for execution outputs Replaces the legacy Query.get, which SQLAlchemy 2 warns about. --- backend/src/ref_backend/api/routes/results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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") From 3cfe64fc340d6e3279caf702a1ae714e0c6461f0 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 09:51:19 +1100 Subject: [PATCH 11/28] refactor: resolve the mip_era query value in one place Adds eras_in_scope, which turns a mip_era query value into the eras it selects, in any case. The era filters and the model pages now share it instead of comparing upper-cased text. An empty mip_era on the model routes now means no filter, as it already did elsewhere. Pins the parameter on every route that takes it: an unknown era matches nothing and case is ignored. --- backend/src/ref_backend/core/mip_eras.py | 14 +++- backend/src/ref_backend/core/model_runs.py | 12 +--- .../test_routes/test_mip_era_params.py | 65 +++++++++++++++++++ 3 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 backend/tests/test_api/test_routes/test_mip_era_params.py diff --git a/backend/src/ref_backend/core/mip_eras.py b/backend/src/ref_backend/core/mip_eras.py index 7b662ce4..b42a12d1 100644 --- a/backend/src/ref_backend/core/mip_eras.py +++ b/backend/src/ref_backend/core/mip_eras.py @@ -35,6 +35,17 @@ def mip_era_for(source_type: SourceDatasetType) -> str | None: return dataset_type_label(source_type).upper() +def eras_in_scope(mip_era: str | None) -> tuple[SourceDatasetType, ...]: + """ + Resolve a `mip_era` query value, in any case, into the eras it selects. + + No value selects every era and an unknown era selects none. + """ + if not mip_era: + return CMIP_ERAS + return tuple(era for era in CMIP_ERAS if dataset_type_label(era) == mip_era.lower()) + + 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) @@ -58,8 +69,7 @@ def cmip_input_filter(facets: Mapping[str, str]) -> InputDatasetFilter | None: A facet only one era carries excludes the other, so a CMIP7-only facet does not pull in all of CMIP6. Returns `None` when nothing can satisfy the request. """ - requested_era = facets.get("mip_era") - eras = tuple(era for era in CMIP_ERAS if not requested_era or requested_era.upper() == mip_era_for(era)) + eras = eras_in_scope(facets.get("mip_era")) known = { key: _facet_values(value) for key, value in facets.items() diff --git a/backend/src/ref_backend/core/model_runs.py b/backend/src/ref_backend/core/model_runs.py index 64f487ab..7426f6db 100644 --- a/backend/src/ref_backend/core/model_runs.py +++ b/backend/src/ref_backend/core/model_runs.py @@ -9,7 +9,7 @@ from climate_ref import models from climate_ref.models.execution import select_latest_executions -from ref_backend.core.mip_eras import CMIP_ERAS, cv_column, dataset_model_for, mip_era_for +from ref_backend.core.mip_eras import cv_column, dataset_model_for, eras_in_scope, mip_era_for from ref_backend.models.model_runs import RunCounts @@ -30,12 +30,6 @@ 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: """ Select every (execution group, model) pairing at the promoted version of each diagnostic. @@ -51,7 +45,7 @@ def _model_groups(mip_era: str | None, source_id: str | None) -> CompoundSelect[ """ 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 = ( @@ -120,7 +114,7 @@ def model_facets( 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") 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..7637dcb8 --- /dev/null +++ b/backend/tests/test_api/test_routes/test_mip_era_params.py @@ -0,0 +1,65 @@ +"""The `mip_era` query parameter on every route that takes it.""" + +import pytest +from fastapi.testclient import TestClient + +MODEL = "ACCESS-ESM1-5" +DIAGNOSTIC = "esmvaltool/climate-at-global-warming-levels" + + +def _group_total(body) -> int: + return sum(d["execution_group_count"] 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"]) + + +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), + ("/executions/", {}, _rows), + ("/models/", {}, _rows), + (f"/models/{MODEL}/ensemble", {}, _rows), +] + + +@pytest.mark.parametrize(("path", "params", "amount"), ROUTES) +def test_an_unknown_era_matches_nothing(client: TestClient, settings, path, params, amount) -> None: + url = f"{settings.API_V1_STR}{path}" + everything = client.get(url, params=params) + unknown = client.get(url, params={**params, "mip_era": "CMIP5"}) + + assert everything.status_code == unknown.status_code == 200 + assert amount(everything.json()) > 0 + assert amount(unknown.json()) == 0 + + +def test_an_unknown_era_on_a_model_is_not_found(client: TestClient, settings) -> None: + r = client.get(f"{settings.API_V1_STR}/models/{MODEL}", params={"mip_era": "CMIP5"}) + assert r.status_code == 404 + + +@pytest.mark.parametrize( + ("path", "params"), [*((path, params) for path, params, _ in ROUTES), (f"/models/{MODEL}", {})] +) +def test_the_era_ignores_case(client: TestClient, settings, path, params) -> None: + url = f"{settings.API_V1_STR}{path}" + upper = client.get(url, params={**params, "mip_era": "CMIP6"}) + lower = client.get(url, params={**params, "mip_era": "cmip6"}) + + assert upper.status_code == 200 + assert lower.json() == upper.json() + + +@pytest.mark.parametrize(("path", "params"), [("/models/", {}), ("/executions/", {})]) +def test_an_empty_era_is_no_filter(client: TestClient, settings, path, params) -> None: + url = f"{settings.API_V1_STR}{path}" + assert client.get(url, params={**params, "mip_era": ""}).json() == client.get(url, params=params).json() From 0e554fabd7fb4a525f663272dcc2c360ac1ea02c Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 09:51:28 +1100 Subject: [PATCH 12/28] test: pin how mixed-era and obs-only executions are scoped Covers an execution mixing CMIP6 and CMIP7 inputs, one with no CMIP input and the mip_id fallback that labels them. These cases are absent from the fixture, so the tests build them in a copy of the database. --- backend/tests/test_core/test_mip_era_edges.py | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 backend/tests/test_core/test_mip_era_edges.py 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..a8d36b63 --- /dev/null +++ b/backend/tests/test_core/test_mip_era_edges.py @@ -0,0 +1,160 @@ +"""How executions that do not sit in exactly one MIP era are scoped and labelled.""" + +from types import SimpleNamespace + +import pytest +from sqlalchemy import select + +from climate_ref import models +from climate_ref_core.datasets import SourceDatasetType +from ref_backend.core.mip_eras import ( + CMIP_ERAS, + cmip_dataset_filter, + dataset_model_for, + eras_in_scope, + executions_in_mip_era, + mip_eras_for_executions, +) +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), + ("", CMIP_ERAS), + ("CMIP7", (SourceDatasetType.CMIP7,)), + ("cmip7", (SourceDatasetType.CMIP7,)), + ("CmIp6", (SourceDatasetType.CMIP6,)), + ("CMIP5", ()), + ("obs4MIPs", ()), + ], +) +def test_eras_in_scope(mip_era, expected): + assert eras_in_scope(mip_era) == expected + + +def _cmip6_execution(session) -> models.Execution: + cmip6 = dataset_model_for(SourceDatasetType.CMIP6) + return session.scalars( + select(models.Execution).join(models.Execution.datasets.of_type(cmip6)).limit(1) + ).one() + + +def _cmip7_dataset(session) -> models.Dataset: + dataset = dataset_model_for(SourceDatasetType.CMIP7)( + slug="CMIP7.probe.v1", + activity_id="CMIP", + institution_id="probe", + source_id="PROBE-ESM", + experiment_id="historical", + variant_label="r1i1p1f1", + variable_id="tas", + grid_label="gn", + frequency="mon", + region="glb", + branding_suffix="tavg-h2m-hxy-u", + version="v1", + mip_era="CMIP7", + instance_id="CMIP7.probe.v1", + version_key="v1", + ) + session.add(dataset) + return dataset + + +def _obs_only_execution(session) -> models.Execution: + template = _cmip6_execution(session) + obs = session.scalars( + select(models.Dataset).where(models.Dataset.dataset_type == SourceDatasetType.obs4MIPs).limit(1) + ).one() + execution = models.Execution( + execution_group_id=template.execution_group_id, + dataset_hash="obs-only", + output_fragment="obs-only", + successful=True, + ) + execution.datasets.append(obs) + session.add(execution) + session.flush() + return execution + + +def _matches(session, execution_id: int, mip_era: str) -> bool: + return ( + execution_id + in session.scalars(select(models.Execution.id).where(cmip_dataset_filter({"mip_era": mip_era}))).all() + ) + + +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_mixed_era_execution_has_no_era_but_passes_both_filters(writable_session): + execution = _cmip6_execution(writable_session) + execution.datasets.append(_cmip7_dataset(writable_session)) + writable_session.flush() + diagnostic_id = execution.execution_group.diagnostic_id + + assert mip_eras_for_executions(writable_session, [execution.id]) == {} + for era in ("CMIP6", "CMIP7"): + assert _matches(writable_session, execution.id, era) + assert execution.id in executions_in_mip_era(writable_session, era, diagnostic_id) + + +def test_an_execution_without_cmip_input_answers_to_every_era(writable_session): + execution = _obs_only_execution(writable_session) + diagnostic_id = execution.execution_group.diagnostic_id + + assert mip_eras_for_executions(writable_session, [execution.id]) == {} + for era in ("CMIP6", "cmip7"): + assert not _matches(writable_session, execution.id, era) + assert execution.id in executions_in_mip_era(writable_session, era, diagnostic_id) + + +def test_an_unknown_era_still_keeps_executions_without_cmip_input(writable_session): + """Pins current behaviour: the no-CMIP half of the rule ignores whether the era exists.""" + execution = _obs_only_execution(writable_session) + diagnostic_id = execution.execution_group.diagnostic_id + + assert executions_in_mip_era(writable_session, "CMIP5", diagnostic_id) == [execution.id] + + +def test_mip_id_labels_an_execution_the_inputs_do_not_settle(writable_session): + mixed = _cmip6_execution(writable_session) + mixed.datasets.append(_cmip7_dataset(writable_session)) + obs_only = _obs_only_execution(writable_session) + app_context = SimpleNamespace(session=writable_session) + + collection = annotate_mip_eras( + app_context, + _values( + _value(mixed.id, mip_id="cmip7"), + _value(obs_only.id, mip_id="CMIP6"), + _value(mixed.id), + ), + ) + + assert [v.dimensions.get("mip_era") for v in collection.data] == ["CMIP7", "CMIP6", None] + + +def test_the_inputs_outrank_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"] From 0a8cbc31697f08ad789b5277c4a4fea63851d7d5 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 13:12:52 +1100 Subject: [PATCH 13/28] refactor: scope values by era with or_no_model_input Uses InputDatasetFilter(or_no_model_input=True) for the "this era or no model input" rule. This replaces executions_in_mip_era and the hand-built OR in the ensemble comparison. Fixes an unknown mip_era on the diagnostic values and ensemble routes returning observation-only runs. It now matches nothing, like every other route. Moves the writable_client fixture to the shared conftest so the era tests can use it. --- .../src/ref_backend/api/routes/diagnostics.py | 13 ++--- backend/src/ref_backend/core/mip_eras.py | 47 ++++++++----------- .../src/ref_backend/core/model_ensemble.py | 5 +- backend/tests/conftest.py | 27 +++++++++++ .../test_api/test_routes/test_diagnostics.py | 32 +------------ .../test_routes/test_mip_era_params.py | 47 +++++++++++++++++++ backend/tests/test_core/test_mip_era_edges.py | 21 +++------ 7 files changed, 109 insertions(+), 83 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index 22912822..80dac6c6 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -16,7 +16,6 @@ cmip_dataset_filter, cmip_input_filter, execution_group_filter, - executions_in_mip_era, ) from ref_backend.core.reader_values import ( fetch_metric_values, @@ -364,13 +363,9 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 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) + 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] + input_datasets = cmip_input_filter({"mip_era": mip_era}, or_no_model_input=True) if mip_era else None # Scope to this diagnostic/provider via exact-match slugs. The filter keeps only the # promoted diagnostic version, so values from superseded versions are hidden. Exposing @@ -378,7 +373,9 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 metric_filter = MetricValueFilter( diagnostic_slug=diagnostic_slug, provider_slug=provider_slug, - execution_ids=execution_ids, + # An unknown era matches no execution. + execution_ids=() if mip_era and input_datasets is None else None, + input_datasets=input_datasets, 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, diff --git a/backend/src/ref_backend/core/mip_eras.py b/backend/src/ref_backend/core/mip_eras.py index b42a12d1..fd0b5f77 100644 --- a/backend/src/ref_backend/core/mip_eras.py +++ b/backend/src/ref_backend/core/mip_eras.py @@ -3,7 +3,7 @@ from collections.abc import Collection, Mapping from typing import Any -from sqlalchemy import ColumnElement, false, or_, select +from sqlalchemy import ColumnElement, false, select from sqlalchemy.orm import Session from climate_ref import models @@ -61,13 +61,19 @@ def _facet_values(value: str) -> tuple[str, ...]: return tuple(item.strip() for item in value.split(",") if item.strip()) -def cmip_input_filter(facets: Mapping[str, str]) -> InputDatasetFilter | None: +def cmip_input_filter( + facets: Mapping[str, str], *, or_no_model_input: bool = False +) -> InputDatasetFilter | None: """ 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. Returns `None` when nothing can satisfy the request. + + With `or_no_model_input` an execution with no model input also matches. + It carries no era, so dropping it would lose values rather than label them. + An unknown era still matches nothing. """ eras = eras_in_scope(facets.get("mip_era")) known = { @@ -77,19 +83,26 @@ def cmip_input_filter(facets: Mapping[str, str]) -> InputDatasetFilter | None: } if not eras or any(not any(_carries(era, key) for era in eras) for key in known): return None - return _input_filter(eras, known or None) + return _input_filter(eras, known or None, or_no_model_input=or_no_model_input) def _input_filter( - eras: tuple[SourceDatasetType, ...], facets: Mapping[str, tuple[str, ...]] | None = None + eras: tuple[SourceDatasetType, ...], + facets: Mapping[str, tuple[str, ...]] | None = None, + *, + or_no_model_input: bool = False, ) -> InputDatasetFilter: # The attrs converter leaves mypy with an unbound Iterable[_T_co] for source_types. - return InputDatasetFilter(source_types=eras, facets=facets) # type: ignore[arg-type] + return InputDatasetFilter( + source_types=eras, # type: ignore[arg-type] + facets=facets, + or_no_model_input=or_no_model_input, + ) -def cmip_dataset_filter(facets: Mapping[str, str]) -> ColumnElement[bool]: +def cmip_dataset_filter(facets: Mapping[str, str], *, or_no_model_input: bool = False) -> ColumnElement[bool]: """Match executions as `cmip_input_filter` does, or no execution when nothing can match.""" - input_filter = cmip_input_filter(facets) + input_filter = cmip_input_filter(facets, or_no_model_input=or_no_model_input) return input_filter.execution_clause() if input_filter else false() @@ -114,26 +127,6 @@ def execution_groups_per_era(reader: Reader) -> dict[str, int]: return counts -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 mip_eras_for_executions(session: Session, execution_ids: Collection[int]) -> dict[int, str]: """ Map each execution onto the MIP era of the model datasets it ran against. diff --git a/backend/src/ref_backend/core/model_ensemble.py b/backend/src/ref_backend/core/model_ensemble.py index ae5483c4..9f2e2c84 100644 --- a/backend/src/ref_backend/core/model_ensemble.py +++ b/backend/src/ref_backend/core/model_ensemble.py @@ -5,7 +5,7 @@ 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 @@ -114,8 +114,7 @@ def _scalar_rows( ) ) 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(cmip_dataset_filter({"mip_era": mip_era}, or_no_model_input=True)) return [ ScalarRow(row[0], row[1], row[2], _units(row[3]), tuple(row[4:])) 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/test_api/test_routes/test_diagnostics.py b/backend/tests/test_api/test_routes/test_diagnostics.py index 1adc075c..3d291364 100644 --- a/backend/tests/test_api/test_routes/test_diagnostics.py +++ b/backend/tests/test_api/test_routes/test_diagnostics.py @@ -1,8 +1,4 @@ -import copy -import shutil -from collections.abc import Generator from datetime import timedelta -from pathlib import Path import pytest from fastapi.testclient import TestClient @@ -12,9 +8,7 @@ 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: @@ -933,30 +927,6 @@ 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"] 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 index 7637dcb8..9ce5c881 100644 --- a/backend/tests/test_api/test_routes/test_mip_era_params.py +++ b/backend/tests/test_api/test_routes/test_mip_era_params.py @@ -2,6 +2,11 @@ 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" @@ -63,3 +68,45 @@ def test_the_era_ignores_case(client: TestClient, settings, path, params) -> Non def test_an_empty_era_is_no_filter(client: TestClient, settings, path, params) -> None: url = f"{settings.API_V1_STR}{path}" assert client.get(url, params={**params, "mip_era": ""}).json() == client.get(url, params=params).json() + + +def _add_obs_only_value(config) -> int: + """Add a scalar value from an execution of `DIAGNOSTIC` that ran on observations alone.""" + provider_slug, diagnostic_slug = DIAGNOSTIC.split("/") + with Database.from_config(config, run_migrations=False).session_scope() as session: + group = session.scalars( + select(models.ExecutionGroup) + .join(models.Diagnostic) + .join(models.Provider) + .where(models.Diagnostic.slug == diagnostic_slug, models.Provider.slug == provider_slug) + .limit(1) + ).one() + obs = session.scalars( + select(models.Dataset).where(models.Dataset.dataset_type == SourceDatasetType.obs4MIPs).limit(1) + ).one() + execution = models.Execution( + execution_group_id=group.id, dataset_hash="obs-only", output_fragment="obs-only", successful=True + ) + execution.datasets.append(obs) + session.add(execution) + session.flush() + value = models.ScalarMetricValue.build( + execution_id=execution.id, value=1.0, dimensions={}, attributes=None + ) + session.add(value) + session.flush() + value_id = value.id + session.commit() + return value_id + + +@pytest.mark.parametrize(("mip_era", "included"), [("CMIP6", True), ("cmip7", True), ("CMIP5", False)]) +def test_values_keep_obs_only_runs_for_a_known_era(writable_client: TestClient, mip_era, included) -> 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"]}) is included diff --git a/backend/tests/test_core/test_mip_era_edges.py b/backend/tests/test_core/test_mip_era_edges.py index a8d36b63..9e4f4a25 100644 --- a/backend/tests/test_core/test_mip_era_edges.py +++ b/backend/tests/test_core/test_mip_era_edges.py @@ -12,7 +12,6 @@ cmip_dataset_filter, dataset_model_for, eras_in_scope, - executions_in_mip_era, mip_eras_for_executions, ) from ref_backend.core.reader_values import annotate_mip_eras @@ -81,11 +80,9 @@ def _obs_only_execution(session) -> models.Execution: return execution -def _matches(session, execution_id: int, mip_era: str) -> bool: - return ( - execution_id - in session.scalars(select(models.Execution.id).where(cmip_dataset_filter({"mip_era": mip_era}))).all() - ) +def _matches(session, execution_id: int, mip_era: str, *, or_no_model_input: bool = False) -> bool: + clause = cmip_dataset_filter({"mip_era": mip_era}, or_no_model_input=or_no_model_input) + return execution_id in session.scalars(select(models.Execution.id).where(clause)).all() def _values(*items: SeriesValue) -> MetricValueCollection: @@ -104,30 +101,26 @@ def test_a_mixed_era_execution_has_no_era_but_passes_both_filters(writable_sessi execution = _cmip6_execution(writable_session) execution.datasets.append(_cmip7_dataset(writable_session)) writable_session.flush() - diagnostic_id = execution.execution_group.diagnostic_id assert mip_eras_for_executions(writable_session, [execution.id]) == {} for era in ("CMIP6", "CMIP7"): assert _matches(writable_session, execution.id, era) - assert execution.id in executions_in_mip_era(writable_session, era, diagnostic_id) + assert _matches(writable_session, execution.id, era, or_no_model_input=True) def test_an_execution_without_cmip_input_answers_to_every_era(writable_session): execution = _obs_only_execution(writable_session) - diagnostic_id = execution.execution_group.diagnostic_id assert mip_eras_for_executions(writable_session, [execution.id]) == {} for era in ("CMIP6", "cmip7"): assert not _matches(writable_session, execution.id, era) - assert execution.id in executions_in_mip_era(writable_session, era, diagnostic_id) + assert _matches(writable_session, execution.id, era, or_no_model_input=True) -def test_an_unknown_era_still_keeps_executions_without_cmip_input(writable_session): - """Pins current behaviour: the no-CMIP half of the rule ignores whether the era exists.""" +def test_an_unknown_era_drops_executions_without_cmip_input(writable_session): execution = _obs_only_execution(writable_session) - diagnostic_id = execution.execution_group.diagnostic_id - assert executions_in_mip_era(writable_session, "CMIP5", diagnostic_id) == [execution.id] + assert not _matches(writable_session, execution.id, "CMIP5", or_no_model_input=True) def test_mip_id_labels_an_execution_the_inputs_do_not_settle(writable_session): From 33145ecf981e9a73d78b7f8ece7e92b719702c4a Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 13:14:45 +1100 Subject: [PATCH 14/28] refactor: read catalog resource usage from ResourcesReader.summaries Removes the hand-written resource aggregates from the diagnostic catalog. The roll-up keeps the promoted version only and still counts retracted executions. The mip_era and source_id scope now applies to each execution rather than to its group. --- .../src/ref_backend/api/routes/diagnostics.py | 19 +++++--- .../src/ref_backend/core/resource_usage.py | 43 ++++++------------- .../test_api/test_routes/test_diagnostics.py | 29 +++++++++++++ 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index 80dac6c6..8aefadbd 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -6,7 +6,7 @@ from climate_ref import models from climate_ref.models.metric_value import MetricValueType as StoredMetricValueType -from climate_ref.results import DiagnosticFilter, InputDatasetFilter, MetricValueFilter +from climate_ref.results import DiagnosticFilter, InputDatasetFilter, MetricValueFilter, ResourceFilter from ref_backend.api.deps import AppContextDep from ref_backend.core.metric_values import ( MetricValueType, @@ -21,7 +21,7 @@ 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, @@ -137,13 +137,11 @@ def _value_types(app_context: AppContextDep, scope: _Scope) -> _ValueTypes: def _catalog_entries(app_context: AppContextDep, scope: _Scope) -> list[DiagnosticCatalogEntry]: group_scope = scope.group_scope - # 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) @@ -154,7 +152,16 @@ def _catalog_entries(app_context: AppContextDep, scope: _Scope) -> list[Diagnost 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} + resource_usage = ( + {} + if scope.matches_nothing + else resource_usage_by_diagnostic( + app_context.reader, + ResourceFilter( + include_superseded=False, include_retracted=True, input_datasets=scope.input_datasets + ), + ) + ) group_stats = ( {} @@ -175,7 +182,7 @@ def _catalog_entries(app_context: AppContextDep, scope: _Scope) -> list[Diagnost execution_stats=execution_stats.get(m.id, {"total": 0, "successful": 0}), execution_group_count=stats.total if stats else 0, successful_execution_group_count=stats.successful if stats else 0, - resource_usage=resource_usage.get(m.id), + resource_usage=resource_usage.get((m.provider.slug, m.slug)), ) ) return entries diff --git a/backend/src/ref_backend/core/resource_usage.py b/backend/src/ref_backend/core/resource_usage.py index fc2cccfd..bc050862 100644 --- a/backend/src/ref_backend/core/resource_usage.py +++ b/backend/src/ref_backend/core/resource_usage.py @@ -1,13 +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 climate_ref import models from climate_ref.results import Reader, ResourceFilter +from climate_ref.results.resources import ResourceSummary class ExecutionResourceSummary(BaseModel): @@ -54,33 +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(reader: Reader, filters: ResourceFilter) -> ExecutionResourceSummary | None: """Roll up the resource usage of the executions matching ``filters``.""" summary = reader.resources.summary(filters) - if summary is None: - return None - return ExecutionResourceSummary.model_validate(attrs.asdict(summary)) + return _summary(summary) if summary else None + + +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/tests/test_api/test_routes/test_diagnostics.py b/backend/tests/test_api/test_routes/test_diagnostics.py index 3d291364..f66414b7 100644 --- a/backend/tests/test_api/test_routes/test_diagnostics.py +++ b/backend/tests/test_api/test_routes/test_diagnostics.py @@ -952,6 +952,35 @@ def test_diagnostic_counts_are_scoped_to_the_promoted_version(writable_client: T assert relisted["execution_group_count"] == 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"] From 029ee437bf6e3ff803bb338de23c7a63eb850e42 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 13:15:54 +1100 Subject: [PATCH 15/28] fix: treat a blank isolate_ids or exclude_ids as no filter climate-ref now reads an empty id list as matching nothing. So `?isolate_ids=,` would have hidden every value instead of isolating none. parse_id_list now returns None for a missing or blank list. --- backend/src/ref_backend/api/routes/diagnostics.py | 4 ++-- backend/src/ref_backend/api/routes/executions.py | 4 ++-- backend/src/ref_backend/core/metric_values.py | 11 ++++++++--- .../tests/test_api/test_routes/test_diagnostics.py | 10 ++++++++++ backend/tests/test_core/test_metric_values.py | 4 ++++ 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index 8aefadbd..64be06f8 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -384,8 +384,8 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 execution_ids=() if mip_era and input_datasets is None else None, input_datasets=input_datasets, 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, + isolate_ids=parse_id_list(isolate_ids), + exclude_ids=parse_id_list(exclude_ids), ) return fetch_metric_values( diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 87719608..2e94e38e 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -335,8 +335,8 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 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, + isolate_ids=parse_id_list(isolate_ids), + exclude_ids=parse_id_list(exclude_ids), ) return fetch_metric_values( diff --git a/backend/src/ref_backend/core/metric_values.py b/backend/src/ref_backend/core/metric_values.py index 8f902778..61be3c0a 100644 --- a/backend/src/ref_backend/core/metric_values.py +++ b/backend/src/ref_backend/core/metric_values.py @@ -12,9 +12,14 @@ class MetricValueType(StrEnum): SERIES = "series" -def parse_id_list(id_str: str) -> list[int]: - """Parse comma-separated list of IDs into integers.""" +def parse_id_list(id_str: str | None) -> list[int] | None: + """ + Parse comma-separated list of IDs into integers. + + A missing or blank list is `None`, because the reader reads an empty list as matching nothing. + """ try: - return [int(i.strip()) for i in id_str.split(",") if i.strip()] + 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/tests/test_api/test_routes/test_diagnostics.py b/backend/tests/test_api/test_routes/test_diagnostics.py index f66414b7..04e42555 100644 --- a/backend/tests/test_api/test_routes/test_diagnostics.py +++ b/backend/tests/test_api/test_routes/test_diagnostics.py @@ -1083,3 +1083,13 @@ 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 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: From 79323cd7c4fc1ed85f853534455b730a1de2a39b Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 13:17:10 +1100 Subject: [PATCH 16/28] fix: return an empty model for an unknown mip_era An unknown era on /models/{source_id} now gives the model with no runs rather than a 404. This matches the empty lists every other route returns for an unknown era. A missing model, or a known era the model has no runs in, still gives a 404, because the model page uses it to say there are no runs in that era. --- backend/src/ref_backend/api/routes/models.py | 4 +++- .../test_routes/test_mip_era_params.py | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/backend/src/ref_backend/api/routes/models.py b/backend/src/ref_backend/api/routes/models.py index 06d3d1e3..70db5f11 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 eras_in_scope from ref_backend.core.model_ensemble import ensemble_comparisons from ref_backend.core.model_runs import ( ModelFacets, @@ -80,7 +81,8 @@ async def get( Summarise the runs a single model took part in, broken down by diagnostic. """ rows = model_run_rows(session, mip_era=mip_era, source_id=source_id) - if not rows: + # An unknown era gives an empty model, as it does an empty list on the other routes. + if not rows and (eras_in_scope(mip_era) or not model_run_rows(session, source_id=source_id)): raise HTTPException(status_code=404, detail="Model not found") by_diagnostic: dict[int, list[ModelRunRow]] = defaultdict(list) 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 index 9ce5c881..c99d4565 100644 --- a/backend/tests/test_api/test_routes/test_mip_era_params.py +++ b/backend/tests/test_api/test_routes/test_mip_era_params.py @@ -47,9 +47,24 @@ def test_an_unknown_era_matches_nothing(client: TestClient, settings, path, para assert amount(unknown.json()) == 0 -def test_an_unknown_era_on_a_model_is_not_found(client: TestClient, settings) -> None: +def test_an_unknown_era_on_a_model_is_an_empty_model(client: TestClient, settings) -> None: r = client.get(f"{settings.API_V1_STR}/models/{MODEL}", params={"mip_era": "CMIP5"}) - assert r.status_code == 404 + + assert r.status_code == 200 + body = r.json() + assert body["source_id"] == MODEL + assert body["diagnostics"] == body["failures"] == body["mip_eras"] == [] + assert body["execution_groups"]["total"] == 0 + + +@pytest.mark.parametrize( + ("source_id", "mip_era"), [("NOT-A-MODEL", None), ("NOT-A-MODEL", "CMIP5"), (MODEL, "CMIP7")] +) +def test_a_missing_model_or_a_known_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 @pytest.mark.parametrize( From 4efb05c2b2ad6afca372bbbd6056e7800e519568 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 13:18:05 +1100 Subject: [PATCH 17/28] test: stop pytest collecting the test config helpers as tests Test modules import test_settings and test_ref_config by name, so pytest ran them as tests. Each one returned a value and raised PytestReturnNotNoneWarning. --- backend/src/ref_backend/testing.py | 5 +++++ 1 file changed, 5 insertions(+) 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] From b2b0861ed9d11737060f843b8e003c558c3aa3fc Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 13:51:01 +1100 Subject: [PATCH 18/28] refactor: build the last era filters from cmip_input_filter --- backend/src/ref_backend/api/routes/diagnostics.py | 11 ++++++----- backend/src/ref_backend/core/mip_eras.py | 8 +------- backend/src/ref_backend/core/model_ensemble.py | 7 +++++-- backend/tests/test_core/test_mip_era_edges.py | 7 +++++-- backend/tests/test_core/test_mip_eras.py | 6 ++++-- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index 64be06f8..404864b1 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -13,7 +13,6 @@ parse_id_list, ) from ref_backend.core.mip_eras import ( - cmip_dataset_filter, cmip_input_filter, execution_group_filter, ) @@ -328,16 +327,18 @@ async def list_executions( e.g. `?source_id=MIROC6&experiment_id=ssp585`. Pass `mip_era` to restrict to one era. """ diagnostic = await _get_diagnostic(app_context, provider_slug, diagnostic_slug) + input_filter = cmip_input_filter(dict(request.query_params)) + if input_filter is None: + return Collection(data=[]) - executions_query = ( + executions = ( app_context.session.query(models.Execution) .join(models.ExecutionGroup) .filter(models.ExecutionGroup.diagnostic_id == diagnostic.id) - .filter(cmip_dataset_filter(dict(request.query_params))) + .filter(input_filter.execution_clause()) + .all() ) - executions = executions_query.all() - return Collection(data=[Execution.build(e, app_context) for e in executions]) diff --git a/backend/src/ref_backend/core/mip_eras.py b/backend/src/ref_backend/core/mip_eras.py index fd0b5f77..0e5423af 100644 --- a/backend/src/ref_backend/core/mip_eras.py +++ b/backend/src/ref_backend/core/mip_eras.py @@ -100,14 +100,8 @@ def _input_filter( ) -def cmip_dataset_filter(facets: Mapping[str, str], *, or_no_model_input: bool = False) -> ColumnElement[bool]: - """Match executions as `cmip_input_filter` does, or no execution when nothing can match.""" - input_filter = cmip_input_filter(facets, or_no_model_input=or_no_model_input) - return input_filter.execution_clause() if input_filter else false() - - def execution_group_filter(facets: Mapping[str, str]) -> ColumnElement[bool]: - """Match execution groups holding an execution that satisfies `cmip_dataset_filter`.""" + """Match execution groups holding an execution that satisfies `cmip_input_filter`.""" input_filter = cmip_input_filter(facets) return input_filter.execution_group_clause() if input_filter else false() diff --git a/backend/src/ref_backend/core/model_ensemble.py b/backend/src/ref_backend/core/model_ensemble.py index 9f2e2c84..f67f7c76 100644 --- a/backend/src/ref_backend/core/model_ensemble.py +++ b/backend/src/ref_backend/core/model_ensemble.py @@ -10,7 +10,7 @@ from climate_ref import models from climate_ref.models.execution import select_latest_executions -from ref_backend.core.mip_eras import cmip_dataset_filter, cv_column +from ref_backend.core.mip_eras import cmip_input_filter, cv_column from ref_backend.models.model_runs import EnsembleComparison, EnsembleStatistics #: Dimensions that say which run produced a value rather than what the value measures. @@ -114,7 +114,10 @@ def _scalar_rows( ) ) if mip_era: - statement = statement.where(cmip_dataset_filter({"mip_era": mip_era}, or_no_model_input=True)) + input_filter = cmip_input_filter({"mip_era": mip_era}, or_no_model_input=True) + if input_filter is None: + return [] + statement = statement.where(input_filter.execution_clause()) return [ ScalarRow(row[0], row[1], row[2], _units(row[3]), tuple(row[4:])) diff --git a/backend/tests/test_core/test_mip_era_edges.py b/backend/tests/test_core/test_mip_era_edges.py index 9e4f4a25..50ef12db 100644 --- a/backend/tests/test_core/test_mip_era_edges.py +++ b/backend/tests/test_core/test_mip_era_edges.py @@ -9,7 +9,7 @@ from climate_ref_core.datasets import SourceDatasetType from ref_backend.core.mip_eras import ( CMIP_ERAS, - cmip_dataset_filter, + cmip_input_filter, dataset_model_for, eras_in_scope, mip_eras_for_executions, @@ -81,7 +81,10 @@ def _obs_only_execution(session) -> models.Execution: def _matches(session, execution_id: int, mip_era: str, *, or_no_model_input: bool = False) -> bool: - clause = cmip_dataset_filter({"mip_era": mip_era}, or_no_model_input=or_no_model_input) + input_filter = cmip_input_filter({"mip_era": mip_era}, or_no_model_input=or_no_model_input) + if input_filter is None: + return False + clause = input_filter.execution_clause() return execution_id in session.scalars(select(models.Execution.id).where(clause)).all() diff --git a/backend/tests/test_core/test_mip_eras.py b/backend/tests/test_core/test_mip_eras.py index 83c51a16..b9b32d7a 100644 --- a/backend/tests/test_core/test_mip_eras.py +++ b/backend/tests/test_core/test_mip_eras.py @@ -5,7 +5,6 @@ from climate_ref.database import Database from ref_backend.core.mip_eras import ( CMIP_ERAS, - cmip_dataset_filter, cmip_input_filter, cv_column, dataset_model_for, @@ -19,7 +18,10 @@ def session(): def _count(session, facets) -> int: - return session.scalar(select(func.count(models.Execution.id)).where(cmip_dataset_filter(facets))) + input_filter = cmip_input_filter(facets) + if input_filter is None: + return 0 + return session.scalar(select(func.count(models.Execution.id)).where(input_filter.execution_clause())) def _a_source_id(session) -> str: From e1c2ae786f71ae8635723326b3001450b6a5a4e8 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 17:26:43 +1100 Subject: [PATCH 19/28] refactor(api)!: drop the per-attempt execution counts from diagnostics Removes execution_count and successful_execution_count from the diagnostic list and detail responses. Nothing in the frontend read them, and the group counts come from the reader. --- .../src/ref_backend/api/routes/diagnostics.py | 36 +---------------- backend/src/ref_backend/models/diagnostics.py | 40 +------------------ .../test_api/test_routes/test_diagnostics.py | 4 +- frontend/src/client/types.gen.ts | 16 -------- frontend/src/lib/resourceUsageCsv.test.ts | 2 - 5 files changed, 4 insertions(+), 94 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index 404864b1..70c6d748 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -1,7 +1,7 @@ from typing import Literal, NamedTuple from fastapi import APIRouter, HTTPException, Query, Request -from sqlalchemy import ColumnElement, Integer, false, func, select +from sqlalchemy import func, select from starlette.responses import StreamingResponse from climate_ref import models @@ -78,7 +78,6 @@ class _Scope(NamedTuple): diagnostics: list[models.Diagnostic] input_datasets: InputDatasetFilter | None matches_nothing: bool - group_scope: list[ColumnElement[bool]] def _scoped_diagnostics(app_context: AppContextDep, mip_era: str | None, source_id: str | None) -> _Scope: @@ -92,21 +91,7 @@ def _scoped_diagnostics(app_context: AppContextDep, mip_era: str | None, source_ facets = {key: value for key, value in {"mip_era": mip_era, "source_id": source_id}.items() if value} input_datasets = cmip_input_filter(facets) if facets else None matches_nothing = not diagnostics or (bool(facets) and input_datasets is None) - - 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_([d.id for d in diagnostics]), - models.ExecutionGroup.diagnostic_version == promoted_version, - ] - if input_datasets: - group_scope.append(input_datasets.execution_group_clause()) - if matches_nothing: - group_scope.append(false()) - return _Scope(diagnostics, input_datasets, matches_nothing, group_scope) + return _Scope(diagnostics, input_datasets, matches_nothing) def _diagnostics_with_values( @@ -135,22 +120,6 @@ def _value_types(app_context: AppContextDep, scope: _Scope) -> _ValueTypes: def _catalog_entries(app_context: AppContextDep, scope: _Scope) -> list[DiagnosticCatalogEntry]: - group_scope = scope.group_scope - 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"), - ) - .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 = ( {} if scope.matches_nothing @@ -178,7 +147,6 @@ def _catalog_entries(app_context: AppContextDep, scope: _Scope) -> list[Diagnost DiagnosticCatalogEntry.build_with_stats( m, app_context, - execution_stats=execution_stats.get(m.id, {"total": 0, "successful": 0}), execution_group_count=stats.total if stats else 0, successful_execution_group_count=stats.successful if stats else 0, resource_usage=resource_usage.get((m.provider.slug, m.slug)), diff --git a/backend/src/ref_backend/models/diagnostics.py b/backend/src/ref_backend/models/diagnostics.py index b5dad6fe..0177e30e 100644 --- a/backend/src/ref_backend/models/diagnostics.py +++ b/backend/src/ref_backend/models/diagnostics.py @@ -67,14 +67,6 @@ class DiagnosticCatalogEntry(BaseModel): """ List of IDs for the provider executions associated with this provider """ - execution_count: int - """ - 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 @@ -185,11 +177,10 @@ 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, resource_usage: ExecutionResourceSummary | None = None, @@ -210,8 +201,6 @@ 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, promoted_version=diagnostic.promoted_version, @@ -265,31 +254,6 @@ def has_values(value_type: StoredMetricValueType) -> bool: 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, - ) - - # 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() - ) - # The reader only matches slugs as substrings, so pick the exact one out. group_stats = next( ( @@ -327,8 +291,6 @@ def has_values(value_type: StoredMetricValueType) -> bool: 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, diff --git a/backend/tests/test_api/test_routes/test_diagnostics.py b/backend/tests/test_api/test_routes/test_diagnostics.py index 04e42555..42f0eaf6 100644 --- a/backend/tests/test_api/test_routes/test_diagnostics.py +++ b/backend/tests/test_api/test_routes/test_diagnostics.py @@ -878,7 +878,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 @@ -944,7 +944,6 @@ def test_diagnostic_counts_are_scoped_to_the_promoted_version(writable_client: T 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 relisted = next( d for d in writable_client.get("/api/v1/diagnostics/").json()["data"] if d["id"] == diagnostic["id"] @@ -1023,7 +1022,6 @@ def test_diagnostics_catalog_with_an_unknown_era_counts_nothing(client: TestClie assert catalog["data"] assert all(d["execution_group_count"] == 0 for d in catalog["data"]) assert all(d["successful_execution_group_count"] == 0 for d in catalog["data"]) - assert all(d["execution_count"] == 0 for d in catalog["data"]) FLAGS = ("has_metric_values", "has_scalar_values", "has_series_values") diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 7ad35540..d6b68756 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -681,14 +681,6 @@ export type DiagnosticCatalogEntry = { * Execution Groups */ execution_groups: Array; - /** - * Execution Count - */ - execution_count: number; - /** - * Successful Execution Count - */ - successful_execution_count: number; /** * Execution Group Count */ @@ -776,14 +768,6 @@ export type DiagnosticSummary = { * Execution Groups */ execution_groups: Array; - /** - * Execution Count - */ - execution_count: number; - /** - * Successful Execution Count - */ - successful_execution_count: number; /** * Execution Group Count */ diff --git a/frontend/src/lib/resourceUsageCsv.test.ts b/frontend/src/lib/resourceUsageCsv.test.ts index a8d58735..969b6ed8 100644 --- a/frontend/src/lib/resourceUsageCsv.test.ts +++ b/frontend/src/lib/resourceUsageCsv.test.ts @@ -9,8 +9,6 @@ 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, promoted_version: 1, From a83b615919e9d1186b7851460a52f9a801ecc950 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Thu, 22 Oct 2026 17:42:52 +1100 Subject: [PATCH 20/28] refactor(api)!: remove the /diagnostics/facets endpoint Nothing in the frontend called it, and it read every value without the version or retraction rules /values applies. --- .../src/ref_backend/api/routes/diagnostics.py | 27 --------- backend/src/ref_backend/models/__init__.py | 2 - backend/src/ref_backend/models/values.py | 15 ----- backend/tests/perf/test_endpoints.py | 1 - .../test_api/test_routes/test_diagnostics.py | 55 ------------------- .../src/client/@tanstack/react-query.gen.ts | 24 +------- frontend/src/client/index.ts | 4 +- frontend/src/client/sdk.gen.ts | 9 +-- frontend/src/client/types.gen.ts | 34 ------------ 9 files changed, 5 insertions(+), 166 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index 70c6d748..cecba4d1 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -1,7 +1,6 @@ from typing import Literal, NamedTuple from fastapi import APIRouter, HTTPException, Query, Request -from sqlalchemy import func, select from starlette.responses import StreamingResponse from climate_ref import models @@ -29,7 +28,6 @@ Execution, ExecutionGroup, MetricValueCollection, - MetricValueFacetSummary, ) from ref_backend.models.executions import EXECUTION_GROUP_LOAD_OPTIONS @@ -219,31 +217,6 @@ async def value_flags( ) -@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: """ diff --git a/backend/src/ref_backend/models/__init__.py b/backend/src/ref_backend/models/__init__.py index d4c6d3e4..1b8c9d86 100644 --- a/backend/src/ref_backend/models/__init__.py +++ b/backend/src/ref_backend/models/__init__.py @@ -34,7 +34,6 @@ NON_FACET_DIMENSIONS, Facet, MetricValueCollection, - MetricValueFacetSummary, ScalarValue, SeriesValue, ) @@ -62,7 +61,6 @@ "FailedRun", "GroupBy", "MetricValueCollection", - "MetricValueFacetSummary", "ModelDetail", "ModelSummary", "ProviderSummary", diff --git a/backend/src/ref_backend/models/values.py b/backend/src/ref_backend/models/values.py index 48b3bfbc..5d31cc66 100644 --- a/backend/src/ref_backend/models/values.py +++ b/backend/src/ref_backend/models/values.py @@ -184,18 +184,3 @@ def build_series_from_reader( 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/tests/perf/test_endpoints.py b/backend/tests/perf/test_endpoints.py index 1123d8ea..f091fa05 100644 --- a/backend/tests/perf/test_endpoints.py +++ b/backend/tests/perf/test_endpoints.py @@ -39,7 +39,6 @@ class Endpoint: ), Endpoint(id="diagnostics.catalog", path="diagnostics/catalog"), 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"}), diff --git a/backend/tests/test_api/test_routes/test_diagnostics.py b/backend/tests/test_api/test_routes/test_diagnostics.py index 42f0eaf6..f167608f 100644 --- a/backend/tests/test_api/test_routes/test_diagnostics.py +++ b/backend/tests/test_api/test_routes/test_diagnostics.py @@ -583,30 +583,6 @@ 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) -> set[int]: query = ( session.query(models.ExecutionGroup.diagnostic_id) @@ -667,26 +643,6 @@ def test_retracted_values_are_hidden(writable_client: TestClient) -> None: assert values["data"] == [] -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. @@ -780,17 +736,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) diff --git a/frontend/src/client/@tanstack/react-query.gen.ts b/frontend/src/client/@tanstack/react-query.gen.ts index 5ca6a7e4..cacbe28b 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, 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, 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'; export type QueryKey = [ Pick & { @@ -308,26 +308,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); /** diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index b09315af..fa16c3ca 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, 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, 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, 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..c0ef8ffe 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, 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'; export type Options = Options2 & { /** @@ -88,13 +88,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 * diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index d6b68756..286a79ac 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1299,24 +1299,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 * @@ -2326,22 +2308,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: { From 2878e5265e4592f05058882726cffa9f43c29f77 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Sat, 24 Oct 2026 11:17:09 +1100 Subject: [PATCH 21/28] refactor(api)!: build on climate-ref's query package with explicit filters Every reader call now names its version, retraction and latest-execution scope. - `mip_era` takes only `CMIP6` or `CMIP7` and rejects anything else with a 422. It keeps the groups that draw on that era, from their recorded source types. - Diagnostics report `groups` (total, successful, failed, running, not_started, dirty) in place of `execution_group_count` and `successful_execution_group_count`. - `/executions/statistics` reports `groups` and `groups_by_mip_era` in place of the flat counts. - `/diagnostics/{provider}/{diagnostic}/execution_groups` keeps only the promoted version and leaves out groups whose latest execution is retracted. - `/datasets/{id}/executions` lists the groups whose latest execution used the dataset. - Removes `/diagnostics/{provider}/{diagnostic}/executions`, which the frontend did not call. - An unknown `failure_reason` on `/executions/` is a 422 rather than an empty list. --- .../src/ref_backend/api/routes/datasets.py | 33 ++- .../src/ref_backend/api/routes/diagnostics.py | 226 +++++++---------- .../src/ref_backend/api/routes/executions.py | 162 +++++------- backend/src/ref_backend/api/routes/models.py | 11 +- .../src/ref_backend/core/execution_groups.py | 44 ++++ backend/src/ref_backend/core/mip_eras.py | 140 ++++------- .../src/ref_backend/core/model_ensemble.py | 13 +- backend/src/ref_backend/core/model_runs.py | 12 +- backend/src/ref_backend/models/__init__.py | 3 +- backend/src/ref_backend/models/common.py | 43 +++- backend/src/ref_backend/models/datasets.py | 4 +- backend/src/ref_backend/models/diagnostics.py | 101 +++----- backend/src/ref_backend/models/executions.py | 54 ++-- .../test_api/test_routes/test_diagnostics.py | 134 +--------- .../test_api/test_routes/test_executions.py | 49 ++-- .../test_routes/test_mip_era_params.py | 81 +++--- backend/tests/test_core/test_mip_era_edges.py | 114 +++------ backend/tests/test_core/test_mip_eras.py | 67 ----- .../src/client/@tanstack/react-query.gen.ts | 86 +++---- frontend/src/client/index.ts | 4 +- frontend/src/client/sdk.gen.ts | 48 ++-- frontend/src/client/types.gen.ts | 235 +++++++----------- .../dashboard/executionStats.test.tsx | 26 +- .../components/dashboard/executionStats.tsx | 8 +- .../datasets/diagnosticSummaryTable.tsx | 4 +- .../components/diagnostics/diagnosticCard.tsx | 3 +- frontend/src/lib/failures.test.ts | 3 +- frontend/src/lib/failures.ts | 4 +- frontend/src/lib/resourceUsageCsv.test.ts | 10 +- .../route.tsx | 3 +- 30 files changed, 651 insertions(+), 1074 deletions(-) create mode 100644 backend/src/ref_backend/core/execution_groups.py delete mode 100644 backend/tests/test_core/test_mip_eras.py diff --git a/backend/src/ref_backend/api/routes/datasets.py b/backend/src/ref_backend/api/routes/datasets.py index 4cd69306..785f8b44 100644 --- a/backend/src/ref_backend/api/routes/datasets.py +++ b/backend/src/ref_backend/api/routes/datasets.py @@ -3,17 +3,16 @@ from fastapi import APIRouter, HTTPException, Query -from climate_ref import models -from climate_ref.results import DatasetFilter +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 +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"]) @@ -100,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 cecba4d1..9a17cc0b 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -5,16 +5,14 @@ from climate_ref import models from climate_ref.models.metric_value import MetricValueType as StoredMetricValueType -from climate_ref.results import DiagnosticFilter, InputDatasetFilter, MetricValueFilter, ResourceFilter +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_input_filter, - execution_group_filter, -) +from ref_backend.core.mip_eras import MipEra, source_id_datasets, source_types_for from ref_backend.core.reader_values import ( fetch_metric_values, parse_dimension_filters, @@ -25,11 +23,9 @@ DiagnosticCatalogEntry, DiagnosticSummary, DiagnosticValueFlags, - Execution, ExecutionGroup, MetricValueCollection, ) -from ref_backend.models.executions import EXECUTION_GROUP_LOAD_OPTIONS router = APIRouter(prefix="/diagnostics", tags=["diagnostics"]) @@ -71,97 +67,76 @@ def _listed_diagnostics(app_context: AppContextDep) -> list[models.Diagnostic]: class _Scope(NamedTuple): - """The listed diagnostics, and which of their execution groups count towards them""" + """The listed diagnostics, and the filter that picks which of their execution groups count""" diagnostics: list[models.Diagnostic] - input_datasets: InputDatasetFilter | None - matches_nothing: bool - - -def _scoped_diagnostics(app_context: AppContextDep, mip_era: str | None, source_id: str | None) -> _Scope: - """ - 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) - # 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} - input_datasets = cmip_input_filter(facets) if facets else None - matches_nothing = not diagnostics or (bool(facets) and input_datasets is None) - return _Scope(diagnostics, input_datasets, matches_nothing) - - -def _diagnostics_with_values( - app_context: AppContextDep, value_type: StoredMetricValueType, scope: _Scope -) -> set[int]: - if scope.matches_nothing: - return set() - pairs = app_context.reader.diagnostics.with_values( - value_type, DiagnosticFilter(input_datasets=scope.input_datasets) + filters: DiagnosticFilter + + +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, + source_types=source_types_for(mip_era), + input_datasets=source_id_datasets(source_id), + ), ) - return {d.id for d in scope.diagnostics if (d.provider.slug, d.slug) in pairs} 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, scope: _Scope) -> _ValueTypes: + with_values = app_context.reader.diagnostics.with_values return _ValueTypes( - scalar=_diagnostics_with_values(app_context, StoredMetricValueType.SCALAR, scope), - series=_diagnostics_with_values(app_context, StoredMetricValueType.SERIES, scope), + scalar=with_values(StoredMetricValueType.SCALAR, scope.filters), + series=with_values(StoredMetricValueType.SERIES, scope.filters), ) -def _catalog_entries(app_context: AppContextDep, scope: _Scope) -> list[DiagnosticCatalogEntry]: - resource_usage = ( - {} - if scope.matches_nothing - else resource_usage_by_diagnostic( - app_context.reader, - ResourceFilter( - include_superseded=False, include_retracted=True, input_datasets=scope.input_datasets - ), - ) - ) +def _key(diagnostic: models.Diagnostic | DiagnosticCatalogEntry) -> tuple[str, str]: + return diagnostic.provider.slug, diagnostic.slug - group_stats = ( - {} - if scope.matches_nothing - else { - (stats.provider, stats.diagnostic): stats - for stats in app_context.reader.diagnostics.stats(input_datasets=scope.input_datasets) - } - ) - entries = [] - for m in scope.diagnostics: - stats = group_stats.get((m.provider.slug, m.slug)) - entries.append( - DiagnosticCatalogEntry.build_with_stats( - m, - app_context, - execution_group_count=stats.total if stats else 0, - successful_execution_group_count=stats.successful if stats else 0, - resource_usage=resource_usage.get((m.provider.slug, m.slug)), - ) +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, + input_datasets=scope.filters.input_datasets, + ), + ) + views = { + (view.provider_slug, view.diagnostic_slug): view + for view in app_context.reader.diagnostics.list(scope.filters, with_count=False) + } + return [ + DiagnosticCatalogEntry.build_with_stats( + m, app_context, view=views.get(_key(m)), resource_usage=resource_usage.get(_key(m)) ) - return entries + 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. """ scope = _scoped_diagnostics(app_context, mip_era, source_id) value_types = _value_types(app_context, scope) @@ -169,8 +144,8 @@ async def _list( 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, scope) ] @@ -179,15 +154,15 @@ async def _list( @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. """ scope = _scoped_diagnostics(app_context, mip_era, source_id) return Collection(data=_catalog_entries(app_context, scope)) @@ -195,7 +170,9 @@ async def catalog( @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 @@ -208,9 +185,9 @@ async def value_flags( 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 scope.diagnostics ] @@ -229,58 +206,29 @@ 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 -) -> Collection[ExecutionGroup]: - """ - Fetch execution groups for a diagnostic. - - Pass `mip_era` to keep only the groups that ran against that era. - """ - 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() - ) - - return Collection(data=[ExecutionGroup.build(e, app_context) for e in execution_groups]) - - -@router.get( - "/{provider_slug}/{diagnostic_slug}/executions", - response_model=Collection[Execution], -) -async def list_executions( app_context: AppContextDep, provider_slug: str, diagnostic_slug: str, - request: Request, -) -> Collection[Execution]: + mip_era: MipEra | None = Query(None, description="Keep only the execution groups that draw on this era"), +) -> Collection[ExecutionGroup]: """ - Fetch executions for a specific diagnostic, with arbitrary filters on the dataset. + Fetch the execution groups of a diagnostic, at its promoted version - e.g. `?source_id=MIROC6&experiment_id=ssp585`. Pass `mip_era` to restrict to one era. + Groups whose latest execution is retracted are left out, matching the counts. """ - diagnostic = await _get_diagnostic(app_context, provider_slug, diagnostic_slug) - input_filter = cmip_input_filter(dict(request.query_params)) - if input_filter is None: - return Collection(data=[]) - - executions = ( - app_context.session.query(models.Execution) - .join(models.ExecutionGroup) - .filter(models.ExecutionGroup.diagnostic_id == diagnostic.id) - .filter(input_filter.execution_clause()) - .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, + source_types=source_types_for(mip_era), + ), + with_count=False, ) - - return Collection(data=[Execution.build(e, app_context) for e in executions]) + return Collection(data=[ExecutionGroup.build(g, app_context) for g in groups], total_count=len(groups)) @router.get("/{provider_slug}/{diagnostic_slug}/values", response_model=MetricValueCollection) @@ -299,7 +247,9 @@ 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" + ), ) -> MetricValueCollection | StreamingResponse: """ Get all the diagnostic values for a given diagnostic (both scalar and series) @@ -308,23 +258,23 @@ 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 + - `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. """ # Validates the provider/diagnostic exist and are not excluded (raises 404 otherwise). await _get_diagnostic(app_context, provider_slug, diagnostic_slug) - input_datasets = cmip_input_filter({"mip_era": mip_era}, or_no_model_input=True) if mip_era else None - - # Scope to this diagnostic/provider via exact-match slugs. The filter keeps only the - # promoted diagnostic version, so values from superseded versions are hidden. Exposing - # previous versions needs a separate design (TODO). Retracted executions are hidden. metric_filter = MetricValueFilter( diagnostic_slug=diagnostic_slug, provider_slug=provider_slug, - # An unknown era matches no execution. - execution_ids=() if mip_era and input_datasets is None else None, - input_datasets=input_datasets, + include_superseded=False, + include_retracted=False, + latest_only=False, + source_types=source_types_for(mip_era), + or_no_model_input=mip_era is not None, dimensions=parse_dimension_filters(request.query_params), isolate_ids=parse_id_list(isolate_ids), exclude_ids=parse_id_list(exclude_ids), diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 2e94e38e..704b6dc0 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -2,27 +2,29 @@ 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.orm import Session from starlette.responses import Response, StreamingResponse from climate_ref import models from climate_ref.models.dataset import DatasetFile from climate_ref.models.execution import ExecutionFailureReason -from climate_ref.results import ExecutionGroupFilter, MetricValueFilter, ResourceFilter +from climate_ref.query import select_group_counts +from climate_ref.results import DiagnosticFilter, ExecutionGroupFilter, MetricValueFilter, ResourceFilter +from climate_ref_core.datasets import SourceDatasetType 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 cmip_input_filter, execution_groups_per_era +from ref_backend.core.mip_eras import MipEra, source_id_datasets, source_types_for from ref_backend.core.reader_values import ( fetch_metric_values, parse_dimension_filters, @@ -34,12 +36,9 @@ Execution, ExecutionGroup, ExecutionStats, + 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 +50,60 @@ 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(source_types: tuple[SourceDatasetType, ...] | None = None) -> GroupCounts: + filters = DiagnosticFilter( + include_superseded=False, include_retracted=False, source_types=source_types + ) + 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(app_context.reader), + groups=group_counts(), + groups_by_mip_era={era: group_counts(source_types_for(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(app_context.reader, ResourceFilter(include_retracted=True)), + 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,67 +111,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 - - # Selectors do not record source_id, so match on the datasets used. - facets = {key: value for key, value in {"source_id": source_id, "mip_era": mip_era}.items() if value} - input_datasets = cmip_input_filter(facets) if facets else None - try: - failure_reasons = [ExecutionFailureReason(failure_reason)] if failure_reason is not None else None - except ValueError: - return Collection(total_count=0, data=[]) - if facets and input_datasets is None: - return Collection(total_count=0, data=[]) - 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_reasons, - input_datasets=input_datasets, + failure_reasons=failure_reason, + source_types=source_types_for(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, with_count=False), - 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, ) - - 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( @@ -325,15 +296,16 @@ 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) + + 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. The filter keeps only the - # promoted diagnostic version, so values from superseded versions are hidden. Exposing - # previous versions needs a separate design (TODO). Retracted executions are hidden. metric_filter = MetricValueFilter( - execution_ids=[execution.id], + 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), diff --git a/backend/src/ref_backend/api/routes/models.py b/backend/src/ref_backend/api/routes/models.py index 70db5f11..74e63f0d 100644 --- a/backend/src/ref_backend/api/routes/models.py +++ b/backend/src/ref_backend/api/routes/models.py @@ -5,7 +5,7 @@ from climate_ref import models from ref_backend.api.deps import SessionDep -from ref_backend.core.mip_eras import eras_in_scope +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, @@ -53,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. @@ -75,14 +75,13 @@ 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. """ rows = model_run_rows(session, mip_era=mip_era, source_id=source_id) - # An unknown era gives an empty model, as it does an empty list on the other routes. - if not rows and (eras_in_scope(mip_era) or not model_run_rows(session, source_id=source_id)): + if not rows: raise HTTPException(status_code=404, detail="Model not found") by_diagnostic: dict[int, list[ModelRunRow]] = defaultdict(list) @@ -133,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/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/mip_eras.py b/backend/src/ref_backend/core/mip_eras.py index 0e5423af..36594360 100644 --- a/backend/src/ref_backend/core/mip_eras.py +++ b/backend/src/ref_backend/core/mip_eras.py @@ -1,17 +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, false, select +from sqlalchemy import ColumnElement, select from sqlalchemy.orm import Session from climate_ref import models -from climate_ref.results import InputDatasetFilter, Reader +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 +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]: @@ -28,122 +37,61 @@ 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() - + return _TYPE_ERAS.get(source_type) -def eras_in_scope(mip_era: str | None) -> tuple[SourceDatasetType, ...]: - """ - Resolve a `mip_era` query value, in any case, into the eras it selects. - - No value selects every era and an unknown era selects none. - """ - if not mip_era: - return CMIP_ERAS - return tuple(era for era in CMIP_ERAS if dataset_type_label(era) == mip_era.lower()) - - -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 _carries(source_type: SourceDatasetType, facet: str) -> bool: - return facet in dataset_model_for(source_type).__mapper__.columns +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 _facet_values(value: str) -> tuple[str, ...]: - if "," not in value: - return (value,) - return tuple(item.strip() for item in value.split(",") if item.strip()) +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(source_types=source_types_for(mip_era), or_no_model_input=True).group_clauses() -def cmip_input_filter( - facets: Mapping[str, str], *, or_no_model_input: bool = False -) -> InputDatasetFilter | None: - """ - 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. Returns `None` when nothing can satisfy the request. +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 - With `or_no_model_input` an execution with no model input also matches. - It carries no era, so dropping it would lose values rather than label them. - An unknown era still matches nothing. - """ - eras = eras_in_scope(facets.get("mip_era")) - known = { - key: _facet_values(value) - for key, value in facets.items() - if key != "mip_era" and any(_carries(era, key) for era in CMIP_ERAS) - } - if not eras or any(not any(_carries(era, key) for era in eras) for key in known): - return None - return _input_filter(eras, known or None, or_no_model_input=or_no_model_input) - - -def _input_filter( - eras: tuple[SourceDatasetType, ...], - facets: Mapping[str, tuple[str, ...]] | None = None, - *, - or_no_model_input: bool = False, -) -> InputDatasetFilter: - # The attrs converter leaves mypy with an unbound Iterable[_T_co] for source_types. - return InputDatasetFilter( - source_types=eras, # type: ignore[arg-type] - facets=facets, - or_no_model_input=or_no_model_input, - ) - - -def execution_group_filter(facets: Mapping[str, str]) -> ColumnElement[bool]: - """Match execution groups holding an execution that satisfies `cmip_input_filter`.""" - input_filter = cmip_input_filter(facets) - return input_filter.execution_group_clause() if input_filter else false() - - -def execution_groups_per_era(reader: Reader) -> 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. - """ - counts = {} - for era in CMIP_ERAS: - if label := mip_era_for(era): - stats = reader.executions.statistics(input_datasets=_input_filter((era,))) - counts[label] = sum(s.total for s in stats) - return counts +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 f67f7c76..3961112e 100644 --- a/backend/src/ref_backend/core/model_ensemble.py +++ b/backend/src/ref_backend/core/model_ensemble.py @@ -9,8 +9,8 @@ from sqlalchemy.orm import Session from climate_ref import models -from climate_ref.models.execution import select_latest_executions -from ref_backend.core.mip_eras import cmip_input_filter, cv_column +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,7 +86,7 @@ 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] @@ -114,10 +114,7 @@ def _scalar_rows( ) ) if mip_era: - input_filter = cmip_input_filter({"mip_era": mip_era}, or_no_model_input=True) - if input_filter is None: - return [] - statement = statement.where(input_filter.execution_clause()) + statement = statement.where(*era_group_clauses(mip_era)) return [ ScalarRow(row[0], row[1], row[2], _units(row[3]), tuple(row[4:])) @@ -146,7 +143,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 7426f6db..44633eeb 100644 --- a/backend/src/ref_backend/core/model_runs.py +++ b/backend/src/ref_backend/core/model_runs.py @@ -8,8 +8,8 @@ from sqlalchemy.orm import Session, aliased from climate_ref import models -from climate_ref.models.execution import select_latest_executions -from ref_backend.core.mip_eras import cv_column, dataset_model_for, eras_in_scope, 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 @@ -30,7 +30,7 @@ class ModelFacets(NamedTuple): dataset_count: int -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. @@ -81,7 +81,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) @@ -107,7 +107,7 @@ 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) @@ -158,7 +158,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/models/__init__.py b/backend/src/ref_backend/models/__init__.py index 1b8c9d86..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 ( @@ -60,6 +60,7 @@ "Facet", "FailedRun", "GroupBy", + "GroupCounts", "MetricValueCollection", "ModelDetail", "ModelSummary", diff --git a/backend/src/ref_backend/models/common.py b/backend/src/ref_backend/models/common.py index 27fc92fe..9b55d25a 100644 --- a/backend/src/ref_backend/models/common.py +++ b/backend/src/ref_backend/models/common.py @@ -1,10 +1,13 @@ """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 +import attrs from pydantic import BaseModel, computed_field from climate_ref import models +from climate_ref.results.diagnostics import DiagnosticView T = TypeVar("T") @@ -43,3 +46,41 @@ 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(view: DiagnosticView | None) -> "GroupCounts": + """Convert a diagnostic's counts from the reader, with `None` for one that has no groups.""" + if view is None: + return GroupCounts.summed([]) + return GroupCounts.model_validate(attrs.asdict(view.groups)) + + @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 0177e30e..e8461470 100644 --- a/backend/src/ref_backend/models/diagnostics.py +++ b/backend/src/ref_backend/models/diagnostics.py @@ -9,6 +9,7 @@ from climate_ref import models from climate_ref.models.metric_value import MetricValueType as StoredMetricValueType from climate_ref.results import DiagnosticFilter, ResourceFilter +from climate_ref.results.diagnostics import DiagnosticView from ref_backend.core.diagnostic_metadata import ( DiagnosticMetadata, ReferenceDatasetLink, @@ -16,7 +17,7 @@ ) 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 @@ -67,13 +68,9 @@ class DiagnosticCatalogEntry(BaseModel): """ List of IDs for the provider executions associated with this provider """ - execution_group_count: int + groups: GroupCounts """ - 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 """ @@ -181,8 +178,7 @@ def build_with_stats( diagnostic: models.Diagnostic, app_context: "AppContext", *, - execution_group_count: int, - successful_execution_group_count: int, + view: DiagnosticView | None, resource_usage: ExecutionResourceSummary | None = None, ) -> "DiagnosticCatalogEntry": """Build a DiagnosticCatalogEntry with pre-computed statistics to avoid N+1 queries.""" @@ -201,8 +197,7 @@ def build_with_stats( name=diagnostic.name, description=description, execution_groups=[e.id for e in diagnostic.execution_groups], - execution_group_count=execution_group_count, - successful_execution_group_count=successful_execution_group_count, + groups=GroupCounts.of(view), promoted_version=diagnostic.promoted_version, group_by=group_by_summary, aft_link=aft, @@ -239,71 +234,37 @@ 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) - - key = (diagnostic.provider.slug, diagnostic.slug) - slug_filter = DiagnosticFilter(provider_contains=[key[0]], diagnostic_contains=[key[1]]) - - def has_values(value_type: StoredMetricValueType) -> bool: - return key in app_context.reader.diagnostics.with_values(value_type, slug_filter) - - has_scalar_values = has_values(StoredMetricValueType.SCALAR) - has_series_values = has_values(StoredMetricValueType.SERIES) - - has_metric_values = has_scalar_values or has_series_values - - # The reader only matches slugs as substrings, so pick the exact one out. - group_stats = next( - ( - stats - for stats in app_context.reader.diagnostics.stats( - provider_contains=[diagnostic.provider.slug], diagnostic_contains=[diagnostic.slug] - ) - if (stats.provider, stats.diagnostic) == (diagnostic.provider.slug, diagnostic.slug) - ), - None, + this_diagnostic = DiagnosticFilter( + provider_slug=diagnostic.provider.slug, + diagnostic_slug=diagnostic.slug, + include_superseded=False, + include_retracted=False, ) - execution_group_count = group_stats.total if group_stats else 0 - successful_execution_group_count = group_stats.successful if group_stats else 0 - - concrete_diagnostic = _concrete_diagnostic(diagnostic, app_context) - description = (concrete_diagnostic.__doc__ or "") if concrete_diagnostic else "" - diagnostic_resource_usage = resource_usage( - app_context.reader, - ResourceFilter( - provider_slug=diagnostic.provider.slug, - diagnostic_slug=diagnostic.slug, - include_retracted=True, + def has_values(value_type: StoredMetricValueType) -> bool: + return bool(app_context.reader.diagnostics.with_values(value_type, this_diagnostic)) + + views = app_context.reader.diagnostics.list(this_diagnostic, with_count=False).items + entry = DiagnosticCatalogEntry.build_with_stats( + diagnostic, + app_context, + view=views[0] if views else None, + resource_usage=resource_usage( + app_context.reader, + ResourceFilter( + provider_slug=diagnostic.provider.slug, + diagnostic_slug=diagnostic.slug, + include_superseded=False, + include_retracted=True, + ), ), ) - - # 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_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=diagnostic_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/tests/test_api/test_routes/test_diagnostics.py b/backend/tests/test_api/test_routes/test_diagnostics.py index f167608f..3e1f04ae 100644 --- a/backend/tests/test_api/test_routes/test_diagnostics.py +++ b/backend/tests/test_api/test_routes/test_diagnostics.py @@ -89,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) @@ -643,50 +625,6 @@ def test_retracted_values_are_hidden(writable_client: TestClient) -> None: assert values["data"] == [] -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"] - - 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. - - 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"] - - r = client.get( - f"{settings.API_V1_STR}/diagnostics/{provider_slug}/{diagnostic_slug}/executions?__tablename__=evil" - ) - # Must not crash with a 500 - assert r.status_code == 200 - - def test_diagnostic_values_ignores_unknown_filter_params(client: TestClient, settings) -> None: """Test that unknown filter params on the values endpoint are safely ignored.""" diagnostic = get_diagnostic_with_scalar_values(client, settings) @@ -736,26 +674,6 @@ def test_diagnostic_404_invalid_provider(client: TestClient, settings) -> None: assert r.status_code == 404 -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) @@ -791,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 @@ -838,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"] @@ -875,7 +774,7 @@ def test_diagnostic_group_by_lists_each_pairing_once(client: TestClient) -> None 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] @@ -887,13 +786,13 @@ 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["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["execution_group_count"] == 0 + assert relisted["groups"]["total"] == 0 def _catalog_usage(client: TestClient, diagnostic_id: int) -> dict | None: @@ -928,7 +827,7 @@ def test_catalog_resource_usage_counts_retracted_runs_at_the_promoted_version( 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["successful_execution_group_count"] > 0) + 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() @@ -955,18 +854,11 @@ def test_a_backfilled_failure_does_not_decide_a_group(writable_client: TestClien ) after = writable_client.get(detail_url).json() - assert after["successful_execution_group_count"] == before["successful_execution_group_count"] + 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["successful_execution_group_count"] == before["successful_execution_group_count"] - - -def test_diagnostics_catalog_with_an_unknown_era_counts_nothing(client: TestClient, settings) -> None: - catalog = client.get(f"{settings.API_V1_STR}/diagnostics/catalog", params={"mip_era": "CMIP5"}).json() - assert catalog["data"] - assert all(d["execution_group_count"] == 0 for d in catalog["data"]) - assert all(d["successful_execution_group_count"] == 0 for d in catalog["data"]) + assert relisted["groups"]["successful"] == before["groups"]["successful"] FLAGS = ("has_metric_values", "has_scalar_values", "has_series_values") @@ -997,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"] } diff --git a/backend/tests/test_api/test_routes/test_executions.py b/backend/tests/test_api/test_routes/test_executions.py index 13506fb6..38a474a8 100644 --- a/backend/tests/test_api/test_routes/test_executions.py +++ b/backend/tests/test_api/test_routes/test_executions.py @@ -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"] @@ -593,18 +584,10 @@ def test_execution_list_filters_by_failure_reason(client: TestClient, settings) assert other["total_count"] == 0 -def test_execution_list_with_an_unknown_failure_reason_is_empty(client: TestClient, settings) -> None: - r = client.get(f"{settings.API_V1_STR}/executions/?failure_reason=not-a-reason") - assert r.status_code == 200 - assert r.json()["total_count"] == 0 - assert r.json()["data"] == [] - - -def test_execution_list_with_an_unknown_mip_era_is_empty(client: TestClient, settings) -> None: - r = client.get(f"{settings.API_V1_STR}/executions/?mip_era=CMIP5") - assert r.status_code == 200 - assert r.json()["total_count"] == 0 - assert r.json()["data"] == [] +@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: @@ -618,9 +601,9 @@ def test_execution_list_filters_by_source_id(client: TestClient, settings) -> No 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["execution_groups_by_mip_era"] + by_era = stats["groups_by_mip_era"] assert set(by_era) == {"CMIP6", "CMIP7"} - assert all(0 <= count <= stats["total_execution_groups"] for count in by_era.values()) + 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"] == listed["total_count"] > 0 + assert by_era["CMIP6"]["total"] == listed["total_count"] > 0 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 index c99d4565..660a7981 100644 --- a/backend/tests/test_api/test_routes/test_mip_era_params.py +++ b/backend/tests/test_api/test_routes/test_mip_era_params.py @@ -13,7 +13,7 @@ def _group_total(body) -> int: - return sum(d["execution_group_count"] for d in body["data"]) + return sum(d["groups"]["total"] for d in body["data"]) def _with_values(body) -> int: @@ -37,72 +37,57 @@ def _rows(body) -> int: @pytest.mark.parametrize(("path", "params", "amount"), ROUTES) -def test_an_unknown_era_matches_nothing(client: TestClient, settings, path, params, amount) -> None: +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) - unknown = client.get(url, params={**params, "mip_era": "CMIP5"}) + cmip6 = client.get(url, params={**params, "mip_era": "CMIP6"}) + cmip7 = client.get(url, params={**params, "mip_era": "CMIP7"}) - assert everything.status_code == unknown.status_code == 200 - assert amount(everything.json()) > 0 - assert amount(unknown.json()) == 0 - - -def test_an_unknown_era_on_a_model_is_an_empty_model(client: TestClient, settings) -> None: - r = client.get(f"{settings.API_V1_STR}/models/{MODEL}", params={"mip_era": "CMIP5"}) - - assert r.status_code == 200 - body = r.json() - assert body["source_id"] == MODEL - assert body["diagnostics"] == body["failures"] == body["mip_eras"] == [] - assert body["execution_groups"]["total"] == 0 + 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( - ("source_id", "mip_era"), [("NOT-A-MODEL", None), ("NOT-A-MODEL", "CMIP5"), (MODEL, "CMIP7")] + ("path", "params"), [*((path, params) for path, params, _ in ROUTES), (f"/models/{MODEL}", {})] ) -def test_a_missing_model_or_a_known_era_without_runs_is_not_found( +@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 -@pytest.mark.parametrize( - ("path", "params"), [*((path, params) for path, params, _ in ROUTES), (f"/models/{MODEL}", {})] -) -def test_the_era_ignores_case(client: TestClient, settings, path, params) -> None: - url = f"{settings.API_V1_STR}{path}" - upper = client.get(url, params={**params, "mip_era": "CMIP6"}) - lower = client.get(url, params={**params, "mip_era": "cmip6"}) - - assert upper.status_code == 200 - assert lower.json() == upper.json() - - -@pytest.mark.parametrize(("path", "params"), [("/models/", {}), ("/executions/", {})]) -def test_an_empty_era_is_no_filter(client: TestClient, settings, path, params) -> None: - url = f"{settings.API_V1_STR}{path}" - assert client.get(url, params={**params, "mip_era": ""}).json() == client.get(url, params=params).json() - - def _add_obs_only_value(config) -> int: - """Add a scalar value from an execution of `DIAGNOSTIC` that ran on observations alone.""" + """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: - group = session.scalars( - select(models.ExecutionGroup) - .join(models.Diagnostic) + diagnostic = session.scalars( + select(models.Diagnostic) .join(models.Provider) .where(models.Diagnostic.slug == diagnostic_slug, models.Provider.slug == provider_slug) - .limit(1) - ).one() - obs = session.scalars( - select(models.Dataset).where(models.Dataset.dataset_type == SourceDatasetType.obs4MIPs).limit(1) ).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 ) - execution.datasets.append(obs) session.add(execution) session.flush() value = models.ScalarMetricValue.build( @@ -115,8 +100,8 @@ def _add_obs_only_value(config) -> int: return value_id -@pytest.mark.parametrize(("mip_era", "included"), [("CMIP6", True), ("cmip7", True), ("CMIP5", False)]) -def test_values_keep_obs_only_runs_for_a_known_era(writable_client: TestClient, mip_era, included) -> None: +@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", @@ -124,4 +109,4 @@ def test_values_keep_obs_only_runs_for_a_known_era(writable_client: TestClient, ) assert r.status_code == 200 - assert (value_id in {v["id"] for v in r.json()["data"]}) is included + assert value_id in {v["id"] for v in r.json()["data"]} diff --git a/backend/tests/test_core/test_mip_era_edges.py b/backend/tests/test_core/test_mip_era_edges.py index 50ef12db..ce273b1d 100644 --- a/backend/tests/test_core/test_mip_era_edges.py +++ b/backend/tests/test_core/test_mip_era_edges.py @@ -1,4 +1,4 @@ -"""How executions that do not sit in exactly one MIP era are scoped and labelled.""" +"""How executions whose group does not draw on exactly one MIP era are labelled.""" from types import SimpleNamespace @@ -6,14 +6,9 @@ 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, - cmip_input_filter, - dataset_model_for, - eras_in_scope, - mip_eras_for_executions, -) +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 @@ -22,72 +17,47 @@ ("mip_era", "expected"), [ (None, CMIP_ERAS), - ("", CMIP_ERAS), ("CMIP7", (SourceDatasetType.CMIP7,)), - ("cmip7", (SourceDatasetType.CMIP7,)), - ("CmIp6", (SourceDatasetType.CMIP6,)), - ("CMIP5", ()), - ("obs4MIPs", ()), + ("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: - cmip6 = dataset_model_for(SourceDatasetType.CMIP6) return session.scalars( - select(models.Execution).join(models.Execution.datasets.of_type(cmip6)).limit(1) + select(models.Execution) + .join( + ExecutionGroupSourceType, + ExecutionGroupSourceType.execution_group_id == models.Execution.execution_group_id, + ) + .where(ExecutionGroupSourceType.source_type == SourceDatasetType.CMIP6) + .limit(1) ).one() -def _cmip7_dataset(session) -> models.Dataset: - dataset = dataset_model_for(SourceDatasetType.CMIP7)( - slug="CMIP7.probe.v1", - activity_id="CMIP", - institution_id="probe", - source_id="PROBE-ESM", - experiment_id="historical", - variant_label="r1i1p1f1", - variable_id="tas", - grid_label="gn", - frequency="mon", - region="glb", - branding_suffix="tavg-h2m-hxy-u", - version="v1", - mip_era="CMIP7", - instance_id="CMIP7.probe.v1", - version_key="v1", +def _mixed_era_execution(session) -> models.Execution: + execution = _cmip6_execution(session) + execution.execution_group.source_types.append( + ExecutionGroupSourceType(source_type=SourceDatasetType.CMIP7) ) - session.add(dataset) - return dataset + session.flush() + return execution def _obs_only_execution(session) -> models.Execution: - template = _cmip6_execution(session) - obs = session.scalars( - select(models.Dataset).where(models.Dataset.dataset_type == SourceDatasetType.obs4MIPs).limit(1) - ).one() - execution = models.Execution( - execution_group_id=template.execution_group_id, - dataset_hash="obs-only", - output_fragment="obs-only", - successful=True, - ) - execution.datasets.append(obs) - session.add(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 _matches(session, execution_id: int, mip_era: str, *, or_no_model_input: bool = False) -> bool: - input_filter = cmip_input_filter({"mip_era": mip_era}, or_no_model_input=or_no_model_input) - if input_filter is None: - return False - clause = input_filter.execution_clause() - return execution_id in session.scalars(select(models.Execution.id).where(clause)).all() - - def _values(*items: SeriesValue) -> MetricValueCollection: return MetricValueCollection( data=list(items), count=len(items), total_count=len(items), facets=[], types=[] @@ -100,51 +70,37 @@ def _value(execution_id: int, **dimensions: str) -> SeriesValue: ) -def test_a_mixed_era_execution_has_no_era_but_passes_both_filters(writable_session): +def test_a_group_drawing_on_one_era_labels_its_executions(writable_session): execution = _cmip6_execution(writable_session) - execution.datasets.append(_cmip7_dataset(writable_session)) - writable_session.flush() - assert mip_eras_for_executions(writable_session, [execution.id]) == {} - for era in ("CMIP6", "CMIP7"): - assert _matches(writable_session, execution.id, era) - assert _matches(writable_session, execution.id, era, or_no_model_input=True) + assert mip_eras_for_executions(writable_session, [execution.id]) == {execution.id: "CMIP6"} -def test_an_execution_without_cmip_input_answers_to_every_era(writable_session): - execution = _obs_only_execution(writable_session) +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]) == {} - for era in ("CMIP6", "cmip7"): - assert not _matches(writable_session, execution.id, era) - assert _matches(writable_session, execution.id, era, or_no_model_input=True) -def test_an_unknown_era_drops_executions_without_cmip_input(writable_session): +def test_a_group_without_cmip_input_has_no_era(writable_session): execution = _obs_only_execution(writable_session) - assert not _matches(writable_session, execution.id, "CMIP5", or_no_model_input=True) + assert mip_eras_for_executions(writable_session, [execution.id]) == {} -def test_mip_id_labels_an_execution_the_inputs_do_not_settle(writable_session): - mixed = _cmip6_execution(writable_session) - mixed.datasets.append(_cmip7_dataset(writable_session)) - obs_only = _obs_only_execution(writable_session) +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(obs_only.id, mip_id="CMIP6"), - _value(mixed.id), - ), + _values(_value(mixed.id, mip_id="cmip7"), _value(mixed.id)), ) - assert [v.dimensions.get("mip_era") for v in collection.data] == ["CMIP7", "CMIP6", None] + assert [v.dimensions.get("mip_era") for v in collection.data] == ["CMIP7", None] -def test_the_inputs_outrank_mip_id_and_a_recorded_era_is_kept(writable_session): +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) diff --git a/backend/tests/test_core/test_mip_eras.py b/backend/tests/test_core/test_mip_eras.py deleted file mode 100644 index b9b32d7a..00000000 --- a/backend/tests/test_core/test_mip_eras.py +++ /dev/null @@ -1,67 +0,0 @@ -import pytest -from sqlalchemy import func, select - -from climate_ref import models -from climate_ref.database import Database -from ref_backend.core.mip_eras import ( - CMIP_ERAS, - cmip_input_filter, - cv_column, - dataset_model_for, -) -from ref_backend.testing import test_ref_config as fixture_ref_config - - -@pytest.fixture(scope="module") -def session(): - return Database.from_config(fixture_ref_config(), run_migrations=False, read_only=True).session - - -def _count(session, facets) -> int: - input_filter = cmip_input_filter(facets) - if input_filter is None: - return 0 - return session.scalar(select(func.count(models.Execution.id)).where(input_filter.execution_clause())) - - -def _a_source_id(session) -> str: - cmip6 = dataset_model_for(CMIP_ERAS[0]) - return session.scalar(select(cv_column(cmip6, "source_id")).limit(1)) - - -def test_no_facets_matches_every_execution_with_a_cmip_input(session): - assert _count(session, {}) > 0 - - -def test_an_unknown_era_matches_nothing(session): - assert cmip_input_filter({"mip_era": "CMIP5"}) is None - assert _count(session, {"mip_era": "CMIP5"}) == 0 - - -def test_the_era_is_case_insensitive(session): - assert _count(session, {"mip_era": "cmip6"}) == _count(session, {"mip_era": "CMIP6"}) > 0 - - -def test_an_unknown_facet_is_ignored(session): - assert _count(session, {"not_a_facet": "x"}) == _count(session, {}) - - -def test_a_facet_only_the_other_era_carries_matches_nothing(session): - assert cmip_input_filter({"mip_era": "CMIP6", "branding_suffix": "x"}) is None - - -def test_a_facet_one_era_carries_drops_the_other_era(session): - cmip6 = dataset_model_for(CMIP_ERAS[0]) - table_id = session.scalar(select(cmip6.table_id).limit(1)) - assert _count(session, {"table_id": table_id}) == _count( - session, {"mip_era": "CMIP6", "table_id": table_id} - ) - assert _count(session, {"mip_era": "CMIP7", "table_id": table_id}) == 0 - - -def test_a_comma_list_matches_any_of_its_values(session): - source_id = _a_source_id(session) - one = _count(session, {"source_id": source_id}) - assert one > 0 - assert _count(session, {"source_id": f"{source_id}, not-a-model"}) == one - assert _count(session, {"source_id": "not-a-model,"}) == 0 diff --git a/frontend/src/client/@tanstack/react-query.gen.ts b/frontend/src/client/@tanstack/react-query.gen.ts index cacbe28b..726d1c9f 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, 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, 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, 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, DiagnosticsGetData, DiagnosticsGetError, DiagnosticsGetResponse, DiagnosticsListData, DiagnosticsListError, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsError, DiagnosticsListExecutionGroupsResponse, 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'; 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 }) => { @@ -333,9 +334,9 @@ export const diagnosticsListExecutionGroupsQueryKey = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -350,28 +351,6 @@ export const diagnosticsListExecutionGroupsOptions = (options: Options) => createQueryKey('diagnosticsListExecutions', options); - -/** - * List Executions - * - * Fetch executions for a specific diagnostic, with arbitrary filters on the dataset. - * - * e.g. `?source_id=MIROC6&experiment_id=ssp585`. Pass `mip_era` to restrict to one era. - */ -export const diagnosticsListExecutionsOptions = (options: Options) => queryOptions>({ - queryFn: async ({ queryKey, signal }) => { - const { data } = await diagnosticsListExecutions({ - ...options, - ...queryKey[0], - signal, - throwOnError: true - }); - return data; - }, - queryKey: diagnosticsListExecutionsQueryKey(options) -}); - export const diagnosticsListMetricValuesQueryKey = (options: Options) => createQueryKey('diagnosticsListMetricValues', options); /** @@ -383,8 +362,11 @@ export const diagnosticsListMetricValuesQueryKey = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -410,8 +392,11 @@ export const diagnosticsListMetricValuesInfiniteQueryKey = (options: Options) => { const opts = infiniteQueryOptions, QueryKey>, number | Pick>[0], 'body' | 'headers' | 'path' | 'query'>>( @@ -446,7 +431,8 @@ export const executionsGetExecutionStatisticsQueryKey = (options?: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -466,9 +452,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 }) => { @@ -658,6 +644,8 @@ export const executionsListMetricValuesQueryKey = (options: Options) => queryOptions>({ queryFn: async ({ queryKey, signal }) => { @@ -683,6 +671,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 fa16c3ca..aceffd72 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, 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, 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, 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, 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, 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, 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, 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 c0ef8ffe..8869b1da 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, 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, 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'; 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 }); @@ -98,21 +97,12 @@ 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 - * - * Fetch executions for a specific diagnostic, with arbitrary filters on the dataset. - * - * e.g. `?source_id=MIROC6&experiment_id=ssp585`. Pass `mip_era` to restrict to one era. - */ -export const diagnosticsListExecutions = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/executions', ...options }); - /** * List Metric Values * @@ -122,8 +112,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 }); @@ -133,16 +126,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) @@ -150,9 +145,8 @@ export const executionsGetExecutionStatistics = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/executions/{group_id}', ...options }); @@ -207,6 +201,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 286a79ac..f20a6990 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,14 +661,7 @@ export type DiagnosticCatalogEntry = { * Execution Groups */ execution_groups: Array; - /** - * Execution Group Count - */ - execution_group_count: number; - /** - * Successful Execution Group Count - */ - successful_execution_group_count: number; + groups: GroupCounts; /** * Promoted Version */ @@ -768,14 +741,7 @@ export type DiagnosticSummary = { * Execution Groups */ execution_groups: Array; - /** - * Execution Group Count - */ - execution_group_count: number; - /** - * Successful Execution Group Count - */ - successful_execution_group_count: number; + groups: GroupCounts; /** * Promoted Version */ @@ -985,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 */ @@ -1124,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 @@ -1170,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; }; @@ -1243,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 */ @@ -1763,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] */ @@ -1811,7 +1787,7 @@ export type DatasetWritable = { /** * Mip Era */ - mip_era?: string | null; + mip_era?: 'CMIP6' | 'CMIP7' | null; }; /** @@ -1900,31 +1876,12 @@ export type EnsembleComparisonWritable = { * Statistics for execution groups and their success rates. */ export type ExecutionStatsWritable = { + groups: GroupCounts; /** - * Total Execution Groups + * Groups By Mip Era */ - total_execution_groups: number; - /** - * Successful Execution Groups - */ - 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 @@ -2212,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; }; @@ -2246,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; }; @@ -2280,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; }; @@ -2357,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'; }; @@ -2381,40 +2352,6 @@ export type DiagnosticsListExecutionGroupsResponses = { export type DiagnosticsListExecutionGroupsResponse = DiagnosticsListExecutionGroupsResponses[keyof DiagnosticsListExecutionGroupsResponses]; -export type DiagnosticsListExecutionsData = { - body?: never; - path: { - /** - * Provider Slug - */ - provider_slug: string; - /** - * Diagnostic Slug - */ - diagnostic_slug: string; - }; - query?: never; - url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/executions'; -}; - -export type DiagnosticsListExecutionsErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type DiagnosticsListExecutionsError = DiagnosticsListExecutionsErrors[keyof DiagnosticsListExecutionsErrors]; - -export type DiagnosticsListExecutionsResponses = { - /** - * Successful Response - */ - 200: CollectionExecution; -}; - -export type DiagnosticsListExecutionsResponse = DiagnosticsListExecutionsResponses[keyof DiagnosticsListExecutionsResponses]; - export type DiagnosticsListMetricValuesData = { body?: never; path: { @@ -2475,9 +2412,9 @@ 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; }; url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/values'; }; @@ -2547,15 +2484,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 * @@ -2973,9 +2914,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/'; }; @@ -3010,9 +2951,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}'; }; @@ -3047,9 +2988,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/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 969b6ed8..23a11181 100644 --- a/frontend/src/lib/resourceUsageCsv.test.ts +++ b/frontend/src/lib/resourceUsageCsv.test.ts @@ -9,8 +9,14 @@ const base = { has_metric_values: false, has_scalar_values: false, has_series_values: false, - 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}

Date: Fri, 23 Oct 2026 23:06:22 +1100 Subject: [PATCH 22/28] perf: count diagnostic groups without counting their executions --- backend/src/ref_backend/api/routes/diagnostics.py | 13 +++++++++---- backend/src/ref_backend/models/common.py | 10 +++------- backend/src/ref_backend/models/diagnostics.py | 11 ++++++----- backend/tests/perf/test_endpoints.py | 10 ++++++---- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index 9a17cc0b..c5c9b0f1 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -5,6 +5,7 @@ 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, ExecutionGroupFilter, MetricValueFilter, ResourceFilter from ref_backend.api.deps import AppContextDep from ref_backend.core.execution_groups import load_execution_groups @@ -24,6 +25,7 @@ DiagnosticSummary, DiagnosticValueFlags, ExecutionGroup, + GroupCounts, MetricValueCollection, ) @@ -115,13 +117,16 @@ def _catalog_entries(app_context: AppContextDep, scope: _Scope) -> list[Diagnost input_datasets=scope.filters.input_datasets, ), ) - views = { - (view.provider_slug, view.diagnostic_slug): view - for view in app_context.reader.diagnostics.list(scope.filters, with_count=False) + 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, view=views.get(_key(m)), resource_usage=resource_usage.get(_key(m)) + m, + app_context, + groups=GroupCounts.of(counts.get(_key(m))), + resource_usage=resource_usage.get(_key(m)), ) for m in scope.diagnostics ] diff --git a/backend/src/ref_backend/models/common.py b/backend/src/ref_backend/models/common.py index 9b55d25a..64dc7568 100644 --- a/backend/src/ref_backend/models/common.py +++ b/backend/src/ref_backend/models/common.py @@ -3,11 +3,9 @@ from collections.abc import Sequence from typing import Any, Generic, TypeVar -import attrs from pydantic import BaseModel, computed_field from climate_ref import models -from climate_ref.results.diagnostics import DiagnosticView T = TypeVar("T") @@ -68,11 +66,9 @@ class GroupCounts(BaseModel): """ @staticmethod - def of(view: DiagnosticView | None) -> "GroupCounts": - """Convert a diagnostic's counts from the reader, with `None` for one that has no groups.""" - if view is None: - return GroupCounts.summed([]) - return GroupCounts.model_validate(attrs.asdict(view.groups)) + 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": diff --git a/backend/src/ref_backend/models/diagnostics.py b/backend/src/ref_backend/models/diagnostics.py index e8461470..a44db7c1 100644 --- a/backend/src/ref_backend/models/diagnostics.py +++ b/backend/src/ref_backend/models/diagnostics.py @@ -8,8 +8,8 @@ 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 climate_ref.results.diagnostics import DiagnosticView from ref_backend.core.diagnostic_metadata import ( DiagnosticMetadata, ReferenceDatasetLink, @@ -178,7 +178,7 @@ def build_with_stats( diagnostic: models.Diagnostic, app_context: "AppContext", *, - view: DiagnosticView | None, + groups: GroupCounts, resource_usage: ExecutionResourceSummary | None = None, ) -> "DiagnosticCatalogEntry": """Build a DiagnosticCatalogEntry with pre-computed statistics to avoid N+1 queries.""" @@ -197,7 +197,7 @@ def build_with_stats( name=diagnostic.name, description=description, execution_groups=[e.id for e in diagnostic.execution_groups], - groups=GroupCounts.of(view), + groups=groups, promoted_version=diagnostic.promoted_version, group_by=group_by_summary, aft_link=aft, @@ -244,11 +244,12 @@ def build(diagnostic: models.Diagnostic, app_context: "AppContext") -> "Diagnost def has_values(value_type: StoredMetricValueType) -> bool: return bool(app_context.reader.diagnostics.with_values(value_type, this_diagnostic)) - views = app_context.reader.diagnostics.list(this_diagnostic, with_count=False).items entry = DiagnosticCatalogEntry.build_with_stats( diagnostic, app_context, - view=views[0] if views else None, + groups=GroupCounts.summed( + app_context.session.execute(select_group_counts(this_diagnostic)).all() + ), resource_usage=resource_usage( app_context.reader, ResourceFilter( diff --git a/backend/tests/perf/test_endpoints.py b/backend/tests/perf/test_endpoints.py index f091fa05..9652b0c2 100644 --- a/backend/tests/perf/test_endpoints.py +++ b/backend/tests/perf/test_endpoints.py @@ -27,17 +27,19 @@ 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.get", path=_DIAGNOSTIC), Endpoint(id="diagnostics.execution-groups", path=f"{_DIAGNOSTIC}/execution_groups"), @@ -59,7 +61,7 @@ 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"}), From 44ad2f70dd51389f6886b5a5b4f88e8606c4a38d Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 23 Oct 2026 23:24:21 +1100 Subject: [PATCH 23/28] perf: fetch metric value facets apart from the values --- .../src/ref_backend/api/routes/diagnostics.py | 69 ++++++++-- .../src/ref_backend/api/routes/executions.py | 49 +++++-- backend/src/ref_backend/core/reader_values.py | 23 +++- backend/src/ref_backend/models/values.py | 25 ++-- backend/tests/perf/test_endpoints.py | 21 +++ .../test_api/test_routes/test_diagnostics.py | 64 +++++++++ .../test_api/test_routes/test_executions.py | 40 +++++- .../test_routes/test_mip_era_params.py | 5 + .../src/client/@tanstack/react-query.gen.ts | 48 ++++++- frontend/src/client/index.ts | 4 +- frontend/src/client/sdk.gen.ts | 20 ++- frontend/src/client/types.gen.ts | 124 ++++++++++++++++++ .../execution/values/valuesPanel.test.tsx | 25 ++-- .../execution/values/valuesPanel.tsx | 11 +- .../components/explorer/chartValues.test.ts | 3 +- .../src/components/explorer/chartValues.ts | 3 +- .../explorer/content/filterControls.tsx | 9 +- .../content/useExplorerMetricValues.ts | 3 +- .../src/hooks/useDiagnosticMetricValues.ts | 6 +- .../src/hooks/useExecutionMetricValues.ts | 6 +- frontend/src/hooks/useMetricValues.test.ts | 61 +++++++++ frontend/src/hooks/useMetricValues.ts | 45 ++++++- 22 files changed, 586 insertions(+), 78 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index c5c9b0f1..ec27dffd 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -15,6 +15,7 @@ ) from ref_backend.core.mip_eras import MipEra, source_id_datasets, source_types_for from ref_backend.core.reader_values import ( + fetch_facets, fetch_metric_values, parse_dimension_filters, ) @@ -25,6 +26,7 @@ DiagnosticSummary, DiagnosticValueFlags, ExecutionGroup, + Facet, GroupCounts, MetricValueCollection, ) @@ -236,6 +238,55 @@ async def list_execution_groups( return Collection(data=[ExecutionGroup.build(g, app_context) for g in groups], total_count=len(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, + source_types=source_types_for(mip_era), + or_no_model_input=mip_era is not None, + 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}/values/facets") +async def list_metric_value_facets( # noqa: PLR0913, PLR0917 + app_context: AppContextDep, + provider_slug: str, + diagnostic_slug: str, + request: Request, + 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]: + """ + 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. + """ + metric_filter = await _metric_filter( + app_context, provider_slug, diagnostic_slug, request, mip_era, isolate_ids, exclude_ids + ) + return fetch_facets(app_context, metric_filter, value_type) + + @router.get("/{provider_slug}/{diagnostic_slug}/values", response_model=MetricValueCollection) async def list_metric_values( # noqa: PLR0913, PLR0917 app_context: AppContextDep, @@ -255,6 +306,7 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 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) @@ -269,25 +321,14 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 Values come from the promoted diagnostic version, and retracted executions are left out. """ - # Validates the provider/diagnostic exist and are not excluded (raises 404 otherwise). - await _get_diagnostic(app_context, provider_slug, diagnostic_slug) - - metric_filter = MetricValueFilter( - diagnostic_slug=diagnostic_slug, - provider_slug=provider_slug, - include_superseded=False, - include_retracted=False, - latest_only=False, - source_types=source_types_for(mip_era), - or_no_model_input=mip_era is not None, - dimensions=parse_dimension_filters(request.query_params), - isolate_ids=parse_id_list(isolate_ids), - exclude_ids=parse_id_list(exclude_ids), + 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 704b6dc0..b233e934 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -26,6 +26,7 @@ ) from ref_backend.core.mip_eras import MipEra, source_id_datasets, source_types_for from ref_backend.core.reader_values import ( + fetch_facets, fetch_metric_values, parse_dimension_filters, ) @@ -36,6 +37,7 @@ Execution, ExecutionGroup, ExecutionStats, + Facet, GroupCounts, MetricValueCollection, ) @@ -272,6 +274,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, @@ -288,6 +323,7 @@ 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) @@ -301,19 +337,10 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 """ execution = await _get_execution(group_id, execution_id, app_context) - metric_filter = 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), - ) - 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/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/models/values.py b/backend/src/ref_backend/models/values.py index 5d31cc66..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"), @@ -129,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=_counted(collection.total_count), - facets=facets, + 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, @@ -169,17 +172,11 @@ 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=_counted(collection.total_count), - facets=facets, + facets=facets_of(collection), types=["series"], had_outliers=None, outlier_count=None, diff --git a/backend/tests/perf/test_endpoints.py b/backend/tests/perf/test_endpoints.py index 9652b0c2..9cc2397c 100644 --- a/backend/tests/perf/test_endpoints.py +++ b/backend/tests/perf/test_endpoints.py @@ -50,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( @@ -66,6 +86,7 @@ class Endpoint: 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_api/test_routes/test_diagnostics.py b/backend/tests/test_api/test_routes/test_diagnostics.py index 3e1f04ae..768de038 100644 --- a/backend/tests/test_api/test_routes/test_diagnostics.py +++ b/backend/tests/test_api/test_routes/test_diagnostics.py @@ -928,3 +928,67 @@ def test_a_blank_id_list_is_no_filter(client: TestClient, param) -> None: 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 38a474a8..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): @@ -607,3 +607,35 @@ def test_execution_statistics_count_groups_per_era(client: TestClient, settings) 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 index 660a7981..7fb5b009 100644 --- a/backend/tests/test_api/test_routes/test_mip_era_params.py +++ b/backend/tests/test_api/test_routes/test_mip_era_params.py @@ -24,12 +24,17 @@ 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), diff --git a/frontend/src/client/@tanstack/react-query.gen.ts b/frontend/src/client/@tanstack/react-query.gen.ts index 726d1c9f..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, diagnosticsGet, diagnosticsList, diagnosticsListExecutionGroups, 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, DiagnosticsGetData, DiagnosticsGetError, DiagnosticsGetResponse, DiagnosticsListData, DiagnosticsListError, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsError, DiagnosticsListExecutionGroupsResponse, 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 & { @@ -351,6 +351,28 @@ export const diagnosticsListExecutionGroupsOptions = (options: Options) => createQueryKey('diagnosticsListMetricValueFacets', 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 diagnosticsListMetricValueFacetsOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await diagnosticsListMetricValueFacets({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: diagnosticsListMetricValueFacetsQueryKey(options) +}); + export const diagnosticsListMetricValuesQueryKey = (options: Options) => createQueryKey('diagnosticsListMetricValues', options); /** @@ -633,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); /** diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index aceffd72..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, diagnosticsGet, diagnosticsList, diagnosticsListExecutionGroups, 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, 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, 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, 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'; +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 8869b1da..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, DiagnosticsGetData, DiagnosticsGetErrors, DiagnosticsGetResponses, DiagnosticsListData, DiagnosticsListErrors, DiagnosticsListExecutionGroupsData, DiagnosticsListExecutionGroupsErrors, DiagnosticsListExecutionGroupsResponses, 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 & { /** @@ -103,6 +103,15 @@ export const diagnosticsGet = (options: Op */ export const diagnosticsListExecutionGroups = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/execution_groups', ...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 diagnosticsListMetricValueFacets = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/diagnostics/{provider_slug}/{diagnostic_slug}/values/facets', ...options }); + /** * List Metric Values * @@ -192,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 * diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index f20a6990..f01e53c3 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -2352,6 +2352,65 @@ export type DiagnosticsListExecutionGroupsResponses = { export type DiagnosticsListExecutionGroupsResponse = DiagnosticsListExecutionGroupsResponses[keyof DiagnosticsListExecutionGroupsResponses]; +export type DiagnosticsListMetricValueFacetsData = { + body?: never; + path: { + /** + * Provider Slug + */ + provider_slug: string; + /** + * Diagnostic Slug + */ + diagnostic_slug: string; + }; + 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 DiagnosticsListMetricValueFacetsErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DiagnosticsListMetricValueFacetsError = DiagnosticsListMetricValueFacetsErrors[keyof DiagnosticsListMetricValueFacetsErrors]; + +export type DiagnosticsListMetricValueFacetsResponses = { + /** + * Response Diagnostics-List Metric Value Facets + * + * Successful Response + */ + 200: Array; +}; + +export type DiagnosticsListMetricValueFacetsResponse = DiagnosticsListMetricValueFacetsResponses[keyof DiagnosticsListMetricValueFacetsResponses]; + export type DiagnosticsListMetricValuesData = { body?: never; path: { @@ -2415,6 +2474,12 @@ export type DiagnosticsListMetricValuesData = { * Keep only the execution groups that draw on this era, or on no model data */ 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'; }; @@ -2699,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: { @@ -2756,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'; }; 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, From 8ba17c5e8b07e54fb6bea7b6eafaf2ba76df3353 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 23 Oct 2026 23:08:05 +1100 Subject: [PATCH 24/28] docs: add changelog for #147 --- changelog/147.breaking.md | 11 +++++++++++ changelog/147.feature.md | 2 ++ changelog/147.improvement.md | 2 ++ 3 files changed, 15 insertions(+) create mode 100644 changelog/147.breaking.md create mode 100644 changelog/147.feature.md create mode 100644 changelog/147.improvement.md 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. From b0c3177975c8078a8d17fca35ef93a0fd5b986d2 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Sat, 24 Oct 2026 11:26:30 +1100 Subject: [PATCH 25/28] docs: drop 'all' from the execution values value_type --- backend/src/ref_backend/api/routes/executions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index b233e934..85599aba 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -328,7 +328,7 @@ async def list_metric_values( # noqa: PLR0913, PLR0917 """ 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) From 0f393c283bfda5a893acc097ef8ece9c35d016b4 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Sat, 24 Oct 2026 22:21:35 +1100 Subject: [PATCH 26/28] fix: keep obs-only groups in every era-scoped count An era now keeps the groups that draw on no model data in the counts, value flags, execution group lists, statistics and resource usage, as it already did for values. --- .../src/ref_backend/api/routes/diagnostics.py | 10 ++++---- .../src/ref_backend/api/routes/executions.py | 13 ++++------ backend/src/ref_backend/core/mip_eras.py | 9 ++++++- .../test_routes/test_mip_era_params.py | 24 ++++++++++++++++++- 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/backend/src/ref_backend/api/routes/diagnostics.py b/backend/src/ref_backend/api/routes/diagnostics.py index ec27dffd..82d2c576 100644 --- a/backend/src/ref_backend/api/routes/diagnostics.py +++ b/backend/src/ref_backend/api/routes/diagnostics.py @@ -13,7 +13,7 @@ MetricValueType, parse_id_list, ) -from ref_backend.core.mip_eras import MipEra, source_id_datasets, source_types_for +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, @@ -84,7 +84,7 @@ def _scoped_diagnostics(app_context: AppContextDep, mip_era: MipEra | None, sour DiagnosticFilter( include_superseded=False, include_retracted=False, - source_types=source_types_for(mip_era), + **era_scope(mip_era), input_datasets=source_id_datasets(source_id), ), ) @@ -116,6 +116,7 @@ def _catalog_entries(app_context: AppContextDep, scope: _Scope) -> list[Diagnost 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, ), ) @@ -231,7 +232,7 @@ async def list_execution_groups( diagnostic_slug=diagnostic_slug, include_superseded=False, include_retracted=False, - source_types=source_types_for(mip_era), + **era_scope(mip_era), ), with_count=False, ) @@ -255,8 +256,7 @@ async def _metric_filter( # noqa: PLR0913, PLR0917 include_superseded=False, include_retracted=False, latest_only=False, - source_types=source_types_for(mip_era), - or_no_model_input=mip_era is not None, + **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), diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 85599aba..050a24a1 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -15,7 +15,6 @@ 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.datasets import SourceDatasetType 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 @@ -24,7 +23,7 @@ MetricValueType, parse_id_list, ) -from ref_backend.core.mip_eras import MipEra, source_id_datasets, source_types_for +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, @@ -63,15 +62,13 @@ async def get_execution_statistics(app_context: AppContextDep) -> ExecutionStats """ session = app_context.reader.session - def group_counts(source_types: tuple[SourceDatasetType, ...] | None = None) -> GroupCounts: - filters = DiagnosticFilter( - include_superseded=False, include_retracted=False, source_types=source_types - ) + 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( groups=group_counts(), - groups_by_mip_era={era: group_counts(source_types_for(era)) for era in get_args(MipEra)}, + 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(), @@ -127,7 +124,7 @@ async def list_recent_execution_groups( # noqa: PLR0913, PLR0917 dirty=dirty, successful=successful, failure_reasons=failure_reason, - source_types=source_types_for(mip_era), + **era_scope(mip_era), input_datasets=source_id_datasets(source_id), ) groups, total_count = load_execution_groups( diff --git a/backend/src/ref_backend/core/mip_eras.py b/backend/src/ref_backend/core/mip_eras.py index 36594360..ae4fa4a5 100644 --- a/backend/src/ref_backend/core/mip_eras.py +++ b/backend/src/ref_backend/core/mip_eras.py @@ -47,6 +47,13 @@ def source_types_for(mip_era: MipEra | None) -> tuple[SourceDatasetType, ...] | return None if mip_era is None else (_ERA_TYPES[mip_era],) +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 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 @@ -54,7 +61,7 @@ def eras_in_scope(mip_era: MipEra | None) -> tuple[SourceDatasetType, ...]: 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(source_types=source_types_for(mip_era), or_no_model_input=True).group_clauses() + return ExecutionScope(**era_scope(mip_era)).group_clauses() def source_id_datasets(source_id: str | None) -> InputDatasetFilter | None: 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 index 7fb5b009..965a7b1f 100644 --- a/backend/tests/test_api/test_routes/test_mip_era_params.py +++ b/backend/tests/test_api/test_routes/test_mip_era_params.py @@ -96,7 +96,7 @@ def _add_obs_only_value(config) -> int: session.add(execution) session.flush() value = models.ScalarMetricValue.build( - execution_id=execution.id, value=1.0, dimensions={}, attributes=None + execution_id=execution.id, value=1.0, dimensions={"source_id": "obs"}, attributes=None ) session.add(value) session.flush() @@ -115,3 +115,25 @@ def test_values_keep_obs_only_runs_for_either_era(writable_client: TestClient, m 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 From 00be0e11f6525a47e0ed236a3a981f0883f483d6 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Sat, 24 Oct 2026 22:21:37 +1100 Subject: [PATCH 27/28] fix: enable an execution's value tabs from its own values The diagnostic's value flags only cover its promoted version, so they disabled the tabs of older groups. --- .../routes/_app/executions.$groupId/route.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/_app/executions.$groupId/route.tsx b/frontend/src/routes/_app/executions.$groupId/route.tsx index 8a32cbfc..25ea381f 100644 --- a/frontend/src/routes/_app/executions.$groupId/route.tsx +++ b/frontend/src/routes/_app/executions.$groupId/route.tsx @@ -1,4 +1,4 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute, Link, @@ -10,6 +10,7 @@ import { format } from "date-fns"; import { diagnosticsGetOptions, executionsGetOptions, + executionsListMetricValuesOptions, } from "@/client/@tanstack/react-query.gen"; import { DownloadOutputs } from "@/components/execution/downloadOutputs.tsx"; import { ExecutionStatusBadge } from "@/components/execution/executionStatusBadge"; @@ -49,6 +50,31 @@ const NavTab = ({ ); }; +/** + * Whether the group's latest execution stored values of this type + * + * The diagnostic's own flags only cover its promoted version, so they miss older groups. + */ +const useHasValues = ( + groupId: string, + valueType: "scalar" | "series", + enabled: boolean, +) => { + 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 = () => { From 1755cb25b28f2185a04479631f80afdb27fe99b8 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Sun, 25 Oct 2026 08:04:19 +1100 Subject: [PATCH 28/28] fix: leave groups with a retracted latest execution out of the model pages The model run counts, failures and ensemble comparisons now drop such a group, matching the other routes. --- .../src/ref_backend/core/model_ensemble.py | 3 ++- backend/src/ref_backend/core/model_runs.py | 2 ++ .../test_core/test_model_ensemble_retries.py | 22 +++++++++++++++++++ backend/tests/test_core/test_model_runs.py | 9 ++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/backend/src/ref_backend/core/model_ensemble.py b/backend/src/ref_backend/core/model_ensemble.py index 3961112e..98b91333 100644 --- a/backend/src/ref_backend/core/model_ensemble.py +++ b/backend/src/ref_backend/core/model_ensemble.py @@ -90,7 +90,7 @@ def _scalar_rows( ) -> 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. + # A retried group holds a value per attempt, so only the unretracted execution that decided it counts. latest = select_latest_executions().subquery() statement = ( @@ -108,6 +108,7 @@ 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), diff --git a/backend/src/ref_backend/core/model_runs.py b/backend/src/ref_backend/core/model_runs.py index 44633eeb..3e5bb65f 100644 --- a/backend/src/ref_backend/core/model_runs.py +++ b/backend/src/ref_backend/core/model_runs.py @@ -40,6 +40,7 @@ def _model_groups(mip_era: MipEra | None, source_id: str | None) -> CompoundSele 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. """ @@ -61,6 +62,7 @@ def _model_groups(mip_era: MipEra | None, source_id: str | None) -> CompoundSele .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() 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 5dbff030..b0c5e56c 100644 --- a/backend/tests/test_core/test_model_runs.py +++ b/backend/tests/test_core/test_model_runs.py @@ -192,3 +192,12 @@ def test_a_backfilled_execution_does_not_become_the_latest(writable_session): 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()