diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/.gitignore b/07 Ruby Exchange Gems/SMO-EA-Hydrology/.gitignore new file mode 100644 index 0000000..9d8cca6 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/.gitignore @@ -0,0 +1,6 @@ +*.gem +batch_rainfall/ +*.csv +.DS_Store +.ruby-lsp/ +Gemfile.lock diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/CHANGELOG.md b/07 Ruby Exchange Gems/SMO-EA-Hydrology/CHANGELOG.md new file mode 100644 index 0000000..c73dabf --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.0] - 2026-05-04 + +### Added +- `batch_download` for downloading readings from multiple stations to individual CSV files +- `rainfall_15min_inventory` and `rainfall_15min_inventory_to_csv` for full station inventory with coverage dates +- `find_stations` for searching stations by name or reference +- `rainfall_15min_stations_with_coverage` for stations with earliest/latest reading dates + +## [0.1.0] - 2026-05-04 + +### Added +- `SmoEaHydrology::Client` for the Environment Agency Hydrology API +- `rainfall_15min_stations` — fetches all active 15-min rainfall stations +- `measures` — fetches 15-min rainfall measures for a station +- `readings` — fetches timestamped readings over a date range +- `readings_to_csv` — exports readings to CSV +- `Station`, `Measure`, `Reading`, and `InventoryEntry` data classes +- `ApiError` and `ParseError` error classes +- Test suite with comprehensive assertions diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/CODE_OF_CONDUCT.md b/07 Ruby Exchange Gems/SMO-EA-Hydrology/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..67fe8ce --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/LICENSE b/07 Ruby Exchange Gems/SMO-EA-Hydrology/LICENSE new file mode 100644 index 0000000..a8c42a6 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sebastian Madrid Ontiveros + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/README.md b/07 Ruby Exchange Gems/SMO-EA-Hydrology/README.md new file mode 100644 index 0000000..af34892 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/README.md @@ -0,0 +1,218 @@ +# smo_ea_hydrology + +**Pure Ruby client for the Environment Agency Hydrology API.** + +Fetch active 15-minute rainfall stations, coverage dates, and timestamped readings over any date range. + +No external dependencies. Uses only Ruby stdlib (`net/http`, `uri`, `json`, `date`, `time`). +Compatible with InfoWorks ICM 2027 embedded Ruby. + +
+ +[![Gem Version](https://badge.fury.io/rb/smo_ea_hydrology.svg)](https://rubygems.org/gems/smo_ea_hydrology) +[![License: MIT](https://img.shields.io/badge/License-MIT-22863a?style=flat&logo=open-source-initiative&logoColor=white)](https://opensource.org/licenses/MIT) +[![Ruby](https://img.shields.io/badge/Ruby-stdlib%20only-CC342D?style=flat&logo=ruby&logoColor=white)](https://www.ruby-lang.org) +[![ICM](https://img.shields.io/badge/InfoWorks%20ICM-2027%20compatible-005F73?style=flat)](https://www.autodesk.com/products/infoworks-icm) +[![EA API](https://img.shields.io/badge/EA%20Hydrology%20API-live-0076D6?style=flat)](https://environment.data.gov.uk/hydrology/doc/reference) + +
+ +
+ + +QR code — buymeacoffee.com/smadrid + + +[buymeacoffee.com/smadrid](https://buymeacoffee.com/smadrid) + +
+ +--- + +## Overview + +`smo_ea_hydrology` wraps the [Environment Agency Hydrology API](https://environment.data.gov.uk/hydrology/doc/reference), giving you clean Ruby objects for stations, measures, and readings with no setup beyond a `gem install`. It is designed for use in hydraulic modelling workflows — including inside InfoWorks ICM 2027's embedded Ruby environment — where pulling rainfall data programmatically saves significant manual effort. + +--- + +## Installation + +```bash +gem install smo_ea_hydrology +``` + +Or add to your `Gemfile`: + +```ruby +gem "smo_ea_hydrology" +``` + +--- + +## Quick start + +```ruby +require "smo_ea_hydrology" + +client = SmoEaHydrology::Client.new + +# List active 15-minute rainfall stations +stations = client.rainfall_15min_stations +puts stations.first.label # "Ulpha Duddo" +puts stations.first.station_reference # "589359" +puts stations.first.measure_label # "Rainfall 15min Total (mm)" + +# Find a station by name or reference +station = client.find_stations("Cosford").first + +# Fetch readings for a date range +measure = client.measures(station.station_reference).first +readings = client.readings(measure.id, from: "2024-06-01", to: "2024-06-07") +puts readings.size # 672 +puts readings.first.value # 0.0 +``` + +--- + +## Features + +### Stations + +```ruby +# Fast — no coverage dates +stations = client.rainfall_15min_stations + +# Slower — populates coverage_from / coverage_to (2 API calls per station) +stations = client.rainfall_15min_stations_with_coverage + +# Search by partial name or exact reference +client.find_stations("Dartmoor") # partial name match +client.find_stations("589359") # exact reference match +``` + +Each `Station` object exposes: + +| Field | Description | +|---|---| +| `label` | Station name | +| `station_reference` | Unique reference, e.g. `"589359"` | +| `lat` / `long` | WGS84 coordinates | +| `easting` / `northing` | OSGB36 coordinates | +| `date_opened` | e.g. `"1990-10-04"` | +| `measure_label` | `"Rainfall 15min Total (mm)"` | +| `measure_id` | Full URI of the 15-minute measure | +| `coverage_from` | `Time` of earliest reading (nil unless fetched) | +| `coverage_to` | `Time` of latest reading (nil unless fetched) | + +### Readings + +```ruby +measures = client.measures("589359") +readings = client.readings(measures.first.id, from: "2024-06-01", to: "2024-06-07") + +# Time-of-day filtering (UTC) +readings = client.readings(measures.first.id, + from: "2024-06-01 09:00", + to: "2024-06-01 17:00") + +# Date / Time objects also accepted +readings = client.readings(measures.first.id, + from: Date.new(2024, 6, 1), + to: Time.utc(2024, 6, 7, 23, 45)) +``` + +Each `Reading` has: `datetime` (Time), `value` (Float, mm), `quality` (String), `completeness` (String). + +### CSV export + +```ruby +# Single station +client.readings_to_csv( + station_reference: "589359", + from: "2024-06-01", + to: "2024-06-07", + path: "ulpha_june2024.csv" +) + +# Batch — multiple stations to individual files +client.batch_download( + from: "2024-06-01", + to: "2024-06-07", + output_dir: "rainfall_data", + refs: %w[589359 1712 603111] # nil = all stations +) + +# Full inventory with coverage dates +client.rainfall_15min_inventory_to_csv("inventory.csv") +``` + +### Inventory + +```ruby +entries = client.rainfall_15min_inventory +# Returns Array — station + measure + coverage_from + coverage_to +# Note: makes 2 API calls per station. Use rainfall_15min_inventory_to_csv for bulk export. +``` + +--- + +## Examples + +| Script | Description | +|---|---| +| `examples/01_stations.rb` | List stations with coverage dates | +| `examples/02_readings.rb` | Fetch readings and show daily totals | +| `examples/03_inventory.rb` | Full inventory export to CSV | +| `examples/04_single_download.rb` | Download one station to CSV | +| `examples/05_batch_download.rb` | Batch download multiple stations | + +Run any example directly: + +```bash +ruby examples/01_stations.rb + +# Edit STATION / FROM / TO at the top first, then: +ruby examples/04_single_download.rb +``` + +--- + +## API coverage + +| EA API endpoint | Gem method | +|---|---| +| `/id/stations?observedProperty=rainfall` | `rainfall_15min_stations` | +| `/id/measures?periodName=15min` | `measures(station_reference)` | +| `/id/measures/{id}/readings` | `readings(measure_id, from:, to:)` | +| Flood Monitoring `latestReading` | `rainfall_15min_stations_with_coverage` | + +Full API reference: [environment.data.gov.uk/hydrology/doc/reference](https://environment.data.gov.uk/hydrology/doc/reference) + +--- + +## License + +MIT. See [LICENSE](LICENSE). + +--- + +
+ +### ☕  Support this project + +This gem is free and open source, built for the hydraulic modelling and flood risk community across the UK.
+If it saves you time on a project, a coffee goes a long way. + +
+ + + Scan to buy Sebastian a coffee + + +[buymeacoffee.com/smadrid](https://buymeacoffee.com/smadrid) + +
+ +*Built by **Sebastian Madrid Ontiveros**  ·  Edinburgh  ·  Hydraulic Modeller* + +
diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/01_stations.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/01_stations.rb new file mode 100644 index 0000000..78d7dbb --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/01_stations.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true +# +# Example 01 — List active 15-min rainfall stations with coverage dates +# +# Usage: +# ruby examples/01_stations.rb + +require "smo_ea_hydrology" + +client = SmoEaHydrology::Client.new + +puts "Fetching active 15-min rainfall stations..." +stations = client.rainfall_15min_stations +puts "Found #{stations.size} stations" +puts + +# Fetch coverage dates only for the stations we will display +display = stations.first(2) +display.each_with_index do |s, i| + print "\r Fetching coverage #{i + 1}/#{display.size}..." + $stdout.flush + s.coverage_from = client.send(:fetch_earliest, s.measure_id) + s.coverage_to = client.send(:fetch_latest_fm, s.station_reference) +end +puts "\r" + " " * 40 + "\r" + +puts format("%-35s %-15s %10s %10s %-17s %-17s", + "Name", "Reference", "Lat", "Long", "Data From", "Data To") +puts "-" * 115 +display.each do |s| + puts format("%-35s %-15s %10.5f %10.5f %-17s %-17s", + s.label[0, 34], + s.station_reference.to_s, + s.lat, + s.long, + s.coverage_from_s, + s.coverage_to_s) +end +puts "... (showing first 20 of #{stations.size})" diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/02_readings.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/02_readings.rb new file mode 100644 index 0000000..cc1581a --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/02_readings.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +# +# Example 02 — Fetch 15-min rainfall readings for a station +# +# Usage: +# ruby examples/02_readings.rb + +require "smo_ea_hydrology" + +client = SmoEaHydrology::Client.new + +# Step 1: pick a station +stations = client.rainfall_15min_stations +station = stations.first +puts "Station : #{station.label}" +puts "Ref : #{station.station_reference}" +puts "Location: lat=#{station.lat} long=#{station.long}" +puts + +# Step 2: get its 15-min measure +measures = client.measures(station.station_reference) +measure = measures.first +puts "Measure : #{measure.label}" +puts "ID : #{measure.id}" +puts + +# Step 3: fetch readings for a date range +from = "2024-06-01" +to = "2024-06-07" +puts "Fetching readings #{from} to #{to}..." +readings = client.readings(measure.id, from: from, to: to) + +puts "#{readings.size} readings returned (expected #{7 * 96} for 7 days)" +puts +puts format("%-25s %8s %-12s %-12s", "DateTime", "mm", "Quality", "Completeness") +puts "-" * 62 +readings.first(10).each do |r| + puts format("%-25s %8.2f %-12s %-12s", r.datetime, r.value, r.quality, r.completeness) +end +puts "... (showing first 10 of #{readings.size})" +puts + +# Daily totals +puts "Daily totals:" +by_day = readings.group_by { |r| r.datetime.strftime("%Y-%m-%d") } +by_day.each do |date, rr| + total = rr.sum(&:value).round(2) + puts " #{date}: #{total} mm (#{rr.size} readings)" +end diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/03_inventory.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/03_inventory.rb new file mode 100644 index 0000000..0b4aca4 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/03_inventory.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true +# +# Example 03 — Fetch the full 15-min rainfall inventory and export to CSV +# +# Usage: +# ruby examples/03_inventory.rb +# ruby examples/03_inventory.rb inventory.csv +# +# This makes two API calls per station (earliest reading + latest reading via +# flood-monitoring API), so expect it to take a few minutes for all stations. + +require "csv" +require "smo_ea_hydrology" + +csv_path = ARGV[0] || "ea_rainfall_15min_inventory.csv" + +client = SmoEaHydrology::Client.new + +puts "Fetching inventory (this may take several minutes)..." +entries = client.rainfall_15min_inventory + +puts "#{entries.size} stations in inventory" +puts + +# Print a sample table +puts format("%-15s %-35s %10s %10s %-22s %-22s", + "Ref", "Station", "Lat", "Long", "Coverage From", "Coverage To") +puts "-" * 120 +entries.first(10).each do |e| + puts format("%-15s %-35s %10.5f %10.5f %-22s %-22s", + e.station_reference.to_s, + e.station_label.to_s[0, 34], + e.lat.to_f, + e.long.to_f, + e.coverage_from_s, + e.coverage_to_s) +end +puts "... (showing first 10 of #{entries.size})" +puts + +# Export to CSV +CSV.open(csv_path, "w") do |csv| + csv << %w[station_reference station_label lat long easting northing + date_opened measure_id period_name unit_name value_type + coverage_from coverage_to] + + entries.each do |e| + csv << [ + e.station_reference, + e.station_label, + e.lat, + e.long, + e.easting, + e.northing, + e.date_opened, + e.measure_id, + e.period_name, + e.unit_name, + e.value_type, + e.coverage_from_s, + e.coverage_to_s + ] + end +end + +puts "Exported #{entries.size} rows to #{csv_path}" diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/04_single_download.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/04_single_download.rb new file mode 100644 index 0000000..aab4280 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/04_single_download.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true +# +# Example 04 — Download readings for a single station to CSV +# +# HOW TO USE: +# 1. Edit the variables below (station name or reference, date range) +# 2. Run: ruby examples/04_single_download.rb + +require "smo_ea_hydrology" + +# ── Edit these ──────────────────────────────────────────────────────────────── +STATION = "Cosford" # station name (partial match) OR exact reference e.g. "589359" +FROM = "2024-06-01" # start date — "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" (times are UTC) +TO = "2024-06-07" # end date — "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" (times are UTC) +# ───────────────────────────────────────────────────────────────────────────── + +client = SmoEaHydrology::Client.new + +# Find the station by name or reference +puts "Searching for station: #{STATION.inspect}" +matches = client.find_stations(STATION) + +if matches.empty? + puts "No station found matching #{STATION.inspect}" + puts "Tip: run example 01 to browse all available stations." + exit 1 +end + +# Show all matches if more than one found +if matches.size > 1 + puts "Found #{matches.size} matches:" + matches.each { |s| puts " #{s.station_reference.to_s.ljust(15)} #{s.label}" } + puts "Using the first match." + puts +end + +station = matches.first +puts "Station : #{station.label}" +puts "Ref : #{station.station_reference}" +puts "Dataset : #{station.measure_label}" +puts "Lat/Long: #{station.lat}, #{station.long}" +puts + +# Download readings to CSV +out = "#{station.station_reference}_#{FROM.gsub(/[: ]/, "-")}_#{TO.gsub(/[: ]/, "-")}.csv" +puts "Fetching #{FROM} to #{TO}..." +count = client.readings_to_csv( + station_reference: station.station_reference, + from: FROM, + to: TO, + path: out +) + +puts "#{count} readings written to #{out}" + +# Quick preview +require "csv" +rows = CSV.read(out, headers: true) +puts +puts format("%-22s %8s %-12s %-12s", "DateTime (local)", "mm", "Quality", "Completeness") +puts "-" * 58 +rows.first(10).each do |r| + puts format("%-22s %8s %-12s %-12s", r["datetime"], r["value_mm"], r["quality"], r["completeness"]) +end +puts "... (showing first 10 of #{rows.size})" diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/05_batch_download.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/05_batch_download.rb new file mode 100644 index 0000000..8d791f9 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/examples/05_batch_download.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +# +# Example 05 — Batch download readings for multiple stations to individual CSVs +# +# HOW TO USE: +# 1. Edit the variables below (stations, date range, output folder) +# 2. Run: ruby examples/05_batch_download.rb + +require "smo_ea_hydrology" + +# ── Edit these ──────────────────────────────────────────────────────────────── + +# Date range — "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" (times are UTC) +FROM = "2024-06-01" +TO = "2024-06-07" + +# Folder where individual CSV files will be saved +OUTPUT_DIR = "batch_rainfall" + +# Stations to download — use station names (partial match) or exact references. +# Set to nil to download ALL active 15-min stations (takes a long time). +STATIONS = [ + "Cosford", + "Easby", + "589359", # Ulpha Duddo — reference works too + "Colliford", + "Coalburn Whitehill", +] + +# ───────────────────────────────────────────────────────────────────────────── + +client = SmoEaHydrology::Client.new + +# Resolve station names/refs to station references +if STATIONS.nil? + refs = nil + puts "Downloading ALL active 15-min rainfall stations." +else + puts "Resolving stations..." + refs = [] + STATIONS.each do |query| + matches = client.find_stations(query) + if matches.empty? + puts " WARNING: no match for #{query.inspect} — skipped" + else + s = matches.first + puts " #{query.inspect.ljust(25)} → #{s.station_reference.to_s.ljust(12)} #{s.label}" + refs << s.station_reference.to_s + end + end + puts +end + +puts "Date range : #{FROM} to #{TO}" +puts "Output dir : #{OUTPUT_DIR}" +puts + +results = client.batch_download( + from: FROM, + to: TO, + output_dir: OUTPUT_DIR, + refs: refs +) + +puts +puts format("%-15s %-30s %8s %s", "Ref", "File", "Readings", "Status") +puts "-" * 80 +results.each do |ref, info| + status = info[:error] ? "ERROR: #{info[:error]}" : "OK" + puts format("%-15s %-30s %8s %s", ref, File.basename(info[:path]), info[:count], status) +end + +ok = results.count { |_, v| !v[:error] } +total = results.values.sum { |v| v[:count] } +puts +puts "#{ok}/#{results.size} stations downloaded, #{total} total readings." +puts "Files saved to: #{File.expand_path(OUTPUT_DIR)}" diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology.rb new file mode 100644 index 0000000..7eb41bd --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative "smo_ea_hydrology/version" +require_relative "smo_ea_hydrology/errors" +require_relative "smo_ea_hydrology/station" +require_relative "smo_ea_hydrology/measure" +require_relative "smo_ea_hydrology/reading" +require_relative "smo_ea_hydrology/inventory_entry" +require_relative "smo_ea_hydrology/client" + +module SmoEaHydrology + # Convenience — build a default client. + def self.client + Client.new + end +end diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/client.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/client.rb new file mode 100644 index 0000000..6d9ce7b --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/client.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +require "net/http" +require "uri" +require "json" +require "date" +require "time" + +module SmoEaHydrology + class Client + BASE_URL = "https://environment.data.gov.uk/hydrology" + FM_BASE_URL = "https://environment.data.gov.uk/flood-monitoring" + + # Returns all active stations that have at least one 15-min rainfall measure. + # + # @return [Array] + def rainfall_15min_stations + items = paginate("/id/stations", observedProperty: "rainfall", "status.label": "Active") + items.filter_map do |item| + measures = Array(item["measures"]) + measure_15 = measures.find { |m| m.is_a?(Hash) && m["period"] == 900 } + next unless measure_15 + + Station.new( + id: item["@id"], + label: item["label"], + station_reference: item["stationReference"] || item["notation"], + wiski_id: item["wiskiID"], + easting: item["easting"], + northing: item["northing"], + lat: item["lat"], + long: item["long"], + date_opened: item["dateOpened"], + status: label_of(item["status"]), + measure_id: measure_15["@id"], + measure_label: derive_measure_label(measure_15) + ) + end + end + + # Returns all 15-min rainfall measures for a given station. + # + # @param station_reference [String] e.g. "589359" + # @return [Array] + def measures(station_reference) + items = get("/id/measures", observedProperty: "rainfall", + periodName: "15min", + "station.stationReference": station_reference.to_s) + items.map { |item| parse_measure(item) } + end + + # Returns 15-min rainfall readings for a measure over a date/datetime range. + # + # @param measure_id [String] full measure URI or the path-only ID + # @param from [String, Date, Time] start inclusive — "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" + # @param to [String, Date, Time] end inclusive — "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" + # @return [Array] + def readings(measure_id, from:, to:) + id_path = measure_path(measure_id) + from_str = parse_datetime(from) + to_str = parse_datetime(to) + date_only = !from_str.include?("T") + params = if date_only + { "mineq-date": from_str, "maxeq-date": to_str } + else + { "mineq-dateTime": from_str, "maxeq-dateTime": to_str } + end + items = paginate("#{id_path}/readings", **params) + items.map { |item| parse_reading(item) } + end + + # Like rainfall_15min_stations but also fetches coverage_from / coverage_to + # for every station. Makes 2 extra API calls per station — slow for all 900+. + # Progress is printed to $stdout. + # + # @return [Array] + def rainfall_15min_stations_with_coverage + stations = rainfall_15min_stations + stations.each_with_index do |station, i| + $stdout.print "\r #{i + 1}/#{stations.size} #{station.station_reference} " + $stdout.flush + station.coverage_from = fetch_earliest(station.measure_id) + station.coverage_to = fetch_latest_fm(station.station_reference) + end + $stdout.puts + stations + end + + # Search stations by partial name (case-insensitive) or exact station reference. + # Calls rainfall_15min_stations internally so no coverage dates are included. + # + # @param query [String] partial station name or exact reference + # @return [Array] + def find_stations(query) + q = query.to_s.strip.downcase + rainfall_15min_stations.select do |s| + s.station_reference.to_s.downcase == q || + s.label.to_s.downcase.include?(q) + end + end + + # Downloads 15-min readings for a single station to a CSV file. + # + # @param station_reference [String] + # @param from [String, Date] start date inclusive (YYYY-MM-DD) + # @param to [String, Date] end date inclusive (YYYY-MM-DD) + # @param path [String] output file path + # @return [Integer] number of readings written + def readings_to_csv(station_reference:, from:, to:, path:) + require "csv" + ms = measures(station_reference) + raise ApiError, "No 15-min rainfall measure found for #{station_reference}" if ms.empty? + + rows = readings(ms.first.id, from: from, to: to) + CSV.open(path, "w") do |csv| + csv << %w[datetime value_mm quality completeness] + rows.each do |r| + csv << [r.datetime.strftime("%Y-%m-%d %H:%M:%S"), r.value, r.quality, r.completeness] + end + end + rows.size + end + + # Writes the full 15-min rainfall inventory to a CSV file. + # Includes coverage dates — makes 2 extra API calls per station. + # + # @param path [String] output file path + def rainfall_15min_inventory_to_csv(path) + require "csv" + entries = rainfall_15min_inventory + CSV.open(path, "w") do |csv| + csv << %w[station_reference station_label lat long easting northing + date_opened measure_id period_name unit_name value_type + coverage_from coverage_to] + entries.each do |e| + csv << [ + e.station_reference, e.station_label, e.lat, e.long, + e.easting, e.northing, e.date_opened, e.measure_id, + e.period_name, e.unit_name, e.value_type, + e.coverage_from_s, e.coverage_to_s + ] + end + end + entries.size + end + + # Downloads readings for multiple stations to individual CSV files. + # + # @param from [String, Date] start date inclusive (YYYY-MM-DD) + # @param to [String, Date] end date inclusive (YYYY-MM-DD) + # @param output_dir [String] directory to write CSV files into + # @param refs [Array, nil] station references to download; + # nil downloads all active 15-min stations + # @return [Hash] { station_reference => { path:, count: } } + def batch_download(from:, to:, output_dir:, refs: nil) + require "csv" + require "fileutils" + FileUtils.mkdir_p(output_dir) + + stations = rainfall_15min_stations + stations = stations.select { |s| refs.map(&:to_s).include?(s.station_reference.to_s) } if refs + + results = {} + stations.each_with_index do |station, i| + ref = station.station_reference.to_s + path = File.join(output_dir, "#{ref}_#{parse_date(from)}_#{parse_date(to)}.csv") + $stdout.print "\r [#{i + 1}/#{stations.size}] #{ref} " + $stdout.flush + + count = readings_to_csv(station_reference: ref, from: from, to: to, path: path) + results[ref] = { path: path, count: count } + rescue => e + results[ref] = { path: path, count: 0, error: e.message } + end + + $stdout.puts + results + end + + # Returns a combined inventory of all active 15-min rainfall stations with + # their measures and coverage dates (earliest and latest reading timestamps). + # + # Note: this makes two additional API calls per station (one to the hydrology + # API for the earliest reading, one to the flood-monitoring API for the latest), + # so it is slow for large inventories. Progress is printed to $stdout. + # + # @return [Array] + def rainfall_15min_inventory + stations = rainfall_15min_stations + entries = [] + + stations.each_with_index do |station, i| + $stdout.print "\r #{i + 1}/#{stations.size} #{station.station_reference} " + $stdout.flush + + measures_list = measures(station.station_reference) + next if measures_list.empty? + + measure = measures_list.first + from_t = fetch_earliest(measure.id) + to_t = fetch_latest_fm(station.station_reference) + + entries << InventoryEntry.new( + station_reference: station.station_reference, + station_label: station.label, + lat: station.lat, + long: station.long, + easting: station.easting, + northing: station.northing, + date_opened: station.date_opened, + measure_id: measure.id, + period_name: measure.period_name, + unit_name: measure.unit_name, + value_type: measure.value_type, + coverage_from: from_t, + coverage_to: to_t + ) + end + + $stdout.puts + entries + end + + private + + # Follows pagination automatically until all items are collected. + def paginate(path, **params) + limit = 10_000 + offset = 0 + all = [] + + loop do + items = get(path, **params, _limit: limit, _offset: offset) + all.concat(items) + break if items.size < limit + + offset += limit + end + + all + end + + def get(path, **params) + uri = URI("#{BASE_URL}#{path}") + uri.query = URI.encode_www_form( + params.merge(_format: "json").reject { |_, v| v.nil? } + ) + response = request(uri) + + raise ApiError.new("HTTP #{response.code} from #{uri}", status: response.code.to_i) \ + unless response.is_a?(Net::HTTPSuccess) + + body = JSON.parse(response.body) + Array(body["items"]) + rescue JSON::ParserError => e + raise ParseError, "JSON parse error: #{e.message}" + end + + def request(uri, limit = 5) + raise ApiError, "Too many redirects" if limit.zero? + + response = Net::HTTP.get_response(uri) + if response.is_a?(Net::HTTPRedirection) + request(URI(response["location"]), limit - 1) + else + response + end + end + + # Fetches the datetime of the earliest available reading for a measure. + # Queries the hydrology API with mineq-date=1990-01-01 and _limit=1. + def fetch_earliest(measure_id) + path = measure_path(measure_id) + items = get("#{path}/readings", "mineq-date": "1990-01-01", _limit: 1) + return nil if items.empty? + + Time.iso8601(items.first["dateTime"]) + rescue + nil + end + + # Fetches the datetime of the latest available reading for a station + # via the EA Flood Monitoring API, which exposes latestReading.dateTime. + # Filters locally to rainfall measures with period == 900 (15 min). + def fetch_latest_fm(station_reference) + uri = URI("#{FM_BASE_URL}/id/stations/#{station_reference}/measures") + uri.query = URI.encode_www_form(_format: "json") + response = request(uri) + return nil unless response.is_a?(Net::HTTPSuccess) + + body = JSON.parse(response.body) + items = Array(body["items"]) + items.each do |item| + next unless item["parameter"] == "rainfall" && item["period"] == 900 + + lr = item["latestReading"] + next unless lr.is_a?(Hash) && lr["dateTime"] + + return Time.iso8601(lr["dateTime"]) + end + nil + rescue + nil + end + + # Builds a human-readable label from the fields available in the embedded + # measure object returned by the stations endpoint (no label field there). + def derive_measure_label(measure) + period = measure["period"].to_i + period_s = period == 900 ? "15min" : "#{period}s" + param = measure["parameter"].to_s.capitalize + stat_uri = measure.dig("valueStatistic", "@id").to_s + stat = stat_uri.split("/").last.to_s.capitalize + stat = "Total" if stat.empty? + "#{param} #{period_s} #{stat} (mm)" + end + + def parse_measure(item) + station = item["station"].is_a?(Hash) ? item["station"] : {} + Measure.new( + id: item["@id"], + label: item["label"], + station_reference: station["stationReference"], + station_label: station["label"], + period_name: item["periodName"], + unit_name: item["unitName"], + value_type: item["valueType"], + timeseries_id: item["timeseriesID"] + ) + end + + def parse_reading(item) + Reading.new( + datetime: Time.iso8601(item["dateTime"]), + value: item["value"].to_f, + quality: item["quality"], + completeness: item["completeness"] + ) + end + + # Parses a date or datetime value into a string for the EA API. + # Returns "YYYY-MM-DD" for date-only, "YYYY-MM-DDTHH:MM:SSZ" when time is present. + def parse_datetime(val) + case val + when Time + val.utc.strftime("%Y-%m-%dT%H:%M:%SZ") + when DateTime + val.to_time.utc.strftime("%Y-%m-%dT%H:%M:%SZ") + when Date + val.strftime("%Y-%m-%d") + when String + s = val.strip + if s =~ /\A\d{4}-\d{2}-\d{2}\z/ + s + elsif s =~ /\A\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/ + require "time" + Time.parse(s).utc.strftime("%Y-%m-%dT%H:%M:%SZ") + else + raise ArgumentError, "Invalid date/time: #{val.inspect}" + end + else + raise ArgumentError, "Cannot convert #{val.class} to date/time" + end + end + + # Returns a YYYY-MM-DD string from a date/datetime — used for filenames. + def parse_date(d) + parse_datetime(d)[0, 10] + end + + def label_of(field) + return nil if field.nil? + return field if field.is_a?(String) + Array(field).first.then { |f| f.is_a?(Hash) ? f["label"] : f } + end + + # Extracts the path relative to BASE_URL from a full measure URI. + # "http://environment.data.gov.uk/hydrology/id/measures/abc" -> "/id/measures/abc" + def measure_path(id) + id.to_s.start_with?("http") ? URI(id).path.sub(%r{\A/hydrology}, "") : id.to_s + end + end +end diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/errors.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/errors.rb new file mode 100644 index 0000000..0fa33b1 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/errors.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module SmoEaHydrology + class Error < StandardError; end + + class ApiError < Error + attr_reader :status + + def initialize(msg, status: nil) + super(msg) + @status = status + end + end + + class ParseError < Error; end +end diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/inventory_entry.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/inventory_entry.rb new file mode 100644 index 0000000..9d39833 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/inventory_entry.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module SmoEaHydrology + InventoryEntry = Struct.new( + :station_reference, # e.g. "589359" + :station_label, # station name + :lat, # WGS84 latitude + :long, # WGS84 longitude + :easting, # OSGB36 easting + :northing, # OSGB36 northing + :date_opened, # e.g. "1990-10-04" + :measure_id, # full measure URI + :period_name, # "15min" + :unit_name, # "mm" + :value_type, # "total" + :coverage_from, # Time of earliest available reading (nil if unknown) + :coverage_to, # Time of latest available reading (nil if unknown) + keyword_init: true + ) do + def coverage_from_s + coverage_from&.strftime("%Y-%m-%d %H:%M") || "unknown" + end + + def coverage_to_s + coverage_to&.strftime("%Y-%m-%d %H:%M") || "unknown" + end + end +end diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/measure.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/measure.rb new file mode 100644 index 0000000..9d797b9 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/measure.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module SmoEaHydrology + Measure = Struct.new( + :id, # full URI @id (use this to fetch readings) + :label, # human-readable description + :station_reference, # parent station reference + :station_label, # parent station name + :period_name, # "15min" + :unit_name, # "mm" + :value_type, # "total" + :timeseries_id, # UUID + :coverage_from, # Time of earliest available reading (nil if unknown) + :coverage_to, # Time of latest available reading (nil if unknown) + keyword_init: true + ) +end diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/reading.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/reading.rb new file mode 100644 index 0000000..745a7ee --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/reading.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module SmoEaHydrology + Reading = Struct.new( + :datetime, # Time object + :value, # Float — rainfall in mm + :quality, # "Good" / "Estimated" / "Suspect" / "Unchecked" / "Missing" + :completeness, # "Complete" / "Incomplete" + keyword_init: true + ) +end diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/station.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/station.rb new file mode 100644 index 0000000..4a4f959 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/station.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module SmoEaHydrology + Station = Struct.new( + :id, # full URI @id + :label, # station name + :station_reference, # e.g. "589359" + :wiski_id, # WISKI system ID + :easting, # OSGB36 easting + :northing, # OSGB36 northing + :lat, # WGS84 latitude + :long, # WGS84 longitude + :date_opened, # e.g. "1990-10-04" + :status, # "Active" / "Closed" + :measure_id, # full URI of the 15-min rainfall measure + :measure_label, # human-readable measure description + :coverage_from, # Time of earliest available reading (nil if not fetched) + :coverage_to, # Time of latest available reading (nil if not fetched) + keyword_init: true + ) do + def coverage_from_s + coverage_from&.strftime("%Y-%m-%d %H:%M") || "unknown" + end + + def coverage_to_s + coverage_to&.strftime("%Y-%m-%d %H:%M") || "unknown" + end + end +end diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/version.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/version.rb new file mode 100644 index 0000000..eb76bca --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/lib/smo_ea_hydrology/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module SmoEaHydrology + VERSION = "0.2.1" +end diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/smo_ea_hydrology.gemspec b/07 Ruby Exchange Gems/SMO-EA-Hydrology/smo_ea_hydrology.gemspec new file mode 100644 index 0000000..c7867c5 --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/smo_ea_hydrology.gemspec @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "lib/smo_ea_hydrology/version" + +Gem::Specification.new do |spec| + spec.name = "smo_ea_hydrology" + spec.version = SmoEaHydrology::VERSION + spec.authors = ["Sebastian Madrid Ontiveros"] + spec.email = ["sebasmadrid20@hotmail.com"] + + spec.summary = "Environment Agency Hydrology API client for 15-min rainfall data." + spec.description = <<~DESC + Developed by Sebastian Madrid Ontiveros. Pure Ruby client for the Environment Agency + Hydrology API (environment.data.gov.uk/hydrology). Fetches active rainfall stations, + 15-minute rainfall measures, and timestamped readings over any date range. + No external dependencies. Uses only Ruby stdlib (net/http, uri, json, date). + Built to support hydraulic modelling and flood risk workflows in the UK. + Compatible with InfoWorks ICM 2027 embedded Ruby. + If this gem saves you time, consider buying Sebastian a coffee at + https://buymeacoffee.com/smadrid + DESC + + spec.homepage = "https://github.com/Sebasmadridmx/smo_ea_hydrology" + spec.license = "MIT" + spec.required_ruby_version = ">= 2.7.0" + + spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE", "CHANGELOG.md"] + spec.require_paths = ["lib"] +end diff --git a/07 Ruby Exchange Gems/SMO-EA-Hydrology/spec/smo_ea_hydrology_spec.rb b/07 Ruby Exchange Gems/SMO-EA-Hydrology/spec/smo_ea_hydrology_spec.rb new file mode 100644 index 0000000..520f76a --- /dev/null +++ b/07 Ruby Exchange Gems/SMO-EA-Hydrology/spec/smo_ea_hydrology_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require_relative "../lib/smo_ea_hydrology" + +$errors_count = 0 + +def assert(desc, &block) + if block.call + puts "PASS #{desc}" + else + puts "FAIL #{desc}" + $errors_count += 1 + end +rescue => e + puts "ERROR #{desc}: #{e.message}" + $errors_count += 1 +end + +def assert_raises(desc, &block) + block.call + puts "FAIL #{desc} (no exception raised)" + $errors_count += 1 +rescue ArgumentError, SmoEaHydrology::Error + puts "PASS #{desc}" +end + +puts "=== smo_ea_hydrology tests ===" +puts + +client = SmoEaHydrology::Client.new + +# --------------------------------------------------------------------------- +# Stations +# --------------------------------------------------------------------------- +puts "-- Stations --" + +stations = client.rainfall_15min_stations +assert("returns an Array") { stations.is_a?(Array) } +assert("returns at least 100 stations") { stations.size >= 100 } +assert("each item is a Station struct") { stations.all? { |s| s.is_a?(SmoEaHydrology::Station) } } +assert("stations have labels") { stations.all? { |s| s.label.is_a?(String) && !s.label.empty? } } +assert("stations have station_reference") { stations.all? { |s| !s.station_reference.nil? } } +assert("stations have lat/long") { stations.all? { |s| s.lat.is_a?(Numeric) && s.long.is_a?(Numeric) } } +assert("all stations are Active") { stations.all? { |s| s.status == "Active" } } + +puts + +# --------------------------------------------------------------------------- +# Measures +# --------------------------------------------------------------------------- +puts "-- Measures --" + +# Use first station from inventory +sample_station = stations.first +measures = client.measures(sample_station.station_reference) + +assert("returns an Array") { measures.is_a?(Array) } +assert("returns at least one measure") { measures.size >= 1 } +assert("each item is a Measure struct") { measures.all? { |m| m.is_a?(SmoEaHydrology::Measure) } } +assert("measures have id") { measures.all? { |m| m.id.is_a?(String) && m.id.start_with?("http") } } +assert("measures have period_name 15min") { measures.all? { |m| m.period_name == "15min" } } +assert("measures have unit mm") { measures.all? { |m| m.unit_name == "mm" } } +assert("measures have station_reference") { measures.all? { |m| !m.station_reference.nil? } } + +puts + +# --------------------------------------------------------------------------- +# Readings +# --------------------------------------------------------------------------- +puts "-- Readings --" + +measure = measures.first +readings = client.readings(measure.id, from: "2024-01-01", to: "2024-01-01") + +assert("returns an Array") { readings.is_a?(Array) } +assert("returns 96 readings for one day (15-min intervals)") { readings.size == 96 } +assert("each item is a Reading struct") { readings.all? { |r| r.is_a?(SmoEaHydrology::Reading) } } +assert("readings have datetime as Time") { readings.all? { |r| r.datetime.is_a?(Time) } } +assert("readings have numeric value") { readings.all? { |r| r.value.is_a?(Numeric) } } +assert("readings have quality string") { readings.all? { |r| r.quality.is_a?(String) } } +assert("readings are in chronological order") { + readings.each_cons(2).all? { |a, b| a.datetime <= b.datetime } +} +assert("readings span exactly one day") { + readings.first.datetime.strftime("%Y-%m-%d") == "2024-01-01" && + readings.last.datetime.strftime("%Y-%m-%d") == "2024-01-01" +} + +puts +assert("Date objects work as from/to") { + r = client.readings(measure.id, from: Date.new(2024, 1, 1), to: Date.new(2024, 1, 1)) + r.size == 96 +} +assert("multi-day range returns proportionally more readings") { + r = client.readings(measure.id, from: "2024-01-01", to: "2024-01-03") + r.size > 96 +} + +puts + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- +puts "-- Error handling --" + +assert_raises("bad date string raises ArgumentError") { + client.readings(measure.id, from: "not-a-date", to: "2024-01-01") +} + +# --------------------------------------------------------------------------- +# Inventory (coverage dates) +# --------------------------------------------------------------------------- +puts "-- Inventory (single station) --" + +# Test coverage fetch on the sample station only (avoid full crawl in tests) +from_t = client.send(:fetch_earliest, measure.id) +to_t = client.send(:fetch_latest_fm, sample_station.station_reference) + +assert("fetch_earliest returns a Time or nil") { from_t.nil? || from_t.is_a?(Time) } +assert("fetch_latest_fm returns a Time or nil") { to_t.nil? || to_t.is_a?(Time) } +assert("coverage_from is before coverage_to") do + from_t.nil? || to_t.nil? || from_t < to_t +end +assert("coverage_from includes HH:MM precision") do + from_t.nil? || from_t.respond_to?(:strftime) +end + +puts + +count = $errors_count +if count.zero? + puts "All tests passed." +else + puts "#{count} test(s) failed." + exit 1 +end