diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 7bee31df84..dc22d00cb3 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -5,6 +5,13 @@
+
@@ -49,7 +56,6 @@
-
\ No newline at end of file
diff --git a/alchemist-full/build.gradle.kts b/alchemist-full/build.gradle.kts
index 7d41d66aee..c84d65f2ce 100644
--- a/alchemist-full/build.gradle.kts
+++ b/alchemist-full/build.gradle.kts
@@ -8,6 +8,7 @@
*/
import Libs.alchemist
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
+import com.github.jengelman.gradle.plugins.shadow.transformers.AppendingTransformer
import com.google.common.hash.Hashing
import it.unibo.alchemist.build.commandExists
import it.unibo.alchemist.build.isMac
@@ -81,6 +82,8 @@ tasks.withType().configureEach {
isZip64 = true
mergeServiceFiles()
duplicatesStrategy = DuplicatesStrategy.INCLUDE
+ // EMF's plugin.properties must be merged if there are multiple entries
+ transform { resource = "plugin.properties" }
destinationDirectory.set(rootProject.layout.buildDirectory.map { it.dir("shadow") })
}
diff --git a/alchemist-geospatial/build.gradle.kts b/alchemist-geospatial/build.gradle.kts
new file mode 100644
index 0000000000..30fb0350fa
--- /dev/null
+++ b/alchemist-geospatial/build.gradle.kts
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+import Libs.alchemist
+
+plugins {
+ id("kotlin-jvm-convention")
+}
+
+dependencies {
+ api(alchemist("api"))
+
+ implementation(libs.cdm.core)
+ implementation(libs.cdm.grib)
+ implementation(libs.gson)
+ implementation(libs.guava)
+ implementation(libs.slf4j)
+
+ testImplementation(alchemist("maps"))
+ testImplementation(alchemist("test"))
+}
+
+publishing.publications {
+ withType {
+ pom {
+ contributors {
+ contributor {
+ name.set("Emir Wanes Aouioua")
+ email.set("emirwanes.aouioua@studio.unibo.it")
+ }
+ }
+ }
+ }
+}
diff --git a/alchemist-geospatial/gradle.properties b/alchemist-geospatial/gradle.properties
new file mode 100644
index 0000000000..b106d3f66d
--- /dev/null
+++ b/alchemist-geospatial/gradle.properties
@@ -0,0 +1,10 @@
+#
+# Copyright (C) 2010-2026, Danilo Pianini and contributors listed in the main project's alchemist/build.gradle file.
+#
+# This file is part of Alchemist, and is distributed under the terms of the
+# GNU General Public License, with a linking exception,
+# as described in the file LICENSE in the Alchemist distribution's top directory.
+#
+artifactId = alchemist-geospatial
+projectLongName = Alchemist Geospatial
+projectDescription = Integration of Copernicus/ECMWF Temporal Raster Data into the Alchemist Simulator
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CacheKey.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CacheKey.kt
new file mode 100644
index 0000000000..55bc2704d7
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CacheKey.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.acquisition
+
+/**
+ * Deterministic identity of a request.
+ */
+fun interface CacheKey {
+
+ /**
+ * @return a **deterministic, file-system-safe** directory name derived *only* from what
+ * determines the downloaded bytes, so that equal content always maps to the same name. The
+ * returned name must be a single path segment: no separators (`/`, `\`), and safe on different OS.
+ */
+ fun toFileName(): String
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CacheManager.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CacheManager.kt
new file mode 100644
index 0000000000..bc0e76a9d0
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CacheManager.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.acquisition
+
+import java.nio.file.Path
+
+/**
+ * Manages disk-based cache directories identified by deterministic [CacheKey] requests.
+ *
+ * When a directory is requested via [getOrProduce], the manager resolves its path.
+ * If the directory is missing or empty, it automatically fetches and populates the
+ * data using the configured [provider].
+ *
+ * @param R the type of [CacheKey] accepted by this manager.
+ *
+ * @see ExternalDataProvider
+ * @see CacheKey
+ */
+interface CacheManager {
+
+ /**
+ * The provider responsible for populating the directory associated with the cache entry in
+ * [CacheManager.getOrProduce], in the event that it does not exist or is empty.
+ */
+ val provider: ExternalDataProvider
+
+ /**
+ * Calculates and returns the directory where the data was cached via the [request].
+ *
+ * @param request the request used to calculate the deterministic directory path.
+ * @return the path to the directory containing the data.
+ */
+ fun getOrProduce(request: R): Path
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CopernicusCacheManager.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CopernicusCacheManager.kt
new file mode 100644
index 0000000000..a9d2a33672
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CopernicusCacheManager.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.acquisition
+
+import java.nio.file.FileSystemException
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardCopyOption
+import org.slf4j.LoggerFactory
+
+/**
+ * Filesystem cache of the directories produced by data providers. This is the **single** place
+ * where cache atomicity lives, once for every provider. It knows nothing about HTTP or the
+ * individual APIs, depending only on [CacheKey].
+ *
+ * This class considers **atomicity but non locking**: if two simultaneous cache-miss runs
+ * for the production of data, one wins the atomic rename, the other discards its own work.
+ * Wasteful in that rare case, but it never corrupts the cache.
+ *
+ * **Note!** The entries are **trust-based**: a present directory is assumed complete and valid: the content
+ * of a cache-hit is not re-verified. Manual alteration of a cache entry is out of contract.
+ *
+ * @param root cache root directory (e.g. `~/.alchemist/cache/geospatial`). Created on demand.
+ */
+class CopernicusCacheManager(override val provider: ExternalDataProvider, private val root: Path) :
+ CacheManager {
+
+ /**
+ * Temporary subdirectory, on the same filesystem as [root].
+ * (required for `ATOMIC_MOVE`).
+ */
+ private val tmpRoot: Path = root.resolve(TEMP_SUBDIR)
+
+ /**
+ * Returns the directory associated with [request], producing it if absent.
+ *
+ * On **cache hit** the existing directory is returned immediately.
+ * On **cache miss**, the [provider] runs into a temporary directory, its non-emptiness is
+ * validated, and it is then promoted to the final location with an atomic rename.
+ * If a concurrent process produced the same entry in the meantime, that one is used
+ * and the local work is discarded. If, for any reason, [provider] fails, the temporary
+ * directory is removed (no "poisoned" entry is left in cache).
+ *
+ * @param request request identity (to determine the directory name).
+ * @return the [Path] of the final cache directory, filled with data.
+ * @throws IllegalStateException if [provider] writes no file in the temporary directory.
+ */
+ override fun getOrProduce(request: CopernicusRequest): Path {
+ val finalDir = root.resolve(request.toFileName())
+ // cache hit: the directory already exists
+ if (Files.isDirectory(finalDir)) {
+ logger.info("Cache hit for '${request.toFileName()}': using $finalDir")
+ return finalDir
+ }
+ logger.info("Cache miss for '${request.toFileName()}': fetching data")
+ // cache miss (also creates root if it does not exist)
+ Files.createDirectories(tmpRoot)
+ val temp = Files.createTempDirectory(tmpRoot, request.toFileName())
+ var moved = false // becomes true if the directory gets promoted
+ try {
+ // tries to fill the directory with data
+ provider.fetch(request, temp)
+ check(hasData(temp)) { "Provider produced no files for '${request.toFileName()}'" }
+ moved = promote(temp, finalDir)
+ logger.info("Asset(s) cached in $finalDir")
+ return finalDir
+ } finally {
+ // deletes the temp directory if any accident occurs
+ if (!moved) temp.toFile().deleteRecursively()
+ }
+ }
+
+ /**
+ * Atomically promotes [temp] to [finalDir].
+ *
+ * @param temp the path of the temporary directory.
+ * @param finalDir the path of the final directory after [temp] gets promoted.
+ * @return true if this call performed the move, false if a concurrent peer had already
+ * produced [finalDir].
+ */
+ private fun promote(temp: Path, finalDir: Path): Boolean = try {
+ Files.move(temp, finalDir, StandardCopyOption.ATOMIC_MOVE)
+ true
+ } catch (raceLost: FileSystemException) {
+ if (!Files.isDirectory(finalDir)) throw raceLost
+ // peer won but temp dir still exists
+ false
+ }
+
+ /**
+ * Checks if [dir] directory holds at least one regular file.
+ *
+ * @return true if [dir] holds any regular file, false otherwise.
+ */
+ private fun hasData(dir: Path): Boolean =
+ Files.list(dir).use { entries -> entries.anyMatch { Files.isRegularFile(it) } }
+
+ private companion object {
+ private val logger = LoggerFactory.getLogger(CopernicusCacheManager::class.java)
+ private const val TEMP_SUBDIR = ".tmp"
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CopernicusDataStoreProvider.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CopernicusDataStoreProvider.kt
new file mode 100644
index 0000000000..564afac31d
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CopernicusDataStoreProvider.kt
@@ -0,0 +1,363 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.acquisition
+
+import it.unibo.alchemist.boundary.utils.CanonicalJson
+import it.unibo.alchemist.boundary.utils.RemoteAsset
+import it.unibo.alchemist.boundary.utils.flattenArchives
+import it.unibo.alchemist.boundary.utils.parseAsset
+import it.unibo.alchemist.boundary.utils.parseFailureMessage
+import it.unibo.alchemist.boundary.utils.parseMonitorUrl
+import it.unibo.alchemist.boundary.utils.parseProblemDetail
+import it.unibo.alchemist.boundary.utils.parseResultsUrl
+import it.unibo.alchemist.boundary.utils.parseStatus
+import it.unibo.alchemist.boundary.utils.verify
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.nio.file.Path
+import java.time.Duration
+import java.time.temporal.ChronoUnit
+import kotlin.io.path.isDirectory
+import org.slf4j.LoggerFactory
+
+/**
+ * The **only point of the module that speaks the ECMWF data stores' REST API**.
+ *
+ * Implements the OGC API - Processes flow (submit -> poll -> results -> download) and confines it
+ * entirely here.
+ *
+ * Serves multiple data stores (CDS, ADS, EWDS) indistinctly: the same ECMWF software
+ * sits underneath, with identical sub-paths; only the host differs, supplied via [endpoint].
+ * The sole auth asymmetry is the final download GET, which carries **no** token
+ * (the asset lives on a public object store on a different host): see [download].
+ *
+ * @param endpoint base URL of the data store (e.g. `https://ewds.climate.copernicus.eu/api`); a
+ * trailing slash, if present, is trimmed.
+ * @param checkMd5 whether to check the MD5 digest of the downloaded file. Sometimes Copernicus stores
+ * return the correct requested assets but report an incorrect MD5, so it may be useful to
+ * disable this check.
+ * @param tokenSupplier supplies the ECMWF token sent as the `PRIVATE-TOKEN` header on the OGC GET/POST calls.
+ * @param http HTTP client to use; defaults to the JDK [HttpClient].
+ * @param pollInterval base interval between two status polls; grows with backoff up to [maxPollInterval].
+ * @param maxPollInterval cap on the polling interval. Defaults to 120 seconds, matching the official
+ * ECMWF client's duration.
+ * @param timeout overall guillotine on the wait for job completion.
+ * @throws IllegalArgumentException if the [endpoint] does not represent a valid URL.
+ */
+class CopernicusDataStoreProvider(
+ private val endpoint: String,
+ private val checkMd5: Boolean = true,
+ private val http: HttpClient = HttpClient.newHttpClient(),
+ private val pollInterval: Duration = Duration.ofSeconds(DEFAULT_POLL_INTERVAL_SEC),
+ private val maxPollInterval: Duration = Duration.ofSeconds(DEFAULT_MAX_POLL_INTERVAL_SEC),
+ private val timeout: Duration = Duration.ofMinutes(DEFAULT_TIMEOUT_MIN),
+ private val tokenSupplier: () -> String,
+) : ExternalDataProvider {
+
+ /**
+ * read on first use, not at construction: a cache hit must not require credentials,
+ * so the token can be absent.
+ */
+ private val token: String by lazy(tokenSupplier)
+
+ /**
+ * endpoint normalized once: no trailing slash, so path concatenation never yields `//`.
+ */
+ private val base: URI = URI.create(endpoint.trimEnd('/')).also {
+ require(it.isAbsolute && it.scheme in setOf("http", "https")) {
+ "The endpoint must be an absolute http/https URL, but was '$endpoint'"
+ }
+ }
+
+ /**
+ * The full OGC API processes flow, written into [targetDir].
+ * Follows the flow submit -> poll -> results -> download, **blocking by design**.
+ * Downloaded assets are verified against their size and, optionally, their MD5.
+ * Archives are flattened into single files.
+ *
+ * @param request the [CopernicusRequest] used to initialize the job on ECMWF servers.
+ * @param targetDir the temporary directory where the downloaded assets will be saved.
+ * @throws IllegalArgumentException if [targetDir] does not exist or is not a directory.
+ * @throws IllegalStateException if the assets can't be downloaded, on timeout or if the
+ * asset validation fails.
+ */
+ override fun fetch(request: CopernicusRequest, targetDir: Path) {
+ require(targetDir.isDirectory()) {
+ "$targetDir is not a directory or does not exist"
+ }
+ logger.info("Fetching dataset '${request.dataset}'.")
+ val start = System.nanoTime()
+ // asks ECMWF servers to process the data.
+ val monitorUrl = submit(request)
+ // polls until the data can be retrieved.
+ val resultsUrl = awaitSuccess(monitorUrl)
+ // retrieves the URI to the produced asset.
+ val asset = fetchAsset(resultsUrl)
+ // downloads the asset.
+ val file = download(asset, targetDir)
+ // asset validation and archive flattening.
+ if (!checkMd5) {
+ logger.warn("MD5 check disabled. Only the size in bytes is checked.")
+ }
+ verify(file, asset.sizeBytes, asset.takeIf { checkMd5 }?.md5) { md5, file ->
+ check(!checkMd5) {
+ "Data store advertised an unusable MD5 checksum ($md5) for '$file': could not check asset integrity."
+ }
+ }
+ flattenArchives(targetDir)
+ val elapsed = Duration.ofNanos(System.nanoTime() - start).truncatedTo(ChronoUnit.MILLIS)
+ logger.info("Dataset '${request.dataset}' successfully downloaded in $elapsed.")
+ }
+
+ /**
+ * Submits the job and returns the URL from which to monitor its status.
+ *
+ * `POST {endpoint}/retrieve/v1/processes/{dataset}/execution`, with `PRIVATE-TOKEN` and
+ * `Content-Type`/`Accept: application/json`, body `{"inputs": }` serialized via
+ * [CanonicalJson]. The monitor URL is read from the `rel="monitor"` link of the response
+ * ([parseMonitorUrl]), never rebuilt from a path.
+ *
+ * @param request the [CopernicusRequest] used to initialize the job on ECMWF servers.
+ * @return the absolute job URL to pass to [awaitSuccess].
+ * @throws IllegalStateException on a non-2xx response, enriched with [parseProblemDetail].
+ */
+ private fun submit(request: CopernicusRequest): String {
+ val body = CanonicalJson.encode(mapOf("inputs" to request.inputs))
+ // builds the full POST request
+ val httpRequest = HttpRequest.newBuilder()
+ .uri(URI.create("$base/retrieve/v1/processes/${request.dataset}/execution"))
+ // always needed! A 403 error would be thrown otherwise
+ .header("PRIVATE-TOKEN", token)
+ .header("Content-Type", APPLICATION_JSON)
+ .header("Accept", APPLICATION_JSON)
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build()
+ val response = http.send(httpRequest, HttpResponse.BodyHandlers.ofString())
+ // something has gone wrong
+ if (!response.isSuccessful) {
+ failOnHttpError("Submit of dataset '${request.dataset}'", response)
+ }
+ val monitorUrl = parseMonitorUrl(response.body())
+ logger.info("Job submitted for '${request.dataset}', monitoring at $monitorUrl")
+ return monitorUrl
+ }
+
+ /**
+ * **Polls** the job status at [monitorUrl] until it becomes `successful`, with exponential
+ * backoff capped at [maxPollInterval] and a [timeout] guillotine.
+ *
+ * Terminal failure states are listed **explicitly** (`failed`/`rejected`/`dismissed`):
+ * consistently with [parseStatus], any other state (known like `accepted`/`running` or unknown),
+ * is treated as transient and polling continues.
+ *
+ * @param monitorUrl the URL to use to check the job status. See [submit].
+ * @return the result URL (`rel="results"` link, present only once the job is `successful`).
+ * @throws IllegalStateException on a terminal failure state, on [timeout], or if a `successful`
+ * job exposes no `rel="results"` link (inconsistent server response).
+ */
+ private fun awaitSuccess(monitorUrl: String): String {
+ // the last available moment to check for a successful poll.
+ val deadline = System.nanoTime() + timeout.toNanos()
+ var interval = pollInterval
+ /*
+ * how often to alert the user that the program is still in
+ * polling mode (to prevent them from thinking the program
+ * has frozen).
+ */
+ val heartbeatNanos = Duration.ofSeconds(USER_ALERT_INTERVAL_SEC).toNanos()
+ var lastHeartbeat = System.nanoTime()
+ // tries to poll until a success/timeout/error
+ while (true) {
+ val body = get(monitorUrl).body()
+ // status check
+ when (val status = parseStatus(body)) {
+ "successful" -> {
+ logger.info("Job completed at $monitorUrl")
+ return parseResultsUrl(body)
+ ?: error("Job 'successful' but no rel='results' link at $monitorUrl: inconsistent response")
+ }
+ "failed", "rejected", "dismissed", "deleted" -> failOnStatus(monitorUrl, status, body)
+ "accepted", "running" -> {
+ // fine details on debug mode
+ logger.debug("Job status '$status' at $monitorUrl")
+ val now = System.nanoTime()
+ // reassures the user that the program is in fact not dead.
+ if (now - lastHeartbeat >= heartbeatNanos) {
+ logger.info("Still waiting for job at $monitorUrl (status: $status)")
+ lastHeartbeat = now
+ }
+ }
+ // warns the user about the new unknow status, but keeps polling.
+ else -> logger.warn("Unrecognized job status '$status' at $monitorUrl, continuing to poll")
+ }
+ // fails on timeout
+ check(System.nanoTime() < deadline) {
+ "Timeout ($timeout) while waiting for job completion at $monitorUrl"
+ }
+ Thread.sleep(interval.toMillis())
+ interval = minOf(interval.multipliedBy(2), maxPollInterval)
+ }
+ }
+
+ /**
+ * Fetches the result metadata. `GET resultUrl` (authenticated).
+ *
+ * Extracts href, size and MD5 via [parseAsset], then resolves the href against [resultsUrl].
+ * ECMWF already serves the asset URI as absolute, so the resolve is a defensive no-op
+ * (tolerates a future relative href).
+ *
+ * @param resultsUrl the results URL from [awaitSuccess] (`rel="results"` link).
+ * @return a [RemoteAsset] with an **absolute** href, expected size, and best-effort checksum.
+ */
+ private fun fetchAsset(resultsUrl: String): RemoteAsset {
+ val asset = parseAsset(get(resultsUrl).body())
+ val absoluteHref = URI.create(resultsUrl).resolve(asset.href).toString()
+ logger.info("Asset will be downloaded from: $absoluteHref (${asset.sizeBytes} bytes)")
+ return asset.copy(href = absoluteHref)
+ }
+
+ /**
+ * Streams the asset file into [targetDir].
+ *
+ * `GET asset.href` **without** `PRIVATE-TOKEN`: unlike the OGC GETs (monitor, results), the
+ * href points to an object store on a different host (e.g. `object-store.os-api.cci2.ecmwf.int`)
+ * that serves the resource **publicly, unauthenticated**.
+ *
+ * @param asset result metadata (absolute href, expected size, nullable checksum).
+ * @param targetDir temporary directory to write into.
+ * @return the [Path] of the downloaded asset.
+ * @throws IllegalStateException on a non-2xx response from the object store (it does not
+ * follow RFC 7807, so only the status is reported).
+ */
+ private fun download(asset: RemoteAsset, targetDir: Path): Path {
+ val uri = URI.create(asset.href)
+ // extracts the asset name from the uri
+ val fileName = uri.path.substringAfterLast('/').ifEmpty { "download" }
+ val target = targetDir.resolve(fileName)
+ logger.info("Downloading $fileName...")
+ val request = HttpRequest.newBuilder().uri(uri).GET().build() // no PRIVATE-TOKEN needed
+ val response = http.send(request, HttpResponse.BodyHandlers.ofFile(target))
+ // http error code check
+ if (!response.isSuccessful) {
+ error("Download failed (HTTP ${response.statusCode()}) from ${asset.href}")
+ }
+ logger.info("Downloaded $fileName")
+ return response.body()
+ }
+
+ /**
+ * Always throws as [IllegalStateException], enriching the message with the RFC 7807 problem-detail
+ * **when** the body is interpretable.
+ *
+ * @param action the action performed that caused the error.
+ * @param response the [HttpResponse] that caused the error.
+ * @throws IllegalStateException always.
+ */
+ private fun failOnHttpError(action: String, response: HttpResponse): Nothing {
+ // problem description, if any
+ val described = runCatching {
+ parseProblemDetail(response.body()).describe()
+ }.getOrNull()
+ error(
+ // if no description is available, the whole body is shown
+ if (described.isNullOrBlank()) {
+ "$action failed (HTTP ${response.statusCode()}). Body: ${response.body()}"
+ } else {
+ "$action failed (HTTP ${response.statusCode()}): $described"
+ },
+ )
+ }
+
+ /**
+ * Always throws for a terminal failure job (HTTP 200 with a `failed`/`rejected`/`dismissed` status).
+ *
+ * **Note**: the status document does **not** carry the cause: ECMWF exposes it only by showing the
+ * `rel="results"` link, which answers 4xx with an RFC 7807 body whose proprietary `traceback`
+ * field holds the backend failure. The OGC `message` field and the raw status body are
+ * the fallbacks, **in that order**.
+ *
+ * @param monitorUrl the poll URL that reported a terminal failure status.
+ * @param status the terminal failure status (`failed`/`rejected`/`dismissed`).
+ * @param body the status document, used to locate the results link and, failing that, as a fallback
+ * message source.
+ * @throws IllegalStateException always.
+ */
+ private fun failOnStatus(monitorUrl: String, status: String, body: String): Nothing {
+ val cause = parseResultsUrl(body)
+ ?.let { runCatching { parseProblemDetail(getRaw(it).body()).describe() }.getOrNull() }
+ ?.takeUnless { it.isBlank() }
+ ?: runCatching { parseFailureMessage(body) }.getOrNull()
+ error(
+ buildString {
+ append("Job in state '$status' at $monitorUrl")
+ if (cause.isNullOrBlank()) append(". Body: $body") else append(": $cause")
+ },
+ )
+ }
+
+ /**
+ * Authenticated GET (with `PRIVATE-TOKEN` header) to an OGC endpoint.
+ *
+ * @param url the request URL.
+ * @return the [HttpResponse] received after the request.
+ * @throws IllegalStateException on a non-2xx response.
+ */
+ private fun get(url: String): HttpResponse = getRaw(url).also {
+ if (!it.isSuccessful) failOnHttpError("GET $url", it)
+ }
+
+ /**
+ * Authenticated GET that does **not** check the status code: the caller inspects the body.
+ */
+ private fun getRaw(url: String): HttpResponse = http.send(
+ HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .header("PRIVATE-TOKEN", token)
+ .header("Accept", APPLICATION_JSON)
+ .GET()
+ .build(),
+ HttpResponse.BodyHandlers.ofString(),
+ )
+
+ private companion object {
+ /**
+ * Default interval in seconds between two consecutive polls.
+ */
+ private const val DEFAULT_POLL_INTERVAL_SEC = 2L
+
+ /**
+ * Default max interval in seconds between two consecutive polls.
+ */
+ private const val DEFAULT_MAX_POLL_INTERVAL_SEC = 120L
+
+ /**
+ * Default maximum number of minutes allowed to wait for a poll with a ‘successful’ status.
+ */
+ private const val DEFAULT_TIMEOUT_MIN = 30L
+
+ /**
+ * How often to alert the user that the program is still in
+ * polling mode (to prevent them from thinking the program
+ * has frozen).
+ */
+ private const val USER_ALERT_INTERVAL_SEC = 30L
+
+ private const val APPLICATION_JSON = "application/json"
+ private val logger = LoggerFactory.getLogger(CopernicusDataStoreProvider::class.java)
+ }
+}
+
+/**
+ * Extension property used to check if a response was successful.
+ */
+private val HttpResponse<*>.isSuccessful: Boolean
+ get() = statusCode() in 200..299
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CopernicusRequest.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CopernicusRequest.kt
new file mode 100644
index 0000000000..ff1df90be1
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/CopernicusRequest.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.acquisition
+
+import it.unibo.alchemist.boundary.utils.CanonicalJson
+import java.security.MessageDigest
+
+/**
+ * A request to an ECMWF api-friendly datastore (CDS / EWDS / ADS): carries the request **identity** and **what to
+ * download**.
+ *
+ * @property dataset dataset identifier (e.g. `"cems-glofas-historical"`).
+ * @property inputs the **opaque** request map: the selection (variables, dates, area, format, ...)
+ * of parameters used for the request to the datastore. It is intentionally untyped because the fields
+ * vary widely per dataset (ERA5 uses `year/month/day`, GloFAS uses `hyear/hmonth/hday/system_version/...`).
+ */
+data class CopernicusRequest(val dataset: String, val inputs: Map) : CacheKey {
+
+ /**
+ * @return a deterministic directory name: a human-readable sanitized prefix from [dataset],
+ * followed by a truncation of the SHA-256 hash of the canonical `(dataset, inputs)` pair. The
+ * prefix aids the human reading the cache directory; the hash provides collision resistance.
+ */
+ override fun toFileName(): String {
+ val canonical = CanonicalJson.encode(
+ mapOf(
+ "dataset" to dataset,
+ "inputs" to inputs,
+ ),
+ )
+ return "${dataset.toFileSystemSafe()}_${sha256Hex(canonical).take(HASH_PREFIX_LENGTH)}"
+ }
+
+ private companion object {
+
+ /**
+ * Number of leading hex characters of the SHA-256 digest kept in the folder name.
+ * Used to provide collision-safety for a personal cache while keeping the name short.
+ */
+ private const val HASH_PREFIX_LENGTH = 16
+
+ /**
+ * Returns the SHA-256 digest of [str] (UTF-8) as a lowercase hex string.
+ *
+ * @param str the UTF-8 string to hash.
+ * @return the digest of SHA-256 over [str].
+ */
+ private fun sha256Hex(str: String): String = MessageDigest.getInstance("SHA-256")
+ .digest(str.toByteArray(Charsets.UTF_8))
+ .toHexString()
+ }
+}
+
+/**
+ * Renders this `String` as a single file-system-safe path segment: every character outside `[A-Za-z0-9._-]`
+ * is replaced with `_`. Distinct strings may collapse to the same output (e.g. `"a b"` and `"a_b"`).
+ *
+ * @return this string sanitized (i.e. all non-alphabetical/numerical characters replaced by `_`)
+ */
+internal fun String.toFileSystemSafe(): String = this.replace(Regex("[^A-Za-z0-9._-]"), "_")
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/ExternalDataProvider.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/ExternalDataProvider.kt
new file mode 100644
index 0000000000..f9927981da
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/acquisition/ExternalDataProvider.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.acquisition
+
+import java.nio.file.Path
+
+/**
+ * Common contract for an external data source: given a typed request, it fills a
+ * directory with files that are ready to be opened. Ensures that the rest of the system
+ * is unaware of the specifics of each individual provider API.
+ *
+ * [R] is associated with [CacheKey], so caching is part of the contract; it is not
+ * an implementation detail: every request must be cacheable.
+ *
+ * The type parameter [R] binds each provider to its own family of requests. For
+ * example, an `ExternalDataProvider` rejects a `BBBikeRequest` at
+ * compile time.
+ *
+ * @param R the consumed request type, bound to [CacheKey] so that its result
+ * is always cacheable.
+ */
+fun interface ExternalDataProvider {
+
+ /**
+ * Fills [targetDir] with the data denoted by [request] (e.g., by downloading resources).
+ *
+ * @param request the request identifying the data to obtain.
+ * @param targetDir the directory to fill with data; it must exist and be writable.
+ * @throws IllegalStateException if the data cannot be produced.
+ */
+ fun fetch(request: R, targetDir: Path)
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/Archives.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/Archives.kt
new file mode 100644
index 0000000000..ea1e1fdb6e
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/Archives.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.zip.ZipException
+import java.util.zip.ZipFile
+
+/**
+ * Extract all ZIP files inside of [dir] and deletes them.
+ *
+ * Each regular file in [dir] that can be opened as a valid ZIP archive is **extracted flat** (each
+ * entry by its basename) into [dir], and the archive itself is then deleted. Files that are not ZIP
+ * archives are left untouched.
+ *
+ * **Note:** it is the caller's responsibility to check, if necessary, whether [dir] is empty after this
+ * operation, as empty ZIPs are extracted too.
+ *
+ * @param dir the directory that contains the ZIP archives to extract.
+ * @throws IllegalStateException if two entries in an archive collapse to the same basename.
+ */
+internal fun flattenArchives(dir: Path) {
+ val files = Files.list(dir).use {
+ it.filter(Files::isRegularFile).toList()
+ }
+ for (file in files) {
+ // extract the ZIP file (if it is one) and deletes it.
+ if (extractArchive(file, dir)) {
+ Files.delete(file)
+ }
+ }
+}
+
+/**
+ * Extracts [archive] flat into [dir] if it is a ZIP.
+ *
+ * @return `true` if [archive] was a valid ZIP (its entries were extracted), `false` if it is not a
+ * ZIP and was left untouched.
+ * @throws IllegalStateException on a basename collision between two entries.
+ */
+private fun extractArchive(archive: Path, dir: Path): Boolean = try {
+ ZipFile(archive.toFile()).use { zip ->
+ for (entry in zip.entries()) {
+ // nothing to do if the entry is a directory.
+ if (entry.isDirectory) continue
+ // it's a file. Resolves the new path.
+ val target = dir.resolve(Path.of(entry.name).fileName.toString())
+ // a file with the exact same path already exists.
+ check(Files.notExists(target)) {
+ "Flatten collision on '${target.fileName}' from '${archive.fileName}'"
+ }
+ // copies the content in the target path
+ zip.getInputStream(entry).use { source ->
+ Files.copy(source, target)
+ }
+ }
+ }
+ true
+} catch (_: ZipException) {
+ // the file was not a zip archive.
+ false
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CanonicalJson.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CanonicalJson.kt
new file mode 100644
index 0000000000..c0a763ca0d
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CanonicalJson.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import java.util.TreeMap
+
+/**
+ * Deterministic JSON encoding used to derive stable cache keys and submit bodies.
+ *
+ * "Canonical" here means exactly one normalization: **map keys are sorted recursively** at every
+ * nesting level, so a request encodes identically regardless of the key order it was authored in.
+ * **List order is preserved**, since in Copernicus requests it is semantic.
+ */
+internal object CanonicalJson {
+
+ private val gson: Gson = GsonBuilder().serializeNulls().create()
+
+ /**
+ * Encodes [value] as canonical JSON.
+ *
+ * @param value a serializable value, typically a nested [Map]/[List].
+ * @return its canonical JSON representation: map keys sorted recursively, list order untouched.
+ */
+ fun encode(value: Any): String = gson.toJson(canonicalize(value))
+
+ /**
+ * Recursively rewrites [value] into a form Gson serializes deterministically: every [Map]
+ * becomes a key-sorted [TreeMap], every [List] keeps its order,
+ * scalars (strings, booleans, numbers, nulls) are left untouched.
+ */
+ private fun canonicalize(value: Any?): Any? = when (value) {
+ // maintains the keys sorted
+ is Map<*, *> -> TreeMap().apply {
+ value.forEach { (key, mapValue) ->
+ // recursively evaluates nested structures
+ put(key.toString(), canonicalize(mapValue))
+ }
+ }
+ is List<*> -> value.map(::canonicalize)
+ else -> value
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CdsApiRc.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CdsApiRc.kt
new file mode 100644
index 0000000000..27e36dfdf5
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CdsApiRc.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Reads **only the token** from a `.cdsapirc` file (the format used by ECMWF's official client,
+ * with `url:` and `key:` lines).
+ * See [how-to-api](https://cds.climate.copernicus.eu/how-to-api).
+ *
+ * Only `key` is read, because the base URL is a layer parameter: a single `.cdsapirc` has one `url`
+ * and cannot serve two different data stores (e.g. CDS and EWDS),
+ * whereas the **same token** (unified ECMWF identity) is valid on both.
+ *
+ * **Note**: The key format is not checked, as there is no guarantee that it will not change in the future.
+ */
+internal object CdsApiRc {
+
+ private const val KEY_FIELD = "key"
+
+ /**
+ * Extracts the token from the `key:` line of the file. Each line is split on its **first** `:`,
+ * so any `:` inside the token itself is preserved.
+ *
+ * @param path path to a `.cdsapirc`-formatted file (already resolved).
+ * @return the token to send in the `PRIVATE-TOKEN` header.
+ * @throws IllegalStateException if the file has no `key:` line.
+ */
+ fun readToken(path: Path): String = Files.readAllLines(path).firstNotNullOfOrNull { line ->
+ val separator = line.indexOf(':')
+ if (separator > 0 && line.take(separator).trim() == KEY_FIELD) {
+ line.substring(separator + 1).trim()
+ } else {
+ null
+ }
+ } ?: error("No 'key:' line in $path")
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CopernicusInputs.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CopernicusInputs.kt
new file mode 100644
index 0000000000..58a2fcb88e
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CopernicusInputs.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import com.google.gson.Gson
+import com.google.gson.JsonSyntaxException
+import com.google.gson.reflect.TypeToken
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Reads the datastore's `inputs` request map from a local JSON file.
+ *
+ * The map is not accepted as an inline YAML mapping: some datasets require a `type` field,
+ * which collides with the Alchemist loader's own `type` keyword used to select
+ * nested-parameter implementations.
+ */
+internal object CopernicusInputs {
+
+ private val gson = Gson()
+ private val mapType = object : TypeToken>() {}.type
+
+ /**
+ * Parses [path] as a JSON object into the request map.
+ *
+ * JSON objects become nested [Map]s, JSON arrays become [List]s preserving order (since list order is
+ * semantic for the datastore API).
+ *
+ * @param path path to the JSON file (already resolved).
+ * @return the parsed request map.
+ * @throws JsonSyntaxException if [path] does not hold a valid JSON object.
+ */
+ fun read(path: Path): Map {
+ val json = Files.readString(path)
+ val parsed: Map = gson.fromJson(json, mapType)
+ return parsed
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CopernicusParsers.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CopernicusParsers.kt
new file mode 100644
index 0000000000..c0ecebceae
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/CopernicusParsers.kt
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import com.google.gson.JsonParser
+
+/*
+ * Pure parsers for the JSON bodies of the ECMWF data store REST API.
+ *
+ * The job envelope (submit/status/results and its `links`) is an instance of "OGC API - Processes,
+ * Part 1: Core (OGC 18-062r2)"; the asset metadata field names (`file:size`, `file:checksum`) are
+ * from the STAC File Info Extension. ECMWF conforms to neither fully: it deviates locally (e.g. an
+ * `asset.value` envelope that is not a STAC document, a bare MD5 instead of a multihash), so these
+ * parsers extract only the few fields needed and tolerate the rest.
+ *
+ * Error bodies come in three distinct shapes, each parsed (or not) accordingly:
+ * - RFC 7807 problem-details on 4xx application errors (400/401/403/404) -> see parseProblemDetail(...).
+ * - FastAPI/Pydantic validation errors on 422, whose `detail` is an array of objects, NOT a
+ * string -> parseProblemDetail(...) yields a null `detail` for them.
+ * - the cause of a FAILED JOB, which despite the OGC schema declaring a top-level `message` string
+ * is not served there: ECMWF leaves `message` empty and exposes the cause only by referencing
+ * the job's `rel="results"` link, which answers 4xx with a problem-details body carrying a
+ * proprietary `traceback` field -> see parseProblemDetail(...); parseFailureMessage(...) reads
+ * `message` as a fallback only.
+ *
+ * Each function takes a raw response body string and is fully testable offline against captured
+ * real responses.
+ *
+ * For reference:
+ * Open Geospatial Consortium API: https://docs.ogc.org/is/18-062r2/18-062r2.html#toc0
+ * Spatio Temporal Asset Catalogs: https://github.com/stac-extensions/file
+ */
+
+/**
+ * Metadata of a result file ready for download.
+ *
+ * @property href absolute, unauthenticated download URL.
+ * @property sizeBytes expected size in bytes; must always be verified after download.
+ * @property md5 md5 expected MD5 as advertised, verbatim, if advertised at all; it may be shorter than
+ * 32 characters, see [parseAsset].
+ */
+internal data class RemoteAsset(val href: String, val sizeBytes: Long, val md5: String?)
+
+/**
+ * An RFC 7807 "Problem Details" error report from the data store.
+ *
+ * @property type a URI (or, for ECMWF, sometimes an opaque label) identifying the problem type.
+ * @property title a short, human-readable summary of the problem type.
+ * @property status the HTTP status code echoed in the body, if present.
+ * @property detail a human-readable explanation specific to this occurrence, if present.
+ * @property instance a URI identifying the specific occurrence of the problem, if present.
+ * @property traceId the data store's trace identifier (`trace_id`), useful when reporting an issue.
+ * @property traceback the backend failure report of a failed job. A **proprietary ECMWF
+ * extension**, not part of RFC 7807, served by the results endpoint; frequently the only field
+ * carrying the actual cause, since such bodies often have no `detail`.
+ */
+internal data class ProblemDetail(
+ val type: String,
+ val title: String?,
+ val status: Int?,
+ val detail: String?,
+ val instance: String?,
+ val traceId: String?,
+ val traceback: String?,
+) {
+ /**
+ * Extracts a human-readable summary of this problem-detail, or an empty string if
+ * no content is extractable. Prefers [detail] (the standard field),
+ * then [traceback] (the only content of a failed-job body), then [title].
+ *
+ * @return a human-readable string describing this problem.
+ */
+ fun describe(): String {
+ val core = detail ?: traceback ?: title ?: return ""
+ return buildString {
+ append(core)
+ append(" [type=$type")
+ traceId?.let { append(", trace=$it") }
+ append("]")
+ }
+ }
+}
+
+/**
+ * Search for and returns the `href` property of the first link in the `links`
+ * array within the [json] whose `rel` property is equal to [rel]. Returns `null`
+ * if the body has no `links` array at all.
+ *
+ * ```json
+ * An example of JSON body is:
+ * { "links": [ { "rel": "self", "href": "..." },
+ * { "rel": "monitor", "href": "URL_TO_RETURN" } ] }
+ * ```
+ * In this example, with `rel = "monitor"` the return value would be `URL_TO_RETURN`.
+ *
+ * @param json the JSON body as a string.
+ * @param rel the `rel` property associated with the desired `href`.
+ * @return the `href` associated with [rel] in the links array of [json], or `null` if not present.
+ */
+private fun linkHref(json: String, rel: String): String? = JsonParser.parseString(json)
+ .asJsonObject.getAsJsonArray("links")
+ ?.map { it.asJsonObject }
+ ?.firstOrNull { it.get("rel")?.asString == rel }
+ ?.get("href")?.asString
+
+/**
+ * Extracts the job-monitoring URL from a submit response [json] (`POST .../execution`).
+ *
+ * Returns the absolute `href` of the link whose `rel` is `"monitor"`.
+ * The same job is also identifiable via the top-level `jobID` field and the `Location` response header.
+ * The `monitor` link is preferred because it keeps polling decoupled from the URL path
+ * layout while remaining a pure JSON parser.
+ *
+ * @param json the JSON string to parse.
+ * @return the `href` associated with `rel="monitor"` in the links array of [json].
+ * @throws IllegalStateException if no `rel="monitor"` link is present in [json].
+ */
+internal fun parseMonitorUrl(json: String): String =
+ linkHref(json, "monitor") ?: error("No link with rel='monitor' in the submit response")
+
+/**
+ * Extracts the job status from a status (`GET .../jobs/{id}`) response [json].
+ *
+ * The states handled by the official ECMWF client are `accepted`, `running`, `successful`,
+ * `failed`, `rejected`, `dismissed`, and `deleted`. Of these, `successful` is the sole success;
+ * `failed`, `rejected`, `dismissed`, `deleted` are terminal failures; `accepted`/`running` are
+ * transient.
+ *
+ * The status is returned as a **raw string**, not an enum, because the API is marked
+ * as "evolving" and the set of states is descriptive rather than contractual: the official
+ * client itself types it as a plain string and provides for an unrecognized value.
+ *
+ * @param json the JSON string to parse.
+ * @return the job's processing status, verbatim.
+ * @throws IllegalStateException if the `status` field is absent.
+ */
+internal fun parseStatus(json: String): String = JsonParser.parseString(json)
+ .asJsonObject.get("status")
+ ?.asString
+ ?: error("No 'status' field in the status response")
+
+/**
+ * Extracts the results URL from a status (`GET .../jobs/{id}`) response [json], or `null` if the
+ * job exposes no results link.
+ *
+ * The `rel="results"` link appears once the job reaches a terminal state; an `accepted`/`running`
+ * job exposes only `rel="self"`. On a `successful` job the link serves the asset metadata (see
+ * [parseAsset]); on a failed one it answers 4xx with the problem-details body that carries the
+ * actual cause. A `null` on an already-`successful` job indicates an inconsistent server response,
+ * and the caller should fail rather than reconstructing a `.../results` path by hand.
+ *
+ * @param json the JSON string to parse.
+ * @return the results URL, or `null` if no `rel="results"` link is present.
+ */
+internal fun parseResultsUrl(json: String): String? = linkHref(json, "results")
+
+/**
+ * Extracts the downloadable asset metadata from a results (`GET .../jobs/{id}/results`) [json].
+ *
+ * Reads `asset.value.href` (the download URL, served by the object store on a different host and
+ * without authentication; absolute as served by ECMWF), `file:size`, and the optional
+ * `file:checksum`.
+ *
+ * **Note on the shape**: the field names `file:size`/`file:checksum` are STAC File Info Extension
+ * naming, but the `asset.value` envelope is an ECMWF convention, NOT STAC structure: a canonical
+ * STAC asset lives in an `assets` map with these fields directly on it (no `value` wrapper), and
+ * this body is not a STAC document. Hence, the two nested lookups (`asset` then `value`).
+ *
+ * **Note on the checksum**: ECMWF emits `file:checksum` as a bare lowercase MD5 hex string.
+ * It is captured verbatim and **not** normalized here, because a parser must report what
+ * the server said: the store omits left zero-padding, so a digest beginning with a zero
+ * is advertised with fewer than 32 characters (e.g., `0aa45b...` served as `aa45b...`).
+ * Padding it back before comparison is the responsibility of the integrity check, not of this parser.
+ * The field is nullable because some datasets/stores omit it entirely.
+ *
+ * @param json the JSON string to parse.
+ * @return asset metadata as a [RemoteAsset].
+ * @throws IllegalStateException if `asset.value`, its `href`, or its `file:size` is absent.
+ */
+internal fun parseAsset(json: String): RemoteAsset {
+ val value = JsonParser.parseString(json).asJsonObject
+ .getAsJsonObject("asset")
+ ?.getAsJsonObject("value")
+ ?: error("No 'asset.value' object in the results response")
+ fun field(name: String) = value.get(name)?.takeUnless { it.isJsonNull }
+ return RemoteAsset(
+ href = field("href")?.asString ?: error("No 'href' in asset.value"),
+ sizeBytes = field("file:size")?.asLong ?: error("No 'file:size' in asset.value"),
+ md5 = field("file:checksum")?.asString,
+ )
+}
+
+/**
+ * Parses an RFC 7807 "Problem Details" error body [json], as returned by the data store on a
+ * failed request (e.g. a `404` result-not-ready, a `401` authentication required, a `403`
+ * dataset-license not accepted, a `400` invalid request).
+ *
+ * All fields are optional per RFC 7807 except `type` (which defaults to `"about:blank"`), so every
+ * field but [ProblemDetail.type] is nullable. Note that ECMWF does not always honor the spec's
+ * recommendation that `type` be a URI (it sometimes repeats the human-readable title, e.g.
+ * `"permission denied"`), so `type` is treated as an opaque string, never parsed as a URI.
+ *
+ * @param json the JSON error body as a string.
+ * @return the extracted [ProblemDetail].
+ */
+internal fun parseProblemDetail(json: String): ProblemDetail {
+ val obj = JsonParser.parseString(json).asJsonObject
+
+ // a field extractor by name
+ fun field(name: String) = obj.get(name)?.takeIf { it.isJsonPrimitive }?.asJsonPrimitive
+ return ProblemDetail(
+ type = field("type")?.asString ?: "about:blank",
+ title = field("title")?.asString,
+ status = field("status")?.asInt,
+ detail = field("detail")?.asString,
+ instance = field("instance")?.asString,
+ traceId = field("trace_id")?.asString,
+ traceback = field("traceback")?.asString,
+ )
+}
+
+/**
+ * Extracts the OGC `message` field from a job status body, or `null` if absent.
+ *
+ * The OGC schema declares a nullable top-level `message` string as the place where a job reports
+ * what happened, but **ECMWF does not populate it**: the cause of a failure is served instead by
+ * dereferencing the job's `rel="results"` link (see [parseResultsUrl] and [parseProblemDetail]).
+ * This parser is, therefore, a fallback, kept because `message` is part of the schema and a future
+ * revision of the data store may start filling it in.
+ *
+ * It returns `null` instead of throwing on an absent or null field, both because the schema
+ * declares it nullable and because it runs on the error path, where the caller falls back to the
+ * raw body.
+ *
+ * @param json the JSON status body as a string.
+ * @return the `message` string, or `null` if missing/null.
+ */
+internal fun parseFailureMessage(json: String): String? = JsonParser.parseString(json)
+ .asJsonObject.get("message")?.takeUnless { it.isJsonNull }?.asString
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/Integrity.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/Integrity.kt
new file mode 100644
index 0000000000..05e37e6770
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/boundary/utils/Integrity.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import java.nio.file.Files
+import java.nio.file.Path
+import java.security.DigestInputStream
+import java.security.MessageDigest
+
+/**
+ * Size of the buffer used to stream a file through the MD5 digest, in bytes.
+ */
+private const val DIGEST_BUFFER_BYTES = 8 * 1024 // 8 KB
+
+/**
+ * Number of hexadecimal digits in a well-formed MD5 digest.
+ */
+private const val MD5_HEX_DIGITS = 32
+
+/**
+ * Verifies the integrity of a downloaded [file] against the metadata the data store advertised for
+ * it: the byte count must equal [expectedSizeBytes] and, when [expectedMd5] is supplied,
+ * the MD5 digest must equal it.
+ *
+ * **Note on the advertised MD5:** the data store emits hexadecimal hashes **without left
+ * zero-padding**, so a digest such as `0aa45b...` is advertised as the 31-character
+ * `aa45b...`. The advertised value is therefore left-padded back to [MD5_HEX_DIGITS] before comparison.
+ *
+ * **Note for archives**: for an archive (e.g. ZIP) the advertised size and digest describe the
+ * archive itself, not its extracted entries.
+ *
+ * @param file the downloaded file to check.
+ * @param expectedSizeBytes the expected size in bytes.
+ * @param expectedMd5 the expected MD5 digest as a hex string, if advertised.
+ * @param checksumUnusableAction an action performed with the advertised checksum and filename
+ * if the checksum turns out to be unusable.
+ * @throws IllegalStateException if the actual size, or the actual MD5, does not match.
+ */
+internal fun verify(
+ file: Path,
+ expectedSizeBytes: Long,
+ expectedMd5: String? = null,
+ checksumUnusableAction: (String, String) -> Unit = { _, _ -> },
+) {
+ val actualSize = Files.size(file)
+ check(actualSize == expectedSizeBytes) {
+ "Size mismatch for '${file.fileName}': expected $expectedSizeBytes bytes, got $actualSize"
+ }
+ // no checksum advertised: nothing to verify.
+ val advertised = expectedMd5 ?: return
+ val expected = advertised.lowercase().padStart(MD5_HEX_DIGITS, '0')
+ // warns the user that no md5 was provided.
+ if (!expected.isMd5Hex()) {
+ checksumUnusableAction(advertised, file.fileName.toString())
+ return
+ }
+ val actual = md5Hex(file)
+ check(actual == expected) {
+ "MD5 mismatch for '${file.fileName}': expected $expected " +
+ "(advertised as '$advertised'), got $actual"
+ }
+}
+
+/**
+ * @return `true` if this string is a well-formed lowercase MD5 hex digest.
+ */
+private fun String.isMd5Hex(): Boolean = length == MD5_HEX_DIGITS && all { it in '0'..'9' || it in 'a'..'f' }
+
+/**
+ * Computes the MD5 digest of [file] as a lowercase hex string,
+ * streaming the file through the digest so arbitrarily large files
+ * are never fully held in memory.
+ *
+ * MD5 is used to verify download integrity against the checksum
+ * reported by the Copernicus API.
+ *
+ * @param file the file to digest.
+ * @return the MD5 digest, as a lowercase hex string.
+ */
+internal fun md5Hex(file: Path): String {
+ val digest = MessageDigest.getInstance("MD5") // NOSONAR: mandated by Copernicus API for integrity checks
+ DigestInputStream(Files.newInputStream(file), digest).use { stream ->
+ // at any given time, a maximum of DIGEST_BUFFER_BYTES bytes are allocated in memory.
+ val buffer = ByteArray(DIGEST_BUFFER_BYTES)
+ // fills the buffer on every call until there are no more bytes available.
+ while (stream.read(buffer) != -1) {
+ // reading feeds the digest. The bytes themselves are discarded.
+ }
+ }
+ return digest.digest().toHexString()
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/ArrayRasterGrid.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/ArrayRasterGrid.kt
new file mode 100644
index 0000000000..c80b01d21e
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/ArrayRasterGrid.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.reading
+
+/**
+ * In-memory implementation of [RasterGrid] where values are stored in a single flattened row-major array.
+ *
+ * Suited for dense grids, where most cells hold a value.
+ *
+ * @property gridValues cell values in row-major order respect to [latitudes] x [longitudes];
+ *
+ * @see RasterGrid
+ */
+class ArrayRasterGrid(latitudes: DoubleArray, longitudes: DoubleArray, val gridValues: DoubleArray) :
+ RasterGrid(latitudes, longitudes, gridValues) {
+
+ override fun valueAt(latIndex: Int, lonIndex: Int): Double = gridValues[latIndex * longitudes.size + lonIndex]
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/EagerGridSnapshots.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/EagerGridSnapshots.kt
new file mode 100644
index 0000000000..98ae0a32cd
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/EagerGridSnapshots.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.reading
+
+import it.unibo.alchemist.model.geospatial.utils.FileAxes
+import it.unibo.alchemist.model.geospatial.utils.ReferenceGrid
+import it.unibo.alchemist.model.geospatial.utils.buildGrid
+import it.unibo.alchemist.model.geospatial.utils.flattenAscending
+import it.unibo.alchemist.model.geospatial.utils.listDataFiles
+import it.unibo.alchemist.model.geospatial.utils.openNetcdfDataset
+import it.unibo.alchemist.model.geospatial.utils.readFileAxes
+import it.unibo.alchemist.model.geospatial.utils.readPermutedSlice
+import java.nio.file.Path
+import java.util.TreeMap
+import kotlin.time.Instant
+import kotlin.time.toKotlinInstant
+import org.slf4j.LoggerFactory
+
+/**
+ * Eager [GridSnapshots] implementation. This implementation depends on NetCDF-Java.
+ *
+ * Reads a directory of **homogeneous** data files (same variable, same spatial grid)
+ * and exposes them as a single time-ordered slice series.
+ * All data is loaded into memory at construction time; file handles are closed before
+ * the constructor returns.
+ * If two files share the same real-world instants, then only the grid associated with the file
+ * having the lower lexicographic order will be evaluated.
+ *
+ * ### Supported datasets
+ * Supports datasets readable by NetCDF-Java whose selected variable is a 2D
+ * regular latitude/longitude field associated with one temporal dimension and whose
+ * coordinate axes are 1D.
+ * The dataset does not need to follow a specific convention (e.g, Climate and Forecast),
+ * what matters is that the relevant variable exposes the standard geophysical metadata
+ * in a format that NetCDF-Java can interpret.
+ *
+ * ### Grid normalization
+ * Latitude and longitude axes are normalized to ascending order regardless of the direction
+ * stored in the file. Spatial homogeneity across files is validated eagerly.
+ *
+ * ### Dimension order
+ * The selected variable's three dimensions may appear in **any** order in the files:
+ * they are matched by name against the detected time/latitude/longitude axes and reordered
+ * internally (via [ucar.ma2.Array.permute]). What is **not** tolerated is a variable with
+ * more or fewer than these three dimensions.
+ *
+ * ### Data representation
+ * The floating-point values that are read are represented as [Double] and missing/fill
+ * values are replaced by [Double.NaN].
+ *
+ * The variable to read is selected by [variableName], or auto-detected as the unique
+ * `{time, lat, lon}` variable in the file.
+ *
+ * @param directory directory of spatially homogeneous data files (NetCDFs/GRIBs).
+ * @param variableName name of the variable as it appears in the file (e.g. `"dis24"`),
+ * NOT the variable name shown in the Copernicus store. If `null`, auto-detected from the file.
+ * @throws IllegalArgumentException if the directory is empty; if the variable is missing
+ * or ambiguous; if files have mismatched spatial axes; if the variable dimensions are not
+ * `{time, lat, lon}`.
+ */
+class EagerGridSnapshots(directory: Path, variableName: String? = null) : GridSnapshots {
+
+ override val instants: List
+ private val grids: List
+
+ init {
+ // maps all file time instances to the corresponding RasterGrid, sorting them by Instant
+ val map = TreeMap()
+ // spatial grid and variable are established by the first file, validated against all the others
+ var reference: ReferenceGrid? = null
+ for (file in listDataFiles(directory)) {
+ /*
+ * opens the file in "enhanced mode": all fill values are replaced with NaN
+ * and expects dimensions to be properly tagged.
+ */
+ openNetcdfDataset(file).use { ds ->
+ val axes = readFileAxes(ds, variableName, file)
+ reference = reference?.also { it.requireMatches(axes, file, directory) } ?: ReferenceGrid(axes)
+ readTimestepsFrom(map, axes, directory)
+ }
+ }
+ instants = map.keys.toList()
+ grids = map.values.toList()
+ }
+
+ /**
+ * Reads every real-world instant of [axes] and inserts the resulting [RasterGrid] slices into [map],
+ * keyed by their real-world [Instant].
+ *
+ * @param map the map, **shared** across all files in [directory].
+ * @param axes the schema of the file currently being read.
+ * @param directory the directory of the file (used for error messages).
+ */
+ private fun readTimestepsFrom(map: TreeMap, axes: FileAxes, directory: Path) {
+ val nLat = axes.latitudes.size
+ val nLon = axes.longitudes.size
+ for (t in 0 until axes.timeAxis.size.toInt()) {
+ // converts a CalendarDate to an Instant
+ val instant = axes.timeAxis.getCalendarDate(t).toDate().toInstant().toKotlinInstant()
+ // there are duplicate timestamps in different files, only the first one is preserved
+ if (!map.containsKey(instant)) {
+ val slice = readPermutedSlice(axes, t, nLat, nLon)
+ val measurements = flattenAscending(slice, nLat, nLon, axes.latDescending, axes.lonDescending)
+ map[instant] = buildGrid(axes.latitudes, axes.longitudes, measurements)
+ } else {
+ logger.warn(
+ "Two different files in $directory share the same real-world instant ($instant)." +
+ "Ignoring the instant of the last one.",
+ )
+ }
+ }
+ }
+
+ override fun grid(index: Int): RasterGrid = grids[index]
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ private val logger = LoggerFactory.getLogger(EagerGridSnapshots::class.java)
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/GridSnapshots.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/GridSnapshots.kt
new file mode 100644
index 0000000000..d4c9f96e2f
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/GridSnapshots.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.reading
+
+import java.io.Serializable
+import kotlin.time.Instant
+
+/**
+ * A time-ordered sequence of [RasterGrid] slices backed by geophysical source data.
+ *
+ * [instants] and [grid] are aligned: the i-th instant is the real-world timestamp of the
+ * slice returned by `grid(i)`. [instants] is guaranteed to be strictly ascending with no
+ * duplicates. [instants] exposes [Instant] so that callers can convert
+ * real-world timestamps to simulation time once at construction.
+ */
+interface GridSnapshots : Serializable {
+ /**
+ * Real-world timestamps of each slice, strictly ascending, aligned with [grid].
+ */
+ val instants: List
+
+ /**
+ * Returns the spatial slice at [index].
+ *
+ * @param index 0-based, aligned with [instants].
+ */
+ fun grid(index: Int): RasterGrid
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/MapRasterGrid.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/MapRasterGrid.kt
new file mode 100644
index 0000000000..1e26b41b51
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/MapRasterGrid.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.reading
+
+/**
+ * In-memory implementation of [RasterGrid] where only non-missing values
+ * are stored in a map keyed by latitude-longitude index pairs.
+ *
+ * Suited for sparse grids, where most cells hold a missing value.
+ *
+ * @see RasterGrid
+ */
+class MapRasterGrid(latitudes: DoubleArray, longitudes: DoubleArray, gridValues: DoubleArray) :
+ RasterGrid(latitudes, longitudes, gridValues) {
+
+ /**
+ * A map that associates each (latIndex, longIndex) pair with the value, if present.
+ */
+ private val availableValues: Map, Double> = buildMap {
+ for (latIndex in latitudes.indices) {
+ for (lonIndex in longitudes.indices) {
+ val cellValue = gridValues[latIndex * longitudes.size + lonIndex]
+ if (!cellValue.isNaN()) put(latIndex to lonIndex, cellValue)
+ }
+ }
+ }
+
+ override fun valueAt(latIndex: Int, lonIndex: Int): Double =
+ availableValues.getOrDefault(latIndex to lonIndex, Double.NaN)
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/RasterGrid.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/RasterGrid.kt
new file mode 100644
index 0000000000..ffa9668f79
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/reading/RasterGrid.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.reading
+
+import java.io.Serializable
+
+/**
+ * A single 2D spatial bounding box, based on a geographic grid (latitude/longitude).
+ *
+ * @property latitudes of grid nodes, in degrees, sorted in **strictly** ascending order.
+ * @property longitudes of grid nodes, in degrees, sorted in **strictly** ascending order.
+ * @param gridValues cell values in row-major order respect to [latitudes] x [longitudes];
+ * [Double.NaN] values represent missing/fill values.
+ * @throws IllegalArgumentException if [latitudes]/[longitudes] are not strictly ascending or if
+ * [gridValues]' size does not equal `latitudes.size * longitudes.size`.
+ */
+abstract class RasterGrid(val latitudes: DoubleArray, val longitudes: DoubleArray, gridValues: DoubleArray) :
+ Serializable {
+
+ init {
+ require(latitudes.isStrictlyAscending()) {
+ "latitudes must be strictly ascending, but got ${latitudes.contentToString()}"
+ }
+ require(longitudes.isStrictlyAscending()) {
+ "longitudes must be strictly ascending, but got ${longitudes.contentToString()}"
+ }
+ val expectedSize = latitudes.size * longitudes.size
+ require(gridValues.size == expectedSize) {
+ "Dimension mismatch: expected $expectedSize values " +
+ "(${latitudes.size} lat x ${longitudes.size} lon), but got ${gridValues.size}"
+ }
+ }
+
+ /**
+ * Raw value of the cell at the given index coordinates.
+ *
+ * @param latIndex index on the [latitudes] axis.
+ * @param lonIndex index on the [longitudes] axis.
+ * @return the raw value of the cell; a [Double.NaN] denotes a missing/fill value.
+ */
+ abstract fun valueAt(latIndex: Int, lonIndex: Int): Double
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ }
+}
+
+/**
+ * @return `true` if this [DoubleArray] is strictly ascending.
+ */
+private fun DoubleArray.isStrictlyAscending(): Boolean = when {
+ this.size <= 1 -> true
+ else -> {
+ for (i in 0 until this.size - 1) {
+ if (this[i] >= this[i + 1]) return false
+ }
+ return true
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/converter/DoubleIdentityWithFallback.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/converter/DoubleIdentityWithFallback.kt
new file mode 100644
index 0000000000..3c1b45e3c9
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/converter/DoubleIdentityWithFallback.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.converter
+
+/**
+ * A [MissingValueFallbackConverter] for `Double` values that leaves valid numerical
+ * values unchanged (identity transformation) and replaces [Double.NaN] with [defaultValue].
+ *
+ * @param defaultValue the fallback value returned when encountering `Double.NaN`.
+ * Defaults to [Double.NaN].
+ */
+data class DoubleIdentityWithFallback(private val defaultValue: Double = Double.NaN) :
+ MeasurementConverter by MissingValueFallbackConverter({ it }, defaultValue)
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/converter/MeasurementConverter.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/converter/MeasurementConverter.kt
new file mode 100644
index 0000000000..ccb62a3884
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/converter/MeasurementConverter.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.converter
+
+import java.io.Serializable
+
+/**
+ * Converts a raw `Double` value read from a GRIB / NetCDF file, into a value of type [T].
+ *
+ * Implementations of this interface must handle the conversion logic,
+ * taking into account that any missing/fill value is represented by a [Double.NaN].
+ *
+ * @param T the data type resulting from the conversion.
+ */
+fun interface MeasurementConverter : Serializable {
+
+ /**
+ * Converts the raw `Double` value to [T].
+ *
+ * @param value the numeric value to be converted.
+ * **Note:** it may be [Double.NaN] if it represents a missing/fill value.
+ * @return the converted value of type [T].
+ */
+ fun convert(value: Double): T
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/converter/MissingValueFallbackConverter.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/converter/MissingValueFallbackConverter.kt
new file mode 100644
index 0000000000..62d71f25da
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/converter/MissingValueFallbackConverter.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.converter
+
+/**
+ * A decorator for [MeasurementConverter] that handles missing/fill values,
+ * represented as [Double.NaN], by replacing them with a [defaultValue].
+ *
+ * @param delegate the inner [MeasurementConverter] used for valid numerical values.
+ * @param defaultValue the fallback value returned when encountering [Double.NaN].
+ * @param T the output type of the measurement conversion.
+ */
+class MissingValueFallbackConverter(private val delegate: MeasurementConverter, private val defaultValue: T) :
+ MeasurementConverter {
+
+ /**
+ * Converts the given [value].
+ *
+ * Returns [defaultValue] if [value] is [Double.NaN]; otherwise forwards
+ * the conversion to [delegate].
+ *
+ * @param value the raw input value to convert.
+ * @return the converted value of type [T], or [defaultValue] if the input was [Double.NaN].
+ */
+ override fun convert(value: Double): T = if (value.isNaN()) defaultValue else delegate.convert(value)
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatial/BilinearInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatial/BilinearInterpolation.kt
new file mode 100644
index 0000000000..ce336de0fd
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatial/BilinearInterpolation.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.spatial
+
+import it.unibo.alchemist.model.GeoPosition
+import it.unibo.alchemist.model.geospatial.reading.RasterGrid
+import it.unibo.alchemist.model.geospatial.utils.StatelessStrategy
+import it.unibo.alchemist.model.geospatial.utils.bracketIndices
+import it.unibo.alchemist.model.geospatial.utils.weight
+
+/**
+ * Bilinear interpolation over the 4 cells surrounding the point. If any of the 4 corners is
+ * missing, returns [Double.NaN].
+ */
+class BilinearInterpolation :
+ StatelessStrategy(),
+ SpatialInterpolation {
+
+ override fun valueAt(grid: RasterGrid, position: GeoPosition): Double {
+ val (lowerLatitudeIndex, upperLatitudeIndex) = bracketIndices(
+ grid.latitudes,
+ position.latitude,
+ )
+ val (lowerLongitudeIndex, upperLongitudeIndex) = bracketIndices(
+ grid.longitudes,
+ position.longitude,
+ )
+ /*
+ * axes are sorted in ascending order, so lower latitude index = south,
+ * lower longitude index = west.
+ */
+ val southWestValue = grid.valueAt(lowerLatitudeIndex, lowerLongitudeIndex)
+ val southEastValue = grid.valueAt(lowerLatitudeIndex, upperLongitudeIndex)
+ val northWestValue = grid.valueAt(upperLatitudeIndex, lowerLongitudeIndex)
+ val northEastValue = grid.valueAt(upperLatitudeIndex, upperLongitudeIndex)
+ // if any of the four points is missing, returns a missing value
+ val anyCornerMissing = southWestValue.isNaN() ||
+ southEastValue.isNaN() ||
+ northWestValue.isNaN() ||
+ northEastValue.isNaN()
+ if (anyCornerMissing) {
+ return Double.NaN
+ }
+ val longitudeWeight = weight(
+ grid.longitudes,
+ lowerLongitudeIndex,
+ upperLongitudeIndex,
+ position.longitude,
+ )
+ val latitudeWeight = weight(
+ grid.latitudes,
+ lowerLatitudeIndex,
+ upperLatitudeIndex,
+ position.latitude,
+ )
+ // interpolates along longitude first (one value per latitude row), then along latitude.
+ val southValue = southWestValue + (southEastValue - southWestValue) * longitudeWeight
+ val northValue = northWestValue + (northEastValue - northWestValue) * longitudeWeight
+ return southValue + (northValue - southValue) * latitudeWeight
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatial/NearestInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatial/NearestInterpolation.kt
new file mode 100644
index 0000000000..502f038378
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatial/NearestInterpolation.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.spatial
+
+import it.unibo.alchemist.model.GeoPosition
+import it.unibo.alchemist.model.geospatial.reading.RasterGrid
+import it.unibo.alchemist.model.geospatial.utils.StatelessStrategy
+import it.unibo.alchemist.model.geospatial.utils.nearestIndex
+
+/**
+ * Value of the nearest cell to the point.
+ */
+class NearestInterpolation :
+ StatelessStrategy(),
+ SpatialInterpolation {
+
+ override fun valueAt(grid: RasterGrid, position: GeoPosition): Double {
+ val nearestLatitudeIndex = nearestIndex(grid.latitudes, position.latitude)
+ val nearestLongitudeIndex = nearestIndex(grid.longitudes, position.longitude)
+ return grid.valueAt(nearestLatitudeIndex, nearestLongitudeIndex)
+ }
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatial/SpatialInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatial/SpatialInterpolation.kt
new file mode 100644
index 0000000000..8bb9e55e08
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatial/SpatialInterpolation.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.spatial
+
+import it.unibo.alchemist.model.GeoPosition
+import it.unibo.alchemist.model.geospatial.reading.RasterGrid
+import java.io.Serializable
+
+/**
+ * Strategy for **spatial interpolation**: given a [RasterGrid] and a position assumed to be *inside*
+ * its extent, produces a value by combining nearby cells.
+ */
+fun interface SpatialInterpolation : Serializable {
+
+ /**
+ * @param grid the slice to sample.
+ * @param position the requested geographical position, assumed to be inside of [grid].
+ * @return the interpolated value, or [Double.NaN] if the interpolation yields a missing value.
+ */
+ fun valueAt(grid: RasterGrid, position: GeoPosition): Double
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatiotemporal/SeparableSpatioTemporalInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatiotemporal/SeparableSpatioTemporalInterpolation.kt
new file mode 100644
index 0000000000..232f2b6c68
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatiotemporal/SeparableSpatioTemporalInterpolation.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.spatiotemporal
+
+import it.unibo.alchemist.model.GeoPosition
+import it.unibo.alchemist.model.geospatial.reading.RasterGrid
+import it.unibo.alchemist.model.geospatial.strategy.spatial.SpatialInterpolation
+import it.unibo.alchemist.model.geospatial.strategy.temporal.TemporalInterpolation
+
+/**
+ * A [SpatioTemporalInterpolation] that decouples the interpolation process
+ * into two independent steps.
+ *
+ * This strategy first resolves the spatial dimensions by applying the provided
+ * [spatialStrategy] independently to both the `gridBefore` and `gridAfter` slices.
+ * Then, it blends the two resulting values across the time dimension using the
+ * [temporalStrategy].
+ *
+ * This approach is modular, allowing any combination of spatial and temporal
+ * algorithms.
+ *
+ * **Warning!** Whether this decoupling preserves an exact mathematical equivalence
+ * to a "joint" spatio-temporal formula depends on the strategies plugged in.
+ *
+ * **Note on missing values:** If the [spatialStrategy] evaluates to [Double.NaN]
+ * for one or both grids, those `NaN` values are propagated to the [temporalStrategy],
+ * which is then strictly responsible for resolving or propagating the missing data.
+ *
+ * @property spatialStrategy the strategy used to interpolate the target position within a single time slice.
+ * @property temporalStrategy the strategy used to blend the spatially interpolated values across time.
+ */
+class SeparableSpatioTemporalInterpolation(
+ private val spatialStrategy: SpatialInterpolation,
+ private val temporalStrategy: TemporalInterpolation,
+) : SpatioTemporalInterpolation {
+
+ override fun interpolate(
+ position: GeoPosition,
+ gridBefore: RasterGrid,
+ gridAfter: RasterGrid,
+ timeWeight: Double,
+ ): Double {
+ val resolvedBeforeValue = spatialStrategy.valueAt(gridBefore, position)
+ val resolvedAfterValue = spatialStrategy.valueAt(gridAfter, position)
+ return temporalStrategy.interpolate(
+ resolvedBeforeValue,
+ resolvedAfterValue,
+ timeWeight,
+ )
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatiotemporal/SpatioTemporalInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatiotemporal/SpatioTemporalInterpolation.kt
new file mode 100644
index 0000000000..f73c0cd554
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatiotemporal/SpatioTemporalInterpolation.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.spatiotemporal
+
+import it.unibo.alchemist.model.GeoPosition
+import it.unibo.alchemist.model.geospatial.reading.RasterGrid
+import java.io.Serializable
+
+/**
+ * Strategy interface for performing combined spatio-temporal interpolation.
+ *
+ * Implementations estimate a value at a target [GeoPosition] and temporal state by combining
+ * spatial grid data from two bounding time slices.
+ *
+ * Missing or invalid data during calculation should be signaled by returning [Double.NaN].
+ */
+fun interface SpatioTemporalInterpolation : Serializable {
+
+ /**
+ * Interpolates a value at the specified geographic [position] and temporal offset.
+ *
+ * @param position the geographic target position to sample.
+ * @param gridBefore the temporal slice at or immediately preceding the target time.
+ * @param gridAfter the temporal slice immediately following the target time.
+ * @param timeWeight normalized temporal factor in the range `[0.0, 1.0]`, where `0.0`
+ * corresponds exactly to [gridBefore] and `1.0` to [gridAfter].
+ * @return the interpolated `Double` value, or [Double.NaN] if the interpolation
+ * produces a missing/fill value.
+ */
+ fun interpolate(position: GeoPosition, gridBefore: RasterGrid, gridAfter: RasterGrid, timeWeight: Double): Double
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatiotemporal/TrilinearInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatiotemporal/TrilinearInterpolation.kt
new file mode 100644
index 0000000000..2e613f0250
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/spatiotemporal/TrilinearInterpolation.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.spatiotemporal
+
+import it.unibo.alchemist.model.geospatial.strategy.spatial.BilinearInterpolation
+import it.unibo.alchemist.model.geospatial.strategy.temporal.LinearInterpolation
+import it.unibo.alchemist.model.geospatial.utils.StatelessStrategy
+
+/**
+ * A [SpatioTemporalInterpolation] strategy that performs a trilinear interpolation
+ * over the 8-point spatio-temporal cube bounding the requested position and time.
+ *
+ * Trilinear interpolation is separable: it is exactly equivalent to two bilinear spatial
+ * interpolations, blended linearly across time.
+ *
+ * If any of the 8 bounding corners is missing (represented as [Double.NaN]),
+ * the interpolation yields [Double.NaN].
+ */
+class TrilinearInterpolation :
+ StatelessStrategy(),
+ SpatioTemporalInterpolation by SeparableSpatioTemporalInterpolation(
+ BilinearInterpolation(),
+ LinearInterpolation(),
+ )
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/ClosestInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/ClosestInterpolation.kt
new file mode 100644
index 0000000000..4735e1aa19
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/ClosestInterpolation.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.temporal
+
+import it.unibo.alchemist.model.geospatial.utils.StatelessStrategy
+
+/**
+ * [TemporalInterpolation] that always returns the value from the closest temporal slice.
+ */
+class ClosestInterpolation :
+ StatelessStrategy(),
+ TemporalInterpolation {
+
+ override fun interpolate(valueBefore: Double, valueAfter: Double, weight: Double): Double =
+ if (weight < 0.5) valueBefore else valueAfter
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/LastInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/LastInterpolation.kt
new file mode 100644
index 0000000000..87762a8f45
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/LastInterpolation.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.temporal
+
+import it.unibo.alchemist.model.geospatial.utils.StatelessStrategy
+
+/**
+ * [TemporalInterpolation] that always resolves with the value of the last temporal slice.
+ */
+class LastInterpolation :
+ StatelessStrategy(),
+ TemporalInterpolation {
+
+ override fun interpolate(valueBefore: Double, valueAfter: Double, weight: Double): Double = valueBefore
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/LinearInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/LinearInterpolation.kt
new file mode 100644
index 0000000000..39cdb329f9
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/LinearInterpolation.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.temporal
+
+import it.unibo.alchemist.model.geospatial.utils.StatelessStrategy
+
+/**
+ * [TemporalInterpolation] that blends between two adjacent values.
+ */
+class LinearInterpolation :
+ StatelessStrategy(),
+ TemporalInterpolation {
+
+ override fun interpolate(valueBefore: Double, valueAfter: Double, weight: Double): Double = when {
+ weight == 0.0 -> valueBefore
+ weight == 1.0 -> valueAfter
+ valueBefore.isNaN() || valueAfter.isNaN() -> Double.NaN
+ else -> valueBefore + (valueAfter - valueBefore) * weight
+ }
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/NextInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/NextInterpolation.kt
new file mode 100644
index 0000000000..03f767fcb6
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/NextInterpolation.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.temporal
+
+import it.unibo.alchemist.model.geospatial.utils.StatelessStrategy
+
+/**
+ * [TemporalInterpolation] that always resolves with the value of the next temporal slice.
+ */
+class NextInterpolation :
+ StatelessStrategy(),
+ TemporalInterpolation {
+
+ override fun interpolate(valueBefore: Double, valueAfter: Double, weight: Double): Double = valueAfter
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/TemporalInterpolation.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/TemporalInterpolation.kt
new file mode 100644
index 0000000000..8022a373b7
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/strategy/temporal/TemporalInterpolation.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy.temporal
+
+import java.io.Serializable
+
+/**
+ * Strategy for **temporal interpolation**: given the two **already** spatially resolved values of the
+ * slices bracketing the current time, plus a normalized weight, produces the value at the current
+ * time.
+ */
+fun interface TemporalInterpolation : Serializable {
+
+ /**
+ * @param valueBefore spatially resolved value of the slice at or immediately before the current time.
+ * @param valueAfter spatially resolved value of the slice immediately after the current time.
+ * @param weight normalized position in `[0.0, 1.0]`: `0.0` coincides with [valueBefore], `1.0` with [valueAfter].
+ * @return the interpolated value.
+ */
+ fun interpolate(valueBefore: Double, valueAfter: Double, weight: Double): Double
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/utils/Axes.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/utils/Axes.kt
new file mode 100644
index 0000000000..304c3646a7
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/utils/Axes.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.utils
+
+import java.util.Arrays
+
+/*
+ * Index-finding helpers over a regular geographic axis (the latitudes or longitudes of a RasterGrid).
+ *
+ * The shared precondition for every function is: axis must be non-empty and sorted in
+ * STRICTLY ascending order with finite values (no duplicates, no NaN within the axis).
+ *
+ * The ascending order and the absence of Double.NaN within the axis are NOT verified, in order to
+ * achieve a O(log(n)) time complexity.
+ */
+
+/**
+ * Finds the index of the closest coordinate on the [axis] relative to the query [coordinate].
+ *
+ * If the [coordinate] lies outside the boundaries of the axis, the result is clamped to the edge
+ * (`0` if below the first coordinate, `axis.lastIndex` if above the last coordinate).
+ * Ties (when the coordinate is exactly halfway between two coordinates) resolve to the lower index.
+ *
+ * @param axis grid coordinates along one dimension, strictly ascending.
+ * @param coordinate the query coordinate in the same unit as [axis].
+ * @return a valid index into [axis], guaranteed to be within `0..axis.lastIndex`.
+ * @throws IllegalArgumentException if [axis] is empty or [coordinate] is NaN.
+ */
+internal fun nearestIndex(axis: DoubleArray, coordinate: Double): Int {
+ require(axis.isNotEmpty()) { "Axis cannot be empty." }
+ require(!coordinate.isNaN()) { "The query coordinate cannot be NaN." }
+ val binarySearchResult = Arrays.binarySearch(axis, coordinate)
+ /*
+ * binarySearch returns -(insertionPoint) - 1 when no match is found. Inverting the formula
+ * yields the insertion point: the index of the first node strictly greater than the coordinate.
+ */
+ val upperIndex = -binarySearchResult - 1
+ return when {
+ // exact match found: the coordinate aligns perfectly with a grid node.
+ binarySearchResult >= 0 -> binarySearchResult
+ upperIndex <= 0 -> 0
+ upperIndex >= axis.size -> axis.lastIndex
+ else -> {
+ // the coordinate is between axis[lowerIndex] and axis[upperIndex]
+ val lowerIndex = upperIndex - 1
+ val distanceToLower = coordinate - axis[lowerIndex]
+ val distanceToUpper = axis[upperIndex] - coordinate
+ if (distanceToLower <= distanceToUpper) lowerIndex else upperIndex
+ }
+ }
+}
+
+/**
+ * Finds the pair of indices `(lowerIndex, upperIndex)` that bracket the given [coordinate]
+ * on the [axis], such that `axis[lowerIndex] <= coordinate <= axis[upperIndex]`.
+ *
+ * The indices will be identical (`lowerIndex == upperIndex`) when the [coordinate] lands exactly
+ * on a node, or when it is outside the axis boundaries (both indices clamp to the same edge).
+ *
+ * @param axis grid coordinates along one dimension, strictly ascending.
+ * @param coordinate the query coordinate in the same unit as [axis].
+ * @return a [Pair] where `first` is the lower index and `second` is the upper index.
+ * @throws IllegalArgumentException if [axis] is empty or [coordinate] is NaN.
+ */
+internal fun bracketIndices(axis: DoubleArray, coordinate: Double): Pair {
+ require(axis.isNotEmpty()) { "Axis cannot be empty." }
+ require(!coordinate.isNaN()) { "The query coordinate cannot be NaN." }
+ val binarySearchResult = Arrays.binarySearch(axis, coordinate)
+ val upperIndex = -binarySearchResult - 1
+ return when {
+ binarySearchResult >= 0 -> binarySearchResult to binarySearchResult
+ upperIndex <= 0 -> 0 to 0
+ upperIndex >= axis.size -> axis.lastIndex to axis.lastIndex
+ else -> {
+ val lowerIndex = upperIndex - 1
+ lowerIndex to upperIndex
+ }
+ }
+}
+
+/**
+ * Computes the normalized position of [coordinate] within the segment
+ * `[axis[lowerIndex], axis[upperIndex]]`: `0.0` exactly at [lowerIndex], `1.0` exactly at
+ * [upperIndex]. Returns `0.0` when `lowerIndex == upperIndex`.
+ *
+ * @param axis grid coordinates along one dimension, strictly ascending.
+ * @param lowerIndex the lower boundary index, as returned by [bracketIndices].
+ * @param upperIndex the upper boundary index, as returned by [bracketIndices].
+ * @param coordinate the query coordinate, within `[axis[lowerIndex], axis[upperIndex]]`.
+ * @return the interpolation weight in `[0.0, 1.0]`, or `0.0` if the indices are equal.
+ */
+internal fun weight(axis: DoubleArray, lowerIndex: Int, upperIndex: Int, coordinate: Double): Double {
+ if (lowerIndex == upperIndex) return 0.0
+ val totalSpan = axis[upperIndex] - axis[lowerIndex]
+ val distanceFromLower = coordinate - axis[lowerIndex]
+ return distanceFromLower / totalSpan
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/utils/GridReading.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/utils/GridReading.kt
new file mode 100644
index 0000000000..5cb2ba9f67
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/utils/GridReading.kt
@@ -0,0 +1,336 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.utils
+
+import it.unibo.alchemist.model.geospatial.reading.ArrayRasterGrid
+import it.unibo.alchemist.model.geospatial.reading.MapRasterGrid
+import it.unibo.alchemist.model.geospatial.reading.RasterGrid
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.Formatter
+import ucar.ma2.Array as CdmArray
+import ucar.nc2.Variable
+import ucar.nc2.constants.AxisType
+import ucar.nc2.dataset.CoordinateAxis1D
+import ucar.nc2.dataset.CoordinateAxis1DTime
+import ucar.nc2.dataset.NetcdfDataset
+import ucar.nc2.dataset.NetcdfDatasets
+
+/*
+ * NetCDF/GRIB schema parsing based on NetCDF-Java.
+ * These functions know how to read and validate ONE file's grid schema and compare it
+ * to other files' schema.
+ */
+
+/**
+ * Below this fraction of non-missing cells, a grid is considered "sparse" (see [buildGrid]).
+ */
+private const val SPARSE_DENSITY_THRESHOLD = 0.1
+
+/**
+ * Parser needed by netCDF-Java to parse GRIB files.
+ */
+private const val SAX_PARSER_FACTORY_KEY = "javax.xml.parsers.SAXParserFactory"
+private const val JDK_XERCES_SAX_PARSER_FACTORY = "com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl"
+
+/**
+ * File types that netCDF-Java uses as indexes for GRIB files.
+ */
+private val netcdfJavaIndexSuffixes = setOf(".gbx9", ".ncx4")
+
+/**
+ * Opens [file] as a [NetcdfDataset], temporarily forcing the JDK's built-in
+ * [javax.xml.parsers.SAXParserFactory].
+ *
+ * NetCDF-Java's GRIB reader requires this specific factory; if another SAX parser is
+ * registered system-wide, GRIB parsing fails.
+ * The system property is restored to its previous value (or cleared) right after opening.
+ *
+ * @param file the NetCDF/GRIB file to open.
+ * @return the opened, enhanced [NetcdfDataset].
+ */
+internal fun openNetcdfDataset(file: Path): NetcdfDataset {
+ val previous = System.getProperty(SAX_PARSER_FACTORY_KEY)
+ System.setProperty(SAX_PARSER_FACTORY_KEY, JDK_XERCES_SAX_PARSER_FACTORY)
+ try {
+ return NetcdfDatasets.openDataset(file.toString())
+ } finally {
+ if (previous == null) {
+ System.clearProperty(SAX_PARSER_FACTORY_KEY)
+ } else {
+ System.setProperty(SAX_PARSER_FACTORY_KEY, previous)
+ }
+ }
+}
+
+/**
+ * Lists the data files contained in [directory], ignoring the indexes created by netCDF-Java,
+ * sorted for a deterministic processing order.
+ *
+ * @param directory the directory to scan.
+ * @return the sorted list of regular files in [directory].
+ * @throws IllegalArgumentException if [directory] contains no regular files.
+ */
+internal fun listDataFiles(directory: Path): List {
+ val files = Files.list(directory).use { stream ->
+ stream.filter { Files.isRegularFile(it) }
+ .filter { file -> netcdfJavaIndexSuffixes.none { suffix -> file.toString().endsWith(suffix) } }
+ .sorted()
+ .toList()
+ }
+ require(files.isNotEmpty()) { "No data files in $directory" }
+ return files
+}
+
+/**
+ * Reads and validates the schema of [file] from the already open [dataset]: locates its time,
+ * latitude, and longitude axes, normalizes latitude/longitude to ascending order, and resolves
+ * the data [Variable] to read (by [variableName], or by auto-detection, see [resolveVariable]).
+ *
+ * @param dataset the already open dataset.
+ * @param variableName explicit variable name, or `null` for auto-detection.
+ * @param file path of the file being read (used for error messages).
+ * @return the extracted, normalized [FileAxes].
+ * @throws IllegalArgumentException if any axis is missing or not 1D, if the time axis cannot be
+ * built, if the variable is missing/ambiguous, or if its dimensions are not `{time, lat, lon}`.
+ */
+internal fun readFileAxes(dataset: NetcdfDataset, variableName: String?, file: Path): FileAxes {
+ // ensures that all {time, lat, lon} axes are present
+ val rawTimeAxis = requireNotNull(
+ dataset.findCoordinateAxis(AxisType.Time) ?: dataset.findCoordinateAxis(AxisType.RunTime),
+ ) {
+ "No time axis in $file"
+ }
+ val latAxis = requireNotNull(dataset.findCoordinateAxis(AxisType.Lat) as? CoordinateAxis1D) {
+ "No 1D latitude axis in $file"
+ }
+ val lonAxis = requireNotNull(dataset.findCoordinateAxis(AxisType.Lon) as? CoordinateAxis1D) {
+ "No 1D longitude axis in $file"
+ }
+ val errMsg = Formatter()
+ // constructs a CF-aware time axis
+ val timeAxis = requireNotNull(CoordinateAxis1DTime.factory(dataset, rawTimeAxis, errMsg)) {
+ "Cannot build time axis in $file: $errMsg"
+ }
+ // normalizes latitude/longitude to ascending order
+ val (latitudes, latDescending) = latAxis.coordValues.ascendingWithDescendingFlag()
+ val (longitudes, lonDescending) = lonAxis.coordValues.ascendingWithDescendingFlag()
+ // derives the dimension names needed to find the variable and to validate its shape
+ val timeDimName = rawTimeAxis.dimensions.first().name
+ val latDimName = latAxis.dimensions.first().name
+ val lonDimName = lonAxis.dimensions.first().name
+ /*
+ * finds the variable to read, either by name or by auto-detection
+ * of the only variable whose dimensions are {time, lat, lon}.
+ */
+ val variable = resolveVariable(dataset, variableName, timeDimName, latDimName, lonDimName, file)
+ // verifies that the variable has exactly the three expected dimensions, in any order
+ val actualDims = variable.dimensions.map { it.name }
+ val expectedDims = setOf(timeDimName, latDimName, lonDimName)
+ require(actualDims.toSet() == expectedDims) {
+ "Variable '${variable.shortName}' in $file has dimensions $actualDims, " +
+ "but $expectedDims was expected (in any order)."
+ }
+ return FileAxes(
+ timeAxis = timeAxis,
+ latitudes = latitudes,
+ longitudes = longitudes,
+ latDescending = latDescending,
+ lonDescending = lonDescending,
+ variable = variable,
+ // position of each axis in the file
+ timePosition = actualDims.indexOf(timeDimName),
+ latPosition = actualDims.indexOf(latDimName),
+ lonPosition = actualDims.indexOf(lonDimName),
+ )
+}
+
+/**
+ * Reads the raw 2D slice at time index [t] from [axes], reordered to (time, lat, lon).
+ *
+ * @param axes the schema of the file being read.
+ * @param t the time index within [axes]'s time axis.
+ * @param nLat number of latitude coordinates.
+ * @param nLon number of longitude coordinates.
+ * @return the rearranged slice of shape (1, [nLat], [nLon]).
+ */
+internal fun readPermutedSlice(axes: FileAxes, t: Int, nLat: Int, nLon: Int): CdmArray {
+ // origin/shape are positional relative to the variable's own axis order in the file
+ val origin = IntArray(3).also { array ->
+ array[axes.timePosition] = t
+ array[axes.latPosition] = 0
+ array[axes.lonPosition] = 0
+ }
+ val shape = IntArray(3).also { array ->
+ array[axes.timePosition] = 1
+ array[axes.latPosition] = nLat
+ array[axes.lonPosition] = nLon
+ }
+ // reorders the read slice to (time, lat, lon)
+ return axes.variable
+ .read(origin, shape)
+ .permute(intArrayOf(axes.timePosition, axes.latPosition, axes.lonPosition))
+ .copy()
+}
+
+/**
+ * Flattens a **(time, lat, lon)-ordered** [slice] of shape (1, [nLat], [nLon]) into a
+ * row-major [DoubleArray] with ascending latitude/longitude, reversing axes that are descending.
+ *
+ * @param slice the reordered slice (see [readPermutedSlice]).
+ * @param nLat number of latitude coordinates.
+ * @param nLon number of longitude coordinates.
+ * @param latDescending `true` if the latitude axis is descending.
+ * @param lonDescending `true` if the longitude axis is descending.
+ * @return a [DoubleArray] representing the values in row-major order.
+ */
+internal fun flattenAscending(
+ slice: CdmArray,
+ nLat: Int,
+ nLon: Int,
+ latDescending: Boolean,
+ lonDescending: Boolean,
+): DoubleArray = DoubleArray(nLat * nLon).also { arr ->
+ for (idx in arr.indices) {
+ val iLat = idx / nLon
+ val iLon = idx % nLon
+ val srcLat = if (latDescending) nLat - 1 - iLat else iLat
+ val srcLon = if (lonDescending) nLon - 1 - iLon else iLon
+ arr[idx] = slice.getDouble(srcLat * nLon + srcLon)
+ }
+}
+
+/**
+ * Chooses the most memory-efficient [RasterGrid] representation for [measurements], based on the
+ * fraction of non-missing cells: below [SPARSE_DENSITY_THRESHOLD], a sparse [MapRasterGrid] is
+ * used; otherwise, a dense [ArrayRasterGrid].
+ *
+ * @param latitudes the grid latitudes.
+ * @param longitudes the grid longitudes.
+ * @param measurements cell values in row-major order; [Double.NaN] denotes missing/fill values.
+ * @return the constructed [RasterGrid].
+ */
+internal fun buildGrid(latitudes: DoubleArray, longitudes: DoubleArray, measurements: DoubleArray): RasterGrid {
+ val density = measurements.count { !it.isNaN() }.toDouble() / measurements.size
+ return if (density < SPARSE_DENSITY_THRESHOLD) {
+ MapRasterGrid(latitudes, longitudes, measurements)
+ } else {
+ ArrayRasterGrid(latitudes, longitudes, measurements)
+ }
+}
+
+/**
+ * Selects the variable to read from the dataset.
+ *
+ * If [name] is provided, looks it up by short name (the name as it appears in the file,
+ * not the store catalogue name). Otherwise, auto-detects the unique 3D variable
+ * whose dimensions match {[timeDimName], [latDimName], [lonDimName]}.
+ * Coordinate axis variables (latitude, longitude, time themselves) are 1D and are therefore excluded
+ * automatically.
+ *
+ * @param ds the open enhanced dataset.
+ * @param name explicit variable name, or null for auto-detection.
+ * @param timeDimName name of the time dimension (from the time axis).
+ * @param latDimName name of the latitude dimension (from the latitude axis).
+ * @param lonDimName name of the longitude dimension (from the longitude axis).
+ * @param file path of the file being read (used for error messages).
+ * @return the selected [Variable].
+ * @throws IllegalArgumentException if the named [Variable] is not found, or if
+ * auto-detection finds zero or more than one candidate.
+ */
+internal fun resolveVariable(
+ ds: NetcdfDataset,
+ name: String?,
+ timeDimName: String,
+ latDimName: String,
+ lonDimName: String,
+ file: Path,
+): Variable {
+ if (name != null) {
+ return requireNotNull(ds.findVariable(name)) {
+ "Variable '$name' not found in $file. " +
+ "Available: ${ds.variables.map { it.shortName }}"
+ }
+ }
+ val targetDims = setOf(timeDimName, latDimName, lonDimName)
+ // 3D variables matching {latitude, longitude, time}
+ val candidates = ds.variables.filter { v ->
+ v.dimensions.size == 3 &&
+ v.dimensions.map { it.name }.toSet() == targetDims
+ }
+ require(candidates.isNotEmpty()) {
+ "No variable with dimensions $targetDims found in $file"
+ }
+ require(candidates.size == 1) {
+ "Multiple candidate variables with dimensions $targetDims in $file. " +
+ "The variables that can be used are: ${candidates.map { it.shortName }}. Specify the variable explicitly."
+ }
+ return candidates.single()
+}
+
+/**
+ * The file schema extracted by [readFileAxes]: the file's time axis, its normalized spatial
+ * axes, the resolved data [variable], and the positions of the time/lat/lon dimensions within it.
+ */
+internal class FileAxes(
+ val timeAxis: CoordinateAxis1DTime,
+ val latitudes: DoubleArray,
+ val longitudes: DoubleArray,
+ val latDescending: Boolean,
+ val lonDescending: Boolean,
+ val variable: Variable,
+ val timePosition: Int,
+ val latPosition: Int,
+ val lonPosition: Int,
+)
+
+/**
+ * Represents the spatial grid and variable established by the FIRST file of a homogeneous series (of a dataset).
+ *
+ * Used to validate that every subsequent file is both spatially and semantically consistent with
+ * the first one.
+ */
+internal class ReferenceGrid(axes: FileAxes) {
+
+ private val latitudes = axes.latitudes
+ private val longitudes = axes.longitudes
+ private val variableName = axes.variable.shortName
+
+ /**
+ * @param axes the schema extracted from another file.
+ * @param file the path of that file (used for error messages).
+ * @param directory the directory where the file is located (used for error messages).
+ * @throws IllegalArgumentException if [axes] does not share this reference's spatial grid
+ * or resolved variable name.
+ */
+ fun requireMatches(axes: FileAxes, file: Path, directory: Path) {
+ // files following the first one must have the same spatial coordinates
+ require(axes.latitudes.contentEquals(latitudes)) {
+ "Latitude axes differ in $file from the others"
+ }
+ require(axes.longitudes.contentEquals(longitudes)) {
+ "Longitude axes differ in $file from the others"
+ }
+ // subsequent files must resolve to the same variable
+ require(axes.variable.shortName == variableName) {
+ "Variable name differs across files: '$variableName' vs '${axes.variable.shortName}' " +
+ "in $file. All files in $directory must contain the same variable."
+ }
+ }
+}
+
+/**
+ * Returns this array sorted in ascending order, with whether it was originally
+ * descending.
+ */
+private fun DoubleArray.ascendingWithDescendingFlag(): Pair {
+ val descending = first() > last()
+ return (if (descending) reversedArray() else this) to descending
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/utils/StatelessStrategy.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/utils/StatelessStrategy.kt
new file mode 100644
index 0000000000..4ffaa13d4c
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/geospatial/utils/StatelessStrategy.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.utils
+
+/**
+ * Base for spatial/temporal/spatio-temporal strategies that hold **no configuration state**.
+ *
+ * Provides [equals] and [hashCode] based solely on the type: two instances of the
+ * same stateless strategy are always equal, since they behave identically regardless of how
+ * or when they were constructed.
+ *
+ * Only for strategies with **no** primary-constructor parameters. A strategy that carries
+ * actual configuration should be a `data class` instead, so equality reflects its configuration,
+ * not just its type.
+ */
+open class StatelessStrategy protected constructor() {
+
+ override fun equals(other: Any?): Boolean = other != null && this::class == other::class
+
+ override fun hashCode(): Int = this::class.hashCode()
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/layers/CopernicusLayer.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/layers/CopernicusLayer.kt
new file mode 100644
index 0000000000..fa964dfa6e
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/layers/CopernicusLayer.kt
@@ -0,0 +1,332 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.layers
+
+import it.unibo.alchemist.boundary.acquisition.CopernicusCacheManager
+import it.unibo.alchemist.boundary.acquisition.CopernicusDataStoreProvider
+import it.unibo.alchemist.boundary.acquisition.CopernicusRequest
+import it.unibo.alchemist.boundary.utils.CdsApiRc
+import it.unibo.alchemist.boundary.utils.CopernicusInputs
+import it.unibo.alchemist.model.Environment
+import it.unibo.alchemist.model.GeoPosition
+import it.unibo.alchemist.model.geospatial.reading.EagerGridSnapshots
+import it.unibo.alchemist.model.geospatial.reading.GridSnapshots
+import it.unibo.alchemist.model.geospatial.reading.RasterGrid
+import it.unibo.alchemist.model.geospatial.strategy.converter.MeasurementConverter
+import it.unibo.alchemist.model.geospatial.strategy.spatiotemporal.SpatioTemporalInterpolation
+import it.unibo.alchemist.model.geospatial.utils.bracketIndices
+import it.unibo.alchemist.model.geospatial.utils.weight
+import java.nio.file.Path
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Instant
+
+/**
+ * A [GeoLayer] that exposes a value of type [T] for any [GeoPosition] as a function
+ * of the current simulation time, backed by temporal NetCDF/GRIB data.
+ * On [getValue], the layer knows the perceived simulation time at that moment via
+ * [Environment.simulationOrNull].
+ *
+ * On [getValue], the two time slices enclosing the current time are located and handed to
+ * [interpolation], which resolves them both spatially and temporally; the resulting `Double`
+ * (possibly [Double.NaN]) is mapped to [T] by [converter]. Outside the covered time range the
+ * value of the first or last slice is returned; outside the covered **spatial** extent
+ * no extrapolation is performed and [getValue] fails.
+ *
+ * Once the construction is complete, real-world time [Instant]s are converted proportionally to
+ * simulation time using [timeOrigin] and [timeScale].
+ *
+ * Three constructors are available:
+ * - **Primary**: accepts a ready-built [GridSnapshots]; it is used in tests.
+ * - **Directory** (YAML): takes the path of a local directory of data files. No network, no cache,
+ * no credentials. Selected from YAML by providing `dataDirectory`.
+ * - **Datastore** (YAML): takes a Copernicus-family endpoint plus an opaque request, retrieves the
+ * data through a local cache manager, then reads it. The only one that touches the network. Selected from
+ * YAML by providing `endpoint`, `dataset` and `inputsFile`.
+ *
+ * @property environment simulation environment. Used only to read the current time via [Environment.simulationOrNull].
+ * @property data temporal raster series backing this layer.
+ * @param timeOrigin real-world [Instant] corresponding to simulation `Time.ZERO`.
+ * Defaults to the first instant in [data].
+ * @param timeScale real-world [Duration] of one simulation time unit. Defaults to one hour.
+ * @param interpolation strategy for generating a value from the spatio-temporal time slices.
+ * @param converter strategy for converting the interpolated [Double] (or [Double.NaN] if missing) into the
+ * target type [T].
+ * @param T the type of data generated by this layer.
+ * @throws IllegalArgumentException if [data] is empty; if [timeScale] is negative or infinity.
+ */
+open class CopernicusLayer(
+ private val environment: Environment<*, GeoPosition>,
+ private val data: GridSnapshots,
+ private val timeScale: Duration = DEFAULT_TIME_SCALE,
+ private val timeOrigin: Instant? = null,
+ private val interpolation: SpatioTemporalInterpolation,
+ private val converter: MeasurementConverter,
+) : GeoLayer {
+
+ init {
+ require(data.instants.isNotEmpty()) { "GridSnapshots is empty." }
+ require(timeScale.isPositive() && timeScale.isFinite()) { "timeScale must hold a finite positive value." }
+ }
+
+ /**
+ * Real-world instant corresponding to simulation `Time.ZERO`.
+ * Equals `timeOrigin` if provided, otherwise the first instant in [data].
+ */
+ private val origin: Instant = timeOrigin ?: data.instants.first()
+
+ /**
+ * Conversion of real-world time instants to simulation time (as an array of `Double`).
+ * The conversion occurs only during construction and takes into account the `origin`
+ * and the `timeScale`.
+ */
+ private val sliceTimes: DoubleArray = data.instants
+ .map { toSimulationTime(it, origin, timeScale) }
+ .toDoubleArray()
+
+ /**
+ * Constructs a [CopernicusLayer] reading a **local directory** of already-available data files:
+ * no network I/O, no cache, no credentials.
+ *
+ * @param dataDirectory absolute path of a directory holding one or more homogeneous data files (same
+ * variable and spatial grid).
+ * @param timeScale real-world duration of one simulation time unit, as an ISO-8601 duration (e.g. `"PT6H"`).
+ * @param timeOrigin real-world instant mapping to simulation time `0.0`, as an ISO-8601 instant
+ * (e.g. `"2024-06-10T00:00:00Z"`). The first instant found in the data is used when `null`.
+ * @param variable variable name inside the file (e.g. `"dis24"`, the GRIB shortName, not the
+ * catalogue name). Auto-detected as the unique `(time, lat, lon)` variable when `null`.
+ * @param interpolation strategy for spatio-temporal evaluation.
+ * @param converter defines how read [Double] values are converted into [T].
+ * @throws IllegalArgumentException if [timeScale] or [timeOrigin] are not valid ISO-8601
+ * strings; if [timeScale] is negative or infinity; if [dataDirectory] does not hold readable,
+ * homogeneous data files or if the path is not correct.
+ */
+ constructor(
+ environment: Environment<*, GeoPosition>,
+ dataDirectory: String,
+ timeScale: String = DEFAULT_TIME_SCALE_ISO,
+ timeOrigin: String? = null,
+ variable: String? = null,
+ interpolation: SpatioTemporalInterpolation,
+ converter: MeasurementConverter,
+ ) : this(
+ environment,
+ EagerGridSnapshots(Path.of(dataDirectory), variable),
+ Duration.parseIsoString(timeScale),
+ timeOrigin?.let(Instant::parse),
+ interpolation,
+ converter,
+ )
+
+ /**
+ * Constructs a [CopernicusLayer] that fetches (and locally caches) data from a
+ * Copernicus-family datastore (CDS / EWDS / ADS) identified by [endpoint], authenticating with
+ * the token found in [cdsApiRcFile].
+ *
+ * The cache is consulted **synchronously at construction**: on a cache hit no network call and
+ * no credential is needed, on a cache miss this performs blocking network I/O before the layer
+ * becomes usable.
+ *
+ * @param endpoint base URL of the datastore (e.g. `"https://ewds.climate.copernicus.eu/api"`).
+ * @param dataset dataset identifier (e.g. `"cems-glofas-historical"`).
+ * @param inputsFile absolute path of a JSON file holding the opaque request map for [dataset] at
+ * [endpoint] (variables, dates, area, type, ...), passed verbatim to the datastore.
+ * @param checkMd5 whether to check the MD5 digest of the downloaded asset.
+ * Sometimes Copernicus stores return the correct requested assets but report an incorrect MD5,
+ * so it may be useful to disable this check.
+ * @param timeScale real-world duration of one simulation time unit, as an ISO-8601 duration.
+ * @param timeOrigin real-world instant mapping to simulation time `0.0`, as an ISO-8601
+ * instant. The first instant found in the data is used when `null`.
+ * @param variable variable name inside the downloaded file. Auto-detected when `null`.
+ * @param cacheDirectory asbolute path to the root of the local cache.
+ * @param cdsApiRcFile absolute path of a `.cdsapirc`-formatted file holding the API token.
+ * @param interpolation strategy for spatio-temporal evaluation.
+ * @param converter defines how read [Double] values are converted into [T].
+ * @throws IllegalArgumentException if [timeScale] or [timeOrigin] are not valid ISO-8601
+ * strings; if [timeScale] is negative or infinity; if [endpoint] is not a valid URL; if any
+ * path is malformed.
+ * @throws IllegalStateException if the token cannot be read; if the remote job fails or times
+ * out; if the downloaded asset fails its integrity check.
+ */
+ constructor(
+ environment: Environment<*, GeoPosition>,
+ endpoint: String,
+ dataset: String,
+ inputsFile: String,
+ checkMd5: Boolean,
+ timeScale: String = DEFAULT_TIME_SCALE_ISO,
+ timeOrigin: String? = null,
+ variable: String? = null,
+ cacheDirectory: String = DEFAULT_CACHE_DIRECTORY,
+ cdsApiRcFile: String = DEFAULT_CDSAPIRC_FILE,
+ interpolation: SpatioTemporalInterpolation,
+ converter: MeasurementConverter,
+ ) : this(
+ environment,
+ EagerGridSnapshots(
+ resolveDataDirectory(
+ endpoint,
+ dataset,
+ checkMd5,
+ Path.of(inputsFile),
+ Path.of(cacheDirectory),
+ Path.of(cdsApiRcFile),
+ ),
+ variable,
+ ),
+ Duration.parseIsoString(timeScale),
+ timeOrigin?.let(Instant::parse),
+ interpolation,
+ converter,
+ )
+
+ /**
+ * Reads the simulation time, finds the adjacent time slices using [bracketIndices],
+ * and applies the spatio-temporal [interpolation] strategy.
+ * If the simulation time falls outside the calculated simulation time range,
+ * the returned value will refer to the first or last temporal slice.
+ *
+ * @param position the geographic position to query.
+ * @return the interpolated or extrapolated value converted to [T].
+ * @throws IllegalArgumentException if the given [position] is outside the spatial extent.
+ */
+ override fun getValue(position: GeoPosition): T {
+ val t = environment.simulationOrNull?.time?.toDouble() ?: 0.0
+ return when {
+ /*
+ * the simulation time is outside the calculated simulation time range.
+ * Returns the first or the last spatially resolved value.
+ */
+ t < sliceTimes.first() -> sampleExactSlice(position, 0)
+ t > sliceTimes.last() -> sampleExactSlice(position, sliceTimes.lastIndex)
+ else -> {
+ // the indices of the spatial slices that enclose time t.
+ val (gridIndexBefore, gridIndexAfter) = bracketIndices(sliceTimes, t)
+ if (gridIndexBefore == gridIndexAfter) {
+ // the time t falls exactly on a slice.
+ sampleExactSlice(position, gridIndexBefore)
+ } else {
+ /*
+ * the time t lies between two distinct slices.
+ * Applies the spatio-temporal interpolation strategy.
+ */
+ val timeWeight = weight(
+ sliceTimes,
+ gridIndexBefore,
+ gridIndexAfter,
+ t,
+ )
+ sample(position, gridIndexBefore, gridIndexAfter, timeWeight)
+ }
+ }
+ }
+ }
+
+ /**
+ * Samples the underlying grids, delegates the math to the [interpolation] strategy,
+ * and directly passes the output to the [converter].
+ */
+ private fun sample(position: GeoPosition, gridBeforeIndex: Int, gridAfterIndex: Int, timeWeight: Double): T {
+ val gridBefore = data.grid(gridBeforeIndex)
+ val gridAfter = data.grid(gridAfterIndex)
+ val latitudes = gridBefore.latitudes
+ val longitudes = gridBefore.longitudes
+ val inBounds = position.latitude in latitudes.first()..latitudes.last() &&
+ position.longitude in longitudes.first()..longitudes.last()
+ require(inBounds) { outOfBoundsMessage(position, gridBefore) }
+ /*
+ * The interpolation returns a Double, which might be NaN.
+ * The converter is fully responsible for mapping it to T.
+ */
+ return converter.convert(interpolation.interpolate(position, gridBefore, gridAfter, timeWeight))
+ }
+
+ /**
+ * Helper method to sample a single grid when the simulation time matches exactly
+ * one slice.
+ */
+ private fun sampleExactSlice(position: GeoPosition, gridIndex: Int): T = sample(
+ position,
+ gridIndex,
+ gridIndex,
+ 0.0,
+ )
+
+ /**
+ * Default values and helper factory logic for [CopernicusLayer].
+ */
+ companion object {
+ private const val serialVersionUID = 1L
+
+ private val USER_HOME: String = System.getProperty("user.home")
+
+ /**
+ * Default root of the local cache, used by the datastore costructor.
+ */
+ internal val DEFAULT_CACHE_DIRECTORY = "$USER_HOME/.alchemist/cache/geospatial"
+
+ /**
+ * Default location of the file holding the datastore API token.
+ */
+ internal val DEFAULT_CDSAPIRC_FILE = "$USER_HOME/.cdsapirc"
+
+ /**
+ * Default real-world duration of one simulation time unit, as a [Duration]
+ * and as a ISO-8601 string.
+ */
+ internal val DEFAULT_TIME_SCALE: Duration = 1.hours
+ internal val DEFAULT_TIME_SCALE_ISO: String = DEFAULT_TIME_SCALE.toIsoString()
+
+ /**
+ * Ensures the data denoted by `(dataset, inputs)` is present in the local cache rooted at
+ * [cacheDirectoryRoot], downloading it from [endpoint] on a cache miss, and returns the
+ * directory it lives in. The token is read from [cdsApiRcFile] lazily, so a cache hit needs
+ * no credentials.
+ *
+ * @return the directory holding the data files, ready to be opened.
+ * @throws IllegalStateException if the data cannot be produced.
+ */
+ private fun resolveDataDirectory(
+ endpoint: String,
+ dataset: String,
+ checkMd5: Boolean,
+ inputsFile: Path,
+ cacheDirectoryRoot: Path,
+ cdsApiRcFile: Path,
+ ): Path {
+ val provider = CopernicusDataStoreProvider(endpoint, checkMd5) { CdsApiRc.readToken(cdsApiRcFile) }
+ return CopernicusCacheManager(provider, cacheDirectoryRoot)
+ .getOrProduce(CopernicusRequest(dataset, CopernicusInputs.read(inputsFile)))
+ }
+ }
+}
+
+/**
+ * Converts a real-world [Instant] to a simulation time [Double].
+ *
+ * @param instant timestamp to convert.
+ * @param origin the instant that maps to `0.0` in simulation time.
+ * @param scale duration of one simulation time unit.
+ */
+private fun toSimulationTime(instant: Instant, origin: Instant, scale: Duration): Double = (instant - origin) / scale
+
+/**
+ * Builds the failure message for a [position] falling outside the extent of [grid].
+ *
+ * @param position the out-of-bounds [GeoPosition].
+ * @param grid the grid whose extent was exceeded.
+ */
+private fun outOfBoundsMessage(position: GeoPosition, grid: RasterGrid): String {
+ val latitudes = grid.latitudes
+ val longitudes = grid.longitudes
+ return "GeoPosition out of bounds: requested (lat: ${position.latitude}, lon: ${position.longitude}) " +
+ "but the bounding box is lat: [${latitudes.first()}, ${latitudes.last()}], " +
+ "lon: [${longitudes.first()}, ${longitudes.last()}]"
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/layers/DoubleCopernicusLayer.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/layers/DoubleCopernicusLayer.kt
new file mode 100644
index 0000000000..c7087352c6
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/layers/DoubleCopernicusLayer.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.layers
+
+import it.unibo.alchemist.model.Environment
+import it.unibo.alchemist.model.GeoPosition
+import it.unibo.alchemist.model.geospatial.reading.GridSnapshots
+import it.unibo.alchemist.model.geospatial.strategy.converter.DoubleIdentityWithFallback
+import it.unibo.alchemist.model.geospatial.strategy.converter.MeasurementConverter
+import it.unibo.alchemist.model.geospatial.strategy.spatiotemporal.SpatioTemporalInterpolation
+import it.unibo.alchemist.model.geospatial.strategy.spatiotemporal.TrilinearInterpolation
+import kotlin.time.Duration
+import kotlin.time.Instant
+
+/**
+ * A specialized version of [CopernicusLayer] configured to yield `Double` values.
+ *
+ * This class mirrors the constructors of its superclass but provides default
+ * strategies: it uses [TrilinearInterpolation] for spatial and temporal interpolation,
+ * and [DoubleIdentityWithFallback] to convert measurements into `Double`.
+ *
+ * For detailed descriptions of the common parameters (such as `environment`, `timeScale`,
+ * `endpoint`, and configuration files), refer to the documentation of [CopernicusLayer].
+ *
+ * @see CopernicusLayer
+ */
+class DoubleCopernicusLayer : CopernicusLayer {
+
+ constructor(
+ environment: Environment<*, GeoPosition>,
+ data: GridSnapshots,
+ timeScale: Duration = DEFAULT_TIME_SCALE,
+ timeOrigin: Instant? = null,
+ interpolation: SpatioTemporalInterpolation = TrilinearInterpolation(),
+ converter: MeasurementConverter = DoubleIdentityWithFallback(),
+ ) : super(
+ environment,
+ data,
+ timeScale,
+ timeOrigin,
+ interpolation,
+ converter,
+ )
+
+ @JvmOverloads
+ constructor(
+ environment: Environment<*, GeoPosition>,
+ dataDirectory: String,
+ timeScale: String = DEFAULT_TIME_SCALE_ISO,
+ timeOrigin: String? = null,
+ variable: String? = null,
+ interpolation: SpatioTemporalInterpolation = TrilinearInterpolation(),
+ converter: MeasurementConverter = DoubleIdentityWithFallback(),
+ ) : super(
+ environment,
+ dataDirectory,
+ timeScale,
+ timeOrigin,
+ variable,
+ interpolation,
+ converter,
+ )
+
+ @JvmOverloads
+ constructor(
+ environment: Environment<*, GeoPosition>,
+ endpoint: String,
+ dataset: String,
+ inputsFile: String,
+ checkMd5: Boolean,
+ timeScale: String = DEFAULT_TIME_SCALE_ISO,
+ timeOrigin: String? = null,
+ variable: String? = null,
+ cacheDirectory: String = DEFAULT_CACHE_DIRECTORY,
+ cdsApiRcFile: String = DEFAULT_CDSAPIRC_FILE,
+ interpolation: SpatioTemporalInterpolation = TrilinearInterpolation(),
+ converter: MeasurementConverter = DoubleIdentityWithFallback(),
+ ) : super(
+ environment,
+ endpoint,
+ dataset,
+ inputsFile,
+ checkMd5,
+ timeScale,
+ timeOrigin,
+ variable,
+ cacheDirectory,
+ cdsApiRcFile,
+ interpolation,
+ converter,
+ )
+
+ private companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/layers/GeoLayer.kt b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/layers/GeoLayer.kt
new file mode 100644
index 0000000000..30cccb4407
--- /dev/null
+++ b/alchemist-geospatial/src/main/kotlin/it/unibo/alchemist/model/layers/GeoLayer.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.layers
+
+import it.unibo.alchemist.model.GeoPosition
+import it.unibo.alchemist.model.Layer
+
+/**
+ * Specialization of [Layer] where spatial positions are represented as [GeoPosition]s.
+ *
+ * @param T the type of value measuring the substance or molecule at a given geographic point.
+ */
+fun interface GeoLayer : Layer
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/TestUtils.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/TestUtils.kt
new file mode 100644
index 0000000000..9863f5b947
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/TestUtils.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist
+
+import io.mockk.every
+import io.mockk.mockk
+import it.unibo.alchemist.model.GeoPosition
+import java.nio.file.Path
+import ucar.ma2.Array as UcarArray
+import ucar.ma2.ArrayDouble
+import ucar.ma2.DataType
+import ucar.nc2.Attribute
+import ucar.nc2.write.NetcdfFormatWriter
+
+/**
+ * Writes a simple NetCDF file that is CF compliant.
+ *
+ * The file contains:
+ * - a `time` axis with units "hours since [timeEpoch]" and values [timeHours].
+ * - a `latitude` axis (CF axis Y) with values [lats].
+ * - a `longitude` axis (CF axis X) with values [lons].
+ * - one data variable for every entry in [variables], all sharing the dimensions declared in
+ * [dimensionOrder], each with its own values and `_FillValue`.
+ *
+ * @param path destination file (the parent directory must already exist).
+ * @param lats latitude values in degrees, stored in the file as-is.
+ * @param lons longitude values in degrees, stored in the file as-is.
+ * @param timeHours time offsets in hours from [timeEpoch], one value per time step.
+ * @param variables list of data variables to write. Defaults to a single variable named `"dis24"`.
+ * @param timeEpoch reference date for the CF `units` attribute, in the format
+ * `"yyyy-MM-dd HH:mm"` (e.g. `"2024-01-01 00:00"`). Values in [timeHours] are interpreted
+ * as hours elapsed since this date.
+ * @param dimensionOrder order of the data variables' three dimensions, using the
+ * names `"time"`, `"latitude"`, `"longitude"`. Default to this specific order.
+ * @throws IllegalArgumentException if [variables] is empty.
+ */
+internal fun writeTestNetcdf(
+ path: Path,
+ lats: DoubleArray,
+ lons: DoubleArray,
+ timeHours: DoubleArray,
+ variables: List = listOf(TestVariable("dis24")),
+ timeEpoch: String = "2024-01-01 00:00",
+ dimensionOrder: List = listOf("time", "latitude", "longitude"),
+) {
+ require(variables.isNotEmpty()) { "variables must not be empty" }
+ // defines the schema on the builder
+ val builder = NetcdfFormatWriter.createNewNetcdf3(path.toString())
+ builder.addDimension("time", timeHours.size)
+ builder.addDimension("latitude", lats.size)
+ builder.addDimension("longitude", lons.size)
+ // CF compliant axis
+ builder.addVariable("time", DataType.DOUBLE, "time").apply {
+ addAttribute(Attribute("units", "hours since $timeEpoch"))
+ addAttribute(Attribute("calendar", "standard"))
+ addAttribute(Attribute("axis", "T"))
+ }
+ builder.addVariable("latitude", DataType.DOUBLE, "latitude").apply {
+ addAttribute(Attribute("units", "degrees_north"))
+ addAttribute(Attribute("axis", "Y"))
+ }
+ builder.addVariable("longitude", DataType.DOUBLE, "longitude").apply {
+ addAttribute(Attribute("units", "degrees_east"))
+ addAttribute(Attribute("axis", "X"))
+ }
+ // each data variable's dimensions must follow the same order
+ variables.forEach { variable ->
+ builder.addVariable(variable.name, DataType.DOUBLE, dimensionOrder.joinToString(" "))
+ .addAttribute(Attribute("_FillValue", variable.fillValue))
+ }
+ // creates the file and enters write mode
+ builder.build().use { writer ->
+ val timeArr = ArrayDouble.D1(timeHours.size)
+ timeHours.forEachIndexed { i, v -> timeArr.set(i, v) }
+ writer.write("time", timeArr)
+ val latArr = ArrayDouble.D1(lats.size)
+ lats.forEachIndexed { i, v -> latArr.set(i, v) }
+ writer.write("latitude", latArr)
+ val lonArr = ArrayDouble.D1(lons.size)
+ lons.forEachIndexed { i, v -> lonArr.set(i, v) }
+ writer.write("longitude", lonArr)
+ val dimSize = mapOf("time" to timeHours.size, "latitude" to lats.size, "longitude" to lons.size)
+ val shape = dimensionOrder.map { dimSize.getValue(it) }.toIntArray()
+ variables.forEach { variable ->
+ val arr = variable.rawValues ?: DoubleArray(timeHours.size * lats.size * lons.size) { idx ->
+ val dimCoord = dimensionOrder.zip(unravel(idx, shape).toList()).toMap()
+ val iLat = dimCoord.getValue("latitude")
+ val iLon = dimCoord.getValue("longitude")
+ // default cell value if not specified
+ (iLat * 10 + iLon).toDouble()
+ }
+ writer.write(variable.name, UcarArray.factory(DataType.DOUBLE, shape, arr))
+ }
+ }
+}
+
+/**
+ * Converts a row-major index into the dimension indices for the given [shape].
+ */
+private fun unravel(index: Int, shape: IntArray): IntArray {
+ val result = IntArray(shape.size)
+ var remainder = index
+
+ for (dim in shape.indices.reversed()) {
+ result[dim] = remainder % shape[dim]
+ remainder /= shape[dim]
+ }
+ return result
+}
+
+/**
+ * Reads the content of the JSON Copernicus responses.
+ * JSON files must be placed in `src/test/resources/copernicus-responses/`.
+ *
+ * @param fileName the name of the file (e.g, "name.json").
+ * @param cls the class used to resolve the path to the file.
+ * @return the JSON file content as a string.
+ */
+internal fun loadJsonCopernicusResponse(fileName: String, cls: Class): String = checkNotNull(
+ cls.getResourceAsStream("/copernicus-responses/$fileName"),
+) { "Missing test fixture: $fileName" }.bufferedReader().use { it.readText() }
+
+/**
+ * [GeoPosition] mock used in test cases.
+ */
+internal fun mockGeoPosition(lat: Double, long: Double): GeoPosition = mockk {
+ every { latitude } returns lat
+ every { longitude } returns long
+}
+
+/**
+ * A single data variable to write into a test NetCDF file.
+ *
+ * @param name short name of the variable as it appears in the file. Defaults to "var".
+ * @param rawValues flat array in row-major order representing the cell values, or
+ * `null` to use the default pattern `iLat * 10 + iLon` (independent of time). Its
+ * size must equal `timeHours.size * lats.size * lons.size` (if provided).
+ * @param fillValue this variable's `_FillValue` attribute encoded value.
+ */
+internal class TestVariable(
+ val name: String = "var",
+ val rawValues: DoubleArray? = null,
+ val fillValue: Double = -9999.0,
+)
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/FakeHttpServer.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/FakeHttpServer.kt
new file mode 100644
index 0000000000..9efa0a1c54
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/FakeHttpServer.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.acquisition
+
+import com.sun.net.httpserver.HttpExchange
+import com.sun.net.httpserver.HttpServer
+import java.net.InetSocketAddress
+import java.util.TreeMap
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+/**
+ * A request captured by [FakeHttpServer].
+ */
+internal class CapturedRequest(
+ val method: String,
+ val route: String,
+ val body: String,
+ private val headers: Map,
+) {
+ fun header(name: String): String? = headers[name]
+}
+
+/**
+ * A function that takes an [HttpExchange] and writes a response into it.
+ */
+internal typealias Responder = (HttpExchange) -> Unit
+
+/**
+ * A dummy in-JVM HTTP server for testing REST providers without external dependencies.
+ *
+ * A single handler intercepts **all** requests; responses are configured via
+ * [enqueue] to simulate states (e.g., polling) or [constant] for static endpoints.
+ *
+ * Each received request is logged in [requests] for test assertions.
+ */
+internal class FakeHttpServer : AutoCloseable {
+
+ /**
+ * An HTTP server. Listen for requests on this machine on a random available port.
+ */
+ private val server: HttpServer = HttpServer.create(
+ InetSocketAddress("127.0.0.1", 0),
+ 0,
+ ).apply {
+ // runs on a single thread
+ executor = Executors.newSingleThreadExecutor()
+ }
+
+ /**
+ * Associates each method-route with a queue of responses for that pair.
+ * Used to simulate responses that change over time (e.g. on polling).
+ */
+ private val queues = HashMap, ArrayDeque>()
+
+ /**
+ * Associates each method-route with a constant response for that pair.
+ */
+ private val constants = HashMap, Responder>()
+
+ // all requests received, in arrival order (for test assertions).
+ val requests = mutableListOf()
+
+ // base URL to feed into the provider's endpoint.
+ val baseUrl: String get() = "http://127.0.0.1:${server.address.port}"
+
+ init {
+ // a single handler intercepts all requests.
+ server.createContext("/") { exchange ->
+ exchange.use { ex ->
+ // reads the full request body
+ val body = ex.requestBody.readBytes().toString(Charsets.UTF_8)
+ // each header can appear multiple times: takes only the first one of each kind
+ val headers = TreeMap(String.CASE_INSENSITIVE_ORDER).apply {
+ ex.requestHeaders.forEach { (name, values) -> put(name, values.firstOrNull().orEmpty()) }
+ }
+ requests += CapturedRequest(ex.requestMethod, ex.requestURI.path, body, headers)
+ val key = ex.requestMethod to ex.requestURI.path
+ // searches in the queues first (consuming the key), fallback on constant responses otherwise
+ val responder = queues[key]?.removeFirstOrNull() ?: constants[key]
+ // executes the responder if it exists, defaults to a 404 otherwise
+ if (responder != null) responder(ex) else respond(ex, 404, ByteArray(0))
+ }
+ }
+ server.start()
+ }
+
+ /**
+ * Enqueues a response for the next [method]-[route] request.
+ *
+ * @param method the HTTP method used.
+ * @param route the requested route.
+ * @param responder the response for the next [method]-[route] request.
+ */
+ fun enqueue(method: String, route: String, responder: Responder) {
+ queues.getOrPut(method to route) { ArrayDeque() }.addLast(responder)
+ }
+
+ /**
+ * Registers a fallback [responder] for [method]-[route],
+ * used whenever its queue is empty.
+ *
+ * @param method the HTTP method used.
+ * @param route the queried route.
+ * @param responder responder the response used for every [method]-[route]
+ * request once the queue is empty.
+ */
+ fun constant(method: String, route: String, responder: Responder) {
+ constants[method to route] = responder
+ }
+
+ /**
+ * Stops the server AND shuts down its executor.
+ */
+ override fun close() {
+ server.stop(0)
+ (server.executor as? ExecutorService)?.shutdownNow()
+ }
+
+ companion object {
+ /**
+ * Returns a responder that writes a JSON string as
+ * a response.
+ *
+ * @param code HTTP response code.
+ * @param body the JSON body as a string.
+ */
+ fun json(code: Int, body: String): Responder = { ex ->
+ ex.responseHeaders.add("Content-Type", "application/json")
+ respond(ex, code, body.toByteArray())
+ }
+
+ /**
+ * Simulates a binary response (e.g. a downloadable file).
+ *
+ * @param code HTTP response code.
+ * @param data the raw data sent in the response.
+ */
+ fun bytes(code: Int, data: ByteArray): Responder = { ex -> respond(ex, code, data) }
+
+ /**
+ * Adds a response in the [ex] http exchange.
+ *
+ * @param ex the HTTP exchange.
+ * @param code the HTTP status code of the response.
+ * @param data the raw data sent in the response.
+ */
+ private fun respond(ex: HttpExchange, code: Int, data: ByteArray) {
+ ex.sendResponseHeaders(
+ code,
+ // -1 means that there is not a body
+ if (data.isEmpty()) -1 else data.size.toLong(),
+ )
+ if (data.isNotEmpty()) ex.responseBody.use { it.write(data) }
+ }
+ }
+}
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/TestCopernicusCacheManager.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/TestCopernicusCacheManager.kt
new file mode 100644
index 0000000000..b882bae457
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/TestCopernicusCacheManager.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.acquisition
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.paths.shouldExist
+import io.kotest.matchers.paths.shouldNotExist
+import io.kotest.matchers.shouldBe
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Delegates each fetch call to a configurable [behavior], so that [CopernicusCacheManager] can
+ * be tested fully offline.
+ */
+private class FakeCopernicusProvider(
+ private val behavior: (request: CopernicusRequest, targetDir: Path) -> Unit,
+) : ExternalDataProvider {
+
+ var calls: Int = 0
+ private set
+
+ override fun fetch(request: CopernicusRequest, targetDir: Path) {
+ calls++
+ behavior(request, targetDir)
+ }
+}
+
+class TestCopernicusCacheManager : StringSpec({
+
+ val tempDir: Path = Files.createTempDirectory("cache-manager-test")
+
+ val dataFileName = "data.nc"
+
+ // writes a single file in a directory
+ val writeOneFile: (Path) -> Unit = { dir ->
+ Files.writeString(dir.resolve(dataFileName), "payload")
+ }
+
+ // deletes the directory and its files after the tests
+ afterSpec {
+ tempDir.toFile().deleteRecursively()
+ }
+
+ // creates a new root.
+ fun newRoot(): Path = Files.createTempDirectory(tempDir, "root")
+
+ /**
+ * A minimal [CopernicusRequest] for a test case.
+ * The inputs remain constant, only the dataset changes.
+ */
+ fun request(id: String): CopernicusRequest = CopernicusRequest(dataset = id, inputs = emptyMap())
+
+ "miss: produce runs exactly once and its files are promoted" {
+ val req = request("entry_a")
+ val provider = FakeCopernicusProvider { _, dir -> writeOneFile(dir) }
+ val cache = CopernicusCacheManager(provider, newRoot())
+ val result = cache.getOrProduce(req)
+ provider.calls shouldBe 1
+ result.shouldExist()
+ result.resolve(dataFileName).shouldExist()
+ }
+
+ "hit: the second call reuses the first entry without re-producing" {
+ val req = request("entry_b")
+ var marker = "first"
+ val provider = FakeCopernicusProvider { _, dir -> Files.writeString(dir.resolve(dataFileName), marker) }
+ val cache = CopernicusCacheManager(provider, newRoot())
+ val first = cache.getOrProduce(req)
+ // if the cache manager wrongly re-ran the provider, the file would contain "second"
+ marker = "second"
+ val second = cache.getOrProduce(req)
+ second shouldBe first
+ Files.readString(second.resolve(dataFileName)) shouldBe "first"
+ provider.calls shouldBe 1
+ }
+
+ "the returned directory name is exactly request.toFileName() under root" {
+ val root = newRoot()
+ val req = request("cems-glofas_abc123")
+ val provider = FakeCopernicusProvider { _, dir -> writeOneFile(dir) }
+ val cache = CopernicusCacheManager(provider, root)
+ val result = cache.getOrProduce(req)
+ result shouldBe root.resolve(req.toFileName())
+ }
+
+ "produce failure: the exception propagates and no entry is promoted" {
+ val root = newRoot()
+ val req = request("entry_fail")
+ val provider = FakeCopernicusProvider { _, _ -> error("download blew up") }
+ val cache = CopernicusCacheManager(provider, root)
+ shouldThrow { cache.getOrProduce(req) }
+ root.resolve(req.toFileName()).shouldNotExist() // no poisoned dir
+ }
+
+ "produce failure: the temporary directory is cleaned up, leaving .tmp empty" {
+ val root = newRoot()
+ val req = request("entry_fail2")
+ val provider = FakeCopernicusProvider { _, dir ->
+ Files.writeString(dir.resolve("partial.nc"), "half")
+ // simulates something gone wrong after having already written a file
+ error("error after writing")
+ }
+ val cache = CopernicusCacheManager(provider, root)
+ // ignores the exception
+ runCatching { cache.getOrProduce(req) }
+ // .tmp must hold no temp dirs left
+ val tmpRoot = root.resolve(".tmp")
+ val leftovers = Files.list(tmpRoot).use { it.toList() }
+ leftovers.size shouldBe 0
+ }
+
+ "empty result: produce leaves no file and throws IllegalStateException, nothing promoted" {
+ val root = newRoot()
+ val req = request("entry_empty")
+ val provider = FakeCopernicusProvider { _, _ -> } // writes nothing
+ val cache = CopernicusCacheManager(provider, root)
+ shouldThrow {
+ cache.getOrProduce(req)
+ }
+ root.resolve(req.toFileName()).shouldNotExist()
+ }
+
+ "validate before promoting: a dir with only subdirs (no regular file) is rejected" {
+ val root = newRoot()
+ val req = request("entry_subdir")
+ val provider = FakeCopernicusProvider { _, dir ->
+ // a directory, but no regular file
+ Files.createDirectory(dir.resolve("nested"))
+ }
+ val cache = CopernicusCacheManager(provider, root)
+ shouldThrow {
+ cache.getOrProduce(req)
+ }
+ root.resolve(req.toFileName()).shouldNotExist()
+ }
+
+ "hit is detected even across a fresh CacheManager over the same root" {
+ val root = newRoot()
+ val req = request("entry_persist")
+ val firstProvider = FakeCopernicusProvider { _, dir ->
+ Files.writeString(dir.resolve(dataFileName), "first")
+ }
+ CopernicusCacheManager(firstProvider, root).getOrProduce(req)
+ val secondProvider = FakeCopernicusProvider { _, dir ->
+ Files.writeString(dir.resolve(dataFileName), "second")
+ }
+ val result = CopernicusCacheManager(secondProvider, root).getOrProduce(req)
+ Files.readString(result.resolve(dataFileName)) shouldBe "first"
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/TestCopernicusDataStoreProvider.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/TestCopernicusDataStoreProvider.kt
new file mode 100644
index 0000000000..d573f286f6
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/TestCopernicusDataStoreProvider.kt
@@ -0,0 +1,429 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.acquisition
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.paths.shouldExist
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import it.unibo.alchemist.loadJsonCopernicusResponse
+import java.net.URI
+import java.net.http.HttpClient
+import java.time.Duration
+import kotlin.io.path.createTempDirectory
+import kotlin.io.path.readBytes
+
+class TestCopernicusDataStoreProvider : StringSpec({
+
+ val token = "test-token"
+ val successful = "successful-status"
+
+ /**
+ * The routes and captured bodies of one data store's job lifecycle.
+ *
+ * The four bodies captured from a store (submit, accepted, successful, results) all describe the
+ * same job, so every route the provider will request during that lifecycle is derivable from
+ * [dataset] and [jobId].
+ *
+ * @property name the store's identifier.
+ * @property dataset the dataset requested.
+ * @property jobId the identifier of the captured job, shared by all the store's bodies.
+ * @property assetPath the path component of the captured download URL, i.e. the route the fake
+ * object store must serve.
+ */
+ data class Store(val name: String, val dataset: String, val jobId: String, val assetPath: String) {
+ val submitRoute: String get() = "/retrieve/v1/processes/$dataset/execution"
+ val jobRoute: String get() = "/retrieve/v1/jobs/$jobId"
+ val resultsRoute: String get() = "$jobRoute/results"
+ val assetName: String get() = assetPath.substringAfterLast('/')
+
+ /**
+ * @return the captured body of the given [action] (e.g., "submit")
+ */
+ fun body(action: String): String = loadBody("$name-$action.json")
+ }
+
+ val cds = Store(
+ name = "cds",
+ dataset = "derived-era5-land-daily-statistics",
+ jobId = "82d0a5fb-f096-42fd-b644-c7ba173b0154",
+ assetPath = "/cci2-prod-cache-1/2026-08-08/f8ec201f667455bd3cf338c39fc03a1a.zip",
+ )
+ val ads = Store(
+ name = "ads",
+ dataset = "cams-global-greenhouse-gas-forecasts",
+ jobId = "a79e0cce-9c46-4bd9-aec5-3570977cbbd1",
+ assetPath = "/cci2-prod-cache-2/2026-08-08/1b2c8f7e437451ffc09a9e23cb32a542.zip",
+ )
+ val ewds = Store(
+ name = "ewds",
+ dataset = "efas-historical",
+ jobId = "bb1ee550-0dea-4c84-b164-b8c54165a25f",
+ assetPath = "/cci2-prod-cache-3/2026-08-09/9600fbec69609809250b901b42f6800.zip",
+ )
+
+ fun createProvider(endpoint: String, timeout: Duration = Duration.ofSeconds(2)) = CopernicusDataStoreProvider(
+ endpoint = endpoint,
+ tokenSupplier = { token },
+ http = HttpClient.newHttpClient(),
+ pollInterval = Duration.ofMillis(100),
+ maxPollInterval = Duration.ofMillis(300),
+ timeout = timeout,
+ )
+
+ /**
+ * Rewrites the real data-store and object-store hosts of a captured body into the loopback
+ * address of [fake].
+ *
+ * Fails if any host other than the fake server survives:
+ * without this check, a host missing from the rewrite list would not produce a readable test
+ * failure, it would issue a REAL network request to ECMWF.
+ */
+ fun String.withFakeBase(fake: FakeHttpServer): String {
+ // matches the host of every absolute http URL that appears as a JSON `href` value.
+ val followedHost = Regex(""""href"\s*:\s*"https?://([^/"]+)""")
+ val replaced = replace("https://cds.climate.copernicus.eu/api", fake.baseUrl)
+ .replace("https://ads.atmosphere.copernicus.eu/api", fake.baseUrl)
+ .replace("https://ewds.climate.copernicus.eu/api", fake.baseUrl)
+ .replace("https://object-store.os-api.cci2.ecmwf.int:443", fake.baseUrl)
+ val fakeHost = URI.create(fake.baseUrl).host
+ val stray = followedHost.findAll(replaced)
+ .map { it.groupValues[1].substringBefore(':') }
+ .filterNot { it == fakeHost || it == "confluence.ecmwf.int" }
+ .toSet()
+ check(stray.isEmpty()) {
+ "The body still points at $stray after the rewrite: the provider would hit the network"
+ }
+ return replaced
+ }
+
+ /**
+ * Rewrites a captured results body so that it advertises the payload the fake object store
+ * will actually serve. A null [checksum] reproduces a store that advertises none.
+ */
+ fun String.advertising(sizeBytes: Int, checksum: String?): String = this
+ .replace(Regex("\"file:size\": *\\d+")) { "\"file:size\": $sizeBytes" }
+ .replace(Regex("\"file:checksum\": *\"[^\"]*\"")) {
+ checksum?.let { hex -> "\"file:checksum\": \"$hex\"" } ?: "\"file:checksum\": null"
+ }
+
+ /**
+ * Replays [store]'s captured submit body: the provider then follows its `rel="monitor"` link.
+ */
+ fun FakeHttpServer.replaySubmit(store: Store) {
+ val body = store.body("submit").withFakeBase(this)
+ enqueue("POST", store.submitRoute) { FakeHttpServer.json(201, body)(it) }
+ }
+
+ /**
+ * Replays one of [store]'s captured status bodies as the next poll response.
+ */
+ fun FakeHttpServer.replayStatus(store: Store, action: String) {
+ val body = store.body(action).withFakeBase(this)
+ enqueue("GET", store.jobRoute) { FakeHttpServer.json(200, body)(it) }
+ }
+
+ /**
+ * Answers the next poll of [store] with a handwritten [json].
+ */
+ fun FakeHttpServer.answerStatus(store: Store, json: String) =
+ enqueue("GET", store.jobRoute) { FakeHttpServer.json(200, json)(it) }
+
+ /**
+ * Replays [store]'s captured results body, rewritten to describe the payload being served.
+ */
+ fun FakeHttpServer.replayResults(store: Store, sizeBytes: Int, checksum: String? = null) {
+ val body = store.body("results").withFakeBase(this).advertising(sizeBytes, checksum)
+ enqueue("GET", store.resultsRoute) { FakeHttpServer.json(200, body)(it) }
+ }
+
+ /**
+ * Serves [payload] from the fake object store at [store]'s asset path.
+ */
+ fun FakeHttpServer.serveAsset(store: Store, payload: ByteArray) =
+ constant("GET", store.assetPath) { FakeHttpServer.bytes(200, payload)(it) }
+
+ // FULL OGC CONVERSATION SIMULATION
+ "completes the OGC workflow on the captured CDS conversation and downloads the asset" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ val payload = "test".toByteArray()
+ fake.replaySubmit(cds)
+ fake.replayStatus(cds, "accepted-status")
+ fake.replayStatus(cds, successful)
+ fake.replayResults(cds, payload.size)
+ fake.serveAsset(cds, payload)
+ provider.fetch(CopernicusRequest(cds.dataset, mapOf("day" to "01")), tempDir)
+ val downloaded = tempDir.resolve(cds.assetName)
+ downloaded.shouldExist()
+ downloaded.readBytes() shouldBe payload
+ // the download must NOT carry the token
+ val downloadReq = fake.requests.single { it.route == cds.assetPath }
+ downloadReq.header("PRIVATE-TOKEN") shouldBe null
+ // every OGC request MUST carry the token
+ fake.requests.filter { it !== downloadReq }.forEach {
+ it.header("PRIVATE-TOKEN") shouldBe token
+ }
+ }
+ }
+
+ /*
+ * The data store emits hexadecimal hashes WITHOUT left zero-padding, so an intact asset whose
+ * MD5 begins with a zero is advertised with 31 characters. Payload "a" has MD5
+ * 0cc175b9c0f1b6a831c399e269772661 (computed with `md5sum`); the store would
+ * announce it stripped of its leading zero, and the download must still be accepted.
+ */
+ "accepts an asset whose advertised MD5 was served without its leading zero" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ val payload = "a".toByteArray()
+ fake.replaySubmit(cds)
+ fake.replayStatus(cds, successful)
+ fake.replayResults(cds, payload.size, checksum = "cc175b9c0f1b6a831c399e269772661")
+ fake.serveAsset(cds, payload)
+ provider.fetch(CopernicusRequest(cds.dataset, mapOf()), tempDir)
+ tempDir.resolve(cds.assetName).readBytes() shouldBe payload
+ }
+ }
+
+ "normalizes an endpoint with a trailing slash (no '//' in the constructed URI)" {
+ FakeHttpServer().use { fake ->
+ // the final '/' is intentional
+ val provider = createProvider("${fake.baseUrl}/")
+ val tempDir = createTempDirectory()
+ fake.replaySubmit(cds)
+ fake.replayStatus(cds, successful)
+ fake.replayResults(cds, sizeBytes = 0)
+ fake.serveAsset(cds, ByteArray(0))
+ provider.fetch(CopernicusRequest(cds.dataset, mapOf()), tempDir)
+ tempDir.resolve(cds.assetName).shouldExist()
+ }
+ }
+
+ "reports the problem-detail of a 400 rejection at submit" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ // synthetic response (could not capture one)
+ val rejection = """
+ {
+ "type": "invalid request",
+ "title": "invalid request",
+ "status": 400,
+ "detail": "Request has not produced a valid combination of values, please check your selection.",
+ "trace_id": "cec329b8-cb55-4b84-a3a8-86b85facdbb4"
+ }
+ """.trimIndent()
+ fake.enqueue("POST", ewds.submitRoute) { FakeHttpServer.json(400, rejection)(it) }
+ val ex = shouldThrow {
+ provider.fetch(CopernicusRequest(ewds.dataset, mapOf()), tempDir)
+ }
+ ex.message shouldContain "400"
+ ex.message shouldContain "valid combination of values"
+ ex.message shouldContain "cec329b8-cb55-4b84-a3a8-86b85facdbb4"
+ }
+ }
+
+ "falls back to the raw body when a 422 carries an array-valued detail" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ // synthetic response (could not capture one)
+ val validation = """
+ {
+ "detail": [
+ { "loc": ["body", "inputs", "hyear"], "msg": "field required", "type": "value_error.missing" }
+ ]
+ }
+ """.trimIndent()
+ fake.enqueue("POST", ewds.submitRoute) { FakeHttpServer.json(422, validation)(it) }
+ val ex = shouldThrow {
+ provider.fetch(CopernicusRequest(ewds.dataset, mapOf()), tempDir)
+ }
+ ex.message shouldContain "422"
+ ex.message shouldContain "field required"
+ }
+ }
+
+ "reports the problem-detail of a 401 raised while polling" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ fake.replaySubmit(ads)
+ fake.enqueue("GET", ads.jobRoute) {
+ FakeHttpServer.json(401, loadBody("error-401-permission-denied.json").withFakeBase(fake))(it)
+ }
+ val ex = shouldThrow {
+ provider.fetch(CopernicusRequest(ads.dataset, mapOf()), tempDir)
+ }
+ ex.message shouldContain "401"
+ ex.message shouldContain "authentication required"
+ }
+ }
+
+ listOf(
+ "failed" to "Internal processing error: out of memory",
+ "rejected" to "Job rejected: malformed processing chain",
+ "dismissed" to "Job dismissed by the user",
+ ).forEach { (status, message) ->
+ "throws on the terminal state '$status', reporting the job message" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ fake.replaySubmit(ewds)
+ fake.answerStatus(
+ ewds,
+ """
+ {
+ "processID": "${ewds.dataset}",
+ "jobID": "${ewds.jobId}",
+ "status": "$status",
+ "message": "$message"
+ }
+ """.trimIndent(),
+ )
+ val ex = shouldThrow {
+ provider.fetch(CopernicusRequest(ewds.dataset, mapOf()), tempDir)
+ }
+ ex.message shouldContain status
+ ex.message shouldContain message
+ }
+ }
+ }
+
+ "reports the backend traceback of a failed job by referencing its results link" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ fake.replaySubmit(ewds)
+ fake.answerStatus(
+ ewds,
+ """
+ {
+ "processID": "${ewds.dataset}",
+ "jobID": "${ewds.jobId}",
+ "status": "failed",
+ "links": [
+ { "href": "${fake.baseUrl}${ewds.resultsRoute}", "rel": "results" }
+ ]
+ }
+ """.trimIndent(),
+ )
+ fake.enqueue("GET", ewds.resultsRoute) {
+ FakeHttpServer.json(400, loadBody("ewds-failed-results.json").withFakeBase(fake))(it)
+ }
+ val ex = shouldThrow {
+ provider.fetch(CopernicusRequest(ewds.dataset, mapOf()), tempDir)
+ }
+ ex.message shouldContain "failed"
+ ex.message shouldContain "MultiAdaptorNoDataError"
+ ex.message shouldContain "4e257fcd-dda1-494a-8e26-d5d6885676d4"
+ }
+ }
+
+ "keeps polling on an undocumented status instead of failing" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ fake.replaySubmit(cds)
+ // an unforeseen status must be treated as transient, not as terminal
+ fake.answerStatus(cds, """{ "status": "queued_for_retry" }""")
+ fake.replayStatus(cds, successful)
+ fake.replayResults(cds, sizeBytes = 0)
+ fake.serveAsset(cds, ByteArray(0))
+ provider.fetch(CopernicusRequest(cds.dataset, mapOf()), tempDir)
+ tempDir.resolve(cds.assetName).shouldExist()
+ }
+ }
+
+ "times out if the job never completes" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl, timeout = Duration.ofMillis(500))
+ val tempDir = createTempDirectory()
+ fake.replaySubmit(cds)
+ fake.constant("GET", cds.jobRoute) { FakeHttpServer.json(200, """{ "status": "running" }""")(it) }
+ val ex = shouldThrow {
+ provider.fetch(CopernicusRequest(cds.dataset, mapOf()), tempDir)
+ }
+ ex.message shouldContain "Timeout"
+ }
+ }
+
+ "reports a 404 result-not-ready returned by the results endpoint" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ fake.replaySubmit(ads)
+ fake.replayStatus(ads, successful)
+ fake.enqueue("GET", ads.resultsRoute) {
+ FakeHttpServer.json(404, loadBody("error-404-result-not-ready.json").withFakeBase(fake))(it)
+ }
+ val ex = shouldThrow {
+ provider.fetch(CopernicusRequest(ads.dataset, mapOf()), tempDir)
+ }
+ ex.message shouldContain "404"
+ ex.message shouldContain "result-not-ready"
+ }
+ }
+
+ "fails when a 'successful' job exposes no rel='results' link (inconsistent server response)" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ fake.replaySubmit(cds)
+ fake.answerStatus(cds, """{ "status": "successful", "links": [] }""")
+ val ex = shouldThrow {
+ provider.fetch(CopernicusRequest(cds.dataset, mapOf()), tempDir)
+ }
+ ex.message shouldContain "rel='results'"
+ }
+ }
+
+ "fails with a clear message when the downloaded size does not match" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ val payload = "test".toByteArray()
+ fake.replaySubmit(cds)
+ fake.replayStatus(cds, successful)
+ // advertises one byte more than the object store will actually serve
+ fake.replayResults(cds, payload.size + 1)
+ fake.serveAsset(cds, payload)
+ val ex = shouldThrow {
+ provider.fetch(CopernicusRequest(cds.dataset, mapOf()), tempDir)
+ }
+ ex.message shouldContain "Size mismatch"
+ }
+ }
+
+ "sends the serialized inputs in the submit body" {
+ FakeHttpServer().use { fake ->
+ val provider = createProvider(fake.baseUrl)
+ val tempDir = createTempDirectory()
+ fake.replaySubmit(cds)
+ fake.replayStatus(cds, successful)
+ fake.replayResults(cds, sizeBytes = 0)
+ fake.serveAsset(cds, ByteArray(0))
+ provider.fetch(CopernicusRequest(cds.dataset, mapOf("year" to "2023")), tempDir)
+ val submit = fake.requests.single { it.method == "POST" }
+ submit.body shouldContain "\"inputs\""
+ submit.body shouldContain "\"year\""
+ submit.body shouldContain "2023"
+ }
+ }
+})
+
+private fun loadBody(fileName: String): String = loadJsonCopernicusResponse(
+ fileName,
+ TestCopernicusDataStoreProvider::class.java,
+)
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/TestCopernicusRequest.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/TestCopernicusRequest.kt
new file mode 100644
index 0000000000..900e989896
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/acquisition/TestCopernicusRequest.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.acquisition
+
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.shouldNotBe
+import io.kotest.matchers.string.shouldMatch
+import io.kotest.matchers.string.shouldNotContain
+import io.kotest.matchers.string.shouldStartWith
+
+class TestCopernicusRequest : StringSpec({
+
+ val dataset = "cems-glofas-historical"
+
+ // a realistic request to the EWDS data store
+ val glofas = CopernicusRequest(
+ dataset = dataset,
+ inputs = mapOf(
+ "variable" to listOf("river_discharge_in_the_last_24_hours"),
+ "hyear" to listOf("2024"),
+ "hmonth" to listOf("06"),
+ "hday" to listOf("10"),
+ "data_format" to "netcdf",
+ ),
+ )
+
+ "toFileName is deterministic: the same request yields the same name" {
+ glofas.toFileName() shouldBe glofas.toFileName()
+ }
+
+ "toFileName is stable under key reordering (the reason CanonicalJson exists)" {
+ // same logical request, just reordered
+ val reordered = CopernicusRequest(
+ dataset = dataset,
+ inputs = mapOf(
+ "data_format" to "netcdf",
+ "hday" to listOf("10"),
+ "hmonth" to listOf("06"),
+ "hyear" to listOf("2024"),
+ "variable" to listOf("river_discharge_in_the_last_24_hours"),
+ ),
+ )
+ reordered.toFileName() shouldBe glofas.toFileName()
+ }
+
+ "a different dataset yields a different name" {
+ glofas.copy(dataset = "reanalysis-era5-single-levels").toFileName() shouldNotBe glofas.toFileName()
+ }
+
+ "a different input value yields a different name (the bytes change)" {
+ val otherDay = glofas.copy(inputs = glofas.inputs + ("hday" to listOf("11")))
+ otherDay.toFileName() shouldNotBe glofas.toFileName()
+ }
+
+ "reordering a list inside inputs yields a different name (list order is semantic)" {
+ // area corners reordered = a different request, must not collide
+ val area = listOf(50.0, 5.0, 45.0, 10.0)
+ val nwse = glofas.copy(inputs = glofas.inputs + ("area" to area))
+ val swapped = glofas.copy(inputs = glofas.inputs + ("area" to area.reversed()))
+ nwse.toFileName() shouldNotBe swapped.toFileName()
+ }
+
+ "the readable prefix comes from the dataset, sanitized" {
+ glofas.toFileName() shouldStartWith "cems-glofas-historical_"
+ }
+
+ "a dataset id with unsafe characters is sanitized in the prefix" {
+ val weird = glofas.copy(dataset = "weird/name with:chars")
+ weird.toFileName() shouldStartWith "weird_name_with_chars_"
+ }
+
+ "the name complies with the CacheKey contract: a single file-system-safe segment" {
+ glofas.toFileName() shouldMatch Regex("^[A-Za-z0-9._-]+$")
+ }
+
+ "the name is a readable prefix followed by a lowercase-hex hash suffix" {
+ glofas.toFileName() shouldMatch Regex("^cems-glofas-historical_[0-9a-f]+$")
+ }
+
+ // toFileSystemSafe extension function tests
+ "a plain name with only safe characters is left unchanged" {
+ dataset.toFileSystemSafe() shouldBe dataset
+ "ERA5_2024.v3".toFileSystemSafe() shouldBe "ERA5_2024.v3"
+ }
+
+ "spaces are replaced" {
+ "New York".toFileSystemSafe() shouldBe "New_York"
+ }
+
+ "path separators are replaced" {
+ "a/b".toFileSystemSafe() shouldBe "a_b"
+ "a\\b".toFileSystemSafe() shouldBe "a_b"
+ }
+
+ "Windows-illegal characters are replaced" {
+ // < > : " | ? * are illegal in a Windows filename
+ """a:bd"e|f?g*h""".toFileSystemSafe() shouldBe "a_b_c_d_e_f_g_h"
+ }
+
+ "non-ASCII letters are replaced" {
+ "Forlì-Cesena".toFileSystemSafe() shouldBe "Forl_-Cesena"
+ }
+
+ "output never contains a path separator, for any input" {
+ listOf("a/b", "a\\b", "/", "\\", "C:/x", "../../etc").forEach { raw ->
+ raw.toFileSystemSafe().shouldNotContain("/")
+ raw.toFileSystemSafe().shouldNotContain("\\")
+ }
+ }
+
+ "output contains only allowlisted characters" {
+ """!"£$%&/()=?^@#°""".toFileSystemSafe() shouldMatch Regex("^[A-Za-z0-9._-]*$")
+ }
+
+ "sanitizing a sanitized name changes nothing" {
+ val once = "a b/c:d".toFileSystemSafe()
+ once.toFileSystemSafe() shouldBe once
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestArchives.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestArchives.kt
new file mode 100644
index 0000000000..77138c86da
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestArchives.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.paths.shouldExist
+import io.kotest.matchers.paths.shouldNotExist
+import io.kotest.matchers.shouldBe
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+class TestArchives : StringSpec({
+
+ val tempDir: Path = Files.createTempDirectory("archives-test")
+ val netCdfFileName = "dis24.nc"
+ val zipFileName = "result.zip"
+
+ // deletes the directory and its files after the tests
+ afterSpec {
+ tempDir.toFile().deleteRecursively()
+ }
+
+ // the subdir for each test
+ lateinit var dir: Path
+ beforeTest {
+ dir = Files.createTempDirectory(tempDir, "dir")
+ }
+
+ /**
+ * Writes a real zip named [zipName] into [dir] with the given entry path -> content.
+ */
+ fun writeZip(dir: Path, zipName: String, entries: Map): Path {
+ val zipPath = dir.resolve(zipName)
+ ZipOutputStream(Files.newOutputStream(zipPath)).use { zos ->
+ entries.forEach { (name, content) ->
+ zos.putNextEntry(ZipEntry(name))
+ zos.write(content.toByteArray())
+ zos.closeEntry()
+ }
+ }
+ return zipPath
+ }
+
+ /**
+ * Returns the set of regular file basenames in [dir].
+ */
+ fun fileNames(dir: Path): Set = Files.list(dir).use { s ->
+ s.filter { Files.isRegularFile(it) }
+ .map { it.fileName.toString() }.toList()
+ }.toSet()
+
+ "a non-zip file is left untouched" {
+ val data = dir.resolve(netCdfFileName)
+ Files.writeString(data, "I'm not a ZIP!")
+ val before = Files.readString(data)
+ flattenArchives(dir)
+ data.shouldExist()
+ Files.readString(data) shouldBe before
+ fileNames(dir) shouldBe setOf(netCdfFileName)
+ }
+
+ "a single-entry zip is extracted flat and the archive is deleted" {
+ val zip = writeZip(dir, zipFileName, mapOf(netCdfFileName to "payload"))
+ flattenArchives(dir)
+ zip.shouldNotExist()
+ dir.resolve(netCdfFileName).shouldExist()
+ Files.readString(dir.resolve(netCdfFileName)) shouldBe "payload"
+ fileNames(dir) shouldBe setOf(netCdfFileName)
+ }
+
+ "multiple archives in the same dir are all extracted and deleted" {
+ writeZip(dir, "part1.zip", mapOf("a.nc" to "A"))
+ writeZip(dir, "part2.zip", mapOf("b.nc" to "B"))
+ flattenArchives(dir)
+ fileNames(dir) shouldBe setOf("a.nc", "b.nc")
+ }
+
+ "a data file alongside an archive: the file stays, the archive is flattened" {
+ Files.writeString(dir.resolve("already.nc"), "plain")
+ writeZip(dir, zipFileName, mapOf("fromzip.nc" to "Z"))
+ flattenArchives(dir)
+ fileNames(dir) shouldBe setOf("already.nc", "fromzip.nc")
+ }
+
+ "a multi-entry zip extracts every entry" {
+ writeZip(dir, zipFileName, mapOf("a.nc" to "A", "b.nc" to "B"))
+ flattenArchives(dir)
+ fileNames(dir) shouldBe setOf("a.nc", "b.nc")
+ Files.readString(dir.resolve("a.nc")) shouldBe "A"
+ Files.readString(dir.resolve("b.nc")) shouldBe "B"
+ }
+
+ "nested entry paths are flattened to their basename" {
+ writeZip(dir, zipFileName, mapOf("data/2024/06/dis24.nc" to "deep"))
+ flattenArchives(dir)
+ dir.resolve(netCdfFileName).shouldExist()
+ Files.readString(dir.resolve(netCdfFileName)) shouldBe "deep"
+ fileNames(dir) shouldBe setOf(netCdfFileName)
+ }
+
+ "detection is by content, not extension: a zip named '.nc' is still extracted" {
+ // the archive itself is named like a data file
+ val zip = writeZip(dir, "payload.nc", mapOf(netCdfFileName to "inner"))
+ flattenArchives(dir)
+ zip.shouldNotExist()
+ dir.resolve(netCdfFileName).shouldExist()
+ Files.readString(dir.resolve(netCdfFileName)) shouldBe "inner"
+ }
+
+ "detection is by content, not extension: a non-zip named '.zip' is left untouched" {
+ val fake = dir.resolve("archive.zip")
+ Files.writeString(fake, "this is plain text, not a zip")
+ flattenArchives(dir)
+ fake.shouldExist()
+ Files.readString(fake) shouldBe "this is plain text, not a zip"
+ }
+
+ "a flatten collision (two entries, same basename) throws IllegalStateException" {
+ writeZip(dir, zipFileName, mapOf("regionA/dis24.nc" to "A", "regionB/dis24.nc" to "B"))
+ shouldThrow { flattenArchives(dir) }
+ }
+
+ "an empty zip (with no entries) extracts nothing, is deleted, and leaves the dir empty" {
+ val zip = writeZip(dir, zipFileName, emptyMap())
+ flattenArchives(dir)
+ zip.shouldNotExist()
+ fileNames(dir) shouldBe emptySet()
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCanonicalJson.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCanonicalJson.kt
new file mode 100644
index 0000000000..1f93843b2e
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCanonicalJson.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.shouldNotBe
+
+class TestCanonicalJson : StringSpec({
+
+ "map keys are sorted, producing a deterministic result" {
+ CanonicalJson.encode(mapOf("b" to "1", "a" to "2")) shouldBe """{"a":"2","b":"1"}"""
+ }
+
+ "the same map written with different key order encodes identically" {
+ val items = listOf("dataset" to "x", "variable" to listOf("dis24"), "year" to "2024")
+ val map1 = mapOf(*items.toTypedArray())
+ val map2 = mapOf(*items.reversed().toTypedArray())
+ CanonicalJson.encode(map1) shouldBe CanonicalJson.encode(map2)
+ }
+
+ "key sorting recurses into nested maps" {
+ val a = mapOf("outer" to mapOf("b" to "1", "a" to "2"))
+ val b = mapOf("outer" to mapOf("a" to "2", "b" to "1"))
+ CanonicalJson.encode(a) shouldBe CanonicalJson.encode(b)
+ }
+
+ "list order is significant: reordering elements yields a different encoding" {
+ val list = listOf(50.0, 5.0, 45.0, 10.0)
+ val nwse = mapOf("area" to list)
+ val shuffled = mapOf("area" to list.reversed())
+ CanonicalJson.encode(nwse) shouldNotBe CanonicalJson.encode(shuffled)
+ }
+
+ "list order is preserved verbatim, including zero-padded strings" {
+ CanonicalJson.encode(mapOf("day" to listOf("02", "01"))) shouldBe """{"day":["02","01"]}"""
+ }
+
+ "explicit nulls are encoded, distinguishing a present but null-key from an absent one" {
+ CanonicalJson.encode(mapOf("a" to null)) shouldNotBe CanonicalJson.encode(emptyMap())
+ }
+
+ "a realistic GloFAS request is order-stable across its keys" {
+ val request = mapOf(
+ "system_version" to listOf("version_3_1"),
+ "hydrological_model" to listOf("lisflood"),
+ "variable" to listOf("river_discharge_in_the_last_24_hours"),
+ "hyear" to listOf("2024"),
+ "hmonth" to listOf("06"),
+ "hday" to listOf("10"),
+ "data_format" to "netcdf",
+ )
+ val reordered = mapOf(
+ "data_format" to "netcdf",
+ "variable" to listOf("river_discharge_in_the_last_24_hours"),
+ "hday" to listOf("10"),
+ "hmonth" to listOf("06"),
+ "hyear" to listOf("2024"),
+ "hydrological_model" to listOf("lisflood"),
+ "system_version" to listOf("version_3_1"),
+ )
+ CanonicalJson.encode(request) shouldBe CanonicalJson.encode(reordered)
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCdsApiRc.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCdsApiRc.kt
new file mode 100644
index 0000000000..b2c4d5c1bf
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCdsApiRc.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.shouldBe
+import java.nio.file.Files
+import java.nio.file.Path
+
+class TestCdsApiRc : StringSpec({
+
+ val tempDir: Path = Files.createTempDirectory("cdsapirc-test")
+
+ // a realistic ECMWF unified access token
+ val realisticKey = "5b65k8c5-fr34-81dc-82b3-88e1hib45559"
+
+ // deletes the directory and its files after the tests
+ afterSpec {
+ tempDir.toFile().deleteRecursively()
+ }
+
+ /**
+ * Writes [content] to a fresh `.cdsapirc`-style file and returns its path.
+ */
+ fun rcFile(content: String): Path = Files.createTempFile(
+ tempDir,
+ "rc",
+ null,
+ ).apply {
+ Files.writeString(this, content)
+ }
+
+ "reads the token from a well-formed file" {
+ val path = rcFile(
+ """
+ url: https://cds.climate.copernicus.eu/api
+ key: $realisticKey
+ """.trimIndent(),
+ )
+ CdsApiRc.readToken(path) shouldBe realisticKey
+ }
+
+ "splits on the FIRST colon, preserving colons inside the token" {
+ /*
+ * the token exact format may change in the future:
+ * non-UUID token with internal ':' must survive intact.
+ */
+ val futureKey = "aaa:bbb:ccc"
+ val path = rcFile("key: $futureKey")
+ CdsApiRc.readToken(path) shouldBe futureKey
+ }
+
+ "trims surrounding whitespace around the token value" {
+ // ECMWF writes a space after the colon in the user-guide
+ val path = rcFile("key: $realisticKey ")
+ CdsApiRc.readToken(path) shouldBe realisticKey
+ }
+
+ "ignores the url line and any other keys" {
+ val path = rcFile(
+ """
+ url: https://ewds.climate.copernicus.eu/api
+ verify: 0
+ key: $realisticKey
+ other: another property
+ """.trimIndent(),
+ )
+ CdsApiRc.readToken(path) shouldBe realisticKey
+ }
+
+ "ignores comment lines" {
+ val path = rcFile(
+ """
+ # personal access token below
+ key: $realisticKey
+ # personal access token above
+ """.trimIndent(),
+ )
+ CdsApiRc.readToken(path) shouldBe realisticKey
+ }
+
+ "throws IllegalStateException when no key line is present" {
+ val path = rcFile("url: https://cds.climate.copernicus.eu/api")
+ shouldThrow { CdsApiRc.readToken(path) }
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCopernicusInputs.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCopernicusInputs.kt
new file mode 100644
index 0000000000..13ea5e9641
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCopernicusInputs.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import com.google.gson.JsonSyntaxException
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.shouldBe
+import java.nio.file.Files
+import java.nio.file.Path
+
+class TestCopernicusInputs : StringSpec({
+
+ val tempDir: Path = Files.createTempDirectory("copernicus-inputs-test")
+
+ afterSpec {
+ tempDir.toFile().deleteRecursively()
+ }
+
+ fun inputsFile(content: String): Path = Files.createTempFile(tempDir, "inputs", ".json").apply {
+ Files.writeString(this, content)
+ }
+
+ "reads a flat JSON object into a map of lists" {
+ val path = inputsFile("""{ "variable": ["2m_temperature"], "year": ["2024"] }""".trimIndent())
+ CopernicusInputs.read(path) shouldBe mapOf(
+ "variable" to listOf("2m_temperature"),
+ "year" to listOf("2024"),
+ )
+ }
+
+ "reads a 'type' key without any loader involved" {
+ val path = inputsFile("""{ "type": ["validated_reanalysis"] }""".trimIndent())
+ CopernicusInputs.read(path) shouldBe mapOf("type" to listOf("validated_reanalysis"))
+ }
+
+ "preserves list order" {
+ val path = inputsFile("""{ "area": [44.0, 11.0, 45.0, 12.0] }""".trimIndent())
+ CopernicusInputs.read(path) shouldBe mapOf("area" to listOf(44.0, 11.0, 45.0, 12.0))
+ }
+
+ "throws JsonSyntaxException when the file holds a JSON array instead of an object" {
+ val path = inputsFile("[1, 2, 3]")
+ shouldThrow { CopernicusInputs.read(path) }
+ }
+
+ "throws JsonSyntaxException when the file holds a scalar" {
+ val path = inputsFile("\"cmon, this is just a string!\"")
+ shouldThrow { CopernicusInputs.read(path) }
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCopernicusParsers.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCopernicusParsers.kt
new file mode 100644
index 0000000000..900e5cdc4a
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestCopernicusParsers.kt
@@ -0,0 +1,200 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import io.kotest.matchers.string.shouldStartWith
+import it.unibo.alchemist.loadJsonCopernicusResponse
+
+/**
+ * The test data are ACTUAL response bodies, captured from the three ECMWF data stores (CDS / ADS / EWDS)
+ * against the current `/execution` submit path. Each store contributes one full job lifecycle,
+ * all four bodies sharing the same job id: submit -> accepted -> successful -> results.
+ * Error bodies were captured separately.
+ */
+class TestCopernicusParsers : StringSpec({
+
+ val objectStoreEndpoint = "https://object-store.os-api.cci2.ecmwf.int:443"
+ val adsJobsEndpoint = "https://ads.atmosphere.copernicus.eu/api/retrieve/v1/jobs"
+ val ewdsJobsEndpoint = "https://ewds.climate.copernicus.eu/api/retrieve/v1/jobs"
+ val cdsJobsEndpoint = "https://cds.climate.copernicus.eu/api/retrieve/v1/jobs"
+
+ /**
+ * One full job lifecycle captured from a single data store.
+ */
+ data class StoreCase(
+ val store: String,
+ val submit: String,
+ val accepted: String,
+ val successful: String,
+ val results: String,
+ val monitorUrl: String,
+ val resultsUrl: String,
+ val asset: RemoteAsset,
+ )
+
+ val cds = StoreCase(
+ store = "CDS",
+ submit = loadBody("cds-submit.json"),
+ accepted = loadBody("cds-accepted-status.json"),
+ successful = loadBody("cds-successful-status.json"),
+ results = loadBody("cds-results.json"),
+ monitorUrl = "$cdsJobsEndpoint/82d0a5fb-f096-42fd-b644-c7ba173b0154",
+ resultsUrl = "$cdsJobsEndpoint/82d0a5fb-f096-42fd-b644-c7ba173b0154/results",
+ asset = RemoteAsset(
+ href = "$objectStoreEndpoint/cci2-prod-cache-1/2026-08-08/f8ec201f667455bd3cf338c39fc03a1a.zip",
+ sizeBytes = 50_689L,
+ md5 = "aa45b382ed6a3d13a4f30cca4d0a9b7",
+ ),
+ )
+
+ val ads = StoreCase(
+ store = "ADS",
+ submit = loadBody("ads-submit.json"),
+ accepted = loadBody("ads-accepted-status.json"),
+ successful = loadBody("ads-successful-status.json"),
+ results = loadBody("ads-results.json"),
+ monitorUrl = "$adsJobsEndpoint/a79e0cce-9c46-4bd9-aec5-3570977cbbd1",
+ resultsUrl = "$adsJobsEndpoint/a79e0cce-9c46-4bd9-aec5-3570977cbbd1/results",
+ asset = RemoteAsset(
+ href = "$objectStoreEndpoint/cci2-prod-cache-2/2026-08-08/1b2c8f7e437451ffc09a9e23cb32a542.zip",
+ sizeBytes = 7_768_356L,
+ md5 = "d6c0964f89e3f43d1a99ee4d7722f505",
+ ),
+ )
+
+ val ewds = StoreCase(
+ store = "EWDS",
+ submit = loadBody("ewds-submit.json"),
+ accepted = loadBody("ewds-accepted-status.json"),
+ successful = loadBody("ewds-successful-status.json"),
+ results = loadBody("ewds-results.json"),
+ monitorUrl = "$ewdsJobsEndpoint/bb1ee550-0dea-4c84-b164-b8c54165a25f",
+ resultsUrl = "$ewdsJobsEndpoint/bb1ee550-0dea-4c84-b164-b8c54165a25f/results",
+ asset = RemoteAsset(
+ href = "$objectStoreEndpoint/cci2-prod-cache-3/2026-08-09/9600fbec69609809250b901b42f6800.zip",
+ sizeBytes = 22_643L,
+ md5 = "104b13e69dc13b15f75c910d08f0e4ac",
+ ),
+ )
+
+ val stores = listOf(cds, ads, ewds)
+
+ /**
+ * Runs [check] on every store, reporting which one failed.
+ */
+ fun eachStore(check: (StoreCase) -> Unit) = stores.forEach { case ->
+ withClue("store: ${case.store}") { check(case) }
+ }
+
+ // link extraction
+ "parseMonitorUrl extracts the monitor link from a submit response" {
+ eachStore { parseMonitorUrl(it.submit) shouldBe it.monitorUrl }
+ }
+
+ // status extraction
+ "parseStatus reads 'accepted' from a pending job" {
+ eachStore { parseStatus(it.accepted) shouldBe "accepted" }
+ }
+
+ "parseStatus reads 'successful' from a finished job" {
+ eachStore { parseStatus(it.successful) shouldBe "successful" }
+ }
+
+ // results link extraction
+ "parseResultsUrl returns null while the job is not yet ready" {
+ eachStore { parseResultsUrl(it.accepted) shouldBe null }
+ }
+
+ "parseResultsUrl returns the results link once the job is successful" {
+ eachStore { parseResultsUrl(it.successful) shouldBe it.resultsUrl }
+ }
+
+ // asset metadata extraction
+ "parseAsset extracts href, size and checksum from a results response" {
+ eachStore { parseAsset(it.results) shouldBe it.asset }
+ }
+
+ // error bodies: RFC 7807
+ "parseProblemDetail extracts all fields from a 404 result-not-ready body" {
+ parseProblemDetail(loadBody("error-404-result-not-ready.json")) shouldBe ProblemDetail(
+ type = "http://www.opengis.net/def/exceptions/ogcapi-processes-1/1.0/result-not-ready",
+ title = "job results not ready",
+ status = 404,
+ detail = "status of 61ebb7be-650e-4aa5-9039-6030eb01bb68 is 'accepted'",
+ instance = "$adsJobsEndpoint/61ebb7be-650e-4aa5-9039-6030eb01bb68/results",
+ traceId = "e7ba3606-9816-43cc-ab6a-4f0642388701",
+ traceback = null,
+ )
+ }
+
+ "parseProblemDetail handles a 401 whose type is a string label, not a URI" {
+ parseProblemDetail(loadBody("error-401-permission-denied.json")) shouldBe ProblemDetail(
+ type = "permission denied",
+ title = "permission denied",
+ status = 401,
+ detail = "authentication required",
+ instance = "$adsJobsEndpoint/61ebb7be-650e-4aa5-9039-6030eb01bb68",
+ traceId = "b63a2882-2510-4ced-935a-b2faec13eead",
+ traceback = null,
+ )
+ }
+
+ "parseProblemDetail defaults type to 'about:blank' and nulls absent fields" {
+ parseProblemDetail("""{"detail":"something went wrong"}""") shouldBe ProblemDetail(
+ type = "about:blank",
+ title = null,
+ status = null,
+ detail = "something went wrong",
+ instance = null,
+ traceId = null,
+ traceback = null,
+ )
+ }
+
+ "parseProblemDetail extracts the traceback from a failed job's results body" {
+ val problem = parseProblemDetail(loadBody("ewds-failed-results.json"))
+ problem.type shouldBe "job results failed"
+ problem.title shouldBe "The job has failed"
+ problem.status shouldBe 400
+ problem.detail shouldBe null
+ problem.instance shouldBe "$ewdsJobsEndpoint/3a891e6b-e602-400d-9f26-d9be8acadc05/results"
+ problem.traceId shouldBe "4e257fcd-dda1-494a-8e26-d5d6885676d4"
+ problem.traceback shouldStartWith "The job failed with: MultiAdaptorNoDataError"
+ }
+
+ "describe falls back to the traceback when the body carries no detail" {
+ val described = parseProblemDetail(loadBody("ewds-failed-results.json")).describe()
+ described shouldContain "MultiAdaptorNoDataError"
+ described shouldContain "type=job results failed"
+ described shouldContain "trace=4e257fcd-dda1-494a-8e26-d5d6885676d4"
+ }
+
+ // synthetic: no real body was captured that populates the OGC 'message' field.
+ "parseFailureMessage reads a message string" {
+ parseFailureMessage("""{"status":"failed","message":"the job blew up"}""") shouldBe "the job blew up"
+ }
+
+ "parseFailureMessage returns null when no message is present" {
+ parseFailureMessage("""{"status":"failed"}""") shouldBe null
+ }
+
+ "parseFailureMessage returns null when message is JSON null" {
+ parseFailureMessage("""{"status":"failed","message":null}""") shouldBe null
+ }
+})
+
+private fun loadBody(fileName: String): String = loadJsonCopernicusResponse(
+ fileName,
+ TestCopernicusParsers::class.java,
+)
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestIntegrity.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestIntegrity.kt
new file mode 100644
index 0000000000..f4277aa301
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/boundary/utils/TestIntegrity.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.boundary.utils
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.shouldBe
+import java.nio.file.Files
+import java.nio.file.Path
+
+class TestIntegrity : StringSpec({
+
+ val tempDir: Path = Files.createTempDirectory("integrity-test")
+
+ lateinit var file: Path
+
+ afterSpec {
+ tempDir.toFile().deleteRecursively()
+ }
+
+ beforeTest {
+ file = Files.createTempFile(tempDir, "asset", ".bin")
+ }
+
+ /**
+ * Exact MD5 digests.
+ */
+ val knownMD5 = mapOf(
+ "" to "d41d8cd98f00b204e9800998ecf8427e",
+ "abc" to "900150983cd24fb0d6963f7d28e17f72",
+ "a" to "0cc175b9c0f1b6a831c399e269772661",
+ "168" to "006f52e9102a8d3be2fe5614f42ba989",
+ )
+
+ /**
+ * [knownMD5], but formatted the way data store advertises them:
+ * hashes are emitted without left zero-padding.
+ */
+ val advertisedUnpadded = mapOf(
+ "a" to "cc175b9c0f1b6a831c399e269772661", // 31 chars
+ "168" to "6f52e9102a8d3be2fe5614f42ba989", // 30 chars
+ )
+
+ "md5Hex of empty file matches the known digest" {
+ // file is created empty by beforeTest
+ md5Hex(file) shouldBe knownMD5[""]
+ }
+
+ knownMD5.forEach { (content, digest) ->
+ "md5Hex of $content matches the known digest" {
+ Files.writeString(file, content)
+ md5Hex(file) shouldBe digest
+ }
+ }
+
+ "md5Hex preserves the leading zero nibbles of a digest" {
+ advertisedUnpadded.keys.forEach { content ->
+ withClue("content: '$content'") {
+ Files.writeString(file, content)
+ md5Hex(file) shouldBe knownMD5.getValue(content)
+ }
+ }
+ }
+
+ "md5Hex is always 32 lowercase hex chars" {
+ knownMD5.keys.forEach { content ->
+ withClue("content: '$content'") {
+ Files.writeString(file, content)
+ val hex = md5Hex(file)
+ hex.length shouldBe 32
+ hex shouldBe hex.lowercase()
+ }
+ }
+ }
+
+ "verify passes when size and MD5 both match" {
+ Files.writeString(file, "abc")
+ // 3 bytes, known MD5
+ verify(file, 3, knownMD5.getValue("abc"))
+ }
+
+ "verify passes when no MD5 is advertised" {
+ Files.writeString(file, "abc")
+ verify(file, 3)
+ }
+
+ "verify accepts uppercase expected MD5 (case-insensitive)" {
+ Files.writeString(file, "abc")
+ verify(file, 3, knownMD5.getValue("abc").uppercase())
+ }
+
+ /*
+ * Data stores strip leading zeros from the digest it advertises, so
+ * a non-32 chars digest can be valid.
+ */
+ "verify accepts an MD5 advertised without its leading zeros" {
+ Files.writeString(file, "a")
+ verify(file, 1, advertisedUnpadded.getValue("a"))
+ }
+
+ "verify accepts an MD5 advertised without two leading zeros" {
+ Files.writeString(file, "168")
+ verify(file, 3, advertisedUnpadded.getValue("168"))
+ }
+
+ "verify throws on size mismatch" {
+ Files.writeString(file, "abc")
+ shouldThrow {
+ verify(file, 999)
+ }
+ }
+
+ "verify throws on MD5 mismatch even when size is right" {
+ Files.writeString(file, "abc")
+ shouldThrow {
+ // correct size (3) but wrong checksum
+ verify(file, 3, "ffffffffffffffffffffffffffffffff")
+ }
+ }
+
+ "verify throws on a short MD5 that is wrong rather than unpadded" {
+ Files.writeString(file, "a")
+ shouldThrow {
+ // unpadded digest (of "a") with its last character altered
+ verify(file, 1, "cc175b9c0f1b6a831c399e26977266f")
+ }
+ }
+
+ "verify skips an unusable MD5 instead of failing" {
+ Files.writeString(file, "abc")
+ verify(file, 3, "sha256:not-a-digest")
+ }
+
+ "verify still enforces the size when the MD5 is unusable" {
+ Files.writeString(file, "abc")
+ shouldThrow {
+ verify(file, 999, "not-a-digest")
+ }
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/reading/TestEagerGridSnapshots.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/reading/TestEagerGridSnapshots.kt
new file mode 100644
index 0000000000..3dcdc7b743
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/reading/TestEagerGridSnapshots.kt
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.reading
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.collections.shouldBeSortedWith
+import io.kotest.matchers.doubles.shouldBeNaN
+import io.kotest.matchers.shouldBe
+import it.unibo.alchemist.TestVariable
+import it.unibo.alchemist.writeTestNetcdf
+import java.nio.file.Files
+import java.nio.file.Path
+
+class TestEagerGridSnapshots : StringSpec({
+
+ /**
+ * Creates a NetCDF-3 file with:
+ * - latitudes: (10°, 20°, 30°).
+ * - longitudes: (5°, 15°, 25°, 35°).
+ * - the provided hours offset from `2024-01-01 00:00`.
+ *
+ * @param dir the directory where the file will be created (must exist).
+ * @param fileName the name of the file.
+ * @param timeHours hours offsets from `2024-01-01 00:00`.
+ */
+ fun writeFixedTestNetcdf(dir: Path, fileName: String, timeHours: DoubleArray) {
+ writeTestNetcdf(
+ path = dir.resolve(fileName),
+ lats = doubleArrayOf(10.0, 20.0, 30.0),
+ lons = doubleArrayOf(5.0, 15.0, 25.0, 35.0),
+ timeHours = timeHours,
+ )
+ }
+
+ /**
+ * the directory where the temporary NetCDF files for the tests will be created.
+ */
+ val tempDir: Path = Files.createTempDirectory("eagergridsnapshots-test")
+
+ // deletes the directory and its files after the tests
+ afterSpec {
+ tempDir.toFile().deleteRecursively()
+ }
+
+ // Basic reading tests
+ "should read a single file and expose the correct number of instants" {
+ val dir = Files.createTempDirectory(tempDir, "basic")
+ writeFixedTestNetcdf(dir, "data.nc", doubleArrayOf(0.0, 24.0, 48.0))
+ val gridSnaps = EagerGridSnapshots(dir)
+ gridSnaps.instants.size shouldBe 3
+ }
+
+ "instants should be sorted in ascending chronological order regardless of file order" {
+ val dir = Files.createTempDirectory(tempDir, "sorted")
+ // b_file.nc is read second (in alphabetical order) but contains the most recent times
+ writeFixedTestNetcdf(dir, "b_file.nc", doubleArrayOf(48.0, 72.0))
+ writeFixedTestNetcdf(dir, "a_file.nc", doubleArrayOf(0.0, 24.0))
+ val gridSnaps = EagerGridSnapshots(dir)
+ gridSnaps.instants.size shouldBe 4
+ gridSnaps.instants shouldBeSortedWith compareBy { it }
+ }
+
+ "grid[i] should be accessible for every index in instants" {
+ val dir = Files.createTempDirectory(tempDir, "align")
+ writeFixedTestNetcdf(dir, "data.nc", doubleArrayOf(0.0, 24.0, 48.0, 72.0, 96.0))
+ val gridSnaps = EagerGridSnapshots(dir)
+ // if instants and grids were misaligned, the grid(s) would throw an IndexOutOfBoundsException
+ gridSnaps.instants.indices.forEach { i -> gridSnaps.grid(i).latitudes.size shouldBe 3 }
+ }
+
+ // Descending latitude normalization tests
+ "latitudes should be ascending even when the file stores them descending" {
+ val dir = Files.createTempDirectory(tempDir, "lat-desc")
+ writeTestNetcdf(
+ path = dir.resolve("desc.nc"),
+ lats = doubleArrayOf(30.0, 20.0, 10.0), // descending latitudes
+ lons = doubleArrayOf(5.0, 15.0, 25.0, 35.0),
+ timeHours = doubleArrayOf(0.0),
+ )
+ val resultLats = EagerGridSnapshots(dir).grid(0).latitudes
+ resultLats shouldBe doubleArrayOf(10.0, 20.0, 30.0)
+ }
+
+ "values should be correctly re-mapped after descending latitude normalization" {
+ val dir = Files.createTempDirectory(tempDir, "lat-remap")
+ /*
+ * 2-by-2 grid with descending lat. Row 0: north (20°), row 1: south (10°).
+ * After normalization: iLat=0 = south (10°), iLat=1 = north (20°).
+ */
+ writeTestNetcdf(
+ path = dir.resolve("remap.nc"),
+ lats = doubleArrayOf(20.0, 10.0),
+ lons = doubleArrayOf(5.0, 15.0),
+ timeHours = doubleArrayOf(0.0),
+ // north=[100,101], south=[200,201]
+ variables = listOf(TestVariable(rawValues = doubleArrayOf(100.0, 101.0, 200.0, 201.0))),
+ )
+ // checks if the rows get reversed
+ val grid = EagerGridSnapshots(dir).grid(0)
+ grid.valueAt(0, 0) shouldBe 200.0
+ grid.valueAt(0, 1) shouldBe 201.0
+ grid.valueAt(1, 0) shouldBe 100.0
+ grid.valueAt(1, 1) shouldBe 101.0
+ }
+
+ // "_FillValue" to NaN test
+ "fill values should be exposed as Double.NaN" {
+ val dir = Files.createTempDirectory(tempDir, "fillval")
+ val fill = -9999.0
+ writeTestNetcdf(
+ path = dir.resolve("fill.nc"),
+ lats = doubleArrayOf(10.0, 20.0),
+ lons = doubleArrayOf(5.0, 15.0),
+ timeHours = doubleArrayOf(0.0),
+ variables = listOf(
+ TestVariable(
+ rawValues = doubleArrayOf(fill, 42.0, 42.0, 42.0),
+ fillValue = fill,
+ ),
+ ),
+ )
+ val grid = EagerGridSnapshots(dir).grid(0)
+ grid.valueAt(0, 0).shouldBeNaN()
+ grid.valueAt(0, 1) shouldBe 42.0
+ }
+
+ // Dimension order test
+ "values should be read correctly regardless of the on-disk dimension order" {
+ val dir = Files.createTempDirectory(tempDir, "dim-order")
+ writeTestNetcdf(
+ path = dir.resolve("permuted.nc"),
+ lats = doubleArrayOf(10.0, 20.0),
+ lons = doubleArrayOf(5.0, 15.0),
+ timeHours = doubleArrayOf(0.0, 24.0),
+ // scrambled relative to the canonical (time, latitude, longitude) order
+ dimensionOrder = listOf("longitude", "time", "latitude"),
+ )
+ val grid = EagerGridSnapshots(dir).grid(0)
+ grid.valueAt(0, 0) shouldBe 0.0
+ grid.valueAt(0, 1) shouldBe 1.0
+ grid.valueAt(1, 0) shouldBe 10.0
+ grid.valueAt(1, 1) shouldBe 11.0
+ }
+
+ // Configuration errors tests
+ "should throw IllegalArgumentException on empty directory" {
+ val emptyDir = Files.createTempDirectory(tempDir, "empty")
+ shouldThrow { EagerGridSnapshots(emptyDir) }
+ }
+
+ "should ignore duplicate timestamps across files and keep the data of the first one read" {
+ val dir = Files.createTempDirectory(tempDir, "dup-values")
+ val lats = doubleArrayOf(10.0)
+ val lons = doubleArrayOf(5.0)
+ val time = doubleArrayOf(0.0)
+ val firstVal = 42.0
+ val secondVal = 99.0
+ // both files use the same instant
+ writeTestNetcdf(
+ path = dir.resolve("first_file.nc"),
+ lats = lats,
+ lons = lons,
+ timeHours = time,
+ variables = listOf(TestVariable(rawValues = doubleArrayOf(firstVal))),
+ )
+ writeTestNetcdf(
+ path = dir.resolve("second_file.nc"),
+ lats = lats,
+ lons = lons,
+ timeHours = time,
+ variables = listOf(TestVariable(rawValues = doubleArrayOf(secondVal))),
+ )
+ val gridSnaps = EagerGridSnapshots(dir)
+ gridSnaps.instants.size shouldBe 1
+ gridSnaps.grid(0).valueAt(0, 0) shouldBe firstVal
+ }
+
+ "should throw IllegalArgumentException when files have mismatched spatial grids" {
+ val dir = Files.createTempDirectory(tempDir, "mismatch")
+ writeTestNetcdf(
+ dir.resolve("f1.nc"),
+ doubleArrayOf(10.0, 20.0, 30.0),
+ doubleArrayOf(5.0, 15.0),
+ doubleArrayOf(0.0),
+ )
+ writeTestNetcdf(
+ dir.resolve("f2.nc"),
+ doubleArrayOf(40.0, 50.0, 60.0),
+ doubleArrayOf(5.0, 15.0),
+ doubleArrayOf(24.0),
+ )
+ shouldThrow { EagerGridSnapshots(dir) }
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/reading/TestRasterGridContract.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/reading/TestRasterGridContract.kt
new file mode 100644
index 0000000000..2e6fbedde2
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/reading/TestRasterGridContract.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.reading
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.core.spec.style.stringSpec
+import io.kotest.matchers.doubles.shouldBeNaN
+import io.kotest.matchers.shouldBe
+
+/**
+ * Contract shared by every [RasterGrid] implementation, verified
+ * once and parameterized by the factory of the concrete implementation under test.
+ */
+fun rasterGridContract(gridOf: (DoubleArray, DoubleArray, DoubleArray) -> RasterGrid) = stringSpec {
+ /**
+ * 3 latitudes by 4 longitudes.
+ * The cell value at (iLat, iLon) is iLat * 10 + iLon,
+ * so it is easily verifiable.
+ */
+ val lats = doubleArrayOf(10.0, 20.0, 30.0)
+ val lons = doubleArrayOf(5.0, 15.0, 25.0, 35.0)
+ val values = DoubleArray(12) { idx -> idx / lons.size * 10.0 + (idx % lons.size) }
+ val grid = gridOf(lats, lons, values)
+
+ // Value access tests
+ "valueAt should return the correct value for an interior cell" {
+ // (iLat=1, iLong=2), then index = 1*4 + 2 = 6, then value = 1*10 + 2 = 12
+ grid.valueAt(1, 2) shouldBe 12.0
+ }
+
+ "valueAt should return correct values at all four corners" {
+ grid.valueAt(0, 0) shouldBe 0.0 // bottom left corner
+ grid.valueAt(0, 3) shouldBe 3.0 // bottom right
+ grid.valueAt(2, 0) shouldBe 20.0 // top left
+ grid.valueAt(2, 3) shouldBe 23.0 // top right
+ }
+
+ // Missing values tests
+ "valueAt should return Double.NaN for missing values" {
+ // (iLat=1, iLon=1) is a missing value
+ val nanValues = DoubleArray(12) { idx -> if (idx == 5) Double.NaN else idx.toDouble() }
+ val nullGrid = ArrayRasterGrid(lats, lons, nanValues)
+ nullGrid.valueAt(1, 1).shouldBeNaN()
+ }
+
+ "a grid entirely made of Double.NaN should return NaN everywhere" {
+ val allNan = ArrayRasterGrid(lats, lons, DoubleArray(12) { Double.NaN })
+ for (iLat in 0..2) {
+ for (iLon in 0..3) {
+ allNan.valueAt(iLat, iLon).shouldBeNaN()
+ }
+ }
+ }
+
+ // Axis tests
+ "latitudes should be accessible and match the constructor argument" {
+ grid.latitudes shouldBe lats
+ }
+
+ "longitudes should be accessible and match the constructor argument" {
+ grid.longitudes shouldBe lons
+ }
+
+ // Dimension mismatch
+ "a mismatch between (lats x lons) and values should raise an exception" {
+ shouldThrow {
+ gridOf(
+ lats,
+ lons,
+ DoubleArray(lats.size * lons.size - 1) { 0.0 },
+ )
+ }
+ }
+
+ "axes that are not strictly increasing should raise an exception" {
+ shouldThrow {
+ gridOf(
+ lats.reversedArray(),
+ lons,
+ values,
+ )
+ }
+ shouldThrow {
+ gridOf(
+ lats,
+ lons.reversedArray(),
+ values,
+ )
+ }
+ }
+}
+
+// tests the array implementation (suitable for dense grids)
+class TestArrayRasterGrid : StringSpec({
+ include(rasterGridContract(::ArrayRasterGrid))
+})
+
+// tests the map implementation (suitable for sparse grids)
+class TestMapRasterGrid : StringSpec({
+ include(rasterGridContract(::MapRasterGrid))
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestMeasurementConverter.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestMeasurementConverter.kt
new file mode 100644
index 0000000000..b67e369f0d
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestMeasurementConverter.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy
+
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.doubles.shouldBeNaN
+import io.kotest.matchers.shouldBe
+import it.unibo.alchemist.model.geospatial.strategy.converter.DoubleIdentityWithFallback
+
+class TestMeasurementConverter : StringSpec({
+
+ val doubleIdentity = DoubleIdentityWithFallback()
+
+ "Double identity gets preserved" {
+ (-99..99).forEach { i ->
+ val value = i.toDouble()
+ doubleIdentity.convert(value) shouldBe value
+ }
+ }
+
+ "Double identity yields NaN by default on NaN" {
+ doubleIdentity.convert(Double.NaN).shouldBeNaN()
+ }
+
+ "Double identity replaces NaN with a default value" {
+ val defaultValue = -1.0
+ val id = DoubleIdentityWithFallback(defaultValue)
+ id.convert(42.0) shouldBe 42.0
+ id.convert(Double.NaN) shouldBe defaultValue
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestSpatialInterpolation.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestSpatialInterpolation.kt
new file mode 100644
index 0000000000..7ec0304974
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestSpatialInterpolation.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy
+
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.doubles.plusOrMinus
+import io.kotest.matchers.doubles.shouldBeNaN
+import io.kotest.matchers.shouldBe
+import it.unibo.alchemist.mockGeoPosition
+import it.unibo.alchemist.model.geospatial.reading.ArrayRasterGrid
+import it.unibo.alchemist.model.geospatial.strategy.spatial.BilinearInterpolation
+import it.unibo.alchemist.model.geospatial.strategy.spatial.NearestInterpolation
+
+class TestSpatialInterpolation : StringSpec({
+
+ val nearest = NearestInterpolation()
+ val bilinear = BilinearInterpolation()
+
+ val tolerance = 1e-9
+
+ /*
+ * 2x2 grid.
+ * It can be represented as follows:
+ * W(lon 100) E(lon 200)
+ * N(lat 20) 20(NW) 100(NE)
+ * S(lat 10) 0(SW) 10(SE)
+ */
+ val southWest = 0.0
+ val southEast = 10.0
+ val northWest = 20.0
+ val northEast = 100.0
+ val grid = ArrayRasterGrid(
+ latitudes = doubleArrayOf(10.0, 20.0),
+ longitudes = doubleArrayOf(100.0, 200.0),
+ // row-major representation
+ gridValues = doubleArrayOf(southWest, southEast, northWest, northEast),
+ )
+
+ /*
+ * 3x3 grid used to verify correct cell selection in a multi-cell grid: on an affine field
+ * bilinear reproduces the value exactly, so the expected result is just lat + lon.
+ */
+ val affineGrid = ArrayRasterGrid(
+ latitudes = doubleArrayOf(10.0, 20.0, 30.0),
+ longitudes = doubleArrayOf(100.0, 200.0, 300.0),
+ gridValues = doubleArrayOf(
+ 110.0, 210.0, 310.0, // lat = 10
+ 120.0, 220.0, 320.0, // lat = 20
+ 130.0, 230.0, 330.0, // lat = 30
+ ),
+ )
+
+ // Nearest spatial interpolation strategy tests
+ "Nearest returns the cell value on an exact node hit" {
+ nearest.valueAt(grid, mockGeoPosition(10.0, 100.0)) shouldBe southWest
+ nearest.valueAt(grid, mockGeoPosition(20.0, 200.0)) shouldBe northEast
+ }
+
+ "Nearest picks the closest cell" {
+ // closest to south-west
+ nearest.valueAt(grid, mockGeoPosition(12.0, 110.0)) shouldBe southWest
+ // closest to north-east
+ nearest.valueAt(grid, mockGeoPosition(18.0, 190.0)) shouldBe northEast
+ }
+
+ "Nearest resolves an exact tie to the lower index on each axis" {
+ // lat 15 and lon 150 are both exactly between nodes, so the lower index should be picked
+ nearest.valueAt(grid, mockGeoPosition(15.0, 150.0)) shouldBe southWest
+ }
+
+ "Nearest selects the correct cell in a multi-cell grid" {
+ // nearest node to (22, 290) is (20, 300)
+ nearest.valueAt(affineGrid, mockGeoPosition(22.0, 290.0)) shouldBe 320.0
+ }
+
+ "Nearest returns NaN when one of the nearest cells is missing" {
+ val gridWithHole = ArrayRasterGrid(
+ latitudes = doubleArrayOf(10.0, 20.0),
+ longitudes = doubleArrayOf(100.0, 200.0),
+ gridValues = doubleArrayOf(Double.NaN, 10.0, 20.0, 100.0), // south-west is missing
+ )
+ nearest.valueAt(gridWithHole, mockGeoPosition(11.0, 105.0)).shouldBeNaN()
+ }
+
+ // Bilinear spatial interpolation strategy tests
+ "Bilinear returns the exact corner value on a node hit (no interpolation)" {
+ bilinear.valueAt(grid, mockGeoPosition(10.0, 100.0)) shouldBe southWest
+ bilinear.valueAt(grid, mockGeoPosition(10.0, 200.0)) shouldBe southEast
+ bilinear.valueAt(grid, mockGeoPosition(20.0, 100.0)) shouldBe northWest
+ bilinear.valueAt(grid, mockGeoPosition(20.0, 200.0)) shouldBe northEast
+ }
+
+ "Bilinear at the cell center is the average of the four corners" {
+ // (0 + 10 + 20 + 100) / 4 = 32.5
+ bilinear.valueAt(grid, mockGeoPosition(15.0, 150.0)) shouldBe (32.5 plusOrMinus tolerance)
+ }
+
+ "Bilinear along an edge degenerates to linear interpolation on that edge" {
+ /*
+ * lat = 10 lies exactly on the south edge: only SW and SE should contribute in the calculation.
+ * halfway in longitude: (0 + 10) / 2 = 5
+ */
+ bilinear.valueAt(grid, mockGeoPosition(10.0, 150.0)) shouldBe (5.0 plusOrMinus tolerance)
+ }
+
+ "Bilinear weights the corners by their fractional distance" {
+ /*
+ * u = (125 - 100)/100 = 0.25, v = (12.5 - 10)/10 = 0.25
+ * 0*0.75*0.75 + 10*0.25*0.75 + 20*0.75*0.25 + 100*0.25*0.25 = 1.875 + 3.75 + 6.25 = 11.875
+ */
+ bilinear.valueAt(grid, mockGeoPosition(12.5, 125.0)) shouldBe (11.875 plusOrMinus tolerance)
+ }
+
+ "Bilinear reproduces an affine field exactly and brackets the correct cell" {
+ // f(lat, lon) = lat + lon, so (15, 250) should be 265
+ bilinear.valueAt(affineGrid, mockGeoPosition(15.0, 250.0)) shouldBe (265.0 plusOrMinus tolerance)
+ }
+
+ "Bilinear propagates NaN when any of the four corners is missing" {
+ for (i in 0..3) {
+ val values = doubleArrayOf(0.0, 10.0, 20.0, 30.0)
+ values[i] = Double.NaN
+ val gridWithHole = ArrayRasterGrid(
+ latitudes = doubleArrayOf(10.0, 20.0),
+ longitudes = doubleArrayOf(100.0, 200.0),
+ gridValues = values,
+ )
+ bilinear.valueAt(gridWithHole, mockGeoPosition(15.0, 150.0)).shouldBeNaN()
+ }
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestSpatioTemporalInterpolation.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestSpatioTemporalInterpolation.kt
new file mode 100644
index 0000000000..57d9e66d5a
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestSpatioTemporalInterpolation.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy
+
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.doubles.plusOrMinus
+import io.kotest.matchers.doubles.shouldBeNaN
+import io.kotest.matchers.shouldBe
+import it.unibo.alchemist.mockGeoPosition
+import it.unibo.alchemist.model.geospatial.reading.ArrayRasterGrid
+import it.unibo.alchemist.model.geospatial.strategy.spatiotemporal.TrilinearInterpolation
+
+class TestSpatioTemporalInterpolation : StringSpec({
+
+ val trilinear = TrilinearInterpolation()
+ val tolerance = 1e-9
+
+ /*
+ * Two 2x2 slices sharing the same spatial grid.
+ * gridAfter corners: every value is gridBefore's value + 1000, to keep the time axis
+ * easily distinguishable from the space axes.
+ */
+ val latitudes = doubleArrayOf(10.0, 20.0)
+ val longitudes = doubleArrayOf(100.0, 200.0)
+
+ val gridBefore = ArrayRasterGrid(
+ latitudes = latitudes,
+ longitudes = longitudes,
+ gridValues = doubleArrayOf(0.0, 10.0, 20.0, 100.0),
+ )
+ val gridAfter = ArrayRasterGrid(
+ latitudes = latitudes,
+ longitudes = longitudes,
+ gridValues = doubleArrayOf(1000.0, 1010.0, 1020.0, 1100.0),
+ )
+
+ val center = mockGeoPosition(15.0, 150.0) // cell center
+
+ "Trilinear at timeWeight 0.0 equals the bilinear value of gridBefore" {
+ // bilinear average of gridBefore corners: (0 + 10 + 20 + 100) / 4 = 32.5
+ trilinear.interpolate(center, gridBefore, gridAfter, 0.0) shouldBe (32.5 plusOrMinus tolerance)
+ }
+
+ "Trilinear at timeWeight 1.0 equals the bilinear value of gridAfter" {
+ // bilinear average of gridAfter corners: (1000 + 1010 + 1020 + 1100) / 4 = 1032.5
+ trilinear.interpolate(center, gridBefore, gridAfter, 1.0) shouldBe (1032.5 plusOrMinus tolerance)
+ }
+
+ "Trilinear at timeWeight 0.5 is the midpoint between the two bilinear slices" {
+ // (32.5 + 1032.5) / 2 = 532.5
+ trilinear.interpolate(center, gridBefore, gridAfter, 0.5) shouldBe (532.5 plusOrMinus tolerance)
+ }
+
+ "Trilinear propagates NaN when a corner is missing" {
+ val gridBeforeWithHole = ArrayRasterGrid(
+ latitudes = latitudes,
+ longitudes = longitudes,
+ gridValues = doubleArrayOf(Double.NaN, 10.0, 20.0, 100.0),
+ )
+ trilinear.interpolate(center, gridBeforeWithHole, gridAfter, 0.5).shouldBeNaN()
+ }
+
+ /*
+ * At an exact boundary (timeWeight 0.0 or 1.0), the other slice's weight is
+ * zero: a hole in the irrelevant slice must NOT contaminate the result.
+ */
+ "Trilinear at timeWeight 0.0 ignores a hole in gridAfter" {
+ val gridAfterWithHole = ArrayRasterGrid(
+ latitudes = latitudes,
+ longitudes = longitudes,
+ gridValues = doubleArrayOf(Double.NaN, 1010.0, 1020.0, 1100.0),
+ )
+ trilinear.interpolate(center, gridBefore, gridAfterWithHole, 0.0) shouldBe (32.5 plusOrMinus tolerance)
+ }
+
+ "Trilinear at timeWeight 1.0 ignores a hole in gridBefore" {
+ val gridBeforeWithHole = ArrayRasterGrid(
+ latitudes = latitudes,
+ longitudes = longitudes,
+ gridValues = doubleArrayOf(Double.NaN, 10.0, 20.0, 100.0),
+ )
+ trilinear.interpolate(center, gridBeforeWithHole, gridAfter, 1.0) shouldBe (1032.5 plusOrMinus tolerance)
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestTemporalInterpolation.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestTemporalInterpolation.kt
new file mode 100644
index 0000000000..355031f166
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/strategy/TestTemporalInterpolation.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.strategy
+
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.doubles.plusOrMinus
+import io.kotest.matchers.shouldBe
+import it.unibo.alchemist.model.geospatial.strategy.temporal.ClosestInterpolation
+import it.unibo.alchemist.model.geospatial.strategy.temporal.LastInterpolation
+import it.unibo.alchemist.model.geospatial.strategy.temporal.LinearInterpolation
+import it.unibo.alchemist.model.geospatial.strategy.temporal.NextInterpolation
+
+class TestTemporalInterpolation : StringSpec({
+
+ val closest = ClosestInterpolation()
+ val last = LastInterpolation()
+ val next = NextInterpolation()
+ val linear = LinearInterpolation()
+
+ val tolerance = 1e-9
+
+ val valueBefore = 10.0
+ val valueAfter = 30.0
+
+ // weights spanning in [0, 1]
+ val steps = 20
+ val weights = (0..steps).map { it.toDouble() / steps }
+
+ // Linear temporal interpolation strategy tests
+ "Linear returns the endpoints at weight 0 and 1" {
+ linear.interpolate(
+ valueBefore,
+ valueAfter,
+ 0.0,
+ ) shouldBe (valueBefore plusOrMinus tolerance)
+ linear.interpolate(
+ valueBefore,
+ valueAfter,
+ 1.0,
+ ) shouldBe (valueAfter plusOrMinus tolerance)
+ }
+
+ "Linear blends proportionally to the weight" {
+ for (weight in weights) {
+ val expected = valueBefore + (valueAfter - valueBefore) * weight
+ withClue("at weight $weight") {
+ linear.interpolate(
+ valueBefore,
+ valueAfter,
+ weight,
+ ) shouldBe (expected plusOrMinus tolerance)
+ }
+ }
+ }
+
+ // Last temporal interpolation strategy test
+ "Last always returns the earlier value regardless of weight" {
+ for (weight in weights) {
+ withClue("at weight $weight") {
+ last.interpolate(valueBefore, valueAfter, weight) shouldBe valueBefore
+ }
+ }
+ }
+
+ // Next temporal interpolation strategy test
+ "Next always returns the later value regardless of weight" {
+ for (weight in weights) {
+ withClue("at weight $weight") {
+ next.interpolate(valueBefore, valueAfter, weight) shouldBe valueAfter
+ }
+ }
+ }
+
+ // Closest temporal interpolation strategy tests
+ "Closest returns the earlier value when the weight is below 0.5" {
+ for (weight in listOf(0.0, 0.25, 0.499)) {
+ withClue("at weight $weight") {
+ closest.interpolate(valueBefore, valueAfter, weight) shouldBe valueBefore
+ }
+ }
+ }
+
+ "Closest returns the later value when the weight is at or above 0.5" {
+ for (weight in listOf(0.5, 0.75, 1.0)) {
+ withClue("at weight $weight") {
+ closest.interpolate(valueBefore, valueAfter, weight) shouldBe valueAfter
+ }
+ }
+ }
+
+ "Closest resolves the exact 0.5 tie to the later value" {
+ closest.interpolate(valueBefore, valueAfter, 0.5) shouldBe valueAfter
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/utils/TestAxes.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/utils/TestAxes.kt
new file mode 100644
index 0000000000..239db46b59
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/utils/TestAxes.kt
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.utils
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.doubles.plusOrMinus
+import io.kotest.matchers.shouldBe
+
+class TestAxes : StringSpec({
+
+ // indices 0..3
+ val axis = doubleArrayOf(10.0, 20.0, 30.0, 40.0)
+ val singleNode = doubleArrayOf(42.0)
+
+ // nearestIndex tests
+ "nearestIndex returns the exact index on a node hit" {
+ nearestIndex(axis, 10.0) shouldBe 0
+ nearestIndex(axis, 20.0) shouldBe 1
+ nearestIndex(axis, 40.0) shouldBe 3
+ }
+
+ "nearestIndex picks the closer node between two nodes" {
+ // closer to 20
+ nearestIndex(axis, 22.0) shouldBe 1
+ // closer to 30
+ nearestIndex(axis, 28.0) shouldBe 2
+ }
+
+ "nearestIndex resolves an exact tie to the lower index" {
+ nearestIndex(axis, 25.0) shouldBe 1
+ }
+
+ "nearestIndex clamps below the first node" {
+ nearestIndex(axis, 5.0) shouldBe 0
+ nearestIndex(axis, -1000.0) shouldBe 0
+ }
+
+ "nearestIndex clamps above the last node" {
+ nearestIndex(axis, 50.0) shouldBe 3
+ nearestIndex(axis, 1000.0) shouldBe 3
+ }
+
+ "nearestIndex always returns 0 on a single-node axis" {
+ nearestIndex(singleNode, 42.0) shouldBe 0
+ nearestIndex(singleNode, 0.0) shouldBe 0
+ nearestIndex(singleNode, 100.0) shouldBe 0
+ }
+
+ "nearestIndex rejects an empty axis" {
+ shouldThrow { nearestIndex(DoubleArray(0), 5.0) }
+ }
+
+ "nearestIndex rejects a NaN query" {
+ shouldThrow { nearestIndex(axis, Double.NaN) }
+ }
+
+ // bracketIndices tests
+ "bracketIndices returns a degenerate pair on a node hit" {
+ bracketIndices(axis, 30.0) shouldBe (2 to 2)
+ }
+
+ "bracketIndices brackets an interior coordinate" {
+ bracketIndices(axis, 23.0) shouldBe (1 to 2)
+ }
+
+ "bracketIndices clamps outside the boundaries" {
+ bracketIndices(axis, 5.0) shouldBe (0 to 0)
+ bracketIndices(axis, 50.0) shouldBe (3 to 3)
+ }
+
+ "bracketIndices is degenerate on a single-node axis" {
+ bracketIndices(singleNode, 42.0) shouldBe (0 to 0)
+ bracketIndices(singleNode, 0.0) shouldBe (0 to 0)
+ bracketIndices(singleNode, 100.0) shouldBe (0 to 0)
+ }
+
+ "bracketIndices rejects an empty axis and a NaN query" {
+ shouldThrow { bracketIndices(DoubleArray(0), 5.0) }
+ shouldThrow { bracketIndices(axis, Double.NaN) }
+ }
+
+ // weight tests
+ "weight is 0.0 at the lower node and 1.0 at the upper node" {
+ weight(axis, 1, 2, 20.0) shouldBe 0.0
+ weight(axis, 1, 2, 30.0) shouldBe 1.0
+ }
+
+ "weight is the normalized position within the segment" {
+ // sets a tolerance for the results
+ val tolerance = 1e-9
+ weight(axis, 1, 2, 25.0) shouldBe (0.5 plusOrMinus tolerance)
+ weight(axis, 1, 2, 23.0) shouldBe (0.3 plusOrMinus tolerance)
+ }
+
+ "weight is 0.0 on a degenerate bracket" {
+ weight(axis, 2, 2, 30.0) shouldBe 0.0
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/utils/TestGridReading.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/utils/TestGridReading.kt
new file mode 100644
index 0000000000..4e5a23347f
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/geospatial/utils/TestGridReading.kt
@@ -0,0 +1,280 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.geospatial.utils
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.collections.shouldContain
+import io.kotest.matchers.comparables.shouldBeGreaterThan
+import io.kotest.matchers.shouldBe
+import it.unibo.alchemist.TestVariable
+import it.unibo.alchemist.writeTestNetcdf
+import java.nio.file.Files
+import java.nio.file.Path
+import ucar.ma2.Array
+
+class TestGridReading : StringSpec({
+
+ val tempDir: Path = Files.createTempDirectory("netcdf-grid-reading-test")
+
+ val gribFileName = "fc.grib"
+
+ /**
+ * Directory containing a real, manually downloaded GRIB fixture.
+ * The grib is from the "reanalysis-era5-single-levels" dataset.
+ */
+ val realGribsDir: Path = Path.of(
+ requireNotNull(object {}.javaClass.getResource("/gribs")) {
+ "Test resource directory 'gribs' not found on the classpath"
+ }.toURI(),
+ )
+
+ afterSpec {
+ tempDir.toFile().deleteRecursively()
+ }
+
+ /**
+ * Opens [file], extracts [FileAxes] and closes it.
+ */
+ fun axesOf(file: Path, variableName: String? = null): FileAxes =
+ openNetcdfDataset(file).use { readFileAxes(it, variableName, file) }
+
+ /**
+ * Writes a minimal test NetCDF file in a subdir of [tempDir].
+ */
+ fun testFile(
+ label: String,
+ lats: DoubleArray = doubleArrayOf(10.0, 20.0),
+ lons: DoubleArray = doubleArrayOf(5.0, 15.0),
+ timeHours: DoubleArray = doubleArrayOf(0.0),
+ variables: List = listOf(TestVariable("dis24")),
+ dimensionOrder: List = listOf("time", "latitude", "longitude"),
+ ): Path {
+ val file = Files.createTempDirectory(tempDir, label).resolve("data.nc")
+ writeTestNetcdf(
+ path = file,
+ lats = lats,
+ lons = lons,
+ timeHours = timeHours,
+ variables = variables,
+ dimensionOrder = dimensionOrder,
+ )
+ return file
+ }
+
+ /**
+ * Opens [file] and returns its [FileAxes] with the permuted slice at time index [t].
+ */
+ fun readAxesAndSlice(file: Path, t: Int = 0, nLat: Int = 2, nLon: Int = 2): Pair =
+ openNetcdfDataset(file).use { ds ->
+ val axes = readFileAxes(ds, null, file)
+ axes to readPermutedSlice(axes, t, nLat, nLon)
+ }
+
+ // listDataFiles tests
+ "listDataFiles should list regular files sorted in a deterministic order" {
+ val dir = Files.createTempDirectory(tempDir, "list-sorted")
+ val c = Files.createFile(dir.resolve("c.nc"))
+ val a = Files.createFile(dir.resolve("a.nc"))
+ val b = Files.createFile(dir.resolve("b.nc"))
+ listDataFiles(dir) shouldBe listOf(a, b, c)
+ }
+
+ "listDataFiles should throw on an empty directory" {
+ val dir = Files.createTempDirectory(tempDir, "list-empty")
+ shouldThrow { listDataFiles(dir) }
+ }
+
+ // readFileAxes tests
+ "readFileAxes should be able to extract all its properties" {
+ val axes = axesOf(
+ testFile(
+ "axes-basic",
+ lats = doubleArrayOf(10.0, 20.0, 30.0),
+ lons = doubleArrayOf(5.0, 15.0, 25.0, 35.0),
+ timeHours = doubleArrayOf(0.0, 24.0, 48.0),
+ ),
+ )
+ axes.latitudes shouldBe doubleArrayOf(10.0, 20.0, 30.0)
+ axes.longitudes shouldBe doubleArrayOf(5.0, 15.0, 25.0, 35.0)
+ axes.latDescending shouldBe false
+ axes.lonDescending shouldBe false
+ axes.variable.shortName shouldBe "dis24"
+ axes.timePosition shouldBe 0
+ axes.latPosition shouldBe 1
+ axes.lonPosition shouldBe 2
+ axes.timeAxis.size.toInt() shouldBe 3
+ }
+
+ "readFileAxes should detect a descending latitude/longitude axis and reverse it/them" {
+ // lat check
+ val latAxes = axesOf(testFile("axes-lat-desc", lats = doubleArrayOf(30.0, 20.0, 10.0)))
+ latAxes.latitudes shouldBe doubleArrayOf(10.0, 20.0, 30.0)
+ latAxes.latDescending shouldBe true
+ // lon check
+ val lonAxes = axesOf(testFile("axes-lon-desc", lons = doubleArrayOf(35.0, 25.0, 15.0, 5.0)))
+ lonAxes.longitudes shouldBe doubleArrayOf(5.0, 15.0, 25.0, 35.0)
+ lonAxes.lonDescending shouldBe true
+ }
+
+ "readFileAxes should preserve an explicit variable name" {
+ val file = testFile("axes-explicit-name", variables = listOf(TestVariable("wind_speed")))
+ axesOf(file, variableName = "wind_speed").variable.shortName shouldBe "wind_speed"
+ }
+
+ "readFileAxes should compute axis positions correctly regardless of the file dimension order" {
+ val axes = axesOf(testFile("axes-scrambled", dimensionOrder = listOf("longitude", "time", "latitude")))
+ axes.lonPosition shouldBe 0
+ axes.timePosition shouldBe 1
+ axes.latPosition shouldBe 2
+ }
+
+ // resolveVariable tests
+ "resolveVariable should throw when the variable does not exist" {
+ val file = testFile("resolve-missing")
+ openNetcdfDataset(file).use { ds ->
+ shouldThrow {
+ resolveVariable(
+ ds,
+ "this_variable_does_not_exist_and_should_throw",
+ "time",
+ "latitude",
+ "longitude",
+ file,
+ )
+ }
+ }
+ }
+
+ "resolveVariable should throw when auto-detection matches more than one variable" {
+ val file = testFile(
+ "resolve-ambiguous",
+ variables = listOf(
+ TestVariable("first"),
+ TestVariable("second"),
+ ),
+ )
+ openNetcdfDataset(file).use { ds ->
+ shouldThrow {
+ resolveVariable(ds, null, "time", "latitude", "longitude", file)
+ }
+ }
+ }
+
+ // readPermutedSlice tests
+ "readPermutedSlice should reorder values to (time, lat, lon) regardless of the file axes order" {
+ val file = testFile("permute", dimensionOrder = listOf("longitude", "time", "latitude"))
+ val (_, slice) = readAxesAndSlice(file)
+ slice.getDouble(0) shouldBe 0.0
+ slice.getDouble(1) shouldBe 1.0
+ slice.getDouble(2) shouldBe 10.0
+ slice.getDouble(3) shouldBe 11.0
+ }
+
+ "readPermutedSlice should select the requested time index" {
+ val file = testFile(
+ "time-index",
+ timeHours = doubleArrayOf(0.0, 24.0),
+ variables = listOf(
+ TestVariable(rawValues = doubleArrayOf(100.0, 101.0, 102.0, 103.0, 200.0, 201.0, 202.0, 203.0)),
+ ),
+ )
+ openNetcdfDataset(file).use { ds ->
+ val axes = readFileAxes(ds, null, file)
+ readPermutedSlice(axes, t = 0, nLat = 2, nLon = 2).getDouble(0) shouldBe 100.0
+ readPermutedSlice(axes, t = 1, nLat = 2, nLon = 2).getDouble(0) shouldBe 200.0
+ }
+ }
+
+ // flattenAscending tests
+ "flattenAscending should preserve values when both axes are ascending" {
+ val file = testFile(
+ "flatten-ascending",
+ variables = listOf(TestVariable(rawValues = doubleArrayOf(1.0, 2.0, 3.0, 4.0))),
+ )
+ val (axes, slice) = readAxesAndSlice(file)
+ flattenAscending(slice, 2, 2, axes.latDescending, axes.lonDescending) shouldBe
+ doubleArrayOf(1.0, 2.0, 3.0, 4.0)
+ }
+
+ "flattenAscending should reverse latitudes/longitudes when they are descending" {
+ // lat check
+ val fileLat = testFile(
+ "lat-decreasing",
+ lats = doubleArrayOf(20.0, 10.0),
+ variables = listOf(TestVariable(rawValues = doubleArrayOf(1.0, 2.0, 3.0, 4.0))),
+ )
+ val (latAxes, latSlice) = readAxesAndSlice(fileLat)
+ flattenAscending(latSlice, 2, 2, latAxes.latDescending, latAxes.lonDescending) shouldBe
+ doubleArrayOf(3.0, 4.0, 1.0, 2.0)
+ // lon check
+ val fileLon = testFile(
+ "lon-decreasing",
+ lons = doubleArrayOf(15.0, 5.0),
+ variables = listOf(TestVariable(rawValues = doubleArrayOf(1.0, 2.0, 3.0, 4.0))),
+ )
+ val (lonAxes, lonSlice) = readAxesAndSlice(fileLon)
+ flattenAscending(lonSlice, 2, 2, lonAxes.latDescending, lonAxes.lonDescending) shouldBe
+ doubleArrayOf(2.0, 1.0, 4.0, 3.0)
+ }
+
+ // ReferenceGrid tests
+ "ReferenceGrid should accept a second file whose grid and variable match" {
+ val reference = ReferenceGrid(axesOf(testFile("reference-match-a")))
+ val fileB = testFile("reference-match-b", timeHours = doubleArrayOf(24.0))
+ reference.requireMatches(axesOf(fileB), fileB, tempDir)
+ }
+
+ "ReferenceGrid should throw on a file with different latitudes/longitudes" {
+ // latitude
+ val latReference = ReferenceGrid(axesOf(testFile("reference-lat-mismatch-a")))
+ val fileB = testFile("reference-lat-mismatch-b", lats = doubleArrayOf(11.0, 21.0))
+ shouldThrow { latReference.requireMatches(axesOf(fileB), fileB, tempDir) }
+ // longitude
+ val lonReference = ReferenceGrid(axesOf(testFile("reference-lon-mismatch-a")))
+ val fileC = testFile("reference-lon-mismatch-b", lons = doubleArrayOf(6.0, 16.0))
+ shouldThrow { lonReference.requireMatches(axesOf(fileB), fileC, tempDir) }
+ }
+
+ "ReferenceGrid should throw on a file with a different variable" {
+ val reference = ReferenceGrid(
+ axesOf(testFile("prima_file", variables = listOf(TestVariable("prima")))),
+ )
+ val fileB = testFile("seconda_file", variables = listOf(TestVariable("seconda")))
+ shouldThrow { reference.requireMatches(axesOf(fileB), fileB, tempDir) }
+ }
+
+ // GRIB reading tests
+ "listDataFiles should find the real GRIB fixture and ignore index files" {
+ val files = listDataFiles(realGribsDir)
+ files.map { it.fileName.toString() } shouldContain gribFileName
+ }
+
+ "readFileAxes should not fail on a GRIB file" {
+ val file = realGribsDir.resolve(gribFileName)
+ val axes = axesOf(file)
+ axes.latitudes.size shouldBeGreaterThan 0
+ axes.longitudes.size shouldBeGreaterThan 0
+ axes.timeAxis.size.toInt() shouldBeGreaterThan 0
+ }
+
+ "the GridReading pipeline should not fail a GRIB file" {
+ val file = realGribsDir.resolve(gribFileName)
+ openNetcdfDataset(file).use { ds ->
+ val axes = readFileAxes(ds, null, file)
+ val nLat = axes.latitudes.size
+ val nLon = axes.longitudes.size
+ val slice = readPermutedSlice(axes, t = 0, nLat = nLat, nLon = nLon)
+ val flat = flattenAscending(slice, nLat, nLon, axes.latDescending, axes.lonDescending)
+ flat.size shouldBe nLat * nLon
+ buildGrid(axes.latitudes, axes.longitudes, flat) // this must not throw
+ }
+ }
+})
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/layers/TestCopernicusLayer.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/layers/TestCopernicusLayer.kt
new file mode 100644
index 0000000000..4852bf4fcd
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/layers/TestCopernicusLayer.kt
@@ -0,0 +1,386 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.layers
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.doubles.plusOrMinus
+import io.kotest.matchers.shouldBe
+import io.mockk.every
+import io.mockk.mockk
+import it.unibo.alchemist.TestVariable
+import it.unibo.alchemist.mockGeoPosition
+import it.unibo.alchemist.model.Environment
+import it.unibo.alchemist.model.GeoPosition
+import it.unibo.alchemist.model.geospatial.reading.ArrayRasterGrid
+import it.unibo.alchemist.model.geospatial.reading.GridSnapshots
+import it.unibo.alchemist.model.geospatial.reading.RasterGrid
+import it.unibo.alchemist.writeTestNetcdf
+import java.nio.file.Path
+import kotlin.io.path.absolutePathString
+import kotlin.io.path.createTempDirectory
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+private const val TOLERANCE = 1e-9
+
+class TestCopernicusLayer : StringSpec({
+ // fixed spatial grid
+ val lats = doubleArrayOf(44.0, 45.0, 46.0)
+ val lons = doubleArrayOf(11.0, 12.0, 13.0)
+
+ val center: GeoPosition = mockGeoPosition(45.0, 12.0)
+
+ val netCdfFileName = "data.nc"
+
+ /**
+ * Environment mock with the simulation fixed at time [t].
+ */
+ fun envAt(t: Double): Environment = mockk {
+ every { simulationOrNull } returns mockk {
+ every { time } returns mockk { every { toDouble() } returns t }
+ }
+ }
+
+ /**
+ * Environment mock whose simulation time is read from [currentT].
+ */
+ fun mutableEnv(currentT: () -> Double): Environment = mockk {
+ every { simulationOrNull } returns mockk {
+ every { time } returns mockk { every { toDouble() } answers { currentT() } }
+ }
+ }
+
+ /**
+ * Environment mock where the simulation has not started yet.
+ */
+ val envNoSim: Environment = mockk {
+ every { simulationOrNull } returns null
+ }
+
+ /**
+ * A [RasterGrid] where every cell has a fixed [value].
+ */
+ fun flatGrid(value: Double): RasterGrid = ArrayRasterGrid(
+ lats,
+ lons,
+ DoubleArray(lats.size * lons.size) { value },
+ )
+
+ /**
+ * Returns a [GridSnapshots] made of flat slices.
+ * THe i-th slice has all cells equal to `sliceValue[i]`.
+ * Time steps are spaced [step] apart starting from [base].
+ */
+ fun syntheticGrid(
+ vararg sliceValues: Double,
+ base: Instant = Instant.EPOCH,
+ step: Duration = 1.hours,
+ ): GridSnapshots {
+ val instants = sliceValues.indices.map { i -> base.plus(step * i) }
+ val grids = sliceValues.map { v -> flatGrid(v) }
+ return object : GridSnapshots {
+ override val instants = instants
+ override fun grid(index: Int): RasterGrid = grids[index]
+ }
+ }
+
+ val tempDir: Path = createTempDirectory("geo-raster-layer-test")
+
+ // deletes the directory and its files after the tests
+ afterSpec {
+ tempDir.toFile().deleteRecursively()
+ }
+
+ "require fails on empty GridSnapshots" {
+ val emptyGrid = object : GridSnapshots {
+ override val instants: List = emptyList()
+ override fun grid(index: Int): RasterGrid = throw UnsupportedOperationException()
+ }
+ shouldThrow {
+ DoubleCopernicusLayer(
+ environment = envAt(0.0),
+ data = emptyGrid,
+ )
+ }
+ }
+
+ // timeOrigin tests
+ "default timeOrigin maps the first instant to t=0.0" {
+ val gridSnaps = syntheticGrid(7.0, 14.0)
+ val layer = DoubleCopernicusLayer(
+ environment = envAt(0.0),
+ data = gridSnaps,
+ )
+ withClue("t=0.0 should hit the first slice exactly") {
+ gridSnaps.instants.indices.forEach { _ ->
+ layer.getValue(center) shouldBe 7.0
+ }
+ }
+ }
+
+ "explicit timeOrigin shifts the temporal origin" {
+ val layer = DoubleCopernicusLayer(
+ envAt(0.0),
+ syntheticGrid(10.0, 20.0, 30.0),
+ // instants: EPOCH, EPOCH+1h, EPOCH+2h
+ timeOrigin = Instant.EPOCH + 1.hours,
+ )
+ withClue("t=0.0 with shifted origin maps to the second slice = 20.0") {
+ layer.getValue(center) shouldBe 20.0
+ }
+ }
+
+ "explicit timeOrigin: t before shifted range extrapolates with the first or last time slice" {
+ // computed simulation times: -1.0, 0.0, 1.0; t=-2.0 and 2 are out of range
+ var layer = DoubleCopernicusLayer(
+ envAt(-2.0),
+ syntheticGrid(10.0, 20.0, 30.0),
+ timeOrigin = Instant.EPOCH + 1.hours,
+ )
+ withClue("t=-2.0 < sliceTimes.first()=-1.0, extrapolates to first slice: 10.0") {
+ layer.getValue(center) shouldBe 10.0
+ }
+ layer = DoubleCopernicusLayer(
+ envAt(2.0),
+ syntheticGrid(10.0, 20.0, 30.0),
+ timeOrigin = Instant.EPOCH + 1.hours,
+ )
+ withClue("t=2.0 > sliceTimes.last()=1.0, extrapolates to last slice: 30.0") {
+ layer.getValue(center) shouldBe 30.0
+ }
+ }
+
+ // timescale tests
+ "timeScale PT30M: one real hour maps to t=2.0" {
+ /*
+ * instants in the file: EPOCH, EPOCH+1h
+ * sliceTimes should be [0.0, 2.0] with scale PT30M
+ */
+ val layer = DoubleCopernicusLayer(
+ envAt(1.0),
+ syntheticGrid(0.0, 10.0),
+ timeScale = 30.minutes,
+ )
+ withClue("t=1.0 is halfway between 0.0 and 2.0, LINEAR blending should return 5.0") {
+ layer.getValue(center) shouldBe (5.0 plusOrMinus TOLERANCE)
+ }
+ }
+
+ "timeScale PT6H: one real six-hour step maps to t=1.0" {
+ /*
+ * instants in the file: EPOCH, EPOCH+6h
+ * sliceTimes should be [0.0, 1.0] with scale PT6H
+ */
+ val layer = DoubleCopernicusLayer(
+ envAt(0.5),
+ syntheticGrid(0.0, 12.0, step = 6.hours),
+ timeScale = 6.hours,
+ )
+ withClue("t=0.5 halfway, LINEAR blending should return 6.0") {
+ layer.getValue(center) shouldBe (6.0 plusOrMinus TOLERANCE)
+ }
+ }
+
+ // out of bound test
+ "getValue() should raise an exception when an out-of-bounds position is passed" {
+ val gridSnaps = syntheticGrid(7.0)
+ val layer = DoubleCopernicusLayer(
+ environment = envAt(0.0),
+ data = gridSnaps,
+ )
+ shouldThrow {
+ layer.getValue(mockGeoPosition(lats.first() - 1, lons.first() - 1))
+ }
+ }
+
+ // strategies tests
+ "exact hits on slices produce values without temporal interpolation" {
+ val sliceValues = doubleArrayOf(1.0, 2.0, 3.0)
+ for (i in sliceValues.indices) {
+ val time = i.toDouble()
+ val layer = DoubleCopernicusLayer(
+ envAt(time),
+ syntheticGrid(*sliceValues),
+ )
+ withClue("t=$time hits slice $i = ${sliceValues[i]}") {
+ layer.getValue(center) shouldBe sliceValues[i]
+ }
+ }
+ }
+
+ // no simulation test
+ "simulationOrNull null falls back to t=0.0 and reads the first slice" {
+ val layer = DoubleCopernicusLayer(
+ envNoSim,
+ syntheticGrid(42.0, 84.0),
+ )
+ withClue("null simulation: t=0.0, first slice = 42.0") {
+ layer.getValue(center) shouldBe 42.0
+ }
+ }
+
+ // directory constructor with real NetCDF files tests
+ "directory constructor: single file, exact hit at t=0 with default timeOrigin" {
+ val dir = tempDir.resolve("single").also { it.toFile().mkdirs() }
+ /*
+ * slice values:
+ * slice 0: all 10.0
+ * slice 1: all 20.0
+ * slice 2: all 30.0
+ */
+ writeTestNetcdf(
+ path = dir.resolve(netCdfFileName),
+ lats = doubleArrayOf(44.0, 45.0, 46.0),
+ lons = doubleArrayOf(11.0, 12.0, 13.0),
+ timeHours = doubleArrayOf(0.0, 1.0, 2.0),
+ variables = listOf(TestVariable(rawValues = DoubleArray(27) { idx -> ((idx / 9) + 1) * 10.0 })),
+ )
+ val layer = DoubleCopernicusLayer(envAt(0.0), dir.absolutePathString())
+ withClue("t=0.0 -> first slice -> all cells = 10.0") {
+ layer.getValue(center) shouldBe (10.0 plusOrMinus TOLERANCE)
+ }
+ }
+
+ "directory constructor: linear interpolation between two slices in file" {
+ val dir = tempDir.resolve("interp").also { it.toFile().mkdirs() }
+ writeTestNetcdf(
+ path = dir.resolve(netCdfFileName),
+ lats = doubleArrayOf(44.0, 45.0, 46.0),
+ lons = doubleArrayOf(11.0, 12.0, 13.0),
+ timeHours = doubleArrayOf(0.0, 1.0),
+ variables = listOf(TestVariable(rawValues = DoubleArray(18) { idx -> if (idx < 9) 0.0 else 10.0 })),
+ )
+ val layer = DoubleCopernicusLayer(envAt(0.5), dir.absolutePathString())
+ withClue("t=0.5 halfway between 0.0 and 10.0 -> 5.0") {
+ layer.getValue(center) shouldBe (5.0 plusOrMinus TOLERANCE)
+ }
+ }
+
+ "directory constructor: descending latitude axis is normalised to ascending" {
+ val dir = tempDir.resolve("desc-lat").also { it.toFile().mkdirs() }
+ writeTestNetcdf(
+ path = dir.resolve(netCdfFileName),
+ lats = doubleArrayOf(46.0, 45.0, 44.0), // lats descending
+ lons = doubleArrayOf(11.0, 12.0, 13.0),
+ timeHours = doubleArrayOf(0.0),
+ variables = listOf(
+ TestVariable(
+ rawValues = DoubleArray(9) { idx ->
+ when (idx / 3) {
+ 0 -> 1.0
+ 1 -> 2.0
+ else -> 3.0
+ }
+ },
+ ),
+ ),
+ )
+ val layer = DoubleCopernicusLayer(envAt(0.0), dir.absolutePathString())
+ val lat44: GeoPosition = mockGeoPosition(44.0, 12.0)
+ val lat46: GeoPosition = mockGeoPosition(46.0, 12.0)
+ withClue("after normalisation, lat=44 (file-row 2) should return 3.0") {
+ layer.getValue(lat44) shouldBe (3.0 plusOrMinus TOLERANCE)
+ }
+ withClue("after normalisation, lat=46 (file-row 0) should return 1.0") {
+ layer.getValue(lat46) shouldBe (1.0 plusOrMinus TOLERANCE)
+ }
+ }
+
+ "directory constructor: variable auto-detected when variableName is null" {
+ val dir = tempDir.resolve("auto-detect").also { it.toFile().mkdirs() }
+ writeTestNetcdf(
+ path = dir.resolve(netCdfFileName),
+ lats = doubleArrayOf(44.0, 45.0, 46.0),
+ lons = doubleArrayOf(11.0, 12.0, 13.0),
+ timeHours = doubleArrayOf(0.0),
+ variables = listOf(
+ TestVariable(
+ "wonderfulvariablename",
+ DoubleArray(9) { 7.0 },
+ ),
+ ),
+ )
+ val layer = DoubleCopernicusLayer(envAt(0.0), dir.absolutePathString(), variable = null)
+ withClue("auto-detected variable; all cells = 7.0") {
+ layer.getValue(center) shouldBe (7.0 plusOrMinus TOLERANCE)
+ }
+ }
+
+ "directory constructor: explicit variable name is used correctly" {
+ val dir = tempDir.resolve("explicit-var").also { it.toFile().mkdirs() }
+ val varName = "temperature"
+ writeTestNetcdf(
+ path = dir.resolve(netCdfFileName),
+ lats = doubleArrayOf(44.0, 45.0, 46.0),
+ lons = doubleArrayOf(11.0, 12.0, 13.0),
+ timeHours = doubleArrayOf(0.0),
+ variables = listOf(
+ TestVariable(
+ varName,
+ DoubleArray(9) { 42.0 },
+ ),
+ ),
+ )
+ val layer = DoubleCopernicusLayer(envAt(0.0), dir.absolutePathString(), variable = varName)
+ withClue("explicit variable '$varName'; all cells = 42.0") {
+ layer.getValue(center) shouldBe (42.0 plusOrMinus TOLERANCE)
+ }
+ }
+
+ "directory constructor: two different files are merged into a single ordered time series" {
+ val dir = tempDir.resolve("two-files").also { it.toFile().mkdirs() }
+ // file 1: t=0h (all values=10.0) and t=1h (all values=20.0)
+ writeTestNetcdf(
+ path = dir.resolve("part1.nc"),
+ lats = doubleArrayOf(44.0, 45.0, 46.0),
+ lons = doubleArrayOf(11.0, 12.0, 13.0),
+ timeHours = doubleArrayOf(0.0, 1.0),
+ variables = listOf(
+ TestVariable(rawValues = DoubleArray(18) { idx -> if (idx < 9) 10.0 else 20.0 }),
+ ),
+ )
+ // file 2: t=2h (all values=30.0) and t=3h (all values=40.0)
+ writeTestNetcdf(
+ path = dir.resolve("part2.nc"),
+ lats = doubleArrayOf(44.0, 45.0, 46.0),
+ lons = doubleArrayOf(11.0, 12.0, 13.0),
+ timeHours = doubleArrayOf(2.0, 3.0),
+ variables = listOf(
+ TestVariable(rawValues = DoubleArray(18) { idx -> if (idx < 9) 30.0 else 40.0 }),
+ ),
+ )
+ var t = 0.0
+ val layer = DoubleCopernicusLayer(mutableEnv { t }, dir.absolutePathString())
+ (0..3).forEach {
+ val time = it.toDouble()
+ val expectedTime = (it + 1) * 10.0
+ t = time
+
+ withClue("t=$time. Exact hit on merged series = $expectedTime") {
+ layer.getValue(center) shouldBe (expectedTime plusOrMinus TOLERANCE)
+ }
+ }
+ // interpolation across the file boundary: t=1.5 should return the mean with linear interpolation
+ t = 1.5
+ withClue("t=1.5 crosses file boundary. LINEAR blend of 20.0 and 30.0 = 25.0") {
+ layer.getValue(center) shouldBe (25.0 plusOrMinus TOLERANCE)
+ }
+ }
+})
+
+/**
+ * Shorthand for EPOCH time.
+ */
+private val Instant.Companion.EPOCH: Instant
+ get() = Instant.fromEpochMilliseconds(0)
diff --git a/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/layers/TestOsmCopernicusCompatibility.kt b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/layers/TestOsmCopernicusCompatibility.kt
new file mode 100644
index 0000000000..fe32f9a50c
--- /dev/null
+++ b/alchemist-geospatial/src/test/kotlin/it/unibo/alchemist/model/layers/TestOsmCopernicusCompatibility.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+/*
+ * Copyright (C) 2010-2026, Danilo Pianini and contributors
+ * listed, for each module, in the respective subproject's build.gradle.kts file.
+ *
+ * This file is part of Alchemist, and is distributed under the terms of the
+ * GNU General Public License, with a linking exception,
+ * as described in the file LICENSE in the Alchemist distribution's top directory.
+ */
+
+package it.unibo.alchemist.model.layers
+
+import io.kotest.assertions.withClue
+import io.kotest.core.spec.style.StringSpec
+import io.kotest.matchers.ints.shouldBeGreaterThan
+import io.kotest.matchers.shouldBe
+import io.mockk.mockk
+import it.unibo.alchemist.TestVariable
+import it.unibo.alchemist.model.GeoPosition
+import it.unibo.alchemist.model.Incarnation
+import it.unibo.alchemist.model.maps.environments.OSMEnvironment
+import it.unibo.alchemist.model.maps.positions.LatLongPosition
+import it.unibo.alchemist.writeTestNetcdf
+import kotlin.io.path.absolutePathString
+import kotlin.io.path.createTempDirectory
+
+class TestOsmCopernicusCompatibility : StringSpec({
+
+ // known positions inside maps/cesena.pbf
+ val pharmacyPos = LatLongPosition(44.14022881997589, 12.234464874617203)
+ val stadiumPos = LatLongPosition(44.140937161857074, 12.261716117329186)
+
+ val incarnation = mockk>()
+
+ "OSMEnvironment .pbf import and DoubleCopernicusLayer must coexist" {
+ // tries to import an actual .pbf file and use it
+ val environment = OSMEnvironment(incarnation, "maps/cesena.pbf")
+ val route = environment.computeRoute(pharmacyPos, stadiumPos)
+ route.points.size shouldBeGreaterThan 1
+
+ // forces cdm-core to be exercised by creating a Copernicus layer
+ val dir = createTempDirectory("pbf-copernicus")
+ try {
+ val fixedMeasurement = 42.0
+ writeTestNetcdf(
+ path = dir.resolve("data.nc"),
+ lats = doubleArrayOf(44.0, 45.0, 46.0),
+ lons = doubleArrayOf(11.0, 12.0, 13.0),
+ timeHours = doubleArrayOf(0.0),
+ variables = listOf(TestVariable(rawValues = DoubleArray(9) { fixedMeasurement })),
+ )
+ val layer = DoubleCopernicusLayer(environment, dir.absolutePathString())
+ withClue("pharmacy is within the synthetic grid's bounding box") {
+ layer.getValue(pharmacyPos) shouldBe fixedMeasurement
+ }
+ } finally {
+ dir.toFile().deleteRecursively()
+ }
+ }
+})
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/ads-accepted-status.json b/alchemist-geospatial/src/test/resources/copernicus-responses/ads-accepted-status.json
new file mode 100644
index 0000000000..905a51cda6
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/ads-accepted-status.json
@@ -0,0 +1,21 @@
+{
+ "processID": "cams-global-greenhouse-gas-forecasts",
+ "type": "process",
+ "jobID": "a79e0cce-9c46-4bd9-aec5-3570977cbbd1",
+ "status": "accepted",
+ "created": "2026-08-09T16:03:57.677938",
+ "updated": "2026-08-09T16:03:57.677938",
+ "links": [
+ {
+ "href": "https://ads.atmosphere.copernicus.eu/api/retrieve/v1/jobs/a79e0cce-9c46-4bd9-aec5-3570977cbbd1",
+ "rel": "self",
+ "type": "application/json"
+ }
+ ],
+ "metadata": {
+ "datasetMetadata": {
+ "catalogue": "cams"
+ },
+ "origin": "api"
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/ads-results.json b/alchemist-geospatial/src/test/resources/copernicus-responses/ads-results.json
new file mode 100644
index 0000000000..53ec18f0f1
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/ads-results.json
@@ -0,0 +1,11 @@
+{
+ "asset": {
+ "value": {
+ "type": "application/zip",
+ "href": "https://object-store.os-api.cci2.ecmwf.int:443/cci2-prod-cache-2/2026-08-08/1b2c8f7e437451ffc09a9e23cb32a542.zip",
+ "file:checksum": "d6c0964f89e3f43d1a99ee4d7722f505",
+ "file:size": 7768356,
+ "file:local_path": "s3://cci2-prod-cache-2/2026-08-08/1b2c8f7e437451ffc09a9e23cb32a542.zip"
+ }
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/ads-submit.json b/alchemist-geospatial/src/test/resources/copernicus-responses/ads-submit.json
new file mode 100644
index 0000000000..cff329f607
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/ads-submit.json
@@ -0,0 +1,26 @@
+{
+ "processID": "cams-global-greenhouse-gas-forecasts",
+ "type": "process",
+ "jobID": "a79e0cce-9c46-4bd9-aec5-3570977cbbd1",
+ "status": "accepted",
+ "created": "2026-08-09T16:03:57.677938",
+ "updated": "2026-08-09T16:03:57.677938",
+ "links": [
+ {
+ "href": "https://ads.atmosphere.copernicus.eu/api/retrieve/v1/processes/cams-global-greenhouse-gas-forecasts/execution",
+ "rel": "self"
+ },
+ {
+ "href": "https://ads.atmosphere.copernicus.eu/api/retrieve/v1/jobs/a79e0cce-9c46-4bd9-aec5-3570977cbbd1",
+ "rel": "monitor",
+ "type": "application/json",
+ "title": "job status info"
+ }
+ ],
+ "metadata": {
+ "datasetMetadata": {
+ "messages": []
+ },
+ "origin": "api"
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/ads-successful-status.json b/alchemist-geospatial/src/test/resources/copernicus-responses/ads-successful-status.json
new file mode 100644
index 0000000000..728fe6b063
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/ads-successful-status.json
@@ -0,0 +1,27 @@
+{
+ "processID": "cams-global-greenhouse-gas-forecasts",
+ "type": "process",
+ "jobID": "a79e0cce-9c46-4bd9-aec5-3570977cbbd1",
+ "status": "successful",
+ "created": "2026-08-09T16:03:57.677938",
+ "started": "2026-08-09T16:04:17.950364",
+ "finished": "2026-08-09T16:04:23.744705",
+ "updated": "2026-08-09T16:04:23.744705",
+ "links": [
+ {
+ "href": "https://ads.atmosphere.copernicus.eu/api/retrieve/v1/jobs/a79e0cce-9c46-4bd9-aec5-3570977cbbd1",
+ "rel": "self",
+ "type": "application/json"
+ },
+ {
+ "href": "https://ads.atmosphere.copernicus.eu/api/retrieve/v1/jobs/a79e0cce-9c46-4bd9-aec5-3570977cbbd1/results",
+ "rel": "results"
+ }
+ ],
+ "metadata": {
+ "datasetMetadata": {
+ "catalogue": "cams"
+ },
+ "origin": "api"
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/cds-accepted-status.json b/alchemist-geospatial/src/test/resources/copernicus-responses/cds-accepted-status.json
new file mode 100644
index 0000000000..95bab2ca81
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/cds-accepted-status.json
@@ -0,0 +1,21 @@
+{
+ "processID": "derived-era5-land-daily-statistics",
+ "type": "process",
+ "jobID": "82d0a5fb-f096-42fd-b644-c7ba173b0154",
+ "status": "accepted",
+ "created": "2026-08-09T16:03:03.634155",
+ "updated": "2026-08-09T16:03:03.634155",
+ "links": [
+ {
+ "href": "https://cds.climate.copernicus.eu/api/retrieve/v1/jobs/82d0a5fb-f096-42fd-b644-c7ba173b0154",
+ "rel": "self",
+ "type": "application/json"
+ }
+ ],
+ "metadata": {
+ "datasetMetadata": {
+ "catalogue": "c3s"
+ },
+ "origin": "api"
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/cds-results.json b/alchemist-geospatial/src/test/resources/copernicus-responses/cds-results.json
new file mode 100644
index 0000000000..11c3845fd0
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/cds-results.json
@@ -0,0 +1,11 @@
+{
+ "asset": {
+ "value": {
+ "type": "application/zip",
+ "href": "https://object-store.os-api.cci2.ecmwf.int:443/cci2-prod-cache-1/2026-08-08/f8ec201f667455bd3cf338c39fc03a1a.zip",
+ "file:checksum": "aa45b382ed6a3d13a4f30cca4d0a9b7",
+ "file:size": 50689,
+ "file:local_path": "s3://cci2-prod-cache-1/2026-08-08/f8ec201f667455bd3cf338c39fc03a1a.zip"
+ }
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/cds-submit.json b/alchemist-geospatial/src/test/resources/copernicus-responses/cds-submit.json
new file mode 100644
index 0000000000..1ad6172698
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/cds-submit.json
@@ -0,0 +1,26 @@
+{
+ "processID": "derived-era5-land-daily-statistics",
+ "type": "process",
+ "jobID": "82d0a5fb-f096-42fd-b644-c7ba173b0154",
+ "status": "accepted",
+ "created": "2026-08-09T16:03:03.634155",
+ "updated": "2026-08-09T16:03:03.634155",
+ "links": [
+ {
+ "href": "https://cds.climate.copernicus.eu/api/retrieve/v1/processes/derived-era5-land-daily-statistics/execution",
+ "rel": "self"
+ },
+ {
+ "href": "https://cds.climate.copernicus.eu/api/retrieve/v1/jobs/82d0a5fb-f096-42fd-b644-c7ba173b0154",
+ "rel": "monitor",
+ "type": "application/json",
+ "title": "job status info"
+ }
+ ],
+ "metadata": {
+ "datasetMetadata": {
+ "messages": []
+ },
+ "origin": "api"
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/cds-successful-status.json b/alchemist-geospatial/src/test/resources/copernicus-responses/cds-successful-status.json
new file mode 100644
index 0000000000..bbc6ffcc27
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/cds-successful-status.json
@@ -0,0 +1,27 @@
+{
+ "processID": "derived-era5-land-daily-statistics",
+ "type": "process",
+ "jobID": "82d0a5fb-f096-42fd-b644-c7ba173b0154",
+ "status": "successful",
+ "created": "2026-08-09T16:03:03.634155",
+ "started": "2026-08-09T16:03:22.103895",
+ "finished": "2026-08-09T16:03:26.451885",
+ "updated": "2026-08-09T16:03:26.451885",
+ "links": [
+ {
+ "href": "https://cds.climate.copernicus.eu/api/retrieve/v1/jobs/82d0a5fb-f096-42fd-b644-c7ba173b0154",
+ "rel": "self",
+ "type": "application/json"
+ },
+ {
+ "href": "https://cds.climate.copernicus.eu/api/retrieve/v1/jobs/82d0a5fb-f096-42fd-b644-c7ba173b0154/results",
+ "rel": "results"
+ }
+ ],
+ "metadata": {
+ "datasetMetadata": {
+ "catalogue": "c3s"
+ },
+ "origin": "api"
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/error-401-permission-denied.json b/alchemist-geospatial/src/test/resources/copernicus-responses/error-401-permission-denied.json
new file mode 100644
index 0000000000..c31214377a
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/error-401-permission-denied.json
@@ -0,0 +1,8 @@
+{
+ "type": "permission denied",
+ "title": "permission denied",
+ "status": 401,
+ "detail": "authentication required",
+ "instance": "https://ads.atmosphere.copernicus.eu/api/retrieve/v1/jobs/61ebb7be-650e-4aa5-9039-6030eb01bb68",
+ "trace_id": "b63a2882-2510-4ced-935a-b2faec13eead"
+}
\ No newline at end of file
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/error-404-result-not-ready.json b/alchemist-geospatial/src/test/resources/copernicus-responses/error-404-result-not-ready.json
new file mode 100644
index 0000000000..3ba12a152c
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/error-404-result-not-ready.json
@@ -0,0 +1,8 @@
+{
+ "type": "http://www.opengis.net/def/exceptions/ogcapi-processes-1/1.0/result-not-ready",
+ "title": "job results not ready",
+ "status": 404,
+ "detail": "status of 61ebb7be-650e-4aa5-9039-6030eb01bb68 is 'accepted'",
+ "instance": "https://ads.atmosphere.copernicus.eu/api/retrieve/v1/jobs/61ebb7be-650e-4aa5-9039-6030eb01bb68/results",
+ "trace_id": "e7ba3606-9816-43cc-ab6a-4f0642388701"
+}
\ No newline at end of file
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-accepted-status.json b/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-accepted-status.json
new file mode 100644
index 0000000000..3c1602f657
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-accepted-status.json
@@ -0,0 +1,21 @@
+{
+ "processID": "efas-historical",
+ "type": "process",
+ "jobID": "bb1ee550-0dea-4c84-b164-b8c54165a25f",
+ "status": "accepted",
+ "created": "2026-08-09T16:07:29.273643",
+ "updated": "2026-08-09T16:07:29.273643",
+ "links": [
+ {
+ "href": "https://ewds.climate.copernicus.eu/api/retrieve/v1/jobs/bb1ee550-0dea-4c84-b164-b8c54165a25f",
+ "rel": "self",
+ "type": "application/json"
+ }
+ ],
+ "metadata": {
+ "datasetMetadata": {
+ "catalogue": "cems"
+ },
+ "origin": "api"
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-failed-results.json b/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-failed-results.json
new file mode 100644
index 0000000000..5a97d0748b
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-failed-results.json
@@ -0,0 +1,8 @@
+{
+ "type": "job results failed",
+ "title": "The job has failed",
+ "status": 400,
+ "instance": "https://ewds.climate.copernicus.eu/api/retrieve/v1/jobs/3a891e6b-e602-400d-9f26-d9be8acadc05/results",
+ "trace_id": "4e257fcd-dda1-494a-8e26-d5d6885676d4",
+ "traceback": "The job failed with: MultiAdaptorNoDataError\nMARS has returned an error, please check your selection.\nRequest submitted to the MARS server:\n[{'area': ['36.99', '28.46', '36.97', '28.48'], 'levtype': ['sfc'], 'date': ['2020-10-14', '2023-01-01'], 'time': ['06:00:00'], 'param': ['240023'], 'type': ['sfo'], 'class': ['ce'], 'model': ['lisflood'], 'expect': ['any'], 'expver': ['1'], 'number': ['all'], 'stream': ['efcl'], 'database': 'ecmwf', 'hdate': ['20100101']}]\nFull error message:\nmars - ERROR - 20260809.160500 - Exception: Assertion failed: RegularGrid::minmax_ij: non-empty area crop/mask (to at least one point) (/home/deploy/git/mars-client/mir/src/mir/repres/regular/RegularGrid.cc:165 minmax_ij)\nmars - ERROR - 20260809.160500 - MIR: Assertion failed: RegularGrid::minmax_ij: non-empty area crop/mask (to at least one point)\nmars - ERROR - 20260809.160500 - Interpolation failed (-2)\nmars - ERROR - 20260809.160500 - Mars server task finished in error\nmars - ERROR - 20260809.160500 - Double buffer error: Assertion failed: length == buffers_[i].length_(RemoteException from Connector[mvr012,bods1-ag0721:9701]) [marser-ecmwf]\nmars - ERROR - 20260809.160500 - Error code is -2\nmars - ERROR - 20260809.160500 - Request failed\nmars - ERROR - 20260809.160500 - Some errors reported (last error -2)\n"
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-results.json b/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-results.json
new file mode 100644
index 0000000000..44e7baf7f2
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-results.json
@@ -0,0 +1,11 @@
+{
+ "asset": {
+ "value": {
+ "type": "application/zip",
+ "href": "https://object-store.os-api.cci2.ecmwf.int:443/cci2-prod-cache-3/2026-08-09/9600fbec69609809250b901b42f6800.zip",
+ "file:checksum": "104b13e69dc13b15f75c910d08f0e4ac",
+ "file:size": 22643,
+ "file:local_path": "s3://cci2-prod-cache-3/2026-08-09/9600fbec69609809250b901b42f6800.zip"
+ }
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-submit.json b/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-submit.json
new file mode 100644
index 0000000000..092f13e210
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-submit.json
@@ -0,0 +1,37 @@
+{
+ "processID": "efas-historical",
+ "type": "process",
+ "jobID": "bb1ee550-0dea-4c84-b164-b8c54165a25f",
+ "status": "accepted",
+ "created": "2026-08-09T16:07:29.273643",
+ "updated": "2026-08-09T16:07:29.273643",
+ "links": [
+ {
+ "href": "https://ewds.climate.copernicus.eu/api/retrieve/v1/processes/efas-historical/execution",
+ "rel": "self"
+ },
+ {
+ "href": "https://ewds.climate.copernicus.eu/api/retrieve/v1/jobs/bb1ee550-0dea-4c84-b164-b8c54165a25f",
+ "rel": "monitor",
+ "type": "application/json",
+ "title": "job status info"
+ }
+ ],
+ "metadata": {
+ "datasetMetadata": {
+ "messages": [
+ {
+ "date": "2024-02-01T00:00:00",
+ "severity": "info",
+ "content": "Please note that accessing this dataset via CDS for time-critical operation is not advised or supported"
+ },
+ {
+ "date": "2024-02-01T00:00:00",
+ "severity": "info",
+ "content": "Please note that we suggest checking the list of known issues on the EFAS wiki\n[here](https://confluence.ecmwf.int/display/CEMS/EFAS+-+Known+Issues)\nbefore downloading the dataset."
+ }
+ ]
+ },
+ "origin": "api"
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-successful-status.json b/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-successful-status.json
new file mode 100644
index 0000000000..ebd47bc6ee
--- /dev/null
+++ b/alchemist-geospatial/src/test/resources/copernicus-responses/ewds-successful-status.json
@@ -0,0 +1,27 @@
+{
+ "processID": "efas-historical",
+ "type": "process",
+ "jobID": "bb1ee550-0dea-4c84-b164-b8c54165a25f",
+ "status": "successful",
+ "created": "2026-08-09T16:07:29.273643",
+ "started": "2026-08-09T16:07:49.420505",
+ "finished": "2026-08-09T16:07:54.348020",
+ "updated": "2026-08-09T16:07:54.348020",
+ "links": [
+ {
+ "href": "https://ewds.climate.copernicus.eu/api/retrieve/v1/jobs/bb1ee550-0dea-4c84-b164-b8c54165a25f",
+ "rel": "self",
+ "type": "application/json"
+ },
+ {
+ "href": "https://ewds.climate.copernicus.eu/api/retrieve/v1/jobs/bb1ee550-0dea-4c84-b164-b8c54165a25f/results",
+ "rel": "results"
+ }
+ ],
+ "metadata": {
+ "datasetMetadata": {
+ "catalogue": "cems"
+ },
+ "origin": "api"
+ }
+}
diff --git a/alchemist-geospatial/src/test/resources/gribs/fc.grib b/alchemist-geospatial/src/test/resources/gribs/fc.grib
new file mode 100644
index 0000000000..58f93a1ddf
Binary files /dev/null and b/alchemist-geospatial/src/test/resources/gribs/fc.grib differ
diff --git a/alchemist-geospatial/src/test/resources/gribs/fc.grib.gbx9 b/alchemist-geospatial/src/test/resources/gribs/fc.grib.gbx9
new file mode 100644
index 0000000000..dadd3c4d3f
Binary files /dev/null and b/alchemist-geospatial/src/test/resources/gribs/fc.grib.gbx9 differ
diff --git a/alchemist-geospatial/src/test/resources/gribs/fc.grib.ncx4 b/alchemist-geospatial/src/test/resources/gribs/fc.grib.ncx4
new file mode 100644
index 0000000000..79f0143b32
Binary files /dev/null and b/alchemist-geospatial/src/test/resources/gribs/fc.grib.ncx4 differ
diff --git a/alchemist-geospatial/src/test/resources/maps/cesena.pbf b/alchemist-geospatial/src/test/resources/maps/cesena.pbf
new file mode 100644
index 0000000000..a634bff183
Binary files /dev/null and b/alchemist-geospatial/src/test/resources/maps/cesena.pbf differ
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index dbf4c1e7f3..b5f80ff2c9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,6 +4,7 @@ antlr4 = "4.13.2"
ais-lib = "2.8.7"
apollo = "5.1.0"
arrow = "2.2.3"
+cdm = "5.10.1-dev01+cd8efd17cc"
compose-multiplatform = "1.11.1"
dokka = "2.2.0"
graphql = "10.2.1"
@@ -38,6 +39,8 @@ appdirs = "net.harawata:appdirs:1.5.0"
arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" }
boilerplate = "org.danilopianini:boilerplate:0.2.2"
caffeine = "com.github.ben-manes.caffeine:caffeine:3.2.4"
+cdm-core = { module = "org.danilopianini:cdm-core", version.ref = "cdm" }
+cdm-grib = { module = "org.danilopianini:grib", version.ref = "cdm" }
classgraph = "io.github.classgraph:classgraph:4.8.193"
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8e0eee544a..abb34bc3a5 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -22,6 +22,7 @@ include(
"alchemist-full",
"alchemist-graphql",
"alchemist-graphql-surrogates",
+ "alchemist-geospatial",
"alchemist-implementationbase",
"alchemist-incarnation-protelis",
"alchemist-incarnation-sapere",