From 2eb58269ad1a44e15f77ca8918823821785da391 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Thu, 1 Oct 2026 20:20:35 +0200 Subject: [PATCH 1/2] Define the mass balance fluxes everywhere acabf, libmassbfgr and libmassbffl move to the forbidden fill policy, as agreed on issue #39: like the other mass fluxes, they hold a number in every cell. A model without floating ice can then submit the mandatory libmassbffl as zeros, where before it had to be all fill values, which the checker rejects. What the old policies said about where the basal fluxes live is kept as a new consistency check: libmassbfgr is 0 where sftgrf is 0, and libmassbffl is 0 where sftflf is 0. It runs at margin_severity, a warning for now. The first round of submissions was written to the old policies, so a fill value in these three variables is a warning rather than an error. A new fill_severity column in the data request says so, and making it an error later is a one-cell change. The generator writes the basal fluxes as 0 where their mask is 0, and acabf drops out of the outside_domain footprint comparison, which is the only change to the reference logs. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/dev/generating-test-files.md | 2 +- docs/user/checks.md | 5 +- docs/user/errors-and-warnings.md | 26 ++++-- isschecker/checker.py | 89 +++++++++++++++++-- isschecker/data/ISMIP7_variable_request.csv | 86 +++++++++--------- isschecker/generate.py | 12 ++- tests/reference/compliance_checker_log.txt | 2 - .../compliance_checker_log_non_mandatory.txt | 2 - tests/test_compliance_checker.py | 58 ++++++++++-- tests/test_consistency.py | 50 +++++++++-- tests/test_generate.py | 12 ++- 11 files changed, 261 insertions(+), 83 deletions(-) diff --git a/docs/dev/generating-test-files.md b/docs/dev/generating-test-files.md index 658a5a9..39c6969 100644 --- a/docs/dev/generating-test-files.md +++ b/docs/dev/generating-test-files.md @@ -70,7 +70,7 @@ ismip7-generate-test-files --grid GrIS_16000m --scenario ctrl \ - Flux (FL) variables: timestamp is Jul 1 of year N (mid-year), with `time_bounds` = [Jan 1 of N, Jan 1 of N+1]. - `x,y,z,t` variables (e.g. `litemp`): ST snapshots at a sparse set of nominal years. For `historical` and `ocx`: first year of run, 1900 (if in range), last year of run. For projection scenarios: 2100, 2200, 2300 (each if within the simulation year range). The filename year range reflects the full simulation period, not the first/last snapshot year. The set is `CENTURY_SNAPSHOT_YEARS` in `isschecker.checker`, imported here so the generator and the checker cannot disagree; note that no snapshot is written at 2000, which the checker accepts but does not require (see {doc}`../user/time-encoding`). - An analytic ice sheet, so that the files agree with one another. Most variables are still drawn at random within the min/max range the data request allows them, but seven cannot be: `sftgif`, `sftgrf`, `sftflf`, `lithk`, `topg`, `base` and `orog` are computed from a radially symmetric dome on a bowl-shaped bed, because the checker compares them against each other and a random thickness does not agree with a random mask about where the ice is. The identities hold exactly rather than to a tolerance: grounded plus floating fraction is the ice fraction, thickness is positive exactly where the ice fraction is, surface elevation is base plus thickness, and the ice base rests on the bed where the ice is grounded and above it where it floats. The geometry is deliberately analytic rather than taken from BedMachine or Bedmap: a synthetic file's job is to be predictable, and depending on one dataset would invite depending on datasets for everything else. -- Missing values follow each variable's `fill_policy`. A variable the request defines only where there is ice is written as the fill value wherever the ice fraction is zero, and likewise for grounded-only and floating-only variables; the ones defined only inside the computational domain are missing outside it; and the rest are defined everywhere. +- Missing values follow each variable's `fill_policy`. A variable the request defines only where there is ice is written as the fill value wherever the ice fraction is zero, and likewise for grounded-only and floating-only variables; the ones defined only inside the computational domain are missing outside it; and the rest are defined everywhere. The basal mass balance fluxes, `libmassbfgr` and `libmassbffl`, are also set to 0 where their grounded or floating fraction is zero, as the checker requires. - Single precision (`float32`) for all variables and time. - `_FillValue` and `missing_value` set to NetCDF4 default `f4` fill value. - `time` is an unlimited (record) dimension. diff --git a/docs/user/checks.md b/docs/user/checks.md index a07c2e8..a1079b7 100644 --- a/docs/user/checks.md +++ b/docs/user/checks.md @@ -71,8 +71,9 @@ required set of snapshots. Both are described in {doc}`time-encoding`. Gridded variables only. Each file is compared against the files beside it: a variable is missing exactly where its ice mask says there is no ice, the -variables of the computational domain cover the ice, the grounded and -floating fractions sum to the ice fraction, thickness agrees with the ice +variables of the computational domain cover the ice, the basal mass balance +fluxes are zero where there is none of their ice, the grounded and floating +fractions sum to the ice fraction, thickness agrees with the ice mask, and surface elevation, ice base and bed agree with each other. See [Checks that compare files](errors-and-warnings.md#checks-that-compare-files). diff --git a/docs/user/errors-and-warnings.md b/docs/user/errors-and-warnings.md index e7f46a0..65fe9e5 100644 --- a/docs/user/errors-and-warnings.md +++ b/docs/user/errors-and-warnings.md @@ -66,12 +66,17 @@ every variable in its fill_policy column; the table is under Two points modelers ask about. **Ice thickness is zero where there is no ice**, not missing, including outside your computational domain, and so are -the three masks and the calving, grounding-line and ice-front fluxes. **The +the three masks. **All mass fluxes are defined everywhere**: acabf, +libmassbfgr, libmassbffl and the calving, grounding-line and ice-front +fluxes hold a number in every cell, never a fill value. Where there is no +ice, that number is usually 0, but acabf may be nonzero there. **The masks are not restricted to 0 and 1**: any fraction in [0, 1] is accepted, because conservative interpolation from your native grid legitimately produces intermediate values. -A `forbidden` variable holding any fill value is an error. The other +A `forbidden` variable holding any fill value is an error. For acabf, +libmassbfgr and libmassbffl it is a warning for now ([issue #39]); see +[Severities that will change](#severities-that-will-change). The other policies say where a field sits relative to the ice masks, which takes more than one file to check; see [Checks that compare files](#checks-that-compare-files). @@ -87,6 +92,7 @@ A variable whose fill_policy cell is blank is unconstrained, and nothing about its missing values is checked. No shipped row is blank today. [issue #23]: https://github.com/ismip/ISM_SimulationChecker/issues/23 +[issue #39]: https://github.com/ismip/ISM_SimulationChecker/issues/39 ## Checks that compare files @@ -106,6 +112,9 @@ the name but the variable, and compares the two. domain is. That is a warning, because a field taken from a forcing or reference dataset may legitimately cover more of the grid than the ice model does. +- libmassbfgr is 0 where sftgrf is 0, and libmassbffl is 0 where sftflf is + 0. Where there is some grounded or floating ice, any value is accepted, + including 0. For now this is a warning (see below). - sftgrf + sftflf equals sftgif; lithk is greater than zero exactly where sftgif is; orog equals base + lithk; and the ice base rests on the bed where sftgrf is 1 and lies above it where sftflf is 1. None of these needs @@ -121,17 +130,20 @@ there. The grounded and floating comparisons against the bed are the exception: they are made only in cells that are wholly one or the other, since in a half-and-half cell the mean ice base sits somewhere between. -### One severity that will change +### Severities that will change -Two findings turn on where a model puts the ice margin: a variable being -*missing where there is ice*, and thickness disagreeing with the ice mask. A +Three findings turn on where a model puts the ice margin: a variable being +*missing where there is ice*, thickness disagreeing with the ice mask, and a +basal mass balance flux that is not 0 where its mask is 0. A conservatively interpolated mask puts fractions like 1e-6 in a ring along the edge, and a model that writes fill from its own native-grid mask will disagree in every one of those cells. -One round of real submissions will settle which is right, so **those two +One round of real submissions will settle which is right, so **those three findings are warnings for the first round and will become errors -afterwards**. The definitions above are fixed, and a model can be written to +afterwards**. A fill value in acabf, libmassbfgr or libmassbffl is also a +warning for now: these fluxes were allowed missing values in the first +round. The definitions above are fixed, and a model can be written to them today. The finding reports how much of itself is margin, "300 of them have sftgif below 0.01", so the decision can be made on evidence. diff --git a/isschecker/checker.py b/isschecker/checker.py index 7915284..a552524 100644 --- a/isschecker/checker.py +++ b/isschecker/checker.py @@ -32,9 +32,11 @@ # an error whatever the variable is. # - A variable whose 'fill_policy' is 'forbidden' holds no fill values at # all: it is defined over the whole grid, and where there is no ice the -# value is 0 rather than missing. The other policies -- outside_domain, -# no_ice, no_grounded_ice, no_floating_ice -- say where a field sits -# relative to the ice masks, which takes more than one file to check. +# value is 0 rather than missing. That is an error, or a warning for a +# variable whose 'fill_severity' says so -- see _fill_severity. The +# other policies -- outside_domain, no_ice, no_grounded_ice, +# no_floating_ice -- say where a field sits relative to the ice masks, +# which takes more than one file to check. # - All values lie within the allowed min/max range for the relevant region. # A few cells outside it are a warning; RANGE_ERROR_FRACTION or more of # the values are an error, unless the variable's 'range_severity' says @@ -52,6 +54,8 @@ # - An outside_domain variable is defined wherever there is ice (error), and # the outside_domain variables agree about where the domain is (warning: # a forcing or reference dataset may legitimately cover more). +# - libmassbfgr is 0 where sftgrf is 0, and libmassbffl is 0 where sftflf +# is 0, at the severity 'margin_severity' names. # - sftgrf + sftflf equals sftgif; lithk is greater than 0 exactly where # sftgif is; orog equals base + lithk; and the ice base rests on the bed # where sftgrf is 1 and lies above it where sftflf is 1. No densities: @@ -578,6 +582,20 @@ def _margin_severity(value) -> str: return "error" +def _fill_severity(value) -> str: + """How to report a fill value in a variable that may not have any. + + Issue #39 moved the surface and basal mass balance fluxes to `forbidden` + after the first round of submissions had been written to the old policies, + so for those variables a fill value is a warning until the later rounds. + As with the other severity columns, anything the column does not say means + error. + """ + if value is not None and pd.notna(value) and str(value).strip().lower() == "warning": + return "warning" + return "error" + + def _fill_policy(value) -> str | None: """Where a variable is defined, from the fill_policy column of the request. @@ -644,6 +662,7 @@ def _load_criteria(variable_list: str): "range_severity": _range_severity(row.get("range_severity")), "fill_policy": _fill_policy(row.get("fill_policy")), "margin_severity": _margin_severity(row.get("margin_severity")), + "fill_severity": _fill_severity(row.get("fill_severity")), } for col in df.columns: lc = str(col).lower() @@ -1824,7 +1843,12 @@ def _check_missing_values(reporter, ivar, criteria, is_fill, is_nonfinite, fill) # whole grid, so a hole in it is a hole in the archive. n_fill = int(is_fill.sum()) if n_fill and criteria.get("fill_policy") == "forbidden": - reporter.error( + report = ( + reporter.warning + if criteria.get("fill_severity") == "warning" + else reporter.error + ) + report( f"variable '{ivar}' holds a fill value in" f" {_count_phrase(n_fill, total)}. The data request does not permit" f" missing values in this variable: where there is no ice the value" @@ -2133,6 +2157,13 @@ def _open_companion(reporter, source_path, file_name, variable, years, "no_floating_ice": "sftflf", } +# The basal mass balance fluxes are defined everywhere, but are zero wherever +# there is none of the ice they are the flux beneath (issue #39). +ZERO_WITHOUT_MASK = { + "libmassbfgr": "sftgrf", + "libmassbffl": "sftflf", +} + # Below this fraction a cell is the partly glaciated margin rather than the # interior. Used only to describe a finding, never to decide one: the rule is # exact, and this says how much of what it found is margin. @@ -2214,6 +2245,45 @@ def _check_ice_extent(reporter, ds, ivar, criteria, companion) -> None: ) +def _check_zero_without_ice(reporter, ds, ivar, criteria, companion) -> None: + """A basal flux is 0 wherever there is none of the ice it lies beneath. + + Only that direction: a cell with some of that ice may still have no melt. + Fill cells have been reported already and are left out here. + """ + values, missing, steps = _field_and_steps(ds, ivar) + nonzero_without_ice = 0 + total = 0 + + for step in range(steps): + here = _step_of(values, step) + fraction, unknown = _companion_slice(companion, step) + if here.shape != fraction.shape: + reporter.note( + f"Not checked against '{companion.variable}': its grid differs" + f" from this file's." + ) + return + no_ice = np.logical_and( + np.logical_and(~unknown, ~_step_of(missing, step)), fraction == 0.0 + ) + nonzero_without_ice += int(np.logical_and(no_ice, here != 0.0).sum()) + total += here.size + + if nonzero_without_ice: + report = ( + reporter.warning + if criteria.get("margin_severity") == "warning" + else reporter.error + ) + report( + f"variable '{ivar}' is not 0 in" + f" {_count_phrase(nonzero_without_ice, total)} where" + f" '{companion.variable}' is 0. The flux must be 0 where there is" + f" no ice for it to be beneath." + ) + + def _companion_slice(companion, step): """The companion's values for one time step, and where it says nothing. @@ -2282,8 +2352,8 @@ def _check_footprint_matches(reporter, ds, ivar, companion) -> None: They are missing in exactly one place -- outside it -- so their missing masks ought to be identical. A warning rather than an error because some of them come from forcing or reference datasets whose footprint is - legitimately wider than the ice model's: acabf over ice-free ground, - hfgeoubed and refgeoid over the whole grid. + legitimately wider than the ice model's: hfgeoubed and refgeoid over the + whole grid. """ is_fill, is_nonfinite = _missing_masks(ds, ivar) missing = np.logical_or(is_fill, is_nonfinite) @@ -2555,7 +2625,7 @@ def _check_consistency(reporter, ds, ivar, criteria, source_path, file_name, # defined -- and some by name, because a particular pair or triple of them # has to agree about the ice sheet. A variable can be both: orog is # defined throughout the domain and is also base plus thickness. - by_name = ivar in ("sftgif", "lithk", "orog", "base") + by_name = ivar in ("sftgif", "lithk", "orog", "base", *ZERO_WITHOUT_MASK) if ivar not in ds or ( mask_name is None and policy != "outside_domain" and not by_name ): @@ -2615,6 +2685,11 @@ def against(names, check): ["topg", "sftgrf", "sftflf"], lambda b, g, f: _check_base_against_bed(reporter, ds, ivar, b, g, f), ) + elif ivar in ZERO_WITHOUT_MASK: + against( + [ZERO_WITHOUT_MASK[ivar]], + lambda c: _check_zero_without_ice(reporter, ds, ivar, criteria, c), + ) if reporter.total_errors + reporter.total_warnings == findings_before: reporter.ok("Consistent with the files beside it: OK") diff --git a/isschecker/data/ISMIP7_variable_request.csv b/isschecker/data/ISMIP7_variable_request.csv index 0296430..107ec00 100644 --- a/isschecker/data/ISMIP7_variable_request.csv +++ b/isschecker/data/ISMIP7_variable_request.csv @@ -1,43 +1,43 @@ -long_name,Dim,Type,Variable Name,standard_name,units,Mandatory (yes/no),Comment,min_value_ais,max_value_ais,min_value_gris,max_value_gris,range_severity,fill_policy,margin_severity -Ice thickness,"x,y,t",ST,lithk,land_ice_thickness,m,yes,Ice thickness of the ice sheet,0,5500,0,5000,error,forbidden,warning -Surface elevation,"x,y,t",ST,orog,surface_altitude,m,yes,Surface elevation of the ice sheet,0,5500,0,4500,error,outside_domain, -Bedrock elevation,"x,y,t",ST,topg,bedrock_altitude,m,yes,The bedrock topography (may change during the projections),-7000,5500,-4000,4000,error,outside_domain, -Ice base elevation,"x,y,t",ST,base,,m,yes,Should get standard name land_ice_temperature,-4000,4000,-4000,4000,error,outside_domain, -Geothermal heat flux,"x,y,t",FL,hfgeoubed,upward_geothermal_heat_flux_in_land_ice,W m-2,no,Geothermal Heat flux at the ice interface,0,0.3,0,0.3,warning,outside_domain, -Surface mass balance flux,"x,y,t",FL,acabf,land_ice_surface_specific_mass_balance_flux,kg m-2 s-1,yes,Surface Mass Balance flux,-0.0006,0.001,-0.0006,0.001,error,outside_domain, -Basal mass balance flux beneath grounded ice,"x,y,t",FL,libmassbfgr,land_ice_basal_specific_mass_balance_flux,kg m-2 s-1,yes,Basal mass balance flux (only beneath grounded ice),-0.0003,0.0001,-0.0003,0.0001,error,no_grounded_ice,warning -Basal mass balance flux beneath floating ice,"x,y,t",FL,libmassbffl,land_ice_basal_specific_mass_balance_flux,kg m-2 s-1,yes,Basal mass balance flux (only beneath floating ice),-0.008,0.001,-0.008,0.001,error,no_floating_ice,warning -Ice thickness imbalance,"x,y,t",FL,dlithkdt,tendency_of_land_ice_thickness,m s-1,yes,dHdt,-0.0001,0.0001,-0.0001,0.0001,error,forbidden, -Surface velocity in x,"x,y,t",ST,xvelsurf,land_ice_surface_x_velocity,m s-1,no,u-velocity at land ice surface,-0.0004,0.0004,-0.0008,0.0008,error,no_ice,warning -Surface velocity in y,"x,y,t",ST,yvelsurf,land_ice_surface_y_velocity,m s-1,no,v-velocity at land ice surface,-0.0004,0.0004,-0.0008,0.0008,error,no_ice,warning -Surface velocity in z,"x,y,t",ST,zvelsurf,land_ice_surface_upward_velocity,m s-1,no,w-velocity at land ice surface,-4.00E-06,4.00E-06,-8.00E-06,8.00E-06,error,no_ice,warning -Basal velocity in x,"x,y,t",ST,xvelbase,land_ice_basal_x_velocity,m s-1,no,u-velocity at land ice base,-0.0004,0.0004,-0.0008,0.0008,error,no_ice,warning -Basal velocity in y,"x,y,t",ST,yvelbase,land_ice_basal_y_velocity,m s-1,no,v-velocity at land ice base,-0.0004,0.0004,-0.0008,0.0008,error,no_ice,warning -Basal velocity in z,"x,y,t",ST,zvelbase,land_ice_basal_upward_velocity,m s-1,no,w-velocity at land ice base,-5.00E-06,5.00E-06,-8.00E-06,8.00E-06,error,no_ice,warning -Mean velocity in x,"x,y,t",ST,xvelmean,land_ice_vertical_mean_x_velocity,m s-1,yes,Vertical mean land ice velocity ,-0.0008,0.0008,-0.0008,0.0008,error,no_ice,warning -Mean velocity in y,"x,y,t",ST,yvelmean,land_ice_vertical_mean_y_velocity,m s-1,yes,The vertical mean land ice velocity is the average from the bedrock to the surface of the ice,-0.0008,0.0008,-0.0008,0.0008,error,no_ice,warning -Surface temperature,"x,y,t",ST,litemptop,temperature_at_top_of_ice_sheet_model,K,no,Ice temperature at surface,183,290,183,290,error,no_ice,warning -Depth average temperature,"x,y,t",ST,litempavg,,K,no,Should get standard name land_ice_temperature,183,290,183,290,error,no_ice,warning -Basal temperature beneath grounded ice sheet,"x,y,t",ST,litempbotgr,temperature_at_base_of_ice_sheet_model,K,no,Ice temperature at base of grounded ice sheet,183,290,183,290,error,no_grounded_ice,warning -Basal temperature beneath floating ice shelf,"x,y,t",ST,litempbotfl,temperature_at_base_of_ice_sheet_model,K,no,Ice temperature at base of floating ice shelf,183,290,183,290,error,no_floating_ice,warning -Basal drag,"x,y,t",ST,strbasemag,land_ice_basal_drag,Pa,yes,Basal drag,0,1.00E+06,0,1.00E+06,error,no_ice,warning -Calving flux,"x,y,t",FL,licalvf,land_ice_specific_mass_flux_due_to_calving,kg m-2 s-1,yes,Ice mass change resulting from iceberg calving. Only for grid cells in contact with ocean,-10,0,-10,0,error,forbidden, -Grounding line flux,"x,y,t",FL,ligroundf,,kg m-2 s-1,yes,Flux of ice mass across the grounding line. Only for grounding line grid cells. Should get standard name: land_ice_specific_grounding_line_flux,-10,10,-10,10,error,forbidden, -Ice front melt flux,"x,y,t",FL,lifmassbf,,kg m-2 s-1,yes,Ice mass change resulting from ice front melting. Only for grid cells in contact with ocean. Should get standard name: land_ice_specific_mass_flux_due_to_ice_front_melting,-10,0,-10,0,error,forbidden, -Land ice area fraction,"x,y,t",ST,sftgif,land_ice_area_fraction,1,yes,"Fraction of grid cell covered by land ice (ice sheet, ice shelf, ice cap, glacier)",0,1,0,1,error,forbidden, -Grounded ice sheet area fraction,"x,y,t",ST,sftgrf,grounded_ice_sheet_area_fraction,1,yes,"Fraction of grid cell covered by grounded ice sheet, where grounded indicates that the quantity correspond to the ice sheet that flows over bedrock",0,1,0,1,error,forbidden, -Floating ice sheet area fraction,"x,y,t",ST,sftflf,floating_ice_shelf_area_fraction,1,yes,Fraction of grid cell covered by ice sheet flowing over seawater,0,1,0,1,error,forbidden, -Thermal forcing at the ice base under floating ice shelves,"x,y,t",ST,thdrflf,,K,no,Thermal forcing (thermal driving) interpolated to the ice draft under floating ice shelves (fill value for purely grounded ice or not ice),-1,30,-1,30,error,outside_domain, -Anomaly in geopotential height from reference geoid,"x,y,t",ST,deltag,geopotential_height_anomaly,m,no,The change in geoid height should be relative to the reference geoid,-4000,4000,-4000,4000,error,outside_domain, -Reference geoid,"x,y",ST,refgeoid,geoid_height_above_reference_ellipsoid,m,no,This field is calculated with respect to the WGS84 reference ellipsoid,-500,500,-500,500,error,outside_domain, -Ice temperature,"x,y,z,t",ST,litemp,land_ice_temperature,K,no,"3D temperature snapshots at (1900 if in historical), initial state, 2014, 2100, 2200, and 2300. Output on the model native vertical coordinate. Only for models that have 3D temperature calculation",183,274,183,274,error,no_ice,warning -Total ice mass,t,ST,lim,land_ice_mass,kg,yes,"spatial integration, volume times density",0,1.00E+25,0,1.00E+25,error,forbidden, -Mass above floatation,t,ST,limnsw,land_ice_mass_not_displacing_sea_water,kg,yes,"spatial integration, volume times density",0,1.00E+25,0,1.00E+25,error,forbidden, -Grounded ice area,t,ST,iareagr,grounded_ice_sheet_area,m^2,yes,spatial integration,0,1.00E+25,0,1.00E+25,error,forbidden, -Floating ice area,t,ST,iareafl,floating_ice_shelf_area,m^2,yes,spatial integration,0,1.00E+25,0,1.00E+25,error,forbidden, -Total SMB flux,t,FL,tendacabf,tendency_of_land_ice_mass_due_to_surface_mass_balance,kg s-1,yes,spatial integration,-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden, -Total BMB flux beneath grounded ice,t,FL,tendlibmassbfgr,tendency_of_land_ice_mass_due_to_basal_mass_balance,kg s-1,yes,spatial integration,-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden, -Total BMB flux beneath floating ice,t,FL,tendlibmassbffl,tendency_of_land_ice_mass_due_to_basal_mass_balance,kg s-1,yes,spatial integration (computed beneath floating ice only),-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden, -Total calving flux,t,FL,tendlicalvf,tendency_of_land_ice_mass_due_to_calving,kg s-1,yes,spatial integration,-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden, -Total ice front melting flux,t,FL,tendlifmassbf,,kg s-1,yes,"spatial integration, should get standard name: tendency_of_land_ice_mass_due_to_ice_front_melting",-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden, -Total grounding line flux,t,FL,tendligroundf,,kg s-1,yes,"spatial integration, should get a standard name",-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden, +long_name,Dim,Type,Variable Name,standard_name,units,Mandatory (yes/no),Comment,min_value_ais,max_value_ais,min_value_gris,max_value_gris,range_severity,fill_policy,margin_severity,fill_severity +Ice thickness,"x,y,t",ST,lithk,land_ice_thickness,m,yes,Ice thickness of the ice sheet,0,5500,0,5000,error,forbidden,warning, +Surface elevation,"x,y,t",ST,orog,surface_altitude,m,yes,Surface elevation of the ice sheet,0,5500,0,4500,error,outside_domain,, +Bedrock elevation,"x,y,t",ST,topg,bedrock_altitude,m,yes,The bedrock topography (may change during the projections),-7000,5500,-4000,4000,error,outside_domain,, +Ice base elevation,"x,y,t",ST,base,,m,yes,Should get standard name land_ice_temperature,-4000,4000,-4000,4000,error,outside_domain,, +Geothermal heat flux,"x,y,t",FL,hfgeoubed,upward_geothermal_heat_flux_in_land_ice,W m-2,no,Geothermal Heat flux at the ice interface,0,0.3,0,0.3,warning,outside_domain,, +Surface mass balance flux,"x,y,t",FL,acabf,land_ice_surface_specific_mass_balance_flux,kg m-2 s-1,yes,Surface Mass Balance flux,-0.0006,0.001,-0.0006,0.001,error,forbidden,,warning +Basal mass balance flux beneath grounded ice,"x,y,t",FL,libmassbfgr,land_ice_basal_specific_mass_balance_flux,kg m-2 s-1,yes,Basal mass balance flux (only beneath grounded ice),-0.0003,0.0001,-0.0003,0.0001,error,forbidden,warning,warning +Basal mass balance flux beneath floating ice,"x,y,t",FL,libmassbffl,land_ice_basal_specific_mass_balance_flux,kg m-2 s-1,yes,Basal mass balance flux (only beneath floating ice),-0.008,0.001,-0.008,0.001,error,forbidden,warning,warning +Ice thickness imbalance,"x,y,t",FL,dlithkdt,tendency_of_land_ice_thickness,m s-1,yes,dHdt,-0.0001,0.0001,-0.0001,0.0001,error,forbidden,, +Surface velocity in x,"x,y,t",ST,xvelsurf,land_ice_surface_x_velocity,m s-1,no,u-velocity at land ice surface,-0.0004,0.0004,-0.0008,0.0008,error,no_ice,warning, +Surface velocity in y,"x,y,t",ST,yvelsurf,land_ice_surface_y_velocity,m s-1,no,v-velocity at land ice surface,-0.0004,0.0004,-0.0008,0.0008,error,no_ice,warning, +Surface velocity in z,"x,y,t",ST,zvelsurf,land_ice_surface_upward_velocity,m s-1,no,w-velocity at land ice surface,-4.00E-06,4.00E-06,-8.00E-06,8.00E-06,error,no_ice,warning, +Basal velocity in x,"x,y,t",ST,xvelbase,land_ice_basal_x_velocity,m s-1,no,u-velocity at land ice base,-0.0004,0.0004,-0.0008,0.0008,error,no_ice,warning, +Basal velocity in y,"x,y,t",ST,yvelbase,land_ice_basal_y_velocity,m s-1,no,v-velocity at land ice base,-0.0004,0.0004,-0.0008,0.0008,error,no_ice,warning, +Basal velocity in z,"x,y,t",ST,zvelbase,land_ice_basal_upward_velocity,m s-1,no,w-velocity at land ice base,-5.00E-06,5.00E-06,-8.00E-06,8.00E-06,error,no_ice,warning, +Mean velocity in x,"x,y,t",ST,xvelmean,land_ice_vertical_mean_x_velocity,m s-1,yes,Vertical mean land ice velocity ,-0.0008,0.0008,-0.0008,0.0008,error,no_ice,warning, +Mean velocity in y,"x,y,t",ST,yvelmean,land_ice_vertical_mean_y_velocity,m s-1,yes,The vertical mean land ice velocity is the average from the bedrock to the surface of the ice,-0.0008,0.0008,-0.0008,0.0008,error,no_ice,warning, +Surface temperature,"x,y,t",ST,litemptop,temperature_at_top_of_ice_sheet_model,K,no,Ice temperature at surface,183,290,183,290,error,no_ice,warning, +Depth average temperature,"x,y,t",ST,litempavg,,K,no,Should get standard name land_ice_temperature,183,290,183,290,error,no_ice,warning, +Basal temperature beneath grounded ice sheet,"x,y,t",ST,litempbotgr,temperature_at_base_of_ice_sheet_model,K,no,Ice temperature at base of grounded ice sheet,183,290,183,290,error,no_grounded_ice,warning, +Basal temperature beneath floating ice shelf,"x,y,t",ST,litempbotfl,temperature_at_base_of_ice_sheet_model,K,no,Ice temperature at base of floating ice shelf,183,290,183,290,error,no_floating_ice,warning, +Basal drag,"x,y,t",ST,strbasemag,land_ice_basal_drag,Pa,yes,Basal drag,0,1.00E+06,0,1.00E+06,error,no_ice,warning, +Calving flux,"x,y,t",FL,licalvf,land_ice_specific_mass_flux_due_to_calving,kg m-2 s-1,yes,Ice mass change resulting from iceberg calving. Only for grid cells in contact with ocean,-10,0,-10,0,error,forbidden,, +Grounding line flux,"x,y,t",FL,ligroundf,,kg m-2 s-1,yes,Flux of ice mass across the grounding line. Only for grounding line grid cells. Should get standard name: land_ice_specific_grounding_line_flux,-10,10,-10,10,error,forbidden,, +Ice front melt flux,"x,y,t",FL,lifmassbf,,kg m-2 s-1,yes,Ice mass change resulting from ice front melting. Only for grid cells in contact with ocean. Should get standard name: land_ice_specific_mass_flux_due_to_ice_front_melting,-10,0,-10,0,error,forbidden,, +Land ice area fraction,"x,y,t",ST,sftgif,land_ice_area_fraction,1,yes,"Fraction of grid cell covered by land ice (ice sheet, ice shelf, ice cap, glacier)",0,1,0,1,error,forbidden,, +Grounded ice sheet area fraction,"x,y,t",ST,sftgrf,grounded_ice_sheet_area_fraction,1,yes,"Fraction of grid cell covered by grounded ice sheet, where grounded indicates that the quantity correspond to the ice sheet that flows over bedrock",0,1,0,1,error,forbidden,, +Floating ice sheet area fraction,"x,y,t",ST,sftflf,floating_ice_shelf_area_fraction,1,yes,Fraction of grid cell covered by ice sheet flowing over seawater,0,1,0,1,error,forbidden,, +Thermal forcing at the ice base under floating ice shelves,"x,y,t",ST,thdrflf,,K,no,Thermal forcing (thermal driving) interpolated to the ice draft under floating ice shelves (fill value for purely grounded ice or not ice),-1,30,-1,30,error,outside_domain,, +Anomaly in geopotential height from reference geoid,"x,y,t",ST,deltag,geopotential_height_anomaly,m,no,The change in geoid height should be relative to the reference geoid,-4000,4000,-4000,4000,error,outside_domain,, +Reference geoid,"x,y",ST,refgeoid,geoid_height_above_reference_ellipsoid,m,no,This field is calculated with respect to the WGS84 reference ellipsoid,-500,500,-500,500,error,outside_domain,, +Ice temperature,"x,y,z,t",ST,litemp,land_ice_temperature,K,no,"3D temperature snapshots at (1900 if in historical), initial state, 2014, 2100, 2200, and 2300. Output on the model native vertical coordinate. Only for models that have 3D temperature calculation",183,274,183,274,error,no_ice,warning, +Total ice mass,t,ST,lim,land_ice_mass,kg,yes,"spatial integration, volume times density",0,1.00E+25,0,1.00E+25,error,forbidden,, +Mass above floatation,t,ST,limnsw,land_ice_mass_not_displacing_sea_water,kg,yes,"spatial integration, volume times density",0,1.00E+25,0,1.00E+25,error,forbidden,, +Grounded ice area,t,ST,iareagr,grounded_ice_sheet_area,m^2,yes,spatial integration,0,1.00E+25,0,1.00E+25,error,forbidden,, +Floating ice area,t,ST,iareafl,floating_ice_shelf_area,m^2,yes,spatial integration,0,1.00E+25,0,1.00E+25,error,forbidden,, +Total SMB flux,t,FL,tendacabf,tendency_of_land_ice_mass_due_to_surface_mass_balance,kg s-1,yes,spatial integration,-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden,, +Total BMB flux beneath grounded ice,t,FL,tendlibmassbfgr,tendency_of_land_ice_mass_due_to_basal_mass_balance,kg s-1,yes,spatial integration,-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden,, +Total BMB flux beneath floating ice,t,FL,tendlibmassbffl,tendency_of_land_ice_mass_due_to_basal_mass_balance,kg s-1,yes,spatial integration (computed beneath floating ice only),-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden,, +Total calving flux,t,FL,tendlicalvf,tendency_of_land_ice_mass_due_to_calving,kg s-1,yes,spatial integration,-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden,, +Total ice front melting flux,t,FL,tendlifmassbf,,kg s-1,yes,"spatial integration, should get standard name: tendency_of_land_ice_mass_due_to_ice_front_melting",-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden,, +Total grounding line flux,t,FL,tendligroundf,,kg s-1,yes,"spatial integration, should get a standard name",-1.00E+09,1.00E+09,-1.00E+09,1.00E+09,error,forbidden,, diff --git a/isschecker/generate.py b/isschecker/generate.py index dfb8f7a..ca416c7 100644 --- a/isschecker/generate.py +++ b/isschecker/generate.py @@ -14,7 +14,7 @@ from pathlib import Path import netCDF4 -from .checker import CENTURY_SNAPSHOT_YEARS +from .checker import CENTURY_SNAPSHOT_YEARS, ZERO_WITHOUT_MASK DATA_PACKAGE = f'{__package__}.data' @@ -142,8 +142,9 @@ def spatial_field(var_name, var_info, shape, min_val, max_val, geometry, """One spatial variable's synthetic data, with its missing values in place. Geometry variables come from the ice sheet; everything else is still drawn - at random within its allowed range, and then holed out where the variable's - policy says it is not defined. + at random within its allowed range, then set to 0 where a basal flux has no + ice to be beneath, and holed out where the variable's policy says it is not + defined. """ field = geometry.get(var_name) if field is None: @@ -151,6 +152,11 @@ def spatial_field(var_name, var_info, shape, min_val, max_val, geometry, else: field = np.broadcast_to(field, shape).astype(np.float32) + mask_name = ZERO_WITHOUT_MASK.get(var_name) + if mask_name is not None: + no_ice = np.broadcast_to(geometry[mask_name] == 0.0, shape) + field = np.where(no_ice, 0.0, field) + missing = missing_where(var_info.get('fill_policy'), geometry) if missing is not None: field = np.where(np.broadcast_to(missing, shape), fillval, field) diff --git a/tests/reference/compliance_checker_log.txt b/tests/reference/compliance_checker_log.txt index cabd1d0..51a0142 100644 --- a/tests/reference/compliance_checker_log.txt +++ b/tests/reference/compliance_checker_log.txt @@ -62,8 +62,6 @@ SPATIAL Tests - Grid: Lowest left corner is well defined. - Grid: Upper right corner is well defined. - The grid resolution (16 km) was successfully verified. -CONSISTENCY Tests - - Consistent with the files beside it: OK TIME Tests - Time is a record (unlimited) dimension: OK - Time axis: 2 annual FL time step(s) covering nominal years 2013-2014, as experiment 'historical' requires: OK diff --git a/tests/reference/compliance_checker_log_non_mandatory.txt b/tests/reference/compliance_checker_log_non_mandatory.txt index 5d95012..78e2968 100644 --- a/tests/reference/compliance_checker_log_non_mandatory.txt +++ b/tests/reference/compliance_checker_log_non_mandatory.txt @@ -61,8 +61,6 @@ SPATIAL Tests - Grid: Lowest left corner is well defined. - Grid: Upper right corner is well defined. - The grid resolution (16 km) was successfully verified. -CONSISTENCY Tests - - Consistent with the files beside it: OK TIME Tests - Time is a record (unlimited) dimension: OK - Time axis: 2 annual FL time step(s) covering nominal years 2013-2014, as experiment 'historical' requires: OK diff --git a/tests/test_compliance_checker.py b/tests/test_compliance_checker.py index 503759e..4054b0c 100644 --- a/tests/test_compliance_checker.py +++ b/tests/test_compliance_checker.py @@ -1489,22 +1489,21 @@ def test_shipped_range_severities(): SHIPPED_FILL_POLICIES = { "forbidden": { - "lithk", "dlithkdt", "sftgif", "sftgrf", "sftflf", "licalvf", - "ligroundf", "lifmassbf", "lim", "limnsw", "iareagr", "iareafl", + "lithk", "dlithkdt", "sftgif", "sftgrf", "sftflf", "acabf", + "libmassbfgr", "libmassbffl", "licalvf", "ligroundf", "lifmassbf", "lim", "limnsw", "iareagr", "iareafl", "tendacabf", "tendlibmassbfgr", "tendlibmassbffl", "tendlicalvf", "tendlifmassbf", "tendligroundf", }, "outside_domain": { - "orog", "topg", "base", "acabf", "hfgeoubed", "thdrflf", "deltag", - "refgeoid", + "orog", "topg", "base", "hfgeoubed", "thdrflf", "deltag", "refgeoid", }, "no_ice": { "xvelsurf", "yvelsurf", "zvelsurf", "xvelbase", "yvelbase", "zvelbase", "xvelmean", "yvelmean", "strbasemag", "litemptop", "litempavg", "litemp", }, - "no_grounded_ice": {"litempbotgr", "libmassbfgr"}, - "no_floating_ice": {"litempbotfl", "libmassbffl"}, + "no_grounded_ice": {"litempbotgr"}, + "no_floating_ice": {"litempbotfl"}, } @@ -1614,6 +1613,51 @@ def test_checker_reports_a_fill_value_the_request_forbids( ) +@pytest.mark.parametrize("variable_name", ["acabf", "libmassbfgr", "libmassbffl"]) +def test_a_fill_value_in_a_mass_balance_flux_is_a_warning_for_now( + xyt_case_dir, variable_name +): + """Issue #39 made these fluxes `forbidden` after the first round. + + Submissions written to the old policies are not failed for it; see + fill_severity. + """ + write_values( + dataset_for_variable(xyt_case_dir, variable_name), + netCDF4.default_fillvals["f4"], + ) + + summary = run_xyt_checker(xyt_case_dir) + + assert summary["total_errors"] == 0, summary["log_text"] + assert summary["total_warnings"] >= 1 + assert ( + f"variable '{variable_name}' holds a fill value in 5 of" + in summary["log_text"] + ) + + +def test_shipped_fill_severities(): + """Which fill values are only a warning is data, and changes on purpose.""" + ismip_meta, _, _, _, _ = checker._load_criteria("ismip7") + + warnings = { + entry["variable"] + for entry in ismip_meta + if entry["fill_severity"] == "warning" + } + assert warnings == {"acabf", "libmassbfgr", "libmassbffl"} + + +@pytest.mark.parametrize( + "value, expected", + [("warning", "warning"), ("error", "error"), (None, "error"), + ("nonsense", "error")], +) +def test_fill_severity_defaults_to_error(value, expected): + assert checker._fill_severity(value) == expected + + def test_checker_reports_a_gappy_scalar_series(case_dir): """A scalar carries the same policy: a missing total ice mass is a hole. @@ -1651,7 +1695,7 @@ def test_a_nan_and_a_fill_value_are_two_separate_findings(xyt_case_dir): @pytest.mark.parametrize( "variable_name, value", - [("xvelmean", 0.0), ("libmassbfgr", 0.0), ("libmassbffl", 0.0)], + [("xvelmean", 0.0), ("litempbotgr", 250.0), ("litempbotfl", 250.0)], ) def test_checker_reports_an_ice_only_variable_defined_everywhere( xyt_case_dir, variable_name, value diff --git a/tests/test_consistency.py b/tests/test_consistency.py index f087940..432c69d 100644 --- a/tests/test_consistency.py +++ b/tests/test_consistency.py @@ -192,26 +192,62 @@ def test_the_two_directions_are_separate_findings(case_dir): @pytest.mark.parametrize( "variable_name, mask_name", - [("libmassbfgr", "sftgrf"), ("libmassbffl", "sftflf")], + [("litempbotgr", "sftgrf"), ("litempbotfl", "sftflf")], ) def test_grounded_and_floating_variables_use_their_own_mask( case_dir, variable_name, mask_name ): - """A basal flux beneath grounded ice is not checked against all the ice. + """A temperature beneath grounded ice is not checked against all the ice.""" + geometry = geometry_of(case_dir) + set_where(dataset_for_variable(case_dir, variable_name), + geometry[mask_name] == 0.0, 250.0) + + summary = run(case_dir) + + assert summary["total_consistency_errors"] == 1, summary["log_text"] + assert f"where '{mask_name}' is 0" in summary["log_text"] + + +@pytest.mark.parametrize( + "variable_name, mask_name", + [("libmassbfgr", "sftgrf"), ("libmassbffl", "sftflf")], +) +def test_a_basal_flux_without_its_ice_is_a_warning_for_now( + case_dir, variable_name, mask_name +): + """Issue #39: the flux is defined everywhere, and 0 without its ice. It is also an FL variable against an ST mask, so this is the case where matching raw timestamps would have aligned nothing. """ geometry = geometry_of(case_dir) set_where(dataset_for_variable(case_dir, variable_name), - geometry[mask_name] == 0.0, 0.0) + geometry[mask_name] == 0.0, -1.0e-5) summary = run(case_dir) - assert summary["total_consistency_errors"] == 1, summary["log_text"] + assert summary["total_consistency_errors"] == 0, summary["log_text"] + assert summary["total_consistency_warnings"] == 1 + assert "is not 0 in" in summary["log_text"] assert f"where '{mask_name}' is 0" in summary["log_text"] +@pytest.mark.parametrize( + "variable_name, mask_name", + [("libmassbfgr", "sftgrf"), ("libmassbffl", "sftflf")], +) +def test_a_basal_flux_may_be_0_under_its_ice(case_dir, variable_name, mask_name): + """Only one direction is checked: ice with no melt beneath it is fine.""" + geometry = geometry_of(case_dir) + set_where(dataset_for_variable(case_dir, variable_name), + geometry[mask_name] > 0.0, 0.0) + + summary = run(case_dir) + + assert summary["total_consistency_errors"] == 0, summary["log_text"] + assert summary["total_consistency_warnings"] == 0 + + def test_an_absent_mask_leaves_a_note_and_no_finding(case_dir): """Checking a submission a part at a time has to keep working.""" for path in case_dir.glob("sftgif_*.nc"): @@ -243,13 +279,13 @@ def test_ice_outside_the_computational_domain_is_an_error(case_dir): def test_a_wider_footprint_than_its_neighbours_is_only_a_warning(case_dir): - """acabf may legitimately come from a forcing dataset covering the grid. + """hfgeoubed may legitimately come from a dataset covering the grid. So a footprint wider than the ice model's is worth telling the modeler about without failing the run over it. """ - set_where(dataset_for_variable(case_dir, "acabf"), - np.ones_like(geometry_of(case_dir)["domain"]), 0.0) + set_where(dataset_for_variable(case_dir, "hfgeoubed"), + np.ones_like(geometry_of(case_dir)["domain"]), 0.05) summary = run(case_dir) diff --git a/tests/test_generate.py b/tests/test_generate.py index 9309e3d..35af689 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -140,8 +140,10 @@ def read(variable_name): "topg": ~geometry["domain"], "orog": ~geometry["domain"], "xvelmean": geometry["sftgif"] == 0.0, - "libmassbfgr": geometry["sftgrf"] == 0.0, - "libmassbffl": geometry["sftflf"] == 0.0, + "litempbotgr": geometry["sftgrf"] == 0.0, + "litempbotfl": geometry["sftflf"] == 0.0, + "libmassbfgr": np.zeros_like(geometry["domain"]), + "libmassbffl": np.zeros_like(geometry["domain"]), } for variable_name, expected in expected_missing.items(): values, _, _ = read(variable_name) @@ -149,3 +151,9 @@ def read(variable_name): assert np.array_equal( missing, np.broadcast_to(expected, missing.shape) ), variable_name + + # The basal fluxes are defined everywhere but are 0 without their ice. + for variable_name, mask_name in generate.ZERO_WITHOUT_MASK.items(): + values, _, _ = read(variable_name) + no_ice = np.broadcast_to(geometry[mask_name] == 0.0, values.shape) + assert np.all(values[no_ice] == 0.0), variable_name From 5361078d97d32aba72b3d96cf8a9c43bfed04583 Mon Sep 17 00:00:00 2001 From: Xylar Asay-Davis Date: Thu, 1 Oct 2026 20:32:57 +0200 Subject: [PATCH 2/2] Bump the version to 0.7.0 for the flux fill policies Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7d5bbc1..423386c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "isschecker" -version = "0.6.0" +version = "0.7.0" description = "ISMIP7 compliance checker for ice sheet model simulation NetCDF datasets" readme = "README.md" license = "MIT"