diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7d09529 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# VCS and editor noise +.git +.gitignore +*.md +!README.md + +# Build artifacts +/bin +/predictor +*.test +*.out + +# Local data and datasets — never bake multi-GB cubes into the image +/data +*.bin +*.bin.downloading +*.manifest.json +/tmp + +# Deployment + docs that aren't needed in the build context +/deploy +/examples +/docs +.forgejo +.github diff --git a/.forgejo/workflows/ci-cd.yml b/.forgejo/workflows/ci-cd.yml new file mode 100644 index 0000000..05bfec5 --- /dev/null +++ b/.forgejo/workflows/ci-cd.yml @@ -0,0 +1,116 @@ +name: CI/CD + +# Test on every push/PR; build + push an image and deploy on develop (staging) +# and on v* tags (production). Deployment goes through the Swarmpit REST API. +on: + push: + branches: [main, develop] + tags: ["v*"] + pull_request: + branches: [main, develop] + +env: + REGISTRY: git.intra.yksa.space + IMAGE_NAME: web/predictor + +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: true + + - name: Check formatting + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files need gofmt:"; echo "$unformatted"; exit 1 + fi + + - name: Vet + run: go vet ./... + + - name: Build + run: go build ./... + + - name: Test + run: go test -race ./... + + build: + needs: test + runs-on: ubuntu-24.04 + if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') + outputs: + tag: ${{ steps.meta.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Resolve image tag + id: meta + run: | + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + TAG="${GITHUB_REF#refs/tags/v}" + else + TAG="develop" + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "Resolved tag: ${TAG}" + + - name: Build and push image + run: | + IMAGE="${REGISTRY}/${IMAGE_NAME}" + TAG="${{ steps.meta.outputs.tag }}" + TAGS="-t ${IMAGE}:${TAG}" + # Tagged releases also move :latest. + if [[ "${TAG}" != "develop" ]]; then + TAGS="${TAGS} -t ${IMAGE}:latest" + fi + docker buildx build \ + --platform linux/amd64 \ + --build-arg VERSION="${TAG}" \ + --build-arg REVISION="${{ github.sha }}" \ + --push ${TAGS} . + + deploy-staging: + needs: build + runs-on: ubuntu-24.04 + if: github.ref == 'refs/heads/develop' + environment: staging + steps: + - uses: actions/checkout@v4 + - name: Deploy to Swarmpit (staging) + env: + SWARMPIT_URL: ${{ secrets.SWARMPIT_URL }} + SWARMPIT_TOKEN: ${{ secrets.SWARMPIT_TOKEN }} + STACK_NAME: ${{ secrets.STACK_NAME }} + CA_CERTIFICATES: ${{ secrets.CA_CERTIFICATES }} + TAG: ${{ needs.build.outputs.tag }} + run: sh deploy/swarmpit-deploy.sh + + deploy-production: + needs: build + runs-on: ubuntu-24.04 + if: startsWith(github.ref, 'refs/tags/v') + environment: production + steps: + - uses: actions/checkout@v4 + - name: Deploy to Swarmpit (production) + env: + SWARMPIT_URL: ${{ secrets.SWARMPIT_URL }} + SWARMPIT_TOKEN: ${{ secrets.SWARMPIT_TOKEN }} + STACK_NAME: ${{ secrets.STACK_NAME }} + CA_CERTIFICATES: ${{ secrets.CA_CERTIFICATES }} + TAG: ${{ needs.build.outputs.tag }} + run: sh deploy/swarmpit-deploy.sh diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..e2db8ba --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,156 @@ +# Deploying stratoflights-predictor + +The predictor is a single static Go binary with no database and no required +external services. It downloads NOAA GFS/GEFS wind data to **node-local disk** +and serves the REST API (see `/docs` or `api/rest/predictor.swagger.yml`). + +It is an **internal backend**: the public entrypoint is the stratoflights API +gateway, which calls the predictor over an internal overlay network. The +predictor enforces no auth of its own. + +## Environments + +| Environment | File | Notes | +|---|---|---| +| Local dev | `docker-compose.yml` | one instance, metrics off, named volume | +| Staging (single host) | `docker-compose.staging.yml` | all features + bundled Prometheus | +| Production (Swarm) | `docker-compose.swarm.yml` | node-pinned, replicated, metrics | + +```bash +# Local +docker compose up --build +curl localhost:8080/ready + +# Staging (single host, exercises the metrics pipeline) +docker compose -f docker-compose.staging.yml up --build +# Prometheus at :9090, predictor target should be UP + +# Production — see below +``` + +## Production (Docker Swarm) + +### Storage and node placement — the important part + +The wind dataset is ~8.9 GiB (0.5°) and must live on **local disk, never NFS**. +To bound the number of copies, the service is pinned to nodes carrying the +`predictor.data=true` label; **label at most two nodes**. Each labelled node +keeps exactly one copy under a node-local bind mount. + +On **each** labelled node, provision the local directories and a writable owner +for the non-root container (uid:gid `65532:65532`): + +```bash +sudo mkdir -p /srv/predictor/data /srv/predictor/elevation +sudo chown -R 65532:65532 /srv/predictor +# (optional) seed the elevation dataset so descent terminates at ground level: +# python3 scripts/build_elevation.py /srv/predictor/elevation/ruaumoko-dataset +``` + +Label the two storage nodes: + +```bash +docker node update --label-add predictor.data=true +docker node update --label-add predictor.data=true +``` + +Replicas are spread one-per-node by default (redundancy across both copies). +Scaling to multiple replicas **per** node is safe: they share the node-local +volume and coordinate the download with an exclusive `flock`, so only one +process per node fetches the dataset — the others wait and load the committed +file. To scale: `docker service scale predictor_predictor=4` (≤2 per node). + +### Network + +The gateway and Prometheus reach the predictor over a shared overlay. Create it +once and have the gateway stack join the same external network: + +```bash +docker network create -d overlay --attachable stratoflights-net +``` + +The service is published only on that network under the alias `predictor` +(`http://predictor:8080`). No public Traefik router — the gateway is the edge. + +### Deploy + +Via the CI pipeline (recommended): push a `v*` tag → the image is built and the +stack is deployed through the Swarmpit API. Manually: + +```bash +TAG=v1.0.0 docker stack deploy -c docker-compose.swarm.yml --with-registry-auth predictor +``` + +or import `docker-compose.swarm.yml` into Swarmpit and set `TAG`. + +### Configuration + +All settings are env vars (file/env/flag precedence; see README). Production +defaults are in `docker-compose.swarm.yml`: + +| Variable | Purpose | +|---|---| +| `PREDICTOR_DATA_DIR=/data` | node-local dataset dir (bind mount) | +| `PREDICTOR_ELEVATION_DATASET=/srv/ruaumoko-dataset` | optional terrain data | +| `PREDICTOR_SOURCE=gfs-0p50-3h` | `gfs-0p50-3h`, `gfs-0p25-3h`, `gfs-0p25-1h`, `gefs-0p50-3h` | +| `PREDICTOR_DOWNLOAD_PARALLEL=16` | concurrent GRIB downloads | +| `PREDICTOR_UPDATE_INTERVAL=6h` | forecast refresh cadence | +| `PREDICTOR_METRICS_ENABLED=true` | expose `/metrics` | + +No Docker secrets are needed — the predictor has no database or credentials. + +### Health + +- `GET /health` — liveness (always 200 while the process runs). The container + `HEALTHCHECK` calls the binary's `-healthcheck` mode (no curl in the image). +- `GET /ready` — readiness (200 only once a dataset is loaded). The gateway + should gate traffic on this; Swarm does **not** kill a container that is still + performing its first download thanks to the 120s `start_period`. + +### Metrics + +`/metrics` exposes Prometheus counters (`predictor_predictions_total`, +`predictor_downloads_total`, `predictor_download_bytes_total`) and the +`predictor_active_dataset_epoch_seconds` gauge. The service carries +`prometheus.scrape/port/path` deploy labels for Swarm service discovery; point +your central Prometheus at the `stratoflights-net` network. + +## CI/CD (Forgejo → Swarmpit) + +`.forgejo/workflows/ci-cd.yml`: + +1. **test** (every push/PR): `gofmt` check, `go vet`, `go build`, `go test -race`. +2. **build** (develop branch and `v*` tags): buildx `linux/amd64` image pushed to + `git.intra.yksa.space/web/predictor` (`:develop`, or `:` + `:latest`). +3. **deploy-staging** (develop) / **deploy-production** (`v*` tags): deploy + `docker-compose.swarm.yml` to the environment's Swarmpit stack via + `deploy/swarmpit-deploy.sh`. + +Configure runner secrets (scope staging/production via Forgejo environments): + +- `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` — container registry +- `SWARMPIT_URL`, `SWARMPIT_TOKEN`, `STACK_NAME` — Swarmpit deploy target +- `CA_CERTIFICATES` — optional PEM bundle if Swarmpit uses a private CA + +Cut a release: + +```bash +git tag v1.0.0 && git push origin v1.0.0 +``` + +## Operations + +```bash +docker service ls --filter label=com.docker.stack.namespace=predictor +docker service logs -f predictor_predictor +docker service scale predictor_predictor=2 # ≤2 per labelled node +docker service rollback predictor_predictor +``` + +Trigger a dataset refresh or inspect jobs through the admin API: + +```bash +curl -X POST http://predictor:8080/api/v1/admin/datasets -d '{"latest":true}' +curl http://predictor:8080/api/v1/admin/jobs +curl http://predictor:8080/api/v1/admin/status +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff2de42 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +# --- build stage --------------------------------------------------------- +FROM golang:1.25 AS builder + +WORKDIR /src + +# Cache module downloads. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Static, stripped binary — no CGO so it runs on distroless/scratch. +ARG VERSION=dev +ARG REVISION=unknown +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w -X main.version=${VERSION} -X main.revision=${REVISION}" \ + -o /predictor ./cmd/predictor + +# --- runtime stage ------------------------------------------------------- +# distroless/static:nonroot ships CA certificates (needed for TLS to the +# NOAA S3 mirror) and runs as uid:gid 65532:65532. +FROM gcr.io/distroless/static-debian12:nonroot AS runtime + +COPY --from=builder /predictor /predictor + +# Default data dir; mount a node-local volume here in production. +ENV PREDICTOR_DATA_DIR=/data +EXPOSE 8080 + +# Liveness probe via the binary itself — no shell/curl in the image. +HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \ + CMD ["/predictor", "-healthcheck"] + +ENTRYPOINT ["/predictor"] diff --git a/Makefile b/Makefile index 7c14792..0560271 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,20 @@ -.PHONY: build run test fmt lint clean generate-ogen help +.PHONY: build server cli compare test fmt lint clean generate-ogen docs help -# Build the application -build: - go build -o predictor ./cmd/api +# Build all binaries +build: server cli compare + +server: + go build -o bin/predictor ./cmd/predictor + +cli: + go build -o bin/predictor-cli ./cmd/predictor-cli + +compare: + go build -o bin/compare-tawhiri ./cmd/compare-tawhiri # Run locally run: - go run ./cmd/api + go run ./cmd/predictor # Run tests test: @@ -20,21 +28,29 @@ fmt: lint: golangci-lint run -# Generate ogen API code from swagger spec +# Build the numerics LaTeX doc (requires pdflatex) +docs: + cd docs && pdflatex numerics.tex + +# Regenerate ogen API code from the OpenAPI spec. The same spec is embedded by +# the api package (api/spec.go) and served at /openapi.yaml + /docs (ReDoc). generate-ogen: go run github.com/ogen-go/ogen/cmd/ogen@latest --target pkg/rest --package rest --clean api/rest/predictor.swagger.yml # Clean build artifacts clean: - rm -f predictor + rm -rf bin/ docs/numerics.pdf docs/numerics.aux docs/numerics.log -# Show help help: @echo "Available commands:" - @echo " build - Build binary" - @echo " run - Run locally" - @echo " test - Run tests" - @echo " fmt - Format code" - @echo " lint - Lint code" - @echo " generate-ogen - Generate API code from swagger spec" - @echo " clean - Remove build artifacts" + @echo " build - Build all binaries to bin/" + @echo " server - Build the HTTP server (cmd/predictor)" + @echo " cli - Build the CLI client (cmd/predictor-cli)" + @echo " compare - Build the validation tool (cmd/compare-tawhiri)" + @echo " run - Run the server with default config" + @echo " test - Run unit tests" + @echo " fmt - Format code" + @echo " lint - Lint code (golangci-lint)" + @echo " docs - Build the numerics LaTeX doc (requires pdflatex)" + @echo " generate-ogen - Regenerate ogen code from the OpenAPI spec" + @echo " clean - Remove build artifacts" diff --git a/README.md b/README.md index 579a255..3d01602 100644 --- a/README.md +++ b/README.md @@ -1,261 +1,285 @@ -# Balloon Trajectory Predictor +# stratoflights-predictor -High-altitude balloon trajectory prediction service. Predicts ascent, burst, and descent trajectories using GFS wind forecast data from NOAA. +High-altitude balloon trajectory prediction service. Forecasts ascent, descent, +and float trajectories from NOAA GFS and GEFS wind data, exposed as a REST API. -The prediction algorithms are an exact port of [Tawhiri](https://github.com/cuspaceflight/tawhiri) (Cambridge University Spaceflight) to Go, verified to produce identical results. +The trajectory engine is a propagator-and-constraint system: any flight +profile can be expressed as a chain of propagators (constant-rate ascent, +parachute descent, piecewise rates with absolute / profile-relative / +propagator-relative timing, wind drift) with attached constraints +(scalar comparisons over altitude or time, terrain contact, geographic +polygons). Constraints can stop the profile, hand off to a fallback +propagator, or clip the violated coordinate to the boundary. The legacy +Tawhiri request shape is kept as a compatibility endpoint so existing +clients work unchanged. -## Quick Start +## Quick start ```bash -# Build -make build +make build # produces bin/{predictor,predictor-cli,compare-tawhiri} +./bin/predictor # downloads ~9 GB of GFS data on first start -# Run (downloads ~9 GB of GFS data on first start, takes 30-60 min) -PREDICTOR_DATA_DIR=/tmp/predictor-data go run ./cmd/api - -# Check readiness -curl http://localhost:8080/ready - -# Run a prediction -curl 'http://localhost:8080/api/v1/prediction?launch_latitude=52.2&launch_longitude=0.1&launch_datetime=2026-03-28T12:00:00Z&launch_altitude=0&ascent_rate=5&burst_altitude=30000&descent_rate=5' +./bin/predictor-cli ready +./bin/predictor-cli predict \ + launch_latitude=52.2 launch_longitude=0.1 \ + launch_datetime=2026-03-28T12:00:00Z \ + ascent_rate=5 burst_altitude=30000 descent_rate=5 ``` ## Configuration -All configuration is via environment variables. +Layered configuration: built-in defaults < YAML file < env vars < CLI flags. -| Variable | Default | Description | -|---|---|---| -| `PREDICTOR_PORT` | `8080` | HTTP server port | -| `PREDICTOR_DATA_DIR` | `/tmp/predictor-data` | Directory for wind datasets and temp files | -| `PREDICTOR_DOWNLOAD_PARALLEL` | `8` | Max concurrent GRIB download goroutines | -| `PREDICTOR_UPDATE_INTERVAL` | `6h` | How often to check for new forecasts | -| `PREDICTOR_DATASET_TTL` | `48h` | Max age before a dataset is considered stale | -| `PREDICTOR_ELEVATION_DATASET` | `/srv/ruaumoko-dataset` | Path to elevation dataset (optional) | +| Setting | Env var | CLI flag | Default | +|---|---|---|---| +| HTTP port | `PREDICTOR_PORT` | `-port` | `8080` | +| Data directory | `PREDICTOR_DATA_DIR` | `-data-dir` | `/tmp/predictor-data` | +| Elevation dataset | `PREDICTOR_ELEVATION_DATASET` | `-elevation` | `/srv/ruaumoko-dataset` | +| Source variant | `PREDICTOR_SOURCE` | — | `gfs-0p50-3h` | +| Download parallelism | `PREDICTOR_DOWNLOAD_PARALLEL` | `-download-parallel` | `8` | +| Download bandwidth (bytes/s; 0 = unlimited) | `PREDICTOR_DOWNLOAD_BANDWIDTH` | `-download-bandwidth` | `0` | +| Scheduler interval | `PREDICTOR_UPDATE_INTERVAL` | `-update-interval` | `6h` | +| Dataset freshness TTL | `PREDICTOR_DATASET_TTL` | `-freshness-ttl` | `48h` | +| Metrics enabled | `PREDICTOR_METRICS_ENABLED` | `-metrics` | `true` | +| Metrics HTTP path | `PREDICTOR_METRICS_PATH` | `-metrics-path` | `/metrics` | +| Log level | `PREDICTOR_LOG_LEVEL` | `-log-level` | `info` | -## API +YAML config mirrors the same structure; see `internal/config/config.go`. -### `GET /api/v1/prediction` +Supported source variants: -Run a balloon trajectory prediction. +| `source` | Resolution | Cadence | Notes | +|---|---|---|---| +| `gfs-0p50-3h` | 0.5° | 3h to 192h | historical Tawhiri default | +| `gfs-0p25-3h` | 0.25° | 3h to 192h | | +| `gfs-0p25-1h` | 0.25° | 1h to 120h | | +| `gefs-0p50-3h` | 0.5° | 3h to 192h | 21-member ensemble; each member is a separate dataset | -**Parameters** (query string): +## REST API -| Parameter | Required | Description | -|---|---|---| -| `launch_latitude` | yes | Launch latitude in degrees (-90 to 90) | -| `launch_longitude` | yes | Launch longitude in degrees (-180 to 180 or 0 to 360) | -| `launch_datetime` | yes | Launch time in RFC 3339 format | -| `launch_altitude` | no | Launch altitude in metres ASL (default: 0) | -| `profile` | no | `standard_profile` (default) or `float_profile` | -| `ascent_rate` | no | Ascent rate in m/s (default: 5) | -| `burst_altitude` | no | Burst altitude in metres (default: 28000) | -| `descent_rate` | no | Sea-level descent rate in m/s (default: 5) | -| `float_altitude` | no | Float altitude in metres (float_profile only) | -| `stop_datetime` | no | Float end time (float_profile only, default: +24h) | +### Tawhiri-compatible (legacy) -**Response** (Tawhiri-compatible): +`GET /api/v1/prediction` — preserves the exact request and response shape of +the upstream Cambridge University Spaceflight predictor. + +`GET /ready` — returns `{"status":"ok", "dataset_time":"..."}` once a dataset +is loaded. + +### Profile-driven (synchronous) + +`POST /api/v2/prediction` — execute a profile synchronously and return the +trajectory. Request shape: ```json { - "prediction": [ + "launch": { "time": "2026-03-28T12:00:00Z", "latitude": 52.2, "longitude": 0.1, "altitude": 0 }, + "direction": "forward", + "profile": [ { - "stage": "ascent", - "trajectory": [ - {"datetime": "2026-03-28T12:00:00Z", "latitude": 52.2, "longitude": 0.1, "altitude": 0}, - ... - ] + "name": "ascent", + "model": { "type": "constant_rate", "rate": 5, "include_wind": true }, + "constraints": [{ "type": "altitude", "op": ">=", "limit": 30000 }] }, { - "stage": "descent", - "trajectory": [...] + "name": "descent", + "model": { "type": "parachute_descent", "sea_level_rate": 5, "include_wind": true }, + "constraints": [{ "type": "terrain_contact" }] } ], - "metadata": { - "start_datetime": "...", - "complete_datetime": "..." - }, - "request": { - "dataset": "2026-03-28T06:00:00Z", - "launch_latitude": 52.2, - ... + "globals": [{ "type": "time", "op": ">", "limit": 1799999999 }] +} +``` + +Model types: `constant_rate`, `parachute_descent`, `piecewise`, `wind`. +Constraint types: `altitude`, `time`, `terrain_contact`, `polygon`. +Operators: `<`, `<=`, `>`, `>=`, `==`. Actions: `stop` (default), `fallback`, `clip`. +Direction: `forward` (default) or `reverse`. + +Piecewise segments support a `reference` field (`absolute`, `profile_start`, or +`propagator_start`) so a single rate schedule can be reused across profiles +with different launch times. + +The response includes per-stage trajectories, detailed termination info +(violation state + refined state + constraint name), an `events` array of +non-fatal observations (e.g. `above_model` when altitude exceeded the dataset's +highest pressure level), and dataset metadata. + +### Profile-driven (asynchronous) + +`POST /api/v1/predictions` — enqueue a prediction. Returns `202` with a job ID: + +```json +{"id":"842107d9-…","status":"pending","created_at":"…"} +``` + +`GET /api/v1/predictions/{id}` — poll status. When `status == "complete"`, +the response includes a `result` field with the full v2 PredictionResponse. + +`DELETE /api/v1/predictions/{id}` — cancel a queued job. + +A worker pool (`http.async_workers`, default 4) services the queue; completed +results are retained for `http.async_result_ttl` (default 1h). + +### Dataset admin + +``` +GET /api/v1/admin/datasets list stored datasets (epoch, subset, coverage, loaded?) +POST /api/v1/admin/datasets trigger a download +DELETE /api/v1/admin/datasets/{filename} delete by filename (DatasetID.Filename()) +GET /api/v1/admin/jobs list every download job +GET /api/v1/admin/jobs/{id} fetch one job +DELETE /api/v1/admin/jobs/{id} cancel a running download +GET /api/v1/admin/status consolidated status (uptime, mem, goroutines, jobs, datasets) +``` + +Trigger-download body: + +```json +{ + "epoch": "2026-03-28T06:00:00Z", + "subset": { + "region": { "min_lat": -10, "max_lat": 10, "min_lng": 0, "max_lng": 30 }, + "hour_range": { "min_hour": 0, "max_hour": 72 }, + "members": [5] } } ``` -### `GET /ready` +`{"latest": true}` is a shortcut that refreshes the latest global dataset +for the configured source. Each `(epoch, subset)` combination is a +separate dataset; the loader auto-selects which loaded dataset covers a +given prediction query. -Health check. Returns `{"status": "ok"}` when a dataset is loaded. +### Wind visualization -## Elevation Dataset +`GET /api/v1/wind/field` — a velocity grid in the +[wind-js-server](https://github.com/danwild/wind-js-server) / leaflet-velocity +format (a two-element `[U, V]` array of `{header, data}` records), suitable for +animated particle layers. Query params: `time`, `altitude`, `min_lat`, +`max_lat`, `min_lng`, `max_lng`, `step` (degrees, min `0.25`). Responses are +cached in memory by parameters. -Without elevation data, descent terminates at sea level (altitude <= 0). With elevation data, descent terminates at ground level, matching Tawhiri's behaviour. +`GET /api/v1/wind/meta` — active dataset source, epoch, suggested altitudes, +and bounding box. -### Building the elevation dataset +A runnable browser client is in [`examples/wind-demo`](examples/wind-demo). -The elevation dataset uses ETOPO 2022 at 30 arc-second resolution, converted to a ruaumoko-compatible binary format (21601 x 43200 grid of int16 little-endian elevation values in metres). +### Documentation & metrics -**Requirements**: Python 3, xarray, netcdf4, numpy. +`GET /docs` serves a [ReDoc](https://github.com/Redocly/redoc) rendering of the +full OpenAPI spec, which is also available raw at `GET /openapi.yaml`. -```bash -pip install xarray netcdf4 numpy - -# Downloads ~1.1 GB from NOAA, produces ~1.74 GB binary file -python3 scripts/build_elevation.py /tmp/predictor-data/ruaumoko-dataset -``` - -To skip the download if you already have the ETOPO NetCDF file: - -```bash -ETOPO_NC_PATH=/path/to/ETOPO_2022_v1_30s_N90W180_surface.nc \ - python3 scripts/build_elevation.py /tmp/predictor-data/ruaumoko-dataset -``` - -The ETOPO 2022 NetCDF can be manually downloaded from: -https://www.ncei.noaa.gov/products/etopo-global-relief-model - -### Using the elevation dataset - -```bash -PREDICTOR_ELEVATION_DATASET=/tmp/predictor-data/ruaumoko-dataset go run ./cmd/api -``` - -If the file doesn't exist or can't be read, the service starts normally with a warning and falls back to sea-level termination. +`GET /metrics` — Prometheus text exposition. Counters: +`predictor_predictions_total{profile,status}`, `predictor_downloads_total`, +`predictor_download_bytes_total`, and a gauge +`predictor_active_dataset_epoch_seconds`. ## Architecture +The entire REST API is defined by one OpenAPI spec and served by an +[ogen](https://ogen.dev)-generated server; the `internal/api` package only +implements the generated `Handler` interface, mapping between the wire types +and the engine/dataset/wind subsystems. `/metrics`, `/docs`, and +`/openapi.yaml` are mounted on the same `http.ServeMux` alongside it. + ``` -cmd/api/main.go Entry point, config, scheduler, HTTP server +cmd/ + predictor/ main server + predictor-cli/ HTTP client + compare-tawhiri/ end-to-end validation against the public Tawhiri instance +api/ + rest/predictor.swagger.yml OpenAPI 3 spec — ogen input AND served at /openapi.yaml + spec.go embeds the spec (go:embed) for the docs handler internal/ - dataset/ - dataset.go Shape constants, pressure levels, S3 URLs - file.go mmap-backed dataset file (read/write/blit) - downloader/ - downloader.go S3 partial GRIB download (idx + range requests) - idx.go NOAA .idx file parser - config.go Environment-based configuration - elevation/ - elevation.go Ruaumoko-compatible elevation dataset (mmap int16) - prediction/ - interpolate.go 4D wind interpolation (time, lat, lon, altitude) - solver.go RK4 integrator with binary search termination - models.go Ascent, descent, wind models; flight profiles - warnings.go Prediction warning counters - service/ - service.go Dataset lifecycle, concurrent-safe access - transport/ - middleware/log.go Request logging middleware - rest/ - handler/handler.go ogen API handler implementation - handler/deps.go Service interface - transport.go ogen HTTP server, CORS -api/rest/predictor.swagger.yml OpenAPI 3.0 spec -pkg/rest/ Generated ogen code (17 files) -scripts/ - build_elevation.py ETOPO 2022 to ruaumoko converter + numerics/ performance-critical core: interpolation, bisection, + RK4 + crossing refinement, atmosphere density, vector + and polygon math (portable to C/Rust) + engine/ propagator + constraint orchestration + registry (thin over numerics) + weather/ WindField interface; gfs/ — variant-parameterized GFS cube + sampler + datasets/ Source / Storage / Manager + transactional, resumable, subsettable downloads + grib/ — shared GRIB downloader skeleton (idx parser, HTTP, parallel blit) + gfs/ — GFS Source (URL templating only) + gefs/ — GEFS Source (URL templating + member resolution) + windviz/ cube-agnostic wind-field rasterizer + cache + elevation/ ruaumoko-format ground elevation reader + config/ layered file+env+CLI config + metrics/ Sink interface + Prometheus text impl + api/ ogen Handler implementation + handler.go — composite handler + NewError + prediction.go — v1 (Tawhiri), v2, async predictions + datasets.go — dataset + job admin + status + wind.go — wind visualization endpoints + mapping.go — ogen <-> engine conversions + async/ — prediction worker pool + docs/ — ReDoc page + /openapi.yaml + middleware/ — ogen logging, CORS +pkg/rest/ ogen-generated server/client/types (regenerate via `make generate-ogen`) +examples/wind-demo/ Leaflet + leaflet-velocity sample client +docs/numerics.tex end-to-end mathematical reference +scripts/build_elevation.py ETOPO 2022 → ruaumoko converter ``` -## Wind Dataset +## Subsetting and ensembles -The service downloads GFS 0.5-degree forecast data from NOAA S3: +Each stored dataset is identified by `DatasetID = (epoch, subset)`. A subset +restricts the data fetched by region, forecast-hour range, or ensemble +member. The downloader honours the subset (skipping out-of-range +forecast steps; member-selecting URLs for GEFS), the storage tracks each +subset as a separate file (filename includes a deterministic subset key), +and the Manager exposes coverage so per-query dataset selection picks the +right one. -| Property | Value | +## Deployment + +The service ships as a single static binary in a distroless image and runs in +three configurations — see **[DEPLOYMENT.md](DEPLOYMENT.md)** for the full guide. + +| Environment | File | |---|---| -| Source | `noaa-gfs-bdp-pds.s3.amazonaws.com` | -| Resolution | 0.5 degrees | -| Grid | 361 lat x 720 lon | -| Time steps | 65 (every 3 hours, 0-192h) | -| Pressure levels | 47 (1000 to 1 hPa) | -| Variables | Geopotential height, U-wind, V-wind | -| Dataset size | 9,528,667,200 bytes (~8.87 GiB) | -| Update cadence | Every 6 hours (GFS runs at 00, 06, 12, 18 UTC) | +| Local dev | `docker compose up --build` (`docker-compose.yml`) | +| Staging (single host, + Prometheus) | `docker-compose.staging.yml` | +| Production (Docker Swarm) | `docker-compose.swarm.yml` | -Data is downloaded using HTTP Range requests against `.idx` index files, fetching only the needed GRIB messages (HGT, UGRD, VGRD at 47 pressure levels). Full download takes 30-60 minutes depending on bandwidth. +Production runs on Docker Swarm pinned to ≤2 nodes labelled `predictor.data=true`, +each holding one copy of the dataset on **node-local disk** (never NFS). +Replicas spread across the two nodes for redundancy; multiple replicas per node +share the node's dataset and coordinate downloads with a file lock so only one +fetches the ~9 GiB cube. The predictor is an internal backend reached by the +API gateway over an overlay network; it enforces no auth itself. CI/CD is a +Forgejo pipeline that builds, tests, and deploys to Swarmpit +(`.forgejo/workflows/ci-cd.yml`). -The dataset is stored as a memory-mapped flat binary file of float32 values in C-order with shape `(65, 47, 3, 361, 720)`. +The async prediction API stores results in memory only; behind a load balancer, +clients must poll the same instance they submitted to (or use the synchronous +`/api/v2/prediction`). -## Prediction Algorithms +### Health -All algorithms are exact ports of the reference implementations in Tawhiri. The following sections describe the key components. +- `GET /health` — liveness, always 200 while the process runs (used by the + container `HEALTHCHECK` via `predictor -healthcheck`). +- `GET /ready` — readiness, 200 only once a dataset is loaded. -### Interpolation (`internal/prediction/interpolate.go`) +## Validation -4D wind interpolation from the dataset grid to arbitrary coordinates. +`./bin/compare-tawhiri --server http://localhost:8080` runs an identical +prediction against the local server and the public SondeHub Tawhiri +instance, reporting the great-circle distance between landing points. -1. **Trilinear weights** (`pick3`): compute 8 interpolation weights for the (hour, lat, lon) cube corners. -2. **Altitude search** (`search`): binary search on interpolated geopotential height to find the two pressure levels bracketing the target altitude. -3. **Wind extraction** (`interp4`): 8-point weighted sum at each bracket level, then linear interpolation between levels. +## Numerical methods -Reference: `tawhiri/interpolate.pyx` - -### Solver (`internal/prediction/solver.go`) - -4th-order Runge-Kutta integrator with dt = 60 seconds. - -- State vector: (latitude, longitude, altitude) in degrees and metres. -- Time: UNIX timestamp in seconds. -- Longitude is kept in [0, 360) via Python-style modulo after each `vecadd`. -- When a terminator fires, binary search refinement (tolerance 0.01) finds the precise termination point between the last good step and the first terminated step. -- Longitude interpolation (`lngLerp`) handles the 0/360 wrap-around. - -Reference: `tawhiri/solver.pyx` - -### Models (`internal/prediction/models.go`) - -- **Constant ascent**: vertical velocity = ascent_rate m/s. -- **Drag descent**: NASA atmosphere density model with drag coefficient = sea_level_rate * 1.1045. Descent rate increases with altitude due to thinner air. -- **Wind velocity**: u, v components from interpolation converted to degrees/second: `dlat = (180/pi) * v / (R)`, `dlng = (180/pi) * u / (R * cos(lat))` where R = 6371009 + altitude. -- **Linear model**: sum of component models (e.g., wind + ascent). -- **Elevation termination**: `ground_elevation > altitude` using ruaumoko dataset. - -Reference: `tawhiri/models.py` - -### Profiles - -- **standard_profile**: ascent (constant rate + wind) until burst altitude, then descent (drag + wind) until ground level. -- **float_profile**: ascent to float altitude, then drift at constant altitude until stop time. - -## Verification - -The predictor has been verified against the reference Tawhiri implementation: - -| Test | Result | -|---|---| -| Dataset (step 0): 36.6M float32 values vs Python/cfgrib | 0 mismatches, max diff = 0.0 | -| Prediction burst point vs public Tawhiri API | Identical (lat, lon, alt all match) | -| Prediction landing point vs public Tawhiri API | Identical lat/lon, 5m altitude diff (different elevation datasets) | -| Descent point count | Identical (46 points) | -| Ascent point count | Identical (101 points) | - -## Development - -```bash -# Regenerate ogen API code after modifying the swagger spec -make generate-ogen - -# Run tests -make test - -# Format -make fmt -``` - -### Comparison tools - -```bash -# Compare single dataset step against Python/cfgrib reference -go run ./cmd/compare_step0 - -# Run prediction and compare against public Tawhiri API -go run ./cmd/compare_prediction -``` +`docs/numerics.tex` is the complete mathematical reference: state vector, +equations of motion (constant rate, parachute drag, piecewise, wind +transport), numerical methods (multilinear interpolation, bisection, +classical RK4, binary-search termination refinement), constraint +geometry (scalar comparisons, point-in-polygon with antimeridian +handling), and design notes on the deferred items (WGS84/ECEF +coordinate system, mass-aware drift, Monte Carlo). ## References -- [Tawhiri](https://github.com/cuspaceflight/tawhiri) — Reference Python/Cython predictor (Cambridge University Spaceflight) -- [tawhiri-downloader](https://github.com/cuspaceflight/tawhiri-downloader) — OCaml dataset downloader -- [ruaumoko](https://github.com/cuspaceflight/ruaumoko) — Global elevation dataset -- [NOAA GFS](https://www.ncei.noaa.gov/products/weather-climate-models/global-forecast) — Global Forecast System -- [NOAA GFS on S3](https://noaa-gfs-bdp-pds.s3.amazonaws.com/index.html) — Public S3 bucket -- [ETOPO 2022](https://www.ncei.noaa.gov/products/etopo-global-relief-model) — Global relief model for elevation data -- [SondeHub Tawhiri API](https://api.v2.sondehub.org/tawhiri) — Public Tawhiri instance for comparison +- [Tawhiri](https://github.com/cuspaceflight/tawhiri) — reference Python/Cython predictor +- [ruaumoko](https://github.com/cuspaceflight/ruaumoko) — global elevation dataset format +- [NOAA GFS](https://www.ncei.noaa.gov/products/weather-climate-models/global-forecast) +- [NOAA GEFS](https://www.ncei.noaa.gov/products/weather-climate-models/global-ensemble-forecast) +- [ETOPO 2022](https://www.ncei.noaa.gov/products/etopo-global-relief-model) +- [SondeHub Tawhiri API](https://api.v2.sondehub.org/tawhiri) — public Tawhiri instance diff --git a/api/rest/predictor.swagger.yml b/api/rest/predictor.swagger.yml index 64ec316..67534c7 100644 --- a/api/rest/predictor.swagger.yml +++ b/api/rest/predictor.swagger.yml @@ -1,84 +1,37 @@ -openapi: 3.0.4 +openapi: 3.0.3 info: - title: Predictor API - version: 0.0.1 + title: stratoflights-predictor API + version: "1.0.0" + description: | + Balloon trajectory prediction and wind-dataset management. + + Three prediction surfaces are exposed: + + * **`GET /api/v1/prediction`** — Tawhiri-compatible, drop-in for the + Cambridge University Spaceflight predictor. + * **`POST /api/v2/prediction`** — profile-driven synchronous prediction + (arbitrary chains of propagators with constraints). + * **`POST /api/v1/predictions`** — the same profile API run asynchronously + via a worker pool, polled by job id. + + Dataset management (download, list, delete, job status) lives under + `/api/v1/admin/`, and wind-field visualization data (leaflet-velocity / + wind-layer format) under `/api/v1/wind/`. + +servers: + - url: / + description: This server. + +tags: + - name: Prediction + - name: Datasets + - name: Wind + - name: Health + paths: - /api/v1/prediction: - get: - tags: - - Prediction - summary: Perform prediction - operationId: performPrediction - parameters: - - in: query - name: launch_latitude - required: true - schema: - type: number - - in: query - name: launch_longitude - required: true - schema: - type: number - - in: query - name: launch_datetime - required: true - schema: - type: string - format: date-time - - in: query - name: launch_altitude - schema: - type: number - - in: query - name: profile - schema: - type: string - enum: [standard_profile, float_profile] - default: standard_profile - - in: query - name: ascent_rate - schema: - type: number - - in: query - name: burst_altitude - schema: - type: number - - in: query - name: descent_rate - schema: - type: number - - in: query - name: float_altitude - schema: - type: number - - in: query - name: stop_datetime - schema: - type: string - format: date-time - - in: query - name: dataset - schema: - type: string - format: date-time - responses: - "200": - description: Prediction response - content: - application/json: - schema: - $ref: '#/components/schemas/PredictionResponse' - default: - description: Error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' /ready: get: - tags: - - Health + tags: [Health] summary: Readiness check operationId: readinessCheck responses: @@ -87,113 +40,652 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReadinessResponse' + $ref: "#/components/schemas/ReadinessResponse" default: - description: Error + $ref: "#/components/responses/DefaultError" + + /api/v1/prediction: + get: + tags: [Prediction] + summary: Tawhiri-compatible prediction + operationId: performPrediction + parameters: + - { in: query, name: launch_latitude, required: true, schema: { type: number } } + - { in: query, name: launch_longitude, required: true, schema: { type: number } } + - { in: query, name: launch_datetime, required: true, schema: { type: string, format: date-time } } + - { in: query, name: launch_altitude, schema: { type: number } } + - { in: query, name: profile, schema: { type: string, enum: [standard_profile, float_profile], default: standard_profile } } + - { in: query, name: ascent_rate, schema: { type: number } } + - { in: query, name: burst_altitude, schema: { type: number } } + - { in: query, name: descent_rate, schema: { type: number } } + - { in: query, name: float_altitude, schema: { type: number } } + - { in: query, name: stop_datetime, schema: { type: string, format: date-time } } + - { in: query, name: dataset, schema: { type: string, format: date-time } } + responses: + "200": + description: Prediction response content: application/json: schema: - $ref: '#/components/schemas/Error' + $ref: "#/components/schemas/PredictionResponse" + default: + $ref: "#/components/responses/DefaultError" + + /api/v2/prediction: + post: + tags: [Prediction] + summary: Profile-driven prediction (synchronous) + operationId: performPredictionV2 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PredictionV2Request" + responses: + "200": + description: Prediction result + content: + application/json: + schema: + $ref: "#/components/schemas/PredictionV2Response" + default: + $ref: "#/components/responses/DefaultError" + + /api/v1/predictions: + post: + tags: [Prediction] + summary: Enqueue an asynchronous prediction + operationId: createPredictionJob + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PredictionV2Request" + responses: + "202": + description: Job accepted + content: + application/json: + schema: + $ref: "#/components/schemas/PredictionJob" + default: + $ref: "#/components/responses/DefaultError" + + /api/v1/predictions/{id}: + get: + tags: [Prediction] + summary: Poll an asynchronous prediction job + operationId: getPredictionJob + parameters: + - { in: path, name: id, required: true, schema: { type: string } } + responses: + "200": + description: Job status (with result when complete) + content: + application/json: + schema: + $ref: "#/components/schemas/PredictionJob" + default: + $ref: "#/components/responses/DefaultError" + delete: + tags: [Prediction] + summary: Cancel a queued prediction job + operationId: cancelPredictionJob + parameters: + - { in: path, name: id, required: true, schema: { type: string } } + responses: + "204": + description: Cancelled + default: + $ref: "#/components/responses/DefaultError" + + /api/v1/admin/datasets: + get: + tags: [Datasets] + summary: List stored datasets + operationId: listDatasets + responses: + "200": + description: Stored datasets + content: + application/json: + schema: + $ref: "#/components/schemas/DatasetList" + default: + $ref: "#/components/responses/DefaultError" + post: + tags: [Datasets] + summary: Trigger a dataset download + operationId: triggerDatasetDownload + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DownloadRequest" + responses: + "202": + description: Download accepted + content: + application/json: + schema: + $ref: "#/components/schemas/DownloadAccepted" + default: + $ref: "#/components/responses/DefaultError" + + /api/v1/admin/datasets/{name}: + delete: + tags: [Datasets] + summary: Delete a stored dataset by filename + operationId: deleteDataset + parameters: + - { in: path, name: name, required: true, schema: { type: string } } + responses: + "204": + description: Deleted + default: + $ref: "#/components/responses/DefaultError" + + /api/v1/admin/jobs: + get: + tags: [Datasets] + summary: List dataset download jobs + operationId: listDatasetJobs + responses: + "200": + description: Download jobs + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DownloadJob" + default: + $ref: "#/components/responses/DefaultError" + + /api/v1/admin/jobs/{id}: + get: + tags: [Datasets] + summary: Get a dataset download job + operationId: getDatasetJob + parameters: + - { in: path, name: id, required: true, schema: { type: string } } + responses: + "200": + description: Download job + content: + application/json: + schema: + $ref: "#/components/schemas/DownloadJob" + default: + $ref: "#/components/responses/DefaultError" + delete: + tags: [Datasets] + summary: Cancel a running download job + operationId: cancelDatasetJob + parameters: + - { in: path, name: id, required: true, schema: { type: string } } + responses: + "204": + description: Cancelled + default: + $ref: "#/components/responses/DefaultError" + + /api/v1/admin/status: + get: + tags: [Datasets] + summary: Service status summary + operationId: getServiceStatus + responses: + "200": + description: Status + content: + application/json: + schema: + $ref: "#/components/schemas/StatusResponse" + default: + $ref: "#/components/responses/DefaultError" + + /api/v1/wind/meta: + get: + tags: [Wind] + summary: Wind-field visualization metadata + operationId: getWindMeta + responses: + "200": + description: Metadata describing the active dataset for visualization + content: + application/json: + schema: + $ref: "#/components/schemas/WindMeta" + default: + $ref: "#/components/responses/DefaultError" + + /api/v1/wind/field: + get: + tags: [Wind] + summary: Wind-field velocity grid (leaflet-velocity / wind-layer format) + operationId: getWindField + parameters: + - { in: query, name: time, schema: { type: string, format: date-time } } + - { in: query, name: altitude, schema: { type: number } } + - { in: query, name: min_lat, schema: { type: number } } + - { in: query, name: max_lat, schema: { type: number } } + - { in: query, name: min_lng, schema: { type: number } } + - { in: query, name: max_lng, schema: { type: number } } + - { in: query, name: step, schema: { type: number } } + responses: + "200": + description: Two-component (U, V) velocity grid + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WindComponent" + default: + $ref: "#/components/responses/DefaultError" components: + responses: + DefaultError: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + schemas: Error: type: object - required: - - error + required: [error] properties: error: type: object - required: - - type - - description + required: [type, description] properties: - type: - type: string - description: - type: string + type: { type: string } + description: { type: string } + + ReadinessResponse: + type: object + required: [status] + properties: + status: { type: string, enum: [ok, not_ready, error] } + dataset_time: { type: string, format: date-time } + error_message: { type: string } + + # --- Tawhiri v1 --------------------------------------------------------- PredictionResponse: type: object - required: - - prediction - - metadata + required: [prediction, metadata] properties: request: type: object properties: - dataset: - type: string - launch_latitude: - type: number - launch_longitude: - type: number - launch_datetime: - type: string - launch_altitude: - type: number - profile: - type: string - ascent_rate: - type: number - burst_altitude: - type: number - descent_rate: - type: number + dataset: { type: string } + launch_latitude: { type: number } + launch_longitude: { type: number } + launch_datetime: { type: string } + launch_altitude: { type: number } + profile: { type: string } + ascent_rate: { type: number } + burst_altitude: { type: number } + descent_rate: { type: number } prediction: type: array items: type: object - required: - - stage - - trajectory + required: [stage, trajectory] properties: - stage: - type: string - enum: ["ascent", "descent", "float"] + stage: { type: string, enum: [ascent, descent, float] } trajectory: type: array items: - type: object - required: - - datetime - - latitude - - longitude - - altitude - properties: - datetime: - type: string - format: date-time - latitude: - type: number - longitude: - type: number - altitude: - type: number + $ref: "#/components/schemas/TawhiriPoint" metadata: type: object - required: - - start_datetime - - complete_datetime + required: [start_datetime, complete_datetime] properties: - start_datetime: - type: string - format: date-time - complete_datetime: - type: string - format: date-time + start_datetime: { type: string, format: date-time } + complete_datetime: { type: string, format: date-time } warnings: type: object additionalProperties: true - ReadinessResponse: + + TawhiriPoint: type: object - required: - - status + required: [datetime, latitude, longitude, altitude] properties: - status: - type: string - enum: [ok, not_ready, error] - dataset_time: - type: string - format: date-time - error_message: + datetime: { type: string, format: date-time } + latitude: { type: number } + longitude: { type: number } + altitude: { type: number } + + # --- v2 profile-driven -------------------------------------------------- + PredictionV2Request: + type: object + required: [launch, profile] + description: | + A profile-driven prediction. `profile` is an ordered chain of + propagators; each integrates from where the previous ended. A stage's + `constraints` decide when it ends and what happens next: stop the + profile, hand off to `fallback_index`, or clip to the boundary. + properties: + launch: { $ref: "#/components/schemas/Launch" } + direction: type: string + enum: [forward, reverse] + default: forward + description: forward integrates launch→landing; reverse integrates backward in time. + profile: + type: array + items: { $ref: "#/components/schemas/StageSpec" } + globals: + type: array + description: constraints evaluated on every stage in addition to its own. + items: { $ref: "#/components/schemas/ConstraintSpec" } + options: { $ref: "#/components/schemas/Options" } + example: + launch: { time: "2026-03-28T12:00:00Z", latitude: 52.2, longitude: 0.1, altitude: 0 } + profile: + - name: ascent + model: { type: constant_rate, rate: 5, include_wind: true } + constraints: [{ type: altitude, op: ">=", limit: 30000 }] + - name: descent + model: { type: parachute_descent, sea_level_rate: 5, include_wind: true } + constraints: [{ type: terrain_contact }] + + Launch: + type: object + required: [time, latitude, longitude] + properties: + time: { type: string, format: date-time } + latitude: { type: number } + longitude: { type: number } + altitude: { type: number } + + StageSpec: + type: object + required: [name, model] + properties: + name: { type: string } + model: { $ref: "#/components/schemas/ModelSpec" } + constraints: + type: array + items: { $ref: "#/components/schemas/ConstraintSpec" } + fallback_index: { type: integer } + + ModelSpec: + type: object + required: [type] + properties: + type: { type: string, enum: [constant_rate, parachute_descent, piecewise, wind] } + rate: { type: number } + sea_level_rate: { type: number } + include_wind: { type: boolean } + segments: + type: array + items: { $ref: "#/components/schemas/PiecewiseSegment" } + + PiecewiseSegment: + type: object + required: [until, rate] + properties: + until: { type: number } + rate: { type: number } + reference: { type: string, enum: [absolute, profile_start, propagator_start], default: absolute } + + ConstraintSpec: + type: object + required: [type] + properties: + type: { type: string, enum: [altitude, time, terrain_contact, polygon] } + op: { type: string, enum: ["<", "<=", ">", ">=", "=="] } + limit: { type: number } + action: { type: string, enum: [stop, fallback, clip], default: stop } + mode: { type: string, enum: [inside, outside] } + label: { type: string } + vertices: + type: array + items: { $ref: "#/components/schemas/PolygonVertex" } + + PolygonVertex: + type: object + required: [lat, lng] + properties: + lat: { type: number } + lng: { type: number } + + Options: + type: object + properties: + step_seconds: { type: number } + tolerance: { type: number } + + PredictionV2Response: + type: object + required: [stages, dataset, started_at, completed_at] + properties: + stages: + type: array + items: { $ref: "#/components/schemas/StageResult" } + events: + type: array + items: { $ref: "#/components/schemas/EventSummary" } + dataset: { $ref: "#/components/schemas/DatasetInfo" } + started_at: { type: string, format: date-time } + completed_at: { type: string, format: date-time } + + StageResult: + type: object + required: [name, outcome, trajectory] + properties: + name: { type: string } + outcome: { type: string, enum: [stopped, fallback, continued] } + constraint: { type: string } + termination: { $ref: "#/components/schemas/TerminationInfo" } + events: + type: array + items: { $ref: "#/components/schemas/EventSummary" } + trajectory: + type: array + items: { $ref: "#/components/schemas/TrajectoryPoint" } + + TrajectoryPoint: + type: object + required: [time, latitude, longitude, altitude] + properties: + time: { type: string, format: date-time } + latitude: { type: number } + longitude: { type: number } + altitude: { type: number } + + GeoState: + type: object + required: [lat, lng, altitude] + properties: + lat: { type: number } + lng: { type: number } + altitude: { type: number } + + TerminationInfo: + type: object + required: [violation_time, violation_state, refined_time, refined_state] + properties: + violation_time: { type: string, format: date-time } + violation_state: { $ref: "#/components/schemas/GeoState" } + refined_time: { type: string, format: date-time } + refined_state: { $ref: "#/components/schemas/GeoState" } + + EventSummary: + type: object + required: [type, count] + properties: + type: { type: string } + count: { type: integer, format: int64 } + first_time: { type: number } + last_time: { type: number } + first_state: { $ref: "#/components/schemas/GeoState" } + last_state: { $ref: "#/components/schemas/GeoState" } + message: { type: string } + + DatasetInfo: + type: object + required: [source, epoch] + properties: + source: { type: string } + epoch: { type: string, format: date-time } + + # --- async jobs --------------------------------------------------------- + PredictionJob: + type: object + required: [id, status, created_at] + properties: + id: { type: string } + status: { type: string, enum: [pending, running, complete, failed, cancelled] } + created_at: { type: string, format: date-time } + started_at: { type: string, format: date-time } + completed_at: { type: string, format: date-time } + error: { type: string } + result: { $ref: "#/components/schemas/PredictionV2Response" } + + # --- dataset admin ------------------------------------------------------ + Region: + type: object + required: [min_lat, max_lat, min_lng, max_lng] + properties: + min_lat: { type: number } + max_lat: { type: number } + min_lng: { type: number } + max_lng: { type: number } + + HourRange: + type: object + required: [min_hour, max_hour] + properties: + min_hour: { type: integer } + max_hour: { type: integer } + + SubsetSpec: + type: object + properties: + region: { $ref: "#/components/schemas/Region" } + hour_range: { $ref: "#/components/schemas/HourRange" } + members: + type: array + items: { type: integer } + + Coverage: + type: object + required: [region, start_time, end_time] + properties: + region: { $ref: "#/components/schemas/Region" } + start_time: { type: string, format: date-time } + end_time: { type: string, format: date-time } + + DownloadRequest: + type: object + properties: + epoch: { type: string, format: date-time } + latest: { type: boolean } + subset: { $ref: "#/components/schemas/SubsetSpec" } + + DownloadAccepted: + type: object + required: [job_id] + properties: + job_id: { type: string } + + DatasetEntry: + type: object + required: [filename, epoch, loaded] + properties: + filename: { type: string } + epoch: { type: string, format: date-time } + subset: { $ref: "#/components/schemas/SubsetSpec" } + coverage: { $ref: "#/components/schemas/Coverage" } + loaded: { type: boolean } + + DatasetList: + type: object + required: [source, datasets] + properties: + source: { type: string } + datasets: + type: array + items: { $ref: "#/components/schemas/DatasetEntry" } + + DownloadJob: + type: object + required: [id, source, dataset, epoch, status, started_at, total_units, done_units, bytes] + properties: + id: { type: string } + source: { type: string } + dataset: { type: string } + epoch: { type: string, format: date-time } + status: { type: string, enum: [pending, running, complete, failed, cancelled] } + started_at: { type: string, format: date-time } + ended_at: { type: string, format: date-time } + error: { type: string } + total_units: { type: integer } + done_units: { type: integer } + bytes: { type: integer, format: int64 } + + StatusResponse: + type: object + required: [source, uptime, goroutines, memory_mb, jobs_by_status, stored_datasets, loaded_datasets] + properties: + source: { type: string } + uptime: { type: string } + goroutines: { type: integer } + memory_mb: { type: integer, format: int64 } + jobs_by_status: + type: object + additionalProperties: { type: integer } + stored_datasets: { type: integer } + loaded_datasets: { type: integer } + + # --- wind visualization ------------------------------------------------- + WindMeta: + type: object + required: [source, epoch, default_step, min_step, suggested_altitudes, bbox] + properties: + source: { type: string } + epoch: { type: string, format: date-time } + default_step: { type: number } + min_step: { type: number } + suggested_altitudes: + type: array + items: { type: integer } + bbox: { $ref: "#/components/schemas/Region" } + + WindComponent: + type: object + required: [header, data] + properties: + header: { $ref: "#/components/schemas/WindHeader" } + data: + type: array + items: { type: number } + + WindHeader: + type: object + required: [parameterCategory, parameterNumber, nx, ny, lo1, la1, lo2, la2, dx, dy, refTime, forecastTime] + properties: + parameterCategory: { type: integer } + parameterNumber: { type: integer } + parameterNumberName: { type: string } + parameterUnit: { type: string } + nx: { type: integer } + ny: { type: integer } + lo1: { type: number } + la1: { type: number } + lo2: { type: number } + la2: { type: number } + dx: { type: number } + dy: { type: number } + refTime: { type: string } + forecastTime: { type: integer } diff --git a/api/spec.go b/api/spec.go new file mode 100644 index 0000000..4a4616e --- /dev/null +++ b/api/spec.go @@ -0,0 +1,13 @@ +// Package apispec embeds the OpenAPI specification so it can be served at +// runtime (for the ReDoc documentation page and /openapi.yaml) without +// shipping a separate file alongside the binary. +// +// The spec at rest/predictor.swagger.yml is the single source of truth: it +// is both the ogen code-generation input (see the Makefile generate-ogen +// target) and the document served by the API's docs handler. +package apispec + +import _ "embed" + +//go:embed rest/predictor.swagger.yml +var Spec []byte diff --git a/cmd/api/main.go b/cmd/api/main.go deleted file mode 100644 index 08d12e3..0000000 --- a/cmd/api/main.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - "time" - - "predictor-refactored/internal/downloader" - "predictor-refactored/internal/service" - "predictor-refactored/internal/transport/rest" - "predictor-refactored/internal/transport/rest/handler" - - "github.com/go-co-op/gocron" - "go.uber.org/zap" -) - -func main() { - log, err := zap.NewProduction() - if err != nil { - panic(err) - } - defer log.Sync() - - cfg := downloader.LoadConfig() - log.Info("configuration loaded", - zap.String("data_dir", cfg.DataDir), - zap.Int("parallel", cfg.Parallel), - zap.Duration("update_interval", cfg.UpdateInterval), - zap.Duration("dataset_ttl", cfg.DatasetTTL)) - - if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { - log.Fatal("failed to create data directory", zap.Error(err)) - } - - svc := service.New(cfg, log) - defer svc.Close() - - // Load elevation dataset (optional — falls back to sea-level termination) - elevPath := "/srv/ruaumoko-dataset" - if v := os.Getenv("PREDICTOR_ELEVATION_DATASET"); v != "" { - elevPath = v - } - svc.LoadElevation(elevPath) - - // Initial dataset load (async so the server starts immediately) - go func() { - log.Info("performing initial dataset update...") - if err := svc.Update(context.Background()); err != nil { - log.Error("initial dataset update failed", zap.Error(err)) - } else { - log.Info("initial dataset update complete") - } - }() - - // Scheduler for periodic dataset updates - scheduler := gocron.NewScheduler(time.UTC) - scheduler.Every(cfg.UpdateInterval).Do(func() { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) - defer cancel() - log.Info("scheduled dataset update starting") - if err := svc.Update(ctx); err != nil { - log.Error("scheduled dataset update failed", zap.Error(err)) - } else { - log.Info("scheduled dataset update complete") - } - }) - scheduler.StartAsync() - defer scheduler.Stop() - - // HTTP transport (ogen) - port := 8080 - if p := os.Getenv("PREDICTOR_PORT"); p != "" { - fmt.Sscanf(p, "%d", &port) - } - - h := handler.New(svc, log) - transport, err := rest.New(h, port, log) - if err != nil { - log.Fatal("failed to create transport", zap.Error(err)) - } - - go func() { - if err := transport.Run(); err != nil { - log.Fatal("HTTP server error", zap.Error(err)) - } - }() - - log.Info("service started") - - // Graceful shutdown - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - sig := <-sigChan - log.Info("received shutdown signal", zap.String("signal", sig.String())) -} diff --git a/cmd/compare-tawhiri/main.go b/cmd/compare-tawhiri/main.go new file mode 100644 index 0000000..c6ea5ee --- /dev/null +++ b/cmd/compare-tawhiri/main.go @@ -0,0 +1,276 @@ +// Command compare-tawhiri runs identical predictions against a local predictor +// and a hosted Tawhiri instance and reports how closely they agree. +// +// To make the comparison test the engine rather than data drift, it discovers +// the local predictor's loaded GFS run via /ready and asks Tawhiri to use the +// same run (the `dataset` parameter), so both integrate identical wind data. +// It compares the burst apex (terrain-independent) and the landing point +// (terrain-dependent) separately, since without the ruaumoko elevation dataset +// the local predictor terminates descent at sea level while Tawhiri uses +// ground elevation. +// +// Usage: +// +// compare-tawhiri --server http://localhost:8080 # built-in suite +// compare-tawhiri --lat 52.2 --lng 0.1 --burst 30000 # single site +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "math" + "net/http" + "net/url" + "os" + "text/tabwriter" + "time" +) + +func main() { + var ( + server = flag.String("server", "http://localhost:8080", "local predictor base URL") + tawhiri = flag.String("tawhiri", "https://api.v2.sondehub.org/tawhiri", "hosted Tawhiri base URL") + lat = flag.Float64("lat", math.NaN(), "launch latitude (single-site mode)") + lng = flag.Float64("lng", math.NaN(), "launch longitude (single-site mode)") + alt = flag.Float64("alt", 0, "launch altitude m") + ascent = flag.Float64("ascent-rate", 5, "ascent rate m/s") + burst = flag.Float64("burst", 30000, "burst altitude m") + descent = flag.Float64("descent-rate", 5, "descent rate m/s") + launch = flag.String("launch", "", "launch time RFC3339 (default: epoch + 3h)") + align = flag.Bool("align-dataset", true, "ask Tawhiri to use the local predictor's GFS run") + ) + flag.Parse() + + epoch, err := fetchActiveEpoch(*server) + if err != nil { + fmt.Fprintln(os.Stderr, "local /ready:", err) + os.Exit(1) + } + fmt.Printf("local dataset epoch: %s\n", epoch.Format(time.RFC3339)) + + launchTime := epoch.Add(3 * time.Hour) + if *launch != "" { + launchTime, err = time.Parse(time.RFC3339, *launch) + if err != nil { + fmt.Fprintln(os.Stderr, "invalid --launch:", err) + os.Exit(1) + } + } + datasetParam := "" + if *align { + datasetParam = epoch.Format(time.RFC3339) + } + + sites := suite() + if !math.IsNaN(*lat) && !math.IsNaN(*lng) { + sites = []site{{name: "custom", lat: *lat, lng: *lng}} + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "\nsite\tburst Δ\tlanding Δ\tapex alt Δ\tland alt Δ\tasc pts\tdesc pts\tnotes") + fmt.Fprintln(tw, "----\t-------\t---------\t----------\t----------\t-------\t--------\t-----") + + var worst float64 + compared := 0 + for _, s := range sites { + p := params{lat: s.lat, lng: s.lng, alt: *alt, launch: launchTime, + ascent: *ascent, burst: *burst, descent: *descent} + + ours, err := predict(*server+"/api/v1/prediction", p, "") + if err != nil { + fmt.Fprintf(tw, "%s\tlocal error: %v\n", s.name, err) + continue + } + theirs, err := predict(*tawhiri, p, datasetParam) + if err != nil { + fmt.Fprintf(tw, "%s\ttawhiri error: %v\n", s.name, err) + continue + } + compared++ + + burstD := haversine(ours.apexLat, ours.apexLng, theirs.apexLat, theirs.apexLng) + landD := haversine(ours.landLat, ours.landLng, theirs.landLat, theirs.landLng) + if landD > worst { + worst = landD + } + note := "" + if theirs.dataset != "" && ours.dataset != "" && theirs.dataset != ours.dataset { + note = fmt.Sprintf("dataset mismatch (theirs=%s)", theirs.dataset) + } + fmt.Fprintf(tw, "%s\t%.0f m\t%.2f km\t%.0f m\t%.0f m\t%d/%d\t%d/%d\t%s\n", + s.name, burstD, landD/1000, + math.Abs(ours.apexAlt-theirs.apexAlt), math.Abs(ours.landAlt-theirs.landAlt), + ours.ascPts, theirs.ascPts, ours.descPts, theirs.descPts, note) + } + tw.Flush() + + if compared == 0 { + fmt.Println("\nVERDICT: NO COMPARISONS (every site errored — see rows above)") + os.Exit(1) + } + fmt.Printf("\ncompared %d/%d sites; worst landing distance: %.2f km\n", compared, len(sites), worst/1000) + switch { + case worst < 1000: + fmt.Println("VERDICT: MATCH (all landings < 1 km — engine agrees with Tawhiri)") + case worst < 50000: + fmt.Println("VERDICT: CLOSE (< 50 km — consistent with elevation/dataset differences)") + default: + fmt.Println("VERDICT: DIVERGENT (> 50 km — investigate)") + os.Exit(2) + } +} + +type site struct { + name string + lat, lng float64 +} + +// suite is a small set of diverse launch points: UK (lands on land/sea +// depending on winds), mid-Atlantic and mid-Pacific (ocean landings, so the +// sea-level-vs-terrain difference vanishes), and southern hemisphere. +func suite() []site { + return []site{ + {"cambridge-uk", 52.2135, 0.0964}, + {"mid-atlantic", 35.0, -40.0}, + {"mid-pacific", 0.0, -160.0}, + {"new-zealand", -41.3, 174.8}, + {"colorado-us", 39.0, -105.5}, + } +} + +type params struct { + lat, lng, alt float64 + launch time.Time + ascent, burst, descent float64 +} + +type result struct { + apexLat, apexLng, apexAlt float64 + landLat, landLng, landAlt float64 + ascPts, descPts int + dataset string +} + +func predict(endpoint string, p params, dataset string) (result, error) { + // Tawhiri requires longitude in [0, 360); normalize so both endpoints get + // the same request. Returned trajectory longitudes are [-180, 180] on both + // sides, so the comparison stays consistent. + lng := p.lng + if lng < 0 { + lng += 360 + } + q := url.Values{} + q.Set("launch_latitude", fmt.Sprintf("%.4f", p.lat)) + q.Set("launch_longitude", fmt.Sprintf("%.4f", lng)) + q.Set("launch_altitude", fmt.Sprintf("%.0f", p.alt)) + q.Set("launch_datetime", p.launch.Format(time.RFC3339)) + q.Set("ascent_rate", fmt.Sprintf("%.2f", p.ascent)) + q.Set("burst_altitude", fmt.Sprintf("%.0f", p.burst)) + q.Set("descent_rate", fmt.Sprintf("%.2f", p.descent)) + if dataset != "" { + q.Set("dataset", dataset) + } + + full := endpoint + "?" + q.Encode() + var body []byte + var status int + var lastErr error + for range 3 { + resp, err := http.Get(full) + if err != nil { + lastErr = err + time.Sleep(time.Second) + continue + } + body, _ = io.ReadAll(resp.Body) + status = resp.StatusCode + resp.Body.Close() + lastErr = nil + break + } + if lastErr != nil { + return result{}, lastErr + } + if status != 200 { + return result{}, fmt.Errorf("HTTP %d: %s", status, truncate(string(body), 160)) + } + + var doc struct { + Prediction []struct { + Stage string `json:"stage"` + Trajectory []struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Altitude float64 `json:"altitude"` + } `json:"trajectory"` + } `json:"prediction"` + Request struct { + Dataset string `json:"dataset"` + } `json:"request"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return result{}, err + } + + var r result + r.dataset = doc.Request.Dataset + for _, st := range doc.Prediction { + if len(st.Trajectory) == 0 { + continue + } + last := st.Trajectory[len(st.Trajectory)-1] + switch st.Stage { + case "ascent": + r.ascPts = len(st.Trajectory) + r.apexLat, r.apexLng, r.apexAlt = last.Latitude, last.Longitude, last.Altitude + case "descent": + r.descPts = len(st.Trajectory) + r.landLat, r.landLng, r.landAlt = last.Latitude, last.Longitude, last.Altitude + } + } + return r, nil +} + +type readinessResp struct { + Status string `json:"status"` + DatasetTime string `json:"dataset_time"` +} + +func fetchActiveEpoch(base string) (time.Time, error) { + resp, err := http.Get(base + "/ready") + if err != nil { + return time.Time{}, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return time.Time{}, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + var r readinessResp + if err := json.Unmarshal(body, &r); err != nil { + return time.Time{}, err + } + if r.Status != "ok" { + return time.Time{}, fmt.Errorf("server status %q (no dataset loaded yet)", r.Status) + } + return time.Parse(time.RFC3339, r.DatasetTime) +} + +func haversine(lat1, lng1, lat2, lng2 float64) float64 { + const R = 6371000.0 + phi1 := lat1 * math.Pi / 180 + phi2 := lat2 * math.Pi / 180 + dphi := (lat2 - lat1) * math.Pi / 180 + dlam := (lng2 - lng1) * math.Pi / 180 + a := math.Sin(dphi/2)*math.Sin(dphi/2) + math.Cos(phi1)*math.Cos(phi2)*math.Sin(dlam/2)*math.Sin(dlam/2) + return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/cmd/compare_prediction/main.go b/cmd/compare_prediction/main.go deleted file mode 100644 index 13ae2a6..0000000 --- a/cmd/compare_prediction/main.go +++ /dev/null @@ -1,195 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "math" - "net/http" - "os" - "time" - - "predictor-refactored/internal/dataset" - "predictor-refactored/internal/downloader" - "predictor-refactored/internal/prediction" - - "go.uber.org/zap" -) - -// Downloads a few forecast steps and runs a prediction, then compares -// against the public Tawhiri API. -func main() { - log, _ := zap.NewDevelopment() - - cfg := &downloader.Config{ - DataDir: os.TempDir(), - Parallel: 4, - } - dl := downloader.NewDownloader(cfg, log) - - ctx := context.Background() - - // Find latest run - run, err := dl.FindLatestRun(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "FindLatestRun: %v\n", err) - os.Exit(1) - } - fmt.Printf("Using run: %s\n", run.Format("2006010215")) - - // Create dataset and download first 10 steps (0-27 hours, enough for a prediction) - dsPath := fmt.Sprintf("/tmp/pred_test_%s.bin", run.Format("2006010215")) - defer os.Remove(dsPath) - - ds, err := dataset.Create(dsPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Create: %v\n", err) - os.Exit(1) - } - - date := run.Format("20060102") - runHour := run.Hour() - stepsToDownload := []int{0, 3, 6, 9, 12, 15, 18, 21, 24, 27} - - fmt.Printf("Downloading %d steps...\n", len(stepsToDownload)) - for _, step := range stepsToDownload { - hourIdx := dataset.HourIndex(step) - fmt.Printf(" step %d (hour idx %d)...\n", step, hourIdx) - - urlA := dataset.GribURL(date, runHour, step) - if err := dl.DownloadAndBlit(ctx, ds, urlA, hourIdx, dataset.LevelSetA); err != nil { - fmt.Fprintf(os.Stderr, " pgrb2 step %d: %v\n", step, err) - os.Exit(1) - } - - urlB := dataset.GribURLB(date, runHour, step) - if err := dl.DownloadAndBlit(ctx, ds, urlB, hourIdx, dataset.LevelSetB); err != nil { - fmt.Fprintf(os.Stderr, " pgrb2b step %d: %v\n", step, err) - os.Exit(1) - } - } - ds.Flush() - fmt.Println("Download complete") - - // Set dataset time - ds.DSTime = run - - // Run our prediction - launchLat := 52.2135 - launchLon := 0.0964 // already in [0, 360) - launchAlt := 0.0 - ascentRate := 5.0 - burstAlt := 30000.0 - descentRate := 5.0 - - // Launch 3 hours into the forecast - launchTime := run.Add(3 * time.Hour) - launchTimestamp := float64(launchTime.Unix()) - dsEpoch := float64(run.Unix()) - - warnings := &prediction.Warnings{} - stages := prediction.StandardProfile(ascentRate, burstAlt, descentRate, ds, dsEpoch, warnings, nil) - results := prediction.RunPrediction(launchTimestamp, launchLat, launchLon, launchAlt, stages) - - fmt.Printf("\n=== Our prediction ===\n") - for i, sr := range results { - stage := "ascent" - if i == 1 { - stage = "descent" - } - first := sr.Points[0] - last := sr.Points[len(sr.Points)-1] - fmt.Printf(" %s: %d points, start=(%.4f, %.4f, %.0fm) end=(%.4f, %.4f, %.0fm)\n", - stage, len(sr.Points), - first.Lat, first.Lng, first.Alt, - last.Lat, last.Lng, last.Alt) - } - - // Get landing point - var ourLandLat, ourLandLon float64 - if len(results) >= 2 { - last := results[1].Points[len(results[1].Points)-1] - ourLandLat = last.Lat - ourLandLon = last.Lng - if ourLandLon > 180 { - ourLandLon -= 360 - } - } - fmt.Printf(" Landing: lat=%.4f, lon=%.4f\n", ourLandLat, ourLandLon) - - // Compare against public Tawhiri API - fmt.Printf("\n=== Tawhiri API comparison ===\n") - tawhiriLandLat, tawhiriLandLon, err := queryTawhiri(launchLat, launchLon, launchAlt, launchTime, ascentRate, burstAlt, descentRate) - if err != nil { - fmt.Printf(" Tawhiri API error: %v\n", err) - fmt.Println(" (Cannot compare — Tawhiri may use a different dataset)") - ds.Close() - return - } - fmt.Printf(" Tawhiri landing: lat=%.4f, lon=%.4f\n", tawhiriLandLat, tawhiriLandLon) - - dist := haversine(ourLandLat, ourLandLon, tawhiriLandLat, tawhiriLandLon) - fmt.Printf(" Distance between landing points: %.2f km\n", dist/1000) - - if dist < 1000 { - fmt.Println(" CLOSE MATCH (< 1 km)") - } else if dist < 50000 { - fmt.Printf(" MODERATE DIFFERENCE (%.1f km) — likely different datasets\n", dist/1000) - } else { - fmt.Printf(" LARGE DIFFERENCE (%.1f km) — possible bug\n", dist/1000) - } - - ds.Close() -} - -func queryTawhiri(lat, lon, alt float64, launchTime time.Time, ascentRate, burstAlt, descentRate float64) (landLat, landLon float64, err error) { - url := fmt.Sprintf( - "https://api.v2.sondehub.org/tawhiri?launch_latitude=%.4f&launch_longitude=%.4f&launch_altitude=%.0f&launch_datetime=%s&ascent_rate=%.1f&burst_altitude=%.0f&descent_rate=%.1f", - lat, lon, alt, launchTime.Format(time.RFC3339), ascentRate, burstAlt, descentRate) - - resp, err := http.Get(url) - if err != nil { - return 0, 0, err - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return 0, 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) - } - - var result struct { - Prediction []struct { - Stage string `json:"stage"` - Trajectory []struct { - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Altitude float64 `json:"altitude"` - } `json:"trajectory"` - } `json:"prediction"` - } - - if err := json.Unmarshal(body, &result); err != nil { - return 0, 0, err - } - - for _, stage := range result.Prediction { - if stage.Stage == "descent" && len(stage.Trajectory) > 0 { - last := stage.Trajectory[len(stage.Trajectory)-1] - return last.Latitude, last.Longitude, nil - } - } - - return 0, 0, fmt.Errorf("no descent stage found") -} - -func haversine(lat1, lon1, lat2, lon2 float64) float64 { - const R = 6371000.0 - phi1 := lat1 * math.Pi / 180 - phi2 := lat2 * math.Pi / 180 - dphi := (lat2 - lat1) * math.Pi / 180 - dlam := (lon2 - lon1) * math.Pi / 180 - a := math.Sin(dphi/2)*math.Sin(dphi/2) + math.Cos(phi1)*math.Cos(phi2)*math.Sin(dlam/2)*math.Sin(dlam/2) - return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) -} diff --git a/cmd/compare_step0/main.go b/cmd/compare_step0/main.go deleted file mode 100644 index d0d697d..0000000 --- a/cmd/compare_step0/main.go +++ /dev/null @@ -1,104 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "time" - - "predictor-refactored/internal/dataset" - "predictor-refactored/internal/downloader" - - "go.uber.org/zap" -) - -// Downloads step 0 of a given run and writes a minimal dataset for comparison. -// Usage: go run ./cmd/compare_step0 -func main() { - if len(os.Args) < 3 { - fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) - os.Exit(1) - } - - runStr := os.Args[1] - outPath := os.Args[2] - - run, err := time.Parse("2006010215", runStr) - if err != nil { - fmt.Fprintf(os.Stderr, "Invalid run time %q: %v\n", runStr, err) - os.Exit(1) - } - - log, _ := zap.NewDevelopment() - - // Create a full-size dataset (we only fill step 0) - fmt.Printf("Creating dataset at %s (%d bytes)...\n", outPath, dataset.DatasetSize) - ds, err := dataset.Create(outPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Create dataset: %v\n", err) - os.Exit(1) - } - defer ds.Close() - - cfg := &downloader.Config{ - DataDir: os.TempDir(), - Parallel: 4, - } - dl := downloader.NewDownloader(cfg, log) - - ctx := context.Background() - date := run.Format("20060102") - runHour := run.Hour() - - // Download and blit step 0 from pgrb2 - fmt.Println("Downloading pgrb2 step 0...") - urlA := dataset.GribURL(date, runHour, 0) - if err := dl.DownloadAndBlit(ctx, ds, urlA, 0, dataset.LevelSetA); err != nil { - fmt.Fprintf(os.Stderr, "pgrb2: %v\n", err) - os.Exit(1) - } - fmt.Println(" done") - - // Download and blit step 0 from pgrb2b - fmt.Println("Downloading pgrb2b step 0...") - urlB := dataset.GribURLB(date, runHour, 0) - if err := dl.DownloadAndBlit(ctx, ds, urlB, 0, dataset.LevelSetB); err != nil { - fmt.Fprintf(os.Stderr, "pgrb2b: %v\n", err) - os.Exit(1) - } - fmt.Println(" done") - - if err := ds.Flush(); err != nil { - fmt.Fprintf(os.Stderr, "Flush: %v\n", err) - os.Exit(1) - } - - // Spot-check: print same values as the Python script - fmt.Println("\n=== Go dataset values (spot check) ===") - type testPoint struct { - varName string - varIdx int - levelIdx int - lat, lon int - } - - points := []testPoint{ - {"HGT", 0, 0, 0, 0}, // HGT @ 1000mb, lat=-90, lon=0 - {"HGT", 0, 0, 180, 0}, // HGT @ 1000mb, lat=0, lon=0 - {"HGT", 0, 0, 360, 0}, // HGT @ 1000mb, lat=+90, lon=0 - {"HGT", 0, 20, 180, 360}, // HGT @ 500mb, lat=0, lon=180 - {"UGRD", 1, 0, 180, 0}, // UGRD @ 1000mb, lat=0, lon=0 - {"VGRD", 2, 0, 180, 0}, // VGRD @ 1000mb, lat=0, lon=0 - {"UGRD", 1, 20, 284, 0}, // UGRD @ 500mb, lat=52N, lon=0 - } - - for _, p := range points { - val := ds.Val(0, p.levelIdx, p.varIdx, p.lat, p.lon) - actualLat := -90.0 + float64(p.lat)*0.5 - actualLon := float64(p.lon) * 0.5 - fmt.Printf(" %-4s %4dmb lat=%+7.1f lon=%6.1f: %12.4f\n", - p.varName, dataset.Pressures[p.levelIdx], actualLat, actualLon, val) - } - - fmt.Printf("\nDataset written to %s\n", outPath) -} diff --git a/cmd/predictor-cli/main.go b/cmd/predictor-cli/main.go new file mode 100644 index 0000000..c8be301 --- /dev/null +++ b/cmd/predictor-cli/main.go @@ -0,0 +1,215 @@ +// Command predictor-cli is a small HTTP client for stratoflights-predictor. +// +// It is intended for operations and development; production callers should +// use the REST API directly. +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const usage = `predictor-cli — HTTP client for stratoflights-predictor + +USAGE + predictor-cli [--server URL] [args...] + +COMMANDS + ready Check service health + predict ... Run a Tawhiri-compat prediction (key=value pairs) + datasets list List stored dataset epochs + datasets download [--latest|--epoch RFC3339] + Trigger a dataset download + datasets delete Delete a stored dataset + jobs list List download jobs + jobs get Show one job + jobs cancel Cancel a running job + +ENVIRONMENT + PREDICTOR_SERVER Default --server (overridden by the flag) +` + +func main() { + fs := flag.NewFlagSet("predictor-cli", flag.ContinueOnError) + fs.Usage = func() { fmt.Fprint(os.Stderr, usage) } + server := fs.String("server", envDefault("PREDICTOR_SERVER", "http://localhost:8080"), "predictor server URL") + if err := fs.Parse(os.Args[1:]); err != nil { + os.Exit(2) + } + args := fs.Args() + if len(args) == 0 { + fs.Usage() + os.Exit(2) + } + + c := &client{base: strings.TrimRight(*server, "/"), http: &http.Client{Timeout: 30 * time.Second}} + if err := dispatch(c, args); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func envDefault(name, fallback string) string { + if v := os.Getenv(name); v != "" { + return v + } + return fallback +} + +func dispatch(c *client, args []string) error { + switch args[0] { + case "ready": + return c.ready() + case "predict": + return c.predict(args[1:]) + case "datasets": + if len(args) < 2 { + return fmt.Errorf("usage: datasets {list|download|delete}") + } + switch args[1] { + case "list": + return c.datasetsList() + case "download": + return c.datasetsDownload(args[2:]) + case "delete": + if len(args) < 3 { + return fmt.Errorf("usage: datasets delete ") + } + return c.datasetsDelete(args[2]) + } + case "jobs": + if len(args) < 2 { + return fmt.Errorf("usage: jobs {list|get|cancel}") + } + switch args[1] { + case "list": + return c.jobsList() + case "get": + if len(args) < 3 { + return fmt.Errorf("usage: jobs get ") + } + return c.jobsGet(args[2]) + case "cancel": + if len(args) < 3 { + return fmt.Errorf("usage: jobs cancel ") + } + return c.jobsCancel(args[2]) + } + } + return fmt.Errorf("unknown command %q", args[0]) +} + +type client struct { + base string + http *http.Client +} + +func (c *client) ready() error { + return c.getPrint("/ready") +} + +func (c *client) predict(kv []string) error { + q := url.Values{} + for _, p := range kv { + idx := strings.IndexByte(p, '=') + if idx <= 0 { + return fmt.Errorf("expected key=value, got %q", p) + } + q.Set(p[:idx], p[idx+1:]) + } + return c.getPrint("/api/v1/prediction?" + q.Encode()) +} + +func (c *client) datasetsList() error { + return c.getPrint("/api/v1/admin/datasets") +} + +func (c *client) datasetsDownload(args []string) error { + fs := flag.NewFlagSet("datasets download", flag.ContinueOnError) + latest := fs.Bool("latest", false, "download the latest available run") + epoch := fs.String("epoch", "", "RFC3339 epoch to download") + if err := fs.Parse(args); err != nil { + return err + } + body := map[string]any{} + if *latest { + body["latest"] = true + } + if *epoch != "" { + body["epoch"] = *epoch + } + return c.postPrint("/api/v1/admin/datasets", body) +} + +func (c *client) datasetsDelete(epoch string) error { + return c.deletePrint("/api/v1/admin/datasets/" + url.PathEscape(epoch)) +} + +func (c *client) jobsList() error { return c.getPrint("/api/v1/admin/jobs") } +func (c *client) jobsGet(id string) error { + return c.getPrint("/api/v1/admin/jobs/" + url.PathEscape(id)) +} +func (c *client) jobsCancel(id string) error { + return c.deletePrint("/api/v1/admin/jobs/" + url.PathEscape(id)) +} + +func (c *client) getPrint(path string) error { + resp, err := c.http.Get(c.base + path) + if err != nil { + return err + } + return printResp(resp) +} + +func (c *client) postPrint(path string, body any) error { + buf, err := json.Marshal(body) + if err != nil { + return err + } + resp, err := c.http.Post(c.base+path, "application/json", bytes.NewReader(buf)) + if err != nil { + return err + } + return printResp(resp) +} + +func (c *client) deletePrint(path string) error { + req, err := http.NewRequest(http.MethodDelete, c.base+path, nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + return printResp(resp) +} + +func printResp(resp *http.Response) error { + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + // Pretty-print JSON when possible; raw bytes otherwise. + if strings.Contains(resp.Header.Get("Content-Type"), "json") && len(body) > 0 { + var any any + if err := json.Unmarshal(body, &any); err == nil { + pretty, _ := json.MarshalIndent(any, "", " ") + fmt.Println(string(pretty)) + return nil + } + } + if len(body) > 0 { + fmt.Println(strings.TrimSpace(string(body))) + } + return nil +} diff --git a/cmd/predictor/main.go b/cmd/predictor/main.go new file mode 100644 index 0000000..b95725e --- /dev/null +++ b/cmd/predictor/main.go @@ -0,0 +1,258 @@ +// Command predictor is the stratoflights-predictor HTTP server. +// +// It wires the configuration, dataset manager, scheduler, and API layer +// into a single process and exits cleanly on SIGINT/SIGTERM. +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-co-op/gocron" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "predictor-refactored/internal/api" + "predictor-refactored/internal/config" + "predictor-refactored/internal/datasets" + "predictor-refactored/internal/datasets/gefs" + "predictor-refactored/internal/datasets/gfs" + "predictor-refactored/internal/elevation" + "predictor-refactored/internal/metrics" + wgfs "predictor-refactored/internal/weather/gfs" + "predictor-refactored/internal/windviz" +) + +// Build metadata, injected via -ldflags at build time (see Dockerfile). +var ( + version = "dev" + revision = "unknown" +) + +func main() { + // `predictor -healthcheck` probes the local /health endpoint and exits + // 0/1. The container HEALTHCHECK uses it so the (distroless) image needs + // no shell or curl. + for _, a := range os.Args[1:] { + if a == "-healthcheck" || a == "--healthcheck" { + os.Exit(healthcheck()) + } + } + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "fatal:", err) + os.Exit(1) + } +} + +// healthcheck performs a liveness probe against the local server. It resolves +// the port through the same config loader as the server, so the probe always +// matches the bind port regardless of how it was set (flag, env, or file). +func healthcheck() int { + port := 8080 + if cfg, err := config.Load(withoutHealthcheckFlag(os.Args[1:])); err == nil { + port = cfg.HTTP.Port + } + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/health", port)) + if err != nil { + fmt.Fprintln(os.Stderr, "healthcheck:", err) + return 1 + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + fmt.Fprintln(os.Stderr, "healthcheck: status", resp.StatusCode) + return 1 + } + return 0 +} + +// withoutHealthcheckFlag drops the -healthcheck flag so the remaining args +// parse cleanly through config.Load (which does not define it). +func withoutHealthcheckFlag(args []string) []string { + out := make([]string, 0, len(args)) + for _, a := range args { + if a == "-healthcheck" || a == "--healthcheck" { + continue + } + out = append(out, a) + } + return out +} + +func run(args []string) error { + cfg, err := config.Load(args) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + log, err := newLogger(cfg.Log.Level) + if err != nil { + return fmt.Errorf("init logger: %w", err) + } + defer log.Sync() + + log.Info("starting stratoflights-predictor", + zap.String("version", version), + zap.String("revision", revision)) + + log.Info("configuration loaded", + zap.Int("port", cfg.HTTP.Port), + zap.String("data_dir", cfg.Data.Dir), + zap.String("source", cfg.Data.Source), + zap.Int("download_parallel", cfg.Download.Parallel), + zap.Duration("update_interval", cfg.Download.UpdateInterval), + zap.Duration("freshness_ttl", cfg.Download.FreshnessTTL), + zap.Bool("metrics_enabled", cfg.Metrics.Enabled), + ) + + store, err := datasets.NewLocalStore(cfg.Data.Dir, cfg.Data.Source) + if err != nil { + return fmt.Errorf("init store: %w", err) + } + + variant, err := wgfs.VariantByID(cfg.Data.Source) + if err != nil { + return fmt.Errorf("unsupported source %q: %w", cfg.Data.Source, err) + } + var source datasets.Source + switch variant.Family { + case wgfs.FamilyGFS: + s := gfs.NewSource(variant, log) + s.Parallel = cfg.Download.Parallel + source = s + case wgfs.FamilyGEFS: + s := gefs.NewSource(variant, log) + s.Parallel = cfg.Download.Parallel + source = s + default: + return fmt.Errorf("unsupported family for %q", cfg.Data.Source) + } + + var throttle datasets.Throttle + if cfg.Download.BandwidthBytesPerSecond > 0 { + throttle = datasets.NewTokenBucket(cfg.Download.BandwidthBytesPerSecond) + } + + // Metrics (optional). + var sink metrics.Sink = metrics.Noop() + var metricsHandler http.Handler + if cfg.Metrics.Enabled { + prom := metrics.NewProm() + sink = prom + metricsHandler = prom + } + + mgr := datasets.New(source, store, throttle, log) + defer mgr.Close() + + // Optional elevation dataset. Missing or unreadable elevation is logged + // but non-fatal; descent terminates at sea level instead. + var elev *elevation.Dataset + if cfg.Data.ElevationPath != "" { + if d, err := elevation.Open(cfg.Data.ElevationPath); err == nil { + elev = d + log.Info("elevation dataset loaded", zap.String("path", cfg.Data.ElevationPath)) + defer elev.Close() + } else { + log.Warn("elevation dataset not available, using sea-level termination", + zap.String("path", cfg.Data.ElevationPath), + zap.Error(err)) + } + } + + // Kick off the initial refresh in the background so the server can start + // answering /ready immediately. + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) + defer cancel() + if _, err := mgr.Refresh(ctx, cfg.Download.FreshnessTTL); err != nil { + log.Error("initial dataset refresh failed", zap.Error(err)) + } + if a := mgr.Active(); a != nil { + sink.ActiveEpoch(a.Epoch()) + } + }() + + scheduler := gocron.NewScheduler(time.UTC) + scheduler.Every(cfg.Download.UpdateInterval).Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) + defer cancel() + log.Info("scheduled dataset refresh starting") + if _, err := mgr.Refresh(ctx, cfg.Download.FreshnessTTL); err != nil { + log.Error("scheduled dataset refresh failed", zap.Error(err)) + } + if a := mgr.Active(); a != nil { + sink.ActiveEpoch(a.Epoch()) + } + }) + scheduler.StartAsync() + defer scheduler.Stop() + + var windCache *windviz.Cache + if cfg.Wind.Enabled { + windCache = windviz.NewCache(cfg.Wind.CacheSize, cfg.Wind.CacheTTL) + } + + server, err := api.New(cfg.HTTP.Port, api.Deps{ + Manager: mgr, + Elevation: elev, + Metrics: sink, + MetricsHandler: metricsHandler, + MetricsPath: cfg.Metrics.Path, + EnableWind: cfg.Wind.Enabled, + WindCache: windCache, + AsyncWorkers: cfg.HTTP.AsyncWorkers, + AsyncQueueSize: cfg.HTTP.AsyncQueueSize, + AsyncResultTTL: cfg.HTTP.AsyncResultTTL, + Log: log, + }) + if err != nil { + return fmt.Errorf("init server: %w", err) + } + defer server.Close() + + // Graceful shutdown + ctx, cancel := signalContext() + defer cancel() + + log.Info("service started") + if err := server.Run(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("http server: %w", err) + } + log.Info("service stopped") + return nil +} + +func signalContext() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + return ctx, cancel +} + +func newLogger(level string) (*zap.Logger, error) { + cfg := zap.NewProductionConfig() + switch level { + case "debug": + cfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel) + case "info": + cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel) + case "warn": + cfg.Level = zap.NewAtomicLevelAt(zapcore.WarnLevel) + case "error": + cfg.Level = zap.NewAtomicLevelAt(zapcore.ErrorLevel) + default: + cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel) + } + return cfg.Build() +} diff --git a/deploy/prometheus.yml b/deploy/prometheus.yml new file mode 100644 index 0000000..68ddb67 --- /dev/null +++ b/deploy/prometheus.yml @@ -0,0 +1,11 @@ +# Minimal Prometheus config for the staging compose stack. In production a +# central Prometheus scrapes the predictor via Docker Swarm service discovery +# (see DEPLOYMENT.md); this file just proves the metrics pipeline locally. +global: + scrape_interval: 15s + +scrape_configs: + - job_name: predictor + metrics_path: /metrics + static_configs: + - targets: ["predictor:8080"] diff --git a/deploy/swarmpit-deploy.sh b/deploy/swarmpit-deploy.sh new file mode 100755 index 0000000..ab3c894 --- /dev/null +++ b/deploy/swarmpit-deploy.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env sh +# Deploy (or update) the predictor stack to a Docker Swarm via the Swarmpit +# REST API, then trigger a redeploy so running services pick up the new image. +# +# Required env: SWARMPIT_URL, SWARMPIT_TOKEN, STACK_NAME, TAG +# Optional env: CA_CERTIFICATES (PEM bundle for a private Swarmpit TLS CA) +set -eu + +: "${SWARMPIT_URL:?SWARMPIT_URL is required}" +: "${SWARMPIT_TOKEN:?SWARMPIT_TOKEN is required}" +: "${STACK_NAME:?STACK_NAME is required}" +TAG="${TAG:-latest}" + +# Pin the image tag in the compose we send (replace the ${TAG:-latest} default +# with the concrete tag) so the exact built image is what gets deployed. +sed "s|:\${TAG:-latest}|:${TAG}|g" docker-compose.swarm.yml > /tmp/stack.yml + +CA_OPT="" +if [ -n "${CA_CERTIFICATES:-}" ]; then + echo "${CA_CERTIFICATES}" > /tmp/swarmpit-ca.crt + CA_OPT="--cacert /tmp/swarmpit-ca.crt" +fi + +compose_json=$(jq -Rs . < /tmp/stack.yml) +jq -n --arg name "${STACK_NAME}" --argjson compose "${compose_json}" \ + '{name: $name, spec: {compose: $compose}}' > /tmp/swarmpit-payload.json + +echo "Deploying stack '${STACK_NAME}' (tag ${TAG}) to ${SWARMPIT_URL}" +curl -fsS -X POST "${SWARMPIT_URL}/api/stacks/${STACK_NAME}" \ + -H "authorization: Bearer ${SWARMPIT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d @/tmp/swarmpit-payload.json \ + --max-time 60 ${CA_OPT} + +echo "Triggering redeploy" +curl -fsS -X POST "${SWARMPIT_URL}/api/stacks/${STACK_NAME}/redeploy" \ + -H "authorization: Bearer ${SWARMPIT_TOKEN}" \ + --max-time 60 ${CA_OPT} || echo "redeploy trigger failed; services may still roll forward via autoredeploy" + +rm -f /tmp/stack.yml /tmp/swarmpit-payload.json /tmp/swarmpit-ca.crt +echo "Done." diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..d15e448 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,47 @@ +# Staging: resembles production on a single host — all features enabled +# (metrics, wind visualization, async predictions) plus a bundled Prometheus +# so the metrics pipeline can be exercised end to end. Runs non-root like prod. +# +# docker compose -f docker-compose.staging.yml up --build +# curl localhost:8080/api/v1/admin/status +# open http://localhost:9090 (Prometheus, predictor target should be UP) +services: + init-perms: + image: busybox:1.36 + command: ["sh", "-c", "mkdir -p /data && chown -R 65532:65532 /data"] + volumes: + - predictor-data:/data + + predictor: + build: + context: . + args: + VERSION: staging + REVISION: staging + image: stratoflights-predictor:staging + depends_on: + init-perms: + condition: service_completed_successfully + ports: + - "8080:8080" + environment: + PREDICTOR_DATA_DIR: /data + PREDICTOR_METRICS_ENABLED: "true" + PREDICTOR_METRICS_PATH: /metrics + PREDICTOR_LOG_LEVEL: info + PREDICTOR_DOWNLOAD_PARALLEL: "16" + volumes: + - predictor-data:/data + # - ./elevation:/srv/ruaumoko-dataset:ro + + prometheus: + image: prom/prometheus:v2.54.1 + depends_on: + - predictor + ports: + - "9090:9090" + volumes: + - ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml:ro + +volumes: + predictor-data: diff --git a/docker-compose.swarm.yml b/docker-compose.swarm.yml new file mode 100644 index 0000000..f36b0a5 --- /dev/null +++ b/docker-compose.swarm.yml @@ -0,0 +1,98 @@ +version: "3.8" + +# Production Docker Swarm stack for stratoflights-predictor. +# +# Deploy: TAG=v1.0.0 docker stack deploy -c docker-compose.swarm.yml --with-registry-auth predictor +# (or import via Swarmpit; the CI pipeline deploys it through the Swarmpit API) +# +# Storage & placement (see DEPLOYMENT.md): +# * The wind dataset (~8.9 GiB) lives on NODE-LOCAL disk — never NFS. To keep +# the number of copies bounded, the service is pinned to nodes labelled +# `predictor.data=true`; label at most two such nodes. Each carries one copy. +# * Replicas are spread one-per-node by default (redundancy + load balancing); +# scaling to multiple replicas per node is safe because they share the +# node-local volume and coordinate downloads via an flock (no duplicate fetch). +# +# The predictor is an internal backend: it has no public Traefik router. The +# Django API gateway and Prometheus reach it over the shared `stratoflights-net` +# overlay by the alias `predictor`. + +services: + predictor: + image: git.intra.yksa.space/web/predictor:${TAG:-latest} + networks: + stratoflights-net: + aliases: + - predictor + environment: + PREDICTOR_DATA_DIR: /data + PREDICTOR_ELEVATION_DATASET: /srv/ruaumoko-dataset + PREDICTOR_SOURCE: ${PREDICTOR_SOURCE:-gfs-0p50-3h} + PREDICTOR_DOWNLOAD_PARALLEL: ${PREDICTOR_DOWNLOAD_PARALLEL:-16} + PREDICTOR_UPDATE_INTERVAL: 6h + PREDICTOR_DATASET_TTL: 48h + PREDICTOR_METRICS_ENABLED: "true" + PREDICTOR_METRICS_PATH: /metrics + PREDICTOR_LOG_LEVEL: info + volumes: + # Node-local storage. Provision these directories on each labelled node + # (chown to 65532:65532 — see DEPLOYMENT.md). NOT a shared/NFS volume. + - type: bind + source: /srv/predictor/data + target: /data + - type: bind + source: /srv/predictor/elevation + target: /srv/ruaumoko-dataset + read_only: true + healthcheck: + test: ["CMD", "/predictor", "-healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 120s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + deploy: + mode: replicated + replicas: 2 + placement: + max_replicas_per_node: 2 + constraints: + - node.labels.predictor.data == true + preferences: + # Spread across the labelled nodes so the two default replicas land + # on different hosts (redundancy across both dataset copies). + - spread: node.labels.predictor.data + update_config: + parallelism: 1 + delay: 15s + order: start-first + failure_action: rollback + rollback_config: + parallelism: 1 + order: stop-first + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + resources: + limits: + memory: 3072M + reservations: + memory: 512M + labels: + # Prometheus Swarm service-discovery hints (adjust to your SD relabel rules). + - "prometheus.scrape=true" + - "prometheus.port=8080" + - "prometheus.path=/metrics" + # Let Swarmpit auto-redeploy when a new :latest (or pinned TAG) is pushed. + - "swarmpit.service.deployment.autoredeploy=true" + +networks: + # Shared overlay also joined by the API gateway and Prometheus. + # Create once: docker network create -d overlay --attachable stratoflights-net + stratoflights-net: + external: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..51a7764 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +# Local development: a single predictor instance, metrics off. +# +# docker compose up --build +# curl localhost:8080/ready +# +# First start downloads the latest GFS 0.5° run (~8.9 GiB) into the named +# volume; subsequent starts reuse it. The init service chowns the volume so +# the non-root image (uid 65532) can write to it. +services: + init-perms: + image: busybox:1.36 + command: ["sh", "-c", "mkdir -p /data && chown -R 65532:65532 /data"] + volumes: + - predictor-data:/data + + predictor: + build: + context: . + args: + VERSION: dev + REVISION: local + image: stratoflights-predictor:dev + depends_on: + init-perms: + condition: service_completed_successfully + ports: + - "8080:8080" + environment: + PREDICTOR_DATA_DIR: /data + PREDICTOR_METRICS_ENABLED: "false" + PREDICTOR_LOG_LEVEL: debug + # Mount and point at an elevation dataset for ground-level descent: + # PREDICTOR_ELEVATION_DATASET: /srv/ruaumoko-dataset + volumes: + - predictor-data:/data + # - ./elevation:/srv/ruaumoko-dataset:ro + +volumes: + predictor-data: diff --git a/docs/numerics.tex b/docs/numerics.tex new file mode 100644 index 0000000..16aaacd --- /dev/null +++ b/docs/numerics.tex @@ -0,0 +1,365 @@ +\documentclass[a4paper,11pt]{article} +\usepackage[margin=1in]{geometry} +\usepackage{amsmath, amssymb} +\usepackage{algorithm, algpseudocode} +\usepackage{hyperref} + +\title{stratoflights-predictor: Mathematical Reference} +\author{stratoflights-predictor} +\date{} + +\begin{document} +\maketitle + +\noindent This document is the end-to-end mathematical specification of the +trajectory calculation performed by stratoflights-predictor. It is meant +to be detailed enough to permit hand verification of the numerical +output. Section~\ref{sec:numerics} covers the small numerics library +(\verb|internal/numerics|); the remaining sections describe the engine +(\verb|internal/engine|) and the data plane. + +\tableofcontents + +% ========================================================================= +\section{State vector and equations of motion} +\label{sec:state} + +\paragraph{State vector.} The balloon state is the four-tuple +\[ + \mathbf{s}(t) \;=\; \bigl(t,\; \varphi(t),\; \lambda(t),\; h(t)\bigr), +\] +where $t$ is UNIX seconds, $\varphi \in [-90, 90]$ is geographic latitude +in degrees, $\lambda \in [0, 360)$ is geographic longitude in degrees, +and $h$ is altitude above mean sea level in metres. Inside the +implementation, the spatial part $(\varphi, \lambda, h)$ is the +\verb|engine.State| struct; $t$ is tracked separately by the integrator. + +\paragraph{Equations of motion.} The time derivative of state is the +direction-agnostic vector +\[ + \dot{\mathbf{s}}(t) \;=\; \bigl(\dot \varphi,\; \dot \lambda,\; \dot h\bigr) + \;=\; \sum_{m \in \text{Models}(t)} \mathbf{F}_m(t, \mathbf{s}), +\] +i.e.\ the sum of the active stage's models evaluated at the current +state. The supported per-model contributions are: + +\paragraph{Constant rate.} A purely vertical model with no horizontal +component, used for the standard balloon ascent profile: +\[ + \mathbf{F}_{\text{const}}(t, \mathbf{s}) = (0, 0, r), \qquad r \in \mathbb{R}. +\] +Positive $r$ is upward; a negative $r$ may be combined with reverse-time +integration to model an ascent backwards from a known apex. + +\paragraph{Parachute descent.} The vertical contribution under a constant +drag coefficient with the NASA atmosphere model density $\rho(h)$: +\[ + \mathbf{F}_{\text{drag}}(t, \mathbf{s}) = \Bigl(0, 0, -\frac{k}{\sqrt{\rho(h)}}\Bigr), + \qquad k = r_0 \cdot 1{.}1045, +\] +where $r_0$ is the descent rate at sea level. Density is computed +piecewise from the layered model described in +\href{https://www.grc.nasa.gov/WWW/K-12/airplane/atmosmet.html}{NASA's +atmosphere page}. + +\paragraph{Piecewise rate.} A schedule $\{(\tau_i, r_i)\}_{i=1}^N$ +parameterised in either absolute UNIX time, or seconds since +profile start, or seconds since the propagator's own start. Resolution +happens lazily through the \verb|Propagator.BuildModel| hook so the same +spec can be reused across profiles with different launch times. +The contribution at time $t$ is +\[ + \mathbf{F}_{\text{pwc}}(t, \mathbf{s}) = (0, 0, r_{i^\star}), + \qquad i^\star = \min\{i : \tau_i > t\}. +\] + +\paragraph{Wind transport.} The horizontal contribution from sampling the +loaded wind field $W$: +\[ + \mathbf{F}_{\text{wind}}(t, \mathbf{s}) = \Bigl( + \frac{180}{\pi}\,\frac{v}{R + h},\;\; + \frac{180}{\pi}\,\frac{u}{(R + h)\cos\bigl(\varphi\,\pi/180\bigr)},\;\; + 0 + \Bigr), +\] +where $(u, v) = W(t, \varphi, \lambda, h)$ are the eastward and northward +wind components in metres per second, and $R = 6{,}371{,}009$~m is the +spherical Earth radius. The implementation lives in +\verb|engine.WindTransport| (\verb|engine/models.go|). + +\paragraph{Coordinate system.} The model is a spherical Earth in +plate-carrée (latitude/longitude/altitude) coordinates. This matches the +reference Tawhiri predictor exactly and is necessary for bit-identical +back-to-back testing. A WGS84/ECEF variant is planned but deferred: it +would require converting U/V wind components from the GFS sphere model +to the ellipsoid, which is not a trivial coordinate transform. + +% ========================================================================= +\section{Profiles and propagators} +\label{sec:profile} + +\paragraph{Propagator.} A propagator owns one Model and a list of +Constraints; it produces a sequence of trajectory points via classical +Runge--Kutta--4 integration with step $\Delta t$ (positive for forward, +negative for reverse propagation): +\[ + \Pi : (t_0, \mathbf{s}_0) \;\longmapsto\; \bigl[(t_k, \mathbf{s}_k)\bigr]_{k=0}^{K}. +\] +The sequence ends at the first $k$ where any constraint is violated; +the violation point is refined by binary search (see +\S\ref{sec:numerics}). + +\paragraph{Profile.} A profile is an ordered chain of propagators +$[\Pi_1, \Pi_2, \ldots, \Pi_N]$. Stage $i$ starts where stage $i-1$ +ended; the time direction (sign of $\Delta t$) is shared. + +\paragraph{Constraint actions.} When a constraint $c$ is violated at the +refined point $(t^\star, \mathbf{s}^\star)$, $c.\text{Action}$ controls +the dispatch: +\begin{itemize} + \item \texttt{stop} — the profile ends at $(t^\star, \mathbf{s}^\star)$. + \item \texttt{fallback} — the current propagator hands off to its + \texttt{Fallback} propagator (chains supported). + \item \texttt{clip} — the violated coordinate is clipped to the + constraint's boundary and integration continues. Useful for soft + constraints such as ``hold altitude above 500~m''. +\end{itemize} +Constraints fire on full RK4 steps only, never on intermediate +sub-evaluations. This matches the reference Tawhiri behaviour +bit-for-bit. + +\paragraph{Reverse propagation.} A profile with \verb|Direction = Reverse| +runs every propagator with $\Delta t = -\Delta t$. Models are +direction-agnostic: their derivative formulas above hold unchanged. The +typical use is to start from a known landing point and recover the +launch position by integrating backwards in time. + +% ========================================================================= +\section{Constraint geometry} +\label{sec:constraints} + +The engine ships four constraint primitives. + +\paragraph{Scalar comparison: altitude.} +\( + c_{\text{alt}}(t, \mathbf{s}) \;=\; h \,\mathrel{\bigotimes}\, h_0, +\) +where $\bigotimes \in \{<, \le, >, \ge, =\}$ is the configured operator +and $h_0$ is the limit (metres). The implementation is +\verb|engine.Altitude| (\verb|engine/constraints.go|). + +\paragraph{Scalar comparison: time.} Same shape as altitude, but acting +on $t$ in UNIX seconds. Implementation: \verb|engine.Time|. + +\paragraph{Terrain contact.} +\( + c_{\text{terr}}(t, \mathbf{s}) = \bigl(z(\varphi, \lambda) > h\bigr), +\) +with $z$ provided by the ruaumoko-compatible elevation dataset. + +\paragraph{Polygon.} For a polygon $P$ with vertices +$(\varphi_i, \lambda_i)_{i=1}^N$ and mode $\mu \in +\{\text{inside}, \text{outside}\}$, the constraint is +$c_{\text{poly}}(\mathbf{s}) = \bigl(\mathbf{s} \in P\bigr) \oplus +[\mu = \text{outside}]$. Containment is tested by ray casting in +plate-carrée after normalising every longitude to within 180\textdegree{} +of the first vertex; this handles antimeridian-crossing edges so long +as the polygon spans no more than 180\textdegree{} in longitude. + +% ========================================================================= +\section{Numerics library} +\label{sec:numerics} + +The numerics package (\verb|internal/numerics|) provides four primitives: +regular-grid bracketing, multilinear interpolation, monotone bisection, +and classical RK4 with termination-point refinement. + +\subsection{Regular-grid bracketing} + +\paragraph{Definition.} An \emph{axis} is the regularly-spaced sequence +$x_i = \ell + i \cdot s$ for $i = 0, 1, \ldots, N - 1$, parameterised by +the left edge $\ell$, the step $s > 0$, and the point count $N$. + +Given a query $v$, the \emph{bracket} is the pair $(i_0, i_1)$ with +$x_{i_0} \le v < x_{i_1}$ and the dimensionless position +\[ + f = \frac{v - x_{i_0}}{s} \in [0, 1). +\] +Implemented as \verb|Axis.Locate| in \verb|internal/numerics/grid.go|. + +\paragraph{Wrapping axes.} For periodic axes (e.g.\ longitude), the +sequence is extended by the convention $x_N = x_0$ so a value approaching +$x_N$ from below brackets $(N{-}1, 0)$ with fraction +$f = (v - x_{N-1})/s$. + +\paragraph{Worked example.} Latitude axis with $\ell = -90$, $s = 0{.}5$, +$N = 361$. Query $v = -89{.}75$ yields $p = 0{.}5$, so $i_0 = 0$, +$i_1 = 1$, $f = 0{.}5$. + +\subsection{Multilinear interpolation} + +\paragraph{Definition.} For a scalar field $u$ defined at the grid nodes +of three axes, the trilinear interpolant at brackets +$b_a, b_b, b_c$ is +\[ + \tilde u = \sum_{i, j, k \in \{0, 1\}} w_{a,i} \, w_{b,j} \, w_{c,k} + \; u\bigl(b_a^i, b_b^j, b_c^k\bigr), +\] +where $w_{\bullet, 0} = 1 - f_\bullet$ and $w_{\bullet, 1} = f_\bullet$. +The corner terms are accumulated in the order +$(0,0,0), (0,0,1), \ldots, (1,1,1)$, matching the reference Cython +implementation so that double-precision results agree byte for byte. + +\paragraph{Linear exactness.} For any affine field +$u(i, j, k) = \alpha i + \beta j + \gamma k + \delta$, the formula returns +$\alpha p_a + \beta p_b + \gamma p_c + \delta$ exactly (modulo +floating-point rounding), where $p_\bullet = b_\bullet^0 + f_\bullet$. + +\subsection{Monotone bisection} + +For an integer-indexed monotone non-decreasing sequence +$f : \{i_{\min}, \ldots, i_{\max}\} \to \mathbb{R}$ and a target $t$, +\verb|Bisect| returns the largest index $i^\star$ with $f(i^\star) < t$. +Used by the wind sampler to locate the pressure level bracketing the +query altitude. Time complexity: +$\mathcal{O}(\log(i_{\max} - i_{\min}))$. + +\subsection{Classical RK4} + +\paragraph{Definition.} For a state $y$, derivative $\dot y = f(t, y)$, +and step $\Delta t$, \verb|RK4Step| applies +\[ +\begin{aligned} + k_1 &= f(t, y), \\ + k_2 &= f\bigl(t + \tfrac{\Delta t}{2}, \; y + \tfrac{\Delta t}{2} k_1\bigr), \\ + k_3 &= f\bigl(t + \tfrac{\Delta t}{2}, \; y + \tfrac{\Delta t}{2} k_2\bigr), \\ + k_4 &= f\bigl(t + \Delta t, \; y + \Delta t \cdot k_3\bigr), \\ + y(t + \Delta t) &= y + \tfrac{\Delta t}{6}\bigl(k_1 + 2 k_2 + 2 k_3 + k_4\bigr). +\end{aligned} +\] +Reverse-time integration uses $\Delta t < 0$ unchanged; the implementation +contains no branch on the sign of $\Delta t$. Domain-specific vector +arithmetic (longitude wrap) is injected via \verb|VecAdd|. + +\subsection{Termination refinement} + +After each integration step the propagator checks one or more +constraints. When a constraint reports a violation between $(t_1, y_1)$ +(not violated) and $(t_2, y_2)$ (violated), \verb|RefineTrigger| +locates the crossing within tolerance $\tau \in (0, 1)$ by binary +search in the linear-interpolation parameter $\lambda \in [0, 1]$: + +\begin{algorithm}[H] +\caption{RefineTrigger}\label{alg:refine} +\begin{algorithmic}[1] + \State $L \gets 0,\; R \gets 1$ + \State $t_3 \gets t_2,\; y_3 \gets y_2$ + \While{$R - L > \tau$} + \State $m \gets (L + R)/2$ + \State $t_3 \gets (1 - m)\,t_1 + m\,t_2$ + \State $y_3 \gets \mathrm{lerp}(y_1, y_2, m)$ + \If{constraint violated at $(t_3, y_3)$} + \State $R \gets m$ + \Else + \State $L \gets m$ + \EndIf + \EndWhile + \State \Return $(t_3, y_3)$ +\end{algorithmic} +\end{algorithm} + +After $\lceil \log_2 \tau^{-1} \rceil$ iterations, $R - L \le \tau$. +With $\tau = 0{.}01$ and $\Delta t = 60$~s, the returned point is within +$0{.}6$~s of the true crossing in parameter space; the corresponding +altitude error is bounded by $0{.}6\,|\dot y|$, which for typical +balloon ascent and parachute descent rates is at most $\sim 3$~m. + +The returned point is the \emph{last midpoint sampled} rather than +guaranteed to lie on the triggered side; this matches the reference +Tawhiri implementation byte for byte. + +% ========================================================================= +\section{Wind data pipeline} +\label{sec:winddata} + +\paragraph{Data source.} NOAA GFS 0.5\textdegree{} (default) or 0.25\textdegree{} +forecasts, optionally subset by region or hour range. GEFS ensemble +runs are supported by selecting one of the 21 members; each member is a +separate dataset (\verb|DatasetID.Subset.Members = \{m\}|). + +\paragraph{Cube layout.} A flat C-order row-major float32 array, shape +$(N_{\text{hours}}, N_{\text{levels}}, 3, N_{\text{lat}}, N_{\text{lng}})$, +where the variable axis is fixed to (HGT, UGRD, VGRD). Per-variant sizes +live in \verb|internal/weather/gfs/variant.go|. + +\paragraph{Sampling.} Given a query $(t, \varphi, \lambda, h)$, the +sampler computes the time-in-hours offset +$\tau = (t - t_0)/3600$ from the dataset epoch $t_0$, brackets +$(\tau, \varphi, \lambda)$ on the three horizontal axes, then bisects +the pressure-level axis to find the largest level $\ell$ whose +trilinearly-interpolated HGT is below $h$. Wind components are +extracted via two more trilinear evaluations (at levels $\ell$ and +$\ell + 1$) and linearly interpolated in altitude: +\[ + W(t, \varphi, \lambda, h) = \alpha \cdot W_\ell + (1 - \alpha) \cdot W_{\ell+1}, + \qquad + \alpha = \frac{H_{\ell+1} - h}{H_{\ell+1} - H_\ell}. +\] + +% ========================================================================= +\section{Coverage and dataset selection} +\label{sec:coverage} + +A loaded dataset $\mathcal{D}$ exposes its \emph{coverage} +$C_\mathcal{D} = (R_\mathcal{D}, [t_0, t_1])$ where $R_\mathcal{D}$ is a +geographic bounding box (possibly antimeridian-spanning) and +$[t_0, t_1]$ is the temporal extent. When more than one dataset is +loaded simultaneously, the predictor selects the first one whose +$C_\mathcal{D}$ contains the launch query. Regional / sub-range +datasets thus complement the global default. + +% ========================================================================= +\section{Deferrals and design notes} +\label{sec:deferrals} + +\paragraph{Mass-aware drift.} The current model assumes the payload moves +horizontally at exactly the local wind velocity. A heavier payload +exhibits a velocity defect proportional to inertial coupling. A +plausible extension is the Stokes-style first-order lag model +\[ + \dot{\mathbf{v}}_p = \frac{1}{\tau}\bigl(\mathbf{v}_{\text{wind}}(t,\mathbf{s}) - \mathbf{v}_p\bigr), +\] +introduced as an additional state variable $\mathbf{v}_p$ alongside the +existing $\mathbf{s}$. The Propagator interface already accepts +arbitrary State types via generics in numerics; the engine could lift +its State to $(\mathbf{s}, \mathbf{v}_p)$ for a future mass-aware +propagator without breaking the existing models. + +\paragraph{Coordinate system upgrades.} Migrating to WGS84/ECEF would +remove the cosine factor in the horizontal wind transport equation and +make distances metric directly. GFS itself uses a spherical Earth; the +wind components are not directly portable. A clean implementation +provides a coordinate-system parameter on the profile request; for now, +the spherical model is used uniformly so that outputs remain bit +identical to the upstream Tawhiri. + +\paragraph{Monte Carlo.} GEFS already provides 21 ensemble members per +epoch. A Monte Carlo prediction would sample $K$ trajectories per +request, each picking a (member, parameter perturbation) pair. The +recommended architecture is to keep the perturbation inside the +predictor (so the same wind sample can serve many members and any +piecewise rate noise is correlated with the wind step), exposed as a +\verb|POST /api/v1/montecarlo| endpoint that returns one job per +sample and aggregates outcomes. + +% ========================================================================= +\section{Implementation notes} +\label{sec:impl} + +The numerics library is intentionally small (under 300 lines of Go) and +uses no allocations on the hot path. The generic \verb|RK4Step| and +\verb|RefineTrigger| compile to per-type specialisations under Go's +generics, so a future C or Rust port can mirror the implementation +verbatim without changing the call sites in the trajectory engine. + +\end{document} diff --git a/examples/wind-demo/README.md b/examples/wind-demo/README.md new file mode 100644 index 0000000..da76546 --- /dev/null +++ b/examples/wind-demo/README.md @@ -0,0 +1,51 @@ +# Wind layer demo + +A minimal browser client that renders the predictor's wind field as an +animated particle layer using [Leaflet](https://leafletjs.com/) and +[leaflet-velocity](https://github.com/onaci/leaflet-velocity). + +The predictor's `GET /api/v1/wind/field` endpoint emits the +[wind-js-server](https://github.com/danwild/wind-js-server) "gfs.json" format +(a two-element `[U, V]` array of `{header, data}` records), which is exactly +what leaflet-velocity and [sakitam-fdd/wind-layer](https://github.com/sakitam-fdd/wind-layer) +consume — so no transformation is needed in the frontend. + +## Running + +Serve this directory and the predictor from the same origin (or set `API` in +`index.html` to the predictor's base URL and rely on the predictor's CORS +headers): + +```bash +# Terminal 1: the predictor (must have a dataset loaded for real data) +./bin/predictor + +# Terminal 2: serve the demo +cd examples/wind-demo && python3 -m http.server 8090 +# open http://localhost:8090 (set API="http://localhost:8080" in index.html) +``` + +## API contract + +`GET /api/v1/wind/field` query parameters (all optional): + +| Param | Default | Meaning | +|---|---|---| +| `time` | dataset epoch | RFC3339 forecast time to sample | +| `altitude` | `0` | altitude in metres | +| `min_lat`,`max_lat`,`min_lng`,`max_lng` | global | bounding box (degrees) | +| `step` | `1.0` | grid resolution in degrees (min `0.25`) | + +`GET /api/v1/wind/meta` returns the active dataset's source, epoch, suggested +altitudes, and bounding box so a client can populate its controls. + +The full OpenAPI definition is served at `/openapi.yaml`, with a browsable +ReDoc rendering at `/docs`. + +## Minimal fetch + +```js +const res = await fetch("/api/v1/wind/field?altitude=10000&step=2"); +const data = await res.json(); // [ {header, data}, {header, data} ] +L.velocityLayer({ data }).addTo(map); +``` diff --git a/examples/wind-demo/index.html b/examples/wind-demo/index.html new file mode 100644 index 0000000..34ee0ca --- /dev/null +++ b/examples/wind-demo/index.html @@ -0,0 +1,98 @@ + + + + + + stratoflights-predictor — wind layer demo + + + + + + + + + + + +
+
+ Wind layer + + + +
+
+ + + + diff --git a/go.mod b/go.mod index 35b9486..744ff76 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-co-op/gocron v1.37.0 github.com/go-faster/errors v0.7.1 github.com/go-faster/jx v1.2.0 + github.com/google/uuid v1.6.0 github.com/nilsmagnus/grib v1.2.8 github.com/ogen-go/ogen v1.20.2 go.opentelemetry.io/otel v1.42.0 @@ -14,6 +15,7 @@ require ( go.opentelemetry.io/otel/trace v1.42.0 go.uber.org/zap v1.27.1 golang.org/x/sync v0.20.0 + gopkg.in/yaml.v2 v2.4.0 ) require ( @@ -24,7 +26,6 @@ require ( github.com/go-faster/yaml v0.4.6 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect @@ -37,5 +38,4 @@ require ( golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/internal/api/async/manager.go b/internal/api/async/manager.go new file mode 100644 index 0000000..93c1671 --- /dev/null +++ b/internal/api/async/manager.go @@ -0,0 +1,279 @@ +// Package async runs profile-driven predictions on a bounded worker pool and +// retains their results in memory for a configurable TTL. It is the engine +// behind the asynchronous prediction endpoints; the HTTP surface itself is +// the ogen-generated server in the parent package. +// +// The package is decoupled from the request/response wire types: a RunFunc is +// injected at construction, so this file imports only the generated API types +// it stores and returns. +package async + +import ( + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "predictor-refactored/internal/metrics" + apirest "predictor-refactored/pkg/rest" +) + +// RunFunc executes one prediction synchronously. +type RunFunc func(req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error) + +// Status is the lifecycle state of a prediction job. +type Status string + +const ( + StatusPending Status = "pending" + StatusRunning Status = "running" + StatusComplete Status = "complete" + StatusFailed Status = "failed" + StatusCancelled Status = "cancelled" +) + +// JobInfo is a snapshot of one prediction job. +type JobInfo struct { + ID string + Status Status + CreatedAt time.Time + StartedAt *time.Time + CompletedAt *time.Time + Error string + Result *apirest.PredictionV2Response +} + +type job struct { + id string + req *apirest.PredictionV2Request + createdAt time.Time + + mu sync.Mutex + status Status + startedAt time.Time + completedAt time.Time + errStr string + result *apirest.PredictionV2Response +} + +func (j *job) snapshot() JobInfo { + j.mu.Lock() + defer j.mu.Unlock() + info := JobInfo{ + ID: j.id, Status: j.status, CreatedAt: j.createdAt, + Error: j.errStr, Result: j.result, + } + if !j.startedAt.IsZero() { + t := j.startedAt + info.StartedAt = &t + } + if !j.completedAt.IsZero() { + t := j.completedAt + info.CompletedAt = &t + } + return info +} + +// Manager runs a fixed pool of workers and retains job results for a TTL. +type Manager struct { + run RunFunc + metrics metrics.Sink + log *zap.Logger + + queue chan *job + ttl time.Duration + + jobsMu sync.RWMutex + jobs map[string]*job + + inflight atomic.Int64 + closed chan struct{} + wg sync.WaitGroup +} + +// Config controls Manager construction. +type Config struct { + Workers int // max concurrent executions + QueueSize int // pending-queue bound + ResultTTL time.Duration // retention of terminal jobs +} + +// New constructs a Manager and starts its workers. run executes one +// prediction; sink and log may be nil. +func New(cfg Config, run RunFunc, sink metrics.Sink, log *zap.Logger) *Manager { + if cfg.Workers <= 0 { + cfg.Workers = 4 + } + if cfg.QueueSize <= 0 { + cfg.QueueSize = 64 + } + if cfg.ResultTTL <= 0 { + cfg.ResultTTL = time.Hour + } + if sink == nil { + sink = metrics.Noop() + } + if log == nil { + log = zap.NewNop() + } + m := &Manager{ + run: run, metrics: sink, log: log, + queue: make(chan *job, cfg.QueueSize), + jobs: make(map[string]*job), + ttl: cfg.ResultTTL, + closed: make(chan struct{}), + } + for range cfg.Workers { + m.wg.Add(1) + go m.worker() + } + m.wg.Add(1) + go m.evictor() + return m +} + +// Enqueue creates a job from req and returns its snapshot. The bool is false +// when the queue is full (the returned job is marked failed). +func (m *Manager) Enqueue(req *apirest.PredictionV2Request) (JobInfo, bool) { + j := &job{ + id: uuid.New().String(), + req: req, + createdAt: time.Now().UTC(), + status: StatusPending, + } + m.jobsMu.Lock() + m.jobs[j.id] = j + m.jobsMu.Unlock() + + select { + case m.queue <- j: + return j.snapshot(), true + default: + j.mu.Lock() + j.status = StatusFailed + j.errStr = "prediction queue full" + j.completedAt = time.Now().UTC() + j.mu.Unlock() + return j.snapshot(), false + } +} + +// Get returns a job's snapshot. +func (m *Manager) Get(id string) (JobInfo, bool) { + m.jobsMu.RLock() + j, ok := m.jobs[id] + m.jobsMu.RUnlock() + if !ok { + return JobInfo{}, false + } + return j.snapshot(), true +} + +// Cancel marks a still-queued job cancelled. Returns false when the job is +// unknown or already running/terminal — a running prediction cannot be +// interrupted (the worker would otherwise overwrite the cancelled status with +// its result), so callers get an honest "too late" rather than a 204 that the +// worker silently undoes. +func (m *Manager) Cancel(id string) bool { + m.jobsMu.RLock() + j, ok := m.jobs[id] + m.jobsMu.RUnlock() + if !ok { + return false + } + j.mu.Lock() + defer j.mu.Unlock() + if j.status != StatusPending { + return false + } + j.status = StatusCancelled + j.completedAt = time.Now().UTC() + return true +} + +// Inflight returns the number of running jobs. +func (m *Manager) Inflight() int64 { return m.inflight.Load() } + +// Close stops the workers and the evictor. +func (m *Manager) Close() { + close(m.closed) + close(m.queue) + m.wg.Wait() +} + +func (m *Manager) worker() { + defer m.wg.Done() + for j := range m.queue { + j.mu.Lock() + cancelled := j.status == StatusCancelled + if !cancelled { + j.status = StatusRunning + j.startedAt = time.Now().UTC() + } + j.mu.Unlock() + if cancelled { + continue + } + m.execute(j) + } +} + +// execute runs one job, recovering from a panic in the injected RunFunc so a +// single bad prediction can't leak the inflight counter or kill the worker. +func (m *Manager) execute(j *job) { + m.inflight.Add(1) + defer m.inflight.Add(-1) + + resp, err := func() (resp *apirest.PredictionV2Response, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("prediction panicked: %v", r) + } + }() + return m.run(j.req) + }() + + j.mu.Lock() + j.completedAt = time.Now().UTC() + if err != nil { + j.status = StatusFailed + j.errStr = err.Error() + } else { + j.status = StatusComplete + j.result = resp + } + dur := j.completedAt.Sub(j.startedAt) + j.mu.Unlock() + m.metrics.Prediction("async", dur, err) +} + +func (m *Manager) evictor() { + defer m.wg.Done() + ticker := time.NewTicker(m.ttl / 4) + defer ticker.Stop() + for { + select { + case <-m.closed: + return + case <-ticker.C: + m.evictExpired() + } + } +} + +func (m *Manager) evictExpired() { + now := time.Now().UTC() + m.jobsMu.Lock() + defer m.jobsMu.Unlock() + for id, j := range m.jobs { + j.mu.Lock() + expired := !j.completedAt.IsZero() && now.Sub(j.completedAt) > m.ttl + j.mu.Unlock() + if expired { + delete(m.jobs, id) + } + } +} diff --git a/internal/api/datasets.go b/internal/api/datasets.go new file mode 100644 index 0000000..2933f7d --- /dev/null +++ b/internal/api/datasets.go @@ -0,0 +1,189 @@ +package api + +import ( + "context" + "net/http" + "runtime" + "time" + + "predictor-refactored/internal/datasets" + apirest "predictor-refactored/pkg/rest" +) + +// ListDatasets implements GET /api/v1/admin/datasets. +func (h *Handler) ListDatasets(_ context.Context) (*apirest.DatasetList, error) { + stored, err := h.mgr.ListEpochs() + if err != nil { + return nil, apiError(http.StatusInternalServerError, err.Error()) + } + loaded := make(map[string]datasets.LoadedDatasetInfo) + for _, ld := range h.mgr.LoadedDatasets() { + loaded[ld.ID.Filename()] = ld + } + + out := &apirest.DatasetList{Source: h.mgr.Source(), Datasets: make([]apirest.DatasetEntry, 0, len(stored))} + for _, id := range stored { + entry := apirest.DatasetEntry{ + Filename: id.Filename(), + Epoch: id.Epoch.UTC(), + } + if !id.Subset.IsGlobal() { + entry.Subset = apirest.NewOptSubsetSpec(subsetToAPI(id.Subset)) + } + if ld, ok := loaded[id.Filename()]; ok { + entry.Loaded = true + entry.Coverage = apirest.NewOptCoverage(coverageToAPI(ld.Coverage)) + } + out.Datasets = append(out.Datasets, entry) + } + return out, nil +} + +// TriggerDatasetDownload implements POST /api/v1/admin/datasets. +func (h *Handler) TriggerDatasetDownload(ctx context.Context, req *apirest.DownloadRequest) (*apirest.DownloadAccepted, error) { + if req.Latest.Or(false) { + dctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + jobID, err := h.mgr.Refresh(dctx, 0) + if err != nil { + return nil, apiError(http.StatusInternalServerError, err.Error()) + } + return &apirest.DownloadAccepted{JobID: jobID}, nil + } + + epoch, ok := req.Epoch.Get() + if !ok { + return nil, apiError(http.StatusBadRequest, "specify either epoch or latest=true") + } + id := datasets.DatasetID{Epoch: epoch.UTC()} + if s, ok := req.Subset.Get(); ok { + id.Subset = subsetFromAPI(s) + } + return &apirest.DownloadAccepted{JobID: h.mgr.Download(id)}, nil +} + +// DeleteDataset implements DELETE /api/v1/admin/datasets/{name}. +func (h *Handler) DeleteDataset(_ context.Context, params apirest.DeleteDatasetParams) error { + stored, err := h.mgr.ListEpochs() + if err != nil { + return apiError(http.StatusInternalServerError, err.Error()) + } + for _, id := range stored { + if id.Filename() == params.Name { + if err := h.mgr.Remove(id); err != nil { + return apiError(http.StatusInternalServerError, err.Error()) + } + return nil + } + } + return apiError(http.StatusNotFound, "dataset not found") +} + +// ListDatasetJobs implements GET /api/v1/admin/jobs. +func (h *Handler) ListDatasetJobs(_ context.Context) ([]apirest.DownloadJob, error) { + jobs := h.mgr.ListJobs() + out := make([]apirest.DownloadJob, 0, len(jobs)) + for _, j := range jobs { + out = append(out, downloadJobToAPI(j)) + } + return out, nil +} + +// GetDatasetJob implements GET /api/v1/admin/jobs/{id}. +func (h *Handler) GetDatasetJob(_ context.Context, params apirest.GetDatasetJobParams) (*apirest.DownloadJob, error) { + j, ok := h.mgr.GetJob(params.ID) + if !ok { + return nil, apiError(http.StatusNotFound, "job not found") + } + dto := downloadJobToAPI(j) + return &dto, nil +} + +// CancelDatasetJob implements DELETE /api/v1/admin/jobs/{id}. +func (h *Handler) CancelDatasetJob(_ context.Context, params apirest.CancelDatasetJobParams) error { + if !h.mgr.CancelJob(params.ID) { + return apiError(http.StatusConflict, "job not found or already terminal") + } + return nil +} + +// GetServiceStatus implements GET /api/v1/admin/status. +func (h *Handler) GetServiceStatus(_ context.Context) (*apirest.StatusResponse, error) { + jobs := h.mgr.ListJobs() + stored, _ := h.mgr.ListEpochs() + loaded := h.mgr.LoadedDatasets() + + byStatus := apirest.StatusResponseJobsByStatus{} + for _, j := range jobs { + byStatus[string(j.Status)]++ + } + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + + return &apirest.StatusResponse{ + Source: h.mgr.Source(), + Uptime: time.Since(h.started).Round(time.Second).String(), + Goroutines: runtime.NumGoroutine(), + MemoryMB: int64(mem.Alloc / 1024 / 1024), + JobsByStatus: byStatus, + StoredDatasets: len(stored), + LoadedDatasets: len(loaded), + }, nil +} + +// --- dataset mapping helpers ---------------------------------------------- + +func downloadJobToAPI(j datasets.JobInfo) apirest.DownloadJob { + dto := apirest.DownloadJob{ + ID: j.ID, + Source: j.Source, + Dataset: j.Dataset.Filename(), + Epoch: j.Dataset.Epoch.UTC(), + Status: apirest.DownloadJobStatus(j.Status), + StartedAt: j.StartedAt.UTC(), + TotalUnits: j.Total, + DoneUnits: j.Done, + Bytes: j.Bytes, + } + if j.EndedAt != nil { + dto.EndedAt = apirest.NewOptDateTime(j.EndedAt.UTC()) + } + if j.Err != "" { + dto.Error = apirest.NewOptString(j.Err) + } + return dto +} + +func subsetToAPI(s datasets.SubsetSpec) apirest.SubsetSpec { + out := apirest.SubsetSpec{Members: s.Members} + if s.Region != nil { + out.Region = apirest.NewOptRegion(regionToAPI(*s.Region)) + } + if s.HourRange != nil { + out.HourRange = apirest.NewOptHourRange(apirest.HourRange{MinHour: s.HourRange.MinHour, MaxHour: s.HourRange.MaxHour}) + } + return out +} + +func subsetFromAPI(s apirest.SubsetSpec) datasets.SubsetSpec { + out := datasets.SubsetSpec{Members: s.Members} + if r, ok := s.Region.Get(); ok { + out.Region = &datasets.Region{MinLat: r.MinLat, MaxLat: r.MaxLat, MinLng: r.MinLng, MaxLng: r.MaxLng} + } + if hr, ok := s.HourRange.Get(); ok { + out.HourRange = &datasets.HourRange{MinHour: hr.MinHour, MaxHour: hr.MaxHour} + } + return out +} + +func regionToAPI(r datasets.Region) apirest.Region { + return apirest.Region{MinLat: r.MinLat, MaxLat: r.MaxLat, MinLng: r.MinLng, MaxLng: r.MaxLng} +} + +func coverageToAPI(c datasets.Coverage) apirest.Coverage { + return apirest.Coverage{ + Region: regionToAPI(c.Region), + StartTime: c.StartTime.UTC(), + EndTime: c.EndTime.UTC(), + } +} diff --git a/internal/api/docs/docs.go b/internal/api/docs/docs.go new file mode 100644 index 0000000..0039dc9 --- /dev/null +++ b/internal/api/docs/docs.go @@ -0,0 +1,48 @@ +// Package docs serves the human-facing API documentation: the OpenAPI +// document and a ReDoc rendering of it. The spec is embedded in the binary +// (see package apispec) so the documentation needs no external files or a +// separate server. +package docs + +import ( + "net/http" + + apispec "predictor-refactored/api" +) + +// redocHTML renders the embedded spec with ReDoc loaded from a CDN. +const redocHTML = ` + + + stratoflights-predictor API + + + + + + + + +` + +// Handler serves the documentation endpoints. +type Handler struct{} + +// New returns a docs Handler. +func New() *Handler { return &Handler{} } + +// Register installs GET /docs and GET /openapi.yaml on mux. +func (h *Handler) Register(mux *http.ServeMux) { + mux.HandleFunc("GET /openapi.yaml", h.spec) + mux.HandleFunc("GET /docs", h.redoc) +} + +func (h *Handler) spec(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write(apispec.Spec) +} + +func (h *Handler) redoc(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(redocHTML)) +} diff --git a/internal/api/handler.go b/internal/api/handler.go new file mode 100644 index 0000000..ae5b6b6 --- /dev/null +++ b/internal/api/handler.go @@ -0,0 +1,70 @@ +package api + +import ( + "context" + "errors" + "net/http" + "time" + + "go.uber.org/zap" + + "predictor-refactored/internal/api/async" + "predictor-refactored/internal/datasets" + "predictor-refactored/internal/elevation" + "predictor-refactored/internal/engine" + "predictor-refactored/internal/metrics" + "predictor-refactored/internal/windviz" + apirest "predictor-refactored/pkg/rest" +) + +// Handler implements the ogen-generated apirest.Handler interface for every +// operation in the OpenAPI spec. Operation methods are grouped by concern +// across prediction.go, datasets.go, and wind.go. +type Handler struct { + mgr *datasets.Manager + elev *elevation.Dataset + async *async.Manager + metrics metrics.Sink + cache *windviz.Cache + started time.Time + log *zap.Logger +} + +var _ apirest.Handler = (*Handler)(nil) + +// terrain returns the elevation dataset as an engine.TerrainProvider, or an +// untyped nil interface when no elevation dataset is loaded. Returning the +// concrete nil *elevation.Dataset directly would produce a non-nil interface +// wrapping a nil pointer, which then panics on first use — so the nil check +// must happen here, on the concrete type. +func (h *Handler) terrain() engine.TerrainProvider { + if h.elev == nil { + return nil + } + return h.elev +} + +// NewError converts an error returned by a handler into the spec's default +// error response. Handlers return *apirest.DefaultErrorStatusCode (via the +// apiError helper) to control the status code; anything else is a 500. +func (h *Handler) NewError(_ context.Context, err error) *apirest.DefaultErrorStatusCode { + var coded *apirest.DefaultErrorStatusCode + if errors.As(err, &coded) { + return coded + } + h.log.Error("unhandled handler error", zap.Error(err)) + return apiError(http.StatusInternalServerError, err.Error()) +} + +// apiError builds a coded error response carrying an HTTP status. +func apiError(status int, description string) *apirest.DefaultErrorStatusCode { + return &apirest.DefaultErrorStatusCode{ + StatusCode: status, + Response: apirest.Error{ + Error: apirest.ErrorError{ + Type: http.StatusText(status), + Description: description, + }, + }, + } +} diff --git a/internal/api/mapping.go b/internal/api/mapping.go new file mode 100644 index 0000000..e3d4d72 --- /dev/null +++ b/internal/api/mapping.go @@ -0,0 +1,217 @@ +package api + +import ( + "fmt" + "time" + + "predictor-refactored/internal/api/async" + "predictor-refactored/internal/engine" + apirest "predictor-refactored/pkg/rest" +) + +// normalizeLng folds a longitude into [0, 360) for internal use. +func normalizeLng(lng float64) float64 { + if lng < 0 { + return lng + 360 + } + return lng +} + +// signedLng converts an internal [0, 360) longitude back to [-180, 180). +func signedLng(lng float64) float64 { + if lng > 180 { + return lng - 360 + } + return lng +} + +// buildProfile translates an API prediction request into an engine profile +// using the engine's model/constraint registry. +// maxProfileStages bounds the propagator chain length to keep a single +// request's work bounded. +const maxProfileStages = 32 + +func buildProfile(req *apirest.PredictionV2Request, deps engine.BuildDeps) (engine.Profile, error) { + if len(req.Profile) == 0 { + return engine.Profile{}, fmt.Errorf("profile must contain at least one stage") + } + if len(req.Profile) > maxProfileStages { + return engine.Profile{}, fmt.Errorf("profile has %d stages; maximum is %d", len(req.Profile), maxProfileStages) + } + + step := 60.0 + tol := 0.01 + if o, ok := req.Options.Get(); ok { + step = o.StepSeconds.Or(step) + tol = o.Tolerance.Or(tol) + } + if step <= 0 || step > 3600 { + return engine.Profile{}, fmt.Errorf("options.step_seconds must be in (0, 3600], got %g", step) + } + if tol <= 0 || tol >= 1 { + return engine.Profile{}, fmt.Errorf("options.tolerance must be in (0, 1), got %g", tol) + } + + dir := engine.Forward + if req.Direction.Or(apirest.PredictionV2RequestDirectionForward) == apirest.PredictionV2RequestDirectionReverse { + dir = engine.Reverse + } + + props := make([]*engine.Propagator, len(req.Profile)) + for i, stage := range req.Profile { + if stage.Name == "" { + return engine.Profile{}, fmt.Errorf("stage %d: name is required", i) + } + built, err := engine.BuildModel(toEngineModelSpec(stage.Model), deps) + if err != nil { + return engine.Profile{}, fmt.Errorf("stage %q model: %w", stage.Name, err) + } + constraints, err := toEngineConstraints(stage.Constraints, deps) + if err != nil { + return engine.Profile{}, fmt.Errorf("stage %q: %w", stage.Name, err) + } + props[i] = &engine.Propagator{ + Name: stage.Name, + Step: step, + Model: built.Model, + BuildModel: built.Build, + Constraints: constraints, + Tolerance: tol, + } + } + for i, stage := range req.Profile { + idx, ok := stage.FallbackIndex.Get() + if !ok { + continue + } + if idx < 0 || idx >= len(props) { + return engine.Profile{}, fmt.Errorf("stage %q: fallback_index %d out of range", stage.Name, idx) + } + props[i].Fallback = props[idx] + } + + globals, err := toEngineConstraints(req.Globals, deps) + if err != nil { + return engine.Profile{}, fmt.Errorf("globals: %w", err) + } + return engine.Profile{Stages: props, Direction: dir, Globals: globals}, nil +} + +func toEngineModelSpec(m apirest.ModelSpec) engine.ModelSpec { + out := engine.ModelSpec{ + Type: string(m.Type), + Rate: m.Rate.Or(0), + SeaLevelRate: m.SeaLevelRate.Or(0), + IncludeWind: m.IncludeWind.Or(false), + } + for _, s := range m.Segments { + out.Segments = append(out.Segments, engine.PiecewiseSegmentSpec{ + Until: s.Until, + Rate: s.Rate, + Reference: string(s.Reference.Or(apirest.PiecewiseSegmentReferenceAbsolute)), + }) + } + return out +} + +func toEngineConstraints(specs []apirest.ConstraintSpec, deps engine.BuildDeps) ([]engine.Constraint, error) { + out := make([]engine.Constraint, 0, len(specs)) + for i, s := range specs { + c, err := engine.BuildConstraint(toEngineConstraintSpec(s), deps) + if err != nil { + return nil, fmt.Errorf("constraint[%d]: %w", i, err) + } + out = append(out, c) + } + return out, nil +} + +func toEngineConstraintSpec(c apirest.ConstraintSpec) engine.ConstraintSpec { + spec := engine.ConstraintSpec{ + Type: string(c.Type), + Op: string(c.Op.Or("")), + Limit: c.Limit.Or(0), + Action: string(c.Action.Or(apirest.ConstraintSpecActionStop)), + Mode: string(c.Mode.Or("")), + Label: c.Label.Or(""), + } + for _, v := range c.Vertices { + spec.Vertices = append(spec.Vertices, engine.PolygonVertex{Lat: v.Lat, Lng: v.Lng}) + } + return spec +} + +// stageResultToAPI maps one engine stage result to the API representation. +func stageResultToAPI(r engine.Result) apirest.StageResult { + out := apirest.StageResult{ + Name: r.Propagator, + Outcome: apirest.StageResultOutcome(r.Outcome.String()), + Events: eventsToAPI(r.Events), + } + if r.Constraint != nil { + out.Constraint = apirest.NewOptString(r.ConstraintName) + out.Termination = apirest.NewOptTerminationInfo(apirest.TerminationInfo{ + ViolationTime: time.Unix(int64(r.ViolationTime), 0).UTC(), + ViolationState: geoStateToAPI(r.ViolationState), + RefinedTime: time.Unix(int64(r.RefinedTime), 0).UTC(), + RefinedState: geoStateToAPI(r.RefinedState), + }) + } + n := r.Path.Len() + out.Trajectory = make([]apirest.TrajectoryPoint, n) + for i := range n { + t, p := r.Path.At(i) + out.Trajectory[i] = apirest.TrajectoryPoint{ + Time: time.Unix(int64(t), 0).UTC(), + Latitude: p.Lat, + Longitude: signedLng(p.Lng), + Altitude: p.Altitude, + } + } + return out +} + +func geoStateToAPI(s engine.State) apirest.GeoState { + return apirest.GeoState{Lat: s.Lat, Lng: signedLng(s.Lng), Altitude: s.Altitude} +} + +func eventsToAPI(in []engine.EventSummary) []apirest.EventSummary { + if len(in) == 0 { + return nil + } + out := make([]apirest.EventSummary, 0, len(in)) + for _, e := range in { + out = append(out, apirest.EventSummary{ + Type: e.Type, + Count: e.Count, + FirstTime: apirest.NewOptFloat64(e.FirstTime), + LastTime: apirest.NewOptFloat64(e.LastTime), + FirstState: apirest.NewOptGeoState(geoStateToAPI(e.FirstState)), + LastState: apirest.NewOptGeoState(geoStateToAPI(e.LastState)), + Message: apirest.NewOptString(e.Message), + }) + } + return out +} + +// asyncJobToAPI maps an async job snapshot to the API PredictionJob. +func asyncJobToAPI(info async.JobInfo) *apirest.PredictionJob { + job := &apirest.PredictionJob{ + ID: info.ID, + Status: apirest.PredictionJobStatus(info.Status), + CreatedAt: info.CreatedAt, + } + if info.StartedAt != nil { + job.StartedAt = apirest.NewOptDateTime(*info.StartedAt) + } + if info.CompletedAt != nil { + job.CompletedAt = apirest.NewOptDateTime(*info.CompletedAt) + } + if info.Error != "" { + job.Error = apirest.NewOptString(info.Error) + } + if info.Result != nil { + job.Result = apirest.NewOptPredictionV2Response(*info.Result) + } + return job +} diff --git a/internal/api/middleware/cors.go b/internal/api/middleware/cors.go new file mode 100644 index 0000000..30a322c --- /dev/null +++ b/internal/api/middleware/cors.go @@ -0,0 +1,20 @@ +package middleware + +import "net/http" + +// CORS wraps next with permissive CORS headers and short-circuits OPTIONS preflight. +// +// This service is meant to sit behind an authenticated gateway, so we set +// "Access-Control-Allow-Origin: *". Tighten this if you deploy elsewhere. +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/api/middleware/log.go b/internal/api/middleware/log.go new file mode 100644 index 0000000..3702bbc --- /dev/null +++ b/internal/api/middleware/log.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "time" + + "github.com/ogen-go/ogen/middleware" + "go.uber.org/zap" +) + +// statusCoder is implemented by ogen's *...StatusCode error wrappers. +type statusCoder interface{ GetStatusCode() int } + +// OgenLogging is an ogen middleware that logs each operation's duration and +// outcome. Handler errors carrying a 4xx/5xx-class status are logged at the +// appropriate level: client errors (and expected 503s during startup) at +// warn without a stacktrace, server errors at error. +func OgenLogging(log *zap.Logger) middleware.Middleware { + return func(req middleware.Request, next func(req middleware.Request) (middleware.Response, error)) (middleware.Response, error) { + start := time.Now() + resp, err := next(req) + lg := log.With(zap.String("operation", req.OperationID), zap.Duration("duration", time.Since(start))) + + if err == nil { + lg.Info("request completed") + return resp, err + } + if sc, ok := err.(statusCoder); ok && sc.GetStatusCode() < 500 { + lg.Warn("request rejected", zap.Int("status", sc.GetStatusCode()), zap.NamedError("reason", err)) + } else { + lg.Error("request failed", zap.Error(err)) + } + return resp, err + } +} diff --git a/internal/api/prediction.go b/internal/api/prediction.go new file mode 100644 index 0000000..5af501d --- /dev/null +++ b/internal/api/prediction.go @@ -0,0 +1,239 @@ +package api + +import ( + "context" + "net/http" + "time" + + "predictor-refactored/internal/engine" + "predictor-refactored/internal/weather" + apirest "predictor-refactored/pkg/rest" +) + +// ReadinessCheck implements GET /ready. +func (h *Handler) ReadinessCheck(_ context.Context) (*apirest.ReadinessResponse, error) { + resp := &apirest.ReadinessResponse{} + if field := h.mgr.Active(); field != nil { + resp.Status = apirest.ReadinessResponseStatusOk + resp.DatasetTime = apirest.NewOptDateTime(field.Epoch()) + } else { + resp.Status = apirest.ReadinessResponseStatusNotReady + resp.ErrorMessage = apirest.NewOptString("no dataset loaded") + } + return resp, nil +} + +// PerformPredictionV2 implements POST /api/v2/prediction. +func (h *Handler) PerformPredictionV2(_ context.Context, req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error) { + resp, err := h.runPredictionV2(req) + if err == nil { + h.metrics.Prediction("v2", resp.CompletedAt.Sub(resp.StartedAt), nil) + } + return resp, err +} + +// CreatePredictionJob implements POST /api/v1/predictions. +func (h *Handler) CreatePredictionJob(_ context.Context, req *apirest.PredictionV2Request) (*apirest.PredictionJob, error) { + info, accepted := h.async.Enqueue(req) + if !accepted { + return nil, apiError(http.StatusServiceUnavailable, info.Error) + } + return asyncJobToAPI(info), nil +} + +// GetPredictionJob implements GET /api/v1/predictions/{id}. +func (h *Handler) GetPredictionJob(_ context.Context, params apirest.GetPredictionJobParams) (*apirest.PredictionJob, error) { + info, ok := h.async.Get(params.ID) + if !ok { + return nil, apiError(http.StatusNotFound, "prediction job not found") + } + return asyncJobToAPI(info), nil +} + +// CancelPredictionJob implements DELETE /api/v1/predictions/{id}. +func (h *Handler) CancelPredictionJob(_ context.Context, params apirest.CancelPredictionJobParams) error { + if !h.async.Cancel(params.ID) { + return apiError(http.StatusConflict, "job not found or already terminal") + } + return nil +} + +// runPredictionV2 is the synchronous prediction core, shared by the v2 +// endpoint and the async worker pool. +func (h *Handler) runPredictionV2(req *apirest.PredictionV2Request) (*apirest.PredictionV2Response, error) { + // Validate the request shape before checking dataset availability, so a + // malformed request is a 400 regardless of startup state. + lat := req.Launch.Latitude + rawLng := req.Launch.Longitude + alt := req.Launch.Altitude.Or(0) + if lat < -90 || lat > 90 { + return nil, apiError(http.StatusBadRequest, "launch.latitude must be in [-90, 90]") + } + if rawLng < -180 || rawLng >= 360 { + return nil, apiError(http.StatusBadRequest, "launch.longitude must be in [-180, 360)") + } + lng := normalizeLng(rawLng) + + field := h.mgr.Active() + if field == nil { + return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded, service is starting up") + } + + events := engine.NewEventSink() + deps := engine.BuildDeps{Wind: field, Events: events, Terrain: h.terrain()} + + prof, err := buildProfile(req, deps) + if err != nil { + return nil, apiError(http.StatusBadRequest, err.Error()) + } + + started := time.Now().UTC() + results := prof.Run(float64(req.Launch.Time.Unix()), engine.State{Lat: lat, Lng: lng, Altitude: alt}, events) + completed := time.Now().UTC() + + resp := &apirest.PredictionV2Response{ + Stages: make([]apirest.StageResult, 0, len(results)), + Events: eventsToAPI(events.Snapshot()), + Dataset: apirest.DatasetInfo{Source: field.Source(), Epoch: field.Epoch()}, + StartedAt: started, + CompletedAt: completed, + } + for _, r := range results { + resp.Stages = append(resp.Stages, stageResultToAPI(r)) + } + return resp, nil +} + +// PerformPrediction implements GET /api/v1/prediction (Tawhiri-compatible). +func (h *Handler) PerformPrediction(_ context.Context, params apirest.PerformPredictionParams) (*apirest.PredictionResponse, error) { + field := h.mgr.Active() + if field == nil { + return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded, service is starting up") + } + + profileKind := "standard_profile" + if p, ok := params.Profile.Get(); ok { + profileKind = string(p) + } + ascentRate := params.AscentRate.Or(5) + descentRate := params.DescentRate.Or(5) + launchAlt := params.LaunchAltitude.Or(0) + lng := normalizeLng(params.LaunchLongitude) + launchTime := float64(params.LaunchDatetime.Unix()) + + events := engine.NewEventSink() + var stageNames []string + var prof engine.Profile + switch profileKind { + case "standard_profile": + stageNames = []string{"ascent", "descent"} + prof = standardProfile(field, h.terrain(), events, ascentRate, params.BurstAltitude.Or(28000), descentRate) + case "float_profile": + stopTime := params.LaunchDatetime.Add(24 * time.Hour) + if v, ok := params.StopDatetime.Get(); ok { + stopTime = v + } + stageNames = []string{"ascent", "float"} + prof = floatProfile(field, events, ascentRate, params.FloatAltitude.Or(25000), stopTime) + default: + return nil, apiError(http.StatusBadRequest, "unknown profile: "+profileKind) + } + + started := time.Now().UTC() + results := prof.Run(launchTime, engine.State{Lat: params.LaunchLatitude, Lng: lng, Altitude: launchAlt}, events) + completed := time.Now().UTC() + h.metrics.Prediction(profileKind, completed.Sub(started), nil) + + resp := &apirest.PredictionResponse{ + Metadata: apirest.PredictionResponseMetadata{StartDatetime: started, CompleteDatetime: completed}, + } + for i, r := range results { + name := "ascent" + if i < len(stageNames) { + name = stageNames[i] + } + resp.Prediction = append(resp.Prediction, tawhiriItem(name, r)) + } + resp.Request = apirest.NewOptPredictionResponseRequest(apirest.PredictionResponseRequest{ + Dataset: apirest.NewOptString(field.Epoch().Format("2006-01-02T15:04:05Z")), + LaunchLatitude: apirest.NewOptFloat64(params.LaunchLatitude), + LaunchLongitude: apirest.NewOptFloat64(params.LaunchLongitude), + LaunchDatetime: apirest.NewOptString(params.LaunchDatetime.Format(time.RFC3339)), + LaunchAltitude: params.LaunchAltitude, + }) + if ev := events.Snapshot(); len(ev) > 0 { + resp.Warnings = apirest.NewOptPredictionResponseWarnings(apirest.PredictionResponseWarnings{}) + } + return resp, nil +} + +// standardProfile builds the Tawhiri ascent → descent chain. +func standardProfile(field weather.WindField, elev engine.TerrainProvider, events *engine.EventSink, ascentRate, burst, descentRate float64) engine.Profile { + wind := engine.WindTransport(field, events) + descentTerm := []engine.Constraint{engine.Altitude{Op: engine.OpLessEqual, Limit: 0, On: engine.ActionStop}} + if elev != nil { + descentTerm = []engine.Constraint{engine.TerrainContact{Provider: elev, On: engine.ActionStop}} + } + return engine.Profile{ + Direction: engine.Forward, + Stages: []*engine.Propagator{ + { + Name: "ascent", + Step: 60, + Model: engine.Sum(engine.ConstantRate(ascentRate), wind), + Constraints: []engine.Constraint{engine.Altitude{Op: engine.OpGreaterEqual, Limit: burst, On: engine.ActionStop}}, + }, + { + Name: "descent", + Step: 60, + Model: engine.Sum(engine.ParachuteDescent(descentRate), wind), + Constraints: descentTerm, + }, + }, + } +} + +// floatProfile builds the Tawhiri ascent → float chain. +func floatProfile(field weather.WindField, events *engine.EventSink, ascentRate, floatAlt float64, stopTime time.Time) engine.Profile { + wind := engine.WindTransport(field, events) + return engine.Profile{ + Direction: engine.Forward, + Stages: []*engine.Propagator{ + { + Name: "ascent", + Step: 60, + Model: engine.Sum(engine.ConstantRate(ascentRate), wind), + Constraints: []engine.Constraint{engine.Altitude{Op: engine.OpGreaterEqual, Limit: floatAlt, On: engine.ActionStop}}, + }, + { + Name: "float", + Step: 60, + Model: wind, + Constraints: []engine.Constraint{engine.Time{Op: engine.OpGreater, Limit: float64(stopTime.Unix()), On: engine.ActionStop}}, + }, + }, + } +} + +// tawhiriItem maps one engine stage result to a v1 prediction item. +func tawhiriItem(name string, r engine.Result) apirest.PredictionResponsePredictionItem { + stage := apirest.PredictionResponsePredictionItemStageAscent + switch name { + case "descent": + stage = apirest.PredictionResponsePredictionItemStageDescent + case "float": + stage = apirest.PredictionResponsePredictionItemStageFloat + } + n := r.Path.Len() + traj := make([]apirest.TawhiriPoint, 0, n) + for i := range n { + t, p := r.Path.At(i) + traj = append(traj, apirest.TawhiriPoint{ + Datetime: time.Unix(int64(t), 0).UTC(), + Latitude: p.Lat, + Longitude: signedLng(p.Lng), + Altitude: p.Altitude, + }) + } + return apirest.PredictionResponsePredictionItem{Stage: stage, Trajectory: traj} +} diff --git a/internal/api/transport.go b/internal/api/transport.go new file mode 100644 index 0000000..75a9122 --- /dev/null +++ b/internal/api/transport.go @@ -0,0 +1,131 @@ +// Package api is the HTTP surface of the service. Every REST operation is +// defined in the OpenAPI spec (api/rest/predictor.swagger.yml) and served by +// the ogen-generated server in pkg/rest; this package implements the +// generated Handler interface and wires the server together with the +// non-OpenAPI endpoints (Prometheus metrics, ReDoc docs). +package api + +import ( + "context" + "fmt" + "net/http" + "time" + + "go.uber.org/zap" + + "predictor-refactored/internal/api/async" + "predictor-refactored/internal/api/docs" + "predictor-refactored/internal/api/middleware" + "predictor-refactored/internal/datasets" + "predictor-refactored/internal/elevation" + "predictor-refactored/internal/metrics" + "predictor-refactored/internal/windviz" + apirest "predictor-refactored/pkg/rest" +) + +// Server is the top-level HTTP server. +type Server struct { + port int + mux *http.ServeMux + async *async.Manager + log *zap.Logger +} + +// Deps are the runtime dependencies the API layer needs. +type Deps struct { + Manager *datasets.Manager + Elevation *elevation.Dataset + Metrics metrics.Sink + MetricsHandler http.Handler // optional; mounted at MetricsPath when non-nil + MetricsPath string + EnableWind bool + WindCache *windviz.Cache // optional; created if nil and EnableWind + + AsyncWorkers int + AsyncQueueSize int + AsyncResultTTL time.Duration + + Log *zap.Logger +} + +// New wires the HTTP server. The returned Server is not yet started. +func New(port int, d Deps) (*Server, error) { + if d.Log == nil { + d.Log = zap.NewNop() + } + if d.Metrics == nil { + d.Metrics = metrics.Noop() + } + if d.EnableWind && d.WindCache == nil { + d.WindCache = windviz.NewCache(64, 10*time.Minute) + } + + h := &Handler{ + mgr: d.Manager, + elev: d.Elevation, + metrics: d.Metrics, + cache: d.WindCache, + started: time.Now().UTC(), + log: d.Log, + } + // The async worker pool runs the same prediction core as the synchronous + // endpoint; inject it so async stays decoupled from the wire types. + h.async = async.New(async.Config{ + Workers: d.AsyncWorkers, + QueueSize: d.AsyncQueueSize, + ResultTTL: d.AsyncResultTTL, + }, h.runPredictionV2, d.Metrics, d.Log) + + ogenSrv, err := apirest.NewServer(h, apirest.WithMiddleware(middleware.OgenLogging(d.Log))) + if err != nil { + return nil, fmt.Errorf("create ogen server: %w", err) + } + + mux := http.NewServeMux() + // Liveness: always 200 while the process is up, independent of whether a + // dataset is loaded. Container/orchestrator health checks use this; the + // readiness of the data plane is /ready (an OpenAPI operation). + mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"alive"}`)) + }) + docs.New().Register(mux) + if d.MetricsHandler != nil && d.MetricsPath != "" { + mux.Handle(d.MetricsPath, d.MetricsHandler) + } + // The ogen server owns every OpenAPI route; mount it last as the catch-all. + mux.Handle("/", ogenSrv) + + return &Server{port: port, mux: mux, async: h.async, log: d.Log}, nil +} + +// Run starts the HTTP server and blocks until ctx is cancelled or the server +// fails. The handler chain is CORS → mux (ogen routes + docs + metrics). +func (s *Server) Run(ctx context.Context) error { + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: middleware.CORS(s.mux), + } + + errCh := make(chan error, 1) + go func() { + s.log.Info("HTTP server starting", zap.Int("port", s.port)) + errCh <- srv.ListenAndServe() + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) + } +} + +// Close releases background resources (the async worker pool). +func (s *Server) Close() { + if s.async != nil { + s.async.Close() + } +} diff --git a/internal/api/wind.go b/internal/api/wind.go new file mode 100644 index 0000000..ba797d1 --- /dev/null +++ b/internal/api/wind.go @@ -0,0 +1,92 @@ +package api + +import ( + "context" + "fmt" + "net/http" + + "predictor-refactored/internal/windviz" + apirest "predictor-refactored/pkg/rest" +) + +// GetWindMeta implements GET /api/v1/wind/meta. +func (h *Handler) GetWindMeta(_ context.Context) (*apirest.WindMeta, error) { + field := h.mgr.Active() + if field == nil { + return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded") + } + return &apirest.WindMeta{ + Source: field.Source(), + Epoch: field.Epoch().UTC(), + DefaultStep: 1.0, + MinStep: 0.25, + SuggestedAltitudes: []int{0, 1000, 5000, 10000, 15000, 20000, 30000}, + Bbox: apirest.Region{MinLat: -90, MaxLat: 90, MinLng: 0, MaxLng: 360}, + }, nil +} + +// GetWindField implements GET /api/v1/wind/field. +func (h *Handler) GetWindField(_ context.Context, params apirest.GetWindFieldParams) ([]apirest.WindComponent, error) { + field := h.mgr.Active() + if field == nil { + return nil, apiError(http.StatusServiceUnavailable, "no dataset loaded") + } + + when := field.Epoch() + if t, ok := params.Time.Get(); ok { + when = t + } + req := windviz.Request{ + Time: float64(when.Unix()), + Altitude: params.Altitude.Or(0), + MinLat: params.MinLat.Or(0), + MaxLat: params.MaxLat.Or(0), + MinLng: params.MinLng.Or(0), + MaxLng: params.MaxLng.Or(0), + Step: params.Step.Or(0), + } + + key := fmt.Sprintf("%s|%v|%.3f|%.3f|%.3f|%.3f|%.3f|%.3f", + field.Source(), req.Time, req.Altitude, req.MinLat, req.MaxLat, req.MinLng, req.MaxLng, req.Step) + if h.cache != nil { + if cached, ok := h.cache.Get(key); ok { + return windFieldToAPI(cached), nil + } + } + + out, err := windviz.Rasterize(field, req) + if err != nil { + return nil, apiError(http.StatusBadRequest, err.Error()) + } + if h.cache != nil { + h.cache.Put(key, out) + } + return windFieldToAPI(out), nil +} + +// windFieldToAPI maps a rasterized field to the generated component slice. +func windFieldToAPI(f windviz.Field) []apirest.WindComponent { + out := make([]apirest.WindComponent, 0, len(f)) + for _, c := range f { + out = append(out, apirest.WindComponent{ + Header: apirest.WindHeader{ + ParameterCategory: c.Header.ParameterCategory, + ParameterNumber: c.Header.ParameterNumber, + ParameterNumberName: apirest.NewOptString(c.Header.ParameterNumberName), + ParameterUnit: apirest.NewOptString(c.Header.ParameterUnit), + Nx: c.Header.Nx, + Ny: c.Header.Ny, + Lo1: c.Header.Lo1, + La1: c.Header.La1, + Lo2: c.Header.Lo2, + La2: c.Header.La2, + Dx: c.Header.Dx, + Dy: c.Header.Dy, + RefTime: c.Header.RefTime, + ForecastTime: c.Header.ForecastTime, + }, + Data: c.Data, + }) + } + return out +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5ad3eae --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,277 @@ +// Package config holds the service's runtime configuration, loaded by +// merging (in order of increasing precedence): built-in defaults, a YAML +// config file, environment variables, and command-line flags. +// +// Validation is performed once on load; downstream consumers receive an +// immutable struct. +package config + +import ( + "flag" + "fmt" + "os" + "strconv" + "time" + + "gopkg.in/yaml.v2" +) + +// Config is the top-level configuration tree. +type Config struct { + HTTP HTTPConfig `yaml:"http"` + Data DataConfig `yaml:"data"` + Download DownloadConfig `yaml:"download"` + Metrics MetricsConfig `yaml:"metrics"` + Wind WindConfig `yaml:"wind"` + Log LogConfig `yaml:"log"` +} + +// HTTPConfig configures the HTTP server. +type HTTPConfig struct { + Port int `yaml:"port"` + // AsyncWorkers caps concurrent prediction executions for the async endpoint. + AsyncWorkers int `yaml:"async_workers"` + // AsyncQueueSize bounds the async pending queue. + AsyncQueueSize int `yaml:"async_queue_size"` + // AsyncResultTTL is how long completed async results are retained. + AsyncResultTTL time.Duration `yaml:"async_result_ttl"` +} + +// DataConfig configures dataset and elevation storage. +type DataConfig struct { + Dir string `yaml:"dir"` + ElevationPath string `yaml:"elevation_path"` + // Source is the dataset variant ID: gfs-0p50-3h (default), gfs-0p25-3h, + // gfs-0p25-1h, or gefs-0p50-3h. See weather/gfs.VariantByID. + Source string `yaml:"source"` +} + +// DownloadConfig configures the dataset downloader. +type DownloadConfig struct { + Parallel int `yaml:"parallel"` + BandwidthBytesPerSecond int64 `yaml:"bandwidth_bytes_per_second"` + UpdateInterval time.Duration `yaml:"update_interval"` + FreshnessTTL time.Duration `yaml:"freshness_ttl"` +} + +// MetricsConfig configures the metrics endpoint. +type MetricsConfig struct { + Enabled bool `yaml:"enabled"` + Path string `yaml:"path"` +} + +// WindConfig configures the wind-visualization endpoints. +type WindConfig struct { + Enabled bool `yaml:"enabled"` + CacheSize int `yaml:"cache_size"` + CacheTTL time.Duration `yaml:"cache_ttl"` +} + +// LogConfig configures logging. +type LogConfig struct { + Level string `yaml:"level"` // "debug", "info", "warn", "error" +} + +// Defaults returns a Config with reasonable default values. +func Defaults() Config { + return Config{ + HTTP: HTTPConfig{ + Port: 8080, + AsyncWorkers: 4, + AsyncQueueSize: 64, + AsyncResultTTL: time.Hour, + }, + Data: DataConfig{ + Dir: "/tmp/predictor-data", + ElevationPath: "/srv/ruaumoko-dataset", + Source: "gfs-0p50-3h", + }, + Download: DownloadConfig{ + Parallel: 8, + BandwidthBytesPerSecond: 0, + UpdateInterval: 6 * time.Hour, + FreshnessTTL: 48 * time.Hour, + }, + Metrics: MetricsConfig{ + Enabled: true, + Path: "/metrics", + }, + Wind: WindConfig{ + Enabled: true, + CacheSize: 64, + CacheTTL: 10 * time.Minute, + }, + Log: LogConfig{Level: "info"}, + } +} + +// Load resolves the configuration by merging built-in defaults with +// (in increasing precedence): a YAML file (path from PREDICTOR_CONFIG_FILE +// env var or --config flag), environment variables, and command-line flags. +// +// args is os.Args[1:] in production code; tests pass a custom slice. +func Load(args []string) (Config, error) { + cfg := Defaults() + + fs := flag.NewFlagSet("predictor", flag.ContinueOnError) + // Surface a deterministic usage by suppressing the default output: + fs.SetOutput(os.Stderr) + + var ( + configPath = fs.String("config", os.Getenv("PREDICTOR_CONFIG_FILE"), "path to YAML config file") + // Flag-driven overrides. Empty / -1 means "not specified". + flagPort = fs.Int("port", -1, "HTTP listen port") + flagDataDir = fs.String("data-dir", "", "directory for dataset files") + flagElevation = fs.String("elevation", "", "path to ruaumoko elevation dataset") + flagParallel = fs.Int("download-parallel", -1, "max concurrent GRIB downloads") + flagBandwidth = fs.Int64("download-bandwidth", -1, "download bandwidth limit in bytes/sec (0 = unlimited)") + flagInterval = fs.Duration("update-interval", 0, "scheduler refresh interval") + flagTTL = fs.Duration("freshness-ttl", 0, "max age before a dataset is considered stale") + flagMetricsEnabled = fs.Bool("metrics", true, "enable Prometheus-compatible metrics endpoint") + flagMetricsPath = fs.String("metrics-path", "", "HTTP path for the metrics endpoint") + flagLogLevel = fs.String("log-level", "", "log level: debug|info|warn|error") + ) + if err := fs.Parse(args); err != nil { + return Config{}, fmt.Errorf("parse flags: %w", err) + } + + // 1. File. + if *configPath != "" { + data, err := os.ReadFile(*configPath) + if err != nil { + return Config{}, fmt.Errorf("read config %s: %w", *configPath, err) + } + if err := yaml.UnmarshalStrict(data, &cfg); err != nil { + return Config{}, fmt.Errorf("parse config %s: %w", *configPath, err) + } + } + + // 2. Env vars. + applyEnv(&cfg) + + // 3. Flags (only when explicitly set). + if *flagPort >= 0 { + cfg.HTTP.Port = *flagPort + } + if *flagDataDir != "" { + cfg.Data.Dir = *flagDataDir + } + if *flagElevation != "" { + cfg.Data.ElevationPath = *flagElevation + } + if *flagParallel >= 0 { + cfg.Download.Parallel = *flagParallel + } + if *flagBandwidth >= 0 { + cfg.Download.BandwidthBytesPerSecond = *flagBandwidth + } + if *flagInterval != 0 { + cfg.Download.UpdateInterval = *flagInterval + } + if *flagTTL != 0 { + cfg.Download.FreshnessTTL = *flagTTL + } + // flag.Bool defaults to true here so we only override if user explicitly disables it. + if isFlagSet(fs, "metrics") { + cfg.Metrics.Enabled = *flagMetricsEnabled + } + if *flagMetricsPath != "" { + cfg.Metrics.Path = *flagMetricsPath + } + if *flagLogLevel != "" { + cfg.Log.Level = *flagLogLevel + } + + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func isFlagSet(fs *flag.FlagSet, name string) bool { + set := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + set = true + } + }) + return set +} + +// applyEnv overlays PREDICTOR_* environment variables onto cfg. +func applyEnv(cfg *Config) { + if v := os.Getenv("PREDICTOR_PORT"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.HTTP.Port = n + } + } + if v := os.Getenv("PREDICTOR_DATA_DIR"); v != "" { + cfg.Data.Dir = v + } + if v := os.Getenv("PREDICTOR_ELEVATION_DATASET"); v != "" { + cfg.Data.ElevationPath = v + } + if v := os.Getenv("PREDICTOR_SOURCE"); v != "" { + cfg.Data.Source = v + } + if v := os.Getenv("PREDICTOR_DOWNLOAD_PARALLEL"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.Download.Parallel = n + } + } + if v := os.Getenv("PREDICTOR_DOWNLOAD_BANDWIDTH"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + cfg.Download.BandwidthBytesPerSecond = n + } + } + if v := os.Getenv("PREDICTOR_UPDATE_INTERVAL"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.Download.UpdateInterval = d + } + } + if v := os.Getenv("PREDICTOR_DATASET_TTL"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.Download.FreshnessTTL = d + } + } + if v := os.Getenv("PREDICTOR_METRICS_ENABLED"); v != "" { + cfg.Metrics.Enabled = v == "1" || v == "true" || v == "yes" + } + if v := os.Getenv("PREDICTOR_METRICS_PATH"); v != "" { + cfg.Metrics.Path = v + } + if v := os.Getenv("PREDICTOR_LOG_LEVEL"); v != "" { + cfg.Log.Level = v + } +} + +// Validate reports configuration errors. +func (c Config) Validate() error { + if c.HTTP.Port < 0 || c.HTTP.Port > 65535 { + return fmt.Errorf("http.port %d outside [0, 65535]", c.HTTP.Port) + } + if c.Data.Dir == "" { + return fmt.Errorf("data.dir is required") + } + if c.Data.Source == "" { + return fmt.Errorf("data.source is required") + } + if c.Download.Parallel <= 0 { + return fmt.Errorf("download.parallel must be > 0") + } + if c.Download.UpdateInterval <= 0 { + return fmt.Errorf("download.update_interval must be > 0") + } + if c.Download.FreshnessTTL <= 0 { + return fmt.Errorf("download.freshness_ttl must be > 0") + } + if c.Metrics.Enabled && c.Metrics.Path == "" { + return fmt.Errorf("metrics.path is required when metrics enabled") + } + switch c.Log.Level { + case "debug", "info", "warn", "error": + default: + return fmt.Errorf("log.level %q is not one of debug|info|warn|error", c.Log.Level) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..8978357 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,76 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadDefaults(t *testing.T) { + t.Setenv("PREDICTOR_DATA_DIR", "") + t.Setenv("PREDICTOR_PORT", "") + t.Setenv("PREDICTOR_CONFIG_FILE", "") + + cfg, err := Load(nil) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.HTTP.Port != 8080 { + t.Errorf("default port = %d, want 8080", cfg.HTTP.Port) + } + if cfg.Download.Parallel != 8 { + t.Errorf("default parallel = %d, want 8", cfg.Download.Parallel) + } +} + +func TestLoadEnvOverridesDefaults(t *testing.T) { + t.Setenv("PREDICTOR_PORT", "9090") + t.Setenv("PREDICTOR_UPDATE_INTERVAL", "30m") + + cfg, err := Load(nil) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.HTTP.Port != 9090 { + t.Errorf("env port = %d, want 9090", cfg.HTTP.Port) + } + if cfg.Download.UpdateInterval != 30*time.Minute { + t.Errorf("env update interval = %v, want 30m", cfg.Download.UpdateInterval) + } +} + +func TestLoadFlagsOverrideEnv(t *testing.T) { + t.Setenv("PREDICTOR_PORT", "9090") + cfg, err := Load([]string{"-port", "7777"}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.HTTP.Port != 7777 { + t.Errorf("flag should override env: port = %d, want 7777", cfg.HTTP.Port) + } +} + +func TestLoadFileOverridesDefaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "predictor.yml") + if err := os.WriteFile(path, []byte("http:\n port: 12345\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load([]string{"-config", path}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.HTTP.Port != 12345 { + t.Errorf("file port = %d, want 12345", cfg.HTTP.Port) + } +} + +func TestValidate(t *testing.T) { + cfg := Defaults() + cfg.Data.Dir = "" + if err := cfg.Validate(); err == nil { + t.Error("expected validation error for empty data dir") + } +} diff --git a/internal/dataset/dataset.go b/internal/dataset/dataset.go deleted file mode 100644 index 53367db..0000000 --- a/internal/dataset/dataset.go +++ /dev/null @@ -1,158 +0,0 @@ -package dataset - -import "fmt" - -// Dataset shape constants. -// Shape: (65, 47, 3, 361, 720) = (hour, pressure_level, variable, latitude, longitude) -// This matches the reference predictor exactly. -const ( - NumHours = 65 // 0, 3, 6, ..., 192 - NumLevels = 47 // pressure levels - NumVariables = 3 // height, wind_u, wind_v - NumLatitudes = 361 // -90.0 to +90.0 in 0.5 degree steps - NumLongitudes = 720 // 0.0 to 359.5 in 0.5 degree steps - - HourStep = 3 // hours between forecast time steps - MaxHour = 192 // maximum forecast hour - Resolution = 0.5 // grid resolution in degrees - LatStart = -90.0 // first latitude in the dataset - LonStart = 0.0 // first longitude in the dataset - - // Variable indices within the dataset. - VarHeight = 0 - VarWindU = 1 - VarWindV = 2 - - ElementSize = 4 // float32 = 4 bytes -) - -// DatasetSize is the total size of the dataset file in bytes. -// 65 * 47 * 3 * 361 * 720 * 4 = 9,528,667,200 -const DatasetSize int64 = int64(NumHours) * int64(NumLevels) * int64(NumVariables) * - int64(NumLatitudes) * int64(NumLongitudes) * int64(ElementSize) - -// LevelSet identifies which GRIB file set a pressure level belongs to. -type LevelSet int - -const ( - LevelSetA LevelSet = iota // pgrb2 (primary) - LevelSetB // pgrb2b (secondary) -) - -// Pressures contains the 47 pressure levels in hPa, sorted descending. -// Index 0 = 1000 hPa (near surface), Index 46 = 1 hPa (high atmosphere). -var Pressures = [NumLevels]int{ - 1000, 975, 950, 925, 900, 875, 850, 825, 800, 775, - 750, 725, 700, 675, 650, 625, 600, 575, 550, 525, - 500, 475, 450, 425, 400, 375, 350, 325, 300, 275, - 250, 225, 200, 175, 150, 125, 100, 70, 50, 30, - 20, 10, 7, 5, 3, 2, 1, -} - -// pressureIndex maps pressure in hPa to its index in the Pressures array. -var pressureIndex map[int]int - -// pressureLevelSet maps pressure in hPa to its GRIB file set. -var pressureLevelSet map[int]LevelSet - -func init() { - pressureIndex = make(map[int]int, NumLevels) - for i, p := range Pressures { - pressureIndex[p] = i - } - - pressureLevelSet = make(map[int]LevelSet, NumLevels) - for _, p := range PressuresPgrb2 { - pressureLevelSet[p] = LevelSetA - } - for _, p := range PressuresPgrb2b { - pressureLevelSet[p] = LevelSetB - } -} - -// PressuresPgrb2 contains levels found in the primary pgrb2 file (26 levels). -var PressuresPgrb2 = []int{ - 10, 20, 30, 50, 70, 100, 150, 200, 250, 300, 350, 400, - 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 925, - 950, 975, 1000, -} - -// PressuresPgrb2b contains levels found in the secondary pgrb2b file (21 levels). -var PressuresPgrb2b = []int{ - 1, 2, 3, 5, 7, 125, 175, 225, 275, 325, 375, 425, - 475, 525, 575, 625, 675, 725, 775, 825, 875, -} - -// PressureIndex returns the dataset index for a given pressure level in hPa. -// Returns -1 if the level is not found. -func PressureIndex(hPa int) int { - idx, ok := pressureIndex[hPa] - if !ok { - return -1 - } - return idx -} - -// PressureLevelSet returns which GRIB file set a pressure level belongs to. -func PressureLevelSet(hPa int) (LevelSet, bool) { - ls, ok := pressureLevelSet[hPa] - return ls, ok -} - -// HourIndex returns the dataset time index for a forecast hour. -// Returns -1 if the hour is invalid (not a multiple of HourStep or out of range). -func HourIndex(hour int) int { - if hour < 0 || hour > MaxHour || hour%HourStep != 0 { - return -1 - } - return hour / HourStep -} - -// Hours returns all forecast hours as a slice: [0, 3, 6, ..., 192]. -func Hours() []int { - out := make([]int, 0, NumHours) - for h := 0; h <= MaxHour; h += HourStep { - out = append(out, h) - } - return out -} - -// S3 URL configuration for NOAA GFS data. -const S3BaseURL = "https://noaa-gfs-bdp-pds.s3.amazonaws.com" - -// GribURL returns the S3 URL for a primary (pgrb2) GRIB file. -func GribURL(date string, runHour, forecastStep int) string { - return fmt.Sprintf("%s/gfs.%s/%02d/atmos/gfs.t%02dz.pgrb2.0p50.f%03d", - S3BaseURL, date, runHour, runHour, forecastStep) -} - -// GribURLB returns the S3 URL for a secondary (pgrb2b) GRIB file. -func GribURLB(date string, runHour, forecastStep int) string { - return fmt.Sprintf("%s/gfs.%s/%02d/atmos/gfs.t%02dz.pgrb2b.0p50.f%03d", - S3BaseURL, date, runHour, runHour, forecastStep) -} - -// GribFileName returns the local filename for a primary GRIB file. -func GribFileName(runHour, forecastStep int) string { - return fmt.Sprintf("gfs.t%02dz.pgrb2.0p50.f%03d", runHour, forecastStep) -} - -// GribFileNameB returns the local filename for a secondary GRIB file. -func GribFileNameB(runHour, forecastStep int) string { - return fmt.Sprintf("gfs.t%02dz.pgrb2b.0p50.f%03d", runHour, forecastStep) -} - -// VariableIndex returns the dataset variable index for a GRIB parameter. -// Returns -1 if the parameter is not recognized. -func VariableIndex(parameterCategory, parameterNumber int) int { - switch { - case parameterCategory == 3 && parameterNumber == 5: - return VarHeight // Geopotential Height - case parameterCategory == 2 && parameterNumber == 2: - return VarWindU // U-component of wind - case parameterCategory == 2 && parameterNumber == 3: - return VarWindV // V-component of wind - default: - return -1 - } -} diff --git a/internal/dataset/dataset_test.go b/internal/dataset/dataset_test.go deleted file mode 100644 index 14b36ef..0000000 --- a/internal/dataset/dataset_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package dataset - -import ( - "testing" -) - -func TestDatasetShape(t *testing.T) { - if NumHours != 65 { - t.Errorf("NumHours = %d, want 65", NumHours) - } - if NumLevels != 47 { - t.Errorf("NumLevels = %d, want 47", NumLevels) - } - if NumVariables != 3 { - t.Errorf("NumVariables = %d, want 3", NumVariables) - } - if NumLatitudes != 361 { - t.Errorf("NumLatitudes = %d, want 361", NumLatitudes) - } - if NumLongitudes != 720 { - t.Errorf("NumLongitudes = %d, want 720", NumLongitudes) - } -} - -func TestDatasetSize(t *testing.T) { - // 65 * 47 * 3 * 361 * 720 * 4 = 9,528,667,200 - want := int64(9_528_667_200) - if DatasetSize != want { - t.Errorf("DatasetSize = %d, want %d", DatasetSize, want) - } -} - -func TestPressureLevels(t *testing.T) { - if len(Pressures) != 47 { - t.Fatalf("len(Pressures) = %d, want 47", len(Pressures)) - } - - // First should be 1000 (highest pressure, near surface) - if Pressures[0] != 1000 { - t.Errorf("Pressures[0] = %d, want 1000", Pressures[0]) - } - // Last should be 1 (lowest pressure, high atmosphere) - if Pressures[46] != 1 { - t.Errorf("Pressures[46] = %d, want 1", Pressures[46]) - } - - // Should be sorted descending - for i := 1; i < len(Pressures); i++ { - if Pressures[i] >= Pressures[i-1] { - t.Errorf("Pressures not descending at [%d]: %d >= %d", i, Pressures[i], Pressures[i-1]) - } - } - - // Total levels: 26 from pgrb2 + 21 from pgrb2b = 47 - if len(PressuresPgrb2) != 26 { - t.Errorf("len(PressuresPgrb2) = %d, want 26", len(PressuresPgrb2)) - } - if len(PressuresPgrb2b) != 21 { - t.Errorf("len(PressuresPgrb2b) = %d, want 21", len(PressuresPgrb2b)) - } -} - -func TestPressureIndex(t *testing.T) { - if PressureIndex(1000) != 0 { - t.Errorf("PressureIndex(1000) = %d, want 0", PressureIndex(1000)) - } - if PressureIndex(1) != 46 { - t.Errorf("PressureIndex(1) = %d, want 46", PressureIndex(1)) - } - if PressureIndex(500) != 20 { - t.Errorf("PressureIndex(500) = %d, want 20", PressureIndex(500)) - } - if PressureIndex(9999) != -1 { - t.Errorf("PressureIndex(9999) = %d, want -1", PressureIndex(9999)) - } -} - -func TestPressureLevelSet(t *testing.T) { - // 1000 mb should be in pgrb2 (A) - ls, ok := PressureLevelSet(1000) - if !ok || ls != LevelSetA { - t.Errorf("PressureLevelSet(1000) = %v, %v; want A, true", ls, ok) - } - - // 125 mb should be in pgrb2b (B) - ls, ok = PressureLevelSet(125) - if !ok || ls != LevelSetB { - t.Errorf("PressureLevelSet(125) = %v, %v; want B, true", ls, ok) - } - - // 1, 2, 3, 5, 7 should be in pgrb2b (B) - for _, p := range []int{1, 2, 3, 5, 7} { - ls, ok := PressureLevelSet(p) - if !ok || ls != LevelSetB { - t.Errorf("PressureLevelSet(%d) = %v, %v; want B, true", p, ls, ok) - } - } - - // Every pressure level should have a level set assignment - for _, p := range Pressures { - _, ok := PressureLevelSet(p) - if !ok { - t.Errorf("PressureLevelSet(%d) not found", p) - } - } -} - -func TestHourIndex(t *testing.T) { - if HourIndex(0) != 0 { - t.Errorf("HourIndex(0) = %d, want 0", HourIndex(0)) - } - if HourIndex(3) != 1 { - t.Errorf("HourIndex(3) = %d, want 1", HourIndex(3)) - } - if HourIndex(192) != 64 { - t.Errorf("HourIndex(192) = %d, want 64", HourIndex(192)) - } - if HourIndex(1) != -1 { - t.Errorf("HourIndex(1) = %d, want -1 (not multiple of 3)", HourIndex(1)) - } - if HourIndex(195) != -1 { - t.Errorf("HourIndex(195) = %d, want -1 (out of range)", HourIndex(195)) - } -} - -func TestHours(t *testing.T) { - hours := Hours() - if len(hours) != NumHours { - t.Fatalf("len(Hours()) = %d, want %d", len(hours), NumHours) - } - if hours[0] != 0 { - t.Errorf("Hours()[0] = %d, want 0", hours[0]) - } - if hours[len(hours)-1] != MaxHour { - t.Errorf("Hours()[last] = %d, want %d", hours[len(hours)-1], MaxHour) - } -} - -func TestVariableIndex(t *testing.T) { - if VariableIndex(3, 5) != VarHeight { - t.Errorf("HGT: got %d, want %d", VariableIndex(3, 5), VarHeight) - } - if VariableIndex(2, 2) != VarWindU { - t.Errorf("UGRD: got %d, want %d", VariableIndex(2, 2), VarWindU) - } - if VariableIndex(2, 3) != VarWindV { - t.Errorf("VGRD: got %d, want %d", VariableIndex(2, 3), VarWindV) - } - if VariableIndex(0, 0) != -1 { - t.Errorf("unknown: got %d, want -1", VariableIndex(0, 0)) - } -} diff --git a/internal/dataset/file.go b/internal/dataset/file.go deleted file mode 100644 index 96f14c2..0000000 --- a/internal/dataset/file.go +++ /dev/null @@ -1,140 +0,0 @@ -package dataset - -import ( - "encoding/binary" - "fmt" - "math" - "os" - "time" - - mmap "github.com/edsrzf/mmap-go" -) - -// File represents an mmap-backed wind dataset file. -type File struct { - mm mmap.MMap - file *os.File - writable bool - DSTime time.Time // forecast run time (UTC) -} - -// Open opens an existing dataset file for reading. -func Open(path string, dsTime time.Time) (*File, error) { - f, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open dataset: %w", err) - } - - info, err := f.Stat() - if err != nil { - f.Close() - return nil, fmt.Errorf("stat dataset: %w", err) - } - if info.Size() != DatasetSize { - f.Close() - return nil, fmt.Errorf("dataset should be %d bytes (was %d)", DatasetSize, info.Size()) - } - - mm, err := mmap.Map(f, mmap.RDONLY, 0) - if err != nil { - f.Close() - return nil, fmt.Errorf("mmap dataset: %w", err) - } - - return &File{mm: mm, file: f, writable: false, DSTime: dsTime}, nil -} - -// Create creates a new dataset file for writing. -// The file is truncated to the correct size and mmap'd read-write. -func Create(path string) (*File, error) { - f, err := os.Create(path) - if err != nil { - return nil, fmt.Errorf("create dataset: %w", err) - } - - if err := f.Truncate(DatasetSize); err != nil { - f.Close() - return nil, fmt.Errorf("truncate dataset: %w", err) - } - - mm, err := mmap.MapRegion(f, int(DatasetSize), mmap.RDWR, 0, 0) - if err != nil { - f.Close() - return nil, fmt.Errorf("mmap dataset: %w", err) - } - - return &File{mm: mm, file: f, writable: true}, nil -} - -// offset computes the byte offset for element [hour][level][variable][lat][lon]. -// Row-major C-order indexing matching the reference implementation: -// shape = (65, 47, 3, 361, 720) -func offset(hour, level, variable, lat, lon int) int64 { - idx := int64(hour) - idx = idx*int64(NumLevels) + int64(level) - idx = idx*int64(NumVariables) + int64(variable) - idx = idx*int64(NumLatitudes) + int64(lat) - idx = idx*int64(NumLongitudes) + int64(lon) - return idx * int64(ElementSize) -} - -// Val reads a float32 value from the dataset at [hour][level][variable][lat][lon]. -func (d *File) Val(hour, level, variable, lat, lon int) float32 { - off := offset(hour, level, variable, lat, lon) - bits := binary.LittleEndian.Uint32(d.mm[off : off+4]) - return math.Float32frombits(bits) -} - -// SetVal writes a float32 value to the dataset at [hour][level][variable][lat][lon]. -// Only valid on writable (created) datasets. -func (d *File) SetVal(hour, level, variable, lat, lon int, val float32) { - off := offset(hour, level, variable, lat, lon) - binary.LittleEndian.PutUint32(d.mm[off:off+4], math.Float32bits(val)) -} - -// BlitGribData copies decoded GRIB grid data into the dataset at the given position. -// gribData is 361*720 float64 values in GRIB scan order (north-to-south, west-to-east). -// This function flips the latitude so that dataset index 0 = -90 (south) and 360 = +90 (north). -func (d *File) BlitGribData(hourIdx, levelIdx, varIdx int, gribData []float64) error { - expected := NumLatitudes * NumLongitudes - if len(gribData) != expected { - return fmt.Errorf("grib data has %d values, expected %d", len(gribData), expected) - } - - for lat := 0; lat < NumLatitudes; lat++ { - for lon := 0; lon < NumLongitudes; lon++ { - // GRIB scans north-to-south: row 0 = 90N, row 360 = 90S - // Dataset stores south-to-north: index 0 = -90 (90S), index 360 = +90 (90N) - gribIdx := (360-lat)*NumLongitudes + lon - val := float32(gribData[gribIdx]) - d.SetVal(hourIdx, levelIdx, varIdx, lat, lon, val) - } - } - - return nil -} - -// Flush flushes the mmap to disk. -func (d *File) Flush() error { - if d.mm != nil { - return d.mm.Flush() - } - return nil -} - -// Close unmaps and closes the dataset file. -func (d *File) Close() error { - if d.mm != nil { - if err := d.mm.Unmap(); err != nil { - d.file.Close() - return fmt.Errorf("unmap: %w", err) - } - d.mm = nil - } - if d.file != nil { - err := d.file.Close() - d.file = nil - return err - } - return nil -} diff --git a/internal/datasets/doc.go b/internal/datasets/doc.go new file mode 100644 index 0000000..645fc90 --- /dev/null +++ b/internal/datasets/doc.go @@ -0,0 +1,11 @@ +// Package datasets manages the lifecycle of atmospheric datasets. It exposes: +// +// - A Source interface for pluggable dataset origins (GFS now, ECMWF later). +// - A Storage interface for transactional, resumable on-disk persistence. +// - A Manager that coordinates downloads, tracks job state, and owns the +// currently-active weather.WindField. +// +// The package is the only one in the service that knows about download +// scheduling, manifests, or bandwidth throttling — engine and API layers +// only see WindField + Manager-as-admin. +package datasets diff --git a/internal/datasets/gefs/source.go b/internal/datasets/gefs/source.go new file mode 100644 index 0000000..bbc1b63 --- /dev/null +++ b/internal/datasets/gefs/source.go @@ -0,0 +1,151 @@ +// Package gefs implements datasets.Source for NOAA GEFS (Global Ensemble +// Forecast System) forecasts. +// +// Each ensemble member is treated as its own dataset, selected via +// DatasetID.Subset.Members. The download skeleton (HTTP, idx parsing, +// parallel blit) lives in internal/datasets/grib; this package only +// supplies GEFS-specific URL templating and member resolution. +package gefs + +import ( + "context" + "fmt" + "net/http" + "time" + + "go.uber.org/zap" + + "predictor-refactored/internal/datasets" + "predictor-refactored/internal/datasets/grib" + "predictor-refactored/internal/weather" + wgfs "predictor-refactored/internal/weather/gfs" +) + +// Source is the GEFS implementation of datasets.Source. +type Source struct { + Variant *wgfs.Variant + Parallel int + Client *http.Client + Log *zap.Logger +} + +// NewSource returns a default Source over variant. If variant is nil, +// GEFS 0.5° 3-hour is used. +func NewSource(variant *wgfs.Variant, log *zap.Logger) *Source { + if variant == nil { + variant = wgfs.GEFS0p50_3h + } + return &Source{ + Variant: variant, + Parallel: 8, + Client: &http.Client{Timeout: 2 * time.Minute}, + Log: log, + } +} + +func (s *Source) ID() string { return s.Variant.ID } + +func (s *Source) downloader() *grib.Downloader { + return &grib.Downloader{ + Variant: s.Variant, + URLs: s.url, + Parallel: s.Parallel, + Client: s.Client, + Log: s.Log, + } +} + +// url generates the GEFS URL for (date, runHour, member, step, levelSet). +func (s *Source) url(date string, runHour, member, step int, ls wgfs.LevelSet) string { + if ls == wgfs.LevelSetB { + return wgfs.GefsGribURLB(date, runHour, member, step, s.Variant.ResToken) + } + return wgfs.GefsGribURL(date, runHour, member, step, s.Variant.ResToken) +} + +// LatestEpoch HEAD-checks the control member's final forecast hour. +func (s *Source) LatestEpoch(ctx context.Context) (time.Time, error) { + now := time.Now().UTC() + hour := now.Hour() - (now.Hour() % 6) + current := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC) + + client := s.Client + if client == nil { + client = &http.Client{Timeout: 2 * time.Minute} + } + log := s.Log + if log == nil { + log = zap.NewNop() + } + + for range 8 { + date := current.Format("20060102") + url := wgfs.GefsGribURL(date, current.Hour(), 0, s.Variant.MaxHour, s.Variant.ResToken) + ".idx" + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err == nil { + resp, err := client.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + log.Info("latest GEFS run discovered", + zap.Time("run", current), + zap.String("verified_url", url)) + return current, nil + } + } + } + current = current.Add(-6 * time.Hour) + } + return time.Time{}, fmt.Errorf("no recent GEFS run found") +} + +// Coverage returns the extent of id. +func (s *Source) Coverage(id datasets.DatasetID) datasets.Coverage { + v := s.Variant + cov := datasets.Coverage{ + Region: datasets.Region{MinLat: -90, MaxLat: 90, MinLng: 0, MaxLng: 360}, + StartTime: id.Epoch, + EndTime: id.Epoch.Add(time.Duration(v.MaxHour) * time.Hour), + } + if r := id.Subset.Region; r != nil { + cov.Region = *r + } + if h := id.Subset.HourRange; h != nil { + cov.StartTime = id.Epoch.Add(time.Duration(h.MinHour) * time.Hour) + cov.EndTime = id.Epoch.Add(time.Duration(h.MaxHour) * time.Hour) + } + return cov +} + +// Open loads a stored GEFS dataset as a WindField. +func (s *Source) Open(_ context.Context, id datasets.DatasetID, store datasets.Storage) (weather.WindField, error) { + if !store.Exists(id) { + return nil, fmt.Errorf("dataset %s not found", id.Filename()) + } + file, err := wgfs.Open(store.Path(id), s.Variant, id.Epoch.UTC()) + if err != nil { + return nil, err + } + return wgfs.NewWind(file), nil +} + +// memberOf extracts the single member index encoded by id.Subset.Members. +func memberOf(id datasets.DatasetID) (int, error) { + if len(id.Subset.Members) != 1 { + return 0, fmt.Errorf("gefs dataset id must specify exactly one member (got %v)", id.Subset.Members) + } + m := id.Subset.Members[0] + if m < 0 || m >= wgfs.GEFSMembers { + return 0, fmt.Errorf("gefs member %d out of range [0, %d)", m, wgfs.GEFSMembers) + } + return m, nil +} + +// Download fetches one ensemble member's dataset. +func (s *Source) Download(ctx context.Context, id datasets.DatasetID, store datasets.Storage, prog datasets.ProgressSink, throttle datasets.Throttle) error { + member, err := memberOf(id) + if err != nil { + return err + } + return s.downloader().Run(ctx, id, member, store, prog, throttle) +} diff --git a/internal/datasets/gfs/source.go b/internal/datasets/gfs/source.go new file mode 100644 index 0000000..af02803 --- /dev/null +++ b/internal/datasets/gfs/source.go @@ -0,0 +1,138 @@ +// Package gfs implements datasets.Source for NOAA GFS forecasts. +// +// The package serves multiple GFS variants (0.5° 3-hour, 0.25° 3-hour, +// 0.25° 1-hour); the variant is selected at construction time. The +// download skeleton (HTTP, idx parsing, parallel blit) lives in +// internal/datasets/grib; this package only supplies URL templating and +// the Source-interface plumbing. +package gfs + +import ( + "context" + "fmt" + "net/http" + "time" + + "go.uber.org/zap" + + "predictor-refactored/internal/datasets" + "predictor-refactored/internal/datasets/grib" + "predictor-refactored/internal/weather" + wgfs "predictor-refactored/internal/weather/gfs" +) + +// Source is the GFS implementation of datasets.Source. +type Source struct { + Variant *wgfs.Variant + Parallel int + Client *http.Client + Log *zap.Logger +} + +// NewSource returns a default Source over variant. If variant is nil, +// GFS 0.5° 3-hour is used (the historical Tawhiri default). +func NewSource(variant *wgfs.Variant, log *zap.Logger) *Source { + if variant == nil { + variant = wgfs.GFS0p50_3h + } + return &Source{ + Variant: variant, + Parallel: 8, + Client: &http.Client{Timeout: 2 * time.Minute}, + Log: log, + } +} + +// ID returns the variant's ID. +func (s *Source) ID() string { return s.Variant.ID } + +func (s *Source) downloader() *grib.Downloader { + return &grib.Downloader{ + Variant: s.Variant, + URLs: s.url, + Parallel: s.Parallel, + Client: s.Client, + Log: s.Log, + } +} + +// url generates the GFS URL for one (date, runHour, _, step, levelSet). +// member is unused for GFS. +func (s *Source) url(date string, runHour, _, step int, ls wgfs.LevelSet) string { + if ls == wgfs.LevelSetB { + return s.Variant.GribURLB(date, runHour, step) + } + return s.Variant.GribURL(date, runHour, step) +} + +// LatestEpoch returns the most recent run NOAA has finished publishing. +func (s *Source) LatestEpoch(ctx context.Context) (time.Time, error) { + now := time.Now().UTC() + hour := now.Hour() - (now.Hour() % 6) + current := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC) + + client := s.Client + if client == nil { + client = &http.Client{Timeout: 2 * time.Minute} + } + log := s.Log + if log == nil { + log = zap.NewNop() + } + + for range 8 { + date := current.Format("20060102") + url := s.Variant.GribURL(date, current.Hour(), s.Variant.MaxHour) + ".idx" + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err == nil { + resp, err := client.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + log.Info("latest run discovered", + zap.String("variant", s.Variant.ID), + zap.Time("run", current), + zap.String("verified_url", url)) + return current, nil + } + } + } + current = current.Add(-6 * time.Hour) + } + return time.Time{}, fmt.Errorf("no recent %s run found (checked 8 runs)", s.Variant.ID) +} + +// Coverage returns the geographic and temporal extent of id. +func (s *Source) Coverage(id datasets.DatasetID) datasets.Coverage { + v := s.Variant + cov := datasets.Coverage{ + Region: datasets.Region{MinLat: -90, MaxLat: 90, MinLng: 0, MaxLng: 360}, + StartTime: id.Epoch, + EndTime: id.Epoch.Add(time.Duration(v.MaxHour) * time.Hour), + } + if r := id.Subset.Region; r != nil { + cov.Region = *r + } + if h := id.Subset.HourRange; h != nil { + cov.StartTime = id.Epoch.Add(time.Duration(h.MinHour) * time.Hour) + cov.EndTime = id.Epoch.Add(time.Duration(h.MaxHour) * time.Hour) + } + return cov +} + +// Open loads a stored dataset as a WindField. +func (s *Source) Open(_ context.Context, id datasets.DatasetID, store datasets.Storage) (weather.WindField, error) { + if !store.Exists(id) { + return nil, fmt.Errorf("dataset %s not found", id.Filename()) + } + file, err := wgfs.Open(store.Path(id), s.Variant, id.Epoch.UTC()) + if err != nil { + return nil, err + } + return wgfs.NewWind(file), nil +} + +// Download fetches the dataset for id. GFS ignores Subset.Members. +func (s *Source) Download(ctx context.Context, id datasets.DatasetID, store datasets.Storage, prog datasets.ProgressSink, throttle datasets.Throttle) error { + return s.downloader().Run(ctx, id, 0, store, prog, throttle) +} diff --git a/internal/datasets/grib/downloader.go b/internal/datasets/grib/downloader.go new file mode 100644 index 0000000..8be86b9 --- /dev/null +++ b/internal/datasets/grib/downloader.go @@ -0,0 +1,369 @@ +package grib + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "os" + "sync" + "time" + + "github.com/nilsmagnus/grib/griblib" + "go.uber.org/zap" + "golang.org/x/sync/errgroup" + + "predictor-refactored/internal/datasets" + wgfs "predictor-refactored/internal/weather/gfs" +) + +// URLFunc returns the GRIB URL for one (date, runHour, member, step, levelSet). +// Sources that don't have members (GFS) ignore the member argument. +type URLFunc func(date string, runHour, member, step int, ls wgfs.LevelSet) string + +// Downloader is the generic GRIB-cube downloader. +// +// A Source plugs in its variant, URL templating, and member-resolution +// logic; the Downloader runs the parallel idx fetch, byte-range download, +// GRIB decode, and blit loop with manifest-based resume. +type Downloader struct { + Variant *wgfs.Variant + URLs URLFunc + Parallel int + Client *http.Client + Log *zap.Logger +} + +func (d *Downloader) log() *zap.Logger { + if d.Log == nil { + return zap.NewNop() + } + return d.Log +} + +func (d *Downloader) client() *http.Client { + if d.Client == nil { + return &http.Client{Timeout: 2 * time.Minute} + } + return d.Client +} + +func (d *Downloader) parallel() int { + if d.Parallel <= 0 { + return 8 + } + return d.Parallel +} + +// neededVariables is the GRIB variable set every source extracts. +var neededVariables = map[string]bool{"HGT": true, "UGRD": true, "VGRD": true} + +// Run downloads the dataset for id, member into store. The caller may +// pass member=0 for non-ensemble sources. +func (d *Downloader) Run(ctx context.Context, id datasets.DatasetID, member int, store datasets.Storage, prog datasets.ProgressSink, throttle datasets.Throttle) error { + if prog == nil { + prog = noopSink{} + } + + handle, err := store.BeginWrite(id) + if err != nil { + return fmt.Errorf("begin write: %w", err) + } + manifest := handle.Manifest() + + file, err := openOrCreateCube(handle.Path(), d.Variant) + if err != nil { + _ = handle.Abort() + return err + } + + epoch := id.Epoch.UTC() + date := epoch.Format("20060102") + runHour := epoch.Hour() + + steps := d.Variant.Hours() + if hr := id.Subset.HourRange; hr != nil { + filtered := steps[:0] + for _, step := range steps { + if step >= hr.MinHour && step <= hr.MaxHour { + filtered = append(filtered, step) + } + } + steps = filtered + } + prog.SetTotal(len(steps) * 2) + for range manifest.Units() { + prog.StepComplete() + } + + start := time.Now() + g, ctx := errgroup.WithContext(ctx) + g.SetLimit(d.parallel()) + var fileMu sync.Mutex + + for _, step := range steps { + hourIdx := d.Variant.HourIndex(step) + if hourIdx < 0 { + continue + } + for _, ls := range []wgfs.LevelSet{wgfs.LevelSetA, wgfs.LevelSetB} { + unit := unitKey(step, ls) + if manifest.Has(unit) { + continue + } + g.Go(func() error { + url := d.URLs(date, runHour, member, step, ls) + if err := d.downloadAndBlit(ctx, file, &fileMu, url, hourIdx, ls, prog, throttle); err != nil { + return fmt.Errorf("step %d %s: %w", step, levelSetLabel(ls), err) + } + if err := manifest.Mark(unit); err != nil { + return fmt.Errorf("mark unit: %w", err) + } + prog.StepComplete() + return nil + }) + } + } + + if err := g.Wait(); err != nil { + _ = file.Close() + if errors.Is(err, context.Canceled) { + return err + } + if len(manifest.Units()) == 0 { + _ = handle.Abort() + } + return err + } + + if err := file.Flush(); err != nil { + _ = file.Close() + return fmt.Errorf("flush: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close: %w", err) + } + if err := handle.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + + d.log().Info("download complete", + zap.String("variant", d.Variant.ID), + zap.Time("epoch", epoch), + zap.Duration("elapsed", time.Since(start))) + return nil +} + +// openOrCreateCube opens an existing cube at path if it matches variant's +// expected size, else truncate-creates a new one. +func openOrCreateCube(path string, variant *wgfs.Variant) (*wgfs.File, error) { + info, err := os.Stat(path) + if err == nil && info.Size() == variant.DatasetSize() { + return wgfs.OpenWritable(path, variant) + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("stat cube: %w", err) + } + return wgfs.Create(path, variant) +} + +// downloadAndBlit fetches and decodes one (URL, level-set) chunk. +func (d *Downloader) downloadAndBlit( + ctx context.Context, + file *wgfs.File, + fileMu *sync.Mutex, + baseURL string, + hourIdx int, + ls wgfs.LevelSet, + prog datasets.ProgressSink, + throttle datasets.Throttle, +) error { + idxBody, err := d.httpGet(ctx, baseURL+".idx", throttle, prog) + if err != nil { + return fmt.Errorf("idx: %w", err) + } + entries := ParseIdx(idxBody) + filtered := FilterIdx(entries, neededVariables) + + var relevant []IdxEntry + for _, e := range filtered { + set, ok := d.Variant.PressureLevelSet(e.LevelMB) + if ok && set == ls { + relevant = append(relevant, e) + } + } + if len(relevant) == 0 { + return nil + } + ranges := EntriesToRanges(relevant) + + tmp, err := os.CreateTemp("", "grib-msg-*.tmp") + if err != nil { + return fmt.Errorf("temp: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + for _, r := range ranges { + body, err := d.httpGetRange(ctx, baseURL, r.Start, r.End, throttle, prog) + if err != nil { + tmp.Close() + return fmt.Errorf("range: %w", err) + } + if _, err := tmp.Write(body); err != nil { + tmp.Close() + return fmt.Errorf("write tmp: %w", err) + } + } + if err := tmp.Close(); err != nil { + return err + } + + f, err := os.Open(tmpPath) + if err != nil { + return err + } + messages, err := griblib.ReadMessages(f) + f.Close() + if err != nil { + return fmt.Errorf("read grib: %w", err) + } + + for _, msg := range messages { + if msg.Section4.ProductDefinitionTemplateNumber != 0 { + continue + } + p := msg.Section4.ProductDefinitionTemplate + varIdx := d.Variant.VariableIndex(int(p.ParameterCategory), int(p.ParameterNumber)) + if varIdx < 0 { + continue + } + if p.FirstSurface.Type != 100 { + continue + } + pressureMB := int(math.Round(float64(p.FirstSurface.Value) / 100.0)) + levelIdx := d.Variant.PressureIndex(pressureMB) + if levelIdx < 0 { + continue + } + data := msg.Data() + fileMu.Lock() + err := file.BlitGribData(hourIdx, levelIdx, varIdx, data) + fileMu.Unlock() + if err != nil { + return fmt.Errorf("blit: %w", err) + } + } + return nil +} + +func (d *Downloader) httpGet(ctx context.Context, url string, throttle datasets.Throttle, prog datasets.ProgressSink) ([]byte, error) { + var lastErr error + for attempt := range 3 { + if attempt > 0 { + select { + case <-time.After(time.Duration(attempt*2) * time.Second): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := d.client().Do(req) + if err != nil { + lastErr = err + continue + } + body, err := readThrottled(ctx, resp.Body, throttle, prog) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + continue + } + if err != nil { + lastErr = err + continue + } + return body, nil + } + return nil, fmt.Errorf("after 3 attempts: %w", lastErr) +} + +func (d *Downloader) httpGetRange(ctx context.Context, url string, start, end int64, throttle datasets.Throttle, prog datasets.ProgressSink) ([]byte, error) { + var lastErr error + for attempt := range 3 { + if attempt > 0 { + select { + case <-time.After(time.Duration(attempt*2) * time.Second): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end)) + resp, err := d.client().Do(req) + if err != nil { + lastErr = err + continue + } + body, err := readThrottled(ctx, resp.Body, throttle, prog) + resp.Body.Close() + if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("HTTP %d for range", resp.StatusCode) + continue + } + if err != nil { + lastErr = err + continue + } + return body, nil + } + return nil, fmt.Errorf("after 3 attempts: %w", lastErr) +} + +func readThrottled(ctx context.Context, r io.Reader, throttle datasets.Throttle, prog datasets.ProgressSink) ([]byte, error) { + buf := make([]byte, 0, 64*1024) + chunk := make([]byte, 32*1024) + for { + if throttle != nil { + if err := throttle.Wait(ctx, len(chunk)); err != nil { + return nil, err + } + } + n, err := r.Read(chunk) + if n > 0 { + buf = append(buf, chunk[:n]...) + prog.Bytes(int64(n)) + } + if errors.Is(err, io.EOF) { + return buf, nil + } + if err != nil { + return nil, err + } + } +} + +func unitKey(step int, ls wgfs.LevelSet) string { + return fmt.Sprintf("step%03d-%s", step, levelSetLabel(ls)) +} + +func levelSetLabel(ls wgfs.LevelSet) string { + if ls == wgfs.LevelSetB { + return "B" + } + return "A" +} + +type noopSink struct{} + +func (noopSink) SetTotal(int) {} +func (noopSink) StepComplete() {} +func (noopSink) Bytes(int64) {} diff --git a/internal/datasets/grib/idx.go b/internal/datasets/grib/idx.go new file mode 100644 index 0000000..aa39e44 --- /dev/null +++ b/internal/datasets/grib/idx.go @@ -0,0 +1,129 @@ +// Package grib contains the GRIB-cube download skeleton shared by every +// NOAA source (GFS, GEFS, future families). It exposes the .idx parser, +// HTTP helpers, and a parallel download loop; source-specific URL +// templating is injected by the caller. +package grib + +import ( + "fmt" + "strconv" + "strings" +) + +// IdxEntry is one parsed line from a NOAA GRIB .idx file. +// +// Example line: "15:1207405:d=2024010100:HGT:1000 mb:0 hour fcst:" +type IdxEntry struct { + Index int + Offset int64 + Variable string + LevelMB int // 0 when the level is not isobaric + Hour int // forecast hour; 0 for analysis ("anl"); -1 if unparseable + EndOffset int64 // computed from the next entry's Offset; -1 for the final entry +} + +// Length returns the byte length of this GRIB message, or -1 if unknown +// (the final entry in an idx file). +func (e *IdxEntry) Length() int64 { + if e.EndOffset <= 0 { + return -1 + } + return e.EndOffset - e.Offset +} + +// ParseIdx parses a .idx file body. Unparseable lines are silently skipped. +func ParseIdx(body []byte) []IdxEntry { + lines := strings.Split(string(body), "\n") + var entries []IdxEntry + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, ":") + if len(parts) < 7 { + continue + } + idx, err := strconv.Atoi(parts[0]) + if err != nil { + continue + } + off, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + continue + } + entries = append(entries, IdxEntry{ + Index: idx, + Offset: off, + Variable: parts[3], + LevelMB: parseLevelMB(parts[4]), + Hour: parseHour(parts[5]), + EndOffset: -1, + }) + } + for i := 0; i < len(entries)-1; i++ { + entries[i].EndOffset = entries[i+1].Offset + } + return entries +} + +// FilterIdx returns entries matching one of the wanted variables at a known +// pressure level with a computable byte length. +func FilterIdx(entries []IdxEntry, wanted map[string]bool) []IdxEntry { + var out []IdxEntry + for _, e := range entries { + if !wanted[e.Variable] || e.LevelMB <= 0 || e.Length() <= 0 { + continue + } + out = append(out, e) + } + return out +} + +func parseLevelMB(s string) int { + s = strings.TrimSpace(s) + if !strings.HasSuffix(s, " mb") { + return 0 + } + n, err := strconv.Atoi(strings.TrimSuffix(s, " mb")) + if err != nil { + return 0 + } + return n +} + +func parseHour(s string) int { + s = strings.TrimSpace(s) + if s == "anl" { + return 0 + } + n, err := strconv.Atoi(strings.TrimSuffix(s, " hour fcst")) + if err != nil { + return -1 + } + return n +} + +// ByteRange is one HTTP range download corresponding to one GRIB message. +type ByteRange struct { + Start int64 + End int64 // inclusive + Entry IdxEntry +} + +// EntriesToRanges converts idx entries to inclusive HTTP byte ranges. +func EntriesToRanges(entries []IdxEntry) []ByteRange { + out := make([]ByteRange, 0, len(entries)) + for _, e := range entries { + if e.Length() <= 0 { + continue + } + out = append(out, ByteRange{Start: e.Offset, End: e.EndOffset - 1, Entry: e}) + } + return out +} + +// FormatRange returns an HTTP Range header value for the byte range. +func (r ByteRange) FormatRange() string { + return fmt.Sprintf("bytes=%d-%d", r.Start, r.End) +} diff --git a/internal/datasets/grib/idx_test.go b/internal/datasets/grib/idx_test.go new file mode 100644 index 0000000..864dfda --- /dev/null +++ b/internal/datasets/grib/idx_test.go @@ -0,0 +1,70 @@ +package grib + +import "testing" + +const sampleIdx = `1:0:d=2024010100:HGT:1000 mb:0 hour fcst: +2:289012:d=2024010100:HGT:975 mb:0 hour fcst: +3:541876:d=2024010100:TMP:1000 mb:0 hour fcst: +4:789012:d=2024010100:UGRD:1000 mb:0 hour fcst: +5:1045678:d=2024010100:VGRD:1000 mb:0 hour fcst: +6:1298765:d=2024010100:UGRD:975 mb:0 hour fcst: +7:1567890:d=2024010100:UGRD:2 m above ground:0 hour fcst: +8:1812345:d=2024010100:VGRD:975 mb:0 hour fcst: +9:2098765:d=2024010100:HGT:500 mb:3 hour fcst: +` + +func TestParseIdx(t *testing.T) { + entries := ParseIdx([]byte(sampleIdx)) + if len(entries) != 9 { + t.Fatalf("expected 9 entries, got %d", len(entries)) + } + if e := entries[0]; e.Index != 1 || e.Offset != 0 || e.Variable != "HGT" || e.LevelMB != 1000 || e.Hour != 0 || e.EndOffset != 289012 { + t.Errorf("entry 0: %+v", e) + } + if e := entries[6]; e.LevelMB != 0 { + t.Errorf("non-pressure level should have LevelMB=0, got %d", e.LevelMB) + } + if e := entries[len(entries)-1]; e.EndOffset != -1 { + t.Errorf("last entry EndOffset: got %d, want -1", e.EndOffset) + } +} + +func TestFilterIdx(t *testing.T) { + entries := ParseIdx([]byte(sampleIdx)) + want := map[string]bool{"HGT": true, "UGRD": true, "VGRD": true} + filtered := FilterIdx(entries, want) + // HGT@1000, HGT@975, UGRD@1000, VGRD@1000, UGRD@975, VGRD@975 = 6 + // HGT@500 at 3hr is last entry (no EndOffset), so dropped. + if len(filtered) != 6 { + t.Errorf("expected 6, got %d", len(filtered)) + } +} + +func TestParseLevelMB(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"1000 mb", 1000}, {"975 mb", 975}, {"1 mb", 1}, + {"2 m above ground", 0}, {"surface", 0}, {"tropopause", 0}, + } + for _, c := range cases { + if got := parseLevelMB(c.in); got != c.want { + t.Errorf("parseLevelMB(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestParseHour(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"0 hour fcst", 0}, {"3 hour fcst", 3}, {"192 hour fcst", 192}, {"anl", 0}, + } + for _, c := range cases { + if got := parseHour(c.in); got != c.want { + t.Errorf("parseHour(%q) = %d, want %d", c.in, got, c.want) + } + } +} diff --git a/internal/datasets/lock_other.go b/internal/datasets/lock_other.go new file mode 100644 index 0000000..58c90c1 --- /dev/null +++ b/internal/datasets/lock_other.go @@ -0,0 +1,11 @@ +//go:build !unix + +package datasets + +import "context" + +// flockExclusive is a no-op on platforms without flock. The service targets +// Linux containers; this stub only keeps non-Unix builds compiling. +func flockExclusive(_ context.Context, _ string) (func(), error) { + return func() {}, nil +} diff --git a/internal/datasets/lock_unix.go b/internal/datasets/lock_unix.go new file mode 100644 index 0000000..95a064f --- /dev/null +++ b/internal/datasets/lock_unix.go @@ -0,0 +1,50 @@ +//go:build unix + +package datasets + +import ( + "context" + "errors" + "fmt" + "os" + "syscall" + "time" +) + +// lockPollInterval is how often a contended lock is retried. The lock is held +// for the duration of a dataset download (minutes), so sub-second acquisition +// latency is irrelevant. +const lockPollInterval = 150 * time.Millisecond + +// flockExclusive acquires an exclusive flock on path, creating the lock file +// if needed, and blocks until it is held or ctx is cancelled. +// +// It uses non-blocking LOCK_NB attempts in a poll loop rather than a blocking +// flock in a goroutine: the file descriptor is only ever touched by this +// goroutine, so there is no race between a pending syscall and Close on +// cancellation. +func flockExclusive(ctx context.Context, path string) (func(), error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, fmt.Errorf("open lock file: %w", err) + } + for { + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil + } + if !errors.Is(err, syscall.EWOULDBLOCK) { + f.Close() + return nil, fmt.Errorf("flock: %w", err) + } + select { + case <-ctx.Done(): + f.Close() + return nil, ctx.Err() + case <-time.After(lockPollInterval): + } + } +} diff --git a/internal/datasets/manager.go b/internal/datasets/manager.go new file mode 100644 index 0000000..b56a265 --- /dev/null +++ b/internal/datasets/manager.go @@ -0,0 +1,466 @@ +package datasets + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "predictor-refactored/internal/weather" +) + +// JobStatus is the lifecycle state of a download job. +type JobStatus string + +const ( + JobPending JobStatus = "pending" + JobRunning JobStatus = "running" + JobComplete JobStatus = "complete" + JobFailed JobStatus = "failed" + JobCancelled JobStatus = "cancelled" +) + +// JobInfo is the externally-visible snapshot of a download job. +type JobInfo struct { + ID string + Source string + Dataset DatasetID + Status JobStatus + StartedAt time.Time + EndedAt *time.Time + Err string + Total int + Done int + Bytes int64 +} + +type jobEntry struct { + id string + source string + dataset DatasetID + startedAt time.Time + cancel context.CancelFunc + + mu sync.Mutex + status JobStatus + endedAt time.Time + errStr string + + total atomic.Int64 + done atomic.Int64 + bytes atomic.Int64 +} + +func (e *jobEntry) snapshot() JobInfo { + e.mu.Lock() + info := JobInfo{ + ID: e.id, Source: e.source, Dataset: e.dataset, + StartedAt: e.startedAt, Status: e.status, Err: e.errStr, + } + if !e.endedAt.IsZero() { + ts := e.endedAt + info.EndedAt = &ts + } + e.mu.Unlock() + info.Total = int(e.total.Load()) + info.Done = int(e.done.Load()) + info.Bytes = e.bytes.Load() + return info +} + +type jobProgress struct{ e *jobEntry } + +func (p jobProgress) SetTotal(n int) { p.e.total.Store(int64(n)) } +func (p jobProgress) StepComplete() { p.e.done.Add(1) } +func (p jobProgress) Bytes(n int64) { p.e.bytes.Add(n) } + +// loadedDataset bundles a loaded WindField with its identity and coverage. +type loadedDataset struct { + ID DatasetID + Field weather.WindField + Coverage Coverage +} + +// Manager coordinates dataset downloads and exposes the active WindFields. +type Manager struct { + src Source + store Storage + throttle Throttle + log *zap.Logger + + activeMu sync.RWMutex + active []loadedDataset + + jobsMu sync.RWMutex + jobs map[string]*jobEntry + + inFlight sync.Map // key: dataset filename, value: jobID +} + +// New wires a Manager. +func New(src Source, store Storage, throttle Throttle, log *zap.Logger) *Manager { + if log == nil { + log = zap.NewNop() + } + if src.ID() != store.SourceID() { + log.Warn("source/store ID mismatch", + zap.String("src", src.ID()), + zap.String("store", store.SourceID())) + } + return &Manager{ + src: src, store: store, throttle: throttle, log: log, + jobs: make(map[string]*jobEntry), + } +} + +// Source returns the underlying source ID. +func (m *Manager) Source() string { return m.src.ID() } + +// Active returns the currently-loaded global WindField (the dataset with +// IsGlobal subset, most recently loaded). Returns nil if no global +// dataset is loaded; in cluster setups with only regional subsets, callers +// should use SelectFor. +func (m *Manager) Active() weather.WindField { + m.activeMu.RLock() + defer m.activeMu.RUnlock() + for _, d := range m.active { + if d.ID.Subset.IsGlobal() { + return d.Field + } + } + if len(m.active) > 0 { + return m.active[0].Field + } + return nil +} + +// Ready reports whether at least one dataset is loaded. +func (m *Manager) Ready() bool { return m.Active() != nil } + +// SelectFor returns a loaded WindField whose coverage contains (t, lat, lng). +// Returns nil when no loaded dataset covers the query. +func (m *Manager) SelectFor(t time.Time, lat, lng float64) weather.WindField { + m.activeMu.RLock() + defer m.activeMu.RUnlock() + for _, d := range m.active { + if d.Coverage.Covers(t, lat, lng) { + return d.Field + } + } + // Fallback: any global dataset is permissive about region. + for _, d := range m.active { + if d.ID.Subset.IsGlobal() { + return d.Field + } + } + return nil +} + +// LoadedDatasets returns snapshots of every currently-loaded dataset. +func (m *Manager) LoadedDatasets() []LoadedDatasetInfo { + m.activeMu.RLock() + defer m.activeMu.RUnlock() + out := make([]LoadedDatasetInfo, 0, len(m.active)) + for _, d := range m.active { + out = append(out, LoadedDatasetInfo{ID: d.ID, Coverage: d.Coverage}) + } + return out +} + +// LoadedDatasetInfo is a serializable snapshot of one active dataset. +type LoadedDatasetInfo struct { + ID DatasetID + Coverage Coverage +} + +// ListEpochs returns all stored datasets, newest first. +func (m *Manager) ListEpochs() ([]DatasetID, error) { return m.store.List() } + +// ListJobs returns snapshots of every job recorded since startup. +func (m *Manager) ListJobs() []JobInfo { + m.jobsMu.RLock() + defer m.jobsMu.RUnlock() + out := make([]JobInfo, 0, len(m.jobs)) + for _, e := range m.jobs { + out = append(out, e.snapshot()) + } + return out +} + +// GetJob returns the snapshot for a job. +func (m *Manager) GetJob(id string) (JobInfo, bool) { + m.jobsMu.RLock() + e, ok := m.jobs[id] + m.jobsMu.RUnlock() + if !ok { + return JobInfo{}, false + } + return e.snapshot(), true +} + +// CancelJob cancels a running job. +func (m *Manager) CancelJob(id string) bool { + m.jobsMu.RLock() + e, ok := m.jobs[id] + m.jobsMu.RUnlock() + if !ok { + return false + } + e.mu.Lock() + terminal := e.status == JobComplete || e.status == JobFailed || e.status == JobCancelled + e.mu.Unlock() + if terminal { + return false + } + e.cancel() + return true +} + +// Remove deletes a stored dataset. If the dataset is currently loaded, +// it is unloaded first. +func (m *Manager) Remove(id DatasetID) error { + m.activeMu.Lock() + out := m.active[:0] + var removed *loadedDataset + for i := range m.active { + d := m.active[i] + if d.ID.Equals(id) { + removed = &d + continue + } + out = append(out, d) + } + m.active = out + m.activeMu.Unlock() + if removed != nil { + closeField(removed.Field, m.log) + } + return m.store.Remove(id) +} + +// Download starts (or resumes) a download job for id in the background. +func (m *Manager) Download(id DatasetID) string { + key := id.Filename() + if existing, ok := m.inFlight.Load(key); ok { + return existing.(string) + } + + jobID := uuid.New().String() + if other, loaded := m.inFlight.LoadOrStore(key, jobID); loaded { + return other.(string) + } + + ctx, cancel := context.WithCancel(context.Background()) + now := time.Now().UTC() + e := &jobEntry{ + id: jobID, + source: m.src.ID(), + dataset: id, + startedAt: now, + status: JobPending, + cancel: cancel, + } + m.jobsMu.Lock() + m.jobs[jobID] = e + m.jobsMu.Unlock() + + if m.store.Exists(id) { + go m.completeShortCircuit(ctx, e) + return jobID + } + go m.runDownload(ctx, e) + return jobID +} + +// Load swaps in id's stored dataset, making it available to predictions. +func (m *Manager) Load(ctx context.Context, id DatasetID) error { + if !m.store.Exists(id) { + return fmt.Errorf("dataset %s not present on disk", id.Filename()) + } + field, err := m.src.Open(ctx, id, m.store) + if err != nil { + return fmt.Errorf("open dataset: %w", err) + } + cov := m.src.Coverage(id) + m.activeMu.Lock() + // Replace any previously-loaded dataset with the same ID. + for i := range m.active { + if m.active[i].ID.Equals(id) { + closeField(m.active[i].Field, m.log) + m.active[i] = loadedDataset{ID: id, Field: field, Coverage: cov} + m.activeMu.Unlock() + return nil + } + } + m.active = append(m.active, loadedDataset{ID: id, Field: field, Coverage: cov}) + m.activeMu.Unlock() + m.log.Info("loaded dataset", + zap.String("filename", id.Filename()), + zap.String("source", m.src.ID())) + return nil +} + +// Refresh ensures the freshest global dataset is downloaded and active. +// +// Returns the JobID started, or empty string when nothing was scheduled. +func (m *Manager) Refresh(ctx context.Context, freshnessTTL time.Duration) (string, error) { + if a := m.activeGlobal(); a != nil && time.Since(a.ID.Epoch) < freshnessTTL { + return "", nil + } + + if datasets, err := m.store.List(); err == nil { + for _, id := range datasets { + if !id.Subset.IsGlobal() { + continue + } + if time.Since(id.Epoch) > freshnessTTL { + continue + } + if a := m.activeGlobal(); a != nil && a.ID.Equals(id) { + return "", nil + } + if err := m.Load(ctx, id); err == nil { + return "", nil + } + } + } + + latest, err := m.src.LatestEpoch(ctx) + if err != nil { + return "", fmt.Errorf("latest epoch: %w", err) + } + id := DatasetID{Epoch: latest} + if a := m.activeGlobal(); a != nil && !latest.After(a.ID.Epoch) { + return "", nil + } + + jobID := m.Download(id) + go m.loadAfterCompletion(jobID, id) + return jobID, nil +} + +// activeGlobal returns the currently-loaded global dataset, if any. +func (m *Manager) activeGlobal() *loadedDataset { + m.activeMu.RLock() + defer m.activeMu.RUnlock() + for i := range m.active { + if m.active[i].ID.Subset.IsGlobal() { + d := m.active[i] + return &d + } + } + return nil +} + +func (m *Manager) loadAfterCompletion(jobID string, id DatasetID) { + for { + info, ok := m.GetJob(jobID) + if !ok { + return + } + switch info.Status { + case JobComplete: + if err := m.Load(context.Background(), id); err != nil { + m.log.Error("load after download", zap.Error(err)) + } + return + case JobFailed, JobCancelled: + return + } + time.Sleep(2 * time.Second) + } +} + +func (m *Manager) runDownload(ctx context.Context, e *jobEntry) { + defer m.inFlight.Delete(e.dataset.Filename()) + + e.mu.Lock() + e.status = JobRunning + e.mu.Unlock() + + m.log.Info("download started", + zap.String("job", e.id), + zap.String("dataset", e.dataset.Filename())) + + err := m.downloadLocked(ctx, e) + now := time.Now().UTC() + + e.mu.Lock() + e.endedAt = now + switch { + case errors.Is(err, context.Canceled): + e.status = JobCancelled + case err != nil: + e.status = JobFailed + e.errStr = err.Error() + default: + e.status = JobComplete + } + finalStatus := e.status + e.mu.Unlock() + + m.log.Info("download finished", + zap.String("job", e.id), + zap.String("status", string(finalStatus)), + zap.NamedError("err", err)) +} + +// downloadLocked runs the source download while holding the storage's +// cross-process lock, so multiple replicas sharing a node-local dataset +// volume coordinate instead of each fetching ~9 GB. After acquiring the lock +// it re-checks existence: if another replica committed the dataset while this +// one waited, it skips the download and lets the caller load the committed file. +func (m *Manager) downloadLocked(ctx context.Context, e *jobEntry) error { + release, err := m.store.Lock(ctx) + if err != nil { + return fmt.Errorf("acquire download lock: %w", err) + } + defer release() + + if m.store.Exists(e.dataset) { + m.log.Info("dataset committed by another instance while waiting; skipping download", + zap.String("dataset", e.dataset.Filename())) + return nil + } + return m.src.Download(ctx, e.dataset, m.store, jobProgress{e: e}, m.throttle) +} + +func (m *Manager) completeShortCircuit(ctx context.Context, e *jobEntry) { + _ = ctx + defer m.inFlight.Delete(e.dataset.Filename()) + now := time.Now().UTC() + e.mu.Lock() + e.status = JobComplete + e.endedAt = now + e.mu.Unlock() +} + +// Close releases all resources, cancelling any in-flight jobs. +func (m *Manager) Close() error { + m.jobsMu.Lock() + for _, e := range m.jobs { + e.cancel() + } + m.jobsMu.Unlock() + + m.activeMu.Lock() + for _, d := range m.active { + closeField(d.Field, m.log) + } + m.active = nil + m.activeMu.Unlock() + return nil +} + +func closeField(f weather.WindField, log *zap.Logger) { + if c, ok := f.(interface{ Close() error }); ok && c != nil { + if err := c.Close(); err != nil && log != nil { + log.Warn("close dataset", zap.Error(err)) + } + } +} diff --git a/internal/datasets/manifest.go b/internal/datasets/manifest.go new file mode 100644 index 0000000..15c3038 --- /dev/null +++ b/internal/datasets/manifest.go @@ -0,0 +1,118 @@ +package datasets + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "sync" +) + +// Manifest tracks completed work units for a partial dataset download. +// Units are arbitrary opaque strings; sources choose the format +// (e.g. "step12-A" for "forecast step 12, level set A"). +// +// A Manifest is persisted as a JSON object: {"units": ["step0-A", "step0-B", ...]}. +type Manifest struct { + path string + + mu sync.Mutex + units map[string]struct{} +} + +// LoadManifest opens or creates the manifest at path. Missing or unreadable +// files are treated as empty; a corrupt file returns an error. +func LoadManifest(path string) (*Manifest, error) { + m := &Manifest{path: path, units: make(map[string]struct{})} + + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return m, nil + } + if err != nil { + return nil, fmt.Errorf("read manifest %s: %w", path, err) + } + if len(data) == 0 { + return m, nil + } + + var doc struct { + Units []string `json:"units"` + } + if err := json.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("parse manifest %s: %w", path, err) + } + for _, u := range doc.Units { + m.units[u] = struct{}{} + } + return m, nil +} + +// Has reports whether unit has been recorded as completed. +func (m *Manifest) Has(unit string) bool { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.units[unit] + return ok +} + +// Mark records unit as completed and persists the manifest to disk. +func (m *Manifest) Mark(unit string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.units[unit]; ok { + return nil + } + m.units[unit] = struct{}{} + return m.persistLocked() +} + +// Units returns the completed units in sorted order. +func (m *Manifest) Units() []string { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]string, 0, len(m.units)) + for u := range m.units { + out = append(out, u) + } + sort.Strings(out) + return out +} + +// Reset clears all recorded units and removes the manifest file. +func (m *Manifest) Reset() error { + m.mu.Lock() + defer m.mu.Unlock() + m.units = make(map[string]struct{}) + if err := os.Remove(m.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove manifest %s: %w", m.path, err) + } + return nil +} + +// persistLocked writes the manifest to disk via temp+rename. +// The caller must hold m.mu. +func (m *Manifest) persistLocked() error { + units := make([]string, 0, len(m.units)) + for u := range m.units { + units = append(units, u) + } + sort.Strings(units) + data, err := json.Marshal(struct { + Units []string `json:"units"` + }{Units: units}) + if err != nil { + return err + } + + tmp := m.path + ".new" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return fmt.Errorf("write manifest temp: %w", err) + } + if err := os.Rename(tmp, m.path); err != nil { + os.Remove(tmp) + return fmt.Errorf("rename manifest: %w", err) + } + return nil +} diff --git a/internal/datasets/store_local.go b/internal/datasets/store_local.go new file mode 100644 index 0000000..39d9d8c --- /dev/null +++ b/internal/datasets/store_local.go @@ -0,0 +1,188 @@ +package datasets + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// LocalStore stores dataset files on the local filesystem. +// +// Layout under Root: +// +// .bin — committed dataset +// .bin.downloading — in-progress dataset +// .bin.manifest.json — completed work units +// +// where is DatasetID.Filename() — typically +// "20060102T150405Z" for the global subset or +// "20060102T150405Z_r-10.10.-30.30_h0.72" for a subset. +type LocalStore struct { + Root string + Source string + Extension string // default ".bin" +} + +// NewLocalStore returns a LocalStore at root. The directory is created if missing. +func NewLocalStore(root, sourceID string) (*LocalStore, error) { + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("create store root %s: %w", root, err) + } + return &LocalStore{Root: root, Source: sourceID, Extension: ".bin"}, nil +} + +// SourceID returns the source ID this store is configured for. +func (s *LocalStore) SourceID() string { return s.Source } + +func (s *LocalStore) ext() string { + if s.Extension == "" { + return ".bin" + } + return s.Extension +} + +// Path returns the canonical path for id's committed dataset. +func (s *LocalStore) Path(id DatasetID) string { + return filepath.Join(s.Root, id.Filename()+s.ext()) +} + +func (s *LocalStore) tempPath(id DatasetID) string { + return s.Path(id) + ".downloading" +} + +func (s *LocalStore) manifestPath(id DatasetID) string { + return s.Path(id) + ".manifest.json" +} + +// Exists reports whether a committed dataset for id is present. +func (s *LocalStore) Exists(id DatasetID) bool { + info, err := os.Stat(s.Path(id)) + return err == nil && !info.IsDir() +} + +// List returns all committed dataset IDs, newest first. +func (s *LocalStore) List() ([]DatasetID, error) { + entries, err := os.ReadDir(s.Root) + if err != nil { + return nil, fmt.Errorf("read store: %w", err) + } + var out []DatasetID + ext := s.ext() + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ext) { + continue + } + stem := strings.TrimSuffix(name, ext) + // Skip in-progress files (their stem ends in .downloading or .manifest) + if strings.Contains(stem, ".") { + continue + } + id, ok := parseFilename(stem) + if !ok { + continue + } + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { + if !out[i].Epoch.Equal(out[j].Epoch) { + return out[i].Epoch.After(out[j].Epoch) + } + return out[i].Subset.Key() < out[j].Subset.Key() + }) + return out, nil +} + +// parseFilename inverts DatasetID.Filename(). The subset portion is not +// fully reversible (Key encoding is one-way for floats), so List returns +// IDs whose Subset is zero — the storage layer treats names as opaque +// identifiers. Callers wanting structured subset metadata should keep an +// out-of-band record. +func parseFilename(stem string) (DatasetID, bool) { + parts := strings.SplitN(stem, "_", 2) + epoch, err := time.Parse("20060102T150405Z", parts[0]) + if err != nil { + return DatasetID{}, false + } + id := DatasetID{Epoch: epoch.UTC()} + // Subset key is opaque on disk; we don't reconstruct its parameters + // here. Admin callers track subset specs separately when they need + // the structured form. + return id, true +} + +// Remove deletes the committed dataset and any sidecar files for id. +func (s *LocalStore) Remove(id DatasetID) error { + var errs []error + for _, p := range []string{s.Path(id), s.tempPath(id), s.manifestPath(id)} { + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return fmt.Errorf("remove dataset: %v", errs) + } + return nil +} + +// Lock acquires the storage-wide download lock (an exclusive flock on a +// sentinel file in the root), serialising downloads across processes that +// share this directory. +func (s *LocalStore) Lock(ctx context.Context) (func(), error) { + return flockExclusive(ctx, filepath.Join(s.Root, ".download.lock")) +} + +// BeginWrite opens or resumes a TempHandle for id. +func (s *LocalStore) BeginWrite(id DatasetID) (TempHandle, error) { + man, err := LoadManifest(s.manifestPath(id)) + if err != nil { + return nil, err + } + return &localHandle{store: s, id: id, manifest: man}, nil +} + +type localHandle struct { + store *LocalStore + id DatasetID + manifest *Manifest + closed bool +} + +func (h *localHandle) Path() string { return h.store.tempPath(h.id) } +func (h *localHandle) Manifest() *Manifest { return h.manifest } + +func (h *localHandle) Commit() error { + if h.closed { + return nil + } + h.closed = true + if err := os.Rename(h.store.tempPath(h.id), h.store.Path(h.id)); err != nil { + return fmt.Errorf("commit rename: %w", err) + } + if err := os.Remove(h.store.manifestPath(h.id)); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("commit remove manifest: %w", err) + } + return nil +} + +func (h *localHandle) Abort() error { + if h.closed { + return nil + } + h.closed = true + var firstErr error + for _, p := range []string{h.store.tempPath(h.id), h.store.manifestPath(h.id)} { + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/internal/datasets/store_test.go b/internal/datasets/store_test.go new file mode 100644 index 0000000..5d4a38c --- /dev/null +++ b/internal/datasets/store_test.go @@ -0,0 +1,145 @@ +package datasets + +import ( + "context" + "os" + "testing" + "time" +) + +func TestLocalStoreLockSerializes(t *testing.T) { + dir := t.TempDir() + store, _ := NewLocalStore(dir, "gfs-test") + ctx := context.Background() + + release, err := store.Lock(ctx) + if err != nil { + t.Fatalf("first Lock: %v", err) + } + + // A second acquisition must block until the first releases. + got := make(chan struct{}) + go func() { + r2, err := store.Lock(ctx) + if err == nil { + r2() + } + close(got) + }() + + select { + case <-got: + t.Fatal("second Lock acquired while first was held") + case <-time.After(100 * time.Millisecond): + // expected: still blocked + } + release() + select { + case <-got: + // expected: acquired after release + case <-time.After(2 * time.Second): + t.Fatal("second Lock did not acquire after release") + } +} + +func TestLocalStoreLockContextCancel(t *testing.T) { + dir := t.TempDir() + store, _ := NewLocalStore(dir, "gfs-test") + + release, err := store.Lock(context.Background()) + if err != nil { + t.Fatalf("Lock: %v", err) + } + defer release() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := store.Lock(ctx); err == nil { + t.Error("expected Lock to fail on cancelled context while held elsewhere") + } +} + +func TestLocalStoreBeginWriteResume(t *testing.T) { + dir := t.TempDir() + store, err := NewLocalStore(dir, "gfs-test") + if err != nil { + t.Fatalf("NewLocalStore: %v", err) + } + + id := DatasetID{Epoch: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} + h, err := store.BeginWrite(id) + if err != nil { + t.Fatalf("BeginWrite: %v", err) + } + if err := os.WriteFile(h.Path(), []byte("partial"), 0o644); err != nil { + t.Fatalf("write partial: %v", err) + } + if err := h.Manifest().Mark("step000-A"); err != nil { + t.Fatalf("mark: %v", err) + } + + // Re-open should see the previous manifest entry. + h2, err := store.BeginWrite(id) + if err != nil { + t.Fatalf("BeginWrite resume: %v", err) + } + if !h2.Manifest().Has("step000-A") { + t.Errorf("resumed manifest missing step000-A; units = %v", h2.Manifest().Units()) + } + + if err := h2.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + if !store.Exists(id) { + t.Errorf("Exists after commit returned false") + } + if _, err := os.Stat(store.manifestPath(id)); !os.IsNotExist(err) { + t.Errorf("manifest should be removed, got err=%v", err) + } + + stored, err := store.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(stored) != 1 || !stored[0].Epoch.Equal(id.Epoch) { + t.Errorf("List = %v, want one item with epoch %v", stored, id.Epoch) + } + + if err := store.Remove(id); err != nil { + t.Fatalf("Remove: %v", err) + } + if store.Exists(id) { + t.Errorf("Exists after remove returned true") + } +} + +func TestLocalStoreSubsetPath(t *testing.T) { + dir := t.TempDir() + store, _ := NewLocalStore(dir, "gfs-test") + epoch := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + regional := DatasetID{ + Epoch: epoch, + Subset: SubsetSpec{ + Region: &Region{MinLat: -10, MaxLat: 10, MinLng: 0, MaxLng: 30}, + HourRange: &HourRange{MinHour: 0, MaxHour: 72}, + }, + } + global := DatasetID{Epoch: epoch} + if store.Path(global) == store.Path(regional) { + t.Errorf("global and regional should have distinct paths") + } +} + +func TestSubsetSpecCoverage(t *testing.T) { + r := Region{MinLat: -10, MaxLat: 10, MinLng: 350, MaxLng: 10} // wraps antimeridian + s := SubsetSpec{Region: &r} + if !s.IncludesLatLng(0, 0) { + t.Errorf("(0,0) should be inside antimeridian region") + } + if !s.IncludesLatLng(0, 359) { + t.Errorf("(0,359) should be inside antimeridian region") + } + if s.IncludesLatLng(0, 180) { + t.Errorf("(0,180) should be outside antimeridian region") + } +} diff --git a/internal/datasets/subset.go b/internal/datasets/subset.go new file mode 100644 index 0000000..c610d43 --- /dev/null +++ b/internal/datasets/subset.go @@ -0,0 +1,156 @@ +package datasets + +import ( + "fmt" + "slices" + "strings" + "time" +) + +// SubsetSpec describes which portion of a dataset to download. +// +// A zero-value SubsetSpec means "the full dataset". The Region and +// HourRange fields independently restrict what is fetched and stored. +type SubsetSpec struct { + // Region restricts the geographic extent. nil means global. + Region *Region `json:"region,omitempty"` + + // HourRange restricts the forecast horizon. nil means the source's + // full horizon (e.g. 0..192h for GFS 0.5°). + HourRange *HourRange `json:"hour_range,omitempty"` + + // Members restricts ensemble members for sources that support them (GEFS). + // nil means all available members. + Members []int `json:"members,omitempty"` +} + +// Region is an axis-aligned geographic bounding box. +// +// Longitudes are in [0, 360); a box crossing the antimeridian has +// MinLng > MaxLng. +type Region struct { + MinLat float64 `json:"min_lat"` + MaxLat float64 `json:"max_lat"` + MinLng float64 `json:"min_lng"` + MaxLng float64 `json:"max_lng"` +} + +// HourRange is an inclusive forecast-hour range. +type HourRange struct { + MinHour int `json:"min_hour"` + MaxHour int `json:"max_hour"` +} + +// IsGlobal reports whether the spec selects the entire dataset. +func (s SubsetSpec) IsGlobal() bool { + return s.Region == nil && s.HourRange == nil && len(s.Members) == 0 +} + +// IncludesLatLng reports whether (lat, lng) lies inside the spec's Region, +// or the spec has no Region. +func (s SubsetSpec) IncludesLatLng(lat, lng float64) bool { + if s.Region == nil { + return true + } + r := s.Region + if lat < r.MinLat || lat > r.MaxLat { + return false + } + if r.MinLng <= r.MaxLng { + return lng >= r.MinLng && lng <= r.MaxLng + } + // Wraps the antimeridian. + return lng >= r.MinLng || lng <= r.MaxLng +} + +// IncludesHour reports whether the forecast hour is in range. +func (s SubsetSpec) IncludesHour(h int) bool { + if s.HourRange == nil { + return true + } + return h >= s.HourRange.MinHour && h <= s.HourRange.MaxHour +} + +// IncludesMember reports whether the ensemble member is in range. +func (s SubsetSpec) IncludesMember(m int) bool { + if len(s.Members) == 0 { + return true + } + return slices.Contains(s.Members, m) +} + +// Key returns a deterministic short identifier for the spec. The empty +// string represents the global subset. +func (s SubsetSpec) Key() string { + if s.IsGlobal() { + return "" + } + var b strings.Builder + if s.Region != nil { + fmt.Fprintf(&b, "r%g.%g.%g.%g", s.Region.MinLat, s.Region.MaxLat, s.Region.MinLng, s.Region.MaxLng) + } + if s.HourRange != nil { + if b.Len() > 0 { + b.WriteByte('_') + } + fmt.Fprintf(&b, "h%d.%d", s.HourRange.MinHour, s.HourRange.MaxHour) + } + if len(s.Members) > 0 { + if b.Len() > 0 { + b.WriteByte('_') + } + fmt.Fprintf(&b, "m") + for i, m := range s.Members { + if i > 0 { + b.WriteByte('.') + } + fmt.Fprintf(&b, "%d", m) + } + } + return b.String() +} + +// DatasetID identifies one storable dataset. +type DatasetID struct { + Epoch time.Time + Subset SubsetSpec +} + +// Equals reports whether two DatasetIDs refer to the same dataset. +// DatasetID is not comparable with == because SubsetSpec contains slices. +func (id DatasetID) Equals(other DatasetID) bool { + return id.Epoch.Equal(other.Epoch) && id.Subset.Key() == other.Subset.Key() +} + +// Filename returns the canonical filename stem for the dataset. The +// extension is appended by the Storage implementation. +func (id DatasetID) Filename() string { + stem := id.Epoch.UTC().Format("20060102T150405Z") + if k := id.Subset.Key(); k != "" { + return stem + "_" + k + } + return stem +} + +// Coverage is the spatial and temporal extent of a loaded dataset, used by +// the Manager to select which dataset can serve a given query. +type Coverage struct { + Region Region `json:"region"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} + +// Covers reports whether (t, lat, lng) lies inside the coverage. +func (c Coverage) Covers(t time.Time, lat, lng float64) bool { + if t.Before(c.StartTime) || t.After(c.EndTime) { + return false + } + r := c.Region + if lat < r.MinLat || lat > r.MaxLat { + return false + } + if r.MinLng <= r.MaxLng { + return lng >= r.MinLng && lng <= r.MaxLng + } + return lng >= r.MinLng || lng <= r.MaxLng +} diff --git a/internal/datasets/throttle.go b/internal/datasets/throttle.go new file mode 100644 index 0000000..980a005 --- /dev/null +++ b/internal/datasets/throttle.go @@ -0,0 +1,63 @@ +package datasets + +import ( + "context" + "sync" + "time" +) + +// TokenBucket is a simple bytes-per-second rate limiter. +// +// The bucket is initialised full (capacity = rate × 1 second). Calls to Wait +// block until enough tokens have accumulated. +type TokenBucket struct { + mu sync.Mutex + rate float64 // tokens per second + tokens float64 + cap float64 + last time.Time +} + +// NewTokenBucket returns a TokenBucket emitting at most bytesPerSecond. +// A non-positive rate disables throttling (Wait becomes a no-op). +func NewTokenBucket(bytesPerSecond int64) *TokenBucket { + if bytesPerSecond <= 0 { + return &TokenBucket{rate: 0} + } + r := float64(bytesPerSecond) + return &TokenBucket{rate: r, tokens: r, cap: r, last: time.Now()} +} + +// Wait blocks until n tokens are available or ctx is cancelled. +func (t *TokenBucket) Wait(ctx context.Context, n int) error { + if t.rate <= 0 { + return nil + } + want := float64(n) + + for { + t.mu.Lock() + now := time.Now() + elapsed := now.Sub(t.last).Seconds() + t.last = now + t.tokens += elapsed * t.rate + if t.tokens > t.cap { + t.tokens = t.cap + } + if t.tokens >= want { + t.tokens -= want + t.mu.Unlock() + return nil + } + // Sleep until we expect enough tokens. + need := want - t.tokens + sleep := time.Duration(need / t.rate * float64(time.Second)) + t.mu.Unlock() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(sleep): + } + } +} diff --git a/internal/datasets/types.go b/internal/datasets/types.go new file mode 100644 index 0000000..c8e08e4 --- /dev/null +++ b/internal/datasets/types.go @@ -0,0 +1,91 @@ +package datasets + +import ( + "context" + "time" + + "predictor-refactored/internal/weather" +) + +// Source is a pluggable origin for atmospheric datasets. +// +// Implementations download dataset files in a transactional, resumable +// manner and load them as weather.WindField. A Source must be safe for +// concurrent use across many Manager calls. +type Source interface { + // ID is a stable identifier, e.g. "gfs-0p50-3h". + ID() string + + // LatestEpoch returns the most recent dataset epoch this source can provide. + LatestEpoch(ctx context.Context) (time.Time, error) + + // Download fetches the dataset identified by id into store. Sources + // must honour any partial progress recorded in store's manifest and + // skip already-completed work so re-invocation after a crash resumes + // cleanly. + // + // prog receives progress events; nil is acceptable. + // throttle, if non-nil, is consulted before each network read for + // bandwidth limiting; nil means no throttling. + Download(ctx context.Context, id DatasetID, store Storage, prog ProgressSink, throttle Throttle) error + + // Open loads id's stored dataset and returns it as a WindField. + Open(ctx context.Context, id DatasetID, store Storage) (weather.WindField, error) + + // Coverage returns the geographical/temporal extent of a downloaded + // dataset. Used by the Manager to decide which loaded dataset can + // serve a given prediction query. + Coverage(id DatasetID) Coverage +} + +// Storage abstracts the on-disk location of dataset files and their manifests. +// +// Atomicity: only datasets promoted via TempHandle.Commit appear in Exists +// or List. Aborted or in-progress downloads are invisible to readers. +type Storage interface { + // SourceID identifies the data source these files belong to. + SourceID() string + + // Path returns the canonical local path for id's dataset. + Path(id DatasetID) string + + // Exists reports whether a committed dataset for id is present. + Exists(id DatasetID) bool + + // List returns all committed dataset IDs available, newest first. + List() ([]DatasetID, error) + + // Remove deletes the dataset and any sidecar manifest for id. + Remove(id DatasetID) error + + // BeginWrite opens (or resumes) a transactional handle for downloading + // id's dataset. + BeginWrite(id DatasetID) (TempHandle, error) + + // Lock acquires an exclusive, storage-wide lock that serialises downloads + // across every process sharing this storage (e.g. multiple replicas on a + // node that share a dataset volume). It blocks until the lock is held or + // ctx is cancelled. The returned function releases the lock. + Lock(ctx context.Context) (release func(), err error) +} + +// TempHandle is the storage state for one in-progress download. +type TempHandle interface { + Path() string + Manifest() *Manifest + Commit() error + Abort() error +} + +// ProgressSink receives progress events during a download. +type ProgressSink interface { + SetTotal(n int) + StepComplete() + Bytes(n int64) +} + +// Throttle is an optional bandwidth limiter consulted by sources before +// each network read. +type Throttle interface { + Wait(ctx context.Context, n int) error +} diff --git a/internal/downloader/config.go b/internal/downloader/config.go deleted file mode 100644 index 91575e1..0000000 --- a/internal/downloader/config.go +++ /dev/null @@ -1,58 +0,0 @@ -package downloader - -import ( - "os" - "strconv" - "time" -) - -// Config holds downloader configuration, loaded from environment variables. -type Config struct { - // DataDir is the directory for storing dataset files and temporary GRIB data. - DataDir string - - // Parallel is the maximum number of concurrent GRIB downloads. - Parallel int - - // UpdateInterval is how often the scheduler checks for new forecast data. - UpdateInterval time.Duration - - // DatasetTTL is how long a dataset is considered fresh before a new one is needed. - DatasetTTL time.Duration -} - -// DefaultConfig returns the default configuration. -func DefaultConfig() *Config { - return &Config{ - DataDir: "/tmp/predictor-data", - Parallel: 8, - UpdateInterval: 6 * time.Hour, - DatasetTTL: 48 * time.Hour, - } -} - -// LoadConfig loads configuration from environment variables, falling back to defaults. -func LoadConfig() *Config { - cfg := DefaultConfig() - - if v := os.Getenv("PREDICTOR_DATA_DIR"); v != "" { - cfg.DataDir = v - } - if v := os.Getenv("PREDICTOR_DOWNLOAD_PARALLEL"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - cfg.Parallel = n - } - } - if v := os.Getenv("PREDICTOR_UPDATE_INTERVAL"); v != "" { - if d, err := time.ParseDuration(v); err == nil { - cfg.UpdateInterval = d - } - } - if v := os.Getenv("PREDICTOR_DATASET_TTL"); v != "" { - if d, err := time.ParseDuration(v); err == nil { - cfg.DatasetTTL = d - } - } - - return cfg -} diff --git a/internal/downloader/downloader.go b/internal/downloader/downloader.go deleted file mode 100644 index 32208a0..0000000 --- a/internal/downloader/downloader.go +++ /dev/null @@ -1,441 +0,0 @@ -package downloader - -import ( - "context" - "fmt" - "io" - "math" - "net/http" - "os" - "path/filepath" - "sync/atomic" - "time" - - "predictor-refactored/internal/dataset" - - "github.com/nilsmagnus/grib/griblib" - "go.uber.org/zap" - "golang.org/x/sync/errgroup" -) - -// Downloader handles fetching GFS forecast data from S3 and assembling dataset files. -type Downloader struct { - cfg *Config - client *http.Client - log *zap.Logger -} - -// NewDownloader creates a new Downloader. -func NewDownloader(cfg *Config, log *zap.Logger) *Downloader { - return &Downloader{ - cfg: cfg, - client: &http.Client{ - Timeout: 2 * time.Minute, - }, - log: log, - } -} - -// neededVariables is the set of GRIB variable names we need. -var neededVariables = map[string]bool{ - "HGT": true, - "UGRD": true, - "VGRD": true, -} - -// FindLatestRun finds the most recent available GFS model run on S3. -// It checks the last forecast step of each run to confirm availability. -func (d *Downloader) FindLatestRun(ctx context.Context) (time.Time, error) { - now := time.Now().UTC() - hour := now.Hour() - (now.Hour() % 6) - current := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC) - - for i := 0; i < 8; i++ { - date := current.Format("20060102") - url := dataset.GribURL(date, current.Hour(), dataset.MaxHour) + ".idx" - - req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) - if err != nil { - current = current.Add(-6 * time.Hour) - continue - } - - resp, err := d.client.Do(req) - if err == nil { - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - d.log.Info("found latest model run", - zap.Time("run", current), - zap.String("verified_url", url)) - return current, nil - } - } - - current = current.Add(-6 * time.Hour) - } - - return time.Time{}, fmt.Errorf("no recent GFS forecast found (checked 8 runs)") -} - -// progress tracks download progress across concurrent goroutines. -type progress struct { - bytesDownloaded atomic.Int64 - stepsCompleted atomic.Int64 - totalSteps int64 - startTime time.Time - log *zap.Logger -} - -func newProgress(totalSteps int, log *zap.Logger) *progress { - return &progress{ - totalSteps: int64(totalSteps), - startTime: time.Now(), - log: log, - } -} - -func (p *progress) addBytes(n int64) { - p.bytesDownloaded.Add(n) -} - -func (p *progress) completeStep() { - done := p.stepsCompleted.Add(1) - total := p.totalSteps - bytes := p.bytesDownloaded.Load() - elapsed := time.Since(p.startTime).Seconds() - - pct := float64(done) / float64(total) * 100 - mbDownloaded := float64(bytes) / (1024 * 1024) - mbPerSec := 0.0 - if elapsed > 0 { - mbPerSec = mbDownloaded / elapsed - } - - // Estimate remaining - eta := "" - if done > 0 && done < total { - secsPerStep := elapsed / float64(done) - remaining := secsPerStep * float64(total-done) - if remaining > 60 { - eta = fmt.Sprintf("%.0fm%02.0fs", math.Floor(remaining/60), math.Mod(remaining, 60)) - } else { - eta = fmt.Sprintf("%.0fs", remaining) - } - } - - p.log.Info("download progress", - zap.String("progress", fmt.Sprintf("%d/%d", done, total)), - zap.String("percent", fmt.Sprintf("%.1f%%", pct)), - zap.String("downloaded", fmt.Sprintf("%.1f MB", mbDownloaded)), - zap.String("speed", fmt.Sprintf("%.1f MB/s", mbPerSec)), - zap.String("eta", eta)) -} - -// Download downloads a complete forecast and assembles a dataset file. -// Returns the path to the completed dataset file. -func (d *Downloader) Download(ctx context.Context, run time.Time) (string, error) { - date := run.Format("20060102") - runHour := run.Hour() - - finalPath := filepath.Join(d.cfg.DataDir, run.Format("2006010215")) - tempPath := finalPath + ".downloading" - - // Check if final dataset already exists - if info, err := os.Stat(finalPath); err == nil && info.Size() == dataset.DatasetSize { - d.log.Info("dataset already exists", zap.String("path", finalPath)) - return finalPath, nil - } - - steps := dataset.Hours() - totalSteps := len(steps) * 2 // pgrb2 + pgrb2b per step - prog := newProgress(totalSteps, d.log) - - d.log.Info("starting dataset download", - zap.Time("run", run), - zap.Int("total_steps", totalSteps), - zap.String("temp_path", tempPath)) - - // Create the dataset file - ds, err := dataset.Create(tempPath) - if err != nil { - return "", fmt.Errorf("create dataset: %w", err) - } - defer ds.Close() - - // Process each forecast step with bounded concurrency - g, ctx := errgroup.WithContext(ctx) - sem := make(chan struct{}, d.cfg.Parallel) - - for _, step := range steps { - step := step - hourIdx := dataset.HourIndex(step) - if hourIdx < 0 { - continue - } - - // Download pgrb2 (level set A) - sem <- struct{}{} - g.Go(func() error { - defer func() { <-sem }() - url := dataset.GribURL(date, runHour, step) - err := d.downloadAndBlit(ctx, ds, url, hourIdx, dataset.LevelSetA, prog) - if err != nil { - return fmt.Errorf("step %d pgrb2: %w", step, err) - } - prog.completeStep() - return nil - }) - - // Download pgrb2b (level set B) - sem <- struct{}{} - g.Go(func() error { - defer func() { <-sem }() - url := dataset.GribURLB(date, runHour, step) - err := d.downloadAndBlit(ctx, ds, url, hourIdx, dataset.LevelSetB, prog) - if err != nil { - return fmt.Errorf("step %d pgrb2b: %w", step, err) - } - prog.completeStep() - return nil - }) - } - - if err := g.Wait(); err != nil { - os.Remove(tempPath) - return "", err - } - - elapsed := time.Since(prog.startTime) - totalMB := float64(prog.bytesDownloaded.Load()) / (1024 * 1024) - d.log.Info("download complete, flushing to disk", - zap.String("downloaded", fmt.Sprintf("%.1f MB", totalMB)), - zap.Duration("elapsed", elapsed), - zap.String("avg_speed", fmt.Sprintf("%.1f MB/s", totalMB/elapsed.Seconds()))) - - // Flush to disk - if err := ds.Flush(); err != nil { - os.Remove(tempPath) - return "", fmt.Errorf("flush dataset: %w", err) - } - - // Close before rename - ds.Close() - - // Atomic rename - if err := os.Rename(tempPath, finalPath); err != nil { - os.Remove(tempPath) - return "", fmt.Errorf("rename dataset: %w", err) - } - - d.log.Info("dataset ready", zap.String("path", finalPath)) - return finalPath, nil -} - -// DownloadAndBlit downloads needed GRIB fields from a URL and writes them into the dataset. -func (d *Downloader) DownloadAndBlit(ctx context.Context, ds *dataset.File, baseURL string, hourIdx int, levelSet dataset.LevelSet) error { - return d.downloadAndBlit(ctx, ds, baseURL, hourIdx, levelSet, nil) -} - -// downloadAndBlit is the internal implementation with optional progress tracking. -func (d *Downloader) downloadAndBlit(ctx context.Context, ds *dataset.File, baseURL string, hourIdx int, levelSet dataset.LevelSet, prog *progress) error { - // 1. Download .idx - idxURL := baseURL + ".idx" - idxBody, err := d.httpGet(ctx, idxURL) - if err != nil { - return fmt.Errorf("download idx: %w", err) - } - - // 2. Parse and filter - entries := ParseIdx(idxBody) - filtered := FilterIdx(entries, neededVariables) - - // Further filter to only levels in this level set - var relevant []IdxEntry - for _, e := range filtered { - ls, ok := dataset.PressureLevelSet(e.LevelMB) - if ok && ls == levelSet { - relevant = append(relevant, e) - } - } - - if len(relevant) == 0 { - d.log.Warn("no relevant entries found in idx", - zap.String("url", idxURL), - zap.Int("total_entries", len(entries)), - zap.Int("filtered", len(filtered))) - return nil - } - - // 3. Download byte ranges and write to temp file - ranges := EntriesToRanges(relevant) - tmpFile, err := d.downloadRangesToTempFile(ctx, baseURL, ranges, prog) - if err != nil { - return fmt.Errorf("download ranges: %w", err) - } - defer os.Remove(tmpFile) - - // 4. Read GRIB messages from temp file - f, err := os.Open(tmpFile) - if err != nil { - return fmt.Errorf("open temp grib: %w", err) - } - - messages, err := griblib.ReadMessages(f) - f.Close() - if err != nil { - return fmt.Errorf("read grib messages: %w", err) - } - - // 5. Decode and blit each message into the dataset - for _, msg := range messages { - if msg.Section4.ProductDefinitionTemplateNumber != 0 { - continue - } - - product := msg.Section4.ProductDefinitionTemplate - - varIdx := dataset.VariableIndex(int(product.ParameterCategory), int(product.ParameterNumber)) - if varIdx < 0 { - continue - } - - if product.FirstSurface.Type != 100 { // isobaric surface - continue - } - - pressurePa := float64(product.FirstSurface.Value) - pressureMB := int(math.Round(pressurePa / 100.0)) - levelIdx := dataset.PressureIndex(pressureMB) - if levelIdx < 0 { - continue - } - - data := msg.Data() - if err := ds.BlitGribData(hourIdx, levelIdx, varIdx, data); err != nil { - d.log.Warn("blit failed", - zap.Int("var", varIdx), - zap.Int("level_mb", pressureMB), - zap.Error(err)) - continue - } - } - - return nil -} - -// downloadRangesToTempFile downloads multiple byte ranges from a URL, -// concatenating them into a single temp file (valid concatenated GRIB messages). -func (d *Downloader) downloadRangesToTempFile(ctx context.Context, baseURL string, ranges []ByteRange, prog *progress) (string, error) { - tmpFile, err := os.CreateTemp(d.cfg.DataDir, "grib-*.tmp") - if err != nil { - return "", fmt.Errorf("create temp file: %w", err) - } - tmpPath := tmpFile.Name() - - for _, r := range ranges { - data, err := d.httpGetRange(ctx, baseURL, r.Start, r.End) - if err != nil { - tmpFile.Close() - os.Remove(tmpPath) - return "", fmt.Errorf("download range %d-%d: %w", r.Start, r.End, err) - } - if _, err := tmpFile.Write(data); err != nil { - tmpFile.Close() - os.Remove(tmpPath) - return "", fmt.Errorf("write temp: %w", err) - } - if prog != nil { - prog.addBytes(int64(len(data))) - } - } - - if err := tmpFile.Close(); err != nil { - os.Remove(tmpPath) - return "", err - } - - return tmpPath, nil -} - -// httpGet downloads a URL and returns the body bytes. -func (d *Downloader) httpGet(ctx context.Context, url string) ([]byte, error) { - var lastErr error - for attempt := 0; attempt < 3; attempt++ { - if attempt > 0 { - select { - case <-time.After(time.Duration(attempt*2) * time.Second): - case <-ctx.Done(): - return nil, ctx.Err() - } - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - - resp, err := d.client.Do(req) - if err != nil { - lastErr = err - continue - } - - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - lastErr = fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) - continue - } - if err != nil { - lastErr = err - continue - } - - return body, nil - } - - return nil, fmt.Errorf("after 3 attempts: %w", lastErr) -} - -// httpGetRange downloads a byte range from a URL. -func (d *Downloader) httpGetRange(ctx context.Context, url string, start, end int64) ([]byte, error) { - var lastErr error - for attempt := 0; attempt < 3; attempt++ { - if attempt > 0 { - select { - case <-time.After(time.Duration(attempt*2) * time.Second): - case <-ctx.Done(): - return nil, ctx.Err() - } - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end)) - - resp, err := d.client.Do(req) - if err != nil { - lastErr = err - continue - } - - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - - if resp.StatusCode != http.StatusPartialContent && resp.StatusCode != http.StatusOK { - lastErr = fmt.Errorf("HTTP %d for range %d-%d of %s", resp.StatusCode, start, end, url) - continue - } - if err != nil { - lastErr = err - continue - } - - return body, nil - } - - return nil, fmt.Errorf("after 3 attempts: %w", lastErr) -} diff --git a/internal/downloader/idx.go b/internal/downloader/idx.go deleted file mode 100644 index 2e09bc4..0000000 --- a/internal/downloader/idx.go +++ /dev/null @@ -1,157 +0,0 @@ -package downloader - -import ( - "fmt" - "strconv" - "strings" -) - -// IdxEntry represents a single parsed line from a GRIB .idx file. -// Example line: "15:1207405:d=2024010100:HGT:1000 mb:0 hour fcst:" -type IdxEntry struct { - Index int - Offset int64 - Variable string // "HGT", "UGRD", "VGRD", etc. - LevelMB int // pressure level in mb (0 if not a pressure level) - Hour int // forecast hour - EndOffset int64 // byte after this message (from next entry's offset, or -1 if last) -} - -// Length returns the byte length of this GRIB message, or -1 if unknown. -func (e *IdxEntry) Length() int64 { - if e.EndOffset <= 0 { - return -1 - } - return e.EndOffset - e.Offset -} - -// ParseIdx parses a .idx file body and returns all entries. -// Lines that can't be parsed are silently skipped. -func ParseIdx(body []byte) []IdxEntry { - lines := strings.Split(string(body), "\n") - var entries []IdxEntry - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - parts := strings.Split(line, ":") - if len(parts) < 7 { - continue - } - - idx, err := strconv.Atoi(parts[0]) - if err != nil { - continue - } - - offset, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil { - continue - } - - variable := parts[3] - levelStr := parts[4] - hourStr := parts[5] - - levelMB := parseLevelMB(levelStr) - hour := parseHour(hourStr) - - entries = append(entries, IdxEntry{ - Index: idx, - Offset: offset, - Variable: variable, - LevelMB: levelMB, - Hour: hour, - EndOffset: -1, // filled in below - }) - } - - // Fill in EndOffset from the next entry's Offset. - for i := 0; i < len(entries)-1; i++ { - entries[i].EndOffset = entries[i+1].Offset - } - - return entries -} - -// FilterIdx returns entries matching the given variables at pressure levels. -// Only entries with a recognized pressure level (levelMB > 0) are returned. -func FilterIdx(entries []IdxEntry, variables map[string]bool) []IdxEntry { - var filtered []IdxEntry - for _, e := range entries { - if !variables[e.Variable] { - continue - } - if e.LevelMB <= 0 { - continue - } - // Must have a known length (not the last entry) or be handled specially - if e.Length() <= 0 { - continue - } - filtered = append(filtered, e) - } - return filtered -} - -// parseLevelMB parses a level string like "1000 mb" and returns the pressure in mb. -// Returns 0 if not a pressure level. -func parseLevelMB(s string) int { - s = strings.TrimSpace(s) - if !strings.HasSuffix(s, " mb") { - return 0 - } - numStr := strings.TrimSuffix(s, " mb") - n, err := strconv.Atoi(numStr) - if err != nil { - return 0 - } - return n -} - -// parseHour parses a forecast hour string like "0 hour fcst" or "anl". -// Returns -1 if it can't be parsed. -func parseHour(s string) int { - s = strings.TrimSpace(s) - if s == "anl" { - return 0 - } - s = strings.TrimSuffix(s, " hour fcst") - n, err := strconv.Atoi(s) - if err != nil { - return -1 - } - return n -} - -// GroupByRange groups idx entries into byte ranges suitable for HTTP Range downloads. -// Each range covers one contiguous GRIB message. -type ByteRange struct { - Start int64 - End int64 // inclusive - Entry IdxEntry -} - -// EntriesToRanges converts filtered idx entries to byte ranges. -func EntriesToRanges(entries []IdxEntry) []ByteRange { - ranges := make([]ByteRange, 0, len(entries)) - for _, e := range entries { - if e.Length() <= 0 { - continue - } - ranges = append(ranges, ByteRange{ - Start: e.Offset, - End: e.EndOffset - 1, // inclusive - Entry: e, - }) - } - return ranges -} - -// FormatRange returns an HTTP Range header value for a byte range. -func (r ByteRange) FormatRange() string { - return fmt.Sprintf("bytes=%d-%d", r.Start, r.End) -} diff --git a/internal/downloader/idx_test.go b/internal/downloader/idx_test.go deleted file mode 100644 index 71a7224..0000000 --- a/internal/downloader/idx_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package downloader - -import ( - "testing" -) - -const sampleIdx = `1:0:d=2024010100:HGT:1000 mb:0 hour fcst: -2:289012:d=2024010100:HGT:975 mb:0 hour fcst: -3:541876:d=2024010100:TMP:1000 mb:0 hour fcst: -4:789012:d=2024010100:UGRD:1000 mb:0 hour fcst: -5:1045678:d=2024010100:VGRD:1000 mb:0 hour fcst: -6:1298765:d=2024010100:UGRD:975 mb:0 hour fcst: -7:1567890:d=2024010100:UGRD:2 m above ground:0 hour fcst: -8:1812345:d=2024010100:VGRD:975 mb:0 hour fcst: -9:2098765:d=2024010100:HGT:500 mb:3 hour fcst: -` - -func TestParseIdx(t *testing.T) { - entries := ParseIdx([]byte(sampleIdx)) - if len(entries) != 9 { - t.Fatalf("expected 9 entries, got %d", len(entries)) - } - - // Check first entry - e := entries[0] - if e.Index != 1 || e.Offset != 0 || e.Variable != "HGT" || e.LevelMB != 1000 || e.Hour != 0 { - t.Errorf("entry 0: got %+v", e) - } - if e.EndOffset != 289012 { - t.Errorf("entry 0 EndOffset: got %d, want 289012", e.EndOffset) - } - - // Check "2 m above ground" is not a pressure level - e = entries[6] // UGRD at "2 m above ground" - if e.LevelMB != 0 { - t.Errorf("non-pressure level should have LevelMB=0, got %d", e.LevelMB) - } - - // Last entry should have EndOffset = -1 - last := entries[len(entries)-1] - if last.EndOffset != -1 { - t.Errorf("last entry EndOffset: got %d, want -1", last.EndOffset) - } -} - -func TestFilterIdx(t *testing.T) { - entries := ParseIdx([]byte(sampleIdx)) - filtered := FilterIdx(entries, neededVariables) - - // Should include HGT/UGRD/VGRD at pressure levels, exclude TMP and "above ground" - // And exclude last entry (no EndOffset) - for _, e := range filtered { - if !neededVariables[e.Variable] { - t.Errorf("unexpected variable %s", e.Variable) - } - if e.LevelMB <= 0 { - t.Errorf("non-pressure level included: %+v", e) - } - if e.Length() <= 0 { - t.Errorf("entry with unknown length included: %+v", e) - } - } - - // Count expected: HGT@1000, HGT@975, UGRD@1000, VGRD@1000, UGRD@975, VGRD@975 = 6 - // But HGT@500 at 3hr fcst is the last entry (no EndOffset), so excluded - if len(filtered) != 6 { - t.Errorf("expected 6 filtered entries, got %d", len(filtered)) - for _, e := range filtered { - t.Logf(" %s %d mb (offset %d, len %d)", e.Variable, e.LevelMB, e.Offset, e.Length()) - } - } -} - -func TestParseLevelMB(t *testing.T) { - tests := []struct { - input string - want int - }{ - {"1000 mb", 1000}, - {"975 mb", 975}, - {"1 mb", 1}, - {"2 m above ground", 0}, - {"surface", 0}, - {"tropopause", 0}, - } - for _, tt := range tests { - got := parseLevelMB(tt.input) - if got != tt.want { - t.Errorf("parseLevelMB(%q) = %d, want %d", tt.input, got, tt.want) - } - } -} - -func TestParseHour(t *testing.T) { - tests := []struct { - input string - want int - }{ - {"0 hour fcst", 0}, - {"3 hour fcst", 3}, - {"192 hour fcst", 192}, - {"anl", 0}, - } - for _, tt := range tests { - got := parseHour(tt.input) - if got != tt.want { - t.Errorf("parseHour(%q) = %d, want %d", tt.input, got, tt.want) - } - } -} diff --git a/internal/elevation/elevation.go b/internal/elevation/elevation.go index 9fde295..addc9a1 100644 --- a/internal/elevation/elevation.go +++ b/internal/elevation/elevation.go @@ -16,8 +16,8 @@ import ( const ( CellsPerDegree = 120 NumLats = 180*CellsPerDegree + 1 // 21601 - NumLons = 360 * CellsPerDegree // 43200 - DataSize = NumLats * NumLons * 2 // 1,866,326,400 bytes (~1.74 GiB) + NumLons = 360 * CellsPerDegree // 43200 + DataSize = NumLats * NumLons * 2 // 1,866,326,400 bytes (~1.74 GiB) ) // Dataset is a memory-mapped global elevation grid. @@ -71,9 +71,10 @@ func (d *Dataset) getCell(latIdx, lngIdx int) int16 { return int16(binary.LittleEndian.Uint16(d.mm[off : off+2])) } -// Get returns the interpolated elevation in metres at the given coordinates. -// lat: -90 to +90, lng: 0 to 360 (or -180 to 180, will be normalised). -func (d *Dataset) Get(lat, lng float64) float64 { +// Elevation returns the bilinearly-interpolated ground elevation in metres at +// the given coordinates. lat is in [-90, +90]; lng accepts either [0, 360) or +// [-180, 180) and is normalised internally. +func (d *Dataset) Elevation(lat, lng float64) float64 { // Normalise longitude to [0, 360) if lng < 0 { lng += 360 diff --git a/internal/engine/constraints.go b/internal/engine/constraints.go new file mode 100644 index 0000000..79dfb10 --- /dev/null +++ b/internal/engine/constraints.go @@ -0,0 +1,117 @@ +package engine + +import ( + "fmt" + + "predictor-refactored/internal/numerics" +) + +// Altitude triggers when the balloon altitude satisfies Op against Limit. +// +// Examples: +// +// Altitude{Op: OpGreaterEqual, Limit: 30000} — burst at 30 km +// Altitude{Op: OpLessEqual, Limit: 0} — sea-level descent termination +type Altitude struct { + Op Operator + Limit float64 + On Action +} + +func (c Altitude) Name() string { + return fmt.Sprintf("altitude %s %g", c.Op, c.Limit) +} +func (c Altitude) Violated(_ float64, s State) bool { return c.Op.Test(s.Altitude, c.Limit) } +func (c Altitude) Action() Action { return c.On } + +// Time triggers when the integration time t (UNIX seconds) satisfies Op +// against Limit. +type Time struct { + Op Operator + Limit float64 + On Action +} + +func (c Time) Name() string { return fmt.Sprintf("time %s %g", c.Op, c.Limit) } +func (c Time) Violated(t float64, _ State) bool { return c.Op.Test(t, c.Limit) } +func (c Time) Action() Action { return c.On } + +// TerrainContact triggers when the ground elevation exceeds the balloon's +// altitude — i.e. the balloon has hit the ground. +type TerrainContact struct { + Provider TerrainProvider + On Action +} + +func (c TerrainContact) Name() string { return "terrain_contact" } +func (c TerrainContact) Violated(_ float64, s State) bool { + return c.Provider.Elevation(s.Lat, s.Lng) > s.Altitude +} +func (c TerrainContact) Action() Action { return c.On } + +// PolygonMode selects whether Polygon fires when the balloon is inside or +// outside the configured polygon. +type PolygonMode int + +const ( + // PolygonInside fires when (lat, lng) lies inside the polygon — useful + // for "must not enter restricted airspace". + PolygonInside PolygonMode = iota + // PolygonOutside fires when (lat, lng) lies outside the polygon — + // useful for "must remain over the test range". + PolygonOutside +) + +// PolygonVertex is one vertex of a geographic polygon. Latitudes are in +// degrees [-90, 90]; longitudes in degrees [0, 360) or [-180, 180] +// (callers normalise — see Polygon.Violated). +type PolygonVertex struct { + Lat float64 + Lng float64 +} + +// Polygon is a constraint over a closed geographic polygon, evaluated in +// plate-carrée coordinates with antimeridian handling (see +// numerics.PointInPolygon). Build one with NewPolygon so the flattened +// vertex slices used by the hot path are precomputed. +type Polygon struct { + Vertices []PolygonVertex + Mode PolygonMode + On Action + // Label, if set, is returned by Name. Defaults to "polygon_inside" or + // "polygon_outside" based on Mode. + Label string + + // Precomputed parallel vertex slices for numerics.PointInPolygon. + polyLat, polyLng []float64 +} + +// NewPolygon builds a Polygon, precomputing the flattened vertex slices. +func NewPolygon(verts []PolygonVertex, mode PolygonMode, on Action, label string) Polygon { + lat := make([]float64, len(verts)) + lng := make([]float64, len(verts)) + for i, v := range verts { + lat[i], lng[i] = v.Lat, v.Lng + } + return Polygon{Vertices: verts, Mode: mode, On: on, Label: label, polyLat: lat, polyLng: lng} +} + +func (c Polygon) Name() string { + if c.Label != "" { + return c.Label + } + if c.Mode == PolygonOutside { + return "polygon_outside" + } + return "polygon_inside" +} +func (c Polygon) Action() Action { return c.On } + +// Violated reports whether the state satisfies the polygon-containment rule. +func (c Polygon) Violated(_ float64, s State) bool { + in := numerics.PointInPolygon(s.Lat, s.Lng, c.polyLat, c.polyLng) + if c.Mode == PolygonInside { + return in + } + return !in +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..8c3fd57 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,265 @@ +package engine + +import ( + "math" + "testing" + "time" + + "predictor-refactored/internal/weather" +) + +// noWind is a WindField that always returns zero wind. +type noWind struct{ epoch time.Time } + +func (n noWind) Wind(_ float64, _, _, _ float64) (weather.Sample, error) { + return weather.Sample{}, nil +} +func (n noWind) Epoch() time.Time { return n.epoch } +func (n noWind) Source() string { return "test" } + +// flatGround returns 0 metres everywhere. +type flatGround struct{} + +func (flatGround) Elevation(_, _ float64) float64 { return 0 } + +func TestConstantAscentToBurst(t *testing.T) { + burst := 30000.0 + rate := 5.0 + + ascend := &Propagator{ + Name: "ascent", + Step: 60, + Model: Sum(ConstantRate(rate), WindTransport(noWind{}, nil)), + Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: burst, On: ActionStop}}, + } + + prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward} + results := prof.Run(0, State{Lat: 0, Lng: 0, Altitude: 0}, NewEventSink()) + + if len(results) != 1 || results[0].Outcome != OutcomeStopped { + t.Fatalf("expected one stopped stage, got %+v", results) + } + if results[0].ConstraintName == "" { + t.Errorf("ConstraintName not populated") + } + if results[0].RefinedState.Altitude == 0 { + t.Errorf("RefinedState not populated") + } + + lastT, last := results[0].Path.Last() + if math.Abs(last.Altitude-burst) > 5 { + t.Errorf("burst altitude = %v, want within 5m of %v", last.Altitude, burst) + } + wantTime := burst / rate + if math.Abs(lastT-wantTime) > 1 { + t.Errorf("burst time = %v, want within 1s of %v", lastT, wantTime) + } +} + +func TestProfileWithFallback(t *testing.T) { + burst := 1000.0 + rate := 5.0 + + descent := &Propagator{ + Name: "descent", + Step: 60, + Model: ParachuteDescent(rate), + Constraints: []Constraint{TerrainContact{Provider: flatGround{}, On: ActionStop}}, + } + ascend := &Propagator{ + Name: "ascent", + Step: 60, + Model: ConstantRate(rate), + Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: burst, On: ActionFallback}}, + Fallback: descent, + } + + prof := Profile{Stages: []*Propagator{ascend}, Direction: Forward} + results := prof.Run(0, State{Altitude: 0}, NewEventSink()) + + if len(results) != 2 { + t.Fatalf("expected 2 results (ascent then descent fallback), got %d", len(results)) + } + if results[0].Outcome != OutcomeFallback { + t.Errorf("first outcome = %v, want OutcomeFallback", results[0].Outcome) + } + if results[1].Outcome != OutcomeStopped { + t.Errorf("second outcome = %v, want OutcomeStopped", results[1].Outcome) + } + + _, last := results[1].Path.Last() + if math.Abs(last.Altitude) > 5 { + t.Errorf("final altitude = %v, want within 5m of 0", last.Altitude) + } +} + +func TestReverseDirection(t *testing.T) { + desc := &Propagator{ + Name: "rewind", + Step: 1, + Model: ConstantRate(-1), + Constraints: []Constraint{Altitude{Op: OpGreaterEqual, Limit: 200, On: ActionStop}}, + } + prof := Profile{Stages: []*Propagator{desc}, Direction: Reverse} + results := prof.Run(0, State{Altitude: 100}, NewEventSink()) + + lastT, last := results[0].Path.Last() + if math.Abs(last.Altitude-200) > 1 { + t.Errorf("reverse final altitude = %v, want ~200", last.Altitude) + } + if lastT >= 0 { + t.Errorf("reverse final time = %v, want < 0", lastT) + } +} + +func TestPiecewiseRate(t *testing.T) { + m := Piecewise([]RateSegment{ + {Until: 100, Rate: 5}, + {Until: 200, Rate: 3}, + {Until: math.Inf(1), Rate: 0}, + }) + + if r := m(50, State{}); r.Altitude != 5 { + t.Errorf("rate at t=50 = %v, want 5", r.Altitude) + } + if r := m(150, State{}); r.Altitude != 3 { + t.Errorf("rate at t=150 = %v, want 3", r.Altitude) + } + if r := m(300, State{}); r.Altitude != 0 { + t.Errorf("rate at t=300 = %v, want 0", r.Altitude) + } +} + +func TestPiecewiseReferenceResolution(t *testing.T) { + // Build via the registry with propagator_start segments. + spec := ModelSpec{ + Type: "piecewise", + Segments: []PiecewiseSegmentSpec{ + {Until: 100, Rate: 5, Reference: "propagator_start"}, + {Until: 200, Rate: 3, Reference: "propagator_start"}, + }, + } + built, err := BuildModel(spec, BuildDeps{}) + if err != nil { + t.Fatalf("BuildModel: %v", err) + } + if built.Build == nil { + t.Fatalf("expected lazy build for propagator_start references") + } + ctx := StageContext{ProfileStart: 1000, PropagatorStart: 5000} + m := built.Build(ctx) + // Until=100 from propagator_start=5000 → absolute 5100. + if r := m(5050, State{}); r.Altitude != 5 { + t.Errorf("rate at t=5050 = %v, want 5", r.Altitude) + } + if r := m(5150, State{}); r.Altitude != 3 { + t.Errorf("rate at t=5150 = %v, want 3", r.Altitude) + } +} + +// fixedWind returns a constant wind sample. +type fixedWind struct{ u, v float64 } + +func (w fixedWind) Wind(_ float64, _, _, _ float64) (weather.Sample, error) { + return weather.Sample{U: w.u, V: w.v}, nil +} +func (fixedWind) Epoch() time.Time { return time.Unix(0, 0) } +func (fixedWind) Source() string { return "test-fixed" } + +func TestWindTransportUnitConversion(t *testing.T) { + wind := WindTransport(fixedWind{u: 10, v: 0}, nil) + d := wind(0, State{Lat: 0, Lng: 0, Altitude: 0}) + wantLng := (180.0 / math.Pi) * 10.0 / 6371009.0 + if math.Abs(d.Lng-wantLng) > 1e-12 { + t.Errorf("dlng = %v, want %v", d.Lng, wantLng) + } + if math.Abs(d.Lat) > 1e-12 { + t.Errorf("dlat = %v, want 0 for u=10 v=0", d.Lat) + } + + wind2 := WindTransport(fixedWind{u: 0, v: 5}, nil) + d = wind2(0, State{Lat: 60, Lng: 0, Altitude: 0}) + wantLat := (180.0 / math.Pi) * 5.0 / 6371009.0 + if math.Abs(d.Lat-wantLat) > 1e-12 { + t.Errorf("dlat at lat=60 = %v, want %v", d.Lat, wantLat) + } +} + +// aboveModelWind reports AboveModel on every sample. Used to verify event emission. +type aboveModelWind struct{} + +func (aboveModelWind) Wind(_ float64, _, _, _ float64) (weather.Sample, error) { + return weather.Sample{AboveModel: true}, nil +} +func (aboveModelWind) Epoch() time.Time { return time.Unix(0, 0) } +func (aboveModelWind) Source() string { return "above" } + +func TestWindTransportEmitsAboveModel(t *testing.T) { + sink := NewEventSink() + wind := WindTransport(aboveModelWind{}, sink) + for range 3 { + _ = wind(0, State{}) + } + events := sink.Snapshot() + if len(events) != 1 || events[0].Type != "above_model" || events[0].Count != 3 { + t.Errorf("expected one above_model event with count=3, got %+v", events) + } +} + +func TestNoTerminatorStopsAtStepCap(t *testing.T) { + // A stage that ascends forever with no constraint must not loop endlessly; + // the integrator's step backstop stops it and records a max_steps event. + sink := NewEventSink() + prof := Profile{ + Stages: []*Propagator{{Name: "runaway", Step: 60, Model: ConstantRate(5)}}, + Direction: Forward, + } + results := prof.Run(0, State{}, sink) + + if results[0].Outcome != OutcomeContinued { + t.Errorf("outcome = %v, want OutcomeContinued (step cap)", results[0].Outcome) + } + if results[0].Path.Len() != DefaultMaxSteps+1 { + t.Errorf("path len = %d, want %d", results[0].Path.Len(), DefaultMaxSteps+1) + } + ev := sink.Snapshot() + if len(ev) != 1 || ev[0].Type != "max_steps" { + t.Errorf("expected a max_steps event, got %+v", ev) + } +} + +func TestPolygonInside(t *testing.T) { + // Unit square at the equator. + square := []PolygonVertex{ + {Lat: -1, Lng: -1}, + {Lat: -1, Lng: 1}, + {Lat: 1, Lng: 1}, + {Lat: 1, Lng: -1}, + } + c := NewPolygon(square, PolygonInside, ActionStop, "") + if !c.Violated(0, State{Lat: 0, Lng: 0}) { + t.Errorf("origin should be inside the square") + } + if c.Violated(0, State{Lat: 5, Lng: 0}) { + t.Errorf("(5, 0) should be outside the square") + } +} + +func TestPolygonOutsideAntimeridian(t *testing.T) { + // A polygon centred near the antimeridian, spanning lng 170..-170 + // (i.e. lng 170..190 in [0, 360) form). + poly := []PolygonVertex{ + {Lat: -10, Lng: 170}, + {Lat: -10, Lng: 190}, + {Lat: 10, Lng: 190}, + {Lat: 10, Lng: 170}, + } + c := NewPolygon(poly, PolygonInside, ActionStop, "") + // A point at the antimeridian. + if !c.Violated(0, State{Lat: 0, Lng: 180}) { + t.Errorf("(0, 180) should be inside the antimeridian polygon") + } + if c.Violated(0, State{Lat: 0, Lng: 0}) { + t.Errorf("(0, 0) should be outside") + } +} diff --git a/internal/engine/events.go b/internal/engine/events.go new file mode 100644 index 0000000..7fde684 --- /dev/null +++ b/internal/engine/events.go @@ -0,0 +1,89 @@ +package engine + +import "sync" + +// Event is a non-fatal observation made during integration. +// +// Events generalise the warnings counter from the original Tawhiri port: +// any model or constraint can emit them, the EventSink aggregates by Type, +// and each Result carries a summary slice for the API to surface. +type Event struct { + Type string // short identifier, e.g. "above_model" + Time float64 // UNIX seconds when the event was emitted + State State + Message string +} + +// EventSummary is the per-type aggregation of repeated emissions. +type EventSummary struct { + Type string `json:"type"` + Count int64 `json:"count"` + FirstTime float64 `json:"first_time"` + LastTime float64 `json:"last_time"` + FirstState State `json:"first_state"` + LastState State `json:"last_state"` + Message string `json:"message"` +} + +// EventSink collects events from models and the integrator, aggregating +// duplicate types into a single EventSummary. Safe for concurrent use. +type EventSink struct { + mu sync.Mutex + summaries map[string]*EventSummary +} + +// NewEventSink returns an empty sink. +func NewEventSink() *EventSink { return &EventSink{summaries: make(map[string]*EventSummary)} } + +// Emit records one occurrence of typ at (t, s) with the provided message. +// Subsequent emits with the same typ update LastTime/LastState and Count. +func (s *EventSink) Emit(typ string, t float64, state State, message string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + sum, ok := s.summaries[typ] + if !ok { + s.summaries[typ] = &EventSummary{ + Type: typ, Count: 1, + FirstTime: t, LastTime: t, + FirstState: state, LastState: state, + Message: message, + } + return + } + sum.Count++ + sum.LastTime = t + sum.LastState = state + if sum.Message == "" && message != "" { + sum.Message = message + } +} + +// Snapshot returns a stable copy of every summary in deterministic order +// (sorted by Type). +func (s *EventSink) Snapshot() []EventSummary { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]EventSummary, 0, len(s.summaries)) + for _, sum := range s.summaries { + out = append(out, *sum) + } + sortEventSummaries(out) + return out +} + +func sortEventSummaries(s []EventSummary) { + // Insertion sort: usually one or two entries. + for i := 1; i < len(s); i++ { + j := i + for j > 0 && s[j-1].Type > s[j].Type { + s[j-1], s[j] = s[j], s[j-1] + j-- + } + } +} diff --git a/internal/engine/models.go b/internal/engine/models.go new file mode 100644 index 0000000..9a738d8 --- /dev/null +++ b/internal/engine/models.go @@ -0,0 +1,96 @@ +package engine + +import ( + "sort" + + "predictor-refactored/internal/numerics" + "predictor-refactored/internal/weather" +) + +// Sum composes models by summing their derivatives at each evaluation point. +// +// Useful for combining a vertical-rate model with a horizontal wind model +// into a single propagator. Equivalent to Tawhiri's LinearModel. +func Sum(models ...Model) Model { + if len(models) == 1 { + return models[0] + } + return func(t float64, s State) State { + var sum State + for _, m := range models { + sum = numerics.AddGeo(sum, m(t, s)) + } + return sum + } +} + +// ConstantRate returns a model with a constant vertical velocity (m/s). +// Positive rates are upward. +func ConstantRate(rate float64) Model { + return func(_ float64, _ State) State { return State{Altitude: rate} } +} + +// ParachuteDescent returns a model where vertical velocity grows with +// altitude because thinner air provides less drag. seaLevelRate is the +// descent speed at sea level (m/s, positive). +// +// Terminal velocity at altitude is computed as +// +// v = -k / sqrt(rho(alt)), k = seaLevelRate * 1.1045, +// +// using the NASA atmosphere model for rho. Equivalent to Tawhiri's drag_descent. +func ParachuteDescent(seaLevelRate float64) Model { + return func(_ float64, s State) State { + return State{Altitude: numerics.DragTerminalVelocity(seaLevelRate, s.Altitude)} + } +} + +// RateSegment is one entry in a Piecewise rate schedule. Until is the UNIX +// timestamp at which this segment ends — the model emits the segment's +// Rate for all t < Until. The final segment's Rate is held indefinitely. +type RateSegment struct { + Until float64 + Rate float64 +} + +// Piecewise returns a model that produces a piecewise-constant vertical +// rate over a sequence of intervals. The input is sorted ascending by +// Until on construction; later segments shadow earlier ones. +func Piecewise(segments []RateSegment) Model { + if len(segments) == 0 { + return ConstantRate(0) + } + sorted := append([]RateSegment(nil), segments...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Until < sorted[j].Until }) + finalRate := sorted[len(sorted)-1].Rate + + return func(t float64, _ State) State { + idx := sort.Search(len(sorted), func(i int) bool { return sorted[i].Until > t }) + if idx == len(sorted) { + return State{Altitude: finalRate} + } + return State{Altitude: sorted[idx].Rate} + } +} + +// WindTransport returns a model that moves laterally at the wind velocity +// sampled from field. The vertical component is zero. Sampling and the +// non-fatal "above_model" event live here (orchestration); the m/s → deg/s +// conversion is numerics.WindToGeoRate. +// +// If events is non-nil, an "above_model" event is emitted whenever the +// wind field reports altitude above the highest pressure level. +func WindTransport(field weather.WindField, events *EventSink) Model { + return func(t float64, s State) State { + sample, err := field.Wind(t, s.Lat, s.Lng, s.Altitude) + if err != nil { + return State{} + } + if sample.AboveModel && events != nil { + events.Emit("above_model", t, s, + "altitude exceeded the highest pressure level of the wind dataset; samples extrapolated") + } + dLat, dLng := numerics.WindToGeoRate(sample.U, sample.V, s.Lat, s.Altitude) + return State{Lat: dLat, Lng: dLng} + } +} diff --git a/internal/engine/operators.go b/internal/engine/operators.go new file mode 100644 index 0000000..13b3a6d --- /dev/null +++ b/internal/engine/operators.go @@ -0,0 +1,69 @@ +package engine + +import "fmt" + +// Operator is a scalar comparison used by generalised constraints like +// Altitude and Time. A constraint fires when its Operator.Test(value, limit) +// returns true. +type Operator int + +const ( + OpLess Operator = iota // value < limit + OpLessEqual // value ≤ limit + OpGreater // value > limit + OpGreaterEqual // value ≥ limit + OpEqual // value == limit +) + +// Test evaluates op(value, limit). +func (o Operator) Test(value, limit float64) bool { + switch o { + case OpLess: + return value < limit + case OpLessEqual: + return value <= limit + case OpGreater: + return value > limit + case OpGreaterEqual: + return value >= limit + case OpEqual: + return value == limit + } + return false +} + +// String returns the symbol "<", "<=", ">", ">=", "==". +func (o Operator) String() string { + switch o { + case OpLess: + return "<" + case OpLessEqual: + return "<=" + case OpGreater: + return ">" + case OpGreaterEqual: + return ">=" + case OpEqual: + return "==" + } + return "?" +} + +// ParseOperator maps a textual operator to its Operator constant. +// Accepts "<", "<=", "le", ">", ">=", "ge", "==", "eq". +func ParseOperator(s string) (Operator, error) { + switch s { + case "<", "lt": + return OpLess, nil + case "<=", "le": + return OpLessEqual, nil + case ">", "gt": + return OpGreater, nil + case ">=", "ge": + return OpGreaterEqual, nil + case "==", "eq": + return OpEqual, nil + default: + return 0, fmt.Errorf("unknown operator %q", s) + } +} diff --git a/internal/engine/profile.go b/internal/engine/profile.go new file mode 100644 index 0000000..386d337 --- /dev/null +++ b/internal/engine/profile.go @@ -0,0 +1,59 @@ +package engine + +// Profile is an ordered chain of propagators executed sequentially. Each +// propagator picks up where the previous one finished. +type Profile struct { + // Stages are run in order. For Direction=Reverse they are still + // iterated from index 0 onwards but each propagator integrates with + // negative dt. + Stages []*Propagator + + // Direction controls the sign of dt across the profile. + Direction Direction + + // Globals are constraints evaluated alongside each stage's local + // Constraints. Useful for profile-wide bounds like "stop after N hours". + Globals []Constraint +} + +// Run executes the profile from the given launch point. Returns one +// Result per executed stage, including any Fallback chains that were +// activated. The supplied EventSink is shared across stages and aggregates +// non-fatal observations. +// +// events may be nil; pass NewEventSink() to capture observations. +func (p *Profile) Run(t0 float64, launch State, events *EventSink) []Result { + if p.Direction == 0 { + p.Direction = Forward + } + + results := make([]Result, 0, len(p.Stages)) + t, s := t0, launch + + for _, stage := range p.Stages { + res := stage.run(p.context(t0, t, launch, s), t, s, p.Globals, events) + results = append(results, res) + t, s = res.Path.Last() + + // Follow Fallback chains until none remains. + for res.Outcome == OutcomeFallback && stage.Fallback != nil { + stage = stage.Fallback + res = stage.run(p.context(t0, t, launch, s), t, s, p.Globals, events) + results = append(results, res) + t, s = res.Path.Last() + } + } + + return results +} + +// context builds the StageContext for a stage starting at (tStart, sStart). +func (p *Profile) context(t0, tStart float64, launch, sStart State) StageContext { + return StageContext{ + ProfileStart: t0, + PropagatorStart: tStart, + Launch: launch, + PropagatorState: sStart, + Direction: p.Direction, + } +} diff --git a/internal/engine/propagator.go b/internal/engine/propagator.go new file mode 100644 index 0000000..19b080e --- /dev/null +++ b/internal/engine/propagator.go @@ -0,0 +1,147 @@ +package engine + +import "predictor-refactored/internal/numerics" + +// Propagator advances state under one Model, checking a set of Constraints +// after every integration step. +// +// When a constraint fires, the propagator binary-search refines the +// violation point and emits it as its final trajectory point. The Action of +// the triggering constraint controls what the surrounding Profile does +// next: stop the profile, transfer to Fallback, or clip and continue. +// +// The per-step numerics (RK4 stepping, crossing refinement) are delegated to +// the numerics package; this type owns only the orchestration: constraint +// evaluation, action dispatch, and trajectory assembly. +type Propagator struct { + // Name identifies the propagator in trajectory metadata. Optional. + Name string + + // Step is the magnitude of the integration step in seconds (always positive). + // The Profile flips its sign for Reverse direction. + Step float64 + + // Model is the per-second derivative function used for integration. + // One of Model or BuildModel must be non-nil. If both are set, BuildModel + // takes precedence (it is invoked once per stage with a StageContext). + Model Model + BuildModel func(ctx StageContext) Model + + // Constraints are evaluated after each step. The first violation wins. + Constraints []Constraint + BuildConstraints func(ctx StageContext) []Constraint + + // Fallback is the propagator to switch to when a constraint with + // ActionFallback fires. Optional. + Fallback *Propagator + + // Tolerance is the binary-search refinement tolerance in parameter + // space (default 0.01, matching Tawhiri). + Tolerance float64 +} + +// estimatedSteps is the initial Path capacity; a typical balloon stage is a +// few hundred 60-second steps. +const estimatedSteps = 256 + +// DefaultMaxSteps bounds the number of integration steps a single propagator +// may take. It is a safety backstop, not a physical limit: a profile whose +// constraints never fire (e.g. a stage with no effective terminator) would +// otherwise integrate forever and exhaust memory. At the default 60-second +// step this allows ~8 simulated years, far beyond any real flight, so it only +// ever trips on a misconfigured profile. +const DefaultMaxSteps = 1_000_000 + +// run integrates the model from (t0, s0) in direction dir, returning a Result. +// globals are constraints injected by the Profile and checked alongside the +// propagator's local Constraints. events receives non-fatal observations. +func (p *Propagator) run(ctx StageContext, t0 float64, s0 State, globals []Constraint, events *EventSink) Result { + dt := p.Step * float64(ctx.Direction) + tol := p.Tolerance + if tol == 0 { + tol = 0.01 + } + + model := p.Model + if p.BuildModel != nil { + model = p.BuildModel(ctx) + } + constraints := p.Constraints + if p.BuildConstraints != nil { + constraints = p.BuildConstraints(ctx) + } + + field := numerics.Field(model) + + out := Result{Propagator: p.Name, Outcome: OutcomeContinued, Path: numerics.NewPath(estimatedSteps)} + out.Path.Append(t0, s0) + + t, s := t0, s0 + for range DefaultMaxSteps { + s2 := numerics.RK4Step(t, s, dt, field) + t2 := t + dt + + c, fired := firstFiring(constraints, globals, t2, s2) + if !fired { + t, s = t2, s2 + out.Path.Append(t, s) + continue + } + + out.ViolationTime, out.ViolationState = t2, s2 + t3, s3 := numerics.RefineCrossing(t, s, t2, s2, c.Violated, tol) + out.Constraint, out.ConstraintName = c, c.Name() + + if c.Action() == ActionClip { + s3 = clipToConstraint(c, s3) + out.RefinedTime, out.RefinedState = t3, s3 + out.Path.Append(t3, s3) + t, s = t3, s3 + continue + } + + out.RefinedTime, out.RefinedState = t3, s3 + out.Path.Append(t3, s3) + if c.Action() == ActionFallback { + out.Outcome = OutcomeFallback + } else { + out.Outcome = OutcomeStopped + } + out.Events = events.Snapshot() + return out + } + + // Step cap reached without any constraint firing — the profile has no + // effective terminator for this stage. Stop safely rather than loop forever. + events.Emit("max_steps", t, s, + "integration step limit reached without a constraint firing; check the stage's terminator") + out.Outcome = OutcomeContinued + out.Events = events.Snapshot() + return out +} + +// firstFiring scans local then global constraints for the first one whose +// Violated returns true at (t, s). +func firstFiring(local, globals []Constraint, t float64, s State) (Constraint, bool) { + for _, c := range local { + if c.Violated(t, s) { + return c, true + } + } + for _, c := range globals { + if c.Violated(t, s) { + return c, true + } + } + return nil, false +} + +// clipToConstraint adjusts s so the given constraint is exactly satisfied. +// Defined only for constraints with a well-defined coordinate boundary; +// others fall through unchanged. +func clipToConstraint(c Constraint, s State) State { + if alt, ok := c.(Altitude); ok { + s.Altitude = alt.Limit + } + return s +} diff --git a/internal/engine/registry.go b/internal/engine/registry.go new file mode 100644 index 0000000..27a7a9f --- /dev/null +++ b/internal/engine/registry.go @@ -0,0 +1,278 @@ +package engine + +import ( + "fmt" + "sync" + + "predictor-refactored/internal/weather" +) + +// ConstraintSpec is the source-agnostic JSON-shape used to declare a +// constraint. The Type field is the registry key; remaining fields are +// extracted by the registered factory. +type ConstraintSpec struct { + Type string `json:"type"` + Action string `json:"action,omitempty"` + // Op is the comparison operator for scalar constraints (altitude, time). + Op string `json:"op,omitempty"` + Limit float64 `json:"limit,omitempty"` + // Vertices and Mode are used by the polygon constraint. + Vertices []PolygonVertex `json:"vertices,omitempty"` + Mode string `json:"mode,omitempty"` + // Label is an optional human-readable identifier surfaced via Name(). + Label string `json:"label,omitempty"` +} + +// ModelSpec is the source-agnostic JSON shape used to declare a model. +type ModelSpec struct { + Type string `json:"type"` + // Rate (m/s) for constant_rate. + Rate float64 `json:"rate,omitempty"` + // SeaLevelRate (m/s, positive) for parachute_descent. + SeaLevelRate float64 `json:"sea_level_rate,omitempty"` + // Segments for piecewise. + Segments []PiecewiseSegmentSpec `json:"segments,omitempty"` + // IncludeWind sums a WindTransport model into the resulting derivative. + IncludeWind bool `json:"include_wind,omitempty"` +} + +// PiecewiseSegmentSpec is one entry in a piecewise rate schedule. +// +// Reference selects how the Until field is interpreted: +// +// - "absolute" (default): UNIX seconds. +// - "profile_start": seconds since the profile's launch time. +// - "propagator_start": seconds since this propagator began running. +type PiecewiseSegmentSpec struct { + Until float64 `json:"until"` + Rate float64 `json:"rate"` + Reference string `json:"reference,omitempty"` +} + +// BuildDeps bundle the runtime dependencies factories may consult. +type BuildDeps struct { + Wind weather.WindField + Terrain TerrainProvider + Events *EventSink +} + +// ConstraintFactory builds one Constraint from a spec. +type ConstraintFactory func(spec ConstraintSpec, deps BuildDeps) (Constraint, error) + +// ModelFactory builds one model from a spec. The returned Built is held by +// a Propagator; if Build is set, it is invoked lazily by the profile +// runner before every stage so it can capture per-stage start times. +type ModelFactory func(spec ModelSpec, deps BuildDeps) (BuiltModel, error) + +// BuiltModel is either an eager Model, a lazy Build, or both. The profile +// runner prefers Build when present. +type BuiltModel struct { + Model Model + Build func(ctx StageContext) Model +} + +var ( + regMu sync.RWMutex + constraintFactories = map[string]ConstraintFactory{} + modelFactories = map[string]ModelFactory{} +) + +// RegisterConstraint installs a factory for typeName. Subsequent calls +// overwrite the previous factory. +func RegisterConstraint(typeName string, f ConstraintFactory) { + regMu.Lock() + defer regMu.Unlock() + constraintFactories[typeName] = f +} + +// RegisterModel installs a model factory. +func RegisterModel(typeName string, f ModelFactory) { + regMu.Lock() + defer regMu.Unlock() + modelFactories[typeName] = f +} + +// BuildConstraint dispatches spec to its registered factory. +func BuildConstraint(spec ConstraintSpec, deps BuildDeps) (Constraint, error) { + regMu.RLock() + f, ok := constraintFactories[spec.Type] + regMu.RUnlock() + if !ok { + return nil, fmt.Errorf("unknown constraint type %q", spec.Type) + } + return f(spec, deps) +} + +// BuildModel dispatches spec to its registered factory. +func BuildModel(spec ModelSpec, deps BuildDeps) (BuiltModel, error) { + regMu.RLock() + f, ok := modelFactories[spec.Type] + regMu.RUnlock() + if !ok { + return BuiltModel{}, fmt.Errorf("unknown model type %q", spec.Type) + } + return f(spec, deps) +} + +// RegisteredConstraints returns the names of every registered constraint type. +func RegisteredConstraints() []string { + regMu.RLock() + defer regMu.RUnlock() + out := make([]string, 0, len(constraintFactories)) + for k := range constraintFactories { + out = append(out, k) + } + return out +} + +// RegisteredModels returns the names of every registered model type. +func RegisteredModels() []string { + regMu.RLock() + defer regMu.RUnlock() + out := make([]string, 0, len(modelFactories)) + for k := range modelFactories { + out = append(out, k) + } + return out +} + +// --- Built-in registrations ------------------------------------------------ + +func init() { + RegisterConstraint("altitude", buildAltitude) + RegisterConstraint("time", buildTime) + RegisterConstraint("terrain_contact", buildTerrainContact) + RegisterConstraint("polygon", buildPolygon) + + RegisterModel("constant_rate", buildConstantRate) + RegisterModel("parachute_descent", buildParachuteDescent) + RegisterModel("piecewise", buildPiecewise) + RegisterModel("wind", buildWind) +} + +func buildAltitude(spec ConstraintSpec, _ BuildDeps) (Constraint, error) { + op, err := ParseOperator(spec.Op) + if err != nil { + return nil, fmt.Errorf("altitude: %w", err) + } + act, err := ParseAction(spec.Action) + if err != nil { + return nil, fmt.Errorf("altitude: %w", err) + } + return Altitude{Op: op, Limit: spec.Limit, On: act}, nil +} + +func buildTime(spec ConstraintSpec, _ BuildDeps) (Constraint, error) { + op, err := ParseOperator(spec.Op) + if err != nil { + return nil, fmt.Errorf("time: %w", err) + } + act, err := ParseAction(spec.Action) + if err != nil { + return nil, fmt.Errorf("time: %w", err) + } + return Time{Op: op, Limit: spec.Limit, On: act}, nil +} + +func buildTerrainContact(spec ConstraintSpec, deps BuildDeps) (Constraint, error) { + if deps.Terrain == nil { + return nil, fmt.Errorf("terrain_contact requires a terrain provider") + } + act, err := ParseAction(spec.Action) + if err != nil { + return nil, fmt.Errorf("terrain_contact: %w", err) + } + return TerrainContact{Provider: deps.Terrain, On: act}, nil +} + +func buildPolygon(spec ConstraintSpec, _ BuildDeps) (Constraint, error) { + if len(spec.Vertices) < 3 { + return nil, fmt.Errorf("polygon requires at least 3 vertices") + } + act, err := ParseAction(spec.Action) + if err != nil { + return nil, fmt.Errorf("polygon: %w", err) + } + mode := PolygonInside + switch spec.Mode { + case "", "inside": + mode = PolygonInside + case "outside": + mode = PolygonOutside + default: + return nil, fmt.Errorf("polygon: unknown mode %q", spec.Mode) + } + return NewPolygon(spec.Vertices, mode, act, spec.Label), nil +} + +func buildConstantRate(spec ModelSpec, _ BuildDeps) (BuiltModel, error) { + return BuiltModel{Model: ConstantRate(spec.Rate)}, nil +} + +func buildParachuteDescent(spec ModelSpec, _ BuildDeps) (BuiltModel, error) { + if spec.SeaLevelRate <= 0 { + return BuiltModel{}, fmt.Errorf("parachute_descent requires positive sea_level_rate") + } + return BuiltModel{Model: ParachuteDescent(spec.SeaLevelRate)}, nil +} + +func buildWind(_ ModelSpec, deps BuildDeps) (BuiltModel, error) { + if deps.Wind == nil { + return BuiltModel{}, fmt.Errorf("wind model requires a loaded wind field") + } + return BuiltModel{Model: WindTransport(deps.Wind, deps.Events)}, nil +} + +func buildPiecewise(spec ModelSpec, deps BuildDeps) (BuiltModel, error) { + for _, s := range spec.Segments { + switch s.Reference { + case "", "absolute", "profile_start", "propagator_start": + default: + return BuiltModel{}, fmt.Errorf("piecewise: unknown segment reference %q", s.Reference) + } + } + // Always build lazily: the profile runner supplies a StageContext before + // each stage, which is what resolves absolute / profile-relative / + // propagator-relative segment times uniformly. + return BuiltModel{ + Build: func(ctx StageContext) Model { + return maybeAddWind(Piecewise(resolveSegments(spec.Segments, ctx)), spec.IncludeWind, deps) + }, + }, nil +} + +// resolveSegments converts spec segments to engine.RateSegment, turning each +// segment's reference-relative Until into an absolute UNIX time. References +// are validated by buildPiecewise, so an unrecognised one here is treated as +// absolute rather than re-erroring. +func resolveSegments(in []PiecewiseSegmentSpec, ctx StageContext) []RateSegment { + out := make([]RateSegment, 0, len(in)) + for _, s := range in { + out = append(out, RateSegment{Until: segmentBase(s.Reference, ctx) + s.Until, Rate: s.Rate}) + } + return out +} + +// segmentBase returns the absolute time a piecewise segment's Until is +// measured from, per its reference. +func segmentBase(reference string, ctx StageContext) float64 { + switch reference { + case "profile_start": + return ctx.ProfileStart + case "propagator_start": + return ctx.PropagatorStart + default: // "", "absolute" + return 0 + } +} + +// maybeAddWind sums a WindTransport model into base when the spec asks for it. +func maybeAddWind(base Model, includeWind bool, deps BuildDeps) Model { + if !includeWind { + return base + } + if deps.Wind == nil { + return base + } + return Sum(base, WindTransport(deps.Wind, deps.Events)) +} diff --git a/internal/engine/types.go b/internal/engine/types.go new file mode 100644 index 0000000..6050c97 --- /dev/null +++ b/internal/engine/types.go @@ -0,0 +1,155 @@ +// Package engine is the trajectory calculation engine. It composes +// propagators (model-driven integrators) into profiles (ordered chains) +// over a wind field. +// +// The engine orchestrates the calculation; the numerically heavy work +// (RK4 stepping, crossing refinement, interpolation, atmosphere density, +// vector and polygon math) lives in the numerics package so it can be +// reimplemented in a faster language without touching this layer. +// +// The engine has no direct dependency on any specific data source: wind +// data is consumed through weather.WindField and terrain data through +// any type satisfying TerrainProvider. +package engine + +import "predictor-refactored/internal/numerics" + +// State is the spatial state of the balloon: latitude/longitude in degrees, +// altitude in metres. When returned by a Model the same struct is the +// per-second derivative. It is an alias of numerics.GeoVec so the engine and +// the numeric core share one hot-path value type without conversions. +type State = numerics.GeoVec + +// Model returns the time derivative of state at (t, s). +// +// The derivative is direction-independent; the integrator applies the +// sign of dt for reverse propagation. +type Model func(t float64, s State) State + +// Direction is the time direction of integration. +type Direction int8 + +const ( + Forward Direction = +1 + Reverse Direction = -1 +) + +// Action is what the profile runner does on a constraint violation. +type Action int + +const ( + // ActionStop ends the current propagator at the refined violation point. + ActionStop Action = iota + // ActionFallback ends the current propagator and starts its Fallback + // propagator from the refined violation point. + ActionFallback + // ActionClip clips the violated coordinate to the boundary and continues + // integration. + ActionClip +) + +// ParseAction maps "stop" | "fallback" | "clip" to an Action. +func ParseAction(s string) (Action, error) { + switch s { + case "", "stop": + return ActionStop, nil + case "fallback": + return ActionFallback, nil + case "clip": + return ActionClip, nil + default: + return 0, errUnknownAction(s) + } +} + +type errUnknownAction string + +func (e errUnknownAction) Error() string { return "unknown constraint action " + string(e) } + +// Constraint defines a stopping, branching, or clipping condition. +type Constraint interface { + // Name identifies the constraint in logs and result metadata. + Name() string + // Violated reports whether the constraint is breached at (t, s). + Violated(t float64, s State) bool + // Action is the behaviour to take on violation. + Action() Action +} + +// TerrainProvider returns ground elevation in metres at a coordinate. +type TerrainProvider interface { + Elevation(lat, lng float64) float64 +} + +// StageContext is provided to Propagator.BuildModel and BuildConstraints by +// the profile runner immediately before each stage executes. +type StageContext struct { + // ProfileStart is the UNIX timestamp of the profile's initial launch. + ProfileStart float64 + // PropagatorStart is the UNIX timestamp at which this propagator begins + // running — equal to ProfileStart for the first stage; the end-time of + // the previous stage thereafter. + PropagatorStart float64 + // Launch is the profile's initial state. + Launch State + // PropagatorState is the state at which this propagator begins. + PropagatorState State + // Direction is the integration direction the profile is configured with. + Direction Direction +} + +// Outcome describes how a propagator's run ended. +type Outcome int + +const ( + // OutcomeStopped means a Constraint with ActionStop fired. + OutcomeStopped Outcome = iota + // OutcomeFallback means a Constraint with ActionFallback fired. + OutcomeFallback + // OutcomeContinued means the propagator finished without a constraint + // firing — only seen when a propagator is misconfigured to run unbounded. + OutcomeContinued +) + +// String renders the outcome as a stable string for API serialisation. +func (o Outcome) String() string { + switch o { + case OutcomeStopped: + return "stopped" + case OutcomeFallback: + return "fallback" + default: + return "continued" + } +} + +// Result is the output of running one propagator. +type Result struct { + // Propagator is the propagator's Name. + Propagator string + + // Path is the emitted trajectory in struct-of-arrays form. + Path numerics.Path + + // Outcome describes how the propagator terminated. + Outcome Outcome + + // Constraint is the constraint that fired, or nil if Outcome is OutcomeContinued. + Constraint Constraint + // ConstraintName captures Constraint.Name() at fire time so callers can + // serialise the result after the Constraint has been garbage collected. + ConstraintName string + + // ViolationTime / ViolationState describe the first integration step at + // which the constraint reported a violation, before binary-search refinement. + ViolationTime float64 + ViolationState State + + // RefinedTime / RefinedState describe the refined violation point that + // appears as the propagator's last trajectory point. + RefinedTime float64 + RefinedState State + + // Events is the aggregated set of non-fatal observations from this stage. + Events []EventSummary +} diff --git a/internal/metrics/prom.go b/internal/metrics/prom.go new file mode 100644 index 0000000..395205e --- /dev/null +++ b/internal/metrics/prom.go @@ -0,0 +1,146 @@ +package metrics + +import ( + "fmt" + "io" + "net/http" + "sort" + "strings" + "sync" + "time" +) + +// Prom is a minimal Sink that exposes counters and gauges in Prometheus's +// text exposition format. No external dependencies. +// +// The Prom sink supports labelled counters, sums (for durations and byte +// counts), and labelled gauges. Histograms are intentionally omitted; if +// they are needed later, swap Prom for an OTel-based sink. +type Prom struct { + mu sync.Mutex + counters map[string]map[string]float64 // name → label-key → value + gauges map[string]map[string]float64 // name → label-key → value +} + +// NewProm returns an empty Prom sink. +func NewProm() *Prom { + return &Prom{ + counters: make(map[string]map[string]float64), + gauges: make(map[string]map[string]float64), + } +} + +// Prediction implements Sink. +func (p *Prom) Prediction(profile string, d time.Duration, err error) { + status := "ok" + if err != nil { + status = "error" + } + labels := map[string]string{"profile": profile, "status": status} + p.incCounter("predictor_predictions_total", labels, 1) + p.incCounter("predictor_prediction_duration_seconds_sum", labels, d.Seconds()) + p.incCounter("predictor_prediction_duration_seconds_count", labels, 1) +} + +// Download implements Sink. +func (p *Prom) Download(source string, d time.Duration, status string, bytes int64) { + labels := map[string]string{"source": source, "status": status} + p.incCounter("predictor_downloads_total", labels, 1) + p.incCounter("predictor_download_duration_seconds_sum", labels, d.Seconds()) + p.incCounter("predictor_download_bytes_total", map[string]string{"source": source}, float64(bytes)) +} + +// ActiveEpoch implements Sink. +func (p *Prom) ActiveEpoch(t time.Time) { + var v float64 + if !t.IsZero() { + v = float64(t.Unix()) + } + p.setGauge("predictor_active_dataset_epoch_seconds", map[string]string{}, v) +} + +// ServeHTTP writes the metrics in Prometheus text exposition format. +func (p *Prom) ServeHTTP(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + p.Write(w) +} + +// Write writes the metrics in Prometheus exposition format to w. +func (p *Prom) Write(w io.Writer) { + p.mu.Lock() + defer p.mu.Unlock() + + names := make([]string, 0, len(p.counters)+len(p.gauges)) + for n := range p.counters { + names = append(names, n) + } + for n := range p.gauges { + names = append(names, n) + } + sort.Strings(names) + + for _, name := range names { + if labels, ok := p.counters[name]; ok { + fmt.Fprintf(w, "# TYPE %s counter\n", name) + writeMetricFamily(w, name, labels) + } + if labels, ok := p.gauges[name]; ok { + fmt.Fprintf(w, "# TYPE %s gauge\n", name) + writeMetricFamily(w, name, labels) + } + } +} + +func writeMetricFamily(w io.Writer, name string, labels map[string]float64) { + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + fmt.Fprintf(w, "%s%s %g\n", name, key, labels[key]) + } +} + +func (p *Prom) incCounter(name string, labels map[string]string, n float64) { + key := labelKey(labels) + p.mu.Lock() + defer p.mu.Unlock() + if p.counters[name] == nil { + p.counters[name] = make(map[string]float64) + } + p.counters[name][key] += n +} + +func (p *Prom) setGauge(name string, labels map[string]string, v float64) { + key := labelKey(labels) + p.mu.Lock() + defer p.mu.Unlock() + if p.gauges[name] == nil { + p.gauges[name] = make(map[string]float64) + } + p.gauges[name][key] = v +} + +// labelKey renders the labels into a Prometheus-format "{k1="v1",k2="v2"}" +// suffix, empty if no labels. +func labelKey(labels map[string]string) string { + if len(labels) == 0 { + return "" + } + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + var sb strings.Builder + sb.WriteByte('{') + for i, k := range keys { + if i > 0 { + sb.WriteByte(',') + } + fmt.Fprintf(&sb, "%s=%q", k, labels[k]) + } + sb.WriteByte('}') + return sb.String() +} diff --git a/internal/metrics/prom_test.go b/internal/metrics/prom_test.go new file mode 100644 index 0000000..4bcce14 --- /dev/null +++ b/internal/metrics/prom_test.go @@ -0,0 +1,49 @@ +package metrics + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func TestPromCounters(t *testing.T) { + p := NewProm() + p.Prediction("standard_profile", 100*time.Millisecond, nil) + p.Prediction("standard_profile", 200*time.Millisecond, nil) + p.Prediction("float_profile", 50*time.Millisecond, nil) + + var buf bytes.Buffer + p.Write(&buf) + out := buf.String() + + if !strings.Contains(out, `predictor_predictions_total{profile="standard_profile",status="ok"} 2`) { + t.Errorf("expected count=2 for standard_profile, got: %s", out) + } + if !strings.Contains(out, `predictor_predictions_total{profile="float_profile",status="ok"} 1`) { + t.Errorf("expected count=1 for float_profile, got: %s", out) + } + // Sum of durations: 0.1 + 0.2 = 0.3 seconds. + if !strings.Contains(out, "predictor_prediction_duration_seconds_sum") { + t.Errorf("expected sum present, got: %s", out) + } +} + +func TestPromGauge(t *testing.T) { + p := NewProm() + p.ActiveEpoch(time.Unix(1700000000, 0)) + + var buf bytes.Buffer + p.Write(&buf) + out := buf.String() + if !strings.Contains(out, "predictor_active_dataset_epoch_seconds 1.7e+09") { + t.Errorf("expected gauge with epoch 1700000000, got: %s", out) + } +} + +func TestNoop(t *testing.T) { + sink := Noop() + sink.Prediction("any", time.Second, nil) + sink.Download("any", time.Second, "complete", 0) + sink.ActiveEpoch(time.Now()) +} diff --git a/internal/metrics/types.go b/internal/metrics/types.go new file mode 100644 index 0000000..64b7903 --- /dev/null +++ b/internal/metrics/types.go @@ -0,0 +1,36 @@ +// Package metrics defines the Sink interface used to record service metrics +// and ships two implementations: a Noop sink (default, zero-cost) and a Prom +// sink that exposes counters in the Prometheus text exposition format. +// +// The metrics layer is optional: if no Sink is wired (or Noop is wired), the +// service runs unchanged. +package metrics + +import "time" + +// Sink collects observations from the rest of the service. +// +// Implementations must be safe for concurrent use across many goroutines. +// All methods are advisory; implementations may ignore any observation. +type Sink interface { + // Prediction records the duration and outcome of one prediction. + // err is nil on success; otherwise the error's class is used as a label. + Prediction(profile string, duration time.Duration, err error) + + // Download records the outcome of one dataset download job. + // status is "complete", "failed", or "cancelled". + Download(source string, duration time.Duration, status string, bytes int64) + + // ActiveEpoch reports the forecast time of the currently-loaded dataset. + // Pass time.Time{} when no dataset is loaded. + ActiveEpoch(t time.Time) +} + +// Noop returns a Sink that discards every observation. +func Noop() Sink { return noop{} } + +type noop struct{} + +func (noop) Prediction(string, time.Duration, error) {} +func (noop) Download(string, time.Duration, string, int64) {} +func (noop) ActiveEpoch(time.Time) {} diff --git a/internal/numerics/atmosphere.go b/internal/numerics/atmosphere.go new file mode 100644 index 0000000..9002360 --- /dev/null +++ b/internal/numerics/atmosphere.go @@ -0,0 +1,38 @@ +package numerics + +import "math" + +// NasaDensity returns air density in kg/m^3 at the given altitude in metres, +// using the NASA piecewise standard-atmosphere model. +// See https://www.grc.nasa.gov/WWW/K-12/airplane/atmosmet.html. +// +// The model is split into three altitude bands (troposphere, lower +// stratosphere, upper stratosphere); density is pressure / (0.2869 * T_K). +func NasaDensity(alt float64) float64 { + var temp, pressure float64 + switch { + case alt > 25000: + temp = -131.21 + 0.00299*alt + pressure = 2.488 * math.Pow((temp+273.1)/216.6, -11.388) + case alt > 11000: + temp = -56.46 + pressure = 22.65 * math.Exp(1.73-0.000157*alt) + default: + temp = 15.04 - 0.00649*alt + pressure = 101.29 * math.Pow((temp+273.1)/288.08, 5.256) + } + return pressure / (0.2869 * (temp + 273.1)) +} + +// DragTerminalVelocity returns the vertical velocity (m/s, negative = downward) +// of a parachute descent at the given altitude. seaLevelRate is the descent +// speed at sea level (positive m/s); the rate grows with altitude as the +// thinner air provides less drag: +// +// v = -k / sqrt(rho(alt)), k = seaLevelRate * 1.1045 +// +// Matches Tawhiri's drag_descent. +func DragTerminalVelocity(seaLevelRate, alt float64) float64 { + k := seaLevelRate * 1.1045 + return -k / math.Sqrt(NasaDensity(alt)) +} diff --git a/internal/numerics/doc.go b/internal/numerics/doc.go new file mode 100644 index 0000000..807ba3d --- /dev/null +++ b/internal/numerics/doc.go @@ -0,0 +1,11 @@ +// Package numerics provides the numerical primitives used by the trajectory +// engine: regular-grid multilinear interpolation, monotone bisection, and +// a generic explicit Runge-Kutta-4 integrator with binary-search refinement +// of a termination point. +// +// The package has no dependencies on any domain type. State and derivative +// types are generic, and all coordinate-wrap or unit-conversion semantics +// live in the caller. +// +// All algorithms are documented in docs/numerics.tex. +package numerics diff --git a/internal/numerics/geometry.go b/internal/numerics/geometry.go new file mode 100644 index 0000000..f5ae89f --- /dev/null +++ b/internal/numerics/geometry.go @@ -0,0 +1,41 @@ +package numerics + +import "math" + +// PointInPolygon reports whether (lat, lng) lies inside the closed polygon +// whose vertices are given as parallel latitude/longitude slices (degrees). +// +// The test is ray casting in plate-carrée space. Every longitude is +// normalised to within 180° of the first vertex before testing, so a polygon +// spanning the antimeridian is handled correctly as long as it spans no more +// than 180° in longitude. polyLat and polyLng must have equal length >= 3. +func PointInPolygon(lat, lng float64, polyLat, polyLng []float64) bool { + n := len(polyLat) + if n < 3 || len(polyLng) != n { + return false + } + ref := polyLng[0] + qx := NormalizeLng(lng, ref) + + inside := false + for i, j := 0, n-1; i < n; j, i = i, i+1 { + yi, yj := polyLat[i], polyLat[j] + xi := NormalizeLng(polyLng[i], ref) + xj := NormalizeLng(polyLng[j], ref) + + if (yi > lat) != (yj > lat) { + xIntersect := (xj-xi)*(lat-yi)/(yj-yi) + xi + if qx < xIntersect { + inside = !inside + } + } + } + return inside +} + +// NormalizeLng rewrites v so that it lies within 180° of ref. For example, +// NormalizeLng(350, 10) returns -10. Used to make longitude comparisons +// continuous across the antimeridian. +func NormalizeLng(v, ref float64) float64 { + return ref + math.Mod(v-ref+540, 360) - 180 +} diff --git a/internal/numerics/grid.go b/internal/numerics/grid.go new file mode 100644 index 0000000..720618d --- /dev/null +++ b/internal/numerics/grid.go @@ -0,0 +1,129 @@ +package numerics + +import "fmt" + +// Axis describes a regularly-spaced grid axis with N grid points, +// values left, left+step, left+2*step, ..., left+(N-1)*step. +// +// If Wrap is true, the axis is periodic with period N*step (e.g. longitude). +// A query value at left+N*step wraps to the value at left+0*step. Locate +// returns Hi = 0 in that case. +type Axis struct { + Left float64 + Step float64 + N int + Wrap bool + Name string +} + +// AxisError is returned by Axis.Locate when value lies outside a non-wrapping axis. +type AxisError struct { + Axis string + Value float64 +} + +func (e *AxisError) Error() string { + return fmt.Sprintf("%s=%v out of range", e.Axis, e.Value) +} + +// Bracket holds the two surrounding grid indices and the fractional position +// of a value within an axis. The weight at Lo is (1 - Frac); the weight at Hi +// is Frac. Frac lies in [0, 1). +type Bracket struct { + Lo, Hi int + Frac float64 +} + +// Locate returns the bracket containing value within the axis. +// For a non-wrapping axis, value must lie in [Left, Left + (N-1)*Step); +// for a wrapping axis, value must lie in [Left, Left + N*Step). +func (a Axis) Locate(value float64) (Bracket, error) { + pos := (value - a.Left) / a.Step + lo := int(pos) // truncates toward zero; pos is non-negative for valid inputs + + maxLo := a.N - 2 + if a.Wrap { + maxLo = a.N - 1 + } + if lo < 0 || lo > maxLo { + return Bracket{}, &AxisError{Axis: a.Name, Value: value} + } + + hi := lo + 1 + if a.Wrap && hi == a.N { + hi = 0 + } + return Bracket{Lo: lo, Hi: hi, Frac: pos - float64(lo)}, nil +} + +// TrilinearWeights returns the eight corner weights for a (axis0, axis1, +// axis2) bracket triple, in the canonical visiting order +// +// (0,0,0) (0,0,1) (0,1,0) (0,1,1) (1,0,0) (1,0,1) (1,1,0) (1,1,1) +// +// where the bit triple selects Lo (0) or Hi (1) on each axis. The weights sum +// to 1. Pair this with Dot8 over corner values fetched in the same order. +func TrilinearWeights(b3 [3]Bracket) [8]float64 { + wa0, wa1 := 1-b3[0].Frac, b3[0].Frac + wb0, wb1 := 1-b3[1].Frac, b3[1].Frac + wc0, wc1 := 1-b3[2].Frac, b3[2].Frac + + wa0wb0 := wa0 * wb0 + wa0wb1 := wa0 * wb1 + wa1wb0 := wa1 * wb0 + wa1wb1 := wa1 * wb1 + + return [8]float64{ + wa0wb0 * wc0, + wa0wb0 * wc1, + wa0wb1 * wc0, + wa0wb1 * wc1, + wa1wb0 * wc0, + wa1wb0 * wc1, + wa1wb1 * wc0, + wa1wb1 * wc1, + } +} + +// Dot8 returns the multiply-accumulate sum w[0]*v[0] + ... + w[7]*v[7]. +// +// The fixed length and straight-line accumulation are written so the Go +// compiler can keep the values in registers and a future hand-vectorised +// port can replace the body with a single SIMD MAC. The accumulation order +// is fixed (ascending index) so results are reproducible. +func Dot8(w, v *[8]float64) float64 { + acc := w[0] * v[0] + acc = w[1]*v[1] + acc + acc = w[2]*v[2] + acc + acc = w[3]*v[3] + acc + acc = w[4]*v[4] + acc + acc = w[5]*v[5] + acc + acc = w[6]*v[6] + acc + acc = w[7]*v[7] + acc + return acc +} + +// EvalTrilinear samples a 3D field via f at the eight corners defined by b3 +// and returns the trilinearly interpolated value. +// +// Corners are visited in the canonical order documented on TrilinearWeights. +// With f(i,j,k) = a*i + b*j + c*k + d this returns a*pos0 + b*pos1 + c*pos2 +// + d, modulo floating-point rounding. For the hot path prefer precomputing +// weights once via TrilinearWeights and reducing with Dot8. +func EvalTrilinear(b3 [3]Bracket, f func(i, j, k int) float64) float64 { + w := TrilinearWeights(b3) + a0, a1 := b3[0].Lo, b3[0].Hi + b0, b1 := b3[1].Lo, b3[1].Hi + c0, c1 := b3[2].Lo, b3[2].Hi + v := [8]float64{ + f(a0, b0, c0), + f(a0, b0, c1), + f(a0, b1, c0), + f(a0, b1, c1), + f(a1, b0, c0), + f(a1, b0, c1), + f(a1, b1, c0), + f(a1, b1, c1), + } + return Dot8(&w, &v) +} diff --git a/internal/numerics/grid_test.go b/internal/numerics/grid_test.go new file mode 100644 index 0000000..342d39c --- /dev/null +++ b/internal/numerics/grid_test.go @@ -0,0 +1,94 @@ +package numerics + +import ( + "math" + "testing" +) + +func TestAxisLocate(t *testing.T) { + a := Axis{Left: -90, Step: 0.5, N: 361, Name: "lat"} + + b, err := a.Locate(-90) + if err != nil || b.Lo != 0 || b.Hi != 1 || b.Frac != 0 { + t.Errorf("Locate(-90) = %+v, %v; want {0 1 0}, nil", b, err) + } + + b, err = a.Locate(0) + if err != nil || b.Lo != 180 || b.Hi != 181 || b.Frac != 0 { + t.Errorf("Locate(0) = %+v, %v; want {180 181 0}, nil", b, err) + } + + b, err = a.Locate(-89.75) + if err != nil || b.Lo != 0 || b.Hi != 1 || math.Abs(b.Frac-0.5) > 1e-12 { + t.Errorf("Locate(-89.75) = %+v, %v; want frac=0.5", b, err) + } + + // 90 is exactly on the upper boundary — there's no Hi above it + if _, err := a.Locate(90); err == nil { + t.Errorf("Locate(90) should error, got nil") + } + + if _, err := a.Locate(-91); err == nil { + t.Errorf("Locate(-91) should error, got nil") + } +} + +func TestAxisLocateWrap(t *testing.T) { + a := Axis{Left: 0, Step: 0.5, N: 720, Wrap: true, Name: "lng"} + + b, err := a.Locate(0) + if err != nil || b.Lo != 0 || b.Hi != 1 || b.Frac != 0 { + t.Errorf("Locate(0) = %+v, %v", b, err) + } + + // Right up against the wrap boundary + b, err = a.Locate(359.75) + if err != nil || b.Lo != 719 || b.Hi != 0 || math.Abs(b.Frac-0.5) > 1e-12 { + t.Errorf("Locate(359.75) = %+v, %v; want {719 0 0.5}", b, err) + } + + // 360 is outside the half-open interval + if _, err := a.Locate(360); err == nil { + t.Errorf("Locate(360) should error, got nil") + } +} + +func TestEvalTrilinear(t *testing.T) { + // Field f(i,j,k) = 100*i + 10*j + k. + f := func(i, j, k int) float64 { return 100*float64(i) + 10*float64(j) + float64(k) } + + // At all fractions = 0.5, expected value is the mean of the 8 corners. + bs := [3]Bracket{{Lo: 0, Hi: 1, Frac: 0.5}, {Lo: 0, Hi: 1, Frac: 0.5}, {Lo: 0, Hi: 1, Frac: 0.5}} + got := EvalTrilinear(bs, f) + want := (0 + 1 + 10 + 11 + 100 + 101 + 110 + 111) / 8.0 + if math.Abs(got-want) > 1e-12 { + t.Errorf("EvalTrilinear at center = %v, want %v", got, want) + } + + // At all fractions = 0, expected value is f(lo, lo, lo) = 0. + bs = [3]Bracket{{Lo: 0, Hi: 1, Frac: 0}, {Lo: 0, Hi: 1, Frac: 0}, {Lo: 0, Hi: 1, Frac: 0}} + got = EvalTrilinear(bs, f) + if got != 0 { + t.Errorf("EvalTrilinear at (lo,lo,lo) = %v, want 0", got) + } + + // Asymmetric: linear field f(i,j,k) = i should give frac of axis 0 exactly. + f2 := func(i, _, _ int) float64 { return float64(i) } + bs = [3]Bracket{{Lo: 0, Hi: 1, Frac: 0.3}, {Lo: 0, Hi: 1, Frac: 0.7}, {Lo: 0, Hi: 1, Frac: 0.9}} + got = EvalTrilinear(bs, f2) + if math.Abs(got-0.3) > 1e-12 { + t.Errorf("EvalTrilinear of i-field = %v, want 0.3", got) + } +} + +func TestLerp(t *testing.T) { + if Lerp(10, 20, 0) != 10 { + t.Errorf("Lerp(10, 20, 0) != 10") + } + if Lerp(10, 20, 1) != 20 { + t.Errorf("Lerp(10, 20, 1) != 20") + } + if math.Abs(Lerp(10, 20, 0.25)-12.5) > 1e-12 { + t.Errorf("Lerp(10, 20, 0.25) != 12.5") + } +} diff --git a/internal/numerics/motion_test.go b/internal/numerics/motion_test.go new file mode 100644 index 0000000..3bc8051 --- /dev/null +++ b/internal/numerics/motion_test.go @@ -0,0 +1,58 @@ +package numerics + +import ( + "math" + "testing" +) + +func TestAddGeo(t *testing.T) { + // Rates sum component-wise with no longitude wrapping. + got := AddGeo(GeoVec{Lat: 1, Lng: 350, Altitude: 2}, GeoVec{Lat: 3, Lng: 20, Altitude: 4}) + want := GeoVec{Lat: 4, Lng: 370, Altitude: 6} + if got != want { + t.Errorf("AddGeo = %+v, want %+v (no wrap on rates)", got, want) + } +} + +func TestWindToGeoRate(t *testing.T) { + // Pure eastward 10 m/s at the equator, sea level. + dLat, dLng := WindToGeoRate(10, 0, 0, 0) + wantLng := (180.0 / math.Pi) * 10.0 / EarthRadius + if math.Abs(dLat) > 1e-15 { + t.Errorf("dLat = %v, want 0", dLat) + } + if math.Abs(dLng-wantLng) > 1e-15 { + t.Errorf("dLng = %v, want %v", dLng, wantLng) + } + + // Northward 5 m/s at 60°N: dLat independent of longitude scaling. + dLat, _ = WindToGeoRate(0, 5, 60, 0) + wantLat := (180.0 / math.Pi) * 5.0 / EarthRadius + if math.Abs(dLat-wantLat) > 1e-15 { + t.Errorf("dLat at 60N = %v, want %v", dLat, wantLat) + } + + // cos(lat) factor makes eastward motion span more degrees nearer the poles. + _, dLngEq := WindToGeoRate(10, 0, 0, 0) + _, dLng60 := WindToGeoRate(10, 0, 60, 0) + if dLng60 <= dLngEq { + t.Errorf("eastward deg/s should grow with latitude: eq=%v 60N=%v", dLngEq, dLng60) + } +} + +func TestDragTerminalVelocity(t *testing.T) { + // Descent is downward (negative) and faster (more negative) at altitude + // where the air is thinner. + sea := DragTerminalVelocity(5, 0) + high := DragTerminalVelocity(5, 20000) + if sea >= 0 { + t.Errorf("sea-level rate = %v, want negative (downward)", sea) + } + if high >= sea { + t.Errorf("expected faster descent at altitude: sea=%v high=%v", sea, high) + } + // Sanity: at sea level rho≈1.225, so v ≈ -5*1.1045/sqrt(1.225) ≈ -4.99 m/s. + if math.Abs(sea-(-5*1.1045/math.Sqrt(NasaDensity(0)))) > 1e-12 { + t.Errorf("sea-level formula mismatch: %v", sea) + } +} diff --git a/internal/numerics/ode.go b/internal/numerics/ode.go new file mode 100644 index 0000000..9a199ce --- /dev/null +++ b/internal/numerics/ode.go @@ -0,0 +1,94 @@ +package numerics + +// Field returns the time derivative of a geographic state at (t, y). +// The derivative is direction-independent; the integrator applies the sign +// of dt for reverse-time integration. +type Field func(t float64, y GeoVec) GeoVec + +// Crossed reports whether a termination condition holds at (t, y). +type Crossed func(t float64, y GeoVec) bool + +// RK4Step performs one classical Runge-Kutta-4 step from (t, y) with step dt. +// dt may be negative to integrate backwards in time. Longitude wrapping is +// applied at every intermediate add via GeoAdd, matching the reference +// integrator. The function performs no heap allocation. +func RK4Step(t float64, y GeoVec, dt float64, f Field) GeoVec { + half := dt / 2 + k1 := f(t, y) + k2 := f(t+half, GeoAdd(y, half, k1)) + k3 := f(t+half, GeoAdd(y, half, k2)) + k4 := f(t+dt, GeoAdd(y, dt, k3)) + + y2 := GeoAdd(y, dt/6, k1) + y2 = GeoAdd(y2, dt/3, k2) + y2 = GeoAdd(y2, dt/3, k3) + y2 = GeoAdd(y2, dt/6, k4) + return y2 +} + +// RefineCrossing locates a crossing between (t1, y1) (not crossed) and +// (t2, y2) (crossed) by binary search in the linear-interpolation parameter +// space, stopping when the parameter interval is narrower than tol. +// +// It returns the final midpoint sampled, matching Tawhiri's solver.pyx: the +// returned point is not guaranteed to satisfy the predicate, but for tol << 1 +// it is within one tolerance-width of the true crossing. +func RefineCrossing(t1 float64, y1 GeoVec, t2 float64, y2 GeoVec, crossed Crossed, tol float64) (float64, GeoVec) { + left, right := 0.0, 1.0 + t3, y3 := t2, y2 + for right-left > tol { + mid := (left + right) / 2 + t3 = Lerp(t1, t2, mid) + y3 = GeoLerp(y1, y2, mid) + if crossed(t3, y3) { + right = mid + } else { + left = mid + } + } + return t3, y3 +} + +// Path is a struct-of-arrays trajectory: parallel slices of time and the +// three state components. SoA layout keeps each component contiguous, which +// is friendlier to cache and to vectorised post-processing than a slice of +// point structs, and lets the integrator append with a single bounds check +// per component. +type Path struct { + T []float64 + Lat []float64 + Lng []float64 + Altitude []float64 +} + +// NewPath returns a Path with capacity reserved for n points. +func NewPath(n int) Path { + return Path{ + T: make([]float64, 0, n), + Lat: make([]float64, 0, n), + Lng: make([]float64, 0, n), + Altitude: make([]float64, 0, n), + } +} + +// Len returns the number of points in the path. +func (p *Path) Len() int { return len(p.T) } + +// Append adds one point to the path. +func (p *Path) Append(t float64, y GeoVec) { + p.T = append(p.T, t) + p.Lat = append(p.Lat, y.Lat) + p.Lng = append(p.Lng, y.Lng) + p.Altitude = append(p.Altitude, y.Altitude) +} + +// Last returns the final (t, state) of the path. It panics on an empty path. +func (p *Path) Last() (float64, GeoVec) { + i := len(p.T) - 1 + return p.T[i], GeoVec{Lat: p.Lat[i], Lng: p.Lng[i], Altitude: p.Altitude[i]} +} + +// At returns the point at index i. +func (p *Path) At(i int) (float64, GeoVec) { + return p.T[i], GeoVec{Lat: p.Lat[i], Lng: p.Lng[i], Altitude: p.Altitude[i]} +} diff --git a/internal/numerics/ode_test.go b/internal/numerics/ode_test.go new file mode 100644 index 0000000..0a0697b --- /dev/null +++ b/internal/numerics/ode_test.go @@ -0,0 +1,78 @@ +package numerics + +import ( + "math" + "testing" +) + +func TestRK4ExponentialDecay(t *testing.T) { + // dAlt/dt = -Alt → exact: Alt(t) = Alt0 * exp(-t). + f := func(_ float64, y GeoVec) GeoVec { return GeoVec{Altitude: -y.Altitude} } + + y := GeoVec{Altitude: 1} + tnow, dt := 0.0, 0.01 + for range 100 { + y = RK4Step(tnow, y, dt, f) + tnow += dt + } + want := math.Exp(-1.0) + if math.Abs(y.Altitude-want) > 1e-8 { + t.Errorf("RK4 exp decay at t=1: got %v, want %v", y.Altitude, want) + } +} + +func TestRK4ReverseTime(t *testing.T) { + // dAlt/dt = Alt → exact: Alt(t) = Alt0 * exp(t). + f := func(_ float64, y GeoVec) GeoVec { return GeoVec{Altitude: y.Altitude} } + + y := GeoVec{Altitude: math.E} + tnow, dt := 1.0, -0.01 + for range 100 { + y = RK4Step(tnow, y, dt, f) + tnow += dt + } + if math.Abs(y.Altitude-1.0) > 1e-8 { + t.Errorf("RK4 reverse: got %v, want 1.0", y.Altitude) + } +} + +func TestRefineCrossing(t *testing.T) { + y1 := GeoVec{Altitude: 1} + y2 := GeoVec{Altitude: -1.5} + crossed := func(_ float64, y GeoVec) bool { return y.Altitude <= 0 } + + tr, yr := RefineCrossing(0, y1, 1, y2, crossed, 0.001) + if math.Abs(tr-0.4) > 0.01 { + t.Errorf("refined t = %v, want ~0.4", tr) + } + if math.Abs(yr.Altitude) > 0.01 { + t.Errorf("refined alt = %v, want ~0", yr.Altitude) + } +} + +func TestGeoAddWrapsLongitude(t *testing.T) { + y := GeoAdd(GeoVec{Lng: 350}, 1, GeoVec{Lng: 20}) + if math.Abs(y.Lng-10) > 1e-9 { + t.Errorf("GeoAdd wrap: lng = %v, want 10", y.Lng) + } +} + +func TestGeoLerpWrap(t *testing.T) { + mid := GeoLerp(GeoVec{Lng: 350}, GeoVec{Lng: 10}, 0.5) + if math.Abs(mid.Lng) > 1e-9 && math.Abs(mid.Lng-360) > 1e-9 { + t.Errorf("GeoLerp lng wrap: %v, want 0 or 360", mid.Lng) + } +} + +func TestPathSoA(t *testing.T) { + p := NewPath(4) + p.Append(0, GeoVec{Lat: 1, Lng: 2, Altitude: 3}) + p.Append(60, GeoVec{Lat: 4, Lng: 5, Altitude: 6}) + if p.Len() != 2 { + t.Fatalf("len = %d, want 2", p.Len()) + } + tt, last := p.Last() + if tt != 60 || last.Lat != 4 { + t.Errorf("last = %v, %+v", tt, last) + } +} diff --git a/internal/numerics/search.go b/internal/numerics/search.go new file mode 100644 index 0000000..71836b0 --- /dev/null +++ b/internal/numerics/search.go @@ -0,0 +1,19 @@ +package numerics + +// Bisect returns the largest index i in [imin, imax] such that f(i) < target, +// assuming f is monotonically nondecreasing on that range. +// +// If target <= f(imin), returns imin. If target > f(imax), returns imax. +// Performs O(log(imax-imin)) evaluations of f. +func Bisect(imin, imax int, target float64, f func(i int) float64) int { + lo, hi := imin, imax + for lo < hi { + mid := (lo + hi + 1) / 2 + if target <= f(mid) { + hi = mid - 1 + } else { + lo = mid + } + } + return lo +} diff --git a/internal/numerics/search_test.go b/internal/numerics/search_test.go new file mode 100644 index 0000000..c84f847 --- /dev/null +++ b/internal/numerics/search_test.go @@ -0,0 +1,28 @@ +package numerics + +import "testing" + +func TestBisect(t *testing.T) { + // f(i) = 10*i, monotone increasing. + f := func(i int) float64 { return 10 * float64(i) } + + // target = 25 → largest i with 10i < 25 is i=2 + if got := Bisect(0, 10, 25, f); got != 2 { + t.Errorf("Bisect target=25 = %d, want 2", got) + } + + // target on boundary: target = 30, condition is target <= f(mid) so f(3)=30 → not less; want 2 + if got := Bisect(0, 10, 30, f); got != 2 { + t.Errorf("Bisect target=30 = %d, want 2", got) + } + + // target below all values + if got := Bisect(0, 10, -5, f); got != 0 { + t.Errorf("Bisect target=-5 = %d, want 0", got) + } + + // target above all values + if got := Bisect(0, 10, 1000, f); got != 10 { + t.Errorf("Bisect target=1000 = %d, want 10", got) + } +} diff --git a/internal/numerics/vec.go b/internal/numerics/vec.go new file mode 100644 index 0000000..08d22a5 --- /dev/null +++ b/internal/numerics/vec.go @@ -0,0 +1,89 @@ +package numerics + +import "math" + +// GeoVec is a geographic state vector: latitude and longitude in degrees and +// altitude in metres. The same struct represents a per-second derivative, +// in which case the fields are deg/s and m/s. +// +// GeoVec is the hot-path state type for the integrator. It is a small value +// type (three float64) and is passed by value to stay allocation-free; a +// future SIMD/SoA batch integrator can lift these fields into parallel +// slices (see Path). +type GeoVec struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Altitude float64 `json:"altitude"` +} + +// PyMod returns a mod b with Python semantics: the result carries the sign of +// b, so for b > 0 it always lies in [0, b). +func PyMod(a, b float64) float64 { + r := math.Mod(a, b) + if r < 0 { + r += b + } + return r +} + +// GeoAdd returns y + k*dy with longitude wrapped to [0, 360). Latitude and +// altitude accumulate linearly. This is the integrator's state-update step. +func GeoAdd(y GeoVec, k float64, dy GeoVec) GeoVec { + return GeoVec{ + Lat: y.Lat + k*dy.Lat, + Lng: PyMod(y.Lng+k*dy.Lng, 360), + Altitude: y.Altitude + k*dy.Altitude, + } +} + +// GeoLerp linearly interpolates two geographic states by parameter l in +// [0, 1]. Longitude takes the shorter great-circle arc. +func GeoLerp(a, b GeoVec, l float64) GeoVec { + return GeoVec{ + Lat: (1-l)*a.Lat + l*b.Lat, + Lng: LngLerp(a.Lng, b.Lng, l), + Altitude: (1-l)*a.Altitude + l*b.Altitude, + } +} + +// LngLerp interpolates between two longitudes in [0, 360), choosing the +// shorter arc and wrapping the result back into range. +func LngLerp(a, b, l float64) float64 { + l2 := 1 - l + if a > b { + a, b = b, a + l, l2 = l2, l + } + if b-a < 180 { + return l2*a + l*b + } + return PyMod(l2*(a+360)+l*b, 360) +} + +// Lerp returns (1-l)*a + l*b. +func Lerp(a, b, l float64) float64 { + return (1-l)*a + l*b +} + +// AddGeo returns the component-wise sum a+b without longitude wrapping. Use it +// to combine derivative (rate) vectors — rates accumulate linearly, unlike +// positions, which wrap via GeoAdd. +func AddGeo(a, b GeoVec) GeoVec { + return GeoVec{Lat: a.Lat + b.Lat, Lng: a.Lng + b.Lng, Altitude: a.Altitude + b.Altitude} +} + +// EarthRadius is the spherical Earth radius (metres) used for horizontal +// motion, matching the reference Tawhiri implementation. +const EarthRadius = 6371009.0 + +// WindToGeoRate converts eastward (u) and northward (v) wind in m/s at the +// given latitude (deg) and altitude (m) into the geographic rate in deg/s on a +// spherical Earth. The returned dLng diverges near the poles as cos(lat) → 0. +func WindToGeoRate(u, v, lat, alt float64) (dLat, dLng float64) { + const degPerRad = 180.0 / math.Pi + const piOver180 = math.Pi / 180.0 + r := EarthRadius + alt + dLat = degPerRad * v / r + dLng = degPerRad * u / (r * math.Cos(lat*piOver180)) + return dLat, dLng +} diff --git a/internal/prediction/interpolate.go b/internal/prediction/interpolate.go deleted file mode 100644 index 5ef0d14..0000000 --- a/internal/prediction/interpolate.go +++ /dev/null @@ -1,153 +0,0 @@ -package prediction - -import ( - "fmt" - - "predictor-refactored/internal/dataset" -) - -// Exact port of the reference interpolation logic (interpolate.pyx). -// 4D interpolation: time, latitude, longitude, altitude (via geopotential height). - -// lerp1 holds an index and interpolation weight for one axis. -type lerp1 struct { - index int - lerp float64 -} - -// lerp3 holds indices and a combined weight for the (hour, lat, lon) axes. -type lerp3 struct { - hour, lat, lng int - lerp float64 -} - -// RangeError indicates a coordinate is outside the dataset bounds. -type RangeError struct { - Variable string - Value float64 -} - -func (e *RangeError) Error() string { - return fmt.Sprintf("%s=%f out of range", e.Variable, e.Value) -} - -// pick computes interpolation indices and weights for a single axis. -// left: axis start, step: axis spacing, n: number of points, value: query value. -// Returns two lerp1 values (lower and upper bracket). -func pick(left, step float64, n int, value float64, variableName string) ([2]lerp1, error) { - a := (value - left) / step - b := int(a) // truncation toward zero, same as Cython cast - if b < 0 || b >= n-1 { - return [2]lerp1{}, &RangeError{Variable: variableName, Value: value} - } - l := a - float64(b) - return [2]lerp1{ - {index: b, lerp: 1 - l}, - {index: b + 1, lerp: l}, - }, nil -} - -// pick3 computes 8 trilinear interpolation weights for (hour, lat, lng). -func pick3(hour, lat, lng float64) ([8]lerp3, error) { - lhour, err := pick(0, 3, 65, hour, "hour") - if err != nil { - return [8]lerp3{}, err - } - llat, err := pick(-90, 0.5, 361, lat, "lat") - if err != nil { - return [8]lerp3{}, err - } - // Longitude wraps: tell pick the axis is one larger, then wrap index 720 → 0 - llng, err := pick(0, 0.5, 720+1, lng, "lng") - if err != nil { - return [8]lerp3{}, err - } - if llng[1].index == 720 { - llng[1].index = 0 - } - - var out [8]lerp3 - i := 0 - for _, a := range lhour { - for _, b := range llat { - for _, c := range llng { - out[i] = lerp3{ - hour: a.index, - lat: b.index, - lng: c.index, - lerp: a.lerp * b.lerp * c.lerp, - } - i++ - } - } - } - return out, nil -} - -// interp3 performs 8-point weighted interpolation at a given variable and pressure level. -func interp3(ds *dataset.File, lerps [8]lerp3, variable, level int) float64 { - var r float64 - for i := 0; i < 8; i++ { - v := ds.Val(lerps[i].hour, level, variable, lerps[i].lat, lerps[i].lng) - r += float64(v) * lerps[i].lerp - } - return r -} - -// search finds the largest pressure level index where interpolated geopotential -// height is less than the target altitude. Searches levels 0..45 (excludes topmost). -func search(ds *dataset.File, lerps [8]lerp3, target float64) int { - lower, upper := 0, 45 - - for lower < upper { - mid := (lower + upper + 1) / 2 - test := interp3(ds, lerps, dataset.VarHeight, mid) - if target <= test { - upper = mid - 1 - } else { - lower = mid - } - } - - return lower -} - -// interp4 performs altitude-interpolated wind lookup using two bracketing levels. -func interp4(ds *dataset.File, lerps [8]lerp3, altLerp lerp1, variable int) float64 { - lower := interp3(ds, lerps, variable, altLerp.index) - upper := interp3(ds, lerps, variable, altLerp.index+1) - return lower*altLerp.lerp + upper*(1-altLerp.lerp) -} - -// GetWind returns interpolated (u, v) wind components for the given position. -// hour: fractional hours since dataset start. -// lat: latitude in degrees (-90 to +90). -// lng: longitude in degrees (0 to 360). -// alt: altitude in metres above sea level. -func GetWind(ds *dataset.File, warnings *Warnings, hour, lat, lng, alt float64) (u, v float64, err error) { - lerps, err := pick3(hour, lat, lng) - if err != nil { - return 0, 0, err - } - - altidx := search(ds, lerps, alt) - lower := interp3(ds, lerps, dataset.VarHeight, altidx) - upper := interp3(ds, lerps, dataset.VarHeight, altidx+1) - - var altLerp float64 - if lower != upper { - altLerp = (upper - alt) / (upper - lower) - } else { - altLerp = 0.5 - } - - if altLerp < 0 { - warnings.AltitudeTooHigh.Add(1) - } - - alt1 := lerp1{index: altidx, lerp: altLerp} - u = interp4(ds, lerps, alt1, dataset.VarWindU) - v = interp4(ds, lerps, alt1, dataset.VarWindV) - - return u, v, nil -} diff --git a/internal/prediction/models.go b/internal/prediction/models.go deleted file mode 100644 index 8048c46..0000000 --- a/internal/prediction/models.go +++ /dev/null @@ -1,188 +0,0 @@ -package prediction - -import ( - "math" - "time" - - "predictor-refactored/internal/dataset" - "predictor-refactored/internal/elevation" -) - -// Exact port of the reference flight models (models.py). - -const ( - pi180 = math.Pi / 180.0 - _180pi = 180.0 / math.Pi -) - -// --- Up/Down Models --- - -// ConstantAscent returns a model with constant vertical velocity (m/s). -func ConstantAscent(ascentRate float64) Model { - return func(t, lat, lng, alt float64) (dlat, dlng, dalt float64) { - return 0, 0, ascentRate - } -} - -// DragDescent returns a descent-under-parachute model. -// seaLevelDescentRate is the descent rate at sea level (m/s, positive value). -// Uses the NASA atmosphere model for density at altitude. -func DragDescent(seaLevelDescentRate float64) Model { - dragCoefficient := seaLevelDescentRate * 1.1045 - - return func(t, lat, lng, alt float64) (dlat, dlng, dalt float64) { - return 0, 0, -dragCoefficient / math.Sqrt(nasaDensity(alt)) - } -} - -// nasaDensity computes air density using the NASA atmosphere model. -// Reference: http://www.grc.nasa.gov/WWW/K-12/airplane/atmosmet.html -func nasaDensity(alt float64) float64 { - var temp, pressure float64 - - switch { - case alt > 25000: - temp = -131.21 + 0.00299*alt - pressure = 2.488 * math.Pow((temp+273.1)/216.6, -11.388) - case alt > 11000: - temp = -56.46 - pressure = 22.65 * math.Exp(1.73-0.000157*alt) - default: - temp = 15.04 - 0.00649*alt - pressure = 101.29 * math.Pow((temp+273.1)/288.08, 5.256) - } - - return pressure / (0.2869 * (temp + 273.1)) -} - -// --- Sideways Models --- - -// WindVelocity returns a model that gives lateral movement at the wind velocity. -// ds is the wind dataset, dsEpoch is the dataset start time as UNIX timestamp. -func WindVelocity(ds *dataset.File, dsEpoch float64, warnings *Warnings) Model { - return func(t, lat, lng, alt float64) (dlat, dlng, dalt float64) { - tHours := (t - dsEpoch) / 3600.0 - u, v, err := GetWind(ds, warnings, tHours, lat, lng, alt) - if err != nil { - return 0, 0, 0 - } - - R := 6371009.0 + alt - dlat = _180pi * v / R - dlng = _180pi * u / (R * math.Cos(lat*pi180)) - return dlat, dlng, 0 - } -} - -// --- Model Combinations --- - -// LinearModel returns a model that sums all component models. -func LinearModel(models ...Model) Model { - return func(t, lat, lng, alt float64) (dlat, dlng, dalt float64) { - for _, m := range models { - d1, d2, d3 := m(t, lat, lng, alt) - dlat += d1 - dlng += d2 - dalt += d3 - } - return - } -} - -// --- Termination Criteria --- - -// BurstTermination returns a terminator that fires when altitude >= burstAltitude. -func BurstTermination(burstAltitude float64) Terminator { - return func(t, lat, lng, alt float64) bool { - return alt >= burstAltitude - } -} - -// SeaLevelTermination fires when altitude <= 0. -func SeaLevelTermination(t, lat, lng, alt float64) bool { - return alt <= 0 -} - -// TimeTermination returns a terminator that fires when t > maxTime. -func TimeTermination(maxTime float64) Terminator { - return func(t, lat, lng, alt float64) bool { - return t > maxTime - } -} - -// ElevationTermination returns a terminator that fires when alt < ground level. -// Uses ruaumoko-compatible elevation data. Longitude is normalised internally. -func ElevationTermination(elev *elevation.Dataset) Terminator { - return func(t, lat, lng, alt float64) bool { - return elev.Get(lat, lng) > alt - } -} - -// --- Pre-Defined Profiles --- - -// Stage pairs a model with its termination criterion. -type Stage struct { - Model Model - Terminator Terminator -} - -// StandardProfile creates the chain for a standard high-altitude balloon flight: -// ascent at constant rate → burst → descent under parachute. -// If elev is non-nil, descent terminates at ground level; otherwise at sea level. -func StandardProfile(ascentRate, burstAltitude, descentRate float64, - ds *dataset.File, dsEpoch float64, warnings *Warnings, - elev *elevation.Dataset) []Stage { - - wind := WindVelocity(ds, dsEpoch, warnings) - - modelUp := LinearModel(ConstantAscent(ascentRate), wind) - termUp := BurstTermination(burstAltitude) - - modelDown := LinearModel(DragDescent(descentRate), wind) - var termDown Terminator - if elev != nil { - termDown = ElevationTermination(elev) - } else { - termDown = Terminator(SeaLevelTermination) - } - - return []Stage{ - {Model: modelUp, Terminator: termUp}, - {Model: modelDown, Terminator: termDown}, - } -} - -// FloatProfile creates the chain for a floating balloon flight: -// ascent to float altitude → float until stop time. -func FloatProfile(ascentRate, floatAltitude float64, stopTime time.Time, - ds *dataset.File, dsEpoch float64, warnings *Warnings) []Stage { - - wind := WindVelocity(ds, dsEpoch, warnings) - - modelUp := LinearModel(ConstantAscent(ascentRate), wind) - termUp := BurstTermination(floatAltitude) - - modelFloat := wind - termFloat := TimeTermination(float64(stopTime.Unix())) - - return []Stage{ - {Model: modelUp, Terminator: termUp}, - {Model: modelFloat, Terminator: termFloat}, - } -} - -// RunPrediction runs a prediction with the given profile stages. -// launchTime is a UNIX timestamp. -func RunPrediction(launchTime float64, lat, lng, alt float64, stages []Stage) []StageResult { - chain := make([]struct { - Model Model - Terminator Terminator - }, len(stages)) - - for i, s := range stages { - chain[i].Model = s.Model - chain[i].Terminator = s.Terminator - } - - return Solve(launchTime, lat, lng, alt, chain) -} diff --git a/internal/prediction/solver.go b/internal/prediction/solver.go deleted file mode 100644 index 62e29a7..0000000 --- a/internal/prediction/solver.go +++ /dev/null @@ -1,180 +0,0 @@ -package prediction - -import "math" - -// Exact port of the reference RK4 solver (solver.pyx). -// Integrates balloon state using RK4 with dt=60 seconds. -// Termination uses binary search refinement (tolerance 0.01). - -// Vec holds the balloon state: latitude, longitude, altitude. -type Vec struct { - Lat float64 - Lng float64 - Alt float64 -} - -// Model is a function that returns (dlat/dt, dlng/dt, dalt/dt) given state. -// t is UNIX timestamp, lat/lng in degrees, alt in metres. -type Model func(t float64, lat, lng, alt float64) (dlat, dlng, dalt float64) - -// Terminator returns true when integration should stop. -type Terminator func(t float64, lat, lng, alt float64) bool - -// StageResult holds the trajectory points for one flight stage. -type StageResult struct { - Points []TrajectoryPoint -} - -// TrajectoryPoint is a single point in a trajectory (used by solver). -type TrajectoryPoint struct { - T float64 // UNIX timestamp - Lat float64 - Lng float64 - Alt float64 -} - -// pymod returns a % b with Python semantics (always non-negative when b > 0). -func pymod(a, b float64) float64 { - r := math.Mod(a, b) - if r < 0 { - r += b - } - return r -} - -// vecadd returns a + k*b, with lng wrapped to [0, 360). -func vecadd(a Vec, k float64, b Vec) Vec { - return Vec{ - Lat: a.Lat + k*b.Lat, - Lng: pymod(a.Lng+k*b.Lng, 360.0), - Alt: a.Alt + k*b.Alt, - } -} - -// scalarLerp returns (1-l)*a + l*b. -func scalarLerp(a, b, l float64) float64 { - return (1-l)*a + l*b -} - -// lngLerp interpolates longitude handling the 0/360 wrap-around. -func lngLerp(a, b, l float64) float64 { - l2 := 1 - l - - if a > b { - a, b = b, a - l, l2 = l2, l - } - - // distance round one way: b - a - // distance around other: (a + 360) - b - if b-a < 180.0 { - return l2*a + l*b - } - return pymod(l2*(a+360)+l*b, 360.0) -} - -// vecLerp returns (1-l)*a + l*b with proper longitude wrapping. -func vecLerp(a, b Vec, l float64) Vec { - return Vec{ - Lat: scalarLerp(a.Lat, b.Lat, l), - Lng: lngLerp(a.Lng, b.Lng, l), - Alt: scalarLerp(a.Alt, b.Alt, l), - } -} - -// rk4 integrates from initial conditions using RK4. -// dt=60.0 seconds, terminationTolerance=0.01. -func rk4(t float64, lat, lng, alt float64, model Model, terminator Terminator) []TrajectoryPoint { - const dt = 60.0 - const terminationTolerance = 0.01 - - y := Vec{Lat: lat, Lng: lng, Alt: alt} - result := []TrajectoryPoint{{T: t, Lat: y.Lat, Lng: y.Lng, Alt: y.Alt}} - - for { - // Evaluate model at 4 points (standard RK4) - k1lat, k1lng, k1alt := model(t, y.Lat, y.Lng, y.Alt) - k1 := Vec{Lat: k1lat, Lng: k1lng, Alt: k1alt} - - mid1 := vecadd(y, dt/2, k1) - k2lat, k2lng, k2alt := model(t+dt/2, mid1.Lat, mid1.Lng, mid1.Alt) - k2 := Vec{Lat: k2lat, Lng: k2lng, Alt: k2alt} - - mid2 := vecadd(y, dt/2, k2) - k3lat, k3lng, k3alt := model(t+dt/2, mid2.Lat, mid2.Lng, mid2.Alt) - k3 := Vec{Lat: k3lat, Lng: k3lng, Alt: k3alt} - - end := vecadd(y, dt, k3) - k4lat, k4lng, k4alt := model(t+dt, end.Lat, end.Lng, end.Alt) - k4 := Vec{Lat: k4lat, Lng: k4lng, Alt: k4alt} - - // y2 = y + dt/6*k1 + dt/3*k2 + dt/3*k3 + dt/6*k4 - y2 := y - y2 = vecadd(y2, dt/6, k1) - y2 = vecadd(y2, dt/3, k2) - y2 = vecadd(y2, dt/3, k3) - y2 = vecadd(y2, dt/6, k4) - - t2 := t + dt - - if terminator(t2, y2.Lat, y2.Lng, y2.Alt) { - // Binary search to refine the termination point. - // Find l in [0, 1] such that (t3, y3) = lerp((t, y), (t2, y2), l) - // is near where the terminator fires. - left := 0.0 - right := 1.0 - - var t3 float64 - var y3 Vec - t3 = t2 - y3 = y2 - - for right-left > terminationTolerance { - mid := (left + right) / 2 - t3 = scalarLerp(t, t2, mid) - y3 = vecLerp(y, y2, mid) - - if terminator(t3, y3.Lat, y3.Lng, y3.Alt) { - right = mid - } else { - left = mid - } - } - - result = append(result, TrajectoryPoint{T: t3, Lat: y3.Lat, Lng: y3.Lng, Alt: y3.Alt}) - break - } - - // Update current state - t = t2 - y = y2 - result = append(result, TrajectoryPoint{T: t, Lat: y.Lat, Lng: y.Lng, Alt: y.Alt}) - } - - return result -} - -// Solve runs through a chain of (model, terminator) stages. -// Returns one StageResult per stage. -func Solve(t, lat, lng, alt float64, chain []struct { - Model Model - Terminator Terminator -}) []StageResult { - var results []StageResult - - for _, stage := range chain { - points := rk4(t, lat, lng, alt, stage.Model, stage.Terminator) - results = append(results, StageResult{Points: points}) - - // Next stage starts where this one ended - if len(points) > 0 { - last := points[len(points)-1] - t = last.T - lat = last.Lat - lng = last.Lng - alt = last.Alt - } - } - - return results -} diff --git a/internal/prediction/warnings.go b/internal/prediction/warnings.go deleted file mode 100644 index 1beeb1a..0000000 --- a/internal/prediction/warnings.go +++ /dev/null @@ -1,21 +0,0 @@ -package prediction - -import "sync/atomic" - -// Warnings tracks warning conditions during a prediction run. -type Warnings struct { - AltitudeTooHigh atomic.Int64 -} - -// ToMap returns warnings as a map suitable for JSON serialization. -// Only includes warnings that have fired. -func (w *Warnings) ToMap() map[string]any { - result := make(map[string]any) - if n := w.AltitudeTooHigh.Load(); n > 0 { - result["altitude_too_high"] = map[string]any{ - "count": n, - "description": "The altitude went too high, above the max forecast wind. Wind data will be unreliable", - } - } - return result -} diff --git a/internal/service/service.go b/internal/service/service.go deleted file mode 100644 index 4ccd1d4..0000000 --- a/internal/service/service.go +++ /dev/null @@ -1,245 +0,0 @@ -package service - -import ( - "context" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" - - "predictor-refactored/internal/dataset" - "predictor-refactored/internal/downloader" - "predictor-refactored/internal/elevation" - - "go.uber.org/zap" -) - -// Service orchestrates the dataset lifecycle and provides prediction capabilities. -type Service struct { - mu sync.RWMutex - ds *dataset.File - elev *elevation.Dataset - cfg *downloader.Config - dl *downloader.Downloader - log *zap.Logger - updating sync.Mutex // prevents concurrent downloads -} - -// New creates a new Service. -func New(cfg *downloader.Config, log *zap.Logger) *Service { - return &Service{ - cfg: cfg, - dl: downloader.NewDownloader(cfg, log), - log: log, - } -} - -// LoadElevation loads the ruaumoko-compatible elevation dataset from path. -// If the file doesn't exist, elevation termination is disabled (falls back to sea level). -func (s *Service) LoadElevation(path string) { - ds, err := elevation.Open(path) - if err != nil { - s.log.Warn("elevation dataset not available, using sea-level termination", - zap.String("path", path), zap.Error(err)) - return - } - s.elev = ds - s.log.Info("elevation dataset loaded", zap.String("path", path)) -} - -// Elevation returns the elevation dataset (may be nil). -func (s *Service) Elevation() *elevation.Dataset { - return s.elev -} - -// Ready returns true if the service has a loaded dataset. -func (s *Service) Ready() bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.ds != nil -} - -// DatasetTime returns the forecast time of the currently loaded dataset. -func (s *Service) DatasetTime() (time.Time, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - if s.ds == nil { - return time.Time{}, false - } - return s.ds.DSTime, true -} - -// Dataset returns the current dataset for reading. -func (s *Service) Dataset() *dataset.File { - s.mu.RLock() - defer s.mu.RUnlock() - return s.ds -} - -// Update checks for and downloads new forecast data if needed. -func (s *Service) Update(ctx context.Context) error { - if !s.updating.TryLock() { - s.log.Info("update already in progress, skipping") - return nil - } - defer s.updating.Unlock() - - // Check if current dataset is still fresh - if dsTime, ok := s.DatasetTime(); ok { - if time.Since(dsTime) < s.cfg.DatasetTTL { - s.log.Info("dataset still fresh, skipping update", - zap.Time("dataset_time", dsTime), - zap.Duration("age", time.Since(dsTime))) - return nil - } - } - - // Try loading an existing dataset from disk first - if err := s.loadExistingDataset(); err == nil { - return nil - } - - // Find latest available model run - run, err := s.dl.FindLatestRun(ctx) - if err != nil { - return err - } - - // Download and assemble - path, err := s.dl.Download(ctx, run) - if err != nil { - return err - } - - // Open the new dataset - ds, err := dataset.Open(path, run) - if err != nil { - return err - } - - // Swap in the new dataset - s.setDataset(ds) - s.log.Info("dataset loaded", zap.Time("run", run), zap.String("path", path)) - - // Clean old datasets - s.cleanOldDatasets(path) - - return nil -} - -// loadExistingDataset tries to find and load an existing dataset from the data directory. -func (s *Service) loadExistingDataset() error { - entries, err := os.ReadDir(s.cfg.DataDir) - if err != nil { - return err - } - - // Collect valid dataset files (name is YYYYMMDDHH, no extension, correct size) - type candidate struct { - name string - path string - run time.Time - } - var candidates []candidate - - for _, e := range entries { - if e.IsDir() || strings.Contains(e.Name(), ".") { - continue - } - if len(e.Name()) != 10 { - continue - } - - run, err := time.Parse("2006010215", e.Name()) - if err != nil { - continue - } - - path := filepath.Join(s.cfg.DataDir, e.Name()) - info, err := os.Stat(path) - if err != nil || info.Size() != dataset.DatasetSize { - continue - } - - if time.Since(run) > s.cfg.DatasetTTL { - continue - } - - candidates = append(candidates, candidate{name: e.Name(), path: path, run: run}) - } - - if len(candidates) == 0 { - return os.ErrNotExist - } - - // Pick the newest - sort.Slice(candidates, func(i, j int) bool { - return candidates[i].run.After(candidates[j].run) - }) - - best := candidates[0] - ds, err := dataset.Open(best.path, best.run) - if err != nil { - return err - } - - s.setDataset(ds) - s.log.Info("loaded existing dataset", - zap.Time("run", best.run), - zap.String("path", best.path)) - return nil -} - -// setDataset swaps the current dataset with a new one, closing the old one. -func (s *Service) setDataset(ds *dataset.File) { - s.mu.Lock() - old := s.ds - s.ds = ds - s.mu.Unlock() - - if old != nil { - if err := old.Close(); err != nil { - s.log.Error("failed to close old dataset", zap.Error(err)) - } - } -} - -// cleanOldDatasets removes dataset files other than the one at keepPath. -func (s *Service) cleanOldDatasets(keepPath string) { - entries, err := os.ReadDir(s.cfg.DataDir) - if err != nil { - return - } - - for _, e := range entries { - if e.IsDir() { - continue - } - path := filepath.Join(s.cfg.DataDir, e.Name()) - if path == keepPath { - continue - } - // Remove old datasets and temp files - if len(e.Name()) == 10 || strings.HasSuffix(e.Name(), ".downloading") { - if err := os.Remove(path); err != nil { - s.log.Warn("failed to remove old file", zap.String("path", path), zap.Error(err)) - } else { - s.log.Info("removed old dataset", zap.String("path", path)) - } - } - } -} - -// Close releases all resources. -func (s *Service) Close() error { - s.mu.Lock() - defer s.mu.Unlock() - if s.ds != nil { - err := s.ds.Close() - s.ds = nil - return err - } - return nil -} diff --git a/internal/transport/middleware/log.go b/internal/transport/middleware/log.go deleted file mode 100644 index fbbbbc1..0000000 --- a/internal/transport/middleware/log.go +++ /dev/null @@ -1,30 +0,0 @@ -package middleware - -import ( - "time" - - "github.com/ogen-go/ogen/middleware" - "go.uber.org/zap" -) - -// Logging returns an ogen middleware that logs request duration. -func Logging(log *zap.Logger) middleware.Middleware { - return func(req middleware.Request, next func(req middleware.Request) (middleware.Response, error)) (middleware.Response, error) { - lg := log.With(zap.String("operation", req.OperationID)) - - start := time.Now() - resp, err := next(req) - dur := time.Since(start) - - if err != nil { - lg.Error("request failed", - zap.Duration("duration", dur), - zap.Error(err)) - } else { - lg.Info("request completed", - zap.Duration("duration", dur)) - } - - return resp, err - } -} diff --git a/internal/transport/rest/handler/deps.go b/internal/transport/rest/handler/deps.go deleted file mode 100644 index f81a3b8..0000000 --- a/internal/transport/rest/handler/deps.go +++ /dev/null @@ -1,16 +0,0 @@ -package handler - -import ( - "time" - - "predictor-refactored/internal/dataset" - "predictor-refactored/internal/elevation" -) - -// Service defines the interface the handler needs from the service layer. -type Service interface { - Ready() bool - DatasetTime() (time.Time, bool) - Dataset() *dataset.File - Elevation() *elevation.Dataset -} diff --git a/internal/transport/rest/handler/handler.go b/internal/transport/rest/handler/handler.go deleted file mode 100644 index fc1f693..0000000 --- a/internal/transport/rest/handler/handler.go +++ /dev/null @@ -1,216 +0,0 @@ -package handler - -import ( - "context" - "net/http" - "time" - - "predictor-refactored/internal/prediction" - api "predictor-refactored/pkg/rest" - - "go.uber.org/zap" -) - -var _ api.Handler = (*Handler)(nil) - -// Handler implements the ogen-generated api.Handler interface. -type Handler struct { - svc Service - log *zap.Logger -} - -// New creates a new Handler. -func New(svc Service, log *zap.Logger) *Handler { - return &Handler{svc: svc, log: log} -} - -// PerformPrediction implements the prediction endpoint. -func (h *Handler) PerformPrediction(ctx context.Context, params api.PerformPredictionParams) (*api.PredictionResponse, error) { - if !h.svc.Ready() { - return nil, newError(http.StatusServiceUnavailable, "no dataset loaded, service is starting up") - } - - ds := h.svc.Dataset() - if ds == nil { - return nil, newError(http.StatusServiceUnavailable, "dataset unavailable") - } - - dsEpoch := float64(ds.DSTime.Unix()) - - // Parse parameters with defaults - profile := "standard_profile" - if p, ok := params.Profile.Get(); ok { - profile = string(p) - } - - ascentRate := 5.0 - if v, ok := params.AscentRate.Get(); ok { - ascentRate = v - } - - burstAltitude := 28000.0 - if v, ok := params.BurstAltitude.Get(); ok { - burstAltitude = v - } - - descentRate := 5.0 - if v, ok := params.DescentRate.Get(); ok { - descentRate = v - } - - launchAlt := 0.0 - if v, ok := params.LaunchAltitude.Get(); ok { - launchAlt = v - } - - // Normalize longitude to [0, 360) - lng := params.LaunchLongitude - if lng < 0 { - lng += 360.0 - } - - launchTime := float64(params.LaunchDatetime.Unix()) - - warnings := &prediction.Warnings{} - - // Build profile chain - elev := h.svc.Elevation() - var stages []prediction.Stage - switch profile { - case "standard_profile": - stages = prediction.StandardProfile( - ascentRate, burstAltitude, descentRate, - ds, dsEpoch, warnings, elev) - case "float_profile": - floatAlt := 25000.0 - if v, ok := params.FloatAltitude.Get(); ok { - floatAlt = v - } - stopTime := params.LaunchDatetime.Add(24 * time.Hour) - if v, ok := params.StopDatetime.Get(); ok { - stopTime = v - } - stages = prediction.FloatProfile( - ascentRate, floatAlt, stopTime, - ds, dsEpoch, warnings) - default: - return nil, newError(http.StatusBadRequest, "unknown profile: "+profile) - } - - // Run prediction - startTime := time.Now().UTC() - results := prediction.RunPrediction(launchTime, params.LaunchLatitude, lng, launchAlt, stages) - completeTime := time.Now().UTC() - - // Build response - stageNames := []string{"ascent", "descent"} - if profile == "float_profile" { - stageNames = []string{"ascent", "float"} - } - - var predItems []api.PredictionResponsePredictionItem - for i, sr := range results { - stageName := "ascent" - if i < len(stageNames) { - stageName = stageNames[i] - } - - var stageEnum api.PredictionResponsePredictionItemStage - switch stageName { - case "ascent": - stageEnum = api.PredictionResponsePredictionItemStageAscent - case "descent": - stageEnum = api.PredictionResponsePredictionItemStageDescent - case "float": - stageEnum = api.PredictionResponsePredictionItemStageFloat - } - - var traj []api.PredictionResponsePredictionItemTrajectoryItem - for _, pt := range sr.Points { - ptLng := pt.Lng - if ptLng > 180 { - ptLng -= 360 - } - traj = append(traj, api.PredictionResponsePredictionItemTrajectoryItem{ - Datetime: time.Unix(int64(pt.T), 0).UTC(), - Latitude: pt.Lat, - Longitude: ptLng, - Altitude: pt.Alt, - }) - } - - predItems = append(predItems, api.PredictionResponsePredictionItem{ - Stage: stageEnum, - Trajectory: traj, - }) - } - - resp := &api.PredictionResponse{ - Prediction: predItems, - Metadata: api.PredictionResponseMetadata{ - StartDatetime: startTime, - CompleteDatetime: completeTime, - }, - } - - // Echo request - resp.Request = api.NewOptPredictionResponseRequest(api.PredictionResponseRequest{ - Dataset: api.NewOptString(ds.DSTime.Format("2006-01-02T15:04:05Z")), - LaunchLatitude: api.NewOptFloat64(params.LaunchLatitude), - LaunchLongitude: api.NewOptFloat64(params.LaunchLongitude), - LaunchDatetime: api.NewOptString(params.LaunchDatetime.Format(time.RFC3339)), - LaunchAltitude: params.LaunchAltitude, - }) - - // Warnings - warnMap := warnings.ToMap() - if len(warnMap) > 0 { - resp.Warnings = api.NewOptPredictionResponseWarnings(api.PredictionResponseWarnings{}) - } - - h.log.Info("prediction complete", - zap.String("profile", profile), - zap.Int("stages", len(results)), - zap.Duration("elapsed", completeTime.Sub(startTime))) - - return resp, nil -} - -// ReadinessCheck implements the health check endpoint. -func (h *Handler) ReadinessCheck(ctx context.Context) (*api.ReadinessResponse, error) { - resp := &api.ReadinessResponse{} - - if h.svc.Ready() { - resp.Status = api.ReadinessResponseStatusOk - if dsTime, ok := h.svc.DatasetTime(); ok { - resp.DatasetTime = api.NewOptDateTime(dsTime) - } - } else { - resp.Status = api.ReadinessResponseStatusNotReady - resp.ErrorMessage = api.NewOptString("no dataset loaded") - } - - return resp, nil -} - -// NewError creates an ErrorStatusCode from an error returned by a handler. -func (h *Handler) NewError(ctx context.Context, err error) *api.ErrorStatusCode { - if statusErr, ok := err.(*api.ErrorStatusCode); ok { - return statusErr - } - - h.log.Error("unhandled error", zap.Error(err)) - return newError(http.StatusInternalServerError, err.Error()) -} - -func newError(status int, description string) *api.ErrorStatusCode { - return &api.ErrorStatusCode{ - StatusCode: status, - Response: api.Error{ - Error: api.ErrorError{ - Type: http.StatusText(status), - Description: description, - }, - }, - } -} diff --git a/internal/transport/rest/transport.go b/internal/transport/rest/transport.go deleted file mode 100644 index 3744270..0000000 --- a/internal/transport/rest/transport.go +++ /dev/null @@ -1,75 +0,0 @@ -package rest - -import ( - "context" - "fmt" - "net/http" - - "predictor-refactored/internal/transport/middleware" - "predictor-refactored/internal/transport/rest/handler" - api "predictor-refactored/pkg/rest" - - "go.uber.org/zap" -) - -// Transport wraps the ogen HTTP server. -type Transport struct { - srv *api.Server - handler *handler.Handler - port int - log *zap.Logger -} - -// New creates a new REST transport. -func New(h *handler.Handler, port int, log *zap.Logger) (*Transport, error) { - srv, err := api.NewServer( - h, - api.WithMiddleware(middleware.Logging(log)), - ) - if err != nil { - return nil, fmt.Errorf("create ogen server: %w", err) - } - - return &Transport{ - srv: srv, - handler: h, - port: port, - log: log, - }, nil -} - -// Run starts the HTTP server. Blocks until the server stops. -func (t *Transport) Run() error { - mux := http.NewServeMux() - mux.Handle("/", t.srv) - - httpSrv := &http.Server{ - Addr: fmt.Sprintf(":%d", t.port), - Handler: corsMiddleware(mux), - } - - t.log.Info("starting HTTP server", zap.Int("port", t.port)) - return httpSrv.ListenAndServe() -} - -// Shutdown gracefully stops the HTTP server. -func (t *Transport) Shutdown(ctx context.Context) error { - // The ogen server doesn't have a shutdown method; - // shutdown is handled by the http.Server in main.go - return nil -} - -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - - next.ServeHTTP(w, r) - }) -} diff --git a/internal/weather/gfs/constants.go b/internal/weather/gfs/constants.go new file mode 100644 index 0000000..bc7a288 --- /dev/null +++ b/internal/weather/gfs/constants.go @@ -0,0 +1,34 @@ +package gfs + +// Cross-variant constants. Per-variant geometry (latitudes, longitudes, +// pressure levels, hour step, max hour, URL token) lives on the Variant +// type; see variant.go. + +const ( + // NumVariables is the number of dataset variables: HGT, UGRD, VGRD. + NumVariables = 3 + // ElementSize is the cell size in bytes (float32). + ElementSize = 4 + + // LatStart is the first latitude in the cube (south to north). + LatStart = -90.0 + // LonStart is the first longitude in the cube (0..360 east). + LonStart = 0.0 + + // Variable indices within the cube's 3rd axis. + VarHeight = 0 + VarWindU = 1 + VarWindV = 2 +) + +// LevelSet identifies which GRIB file (primary or secondary) carries a +// pressure level. +type LevelSet int + +const ( + LevelSetA LevelSet = iota // pgrb2 — primary file + LevelSetB // pgrb2b — secondary file +) + +// S3BaseURL is the public NOAA S3 mirror. +const S3BaseURL = "https://noaa-gfs-bdp-pds.s3.amazonaws.com" diff --git a/internal/weather/gfs/file.go b/internal/weather/gfs/file.go new file mode 100644 index 0000000..08dae88 --- /dev/null +++ b/internal/weather/gfs/file.go @@ -0,0 +1,168 @@ +package gfs + +import ( + "encoding/binary" + "fmt" + "math" + "os" + "time" + + mmap "github.com/edsrzf/mmap-go" +) + +// File is an mmap-backed wind dataset file. The layout is a flat C-order +// row-major float32 array, shape (hour, level, variable, lat, lng), with +// the per-axis sizes coming from Variant. +type File struct { + variant *Variant + mm mmap.MMap + file *os.File + writable bool + // Epoch is the forecast run time (UTC) the file represents. + Epoch time.Time +} + +// Variant returns the Variant the file was created with. +func (d *File) Variant() *Variant { return d.variant } + +// Open opens an existing dataset file for reading. +func Open(path string, variant *Variant, epoch time.Time) (*File, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open dataset: %w", err) + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, fmt.Errorf("stat dataset: %w", err) + } + if info.Size() != variant.DatasetSize() { + f.Close() + return nil, fmt.Errorf("dataset should be %d bytes (was %d)", variant.DatasetSize(), info.Size()) + } + mm, err := mmap.Map(f, mmap.RDONLY, 0) + if err != nil { + f.Close() + return nil, fmt.Errorf("mmap dataset: %w", err) + } + return &File{variant: variant, mm: mm, file: f, writable: false, Epoch: epoch}, nil +} + +// Create creates a new dataset file sized for variant, mmap'd read-write. +func Create(path string, variant *Variant) (*File, error) { + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("create dataset: %w", err) + } + size := variant.DatasetSize() + if err := f.Truncate(size); err != nil { + f.Close() + return nil, fmt.Errorf("truncate dataset: %w", err) + } + mm, err := mmap.MapRegion(f, int(size), mmap.RDWR, 0, 0) + if err != nil { + f.Close() + return nil, fmt.Errorf("mmap dataset: %w", err) + } + return &File{variant: variant, mm: mm, file: f, writable: true}, nil +} + +// OpenWritable opens an existing dataset file for read-write access. Used +// when resuming a partial download. +func OpenWritable(path string, variant *Variant) (*File, error) { + f, err := os.OpenFile(path, os.O_RDWR, 0o644) + if err != nil { + return nil, fmt.Errorf("open dataset rw: %w", err) + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, fmt.Errorf("stat dataset: %w", err) + } + if info.Size() != variant.DatasetSize() { + f.Close() + return nil, fmt.Errorf("dataset should be %d bytes (was %d)", variant.DatasetSize(), info.Size()) + } + mm, err := mmap.MapRegion(f, int(info.Size()), mmap.RDWR, 0, 0) + if err != nil { + f.Close() + return nil, fmt.Errorf("mmap dataset: %w", err) + } + return &File{variant: variant, mm: mm, file: f, writable: true}, nil +} + +// offset returns the byte offset of the [hour][level][variable][lat][lng] cell. +func (d *File) offset(hour, level, variable, lat, lng int) int64 { + v := d.variant + idx := int64(hour) + idx = idx*int64(v.NumLevels()) + int64(level) + idx = idx*int64(NumVariables) + int64(variable) + idx = idx*int64(v.NumLatitudes()) + int64(lat) + idx = idx*int64(v.NumLongitudes()) + int64(lng) + return idx * int64(ElementSize) +} + +// Val reads one cell as a float32. +func (d *File) Val(hour, level, variable, lat, lng int) float32 { + off := d.offset(hour, level, variable, lat, lng) + return math.Float32frombits(binary.LittleEndian.Uint32(d.mm[off : off+4])) +} + +// ValByElem reads the float32 at a precomputed flat element index (not a byte +// offset). The wind sampler uses this to read the eight interpolation corners +// after computing their flat indices once via cube strides. +func (d *File) ValByElem(elem int64) float32 { + off := elem * ElementSize + return math.Float32frombits(binary.LittleEndian.Uint32(d.mm[off : off+4])) +} + +// SetVal writes one cell. Only valid on writable files. +func (d *File) SetVal(hour, level, variable, lat, lng int, val float32) { + off := d.offset(hour, level, variable, lat, lng) + binary.LittleEndian.PutUint32(d.mm[off:off+4], math.Float32bits(val)) +} + +// BlitGribData copies one decoded GRIB grid into the dataset, flipping the +// latitude axis from GRIB's north-to-south scan order to our south-to-north +// storage order. +func (d *File) BlitGribData(hourIdx, levelIdx, varIdx int, gribData []float64) error { + v := d.variant + expected := v.NumLatitudes() * v.NumLongitudes() + if len(gribData) != expected { + return fmt.Errorf("grib data has %d values, expected %d", len(gribData), expected) + } + lats := v.NumLatitudes() + lngs := v.NumLongitudes() + for lat := range lats { + for lng := range lngs { + gribIdx := (lats-1-lat)*lngs + lng + d.SetVal(hourIdx, levelIdx, varIdx, lat, lng, float32(gribData[gribIdx])) + } + } + return nil +} + +// Flush flushes the mmap to disk. +func (d *File) Flush() error { + if d.mm != nil { + return d.mm.Flush() + } + return nil +} + +// Close unmaps and closes the file. +func (d *File) Close() error { + if d.mm != nil { + if err := d.mm.Unmap(); err != nil { + d.file.Close() + return fmt.Errorf("unmap: %w", err) + } + d.mm = nil + } + if d.file != nil { + err := d.file.Close() + d.file = nil + return err + } + return nil +} diff --git a/internal/weather/gfs/gefs_variants.go b/internal/weather/gfs/gefs_variants.go new file mode 100644 index 0000000..a18a265 --- /dev/null +++ b/internal/weather/gfs/gefs_variants.go @@ -0,0 +1,68 @@ +package gfs + +import "fmt" + +// Family is the dataset family ("gfs" or "gefs"). Variants of different +// families have different URL layouts but share the cube format. +type Family int + +const ( + FamilyGFS Family = iota + FamilyGEFS +) + +func (f Family) String() string { + switch f { + case FamilyGEFS: + return "gefs" + default: + return "gfs" + } +} + +// HasMember reports whether the family requires a member index in URLs. +func (f Family) HasMember() bool { return f == FamilyGEFS } + +// GEFS variant constants. +// +// The 21-member ensemble is gec00 (control) + gep01..gep20 (perturbations). +// NOAA publishes more members today but 21 matches the historical Tawhiri +// configuration and is what the phase 2 spec calls for. +const GEFSMembers = 21 + +// GefsMemberName returns the file-name token for a GEFS member. +// member=0 → "gec00", member=1..20 → "gep01".."gep20". +func GefsMemberName(member int) string { + if member == 0 { + return "gec00" + } + return fmt.Sprintf("gep%02d", member) +} + +// GEFS S3 mirror. +const GEFSS3BaseURL = "https://noaa-gefs-pds.s3.amazonaws.com" + +// GefsGribURL returns the S3 URL for a GEFS primary GRIB file. +func GefsGribURL(date string, runHour, member, forecastStep int, resToken string) string { + return fmt.Sprintf("%s/gefs.%s/%02d/atmos/pgrb2ap5/%s.t%02dz.pgrb2a.%s.f%03d", + GEFSS3BaseURL, date, runHour, GefsMemberName(member), runHour, resToken, forecastStep) +} + +// GefsGribURLB returns the S3 URL for a GEFS secondary GRIB file. +func GefsGribURLB(date string, runHour, member, forecastStep int, resToken string) string { + return fmt.Sprintf("%s/gefs.%s/%02d/atmos/pgrb2bp5/%s.t%02dz.pgrb2b.%s.f%03d", + GEFSS3BaseURL, date, runHour, GefsMemberName(member), runHour, resToken, forecastStep) +} + +// GEFS variants — 0.5° resolution, 3-hour cadence, 192h horizon. +var GEFS0p50_3h = &Variant{ + ID: "gefs-0p50-3h", + Family: FamilyGEFS, + ResToken: "0p50", + Resolution: 0.5, + HourStep: 3, + MaxHour: 192, + Pressures: GFS0p50_3h.Pressures, + PressuresPgrb2: GFS0p50_3h.PressuresPgrb2, + PressuresPgrb2b: GFS0p50_3h.PressuresPgrb2b, +} diff --git a/internal/weather/gfs/variant.go b/internal/weather/gfs/variant.go new file mode 100644 index 0000000..e2c6443 --- /dev/null +++ b/internal/weather/gfs/variant.go @@ -0,0 +1,191 @@ +package gfs + +import "fmt" + +// Variant describes one configuration of a NOAA dataset family (GFS or GEFS). +// +// The dataset cube is a 5-D float32 array with shape +// (NumHours, NumLevels, NumVariables, NumLatitudes, NumLongitudes) where +// NumVariables and ElementSize are fixed across all GFS variants but the +// other dimensions depend on the resolution and forecast cadence. +type Variant struct { + // ID is a stable identifier ("gfs-0p50-3h", "gefs-0p50-3h", ...). + ID string + // Family identifies the dataset family the variant belongs to. + Family Family + + // Resolution token used in NOAA URLs ("0p50", "0p25"). + ResToken string + // Grid step in degrees (0.5, 0.25). 180 / Resolution + 1 latitudes and + // 360 / Resolution longitudes. + Resolution float64 + + HourStep int // hours between forecast steps + MaxHour int // largest forecast hour (inclusive) + + // Pressures lists every pressure level in dataset index order, descending. + Pressures []int + // PressuresPgrb2 / PressuresPgrb2b split the pressures between the two + // downloaded GRIB files. Their union must equal Pressures. + PressuresPgrb2 []int + PressuresPgrb2b []int + + pressureIndex map[int]int + pressureLevelSet map[int]LevelSet +} + +// NumHours returns MaxHour/HourStep + 1. +func (v *Variant) NumHours() int { return v.MaxHour/v.HourStep + 1 } + +// NumLevels returns len(Pressures). +func (v *Variant) NumLevels() int { return len(v.Pressures) } + +// NumLatitudes returns 180/Resolution + 1. +func (v *Variant) NumLatitudes() int { return int(180.0/v.Resolution) + 1 } + +// NumLongitudes returns 360/Resolution. +func (v *Variant) NumLongitudes() int { return int(360.0 / v.Resolution) } + +// DatasetSize returns the canonical file size in bytes. +func (v *Variant) DatasetSize() int64 { + return int64(v.NumHours()) * int64(v.NumLevels()) * int64(NumVariables) * + int64(v.NumLatitudes()) * int64(v.NumLongitudes()) * int64(ElementSize) +} + +// Hours returns the full list of forecast hours [0, HourStep, ..., MaxHour]. +func (v *Variant) Hours() []int { + out := make([]int, 0, v.NumHours()) + for h := 0; h <= v.MaxHour; h += v.HourStep { + out = append(out, h) + } + return out +} + +// HourIndex returns the dataset time index for an hour, or -1 if invalid. +func (v *Variant) HourIndex(hour int) int { + if hour < 0 || hour > v.MaxHour || hour%v.HourStep != 0 { + return -1 + } + return hour / v.HourStep +} + +// PressureIndex returns the dataset index for a pressure level in hPa, +// or -1 when the level is unknown to this variant. +func (v *Variant) PressureIndex(hPa int) int { + v.indexLazyInit() + if i, ok := v.pressureIndex[hPa]; ok { + return i + } + return -1 +} + +// PressureLevelSet returns the GRIB file set carrying a pressure level. +func (v *Variant) PressureLevelSet(hPa int) (LevelSet, bool) { + v.indexLazyInit() + ls, ok := v.pressureLevelSet[hPa] + return ls, ok +} + +// VariableIndex maps a GRIB (category, number) pair to a dataset variable index. +func (v *Variant) VariableIndex(parameterCategory, parameterNumber int) int { + switch { + case parameterCategory == 3 && parameterNumber == 5: + return VarHeight + case parameterCategory == 2 && parameterNumber == 2: + return VarWindU + case parameterCategory == 2 && parameterNumber == 3: + return VarWindV + default: + return -1 + } +} + +// GribURL returns the S3 URL for the primary (pgrb2) GRIB file. +func (v *Variant) GribURL(date string, runHour, forecastStep int) string { + return fmt.Sprintf("%s/gfs.%s/%02d/atmos/gfs.t%02dz.pgrb2.%s.f%03d", + S3BaseURL, date, runHour, runHour, v.ResToken, forecastStep) +} + +// GribURLB returns the S3 URL for the secondary (pgrb2b) GRIB file. +func (v *Variant) GribURLB(date string, runHour, forecastStep int) string { + return fmt.Sprintf("%s/gfs.%s/%02d/atmos/gfs.t%02dz.pgrb2b.%s.f%03d", + S3BaseURL, date, runHour, runHour, v.ResToken, forecastStep) +} + +func (v *Variant) indexLazyInit() { + if v.pressureIndex != nil { + return + } + v.pressureIndex = make(map[int]int, len(v.Pressures)) + for i, p := range v.Pressures { + v.pressureIndex[p] = i + } + v.pressureLevelSet = make(map[int]LevelSet, len(v.Pressures)) + for _, p := range v.PressuresPgrb2 { + v.pressureLevelSet[p] = LevelSetA + } + for _, p := range v.PressuresPgrb2b { + v.pressureLevelSet[p] = LevelSetB + } +} + +// Standard variants -- these mirror what NOAA publishes today. +// +// GFS0p50_3h is the historical Tawhiri default: 0.5° resolution, 3-hour +// forecast cadence, 0..192h horizon, 47 pressure levels split across the +// primary and secondary GRIB files. +// +// GFS0p25_3h mirrors the same 3-hour cadence at 0.25° resolution (the +// horizon is larger in practice but we keep 192h for parity with 0p50). +// +// GFS0p25_1h targets the 1-hourly portion NOAA publishes out to 120h. +var ( + GFS0p50_3h = &Variant{ + ID: "gfs-0p50-3h", + ResToken: "0p50", + Resolution: 0.5, + HourStep: 3, + MaxHour: 192, + Pressures: []int{1000, 975, 950, 925, 900, 875, 850, 825, 800, 775, 750, 725, 700, 675, 650, 625, 600, 575, 550, 525, 500, 475, 450, 425, 400, 375, 350, 325, 300, 275, 250, 225, 200, 175, 150, 125, 100, 70, 50, 30, 20, 10, 7, 5, 3, 2, 1}, + PressuresPgrb2: []int{10, 20, 30, 50, 70, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 925, 950, 975, 1000}, + PressuresPgrb2b: []int{1, 2, 3, 5, 7, 125, 175, 225, 275, 325, 375, 425, 475, 525, 575, 625, 675, 725, 775, 825, 875}, + } + + GFS0p25_3h = &Variant{ + ID: "gfs-0p25-3h", + ResToken: "0p25", + Resolution: 0.25, + HourStep: 3, + MaxHour: 192, + Pressures: GFS0p50_3h.Pressures, + PressuresPgrb2: GFS0p50_3h.PressuresPgrb2, + PressuresPgrb2b: GFS0p50_3h.PressuresPgrb2b, + } + + GFS0p25_1h = &Variant{ + ID: "gfs-0p25-1h", + ResToken: "0p25", + Resolution: 0.25, + HourStep: 1, + MaxHour: 120, + Pressures: GFS0p50_3h.Pressures, + PressuresPgrb2: GFS0p50_3h.PressuresPgrb2, + PressuresPgrb2b: GFS0p50_3h.PressuresPgrb2b, + } +) + +// VariantByID returns one of the predefined variants by its ID. +func VariantByID(id string) (*Variant, error) { + switch id { + case GFS0p50_3h.ID: + return GFS0p50_3h, nil + case GFS0p25_3h.ID: + return GFS0p25_3h, nil + case GFS0p25_1h.ID: + return GFS0p25_1h, nil + case GEFS0p50_3h.ID: + return GEFS0p50_3h, nil + default: + return nil, fmt.Errorf("unknown variant %q", id) + } +} diff --git a/internal/weather/gfs/wind.go b/internal/weather/gfs/wind.go new file mode 100644 index 0000000..082f572 --- /dev/null +++ b/internal/weather/gfs/wind.go @@ -0,0 +1,129 @@ +package gfs + +import ( + "time" + + "predictor-refactored/internal/numerics" + "predictor-refactored/internal/weather" +) + +// Wind is a WindField backed by a GFS dataset file. +// +// The cube is addressed in flat element units with fixed strides so the +// sampler can compute the eight horizontal interpolation corners once and +// reach any (level, variable) by adding constant strides — avoiding the +// five-multiply offset computation per corner per evaluation. +type Wind struct { + file *File + + hourAxis numerics.Axis + latAxis numerics.Axis + lngAxis numerics.Axis + + hourStride int64 // elements between successive hours + levelStride int64 // elements between successive pressure levels + varStride int64 // elements between successive variables + latStride int64 // elements between successive latitudes +} + +// NewWind returns a Wind backed by file. Axes and strides are derived from +// the file's variant geometry. +func NewWind(file *File) *Wind { + v := file.variant + nLat := v.NumLatitudes() + nLng := v.NumLongitudes() + nLev := v.NumLevels() + return &Wind{ + file: file, + hourAxis: numerics.Axis{Left: 0, Step: float64(v.HourStep), N: v.NumHours(), Name: "hour"}, + latAxis: numerics.Axis{Left: LatStart, Step: v.Resolution, N: nLat, Name: "lat"}, + lngAxis: numerics.Axis{Left: LonStart, Step: v.Resolution, N: nLng, Wrap: true, Name: "lng"}, + hourStride: int64(nLev) * NumVariables * int64(nLat) * int64(nLng), + levelStride: NumVariables * int64(nLat) * int64(nLng), + varStride: int64(nLat) * int64(nLng), + latStride: int64(nLng), + } +} + +// Epoch returns the forecast run time of the underlying file. +func (w *Wind) Epoch() time.Time { return w.file.Epoch } + +// Source returns the variant ID (e.g. "gfs-0p50-3h"). +func (w *Wind) Source() string { return w.file.variant.ID } + +// Close releases the underlying file's resources. +func (w *Wind) Close() error { return w.file.Close() } + +// Wind samples the field at the given UNIX time, geographic coordinate, and +// altitude. Vertical interpolation matches Tawhiri: locate the two pressure +// levels whose interpolated geopotential heights bracket alt, then linearly +// interpolate U and V between them. +func (w *Wind) Wind(t, lat, lng, alt float64) (weather.Sample, error) { + hours := (t - float64(w.file.Epoch.Unix())) / 3600.0 + + bh, err := w.hourAxis.Locate(hours) + if err != nil { + return weather.Sample{}, err + } + bla, err := w.latAxis.Locate(lat) + if err != nil { + return weather.Sample{}, err + } + bln, err := w.lngAxis.Locate(lng) + if err != nil { + return weather.Sample{}, err + } + + weights := numerics.TrilinearWeights([3]numerics.Bracket{bh, bla, bln}) + + // Flat element index of each of the eight horizontal corners, at level 0 + // variable 0, in the canonical TrilinearWeights order (hour outer, lng + // inner). Reaching a given (level, variable) corner only adds constant + // strides. + var base [8]int64 + hours2 := [2]int64{int64(bh.Lo) * w.hourStride, int64(bh.Hi) * w.hourStride} + lats2 := [2]int64{int64(bla.Lo) * w.latStride, int64(bla.Hi) * w.latStride} + lngs2 := [2]int64{int64(bln.Lo), int64(bln.Hi)} + i := 0 + for _, h := range hours2 { + for _, la := range lats2 { + for _, ln := range lngs2 { + base[i] = h + la + ln + i++ + } + } + } + + sample := func(level int, varIdx int64) float64 { + off := int64(level)*w.levelStride + varIdx*w.varStride + var vals [8]float64 + for k := range 8 { + vals[k] = float64(w.file.ValByElem(base[k] + off)) + } + return numerics.Dot8(&weights, &vals) + } + + // Largest pressure level whose interpolated geopotential height is below alt. + levelIdx := numerics.Bisect(0, w.file.variant.NumLevels()-2, alt, func(level int) float64 { + return sample(level, VarHeight) + }) + + lowerHGT := sample(levelIdx, VarHeight) + upperHGT := sample(levelIdx+1, VarHeight) + + altFrac := 0.5 + if lowerHGT != upperHGT { + altFrac = (upperHGT - alt) / (upperHGT - lowerHGT) + } + + lowerU := sample(levelIdx, VarWindU) + upperU := sample(levelIdx+1, VarWindU) + lowerV := sample(levelIdx, VarWindV) + upperV := sample(levelIdx+1, VarWindV) + + return weather.Sample{ + U: lowerU*altFrac + upperU*(1-altFrac), + V: lowerV*altFrac + upperV*(1-altFrac), + AboveModel: altFrac < 0, + }, nil +} diff --git a/internal/weather/gfs/wind_test.go b/internal/weather/gfs/wind_test.go new file mode 100644 index 0000000..7ef6dd7 --- /dev/null +++ b/internal/weather/gfs/wind_test.go @@ -0,0 +1,69 @@ +package gfs + +import ( + "math" + "path/filepath" + "testing" + "time" +) + +// testVariant is a tiny cube (2 hours × 3 levels × 3 lat × 4 lng) used to +// exercise the sampler without allocating a multi-gigabyte real dataset. +func testVariant() *Variant { + return &Variant{ + ID: "gfs-test", + ResToken: "test", + Resolution: 90, // 180/90+1 = 3 lats, 360/90 = 4 lngs + HourStep: 3, + MaxHour: 3, // 2 hours + Pressures: []int{1000, 500, 100}, + PressuresPgrb2: []int{1000, 500, 100}, + PressuresPgrb2b: []int{}, + } +} + +func TestWindSampler(t *testing.T) { + v := testVariant() + path := filepath.Join(t.TempDir(), "cube.bin") + f, err := Create(path, v) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // HGT increases with level so the altitude bisection has a gradient; + // U and V are constant so interpolation must return them exactly. + for h := range v.NumHours() { + for lvl := range v.NumLevels() { + for la := range v.NumLatitudes() { + for ln := range v.NumLongitudes() { + f.SetVal(h, lvl, VarHeight, la, ln, float32(lvl*1000)) + f.SetVal(h, lvl, VarWindU, la, ln, 7) + f.SetVal(h, lvl, VarWindV, la, ln, 3) + } + } + } + } + f.Flush() + f.Close() + + epoch := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + rf, err := Open(path, v, epoch) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer rf.Close() + w := NewWind(rf) + + // Query at the dataset epoch, equator, lng 45, altitude 500m (between + // level 0 @ 0m and level 1 @ 1000m). + s, err := w.Wind(float64(epoch.Unix()), 0, 45, 500) + if err != nil { + t.Fatalf("Wind: %v", err) + } + if math.Abs(s.U-7) > 1e-5 || math.Abs(s.V-3) > 1e-5 { + t.Errorf("constant wind not recovered: got U=%v V=%v, want 7,3", s.U, s.V) + } + if s.AboveModel { + t.Errorf("AboveModel should be false at altitude within model range") + } +} diff --git a/internal/weather/types.go b/internal/weather/types.go new file mode 100644 index 0000000..68ff4a6 --- /dev/null +++ b/internal/weather/types.go @@ -0,0 +1,37 @@ +// Package weather defines the abstract interface trajectory engines use +// to sample atmospheric data, and contains source-specific implementations +// in its subpackages. +package weather + +import "time" + +// Sample is the result of sampling a wind field at one point. +type Sample struct { + // U is the eastward wind component in m/s. + U float64 + // V is the northward wind component in m/s. + V float64 + // AboveModel is set when the query altitude was above the highest + // pressure level represented in the underlying dataset. The returned + // U/V values are linear extrapolations and should be treated as unreliable. + AboveModel bool +} + +// WindField provides 3D wind data interpolated at arbitrary points. +// +// Implementations must be safe for concurrent use. +type WindField interface { + // Wind samples the field at (t, lat, lng, alt). + // + // t is UNIX seconds. lat is in degrees, -90 to +90. lng is in degrees, + // 0 to 360 (callers must normalize). alt is metres above mean sea level. + // + // Returns an error if any coordinate is outside the field's domain. + Wind(t, lat, lng, alt float64) (Sample, error) + + // Epoch returns the time the field is anchored to (forecast run time). + Epoch() time.Time + + // Source identifies the underlying dataset for logs and metrics. + Source() string +} diff --git a/internal/windviz/cache.go b/internal/windviz/cache.go new file mode 100644 index 0000000..c32d578 --- /dev/null +++ b/internal/windviz/cache.go @@ -0,0 +1,63 @@ +package windviz + +import ( + "sync" + "time" +) + +// Cache is a small bounded cache of rasterized fields keyed by request +// parameters and dataset epoch. It is safe for concurrent use. +// +// Visualization requests repeat heavily (a frontend re-fetches the same +// layer as users pan within a tile), so even a tiny cache removes most +// recomputation. Eviction is simplest-possible: when full, the whole map is +// cleared. Entries also expire after TTL. +type Cache struct { + mu sync.Mutex + entries map[string]cacheEntry + max int + ttl time.Duration + now func() time.Time +} + +type cacheEntry struct { + field Field + expires time.Time +} + +// NewCache returns a cache holding up to max entries for ttl each. +func NewCache(max int, ttl time.Duration) *Cache { + if max <= 0 { + max = 64 + } + if ttl <= 0 { + ttl = 10 * time.Minute + } + return &Cache{ + entries: make(map[string]cacheEntry, max), + max: max, + ttl: ttl, + now: time.Now, + } +} + +// Get returns the cached field for key, if present and unexpired. +func (c *Cache) Get(key string) (Field, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[key] + if !ok || c.now().After(e.expires) { + return nil, false + } + return e.field, true +} + +// Put stores field under key. +func (c *Cache) Put(key string, field Field) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.entries) >= c.max { + c.entries = make(map[string]cacheEntry, c.max) + } + c.entries[key] = cacheEntry{field: field, expires: c.now().Add(c.ttl)} +} diff --git a/internal/windviz/windviz.go b/internal/windviz/windviz.go new file mode 100644 index 0000000..bec386a --- /dev/null +++ b/internal/windviz/windviz.go @@ -0,0 +1,179 @@ +// Package windviz rasterizes a weather.WindField into the JSON grid format +// consumed by browser velocity layers such as leaflet-velocity and +// wind-layer (the "gfs.json" / wind-js-server format). +// +// The module is decoupled from any specific dataset: it samples any +// weather.WindField on a regular latitude/longitude grid at a chosen time +// and altitude, downsampling by a configurable step to bound payload size. +package windviz + +import ( + "fmt" + "time" + + "predictor-refactored/internal/weather" +) + +// Request describes a wind-field rasterization. +type Request struct { + // Time is the forecast time to sample (UNIX seconds). Sampling outside + // the field's temporal coverage returns an error. + Time float64 + // Altitude is the altitude in metres to sample at. + Altitude float64 + // Bounding box in degrees. Latitudes in [-90, 90]; longitudes in + // [0, 360). For a global field use 0..360 (the rasterizer drops the + // duplicate 360° column). + MinLat, MaxLat float64 + MinLng, MaxLng float64 + // Step is the grid resolution in degrees (e.g. 1.0). Smaller is denser. + Step float64 +} + +// Component is one wind-js-server record: a header plus a flat data grid. +type Component struct { + Header Header `json:"header"` + Data []float64 `json:"data"` +} + +// Header is the wind-js-server grid header. Field names and semantics match +// what leaflet-velocity / wind-layer expect. +type Header struct { + ParameterCategory int `json:"parameterCategory"` + ParameterNumber int `json:"parameterNumber"` + ParameterNumberName string `json:"parameterNumberName"` + ParameterUnit string `json:"parameterUnit"` + Nx int `json:"nx"` + Ny int `json:"ny"` + Lo1 float64 `json:"lo1"` + La1 float64 `json:"la1"` + Lo2 float64 `json:"lo2"` + La2 float64 `json:"la2"` + Dx float64 `json:"dx"` + Dy float64 `json:"dy"` + RefTime string `json:"refTime"` + ForecastTime int `json:"forecastTime"` +} + +// Field is the two-component (U then V) payload. JSON-encoding a Field +// produces the array the velocity layers consume directly. +type Field []Component + +const ( + defaultStep = 1.0 + minStep = 0.25 // clamp to bound output size + maxCells = 1 << 21 +) + +// Rasterize samples field over req and returns the U/V grid payload. +// +// Data is laid out in wind-js scan order: row 0 is the northernmost +// latitude (la1), each row runs west→east, longitudes increasing. Per-cell +// sampling errors (e.g. altitude outside the model) are written as 0 rather +// than failing the whole request; a time outside coverage is a hard error. +func Rasterize(field weather.WindField, req Request) (Field, error) { + step := req.Step + if step <= 0 { + step = defaultStep + } + if step < minStep { + step = minStep + } + + minLat, maxLat := req.MinLat, req.MaxLat + minLng, maxLng := req.MinLng, req.MaxLng + if minLat == 0 && maxLat == 0 { + minLat, maxLat = -90, 90 + } + if minLng == 0 && maxLng == 0 { + minLng, maxLng = 0, 360 + } + if maxLat <= minLat { + return nil, fmt.Errorf("invalid bounding box latitude") + } + + // Longitudes may arrive in either the [0, 360) or the [-180, 180] + // convention (the latter is what the rest of the API emits). Detect a + // full-globe span first, then fold a regional box's western edge into + // [0, 360); per-cell sampling re-folds via normLng so an eastern edge + // past 360° still reads the correct column. + lngSpan := maxLng - minLng + if lngSpan <= 0 { + return nil, fmt.Errorf("invalid bounding box longitude") + } + global := lngSpan >= 360-1e-9 + var nx int + if global { + // Drop the duplicate wrap column so the layer tiles cleanly. + minLng = 0 + nx = int(360/step + 0.5) + maxLng = float64(nx-1) * step + } else { + minLng = normLng(minLng) + maxLng = minLng + lngSpan + nx = int(lngSpan/step+0.5) + 1 + } + ny := int((maxLat-minLat)/step+0.5) + 1 + if nx < 1 || ny < 1 { + return nil, fmt.Errorf("empty grid") + } + if nx*ny > maxCells { + return nil, fmt.Errorf("grid too large (%d cells); increase step or shrink bbox", nx*ny) + } + + u := make([]float64, nx*ny) + v := make([]float64, nx*ny) + + // Row 0 = north (la1); rows descend in latitude. + for j := range ny { + lat := maxLat - float64(j)*step + for i := range nx { + lng := minLng + float64(i)*step + s, err := field.Wind(req.Time, lat, normLng(lng), req.Altitude) + idx := j*nx + i + if err != nil { + continue // leave as 0 + } + u[idx] = s.U + v[idx] = s.V + } + } + + refTime := time.Unix(int64(req.Time), 0).UTC().Format("2006-01-02T15:04:05.000Z") + mk := func(num int, name string, data []float64) Component { + return Component{ + Header: Header{ + ParameterCategory: 2, + ParameterNumber: num, + ParameterNumberName: name, + ParameterUnit: "m.s-1", + Nx: nx, + Ny: ny, + Lo1: minLng, + La1: maxLat, + Lo2: maxLng, + La2: minLat, + Dx: step, + Dy: step, + RefTime: refTime, + ForecastTime: 0, + }, + Data: data, + } + } + return Field{ + mk(2, "eastward_wind", u), + mk(3, "northward_wind", v), + }, nil +} + +// normLng folds a longitude into [0, 360) for sampling. +func normLng(lng float64) float64 { + for lng < 0 { + lng += 360 + } + for lng >= 360 { + lng -= 360 + } + return lng +} diff --git a/internal/windviz/windviz_test.go b/internal/windviz/windviz_test.go new file mode 100644 index 0000000..521cbdb --- /dev/null +++ b/internal/windviz/windviz_test.go @@ -0,0 +1,96 @@ +package windviz + +import ( + "testing" + "time" + + "predictor-refactored/internal/weather" +) + +// constWind is a WindField returning a fixed sample everywhere. +type constWind struct { + u, v float64 + epoch time.Time +} + +func (c constWind) Wind(_ float64, _, _, _ float64) (weather.Sample, error) { + return weather.Sample{U: c.u, V: c.v}, nil +} +func (c constWind) Epoch() time.Time { return c.epoch } +func (c constWind) Source() string { return "test" } + +func TestRasterizeGlobalDropsDuplicateColumn(t *testing.T) { + f := constWind{u: 5, v: -3, epoch: time.Unix(0, 0)} + out, err := Rasterize(f, Request{MinLng: 0, MaxLng: 360, Step: 90}) + if err != nil { + t.Fatalf("Rasterize: %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 components, got %d", len(out)) + } + u := out[0] + // 360/90 = 4 columns (no duplicate 360°); lat -90..90 step 90 = 3 rows. + if u.Header.Nx != 4 || u.Header.Ny != 3 { + t.Errorf("grid = %dx%d, want 4x3", u.Header.Nx, u.Header.Ny) + } + if len(u.Data) != 12 { + t.Errorf("data len = %d, want 12", len(u.Data)) + } + if u.Header.La1 != 90 || u.Header.La2 != -90 { + t.Errorf("lat range = %v..%v, want 90..-90 (north first)", u.Header.La1, u.Header.La2) + } + if u.Header.Lo1 != 0 || u.Header.Lo2 != 270 { + t.Errorf("lng range = %v..%v, want 0..270", u.Header.Lo1, u.Header.Lo2) + } + for _, d := range u.Data { + if d != 5 { + t.Errorf("U data = %v, want 5", d) + break + } + } + if out[0].Header.ParameterNumber != 2 || out[1].Header.ParameterNumber != 3 { + t.Errorf("component order should be U(2) then V(3)") + } +} + +func TestRasterizeSignedLongitudeConvention(t *testing.T) { + f := constWind{u: 1, v: 2, epoch: time.Unix(0, 0)} + + // A [-180, 180] global request must be detected as global and tiled + // without a duplicate seam column, identical to a 0..360 request. + signed, err := Rasterize(f, Request{MinLng: -180, MaxLng: 180, Step: 90}) + if err != nil { + t.Fatalf("signed-global Rasterize: %v", err) + } + if signed[0].Header.Nx != 4 { + t.Errorf("signed-global nx = %d, want 4 (no duplicate column)", signed[0].Header.Nx) + } + + // A western-hemisphere box must not 400; its western edge folds into [0,360). + west, err := Rasterize(f, Request{MinLat: 10, MaxLat: 20, MinLng: -100, MaxLng: -50, Step: 10}) + if err != nil { + t.Fatalf("western-box Rasterize: %v", err) + } + if west[0].Header.Lo1 != 260 { + t.Errorf("western-box lo1 = %v, want 260 (=-100 folded)", west[0].Header.Lo1) + } +} + +func TestRasterizeStepClamp(t *testing.T) { + f := constWind{epoch: time.Unix(0, 0)} + // step below min gets clamped, not rejected. + if _, err := Rasterize(f, Request{MinLat: -1, MaxLat: 1, MinLng: 0, MaxLng: 2, Step: 0.01}); err != nil { + t.Fatalf("Rasterize with tiny step: %v", err) + } +} + +func TestCacheRoundTrip(t *testing.T) { + c := NewCache(2, time.Minute) + if _, ok := c.Get("a"); ok { + t.Errorf("empty cache should miss") + } + c.Put("a", Field{}) + if _, ok := c.Get("a"); !ok { + t.Errorf("cache should hit after put") + } +} diff --git a/pkg/rest/oas_client_gen.go b/pkg/rest/oas_client_gen.go index 02e95c3..b7dad2f 100644 --- a/pkg/rest/oas_client_gen.go +++ b/pkg/rest/oas_client_gen.go @@ -27,18 +27,96 @@ func trimTrailingSlashes(u *url.URL) { // Invoker invokes operations described by OpenAPI v3 specification. type Invoker interface { + // CancelDatasetJob invokes cancelDatasetJob operation. + // + // Cancel a running download job. + // + // DELETE /api/v1/admin/jobs/{id} + CancelDatasetJob(ctx context.Context, params CancelDatasetJobParams) error + // CancelPredictionJob invokes cancelPredictionJob operation. + // + // Cancel a queued prediction job. + // + // DELETE /api/v1/predictions/{id} + CancelPredictionJob(ctx context.Context, params CancelPredictionJobParams) error + // CreatePredictionJob invokes createPredictionJob operation. + // + // Enqueue an asynchronous prediction. + // + // POST /api/v1/predictions + CreatePredictionJob(ctx context.Context, request *PredictionV2Request) (*PredictionJob, error) + // DeleteDataset invokes deleteDataset operation. + // + // Delete a stored dataset by filename. + // + // DELETE /api/v1/admin/datasets/{name} + DeleteDataset(ctx context.Context, params DeleteDatasetParams) error + // GetDatasetJob invokes getDatasetJob operation. + // + // Get a dataset download job. + // + // GET /api/v1/admin/jobs/{id} + GetDatasetJob(ctx context.Context, params GetDatasetJobParams) (*DownloadJob, error) + // GetPredictionJob invokes getPredictionJob operation. + // + // Poll an asynchronous prediction job. + // + // GET /api/v1/predictions/{id} + GetPredictionJob(ctx context.Context, params GetPredictionJobParams) (*PredictionJob, error) + // GetServiceStatus invokes getServiceStatus operation. + // + // Service status summary. + // + // GET /api/v1/admin/status + GetServiceStatus(ctx context.Context) (*StatusResponse, error) + // GetWindField invokes getWindField operation. + // + // Wind-field velocity grid (leaflet-velocity / wind-layer format). + // + // GET /api/v1/wind/field + GetWindField(ctx context.Context, params GetWindFieldParams) ([]WindComponent, error) + // GetWindMeta invokes getWindMeta operation. + // + // Wind-field visualization metadata. + // + // GET /api/v1/wind/meta + GetWindMeta(ctx context.Context) (*WindMeta, error) + // ListDatasetJobs invokes listDatasetJobs operation. + // + // List dataset download jobs. + // + // GET /api/v1/admin/jobs + ListDatasetJobs(ctx context.Context) ([]DownloadJob, error) + // ListDatasets invokes listDatasets operation. + // + // List stored datasets. + // + // GET /api/v1/admin/datasets + ListDatasets(ctx context.Context) (*DatasetList, error) // PerformPrediction invokes performPrediction operation. // - // Perform prediction. + // Tawhiri-compatible prediction. // // GET /api/v1/prediction PerformPrediction(ctx context.Context, params PerformPredictionParams) (*PredictionResponse, error) + // PerformPredictionV2 invokes performPredictionV2 operation. + // + // Profile-driven prediction (synchronous). + // + // POST /api/v2/prediction + PerformPredictionV2(ctx context.Context, request *PredictionV2Request) (*PredictionV2Response, error) // ReadinessCheck invokes readinessCheck operation. // // Readiness check. // // GET /ready ReadinessCheck(ctx context.Context) (*ReadinessResponse, error) + // TriggerDatasetDownload invokes triggerDatasetDownload operation. + // + // Trigger a dataset download. + // + // POST /api/v1/admin/datasets + TriggerDatasetDownload(ctx context.Context, request *DownloadRequest) (*DownloadAccepted, error) } // Client implements OAS client. @@ -80,9 +158,1039 @@ func (c *Client) requestURL(ctx context.Context) *url.URL { return u } +// CancelDatasetJob invokes cancelDatasetJob operation. +// +// Cancel a running download job. +// +// DELETE /api/v1/admin/jobs/{id} +func (c *Client) CancelDatasetJob(ctx context.Context, params CancelDatasetJobParams) error { + _, err := c.sendCancelDatasetJob(ctx, params) + return err +} + +func (c *Client) sendCancelDatasetJob(ctx context.Context, params CancelDatasetJobParams) (res *CancelDatasetJobNoContent, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("cancelDatasetJob"), + semconv.HTTPRequestMethodKey.String("DELETE"), + semconv.URLTemplateKey.String("/api/v1/admin/jobs/{id}"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, CancelDatasetJobOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [2]string + pathParts[0] = "/api/v1/admin/jobs/" + { + // Encode "id" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "id", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.StringToString(params.ID)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "DELETE", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeCancelDatasetJobResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// CancelPredictionJob invokes cancelPredictionJob operation. +// +// Cancel a queued prediction job. +// +// DELETE /api/v1/predictions/{id} +func (c *Client) CancelPredictionJob(ctx context.Context, params CancelPredictionJobParams) error { + _, err := c.sendCancelPredictionJob(ctx, params) + return err +} + +func (c *Client) sendCancelPredictionJob(ctx context.Context, params CancelPredictionJobParams) (res *CancelPredictionJobNoContent, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("cancelPredictionJob"), + semconv.HTTPRequestMethodKey.String("DELETE"), + semconv.URLTemplateKey.String("/api/v1/predictions/{id}"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, CancelPredictionJobOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [2]string + pathParts[0] = "/api/v1/predictions/" + { + // Encode "id" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "id", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.StringToString(params.ID)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "DELETE", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeCancelPredictionJobResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// CreatePredictionJob invokes createPredictionJob operation. +// +// Enqueue an asynchronous prediction. +// +// POST /api/v1/predictions +func (c *Client) CreatePredictionJob(ctx context.Context, request *PredictionV2Request) (*PredictionJob, error) { + res, err := c.sendCreatePredictionJob(ctx, request) + return res, err +} + +func (c *Client) sendCreatePredictionJob(ctx context.Context, request *PredictionV2Request) (res *PredictionJob, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("createPredictionJob"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.URLTemplateKey.String("/api/v1/predictions"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, CreatePredictionJobOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/api/v1/predictions" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + if err := encodeCreatePredictionJobRequest(request, r); err != nil { + return res, errors.Wrap(err, "encode request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeCreatePredictionJobResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// DeleteDataset invokes deleteDataset operation. +// +// Delete a stored dataset by filename. +// +// DELETE /api/v1/admin/datasets/{name} +func (c *Client) DeleteDataset(ctx context.Context, params DeleteDatasetParams) error { + _, err := c.sendDeleteDataset(ctx, params) + return err +} + +func (c *Client) sendDeleteDataset(ctx context.Context, params DeleteDatasetParams) (res *DeleteDatasetNoContent, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("deleteDataset"), + semconv.HTTPRequestMethodKey.String("DELETE"), + semconv.URLTemplateKey.String("/api/v1/admin/datasets/{name}"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, DeleteDatasetOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [2]string + pathParts[0] = "/api/v1/admin/datasets/" + { + // Encode "name" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "name", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.StringToString(params.Name)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "DELETE", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeDeleteDatasetResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// GetDatasetJob invokes getDatasetJob operation. +// +// Get a dataset download job. +// +// GET /api/v1/admin/jobs/{id} +func (c *Client) GetDatasetJob(ctx context.Context, params GetDatasetJobParams) (*DownloadJob, error) { + res, err := c.sendGetDatasetJob(ctx, params) + return res, err +} + +func (c *Client) sendGetDatasetJob(ctx context.Context, params GetDatasetJobParams) (res *DownloadJob, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getDatasetJob"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.URLTemplateKey.String("/api/v1/admin/jobs/{id}"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, GetDatasetJobOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [2]string + pathParts[0] = "/api/v1/admin/jobs/" + { + // Encode "id" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "id", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.StringToString(params.ID)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeGetDatasetJobResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// GetPredictionJob invokes getPredictionJob operation. +// +// Poll an asynchronous prediction job. +// +// GET /api/v1/predictions/{id} +func (c *Client) GetPredictionJob(ctx context.Context, params GetPredictionJobParams) (*PredictionJob, error) { + res, err := c.sendGetPredictionJob(ctx, params) + return res, err +} + +func (c *Client) sendGetPredictionJob(ctx context.Context, params GetPredictionJobParams) (res *PredictionJob, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getPredictionJob"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.URLTemplateKey.String("/api/v1/predictions/{id}"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, GetPredictionJobOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [2]string + pathParts[0] = "/api/v1/predictions/" + { + // Encode "id" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "id", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.StringToString(params.ID)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeGetPredictionJobResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// GetServiceStatus invokes getServiceStatus operation. +// +// Service status summary. +// +// GET /api/v1/admin/status +func (c *Client) GetServiceStatus(ctx context.Context) (*StatusResponse, error) { + res, err := c.sendGetServiceStatus(ctx) + return res, err +} + +func (c *Client) sendGetServiceStatus(ctx context.Context) (res *StatusResponse, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getServiceStatus"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.URLTemplateKey.String("/api/v1/admin/status"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, GetServiceStatusOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/api/v1/admin/status" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeGetServiceStatusResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// GetWindField invokes getWindField operation. +// +// Wind-field velocity grid (leaflet-velocity / wind-layer format). +// +// GET /api/v1/wind/field +func (c *Client) GetWindField(ctx context.Context, params GetWindFieldParams) ([]WindComponent, error) { + res, err := c.sendGetWindField(ctx, params) + return res, err +} + +func (c *Client) sendGetWindField(ctx context.Context, params GetWindFieldParams) (res []WindComponent, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getWindField"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.URLTemplateKey.String("/api/v1/wind/field"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, GetWindFieldOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/api/v1/wind/field" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeQueryParams" + q := uri.NewQueryEncoder() + { + // Encode "time" parameter. + cfg := uri.QueryParameterEncodingConfig{ + Name: "time", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.EncodeParam(cfg, func(e uri.Encoder) error { + if val, ok := params.Time.Get(); ok { + return e.EncodeValue(conv.DateTimeToString(val)) + } + return nil + }); err != nil { + return res, errors.Wrap(err, "encode query") + } + } + { + // Encode "altitude" parameter. + cfg := uri.QueryParameterEncodingConfig{ + Name: "altitude", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.EncodeParam(cfg, func(e uri.Encoder) error { + if val, ok := params.Altitude.Get(); ok { + return e.EncodeValue(conv.Float64ToString(val)) + } + return nil + }); err != nil { + return res, errors.Wrap(err, "encode query") + } + } + { + // Encode "min_lat" parameter. + cfg := uri.QueryParameterEncodingConfig{ + Name: "min_lat", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.EncodeParam(cfg, func(e uri.Encoder) error { + if val, ok := params.MinLat.Get(); ok { + return e.EncodeValue(conv.Float64ToString(val)) + } + return nil + }); err != nil { + return res, errors.Wrap(err, "encode query") + } + } + { + // Encode "max_lat" parameter. + cfg := uri.QueryParameterEncodingConfig{ + Name: "max_lat", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.EncodeParam(cfg, func(e uri.Encoder) error { + if val, ok := params.MaxLat.Get(); ok { + return e.EncodeValue(conv.Float64ToString(val)) + } + return nil + }); err != nil { + return res, errors.Wrap(err, "encode query") + } + } + { + // Encode "min_lng" parameter. + cfg := uri.QueryParameterEncodingConfig{ + Name: "min_lng", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.EncodeParam(cfg, func(e uri.Encoder) error { + if val, ok := params.MinLng.Get(); ok { + return e.EncodeValue(conv.Float64ToString(val)) + } + return nil + }); err != nil { + return res, errors.Wrap(err, "encode query") + } + } + { + // Encode "max_lng" parameter. + cfg := uri.QueryParameterEncodingConfig{ + Name: "max_lng", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.EncodeParam(cfg, func(e uri.Encoder) error { + if val, ok := params.MaxLng.Get(); ok { + return e.EncodeValue(conv.Float64ToString(val)) + } + return nil + }); err != nil { + return res, errors.Wrap(err, "encode query") + } + } + { + // Encode "step" parameter. + cfg := uri.QueryParameterEncodingConfig{ + Name: "step", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.EncodeParam(cfg, func(e uri.Encoder) error { + if val, ok := params.Step.Get(); ok { + return e.EncodeValue(conv.Float64ToString(val)) + } + return nil + }); err != nil { + return res, errors.Wrap(err, "encode query") + } + } + u.RawQuery = q.Values().Encode() + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeGetWindFieldResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// GetWindMeta invokes getWindMeta operation. +// +// Wind-field visualization metadata. +// +// GET /api/v1/wind/meta +func (c *Client) GetWindMeta(ctx context.Context) (*WindMeta, error) { + res, err := c.sendGetWindMeta(ctx) + return res, err +} + +func (c *Client) sendGetWindMeta(ctx context.Context) (res *WindMeta, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getWindMeta"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.URLTemplateKey.String("/api/v1/wind/meta"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, GetWindMetaOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/api/v1/wind/meta" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeGetWindMetaResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// ListDatasetJobs invokes listDatasetJobs operation. +// +// List dataset download jobs. +// +// GET /api/v1/admin/jobs +func (c *Client) ListDatasetJobs(ctx context.Context) ([]DownloadJob, error) { + res, err := c.sendListDatasetJobs(ctx) + return res, err +} + +func (c *Client) sendListDatasetJobs(ctx context.Context) (res []DownloadJob, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("listDatasetJobs"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.URLTemplateKey.String("/api/v1/admin/jobs"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, ListDatasetJobsOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/api/v1/admin/jobs" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeListDatasetJobsResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// ListDatasets invokes listDatasets operation. +// +// List stored datasets. +// +// GET /api/v1/admin/datasets +func (c *Client) ListDatasets(ctx context.Context) (*DatasetList, error) { + res, err := c.sendListDatasets(ctx) + return res, err +} + +func (c *Client) sendListDatasets(ctx context.Context) (res *DatasetList, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("listDatasets"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.URLTemplateKey.String("/api/v1/admin/datasets"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, ListDatasetsOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/api/v1/admin/datasets" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeListDatasetsResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + // PerformPrediction invokes performPrediction operation. // -// Perform prediction. +// Tawhiri-compatible prediction. // // GET /api/v1/prediction func (c *Client) PerformPrediction(ctx context.Context, params PerformPredictionParams) (*PredictionResponse, error) { @@ -336,6 +1444,83 @@ func (c *Client) sendPerformPrediction(ctx context.Context, params PerformPredic return result, nil } +// PerformPredictionV2 invokes performPredictionV2 operation. +// +// Profile-driven prediction (synchronous). +// +// POST /api/v2/prediction +func (c *Client) PerformPredictionV2(ctx context.Context, request *PredictionV2Request) (*PredictionV2Response, error) { + res, err := c.sendPerformPredictionV2(ctx, request) + return res, err +} + +func (c *Client) sendPerformPredictionV2(ctx context.Context, request *PredictionV2Request) (res *PredictionV2Response, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("performPredictionV2"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.URLTemplateKey.String("/api/v2/prediction"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, PerformPredictionV2Operation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/api/v2/prediction" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + if err := encodePerformPredictionV2Request(request, r); err != nil { + return res, errors.Wrap(err, "encode request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodePerformPredictionV2Response(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + // ReadinessCheck invokes readinessCheck operation. // // Readiness check. @@ -409,3 +1594,80 @@ func (c *Client) sendReadinessCheck(ctx context.Context) (res *ReadinessResponse return result, nil } + +// TriggerDatasetDownload invokes triggerDatasetDownload operation. +// +// Trigger a dataset download. +// +// POST /api/v1/admin/datasets +func (c *Client) TriggerDatasetDownload(ctx context.Context, request *DownloadRequest) (*DownloadAccepted, error) { + res, err := c.sendTriggerDatasetDownload(ctx, request) + return res, err +} + +func (c *Client) sendTriggerDatasetDownload(ctx context.Context, request *DownloadRequest) (res *DownloadAccepted, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("triggerDatasetDownload"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.URLTemplateKey.String("/api/v1/admin/datasets"), + } + otelAttrs = append(otelAttrs, c.cfg.Attributes...) + + // Run stopwatch. + startTime := time.Now() + defer func() { + // Use floating point division here for higher precision (instead of Millisecond method). + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), metric.WithAttributes(otelAttrs...)) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, TriggerDatasetDownloadOperation, + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, metric.WithAttributes(otelAttrs...)) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/api/v1/admin/datasets" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u) + if err != nil { + return res, errors.Wrap(err, "create request") + } + if err := encodeTriggerDatasetDownloadRequest(request, r); err != nil { + return res, errors.Wrap(err, "encode request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + body := resp.Body + defer body.Close() + + stage = "DecodeResponse" + result, err := decodeTriggerDatasetDownloadResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} diff --git a/pkg/rest/oas_defaults_gen.go b/pkg/rest/oas_defaults_gen.go new file mode 100644 index 0000000..a40f023 --- /dev/null +++ b/pkg/rest/oas_defaults_gen.go @@ -0,0 +1,27 @@ +// Code generated by ogen, DO NOT EDIT. + +package rest + +// setDefaults set default value of fields. +func (s *ConstraintSpec) setDefaults() { + { + val := ConstraintSpecAction("stop") + s.Action.SetTo(val) + } +} + +// setDefaults set default value of fields. +func (s *PiecewiseSegment) setDefaults() { + { + val := PiecewiseSegmentReference("absolute") + s.Reference.SetTo(val) + } +} + +// setDefaults set default value of fields. +func (s *PredictionV2Request) setDefaults() { + { + val := PredictionV2RequestDirection("forward") + s.Direction.SetTo(val) + } +} diff --git a/pkg/rest/oas_handlers_gen.go b/pkg/rest/oas_handlers_gen.go index d41771a..4867c13 100644 --- a/pkg/rest/oas_handlers_gen.go +++ b/pkg/rest/oas_handlers_gen.go @@ -33,9 +33,1651 @@ func (c *codeRecorder) Unwrap() http.ResponseWriter { return c.ResponseWriter } +// handleCancelDatasetJobRequest handles cancelDatasetJob operation. +// +// Cancel a running download job. +// +// DELETE /api/v1/admin/jobs/{id} +func (s *Server) handleCancelDatasetJobRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("cancelDatasetJob"), + semconv.HTTPRequestMethodKey.String("DELETE"), + semconv.HTTPRouteKey.String("/api/v1/admin/jobs/{id}"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), CancelDatasetJobOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: CancelDatasetJobOperation, + ID: "cancelDatasetJob", + } + ) + params, err := decodeCancelDatasetJobParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var rawBody []byte + + var response *CancelDatasetJobNoContent + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: CancelDatasetJobOperation, + OperationSummary: "Cancel a running download job", + OperationID: "cancelDatasetJob", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{ + { + Name: "id", + In: "path", + }: params.ID, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = CancelDatasetJobParams + Response = *CancelDatasetJobNoContent + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackCancelDatasetJobParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + err = s.h.CancelDatasetJob(ctx, params) + return response, err + }, + ) + } else { + err = s.h.CancelDatasetJob(ctx, params) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeCancelDatasetJobResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleCancelPredictionJobRequest handles cancelPredictionJob operation. +// +// Cancel a queued prediction job. +// +// DELETE /api/v1/predictions/{id} +func (s *Server) handleCancelPredictionJobRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("cancelPredictionJob"), + semconv.HTTPRequestMethodKey.String("DELETE"), + semconv.HTTPRouteKey.String("/api/v1/predictions/{id}"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), CancelPredictionJobOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: CancelPredictionJobOperation, + ID: "cancelPredictionJob", + } + ) + params, err := decodeCancelPredictionJobParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var rawBody []byte + + var response *CancelPredictionJobNoContent + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: CancelPredictionJobOperation, + OperationSummary: "Cancel a queued prediction job", + OperationID: "cancelPredictionJob", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{ + { + Name: "id", + In: "path", + }: params.ID, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = CancelPredictionJobParams + Response = *CancelPredictionJobNoContent + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackCancelPredictionJobParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + err = s.h.CancelPredictionJob(ctx, params) + return response, err + }, + ) + } else { + err = s.h.CancelPredictionJob(ctx, params) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeCancelPredictionJobResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleCreatePredictionJobRequest handles createPredictionJob operation. +// +// Enqueue an asynchronous prediction. +// +// POST /api/v1/predictions +func (s *Server) handleCreatePredictionJobRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("createPredictionJob"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/api/v1/predictions"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), CreatePredictionJobOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: CreatePredictionJobOperation, + ID: "createPredictionJob", + } + ) + + var rawBody []byte + request, rawBody, close, err := s.decodeCreatePredictionJobRequest(r) + if err != nil { + err = &ogenerrors.DecodeRequestError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeRequest", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + defer func() { + if err := close(); err != nil { + recordError("CloseRequest", err) + } + }() + + var response *PredictionJob + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: CreatePredictionJobOperation, + OperationSummary: "Enqueue an asynchronous prediction", + OperationID: "createPredictionJob", + Body: request, + RawBody: rawBody, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = *PredictionV2Request + Params = struct{} + Response = *PredictionJob + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.CreatePredictionJob(ctx, request) + return response, err + }, + ) + } else { + response, err = s.h.CreatePredictionJob(ctx, request) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeCreatePredictionJobResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleDeleteDatasetRequest handles deleteDataset operation. +// +// Delete a stored dataset by filename. +// +// DELETE /api/v1/admin/datasets/{name} +func (s *Server) handleDeleteDatasetRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("deleteDataset"), + semconv.HTTPRequestMethodKey.String("DELETE"), + semconv.HTTPRouteKey.String("/api/v1/admin/datasets/{name}"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), DeleteDatasetOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: DeleteDatasetOperation, + ID: "deleteDataset", + } + ) + params, err := decodeDeleteDatasetParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var rawBody []byte + + var response *DeleteDatasetNoContent + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: DeleteDatasetOperation, + OperationSummary: "Delete a stored dataset by filename", + OperationID: "deleteDataset", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{ + { + Name: "name", + In: "path", + }: params.Name, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = DeleteDatasetParams + Response = *DeleteDatasetNoContent + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackDeleteDatasetParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + err = s.h.DeleteDataset(ctx, params) + return response, err + }, + ) + } else { + err = s.h.DeleteDataset(ctx, params) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeDeleteDatasetResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleGetDatasetJobRequest handles getDatasetJob operation. +// +// Get a dataset download job. +// +// GET /api/v1/admin/jobs/{id} +func (s *Server) handleGetDatasetJobRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getDatasetJob"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/api/v1/admin/jobs/{id}"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), GetDatasetJobOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: GetDatasetJobOperation, + ID: "getDatasetJob", + } + ) + params, err := decodeGetDatasetJobParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var rawBody []byte + + var response *DownloadJob + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: GetDatasetJobOperation, + OperationSummary: "Get a dataset download job", + OperationID: "getDatasetJob", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{ + { + Name: "id", + In: "path", + }: params.ID, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = GetDatasetJobParams + Response = *DownloadJob + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackGetDatasetJobParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.GetDatasetJob(ctx, params) + return response, err + }, + ) + } else { + response, err = s.h.GetDatasetJob(ctx, params) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeGetDatasetJobResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleGetPredictionJobRequest handles getPredictionJob operation. +// +// Poll an asynchronous prediction job. +// +// GET /api/v1/predictions/{id} +func (s *Server) handleGetPredictionJobRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getPredictionJob"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/api/v1/predictions/{id}"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), GetPredictionJobOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: GetPredictionJobOperation, + ID: "getPredictionJob", + } + ) + params, err := decodeGetPredictionJobParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var rawBody []byte + + var response *PredictionJob + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: GetPredictionJobOperation, + OperationSummary: "Poll an asynchronous prediction job", + OperationID: "getPredictionJob", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{ + { + Name: "id", + In: "path", + }: params.ID, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = GetPredictionJobParams + Response = *PredictionJob + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackGetPredictionJobParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.GetPredictionJob(ctx, params) + return response, err + }, + ) + } else { + response, err = s.h.GetPredictionJob(ctx, params) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeGetPredictionJobResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleGetServiceStatusRequest handles getServiceStatus operation. +// +// Service status summary. +// +// GET /api/v1/admin/status +func (s *Server) handleGetServiceStatusRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getServiceStatus"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/api/v1/admin/status"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), GetServiceStatusOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + ) + + var rawBody []byte + + var response *StatusResponse + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: GetServiceStatusOperation, + OperationSummary: "Service status summary", + OperationID: "getServiceStatus", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = struct{} + Params = struct{} + Response = *StatusResponse + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.GetServiceStatus(ctx) + return response, err + }, + ) + } else { + response, err = s.h.GetServiceStatus(ctx) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeGetServiceStatusResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleGetWindFieldRequest handles getWindField operation. +// +// Wind-field velocity grid (leaflet-velocity / wind-layer format). +// +// GET /api/v1/wind/field +func (s *Server) handleGetWindFieldRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getWindField"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/api/v1/wind/field"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), GetWindFieldOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: GetWindFieldOperation, + ID: "getWindField", + } + ) + params, err := decodeGetWindFieldParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var rawBody []byte + + var response []WindComponent + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: GetWindFieldOperation, + OperationSummary: "Wind-field velocity grid (leaflet-velocity / wind-layer format)", + OperationID: "getWindField", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{ + { + Name: "time", + In: "query", + }: params.Time, + { + Name: "altitude", + In: "query", + }: params.Altitude, + { + Name: "min_lat", + In: "query", + }: params.MinLat, + { + Name: "max_lat", + In: "query", + }: params.MaxLat, + { + Name: "min_lng", + In: "query", + }: params.MinLng, + { + Name: "max_lng", + In: "query", + }: params.MaxLng, + { + Name: "step", + In: "query", + }: params.Step, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = GetWindFieldParams + Response = []WindComponent + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackGetWindFieldParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.GetWindField(ctx, params) + return response, err + }, + ) + } else { + response, err = s.h.GetWindField(ctx, params) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeGetWindFieldResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleGetWindMetaRequest handles getWindMeta operation. +// +// Wind-field visualization metadata. +// +// GET /api/v1/wind/meta +func (s *Server) handleGetWindMetaRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getWindMeta"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/api/v1/wind/meta"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), GetWindMetaOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + ) + + var rawBody []byte + + var response *WindMeta + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: GetWindMetaOperation, + OperationSummary: "Wind-field visualization metadata", + OperationID: "getWindMeta", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = struct{} + Params = struct{} + Response = *WindMeta + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.GetWindMeta(ctx) + return response, err + }, + ) + } else { + response, err = s.h.GetWindMeta(ctx) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeGetWindMetaResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleListDatasetJobsRequest handles listDatasetJobs operation. +// +// List dataset download jobs. +// +// GET /api/v1/admin/jobs +func (s *Server) handleListDatasetJobsRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("listDatasetJobs"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/api/v1/admin/jobs"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), ListDatasetJobsOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + ) + + var rawBody []byte + + var response []DownloadJob + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: ListDatasetJobsOperation, + OperationSummary: "List dataset download jobs", + OperationID: "listDatasetJobs", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = struct{} + Params = struct{} + Response = []DownloadJob + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.ListDatasetJobs(ctx) + return response, err + }, + ) + } else { + response, err = s.h.ListDatasetJobs(ctx) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeListDatasetJobsResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + +// handleListDatasetsRequest handles listDatasets operation. +// +// List stored datasets. +// +// GET /api/v1/admin/datasets +func (s *Server) handleListDatasetsRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("listDatasets"), + semconv.HTTPRequestMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/api/v1/admin/datasets"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), ListDatasetsOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + ) + + var rawBody []byte + + var response *DatasetList + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: ListDatasetsOperation, + OperationSummary: "List stored datasets", + OperationID: "listDatasets", + Body: nil, + RawBody: rawBody, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = struct{} + Params = struct{} + Response = *DatasetList + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.ListDatasets(ctx) + return response, err + }, + ) + } else { + response, err = s.h.ListDatasets(ctx) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeListDatasetsResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + // handlePerformPredictionRequest handles performPrediction operation. // -// Perform prediction. +// Tawhiri-compatible prediction. // // GET /api/v1/prediction func (s *Server) handlePerformPredictionRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { @@ -127,7 +1769,7 @@ func (s *Server) handlePerformPredictionRequest(args [0]string, argsEscaped bool mreq := middleware.Request{ Context: ctx, OperationName: PerformPredictionOperation, - OperationSummary: "Perform prediction", + OperationSummary: "Tawhiri-compatible prediction", OperationID: "performPrediction", Body: nil, RawBody: rawBody, @@ -202,7 +1844,7 @@ func (s *Server) handlePerformPredictionRequest(args [0]string, argsEscaped bool response, err = s.h.PerformPrediction(ctx, params) } if err != nil { - if errRes, ok := errors.Into[*ErrorStatusCode](err); ok { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { if err := encodeErrorResponse(errRes, w, span); err != nil { defer recordError("Internal", err) } @@ -227,6 +1869,160 @@ func (s *Server) handlePerformPredictionRequest(args [0]string, argsEscaped bool } } +// handlePerformPredictionV2Request handles performPredictionV2 operation. +// +// Profile-driven prediction (synchronous). +// +// POST /api/v2/prediction +func (s *Server) handlePerformPredictionV2Request(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("performPredictionV2"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/api/v2/prediction"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), PerformPredictionV2Operation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: PerformPredictionV2Operation, + ID: "performPredictionV2", + } + ) + + var rawBody []byte + request, rawBody, close, err := s.decodePerformPredictionV2Request(r) + if err != nil { + err = &ogenerrors.DecodeRequestError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeRequest", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + defer func() { + if err := close(); err != nil { + recordError("CloseRequest", err) + } + }() + + var response *PredictionV2Response + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: PerformPredictionV2Operation, + OperationSummary: "Profile-driven prediction (synchronous)", + OperationID: "performPredictionV2", + Body: request, + RawBody: rawBody, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = *PredictionV2Request + Params = struct{} + Response = *PredictionV2Response + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.PerformPredictionV2(ctx, request) + return response, err + }, + ) + } else { + response, err = s.h.PerformPredictionV2(ctx, request) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodePerformPredictionV2Response(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} + // handleReadinessCheckRequest handles readinessCheck operation. // // Readiness check. @@ -337,7 +2133,7 @@ func (s *Server) handleReadinessCheckRequest(args [0]string, argsEscaped bool, w response, err = s.h.ReadinessCheck(ctx) } if err != nil { - if errRes, ok := errors.Into[*ErrorStatusCode](err); ok { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { if err := encodeErrorResponse(errRes, w, span); err != nil { defer recordError("Internal", err) } @@ -361,3 +2157,157 @@ func (s *Server) handleReadinessCheckRequest(args [0]string, argsEscaped bool, w return } } + +// handleTriggerDatasetDownloadRequest handles triggerDatasetDownload operation. +// +// Trigger a dataset download. +// +// POST /api/v1/admin/datasets +func (s *Server) handleTriggerDatasetDownloadRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + statusWriter := &codeRecorder{ResponseWriter: w} + w = statusWriter + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("triggerDatasetDownload"), + semconv.HTTPRequestMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/api/v1/admin/datasets"), + } + // Add attributes from config. + otelAttrs = append(otelAttrs, s.cfg.Attributes...) + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), TriggerDatasetDownloadOperation, + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Add Labeler to context. + labeler := &Labeler{attrs: otelAttrs} + ctx = contextWithLabeler(ctx, labeler) + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + code := statusWriter.status + if code != 0 { + codeAttr := semconv.HTTPResponseStatusCode(code) + attrs = append(attrs, codeAttr) + span.SetAttributes(codeAttr) + } + attrOpt := metric.WithAttributes(attrs...) + + // Increment request counter. + s.requests.Add(ctx, 1, attrOpt) + + // Use floating point division here for higher precision (instead of Millisecond method). + s.duration.Record(ctx, float64(elapsedDuration)/float64(time.Millisecond), attrOpt) + }() + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + // Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, + // unless there was another error (e.g., network error receiving the response body; or 3xx codes with + // max redirects exceeded), in which case status MUST be set to Error. + code := statusWriter.status + if code < 100 || code >= 500 { + span.SetStatus(codes.Error, stage) + } + + attrSet := labeler.AttributeSet() + attrs := attrSet.ToSlice() + if code != 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(code)) + } + + s.errors.Add(ctx, 1, metric.WithAttributes(attrs...)) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: TriggerDatasetDownloadOperation, + ID: "triggerDatasetDownload", + } + ) + + var rawBody []byte + request, rawBody, close, err := s.decodeTriggerDatasetDownloadRequest(r) + if err != nil { + err = &ogenerrors.DecodeRequestError{ + OperationContext: opErrContext, + Err: err, + } + defer recordError("DecodeRequest", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + defer func() { + if err := close(); err != nil { + recordError("CloseRequest", err) + } + }() + + var response *DownloadAccepted + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: TriggerDatasetDownloadOperation, + OperationSummary: "Trigger a dataset download", + OperationID: "triggerDatasetDownload", + Body: request, + RawBody: rawBody, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = *DownloadRequest + Params = struct{} + Response = *DownloadAccepted + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.TriggerDatasetDownload(ctx, request) + return response, err + }, + ) + } else { + response, err = s.h.TriggerDatasetDownload(ctx, request) + } + if err != nil { + if errRes, ok := errors.Into[*DefaultErrorStatusCode](err); ok { + if err := encodeErrorResponse(errRes, w, span); err != nil { + defer recordError("Internal", err) + } + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + if err := encodeErrorResponse(s.h.NewError(ctx, err), w, span); err != nil { + defer recordError("Internal", err) + } + return + } + + if err := encodeTriggerDatasetDownloadResponse(response, w, span); err != nil { + defer recordError("EncodeResponse", err) + if !errors.Is(err, ht.ErrInternalServerErrorResponse) { + s.cfg.ErrorHandler(ctx, w, r, err) + } + return + } +} diff --git a/pkg/rest/oas_json_gen.go b/pkg/rest/oas_json_gen.go index 8fa8634..21c5d84 100644 --- a/pkg/rest/oas_json_gen.go +++ b/pkg/rest/oas_json_gen.go @@ -13,6 +13,1418 @@ import ( "github.com/ogen-go/ogen/validate" ) +// Encode implements json.Marshaler. +func (s *ConstraintSpec) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *ConstraintSpec) encodeFields(e *jx.Encoder) { + { + e.FieldStart("type") + s.Type.Encode(e) + } + { + if s.Op.Set { + e.FieldStart("op") + s.Op.Encode(e) + } + } + { + if s.Limit.Set { + e.FieldStart("limit") + s.Limit.Encode(e) + } + } + { + if s.Action.Set { + e.FieldStart("action") + s.Action.Encode(e) + } + } + { + if s.Mode.Set { + e.FieldStart("mode") + s.Mode.Encode(e) + } + } + { + if s.Label.Set { + e.FieldStart("label") + s.Label.Encode(e) + } + } + { + if s.Vertices != nil { + e.FieldStart("vertices") + e.ArrStart() + for _, elem := range s.Vertices { + elem.Encode(e) + } + e.ArrEnd() + } + } +} + +var jsonFieldsNameOfConstraintSpec = [7]string{ + 0: "type", + 1: "op", + 2: "limit", + 3: "action", + 4: "mode", + 5: "label", + 6: "vertices", +} + +// Decode decodes ConstraintSpec from json. +func (s *ConstraintSpec) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ConstraintSpec to nil") + } + var requiredBitSet [1]uint8 + s.setDefaults() + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "type": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Type.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"type\"") + } + case "op": + if err := func() error { + s.Op.Reset() + if err := s.Op.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"op\"") + } + case "limit": + if err := func() error { + s.Limit.Reset() + if err := s.Limit.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"limit\"") + } + case "action": + if err := func() error { + s.Action.Reset() + if err := s.Action.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"action\"") + } + case "mode": + if err := func() error { + s.Mode.Reset() + if err := s.Mode.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"mode\"") + } + case "label": + if err := func() error { + s.Label.Reset() + if err := s.Label.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"label\"") + } + case "vertices": + if err := func() error { + s.Vertices = make([]PolygonVertex, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem PolygonVertex + if err := elem.Decode(d); err != nil { + return err + } + s.Vertices = append(s.Vertices, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"vertices\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode ConstraintSpec") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfConstraintSpec) { + name = jsonFieldsNameOfConstraintSpec[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *ConstraintSpec) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ConstraintSpec) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ConstraintSpecAction as json. +func (s ConstraintSpecAction) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes ConstraintSpecAction from json. +func (s *ConstraintSpecAction) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ConstraintSpecAction to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch ConstraintSpecAction(v) { + case ConstraintSpecActionStop: + *s = ConstraintSpecActionStop + case ConstraintSpecActionFallback: + *s = ConstraintSpecActionFallback + case ConstraintSpecActionClip: + *s = ConstraintSpecActionClip + default: + *s = ConstraintSpecAction(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s ConstraintSpecAction) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ConstraintSpecAction) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ConstraintSpecMode as json. +func (s ConstraintSpecMode) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes ConstraintSpecMode from json. +func (s *ConstraintSpecMode) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ConstraintSpecMode to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch ConstraintSpecMode(v) { + case ConstraintSpecModeInside: + *s = ConstraintSpecModeInside + case ConstraintSpecModeOutside: + *s = ConstraintSpecModeOutside + default: + *s = ConstraintSpecMode(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s ConstraintSpecMode) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ConstraintSpecMode) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ConstraintSpecOp as json. +func (s ConstraintSpecOp) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes ConstraintSpecOp from json. +func (s *ConstraintSpecOp) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ConstraintSpecOp to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch ConstraintSpecOp(v) { + case ConstraintSpecOpLess: + *s = ConstraintSpecOpLess + case ConstraintSpecOpLessEq: + *s = ConstraintSpecOpLessEq + case ConstraintSpecOpGreater: + *s = ConstraintSpecOpGreater + case ConstraintSpecOpGreaterEq: + *s = ConstraintSpecOpGreaterEq + case ConstraintSpecOpEqEq: + *s = ConstraintSpecOpEqEq + default: + *s = ConstraintSpecOp(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s ConstraintSpecOp) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ConstraintSpecOp) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ConstraintSpecType as json. +func (s ConstraintSpecType) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes ConstraintSpecType from json. +func (s *ConstraintSpecType) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ConstraintSpecType to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch ConstraintSpecType(v) { + case ConstraintSpecTypeAltitude: + *s = ConstraintSpecTypeAltitude + case ConstraintSpecTypeTime: + *s = ConstraintSpecTypeTime + case ConstraintSpecTypeTerrainContact: + *s = ConstraintSpecTypeTerrainContact + case ConstraintSpecTypePolygon: + *s = ConstraintSpecTypePolygon + default: + *s = ConstraintSpecType(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s ConstraintSpecType) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ConstraintSpecType) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *Coverage) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *Coverage) encodeFields(e *jx.Encoder) { + { + e.FieldStart("region") + s.Region.Encode(e) + } + { + e.FieldStart("start_time") + json.EncodeDateTime(e, s.StartTime) + } + { + e.FieldStart("end_time") + json.EncodeDateTime(e, s.EndTime) + } +} + +var jsonFieldsNameOfCoverage = [3]string{ + 0: "region", + 1: "start_time", + 2: "end_time", +} + +// Decode decodes Coverage from json. +func (s *Coverage) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Coverage to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "region": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Region.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"region\"") + } + case "start_time": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.StartTime = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"start_time\"") + } + case "end_time": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.EndTime = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"end_time\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode Coverage") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfCoverage) { + name = jsonFieldsNameOfCoverage[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *Coverage) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Coverage) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *DatasetEntry) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *DatasetEntry) encodeFields(e *jx.Encoder) { + { + e.FieldStart("filename") + e.Str(s.Filename) + } + { + e.FieldStart("epoch") + json.EncodeDateTime(e, s.Epoch) + } + { + if s.Subset.Set { + e.FieldStart("subset") + s.Subset.Encode(e) + } + } + { + if s.Coverage.Set { + e.FieldStart("coverage") + s.Coverage.Encode(e) + } + } + { + e.FieldStart("loaded") + e.Bool(s.Loaded) + } +} + +var jsonFieldsNameOfDatasetEntry = [5]string{ + 0: "filename", + 1: "epoch", + 2: "subset", + 3: "coverage", + 4: "loaded", +} + +// Decode decodes DatasetEntry from json. +func (s *DatasetEntry) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode DatasetEntry to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "filename": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Filename = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"filename\"") + } + case "epoch": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.Epoch = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"epoch\"") + } + case "subset": + if err := func() error { + s.Subset.Reset() + if err := s.Subset.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"subset\"") + } + case "coverage": + if err := func() error { + s.Coverage.Reset() + if err := s.Coverage.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"coverage\"") + } + case "loaded": + requiredBitSet[0] |= 1 << 4 + if err := func() error { + v, err := d.Bool() + s.Loaded = bool(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"loaded\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode DatasetEntry") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00010011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfDatasetEntry) { + name = jsonFieldsNameOfDatasetEntry[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *DatasetEntry) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *DatasetEntry) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *DatasetInfo) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *DatasetInfo) encodeFields(e *jx.Encoder) { + { + e.FieldStart("source") + e.Str(s.Source) + } + { + e.FieldStart("epoch") + json.EncodeDateTime(e, s.Epoch) + } +} + +var jsonFieldsNameOfDatasetInfo = [2]string{ + 0: "source", + 1: "epoch", +} + +// Decode decodes DatasetInfo from json. +func (s *DatasetInfo) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode DatasetInfo to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "source": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Source = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"source\"") + } + case "epoch": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.Epoch = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"epoch\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode DatasetInfo") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfDatasetInfo) { + name = jsonFieldsNameOfDatasetInfo[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *DatasetInfo) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *DatasetInfo) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *DatasetList) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *DatasetList) encodeFields(e *jx.Encoder) { + { + e.FieldStart("source") + e.Str(s.Source) + } + { + e.FieldStart("datasets") + e.ArrStart() + for _, elem := range s.Datasets { + elem.Encode(e) + } + e.ArrEnd() + } +} + +var jsonFieldsNameOfDatasetList = [2]string{ + 0: "source", + 1: "datasets", +} + +// Decode decodes DatasetList from json. +func (s *DatasetList) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode DatasetList to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "source": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Source = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"source\"") + } + case "datasets": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + s.Datasets = make([]DatasetEntry, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem DatasetEntry + if err := elem.Decode(d); err != nil { + return err + } + s.Datasets = append(s.Datasets, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"datasets\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode DatasetList") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfDatasetList) { + name = jsonFieldsNameOfDatasetList[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *DatasetList) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *DatasetList) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *DownloadAccepted) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *DownloadAccepted) encodeFields(e *jx.Encoder) { + { + e.FieldStart("job_id") + e.Str(s.JobID) + } +} + +var jsonFieldsNameOfDownloadAccepted = [1]string{ + 0: "job_id", +} + +// Decode decodes DownloadAccepted from json. +func (s *DownloadAccepted) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode DownloadAccepted to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "job_id": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.JobID = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"job_id\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode DownloadAccepted") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfDownloadAccepted) { + name = jsonFieldsNameOfDownloadAccepted[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *DownloadAccepted) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *DownloadAccepted) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *DownloadJob) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *DownloadJob) encodeFields(e *jx.Encoder) { + { + e.FieldStart("id") + e.Str(s.ID) + } + { + e.FieldStart("source") + e.Str(s.Source) + } + { + e.FieldStart("dataset") + e.Str(s.Dataset) + } + { + e.FieldStart("epoch") + json.EncodeDateTime(e, s.Epoch) + } + { + e.FieldStart("status") + s.Status.Encode(e) + } + { + e.FieldStart("started_at") + json.EncodeDateTime(e, s.StartedAt) + } + { + if s.EndedAt.Set { + e.FieldStart("ended_at") + s.EndedAt.Encode(e, json.EncodeDateTime) + } + } + { + if s.Error.Set { + e.FieldStart("error") + s.Error.Encode(e) + } + } + { + e.FieldStart("total_units") + e.Int(s.TotalUnits) + } + { + e.FieldStart("done_units") + e.Int(s.DoneUnits) + } + { + e.FieldStart("bytes") + e.Int64(s.Bytes) + } +} + +var jsonFieldsNameOfDownloadJob = [11]string{ + 0: "id", + 1: "source", + 2: "dataset", + 3: "epoch", + 4: "status", + 5: "started_at", + 6: "ended_at", + 7: "error", + 8: "total_units", + 9: "done_units", + 10: "bytes", +} + +// Decode decodes DownloadJob from json. +func (s *DownloadJob) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode DownloadJob to nil") + } + var requiredBitSet [2]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "id": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.ID = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"id\"") + } + case "source": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Source = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"source\"") + } + case "dataset": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Str() + s.Dataset = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"dataset\"") + } + case "epoch": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.Epoch = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"epoch\"") + } + case "status": + requiredBitSet[0] |= 1 << 4 + if err := func() error { + if err := s.Status.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"status\"") + } + case "started_at": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.StartedAt = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"started_at\"") + } + case "ended_at": + if err := func() error { + s.EndedAt.Reset() + if err := s.EndedAt.Decode(d, json.DecodeDateTime); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"ended_at\"") + } + case "error": + if err := func() error { + s.Error.Reset() + if err := s.Error.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"error\"") + } + case "total_units": + requiredBitSet[1] |= 1 << 0 + if err := func() error { + v, err := d.Int() + s.TotalUnits = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"total_units\"") + } + case "done_units": + requiredBitSet[1] |= 1 << 1 + if err := func() error { + v, err := d.Int() + s.DoneUnits = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"done_units\"") + } + case "bytes": + requiredBitSet[1] |= 1 << 2 + if err := func() error { + v, err := d.Int64() + s.Bytes = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"bytes\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode DownloadJob") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [2]uint8{ + 0b00111111, + 0b00000111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfDownloadJob) { + name = jsonFieldsNameOfDownloadJob[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *DownloadJob) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *DownloadJob) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes DownloadJobStatus as json. +func (s DownloadJobStatus) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes DownloadJobStatus from json. +func (s *DownloadJobStatus) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode DownloadJobStatus to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch DownloadJobStatus(v) { + case DownloadJobStatusPending: + *s = DownloadJobStatusPending + case DownloadJobStatusRunning: + *s = DownloadJobStatusRunning + case DownloadJobStatusComplete: + *s = DownloadJobStatusComplete + case DownloadJobStatusFailed: + *s = DownloadJobStatusFailed + case DownloadJobStatusCancelled: + *s = DownloadJobStatusCancelled + default: + *s = DownloadJobStatus(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s DownloadJobStatus) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *DownloadJobStatus) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *DownloadRequest) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *DownloadRequest) encodeFields(e *jx.Encoder) { + { + if s.Epoch.Set { + e.FieldStart("epoch") + s.Epoch.Encode(e, json.EncodeDateTime) + } + } + { + if s.Latest.Set { + e.FieldStart("latest") + s.Latest.Encode(e) + } + } + { + if s.Subset.Set { + e.FieldStart("subset") + s.Subset.Encode(e) + } + } +} + +var jsonFieldsNameOfDownloadRequest = [3]string{ + 0: "epoch", + 1: "latest", + 2: "subset", +} + +// Decode decodes DownloadRequest from json. +func (s *DownloadRequest) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode DownloadRequest to nil") + } + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "epoch": + if err := func() error { + s.Epoch.Reset() + if err := s.Epoch.Decode(d, json.DecodeDateTime); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"epoch\"") + } + case "latest": + if err := func() error { + s.Latest.Reset() + if err := s.Latest.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"latest\"") + } + case "subset": + if err := func() error { + s.Subset.Reset() + if err := s.Subset.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"subset\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode DownloadRequest") + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *DownloadRequest) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *DownloadRequest) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *Error) Encode(e *jx.Encoder) { e.ObjStart() @@ -220,6 +1632,978 @@ func (s *ErrorError) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode implements json.Marshaler. +func (s *EventSummary) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *EventSummary) encodeFields(e *jx.Encoder) { + { + e.FieldStart("type") + e.Str(s.Type) + } + { + e.FieldStart("count") + e.Int64(s.Count) + } + { + if s.FirstTime.Set { + e.FieldStart("first_time") + s.FirstTime.Encode(e) + } + } + { + if s.LastTime.Set { + e.FieldStart("last_time") + s.LastTime.Encode(e) + } + } + { + if s.FirstState.Set { + e.FieldStart("first_state") + s.FirstState.Encode(e) + } + } + { + if s.LastState.Set { + e.FieldStart("last_state") + s.LastState.Encode(e) + } + } + { + if s.Message.Set { + e.FieldStart("message") + s.Message.Encode(e) + } + } +} + +var jsonFieldsNameOfEventSummary = [7]string{ + 0: "type", + 1: "count", + 2: "first_time", + 3: "last_time", + 4: "first_state", + 5: "last_state", + 6: "message", +} + +// Decode decodes EventSummary from json. +func (s *EventSummary) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode EventSummary to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "type": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Type = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"type\"") + } + case "count": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Int64() + s.Count = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"count\"") + } + case "first_time": + if err := func() error { + s.FirstTime.Reset() + if err := s.FirstTime.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"first_time\"") + } + case "last_time": + if err := func() error { + s.LastTime.Reset() + if err := s.LastTime.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"last_time\"") + } + case "first_state": + if err := func() error { + s.FirstState.Reset() + if err := s.FirstState.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"first_state\"") + } + case "last_state": + if err := func() error { + s.LastState.Reset() + if err := s.LastState.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"last_state\"") + } + case "message": + if err := func() error { + s.Message.Reset() + if err := s.Message.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"message\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode EventSummary") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfEventSummary) { + name = jsonFieldsNameOfEventSummary[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *EventSummary) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *EventSummary) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *GeoState) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *GeoState) encodeFields(e *jx.Encoder) { + { + e.FieldStart("lat") + e.Float64(s.Lat) + } + { + e.FieldStart("lng") + e.Float64(s.Lng) + } + { + e.FieldStart("altitude") + e.Float64(s.Altitude) + } +} + +var jsonFieldsNameOfGeoState = [3]string{ + 0: "lat", + 1: "lng", + 2: "altitude", +} + +// Decode decodes GeoState from json. +func (s *GeoState) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode GeoState to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "lat": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Float64() + s.Lat = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"lat\"") + } + case "lng": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Float64() + s.Lng = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"lng\"") + } + case "altitude": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Float64() + s.Altitude = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"altitude\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode GeoState") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfGeoState) { + name = jsonFieldsNameOfGeoState[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *GeoState) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *GeoState) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *HourRange) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *HourRange) encodeFields(e *jx.Encoder) { + { + e.FieldStart("min_hour") + e.Int(s.MinHour) + } + { + e.FieldStart("max_hour") + e.Int(s.MaxHour) + } +} + +var jsonFieldsNameOfHourRange = [2]string{ + 0: "min_hour", + 1: "max_hour", +} + +// Decode decodes HourRange from json. +func (s *HourRange) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode HourRange to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "min_hour": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Int() + s.MinHour = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"min_hour\"") + } + case "max_hour": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Int() + s.MaxHour = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"max_hour\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode HourRange") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfHourRange) { + name = jsonFieldsNameOfHourRange[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *HourRange) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *HourRange) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *Launch) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *Launch) encodeFields(e *jx.Encoder) { + { + e.FieldStart("time") + json.EncodeDateTime(e, s.Time) + } + { + e.FieldStart("latitude") + e.Float64(s.Latitude) + } + { + e.FieldStart("longitude") + e.Float64(s.Longitude) + } + { + if s.Altitude.Set { + e.FieldStart("altitude") + s.Altitude.Encode(e) + } + } +} + +var jsonFieldsNameOfLaunch = [4]string{ + 0: "time", + 1: "latitude", + 2: "longitude", + 3: "altitude", +} + +// Decode decodes Launch from json. +func (s *Launch) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Launch to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "time": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.Time = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"time\"") + } + case "latitude": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Float64() + s.Latitude = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"latitude\"") + } + case "longitude": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Float64() + s.Longitude = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"longitude\"") + } + case "altitude": + if err := func() error { + s.Altitude.Reset() + if err := s.Altitude.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"altitude\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode Launch") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfLaunch) { + name = jsonFieldsNameOfLaunch[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *Launch) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Launch) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *ModelSpec) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *ModelSpec) encodeFields(e *jx.Encoder) { + { + e.FieldStart("type") + s.Type.Encode(e) + } + { + if s.Rate.Set { + e.FieldStart("rate") + s.Rate.Encode(e) + } + } + { + if s.SeaLevelRate.Set { + e.FieldStart("sea_level_rate") + s.SeaLevelRate.Encode(e) + } + } + { + if s.IncludeWind.Set { + e.FieldStart("include_wind") + s.IncludeWind.Encode(e) + } + } + { + if s.Segments != nil { + e.FieldStart("segments") + e.ArrStart() + for _, elem := range s.Segments { + elem.Encode(e) + } + e.ArrEnd() + } + } +} + +var jsonFieldsNameOfModelSpec = [5]string{ + 0: "type", + 1: "rate", + 2: "sea_level_rate", + 3: "include_wind", + 4: "segments", +} + +// Decode decodes ModelSpec from json. +func (s *ModelSpec) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ModelSpec to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "type": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Type.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"type\"") + } + case "rate": + if err := func() error { + s.Rate.Reset() + if err := s.Rate.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"rate\"") + } + case "sea_level_rate": + if err := func() error { + s.SeaLevelRate.Reset() + if err := s.SeaLevelRate.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"sea_level_rate\"") + } + case "include_wind": + if err := func() error { + s.IncludeWind.Reset() + if err := s.IncludeWind.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"include_wind\"") + } + case "segments": + if err := func() error { + s.Segments = make([]PiecewiseSegment, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem PiecewiseSegment + if err := elem.Decode(d); err != nil { + return err + } + s.Segments = append(s.Segments, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"segments\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode ModelSpec") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfModelSpec) { + name = jsonFieldsNameOfModelSpec[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *ModelSpec) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ModelSpec) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ModelSpecType as json. +func (s ModelSpecType) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes ModelSpecType from json. +func (s *ModelSpecType) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ModelSpecType to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch ModelSpecType(v) { + case ModelSpecTypeConstantRate: + *s = ModelSpecTypeConstantRate + case ModelSpecTypeParachuteDescent: + *s = ModelSpecTypeParachuteDescent + case ModelSpecTypePiecewise: + *s = ModelSpecTypePiecewise + case ModelSpecTypeWind: + *s = ModelSpecTypeWind + default: + *s = ModelSpecType(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s ModelSpecType) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ModelSpecType) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes bool as json. +func (o OptBool) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Bool(bool(o.Value)) +} + +// Decode decodes bool from json. +func (o *OptBool) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptBool to nil") + } + o.Set = true + v, err := d.Bool() + if err != nil { + return err + } + o.Value = bool(v) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptBool) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptBool) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ConstraintSpecAction as json. +func (o OptConstraintSpecAction) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Str(string(o.Value)) +} + +// Decode decodes ConstraintSpecAction from json. +func (o *OptConstraintSpecAction) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptConstraintSpecAction to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptConstraintSpecAction) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptConstraintSpecAction) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ConstraintSpecMode as json. +func (o OptConstraintSpecMode) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Str(string(o.Value)) +} + +// Decode decodes ConstraintSpecMode from json. +func (o *OptConstraintSpecMode) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptConstraintSpecMode to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptConstraintSpecMode) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptConstraintSpecMode) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes ConstraintSpecOp as json. +func (o OptConstraintSpecOp) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Str(string(o.Value)) +} + +// Decode decodes ConstraintSpecOp from json. +func (o *OptConstraintSpecOp) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptConstraintSpecOp to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptConstraintSpecOp) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptConstraintSpecOp) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes Coverage as json. +func (o OptCoverage) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes Coverage from json. +func (o *OptCoverage) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptCoverage to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptCoverage) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptCoverage) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes time.Time as json. func (o OptDateTime) Encode(e *jx.Encoder, format func(*jx.Encoder, time.Time)) { if !o.Set { @@ -290,6 +2674,173 @@ func (s *OptFloat64) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes GeoState as json. +func (o OptGeoState) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes GeoState from json. +func (o *OptGeoState) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptGeoState to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptGeoState) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptGeoState) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes HourRange as json. +func (o OptHourRange) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes HourRange from json. +func (o *OptHourRange) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptHourRange to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptHourRange) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptHourRange) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes int as json. +func (o OptInt) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Int(int(o.Value)) +} + +// Decode decodes int from json. +func (o *OptInt) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptInt to nil") + } + o.Set = true + v, err := d.Int() + if err != nil { + return err + } + o.Value = int(v) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptInt) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptInt) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes Options as json. +func (o OptOptions) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes Options from json. +func (o *OptOptions) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptOptions to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptOptions) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptOptions) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes PiecewiseSegmentReference as json. +func (o OptPiecewiseSegmentReference) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Str(string(o.Value)) +} + +// Decode decodes PiecewiseSegmentReference from json. +func (o *OptPiecewiseSegmentReference) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptPiecewiseSegmentReference to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptPiecewiseSegmentReference) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptPiecewiseSegmentReference) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes PredictionResponseRequest as json. func (o OptPredictionResponseRequest) Encode(e *jx.Encoder) { if !o.Set { @@ -357,6 +2908,105 @@ func (s *OptPredictionResponseWarnings) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes PredictionV2RequestDirection as json. +func (o OptPredictionV2RequestDirection) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Str(string(o.Value)) +} + +// Decode decodes PredictionV2RequestDirection from json. +func (o *OptPredictionV2RequestDirection) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptPredictionV2RequestDirection to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptPredictionV2RequestDirection) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptPredictionV2RequestDirection) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes PredictionV2Response as json. +func (o OptPredictionV2Response) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes PredictionV2Response from json. +func (o *OptPredictionV2Response) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptPredictionV2Response to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptPredictionV2Response) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptPredictionV2Response) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes Region as json. +func (o OptRegion) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes Region from json. +func (o *OptRegion) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptRegion to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptRegion) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptRegion) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes string as json. func (o OptString) Encode(e *jx.Encoder) { if !o.Set { @@ -392,6 +3042,680 @@ func (s *OptString) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode encodes SubsetSpec as json. +func (o OptSubsetSpec) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes SubsetSpec from json. +func (o *OptSubsetSpec) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptSubsetSpec to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptSubsetSpec) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptSubsetSpec) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes TerminationInfo as json. +func (o OptTerminationInfo) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes TerminationInfo from json. +func (o *OptTerminationInfo) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptTerminationInfo to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptTerminationInfo) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptTerminationInfo) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *Options) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *Options) encodeFields(e *jx.Encoder) { + { + if s.StepSeconds.Set { + e.FieldStart("step_seconds") + s.StepSeconds.Encode(e) + } + } + { + if s.Tolerance.Set { + e.FieldStart("tolerance") + s.Tolerance.Encode(e) + } + } +} + +var jsonFieldsNameOfOptions = [2]string{ + 0: "step_seconds", + 1: "tolerance", +} + +// Decode decodes Options from json. +func (s *Options) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Options to nil") + } + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "step_seconds": + if err := func() error { + s.StepSeconds.Reset() + if err := s.StepSeconds.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"step_seconds\"") + } + case "tolerance": + if err := func() error { + s.Tolerance.Reset() + if err := s.Tolerance.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"tolerance\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode Options") + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *Options) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Options) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *PiecewiseSegment) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PiecewiseSegment) encodeFields(e *jx.Encoder) { + { + e.FieldStart("until") + e.Float64(s.Until) + } + { + e.FieldStart("rate") + e.Float64(s.Rate) + } + { + if s.Reference.Set { + e.FieldStart("reference") + s.Reference.Encode(e) + } + } +} + +var jsonFieldsNameOfPiecewiseSegment = [3]string{ + 0: "until", + 1: "rate", + 2: "reference", +} + +// Decode decodes PiecewiseSegment from json. +func (s *PiecewiseSegment) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PiecewiseSegment to nil") + } + var requiredBitSet [1]uint8 + s.setDefaults() + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "until": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Float64() + s.Until = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"until\"") + } + case "rate": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Float64() + s.Rate = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"rate\"") + } + case "reference": + if err := func() error { + s.Reference.Reset() + if err := s.Reference.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"reference\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PiecewiseSegment") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPiecewiseSegment) { + name = jsonFieldsNameOfPiecewiseSegment[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PiecewiseSegment) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PiecewiseSegment) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes PiecewiseSegmentReference as json. +func (s PiecewiseSegmentReference) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes PiecewiseSegmentReference from json. +func (s *PiecewiseSegmentReference) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PiecewiseSegmentReference to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch PiecewiseSegmentReference(v) { + case PiecewiseSegmentReferenceAbsolute: + *s = PiecewiseSegmentReferenceAbsolute + case PiecewiseSegmentReferenceProfileStart: + *s = PiecewiseSegmentReferenceProfileStart + case PiecewiseSegmentReferencePropagatorStart: + *s = PiecewiseSegmentReferencePropagatorStart + default: + *s = PiecewiseSegmentReference(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s PiecewiseSegmentReference) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PiecewiseSegmentReference) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *PolygonVertex) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PolygonVertex) encodeFields(e *jx.Encoder) { + { + e.FieldStart("lat") + e.Float64(s.Lat) + } + { + e.FieldStart("lng") + e.Float64(s.Lng) + } +} + +var jsonFieldsNameOfPolygonVertex = [2]string{ + 0: "lat", + 1: "lng", +} + +// Decode decodes PolygonVertex from json. +func (s *PolygonVertex) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PolygonVertex to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "lat": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Float64() + s.Lat = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"lat\"") + } + case "lng": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Float64() + s.Lng = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"lng\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PolygonVertex") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPolygonVertex) { + name = jsonFieldsNameOfPolygonVertex[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PolygonVertex) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PolygonVertex) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *PredictionJob) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PredictionJob) encodeFields(e *jx.Encoder) { + { + e.FieldStart("id") + e.Str(s.ID) + } + { + e.FieldStart("status") + s.Status.Encode(e) + } + { + e.FieldStart("created_at") + json.EncodeDateTime(e, s.CreatedAt) + } + { + if s.StartedAt.Set { + e.FieldStart("started_at") + s.StartedAt.Encode(e, json.EncodeDateTime) + } + } + { + if s.CompletedAt.Set { + e.FieldStart("completed_at") + s.CompletedAt.Encode(e, json.EncodeDateTime) + } + } + { + if s.Error.Set { + e.FieldStart("error") + s.Error.Encode(e) + } + } + { + if s.Result.Set { + e.FieldStart("result") + s.Result.Encode(e) + } + } +} + +var jsonFieldsNameOfPredictionJob = [7]string{ + 0: "id", + 1: "status", + 2: "created_at", + 3: "started_at", + 4: "completed_at", + 5: "error", + 6: "result", +} + +// Decode decodes PredictionJob from json. +func (s *PredictionJob) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PredictionJob to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "id": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.ID = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"id\"") + } + case "status": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + if err := s.Status.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"status\"") + } + case "created_at": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.CreatedAt = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"created_at\"") + } + case "started_at": + if err := func() error { + s.StartedAt.Reset() + if err := s.StartedAt.Decode(d, json.DecodeDateTime); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"started_at\"") + } + case "completed_at": + if err := func() error { + s.CompletedAt.Reset() + if err := s.CompletedAt.Decode(d, json.DecodeDateTime); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"completed_at\"") + } + case "error": + if err := func() error { + s.Error.Reset() + if err := s.Error.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"error\"") + } + case "result": + if err := func() error { + s.Result.Reset() + if err := s.Result.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"result\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PredictionJob") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPredictionJob) { + name = jsonFieldsNameOfPredictionJob[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PredictionJob) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PredictionJob) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes PredictionJobStatus as json. +func (s PredictionJobStatus) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes PredictionJobStatus from json. +func (s *PredictionJobStatus) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PredictionJobStatus to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch PredictionJobStatus(v) { + case PredictionJobStatusPending: + *s = PredictionJobStatusPending + case PredictionJobStatusRunning: + *s = PredictionJobStatusRunning + case PredictionJobStatusComplete: + *s = PredictionJobStatusComplete + case PredictionJobStatusFailed: + *s = PredictionJobStatusFailed + case PredictionJobStatusCancelled: + *s = PredictionJobStatusCancelled + default: + *s = PredictionJobStatus(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s PredictionJobStatus) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PredictionJobStatus) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *PredictionResponse) Encode(e *jx.Encoder) { e.ObjStart() @@ -710,9 +4034,9 @@ func (s *PredictionResponsePredictionItem) Decode(d *jx.Decoder) error { case "trajectory": requiredBitSet[0] |= 1 << 1 if err := func() error { - s.Trajectory = make([]PredictionResponsePredictionItemTrajectoryItem, 0) + s.Trajectory = make([]TawhiriPoint, 0) if err := d.Arr(func(d *jx.Decoder) error { - var elem PredictionResponsePredictionItemTrajectoryItem + var elem TawhiriPoint if err := elem.Decode(d); err != nil { return err } @@ -823,153 +4147,6 @@ func (s *PredictionResponsePredictionItemStage) UnmarshalJSON(data []byte) error return s.Decode(d) } -// Encode implements json.Marshaler. -func (s *PredictionResponsePredictionItemTrajectoryItem) Encode(e *jx.Encoder) { - e.ObjStart() - s.encodeFields(e) - e.ObjEnd() -} - -// encodeFields encodes fields. -func (s *PredictionResponsePredictionItemTrajectoryItem) encodeFields(e *jx.Encoder) { - { - e.FieldStart("datetime") - json.EncodeDateTime(e, s.Datetime) - } - { - e.FieldStart("latitude") - e.Float64(s.Latitude) - } - { - e.FieldStart("longitude") - e.Float64(s.Longitude) - } - { - e.FieldStart("altitude") - e.Float64(s.Altitude) - } -} - -var jsonFieldsNameOfPredictionResponsePredictionItemTrajectoryItem = [4]string{ - 0: "datetime", - 1: "latitude", - 2: "longitude", - 3: "altitude", -} - -// Decode decodes PredictionResponsePredictionItemTrajectoryItem from json. -func (s *PredictionResponsePredictionItemTrajectoryItem) Decode(d *jx.Decoder) error { - if s == nil { - return errors.New("invalid: unable to decode PredictionResponsePredictionItemTrajectoryItem to nil") - } - var requiredBitSet [1]uint8 - - if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { - switch string(k) { - case "datetime": - requiredBitSet[0] |= 1 << 0 - if err := func() error { - v, err := json.DecodeDateTime(d) - s.Datetime = v - if err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"datetime\"") - } - case "latitude": - requiredBitSet[0] |= 1 << 1 - if err := func() error { - v, err := d.Float64() - s.Latitude = float64(v) - if err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"latitude\"") - } - case "longitude": - requiredBitSet[0] |= 1 << 2 - if err := func() error { - v, err := d.Float64() - s.Longitude = float64(v) - if err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"longitude\"") - } - case "altitude": - requiredBitSet[0] |= 1 << 3 - if err := func() error { - v, err := d.Float64() - s.Altitude = float64(v) - if err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"altitude\"") - } - default: - return d.Skip() - } - return nil - }); err != nil { - return errors.Wrap(err, "decode PredictionResponsePredictionItemTrajectoryItem") - } - // Validate required fields. - var failures []validate.FieldError - for i, mask := range [1]uint8{ - 0b00001111, - } { - if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { - // Mask only required fields and check equality to mask using XOR. - // - // If XOR result is not zero, result is not equal to expected, so some fields are missed. - // Bits of fields which would be set are actually bits of missed fields. - missed := bits.OnesCount8(result) - for bitN := 0; bitN < missed; bitN++ { - bitIdx := bits.TrailingZeros8(result) - fieldIdx := i*8 + bitIdx - var name string - if fieldIdx < len(jsonFieldsNameOfPredictionResponsePredictionItemTrajectoryItem) { - name = jsonFieldsNameOfPredictionResponsePredictionItemTrajectoryItem[fieldIdx] - } else { - name = strconv.Itoa(fieldIdx) - } - failures = append(failures, validate.FieldError{ - Name: name, - Error: validate.ErrFieldRequired, - }) - // Reset bit. - result &^= 1 << bitIdx - } - } - } - if len(failures) > 0 { - return &validate.Error{Fields: failures} - } - - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s *PredictionResponsePredictionItemTrajectoryItem) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *PredictionResponsePredictionItemTrajectoryItem) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode implements json.Marshaler. func (s *PredictionResponseRequest) Encode(e *jx.Encoder) { e.ObjStart() @@ -1227,6 +4404,413 @@ func (s *PredictionResponseWarnings) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode implements json.Marshaler. +func (s *PredictionV2Request) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PredictionV2Request) encodeFields(e *jx.Encoder) { + { + e.FieldStart("launch") + s.Launch.Encode(e) + } + { + if s.Direction.Set { + e.FieldStart("direction") + s.Direction.Encode(e) + } + } + { + e.FieldStart("profile") + e.ArrStart() + for _, elem := range s.Profile { + elem.Encode(e) + } + e.ArrEnd() + } + { + if s.Globals != nil { + e.FieldStart("globals") + e.ArrStart() + for _, elem := range s.Globals { + elem.Encode(e) + } + e.ArrEnd() + } + } + { + if s.Options.Set { + e.FieldStart("options") + s.Options.Encode(e) + } + } +} + +var jsonFieldsNameOfPredictionV2Request = [5]string{ + 0: "launch", + 1: "direction", + 2: "profile", + 3: "globals", + 4: "options", +} + +// Decode decodes PredictionV2Request from json. +func (s *PredictionV2Request) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PredictionV2Request to nil") + } + var requiredBitSet [1]uint8 + s.setDefaults() + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "launch": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Launch.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"launch\"") + } + case "direction": + if err := func() error { + s.Direction.Reset() + if err := s.Direction.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"direction\"") + } + case "profile": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + s.Profile = make([]StageSpec, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem StageSpec + if err := elem.Decode(d); err != nil { + return err + } + s.Profile = append(s.Profile, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"profile\"") + } + case "globals": + if err := func() error { + s.Globals = make([]ConstraintSpec, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem ConstraintSpec + if err := elem.Decode(d); err != nil { + return err + } + s.Globals = append(s.Globals, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"globals\"") + } + case "options": + if err := func() error { + s.Options.Reset() + if err := s.Options.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"options\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PredictionV2Request") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000101, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPredictionV2Request) { + name = jsonFieldsNameOfPredictionV2Request[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PredictionV2Request) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PredictionV2Request) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes PredictionV2RequestDirection as json. +func (s PredictionV2RequestDirection) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes PredictionV2RequestDirection from json. +func (s *PredictionV2RequestDirection) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PredictionV2RequestDirection to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch PredictionV2RequestDirection(v) { + case PredictionV2RequestDirectionForward: + *s = PredictionV2RequestDirectionForward + case PredictionV2RequestDirectionReverse: + *s = PredictionV2RequestDirectionReverse + default: + *s = PredictionV2RequestDirection(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s PredictionV2RequestDirection) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PredictionV2RequestDirection) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *PredictionV2Response) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PredictionV2Response) encodeFields(e *jx.Encoder) { + { + e.FieldStart("stages") + e.ArrStart() + for _, elem := range s.Stages { + elem.Encode(e) + } + e.ArrEnd() + } + { + if s.Events != nil { + e.FieldStart("events") + e.ArrStart() + for _, elem := range s.Events { + elem.Encode(e) + } + e.ArrEnd() + } + } + { + e.FieldStart("dataset") + s.Dataset.Encode(e) + } + { + e.FieldStart("started_at") + json.EncodeDateTime(e, s.StartedAt) + } + { + e.FieldStart("completed_at") + json.EncodeDateTime(e, s.CompletedAt) + } +} + +var jsonFieldsNameOfPredictionV2Response = [5]string{ + 0: "stages", + 1: "events", + 2: "dataset", + 3: "started_at", + 4: "completed_at", +} + +// Decode decodes PredictionV2Response from json. +func (s *PredictionV2Response) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PredictionV2Response to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "stages": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + s.Stages = make([]StageResult, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem StageResult + if err := elem.Decode(d); err != nil { + return err + } + s.Stages = append(s.Stages, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"stages\"") + } + case "events": + if err := func() error { + s.Events = make([]EventSummary, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem EventSummary + if err := elem.Decode(d); err != nil { + return err + } + s.Events = append(s.Events, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"events\"") + } + case "dataset": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + if err := s.Dataset.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"dataset\"") + } + case "started_at": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.StartedAt = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"started_at\"") + } + case "completed_at": + requiredBitSet[0] |= 1 << 4 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.CompletedAt = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"completed_at\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PredictionV2Response") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00011101, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPredictionV2Response) { + name = jsonFieldsNameOfPredictionV2Response[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PredictionV2Response) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PredictionV2Response) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *ReadinessResponse) Encode(e *jx.Encoder) { e.ObjStart() @@ -1396,3 +4980,1979 @@ func (s *ReadinessResponseStatus) UnmarshalJSON(data []byte) error { d := jx.DecodeBytes(data) return s.Decode(d) } + +// Encode implements json.Marshaler. +func (s *Region) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *Region) encodeFields(e *jx.Encoder) { + { + e.FieldStart("min_lat") + e.Float64(s.MinLat) + } + { + e.FieldStart("max_lat") + e.Float64(s.MaxLat) + } + { + e.FieldStart("min_lng") + e.Float64(s.MinLng) + } + { + e.FieldStart("max_lng") + e.Float64(s.MaxLng) + } +} + +var jsonFieldsNameOfRegion = [4]string{ + 0: "min_lat", + 1: "max_lat", + 2: "min_lng", + 3: "max_lng", +} + +// Decode decodes Region from json. +func (s *Region) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Region to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "min_lat": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Float64() + s.MinLat = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"min_lat\"") + } + case "max_lat": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Float64() + s.MaxLat = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"max_lat\"") + } + case "min_lng": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Float64() + s.MinLng = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"min_lng\"") + } + case "max_lng": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Float64() + s.MaxLng = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"max_lng\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode Region") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00001111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfRegion) { + name = jsonFieldsNameOfRegion[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *Region) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Region) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *StageResult) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *StageResult) encodeFields(e *jx.Encoder) { + { + e.FieldStart("name") + e.Str(s.Name) + } + { + e.FieldStart("outcome") + s.Outcome.Encode(e) + } + { + if s.Constraint.Set { + e.FieldStart("constraint") + s.Constraint.Encode(e) + } + } + { + if s.Termination.Set { + e.FieldStart("termination") + s.Termination.Encode(e) + } + } + { + if s.Events != nil { + e.FieldStart("events") + e.ArrStart() + for _, elem := range s.Events { + elem.Encode(e) + } + e.ArrEnd() + } + } + { + e.FieldStart("trajectory") + e.ArrStart() + for _, elem := range s.Trajectory { + elem.Encode(e) + } + e.ArrEnd() + } +} + +var jsonFieldsNameOfStageResult = [6]string{ + 0: "name", + 1: "outcome", + 2: "constraint", + 3: "termination", + 4: "events", + 5: "trajectory", +} + +// Decode decodes StageResult from json. +func (s *StageResult) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode StageResult to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "name": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Name = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"name\"") + } + case "outcome": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + if err := s.Outcome.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"outcome\"") + } + case "constraint": + if err := func() error { + s.Constraint.Reset() + if err := s.Constraint.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"constraint\"") + } + case "termination": + if err := func() error { + s.Termination.Reset() + if err := s.Termination.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"termination\"") + } + case "events": + if err := func() error { + s.Events = make([]EventSummary, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem EventSummary + if err := elem.Decode(d); err != nil { + return err + } + s.Events = append(s.Events, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"events\"") + } + case "trajectory": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + s.Trajectory = make([]TrajectoryPoint, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem TrajectoryPoint + if err := elem.Decode(d); err != nil { + return err + } + s.Trajectory = append(s.Trajectory, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"trajectory\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode StageResult") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00100011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfStageResult) { + name = jsonFieldsNameOfStageResult[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *StageResult) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *StageResult) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes StageResultOutcome as json. +func (s StageResultOutcome) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes StageResultOutcome from json. +func (s *StageResultOutcome) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode StageResultOutcome to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch StageResultOutcome(v) { + case StageResultOutcomeStopped: + *s = StageResultOutcomeStopped + case StageResultOutcomeFallback: + *s = StageResultOutcomeFallback + case StageResultOutcomeContinued: + *s = StageResultOutcomeContinued + default: + *s = StageResultOutcome(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s StageResultOutcome) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *StageResultOutcome) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *StageSpec) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *StageSpec) encodeFields(e *jx.Encoder) { + { + e.FieldStart("name") + e.Str(s.Name) + } + { + e.FieldStart("model") + s.Model.Encode(e) + } + { + if s.Constraints != nil { + e.FieldStart("constraints") + e.ArrStart() + for _, elem := range s.Constraints { + elem.Encode(e) + } + e.ArrEnd() + } + } + { + if s.FallbackIndex.Set { + e.FieldStart("fallback_index") + s.FallbackIndex.Encode(e) + } + } +} + +var jsonFieldsNameOfStageSpec = [4]string{ + 0: "name", + 1: "model", + 2: "constraints", + 3: "fallback_index", +} + +// Decode decodes StageSpec from json. +func (s *StageSpec) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode StageSpec to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "name": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Name = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"name\"") + } + case "model": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + if err := s.Model.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"model\"") + } + case "constraints": + if err := func() error { + s.Constraints = make([]ConstraintSpec, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem ConstraintSpec + if err := elem.Decode(d); err != nil { + return err + } + s.Constraints = append(s.Constraints, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"constraints\"") + } + case "fallback_index": + if err := func() error { + s.FallbackIndex.Reset() + if err := s.FallbackIndex.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"fallback_index\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode StageSpec") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfStageSpec) { + name = jsonFieldsNameOfStageSpec[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *StageSpec) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *StageSpec) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *StatusResponse) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *StatusResponse) encodeFields(e *jx.Encoder) { + { + e.FieldStart("source") + e.Str(s.Source) + } + { + e.FieldStart("uptime") + e.Str(s.Uptime) + } + { + e.FieldStart("goroutines") + e.Int(s.Goroutines) + } + { + e.FieldStart("memory_mb") + e.Int64(s.MemoryMB) + } + { + e.FieldStart("jobs_by_status") + s.JobsByStatus.Encode(e) + } + { + e.FieldStart("stored_datasets") + e.Int(s.StoredDatasets) + } + { + e.FieldStart("loaded_datasets") + e.Int(s.LoadedDatasets) + } +} + +var jsonFieldsNameOfStatusResponse = [7]string{ + 0: "source", + 1: "uptime", + 2: "goroutines", + 3: "memory_mb", + 4: "jobs_by_status", + 5: "stored_datasets", + 6: "loaded_datasets", +} + +// Decode decodes StatusResponse from json. +func (s *StatusResponse) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode StatusResponse to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "source": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Source = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"source\"") + } + case "uptime": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Uptime = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"uptime\"") + } + case "goroutines": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Int() + s.Goroutines = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"goroutines\"") + } + case "memory_mb": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Int64() + s.MemoryMB = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"memory_mb\"") + } + case "jobs_by_status": + requiredBitSet[0] |= 1 << 4 + if err := func() error { + if err := s.JobsByStatus.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"jobs_by_status\"") + } + case "stored_datasets": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + v, err := d.Int() + s.StoredDatasets = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"stored_datasets\"") + } + case "loaded_datasets": + requiredBitSet[0] |= 1 << 6 + if err := func() error { + v, err := d.Int() + s.LoadedDatasets = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"loaded_datasets\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode StatusResponse") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b01111111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfStatusResponse) { + name = jsonFieldsNameOfStatusResponse[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *StatusResponse) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *StatusResponse) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s StatusResponseJobsByStatus) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields implements json.Marshaler. +func (s StatusResponseJobsByStatus) encodeFields(e *jx.Encoder) { + for k, elem := range s { + e.FieldStart(k) + + e.Int(elem) + } +} + +// Decode decodes StatusResponseJobsByStatus from json. +func (s *StatusResponseJobsByStatus) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode StatusResponseJobsByStatus to nil") + } + m := s.init() + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + var elem int + if err := func() error { + v, err := d.Int() + elem = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrapf(err, "decode field %q", k) + } + m[string(k)] = elem + return nil + }); err != nil { + return errors.Wrap(err, "decode StatusResponseJobsByStatus") + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s StatusResponseJobsByStatus) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *StatusResponseJobsByStatus) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *SubsetSpec) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *SubsetSpec) encodeFields(e *jx.Encoder) { + { + if s.Region.Set { + e.FieldStart("region") + s.Region.Encode(e) + } + } + { + if s.HourRange.Set { + e.FieldStart("hour_range") + s.HourRange.Encode(e) + } + } + { + if s.Members != nil { + e.FieldStart("members") + e.ArrStart() + for _, elem := range s.Members { + e.Int(elem) + } + e.ArrEnd() + } + } +} + +var jsonFieldsNameOfSubsetSpec = [3]string{ + 0: "region", + 1: "hour_range", + 2: "members", +} + +// Decode decodes SubsetSpec from json. +func (s *SubsetSpec) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode SubsetSpec to nil") + } + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "region": + if err := func() error { + s.Region.Reset() + if err := s.Region.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"region\"") + } + case "hour_range": + if err := func() error { + s.HourRange.Reset() + if err := s.HourRange.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"hour_range\"") + } + case "members": + if err := func() error { + s.Members = make([]int, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem int + v, err := d.Int() + elem = int(v) + if err != nil { + return err + } + s.Members = append(s.Members, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"members\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode SubsetSpec") + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *SubsetSpec) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *SubsetSpec) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *TawhiriPoint) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *TawhiriPoint) encodeFields(e *jx.Encoder) { + { + e.FieldStart("datetime") + json.EncodeDateTime(e, s.Datetime) + } + { + e.FieldStart("latitude") + e.Float64(s.Latitude) + } + { + e.FieldStart("longitude") + e.Float64(s.Longitude) + } + { + e.FieldStart("altitude") + e.Float64(s.Altitude) + } +} + +var jsonFieldsNameOfTawhiriPoint = [4]string{ + 0: "datetime", + 1: "latitude", + 2: "longitude", + 3: "altitude", +} + +// Decode decodes TawhiriPoint from json. +func (s *TawhiriPoint) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode TawhiriPoint to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "datetime": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.Datetime = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"datetime\"") + } + case "latitude": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Float64() + s.Latitude = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"latitude\"") + } + case "longitude": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Float64() + s.Longitude = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"longitude\"") + } + case "altitude": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Float64() + s.Altitude = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"altitude\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode TawhiriPoint") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00001111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfTawhiriPoint) { + name = jsonFieldsNameOfTawhiriPoint[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *TawhiriPoint) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *TawhiriPoint) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *TerminationInfo) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *TerminationInfo) encodeFields(e *jx.Encoder) { + { + e.FieldStart("violation_time") + json.EncodeDateTime(e, s.ViolationTime) + } + { + e.FieldStart("violation_state") + s.ViolationState.Encode(e) + } + { + e.FieldStart("refined_time") + json.EncodeDateTime(e, s.RefinedTime) + } + { + e.FieldStart("refined_state") + s.RefinedState.Encode(e) + } +} + +var jsonFieldsNameOfTerminationInfo = [4]string{ + 0: "violation_time", + 1: "violation_state", + 2: "refined_time", + 3: "refined_state", +} + +// Decode decodes TerminationInfo from json. +func (s *TerminationInfo) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode TerminationInfo to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "violation_time": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.ViolationTime = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"violation_time\"") + } + case "violation_state": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + if err := s.ViolationState.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"violation_state\"") + } + case "refined_time": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.RefinedTime = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"refined_time\"") + } + case "refined_state": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + if err := s.RefinedState.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"refined_state\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode TerminationInfo") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00001111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfTerminationInfo) { + name = jsonFieldsNameOfTerminationInfo[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *TerminationInfo) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *TerminationInfo) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *TrajectoryPoint) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *TrajectoryPoint) encodeFields(e *jx.Encoder) { + { + e.FieldStart("time") + json.EncodeDateTime(e, s.Time) + } + { + e.FieldStart("latitude") + e.Float64(s.Latitude) + } + { + e.FieldStart("longitude") + e.Float64(s.Longitude) + } + { + e.FieldStart("altitude") + e.Float64(s.Altitude) + } +} + +var jsonFieldsNameOfTrajectoryPoint = [4]string{ + 0: "time", + 1: "latitude", + 2: "longitude", + 3: "altitude", +} + +// Decode decodes TrajectoryPoint from json. +func (s *TrajectoryPoint) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode TrajectoryPoint to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "time": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.Time = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"time\"") + } + case "latitude": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Float64() + s.Latitude = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"latitude\"") + } + case "longitude": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Float64() + s.Longitude = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"longitude\"") + } + case "altitude": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Float64() + s.Altitude = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"altitude\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode TrajectoryPoint") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00001111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfTrajectoryPoint) { + name = jsonFieldsNameOfTrajectoryPoint[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *TrajectoryPoint) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *TrajectoryPoint) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *WindComponent) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *WindComponent) encodeFields(e *jx.Encoder) { + { + e.FieldStart("header") + s.Header.Encode(e) + } + { + e.FieldStart("data") + e.ArrStart() + for _, elem := range s.Data { + e.Float64(elem) + } + e.ArrEnd() + } +} + +var jsonFieldsNameOfWindComponent = [2]string{ + 0: "header", + 1: "data", +} + +// Decode decodes WindComponent from json. +func (s *WindComponent) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode WindComponent to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "header": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Header.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"header\"") + } + case "data": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + s.Data = make([]float64, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem float64 + v, err := d.Float64() + elem = float64(v) + if err != nil { + return err + } + s.Data = append(s.Data, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"data\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode WindComponent") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfWindComponent) { + name = jsonFieldsNameOfWindComponent[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *WindComponent) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *WindComponent) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *WindHeader) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *WindHeader) encodeFields(e *jx.Encoder) { + { + e.FieldStart("parameterCategory") + e.Int(s.ParameterCategory) + } + { + e.FieldStart("parameterNumber") + e.Int(s.ParameterNumber) + } + { + if s.ParameterNumberName.Set { + e.FieldStart("parameterNumberName") + s.ParameterNumberName.Encode(e) + } + } + { + if s.ParameterUnit.Set { + e.FieldStart("parameterUnit") + s.ParameterUnit.Encode(e) + } + } + { + e.FieldStart("nx") + e.Int(s.Nx) + } + { + e.FieldStart("ny") + e.Int(s.Ny) + } + { + e.FieldStart("lo1") + e.Float64(s.Lo1) + } + { + e.FieldStart("la1") + e.Float64(s.La1) + } + { + e.FieldStart("lo2") + e.Float64(s.Lo2) + } + { + e.FieldStart("la2") + e.Float64(s.La2) + } + { + e.FieldStart("dx") + e.Float64(s.Dx) + } + { + e.FieldStart("dy") + e.Float64(s.Dy) + } + { + e.FieldStart("refTime") + e.Str(s.RefTime) + } + { + e.FieldStart("forecastTime") + e.Int(s.ForecastTime) + } +} + +var jsonFieldsNameOfWindHeader = [14]string{ + 0: "parameterCategory", + 1: "parameterNumber", + 2: "parameterNumberName", + 3: "parameterUnit", + 4: "nx", + 5: "ny", + 6: "lo1", + 7: "la1", + 8: "lo2", + 9: "la2", + 10: "dx", + 11: "dy", + 12: "refTime", + 13: "forecastTime", +} + +// Decode decodes WindHeader from json. +func (s *WindHeader) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode WindHeader to nil") + } + var requiredBitSet [2]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "parameterCategory": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Int() + s.ParameterCategory = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"parameterCategory\"") + } + case "parameterNumber": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Int() + s.ParameterNumber = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"parameterNumber\"") + } + case "parameterNumberName": + if err := func() error { + s.ParameterNumberName.Reset() + if err := s.ParameterNumberName.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"parameterNumberName\"") + } + case "parameterUnit": + if err := func() error { + s.ParameterUnit.Reset() + if err := s.ParameterUnit.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"parameterUnit\"") + } + case "nx": + requiredBitSet[0] |= 1 << 4 + if err := func() error { + v, err := d.Int() + s.Nx = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"nx\"") + } + case "ny": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + v, err := d.Int() + s.Ny = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"ny\"") + } + case "lo1": + requiredBitSet[0] |= 1 << 6 + if err := func() error { + v, err := d.Float64() + s.Lo1 = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"lo1\"") + } + case "la1": + requiredBitSet[0] |= 1 << 7 + if err := func() error { + v, err := d.Float64() + s.La1 = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"la1\"") + } + case "lo2": + requiredBitSet[1] |= 1 << 0 + if err := func() error { + v, err := d.Float64() + s.Lo2 = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"lo2\"") + } + case "la2": + requiredBitSet[1] |= 1 << 1 + if err := func() error { + v, err := d.Float64() + s.La2 = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"la2\"") + } + case "dx": + requiredBitSet[1] |= 1 << 2 + if err := func() error { + v, err := d.Float64() + s.Dx = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"dx\"") + } + case "dy": + requiredBitSet[1] |= 1 << 3 + if err := func() error { + v, err := d.Float64() + s.Dy = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"dy\"") + } + case "refTime": + requiredBitSet[1] |= 1 << 4 + if err := func() error { + v, err := d.Str() + s.RefTime = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"refTime\"") + } + case "forecastTime": + requiredBitSet[1] |= 1 << 5 + if err := func() error { + v, err := d.Int() + s.ForecastTime = int(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"forecastTime\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode WindHeader") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [2]uint8{ + 0b11110011, + 0b00111111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfWindHeader) { + name = jsonFieldsNameOfWindHeader[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *WindHeader) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *WindHeader) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *WindMeta) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *WindMeta) encodeFields(e *jx.Encoder) { + { + e.FieldStart("source") + e.Str(s.Source) + } + { + e.FieldStart("epoch") + json.EncodeDateTime(e, s.Epoch) + } + { + e.FieldStart("default_step") + e.Float64(s.DefaultStep) + } + { + e.FieldStart("min_step") + e.Float64(s.MinStep) + } + { + e.FieldStart("suggested_altitudes") + e.ArrStart() + for _, elem := range s.SuggestedAltitudes { + e.Int(elem) + } + e.ArrEnd() + } + { + e.FieldStart("bbox") + s.Bbox.Encode(e) + } +} + +var jsonFieldsNameOfWindMeta = [6]string{ + 0: "source", + 1: "epoch", + 2: "default_step", + 3: "min_step", + 4: "suggested_altitudes", + 5: "bbox", +} + +// Decode decodes WindMeta from json. +func (s *WindMeta) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode WindMeta to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "source": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Source = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"source\"") + } + case "epoch": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.Epoch = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"epoch\"") + } + case "default_step": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Float64() + s.DefaultStep = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"default_step\"") + } + case "min_step": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Float64() + s.MinStep = float64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"min_step\"") + } + case "suggested_altitudes": + requiredBitSet[0] |= 1 << 4 + if err := func() error { + s.SuggestedAltitudes = make([]int, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem int + v, err := d.Int() + elem = int(v) + if err != nil { + return err + } + s.SuggestedAltitudes = append(s.SuggestedAltitudes, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"suggested_altitudes\"") + } + case "bbox": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + if err := s.Bbox.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"bbox\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode WindMeta") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00111111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfWindMeta) { + name = jsonFieldsNameOfWindMeta[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *WindMeta) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *WindMeta) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} diff --git a/pkg/rest/oas_operations_gen.go b/pkg/rest/oas_operations_gen.go index 68097b0..26d8416 100644 --- a/pkg/rest/oas_operations_gen.go +++ b/pkg/rest/oas_operations_gen.go @@ -6,6 +6,19 @@ package rest type OperationName = string const ( - PerformPredictionOperation OperationName = "PerformPrediction" - ReadinessCheckOperation OperationName = "ReadinessCheck" + CancelDatasetJobOperation OperationName = "CancelDatasetJob" + CancelPredictionJobOperation OperationName = "CancelPredictionJob" + CreatePredictionJobOperation OperationName = "CreatePredictionJob" + DeleteDatasetOperation OperationName = "DeleteDataset" + GetDatasetJobOperation OperationName = "GetDatasetJob" + GetPredictionJobOperation OperationName = "GetPredictionJob" + GetServiceStatusOperation OperationName = "GetServiceStatus" + GetWindFieldOperation OperationName = "GetWindField" + GetWindMetaOperation OperationName = "GetWindMeta" + ListDatasetJobsOperation OperationName = "ListDatasetJobs" + ListDatasetsOperation OperationName = "ListDatasets" + PerformPredictionOperation OperationName = "PerformPrediction" + PerformPredictionV2Operation OperationName = "PerformPredictionV2" + ReadinessCheckOperation OperationName = "ReadinessCheck" + TriggerDatasetDownloadOperation OperationName = "TriggerDatasetDownload" ) diff --git a/pkg/rest/oas_parameters_gen.go b/pkg/rest/oas_parameters_gen.go index c3be508..0567d98 100644 --- a/pkg/rest/oas_parameters_gen.go +++ b/pkg/rest/oas_parameters_gen.go @@ -4,6 +4,7 @@ package rest import ( "net/http" + "net/url" "time" "github.com/go-faster/errors" @@ -14,6 +15,791 @@ import ( "github.com/ogen-go/ogen/validate" ) +// CancelDatasetJobParams is parameters of cancelDatasetJob operation. +type CancelDatasetJobParams struct { + ID string +} + +func unpackCancelDatasetJobParams(packed middleware.Parameters) (params CancelDatasetJobParams) { + { + key := middleware.ParameterKey{ + Name: "id", + In: "path", + } + params.ID = packed[key].(string) + } + return params +} + +func decodeCancelDatasetJobParams(args [1]string, argsEscaped bool, r *http.Request) (params CancelDatasetJobParams, _ error) { + // Decode path: id. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "id", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToString(val) + if err != nil { + return err + } + + params.ID = c + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "id", + In: "path", + Err: err, + } + } + return params, nil +} + +// CancelPredictionJobParams is parameters of cancelPredictionJob operation. +type CancelPredictionJobParams struct { + ID string +} + +func unpackCancelPredictionJobParams(packed middleware.Parameters) (params CancelPredictionJobParams) { + { + key := middleware.ParameterKey{ + Name: "id", + In: "path", + } + params.ID = packed[key].(string) + } + return params +} + +func decodeCancelPredictionJobParams(args [1]string, argsEscaped bool, r *http.Request) (params CancelPredictionJobParams, _ error) { + // Decode path: id. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "id", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToString(val) + if err != nil { + return err + } + + params.ID = c + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "id", + In: "path", + Err: err, + } + } + return params, nil +} + +// DeleteDatasetParams is parameters of deleteDataset operation. +type DeleteDatasetParams struct { + Name string +} + +func unpackDeleteDatasetParams(packed middleware.Parameters) (params DeleteDatasetParams) { + { + key := middleware.ParameterKey{ + Name: "name", + In: "path", + } + params.Name = packed[key].(string) + } + return params +} + +func decodeDeleteDatasetParams(args [1]string, argsEscaped bool, r *http.Request) (params DeleteDatasetParams, _ error) { + // Decode path: name. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "name", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToString(val) + if err != nil { + return err + } + + params.Name = c + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "name", + In: "path", + Err: err, + } + } + return params, nil +} + +// GetDatasetJobParams is parameters of getDatasetJob operation. +type GetDatasetJobParams struct { + ID string +} + +func unpackGetDatasetJobParams(packed middleware.Parameters) (params GetDatasetJobParams) { + { + key := middleware.ParameterKey{ + Name: "id", + In: "path", + } + params.ID = packed[key].(string) + } + return params +} + +func decodeGetDatasetJobParams(args [1]string, argsEscaped bool, r *http.Request) (params GetDatasetJobParams, _ error) { + // Decode path: id. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "id", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToString(val) + if err != nil { + return err + } + + params.ID = c + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "id", + In: "path", + Err: err, + } + } + return params, nil +} + +// GetPredictionJobParams is parameters of getPredictionJob operation. +type GetPredictionJobParams struct { + ID string +} + +func unpackGetPredictionJobParams(packed middleware.Parameters) (params GetPredictionJobParams) { + { + key := middleware.ParameterKey{ + Name: "id", + In: "path", + } + params.ID = packed[key].(string) + } + return params +} + +func decodeGetPredictionJobParams(args [1]string, argsEscaped bool, r *http.Request) (params GetPredictionJobParams, _ error) { + // Decode path: id. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "id", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToString(val) + if err != nil { + return err + } + + params.ID = c + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "id", + In: "path", + Err: err, + } + } + return params, nil +} + +// GetWindFieldParams is parameters of getWindField operation. +type GetWindFieldParams struct { + Time OptDateTime `json:",omitempty,omitzero"` + Altitude OptFloat64 `json:",omitempty,omitzero"` + MinLat OptFloat64 `json:",omitempty,omitzero"` + MaxLat OptFloat64 `json:",omitempty,omitzero"` + MinLng OptFloat64 `json:",omitempty,omitzero"` + MaxLng OptFloat64 `json:",omitempty,omitzero"` + Step OptFloat64 `json:",omitempty,omitzero"` +} + +func unpackGetWindFieldParams(packed middleware.Parameters) (params GetWindFieldParams) { + { + key := middleware.ParameterKey{ + Name: "time", + In: "query", + } + if v, ok := packed[key]; ok { + params.Time = v.(OptDateTime) + } + } + { + key := middleware.ParameterKey{ + Name: "altitude", + In: "query", + } + if v, ok := packed[key]; ok { + params.Altitude = v.(OptFloat64) + } + } + { + key := middleware.ParameterKey{ + Name: "min_lat", + In: "query", + } + if v, ok := packed[key]; ok { + params.MinLat = v.(OptFloat64) + } + } + { + key := middleware.ParameterKey{ + Name: "max_lat", + In: "query", + } + if v, ok := packed[key]; ok { + params.MaxLat = v.(OptFloat64) + } + } + { + key := middleware.ParameterKey{ + Name: "min_lng", + In: "query", + } + if v, ok := packed[key]; ok { + params.MinLng = v.(OptFloat64) + } + } + { + key := middleware.ParameterKey{ + Name: "max_lng", + In: "query", + } + if v, ok := packed[key]; ok { + params.MaxLng = v.(OptFloat64) + } + } + { + key := middleware.ParameterKey{ + Name: "step", + In: "query", + } + if v, ok := packed[key]; ok { + params.Step = v.(OptFloat64) + } + } + return params +} + +func decodeGetWindFieldParams(args [0]string, argsEscaped bool, r *http.Request) (params GetWindFieldParams, _ error) { + q := uri.NewQueryDecoder(r.URL.Query()) + // Decode query: time. + if err := func() error { + cfg := uri.QueryParameterDecodingConfig{ + Name: "time", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.HasParam(cfg); err == nil { + if err := q.DecodeParam(cfg, func(d uri.Decoder) error { + var paramsDotTimeVal time.Time + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToDateTime(val) + if err != nil { + return err + } + + paramsDotTimeVal = c + return nil + }(); err != nil { + return err + } + params.Time.SetTo(paramsDotTimeVal) + return nil + }); err != nil { + return err + } + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "time", + In: "query", + Err: err, + } + } + // Decode query: altitude. + if err := func() error { + cfg := uri.QueryParameterDecodingConfig{ + Name: "altitude", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.HasParam(cfg); err == nil { + if err := q.DecodeParam(cfg, func(d uri.Decoder) error { + var paramsDotAltitudeVal float64 + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToFloat64(val) + if err != nil { + return err + } + + paramsDotAltitudeVal = c + return nil + }(); err != nil { + return err + } + params.Altitude.SetTo(paramsDotAltitudeVal) + return nil + }); err != nil { + return err + } + if err := func() error { + if value, ok := params.Altitude.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "altitude", + In: "query", + Err: err, + } + } + // Decode query: min_lat. + if err := func() error { + cfg := uri.QueryParameterDecodingConfig{ + Name: "min_lat", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.HasParam(cfg); err == nil { + if err := q.DecodeParam(cfg, func(d uri.Decoder) error { + var paramsDotMinLatVal float64 + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToFloat64(val) + if err != nil { + return err + } + + paramsDotMinLatVal = c + return nil + }(); err != nil { + return err + } + params.MinLat.SetTo(paramsDotMinLatVal) + return nil + }); err != nil { + return err + } + if err := func() error { + if value, ok := params.MinLat.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "min_lat", + In: "query", + Err: err, + } + } + // Decode query: max_lat. + if err := func() error { + cfg := uri.QueryParameterDecodingConfig{ + Name: "max_lat", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.HasParam(cfg); err == nil { + if err := q.DecodeParam(cfg, func(d uri.Decoder) error { + var paramsDotMaxLatVal float64 + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToFloat64(val) + if err != nil { + return err + } + + paramsDotMaxLatVal = c + return nil + }(); err != nil { + return err + } + params.MaxLat.SetTo(paramsDotMaxLatVal) + return nil + }); err != nil { + return err + } + if err := func() error { + if value, ok := params.MaxLat.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "max_lat", + In: "query", + Err: err, + } + } + // Decode query: min_lng. + if err := func() error { + cfg := uri.QueryParameterDecodingConfig{ + Name: "min_lng", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.HasParam(cfg); err == nil { + if err := q.DecodeParam(cfg, func(d uri.Decoder) error { + var paramsDotMinLngVal float64 + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToFloat64(val) + if err != nil { + return err + } + + paramsDotMinLngVal = c + return nil + }(); err != nil { + return err + } + params.MinLng.SetTo(paramsDotMinLngVal) + return nil + }); err != nil { + return err + } + if err := func() error { + if value, ok := params.MinLng.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "min_lng", + In: "query", + Err: err, + } + } + // Decode query: max_lng. + if err := func() error { + cfg := uri.QueryParameterDecodingConfig{ + Name: "max_lng", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.HasParam(cfg); err == nil { + if err := q.DecodeParam(cfg, func(d uri.Decoder) error { + var paramsDotMaxLngVal float64 + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToFloat64(val) + if err != nil { + return err + } + + paramsDotMaxLngVal = c + return nil + }(); err != nil { + return err + } + params.MaxLng.SetTo(paramsDotMaxLngVal) + return nil + }); err != nil { + return err + } + if err := func() error { + if value, ok := params.MaxLng.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "max_lng", + In: "query", + Err: err, + } + } + // Decode query: step. + if err := func() error { + cfg := uri.QueryParameterDecodingConfig{ + Name: "step", + Style: uri.QueryStyleForm, + Explode: true, + } + + if err := q.HasParam(cfg); err == nil { + if err := q.DecodeParam(cfg, func(d uri.Decoder) error { + var paramsDotStepVal float64 + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToFloat64(val) + if err != nil { + return err + } + + paramsDotStepVal = c + return nil + }(); err != nil { + return err + } + params.Step.SetTo(paramsDotStepVal) + return nil + }); err != nil { + return err + } + if err := func() error { + if value, ok := params.Step.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "step", + In: "query", + Err: err, + } + } + return params, nil +} + // PerformPredictionParams is parameters of performPrediction operation. type PerformPredictionParams struct { LaunchLatitude float64 diff --git a/pkg/rest/oas_request_decoders_gen.go b/pkg/rest/oas_request_decoders_gen.go index 1ad6008..adb4d3f 100644 --- a/pkg/rest/oas_request_decoders_gen.go +++ b/pkg/rest/oas_request_decoders_gen.go @@ -1,3 +1,252 @@ // Code generated by ogen, DO NOT EDIT. package rest + +import ( + "bytes" + "io" + "mime" + "net/http" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/validate" +) + +func (s *Server) decodeCreatePredictionJobRequest(r *http.Request) ( + req *PredictionV2Request, + rawBody []byte, + close func() error, + rerr error, +) { + var closers []func() error + close = func() error { + var merr error + // Close in reverse order, to match defer behavior. + for i := len(closers) - 1; i >= 0; i-- { + c := closers[i] + merr = errors.Join(merr, c()) + } + return merr + } + defer func() { + if rerr != nil { + rerr = errors.Join(rerr, close()) + } + }() + ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, rawBody, close, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + if r.ContentLength == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + buf, err := io.ReadAll(r.Body) + defer func() { + _ = r.Body.Close() + }() + if err != nil { + return req, rawBody, close, err + } + + // Reset the body to allow for downstream reading. + r.Body = io.NopCloser(bytes.NewBuffer(buf)) + + if len(buf) == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + + rawBody = append(rawBody, buf...) + d := jx.DecodeBytes(buf) + + var request PredictionV2Request + if err := func() error { + if err := request.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return req, rawBody, close, err + } + if err := func() error { + if err := request.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return req, rawBody, close, errors.Wrap(err, "validate") + } + return &request, rawBody, close, nil + default: + return req, rawBody, close, validate.InvalidContentType(ct) + } +} + +func (s *Server) decodePerformPredictionV2Request(r *http.Request) ( + req *PredictionV2Request, + rawBody []byte, + close func() error, + rerr error, +) { + var closers []func() error + close = func() error { + var merr error + // Close in reverse order, to match defer behavior. + for i := len(closers) - 1; i >= 0; i-- { + c := closers[i] + merr = errors.Join(merr, c()) + } + return merr + } + defer func() { + if rerr != nil { + rerr = errors.Join(rerr, close()) + } + }() + ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, rawBody, close, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + if r.ContentLength == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + buf, err := io.ReadAll(r.Body) + defer func() { + _ = r.Body.Close() + }() + if err != nil { + return req, rawBody, close, err + } + + // Reset the body to allow for downstream reading. + r.Body = io.NopCloser(bytes.NewBuffer(buf)) + + if len(buf) == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + + rawBody = append(rawBody, buf...) + d := jx.DecodeBytes(buf) + + var request PredictionV2Request + if err := func() error { + if err := request.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return req, rawBody, close, err + } + if err := func() error { + if err := request.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return req, rawBody, close, errors.Wrap(err, "validate") + } + return &request, rawBody, close, nil + default: + return req, rawBody, close, validate.InvalidContentType(ct) + } +} + +func (s *Server) decodeTriggerDatasetDownloadRequest(r *http.Request) ( + req *DownloadRequest, + rawBody []byte, + close func() error, + rerr error, +) { + var closers []func() error + close = func() error { + var merr error + // Close in reverse order, to match defer behavior. + for i := len(closers) - 1; i >= 0; i-- { + c := closers[i] + merr = errors.Join(merr, c()) + } + return merr + } + defer func() { + if rerr != nil { + rerr = errors.Join(rerr, close()) + } + }() + ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, rawBody, close, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + if r.ContentLength == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + buf, err := io.ReadAll(r.Body) + defer func() { + _ = r.Body.Close() + }() + if err != nil { + return req, rawBody, close, err + } + + // Reset the body to allow for downstream reading. + r.Body = io.NopCloser(bytes.NewBuffer(buf)) + + if len(buf) == 0 { + return req, rawBody, close, validate.ErrBodyRequired + } + + rawBody = append(rawBody, buf...) + d := jx.DecodeBytes(buf) + + var request DownloadRequest + if err := func() error { + if err := request.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return req, rawBody, close, err + } + if err := func() error { + if err := request.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return req, rawBody, close, errors.Wrap(err, "validate") + } + return &request, rawBody, close, nil + default: + return req, rawBody, close, validate.InvalidContentType(ct) + } +} diff --git a/pkg/rest/oas_request_encoders_gen.go b/pkg/rest/oas_request_encoders_gen.go index 1ad6008..1851530 100644 --- a/pkg/rest/oas_request_encoders_gen.go +++ b/pkg/rest/oas_request_encoders_gen.go @@ -1,3 +1,53 @@ // Code generated by ogen, DO NOT EDIT. package rest + +import ( + "bytes" + "net/http" + + "github.com/go-faster/jx" + ht "github.com/ogen-go/ogen/http" +) + +func encodeCreatePredictionJobRequest( + req *PredictionV2Request, + r *http.Request, +) error { + const contentType = "application/json" + e := new(jx.Encoder) + { + req.Encode(e) + } + encoded := e.Bytes() + ht.SetBody(r, bytes.NewReader(encoded), contentType) + return nil +} + +func encodePerformPredictionV2Request( + req *PredictionV2Request, + r *http.Request, +) error { + const contentType = "application/json" + e := new(jx.Encoder) + { + req.Encode(e) + } + encoded := e.Bytes() + ht.SetBody(r, bytes.NewReader(encoded), contentType) + return nil +} + +func encodeTriggerDatasetDownloadRequest( + req *DownloadRequest, + r *http.Request, +) error { + const contentType = "application/json" + e := new(jx.Encoder) + { + req.Encode(e) + } + encoded := e.Bytes() + ht.SetBody(r, bytes.NewReader(encoded), contentType) + return nil +} diff --git a/pkg/rest/oas_response_decoders_gen.go b/pkg/rest/oas_response_decoders_gen.go index 842583d..170f862 100644 --- a/pkg/rest/oas_response_decoders_gen.go +++ b/pkg/rest/oas_response_decoders_gen.go @@ -3,6 +3,7 @@ package rest import ( + "fmt" "io" "mime" "net/http" @@ -13,6 +14,936 @@ import ( "github.com/ogen-go/ogen/validate" ) +func decodeCancelDatasetJobResponse(resp *http.Response) (res *CancelDatasetJobNoContent, _ error) { + switch resp.StatusCode { + case 204: + // Code 204. + return &CancelDatasetJobNoContent{}, nil + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeCancelPredictionJobResponse(resp *http.Response) (res *CancelPredictionJobNoContent, _ error) { + switch resp.StatusCode { + case 204: + // Code 204. + return &CancelPredictionJobNoContent{}, nil + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeCreatePredictionJobResponse(resp *http.Response) (res *PredictionJob, _ error) { + switch resp.StatusCode { + case 202: + // Code 202. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response PredictionJob + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeDeleteDatasetResponse(resp *http.Response) (res *DeleteDatasetNoContent, _ error) { + switch resp.StatusCode { + case 204: + // Code 204. + return &DeleteDatasetNoContent{}, nil + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeGetDatasetJobResponse(resp *http.Response) (res *DownloadJob, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response DownloadJob + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeGetPredictionJobResponse(resp *http.Response) (res *PredictionJob, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response PredictionJob + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeGetServiceStatusResponse(resp *http.Response) (res *StatusResponse, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response StatusResponse + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeGetWindFieldResponse(resp *http.Response) (res []WindComponent, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response []WindComponent + if err := func() error { + response = make([]WindComponent, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem WindComponent + if err := elem.Decode(d); err != nil { + return err + } + response = append(response, elem) + return nil + }); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if response == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range response { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeGetWindMetaResponse(resp *http.Response) (res *WindMeta, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response WindMeta + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeListDatasetJobsResponse(resp *http.Response) (res []DownloadJob, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response []DownloadJob + if err := func() error { + response = make([]DownloadJob, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem DownloadJob + if err := elem.Decode(d); err != nil { + return err + } + response = append(response, elem) + return nil + }); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if response == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range response { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeListDatasetsResponse(resp *http.Response) (res *DatasetList, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response DatasetList + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + func decodePerformPredictionResponse(resp *http.Response) (res *PredictionResponse, _ error) { switch resp.StatusCode { case 200: @@ -61,7 +992,7 @@ func decodePerformPredictionResponse(resp *http.Response) (res *PredictionRespon } } // Convenient error response. - defRes, err := func() (res *ErrorStatusCode, err error) { + defRes, err := func() (res *DefaultErrorStatusCode, err error) { ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) if err != nil { return res, errors.Wrap(err, "parse media type") @@ -91,7 +1022,99 @@ func decodePerformPredictionResponse(resp *http.Response) (res *PredictionRespon } return res, err } - return &ErrorStatusCode{ + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodePerformPredictionV2Response(resp *http.Response) (res *PredictionV2Response, _ error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response PredictionV2Response + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + // Validate response. + if err := func() error { + if err := response.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ StatusCode: resp.StatusCode, Response: response, }, nil @@ -153,7 +1176,7 @@ func decodeReadinessCheckResponse(resp *http.Response) (res *ReadinessResponse, } } // Convenient error response. - defRes, err := func() (res *ErrorStatusCode, err error) { + defRes, err := func() (res *DefaultErrorStatusCode, err error) { ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) if err != nil { return res, errors.Wrap(err, "parse media type") @@ -183,7 +1206,90 @@ func decodeReadinessCheckResponse(resp *http.Response) (res *ReadinessResponse, } return res, err } - return &ErrorStatusCode{ + return &DefaultErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrapf(err, "default (code %d)", resp.StatusCode) + } + return res, errors.Wrap(defRes, "error") +} + +func decodeTriggerDatasetDownloadResponse(resp *http.Response) (res *DownloadAccepted, _ error) { + switch resp.StatusCode { + case 202: + // Code 202. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response DownloadAccepted + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *DefaultErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &DefaultErrorStatusCode{ StatusCode: resp.StatusCode, Response: response, }, nil diff --git a/pkg/rest/oas_response_encoders_gen.go b/pkg/rest/oas_response_encoders_gen.go index 37892a3..33a690c 100644 --- a/pkg/rest/oas_response_encoders_gen.go +++ b/pkg/rest/oas_response_encoders_gen.go @@ -12,6 +12,147 @@ import ( "go.opentelemetry.io/otel/trace" ) +func encodeCancelDatasetJobResponse(response *CancelDatasetJobNoContent, w http.ResponseWriter, span trace.Span) error { + w.WriteHeader(204) + span.SetStatus(codes.Ok, http.StatusText(204)) + + return nil +} + +func encodeCancelPredictionJobResponse(response *CancelPredictionJobNoContent, w http.ResponseWriter, span trace.Span) error { + w.WriteHeader(204) + span.SetStatus(codes.Ok, http.StatusText(204)) + + return nil +} + +func encodeCreatePredictionJobResponse(response *PredictionJob, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(202) + span.SetStatus(codes.Ok, http.StatusText(202)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + +func encodeDeleteDatasetResponse(response *DeleteDatasetNoContent, w http.ResponseWriter, span trace.Span) error { + w.WriteHeader(204) + span.SetStatus(codes.Ok, http.StatusText(204)) + + return nil +} + +func encodeGetDatasetJobResponse(response *DownloadJob, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + +func encodeGetPredictionJobResponse(response *PredictionJob, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + +func encodeGetServiceStatusResponse(response *StatusResponse, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + +func encodeGetWindFieldResponse(response []WindComponent, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := new(jx.Encoder) + e.ArrStart() + for _, elem := range response { + elem.Encode(e) + } + e.ArrEnd() + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + +func encodeGetWindMetaResponse(response *WindMeta, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + +func encodeListDatasetJobsResponse(response []DownloadJob, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := new(jx.Encoder) + e.ArrStart() + for _, elem := range response { + elem.Encode(e) + } + e.ArrEnd() + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + +func encodeListDatasetsResponse(response *DatasetList, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + func encodePerformPredictionResponse(response *PredictionResponse, w http.ResponseWriter, span trace.Span) error { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -26,6 +167,20 @@ func encodePerformPredictionResponse(response *PredictionResponse, w http.Respon return nil } +func encodePerformPredictionV2Response(response *PredictionV2Response, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + func encodeReadinessCheckResponse(response *ReadinessResponse, w http.ResponseWriter, span trace.Span) error { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(200) @@ -40,7 +195,21 @@ func encodeReadinessCheckResponse(response *ReadinessResponse, w http.ResponseWr return nil } -func encodeErrorResponse(response *ErrorStatusCode, w http.ResponseWriter, span trace.Span) error { +func encodeTriggerDatasetDownloadResponse(response *DownloadAccepted, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(202) + span.SetStatus(codes.Ok, http.StatusText(202)) + + e := new(jx.Encoder) + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + + return nil +} + +func encodeErrorResponse(response *DefaultErrorStatusCode, w http.ResponseWriter, span trace.Span) error { w.Header().Set("Content-Type", "application/json; charset=utf-8") code := response.StatusCode if code == 0 { diff --git a/pkg/rest/oas_router_gen.go b/pkg/rest/oas_router_gen.go index ac8879a..95d2374 100644 --- a/pkg/rest/oas_router_gen.go +++ b/pkg/rest/oas_router_gen.go @@ -10,6 +10,18 @@ import ( "github.com/ogen-go/ogen/uri" ) +var ( + rn15AllowedHeaders = map[string]string{ + "POST": "Content-Type", + } + rn6AllowedHeaders = map[string]string{ + "POST": "Content-Type", + } + rn18AllowedHeaders = map[string]string{ + "POST": "Content-Type", + } +) + func (s *Server) cutPrefix(path string) (string, bool) { prefix := s.cfg.Prefix if prefix == "" { @@ -40,6 +52,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.notFound(w, r) return } + args := [1]string{} // Static code generated router with unwrapped path search. switch { @@ -60,29 +73,382 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { break } switch elem[0] { - case 'a': // Prefix: "api/v1/prediction" + case 'a': // Prefix: "api/v" - if l := len("api/v1/prediction"); len(elem) >= l && elem[0:l] == "api/v1/prediction" { + if l := len("api/v"); len(elem) >= l && elem[0:l] == "api/v" { elem = elem[l:] } else { break } if len(elem) == 0 { - // Leaf node. - switch r.Method { - case "GET": - s.handlePerformPredictionRequest([0]string{}, elemIsEscaped, w, r) - default: - s.notAllowed(w, r, notAllowedParams{ - allowedMethods: "GET", - allowedHeaders: nil, - acceptPost: "", - acceptPatch: "", - }) + break + } + switch elem[0] { + case '1': // Prefix: "1/" + + if l := len("1/"); len(elem) >= l && elem[0:l] == "1/" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + break + } + switch elem[0] { + case 'a': // Prefix: "admin/" + + if l := len("admin/"); len(elem) >= l && elem[0:l] == "admin/" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + break + } + switch elem[0] { + case 'd': // Prefix: "datasets" + + if l := len("datasets"); len(elem) >= l && elem[0:l] == "datasets" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch r.Method { + case "GET": + s.handleListDatasetsRequest([0]string{}, elemIsEscaped, w, r) + case "POST": + s.handleTriggerDatasetDownloadRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "GET,POST", + allowedHeaders: rn15AllowedHeaders, + acceptPost: "application/json", + acceptPatch: "", + }) + } + + return + } + switch elem[0] { + case '/': // Prefix: "/" + + if l := len("/"); len(elem) >= l && elem[0:l] == "/" { + elem = elem[l:] + } else { + break + } + + // Param: "name" + // Leaf parameter, slashes are prohibited + idx := strings.IndexByte(elem, '/') + if idx >= 0 { + break + } + args[0] = elem + elem = "" + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "DELETE": + s.handleDeleteDatasetRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "DELETE", + allowedHeaders: nil, + acceptPost: "", + acceptPatch: "", + }) + } + + return + } + + } + + case 'j': // Prefix: "jobs" + + if l := len("jobs"); len(elem) >= l && elem[0:l] == "jobs" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch r.Method { + case "GET": + s.handleListDatasetJobsRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "GET", + allowedHeaders: nil, + acceptPost: "", + acceptPatch: "", + }) + } + + return + } + switch elem[0] { + case '/': // Prefix: "/" + + if l := len("/"); len(elem) >= l && elem[0:l] == "/" { + elem = elem[l:] + } else { + break + } + + // Param: "id" + // Leaf parameter, slashes are prohibited + idx := strings.IndexByte(elem, '/') + if idx >= 0 { + break + } + args[0] = elem + elem = "" + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "DELETE": + s.handleCancelDatasetJobRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + case "GET": + s.handleGetDatasetJobRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "DELETE,GET", + allowedHeaders: nil, + acceptPost: "", + acceptPatch: "", + }) + } + + return + } + + } + + case 's': // Prefix: "status" + + if l := len("status"); len(elem) >= l && elem[0:l] == "status" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "GET": + s.handleGetServiceStatusRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "GET", + allowedHeaders: nil, + acceptPost: "", + acceptPatch: "", + }) + } + + return + } + + } + + case 'p': // Prefix: "prediction" + + if l := len("prediction"); len(elem) >= l && elem[0:l] == "prediction" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch r.Method { + case "GET": + s.handlePerformPredictionRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "GET", + allowedHeaders: nil, + acceptPost: "", + acceptPatch: "", + }) + } + + return + } + switch elem[0] { + case 's': // Prefix: "s" + + if l := len("s"); len(elem) >= l && elem[0:l] == "s" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch r.Method { + case "POST": + s.handleCreatePredictionJobRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "POST", + allowedHeaders: rn6AllowedHeaders, + acceptPost: "application/json", + acceptPatch: "", + }) + } + + return + } + switch elem[0] { + case '/': // Prefix: "/" + + if l := len("/"); len(elem) >= l && elem[0:l] == "/" { + elem = elem[l:] + } else { + break + } + + // Param: "id" + // Leaf parameter, slashes are prohibited + idx := strings.IndexByte(elem, '/') + if idx >= 0 { + break + } + args[0] = elem + elem = "" + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "DELETE": + s.handleCancelPredictionJobRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + case "GET": + s.handleGetPredictionJobRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "DELETE,GET", + allowedHeaders: nil, + acceptPost: "", + acceptPatch: "", + }) + } + + return + } + + } + + } + + case 'w': // Prefix: "wind/" + + if l := len("wind/"); len(elem) >= l && elem[0:l] == "wind/" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + break + } + switch elem[0] { + case 'f': // Prefix: "field" + + if l := len("field"); len(elem) >= l && elem[0:l] == "field" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "GET": + s.handleGetWindFieldRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "GET", + allowedHeaders: nil, + acceptPost: "", + acceptPatch: "", + }) + } + + return + } + + case 'm': // Prefix: "meta" + + if l := len("meta"); len(elem) >= l && elem[0:l] == "meta" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "GET": + s.handleGetWindMetaRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "GET", + allowedHeaders: nil, + acceptPost: "", + acceptPatch: "", + }) + } + + return + } + + } + + } + + case '2': // Prefix: "2/prediction" + + if l := len("2/prediction"); len(elem) >= l && elem[0:l] == "2/prediction" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "POST": + s.handlePerformPredictionV2Request([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, notAllowedParams{ + allowedMethods: "POST", + allowedHeaders: rn18AllowedHeaders, + acceptPost: "application/json", + acceptPatch: "", + }) + } + + return } - return } case 'r': // Prefix: "ready" @@ -125,7 +491,7 @@ type Route struct { operationGroup string pathPattern string count int - args [0]string + args [1]string } // Name returns ogen operation name. @@ -210,29 +576,393 @@ func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { break } switch elem[0] { - case 'a': // Prefix: "api/v1/prediction" + case 'a': // Prefix: "api/v" - if l := len("api/v1/prediction"); len(elem) >= l && elem[0:l] == "api/v1/prediction" { + if l := len("api/v"); len(elem) >= l && elem[0:l] == "api/v" { elem = elem[l:] } else { break } if len(elem) == 0 { - // Leaf node. - switch method { - case "GET": - r.name = PerformPredictionOperation - r.summary = "Perform prediction" - r.operationID = "performPrediction" - r.operationGroup = "" - r.pathPattern = "/api/v1/prediction" - r.args = args - r.count = 0 - return r, true - default: - return + break + } + switch elem[0] { + case '1': // Prefix: "1/" + + if l := len("1/"); len(elem) >= l && elem[0:l] == "1/" { + elem = elem[l:] + } else { + break } + + if len(elem) == 0 { + break + } + switch elem[0] { + case 'a': // Prefix: "admin/" + + if l := len("admin/"); len(elem) >= l && elem[0:l] == "admin/" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + break + } + switch elem[0] { + case 'd': // Prefix: "datasets" + + if l := len("datasets"); len(elem) >= l && elem[0:l] == "datasets" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch method { + case "GET": + r.name = ListDatasetsOperation + r.summary = "List stored datasets" + r.operationID = "listDatasets" + r.operationGroup = "" + r.pathPattern = "/api/v1/admin/datasets" + r.args = args + r.count = 0 + return r, true + case "POST": + r.name = TriggerDatasetDownloadOperation + r.summary = "Trigger a dataset download" + r.operationID = "triggerDatasetDownload" + r.operationGroup = "" + r.pathPattern = "/api/v1/admin/datasets" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + switch elem[0] { + case '/': // Prefix: "/" + + if l := len("/"); len(elem) >= l && elem[0:l] == "/" { + elem = elem[l:] + } else { + break + } + + // Param: "name" + // Leaf parameter, slashes are prohibited + idx := strings.IndexByte(elem, '/') + if idx >= 0 { + break + } + args[0] = elem + elem = "" + + if len(elem) == 0 { + // Leaf node. + switch method { + case "DELETE": + r.name = DeleteDatasetOperation + r.summary = "Delete a stored dataset by filename" + r.operationID = "deleteDataset" + r.operationGroup = "" + r.pathPattern = "/api/v1/admin/datasets/{name}" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + + } + + case 'j': // Prefix: "jobs" + + if l := len("jobs"); len(elem) >= l && elem[0:l] == "jobs" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch method { + case "GET": + r.name = ListDatasetJobsOperation + r.summary = "List dataset download jobs" + r.operationID = "listDatasetJobs" + r.operationGroup = "" + r.pathPattern = "/api/v1/admin/jobs" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + switch elem[0] { + case '/': // Prefix: "/" + + if l := len("/"); len(elem) >= l && elem[0:l] == "/" { + elem = elem[l:] + } else { + break + } + + // Param: "id" + // Leaf parameter, slashes are prohibited + idx := strings.IndexByte(elem, '/') + if idx >= 0 { + break + } + args[0] = elem + elem = "" + + if len(elem) == 0 { + // Leaf node. + switch method { + case "DELETE": + r.name = CancelDatasetJobOperation + r.summary = "Cancel a running download job" + r.operationID = "cancelDatasetJob" + r.operationGroup = "" + r.pathPattern = "/api/v1/admin/jobs/{id}" + r.args = args + r.count = 1 + return r, true + case "GET": + r.name = GetDatasetJobOperation + r.summary = "Get a dataset download job" + r.operationID = "getDatasetJob" + r.operationGroup = "" + r.pathPattern = "/api/v1/admin/jobs/{id}" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + + } + + case 's': // Prefix: "status" + + if l := len("status"); len(elem) >= l && elem[0:l] == "status" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "GET": + r.name = GetServiceStatusOperation + r.summary = "Service status summary" + r.operationID = "getServiceStatus" + r.operationGroup = "" + r.pathPattern = "/api/v1/admin/status" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + + } + + case 'p': // Prefix: "prediction" + + if l := len("prediction"); len(elem) >= l && elem[0:l] == "prediction" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch method { + case "GET": + r.name = PerformPredictionOperation + r.summary = "Tawhiri-compatible prediction" + r.operationID = "performPrediction" + r.operationGroup = "" + r.pathPattern = "/api/v1/prediction" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + switch elem[0] { + case 's': // Prefix: "s" + + if l := len("s"); len(elem) >= l && elem[0:l] == "s" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch method { + case "POST": + r.name = CreatePredictionJobOperation + r.summary = "Enqueue an asynchronous prediction" + r.operationID = "createPredictionJob" + r.operationGroup = "" + r.pathPattern = "/api/v1/predictions" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + switch elem[0] { + case '/': // Prefix: "/" + + if l := len("/"); len(elem) >= l && elem[0:l] == "/" { + elem = elem[l:] + } else { + break + } + + // Param: "id" + // Leaf parameter, slashes are prohibited + idx := strings.IndexByte(elem, '/') + if idx >= 0 { + break + } + args[0] = elem + elem = "" + + if len(elem) == 0 { + // Leaf node. + switch method { + case "DELETE": + r.name = CancelPredictionJobOperation + r.summary = "Cancel a queued prediction job" + r.operationID = "cancelPredictionJob" + r.operationGroup = "" + r.pathPattern = "/api/v1/predictions/{id}" + r.args = args + r.count = 1 + return r, true + case "GET": + r.name = GetPredictionJobOperation + r.summary = "Poll an asynchronous prediction job" + r.operationID = "getPredictionJob" + r.operationGroup = "" + r.pathPattern = "/api/v1/predictions/{id}" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + + } + + } + + case 'w': // Prefix: "wind/" + + if l := len("wind/"); len(elem) >= l && elem[0:l] == "wind/" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + break + } + switch elem[0] { + case 'f': // Prefix: "field" + + if l := len("field"); len(elem) >= l && elem[0:l] == "field" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "GET": + r.name = GetWindFieldOperation + r.summary = "Wind-field velocity grid (leaflet-velocity / wind-layer format)" + r.operationID = "getWindField" + r.operationGroup = "" + r.pathPattern = "/api/v1/wind/field" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + + case 'm': // Prefix: "meta" + + if l := len("meta"); len(elem) >= l && elem[0:l] == "meta" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "GET": + r.name = GetWindMetaOperation + r.summary = "Wind-field visualization metadata" + r.operationID = "getWindMeta" + r.operationGroup = "" + r.pathPattern = "/api/v1/wind/meta" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + + } + + } + + case '2': // Prefix: "2/prediction" + + if l := len("2/prediction"); len(elem) >= l && elem[0:l] == "2/prediction" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf node. + switch method { + case "POST": + r.name = PerformPredictionV2Operation + r.summary = "Profile-driven prediction (synchronous)" + r.operationID = "performPredictionV2" + r.operationGroup = "" + r.pathPattern = "/api/v2/prediction" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + } case 'r': // Prefix: "ready" diff --git a/pkg/rest/oas_schemas_gen.go b/pkg/rest/oas_schemas_gen.go index 28faae8..bdcd84a 100644 --- a/pkg/rest/oas_schemas_gen.go +++ b/pkg/rest/oas_schemas_gen.go @@ -10,10 +10,719 @@ import ( "github.com/go-faster/jx" ) -func (s *ErrorStatusCode) Error() string { +func (s *DefaultErrorStatusCode) Error() string { return fmt.Sprintf("code %d: %+v", s.StatusCode, s.Response) } +// CancelDatasetJobNoContent is response for CancelDatasetJob operation. +type CancelDatasetJobNoContent struct{} + +// CancelPredictionJobNoContent is response for CancelPredictionJob operation. +type CancelPredictionJobNoContent struct{} + +// Ref: #/components/schemas/ConstraintSpec +type ConstraintSpec struct { + Type ConstraintSpecType `json:"type"` + Op OptConstraintSpecOp `json:"op"` + Limit OptFloat64 `json:"limit"` + Action OptConstraintSpecAction `json:"action"` + Mode OptConstraintSpecMode `json:"mode"` + Label OptString `json:"label"` + Vertices []PolygonVertex `json:"vertices"` +} + +// GetType returns the value of Type. +func (s *ConstraintSpec) GetType() ConstraintSpecType { + return s.Type +} + +// GetOp returns the value of Op. +func (s *ConstraintSpec) GetOp() OptConstraintSpecOp { + return s.Op +} + +// GetLimit returns the value of Limit. +func (s *ConstraintSpec) GetLimit() OptFloat64 { + return s.Limit +} + +// GetAction returns the value of Action. +func (s *ConstraintSpec) GetAction() OptConstraintSpecAction { + return s.Action +} + +// GetMode returns the value of Mode. +func (s *ConstraintSpec) GetMode() OptConstraintSpecMode { + return s.Mode +} + +// GetLabel returns the value of Label. +func (s *ConstraintSpec) GetLabel() OptString { + return s.Label +} + +// GetVertices returns the value of Vertices. +func (s *ConstraintSpec) GetVertices() []PolygonVertex { + return s.Vertices +} + +// SetType sets the value of Type. +func (s *ConstraintSpec) SetType(val ConstraintSpecType) { + s.Type = val +} + +// SetOp sets the value of Op. +func (s *ConstraintSpec) SetOp(val OptConstraintSpecOp) { + s.Op = val +} + +// SetLimit sets the value of Limit. +func (s *ConstraintSpec) SetLimit(val OptFloat64) { + s.Limit = val +} + +// SetAction sets the value of Action. +func (s *ConstraintSpec) SetAction(val OptConstraintSpecAction) { + s.Action = val +} + +// SetMode sets the value of Mode. +func (s *ConstraintSpec) SetMode(val OptConstraintSpecMode) { + s.Mode = val +} + +// SetLabel sets the value of Label. +func (s *ConstraintSpec) SetLabel(val OptString) { + s.Label = val +} + +// SetVertices sets the value of Vertices. +func (s *ConstraintSpec) SetVertices(val []PolygonVertex) { + s.Vertices = val +} + +type ConstraintSpecAction string + +const ( + ConstraintSpecActionStop ConstraintSpecAction = "stop" + ConstraintSpecActionFallback ConstraintSpecAction = "fallback" + ConstraintSpecActionClip ConstraintSpecAction = "clip" +) + +// AllValues returns all ConstraintSpecAction values. +func (ConstraintSpecAction) AllValues() []ConstraintSpecAction { + return []ConstraintSpecAction{ + ConstraintSpecActionStop, + ConstraintSpecActionFallback, + ConstraintSpecActionClip, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s ConstraintSpecAction) MarshalText() ([]byte, error) { + switch s { + case ConstraintSpecActionStop: + return []byte(s), nil + case ConstraintSpecActionFallback: + return []byte(s), nil + case ConstraintSpecActionClip: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *ConstraintSpecAction) UnmarshalText(data []byte) error { + switch ConstraintSpecAction(data) { + case ConstraintSpecActionStop: + *s = ConstraintSpecActionStop + return nil + case ConstraintSpecActionFallback: + *s = ConstraintSpecActionFallback + return nil + case ConstraintSpecActionClip: + *s = ConstraintSpecActionClip + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +type ConstraintSpecMode string + +const ( + ConstraintSpecModeInside ConstraintSpecMode = "inside" + ConstraintSpecModeOutside ConstraintSpecMode = "outside" +) + +// AllValues returns all ConstraintSpecMode values. +func (ConstraintSpecMode) AllValues() []ConstraintSpecMode { + return []ConstraintSpecMode{ + ConstraintSpecModeInside, + ConstraintSpecModeOutside, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s ConstraintSpecMode) MarshalText() ([]byte, error) { + switch s { + case ConstraintSpecModeInside: + return []byte(s), nil + case ConstraintSpecModeOutside: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *ConstraintSpecMode) UnmarshalText(data []byte) error { + switch ConstraintSpecMode(data) { + case ConstraintSpecModeInside: + *s = ConstraintSpecModeInside + return nil + case ConstraintSpecModeOutside: + *s = ConstraintSpecModeOutside + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +type ConstraintSpecOp string + +const ( + ConstraintSpecOpLess ConstraintSpecOp = "<" + ConstraintSpecOpLessEq ConstraintSpecOp = "<=" + ConstraintSpecOpGreater ConstraintSpecOp = ">" + ConstraintSpecOpGreaterEq ConstraintSpecOp = ">=" + ConstraintSpecOpEqEq ConstraintSpecOp = "==" +) + +// AllValues returns all ConstraintSpecOp values. +func (ConstraintSpecOp) AllValues() []ConstraintSpecOp { + return []ConstraintSpecOp{ + ConstraintSpecOpLess, + ConstraintSpecOpLessEq, + ConstraintSpecOpGreater, + ConstraintSpecOpGreaterEq, + ConstraintSpecOpEqEq, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s ConstraintSpecOp) MarshalText() ([]byte, error) { + switch s { + case ConstraintSpecOpLess: + return []byte(s), nil + case ConstraintSpecOpLessEq: + return []byte(s), nil + case ConstraintSpecOpGreater: + return []byte(s), nil + case ConstraintSpecOpGreaterEq: + return []byte(s), nil + case ConstraintSpecOpEqEq: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *ConstraintSpecOp) UnmarshalText(data []byte) error { + switch ConstraintSpecOp(data) { + case ConstraintSpecOpLess: + *s = ConstraintSpecOpLess + return nil + case ConstraintSpecOpLessEq: + *s = ConstraintSpecOpLessEq + return nil + case ConstraintSpecOpGreater: + *s = ConstraintSpecOpGreater + return nil + case ConstraintSpecOpGreaterEq: + *s = ConstraintSpecOpGreaterEq + return nil + case ConstraintSpecOpEqEq: + *s = ConstraintSpecOpEqEq + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +type ConstraintSpecType string + +const ( + ConstraintSpecTypeAltitude ConstraintSpecType = "altitude" + ConstraintSpecTypeTime ConstraintSpecType = "time" + ConstraintSpecTypeTerrainContact ConstraintSpecType = "terrain_contact" + ConstraintSpecTypePolygon ConstraintSpecType = "polygon" +) + +// AllValues returns all ConstraintSpecType values. +func (ConstraintSpecType) AllValues() []ConstraintSpecType { + return []ConstraintSpecType{ + ConstraintSpecTypeAltitude, + ConstraintSpecTypeTime, + ConstraintSpecTypeTerrainContact, + ConstraintSpecTypePolygon, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s ConstraintSpecType) MarshalText() ([]byte, error) { + switch s { + case ConstraintSpecTypeAltitude: + return []byte(s), nil + case ConstraintSpecTypeTime: + return []byte(s), nil + case ConstraintSpecTypeTerrainContact: + return []byte(s), nil + case ConstraintSpecTypePolygon: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *ConstraintSpecType) UnmarshalText(data []byte) error { + switch ConstraintSpecType(data) { + case ConstraintSpecTypeAltitude: + *s = ConstraintSpecTypeAltitude + return nil + case ConstraintSpecTypeTime: + *s = ConstraintSpecTypeTime + return nil + case ConstraintSpecTypeTerrainContact: + *s = ConstraintSpecTypeTerrainContact + return nil + case ConstraintSpecTypePolygon: + *s = ConstraintSpecTypePolygon + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +// Ref: #/components/schemas/Coverage +type Coverage struct { + Region Region `json:"region"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} + +// GetRegion returns the value of Region. +func (s *Coverage) GetRegion() Region { + return s.Region +} + +// GetStartTime returns the value of StartTime. +func (s *Coverage) GetStartTime() time.Time { + return s.StartTime +} + +// GetEndTime returns the value of EndTime. +func (s *Coverage) GetEndTime() time.Time { + return s.EndTime +} + +// SetRegion sets the value of Region. +func (s *Coverage) SetRegion(val Region) { + s.Region = val +} + +// SetStartTime sets the value of StartTime. +func (s *Coverage) SetStartTime(val time.Time) { + s.StartTime = val +} + +// SetEndTime sets the value of EndTime. +func (s *Coverage) SetEndTime(val time.Time) { + s.EndTime = val +} + +// Ref: #/components/schemas/DatasetEntry +type DatasetEntry struct { + Filename string `json:"filename"` + Epoch time.Time `json:"epoch"` + Subset OptSubsetSpec `json:"subset"` + Coverage OptCoverage `json:"coverage"` + Loaded bool `json:"loaded"` +} + +// GetFilename returns the value of Filename. +func (s *DatasetEntry) GetFilename() string { + return s.Filename +} + +// GetEpoch returns the value of Epoch. +func (s *DatasetEntry) GetEpoch() time.Time { + return s.Epoch +} + +// GetSubset returns the value of Subset. +func (s *DatasetEntry) GetSubset() OptSubsetSpec { + return s.Subset +} + +// GetCoverage returns the value of Coverage. +func (s *DatasetEntry) GetCoverage() OptCoverage { + return s.Coverage +} + +// GetLoaded returns the value of Loaded. +func (s *DatasetEntry) GetLoaded() bool { + return s.Loaded +} + +// SetFilename sets the value of Filename. +func (s *DatasetEntry) SetFilename(val string) { + s.Filename = val +} + +// SetEpoch sets the value of Epoch. +func (s *DatasetEntry) SetEpoch(val time.Time) { + s.Epoch = val +} + +// SetSubset sets the value of Subset. +func (s *DatasetEntry) SetSubset(val OptSubsetSpec) { + s.Subset = val +} + +// SetCoverage sets the value of Coverage. +func (s *DatasetEntry) SetCoverage(val OptCoverage) { + s.Coverage = val +} + +// SetLoaded sets the value of Loaded. +func (s *DatasetEntry) SetLoaded(val bool) { + s.Loaded = val +} + +// Ref: #/components/schemas/DatasetInfo +type DatasetInfo struct { + Source string `json:"source"` + Epoch time.Time `json:"epoch"` +} + +// GetSource returns the value of Source. +func (s *DatasetInfo) GetSource() string { + return s.Source +} + +// GetEpoch returns the value of Epoch. +func (s *DatasetInfo) GetEpoch() time.Time { + return s.Epoch +} + +// SetSource sets the value of Source. +func (s *DatasetInfo) SetSource(val string) { + s.Source = val +} + +// SetEpoch sets the value of Epoch. +func (s *DatasetInfo) SetEpoch(val time.Time) { + s.Epoch = val +} + +// Ref: #/components/schemas/DatasetList +type DatasetList struct { + Source string `json:"source"` + Datasets []DatasetEntry `json:"datasets"` +} + +// GetSource returns the value of Source. +func (s *DatasetList) GetSource() string { + return s.Source +} + +// GetDatasets returns the value of Datasets. +func (s *DatasetList) GetDatasets() []DatasetEntry { + return s.Datasets +} + +// SetSource sets the value of Source. +func (s *DatasetList) SetSource(val string) { + s.Source = val +} + +// SetDatasets sets the value of Datasets. +func (s *DatasetList) SetDatasets(val []DatasetEntry) { + s.Datasets = val +} + +// DefaultErrorStatusCode wraps Error with StatusCode. +type DefaultErrorStatusCode struct { + StatusCode int + Response Error +} + +// GetStatusCode returns the value of StatusCode. +func (s *DefaultErrorStatusCode) GetStatusCode() int { + return s.StatusCode +} + +// GetResponse returns the value of Response. +func (s *DefaultErrorStatusCode) GetResponse() Error { + return s.Response +} + +// SetStatusCode sets the value of StatusCode. +func (s *DefaultErrorStatusCode) SetStatusCode(val int) { + s.StatusCode = val +} + +// SetResponse sets the value of Response. +func (s *DefaultErrorStatusCode) SetResponse(val Error) { + s.Response = val +} + +// DeleteDatasetNoContent is response for DeleteDataset operation. +type DeleteDatasetNoContent struct{} + +// Ref: #/components/schemas/DownloadAccepted +type DownloadAccepted struct { + JobID string `json:"job_id"` +} + +// GetJobID returns the value of JobID. +func (s *DownloadAccepted) GetJobID() string { + return s.JobID +} + +// SetJobID sets the value of JobID. +func (s *DownloadAccepted) SetJobID(val string) { + s.JobID = val +} + +// Ref: #/components/schemas/DownloadJob +type DownloadJob struct { + ID string `json:"id"` + Source string `json:"source"` + Dataset string `json:"dataset"` + Epoch time.Time `json:"epoch"` + Status DownloadJobStatus `json:"status"` + StartedAt time.Time `json:"started_at"` + EndedAt OptDateTime `json:"ended_at"` + Error OptString `json:"error"` + TotalUnits int `json:"total_units"` + DoneUnits int `json:"done_units"` + Bytes int64 `json:"bytes"` +} + +// GetID returns the value of ID. +func (s *DownloadJob) GetID() string { + return s.ID +} + +// GetSource returns the value of Source. +func (s *DownloadJob) GetSource() string { + return s.Source +} + +// GetDataset returns the value of Dataset. +func (s *DownloadJob) GetDataset() string { + return s.Dataset +} + +// GetEpoch returns the value of Epoch. +func (s *DownloadJob) GetEpoch() time.Time { + return s.Epoch +} + +// GetStatus returns the value of Status. +func (s *DownloadJob) GetStatus() DownloadJobStatus { + return s.Status +} + +// GetStartedAt returns the value of StartedAt. +func (s *DownloadJob) GetStartedAt() time.Time { + return s.StartedAt +} + +// GetEndedAt returns the value of EndedAt. +func (s *DownloadJob) GetEndedAt() OptDateTime { + return s.EndedAt +} + +// GetError returns the value of Error. +func (s *DownloadJob) GetError() OptString { + return s.Error +} + +// GetTotalUnits returns the value of TotalUnits. +func (s *DownloadJob) GetTotalUnits() int { + return s.TotalUnits +} + +// GetDoneUnits returns the value of DoneUnits. +func (s *DownloadJob) GetDoneUnits() int { + return s.DoneUnits +} + +// GetBytes returns the value of Bytes. +func (s *DownloadJob) GetBytes() int64 { + return s.Bytes +} + +// SetID sets the value of ID. +func (s *DownloadJob) SetID(val string) { + s.ID = val +} + +// SetSource sets the value of Source. +func (s *DownloadJob) SetSource(val string) { + s.Source = val +} + +// SetDataset sets the value of Dataset. +func (s *DownloadJob) SetDataset(val string) { + s.Dataset = val +} + +// SetEpoch sets the value of Epoch. +func (s *DownloadJob) SetEpoch(val time.Time) { + s.Epoch = val +} + +// SetStatus sets the value of Status. +func (s *DownloadJob) SetStatus(val DownloadJobStatus) { + s.Status = val +} + +// SetStartedAt sets the value of StartedAt. +func (s *DownloadJob) SetStartedAt(val time.Time) { + s.StartedAt = val +} + +// SetEndedAt sets the value of EndedAt. +func (s *DownloadJob) SetEndedAt(val OptDateTime) { + s.EndedAt = val +} + +// SetError sets the value of Error. +func (s *DownloadJob) SetError(val OptString) { + s.Error = val +} + +// SetTotalUnits sets the value of TotalUnits. +func (s *DownloadJob) SetTotalUnits(val int) { + s.TotalUnits = val +} + +// SetDoneUnits sets the value of DoneUnits. +func (s *DownloadJob) SetDoneUnits(val int) { + s.DoneUnits = val +} + +// SetBytes sets the value of Bytes. +func (s *DownloadJob) SetBytes(val int64) { + s.Bytes = val +} + +type DownloadJobStatus string + +const ( + DownloadJobStatusPending DownloadJobStatus = "pending" + DownloadJobStatusRunning DownloadJobStatus = "running" + DownloadJobStatusComplete DownloadJobStatus = "complete" + DownloadJobStatusFailed DownloadJobStatus = "failed" + DownloadJobStatusCancelled DownloadJobStatus = "cancelled" +) + +// AllValues returns all DownloadJobStatus values. +func (DownloadJobStatus) AllValues() []DownloadJobStatus { + return []DownloadJobStatus{ + DownloadJobStatusPending, + DownloadJobStatusRunning, + DownloadJobStatusComplete, + DownloadJobStatusFailed, + DownloadJobStatusCancelled, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s DownloadJobStatus) MarshalText() ([]byte, error) { + switch s { + case DownloadJobStatusPending: + return []byte(s), nil + case DownloadJobStatusRunning: + return []byte(s), nil + case DownloadJobStatusComplete: + return []byte(s), nil + case DownloadJobStatusFailed: + return []byte(s), nil + case DownloadJobStatusCancelled: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *DownloadJobStatus) UnmarshalText(data []byte) error { + switch DownloadJobStatus(data) { + case DownloadJobStatusPending: + *s = DownloadJobStatusPending + return nil + case DownloadJobStatusRunning: + *s = DownloadJobStatusRunning + return nil + case DownloadJobStatusComplete: + *s = DownloadJobStatusComplete + return nil + case DownloadJobStatusFailed: + *s = DownloadJobStatusFailed + return nil + case DownloadJobStatusCancelled: + *s = DownloadJobStatusCancelled + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +// Ref: #/components/schemas/DownloadRequest +type DownloadRequest struct { + Epoch OptDateTime `json:"epoch"` + Latest OptBool `json:"latest"` + Subset OptSubsetSpec `json:"subset"` +} + +// GetEpoch returns the value of Epoch. +func (s *DownloadRequest) GetEpoch() OptDateTime { + return s.Epoch +} + +// GetLatest returns the value of Latest. +func (s *DownloadRequest) GetLatest() OptBool { + return s.Latest +} + +// GetSubset returns the value of Subset. +func (s *DownloadRequest) GetSubset() OptSubsetSpec { + return s.Subset +} + +// SetEpoch sets the value of Epoch. +func (s *DownloadRequest) SetEpoch(val OptDateTime) { + s.Epoch = val +} + +// SetLatest sets the value of Latest. +func (s *DownloadRequest) SetLatest(val OptBool) { + s.Latest = val +} + +// SetSubset sets the value of Subset. +func (s *DownloadRequest) SetSubset(val OptSubsetSpec) { + s.Subset = val +} + // Ref: #/components/schemas/Error type Error struct { Error ErrorError `json:"error"` @@ -54,30 +763,540 @@ func (s *ErrorError) SetDescription(val string) { s.Description = val } -// ErrorStatusCode wraps Error with StatusCode. -type ErrorStatusCode struct { - StatusCode int - Response Error +// Ref: #/components/schemas/EventSummary +type EventSummary struct { + Type string `json:"type"` + Count int64 `json:"count"` + FirstTime OptFloat64 `json:"first_time"` + LastTime OptFloat64 `json:"last_time"` + FirstState OptGeoState `json:"first_state"` + LastState OptGeoState `json:"last_state"` + Message OptString `json:"message"` } -// GetStatusCode returns the value of StatusCode. -func (s *ErrorStatusCode) GetStatusCode() int { - return s.StatusCode +// GetType returns the value of Type. +func (s *EventSummary) GetType() string { + return s.Type } -// GetResponse returns the value of Response. -func (s *ErrorStatusCode) GetResponse() Error { - return s.Response +// GetCount returns the value of Count. +func (s *EventSummary) GetCount() int64 { + return s.Count } -// SetStatusCode sets the value of StatusCode. -func (s *ErrorStatusCode) SetStatusCode(val int) { - s.StatusCode = val +// GetFirstTime returns the value of FirstTime. +func (s *EventSummary) GetFirstTime() OptFloat64 { + return s.FirstTime } -// SetResponse sets the value of Response. -func (s *ErrorStatusCode) SetResponse(val Error) { - s.Response = val +// GetLastTime returns the value of LastTime. +func (s *EventSummary) GetLastTime() OptFloat64 { + return s.LastTime +} + +// GetFirstState returns the value of FirstState. +func (s *EventSummary) GetFirstState() OptGeoState { + return s.FirstState +} + +// GetLastState returns the value of LastState. +func (s *EventSummary) GetLastState() OptGeoState { + return s.LastState +} + +// GetMessage returns the value of Message. +func (s *EventSummary) GetMessage() OptString { + return s.Message +} + +// SetType sets the value of Type. +func (s *EventSummary) SetType(val string) { + s.Type = val +} + +// SetCount sets the value of Count. +func (s *EventSummary) SetCount(val int64) { + s.Count = val +} + +// SetFirstTime sets the value of FirstTime. +func (s *EventSummary) SetFirstTime(val OptFloat64) { + s.FirstTime = val +} + +// SetLastTime sets the value of LastTime. +func (s *EventSummary) SetLastTime(val OptFloat64) { + s.LastTime = val +} + +// SetFirstState sets the value of FirstState. +func (s *EventSummary) SetFirstState(val OptGeoState) { + s.FirstState = val +} + +// SetLastState sets the value of LastState. +func (s *EventSummary) SetLastState(val OptGeoState) { + s.LastState = val +} + +// SetMessage sets the value of Message. +func (s *EventSummary) SetMessage(val OptString) { + s.Message = val +} + +// Ref: #/components/schemas/GeoState +type GeoState struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Altitude float64 `json:"altitude"` +} + +// GetLat returns the value of Lat. +func (s *GeoState) GetLat() float64 { + return s.Lat +} + +// GetLng returns the value of Lng. +func (s *GeoState) GetLng() float64 { + return s.Lng +} + +// GetAltitude returns the value of Altitude. +func (s *GeoState) GetAltitude() float64 { + return s.Altitude +} + +// SetLat sets the value of Lat. +func (s *GeoState) SetLat(val float64) { + s.Lat = val +} + +// SetLng sets the value of Lng. +func (s *GeoState) SetLng(val float64) { + s.Lng = val +} + +// SetAltitude sets the value of Altitude. +func (s *GeoState) SetAltitude(val float64) { + s.Altitude = val +} + +// Ref: #/components/schemas/HourRange +type HourRange struct { + MinHour int `json:"min_hour"` + MaxHour int `json:"max_hour"` +} + +// GetMinHour returns the value of MinHour. +func (s *HourRange) GetMinHour() int { + return s.MinHour +} + +// GetMaxHour returns the value of MaxHour. +func (s *HourRange) GetMaxHour() int { + return s.MaxHour +} + +// SetMinHour sets the value of MinHour. +func (s *HourRange) SetMinHour(val int) { + s.MinHour = val +} + +// SetMaxHour sets the value of MaxHour. +func (s *HourRange) SetMaxHour(val int) { + s.MaxHour = val +} + +// Ref: #/components/schemas/Launch +type Launch struct { + Time time.Time `json:"time"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Altitude OptFloat64 `json:"altitude"` +} + +// GetTime returns the value of Time. +func (s *Launch) GetTime() time.Time { + return s.Time +} + +// GetLatitude returns the value of Latitude. +func (s *Launch) GetLatitude() float64 { + return s.Latitude +} + +// GetLongitude returns the value of Longitude. +func (s *Launch) GetLongitude() float64 { + return s.Longitude +} + +// GetAltitude returns the value of Altitude. +func (s *Launch) GetAltitude() OptFloat64 { + return s.Altitude +} + +// SetTime sets the value of Time. +func (s *Launch) SetTime(val time.Time) { + s.Time = val +} + +// SetLatitude sets the value of Latitude. +func (s *Launch) SetLatitude(val float64) { + s.Latitude = val +} + +// SetLongitude sets the value of Longitude. +func (s *Launch) SetLongitude(val float64) { + s.Longitude = val +} + +// SetAltitude sets the value of Altitude. +func (s *Launch) SetAltitude(val OptFloat64) { + s.Altitude = val +} + +// Ref: #/components/schemas/ModelSpec +type ModelSpec struct { + Type ModelSpecType `json:"type"` + Rate OptFloat64 `json:"rate"` + SeaLevelRate OptFloat64 `json:"sea_level_rate"` + IncludeWind OptBool `json:"include_wind"` + Segments []PiecewiseSegment `json:"segments"` +} + +// GetType returns the value of Type. +func (s *ModelSpec) GetType() ModelSpecType { + return s.Type +} + +// GetRate returns the value of Rate. +func (s *ModelSpec) GetRate() OptFloat64 { + return s.Rate +} + +// GetSeaLevelRate returns the value of SeaLevelRate. +func (s *ModelSpec) GetSeaLevelRate() OptFloat64 { + return s.SeaLevelRate +} + +// GetIncludeWind returns the value of IncludeWind. +func (s *ModelSpec) GetIncludeWind() OptBool { + return s.IncludeWind +} + +// GetSegments returns the value of Segments. +func (s *ModelSpec) GetSegments() []PiecewiseSegment { + return s.Segments +} + +// SetType sets the value of Type. +func (s *ModelSpec) SetType(val ModelSpecType) { + s.Type = val +} + +// SetRate sets the value of Rate. +func (s *ModelSpec) SetRate(val OptFloat64) { + s.Rate = val +} + +// SetSeaLevelRate sets the value of SeaLevelRate. +func (s *ModelSpec) SetSeaLevelRate(val OptFloat64) { + s.SeaLevelRate = val +} + +// SetIncludeWind sets the value of IncludeWind. +func (s *ModelSpec) SetIncludeWind(val OptBool) { + s.IncludeWind = val +} + +// SetSegments sets the value of Segments. +func (s *ModelSpec) SetSegments(val []PiecewiseSegment) { + s.Segments = val +} + +type ModelSpecType string + +const ( + ModelSpecTypeConstantRate ModelSpecType = "constant_rate" + ModelSpecTypeParachuteDescent ModelSpecType = "parachute_descent" + ModelSpecTypePiecewise ModelSpecType = "piecewise" + ModelSpecTypeWind ModelSpecType = "wind" +) + +// AllValues returns all ModelSpecType values. +func (ModelSpecType) AllValues() []ModelSpecType { + return []ModelSpecType{ + ModelSpecTypeConstantRate, + ModelSpecTypeParachuteDescent, + ModelSpecTypePiecewise, + ModelSpecTypeWind, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s ModelSpecType) MarshalText() ([]byte, error) { + switch s { + case ModelSpecTypeConstantRate: + return []byte(s), nil + case ModelSpecTypeParachuteDescent: + return []byte(s), nil + case ModelSpecTypePiecewise: + return []byte(s), nil + case ModelSpecTypeWind: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *ModelSpecType) UnmarshalText(data []byte) error { + switch ModelSpecType(data) { + case ModelSpecTypeConstantRate: + *s = ModelSpecTypeConstantRate + return nil + case ModelSpecTypeParachuteDescent: + *s = ModelSpecTypeParachuteDescent + return nil + case ModelSpecTypePiecewise: + *s = ModelSpecTypePiecewise + return nil + case ModelSpecTypeWind: + *s = ModelSpecTypeWind + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +// NewOptBool returns new OptBool with value set to v. +func NewOptBool(v bool) OptBool { + return OptBool{ + Value: v, + Set: true, + } +} + +// OptBool is optional bool. +type OptBool struct { + Value bool + Set bool +} + +// IsSet returns true if OptBool was set. +func (o OptBool) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptBool) Reset() { + var v bool + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptBool) SetTo(v bool) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptBool) Get() (v bool, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptBool) Or(d bool) bool { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptConstraintSpecAction returns new OptConstraintSpecAction with value set to v. +func NewOptConstraintSpecAction(v ConstraintSpecAction) OptConstraintSpecAction { + return OptConstraintSpecAction{ + Value: v, + Set: true, + } +} + +// OptConstraintSpecAction is optional ConstraintSpecAction. +type OptConstraintSpecAction struct { + Value ConstraintSpecAction + Set bool +} + +// IsSet returns true if OptConstraintSpecAction was set. +func (o OptConstraintSpecAction) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptConstraintSpecAction) Reset() { + var v ConstraintSpecAction + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptConstraintSpecAction) SetTo(v ConstraintSpecAction) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptConstraintSpecAction) Get() (v ConstraintSpecAction, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptConstraintSpecAction) Or(d ConstraintSpecAction) ConstraintSpecAction { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptConstraintSpecMode returns new OptConstraintSpecMode with value set to v. +func NewOptConstraintSpecMode(v ConstraintSpecMode) OptConstraintSpecMode { + return OptConstraintSpecMode{ + Value: v, + Set: true, + } +} + +// OptConstraintSpecMode is optional ConstraintSpecMode. +type OptConstraintSpecMode struct { + Value ConstraintSpecMode + Set bool +} + +// IsSet returns true if OptConstraintSpecMode was set. +func (o OptConstraintSpecMode) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptConstraintSpecMode) Reset() { + var v ConstraintSpecMode + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptConstraintSpecMode) SetTo(v ConstraintSpecMode) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptConstraintSpecMode) Get() (v ConstraintSpecMode, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptConstraintSpecMode) Or(d ConstraintSpecMode) ConstraintSpecMode { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptConstraintSpecOp returns new OptConstraintSpecOp with value set to v. +func NewOptConstraintSpecOp(v ConstraintSpecOp) OptConstraintSpecOp { + return OptConstraintSpecOp{ + Value: v, + Set: true, + } +} + +// OptConstraintSpecOp is optional ConstraintSpecOp. +type OptConstraintSpecOp struct { + Value ConstraintSpecOp + Set bool +} + +// IsSet returns true if OptConstraintSpecOp was set. +func (o OptConstraintSpecOp) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptConstraintSpecOp) Reset() { + var v ConstraintSpecOp + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptConstraintSpecOp) SetTo(v ConstraintSpecOp) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptConstraintSpecOp) Get() (v ConstraintSpecOp, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptConstraintSpecOp) Or(d ConstraintSpecOp) ConstraintSpecOp { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptCoverage returns new OptCoverage with value set to v. +func NewOptCoverage(v Coverage) OptCoverage { + return OptCoverage{ + Value: v, + Set: true, + } +} + +// OptCoverage is optional Coverage. +type OptCoverage struct { + Value Coverage + Set bool +} + +// IsSet returns true if OptCoverage was set. +func (o OptCoverage) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptCoverage) Reset() { + var v Coverage + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptCoverage) SetTo(v Coverage) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptCoverage) Get() (v Coverage, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptCoverage) Or(d Coverage) Coverage { + if v, ok := o.Get(); ok { + return v + } + return d } // NewOptDateTime returns new OptDateTime with value set to v. @@ -172,6 +1391,190 @@ func (o OptFloat64) Or(d float64) float64 { return d } +// NewOptGeoState returns new OptGeoState with value set to v. +func NewOptGeoState(v GeoState) OptGeoState { + return OptGeoState{ + Value: v, + Set: true, + } +} + +// OptGeoState is optional GeoState. +type OptGeoState struct { + Value GeoState + Set bool +} + +// IsSet returns true if OptGeoState was set. +func (o OptGeoState) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptGeoState) Reset() { + var v GeoState + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptGeoState) SetTo(v GeoState) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptGeoState) Get() (v GeoState, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptGeoState) Or(d GeoState) GeoState { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptHourRange returns new OptHourRange with value set to v. +func NewOptHourRange(v HourRange) OptHourRange { + return OptHourRange{ + Value: v, + Set: true, + } +} + +// OptHourRange is optional HourRange. +type OptHourRange struct { + Value HourRange + Set bool +} + +// IsSet returns true if OptHourRange was set. +func (o OptHourRange) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptHourRange) Reset() { + var v HourRange + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptHourRange) SetTo(v HourRange) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptHourRange) Get() (v HourRange, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptHourRange) Or(d HourRange) HourRange { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptInt returns new OptInt with value set to v. +func NewOptInt(v int) OptInt { + return OptInt{ + Value: v, + Set: true, + } +} + +// OptInt is optional int. +type OptInt struct { + Value int + Set bool +} + +// IsSet returns true if OptInt was set. +func (o OptInt) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptInt) Reset() { + var v int + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptInt) SetTo(v int) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptInt) Get() (v int, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptInt) Or(d int) int { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptOptions returns new OptOptions with value set to v. +func NewOptOptions(v Options) OptOptions { + return OptOptions{ + Value: v, + Set: true, + } +} + +// OptOptions is optional Options. +type OptOptions struct { + Value Options + Set bool +} + +// IsSet returns true if OptOptions was set. +func (o OptOptions) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptOptions) Reset() { + var v Options + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptOptions) SetTo(v Options) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptOptions) Get() (v Options, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptOptions) Or(d Options) Options { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptPerformPredictionProfile returns new OptPerformPredictionProfile with value set to v. func NewOptPerformPredictionProfile(v PerformPredictionProfile) OptPerformPredictionProfile { return OptPerformPredictionProfile{ @@ -218,6 +1621,52 @@ func (o OptPerformPredictionProfile) Or(d PerformPredictionProfile) PerformPredi return d } +// NewOptPiecewiseSegmentReference returns new OptPiecewiseSegmentReference with value set to v. +func NewOptPiecewiseSegmentReference(v PiecewiseSegmentReference) OptPiecewiseSegmentReference { + return OptPiecewiseSegmentReference{ + Value: v, + Set: true, + } +} + +// OptPiecewiseSegmentReference is optional PiecewiseSegmentReference. +type OptPiecewiseSegmentReference struct { + Value PiecewiseSegmentReference + Set bool +} + +// IsSet returns true if OptPiecewiseSegmentReference was set. +func (o OptPiecewiseSegmentReference) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptPiecewiseSegmentReference) Reset() { + var v PiecewiseSegmentReference + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptPiecewiseSegmentReference) SetTo(v PiecewiseSegmentReference) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptPiecewiseSegmentReference) Get() (v PiecewiseSegmentReference, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptPiecewiseSegmentReference) Or(d PiecewiseSegmentReference) PiecewiseSegmentReference { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptPredictionResponseRequest returns new OptPredictionResponseRequest with value set to v. func NewOptPredictionResponseRequest(v PredictionResponseRequest) OptPredictionResponseRequest { return OptPredictionResponseRequest{ @@ -310,6 +1759,144 @@ func (o OptPredictionResponseWarnings) Or(d PredictionResponseWarnings) Predicti return d } +// NewOptPredictionV2RequestDirection returns new OptPredictionV2RequestDirection with value set to v. +func NewOptPredictionV2RequestDirection(v PredictionV2RequestDirection) OptPredictionV2RequestDirection { + return OptPredictionV2RequestDirection{ + Value: v, + Set: true, + } +} + +// OptPredictionV2RequestDirection is optional PredictionV2RequestDirection. +type OptPredictionV2RequestDirection struct { + Value PredictionV2RequestDirection + Set bool +} + +// IsSet returns true if OptPredictionV2RequestDirection was set. +func (o OptPredictionV2RequestDirection) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptPredictionV2RequestDirection) Reset() { + var v PredictionV2RequestDirection + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptPredictionV2RequestDirection) SetTo(v PredictionV2RequestDirection) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptPredictionV2RequestDirection) Get() (v PredictionV2RequestDirection, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptPredictionV2RequestDirection) Or(d PredictionV2RequestDirection) PredictionV2RequestDirection { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptPredictionV2Response returns new OptPredictionV2Response with value set to v. +func NewOptPredictionV2Response(v PredictionV2Response) OptPredictionV2Response { + return OptPredictionV2Response{ + Value: v, + Set: true, + } +} + +// OptPredictionV2Response is optional PredictionV2Response. +type OptPredictionV2Response struct { + Value PredictionV2Response + Set bool +} + +// IsSet returns true if OptPredictionV2Response was set. +func (o OptPredictionV2Response) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptPredictionV2Response) Reset() { + var v PredictionV2Response + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptPredictionV2Response) SetTo(v PredictionV2Response) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptPredictionV2Response) Get() (v PredictionV2Response, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptPredictionV2Response) Or(d PredictionV2Response) PredictionV2Response { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptRegion returns new OptRegion with value set to v. +func NewOptRegion(v Region) OptRegion { + return OptRegion{ + Value: v, + Set: true, + } +} + +// OptRegion is optional Region. +type OptRegion struct { + Value Region + Set bool +} + +// IsSet returns true if OptRegion was set. +func (o OptRegion) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptRegion) Reset() { + var v Region + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptRegion) SetTo(v Region) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptRegion) Get() (v Region, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptRegion) Or(d Region) Region { + if v, ok := o.Get(); ok { + return v + } + return d +} + // NewOptString returns new OptString with value set to v. func NewOptString(v string) OptString { return OptString{ @@ -356,6 +1943,124 @@ func (o OptString) Or(d string) string { return d } +// NewOptSubsetSpec returns new OptSubsetSpec with value set to v. +func NewOptSubsetSpec(v SubsetSpec) OptSubsetSpec { + return OptSubsetSpec{ + Value: v, + Set: true, + } +} + +// OptSubsetSpec is optional SubsetSpec. +type OptSubsetSpec struct { + Value SubsetSpec + Set bool +} + +// IsSet returns true if OptSubsetSpec was set. +func (o OptSubsetSpec) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptSubsetSpec) Reset() { + var v SubsetSpec + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptSubsetSpec) SetTo(v SubsetSpec) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptSubsetSpec) Get() (v SubsetSpec, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptSubsetSpec) Or(d SubsetSpec) SubsetSpec { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptTerminationInfo returns new OptTerminationInfo with value set to v. +func NewOptTerminationInfo(v TerminationInfo) OptTerminationInfo { + return OptTerminationInfo{ + Value: v, + Set: true, + } +} + +// OptTerminationInfo is optional TerminationInfo. +type OptTerminationInfo struct { + Value TerminationInfo + Set bool +} + +// IsSet returns true if OptTerminationInfo was set. +func (o OptTerminationInfo) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptTerminationInfo) Reset() { + var v TerminationInfo + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptTerminationInfo) SetTo(v TerminationInfo) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptTerminationInfo) Get() (v TerminationInfo, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptTerminationInfo) Or(d TerminationInfo) TerminationInfo { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// Ref: #/components/schemas/Options +type Options struct { + StepSeconds OptFloat64 `json:"step_seconds"` + Tolerance OptFloat64 `json:"tolerance"` +} + +// GetStepSeconds returns the value of StepSeconds. +func (s *Options) GetStepSeconds() OptFloat64 { + return s.StepSeconds +} + +// GetTolerance returns the value of Tolerance. +func (s *Options) GetTolerance() OptFloat64 { + return s.Tolerance +} + +// SetStepSeconds sets the value of StepSeconds. +func (s *Options) SetStepSeconds(val OptFloat64) { + s.StepSeconds = val +} + +// SetTolerance sets the value of Tolerance. +func (s *Options) SetTolerance(val OptFloat64) { + s.Tolerance = val +} + type PerformPredictionProfile string const ( @@ -397,6 +2102,260 @@ func (s *PerformPredictionProfile) UnmarshalText(data []byte) error { } } +// Ref: #/components/schemas/PiecewiseSegment +type PiecewiseSegment struct { + Until float64 `json:"until"` + Rate float64 `json:"rate"` + Reference OptPiecewiseSegmentReference `json:"reference"` +} + +// GetUntil returns the value of Until. +func (s *PiecewiseSegment) GetUntil() float64 { + return s.Until +} + +// GetRate returns the value of Rate. +func (s *PiecewiseSegment) GetRate() float64 { + return s.Rate +} + +// GetReference returns the value of Reference. +func (s *PiecewiseSegment) GetReference() OptPiecewiseSegmentReference { + return s.Reference +} + +// SetUntil sets the value of Until. +func (s *PiecewiseSegment) SetUntil(val float64) { + s.Until = val +} + +// SetRate sets the value of Rate. +func (s *PiecewiseSegment) SetRate(val float64) { + s.Rate = val +} + +// SetReference sets the value of Reference. +func (s *PiecewiseSegment) SetReference(val OptPiecewiseSegmentReference) { + s.Reference = val +} + +type PiecewiseSegmentReference string + +const ( + PiecewiseSegmentReferenceAbsolute PiecewiseSegmentReference = "absolute" + PiecewiseSegmentReferenceProfileStart PiecewiseSegmentReference = "profile_start" + PiecewiseSegmentReferencePropagatorStart PiecewiseSegmentReference = "propagator_start" +) + +// AllValues returns all PiecewiseSegmentReference values. +func (PiecewiseSegmentReference) AllValues() []PiecewiseSegmentReference { + return []PiecewiseSegmentReference{ + PiecewiseSegmentReferenceAbsolute, + PiecewiseSegmentReferenceProfileStart, + PiecewiseSegmentReferencePropagatorStart, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s PiecewiseSegmentReference) MarshalText() ([]byte, error) { + switch s { + case PiecewiseSegmentReferenceAbsolute: + return []byte(s), nil + case PiecewiseSegmentReferenceProfileStart: + return []byte(s), nil + case PiecewiseSegmentReferencePropagatorStart: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *PiecewiseSegmentReference) UnmarshalText(data []byte) error { + switch PiecewiseSegmentReference(data) { + case PiecewiseSegmentReferenceAbsolute: + *s = PiecewiseSegmentReferenceAbsolute + return nil + case PiecewiseSegmentReferenceProfileStart: + *s = PiecewiseSegmentReferenceProfileStart + return nil + case PiecewiseSegmentReferencePropagatorStart: + *s = PiecewiseSegmentReferencePropagatorStart + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +// Ref: #/components/schemas/PolygonVertex +type PolygonVertex struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` +} + +// GetLat returns the value of Lat. +func (s *PolygonVertex) GetLat() float64 { + return s.Lat +} + +// GetLng returns the value of Lng. +func (s *PolygonVertex) GetLng() float64 { + return s.Lng +} + +// SetLat sets the value of Lat. +func (s *PolygonVertex) SetLat(val float64) { + s.Lat = val +} + +// SetLng sets the value of Lng. +func (s *PolygonVertex) SetLng(val float64) { + s.Lng = val +} + +// Ref: #/components/schemas/PredictionJob +type PredictionJob struct { + ID string `json:"id"` + Status PredictionJobStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` + StartedAt OptDateTime `json:"started_at"` + CompletedAt OptDateTime `json:"completed_at"` + Error OptString `json:"error"` + Result OptPredictionV2Response `json:"result"` +} + +// GetID returns the value of ID. +func (s *PredictionJob) GetID() string { + return s.ID +} + +// GetStatus returns the value of Status. +func (s *PredictionJob) GetStatus() PredictionJobStatus { + return s.Status +} + +// GetCreatedAt returns the value of CreatedAt. +func (s *PredictionJob) GetCreatedAt() time.Time { + return s.CreatedAt +} + +// GetStartedAt returns the value of StartedAt. +func (s *PredictionJob) GetStartedAt() OptDateTime { + return s.StartedAt +} + +// GetCompletedAt returns the value of CompletedAt. +func (s *PredictionJob) GetCompletedAt() OptDateTime { + return s.CompletedAt +} + +// GetError returns the value of Error. +func (s *PredictionJob) GetError() OptString { + return s.Error +} + +// GetResult returns the value of Result. +func (s *PredictionJob) GetResult() OptPredictionV2Response { + return s.Result +} + +// SetID sets the value of ID. +func (s *PredictionJob) SetID(val string) { + s.ID = val +} + +// SetStatus sets the value of Status. +func (s *PredictionJob) SetStatus(val PredictionJobStatus) { + s.Status = val +} + +// SetCreatedAt sets the value of CreatedAt. +func (s *PredictionJob) SetCreatedAt(val time.Time) { + s.CreatedAt = val +} + +// SetStartedAt sets the value of StartedAt. +func (s *PredictionJob) SetStartedAt(val OptDateTime) { + s.StartedAt = val +} + +// SetCompletedAt sets the value of CompletedAt. +func (s *PredictionJob) SetCompletedAt(val OptDateTime) { + s.CompletedAt = val +} + +// SetError sets the value of Error. +func (s *PredictionJob) SetError(val OptString) { + s.Error = val +} + +// SetResult sets the value of Result. +func (s *PredictionJob) SetResult(val OptPredictionV2Response) { + s.Result = val +} + +type PredictionJobStatus string + +const ( + PredictionJobStatusPending PredictionJobStatus = "pending" + PredictionJobStatusRunning PredictionJobStatus = "running" + PredictionJobStatusComplete PredictionJobStatus = "complete" + PredictionJobStatusFailed PredictionJobStatus = "failed" + PredictionJobStatusCancelled PredictionJobStatus = "cancelled" +) + +// AllValues returns all PredictionJobStatus values. +func (PredictionJobStatus) AllValues() []PredictionJobStatus { + return []PredictionJobStatus{ + PredictionJobStatusPending, + PredictionJobStatusRunning, + PredictionJobStatusComplete, + PredictionJobStatusFailed, + PredictionJobStatusCancelled, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s PredictionJobStatus) MarshalText() ([]byte, error) { + switch s { + case PredictionJobStatusPending: + return []byte(s), nil + case PredictionJobStatusRunning: + return []byte(s), nil + case PredictionJobStatusComplete: + return []byte(s), nil + case PredictionJobStatusFailed: + return []byte(s), nil + case PredictionJobStatusCancelled: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *PredictionJobStatus) UnmarshalText(data []byte) error { + switch PredictionJobStatus(data) { + case PredictionJobStatusPending: + *s = PredictionJobStatusPending + return nil + case PredictionJobStatusRunning: + *s = PredictionJobStatusRunning + return nil + case PredictionJobStatusComplete: + *s = PredictionJobStatusComplete + return nil + case PredictionJobStatusFailed: + *s = PredictionJobStatusFailed + return nil + case PredictionJobStatusCancelled: + *s = PredictionJobStatusCancelled + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + // Ref: #/components/schemas/PredictionResponse type PredictionResponse struct { Request OptPredictionResponseRequest `json:"request"` @@ -471,8 +2430,8 @@ func (s *PredictionResponseMetadata) SetCompleteDatetime(val time.Time) { } type PredictionResponsePredictionItem struct { - Stage PredictionResponsePredictionItemStage `json:"stage"` - Trajectory []PredictionResponsePredictionItemTrajectoryItem `json:"trajectory"` + Stage PredictionResponsePredictionItemStage `json:"stage"` + Trajectory []TawhiriPoint `json:"trajectory"` } // GetStage returns the value of Stage. @@ -481,7 +2440,7 @@ func (s *PredictionResponsePredictionItem) GetStage() PredictionResponsePredicti } // GetTrajectory returns the value of Trajectory. -func (s *PredictionResponsePredictionItem) GetTrajectory() []PredictionResponsePredictionItemTrajectoryItem { +func (s *PredictionResponsePredictionItem) GetTrajectory() []TawhiriPoint { return s.Trajectory } @@ -491,7 +2450,7 @@ func (s *PredictionResponsePredictionItem) SetStage(val PredictionResponsePredic } // SetTrajectory sets the value of Trajectory. -func (s *PredictionResponsePredictionItem) SetTrajectory(val []PredictionResponsePredictionItemTrajectoryItem) { +func (s *PredictionResponsePredictionItem) SetTrajectory(val []TawhiriPoint) { s.Trajectory = val } @@ -543,53 +2502,6 @@ func (s *PredictionResponsePredictionItemStage) UnmarshalText(data []byte) error } } -type PredictionResponsePredictionItemTrajectoryItem struct { - Datetime time.Time `json:"datetime"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Altitude float64 `json:"altitude"` -} - -// GetDatetime returns the value of Datetime. -func (s *PredictionResponsePredictionItemTrajectoryItem) GetDatetime() time.Time { - return s.Datetime -} - -// GetLatitude returns the value of Latitude. -func (s *PredictionResponsePredictionItemTrajectoryItem) GetLatitude() float64 { - return s.Latitude -} - -// GetLongitude returns the value of Longitude. -func (s *PredictionResponsePredictionItemTrajectoryItem) GetLongitude() float64 { - return s.Longitude -} - -// GetAltitude returns the value of Altitude. -func (s *PredictionResponsePredictionItemTrajectoryItem) GetAltitude() float64 { - return s.Altitude -} - -// SetDatetime sets the value of Datetime. -func (s *PredictionResponsePredictionItemTrajectoryItem) SetDatetime(val time.Time) { - s.Datetime = val -} - -// SetLatitude sets the value of Latitude. -func (s *PredictionResponsePredictionItemTrajectoryItem) SetLatitude(val float64) { - s.Latitude = val -} - -// SetLongitude sets the value of Longitude. -func (s *PredictionResponsePredictionItemTrajectoryItem) SetLongitude(val float64) { - s.Longitude = val -} - -// SetAltitude sets the value of Altitude. -func (s *PredictionResponsePredictionItemTrajectoryItem) SetAltitude(val float64) { - s.Altitude = val -} - type PredictionResponseRequest struct { Dataset OptString `json:"dataset"` LaunchLatitude OptFloat64 `json:"launch_latitude"` @@ -703,6 +2615,172 @@ func (s *PredictionResponseWarnings) init() PredictionResponseWarnings { return m } +// A profile-driven prediction. `profile` is an ordered chain of +// propagators; each integrates from where the previous ended. A stage's +// `constraints` decide when it ends and what happens next: stop the +// profile, hand off to `fallback_index`, or clip to the boundary. +// Ref: #/components/schemas/PredictionV2Request +type PredictionV2Request struct { + Launch Launch `json:"launch"` + // Forward integrates launch→landing; reverse integrates backward in time. + Direction OptPredictionV2RequestDirection `json:"direction"` + Profile []StageSpec `json:"profile"` + // Constraints evaluated on every stage in addition to its own. + Globals []ConstraintSpec `json:"globals"` + Options OptOptions `json:"options"` +} + +// GetLaunch returns the value of Launch. +func (s *PredictionV2Request) GetLaunch() Launch { + return s.Launch +} + +// GetDirection returns the value of Direction. +func (s *PredictionV2Request) GetDirection() OptPredictionV2RequestDirection { + return s.Direction +} + +// GetProfile returns the value of Profile. +func (s *PredictionV2Request) GetProfile() []StageSpec { + return s.Profile +} + +// GetGlobals returns the value of Globals. +func (s *PredictionV2Request) GetGlobals() []ConstraintSpec { + return s.Globals +} + +// GetOptions returns the value of Options. +func (s *PredictionV2Request) GetOptions() OptOptions { + return s.Options +} + +// SetLaunch sets the value of Launch. +func (s *PredictionV2Request) SetLaunch(val Launch) { + s.Launch = val +} + +// SetDirection sets the value of Direction. +func (s *PredictionV2Request) SetDirection(val OptPredictionV2RequestDirection) { + s.Direction = val +} + +// SetProfile sets the value of Profile. +func (s *PredictionV2Request) SetProfile(val []StageSpec) { + s.Profile = val +} + +// SetGlobals sets the value of Globals. +func (s *PredictionV2Request) SetGlobals(val []ConstraintSpec) { + s.Globals = val +} + +// SetOptions sets the value of Options. +func (s *PredictionV2Request) SetOptions(val OptOptions) { + s.Options = val +} + +// Forward integrates launch→landing; reverse integrates backward in time. +type PredictionV2RequestDirection string + +const ( + PredictionV2RequestDirectionForward PredictionV2RequestDirection = "forward" + PredictionV2RequestDirectionReverse PredictionV2RequestDirection = "reverse" +) + +// AllValues returns all PredictionV2RequestDirection values. +func (PredictionV2RequestDirection) AllValues() []PredictionV2RequestDirection { + return []PredictionV2RequestDirection{ + PredictionV2RequestDirectionForward, + PredictionV2RequestDirectionReverse, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s PredictionV2RequestDirection) MarshalText() ([]byte, error) { + switch s { + case PredictionV2RequestDirectionForward: + return []byte(s), nil + case PredictionV2RequestDirectionReverse: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *PredictionV2RequestDirection) UnmarshalText(data []byte) error { + switch PredictionV2RequestDirection(data) { + case PredictionV2RequestDirectionForward: + *s = PredictionV2RequestDirectionForward + return nil + case PredictionV2RequestDirectionReverse: + *s = PredictionV2RequestDirectionReverse + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +// Ref: #/components/schemas/PredictionV2Response +type PredictionV2Response struct { + Stages []StageResult `json:"stages"` + Events []EventSummary `json:"events"` + Dataset DatasetInfo `json:"dataset"` + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at"` +} + +// GetStages returns the value of Stages. +func (s *PredictionV2Response) GetStages() []StageResult { + return s.Stages +} + +// GetEvents returns the value of Events. +func (s *PredictionV2Response) GetEvents() []EventSummary { + return s.Events +} + +// GetDataset returns the value of Dataset. +func (s *PredictionV2Response) GetDataset() DatasetInfo { + return s.Dataset +} + +// GetStartedAt returns the value of StartedAt. +func (s *PredictionV2Response) GetStartedAt() time.Time { + return s.StartedAt +} + +// GetCompletedAt returns the value of CompletedAt. +func (s *PredictionV2Response) GetCompletedAt() time.Time { + return s.CompletedAt +} + +// SetStages sets the value of Stages. +func (s *PredictionV2Response) SetStages(val []StageResult) { + s.Stages = val +} + +// SetEvents sets the value of Events. +func (s *PredictionV2Response) SetEvents(val []EventSummary) { + s.Events = val +} + +// SetDataset sets the value of Dataset. +func (s *PredictionV2Response) SetDataset(val DatasetInfo) { + s.Dataset = val +} + +// SetStartedAt sets the value of StartedAt. +func (s *PredictionV2Response) SetStartedAt(val time.Time) { + s.StartedAt = val +} + +// SetCompletedAt sets the value of CompletedAt. +func (s *PredictionV2Response) SetCompletedAt(val time.Time) { + s.CompletedAt = val +} + // Ref: #/components/schemas/ReadinessResponse type ReadinessResponse struct { Status ReadinessResponseStatus `json:"status"` @@ -787,3 +2865,744 @@ func (s *ReadinessResponseStatus) UnmarshalText(data []byte) error { return errors.Errorf("invalid value: %q", data) } } + +// Ref: #/components/schemas/Region +type Region struct { + MinLat float64 `json:"min_lat"` + MaxLat float64 `json:"max_lat"` + MinLng float64 `json:"min_lng"` + MaxLng float64 `json:"max_lng"` +} + +// GetMinLat returns the value of MinLat. +func (s *Region) GetMinLat() float64 { + return s.MinLat +} + +// GetMaxLat returns the value of MaxLat. +func (s *Region) GetMaxLat() float64 { + return s.MaxLat +} + +// GetMinLng returns the value of MinLng. +func (s *Region) GetMinLng() float64 { + return s.MinLng +} + +// GetMaxLng returns the value of MaxLng. +func (s *Region) GetMaxLng() float64 { + return s.MaxLng +} + +// SetMinLat sets the value of MinLat. +func (s *Region) SetMinLat(val float64) { + s.MinLat = val +} + +// SetMaxLat sets the value of MaxLat. +func (s *Region) SetMaxLat(val float64) { + s.MaxLat = val +} + +// SetMinLng sets the value of MinLng. +func (s *Region) SetMinLng(val float64) { + s.MinLng = val +} + +// SetMaxLng sets the value of MaxLng. +func (s *Region) SetMaxLng(val float64) { + s.MaxLng = val +} + +// Ref: #/components/schemas/StageResult +type StageResult struct { + Name string `json:"name"` + Outcome StageResultOutcome `json:"outcome"` + Constraint OptString `json:"constraint"` + Termination OptTerminationInfo `json:"termination"` + Events []EventSummary `json:"events"` + Trajectory []TrajectoryPoint `json:"trajectory"` +} + +// GetName returns the value of Name. +func (s *StageResult) GetName() string { + return s.Name +} + +// GetOutcome returns the value of Outcome. +func (s *StageResult) GetOutcome() StageResultOutcome { + return s.Outcome +} + +// GetConstraint returns the value of Constraint. +func (s *StageResult) GetConstraint() OptString { + return s.Constraint +} + +// GetTermination returns the value of Termination. +func (s *StageResult) GetTermination() OptTerminationInfo { + return s.Termination +} + +// GetEvents returns the value of Events. +func (s *StageResult) GetEvents() []EventSummary { + return s.Events +} + +// GetTrajectory returns the value of Trajectory. +func (s *StageResult) GetTrajectory() []TrajectoryPoint { + return s.Trajectory +} + +// SetName sets the value of Name. +func (s *StageResult) SetName(val string) { + s.Name = val +} + +// SetOutcome sets the value of Outcome. +func (s *StageResult) SetOutcome(val StageResultOutcome) { + s.Outcome = val +} + +// SetConstraint sets the value of Constraint. +func (s *StageResult) SetConstraint(val OptString) { + s.Constraint = val +} + +// SetTermination sets the value of Termination. +func (s *StageResult) SetTermination(val OptTerminationInfo) { + s.Termination = val +} + +// SetEvents sets the value of Events. +func (s *StageResult) SetEvents(val []EventSummary) { + s.Events = val +} + +// SetTrajectory sets the value of Trajectory. +func (s *StageResult) SetTrajectory(val []TrajectoryPoint) { + s.Trajectory = val +} + +type StageResultOutcome string + +const ( + StageResultOutcomeStopped StageResultOutcome = "stopped" + StageResultOutcomeFallback StageResultOutcome = "fallback" + StageResultOutcomeContinued StageResultOutcome = "continued" +) + +// AllValues returns all StageResultOutcome values. +func (StageResultOutcome) AllValues() []StageResultOutcome { + return []StageResultOutcome{ + StageResultOutcomeStopped, + StageResultOutcomeFallback, + StageResultOutcomeContinued, + } +} + +// MarshalText implements encoding.TextMarshaler. +func (s StageResultOutcome) MarshalText() ([]byte, error) { + switch s { + case StageResultOutcomeStopped: + return []byte(s), nil + case StageResultOutcomeFallback: + return []byte(s), nil + case StageResultOutcomeContinued: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *StageResultOutcome) UnmarshalText(data []byte) error { + switch StageResultOutcome(data) { + case StageResultOutcomeStopped: + *s = StageResultOutcomeStopped + return nil + case StageResultOutcomeFallback: + *s = StageResultOutcomeFallback + return nil + case StageResultOutcomeContinued: + *s = StageResultOutcomeContinued + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +// Ref: #/components/schemas/StageSpec +type StageSpec struct { + Name string `json:"name"` + Model ModelSpec `json:"model"` + Constraints []ConstraintSpec `json:"constraints"` + FallbackIndex OptInt `json:"fallback_index"` +} + +// GetName returns the value of Name. +func (s *StageSpec) GetName() string { + return s.Name +} + +// GetModel returns the value of Model. +func (s *StageSpec) GetModel() ModelSpec { + return s.Model +} + +// GetConstraints returns the value of Constraints. +func (s *StageSpec) GetConstraints() []ConstraintSpec { + return s.Constraints +} + +// GetFallbackIndex returns the value of FallbackIndex. +func (s *StageSpec) GetFallbackIndex() OptInt { + return s.FallbackIndex +} + +// SetName sets the value of Name. +func (s *StageSpec) SetName(val string) { + s.Name = val +} + +// SetModel sets the value of Model. +func (s *StageSpec) SetModel(val ModelSpec) { + s.Model = val +} + +// SetConstraints sets the value of Constraints. +func (s *StageSpec) SetConstraints(val []ConstraintSpec) { + s.Constraints = val +} + +// SetFallbackIndex sets the value of FallbackIndex. +func (s *StageSpec) SetFallbackIndex(val OptInt) { + s.FallbackIndex = val +} + +// Ref: #/components/schemas/StatusResponse +type StatusResponse struct { + Source string `json:"source"` + Uptime string `json:"uptime"` + Goroutines int `json:"goroutines"` + MemoryMB int64 `json:"memory_mb"` + JobsByStatus StatusResponseJobsByStatus `json:"jobs_by_status"` + StoredDatasets int `json:"stored_datasets"` + LoadedDatasets int `json:"loaded_datasets"` +} + +// GetSource returns the value of Source. +func (s *StatusResponse) GetSource() string { + return s.Source +} + +// GetUptime returns the value of Uptime. +func (s *StatusResponse) GetUptime() string { + return s.Uptime +} + +// GetGoroutines returns the value of Goroutines. +func (s *StatusResponse) GetGoroutines() int { + return s.Goroutines +} + +// GetMemoryMB returns the value of MemoryMB. +func (s *StatusResponse) GetMemoryMB() int64 { + return s.MemoryMB +} + +// GetJobsByStatus returns the value of JobsByStatus. +func (s *StatusResponse) GetJobsByStatus() StatusResponseJobsByStatus { + return s.JobsByStatus +} + +// GetStoredDatasets returns the value of StoredDatasets. +func (s *StatusResponse) GetStoredDatasets() int { + return s.StoredDatasets +} + +// GetLoadedDatasets returns the value of LoadedDatasets. +func (s *StatusResponse) GetLoadedDatasets() int { + return s.LoadedDatasets +} + +// SetSource sets the value of Source. +func (s *StatusResponse) SetSource(val string) { + s.Source = val +} + +// SetUptime sets the value of Uptime. +func (s *StatusResponse) SetUptime(val string) { + s.Uptime = val +} + +// SetGoroutines sets the value of Goroutines. +func (s *StatusResponse) SetGoroutines(val int) { + s.Goroutines = val +} + +// SetMemoryMB sets the value of MemoryMB. +func (s *StatusResponse) SetMemoryMB(val int64) { + s.MemoryMB = val +} + +// SetJobsByStatus sets the value of JobsByStatus. +func (s *StatusResponse) SetJobsByStatus(val StatusResponseJobsByStatus) { + s.JobsByStatus = val +} + +// SetStoredDatasets sets the value of StoredDatasets. +func (s *StatusResponse) SetStoredDatasets(val int) { + s.StoredDatasets = val +} + +// SetLoadedDatasets sets the value of LoadedDatasets. +func (s *StatusResponse) SetLoadedDatasets(val int) { + s.LoadedDatasets = val +} + +type StatusResponseJobsByStatus map[string]int + +func (s *StatusResponseJobsByStatus) init() StatusResponseJobsByStatus { + m := *s + if m == nil { + m = map[string]int{} + *s = m + } + return m +} + +// Ref: #/components/schemas/SubsetSpec +type SubsetSpec struct { + Region OptRegion `json:"region"` + HourRange OptHourRange `json:"hour_range"` + Members []int `json:"members"` +} + +// GetRegion returns the value of Region. +func (s *SubsetSpec) GetRegion() OptRegion { + return s.Region +} + +// GetHourRange returns the value of HourRange. +func (s *SubsetSpec) GetHourRange() OptHourRange { + return s.HourRange +} + +// GetMembers returns the value of Members. +func (s *SubsetSpec) GetMembers() []int { + return s.Members +} + +// SetRegion sets the value of Region. +func (s *SubsetSpec) SetRegion(val OptRegion) { + s.Region = val +} + +// SetHourRange sets the value of HourRange. +func (s *SubsetSpec) SetHourRange(val OptHourRange) { + s.HourRange = val +} + +// SetMembers sets the value of Members. +func (s *SubsetSpec) SetMembers(val []int) { + s.Members = val +} + +// Ref: #/components/schemas/TawhiriPoint +type TawhiriPoint struct { + Datetime time.Time `json:"datetime"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Altitude float64 `json:"altitude"` +} + +// GetDatetime returns the value of Datetime. +func (s *TawhiriPoint) GetDatetime() time.Time { + return s.Datetime +} + +// GetLatitude returns the value of Latitude. +func (s *TawhiriPoint) GetLatitude() float64 { + return s.Latitude +} + +// GetLongitude returns the value of Longitude. +func (s *TawhiriPoint) GetLongitude() float64 { + return s.Longitude +} + +// GetAltitude returns the value of Altitude. +func (s *TawhiriPoint) GetAltitude() float64 { + return s.Altitude +} + +// SetDatetime sets the value of Datetime. +func (s *TawhiriPoint) SetDatetime(val time.Time) { + s.Datetime = val +} + +// SetLatitude sets the value of Latitude. +func (s *TawhiriPoint) SetLatitude(val float64) { + s.Latitude = val +} + +// SetLongitude sets the value of Longitude. +func (s *TawhiriPoint) SetLongitude(val float64) { + s.Longitude = val +} + +// SetAltitude sets the value of Altitude. +func (s *TawhiriPoint) SetAltitude(val float64) { + s.Altitude = val +} + +// Ref: #/components/schemas/TerminationInfo +type TerminationInfo struct { + ViolationTime time.Time `json:"violation_time"` + ViolationState GeoState `json:"violation_state"` + RefinedTime time.Time `json:"refined_time"` + RefinedState GeoState `json:"refined_state"` +} + +// GetViolationTime returns the value of ViolationTime. +func (s *TerminationInfo) GetViolationTime() time.Time { + return s.ViolationTime +} + +// GetViolationState returns the value of ViolationState. +func (s *TerminationInfo) GetViolationState() GeoState { + return s.ViolationState +} + +// GetRefinedTime returns the value of RefinedTime. +func (s *TerminationInfo) GetRefinedTime() time.Time { + return s.RefinedTime +} + +// GetRefinedState returns the value of RefinedState. +func (s *TerminationInfo) GetRefinedState() GeoState { + return s.RefinedState +} + +// SetViolationTime sets the value of ViolationTime. +func (s *TerminationInfo) SetViolationTime(val time.Time) { + s.ViolationTime = val +} + +// SetViolationState sets the value of ViolationState. +func (s *TerminationInfo) SetViolationState(val GeoState) { + s.ViolationState = val +} + +// SetRefinedTime sets the value of RefinedTime. +func (s *TerminationInfo) SetRefinedTime(val time.Time) { + s.RefinedTime = val +} + +// SetRefinedState sets the value of RefinedState. +func (s *TerminationInfo) SetRefinedState(val GeoState) { + s.RefinedState = val +} + +// Ref: #/components/schemas/TrajectoryPoint +type TrajectoryPoint struct { + Time time.Time `json:"time"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Altitude float64 `json:"altitude"` +} + +// GetTime returns the value of Time. +func (s *TrajectoryPoint) GetTime() time.Time { + return s.Time +} + +// GetLatitude returns the value of Latitude. +func (s *TrajectoryPoint) GetLatitude() float64 { + return s.Latitude +} + +// GetLongitude returns the value of Longitude. +func (s *TrajectoryPoint) GetLongitude() float64 { + return s.Longitude +} + +// GetAltitude returns the value of Altitude. +func (s *TrajectoryPoint) GetAltitude() float64 { + return s.Altitude +} + +// SetTime sets the value of Time. +func (s *TrajectoryPoint) SetTime(val time.Time) { + s.Time = val +} + +// SetLatitude sets the value of Latitude. +func (s *TrajectoryPoint) SetLatitude(val float64) { + s.Latitude = val +} + +// SetLongitude sets the value of Longitude. +func (s *TrajectoryPoint) SetLongitude(val float64) { + s.Longitude = val +} + +// SetAltitude sets the value of Altitude. +func (s *TrajectoryPoint) SetAltitude(val float64) { + s.Altitude = val +} + +// Ref: #/components/schemas/WindComponent +type WindComponent struct { + Header WindHeader `json:"header"` + Data []float64 `json:"data"` +} + +// GetHeader returns the value of Header. +func (s *WindComponent) GetHeader() WindHeader { + return s.Header +} + +// GetData returns the value of Data. +func (s *WindComponent) GetData() []float64 { + return s.Data +} + +// SetHeader sets the value of Header. +func (s *WindComponent) SetHeader(val WindHeader) { + s.Header = val +} + +// SetData sets the value of Data. +func (s *WindComponent) SetData(val []float64) { + s.Data = val +} + +// Ref: #/components/schemas/WindHeader +type WindHeader struct { + ParameterCategory int `json:"parameterCategory"` + ParameterNumber int `json:"parameterNumber"` + ParameterNumberName OptString `json:"parameterNumberName"` + ParameterUnit OptString `json:"parameterUnit"` + Nx int `json:"nx"` + Ny int `json:"ny"` + Lo1 float64 `json:"lo1"` + La1 float64 `json:"la1"` + Lo2 float64 `json:"lo2"` + La2 float64 `json:"la2"` + Dx float64 `json:"dx"` + Dy float64 `json:"dy"` + RefTime string `json:"refTime"` + ForecastTime int `json:"forecastTime"` +} + +// GetParameterCategory returns the value of ParameterCategory. +func (s *WindHeader) GetParameterCategory() int { + return s.ParameterCategory +} + +// GetParameterNumber returns the value of ParameterNumber. +func (s *WindHeader) GetParameterNumber() int { + return s.ParameterNumber +} + +// GetParameterNumberName returns the value of ParameterNumberName. +func (s *WindHeader) GetParameterNumberName() OptString { + return s.ParameterNumberName +} + +// GetParameterUnit returns the value of ParameterUnit. +func (s *WindHeader) GetParameterUnit() OptString { + return s.ParameterUnit +} + +// GetNx returns the value of Nx. +func (s *WindHeader) GetNx() int { + return s.Nx +} + +// GetNy returns the value of Ny. +func (s *WindHeader) GetNy() int { + return s.Ny +} + +// GetLo1 returns the value of Lo1. +func (s *WindHeader) GetLo1() float64 { + return s.Lo1 +} + +// GetLa1 returns the value of La1. +func (s *WindHeader) GetLa1() float64 { + return s.La1 +} + +// GetLo2 returns the value of Lo2. +func (s *WindHeader) GetLo2() float64 { + return s.Lo2 +} + +// GetLa2 returns the value of La2. +func (s *WindHeader) GetLa2() float64 { + return s.La2 +} + +// GetDx returns the value of Dx. +func (s *WindHeader) GetDx() float64 { + return s.Dx +} + +// GetDy returns the value of Dy. +func (s *WindHeader) GetDy() float64 { + return s.Dy +} + +// GetRefTime returns the value of RefTime. +func (s *WindHeader) GetRefTime() string { + return s.RefTime +} + +// GetForecastTime returns the value of ForecastTime. +func (s *WindHeader) GetForecastTime() int { + return s.ForecastTime +} + +// SetParameterCategory sets the value of ParameterCategory. +func (s *WindHeader) SetParameterCategory(val int) { + s.ParameterCategory = val +} + +// SetParameterNumber sets the value of ParameterNumber. +func (s *WindHeader) SetParameterNumber(val int) { + s.ParameterNumber = val +} + +// SetParameterNumberName sets the value of ParameterNumberName. +func (s *WindHeader) SetParameterNumberName(val OptString) { + s.ParameterNumberName = val +} + +// SetParameterUnit sets the value of ParameterUnit. +func (s *WindHeader) SetParameterUnit(val OptString) { + s.ParameterUnit = val +} + +// SetNx sets the value of Nx. +func (s *WindHeader) SetNx(val int) { + s.Nx = val +} + +// SetNy sets the value of Ny. +func (s *WindHeader) SetNy(val int) { + s.Ny = val +} + +// SetLo1 sets the value of Lo1. +func (s *WindHeader) SetLo1(val float64) { + s.Lo1 = val +} + +// SetLa1 sets the value of La1. +func (s *WindHeader) SetLa1(val float64) { + s.La1 = val +} + +// SetLo2 sets the value of Lo2. +func (s *WindHeader) SetLo2(val float64) { + s.Lo2 = val +} + +// SetLa2 sets the value of La2. +func (s *WindHeader) SetLa2(val float64) { + s.La2 = val +} + +// SetDx sets the value of Dx. +func (s *WindHeader) SetDx(val float64) { + s.Dx = val +} + +// SetDy sets the value of Dy. +func (s *WindHeader) SetDy(val float64) { + s.Dy = val +} + +// SetRefTime sets the value of RefTime. +func (s *WindHeader) SetRefTime(val string) { + s.RefTime = val +} + +// SetForecastTime sets the value of ForecastTime. +func (s *WindHeader) SetForecastTime(val int) { + s.ForecastTime = val +} + +// Ref: #/components/schemas/WindMeta +type WindMeta struct { + Source string `json:"source"` + Epoch time.Time `json:"epoch"` + DefaultStep float64 `json:"default_step"` + MinStep float64 `json:"min_step"` + SuggestedAltitudes []int `json:"suggested_altitudes"` + Bbox Region `json:"bbox"` +} + +// GetSource returns the value of Source. +func (s *WindMeta) GetSource() string { + return s.Source +} + +// GetEpoch returns the value of Epoch. +func (s *WindMeta) GetEpoch() time.Time { + return s.Epoch +} + +// GetDefaultStep returns the value of DefaultStep. +func (s *WindMeta) GetDefaultStep() float64 { + return s.DefaultStep +} + +// GetMinStep returns the value of MinStep. +func (s *WindMeta) GetMinStep() float64 { + return s.MinStep +} + +// GetSuggestedAltitudes returns the value of SuggestedAltitudes. +func (s *WindMeta) GetSuggestedAltitudes() []int { + return s.SuggestedAltitudes +} + +// GetBbox returns the value of Bbox. +func (s *WindMeta) GetBbox() Region { + return s.Bbox +} + +// SetSource sets the value of Source. +func (s *WindMeta) SetSource(val string) { + s.Source = val +} + +// SetEpoch sets the value of Epoch. +func (s *WindMeta) SetEpoch(val time.Time) { + s.Epoch = val +} + +// SetDefaultStep sets the value of DefaultStep. +func (s *WindMeta) SetDefaultStep(val float64) { + s.DefaultStep = val +} + +// SetMinStep sets the value of MinStep. +func (s *WindMeta) SetMinStep(val float64) { + s.MinStep = val +} + +// SetSuggestedAltitudes sets the value of SuggestedAltitudes. +func (s *WindMeta) SetSuggestedAltitudes(val []int) { + s.SuggestedAltitudes = val +} + +// SetBbox sets the value of Bbox. +func (s *WindMeta) SetBbox(val Region) { + s.Bbox = val +} diff --git a/pkg/rest/oas_server_gen.go b/pkg/rest/oas_server_gen.go index 7b6c592..20eb63a 100644 --- a/pkg/rest/oas_server_gen.go +++ b/pkg/rest/oas_server_gen.go @@ -8,22 +8,100 @@ import ( // Handler handles operations described by OpenAPI v3 specification. type Handler interface { + // CancelDatasetJob implements cancelDatasetJob operation. + // + // Cancel a running download job. + // + // DELETE /api/v1/admin/jobs/{id} + CancelDatasetJob(ctx context.Context, params CancelDatasetJobParams) error + // CancelPredictionJob implements cancelPredictionJob operation. + // + // Cancel a queued prediction job. + // + // DELETE /api/v1/predictions/{id} + CancelPredictionJob(ctx context.Context, params CancelPredictionJobParams) error + // CreatePredictionJob implements createPredictionJob operation. + // + // Enqueue an asynchronous prediction. + // + // POST /api/v1/predictions + CreatePredictionJob(ctx context.Context, req *PredictionV2Request) (*PredictionJob, error) + // DeleteDataset implements deleteDataset operation. + // + // Delete a stored dataset by filename. + // + // DELETE /api/v1/admin/datasets/{name} + DeleteDataset(ctx context.Context, params DeleteDatasetParams) error + // GetDatasetJob implements getDatasetJob operation. + // + // Get a dataset download job. + // + // GET /api/v1/admin/jobs/{id} + GetDatasetJob(ctx context.Context, params GetDatasetJobParams) (*DownloadJob, error) + // GetPredictionJob implements getPredictionJob operation. + // + // Poll an asynchronous prediction job. + // + // GET /api/v1/predictions/{id} + GetPredictionJob(ctx context.Context, params GetPredictionJobParams) (*PredictionJob, error) + // GetServiceStatus implements getServiceStatus operation. + // + // Service status summary. + // + // GET /api/v1/admin/status + GetServiceStatus(ctx context.Context) (*StatusResponse, error) + // GetWindField implements getWindField operation. + // + // Wind-field velocity grid (leaflet-velocity / wind-layer format). + // + // GET /api/v1/wind/field + GetWindField(ctx context.Context, params GetWindFieldParams) ([]WindComponent, error) + // GetWindMeta implements getWindMeta operation. + // + // Wind-field visualization metadata. + // + // GET /api/v1/wind/meta + GetWindMeta(ctx context.Context) (*WindMeta, error) + // ListDatasetJobs implements listDatasetJobs operation. + // + // List dataset download jobs. + // + // GET /api/v1/admin/jobs + ListDatasetJobs(ctx context.Context) ([]DownloadJob, error) + // ListDatasets implements listDatasets operation. + // + // List stored datasets. + // + // GET /api/v1/admin/datasets + ListDatasets(ctx context.Context) (*DatasetList, error) // PerformPrediction implements performPrediction operation. // - // Perform prediction. + // Tawhiri-compatible prediction. // // GET /api/v1/prediction PerformPrediction(ctx context.Context, params PerformPredictionParams) (*PredictionResponse, error) + // PerformPredictionV2 implements performPredictionV2 operation. + // + // Profile-driven prediction (synchronous). + // + // POST /api/v2/prediction + PerformPredictionV2(ctx context.Context, req *PredictionV2Request) (*PredictionV2Response, error) // ReadinessCheck implements readinessCheck operation. // // Readiness check. // // GET /ready ReadinessCheck(ctx context.Context) (*ReadinessResponse, error) - // NewError creates *ErrorStatusCode from error returned by handler. + // TriggerDatasetDownload implements triggerDatasetDownload operation. + // + // Trigger a dataset download. + // + // POST /api/v1/admin/datasets + TriggerDatasetDownload(ctx context.Context, req *DownloadRequest) (*DownloadAccepted, error) + // NewError creates *DefaultErrorStatusCode from error returned by handler. // // Used for common default response. - NewError(ctx context.Context, err error) *ErrorStatusCode + NewError(ctx context.Context, err error) *DefaultErrorStatusCode } // Server implements http server based on OpenAPI v3 specification and diff --git a/pkg/rest/oas_unimplemented_gen.go b/pkg/rest/oas_unimplemented_gen.go index 8c3d8be..22a9e02 100644 --- a/pkg/rest/oas_unimplemented_gen.go +++ b/pkg/rest/oas_unimplemented_gen.go @@ -13,15 +13,123 @@ type UnimplementedHandler struct{} var _ Handler = UnimplementedHandler{} +// CancelDatasetJob implements cancelDatasetJob operation. +// +// Cancel a running download job. +// +// DELETE /api/v1/admin/jobs/{id} +func (UnimplementedHandler) CancelDatasetJob(ctx context.Context, params CancelDatasetJobParams) error { + return ht.ErrNotImplemented +} + +// CancelPredictionJob implements cancelPredictionJob operation. +// +// Cancel a queued prediction job. +// +// DELETE /api/v1/predictions/{id} +func (UnimplementedHandler) CancelPredictionJob(ctx context.Context, params CancelPredictionJobParams) error { + return ht.ErrNotImplemented +} + +// CreatePredictionJob implements createPredictionJob operation. +// +// Enqueue an asynchronous prediction. +// +// POST /api/v1/predictions +func (UnimplementedHandler) CreatePredictionJob(ctx context.Context, req *PredictionV2Request) (r *PredictionJob, _ error) { + return r, ht.ErrNotImplemented +} + +// DeleteDataset implements deleteDataset operation. +// +// Delete a stored dataset by filename. +// +// DELETE /api/v1/admin/datasets/{name} +func (UnimplementedHandler) DeleteDataset(ctx context.Context, params DeleteDatasetParams) error { + return ht.ErrNotImplemented +} + +// GetDatasetJob implements getDatasetJob operation. +// +// Get a dataset download job. +// +// GET /api/v1/admin/jobs/{id} +func (UnimplementedHandler) GetDatasetJob(ctx context.Context, params GetDatasetJobParams) (r *DownloadJob, _ error) { + return r, ht.ErrNotImplemented +} + +// GetPredictionJob implements getPredictionJob operation. +// +// Poll an asynchronous prediction job. +// +// GET /api/v1/predictions/{id} +func (UnimplementedHandler) GetPredictionJob(ctx context.Context, params GetPredictionJobParams) (r *PredictionJob, _ error) { + return r, ht.ErrNotImplemented +} + +// GetServiceStatus implements getServiceStatus operation. +// +// Service status summary. +// +// GET /api/v1/admin/status +func (UnimplementedHandler) GetServiceStatus(ctx context.Context) (r *StatusResponse, _ error) { + return r, ht.ErrNotImplemented +} + +// GetWindField implements getWindField operation. +// +// Wind-field velocity grid (leaflet-velocity / wind-layer format). +// +// GET /api/v1/wind/field +func (UnimplementedHandler) GetWindField(ctx context.Context, params GetWindFieldParams) (r []WindComponent, _ error) { + return r, ht.ErrNotImplemented +} + +// GetWindMeta implements getWindMeta operation. +// +// Wind-field visualization metadata. +// +// GET /api/v1/wind/meta +func (UnimplementedHandler) GetWindMeta(ctx context.Context) (r *WindMeta, _ error) { + return r, ht.ErrNotImplemented +} + +// ListDatasetJobs implements listDatasetJobs operation. +// +// List dataset download jobs. +// +// GET /api/v1/admin/jobs +func (UnimplementedHandler) ListDatasetJobs(ctx context.Context) (r []DownloadJob, _ error) { + return r, ht.ErrNotImplemented +} + +// ListDatasets implements listDatasets operation. +// +// List stored datasets. +// +// GET /api/v1/admin/datasets +func (UnimplementedHandler) ListDatasets(ctx context.Context) (r *DatasetList, _ error) { + return r, ht.ErrNotImplemented +} + // PerformPrediction implements performPrediction operation. // -// Perform prediction. +// Tawhiri-compatible prediction. // // GET /api/v1/prediction func (UnimplementedHandler) PerformPrediction(ctx context.Context, params PerformPredictionParams) (r *PredictionResponse, _ error) { return r, ht.ErrNotImplemented } +// PerformPredictionV2 implements performPredictionV2 operation. +// +// Profile-driven prediction (synchronous). +// +// POST /api/v2/prediction +func (UnimplementedHandler) PerformPredictionV2(ctx context.Context, req *PredictionV2Request) (r *PredictionV2Response, _ error) { + return r, ht.ErrNotImplemented +} + // ReadinessCheck implements readinessCheck operation. // // Readiness check. @@ -31,10 +139,19 @@ func (UnimplementedHandler) ReadinessCheck(ctx context.Context) (r *ReadinessRes return r, ht.ErrNotImplemented } -// NewError creates *ErrorStatusCode from error returned by handler. +// TriggerDatasetDownload implements triggerDatasetDownload operation. +// +// Trigger a dataset download. +// +// POST /api/v1/admin/datasets +func (UnimplementedHandler) TriggerDatasetDownload(ctx context.Context, req *DownloadRequest) (r *DownloadAccepted, _ error) { + return r, ht.ErrNotImplemented +} + +// NewError creates *DefaultErrorStatusCode from error returned by handler. // // Used for common default response. -func (UnimplementedHandler) NewError(ctx context.Context, err error) (r *ErrorStatusCode) { - r = new(ErrorStatusCode) +func (UnimplementedHandler) NewError(ctx context.Context, err error) (r *DefaultErrorStatusCode) { + r = new(DefaultErrorStatusCode) return r } diff --git a/pkg/rest/oas_validators_gen.go b/pkg/rest/oas_validators_gen.go index 33b3d41..62c68b8 100644 --- a/pkg/rest/oas_validators_gen.go +++ b/pkg/rest/oas_validators_gen.go @@ -9,6 +9,691 @@ import ( "github.com/ogen-go/ogen/validate" ) +func (s *ConstraintSpec) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Type.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "type", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Op.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "op", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Limit.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "limit", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Action.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "action", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Mode.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "mode", + Error: err, + }) + } + if err := func() error { + var failures []validate.FieldError + for i, elem := range s.Vertices { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "vertices", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s ConstraintSpecAction) Validate() error { + switch s { + case "stop": + return nil + case "fallback": + return nil + case "clip": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s ConstraintSpecMode) Validate() error { + switch s { + case "inside": + return nil + case "outside": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s ConstraintSpecOp) Validate() error { + switch s { + case "<": + return nil + case "<=": + return nil + case ">": + return nil + case ">=": + return nil + case "==": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s ConstraintSpecType) Validate() error { + switch s { + case "altitude": + return nil + case "time": + return nil + case "terrain_contact": + return nil + case "polygon": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s *Coverage) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Region.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "region", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *DatasetEntry) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if value, ok := s.Subset.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "subset", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Coverage.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "coverage", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *DatasetList) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if s.Datasets == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range s.Datasets { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "datasets", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *DownloadJob) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Status.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "status", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s DownloadJobStatus) Validate() error { + switch s { + case "pending": + return nil + case "running": + return nil + case "complete": + return nil + case "failed": + return nil + case "cancelled": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s *DownloadRequest) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if value, ok := s.Subset.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "subset", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *EventSummary) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if value, ok := s.FirstTime.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "first_time", + Error: err, + }) + } + if err := func() error { + if value, ok := s.LastTime.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "last_time", + Error: err, + }) + } + if err := func() error { + if value, ok := s.FirstState.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "first_state", + Error: err, + }) + } + if err := func() error { + if value, ok := s.LastState.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "last_state", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *GeoState) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Lat)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "lat", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Lng)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "lng", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Altitude)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "altitude", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *Launch) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Latitude)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "latitude", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Longitude)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "longitude", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Altitude.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "altitude", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *ModelSpec) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Type.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "type", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Rate.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "rate", + Error: err, + }) + } + if err := func() error { + if value, ok := s.SeaLevelRate.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "sea_level_rate", + Error: err, + }) + } + if err := func() error { + var failures []validate.FieldError + for i, elem := range s.Segments { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "segments", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s ModelSpecType) Validate() error { + switch s { + case "constant_rate": + return nil + case "parachute_descent": + return nil + case "piecewise": + return nil + case "wind": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s *Options) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if value, ok := s.StepSeconds.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "step_seconds", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Tolerance.Get(); ok { + if err := func() error { + if err := (validate.Float{}).Validate(float64(value)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "tolerance", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + func (s PerformPredictionProfile) Validate() error { switch s { case "standard_profile": @@ -20,6 +705,163 @@ func (s PerformPredictionProfile) Validate() error { } } +func (s *PiecewiseSegment) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Until)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "until", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Rate)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "rate", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Reference.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "reference", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s PiecewiseSegmentReference) Validate() error { + switch s { + case "absolute": + return nil + case "profile_start": + return nil + case "propagator_start": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s *PolygonVertex) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Lat)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "lat", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Lng)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "lng", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *PredictionJob) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Status.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "status", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Result.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "result", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s PredictionJobStatus) Validate() error { + switch s { + case "pending": + return nil + case "running": + return nil + case "complete": + return nil + case "failed": + return nil + case "cancelled": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + func (s *PredictionResponse) Validate() error { if s == nil { return validate.ErrNilPointer @@ -142,51 +984,6 @@ func (s PredictionResponsePredictionItemStage) Validate() error { } } -func (s *PredictionResponsePredictionItemTrajectoryItem) Validate() error { - if s == nil { - return validate.ErrNilPointer - } - - var failures []validate.FieldError - if err := func() error { - if err := (validate.Float{}).Validate(float64(s.Latitude)); err != nil { - return errors.Wrap(err, "float") - } - return nil - }(); err != nil { - failures = append(failures, validate.FieldError{ - Name: "latitude", - Error: err, - }) - } - if err := func() error { - if err := (validate.Float{}).Validate(float64(s.Longitude)); err != nil { - return errors.Wrap(err, "float") - } - return nil - }(); err != nil { - failures = append(failures, validate.FieldError{ - Name: "longitude", - Error: err, - }) - } - if err := func() error { - if err := (validate.Float{}).Validate(float64(s.Altitude)); err != nil { - return errors.Wrap(err, "float") - } - return nil - }(); err != nil { - failures = append(failures, validate.FieldError{ - Name: "altitude", - Error: err, - }) - } - if len(failures) > 0 { - return &validate.Error{Fields: failures} - } - return nil -} - func (s *PredictionResponseRequest) Validate() error { if s == nil { return validate.ErrNilPointer @@ -307,6 +1104,194 @@ func (s *PredictionResponseRequest) Validate() error { return nil } +func (s *PredictionV2Request) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Launch.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "launch", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Direction.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "direction", + Error: err, + }) + } + if err := func() error { + if s.Profile == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range s.Profile { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "profile", + Error: err, + }) + } + if err := func() error { + var failures []validate.FieldError + for i, elem := range s.Globals { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "globals", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Options.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "options", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s PredictionV2RequestDirection) Validate() error { + switch s { + case "forward": + return nil + case "reverse": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s *PredictionV2Response) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if s.Stages == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range s.Stages { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "stages", + Error: err, + }) + } + if err := func() error { + var failures []validate.FieldError + for i, elem := range s.Events { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "events", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + func (s *ReadinessResponse) Validate() error { if s == nil { return validate.ErrNilPointer @@ -342,3 +1327,553 @@ func (s ReadinessResponseStatus) Validate() error { return errors.Errorf("invalid value: %v", s) } } + +func (s *Region) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.MinLat)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "min_lat", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.MaxLat)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "max_lat", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.MinLng)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "min_lng", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.MaxLng)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "max_lng", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *StageResult) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Outcome.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "outcome", + Error: err, + }) + } + if err := func() error { + if value, ok := s.Termination.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "termination", + Error: err, + }) + } + if err := func() error { + var failures []validate.FieldError + for i, elem := range s.Events { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "events", + Error: err, + }) + } + if err := func() error { + if s.Trajectory == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range s.Trajectory { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "trajectory", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s StageResultOutcome) Validate() error { + switch s { + case "stopped": + return nil + case "fallback": + return nil + case "continued": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s *StageSpec) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Model.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "model", + Error: err, + }) + } + if err := func() error { + var failures []validate.FieldError + for i, elem := range s.Constraints { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "constraints", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *SubsetSpec) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if value, ok := s.Region.Get(); ok { + if err := func() error { + if err := value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "region", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *TawhiriPoint) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Latitude)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "latitude", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Longitude)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "longitude", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Altitude)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "altitude", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *TerminationInfo) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.ViolationState.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "violation_state", + Error: err, + }) + } + if err := func() error { + if err := s.RefinedState.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "refined_state", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *TrajectoryPoint) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Latitude)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "latitude", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Longitude)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "longitude", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Altitude)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "altitude", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *WindComponent) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := s.Header.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "header", + Error: err, + }) + } + if err := func() error { + if s.Data == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range s.Data { + if err := func() error { + if err := (validate.Float{}).Validate(float64(elem)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "data", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *WindHeader) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Lo1)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "lo1", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.La1)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "la1", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Lo2)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "lo2", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.La2)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "la2", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Dx)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "dx", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.Dy)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "dy", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} + +func (s *WindMeta) Validate() error { + if s == nil { + return validate.ErrNilPointer + } + + var failures []validate.FieldError + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.DefaultStep)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "default_step", + Error: err, + }) + } + if err := func() error { + if err := (validate.Float{}).Validate(float64(s.MinStep)); err != nil { + return errors.Wrap(err, "float") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "min_step", + Error: err, + }) + } + if err := func() error { + if s.SuggestedAltitudes == nil { + return errors.New("nil is invalid value") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "suggested_altitudes", + Error: err, + }) + } + if err := func() error { + if err := s.Bbox.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "bbox", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +}