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.
@@ -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