Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
import { z } from "zod";

export const climateOpenEoConfigSchema = z.object({
datasetId: z.string().min(1),
variable: z.object({
dataElement: z.string().regex(/^[A-Za-z0-9]{11}$/),
}),
aggregation: z.object({
method: z.enum(["mean", "min", "max", "sum"]),
}),
period: z.object({
const periodSchema = z
.object({
periodType: z.enum(["daily", "weekly", "monthly"]),
/** First period covered by the run. */
id: z.string().min(1),
/** First period covered by the run. Takes precedence over `lastDays`. */
id: z.string().min(1).optional(),
/**
* Last period covered by the run. Omitted for a single-period run
*/
endId: z.string().min(1).optional(),
/**
* Without `id`: cover the `lastDays` days up to and including yesterday (UTC), worked
* out when the step runs, so a scheduled pipeline picks up recent and revised data.
*/
lastDays: z.number().int().positive().optional(),
})
.refine((p) => p.id !== undefined || p.lastDays !== undefined, {
message: "Either id or lastDays must be set",
});

export const climateOpenEoConfigSchema = z.object({
datasetId: z.string().min(1),
/**
* Named DHIS2 export declared under `exports` in the Open Climate Service instance
* config. It owns the data element mapping and the period type of the dataValueSet.
*/
exportId: z.string().regex(/^[A-Za-z0-9_-]+$/),
aggregation: z.object({
method: z.enum(["mean", "min", "max", "sum"]),
}),
period: periodSchema,
orgUnit: z
.object({
levels: z.array(z.string()).optional(),
Expand All @@ -30,11 +43,7 @@ export const climateOpenEoConfigSchema = z.object({
export type ClimateOpenEoConfig = z.infer<typeof climateOpenEoConfigSchema>;

export const climateOpenEoContextSchema = z.object({
period: z.object({
periodType: z.enum(["daily", "weekly", "monthly"]),
id: z.string(),
endId: z.string().min(1).optional(),
}),
period: periodSchema,
orgUnit: z
.object({
levels: z.array(z.string()).optional(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import type { OpenEoJobCreateBody } from "@/shared/clients/openeo.ts";
import type { OrgUnitFeatureCollection } from "@/services/worker/utils/orgUnitsGeoJSON.ts";
import {
openEoPeriodTypeFromConfig,
temporalExtentFromPeriod,
} from "@/services/worker/utils/period.ts";
import { temporalExtentFromPeriod } from "@/services/worker/utils/period.ts";
import type { ClimateOpenEoConfig } from "../../climateOpenEo/schemas/config.ts";

const AGGREGATE_TO_DHIS2_ORG_UNITS = "aggregate_to_dhis2_json";
Expand All @@ -18,7 +15,6 @@ export function buildOpenEoJobBody({
title?: string;
}): OpenEoJobCreateBody {
const temporal_extent = temporalExtentFromPeriod(config.period);
const period_type = openEoPeriodTypeFromConfig(config.period.periodType);

return {
process: {
Expand All @@ -29,9 +25,8 @@ export function buildOpenEoJobBody({
dataset_id: config.datasetId,
temporal_extent,
geometries,
data_element_id: config.variable.dataElement,
export: config.exportId,
method: config.aggregation.method,
period_type,
},
result: true,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ export const climateOpenEoDownload: StepHandler = {
const downloadTask = await ctx.tasks.startTask("download-result", { jobId });
let dataValueSet;
try {
await getOpenEoJobResults(jobId);
const resultFilename = resolveResultFilename();
const results = await getOpenEoJobResults(jobId);
const resultFilename = resolveResultFilename(results);
const raw = await downloadOpenEoJobResult(jobId, resultFilename);
const parsed = dataValueSetSchema.safeParse(raw);
if (!parsed.success) {
Expand Down
32 changes: 17 additions & 15 deletions src/services/worker/utils/period.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,30 @@ import { createFixedPeriodFromPeriodId } from "@dhis2/multi-calendar-dates";

export type ClimatePeriodConfig = {
periodType: "daily" | "weekly" | "monthly";
id: string;
id?: string;
endId?: string;
lastDays?: number;
};

const DAY_MS = 24 * 60 * 60 * 1000;

function formatIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}

export function temporalExtentFromPeriod(period: ClimatePeriodConfig): [string, string] {
export function temporalExtentFromPeriod(
period: ClimatePeriodConfig,
now: Date = new Date()
): [string, string] {
if (period.id === undefined) {
if (period.lastDays === undefined) {
throw new Error("A climate period needs either id or lastDays");
}
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const end = today - DAY_MS;
const start = end - (period.lastDays - 1) * DAY_MS;
return [formatIsoDate(new Date(start)), formatIsoDate(new Date(end))];
}
const startPeriod = createFixedPeriodFromPeriodId({
periodId: period.id,
calendar: "iso8601",
Expand All @@ -27,16 +42,3 @@ export function temporalExtentFromPeriod(period: ClimatePeriodConfig): [string,
formatIsoDate(new Date(endPeriod.endDate)),
];
}

export function openEoPeriodTypeFromConfig(
periodType: ClimatePeriodConfig["periodType"]
): "day" | "week" | "month" {
switch (periodType) {
case "daily":
return "day";
case "weekly":
return "week";
case "monthly":
return "month";
}
}
17 changes: 15 additions & 2 deletions src/shared/clients/openeo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,21 @@ export async function getOpenEoJobResults(jobId: string): Promise<OpenEoJobResul
return parsed.data;
}

export function resolveResultFilename(): string {
return "result.json";
/**
* The file name of a finished job's data asset. Open Climate Service lists it under the
* `result` asset (a named DHIS2 export is saved as `export-<id>.json`, beside a `manifest`
* asset); otherwise the first asset with the `data` role.
*/
export function resolveResultFilename(results: OpenEoJobResults): string {
const assets = Object.entries(results.assets ?? {});
const [key, asset] =
assets.find(([name]) => name === "result") ??
assets.find(([, a]) => Array.isArray(a.roles) && a.roles.includes("data")) ??
[];
if (!key || !asset) {
throw new Error("Open Climate Service job results have no data asset");
}
return asset.href ? asset.href.split("/").pop()! : key;
}

export async function downloadOpenEoJobResult(jobId: string, filename: string): Promise<unknown> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const mockStartJob = vi.mocked(startOpenEoJob);
function baseHandlerConfig(): Record<string, unknown> {
return {
datasetId: "era5land_precipitation_monthly",
variable: { dataElement: "AbCdEfGhIjK" },
exportId: "rainfall-monthly",
aggregation: { method: "mean" },
period: { periodType: "monthly", id: "202601" },
orgUnit: { ids: ["OU_ALPHA"] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ describe("climateOpenEoDownload", () => {
expect(result.jobId).toBe("job-123");
expect(result.count).toBe(1);
expect(result.filename).toMatch(/^climate-openeo-/);
expect(mockResolveFilename).toHaveBeenCalledWith({
assets: { "result.dhis2.json": { href: "/jobs/job-123/results/result.dhis2.json" } },
});
expect(mockDownload).toHaveBeenCalledWith("job-123", "result.dhis2.json");
expect(mockBunWrite).toHaveBeenCalledOnce();
});
Expand Down
2 changes: 1 addition & 1 deletion src/tests/unit/handlers-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ describe("handler config JSON Schema round-trip", () => {
it("climate-openeo-create round-trips valid structural config", () => {
const valid = {
datasetId: "x",
variable: { dataElement: "abcdefghijk" },
exportId: "x-daily",
aggregation: { method: "mean" as const },
period: { periodType: "daily" as const, id: "20240101" },
orgUnit: { ids: ["ou1"] },
Expand Down
6 changes: 2 additions & 4 deletions src/tests/unit/handlers/buildOpenEoJob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ClimateOpenEoConfig } from "@/services/worker/services/handlers/cl

const config: ClimateOpenEoConfig = {
datasetId: "era5land_precipitation_monthly",
variable: { dataElement: "AbCdEfGhIjK" },
exportId: "rainfall-monthly",
aggregation: { method: "sum" },
period: { periodType: "monthly", id: "202601" },
orgUnit: { ids: ["OU_ALPHA"] },
Expand Down Expand Up @@ -43,7 +43,6 @@ describe("buildOpenEoJobBody", () => {
};
expect(graph.arguments).toMatchObject({
temporal_extent: ["2024-05-01", "2026-07-31"],
period_type: "month",
});
});

Expand All @@ -61,9 +60,8 @@ describe("buildOpenEoJobBody", () => {
expect(graph.arguments).toMatchObject({
dataset_id: "era5land_precipitation_monthly",
temporal_extent: ["2026-01-01", "2026-01-31"],
data_element_id: "AbCdEfGhIjK",
export: "rainfall-monthly",
method: "sum",
period_type: "month",
geometries,
});
});
Expand Down
45 changes: 30 additions & 15 deletions src/tests/unit/handlers/climateOpenEoConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { describe, it, expect } from "vitest";
import { climateOpenEoConfigSchema } from "@/services/worker/services/handlers/climateOpenEo/schemas/config.ts";
import {
openEoPeriodTypeFromConfig,
temporalExtentFromPeriod,
} from "@/services/worker/utils/period.ts";
import { temporalExtentFromPeriod } from "@/services/worker/utils/period.ts";

function baseConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
datasetId: "era5land_precipitation_monthly",
variable: {
dataElement: "AbCdEfGhIjK",
},
exportId: "rainfall-monthly",
aggregation: {
method: "mean",
},
Expand Down Expand Up @@ -39,8 +34,18 @@ describe("climateOpenEoConfigSchema", () => {
expect(climateOpenEoConfigSchema.safeParse(baseConfig({ orgUnit: {} })).success).toBe(false);
});

it("fails for invalid data element id", () => {
const cfg = baseConfig({ variable: { dataElement: "short" } });
it("passes with lastDays instead of a period id", () => {
const cfg = baseConfig({ period: { periodType: "daily", lastDays: 35 } });
expect(climateOpenEoConfigSchema.safeParse(cfg).success).toBe(true);
});

it("fails when the period has neither id nor lastDays", () => {
const cfg = baseConfig({ period: { periodType: "daily" } });
expect(climateOpenEoConfigSchema.safeParse(cfg).success).toBe(false);
});

it("fails for an export id OCS would not accept", () => {
const cfg = baseConfig({ exportId: "rainfall monthly" });
expect(climateOpenEoConfigSchema.safeParse(cfg).success).toBe(false);
});
});
Expand All @@ -51,12 +56,22 @@ describe("temporalExtentFromPeriod", () => {
expect(start).toBe("2026-01-01");
expect(end).toBe("2026-01-31");
});
});

describe("openEoPeriodTypeFromConfig", () => {
it("maps period types to openEO period_type values", () => {
expect(openEoPeriodTypeFromConfig("daily")).toBe("day");
expect(openEoPeriodTypeFromConfig("weekly")).toBe("week");
expect(openEoPeriodTypeFromConfig("monthly")).toBe("month");
it("covers the lastDays days up to yesterday when there is no id", () => {
const now = new Date("2026-10-09T13:00:00Z");
expect(temporalExtentFromPeriod({ periodType: "daily", lastDays: 35 }, now)).toEqual([
"2026-09-04",
"2026-10-08",
]);
});

it("uses the period id over lastDays, so a run can override a rolling config", () => {
const now = new Date("2026-10-09T13:00:00Z");
expect(
temporalExtentFromPeriod(
{ periodType: "daily", id: "20180101", endId: "20181231", lastDays: 35 },
now
)
).toEqual(["2018-01-01", "2018-12-31"]);
});
});
26 changes: 26 additions & 0 deletions src/tests/unit/openEoResultFilename.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { resolveResultFilename } from "@/shared/clients/openeo.ts";

describe("resolveResultFilename", () => {
it("takes the result asset of a named export, not its manifest", () => {
const results = {
assets: {
result: { href: "/jobs/job-1/results/export-2300ff9a.json", roles: ["data"] },
manifest: {
href: "/jobs/job-1/results/export-2300ff9a.manifest.json",
roles: ["metadata"],
},
},
};
expect(resolveResultFilename(results)).toBe("export-2300ff9a.json");
});

it("falls back to the first data asset", () => {
const results = { assets: { "result.dhis2.json": { roles: ["data"] } } };
expect(resolveResultFilename(results)).toBe("result.dhis2.json");
});

it("fails when the job has no data asset", () => {
expect(() => resolveResultFilename({ assets: {} })).toThrow("no data asset");
});
});