Class: ElectricityRate

Inherits:
Object
  • Object
show all
Defined in:
app/models/electricity_rate.rb

Constant Summary collapse

US_API_URL =
'https://developer.nrel.gov/api/utility_rates/v3.json'
US_API_KEY =
'pNYthBjsnd8WKS85K9qkg6mkZ2gISU6iPchwqdKi'
CAN_RATE_BY_PROVINCE =

Updated December 2025
Source: https://offgridsolarsystem.ca/blog/Canada-electricity-rates.html (July 2025)
IMPORTANT: These are MARGINAL rates (Tier 2 / Step 2) for provinces with tiered pricing.
Floor heating is additive consumption, so customers pay the higher tier rate, not the blended average.

  • BC: Step 2 rate (14.08¢) used instead of Step 1 (11.72¢)
  • QC: Tier 2 rate (10.7¢) used instead of blended average (7.8¢) per Hydro-Québec Rate D
  • ON: Mid-peak TOU rate (15.7¢) used as representative rate for additional heating load
{
  'AB' => 0.258,  # Alberta - includes delivery & admin charges
  'BC' => 0.141,  # BC Hydro Step 2 rate (14.08¢ rounded)
  'MB' => 0.102,  # Manitoba Hydro
  'NB' => 0.139,  # NB Power
  'NL' => 0.148,  # NL Hydro / Newfoundland Power
  'NS' => 0.183,  # Nova Scotia Power
  'NT' => 0.410,  # NTPC (high due to diesel generation)
  'NU' => 0.354,  # Qulliq Energy Corp
  'ON' => 0.157,  # Ontario - mid-peak TOU rate (Nov 2025)
  'PE' => 0.184,  # Maritime Electric
  'QC' => 0.110,  # Hydro-Québec Tier 2 rate (~10.7¢)
  'SK' => 0.199,  # SaskPower
  'YT' => 0.187   # Yukon Energy / ATCO Electric
}.freeze
US_RATE_BY_STATE =
{
  'AL' => 0.1491,
  'AK' => 0.2238,
  'AZ' => 0.1520,
  'AR' => 0.1174,
  'CA' => 0.3055,
  'CO' => 0.1516,
  'CT' => 0.2816,
  'DE' => 0.1668,
  'DC' => 0.1626, # This one i don't know it's not from that table, i used the average
  'FL' => 0.1420,
  'GA' => 0.1349,
  'HI' => 0.4234,
  'ID' => 0.1097,
  'IL' => 0.1599,
  'IN' => 0.1442,
  'IA' => 0.1243,
  'KS' => 0.1385,
  'KY' => 0.1328,
  'LA' => 0.1170,
  'ME' => 0.2629,
  'MD' => 0.1815,
  'MA' => 0.3122,
  'MI' => 0.1841,
  'MN' => 0.1405,
  'MS' => 0.1344,
  'MO' => 0.1157,
  'MT' => 0.1187,
  'NE' => 0.1078,
  'NV' => 0.1488,
  'NH' => 0.2362,
  'NJ' => 0.1949,
  'NM' => 0.1426,
  'NY' => 0.2437,
  'NC' => 0.1349,
  'ND' => 0.1021,
  'OH' => 0.1598,
  'OK' => 0.1152,
  'OR' => 0.1412,
  'PA' => 0.1760,
  'RI' => 0.2531,
  'SC' => 0.1387,
  'SD' => 0.1241,
  'TN' => 0.1304,
  'TX' => 0.1542,
  'UT' => 0.1102,
  'VT' => 0.2229,
  'VA' => 0.1446,
  'WA' => 0.1183,
  'WV' => 0.1451,
  'WI' => 0.1631,
  'WY' => 0.1178
}.freeze
ALL_STATES_AND_PROVINCES =
CAN_RATE_BY_PROVINCE.merge(US_RATE_BY_STATE).freeze
CAN_AVERAGE_RATE =

Canadian average rate - used as fallback when province cannot be determined.
This is a weighted average biased toward Tier 2/marginal rates to better reflect
the cost of additional consumption like floor heating.
Updated December 2025 based on provincial rates above.

0.175
US_AVERAGE_RATE =
0.1626
RATES_BY_COUNTRY =
{
  'USA' => US_AVERAGE_RATE,
  'CAN' => CAN_AVERAGE_RATE
}.freeze
RATES_BY_COUNTRY_ISO =
{
  'US' => US_AVERAGE_RATE,
  'CA' => CAN_AVERAGE_RATE
}.freeze
POSTAL_CA_REGEXP =

This regexp can handle regional postal code as well as full canadian postal codes

/^([ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z])([ -]?\d[ABCEGHJ-NPRSTV-Z]\d)?$/i
POSTAL_US_REGEXP =

This one handles US with +4 optional

/^(\d{5})(-{0,1}\d{4})?$/

Class Method Summary collapse

Class Method Details

.get_average_for_locale(locale = I18n.locale) ⇒ Object



193
194
195
196
# File 'app/models/electricity_rate.rb', line 193

def self.get_average_for_locale(locale = I18n.locale)
  country_iso = locale.to_s.last(2)
  RATES_BY_COUNTRY_ISO[country_iso]
end

.get_average_rate_for_country_iso_state_code(country_iso, state_code) ⇒ Object



110
111
112
113
114
115
# File 'app/models/electricity_rate.rb', line 110

def self.get_average_rate_for_country_iso_state_code(country_iso, state_code)
  {
    'US' => US_RATE_BY_STATE,
    'CA' => CAN_RATE_BY_PROVINCE
  }[country_iso]&.dig(state_code)
end

.get_average_rate_for_locale(locale = I18n.locale) ⇒ Object



117
118
119
120
121
122
123
124
# File 'app/models/electricity_rate.rb', line 117

def self.get_average_rate_for_locale(locale = I18n.locale)
  case LocaleUtility.locale_to_country_iso(locale)
  when 'US'
    US_AVERAGE_RATE
  when 'CA'
    CAN_AVERAGE_RATE
  end
end

.get_average_rate_for_locale_as_money(locale = I18n.locale) ⇒ Object



126
127
128
129
130
131
132
133
# File 'app/models/electricity_rate.rb', line 126

def self.get_average_rate_for_locale_as_money(locale = I18n.locale)
  case LocaleUtility.locale_to_country_iso(locale)
  when 'US'
    Money.from_amount(US_AVERAGE_RATE, 'USD')
  when 'CA'
    Money.from_amount(CAN_AVERAGE_RATE, 'CAD')
  end
end

.get_by_lat_lng(lat, lng) ⇒ Object



198
199
200
# File 'app/models/electricity_rate.rb', line 198

def self.get_by_lat_lng(lat, lng)
  get_from_location([lat, lng], :coordinates)
end

.get_from_location(location, search_type) ⇒ Object



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'app/models/electricity_rate.rb', line 151

def self.get_from_location(location, search_type)
  res = {}
  res[:location] = location
  res[:search_type] = search_type

  # Try to find location with state code first
  geo = Geocoder.search(location).select { |g| g.country_code.present? && g.state_code.present? }.first
  # Fall back to just country if no state code
  geo ||= Geocoder.search(location).select { |g| g.country_code.present? }.first

  if geo
    iso_country = ISO3166::Country[geo.country_code]
    res[:country_iso3] = iso_country&.alpha3
    res[:country_iso] = iso_country&.alpha2
    res[:currency_code] = iso_country&.currency_code || 'USD'
    res[:state_code] = geo.state_code
    res[:postal_code] = geo.postal_code if geo.respond_to?(:postal_code)

    # Try state/province rate first, then country rate
    res[:average_rate] = ALL_STATES_AND_PROVINCES[res[:state_code]] if res[:state_code]
    if res[:average_rate]
      res[:precision] = :state_or_province
    elsif res[:average_rate] = RATES_BY_COUNTRY_ISO[res[:country_iso]]
      res[:precision] = :country
    end
  end

  if res[:average_rate]
    # Money is expressed in the destination country value
    rate_money = Money.from_amount(res[:average_rate], res[:currency_code])
    res[:currency] = rate_money.currency.iso_code
    res[:currency_symbol] = rate_money.currency.symbol
    res[:status] = :ok
  else
    # Return OK with no rate - let the client keep its current value
    res[:status] = :ok
    res[:message] = 'No electricity rate data available for this location'
    res[:average_rate] = nil
  end
  res
end

.get_from_postal_code(code) ⇒ Object

Some test code
J9X4M2 - Quebec
60047 - Lake Zurich
J9X%204M2 - Poorly formatted postal code
J9X region code



140
141
142
143
144
145
146
147
148
149
# File 'app/models/electricity_rate.rb', line 140

def self.get_from_postal_code(code)
  require 'cgi'
  clean_code = CGI.unescape(code)
  # Scan for canadian first
  postal_code = clean_code.to_s.scan(POSTAL_CA_REGEXP).join.presence&.upcase
  # For US zips, only use the 5-digit base (strip +4 suffix) since Geocoder doesn't handle ZIP+4
  postal_code ||= clean_code.to_s.match(POSTAL_US_REGEXP)&.captures&.first
  Rails.logger.debug { "ElectricityRate: Looking up postal code #{postal_code}" }
  get_from_location("#{postal_code}, North America", :postal_code) # patch pending more thorough international support top avoid, for e.g., an IA zip code being returned as Kuala Lumpur
end