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.
+
+
+
+[](https://rubygems.org/gems/smo_ea_hydrology)
+[](https://opensource.org/licenses/MIT)
+[](https://www.ruby-lang.org)
+[](https://www.autodesk.com/products/infoworks-icm)
+[](https://environment.data.gov.uk/hydrology/doc/reference)
+
+
+
+
+
+
+
+
+
+[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.
+
+
+
+
+
+
+
+[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