Skip to content

Export plugins and named mappings

Named exports render a computed aggregate using a mapping in instance configuration. They work with synchronous POST /result and batch jobs. Rendering does not contact DHIS2, resolve a credential, fetch organisation units, or run another aggregation.

Render one series for DHIS2

Add an export to the file referenced by CLIMATE_SERVICE_CONFIG:

exports:
  - id: rainfall-monthly
    plugin: dhis2
    period_type: monthly
    org_unit_field: feature_id
    period_field: t
    series:
      - select: {}
        data_element: BXgDHhPdFVU

Replace the data-element UID with the destination defined in your DHIS2 instance. org_unit_field: feature_id, the default, reads each value's organisation unit from the feature ids a spatial aggregation keeps beside its geometries. CHAP CSV's location_field defaults to feature_id too. Geometries are never used as ids: a result that has shapes but no feature_id is refused, so give each GeoJSON Feature an id. Pass the prepared aggregate to save_result:

{
  "process_id": "save_result",
  "arguments": {
    "data": {"from_node": "aggregate"},
    "format": "DHIS2JSON",
    "options": {"export": "rainfall-monthly"}
  },
  "result": true
}

This is a graph node; aggregate must be a preceding node in the full graph. The built-in aggregate_to_dhis2_json workflow wraps this aggregation and save_result call, taking the export ID as its export parameter. See Importing data to DHIS2. Named exports accept only the export option. Change the configured mapping to change its destination or fields; per-request overrides are rejected. Hand-written DHIS2JSON graphs using data_element_id, org_unit_field, and period_type still render an ad-hoc payload without a named export, but that payload cannot be delivered by the server.

Each series mapping selects one value series. select: {} requires an unambiguous value column. To select a variable from a result with several variables, use select: {variable: precip}. Additional dimensions such as quantile can be selected with select: {variable: precip, quantile: 0.1}; other dimensions, such as ensemble member, must be reduced upstream. Several datasets can be published through separate single-series exports; no merged cube is required.

The series may also specify category_option_combo and attribute_option_combo. When several series target the same data element, specify each combo consistently on every series or omit it on every series. Mixing implicit and explicit defaults is rejected because the renderer cannot resolve target metadata without a network request. Explicit duplicate destination keys are always rejected. All destination and organisation-unit IDs must have DHIS2 UID syntax. Positional labels such as 0, missing IDs, duplicate organisation-unit/period keys, and invalid calendar periods are rejected. Zero values are preserved; missing observations are omitted and counted. Non-finite and non-numeric values are rejected.

Supported output periods are daily, weekly (ISO), monthly, quarterly, and yearly. period_type formats already prepared observations; it does not aggregate them. Multiple dekads in the same month cannot be exported simply by labelling them monthly, since they would produce duplicate destination keys. Explicit incompatible period_type attributes are also rejected. Without execution provenance, the renderer cannot prove that an arbitrary input value was aggregated correctly.

The optional dataset, org_units, and connection references, and the optional aggregation declaration (mean, sum, min, max, or median), describe intended input/delivery configuration. They do not trigger any work. Synchronous and batch renders check them against execution provenance where it contains an observation, and batch exports bind them to a manifest. The dataset must match an observed source. The aggregation is checked when exactly one spatial aggregation process ran — either the built-in aggregate_spatial with a reduce_by_method reducer, or the custom aggregate_spatial_weighted with a parsed reducer string, as the built-in workflow does. With no spatial aggregation, several, or another reducer, the aggregation cannot be attributed to the result; it remains an unverified declaration and the manifest lists spatial_aggregation_method as missing. A connection is not required to render or download a payload, but a bound connection is required for later server-side delivery. Use named connections for that binding.

The export's period must be reachable from the dataset's cadence

An export exports what exists. period_type labels; it never aggregates, and neither does anything else on the export path. So the export has to say how its period relates to the dataset it declares, and OCS checks it twice: at startup, from the dataset template's period_type, and at render time, from the spacing of the data actually being exported.

Dataset cadence Export period_type Outcome
the same the same Pass-through. No temporal_aggregation; declaring one is refused.
finer, and tiling the period coarser Exportable only after an explicit aggregation. temporal_aggregation: sum \| mean \| min \| max declares which, and the export verifies that it ran.
anything else Refused, with the reason.

Producing the coarser data is openEO's job, not the export's, and there are two ways to do it. Publish a derived dataset (load_collection, aggregate_temporal_period, save_result as Zarr with a dataset_id) and point the export at that dataset, which then needs no declaration at all. Or put aggregate_temporal_period in the graph that feeds the export and declare its reducer as temporal_aggregation. The built-in org-unit workflows do neither: given a dataset finer than the export's period, they are refused.

A derived dataset is published by an openEO job, and openEO jobs record no dataset.updated event, so a trigger cannot yet listen for it the way it listens for a sync. Until a derivation step emits that event, an export from a derived dataset is run by hand or by a workflow that derives and exports in one graph.

The pairs currently supported by aggregate_temporal_period: hourly into daily, weekly, monthly and yearly; daily into weekly, monthly and yearly; dekadal into monthly and yearly; monthly into yearly; and quarterly into yearly. Weekly data tiles nothing, because ISO weeks straddle months, quarters and years. Calendar-quarter destinations are refused for now: they tile arithmetically, but the standard process has no calendar-quarter period with which to produce and record them. Nothing can be made finer than it is stored.

aggregate_dekads(period="week") is a separate, day-overlap-weighted transformation rather than a tiling aggregation. Its weekly result can be exported ad hoc, or published as a derived weekly dataset and then used by a named export. A named weekly export declared directly against the original dekadal dataset is still refused at startup.

exports:
  - id: rainfall-monthly
    plugin: dhis2
    dataset: chirps3_precipitation_daily
    period_type: monthly
    temporal_aggregation: sum       # required: the dataset is daily
    incomplete_periods: reject      # the default; or drop
    series:
      - select: {}
        data_element: BXgDHhPdFVU

When the graph aggregates, a destination period counts only when every source period inside it is present. A sync that ends on the 14th does not produce that month: the job fails naming the period, unless the export says incomplete_periods: drop, in which case the period is left out and the rest is delivered. Execution provenance records each temporal aggregation (its period, reducer and incomplete periods), so a declared temporal_aggregation is verified against what ran, the way the spatial aggregation is. A derived dataset carries no such record; its completeness is the derivation's concern.

A hand-written graph gets the same guard at save_result: a period_type coarser than the result's spacing is refused with the aggregate_temporal_period step to add, instead of collapsing several values onto one DHIS2 key.

Write a render-only plugin

Export plugins implement the public BaseExportPlugin contract and expose an instance named plugin in each discoverable module:

import json

from open_climate_service.exports import BaseExportPlugin, RenderedExport


class SummaryExport(BaseExportPlugin):
    id = "summary"
    format = "SUMMARYJSON"
    extension = ".json"
    media_type = "application/json"

    def validate_mapping(self, mapping):
        if mapping:
            raise ValueError("Summary export does not accept mapping fields")
        return {}

    def render(self, data, mapping):
        payload = {"variables": list(data.data_vars)}
        return RenderedExport(json.dumps(payload).encode(), record_count=len(data.data_vars))


plugin = SummaryExport()

Declare exports: [{id: summary, plugin: summary}] and use save_result(format="SUMMARYJSON", options={"export": "summary"}). The format is advertised in GET /file_formats with its export parameter.

render receives the computed result and the validated mapping. It returns bytes and non-negative record_count / skipped_count values. The framework owns file paths, persistence, and HTTP response metadata. Both methods must be pure: no network calls, credential resolution, file writes, or delivery. Keep invocation state local because plugin instances may be shared by concurrent jobs.

For an installed package, put the module in <package>/exports/ and include __init__.py. Use the same open_climate_service.plugins entry point as other installable plugins. No separate export entry point is needed. For an instance, put it in plugins_dir/exports/; relative helper imports are supported. Prefix helper filenames with _ so discovery skips them.

Precedence is built-in, then installed packages, then local instance plugins, matched by plugin ID. Installed packages and module filenames are sorted for deterministic loading. Broken modules fail explicitly rather than falling back to a different renderer. Python modules are cached by Python's import machinery; restart the service after changing plugin code.

Format identifiers use uppercase letters, digits, and underscores. File extensions are a dot followed by lowercase letters or digits; Zarr directories are excluded. Media types use type/subtype without parameters. Plugin code is operator-installed Python code and must be trusted by the deployment.

Saved results and delivery eligibility

Each batch render writes a fresh payload generation and a versioned JSON manifest, then atomically replaces .export.json as the pointer to that generation. The manifest records payload and mapping digests, renderer identity and version, record counts and periods, the source job, public configuration references, a credential-free target fingerprint, and execution provenance available from OCS processes. The payload and manifest are both exposed as job result assets. Synchronous rendering still returns the payload directly and remains available in read-only mode.

Execution provenance records observed managed artifacts, Icechunk snapshot IDs, hashes of inline spatial features, and the method of each spatial aggregation call — both built-in aggregate_spatial and custom aggregate_spatial_weighted — where those inputs pass through native OCS processes. The manifest explicitly lists evidence that is unavailable; declarations alone do not prove aggregation semantics or per-output lineage.

The delivery-input validator accepts only completed jobs with intact payloads and manifests whose mapping, plugin version, target, references, and process graph still match. It holds a cross-process lease that prevents job update, rerun, or deletion while a delivery worker consumes the bytes. Older named-export assets remain downloadable but are not eligible for automatic delivery.

Deliver a saved export

Configure connection on the export, then submit a completed source job:

POST /exports/rainfall-monthly
Idempotency-Key: rainfall-job-123-validation
Content-Type: application/json

{"job_id": "job-123", "dry_run": true}

The response is 202 Accepted with a delivery job ID and status/report URL. The source job also links to that delivery. Inspect report.outcome, which distinguishes success, dry-run validation, partial imports, rejection, cancellation and unknown remote outcomes. A dry run can be rejected; its counts describe validation rather than saved writes. Use a new idempotency key to request the actual import with dry_run: false. Reports are scoped to /exports/{export_id}/jobs/{delivery_job_id}.

Reservations are persisted before jobs are enqueued. Repeating a key returns the original job; changing its source, manifest or mode is a conflict. After a crash between reservation and job creation, repeat the request to create the reserved job. The worker checks the exact manifest captured at submission, so rerendering the source while delivery is queued requires a new submission. Old queued delivery jobs without that binding fail before sending and must be submitted again.

DHIS2 payloads are split into deterministic chunks of at most 1,000 values. Chunk intent is saved before POST; completed chunks are reused on recovery. An uncertain POST without a task ID is reported as unknown and is never automatically resent. Known async tasks are polled through the DHIS2 DATAVALUE_IMPORT task summaries endpoint. Corrupt or incompatible checkpoints stop recovery. Imports for the same export are serialized; different exports may still overlap in their destination keys, so operators must coordinate those mappings. submitted counts attempted values, including uncertain writes, rather than the full planned payload.

Delivery and reports require a writable instance and are closed in read-only mode. Writable deployments must put these endpoints behind an operator access boundary; OCS does not yet provide per-user authorization. Downloaded JSON remains usable through the existing client workflow.

Map multiple series

Use one entry per output series; a merged raster cube is not required:

series:
  - select: {variable: temperature}
    data_element: TEMP0000001
  - select: {variable: precipitation}
    data_element: PREC0000001

Replace these example UIDs with target metadata. Wide DataFrames, multi-variable xarray aggregates and merged aggregate cubes are supported. Zero is retained and missing values are counted separately per series. Named DHIS2 graphs, including workflow calls, check that original GeoJSON feature IDs are unique DHIS2 UIDs before spatial aggregation. The renderer also rejects invalid organisation-unit UIDs and duplicate destination keys.