openapi: 3.0.3
info:
  title: Avonmouth Tide Predictor API
  version: "1.0.0"
  description: |
    Predicts sea level at Avonmouth, Bristol Channel, UK, for any date from
    1970 to 2037. Two layers, both returned together:

    - **Harmonic (astronomical) prediction** — always available, exact for
      the whole date range, needs no external data.
    - **Weather-corrected (ML-assisted) prediction** — a gradient-boosted
      correction for meteorological surge, available only when weather
      data actually exists for the requested date: real historic
      observations for 2002-01-01 to 2012-04-10, or a live forecast for
      roughly today through the next 10 days. Outside both windows only
      the harmonic prediction is returned.

    When the requested date falls inside the historic tide-gauge record
    (2002-01-01 to 2012-04-10), the response also includes the real
    measured gauge reading and RMSE/bias for both models against it.

    All timestamps in request and response bodies are Unix time (UTC
    seconds since epoch); the `date` parameter itself is a UK local
    calendar day (`Europe/London`, DST-aware) rather than a UTC day.

    See `/about` and `/help` on the running site for a plain-language
    explanation of the data sources, methodology, and accuracy.

    **Not an official source.** For navigation, safety planning, or
    flood-risk decisions, use the UK Hydrographic Office's Admiralty
    EasyTide or the Environment Agency's flood warning service instead.

servers:
  - url: https://tide.pysc.org.uk
    description: Production (planned; see CLAUDE.md's "Hosting environment")
  - url: http://127.0.0.1:8099
    description: Local development (python3 app.py)

tags:
  - name: prediction
    description: Tide height prediction

paths:
  /predict:
    get:
      tags: [prediction]
      summary: Predict tide height for a date
      description: |
        Returns predicted sea level at 1970 to 2037 (defaults to `step`-second
        resolution, 600s), the day's high/low tides (from the harmonic-only
        model), and — when available — the weather-corrected prediction and
        real observed data for the same range.
      operationId: predict
      parameters:
        - name: date
          in: query
          required: true
          description: >-
            UK local calendar day (`Europe/London`, DST-aware — not UTC),
            `YYYY-MM-DD`. Must be within 1970-01-01 to 2037-12-31, the range
            the underlying harmonic constituent tables support.
          schema:
            type: string
            format: date
            pattern: '^\d{4}-\d{2}-\d{2}$'
          example: "2011-12-13"
        - name: step
          in: query
          required: false
          description: Sample resolution in seconds for the `predicted`/`ml_predicted` series.
          schema:
            type: integer
            minimum: 60
            maximum: 3600
            default: 600
          example: 900
        - name: days
          in: query
          required: false
          description: >-
            Number of UK local calendar days to return, starting at `date`.
            A DST-transition day inside the window still contributes its own
            real length (23h/25h), not a fixed 86400s.
          schema:
            type: integer
            minimum: 1
            maximum: 14
            default: 1
          example: 7
      responses:
        "200":
          description: Prediction succeeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PredictResponse"
              examples:
                historicWithObservedAndMl:
                  summary: Historic date — observed data and ML overlay both present
                  value:
                    date: "2011-12-13"
                    display_timezone: "Europe/London"
                    station: "Avonmouth"
                    latitude: 51.51089
                    longitude: -2.71497
                    datum: "Admiralty Chart Datum (ACD)"
                    z0: 6.9954
                    step_seconds: 900
                    days: 1
                    range: { start: 1323734400, end: 1323820800 }
                    predicted:
                      - { t: 1323734400, height: 5.699 }
                      - { t: 1323735300, height: 5.309 }
                    extremes:
                      - { t: 1323745140, height: 1.83, type: "low" }
                      - { t: 1323765452, height: 12.963, type: "high" }
                    observed:
                      - { t: 1323734400, height: 6.289 }
                      - { t: 1323735300, height: 5.906 }
                    observed_available: true
                    observed_data_enabled: true
                    ml_predicted:
                      - { t: 1323734400, height: 6.289 }
                      - { t: 1323735300, height: 5.906 }
                    weather_source: "historic"
                    validation: { n: 97, rmse_m: 0.6815, bias_m: -0.4648 }
                    ml_validation: { n: 97, rmse_m: 0.3496, bias_m: 0.0295 }
                futureForecast:
                  summary: Future date within the forecast horizon — no observed data yet
                  value:
                    date: "2026-08-22"
                    display_timezone: "Europe/London"
                    station: "Avonmouth"
                    latitude: 51.51089
                    longitude: -2.71497
                    datum: "Admiralty Chart Datum (ACD)"
                    z0: 6.9954
                    step_seconds: 600
                    days: 1
                    range: { start: 1787349600, end: 1787436000 }
                    predicted:
                      - { t: 1787349600, height: 9.32 }
                    extremes:
                      - { t: 1787359302, height: 9.319, type: "high" }
                    observed: null
                    observed_available: false
                    observed_data_enabled: false
                    ml_predicted:
                      - { t: 1787349600, height: 9.41 }
                    weather_source: "forecast"
                gapPeriodHarmonicOnly:
                  summary: No weather data available for this date (neither historic nor forecast)
                  value:
                    date: "2015-06-01"
                    display_timezone: "Europe/London"
                    station: "Avonmouth"
                    latitude: 51.51089
                    longitude: -2.71497
                    datum: "Admiralty Chart Datum (ACD)"
                    z0: 6.9954
                    step_seconds: 600
                    days: 1
                    range: { start: 1433113200, end: 1433199600 }
                    predicted:
                      - { t: 1433113200, height: 3.294 }
                    extremes:
                      - { t: 1433118198, height: 1.986, type: "low" }
                    observed: null
                    observed_available: false
                    observed_data_enabled: false
                    ml_predicted: null
                    weather_source: null
        "400":
          description: >-
            Missing/malformed `date`, invalid calendar date, or `step`/`days`
            out of range.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                missingDate:
                  value: { error: 'Missing or invalid "date" parameter; expected YYYY-MM-DD' }
                badCalendarDate:
                  value: { error: "Invalid calendar date: 2020-02-30" }
                stepOutOfRange:
                  value: { error: "step must be between 60 and 3600 seconds" }
                daysOutOfRange:
                  value: { error: "days must be between 1 and 14" }
        "422":
          description: >-
            Well-formed request the server can't fulfil — the year is outside
            the range the harmonic tables support (1970-2037).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error: >-
                  Year 2038 is outside the range this station's tables support
                  (1970-2037). Regenerate avonmouth_constants.json with a
                  wider congen -b/-e range to extend it.
        "429":
          description: >-
            Rate limit exceeded: 30 requests/minute or 300/hour per client IP.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error: "Rate limit exceeded: 30 per 1 minute"
      x-cors:
        description: >-
          Access-Control-Allow-Origin is echoed only for pysc.org.uk and
          www.pysc.org.uk (http and https). Requests from other browser
          origins still reach the server (this is not an auth mechanism) but
          the browser will block the page's JS from reading the response.
          Server-to-server calls are unaffected either way.

components:
  schemas:
    Point:
      type: object
      description: One sampled instant on the tide curve.
      required: [t, height]
      properties:
        t:
          type: integer
          format: int64
          description: Unix time, seconds since epoch (UTC).
          example: 1323734400
        height:
          type: number
          format: float
          description: Height in metres above the station datum (Admiralty Chart Datum).
          example: 5.699

    Extreme:
      type: object
      description: A predicted high or low tide (from the harmonic-only model).
      required: [t, height, type]
      properties:
        t:
          type: integer
          format: int64
          example: 1323765452
        height:
          type: number
          format: float
          example: 12.963
        type:
          type: string
          enum: [high, low]

    Validation:
      type: object
      description: Agreement between a prediction and the real observed record, for one day.
      required: [n, rmse_m, bias_m]
      properties:
        n:
          type: integer
          description: Number of observed points compared.
          example: 97
        rmse_m:
          type: number
          description: Root-mean-square error, metres.
          example: 0.3496
        bias_m:
          type: number
          description: Mean signed error, metres (positive = prediction ran high).
          example: 0.0295

    PredictResponse:
      type: object
      required:
        - date
        - display_timezone
        - station
        - latitude
        - longitude
        - datum
        - z0
        - step_seconds
        - days
        - range
        - predicted
        - extremes
        - observed
        - observed_available
        - observed_data_enabled
        - ml_predicted
        - weather_source
      properties:
        date:
          type: string
          format: date
          description: Echo of the requested date.
          example: "2011-12-13"
        display_timezone:
          type: string
          description: Timezone the `date` parameter and chart labelling are interpreted in. Always "Europe/London".
          example: "Europe/London"
        station:
          type: string
          example: "Avonmouth"
        latitude:
          type: number
          example: 51.51089
        longitude:
          type: number
          example: -2.71497
        datum:
          type: string
          example: "Admiralty Chart Datum (ACD)"
        z0:
          type: number
          description: Mean sea level offset above the datum, metres.
          example: 6.9954
        step_seconds:
          type: integer
          description: Resolution actually used (the request's `step`, or the 600s default).
          example: 900
        days:
          type: integer
          description: Number of UK local calendar days covered (the request's `days`, or the 1-day default).
          example: 1
        range:
          type: object
          description: >-
            The requested UK calendar day(s), expressed as a Unix time
            interval spanning `days` local days from `date`.
          required: [start, end]
          properties:
            start: { type: integer, format: int64, example: 1323734400 }
            end: { type: integer, format: int64, example: 1323820800 }
        predicted:
          type: array
          description: Harmonic-only (astronomical) prediction. Always present, any date 1970-2037.
          items: { $ref: "#/components/schemas/Point" }
        extremes:
          type: array
          description: >-
            High/low tides in and around the requested day, from the
            harmonic-only model. May include entries slightly outside
            `range` (padded so a high/low right at local midnight isn't clipped).
          items: { $ref: "#/components/schemas/Extreme" }
        observed:
          type: array
          nullable: true
          description: >-
            Real measured gauge readings, only when the date falls within
            the historic BODC record (approximately 2002-01-01 to
            2012-04-10). `null` otherwise.
          items: { $ref: "#/components/schemas/Point" }
        observed_available:
          type: boolean
          description: Whether `observed` (and `validation`) are populated.
        observed_data_enabled:
          type: boolean
          description: >-
            Whether this deployment can ever show the observed overlay at
            all (the server's `SHOW_OBSERVED_DATA` setting), independent of
            whether the requested date has data. The BODC/NTSLF data licence
            requires their prior consent before redistributing the raw
            gauge readings publicly, so this is `false` by default; a
            client can use it to decide whether to display an "Observed"
            legend entry at all, rather than one that flickers on and off
            per date.
        ml_predicted:
          type: array
          nullable: true
          description: >-
            Harmonic prediction plus the weather-correction model's output.
            `null` when no weather data (historic or forecast) is available
            for this date — see `weather_source`.
          items: { $ref: "#/components/schemas/Point" }
        weather_source:
          type: string
          nullable: true
          enum: [historic, forecast, null]
          description: >-
            Which weather feed backed `ml_predicted`: `"historic"` (real
            measured Bristol Airport weather, 2002-2012), `"forecast"` (a
            live Open-Meteo forecast, roughly today to +10 days — carries
            the forecast's own uncertainty on top of the model's), or
            `null` (neither available for this date; `ml_predicted` is
            also `null` in that case).
        validation:
          allOf: [{ $ref: "#/components/schemas/Validation" }]
          nullable: true
          description: Harmonic-only accuracy against `observed`. Present only when `observed_available` is true.
        ml_validation:
          allOf: [{ $ref: "#/components/schemas/Validation" }]
          nullable: true
          description: Weather-corrected accuracy against `observed`. Present only when both `observed_available` is true and `ml_predicted` is non-null.
        observed_error:
          type: string
          nullable: true
          description: >-
            Set (to a generic message, with the real cause logged
            server-side, not exposed here) if the observed-data lookup
            itself failed. Rare; the request still returns 200.
        ml_error:
          type: string
          nullable: true
          description: >-
            Set (to a generic message; same reasoning as `observed_error`)
            if computing the weather correction failed. Rare; the request
            still returns 200 with `ml_predicted: null`.

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          example: 'Missing or invalid "date" parameter; expected YYYY-MM-DD'
