smo_ea_hydrology 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9856d71b70c518ecd36a6f8c7b3c8c0884310c5cf284ba94205cc5a901553438
4
+ data.tar.gz: d8ba74d3ed08498b304bc694f1a8106d16925152ab8cbe20be2102d1b3e01f4b
5
+ SHA512:
6
+ metadata.gz: 0d5e4eea4de3682f1a424a170c2013b46be66f659012635bb0f9dd9f7fa39f251da38f776d6bf6ccd6b79eaff9e7c0003656eb64eea08e8ac4804b78637b52d4
7
+ data.tar.gz: 416661e9cd211ec793faece7ed13fcc16ef3af5d0911dbfa4f3c3d418c8c99aefcc0f21dbb6236fffc9049f515d15b17ab3d18a4c0a6408c783ef5312b2161bf
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "date"
7
+ require "time"
8
+
9
+ module SmoEaHydrology
10
+ class Client
11
+ BASE_URL = "https://environment.data.gov.uk/hydrology"
12
+ FM_BASE_URL = "https://environment.data.gov.uk/flood-monitoring"
13
+
14
+ # Returns all active stations that have at least one 15-min rainfall measure.
15
+ #
16
+ # @return [Array<Station>]
17
+ def rainfall_15min_stations
18
+ items = paginate("/id/stations", observedProperty: "rainfall", "status.label": "Active")
19
+ items.filter_map do |item|
20
+ measures = Array(item["measures"])
21
+ measure_15 = measures.find { |m| m.is_a?(Hash) && m["period"] == 900 }
22
+ next unless measure_15
23
+
24
+ Station.new(
25
+ id: item["@id"],
26
+ label: item["label"],
27
+ station_reference: item["stationReference"] || item["notation"],
28
+ wiski_id: item["wiskiID"],
29
+ easting: item["easting"],
30
+ northing: item["northing"],
31
+ lat: item["lat"],
32
+ long: item["long"],
33
+ date_opened: item["dateOpened"],
34
+ status: label_of(item["status"]),
35
+ measure_id: measure_15["@id"],
36
+ measure_label: derive_measure_label(measure_15)
37
+ )
38
+ end
39
+ end
40
+
41
+ # Returns all 15-min rainfall measures for a given station.
42
+ #
43
+ # @param station_reference [String] e.g. "589359"
44
+ # @return [Array<Measure>]
45
+ def measures(station_reference)
46
+ items = get("/id/measures", observedProperty: "rainfall",
47
+ periodName: "15min",
48
+ "station.stationReference": station_reference.to_s)
49
+ items.map { |item| parse_measure(item) }
50
+ end
51
+
52
+ # Returns 15-min rainfall readings for a measure over a date/datetime range.
53
+ #
54
+ # @param measure_id [String] full measure URI or the path-only ID
55
+ # @param from [String, Date, Time] start inclusive — "YYYY-MM-DD" or "YYYY-MM-DD HH:MM"
56
+ # @param to [String, Date, Time] end inclusive — "YYYY-MM-DD" or "YYYY-MM-DD HH:MM"
57
+ # @return [Array<Reading>]
58
+ def readings(measure_id, from:, to:)
59
+ id_path = measure_path(measure_id)
60
+ from_str = parse_datetime(from)
61
+ to_str = parse_datetime(to)
62
+ date_only = !from_str.include?("T")
63
+ params = if date_only
64
+ { "mineq-date": from_str, "maxeq-date": to_str }
65
+ else
66
+ { "mineq-dateTime": from_str, "maxeq-dateTime": to_str }
67
+ end
68
+ items = paginate("#{id_path}/readings", **params)
69
+ items.map { |item| parse_reading(item) }
70
+ end
71
+
72
+ # Like rainfall_15min_stations but also fetches coverage_from / coverage_to
73
+ # for every station. Makes 2 extra API calls per station — slow for all 900+.
74
+ # Progress is printed to $stdout.
75
+ #
76
+ # @return [Array<Station>]
77
+ def rainfall_15min_stations_with_coverage
78
+ stations = rainfall_15min_stations
79
+ stations.each_with_index do |station, i|
80
+ $stdout.print "\r #{i + 1}/#{stations.size} #{station.station_reference} "
81
+ $stdout.flush
82
+ station.coverage_from = fetch_earliest(station.measure_id)
83
+ station.coverage_to = fetch_latest_fm(station.station_reference)
84
+ end
85
+ $stdout.puts
86
+ stations
87
+ end
88
+
89
+ # Search stations by partial name (case-insensitive) or exact station reference.
90
+ # Calls rainfall_15min_stations internally so no coverage dates are included.
91
+ #
92
+ # @param query [String] partial station name or exact reference
93
+ # @return [Array<Station>]
94
+ def find_stations(query)
95
+ q = query.to_s.strip.downcase
96
+ rainfall_15min_stations.select do |s|
97
+ s.station_reference.to_s.downcase == q ||
98
+ s.label.to_s.downcase.include?(q)
99
+ end
100
+ end
101
+
102
+ # Downloads 15-min readings for a single station to a CSV file.
103
+ #
104
+ # @param station_reference [String]
105
+ # @param from [String, Date] start date inclusive (YYYY-MM-DD)
106
+ # @param to [String, Date] end date inclusive (YYYY-MM-DD)
107
+ # @param path [String] output file path
108
+ # @return [Integer] number of readings written
109
+ def readings_to_csv(station_reference:, from:, to:, path:)
110
+ require "csv"
111
+ ms = measures(station_reference)
112
+ raise ApiError, "No 15-min rainfall measure found for #{station_reference}" if ms.empty?
113
+
114
+ rows = readings(ms.first.id, from: from, to: to)
115
+ CSV.open(path, "w") do |csv|
116
+ csv << %w[datetime value_mm quality completeness]
117
+ rows.each do |r|
118
+ csv << [r.datetime.strftime("%Y-%m-%d %H:%M:%S"), r.value, r.quality, r.completeness]
119
+ end
120
+ end
121
+ rows.size
122
+ end
123
+
124
+ # Writes the full 15-min rainfall inventory to a CSV file.
125
+ # Includes coverage dates — makes 2 extra API calls per station.
126
+ #
127
+ # @param path [String] output file path
128
+ def rainfall_15min_inventory_to_csv(path)
129
+ require "csv"
130
+ entries = rainfall_15min_inventory
131
+ CSV.open(path, "w") do |csv|
132
+ csv << %w[station_reference station_label lat long easting northing
133
+ date_opened measure_id period_name unit_name value_type
134
+ coverage_from coverage_to]
135
+ entries.each do |e|
136
+ csv << [
137
+ e.station_reference, e.station_label, e.lat, e.long,
138
+ e.easting, e.northing, e.date_opened, e.measure_id,
139
+ e.period_name, e.unit_name, e.value_type,
140
+ e.coverage_from_s, e.coverage_to_s
141
+ ]
142
+ end
143
+ end
144
+ entries.size
145
+ end
146
+
147
+ # Downloads readings for multiple stations to individual CSV files.
148
+ #
149
+ # @param from [String, Date] start date inclusive (YYYY-MM-DD)
150
+ # @param to [String, Date] end date inclusive (YYYY-MM-DD)
151
+ # @param output_dir [String] directory to write CSV files into
152
+ # @param refs [Array<String>, nil] station references to download;
153
+ # nil downloads all active 15-min stations
154
+ # @return [Hash] { station_reference => { path:, count: } }
155
+ def batch_download(from:, to:, output_dir:, refs: nil)
156
+ require "csv"
157
+ require "fileutils"
158
+ FileUtils.mkdir_p(output_dir)
159
+
160
+ stations = rainfall_15min_stations
161
+ stations = stations.select { |s| refs.map(&:to_s).include?(s.station_reference.to_s) } if refs
162
+
163
+ results = {}
164
+ stations.each_with_index do |station, i|
165
+ ref = station.station_reference.to_s
166
+ path = File.join(output_dir, "#{ref}_#{parse_date(from)}_#{parse_date(to)}.csv")
167
+ $stdout.print "\r [#{i + 1}/#{stations.size}] #{ref} "
168
+ $stdout.flush
169
+
170
+ count = readings_to_csv(station_reference: ref, from: from, to: to, path: path)
171
+ results[ref] = { path: path, count: count }
172
+ rescue => e
173
+ results[ref] = { path: path, count: 0, error: e.message }
174
+ end
175
+
176
+ $stdout.puts
177
+ results
178
+ end
179
+
180
+ # Returns a combined inventory of all active 15-min rainfall stations with
181
+ # their measures and coverage dates (earliest and latest reading timestamps).
182
+ #
183
+ # Note: this makes two additional API calls per station (one to the hydrology
184
+ # API for the earliest reading, one to the flood-monitoring API for the latest),
185
+ # so it is slow for large inventories. Progress is printed to $stdout.
186
+ #
187
+ # @return [Array<InventoryEntry>]
188
+ def rainfall_15min_inventory
189
+ stations = rainfall_15min_stations
190
+ entries = []
191
+
192
+ stations.each_with_index do |station, i|
193
+ $stdout.print "\r #{i + 1}/#{stations.size} #{station.station_reference} "
194
+ $stdout.flush
195
+
196
+ measures_list = measures(station.station_reference)
197
+ next if measures_list.empty?
198
+
199
+ measure = measures_list.first
200
+ from_t = fetch_earliest(measure.id)
201
+ to_t = fetch_latest_fm(station.station_reference)
202
+
203
+ entries << InventoryEntry.new(
204
+ station_reference: station.station_reference,
205
+ station_label: station.label,
206
+ lat: station.lat,
207
+ long: station.long,
208
+ easting: station.easting,
209
+ northing: station.northing,
210
+ date_opened: station.date_opened,
211
+ measure_id: measure.id,
212
+ period_name: measure.period_name,
213
+ unit_name: measure.unit_name,
214
+ value_type: measure.value_type,
215
+ coverage_from: from_t,
216
+ coverage_to: to_t
217
+ )
218
+ end
219
+
220
+ $stdout.puts
221
+ entries
222
+ end
223
+
224
+ private
225
+
226
+ # Follows pagination automatically until all items are collected.
227
+ def paginate(path, **params)
228
+ limit = 10_000
229
+ offset = 0
230
+ all = []
231
+
232
+ loop do
233
+ items = get(path, **params, _limit: limit, _offset: offset)
234
+ all.concat(items)
235
+ break if items.size < limit
236
+
237
+ offset += limit
238
+ end
239
+
240
+ all
241
+ end
242
+
243
+ def get(path, **params)
244
+ uri = URI("#{BASE_URL}#{path}")
245
+ uri.query = URI.encode_www_form(
246
+ params.merge(_format: "json").reject { |_, v| v.nil? }
247
+ )
248
+ response = request(uri)
249
+
250
+ raise ApiError.new("HTTP #{response.code} from #{uri}", status: response.code.to_i) \
251
+ unless response.is_a?(Net::HTTPSuccess)
252
+
253
+ body = JSON.parse(response.body)
254
+ Array(body["items"])
255
+ rescue JSON::ParserError => e
256
+ raise ParseError, "JSON parse error: #{e.message}"
257
+ end
258
+
259
+ def request(uri, limit = 5)
260
+ raise ApiError, "Too many redirects" if limit.zero?
261
+
262
+ response = Net::HTTP.get_response(uri)
263
+ if response.is_a?(Net::HTTPRedirection)
264
+ request(URI(response["location"]), limit - 1)
265
+ else
266
+ response
267
+ end
268
+ end
269
+
270
+ # Fetches the datetime of the earliest available reading for a measure.
271
+ # Queries the hydrology API with mineq-date=1990-01-01 and _limit=1.
272
+ def fetch_earliest(measure_id)
273
+ path = measure_path(measure_id)
274
+ items = get("#{path}/readings", "mineq-date": "1990-01-01", _limit: 1)
275
+ return nil if items.empty?
276
+
277
+ Time.iso8601(items.first["dateTime"])
278
+ rescue
279
+ nil
280
+ end
281
+
282
+ # Fetches the datetime of the latest available reading for a station
283
+ # via the EA Flood Monitoring API, which exposes latestReading.dateTime.
284
+ # Filters locally to rainfall measures with period == 900 (15 min).
285
+ def fetch_latest_fm(station_reference)
286
+ uri = URI("#{FM_BASE_URL}/id/stations/#{station_reference}/measures")
287
+ uri.query = URI.encode_www_form(_format: "json")
288
+ response = request(uri)
289
+ return nil unless response.is_a?(Net::HTTPSuccess)
290
+
291
+ body = JSON.parse(response.body)
292
+ items = Array(body["items"])
293
+ items.each do |item|
294
+ next unless item["parameter"] == "rainfall" && item["period"] == 900
295
+
296
+ lr = item["latestReading"]
297
+ next unless lr.is_a?(Hash) && lr["dateTime"]
298
+
299
+ return Time.iso8601(lr["dateTime"])
300
+ end
301
+ nil
302
+ rescue
303
+ nil
304
+ end
305
+
306
+ # Builds a human-readable label from the fields available in the embedded
307
+ # measure object returned by the stations endpoint (no label field there).
308
+ def derive_measure_label(measure)
309
+ period = measure["period"].to_i
310
+ period_s = period == 900 ? "15min" : "#{period}s"
311
+ param = measure["parameter"].to_s.capitalize
312
+ stat_uri = measure.dig("valueStatistic", "@id").to_s
313
+ stat = stat_uri.split("/").last.to_s.capitalize
314
+ stat = "Total" if stat.empty?
315
+ "#{param} #{period_s} #{stat} (mm)"
316
+ end
317
+
318
+ def parse_measure(item)
319
+ station = item["station"].is_a?(Hash) ? item["station"] : {}
320
+ Measure.new(
321
+ id: item["@id"],
322
+ label: item["label"],
323
+ station_reference: station["stationReference"],
324
+ station_label: station["label"],
325
+ period_name: item["periodName"],
326
+ unit_name: item["unitName"],
327
+ value_type: item["valueType"],
328
+ timeseries_id: item["timeseriesID"]
329
+ )
330
+ end
331
+
332
+ def parse_reading(item)
333
+ Reading.new(
334
+ datetime: Time.iso8601(item["dateTime"]),
335
+ value: item["value"].to_f,
336
+ quality: item["quality"],
337
+ completeness: item["completeness"]
338
+ )
339
+ end
340
+
341
+ # Parses a date or datetime value into a string for the EA API.
342
+ # Returns "YYYY-MM-DD" for date-only, "YYYY-MM-DDTHH:MM:SSZ" when time is present.
343
+ def parse_datetime(val)
344
+ case val
345
+ when Time
346
+ val.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
347
+ when DateTime
348
+ val.to_time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
349
+ when Date
350
+ val.strftime("%Y-%m-%d")
351
+ when String
352
+ s = val.strip
353
+ if s =~ /\A\d{4}-\d{2}-\d{2}\z/
354
+ s
355
+ elsif s =~ /\A\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/
356
+ require "time"
357
+ Time.parse(s).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
358
+ else
359
+ raise ArgumentError, "Invalid date/time: #{val.inspect}"
360
+ end
361
+ else
362
+ raise ArgumentError, "Cannot convert #{val.class} to date/time"
363
+ end
364
+ end
365
+
366
+ # Returns a YYYY-MM-DD string from a date/datetime — used for filenames.
367
+ def parse_date(d)
368
+ parse_datetime(d)[0, 10]
369
+ end
370
+
371
+ def label_of(field)
372
+ return nil if field.nil?
373
+ return field if field.is_a?(String)
374
+ Array(field).first.then { |f| f.is_a?(Hash) ? f["label"] : f }
375
+ end
376
+
377
+ # Extracts the path relative to BASE_URL from a full measure URI.
378
+ # "http://environment.data.gov.uk/hydrology/id/measures/abc" -> "/id/measures/abc"
379
+ def measure_path(id)
380
+ id.to_s.start_with?("http") ? URI(id).path.sub(%r{\A/hydrology}, "") : id.to_s
381
+ end
382
+ end
383
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmoEaHydrology
4
+ class Error < StandardError; end
5
+
6
+ class ApiError < Error
7
+ attr_reader :status
8
+
9
+ def initialize(msg, status: nil)
10
+ super(msg)
11
+ @status = status
12
+ end
13
+ end
14
+
15
+ class ParseError < Error; end
16
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmoEaHydrology
4
+ InventoryEntry = Struct.new(
5
+ :station_reference, # e.g. "589359"
6
+ :station_label, # station name
7
+ :lat, # WGS84 latitude
8
+ :long, # WGS84 longitude
9
+ :easting, # OSGB36 easting
10
+ :northing, # OSGB36 northing
11
+ :date_opened, # e.g. "1990-10-04"
12
+ :measure_id, # full measure URI
13
+ :period_name, # "15min"
14
+ :unit_name, # "mm"
15
+ :value_type, # "total"
16
+ :coverage_from, # Time of earliest available reading (nil if unknown)
17
+ :coverage_to, # Time of latest available reading (nil if unknown)
18
+ keyword_init: true
19
+ ) do
20
+ def coverage_from_s
21
+ coverage_from&.strftime("%Y-%m-%d %H:%M") || "unknown"
22
+ end
23
+
24
+ def coverage_to_s
25
+ coverage_to&.strftime("%Y-%m-%d %H:%M") || "unknown"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmoEaHydrology
4
+ Measure = Struct.new(
5
+ :id, # full URI @id (use this to fetch readings)
6
+ :label, # human-readable description
7
+ :station_reference, # parent station reference
8
+ :station_label, # parent station name
9
+ :period_name, # "15min"
10
+ :unit_name, # "mm"
11
+ :value_type, # "total"
12
+ :timeseries_id, # UUID
13
+ :coverage_from, # Time of earliest available reading (nil if unknown)
14
+ :coverage_to, # Time of latest available reading (nil if unknown)
15
+ keyword_init: true
16
+ )
17
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmoEaHydrology
4
+ Reading = Struct.new(
5
+ :datetime, # Time object
6
+ :value, # Float — rainfall in mm
7
+ :quality, # "Good" / "Estimated" / "Suspect" / "Unchecked" / "Missing"
8
+ :completeness, # "Complete" / "Incomplete"
9
+ keyword_init: true
10
+ )
11
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmoEaHydrology
4
+ Station = Struct.new(
5
+ :id, # full URI @id
6
+ :label, # station name
7
+ :station_reference, # e.g. "589359"
8
+ :wiski_id, # WISKI system ID
9
+ :easting, # OSGB36 easting
10
+ :northing, # OSGB36 northing
11
+ :lat, # WGS84 latitude
12
+ :long, # WGS84 longitude
13
+ :date_opened, # e.g. "1990-10-04"
14
+ :status, # "Active" / "Closed"
15
+ :measure_id, # full URI of the 15-min rainfall measure
16
+ :measure_label, # human-readable measure description
17
+ :coverage_from, # Time of earliest available reading (nil if not fetched)
18
+ :coverage_to, # Time of latest available reading (nil if not fetched)
19
+ keyword_init: true
20
+ ) do
21
+ def coverage_from_s
22
+ coverage_from&.strftime("%Y-%m-%d %H:%M") || "unknown"
23
+ end
24
+
25
+ def coverage_to_s
26
+ coverage_to&.strftime("%Y-%m-%d %H:%M") || "unknown"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmoEaHydrology
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "smo_ea_hydrology/version"
4
+ require_relative "smo_ea_hydrology/errors"
5
+ require_relative "smo_ea_hydrology/station"
6
+ require_relative "smo_ea_hydrology/measure"
7
+ require_relative "smo_ea_hydrology/reading"
8
+ require_relative "smo_ea_hydrology/inventory_entry"
9
+ require_relative "smo_ea_hydrology/client"
10
+
11
+ module SmoEaHydrology
12
+ # Convenience — build a default client.
13
+ def self.client
14
+ Client.new
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: smo_ea_hydrology
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Sebastian Madrid Ontiveros
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-05-04 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ Developed by Sebastian Madrid Ontiveros. Pure Ruby client for the Environment Agency
14
+ Hydrology API (environment.data.gov.uk/hydrology). Fetches active rainfall stations,
15
+ 15-minute rainfall measures, and timestamped readings over any date range.
16
+ No external dependencies. Uses only Ruby stdlib (net/http, uri, json, date).
17
+ Built to support hydraulic modelling and flood risk workflows in the UK.
18
+ Compatible with InfoWorks ICM 2027 embedded Ruby.
19
+ If this gem saves you time, consider buying Sebastian a coffee at
20
+ https://buymeacoffee.com/smadrid
21
+ email: []
22
+ executables: []
23
+ extensions: []
24
+ extra_rdoc_files: []
25
+ files:
26
+ - lib/smo_ea_hydrology.rb
27
+ - lib/smo_ea_hydrology/client.rb
28
+ - lib/smo_ea_hydrology/errors.rb
29
+ - lib/smo_ea_hydrology/inventory_entry.rb
30
+ - lib/smo_ea_hydrology/measure.rb
31
+ - lib/smo_ea_hydrology/reading.rb
32
+ - lib/smo_ea_hydrology/station.rb
33
+ - lib/smo_ea_hydrology/version.rb
34
+ homepage: https://github.com/Sebasmadridmx/smo_ea_hydrology
35
+ licenses:
36
+ - MIT
37
+ metadata: {}
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 2.7.0
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 3.6.2
53
+ specification_version: 4
54
+ summary: Environment Agency Hydrology API client for 15-min rainfall data.
55
+ test_files: []