nws 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/LICENSE +24 -0
- data/README.md +55 -0
- data/bin/nws +429 -0
- data/lib/nws/alert.rb +267 -0
- data/lib/nws/client.rb +113 -0
- data/lib/nws/errors.rb +36 -0
- data/lib/nws/forecast.rb +207 -0
- data/lib/nws/geocoder.rb +276 -0
- data/lib/nws/hourly_forecast.rb +209 -0
- data/lib/nws/observation.rb +204 -0
- data/lib/nws/point.rb +166 -0
- data/lib/nws/station.rb +84 -0
- data/lib/nws/version.rb +3 -0
- data/lib/nws.rb +109 -0
- metadata +98 -0
data/lib/nws/geocoder.rb
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module NWS
|
|
9
|
+
# Geocoder for converting location names to coordinates using OpenStreetMap Nominatim
|
|
10
|
+
#
|
|
11
|
+
# Implements rate limiting (1 request/second) and caching (7-day TTL) to comply
|
|
12
|
+
# with Nominatim usage policy.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# geocoder = NWS::Geocoder.new
|
|
16
|
+
# result = geocoder.geocode("Denver, CO")
|
|
17
|
+
# puts result.latitude # => 39.7392
|
|
18
|
+
# puts result.longitude # => -104.9903
|
|
19
|
+
class Geocoder
|
|
20
|
+
# @return [String] Nominatim API endpoint URL
|
|
21
|
+
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
|
22
|
+
|
|
23
|
+
# @return [Float] Minimum seconds between API requests
|
|
24
|
+
MIN_REQUEST_INTERVAL = 1.0
|
|
25
|
+
|
|
26
|
+
# @return [Integer] Cache time-to-live in seconds (7 days)
|
|
27
|
+
CACHE_TTL = 86400 * 7
|
|
28
|
+
|
|
29
|
+
# @return [String] Attribution text required by ODbL license
|
|
30
|
+
ATTRIBUTION = "Geocoding data © OpenStreetMap contributors (ODbL)".freeze
|
|
31
|
+
|
|
32
|
+
@last_request_time = nil
|
|
33
|
+
@cache = {}
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# @return [Time, nil] Timestamp of the last API request
|
|
37
|
+
attr_accessor :last_request_time
|
|
38
|
+
|
|
39
|
+
# @return [Hash] In-memory cache storage
|
|
40
|
+
attr_accessor :cache
|
|
41
|
+
|
|
42
|
+
# Enforce rate limiting by sleeping if necessary
|
|
43
|
+
#
|
|
44
|
+
# Ensures at least MIN_REQUEST_INTERVAL seconds between API requests
|
|
45
|
+
# to comply with Nominatim usage policy.
|
|
46
|
+
#
|
|
47
|
+
# @return [void]
|
|
48
|
+
def rate_limit!
|
|
49
|
+
if last_request_time
|
|
50
|
+
elapsed = Time.now - last_request_time
|
|
51
|
+
if elapsed < MIN_REQUEST_INTERVAL
|
|
52
|
+
sleep(MIN_REQUEST_INTERVAL - elapsed)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
self.last_request_time = Time.now
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get the cache directory path
|
|
59
|
+
#
|
|
60
|
+
# @return [String] Path to the cache directory (~/.cache/nws by default)
|
|
61
|
+
def cache_dir
|
|
62
|
+
@cache_dir ||= File.join(Dir.home, ".cache", "nws")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Set the cache directory path
|
|
66
|
+
#
|
|
67
|
+
# @param dir [String] New cache directory path
|
|
68
|
+
# @return [String] The new cache directory path
|
|
69
|
+
def cache_dir=(dir)
|
|
70
|
+
@cache_dir = dir
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Ensure the cache directory exists
|
|
74
|
+
#
|
|
75
|
+
# @return [void]
|
|
76
|
+
def ensure_cache_dir
|
|
77
|
+
FileUtils.mkdir_p(cache_dir) unless File.directory?(cache_dir)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Generate the cache file path for a query
|
|
81
|
+
#
|
|
82
|
+
# @param query [String] The geocoding query
|
|
83
|
+
# @return [String] Path to the cache file for this query
|
|
84
|
+
def cache_path(query)
|
|
85
|
+
safe_name = query.downcase.gsub(/[^a-z0-9]+/, "_")[0..50]
|
|
86
|
+
File.join(cache_dir, "geocode_#{safe_name}.json")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Read a cached geocoding result
|
|
90
|
+
#
|
|
91
|
+
# @param query [String] The geocoding query
|
|
92
|
+
# @return [Hash, nil] Cached result data or nil if not found or expired
|
|
93
|
+
def read_cache(query)
|
|
94
|
+
path = cache_path(query)
|
|
95
|
+
return nil unless File.exist?(path)
|
|
96
|
+
|
|
97
|
+
data = JSON.parse(File.read(path))
|
|
98
|
+
cached_at = Time.parse(data["cached_at"])
|
|
99
|
+
|
|
100
|
+
if Time.now - cached_at > CACHE_TTL
|
|
101
|
+
File.delete(path) rescue nil
|
|
102
|
+
return nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
data["result"]
|
|
106
|
+
rescue JSON::ParserError, ArgumentError
|
|
107
|
+
File.delete(path) rescue nil
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Write a geocoding result to the cache
|
|
112
|
+
#
|
|
113
|
+
# @param query [String] The geocoding query
|
|
114
|
+
# @param result [GeocodingResult] The result to cache
|
|
115
|
+
# @return [void]
|
|
116
|
+
def write_cache(query, result)
|
|
117
|
+
ensure_cache_dir
|
|
118
|
+
path = cache_path(query)
|
|
119
|
+
data = {
|
|
120
|
+
"cached_at" => Time.now.iso8601,
|
|
121
|
+
"query" => query,
|
|
122
|
+
"result" => {
|
|
123
|
+
"latitude" => result.latitude,
|
|
124
|
+
"longitude" => result.longitude,
|
|
125
|
+
"display_name" => result.display_name,
|
|
126
|
+
"place_type" => result.place_type
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
File.write(path, JSON.pretty_generate(data))
|
|
130
|
+
rescue StandardError
|
|
131
|
+
# Ignore cache write failures
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Get the required attribution text
|
|
135
|
+
#
|
|
136
|
+
# @return [String] Attribution text for OpenStreetMap/ODbL
|
|
137
|
+
def attribution
|
|
138
|
+
ATTRIBUTION
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Initialize a new Geocoder instance
|
|
143
|
+
#
|
|
144
|
+
# @param user_agent [String, nil] User-Agent header for API requests
|
|
145
|
+
def initialize(user_agent: nil)
|
|
146
|
+
@user_agent = user_agent || "(nws-ruby-gem, #{NWS::VERSION})"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Geocode a location query to coordinates
|
|
150
|
+
#
|
|
151
|
+
# @param query [String] Location name or address to geocode
|
|
152
|
+
# @return [GeocodingResult] Result containing coordinates and metadata
|
|
153
|
+
# @raise [APIError] if the API request fails
|
|
154
|
+
# @raise [NotFoundError] if no results are found for the query
|
|
155
|
+
def geocode(query)
|
|
156
|
+
# Check cache first
|
|
157
|
+
if (cached = self.class.read_cache(query))
|
|
158
|
+
return GeocodingResult.new(
|
|
159
|
+
latitude: cached["latitude"],
|
|
160
|
+
longitude: cached["longitude"],
|
|
161
|
+
display_name: cached["display_name"],
|
|
162
|
+
place_type: cached["place_type"],
|
|
163
|
+
from_cache: true
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Rate limit before making request
|
|
168
|
+
self.class.rate_limit!
|
|
169
|
+
|
|
170
|
+
uri = URI.parse(NOMINATIM_URL)
|
|
171
|
+
uri.query = URI.encode_www_form(format: "json", q: query, limit: 1)
|
|
172
|
+
|
|
173
|
+
request = Net::HTTP::Get.new(uri)
|
|
174
|
+
request["User-Agent"] = @user_agent
|
|
175
|
+
request["Accept"] = "application/json"
|
|
176
|
+
|
|
177
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
178
|
+
http.use_ssl = true
|
|
179
|
+
http.open_timeout = 10
|
|
180
|
+
http.read_timeout = 30
|
|
181
|
+
|
|
182
|
+
response = http.request(request)
|
|
183
|
+
|
|
184
|
+
unless response.code.to_i == 200
|
|
185
|
+
raise APIError.new("Geocoding failed: #{response.code}", status_code: response.code.to_i)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
results = JSON.parse(response.body)
|
|
189
|
+
|
|
190
|
+
if results.empty?
|
|
191
|
+
raise NotFoundError.new("Location not found: #{query}")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
result_data = results.first
|
|
195
|
+
lat = result_data["lat"].to_f.round(4)
|
|
196
|
+
lon = result_data["lon"].to_f.round(4)
|
|
197
|
+
|
|
198
|
+
result = GeocodingResult.new(
|
|
199
|
+
latitude: lat,
|
|
200
|
+
longitude: lon,
|
|
201
|
+
display_name: result_data["display_name"],
|
|
202
|
+
place_type: result_data["type"],
|
|
203
|
+
from_cache: false
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Cache the result
|
|
207
|
+
self.class.write_cache(query, result)
|
|
208
|
+
|
|
209
|
+
result
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Represents a geocoding result with coordinates and metadata
|
|
214
|
+
class GeocodingResult
|
|
215
|
+
# @return [Float] Latitude of the location
|
|
216
|
+
attr_reader :latitude
|
|
217
|
+
|
|
218
|
+
# @return [Float] Longitude of the location
|
|
219
|
+
attr_reader :longitude
|
|
220
|
+
|
|
221
|
+
# @return [String] Full display name of the location
|
|
222
|
+
attr_reader :display_name
|
|
223
|
+
|
|
224
|
+
# @return [String] Type of place (e.g., "city", "administrative")
|
|
225
|
+
attr_reader :place_type
|
|
226
|
+
|
|
227
|
+
# @return [Boolean] Whether this result was retrieved from cache
|
|
228
|
+
attr_reader :from_cache
|
|
229
|
+
|
|
230
|
+
# @return [String] Attribution text required by ODbL license
|
|
231
|
+
ATTRIBUTION = Geocoder::ATTRIBUTION
|
|
232
|
+
|
|
233
|
+
# Initialize a new GeocodingResult
|
|
234
|
+
#
|
|
235
|
+
# @param latitude [Float] Latitude of the location
|
|
236
|
+
# @param longitude [Float] Longitude of the location
|
|
237
|
+
# @param display_name [String] Full display name of the location
|
|
238
|
+
# @param place_type [String] Type of place
|
|
239
|
+
# @param from_cache [Boolean] Whether this result was retrieved from cache
|
|
240
|
+
def initialize(latitude:, longitude:, display_name:, place_type:, from_cache: false)
|
|
241
|
+
@latitude = latitude
|
|
242
|
+
@longitude = longitude
|
|
243
|
+
@display_name = display_name
|
|
244
|
+
@place_type = place_type
|
|
245
|
+
@from_cache = from_cache
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Check if this result was retrieved from cache
|
|
249
|
+
#
|
|
250
|
+
# @return [Boolean] True if from cache
|
|
251
|
+
def from_cache?
|
|
252
|
+
@from_cache
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Convert to array of [latitude, longitude]
|
|
256
|
+
#
|
|
257
|
+
# @return [Array<Float>] Coordinate pair as [latitude, longitude]
|
|
258
|
+
def to_a
|
|
259
|
+
[latitude, longitude]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Get a formatted string representation
|
|
263
|
+
#
|
|
264
|
+
# @return [String] Coordinates and display name
|
|
265
|
+
def to_s
|
|
266
|
+
"#{latitude}, #{longitude} (#{display_name})"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Get the required attribution text
|
|
270
|
+
#
|
|
271
|
+
# @return [String] Attribution text for OpenStreetMap/ODbL
|
|
272
|
+
def attribution
|
|
273
|
+
ATTRIBUTION
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NWS
|
|
4
|
+
# Represents an hourly weather forecast
|
|
5
|
+
class HourlyForecast
|
|
6
|
+
# @return [Point, nil] The point this forecast is for
|
|
7
|
+
attr_reader :point
|
|
8
|
+
|
|
9
|
+
# @return [Time, nil] When the forecast was last updated
|
|
10
|
+
attr_reader :updated_at
|
|
11
|
+
|
|
12
|
+
# @return [Array<HourlyPeriod>] Hourly forecast periods
|
|
13
|
+
attr_reader :periods
|
|
14
|
+
|
|
15
|
+
# @return [Hash] Raw API response data
|
|
16
|
+
attr_reader :raw_data
|
|
17
|
+
|
|
18
|
+
# Initialize an HourlyForecast from API response data
|
|
19
|
+
#
|
|
20
|
+
# @param data [Hash] Parsed JSON response from the hourly forecast API
|
|
21
|
+
# @param point [Point, nil] The point this forecast is for
|
|
22
|
+
def initialize(data, point: nil)
|
|
23
|
+
@point = point
|
|
24
|
+
@raw_data = data
|
|
25
|
+
parse_data(data)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get the next N hours of forecast data
|
|
29
|
+
#
|
|
30
|
+
# @param count [Integer] Number of hours to return (default: 24)
|
|
31
|
+
# @return [Array<HourlyPeriod>] Array of hourly periods
|
|
32
|
+
def next_hours(count = 24)
|
|
33
|
+
@periods.first(count)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Group forecast periods by day
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash{String => Array<HourlyPeriod>}] Periods grouped by date (YYYY-MM-DD)
|
|
39
|
+
def by_day
|
|
40
|
+
@periods.group_by { |p| p.start_time.strftime("%Y-%m-%d") }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get a formatted string representation of the hourly forecast
|
|
44
|
+
#
|
|
45
|
+
# @param hours [Integer] Number of hours to display (default: 24)
|
|
46
|
+
# @return [String] Multi-line formatted hourly forecast
|
|
47
|
+
def to_s(hours: 24)
|
|
48
|
+
lines = []
|
|
49
|
+
lines << "Hourly Forecast for #{point&.location_string || 'Unknown Location'}"
|
|
50
|
+
lines << "Updated: #{updated_at&.strftime('%Y-%m-%d %I:%M %p')}"
|
|
51
|
+
lines << ""
|
|
52
|
+
|
|
53
|
+
by_day.each do |date, periods|
|
|
54
|
+
lines << date_label(periods.first.start_time)
|
|
55
|
+
|
|
56
|
+
periods.first(hours).each do |period|
|
|
57
|
+
time_str = period.start_time.strftime("%-l%P")
|
|
58
|
+
lines << " #{time_str.rjust(4)}: #{period.temperature}°#{period.temperature_unit} - #{period.short_forecast}"
|
|
59
|
+
hours -= 1
|
|
60
|
+
break if hours <= 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
lines << ""
|
|
64
|
+
break if hours <= 0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
lines.join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Parse API response data into instance variables
|
|
73
|
+
#
|
|
74
|
+
# @param data [Hash] Parsed JSON response
|
|
75
|
+
# @return [void]
|
|
76
|
+
def parse_data(data)
|
|
77
|
+
props = data["properties"] || {}
|
|
78
|
+
@updated_at = parse_time(props["updateTime"])
|
|
79
|
+
|
|
80
|
+
raw_periods = props["periods"] || []
|
|
81
|
+
@periods = raw_periods.map { |p| HourlyPeriod.new(p) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Parse an ISO8601 time string
|
|
85
|
+
#
|
|
86
|
+
# @param str [String, nil] Time string
|
|
87
|
+
# @return [Time, nil] Parsed time or nil
|
|
88
|
+
def parse_time(str)
|
|
89
|
+
return nil unless str
|
|
90
|
+
Time.parse(str)
|
|
91
|
+
rescue ArgumentError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Get a human-readable label for a date
|
|
96
|
+
#
|
|
97
|
+
# @param time [Time] Time to generate label for
|
|
98
|
+
# @return [String] Date label (e.g., "Today", "Tomorrow", "Friday, January 24")
|
|
99
|
+
def date_label(time)
|
|
100
|
+
today = Date.today
|
|
101
|
+
date = time.to_date
|
|
102
|
+
|
|
103
|
+
if date == today
|
|
104
|
+
"Today"
|
|
105
|
+
elsif date == today + 1
|
|
106
|
+
"Tomorrow"
|
|
107
|
+
else
|
|
108
|
+
time.strftime("%A, %B %-d")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Represents a single hourly forecast period
|
|
114
|
+
class HourlyPeriod
|
|
115
|
+
# @return [Integer] Period number
|
|
116
|
+
attr_reader :number
|
|
117
|
+
|
|
118
|
+
# @return [Time, nil] Start time of the period
|
|
119
|
+
attr_reader :start_time
|
|
120
|
+
|
|
121
|
+
# @return [Time, nil] End time of the period
|
|
122
|
+
attr_reader :end_time
|
|
123
|
+
|
|
124
|
+
# @return [Boolean] Whether this is a daytime period
|
|
125
|
+
attr_reader :is_daytime
|
|
126
|
+
|
|
127
|
+
# @return [Integer] Temperature value
|
|
128
|
+
attr_reader :temperature
|
|
129
|
+
|
|
130
|
+
# @return [String] Temperature unit (e.g., "F")
|
|
131
|
+
attr_reader :temperature_unit
|
|
132
|
+
|
|
133
|
+
# @return [String, nil] Temperature trend
|
|
134
|
+
attr_reader :temperature_trend
|
|
135
|
+
|
|
136
|
+
# @return [Integer, nil] Probability of precipitation (0-100)
|
|
137
|
+
attr_reader :probability_of_precipitation
|
|
138
|
+
|
|
139
|
+
# @return [String] Wind speed (e.g., "10 mph")
|
|
140
|
+
attr_reader :wind_speed
|
|
141
|
+
|
|
142
|
+
# @return [String] Wind direction (e.g., "NW")
|
|
143
|
+
attr_reader :wind_direction
|
|
144
|
+
|
|
145
|
+
# @return [String] Short forecast description
|
|
146
|
+
attr_reader :short_forecast
|
|
147
|
+
|
|
148
|
+
# @return [String] URL to forecast icon
|
|
149
|
+
attr_reader :icon
|
|
150
|
+
|
|
151
|
+
# @return [Hash] Raw API response data
|
|
152
|
+
attr_reader :raw_data
|
|
153
|
+
|
|
154
|
+
# Initialize an HourlyPeriod from API response data
|
|
155
|
+
#
|
|
156
|
+
# @param data [Hash] Parsed JSON period data
|
|
157
|
+
def initialize(data)
|
|
158
|
+
@raw_data = data
|
|
159
|
+
parse_data(data)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Check if this is a daytime period
|
|
163
|
+
#
|
|
164
|
+
# @return [Boolean] True if daytime period
|
|
165
|
+
def daytime?
|
|
166
|
+
@is_daytime
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Get a formatted string representation of the period
|
|
170
|
+
#
|
|
171
|
+
# @return [String] Formatted period string
|
|
172
|
+
def to_s
|
|
173
|
+
time_str = @start_time.strftime("%-l%P")
|
|
174
|
+
"#{time_str}: #{@temperature}°#{@temperature_unit} - #{@short_forecast}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# Parse API response data into instance variables
|
|
180
|
+
#
|
|
181
|
+
# @param data [Hash] Parsed JSON period data
|
|
182
|
+
# @return [void]
|
|
183
|
+
def parse_data(data)
|
|
184
|
+
@number = data["number"]
|
|
185
|
+
@start_time = parse_time(data["startTime"])
|
|
186
|
+
@end_time = parse_time(data["endTime"])
|
|
187
|
+
@is_daytime = data["isDaytime"]
|
|
188
|
+
@temperature = data["temperature"]
|
|
189
|
+
@temperature_unit = data["temperatureUnit"]
|
|
190
|
+
@temperature_trend = data["temperatureTrend"]
|
|
191
|
+
@probability_of_precipitation = data.dig("probabilityOfPrecipitation", "value")
|
|
192
|
+
@wind_speed = data["windSpeed"]
|
|
193
|
+
@wind_direction = data["windDirection"]
|
|
194
|
+
@short_forecast = data["shortForecast"]
|
|
195
|
+
@icon = data["icon"]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Parse an ISO8601 time string
|
|
199
|
+
#
|
|
200
|
+
# @param str [String, nil] Time string
|
|
201
|
+
# @return [Time, nil] Parsed time or nil
|
|
202
|
+
def parse_time(str)
|
|
203
|
+
return nil unless str
|
|
204
|
+
Time.parse(str)
|
|
205
|
+
rescue ArgumentError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NWS
|
|
4
|
+
# Represents a weather observation from a station
|
|
5
|
+
class Observation
|
|
6
|
+
# @return [Station, nil] The station this observation is from
|
|
7
|
+
attr_reader :station
|
|
8
|
+
|
|
9
|
+
# @return [Time, nil] Timestamp of the observation
|
|
10
|
+
attr_reader :timestamp
|
|
11
|
+
|
|
12
|
+
# @return [String, nil] Text description of conditions (e.g., "Partly Cloudy")
|
|
13
|
+
attr_reader :text_description
|
|
14
|
+
|
|
15
|
+
# @return [Hash] Raw API response data
|
|
16
|
+
attr_reader :raw_data
|
|
17
|
+
|
|
18
|
+
# @return [Float, nil] Temperature in Celsius
|
|
19
|
+
attr_reader :temperature_c
|
|
20
|
+
|
|
21
|
+
# @return [Float, nil] Dewpoint in Celsius
|
|
22
|
+
attr_reader :dewpoint_c
|
|
23
|
+
|
|
24
|
+
# @return [Float, nil] Relative humidity percentage
|
|
25
|
+
attr_reader :relative_humidity
|
|
26
|
+
|
|
27
|
+
# @return [Float, nil] Wind speed in km/h
|
|
28
|
+
attr_reader :wind_speed_kmh
|
|
29
|
+
|
|
30
|
+
# @return [Integer, nil] Wind direction in degrees
|
|
31
|
+
attr_reader :wind_direction
|
|
32
|
+
|
|
33
|
+
# @return [Float, nil] Wind gust speed in km/h
|
|
34
|
+
attr_reader :wind_gust_kmh
|
|
35
|
+
|
|
36
|
+
# @return [Float, nil] Barometric pressure in Pascals
|
|
37
|
+
attr_reader :barometric_pressure
|
|
38
|
+
|
|
39
|
+
# @return [Float, nil] Visibility in meters
|
|
40
|
+
attr_reader :visibility_m
|
|
41
|
+
|
|
42
|
+
# @return [Float, nil] Heat index in Celsius
|
|
43
|
+
attr_reader :heat_index_c
|
|
44
|
+
|
|
45
|
+
# @return [Float, nil] Wind chill in Celsius
|
|
46
|
+
attr_reader :wind_chill_c
|
|
47
|
+
|
|
48
|
+
# Initialize an Observation from API response data
|
|
49
|
+
#
|
|
50
|
+
# @param data [Hash] Parsed JSON response from the observations API
|
|
51
|
+
# @param station [Station, nil] The station this observation is from
|
|
52
|
+
def initialize(data, station: nil)
|
|
53
|
+
@station = station
|
|
54
|
+
@raw_data = data
|
|
55
|
+
parse_data(data)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get temperature in Fahrenheit
|
|
59
|
+
#
|
|
60
|
+
# @return [Float, nil] Temperature in Fahrenheit
|
|
61
|
+
def temperature_f
|
|
62
|
+
celsius_to_fahrenheit(@temperature_c)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get dewpoint in Fahrenheit
|
|
66
|
+
#
|
|
67
|
+
# @return [Float, nil] Dewpoint in Fahrenheit
|
|
68
|
+
def dewpoint_f
|
|
69
|
+
celsius_to_fahrenheit(@dewpoint_c)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get heat index in Fahrenheit
|
|
73
|
+
#
|
|
74
|
+
# @return [Float, nil] Heat index in Fahrenheit
|
|
75
|
+
def heat_index_f
|
|
76
|
+
celsius_to_fahrenheit(@heat_index_c)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Get wind chill in Fahrenheit
|
|
80
|
+
#
|
|
81
|
+
# @return [Float, nil] Wind chill in Fahrenheit
|
|
82
|
+
def wind_chill_f
|
|
83
|
+
celsius_to_fahrenheit(@wind_chill_c)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get wind speed in mph
|
|
87
|
+
#
|
|
88
|
+
# @return [Float, nil] Wind speed in mph
|
|
89
|
+
def wind_speed_mph
|
|
90
|
+
kmh_to_mph(@wind_speed_kmh)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Get wind gust speed in mph
|
|
94
|
+
#
|
|
95
|
+
# @return [Float, nil] Wind gust speed in mph
|
|
96
|
+
def wind_gust_mph
|
|
97
|
+
kmh_to_mph(@wind_gust_kmh)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Get visibility in miles
|
|
101
|
+
#
|
|
102
|
+
# @return [Float, nil] Visibility in miles
|
|
103
|
+
def visibility_miles
|
|
104
|
+
return nil unless @visibility_m
|
|
105
|
+
(@visibility_m / 1609.34).round(2)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get barometric pressure in inches of mercury
|
|
109
|
+
#
|
|
110
|
+
# @return [Float, nil] Pressure in inHg
|
|
111
|
+
def barometric_pressure_inhg
|
|
112
|
+
return nil unless @barometric_pressure
|
|
113
|
+
(@barometric_pressure / 3386.39).round(2)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get a brief summary of current conditions
|
|
117
|
+
#
|
|
118
|
+
# @return [String] Summary string (e.g., "72°F, Partly Cloudy")
|
|
119
|
+
def summary
|
|
120
|
+
parts = []
|
|
121
|
+
parts << "#{temperature_f.round(0)}°F" if temperature_f
|
|
122
|
+
parts << text_description if text_description
|
|
123
|
+
parts.join(", ")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Get a formatted string representation of the observation
|
|
127
|
+
#
|
|
128
|
+
# @return [String] Multi-line formatted string
|
|
129
|
+
def to_s
|
|
130
|
+
lines = []
|
|
131
|
+
lines << "Current Conditions (#{timestamp&.strftime('%Y-%m-%d %I:%M %p')})"
|
|
132
|
+
lines << " Temperature: #{temperature_f&.round(1)}°F" if temperature_f
|
|
133
|
+
lines << " Conditions: #{text_description}" if text_description
|
|
134
|
+
lines << " Humidity: #{relative_humidity&.round(0)}%" if relative_humidity
|
|
135
|
+
lines << " Dewpoint: #{dewpoint_f&.round(1)}°F" if dewpoint_f
|
|
136
|
+
lines << " Wind: #{wind_direction}° at #{wind_speed_mph&.round(1)} mph" if wind_speed_mph
|
|
137
|
+
lines << " Wind Gusts: #{wind_gust_mph&.round(1)} mph" if wind_gust_mph
|
|
138
|
+
lines << " Visibility: #{visibility_miles} miles" if visibility_miles
|
|
139
|
+
lines << " Pressure: #{barometric_pressure_inhg} inHg" if barometric_pressure_inhg
|
|
140
|
+
lines.join("\n")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# Parse API response data into instance variables
|
|
146
|
+
#
|
|
147
|
+
# @param data [Hash] Parsed JSON response
|
|
148
|
+
# @return [void]
|
|
149
|
+
def parse_data(data)
|
|
150
|
+
props = data["properties"] || {}
|
|
151
|
+
|
|
152
|
+
@timestamp = parse_time(props["timestamp"])
|
|
153
|
+
@text_description = props["textDescription"]
|
|
154
|
+
@temperature_c = extract_value(props["temperature"])
|
|
155
|
+
@dewpoint_c = extract_value(props["dewpoint"])
|
|
156
|
+
@relative_humidity = extract_value(props["relativeHumidity"])
|
|
157
|
+
@wind_speed_kmh = extract_value(props["windSpeed"])
|
|
158
|
+
@wind_direction = extract_value(props["windDirection"])
|
|
159
|
+
@wind_gust_kmh = extract_value(props["windGust"])
|
|
160
|
+
@barometric_pressure = extract_value(props["barometricPressure"])
|
|
161
|
+
@visibility_m = extract_value(props["visibility"])
|
|
162
|
+
@heat_index_c = extract_value(props["heatIndex"])
|
|
163
|
+
@wind_chill_c = extract_value(props["windChill"])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Extract the value from a measurement object
|
|
167
|
+
#
|
|
168
|
+
# @param measurement [Hash, nil] Measurement object with "value" key
|
|
169
|
+
# @return [Float, nil] The extracted value
|
|
170
|
+
def extract_value(measurement)
|
|
171
|
+
return nil unless measurement.is_a?(Hash)
|
|
172
|
+
measurement["value"]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Parse an ISO8601 time string
|
|
176
|
+
#
|
|
177
|
+
# @param str [String, nil] Time string
|
|
178
|
+
# @return [Time, nil] Parsed time or nil
|
|
179
|
+
def parse_time(str)
|
|
180
|
+
return nil unless str
|
|
181
|
+
Time.parse(str)
|
|
182
|
+
rescue ArgumentError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Convert Celsius to Fahrenheit
|
|
187
|
+
#
|
|
188
|
+
# @param c [Float, nil] Temperature in Celsius
|
|
189
|
+
# @return [Float, nil] Temperature in Fahrenheit
|
|
190
|
+
def celsius_to_fahrenheit(c)
|
|
191
|
+
return nil unless c
|
|
192
|
+
(c * 9.0 / 5.0) + 32
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Convert km/h to mph
|
|
196
|
+
#
|
|
197
|
+
# @param kmh [Float, nil] Speed in km/h
|
|
198
|
+
# @return [Float, nil] Speed in mph
|
|
199
|
+
def kmh_to_mph(kmh)
|
|
200
|
+
return nil unless kmh
|
|
201
|
+
kmh * 0.621371
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|