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: + +

+ + Buy Me a Coffee QR code + +
+ 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: + +

+ + Buy Me a Coffee QR code + +
+ 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