Class: ElectricityRate

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

Overview

Per-state / per-utility electricity-rate lookups powered by the NREL
Utility Rates API. Used by the heat-loss and operating-cost calculators
on the public website to estimate "$/year to run this floor heater"
given a customer's location.

Constant Summary collapse

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

Key used for us api.

'pNYthBjsnd8WKS85K9qkg6mkZ2gISU6iPchwqdKi'.freeze
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 =

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 =

Rates by country.

{
  'USA' => US_AVERAGE_RATE,
  'CAN' => CAN_AVERAGE_RATE
}.freeze
RATES_BY_COUNTRY_ISO =

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



204
205
206
207
# File 'app/models/electricity_rate.rb', line 204

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



121
122
123
124
125
126
# File 'app/models/electricity_rate.rb', line 121

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



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

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



137
138
139
140
141
142
143
144
# File 'app/models/electricity_rate.rb', line 137

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



209
210
211
# File 'app/models/electricity_rate.rb', line 209

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

.get_from_location(location, search_type) ⇒ Object



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
192
193
194
195
196
197
198
199
200
201
202
# File 'app/models/electricity_rate.rb', line 162

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).find { |g| g.country_code.present? && g.state_code.present? }
  # Fall back to just country if no state code
  geo ||= Geocoder.search(location).find { |g| g.country_code.present? }

  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



151
152
153
154
155
156
157
158
159
160
# File 'app/models/electricity_rate.rb', line 151

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