diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/.gitignore b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/.gitignore
new file mode 100644
index 0000000..68c162e
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/.gitignore
@@ -0,0 +1,5 @@
+*.gem
+*.csv
+.ruby-lsp/
+.DS_Store
+Gemfile.lock
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/CHANGELOG.md b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/CHANGELOG.md
new file mode 100644
index 0000000..3470d31
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/CHANGELOG.md
@@ -0,0 +1,24 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [0.1.1] - 2025-05-03
+
+### Changed
+
+- Updated gem description and author attribution.
+
+## [0.1.0] - 2024-01-01
+
+### Added
+
+- Initial release.
+- `Client` with six public methods: `rainfall_stations`, `rainfall_15min_timeseries`,
+ `rainfall_15min_inventory`, `timeseries_values`, `rainfall_15min_inventory_to_csv`,
+ `timeseries_values_to_csv`.
+- `Station`, `Timeseries`, and `Value` structs.
+- `ResponseParser` for KiWIS column-array JSON format.
+- `ApiError` and `ParseError` error classes.
+- Chunked fetching via `chunk_days` parameter.
+- Four runnable example scripts.
+- Minitest test suite with recorded fixtures (no network access).
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/CODE_OF_CONDUCT.md b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..67fe8ce
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/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-SEPA-KiWIS/Gemfile b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/Gemfile
new file mode 100644
index 0000000..b4e2a20
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/Gemfile
@@ -0,0 +1,3 @@
+source "https://rubygems.org"
+
+gemspec
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/LICENSE b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/LICENSE
new file mode 100644
index 0000000..a8c42a6
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/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-SEPA-KiWIS/README.md b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/README.md
new file mode 100644
index 0000000..9a67999
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/README.md
@@ -0,0 +1,175 @@
+# smo_sepa_kiwis
+
+A pure-Ruby client for the [SEPA Time Series KiWIS API](https://timeseriesdoc.sepa.org.uk/api-documentation/).
+Fetch rainfall stations, 15-minute timeseries metadata, and timeseries values.
+Export everything to CSV.
+
+Built specifically for use inside InfoWorks ICM 2027's embedded Ruby interpreter,
+where no `gem install` step is possible.
+
+## Support
+
+This gem is free and open source. If it saves you time on a hydraulic
+modelling project, you can support development here:
+
+
+
+
+
+
+ buymeacoffee.com/smadrid
+
+
+## Why this gem
+
+Hydraulic modellers working in InfoWorks ICM often need rainfall observations
+from SEPA gauges to drive 1D-2D simulations or validate model performance.
+Pulling that data manually through the SEPA web portal does not scale beyond
+a handful of stations.
+
+This gem makes the SEPA KiWIS API directly callable from any Ruby environment,
+including ICM's embedded interpreter. It uses stdlib only (`net/http`, `uri`,
+`json`, `csv`, `date`, `time`), has no native extensions, and no Bundler
+runtime dependencies.
+
+## Installation
+
+```sh
+gem install smo_sepa_kiwis
+```
+
+```ruby
+require "smo_sepa_kiwis"
+```
+
+## Quick start
+
+```ruby
+require "smo_sepa_kiwis"
+
+client = SmoSepaKiwis::Client.new
+# Optional kwargs: base_url:, timeout: (default 60), user_agent:
+```
+
+### 1. List all rainfall stations
+
+```ruby
+stations = client.rainfall_stations
+# => Array
+# Station fields: no, name, lat, lon, catchment, river
+```
+
+### 2. Find 15-minute timeseries for a specific station
+
+```ruby
+series = client.rainfall_15min_timeseries(station_no: "14964")
+# => Array
+# Timeseries fields: ts_id, ts_path, ts_name, station_no, coverage_from, coverage_to
+```
+
+### 3. Network-wide inventory in one call
+
+```ruby
+inventory = client.rainfall_15min_inventory
+# => Array with keys:
+# :station_no, :station_name, :lat, :lon, :catchment, :river,
+# :ts_id, :ts_path, :coverage_from, :coverage_to
+```
+
+### 4. Download timeseries values
+
+```ruby
+values = client.timeseries_values(
+ ts_id: 55570010,
+ from: "2021-10-22",
+ to: "2021-10-25"
+)
+# => Array
+# Value fields: timestamp (Time UTC), value (Float or nil), quality_code (Integer or nil)
+```
+
+`from` and `to` accept `String`, `Date`, `Time`, or `DateTime`.
+
+For long date ranges, use `chunk_days` to split the request into smaller
+windows and avoid server timeouts:
+
+```ruby
+values = client.timeseries_values(
+ ts_id: 55570010,
+ from: "2020-01-01",
+ to: "2021-01-01",
+ chunk_days: 30
+)
+```
+
+### 5. Export the full network inventory to CSV
+
+```ruby
+client.rainfall_15min_inventory_to_csv("inventory.csv")
+```
+
+### 6. Export timeseries values to CSV
+
+```ruby
+client.timeseries_values_to_csv(
+ ts_id: 55570010,
+ from: "2021-10-22",
+ to: "2021-10-25",
+ path: "rainfall.csv"
+)
+```
+
+## Data fidelity
+
+This gem returns SEPA fields verbatim. String fields that are blank in the
+API response are set to `nil`. Numeric fields (`lat`, `lon`, `ts_id`) are
+coerced from the string representation SEPA provides. Timestamps are parsed
+as ISO 8601 and converted to UTC. No values are derived, interpolated, or
+synthesised.
+
+If you need British National Grid coordinates, the companion gem
+[`smo_wgs84_to_bng`](https://github.com/Sebasmadridmx) converts WGS84
+lat/lon to BNG easting/northing.
+
+## Examples
+
+The `examples/` directory contains runnable scripts:
+
+- `01_list_rainfall_stations.rb`: fetch all rainfall stations and save to CSV
+- `02_find_15min_timeseries.rb`: list 15-min series for a given station
+- `03_download_rainfall_event.rb`: download a storm event window
+- `04_bulk_export_csv.rb`: full inventory plus the last 7 days of values for every station
+
+Run any of them with:
+
+```sh
+ruby -Ilib examples/01_list_rainfall_stations.rb
+```
+
+## Compatibility
+
+- Ruby 3.2 and above
+- Tested with InfoWorks ICM 2027 embedded Ruby (3.4.6)
+- No external runtime dependencies
+
+## Author
+
+Sebastian Madrid Ontiveros, Senior Hydraulic Modeller (Edinburgh, UK).
+GitHub: [Sebasmadridmx](https://github.com/Sebasmadridmx)
+
+## Support this work
+
+If this gem saves you time on a hydraulic modelling project, consider
+buying me a coffee:
+
+
+
+
+
+
+ buymeacoffee.com/smadrid
+
+
+## License
+
+MIT. Copyright (c) 2026 Sebastian Madrid Ontiveros.
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/Rakefile b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/Rakefile
new file mode 100644
index 0000000..6902385
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/Rakefile
@@ -0,0 +1,9 @@
+require "rake/testtask"
+
+Rake::TestTask.new(:test) do |t|
+ t.libs << "lib" << "test"
+ t.pattern = "test/test_*.rb"
+ t.verbose = true
+end
+
+task default: :test
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/01_list_rainfall_stations.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/01_list_rainfall_stations.rb
new file mode 100644
index 0000000..b35b877
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/01_list_rainfall_stations.rb
@@ -0,0 +1,12 @@
+require_relative "../lib/smo_sepa_kiwis"
+
+client = SmoSepaKiwis::Client.new
+stations = client.rainfall_stations
+
+puts "Total stations: #{stations.size}"
+puts
+puts "%-10s %-30s %10s %10s" % ["No", "Name", "Lat", "Lon"]
+puts "-" * 64
+stations.each do |s|
+ puts "%-10s %-30s %10s %10s" % [s.no.to_s, s.name.to_s, s.lat.to_s, s.lon.to_s]
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/02_find_15min_timeseries.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/02_find_15min_timeseries.rb
new file mode 100644
index 0000000..38bf558
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/02_find_15min_timeseries.rb
@@ -0,0 +1,18 @@
+require_relative "../lib/smo_sepa_kiwis"
+
+# Change this to a valid SEPA station number for your area.
+station_no = ARGV[0] || "14964"
+
+client = SmoSepaKiwis::Client.new
+series = client.rainfall_15min_timeseries(station_no: station_no)
+
+puts "15-minute timeseries for station #{station_no}: #{series.size} found"
+puts
+puts "%-12s %-30s %-12s %-12s" % ["ts_id", "ts_path", "from", "to"]
+puts "-" * 70
+
+series.each do |ts|
+ from_s = ts.coverage_from ? ts.coverage_from.strftime("%Y-%m-%d") : "n/a"
+ to_s = ts.coverage_to ? ts.coverage_to.strftime("%Y-%m-%d") : "n/a"
+ puts "%-12s %-30s %-12s %-12s" % [ts.ts_id.to_s, ts.ts_path.to_s, from_s, to_s]
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/03_download_rainfall_event.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/03_download_rainfall_event.rb
new file mode 100644
index 0000000..9d316c5
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/03_download_rainfall_event.rb
@@ -0,0 +1,24 @@
+require_relative "../lib/smo_sepa_kiwis"
+
+# Change ts_id to a valid 15-minute rainfall timeseries ID for your station.
+TS_ID = 55570010
+FROM = "2021-10-22"
+TO = "2021-10-25"
+
+client = SmoSepaKiwis::Client.new
+values = client.timeseries_values(ts_id: TS_ID, from: FROM, to: TO)
+
+non_nil = values.reject { |v| v.value.nil? }
+total = non_nil.sum { |v| v.value }
+peak = non_nil.max_by { |v| v.value }
+
+puts "ts_id: #{TS_ID}, period: #{FROM} to #{TO}"
+puts "Values fetched: #{values.size}"
+puts "Total rainfall: #{"%.2f" % total} mm"
+if peak
+ puts "Peak intensity: #{"%.2f" % peak.value} mm at #{peak.timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")}"
+end
+
+out = "rainfall_event_#{TS_ID}.csv"
+client.timeseries_values_to_csv(ts_id: TS_ID, from: FROM, to: TO, path: out)
+puts "Saved to #{out}."
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/04_bulk_export_csv.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/04_bulk_export_csv.rb
new file mode 100644
index 0000000..33a0c9a
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/04_bulk_export_csv.rb
@@ -0,0 +1,42 @@
+require_relative "../lib/smo_sepa_kiwis"
+require "fileutils"
+require "csv"
+
+# Usage: ruby examples/04_bulk_export_csv.rb [output_dir]
+# Defaults to ./bulk_export in the current working directory.
+output_dir = ARGV[0] || File.join(Dir.pwd, "bulk_export")
+data_dir = File.join(output_dir, "data")
+FileUtils.mkdir_p(data_dir)
+
+client = SmoSepaKiwis::Client.new
+
+puts "Fetching 15-minute inventory..."
+inventory = client.rainfall_15min_inventory
+puts "#{inventory.size} timeseries found."
+
+cutoff = Time.now.utc - (30 * 86400)
+active = inventory.select { |r| r[:coverage_to] && r[:coverage_to] >= cutoff }
+puts "Active (coverage_to within 30 days): #{active.size}"
+puts
+
+to_time = Time.now.utc
+from_time = to_time - (7 * 86400)
+
+active.each_with_index do |row, idx|
+ sno = row[:station_no]
+ ts_id = row[:ts_id]
+ path = File.join(data_dir, "#{sno}_#{ts_id}.csv")
+
+ values = client.timeseries_values(ts_id: ts_id, from: from_time, to: to_time)
+
+ CSV.open(path, "w") do |csv|
+ csv << %w[timestamp value quality_code]
+ values.each { |v| csv << [v.timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"), v.value, v.quality_code] }
+ end
+
+ puts "[#{idx + 1}/#{active.size}] ts #{ts_id} station #{sno}: #{values.size} values -> #{path}"
+ sleep 0.5
+end
+
+puts
+puts "Done. Files written to #{output_dir}."
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/demo.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/demo.rb
new file mode 100644
index 0000000..e74680e
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/examples/demo.rb
@@ -0,0 +1,142 @@
+require_relative "../lib/smo_sepa_kiwis"
+
+client = SmoSepaKiwis::Client.new
+
+# -----------------------------------------------------------------------------
+# 1. List all rainfall stations
+# -----------------------------------------------------------------------------
+puts "=" * 60
+puts "1. RAINFALL STATIONS"
+puts "=" * 60
+
+stations = client.rainfall_stations
+puts "Total stations: #{stations.size}"
+puts
+puts "%-10s %-28s %9s %9s" % ["No", "Name", "Lat", "Lon"]
+puts "-" * 60
+stations.first(5).each do |s|
+ puts "%-10s %-28s %9s %9s" % [s.no, s.name, s.lat, s.lon]
+end
+puts " ... (#{stations.size - 5} more)"
+puts
+
+# -----------------------------------------------------------------------------
+# 2. Find 15-min timeseries for a specific station
+# -----------------------------------------------------------------------------
+puts "=" * 60
+puts "2. 15-MIN TIMESERIES FOR A STATION"
+puts "=" * 60
+
+station = stations.first
+puts "Station: #{station.no} - #{station.name}"
+puts
+
+series = client.rainfall_15min_timeseries(station_no: station.no)
+puts "#{series.size} timeseries found."
+series.each do |ts|
+ from = ts.coverage_from&.strftime("%Y-%m-%d %H:%M:%S UTC") || "n/a"
+ to = ts.coverage_to&.strftime("%Y-%m-%d %H:%M:%S UTC") || "n/a"
+ puts " ts_id: #{ts.ts_id}"
+ puts " path: #{ts.ts_path}"
+ puts " from: #{from}"
+ puts " to: #{to}"
+ puts
+end
+
+# -----------------------------------------------------------------------------
+# 3. Full inventory (all stations + all 15-min series combined)
+# -----------------------------------------------------------------------------
+puts "=" * 60
+puts "3. FULL 15-MIN INVENTORY"
+puts "=" * 60
+
+inventory = client.rainfall_15min_inventory
+puts "Total timeseries across all stations: #{inventory.size}"
+puts
+puts "%-10s %-22s %12s %12s %10s" % ["Station", "Name", "Coverage from", "Coverage to", "ts_id"]
+puts "-" * 70
+inventory.first(5).each do |r|
+ from = r[:coverage_from]&.strftime("%Y-%m-%d") || "n/a"
+ to = r[:coverage_to]&.strftime("%Y-%m-%d") || "n/a"
+ puts "%-10s %-22s %12s %12s %10s" % [r[:station_no], r[:station_name].to_s[0, 21], from, to, r[:ts_id]]
+end
+puts " ... (#{inventory.size - 5} more)"
+puts
+
+# -----------------------------------------------------------------------------
+# 4. Download timeseries values for a specific ts_id and date range
+# -----------------------------------------------------------------------------
+puts "=" * 60
+puts "4. TIMESERIES VALUES"
+puts "=" * 60
+
+ts = series.first
+from = "2024-01-01"
+to = "2024-01-08"
+
+puts "ts_id: #{ts.ts_id} (#{ts.ts_path})"
+puts "Period: #{from} to #{to}"
+puts
+
+values = client.timeseries_values(ts_id: ts.ts_id, from: from, to: to)
+non_nil = values.reject { |v| v.value.nil? }
+
+puts "Values fetched: #{values.size}"
+puts "Non-nil values: #{non_nil.size}"
+puts "Total rainfall: #{"%.2f" % non_nil.sum(&:value)} mm"
+
+if (peak = non_nil.max_by(&:value))
+ puts "Peak intensity: #{"%.2f" % peak.value} mm at #{peak.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")}"
+end
+puts
+puts "First 5 values:"
+puts " %-28s %8s" % ["Timestamp", "Value (mm)"]
+puts " " + "-" * 38
+values.first(5).each do |v|
+ puts " %-28s %8s" % [v.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC"), v.value.nil? ? "nil" : "%.2f" % v.value]
+end
+puts
+
+# -----------------------------------------------------------------------------
+# 5. Chunked download over a long range
+# -----------------------------------------------------------------------------
+puts "=" * 60
+puts "5. CHUNKED DOWNLOAD (30-day chunks over 1 year)"
+puts "=" * 60
+
+values_chunked = client.timeseries_values(
+ ts_id: ts.ts_id,
+ from: "2023-01-01",
+ to: "2024-01-01",
+ chunk_days: 30
+)
+puts "ts_id: #{ts.ts_id}"
+puts "Period: 2023-01-01 to 2024-01-01 in 30-day chunks"
+puts "Total values: #{values_chunked.size}"
+total = values_chunked.reject { |v| v.value.nil? }.sum(&:value)
+puts "Total rainfall: #{"%.2f" % total} mm"
+puts
+
+# -----------------------------------------------------------------------------
+# 6. Write values to CSV
+# -----------------------------------------------------------------------------
+puts "=" * 60
+puts "6. EXPORT VALUES TO CSV"
+puts "=" * 60
+
+csv_path = File.join(Dir.pwd, "#{ts.ts_id}_2024_jan.csv")
+client.timeseries_values_to_csv(
+ ts_id: ts.ts_id,
+ from: "2024-01-01",
+ to: "2024-01-08",
+ path: csv_path
+)
+lines = File.readlines(csv_path)
+puts "Saved #{lines.size - 1} rows to #{csv_path}"
+puts "Header: #{lines.first.chomp}"
+puts "Row 1: #{lines[1]&.chomp}"
+puts
+
+puts "=" * 60
+puts "All done."
+puts "=" * 60
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis.rb
new file mode 100644
index 0000000..9a58f8a
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis.rb
@@ -0,0 +1,7 @@
+require_relative "smo_sepa_kiwis/version"
+require_relative "smo_sepa_kiwis/errors"
+require_relative "smo_sepa_kiwis/station"
+require_relative "smo_sepa_kiwis/timeseries"
+require_relative "smo_sepa_kiwis/value"
+require_relative "smo_sepa_kiwis/response_parser"
+require_relative "smo_sepa_kiwis/client"
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/client.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/client.rb
new file mode 100644
index 0000000..b2f3e67
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/client.rb
@@ -0,0 +1,304 @@
+require "net/http"
+require "uri"
+require "json"
+require "csv"
+require "date"
+require "time"
+
+module SmoSepaKiwis
+ class Client
+ DEFAULT_BASE_URL = "https://timeseries.sepa.org.uk/KiWIS/KiWIS"
+
+ def initialize(
+ base_url: DEFAULT_BASE_URL,
+ timeout: 60,
+ user_agent: "smo_sepa_kiwis/#{VERSION}"
+ )
+ @uri = URI.parse(base_url)
+ @timeout = timeout
+ @user_agent = user_agent
+ end
+
+ # Returns Array. Fetches all rainfall stations from SEPA.
+ def rainfall_stations
+ rows = request("getStationList",
+ stationparameter_no: "RE",
+ returnfields: "station_no,station_name,station_latitude,station_longitude," \
+ "catchment_name,river_name"
+ )
+ rows.map { |h| build_station(h) }
+ end
+
+ # Returns Array for a given station_no.
+ def rainfall_15min_timeseries(station_no:)
+ rows = request("getTimeseriesList",
+ station_no: station_no,
+ parametertype_name: "Precipitation",
+ ts_path: "1/*/RE/15*",
+ returnfields: "ts_id,ts_path,ts_name,station_no,coverage"
+ )
+ rows.map { |h| build_timeseries(h) }
+ end
+
+ # Returns Array with combined station and timeseries fields.
+ # Uses two API calls (getTimeseriesList + getStationList) and joins in Ruby,
+ # because getTimeseriesList on SEPA's instance rejects station detail fields.
+ def rainfall_15min_inventory
+ ts_rows = request("getTimeseriesList",
+ parametertype_name: "Precipitation",
+ ts_path: "1/*/RE/15*",
+ returnfields: "ts_id,ts_path,ts_name,station_no,coverage"
+ )
+
+ station_map = rainfall_stations.each_with_object({}) { |s, m| m[s.no] = s }
+
+ ts_rows.map do |h|
+ s = station_map[h[:station_no].to_s]
+ from_t = safe_parse_time(h[:from])
+ to_t = safe_parse_time(h[:to])
+ {
+ station_no: presence(h[:station_no]),
+ station_name: s&.name,
+ lat: s&.lat,
+ lon: s&.lon,
+ catchment: s&.catchment,
+ river: s&.river,
+ ts_id: to_integer(h[:ts_id]),
+ ts_path: presence(h[:ts_path]),
+ coverage_from: from_t,
+ coverage_to: to_t
+ }
+ end
+ end
+
+ # Returns Array. Accepts String/Date/Time/DateTime for from/to.
+ # If chunk_days is set, splits the window into N-day chunks and concatenates results.
+ def timeseries_values(ts_id:, from:, to:, chunk_days: nil)
+ from_time = parse_time_arg(from)
+ to_time = parse_time_arg(to)
+
+ if chunk_days
+ fetch_chunked(ts_id: ts_id, from: from_time, to: to_time, chunk_days: chunk_days)
+ else
+ fetch_values(ts_id: ts_id, from: from_time, to: to_time)
+ end
+ end
+
+ # Writes the full rainfall 15-min inventory to a CSV file.
+ def rainfall_15min_inventory_to_csv(path)
+ rows = rainfall_15min_inventory
+ CSV.open(path, "w") do |csv|
+ csv << %w[station_no station_name lat lon catchment river ts_id ts_path coverage_from coverage_to]
+ rows.each do |r|
+ csv << [
+ r[:station_no], r[:station_name], r[:lat], r[:lon],
+ r[:catchment], r[:river],
+ r[:ts_id], r[:ts_path],
+ r[:coverage_from]&.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ r[:coverage_to]&.strftime("%Y-%m-%dT%H:%M:%SZ")
+ ]
+ end
+ end
+ end
+
+ # Writes timeseries values to a CSV file.
+ def timeseries_values_to_csv(ts_id:, from:, to:, path:, chunk_days: nil)
+ values = timeseries_values(ts_id: ts_id, from: from, to: to, chunk_days: chunk_days)
+ CSV.open(path, "w") do |csv|
+ csv << %w[timestamp value quality_code]
+ values.each do |v|
+ csv << [v.timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"), v.value, v.quality_code]
+ end
+ end
+ end
+
+ private
+
+ def request(function, **params)
+ ResponseParser.parse(request_body(build_uri(function, **params)))
+ end
+
+ def build_uri(function, **params)
+ uri = @uri.dup
+ uri.query = URI.encode_www_form(
+ service: "kisters",
+ type: "queryServices",
+ datasource: "0",
+ request: function,
+ format: "json",
+ **params
+ )
+ uri
+ end
+
+ def request_body(uri)
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: @timeout) do |http|
+ req = Net::HTTP::Get.new(uri)
+ req["User-Agent"] = @user_agent
+ response = http.request(req)
+
+ unless response.is_a?(Net::HTTPSuccess)
+ snippet = response.body.to_s[0, 300]
+ raise ApiError.new(
+ "HTTP #{response.code} from KiWIS: #{snippet}",
+ status: response.code.to_i
+ )
+ end
+
+ response.body
+ end
+ end
+
+ def build_station(h)
+ Station.new(
+ no: presence(h[:station_no]),
+ name: presence(h[:station_name]),
+ lat: to_float(h[:station_latitude]),
+ lon: to_float(h[:station_longitude]),
+ catchment: presence(h[:catchment_name]),
+ river: presence(h[:river_name])
+ )
+ end
+
+ def build_timeseries(h)
+ from_t, to_t = parse_coverage(h)
+ Timeseries.new(
+ ts_id: to_integer(h[:ts_id]),
+ ts_path: presence(h[:ts_path]),
+ ts_name: presence(h[:ts_name]),
+ station_no: presence(h[:station_no]),
+ coverage_from: from_t,
+ coverage_to: to_t
+ )
+ end
+
+ def build_inventory_row(h)
+ from_t, to_t = parse_coverage(h)
+ {
+ station_no: presence(h[:station_no]),
+ station_name: presence(h[:station_name]),
+ lat: to_float(h[:station_latitude]),
+ lon: to_float(h[:station_longitude]),
+ catchment: presence(h[:catchment_name]),
+ river: presence(h[:river_name]),
+ ts_id: to_integer(h[:ts_id]),
+ ts_path: presence(h[:ts_path]),
+ coverage_from: from_t,
+ coverage_to: to_t
+ }
+ end
+
+ def parse_values_body(body)
+ # getTimeseriesValues returns [{ts_id, rows, columns: "Timestamp,Value,...", data: [[ts,val],...]}]
+ outer = JSON.parse(body)
+ rescue JSON::ParserError => e
+ raise ParseError, "Invalid JSON from getTimeseriesValues: #{e.message}"
+ else
+ return [] if outer.nil? || outer.empty?
+ block = outer.first
+ raise ParseError, "Unexpected values shape" unless block.is_a?(Hash) && block["data"]
+
+ cols = block["columns"].to_s.split(",").map { |c| c.strip.downcase }
+ ts_idx = cols.index("timestamp") || 0
+ v_idx = cols.index("value") || 1
+ qc_idx = cols.index("quality code")
+
+ (block["data"] || []).map do |row|
+ Value.new(
+ timestamp: parse_iso8601(row[ts_idx].to_s),
+ value: row[v_idx].nil? ? nil : to_float(row[v_idx]),
+ quality_code: qc_idx ? to_integer(row[qc_idx]) : nil
+ )
+ end
+ end
+
+ # Parses coverage field. KiWIS may return a combined "from/to" string
+ # in :coverage, or separate :from and :to fields.
+ def parse_coverage(h)
+ if h.key?(:coverage) && h[:coverage]
+ parts = h[:coverage].to_s.split("/")
+ [safe_parse_time(parts[0]), safe_parse_time(parts[1])]
+ else
+ [safe_parse_time(h[:from]), safe_parse_time(h[:to])]
+ end
+ end
+
+ def fetch_values(ts_id:, from:, to:)
+ uri = build_uri("getTimeseriesValues",
+ ts_id: ts_id,
+ from: from.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ to: to.strftime("%Y-%m-%dT%H:%M:%SZ")
+ )
+ parse_values_body(request_body(uri))
+ end
+
+ def fetch_chunked(ts_id:, from:, to:, chunk_days:)
+ results = {}
+ window_start = from
+ chunk_secs = chunk_days * 86400
+
+ while window_start < to
+ window_end = [window_start + chunk_secs, to].min
+ fetch_values(ts_id: ts_id, from: window_start, to: window_end).each do |v|
+ results[v.timestamp] = v
+ end
+ window_start = window_end
+ end
+
+ results.values.sort_by(&:timestamp)
+ end
+
+ def parse_time_arg(val)
+ case val
+ when Time then val.utc
+ when DateTime then val.to_time.utc
+ when Date then Time.utc(val.year, val.month, val.day)
+ when String
+ # Accept full ISO 8601 datetimes or bare date strings (YYYY-MM-DD).
+ if val =~ /\A\d{4}-\d{2}-\d{2}\z/
+ d = Date.parse(val)
+ Time.utc(d.year, d.month, d.day)
+ else
+ Time.iso8601(val).utc
+ end
+ else raise ArgumentError, "Cannot convert #{val.class} to Time"
+ end
+ end
+
+ def safe_parse_time(str)
+ return nil if str.nil? || str.to_s.strip.empty?
+ Time.iso8601(str.to_s.strip).utc
+ rescue ArgumentError
+ nil
+ end
+
+ def parse_iso8601(str)
+ return nil if str.nil? || str.to_s.strip.empty?
+ Time.iso8601(str.to_s.strip).utc
+ rescue ArgumentError
+ nil
+ end
+
+ def presence(val)
+ return nil if val.nil?
+ s = val.to_s.strip
+ s.empty? ? nil : s
+ end
+
+ def to_float(val)
+ return nil if val.nil?
+ s = val.to_s.strip
+ return nil if s.empty?
+ Float(s)
+ rescue ArgumentError
+ nil
+ end
+
+ def to_integer(val)
+ return nil if val.nil?
+ s = val.to_s.strip
+ return nil if s.empty?
+ Integer(s, exception: false)
+ end
+ end
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/errors.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/errors.rb
new file mode 100644
index 0000000..afad9b6
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/errors.rb
@@ -0,0 +1,14 @@
+module SmoSepaKiwis
+ class Error < StandardError; end
+
+ class ApiError < Error
+ attr_reader :status
+
+ def initialize(message, status:)
+ super(message)
+ @status = status
+ end
+ end
+
+ class ParseError < Error; end
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/response_parser.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/response_parser.rb
new file mode 100644
index 0000000..3ab0836
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/response_parser.rb
@@ -0,0 +1,35 @@
+require "json"
+
+module SmoSepaKiwis
+ module ResponseParser
+ def self.parse(body)
+ data = JSON.parse(body)
+ rescue JSON::ParserError => e
+ raise ParseError, "Invalid JSON: #{e.message}"
+ else
+ validate_and_convert(data)
+ end
+
+ def self.validate_and_convert(data)
+ # Empty result.
+ return [] if data == []
+
+ unless data.is_a?(Array) && data.first.is_a?(Array) && data.first.all? { |h| h.is_a?(String) }
+ raise ParseError, "Unexpected response shape: #{data.class}"
+ end
+
+ headers = data.first.map { |h| h.to_sym }
+ rows = data[1..]
+
+ # No data rows (headers only).
+ return [] if rows.nil? || rows.empty?
+
+ rows.map do |row|
+ unless row.is_a?(Array)
+ raise ParseError, "Expected row to be an Array, got #{row.class}"
+ end
+ headers.zip(row).to_h
+ end
+ end
+ end
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/station.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/station.rb
new file mode 100644
index 0000000..a809bea
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/station.rb
@@ -0,0 +1,6 @@
+module SmoSepaKiwis
+ Station = Struct.new(
+ :no, :name, :lat, :lon, :catchment, :river,
+ keyword_init: true
+ )
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/timeseries.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/timeseries.rb
new file mode 100644
index 0000000..0c9cbce
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/timeseries.rb
@@ -0,0 +1,6 @@
+module SmoSepaKiwis
+ Timeseries = Struct.new(
+ :ts_id, :ts_path, :ts_name, :station_no, :coverage_from, :coverage_to,
+ keyword_init: true
+ )
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/value.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/value.rb
new file mode 100644
index 0000000..f2ec2bd
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/value.rb
@@ -0,0 +1,6 @@
+module SmoSepaKiwis
+ Value = Struct.new(
+ :timestamp, :value, :quality_code,
+ keyword_init: true
+ )
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/version.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/version.rb
new file mode 100644
index 0000000..723eb0a
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/lib/smo_sepa_kiwis/version.rb
@@ -0,0 +1,3 @@
+module SmoSepaKiwis
+ VERSION = "0.1.2"
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/smo_sepa_kiwis.gemspec b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/smo_sepa_kiwis.gemspec
new file mode 100644
index 0000000..0f8072d
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/smo_sepa_kiwis.gemspec
@@ -0,0 +1,25 @@
+require_relative "lib/smo_sepa_kiwis/version"
+
+Gem::Specification.new do |spec|
+ spec.name = "smo_sepa_kiwis"
+ spec.version = SmoSepaKiwis::VERSION
+ spec.authors = ["Sebastian Madrid Ontiveros"]
+ spec.summary = "Pure-Ruby client for the SEPA Time Series KiWIS API, " \
+ "developed to save time in hydraulic modelling workflows " \
+ "and interact directly with InfoWorks ICM 2027."
+ spec.description = "Developed by Sebastian Madrid Ontiveros. Fetch rainfall " \
+ "stations, 15-minute timeseries, and values from the SEPA " \
+ "KiWIS API. Pure Ruby, stdlib only, no native extensions or " \
+ "external gem dependencies. Compatible with InfoWorks ICM " \
+ "2027 embedded Ruby. Built by a hydraulic modeller to support " \
+ "rainfall data ingestion, 1D-2D model build workflows, and " \
+ "flood risk assessment in the UK. If this gem saves you time, " \
+ "you can support development at https://buymeacoffee.com/smadrid."
+ spec.license = "MIT"
+ spec.homepage = "https://github.com/Sebasmadridmx/smo_sepa_kiwis"
+
+ spec.required_ruby_version = ">= 3.2"
+ spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE", "CHANGELOG.md"]
+
+ # No runtime dependencies. Stdlib only.
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/fixtures/station_list.json b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/fixtures/station_list.json
new file mode 100644
index 0000000..92155e7
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/fixtures/station_list.json
@@ -0,0 +1,6 @@
+[
+ ["station_no","station_name","station_latitude","station_longitude","catchment_name","river_name"],
+ ["14964","Auchinner","56.36","-4.05","Earn","Allt Srath a Ghlinne"],
+ ["14933","Killin","56.47","-4.32","Tay",""],
+ ["14901","Eskdalemuir","55.31","-3.20","Esk","White Esk"]
+]
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/fixtures/timeseries_list.json b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/fixtures/timeseries_list.json
new file mode 100644
index 0000000..032545b
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/fixtures/timeseries_list.json
@@ -0,0 +1,5 @@
+[
+ ["ts_id","ts_path","ts_name","station_no","from","to"],
+ ["55570010","1/14964/RE/15m.Cmd","15m.Cmd","14964","2010-01-01T00:00:00.000Z","2026-05-02T23:45:00.000Z"],
+ ["55570011","1/14933/RE/15m.Cmd","15m.Cmd","14933","2012-06-15T00:00:00.000Z","2026-05-02T23:30:00.000Z"]
+]
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/fixtures/timeseries_values.json b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/fixtures/timeseries_values.json
new file mode 100644
index 0000000..aab8056
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/fixtures/timeseries_values.json
@@ -0,0 +1 @@
+[{"ts_id": "55570010","rows": "4","columns":"Timestamp,Value","data":[["2021-10-22T12:00:00.000Z",0.2],["2021-10-22T12:15:00.000Z",0.4],["2021-10-22T12:30:00.000Z",null],["2021-10-22T12:45:00.000Z",1.2]]}]
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_client.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_client.rb
new file mode 100644
index 0000000..8a581a5
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_client.rb
@@ -0,0 +1,208 @@
+require_relative "test_helper"
+require "tmpdir"
+
+# Stub transport: intercepts request_body to serve fixture files without
+# hitting the network. Accepts an ordered queue of fixture names consumed
+# one per call; repeats the last name if the queue runs out.
+module StubRequest
+ def stub_next(*fixture_names)
+ @_stub_queue = fixture_names.dup
+ @_stub_default = fixture_names.last
+ end
+
+ private
+
+ def request_body(_uri)
+ name = @_stub_queue.shift || @_stub_default
+ File.read(File.join(FIXTURES_DIR, name))
+ end
+end
+
+def make_client(*fixture_names)
+ c = SmoSepaKiwis::Client.new
+ c.extend(StubRequest)
+ c.stub_next(*fixture_names)
+ c
+end
+
+class TestClientRainfallStations < Minitest::Test
+ def setup
+ @client = make_client("station_list.json")
+ @stations = @client.rainfall_stations
+ end
+
+ def test_returns_array_of_stations
+ assert_instance_of Array, @stations
+ assert @stations.all? { |s| s.is_a?(SmoSepaKiwis::Station) }
+ end
+
+ def test_station_count
+ assert_equal 3, @stations.size
+ end
+
+ def test_lat_lon_parsed_to_float
+ s = @stations[0]
+ assert_instance_of Float, s.lat
+ assert_instance_of Float, s.lon
+ assert_in_delta 56.36, s.lat
+ assert_in_delta(-4.05, s.lon)
+ end
+
+ def test_empty_catchment_river_become_nil
+ killin = @stations[1]
+ assert_nil killin.river
+ end
+
+ def test_station_no_is_string
+ assert_equal "14964", @stations[0].no
+ end
+
+ def test_third_station_fields
+ s = @stations[2]
+ assert_equal "14901", s.no
+ assert_equal "Eskdalemuir", s.name
+ assert_equal "Esk", s.catchment
+ assert_equal "White Esk", s.river
+ end
+end
+
+class TestClientRainfall15minTimeseries < Minitest::Test
+ def setup
+ @client = make_client("timeseries_list.json")
+ @series = @client.rainfall_15min_timeseries(station_no: "14964")
+ end
+
+ def test_returns_array_of_timeseries
+ assert_instance_of Array, @series
+ assert @series.all? { |t| t.is_a?(SmoSepaKiwis::Timeseries) }
+ end
+
+ def test_ts_id_is_integer
+ assert_equal 55570010, @series[0].ts_id
+ assert_instance_of Integer, @series[0].ts_id
+ end
+
+ def test_coverage_from_is_utc_time
+ t = @series[0].coverage_from
+ assert_instance_of Time, t
+ assert_equal "UTC", t.zone
+ assert_equal 2010, t.year
+ assert_equal 1, t.month
+ assert_equal 1, t.day
+ end
+
+ def test_coverage_to_is_utc_time
+ t = @series[0].coverage_to
+ assert_instance_of Time, t
+ assert_equal "UTC", t.zone
+ assert_equal 2026, t.year
+ end
+
+ def test_station_no_string
+ assert_equal "14964", @series[0].station_no
+ end
+
+ def test_ts_path
+ assert_equal "1/14964/RE/15m.Cmd", @series[0].ts_path
+ end
+end
+
+class TestClientTimeseriesValues < Minitest::Test
+ def setup
+ @client = make_client("timeseries_values.json")
+ @values = @client.timeseries_values(
+ ts_id: 55570010,
+ from: "2021-10-22T12:00:00Z",
+ to: "2021-10-22T13:00:00Z"
+ )
+ end
+
+ def test_returns_array_of_values
+ assert_instance_of Array, @values
+ assert @values.all? { |v| v.is_a?(SmoSepaKiwis::Value) }
+ end
+
+ def test_count
+ assert_equal 4, @values.size
+ end
+
+ def test_timestamp_is_utc_time
+ v = @values[0]
+ assert_instance_of Time, v.timestamp
+ assert_equal "UTC", v.timestamp.zone
+ assert_equal 2021, v.timestamp.year
+ assert_equal 10, v.timestamp.month
+ assert_equal 22, v.timestamp.day
+ assert_equal 12, v.timestamp.hour
+ end
+
+ def test_value_is_float
+ assert_in_delta 0.2, @values[0].value
+ end
+
+ def test_null_value_becomes_nil
+ assert_nil @values[2].value
+ end
+
+ def test_quality_code_nil_when_not_in_response
+ assert_nil @values[0].quality_code
+ end
+end
+
+class TestClientInventory < Minitest::Test
+ def setup
+ # rainfall_15min_inventory calls getTimeseriesList then getStationList.
+ @client = make_client("timeseries_list.json", "station_list.json")
+ @inv = @client.rainfall_15min_inventory
+ end
+
+ def test_returns_array_of_hashes
+ assert_instance_of Array, @inv
+ assert @inv.all? { |r| r.is_a?(Hash) }
+ end
+
+ def test_has_expected_keys
+ expected = %i[station_no station_name lat lon catchment river ts_id ts_path coverage_from coverage_to]
+ assert_equal expected.sort, @inv[0].keys.sort
+ end
+
+ def test_ts_id_integer
+ assert_instance_of Integer, @inv[0][:ts_id]
+ end
+
+ def test_lat_float
+ assert_instance_of Float, @inv[0][:lat]
+ end
+end
+
+class TestClientCsvMethods < Minitest::Test
+ def test_inventory_to_csv
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, "inv.csv")
+ client = make_client("timeseries_list.json", "station_list.json")
+ client.rainfall_15min_inventory_to_csv(path)
+ lines = File.readlines(path)
+ assert lines.first.start_with?("station_no")
+ assert_equal 3, lines.size # header + 2 data rows
+ end
+ end
+
+ def test_values_to_csv
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, "vals.csv")
+ client = make_client("timeseries_values.json")
+ client.timeseries_values_to_csv(
+ ts_id: 55570010,
+ from: "2021-10-22T12:00:00Z",
+ to: "2021-10-22T13:00:00Z",
+ path: path
+ )
+ lines = File.readlines(path)
+ assert lines.first.start_with?("timestamp")
+ assert_equal 5, lines.size # header + 4 data rows
+ # Null value row: value field is empty (two consecutive commas after timestamp).
+ null_row = lines[3]
+ assert_match(/,,/, null_row)
+ end
+ end
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_helper.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_helper.rb
new file mode 100644
index 0000000..02c1ddb
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_helper.rb
@@ -0,0 +1,8 @@
+require "minitest/autorun"
+require "smo_sepa_kiwis"
+
+FIXTURES_DIR = File.expand_path("fixtures", __dir__)
+
+def fixture(name)
+ File.read(File.join(FIXTURES_DIR, name))
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_response_parser.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_response_parser.rb
new file mode 100644
index 0000000..903ea9f
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_response_parser.rb
@@ -0,0 +1,54 @@
+require_relative "test_helper"
+
+class TestResponseParser < Minitest::Test
+ def test_headers_and_rows
+ body = fixture("station_list.json")
+ result = SmoSepaKiwis::ResponseParser.parse(body)
+ assert_equal 3, result.size
+ assert_equal "14964", result[0][:station_no]
+ assert_equal "Auchinner", result[0][:station_name]
+ end
+
+ def test_empty_array_returns_empty
+ result = SmoSepaKiwis::ResponseParser.parse("[]")
+ assert_equal [], result
+ end
+
+ def test_headers_only_returns_empty
+ body = '[["station_no","station_name"]]'
+ result = SmoSepaKiwis::ResponseParser.parse(body)
+ assert_equal [], result
+ end
+
+ def test_malformed_json_raises_parse_error
+ assert_raises(SmoSepaKiwis::ParseError) do
+ SmoSepaKiwis::ResponseParser.parse("not json {{{")
+ end
+ end
+
+ def test_unexpected_shape_raises_parse_error
+ assert_raises(SmoSepaKiwis::ParseError) do
+ SmoSepaKiwis::ResponseParser.parse('{"error":"no data"}')
+ end
+ end
+
+ def test_null_in_list_fixture_preserved
+ # station_list has no nulls, but confirm the parser passes nil JSON values through.
+ body = '[["station_no","river_name"],["14964",null]]'
+ result = SmoSepaKiwis::ResponseParser.parse(body)
+ assert_nil result[0][:river_name]
+ end
+
+ def test_empty_string_fields_preserved_as_empty_string
+ body = fixture("station_list.json")
+ result = SmoSepaKiwis::ResponseParser.parse(body)
+ # Killin has empty river_name.
+ assert_equal "", result[1][:river_name]
+ end
+
+ def test_symbol_keys
+ body = fixture("station_list.json")
+ result = SmoSepaKiwis::ResponseParser.parse(body)
+ assert result[0].key?(:station_no)
+ end
+end
diff --git a/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_station.rb b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_station.rb
new file mode 100644
index 0000000..8a0888b
--- /dev/null
+++ b/07 Ruby Exchange Gems/SMO-SEPA-KiWIS/test/test_station.rb
@@ -0,0 +1,37 @@
+require_relative "test_helper"
+
+class TestStation < Minitest::Test
+ def test_keyword_init
+ s = SmoSepaKiwis::Station.new(
+ no: "14964", name: "Auchinner", lat: 56.36, lon: -4.05,
+ catchment: "Earn", river: "Allt Srath a Ghlinne"
+ )
+ assert_equal "14964", s.no
+ assert_equal "Auchinner", s.name
+ assert_in_delta 56.36, s.lat
+ assert_in_delta(-4.05, s.lon)
+ assert_equal "Earn", s.catchment
+ assert_equal "Allt Srath a Ghlinne", s.river
+ end
+
+ def test_nil_fields_allowed
+ s = SmoSepaKiwis::Station.new(
+ no: "14933", name: "Killin", lat: nil, lon: nil,
+ catchment: nil, river: nil
+ )
+ assert_nil s.lat
+ assert_nil s.catchment
+ end
+
+ def test_equality
+ s1 = SmoSepaKiwis::Station.new(no: "1", name: "A", lat: 1.0, lon: 2.0, catchment: nil, river: nil)
+ s2 = SmoSepaKiwis::Station.new(no: "1", name: "A", lat: 1.0, lon: 2.0, catchment: nil, river: nil)
+ assert_equal s1, s2
+ end
+
+ def test_inequality
+ s1 = SmoSepaKiwis::Station.new(no: "1", name: "A", lat: 1.0, lon: 2.0, catchment: nil, river: nil)
+ s2 = SmoSepaKiwis::Station.new(no: "2", name: "B", lat: 1.0, lon: 2.0, catchment: nil, river: nil)
+ refute_equal s1, s2
+ end
+end