Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,55 @@ const slice = await point.timeRange({
console.log(await slice.toRecords("precipitation"));
```

### Station data usage

Gridded Zarr datasets come from `loadDataset`. Point-observation **station**
datasets (GHCND and friends) live under `client.stations`, and read the same way:
degrees, ISO timestamps, chained selections.

```typescript
const stations = await client.stations.load({ cid: "bafyr4i..." });
Comment on lines +82 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a valid CID in the loading example.

StationsClient.load parses request.cid with CID.parse. The "bafyr4i..." value is invalid, so this example throws DatasetNotFoundError when copied. Use a full valid CID or mark the value as a non-runnable placeholder.

Proposed documentation fix
-const stations = await client.stations.load({ cid: "bafyr4i..." });
+// Replace with a full, valid station dataset root CID.
+const stations = await client.stations.load({ cid: "<full-root-cid>" });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```typescript
const stations = await client.stations.load({ cid: "bafyr4i..." });
// Replace with a full, valid station dataset root CID.
const stations = await client.stations.load({ cid: "<full-root-cid>" });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 82 - 83, Update the README StationsClient.load
example to use a complete valid CID, or explicitly mark the current value as a
non-runnable placeholder so readers do not copy an invalid request.


// Every station, with position and coverage window.
for (const s of stations.stations) {
console.log(s.stationId, s.latitude, s.longitude, s.start, s.end);
}

// Stations within 50 km of a point, over one week.
const records = await stations
.circle(40.75, -73.99, 50)
.timeRange({ start: "2023-01-01", end: "2023-01-07" })
.toRecords("TMAX");
```

Selections return new instances, so a partial selection can be branched:

```typescript
const week = stations.timeRange({ start: "2023-01-01", end: "2023-01-07" });
const nyc = await week.select("USW00094728").rows();
const lax = await week.select("USW00023174").rows();
```

Two things differ from `GeoTemporalDataset`, because the data model differs:

- **`nearest(lat, lon, { maxKm })` instead of `point()`.** A grid always has a
cell under any coordinate; stations are irregular, so the nearest one may be
far away. Pass `maxKm` to make that a hard bound rather than a surprise.
- **`where(...)` has no gridded counterpart.** Row-level predicates are pushed
down to fragment statistics, so most fragments are skipped without being read:

```typescript
const hotDays = await stations
.nearest(29.98, -95.36)
.timeRange({ start: "2025-01-01", end: "2025-12-31" })
.where({ element: "TMAX", op: "gt", value: 350 }) // tenths of °C, so 35 °C
.rows();
Comment on lines +110 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 '\bwhere\s*\(|fragment|statistics|predicate|skip' src tests

Repository: dClimate/dclimate-client-js

Length of output: 24478


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files likely to define where/rows/fetch paths =="
git ls-files | rg '(^|/)(README\.md|.*\.ts)$' | rg 'src |package.json|tests' | sed -n '1,200p'

echo
echo "== find all identifiers that export/assign 'where' or related query builder methods =="
rg -n "where:|method:.*where|\\.where\\(|function where|const where|rows\\(|nearest\\(|timeRange\\(" src tests --glob '*.ts' --glob '!src/shapes/circle.ts' --glob '!src/actions/concatenate-variants.ts' | sed -n '1,240p'

Repository: dClimate/dclimate-client-js

Length of output: 4384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== src/geotemporal-dataset.ts outline =="
ast-grep outline src/geotemporal-dataset.ts || true

echo
echo "== src/geotemporal-dataset.ts relevant section =="
sed -n '1,340p' src/geotemporal-dataset.ts | cat -n

echo
echo "== occurrences of API query predicates in src and tests =="
rg -n "where|op:|value:|element|statistic|min|max|stats|bounds|bbox|spatialExtent|fragment|skip" src tests --glob '*.ts' | sed -n '1,320p'

Repository: dClimate/dclimate-client-js

Length of output: 28615


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== README.md around documented API usage =="
sed -n '90,130p' README.md | cat -n

echo
echo "== all source/test references to rows, station, nearest, and predicate shapes =="
rg -n "\.rows\(|stations|nearest\(|where\(|{ element:|element.*op:|op: .*value:|\.where\(" . --glob '*.ts' --glob '*.md' --glob '!package-lock.json' --glob '!node_modules/**' | sed -n '1,320p'

echo
echo "== git tracked ts files under src with top-level exports/classes likely query API =="
git ls-files src/*.ts src/**/*.ts | sed -n '1,200p'

Repository: dClimate/dclimate-client-js

Length of output: 2284


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all source/test references to stations, nearest, rows, and where =="
python3 - <<'PY'
import subprocess
patterns = [r"\.rows\(", r"stations", r"\.nearest\(", r"\.where\(", r"\brows\(", r"\bnearest\("]
for pattern in patterns:
    print(f"\n-- pattern: {pattern} --")
    try:
        subprocess.run([
            "rg", "-n", "-C", "4", pattern, ".",
            "--glob", "*.ts", "--glob", "*.md",
            "--glob", "!package-lock.json",
            "--glob", "!node_modules/**"
        ], check=False)
    except subprocess.CalledProcessError as e:
        if e.returncode != 1: raise
PY

Repository: dClimate/dclimate-client-js

Length of output: 23875


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files under src/stations =="
git ls-files src/stations | sort

echo
echo "== inspect relevant station files excluding data files =="
for f in $(git ls-files 'scripts/inspect-stations.ts' 'src/stations/**/*.ts' | grep -v '\.parquet$' | grep -v '\.json$'); do
  echo "--- $f ($(wc -l < "$f") lines) ---"
  ast-grep outline "$f" || true
done

echo
echo "== search for where/predicate/statistics implementation in src scripts =="
rg -n -C 6 "where|predicate|element.*op:|op:.*element|fragment.*statistic|statistics|min|max|bounds|selectFragments|planFragments|toQuery|nearest|rows|rowCount|min|max|min_value|max_value|byteLength" scripts src --glob '*.ts' --glob '!src/shapes/circle.ts' | sed -n '1,500p'

Repository: dClimate/dclimate-client-js

Length of output: 31577


Document where(...) as an external optimization, not a client-side guarantee.

The SDK exports client.stations.load(), but the documented .where(...).rows() API and the described fragment-stats pruning are not present in this code. Qualify the guarantee by ownership, or make the documented client paths implement and test that behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 110 - 118, Update the README section describing
where(...) and fragment-statistics pruning to clearly qualify it as an external
or backend-owned optimization rather than an SDK/client-side guarantee. Avoid
presenting .where(...).rows() and client.stations.load() behavior as implemented
unless those client paths actually provide it; limit the documentation to
behavior supported by the current code.

```

Reads go over the IPFS HTTP gateway, so no local daemon is required and the same
code runs in a browser. Resolution is by CID for now; STAC catalog support will
follow.

### Siren REST API usage

Use Siren methods by configuring `siren` in the client options.
Expand Down
58 changes: 54 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"test:ci": "vitest run",
"test:coverage": "vitest run --coverage",
"prepare": "npm run build",
"prepublishOnly": "npm run build"
"prepublishOnly": "npm run build",
"inspect:stations": "tsx scripts/inspect-stations.ts"
},
"keywords": [
"dclimate",
Expand All @@ -52,7 +53,9 @@
"homepage": "https://github.com/dClimate/dclimate-client-js#readme",
"dependencies": {
"@dclimate/jaxray": "^0.7.0",
"@opentelemetry/api": "^1.9.1"
"@opentelemetry/api": "^1.9.1",
"@dclimate/dparquet": "file:../dparquet",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH
@dclimate/dparquet points outside the repository, and that package in turn points to ../ipld-index. A normal CI checkout or npm consumer will not have either sibling directory, so npm install/npm ci and the published package fail to resolve dependencies. Use a published version or include these packages in an in-repository workspace.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH
file:../dparquet depends on a sibling directory outside this repository. Fresh clones/CI cannot install it, and a published package will retain an unusable local-path dependency for consumers. Publish @dclimate/dparquet and reference a registry version (or include it in a committed workspace).

"multiformats": "^14.0.4"
},
"devDependencies": {
"@eslint/js": "10.0.1",
Expand Down
218 changes: 218 additions & 0 deletions scripts/inspect-stations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* Load and inspect a station dataset through `client.stations`.
*
* The station counterpart to `inspect-dataset.ts`: that one walks a gridded
* Zarr dataset, this one walks point observations. Both go through the same
* client, which is the thing worth seeing -- station data is not a separate
* SDK, just a second namespace.
*
* Usage:
* npx tsx scripts/inspect-stations.ts <cid>
* npx tsx scripts/inspect-stations.ts <cid> --station USW00094728 --element TMAX
* npx tsx scripts/inspect-stations.ts <cid> --near 40.78,-73.97 --from 2025-07-01 --to 2025-07-08
*
* Options:
* --gateway <url> IPFS HTTP gateway (default http://127.0.0.1:8080)
* --station <id> Restrict to one station id (repeatable)
* --near <lat,lon> Use the single nearest station to a point instead
* --element <name> Restrict to one element, e.g. TMAX, TMIN, PRCP (repeatable)
* --from <date> ISO date, inclusive
* --to <date> ISO date, inclusive
* --limit <n> Rows to print (default 10; 0 prints all)
* --plan Show what would be fetched, then stop
*
* Requires an IPFS gateway that can serve the dataset's blocks. A local Kubo
* daemon (`ipfs daemon`) is the usual answer; any gateway works.
*/

import { DClimateClient } from "../src/index.js";
import type { StationDataset, StationInfo } from "@dclimate/dparquet/reader";

const DEFAULT_GATEWAY = "http://127.0.0.1:8080";

interface Args {
cid: string;
gateway: string;
stations: string[];
near: [number, number] | null;
elements: string[];
from: string | null;
to: string | null;
limit: number;
plan: boolean;
}

const USAGE = `Usage:
npx tsx scripts/inspect-stations.ts <cid> [options]

Options:
--gateway <url> IPFS HTTP gateway (default ${DEFAULT_GATEWAY})
--station <id> Restrict to one station id (repeatable)
--near <lat,lon> Use the single nearest station to a point
--element <name> Restrict to one element, e.g. TMAX (repeatable)
--from <date> ISO date, inclusive
--to <date> ISO date, inclusive
--limit <n> Rows to print (default 10; 0 prints all)
--plan Show what would be fetched, then stop`;

function parseArgs(argv: string[]): Args {
const args: Args = {
cid: "",
gateway: process.env.IPFS_GATEWAY_URL ?? DEFAULT_GATEWAY,
stations: [],
near: null,
elements: [],
from: null,
to: null,
limit: 10,
plan: false,
};

for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
// A flag whose value is missing is a typo, not a request for the default:
// silently falling back would produce a plausible-looking wrong answer.
const value = (): string => {
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
throw new Error(`${arg} needs a value`);
}
i += 1;
return next;
};

switch (arg) {
case "--gateway": args.gateway = value(); break;
case "--station": args.stations.push(value()); break;
case "--element": args.elements.push(value().toUpperCase()); break;
case "--from": args.from = value(); break;
case "--to": args.to = value(); break;
case "--limit": args.limit = Number(value()); break;
case "--plan": args.plan = true; break;
case "--near": {
const parts = value().split(",");
const lat = Number(parts[0]);
const lon = Number(parts[1]);
if (parts.length !== 2 || !Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new Error("--near expects <lat,lon>, e.g. --near 40.78,-73.97");
}
args.near = [lat, lon];
Comment on lines +90 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate numeric arguments against their documented domains.

Fractional --limit values pass validation but slice truncates them. Coordinates outside valid latitude and longitude bounds also pass validation. Reject non-integer limits and reject latitude values outside [-90, 90] or longitude values outside [-180, 180].

Proposed fix
-      case "--limit": args.limit = Number(value()); break;
+      case "--limit": {
+        const limit = Number(value());
+        if (!Number.isInteger(limit) || limit < 0) {
+          throw new Error("--limit must be a non-negative integer");
+        }
+        args.limit = limit;
+        break;
+      }
       case "--plan": args.plan = true; break;
       case "--near": {
         const parts = value().split(",");
         const lat = Number(parts[0]);
         const lon = Number(parts[1]);
-        if (parts.length !== 2 || !Number.isFinite(lat) || !Number.isFinite(lon)) {
+        if (
+          parts.length !== 2 ||
+          !Number.isFinite(lat) ||
+          !Number.isFinite(lon) ||
+          lat < -90 || lat > 90 ||
+          lon < -180 || lon > 180
+        ) {
           throw new Error("--near expects <lat,lon>, e.g. --near 40.78,-73.97");
         }

Also applies to: 109-112

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/inspect-stations.ts` around lines 90 - 99, Update argument validation
in the --limit and --near parsing cases: require limit to be a finite integer,
and require latitude and longitude to fall within [-90, 90] and [-180, 180]
respectively. Preserve the existing invalid-argument error behavior and
near-coordinate assignment for valid inputs.

break;
}
default:
if (arg.startsWith("--")) throw new Error(`Unknown option: ${arg}`);
if (args.cid) throw new Error(`Unexpected argument: ${arg}`);
args.cid = arg;
}
}

if (!args.cid) throw new Error("A root CID is required");
if (!Number.isFinite(args.limit) || args.limit < 0) {
throw new Error("--limit must be a non-negative number");
}
return args;
}

const day = (date: Date): string => date.toISOString().slice(0, 10);

function describeStation(station: StationInfo): string {
const where =
station.latitude === null || station.longitude === null
? "no position"
: `${station.latitude.toFixed(4)}, ${station.longitude.toFixed(4)}`;
return `${station.stationId} ${where} ${day(station.start)} .. ${day(station.end)}`;
}

function applySelection(dataset: StationDataset, args: Args): StationDataset {
let selected = dataset;

if (args.near) {
const [lat, lon] = args.near;
selected = selected.nearest(lat, lon);
console.log(`\nNearest station to ${lat}, ${lon}: ${selected.toQuery().stations?.[0]}`);
} else if (args.stations.length > 0) {
selected = selected.select(...args.stations);
}
Comment on lines +129 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject conflicting station selectors.

When callers pass both --near and --station, --near silently ignores every station ID. Reject this combination, or document and implement an explicit combined-selection behavior.

Proposed fix
 function applySelection(dataset: StationDataset, args: Args): StationDataset {
+  if (args.near && args.stations.length > 0) {
+    throw new Error("--near cannot be used with --station");
+  }
+
   let selected = dataset;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/inspect-stations.ts` around lines 129 - 135, Update the selector
handling around args.near and args.stations so passing both --near and --station
is rejected explicitly before either selection path runs. Preserve the existing
nearest behavior when only args.near is provided and station-ID selection when
only args.stations is provided, with a clear user-facing error for the
conflicting combination.


if (args.elements.length > 0) selected = selected.elements(...args.elements);

if (args.from || args.to) {
// Either bound alone is meaningful, so the missing side widens to the
// dataset's own extent rather than forcing the caller to pass both.
const covered = dataset.stations;
const earliest = Math.min(...covered.map((s) => s.start.getTime()));
const latest = Math.max(...covered.map((s) => s.end.getTime()));
selected = selected.timeRange({
start: args.from ?? new Date(earliest),
end: args.to ?? new Date(latest),
});
}

return selected;
}

async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
console.log(USAGE);
process.exit(argv.length === 0 ? 1 : 0);
}

const args = parseArgs(argv);
const client = new DClimateClient({ gatewayUrl: args.gateway, stacServerUrl: null });

console.log(`Loading station dataset ${args.cid}`);
console.log(`Gateway: ${args.gateway}\n`);

const dataset = await client.stations.load({ cid: args.cid });

console.log(`Stations (${dataset.stations.length}):`);
for (const station of dataset.stations) console.log(` ${describeStation(station)}`);

const selected = applySelection(dataset, args);
console.log(`\nQuery (wire units): ${JSON.stringify(selected.toQuery())}`);

const plan = await selected.plan();
const bytes = plan.fragments.reduce((sum, f) => sum + f.byteLength, 0);
const rows = plan.fragments.reduce((sum, f) => sum + f.rowCount, 0);
console.log(
`Plan: ${plan.fragments.length} fragment(s), ` +
`${plan.stations.length} station(s), ` +
`${rows} row(s), ${(bytes / 1024).toFixed(1)} KiB`
);
// The number that shows predicate pushdown doing something: fragments ruled
// out by column statistics are never fetched at all.
console.log(` ${plan.stats.fragmentsPruned} fragment(s) pruned by statistics`);

if (args.plan) return;

const started = Date.now();
const element = args.elements.length === 1 ? args.elements[0] : undefined;
const records = await selected.toRecords(element);
const elapsed = Date.now() - started;

console.log(`\n${records.length} record(s) in ${elapsed} ms`);

const shown = args.limit === 0 ? records : records.slice(0, args.limit);
for (const record of shown) {
const value =
element === undefined
? JSON.stringify(record.values)
: String(record.value ?? "—");
console.log(` ${day(record.time as Date)} ${record.stationId} ${value}`);
}
if (shown.length < records.length) {
console.log(` ... ${records.length - shown.length} more (--limit 0 for all)`);
}

// Stored in NOAA's own scaling rather than converted, so the archive's exact
// integers survive. Saying so beats letting a reader assume whole °C.
if (element) {
console.log(`\nValues are integers in tenths (TMAX 317 = 31.7 °C).`);
}
}

main().catch((error: unknown) => {
console.error(`\nError: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
Loading
Loading