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 +7 -0
- data/lib/smo_ea_hydrology/client.rb +383 -0
- data/lib/smo_ea_hydrology/errors.rb +16 -0
- data/lib/smo_ea_hydrology/inventory_entry.rb +28 -0
- data/lib/smo_ea_hydrology/measure.rb +17 -0
- data/lib/smo_ea_hydrology/reading.rb +11 -0
- data/lib/smo_ea_hydrology/station.rb +29 -0
- data/lib/smo_ea_hydrology/version.rb +5 -0
- data/lib/smo_ea_hydrology.rb +16 -0
- metadata +55 -0
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,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: []
|