diff --git a/js/store-page.js b/js/store-page.js index 291b375..c5c22f9 100644 --- a/js/store-page.js +++ b/js/store-page.js @@ -56,7 +56,7 @@ const PRESETS = [ // gridlook's keys (url, varname, projection, lat, lon, dimIndices_) and // this page's own, which gridlook ignores const defaults = { url: PRESETS[0].url, varname: "", projection: "mollweide", lat: null, lon: null, - t: 0, colour: "data", chunks: 1, cells: 1, coast: 1, grat: 1, pick: "", cam: null }; + t: 0, colour: "data", chunks: 1, cells: 1, coast: 1, grat: 1, pick: "", cam: null, assume: "" }; const state = { ...defaults, dims: {} }; function readHash() { @@ -74,6 +74,7 @@ function readHash() { const c = params.get("colour"); if (["data", "area", "omega", "none"].includes(c)) state.colour = c; state.pick = params.get("pick") || ""; + state.assume = R.ASSUME[params.get("assume")] ? params.get("assume") : ""; const cam = (params.get("cam") || "").split(",").map(Number); state.cam = cam.length === 6 && cam.every(isFinite) ? cam : null; for (const [k, v] of params) if (k.startsWith("dimIndices_") && isFinite(+v)) state.dims[k.slice(11)] = Math.max(0, Math.round(+v)); @@ -90,6 +91,7 @@ function writeHash() { if (state.t) p.t = +state.t.toFixed(3); for (const k of ["colour", "chunks", "cells", "coast", "grat"]) if (state[k] !== defaults[k]) p[k] = state[k]; if (state.pick) p.pick = state.pick; + if (state.assume) p.assume = state.assume; if (state.cam) p.cam = state.cam.map((x) => +x.toFixed(2)).join(","); history.replaceState(null, "", "#" + C.hashString(state.url, p)); $("open-full").href = location.pathname + location.hash; @@ -727,7 +729,10 @@ async function open() { if (!state.varname) throw new Error("the store lists no arrays: add ::varname= to the address"); if (![...sel.options].some((o) => o.value === state.varname)) { const o = document.createElement("option"); o.value = o.textContent = state.varname; sel.appendChild(o); } sel.value = state.varname; - const v = await R.describeVariable(s, state.varname); + const v = await R.describeVariable(s, state.varname, { assume: state.assume }); + // the choice of satellite, offered only where the store leaves it open + $("assume-row").hidden = !(v.abiLike || (v.crs && v.crs.assumed)); + $("assume").value = state.assume; const F = v.grid ? await crsOf(v) : null; if (serial !== openSerial) return; const g = v.grid || { x0: 0, dx: 1, y0: 0, dy: 1, nx: v.meta.shape[v.ix], ny: v.meta.shape[v.iy] }; @@ -1010,6 +1015,7 @@ $("centre-grid").addEventListener("click", () => { if (cur) { buildFlat(); place(); } writeHash(); }); +$("assume").addEventListener("change", (e) => { state.assume = e.target.value; writeHash(); open(); }); ui.colour.addEventListener("change", () => { state.colour = ui.colour.value; if (cur) { drawTexture(); showLegend(); } writeHash(); }); for (const k of ["chunks", "cells", "coast", "grat"]) $(k).addEventListener("change", () => { state[k] = $(k).checked ? 1 : 0; if (cur) { drawTexture(); place(); showLegend(); } writeHash(); }); diff --git a/js/store-read.js b/js/store-read.js index d151466..4aad222 100644 --- a/js/store-read.js +++ b/js/store-read.js @@ -193,7 +193,15 @@ async function bodyNamed(s, attrs, dir) { // Where the cells of the variable at path are, and in which CRS: // { path, meta, dims, ix, iy, grid (storecrs.gridOf), crs (a cfProj()-like // description or null), crsFrom, notes[] }. -export async function describeVariable(s, path) { +// Satellites whose fixed grid can be assumed when a store has GOES ABI scan +// angles but names no satellite (the ABI fixed grid's own parameters). +export const ASSUME = { + goes_east: { label: "GOES-East (75.0 W)", lon0: -75 }, + goes_west: { label: "GOES-West (137.0 W)", lon0: -137 } +}; +const ABI_H = 35786023; + +export async function describeVariable(s, path, opts = {}) { const meta = await nodeMeta(s, path); if (!meta || meta.group) throw new Error(`${path || "the root"} is not an array`); const nd = meta.shape.length; @@ -275,7 +283,7 @@ export async function describeVariable(s, path) { } // without a transform, the coordinate arrays - let x = null, y = null, xs = 1, ys = 1, xc = null, yc = null; + let x = null, y = null, xs = 1, ys = 1, xc = null, yc = null, abiLike = false; if (!t && !curv) { const cx = coords[ix] ? await read1d(s, join(dir, dims[ix])) : null; const cy = coords[iy] ? await read1d(s, join(dir, dims[iy])) : null; @@ -283,11 +291,22 @@ export async function describeVariable(s, path) { const lonlat = role[ix] === "lon" && role[iy] === "lat"; x = axisOf(cx.values, { lon: lonlat || role[ix] === "rlon" }); y = axisOf(cy.values); + // scan angles with no CRS: a GOES ABI fixed grid, if the user says whose + const u = String(cx.attrs.units || "").toLowerCase(); + abiLike = !crs && !lonlat && x.regular && y.regular && (!u || u.startsWith("rad")) && + Math.max(...[cx.values[0], cx.values[cx.values.length - 1], cy.values[0], cy.values[cy.values.length - 1]].map((q) => Math.abs(Number(q)))) <= 0.16; + if (abiLike && ASSUME[opts.assume]) { + const a = ASSUME[opts.assume]; + crs = { proj4: `+proj=geos +h=${ABI_H} +lon_0=${a.lon0} +sweep=x +x_0=0 +y_0=0 +a=6378137 +b=6356752.31414 +units=m +no_defs`, name: `${a.label} fixed grid, assumed`, h: ABI_H, assumed: opts.assume }; + crsFrom = `an assumption: the store names no CRS, and its x and y look like the GOES ABI fixed grid, here taken as ${a.label}`; + } else if (abiLike) notes.push("x and y look like GOES ABI scan angles (radians), but nothing names the satellite: choose one under Assume to place the grid"); if (!lonlat && role[ix] !== "rlon") { - const h = crs && /\+proj=geos\b/.test(crs.def || crs.proj4 || "") && gma ? Number(gma.perspective_point_height) || 0 : 0; + const h = crs && crs.h ? crs.h : crs && /\+proj=geos\b/.test(crs.def || crs.proj4 || "") && gma ? Number(gma.perspective_point_height) || 0 : 0; + if (crs && crs.h && !cx.attrs.units) { xs = ys = h; } else { xs = unitScale(cx.attrs.units, h); ys = unitScale(cy.attrs.units, h); + } } - if (xs !== 1) notes.push(`x and y are in ${cx.attrs.units}: multiplied by ${xs} for the CRS, which is in metres`); + if (xs !== 1) notes.push(`x and y are in ${cx.attrs.units || "radians (assumed)"}: multiplied by ${xs} for the CRS, which is in metres`); // uneven but 1D (a Gaussian grid's latitudes, say): kept as they are if (!x.regular) xc = unwrapped(cx.values, lonlat || role[ix] === "rlon").map((v) => v * xs); if (!y.regular) yc = Array.from(cy.values, (v) => v * ys); @@ -313,7 +332,7 @@ export async function describeVariable(s, path) { } if (!grid) notes.push("nothing in the store says where the cells are: drawn in index space"); if (!crs && !curv) notes.push("no CRS found"); - return { path, meta, dims, ix, iy, role, grid, crs, crsFrom, gm: gma, notes }; + return { path, meta, dims, ix, iy, role, grid, crs, crsFrom, gm: gma, notes, abiLike }; } // One chunk of the variable, as { data, shape } for the grid dimensions at diff --git a/store.html b/store.html index bdfe0d0..e2a07d5 100644 --- a/store.html +++ b/store.html @@ -97,6 +97,14 @@

playproj

+
diff --git a/test/check-storecrs.mjs b/test/check-storecrs.mjs index cc9c4e6..107759d 100644 --- a/test/check-storecrs.mjs +++ b/test/check-storecrs.mjs @@ -116,7 +116,12 @@ for (const t of man.stores) { let s, v; try { s = await R.openStore(`${base}/${t.name}`); - v = await R.describeVariable(s, t.variable); + if (t.assume) { + // left unplaced until a satellite is chosen + const v0 = await R.describeVariable(s, t.variable); + check(v0.abiLike && !v0.crs, `${t.name}: without an assumption, abiLike ${v0.abiLike}, crs ${JSON.stringify(v0.crs)}`); + } + v = await R.describeVariable(s, t.variable, { assume: t.assume }); } catch (e) { check(false, `${t.name}: ${e.message}`); continue; } const g = v.grid; check(s.kind === (t.name.endsWith(".icechunk") ? "icechunk" : t.kind ? "zarr2" : "zarr3"), `${t.name}: opened as ${s.kind}`); diff --git a/test/fixtures/store/abi.zarr/u_wind/c/0/0/0 b/test/fixtures/store/abi.zarr/u_wind/c/0/0/0 new file mode 100644 index 0000000..dc13ff3 Binary files /dev/null and b/test/fixtures/store/abi.zarr/u_wind/c/0/0/0 differ diff --git a/test/fixtures/store/abi.zarr/u_wind/c/0/0/1 b/test/fixtures/store/abi.zarr/u_wind/c/0/0/1 new file mode 100644 index 0000000..ab361f5 Binary files /dev/null and b/test/fixtures/store/abi.zarr/u_wind/c/0/0/1 differ diff --git a/test/fixtures/store/abi.zarr/u_wind/c/0/1/0 b/test/fixtures/store/abi.zarr/u_wind/c/0/1/0 new file mode 100644 index 0000000..21e8dd6 Binary files /dev/null and b/test/fixtures/store/abi.zarr/u_wind/c/0/1/0 differ diff --git a/test/fixtures/store/abi.zarr/u_wind/c/0/1/1 b/test/fixtures/store/abi.zarr/u_wind/c/0/1/1 new file mode 100644 index 0000000..4b2a8a1 Binary files /dev/null and b/test/fixtures/store/abi.zarr/u_wind/c/0/1/1 differ diff --git a/test/fixtures/store/abi.zarr/u_wind/zarr.json b/test/fixtures/store/abi.zarr/u_wind/zarr.json new file mode 100644 index 0000000..2a0a695 --- /dev/null +++ b/test/fixtures/store/abi.zarr/u_wind/zarr.json @@ -0,0 +1,51 @@ +{ + "shape": [ + 1, + 60, + 60 + ], + "data_type": "float32", + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [ + 1, + 30, + 30 + ] + } + }, + "chunk_key_encoding": { + "name": "default", + "configuration": { + "separator": "/" + } + }, + "fill_value": "NaN", + "codecs": [ + { + "name": "bytes", + "configuration": { + "endian": "little" + } + }, + { + "name": "zstd", + "configuration": { + "level": 0, + "checksum": false + } + } + ], + "attributes": { + "_FillValue": "AAAAAAAA+H8=" + }, + "dimension_names": [ + "time", + "y", + "x" + ], + "zarr_format": 3, + "node_type": "array", + "storage_transformers": [] +} \ No newline at end of file diff --git a/test/fixtures/store/abi.zarr/x/c/0 b/test/fixtures/store/abi.zarr/x/c/0 new file mode 100644 index 0000000..a0edbb6 Binary files /dev/null and b/test/fixtures/store/abi.zarr/x/c/0 differ diff --git a/test/fixtures/store/abi.zarr/x/zarr.json b/test/fixtures/store/abi.zarr/x/zarr.json new file mode 100644 index 0000000..a491f4f --- /dev/null +++ b/test/fixtures/store/abi.zarr/x/zarr.json @@ -0,0 +1,45 @@ +{ + "shape": [ + 60 + ], + "data_type": "float64", + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [ + 60 + ] + } + }, + "chunk_key_encoding": { + "name": "default", + "configuration": { + "separator": "/" + } + }, + "fill_value": "NaN", + "codecs": [ + { + "name": "bytes", + "configuration": { + "endian": "little" + } + }, + { + "name": "zstd", + "configuration": { + "level": 0, + "checksum": false + } + } + ], + "attributes": { + "_FillValue": "AAAAAAAA+H8=" + }, + "dimension_names": [ + "x" + ], + "zarr_format": 3, + "node_type": "array", + "storage_transformers": [] +} \ No newline at end of file diff --git a/test/fixtures/store/abi.zarr/y/c/0 b/test/fixtures/store/abi.zarr/y/c/0 new file mode 100644 index 0000000..6614aff Binary files /dev/null and b/test/fixtures/store/abi.zarr/y/c/0 differ diff --git a/test/fixtures/store/abi.zarr/y/zarr.json b/test/fixtures/store/abi.zarr/y/zarr.json new file mode 100644 index 0000000..2352447 --- /dev/null +++ b/test/fixtures/store/abi.zarr/y/zarr.json @@ -0,0 +1,45 @@ +{ + "shape": [ + 60 + ], + "data_type": "float64", + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [ + 60 + ] + } + }, + "chunk_key_encoding": { + "name": "default", + "configuration": { + "separator": "/" + } + }, + "fill_value": "NaN", + "codecs": [ + { + "name": "bytes", + "configuration": { + "endian": "little" + } + }, + { + "name": "zstd", + "configuration": { + "level": 0, + "checksum": false + } + } + ], + "attributes": { + "_FillValue": "AAAAAAAA+H8=" + }, + "dimension_names": [ + "y" + ], + "zarr_format": 3, + "node_type": "array", + "storage_transformers": [] +} \ No newline at end of file diff --git a/test/fixtures/store/abi.zarr/zarr.json b/test/fixtures/store/abi.zarr/zarr.json new file mode 100644 index 0000000..8dfdd2e --- /dev/null +++ b/test/fixtures/store/abi.zarr/zarr.json @@ -0,0 +1,152 @@ +{ + "attributes": {}, + "zarr_format": 3, + "consolidated_metadata": { + "kind": "inline", + "must_understand": false, + "metadata": { + "u_wind": { + "shape": [ + 1, + 60, + 60 + ], + "data_type": "float32", + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [ + 1, + 30, + 30 + ] + } + }, + "chunk_key_encoding": { + "name": "default", + "configuration": { + "separator": "/" + } + }, + "fill_value": "NaN", + "codecs": [ + { + "name": "bytes", + "configuration": { + "endian": "little" + } + }, + { + "name": "zstd", + "configuration": { + "level": 0, + "checksum": false + } + } + ], + "attributes": { + "_FillValue": "AAAAAAAA+H8=" + }, + "dimension_names": [ + "time", + "y", + "x" + ], + "zarr_format": 3, + "node_type": "array", + "storage_transformers": [] + }, + "x": { + "shape": [ + 60 + ], + "data_type": "float64", + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [ + 60 + ] + } + }, + "chunk_key_encoding": { + "name": "default", + "configuration": { + "separator": "/" + } + }, + "fill_value": "NaN", + "codecs": [ + { + "name": "bytes", + "configuration": { + "endian": "little" + } + }, + { + "name": "zstd", + "configuration": { + "level": 0, + "checksum": false + } + } + ], + "attributes": { + "_FillValue": "AAAAAAAA+H8=" + }, + "dimension_names": [ + "x" + ], + "zarr_format": 3, + "node_type": "array", + "storage_transformers": [] + }, + "y": { + "shape": [ + 60 + ], + "data_type": "float64", + "chunk_grid": { + "name": "regular", + "configuration": { + "chunk_shape": [ + 60 + ] + } + }, + "chunk_key_encoding": { + "name": "default", + "configuration": { + "separator": "/" + } + }, + "fill_value": "NaN", + "codecs": [ + { + "name": "bytes", + "configuration": { + "endian": "little" + } + }, + { + "name": "zstd", + "configuration": { + "level": 0, + "checksum": false + } + } + ], + "attributes": { + "_FillValue": "AAAAAAAA+H8=" + }, + "dimension_names": [ + "y" + ], + "zarr_format": 3, + "node_type": "array", + "storage_transformers": [] + } + } + }, + "node_type": "group" +} \ No newline at end of file diff --git a/test/fixtures/store/manifest.json b/test/fixtures/store/manifest.json index 42f6873..ebda034 100644 --- a/test/fixtures/store/manifest.json +++ b/test/fixtures/store/manifest.json @@ -165,6 +165,26 @@ 25.626005569469218 ] }, + { + "name": "abi.zarr", + "variable": "u_wind", + "nx": 60, + "ny": 60, + "chunks": [ + 1, + 30, + 30 + ], + "assume": "goes_west", + "x0": -2683951.725, + "dx": 89465.0575, + "y0": 2683951.725, + "dy": -89465.0575, + "corner": [ + -165.53956341627742, + 25.626005569469218 + ] + }, { "name": "mars.zarr", "variable": "tsurf", diff --git a/test/make-store-fixtures.py b/test/make-store-fixtures.py index a958bfc..6259906 100644 --- a/test/make-store-fixtures.py +++ b/test/make-store-fixtures.py @@ -229,6 +229,16 @@ def write_icechunk(name, ds, encoding, group=None): "x0": float((ang[0] - step / 2) * h), "dx": step * h, "y0": float((ang[-1] + step / 2) * h), "dy": -step * h, "corner": list(pyproj.Transformer.from_crs(gcrs, gcrs.geodetic_crs, always_xy=True).transform(ang[0] * h, ang[-1] * h))}) +# the same scan angles with nothing to say whose: no units, no grid mapping +# (as the GEO-GEO stereo wind store has them); placed only by assumption +ds = xr.Dataset({"u_wind": (("time", "y", "x"), field(n, n, 1))}, + coords={"x": ("x", ang), "y": ("y", ang[::-1].copy())}) +ds.to_zarr(os.path.join(OUT, "abi.zarr"), mode="w", encoding={"u_wind": {"chunks": (1, 30, 30)}}, consolidated=True, zarr_format=3) +west = pyproj.CRS.from_cf({**goes, "longitude_of_projection_origin": -137.0}) +stores.append({"name": "abi.zarr", "variable": "u_wind", "nx": n, "ny": n, "chunks": [1, 30, 30], "assume": "goes_west", + "x0": float((ang[0] - step / 2) * h), "dx": step * h, "y0": float((ang[-1] + step / 2) * h), "dy": -step * h, + "corner": list(pyproj.Transformer.from_crs(west, west.geodetic_crs, always_xy=True).transform(ang[0] * h, ang[-1] * h))}) + # another world: lon/lat with no CRS, and only the attributes say Mars mlat = np.arange(87.5, -90, -5.0); mlon = np.arange(-180, 180, 5.0) ds = xr.Dataset({"tsurf": (("time", "lat", "lon"), field(len(mlat), len(mlon)), {"units": "K", "CATDESC": "Atmospheric temperature at the surface of Mars"})},