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/alert.rb
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NWS
|
|
4
|
+
# Represents a weather alert from the NWS
|
|
5
|
+
class Alert
|
|
6
|
+
# @return [String, nil] Alert identifier
|
|
7
|
+
attr_reader :id
|
|
8
|
+
|
|
9
|
+
# @return [String, nil] Description of affected areas
|
|
10
|
+
attr_reader :area_desc
|
|
11
|
+
|
|
12
|
+
# @return [Time, nil] When the alert was sent
|
|
13
|
+
attr_reader :sent
|
|
14
|
+
|
|
15
|
+
# @return [Time, nil] When the alert becomes effective
|
|
16
|
+
attr_reader :effective
|
|
17
|
+
|
|
18
|
+
# @return [Time, nil] When the alert conditions begin
|
|
19
|
+
attr_reader :onset
|
|
20
|
+
|
|
21
|
+
# @return [Time, nil] When the alert expires
|
|
22
|
+
attr_reader :expires
|
|
23
|
+
|
|
24
|
+
# @return [Time, nil] When the alert conditions end
|
|
25
|
+
attr_reader :ends
|
|
26
|
+
|
|
27
|
+
# @return [String, nil] Alert status (e.g., "Actual", "Test")
|
|
28
|
+
attr_reader :status
|
|
29
|
+
|
|
30
|
+
# @return [String, nil] Message type (e.g., "Alert", "Update", "Cancel")
|
|
31
|
+
attr_reader :message_type
|
|
32
|
+
|
|
33
|
+
# @return [String, nil] Severity level (e.g., "Extreme", "Severe", "Moderate", "Minor")
|
|
34
|
+
attr_reader :severity
|
|
35
|
+
|
|
36
|
+
# @return [String, nil] Certainty level (e.g., "Observed", "Likely", "Possible")
|
|
37
|
+
attr_reader :certainty
|
|
38
|
+
|
|
39
|
+
# @return [String, nil] Urgency level (e.g., "Immediate", "Expected", "Future")
|
|
40
|
+
attr_reader :urgency
|
|
41
|
+
|
|
42
|
+
# @return [String, nil] Event type (e.g., "Tornado Warning", "Winter Storm Watch")
|
|
43
|
+
attr_reader :event
|
|
44
|
+
|
|
45
|
+
# @return [String, nil] Name of the sending organization
|
|
46
|
+
attr_reader :sender_name
|
|
47
|
+
|
|
48
|
+
# @return [String, nil] Alert headline
|
|
49
|
+
attr_reader :headline
|
|
50
|
+
|
|
51
|
+
# @return [String, nil] Detailed description of the alert
|
|
52
|
+
attr_reader :description
|
|
53
|
+
|
|
54
|
+
# @return [String, nil] Instructions for the public
|
|
55
|
+
attr_reader :instruction
|
|
56
|
+
|
|
57
|
+
# @return [String, nil] Recommended response type
|
|
58
|
+
attr_reader :response
|
|
59
|
+
|
|
60
|
+
# @return [Array<String>] URLs of affected forecast zones
|
|
61
|
+
attr_reader :affected_zones
|
|
62
|
+
|
|
63
|
+
# @return [Hash] Raw API response data
|
|
64
|
+
attr_reader :raw_data
|
|
65
|
+
|
|
66
|
+
# Initialize an Alert from API response data
|
|
67
|
+
#
|
|
68
|
+
# @param data [Hash] Parsed JSON response from the alerts API
|
|
69
|
+
def initialize(data)
|
|
70
|
+
@raw_data = data
|
|
71
|
+
parse_data(data)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Fetch alerts from the NWS API
|
|
75
|
+
#
|
|
76
|
+
# @param state [String, nil] Two-letter state code to filter alerts
|
|
77
|
+
# @param point [Array<Float>, nil] Latitude and longitude pair [lat, lon]
|
|
78
|
+
# @param zone [String, nil] NWS zone identifier
|
|
79
|
+
# @param active [Boolean] Whether to fetch only active alerts
|
|
80
|
+
# @param client [Client] NWS API client instance
|
|
81
|
+
# @return [Array<Alert>] Array of Alert objects
|
|
82
|
+
def self.fetch(state: nil, point: nil, zone: nil, active: true, client: NWS.client)
|
|
83
|
+
params = {}
|
|
84
|
+
params[:status] = "actual" if active
|
|
85
|
+
|
|
86
|
+
path = if state
|
|
87
|
+
params[:area] = state.upcase
|
|
88
|
+
"/alerts/active"
|
|
89
|
+
elsif point
|
|
90
|
+
params[:point] = "#{point[0]},#{point[1]}"
|
|
91
|
+
"/alerts/active"
|
|
92
|
+
elsif zone
|
|
93
|
+
"/alerts/active/zone/#{zone}"
|
|
94
|
+
else
|
|
95
|
+
"/alerts/active"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
data = client.get(path, params: params)
|
|
99
|
+
features = data["features"] || []
|
|
100
|
+
features.map { |f| new(f) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Fetch all available alert types
|
|
104
|
+
#
|
|
105
|
+
# @param client [Client] NWS API client instance
|
|
106
|
+
# @return [Array<String>] Array of alert event type names
|
|
107
|
+
def self.types(client: NWS.client)
|
|
108
|
+
data = client.get("/alerts/types")
|
|
109
|
+
data["eventTypes"] || []
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Check if the alert is currently active
|
|
113
|
+
#
|
|
114
|
+
# @return [Boolean] True if the alert has not yet expired
|
|
115
|
+
def active?
|
|
116
|
+
return false unless @expires
|
|
117
|
+
Time.now < @expires
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Check if this is a severe alert
|
|
121
|
+
#
|
|
122
|
+
# @return [Boolean] True if severity is "Extreme" or "Severe"
|
|
123
|
+
def severe?
|
|
124
|
+
%w[Extreme Severe].include?(@severity)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Check if this is a warning
|
|
128
|
+
#
|
|
129
|
+
# @return [Boolean] True if the event name contains "warning"
|
|
130
|
+
def warning?
|
|
131
|
+
!!@event&.downcase&.include?("warning")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check if this is a watch
|
|
135
|
+
#
|
|
136
|
+
# @return [Boolean] True if the event name contains "watch"
|
|
137
|
+
def watch?
|
|
138
|
+
!!@event&.downcase&.include?("watch")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Check if this is an advisory
|
|
142
|
+
#
|
|
143
|
+
# @return [Boolean] True if the event name contains "advisory"
|
|
144
|
+
def advisory?
|
|
145
|
+
!!@event&.downcase&.include?("advisory")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Get a formatted string representation of the alert
|
|
149
|
+
#
|
|
150
|
+
# @return [String] Multi-line formatted alert summary
|
|
151
|
+
def to_s
|
|
152
|
+
lines = []
|
|
153
|
+
lines << "#{@event}"
|
|
154
|
+
lines << " Severity: #{@severity} | Urgency: #{@urgency} | Certainty: #{@certainty}"
|
|
155
|
+
lines << " Areas: #{@area_desc}"
|
|
156
|
+
lines << " Effective: #{@effective&.strftime('%Y-%m-%d %I:%M %p')}"
|
|
157
|
+
lines << " Expires: #{@expires&.strftime('%Y-%m-%d %I:%M %p')}"
|
|
158
|
+
lines << ""
|
|
159
|
+
lines << " #{@headline}"
|
|
160
|
+
lines.join("\n")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Get a detailed formatted string representation of the alert
|
|
164
|
+
#
|
|
165
|
+
# @return [String] Multi-line detailed alert with description and instructions
|
|
166
|
+
def detailed
|
|
167
|
+
lines = []
|
|
168
|
+
lines << "#{@event}"
|
|
169
|
+
lines << "=" * @event.length
|
|
170
|
+
lines << ""
|
|
171
|
+
lines << "Severity: #{@severity}"
|
|
172
|
+
lines << "Urgency: #{@urgency}"
|
|
173
|
+
lines << "Certainty: #{@certainty}"
|
|
174
|
+
lines << ""
|
|
175
|
+
lines << "Areas Affected:"
|
|
176
|
+
lines << " #{@area_desc}"
|
|
177
|
+
lines << ""
|
|
178
|
+
lines << "Timing:"
|
|
179
|
+
lines << " Effective: #{@effective&.strftime('%Y-%m-%d %I:%M %p')}"
|
|
180
|
+
lines << " Expires: #{@expires&.strftime('%Y-%m-%d %I:%M %p')}"
|
|
181
|
+
lines << ""
|
|
182
|
+
lines << "Headline:"
|
|
183
|
+
lines << " #{@headline}"
|
|
184
|
+
lines << ""
|
|
185
|
+
lines << "Description:"
|
|
186
|
+
wrap_text(@description, 80).each { |line| lines << " #{line}" }
|
|
187
|
+
if @instruction
|
|
188
|
+
lines << ""
|
|
189
|
+
lines << "Instructions:"
|
|
190
|
+
wrap_text(@instruction, 80).each { |line| lines << " #{line}" }
|
|
191
|
+
end
|
|
192
|
+
lines.join("\n")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
# Parse API response data into instance variables
|
|
198
|
+
#
|
|
199
|
+
# @param data [Hash] Parsed JSON response
|
|
200
|
+
# @return [void]
|
|
201
|
+
def parse_data(data)
|
|
202
|
+
props = data["properties"] || {}
|
|
203
|
+
|
|
204
|
+
@id = props["id"]
|
|
205
|
+
@area_desc = props["areaDesc"]
|
|
206
|
+
@sent = parse_time(props["sent"])
|
|
207
|
+
@effective = parse_time(props["effective"])
|
|
208
|
+
@onset = parse_time(props["onset"])
|
|
209
|
+
@expires = parse_time(props["expires"])
|
|
210
|
+
@ends = parse_time(props["ends"])
|
|
211
|
+
@status = props["status"]
|
|
212
|
+
@message_type = props["messageType"]
|
|
213
|
+
@severity = props["severity"]
|
|
214
|
+
@certainty = props["certainty"]
|
|
215
|
+
@urgency = props["urgency"]
|
|
216
|
+
@event = props["event"]
|
|
217
|
+
@sender_name = props["senderName"]
|
|
218
|
+
@headline = props["headline"]
|
|
219
|
+
@description = props["description"]
|
|
220
|
+
@instruction = props["instruction"]
|
|
221
|
+
@response = props["response"]
|
|
222
|
+
@affected_zones = props["affectedZones"] || []
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Parse an ISO8601 time string
|
|
226
|
+
#
|
|
227
|
+
# @param str [String, nil] Time string
|
|
228
|
+
# @return [Time, nil] Parsed time or nil
|
|
229
|
+
def parse_time(str)
|
|
230
|
+
return nil unless str
|
|
231
|
+
Time.parse(str)
|
|
232
|
+
rescue ArgumentError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Wrap text to a specified width
|
|
237
|
+
#
|
|
238
|
+
# @param text [String, nil] Text to wrap
|
|
239
|
+
# @param width [Integer] Maximum line width
|
|
240
|
+
# @return [Array<String>] Array of wrapped lines
|
|
241
|
+
def wrap_text(text, width)
|
|
242
|
+
return [] unless text
|
|
243
|
+
text.split("\n").flat_map do |paragraph|
|
|
244
|
+
if paragraph.strip.empty?
|
|
245
|
+
[""]
|
|
246
|
+
else
|
|
247
|
+
words = paragraph.split
|
|
248
|
+
lines = []
|
|
249
|
+
current_line = ""
|
|
250
|
+
|
|
251
|
+
words.each do |word|
|
|
252
|
+
if current_line.empty?
|
|
253
|
+
current_line = word
|
|
254
|
+
elsif (current_line.length + word.length + 1) <= width
|
|
255
|
+
current_line += " #{word}"
|
|
256
|
+
else
|
|
257
|
+
lines << current_line
|
|
258
|
+
current_line = word
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
lines << current_line unless current_line.empty?
|
|
262
|
+
lines
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
data/lib/nws/client.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module NWS
|
|
8
|
+
# HTTP client for making requests to the NWS API
|
|
9
|
+
class Client
|
|
10
|
+
# Base URL for the NWS API
|
|
11
|
+
BASE_URL = "https://api.weather.gov"
|
|
12
|
+
|
|
13
|
+
# @return [String] User-Agent string sent with requests
|
|
14
|
+
attr_reader :user_agent
|
|
15
|
+
|
|
16
|
+
# Initialize a new NWS API client
|
|
17
|
+
#
|
|
18
|
+
# @param user_agent [String, nil] Custom User-Agent string. If nil, uses default.
|
|
19
|
+
def initialize(user_agent: nil)
|
|
20
|
+
@user_agent = user_agent || default_user_agent
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Make a GET request to the NWS API
|
|
24
|
+
#
|
|
25
|
+
# @param path [String] API path (e.g., "/points/38.8977,-77.0365")
|
|
26
|
+
# @param params [Hash] Query parameters
|
|
27
|
+
# @return [Hash] Parsed JSON response
|
|
28
|
+
# @raise [NotFoundError] if resource is not found (404)
|
|
29
|
+
# @raise [RateLimitError] if rate limit exceeded (429)
|
|
30
|
+
# @raise [ServerError] if server error (5xx)
|
|
31
|
+
# @raise [APIError] for other API errors
|
|
32
|
+
def get(path, params: {})
|
|
33
|
+
uri = build_uri(path, params)
|
|
34
|
+
request = Net::HTTP::Get.new(uri)
|
|
35
|
+
request["User-Agent"] = user_agent
|
|
36
|
+
request["Accept"] = "application/geo+json"
|
|
37
|
+
|
|
38
|
+
response = execute_request(uri, request)
|
|
39
|
+
handle_response(response)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Make a GET request to a full URL
|
|
43
|
+
#
|
|
44
|
+
# @param url [String] Full URL to request
|
|
45
|
+
# @return [Hash] Parsed JSON response
|
|
46
|
+
# @raise [NotFoundError] if resource is not found (404)
|
|
47
|
+
# @raise [RateLimitError] if rate limit exceeded (429)
|
|
48
|
+
# @raise [ServerError] if server error (5xx)
|
|
49
|
+
# @raise [APIError] for other API errors
|
|
50
|
+
def get_url(url)
|
|
51
|
+
uri = URI.parse(url)
|
|
52
|
+
request = Net::HTTP::Get.new(uri)
|
|
53
|
+
request["User-Agent"] = user_agent
|
|
54
|
+
request["Accept"] = "application/geo+json"
|
|
55
|
+
|
|
56
|
+
response = execute_request(uri, request)
|
|
57
|
+
handle_response(response)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Build a URI from path and params
|
|
63
|
+
#
|
|
64
|
+
# @param path [String] API path
|
|
65
|
+
# @param params [Hash] Query parameters
|
|
66
|
+
# @return [URI] Constructed URI
|
|
67
|
+
def build_uri(path, params)
|
|
68
|
+
uri = URI.parse("#{BASE_URL}#{path}")
|
|
69
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
70
|
+
uri
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Execute an HTTP request
|
|
74
|
+
#
|
|
75
|
+
# @param uri [URI] Request URI
|
|
76
|
+
# @param request [Net::HTTP::Request] Request object
|
|
77
|
+
# @return [Net::HTTPResponse] Response object
|
|
78
|
+
def execute_request(uri, request)
|
|
79
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
80
|
+
http.use_ssl = (uri.scheme == "https")
|
|
81
|
+
http.open_timeout = 10
|
|
82
|
+
http.read_timeout = 30
|
|
83
|
+
http.request(request)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Handle API response, raising errors for non-success codes
|
|
87
|
+
#
|
|
88
|
+
# @param response [Net::HTTPResponse] Response object
|
|
89
|
+
# @return [Hash] Parsed JSON response
|
|
90
|
+
# @raise [NotFoundError, RateLimitError, ServerError, APIError] for error responses
|
|
91
|
+
def handle_response(response)
|
|
92
|
+
case response.code.to_i
|
|
93
|
+
when 200..299
|
|
94
|
+
JSON.parse(response.body)
|
|
95
|
+
when 404
|
|
96
|
+
raise NotFoundError.new("Resource not found", status_code: 404, response_body: response.body)
|
|
97
|
+
when 429
|
|
98
|
+
raise RateLimitError.new("Rate limit exceeded. Retry after a few seconds.", status_code: 429, response_body: response.body)
|
|
99
|
+
when 500..599
|
|
100
|
+
raise ServerError.new("Server error: #{response.code}", status_code: response.code.to_i, response_body: response.body)
|
|
101
|
+
else
|
|
102
|
+
raise APIError.new("API error: #{response.code}", status_code: response.code.to_i, response_body: response.body)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Generate default User-Agent string
|
|
107
|
+
#
|
|
108
|
+
# @return [String] Default User-Agent
|
|
109
|
+
def default_user_agent
|
|
110
|
+
"(nws-ruby-gem, #{NWS::VERSION})"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/nws/errors.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NWS
|
|
4
|
+
# Base error class for all NWS errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Error raised when the NWS API returns an error response
|
|
8
|
+
class APIError < Error
|
|
9
|
+
# @return [Integer, nil] HTTP status code from the API response
|
|
10
|
+
attr_reader :status_code
|
|
11
|
+
|
|
12
|
+
# @return [String, nil] Raw response body from the API
|
|
13
|
+
attr_reader :response_body
|
|
14
|
+
|
|
15
|
+
# @param message [String] Error message
|
|
16
|
+
# @param status_code [Integer, nil] HTTP status code
|
|
17
|
+
# @param response_body [String, nil] Raw response body
|
|
18
|
+
def initialize(message, status_code: nil, response_body: nil)
|
|
19
|
+
@status_code = status_code
|
|
20
|
+
@response_body = response_body
|
|
21
|
+
super(message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Error raised when a requested resource is not found (404)
|
|
26
|
+
class NotFoundError < APIError; end
|
|
27
|
+
|
|
28
|
+
# Error raised when rate limit is exceeded (429)
|
|
29
|
+
class RateLimitError < APIError; end
|
|
30
|
+
|
|
31
|
+
# Error raised when the server returns a 5xx error
|
|
32
|
+
class ServerError < APIError; end
|
|
33
|
+
|
|
34
|
+
# Error raised when invalid coordinates are provided
|
|
35
|
+
class InvalidCoordinatesError < Error; end
|
|
36
|
+
end
|
data/lib/nws/forecast.rb
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NWS
|
|
4
|
+
# Represents a 7-day weather forecast with 12-hour periods
|
|
5
|
+
class Forecast
|
|
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<ForecastPeriod>] Forecast periods (typically 14, covering 7 days)
|
|
13
|
+
attr_reader :periods
|
|
14
|
+
|
|
15
|
+
# @return [Hash] Raw API response data
|
|
16
|
+
attr_reader :raw_data
|
|
17
|
+
|
|
18
|
+
# Initialize a Forecast from API response data
|
|
19
|
+
#
|
|
20
|
+
# @param data [Hash] Parsed JSON response from the 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 today's forecast period
|
|
29
|
+
#
|
|
30
|
+
# @return [ForecastPeriod] First forecast period (typically today)
|
|
31
|
+
def today
|
|
32
|
+
@periods.first
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get tonight's forecast period
|
|
36
|
+
#
|
|
37
|
+
# @return [ForecastPeriod, nil] Tonight's forecast period or nil if not found
|
|
38
|
+
def tonight
|
|
39
|
+
@periods.find { |p| p.name.downcase.include?("tonight") || p.name.downcase.include?("night") }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get a formatted string representation of the forecast
|
|
43
|
+
#
|
|
44
|
+
# @return [String] Multi-line formatted forecast
|
|
45
|
+
def to_s
|
|
46
|
+
lines = []
|
|
47
|
+
lines << "Forecast for #{point&.location_string || 'Unknown Location'}"
|
|
48
|
+
lines << "Updated: #{updated_at&.strftime('%Y-%m-%d %I:%M %p')}"
|
|
49
|
+
lines << ""
|
|
50
|
+
|
|
51
|
+
@periods.each do |period|
|
|
52
|
+
lines << "#{period.name}: #{period.temperature}°#{period.temperature_unit}"
|
|
53
|
+
lines << " #{period.short_forecast}"
|
|
54
|
+
lines << ""
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
lines.join("\n")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get a detailed formatted string representation of the forecast
|
|
61
|
+
#
|
|
62
|
+
# @return [String] Multi-line detailed forecast
|
|
63
|
+
def detailed
|
|
64
|
+
lines = []
|
|
65
|
+
lines << "Forecast for #{point&.location_string || 'Unknown Location'}"
|
|
66
|
+
lines << "Updated: #{updated_at&.strftime('%Y-%m-%d %I:%M %p')}"
|
|
67
|
+
lines << ""
|
|
68
|
+
|
|
69
|
+
@periods.each do |period|
|
|
70
|
+
lines << "#{period.name}:"
|
|
71
|
+
lines << " #{period.detailed_forecast}"
|
|
72
|
+
lines << ""
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
lines.join("\n")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Parse API response data into instance variables
|
|
81
|
+
#
|
|
82
|
+
# @param data [Hash] Parsed JSON response
|
|
83
|
+
# @return [void]
|
|
84
|
+
def parse_data(data)
|
|
85
|
+
props = data["properties"] || {}
|
|
86
|
+
@updated_at = parse_time(props["updateTime"])
|
|
87
|
+
|
|
88
|
+
raw_periods = props["periods"] || []
|
|
89
|
+
@periods = raw_periods.map { |p| ForecastPeriod.new(p) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Parse an ISO8601 time string
|
|
93
|
+
#
|
|
94
|
+
# @param str [String, nil] Time string
|
|
95
|
+
# @return [Time, nil] Parsed time or nil
|
|
96
|
+
def parse_time(str)
|
|
97
|
+
return nil unless str
|
|
98
|
+
Time.parse(str)
|
|
99
|
+
rescue ArgumentError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Represents a single forecast period (typically 12 hours)
|
|
105
|
+
class ForecastPeriod
|
|
106
|
+
# @return [Integer] Period number
|
|
107
|
+
attr_reader :number
|
|
108
|
+
|
|
109
|
+
# @return [String] Period name (e.g., "Today", "Tonight", "Friday")
|
|
110
|
+
attr_reader :name
|
|
111
|
+
|
|
112
|
+
# @return [Time, nil] Start time of the period
|
|
113
|
+
attr_reader :start_time
|
|
114
|
+
|
|
115
|
+
# @return [Time, nil] End time of the period
|
|
116
|
+
attr_reader :end_time
|
|
117
|
+
|
|
118
|
+
# @return [Boolean] Whether this is a daytime period
|
|
119
|
+
attr_reader :is_daytime
|
|
120
|
+
|
|
121
|
+
# @return [Integer] Temperature value
|
|
122
|
+
attr_reader :temperature
|
|
123
|
+
|
|
124
|
+
# @return [String] Temperature unit (e.g., "F")
|
|
125
|
+
attr_reader :temperature_unit
|
|
126
|
+
|
|
127
|
+
# @return [String, nil] Temperature trend
|
|
128
|
+
attr_reader :temperature_trend
|
|
129
|
+
|
|
130
|
+
# @return [Integer, nil] Probability of precipitation (0-100)
|
|
131
|
+
attr_reader :probability_of_precipitation
|
|
132
|
+
|
|
133
|
+
# @return [String] Wind speed (e.g., "10 mph")
|
|
134
|
+
attr_reader :wind_speed
|
|
135
|
+
|
|
136
|
+
# @return [String] Wind direction (e.g., "NW")
|
|
137
|
+
attr_reader :wind_direction
|
|
138
|
+
|
|
139
|
+
# @return [String] Short forecast description
|
|
140
|
+
attr_reader :short_forecast
|
|
141
|
+
|
|
142
|
+
# @return [String] Detailed forecast description
|
|
143
|
+
attr_reader :detailed_forecast
|
|
144
|
+
|
|
145
|
+
# @return [String] URL to forecast icon
|
|
146
|
+
attr_reader :icon
|
|
147
|
+
|
|
148
|
+
# @return [Hash] Raw API response data
|
|
149
|
+
attr_reader :raw_data
|
|
150
|
+
|
|
151
|
+
# Initialize a ForecastPeriod from API response data
|
|
152
|
+
#
|
|
153
|
+
# @param data [Hash] Parsed JSON period data
|
|
154
|
+
def initialize(data)
|
|
155
|
+
@raw_data = data
|
|
156
|
+
parse_data(data)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Check if this is a daytime period
|
|
160
|
+
#
|
|
161
|
+
# @return [Boolean] True if daytime period
|
|
162
|
+
def daytime?
|
|
163
|
+
@is_daytime
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Get a formatted string representation of the period
|
|
167
|
+
#
|
|
168
|
+
# @return [String] Formatted period string
|
|
169
|
+
def to_s
|
|
170
|
+
"#{@name}: #{@temperature}°#{@temperature_unit} - #{@short_forecast}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
# Parse API response data into instance variables
|
|
176
|
+
#
|
|
177
|
+
# @param data [Hash] Parsed JSON period data
|
|
178
|
+
# @return [void]
|
|
179
|
+
def parse_data(data)
|
|
180
|
+
@number = data["number"]
|
|
181
|
+
@name = data["name"]
|
|
182
|
+
@start_time = parse_time(data["startTime"])
|
|
183
|
+
@end_time = parse_time(data["endTime"])
|
|
184
|
+
@is_daytime = data["isDaytime"]
|
|
185
|
+
@temperature = data["temperature"]
|
|
186
|
+
@temperature_unit = data["temperatureUnit"]
|
|
187
|
+
@temperature_trend = data["temperatureTrend"]
|
|
188
|
+
@probability_of_precipitation = data.dig("probabilityOfPrecipitation", "value")
|
|
189
|
+
@wind_speed = data["windSpeed"]
|
|
190
|
+
@wind_direction = data["windDirection"]
|
|
191
|
+
@short_forecast = data["shortForecast"]
|
|
192
|
+
@detailed_forecast = data["detailedForecast"]
|
|
193
|
+
@icon = data["icon"]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Parse an ISO8601 time string
|
|
197
|
+
#
|
|
198
|
+
# @param str [String, nil] Time string
|
|
199
|
+
# @return [Time, nil] Parsed time or nil
|
|
200
|
+
def parse_time(str)
|
|
201
|
+
return nil unless str
|
|
202
|
+
Time.parse(str)
|
|
203
|
+
rescue ArgumentError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|