usgs-ruby 1.0.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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +87 -0
  3. data/CHANGELOG.md +5 -0
  4. data/CODE_OF_CONDUCT.md +84 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +256 -0
  7. data/Rakefile +24 -0
  8. data/docs/Usgs/Client.html +563 -0
  9. data/docs/Usgs/DailyValues.html +338 -0
  10. data/docs/Usgs/InstantaneousValues.html +329 -0
  11. data/docs/Usgs/Models/Reading.html +1124 -0
  12. data/docs/Usgs/Models/Site.html +2030 -0
  13. data/docs/Usgs/Models/Statistic.html +3981 -0
  14. data/docs/Usgs/Models.html +117 -0
  15. data/docs/Usgs/Parser.html +346 -0
  16. data/docs/Usgs/Parsers/RdbParser.html +354 -0
  17. data/docs/Usgs/Parsers/SiteParser.html +174 -0
  18. data/docs/Usgs/Parsers/StatisticsParser.html +228 -0
  19. data/docs/Usgs/Parsers/TimeSeriesParser.html +189 -0
  20. data/docs/Usgs/Parsers.html +117 -0
  21. data/docs/Usgs/Site.html +445 -0
  22. data/docs/Usgs/Statistics.html +335 -0
  23. data/docs/Usgs/Utils.html +357 -0
  24. data/docs/Usgs.html +303 -0
  25. data/docs/_index.html +286 -0
  26. data/docs/class_list.html +54 -0
  27. data/docs/css/common.css +1 -0
  28. data/docs/css/full_list.css +58 -0
  29. data/docs/css/style.css +503 -0
  30. data/docs/file.README.html +111 -0
  31. data/docs/file_list.html +59 -0
  32. data/docs/frames.html +22 -0
  33. data/docs/index.html +111 -0
  34. data/docs/js/app.js +344 -0
  35. data/docs/js/full_list.js +242 -0
  36. data/docs/js/jquery.js +4 -0
  37. data/docs/method_list.html +598 -0
  38. data/docs/top-level-namespace.html +110 -0
  39. data/lib/usgs/client.rb +62 -0
  40. data/lib/usgs/daily_values.rb +38 -0
  41. data/lib/usgs/instantaneous_values.rb +35 -0
  42. data/lib/usgs/models/reading.rb +43 -0
  43. data/lib/usgs/models/site.rb +61 -0
  44. data/lib/usgs/models/statistic.rb +95 -0
  45. data/lib/usgs/parser.rb +23 -0
  46. data/lib/usgs/parsers/rdb_parser.rb +55 -0
  47. data/lib/usgs/parsers/site_parser.rb +13 -0
  48. data/lib/usgs/parsers/statistics_parser.rb +40 -0
  49. data/lib/usgs/parsers/time_series_parser.rb +54 -0
  50. data/lib/usgs/site.rb +50 -0
  51. data/lib/usgs/statistics.rb +42 -0
  52. data/lib/usgs/utils.rb +50 -0
  53. data/lib/usgs/version.rb +5 -0
  54. data/lib/usgs.rb +28 -0
  55. data/sig/usgs/ruby.rbs +6 -0
  56. data/usgs-ruby.gemspec +48 -0
  57. metadata +231 -0
@@ -0,0 +1,110 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>
7
+ Top Level Namespace
8
+
9
+ &mdash; Documentation by YARD 0.9.37
10
+
11
+ </title>
12
+
13
+ <link rel="stylesheet" href="css/style.css" type="text/css" />
14
+
15
+ <link rel="stylesheet" href="css/common.css" type="text/css" />
16
+
17
+ <script type="text/javascript">
18
+ pathId = "";
19
+ relpath = '';
20
+ </script>
21
+
22
+
23
+ <script type="text/javascript" charset="utf-8" src="js/jquery.js"></script>
24
+
25
+ <script type="text/javascript" charset="utf-8" src="js/app.js"></script>
26
+
27
+
28
+ </head>
29
+ <body>
30
+ <div class="nav_wrap">
31
+ <iframe id="nav" src="class_list.html?1"></iframe>
32
+ <div id="resizer"></div>
33
+ </div>
34
+
35
+ <div id="main" tabindex="-1">
36
+ <div id="header">
37
+ <div id="menu">
38
+
39
+ <a href="_index.html">Index</a> &raquo;
40
+
41
+
42
+ <span class="title">Top Level Namespace</span>
43
+
44
+ </div>
45
+
46
+ <div id="search">
47
+
48
+ <a class="full_list_link" id="class_list_link"
49
+ href="class_list.html">
50
+
51
+ <svg width="24" height="24">
52
+ <rect x="0" y="4" width="24" height="4" rx="1" ry="1"></rect>
53
+ <rect x="0" y="12" width="24" height="4" rx="1" ry="1"></rect>
54
+ <rect x="0" y="20" width="24" height="4" rx="1" ry="1"></rect>
55
+ </svg>
56
+ </a>
57
+
58
+ </div>
59
+ <div class="clear"></div>
60
+ </div>
61
+
62
+ <div id="content"><h1>Top Level Namespace
63
+
64
+
65
+
66
+ </h1>
67
+ <div class="box_info">
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+ </div>
80
+
81
+ <h2>Defined Under Namespace</h2>
82
+ <p class="children">
83
+
84
+
85
+ <strong class="modules">Modules:</strong> <span class='object_link'><a href="Usgs.html" title="Usgs (module)">Usgs</a></span>
86
+
87
+
88
+
89
+
90
+ </p>
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+ </div>
101
+
102
+ <div id="footer">
103
+ Generated on Mon Dec 15 21:52:18 2025 by
104
+ <a href="https://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
105
+ 0.9.37 (ruby-3.2.2).
106
+ </div>
107
+
108
+ </div>
109
+ </body>
110
+ </html>
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ class Client
5
+ include DailyValues
6
+ include InstantaneousValues
7
+ include Site
8
+ include Statistics
9
+
10
+ attr_reader :timeout, :user_agent, :debug
11
+
12
+ def initialize(timeout: 30, user_agent: "usgs-ruby/#{Usgs::VERSION}", debug: nil)
13
+ @timeout = timeout
14
+ @user_agent = user_agent
15
+ @debug = debug.nil? ? Usgs.config.debug : debug
16
+ end
17
+
18
+ # Base URL for USGS Water Services
19
+ def base_url
20
+ "https://waterservices.usgs.gov/nwis"
21
+ end
22
+
23
+ # Public: Perform GET and return response from API
24
+ def api_get(path, query = {})
25
+ query = query.compact
26
+ url = "#{base_url}#{path}"
27
+
28
+ fetch_url(url, query: query, timeout: timeout, user_agent: user_agent)
29
+ end
30
+
31
+ private
32
+
33
+ def fetch_url(url, query: {}, timeout: 30, user_agent: nil)
34
+ uri = URI(url)
35
+ uri.query = URI.encode_www_form(query).gsub("+", "%20") unless query.empty?
36
+
37
+ puts "\n=== USGS Request ===\n#{uri}\n====================\n" if @debug
38
+
39
+ http_get(uri, timeout: timeout, user_agent: user_agent)
40
+ end
41
+
42
+ def http_get(uri, timeout: 30, user_agent: nil)
43
+ Net::HTTP.start(
44
+ uri.host,
45
+ uri.port,
46
+ use_ssl: true,
47
+ open_timeout: timeout,
48
+ read_timeout: timeout
49
+ ) do |http|
50
+ request = Net::HTTP::Get.new(uri)
51
+ request["User-Agent"] = user_agent if user_agent
52
+ request["Accept"] = "application/json"
53
+
54
+ response = http.request(request)
55
+
56
+ raise "USGS API Error #{response.code}: #{response.message}\n#{response.body}" unless response.is_a?(Net::HTTPSuccess)
57
+
58
+ response
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,38 @@
1
+ # lib/usgs/daily_values.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Usgs
5
+ module DailyValues
6
+ include Utils
7
+ # Fetch daily values (DV) from USGS NWIS
8
+ #
9
+ # @param sites [String, Array<String>] USGS site ID(s)
10
+ # @param parameter_cd [Symbol, String, Array] e.g. :discharge
11
+ # @param start_date [Date, String, nil] Start date — defaults to 10 years ago if omitted
12
+ # @param end_date [Date, String, nil] End date — defaults to today
13
+ #
14
+ # @return [Array<Usgs::Models::Reading>]
15
+ #
16
+ # @example Most recent year
17
+ # Usgs.client.get_dv(sites: "06754000", parameter_cd: :discharge)
18
+ #
19
+ # @example Full POR
20
+ # Usgs.client.get_dv(sites: "06754000", parameter_cd: :discharge, start_date: "1900-01-01")
21
+ #
22
+ def get_dv(sites:, parameter_cd: nil, start_date: nil, end_date: nil)
23
+ site_list = Array(sites).join(",")
24
+ param_list = resolve_parameter_codes(parameter_cd)
25
+
26
+ query = {
27
+ format: "json",
28
+ sites: site_list,
29
+ parameterCd: param_list,
30
+ startDT: format_date(start_date || (Time.now.utc - (48 * 60 * 60))),
31
+ endDT: format_date(end_date || Time.now.utc)
32
+ }.compact
33
+
34
+ response = api_get("/dv/", query)
35
+ Parser.parse_time_series_values(JSON.parse(response.body))
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ module InstantaneousValues
5
+ include Utils
6
+ # Fetch instantaneous values (IV) from USGS NWIS
7
+ #
8
+ # @param sites [String, Array<String>] One or more USGS site IDs
9
+ # @param parameter_cd [Symbol, String, Array] e.g. :discharge, "00060", or [:discharge, :gage_height]
10
+ # @param start_date [DateTime, Date, Time, String, nil] Start time
11
+ # @param end_date [DateTime, Date, Time, String, nil] End time (default: now)
12
+ #
13
+ # @return [Array<Usgs::Models::Reading>]
14
+ #
15
+ # @example
16
+ # Usgs.client.get_iv(sites: "06754000", parameter_cd: :discharge, start_date: 1.day.ago)
17
+ #
18
+ def get_iv(sites:, parameter_cd: nil, start_date: nil, end_date: nil)
19
+ site_list = Array(sites).join(",")
20
+ param_list = resolve_parameter_codes(parameter_cd)
21
+
22
+ query = {
23
+ format: "json",
24
+ sites: site_list,
25
+ parameterCd: param_list,
26
+ # Default to the the last 24hrs if not filled out
27
+ startDT: format_datetime(start_date || (Time.now.utc - (24 * 60 * 60))),
28
+ endDT: format_datetime(end_date || Time.now.utc)
29
+ }.compact
30
+
31
+ response = api_get("/iv/", query)
32
+ Parser.parse_time_series_values(JSON.parse(response.body))
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ module Models
5
+ # Represents a USGS reading/measurement
6
+ #
7
+ # @!attribute [rw] site_no
8
+ # @return [String] USGS site number
9
+ # @!attribute [rw] parameter_cd
10
+ # @return [String] Parameter code
11
+ # @!attribute [rw] datetime
12
+ # @return [String] Date and time of reading
13
+ # @!attribute [rw] value
14
+ # @return [Float, nil] Measured value
15
+ # @!attribute [rw] qualifiers
16
+ # @return [Array<String>] Quality/approval codes
17
+ # @!attribute [rw] unit
18
+ # @return [String] Unit of measurement
19
+ # @!attribute [rw] metadata
20
+ # @return [Hash] Additional metadata
21
+ class Reading
22
+ ATTRIBUTES = %i[
23
+ site_no
24
+ parameter_cd
25
+ datetime
26
+ value
27
+ qualifiers
28
+ unit
29
+ metadata
30
+ ].freeze
31
+
32
+ attr_accessor(*ATTRIBUTES) # :nodoc:
33
+
34
+ def initialize(data = {})
35
+ data[:metadata] ||= {}
36
+ attrs = data.is_a?(Hash) ? data : {}
37
+ ATTRIBUTES.each do |attr|
38
+ instance_variable_set(:"@#{attr}", attrs[attr]) if attrs.key?(attr)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ module Models
5
+ # Represents a USGS monitoring site/station
6
+ #
7
+ # @!attribute [rw] agency_cd
8
+ # @return [String] Agency code
9
+ # @!attribute [rw] site_no
10
+ # @return [String] USGS site number
11
+ # @!attribute [rw] station_nm
12
+ # @return [String] Station name
13
+ # @!attribute [rw] site_tp_cd
14
+ # @return [String] Site type code
15
+ # @!attribute [rw] dec_lat_va
16
+ # @return [Float, nil] Decimal latitude
17
+ # @!attribute [rw] dec_long_va
18
+ # @return [Float, nil] Decimal longitude
19
+ # @!attribute [rw] coord_acy_cd
20
+ # @return [String] Coordinate accuracy code
21
+ # @!attribute [rw] dec_coord_datum_cd
22
+ # @return [String] Decimal coordinate datum code
23
+ # @!attribute [rw] alt_va
24
+ # @return [String] Altitude value
25
+ # @!attribute [rw] alt_acy_va
26
+ # @return [String] Altitude accuracy value
27
+ # @!attribute [rw] alt_datum_cd
28
+ # @return [String] Altitude datum code
29
+ # @!attribute [rw] huc_cd
30
+ # @return [String] Hydrologic unit code
31
+ # @!attribute [rw] metadata
32
+ # @return [Hash] Additional metadata
33
+ class Site
34
+ ATTRIBUTES = %i[
35
+ agency_cd
36
+ site_no
37
+ station_nm
38
+ site_tp_cd
39
+ dec_lat_va
40
+ dec_long_va
41
+ coord_acy_cd
42
+ dec_coord_datum_cd
43
+ alt_va
44
+ alt_acy_va
45
+ alt_datum_cd
46
+ huc_cd
47
+ metadata
48
+ ].freeze
49
+
50
+ attr_accessor(*ATTRIBUTES) # :nodoc:
51
+
52
+ def initialize(attrs = {})
53
+ attrs = {} unless attrs.is_a?(Hash)
54
+ attrs[:metadata] ||= {}
55
+ ATTRIBUTES.each do |attr|
56
+ instance_variable_set(:"@#{attr}", attrs[attr]) if attrs.key?(attr)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ module Models
5
+ # Represents a USGS statistic record
6
+ #
7
+ # @!attribute [rw] site_no
8
+ # @return [String] USGS site number
9
+ # @!attribute [rw] parameter_cd
10
+ # @return [String] Parameter code
11
+ # @!attribute [rw] month_nu
12
+ # @return [Float, nil] Month number
13
+ # @!attribute [rw] day_nu
14
+ # @return [Float, nil] Day number
15
+ # @!attribute [rw] begin_yr
16
+ # @return [Float, nil] Beginning year
17
+ # @!attribute [rw] end_yr
18
+ # @return [Float, nil] Ending year
19
+ # @!attribute [rw] count_nu
20
+ # @return [Float, nil] Count of values
21
+ # @!attribute [rw] mean_va
22
+ # @return [Float, nil] Mean value
23
+ # @!attribute [rw] max_va
24
+ # @return [Float, nil] Maximum value
25
+ # @!attribute [rw] max_va_yr
26
+ # @return [Float, nil] Year of maximum value
27
+ # @!attribute [rw] min_va
28
+ # @return [Float, nil] Minimum value
29
+ # @!attribute [rw] min_va_yr
30
+ # @return [Float, nil] Year of minimum value
31
+ # @!attribute [rw] p05_va
32
+ # @return [Float, nil] 5th percentile value
33
+ # @!attribute [rw] p10_va
34
+ # @return [Float, nil] 10th percentile value
35
+ # @!attribute [rw] p20_va
36
+ # @return [Float, nil] 20th percentile value
37
+ # @!attribute [rw] p25_va
38
+ # @return [Float, nil] 25th percentile value
39
+ # @!attribute [rw] p50_va
40
+ # @return [Float, nil] 50th percentile (median) value
41
+ # @!attribute [rw] p75_va
42
+ # @return [Float, nil] 75th percentile value
43
+ # @!attribute [rw] p80_va
44
+ # @return [Float, nil] 80th percentile value
45
+ # @!attribute [rw] p90_va
46
+ # @return [Float, nil] 90th percentile value
47
+ # @!attribute [rw] p95_va
48
+ # @return [Float, nil] 95th percentile value
49
+ # @!attribute [rw] metadata
50
+ # @return [Hash] Additional metadata
51
+ class Statistic
52
+ ATTRIBUTES = %i[
53
+ site_no
54
+ parameter_cd
55
+ month_nu
56
+ day_nu
57
+ begin_yr
58
+ end_yr
59
+ count_nu
60
+ mean_va
61
+ max_va
62
+ max_va_yr
63
+ min_va
64
+ min_va_yr
65
+ p05_va
66
+ p10_va
67
+ p20_va
68
+ p25_va
69
+ p50_va
70
+ p75_va
71
+ p80_va
72
+ p90_va
73
+ p95_va
74
+ metadata
75
+ ].freeze
76
+
77
+ attr_accessor(*ATTRIBUTES) # :nodoc:
78
+
79
+ def initialize(data = {})
80
+ data[:metadata] ||= {}
81
+ attrs = data.is_a?(Hash) ? data : {}
82
+ ATTRIBUTES.each do |attr|
83
+ value = attrs[attr]
84
+
85
+ if value.is_a?(String) && attr.to_s.end_with?("_va", "_yr", "_nu")
86
+ stripped = value.strip
87
+ value = stripped.empty? || stripped == "." ? nil : stripped.to_f
88
+ end
89
+
90
+ instance_variable_set(:"@#{attr}", value) if attrs.key?(attr)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ class Parser
5
+ class << self
6
+ def parse_sites(response)
7
+ Parsers::RdbParser.parse(response)
8
+ end
9
+
10
+ def parse_time_series(response, timescale: :instantaneous)
11
+ # Parsers::TimeSeriesParser.parse(response, timescale: timescale)
12
+ end
13
+
14
+ def parse_time_series_values(response)
15
+ Parsers::TimeSeriesParser.parse(response)
16
+ end
17
+
18
+ def parse_statistics(response)
19
+ Parsers::StatisticsParser.parse(response)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ module Parsers
5
+ module RdbParser
6
+ class << self
7
+ # Parse any USGS RDB response (format=rdb or rdb,1.0)
8
+ #
9
+ # @param text [String] Raw RDB text
10
+ # @return [Array<Hash>] Rows with symbolized column names
11
+ def parse(text)
12
+ lines = text.lines.map(&:rstrip)
13
+
14
+ # Drop all comment lines (#)
15
+ data_lines = lines.drop_while { |l| l.start_with?("#") }
16
+
17
+ return [] if data_lines.empty?
18
+
19
+ field_names_line = data_lines[0]
20
+
21
+ data_start_index = if data_lines[1]&.match?(/\A(\d+[sd])\t/)
22
+ 2
23
+ else
24
+ 1
25
+ end
26
+
27
+ column_names = field_names_line.split("\t")
28
+ data_lines[data_start_index..].map do |line|
29
+ next if line.empty? || line.start_with?("#")
30
+
31
+ values = line.split("\t")
32
+ column_names.zip(values).to_h.transform_keys { |k| k.strip.to_sym }
33
+ end.compact
34
+ end
35
+
36
+ # For time series (iv, dv, etc.)
37
+ def parse_time_series(text)
38
+ rows = parse(text)
39
+ rows.map do |row|
40
+ value = row[:value]&.strip
41
+ value = nil if value.nil? || value == "" || value == "-999999" || value.downcase.include?("ice")
42
+
43
+ {
44
+ site_no: row[:site_no]&.strip,
45
+ datetime: row[:datetime] || row[:dateTime],
46
+ value: value&.to_f,
47
+ code: row[:value_cd] || row[:qualifiers],
48
+ parameter_cd: row[:parameter_cd] || row[:parm_cd]
49
+ }.compact
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ module Parsers
5
+ module SiteParser
6
+ class << self
7
+ def parse_sites(response)
8
+ response
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ module Parsers
5
+ module StatisticsParser
6
+ class << self
7
+ def parse(rdb_text)
8
+ rows = Parsers::RdbParser.parse(rdb_text)
9
+
10
+ rows.map do |row|
11
+ {
12
+ site_no: row[:site_no]&.strip,
13
+ parameter_cd: row[:parameter_cd]&.strip,
14
+ month_nu: row[:month_nu]&.to_i,
15
+ day_nu: row[:day_nu]&.to_i,
16
+ begin_yr: row[:begin_yr]&.to_i,
17
+ end_yr: row[:end_yr]&.to_i,
18
+ count_nu: row[:count_nu]&.to_i,
19
+ mean_va: row[:mean_va],
20
+ max_va: row[:max_va],
21
+ max_va_yr: row[:max_va_yr]&.to_i,
22
+ min_va: row[:min_va],
23
+ min_va_yr: row[:min_va_yr]&.to_i,
24
+ p05_va: row[:p05_va],
25
+ p10_va: row[:p10_va],
26
+ p20_va: row[:p20_va],
27
+ p25_va: row[:p25_va],
28
+ p50_va: row[:p50_va],
29
+ p75_va: row[:p75_va],
30
+ p80_va: row[:p80_va],
31
+ p90_va: row[:p90_va],
32
+ p95_va: row[:p95_va],
33
+ metadata: {}
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ module Parsers
5
+ module TimeSeriesParser
6
+ class << self
7
+ # Parse both Instantaneous Values (IV) and Daily Values (DV)
8
+ # They have identical JSON structure
9
+ def parse(response)
10
+ series_list = response.dig("value", "timeSeries") || []
11
+ series_list.flat_map { |series| parse_series(series) }
12
+ end
13
+
14
+ private
15
+
16
+ def parse_series(series)
17
+ info = series["sourceInfo"]
18
+ variable = series["variable"]
19
+ values = series["values"]&.first&.dig("value") || []
20
+
21
+ site_no = info.dig("siteCode", 0, "value")
22
+ parameter_cd = variable.dig("variableCode", 0, "value")
23
+ unit = variable.dig("unit", "unitCode")
24
+
25
+ values.map do |v|
26
+ raw_value = v["value"]
27
+
28
+ value = case raw_value
29
+ when nil, "", "-999999", "Ice", "Eqp", "Fld", "Bkw", "Rat"
30
+ nil
31
+ else
32
+ raw_value.to_f
33
+ end
34
+
35
+ # For DV: "2025-12-04T00:00:00.000" → "2025-12-04"
36
+ # For IV: "2025-12-04T12:15:00.000-07:00" → full string (preserved)
37
+ datetime = v["dateTime"]
38
+ datetime = datetime[0..9] if datetime&.include?("T00:00:00.000")
39
+
40
+ {
41
+ site_no: site_no,
42
+ parameter_cd: parameter_cd,
43
+ datetime: datetime,
44
+ value: value,
45
+ qualifiers: v["qualifiers"],
46
+ unit: unit,
47
+ metadata: {}
48
+ }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/usgs/site.rb ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usgs
4
+ module Site
5
+ include Utils
6
+
7
+ # Fetch USGS monitoring locations (sites)
8
+ #
9
+ # @param state_cd [String] Two-letter state code (e.g., "CO")
10
+ # @param county_cd [String] FIPS county code (e.g., "08013")
11
+ # @param huc [String] 2-16 digit Hydrologic Unit Code
12
+ # @param bBox [String] "west,south,east,north" in decimal degrees
13
+ # @param site_name [String] Text search in station name
14
+ # @param site_type [String] e.g., "ST" (stream), "GW", "LK", "WE"
15
+ # @param site_status [String] "active", "inactive", "all" (default: "all")
16
+ # @param parameter_cd [String, Symbol, Array] e.g. :discharge, "00060", or [:discharge, :gage_height]
17
+ #
18
+ # @return [Array<Usgs::Models::Site>]
19
+ # @raise [ArgumentError] if no major filter is provided
20
+ #
21
+ # @example
22
+ # Usgs.client.get_sites(state_cd: "CO", parameter_cd: :discharge)
23
+ #
24
+ def get_sites(state_cd: nil, county_cd: nil, huc: nil, b_box: nil, site_name: nil,
25
+ site_type: nil, site_status: "all", parameter_cd: nil)
26
+ query = {
27
+ format: "rdb,1.0",
28
+ stateCd: state_cd,
29
+ countyCd: county_cd,
30
+ huc: huc,
31
+ bBox: b_box,
32
+ siteName: site_name,
33
+ siteType: site_type,
34
+ siteStatus: site_status
35
+ }.compact
36
+
37
+ if parameter_cd
38
+ resolved = resolve_parameter_codes(parameter_cd)
39
+ query[:parameterCd] = resolved if resolved && !resolved.empty?
40
+ end
41
+
42
+ # Validate for at least one major filter
43
+ major_keys = %i[stateCd countyCd huc bBox].map(&:to_sym)
44
+ raise ArgumentError, "You must provide at least one major filter: state_cd, county_cd, huc or b_box" if (query.keys & major_keys).empty?
45
+
46
+ raw = api_get("/site/", query)
47
+ Parser.parse_sites(raw.body).map { |row| Models::Site.new(row) }
48
+ end
49
+ end
50
+ end