app/models/meter.rb
# == Schema Information
#
# Table name: meters
#
# active :boolean default(TRUE)
# admin_meter_statuses_id :bigint(8)
# consent_granted :boolean default(FALSE)
# created_at :datetime not null
# data_source_id :bigint(8)
# dcc_checked_at :datetime
# dcc_meter :enum default("no"), not null
# id :bigint(8) not null, primary key
# low_carbon_hub_installation_id :bigint(8)
# meter_review_id :bigint(8)
# meter_serial_number :text
# meter_system :integer default("nhh_amr")
# meter_type :integer
# mpan_mprn :bigint(8)
# name :string
# procurement_route_id :bigint(8)
# pseudo :boolean default(FALSE)
# school_id :bigint(8) not null
# solar_edge_installation_id :bigint(8)
# updated_at :datetime not null
#
# Indexes
#
# index_meters_on_data_source_id (data_source_id)
# index_meters_on_low_carbon_hub_installation_id (low_carbon_hub_installation_id)
# index_meters_on_meter_review_id (meter_review_id)
# index_meters_on_meter_type (meter_type)
# index_meters_on_mpan_mprn (mpan_mprn) UNIQUE
# index_meters_on_procurement_route_id (procurement_route_id)
# index_meters_on_school_id (school_id)
# index_meters_on_solar_edge_installation_id (solar_edge_installation_id)
#
# Foreign Keys
#
# fk_rails_... (low_carbon_hub_installation_id => low_carbon_hub_installations.id) ON DELETE => cascade
# fk_rails_... (meter_review_id => meter_reviews.id)
# fk_rails_... (school_id => schools.id) ON DELETE => cascade
# fk_rails_... (solar_edge_installation_id => solar_edge_installations.id) ON DELETE => cascade
#
class Meter < ApplicationRecord
belongs_to :school, inverse_of: :meters
belongs_to :low_carbon_hub_installation, optional: true
belongs_to :solar_edge_installation, optional: true
belongs_to :meter_review, optional: true
belongs_to :data_source, optional: true
belongs_to :procurement_route, optional: true
belongs_to :admin_meter_status, foreign_key: 'admin_meter_statuses_id', optional: true
has_one :rtone_variant_installation, required: false
has_many :amr_data_feed_readings, inverse_of: :meter
has_many :amr_validated_readings, inverse_of: :meter, dependent: :destroy
has_many :meter_attributes
has_many :issue_meters, dependent: :destroy
has_many :issues, through: :issue_meters
has_one :school_group, through: :school
has_and_belongs_to_many :energy_tariffs, inverse_of: :meters
CREATABLE_METER_TYPES = [:electricity, :gas, :solar_pv, :exported_solar_pv].freeze
MAIN_METER_TYPES = [:electricity, :gas].freeze
SUB_METER_TYPES = [:solar_pv, :exported_solar_pv].freeze
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
scope :real, -> { where(pseudo: false) }
scope :pseudo, -> { where(pseudo: true) }
scope :main_meter, -> { where(pseudo: false, meter_type: MAIN_METER_TYPES) }
scope :sub_meter, -> { where(pseudo: true, meter_type: SUB_METER_TYPES) }
scope :no_amr_validated_readings, -> { left_outer_joins(:amr_validated_readings).where(amr_validated_readings: { meter_id: nil }) }
scope :unreviewed_dcc_meter, -> { dcc.where(consent_granted: false, meter_review_id: nil) }
scope :reviewed_dcc_meter, -> { dcc.where.not(meter_review_id: nil) }
scope :awaiting_trusted_consent, -> { dcc.where(consent_granted: false).where.not(meter_review: nil) }
scope :not_dcc, -> { where(dcc_meter: :no) }
scope :dcc, -> { where(dcc_meter: %i[smets2 other]) }
scope :consented, -> { dcc.where(consent_granted: true) }
scope :not_recently_checked, -> { where('dcc_checked_at is NULL OR dcc_checked_at < ?', 7.days.ago) }
scope :meters_to_check_against_dcc, -> { main_meter.not_dcc.not_recently_checked }
scope :data_source_known, -> { where.not(data_source: nil) }
scope :procurement_route_known, -> { where.not(procurement_route: nil) }
scope :from_active_schools, -> { joins(:school).where('schools.active = TRUE') }
scope :with_zero_reading_days_and_dates, -> {
left_outer_joins(:amr_validated_readings)
.group('schools.id', 'meters.id')
.select(
"meters.*,
MIN(amr_validated_readings.reading_date) AS first_validated_reading_date,
MAX(amr_validated_readings.reading_date) AS last_validated_reading_date,
COUNT(1) FILTER (WHERE one_day_kwh = 0.0) AS zero_reading_days_count")
}
# If adding a new meter_type, add to the amr_validated_reading case statement for downloading data
enum meter_type: [:electricity, :gas, :solar_pv, :exported_solar_pv]
# The Meter's meter sytem defaults to NHH AMR (Non Half-Hourly Automatic Meter Reading)
# Other options are: NHH (Non Half-Hourly), HH (Half-Hourly), and SMETS2/smart (SMETS2 Smart Meters)
enum meter_system: [:nhh_amr, :nhh, :hh, :smets2_smart]
enum :dcc_meter, %w[no smets2 other].to_h { |v| [v, v] }, prefix: true
delegate :area_name, to: :school
validates_presence_of :school, :mpan_mprn, :meter_type
validates_uniqueness_of :mpan_mprn
validates_format_of :mpan_mprn, with: /\A[6,7,9]\d{13}\Z/, if: :pseudo?, message: 'for pseudo electricity meters should be a 14 digit number starting with 6, 7 or 9'
validates_format_of :mpan_mprn, with: /\A[1-9]{1,3}\d{12}\Z/, if: :real_electric?, message: 'for electricity meters should be a 13 to 14 digit number'
validates_format_of :mpan_mprn, with: /\A\d{1,15}\Z/, if: :gas?, message: 'for gas meters should be a 1-15 digit number'
validate :pseudo_meter_type_not_changed, on: :update, if: :pseudo
validate :pseudo_mpan_mprn_not_changed, on: :update, if: :pseudo
def self.hash_of_meter_data
meter_data_array = Meter.pluck(:mpan_mprn, :meter_type, :school_id)
meter_data_array.to_h { |record| [record[0].to_s, { fuel_type: record[1], school_id: record[2] }]}
end
def school_name
school.name
end
def t_meter_system
I18n.t("meter.meter_system.#{meter_system}")
end
def admin_meter_status_label
for_fuel_type = (fuel_type == :exported_solar_pv ? :solar_pv : fuel_type)
admin_meter_status&.label || school&.school_group&.send(:"admin_meter_status_#{for_fuel_type}")&.label || ''
end
def fuel_type
meter_type.to_sym
end
def self.non_gas_meter_types
Meter.meter_types.keys - ['gas']
end
def number_of_validated_readings
last_reading = last_validated_reading
first_reading = first_validated_reading
return 0 if last_reading.nil?
return (last_reading - first_reading).to_i + 1
end
def first_validated_reading
amr_validated_readings.minimum(:reading_date)
end
def last_validated_reading
amr_validated_readings.maximum(:reading_date)
end
def modified_validated_readings(years = 2)
since_date = Time.zone.today - years.years
amr_validated_readings.since(since_date).modified
end
def gappy_validated_readings(gap_size = 14, years = 2)
since_date = Time.zone.today - years.years
# only interested if there are enough non_ORIG readings
return [] unless amr_validated_readings.since(since_date).modified.count >= gap_size
# find chunks where consecutive readings were all non-ORIG
gaps = amr_validated_readings.since(since_date).by_date.select(:reading_date, :status).chunk_while { |r1, r2| r1.status != 'ORIG' && r2.status != 'ORIG' }
# return chunks of specified size or bigger
gaps.select { |gap| gap.count >= gap_size }
end
def zero_reading_days
amr_validated_readings.where(one_day_kwh: 0)
end
def zero_reading_days_warning?
return true if fuel_type == :electricity && zero_reading_days.any?
end
def has_readings?
amr_validated_readings.any?
end
def name_or_mpan_mprn
name.present? ? name : mpan_mprn.to_s
end
def mpan_mprn_and_name
name.present? ? "#{mpan_mprn} - #{name}" : mpan_mprn
end
def display_name
mpan_mprn_and_name
end
def display_summary(display_name: true, display_data_source: true, display_inactive: false)
output = mpan_mprn.to_s
output += " - #{name}" if display_name && name.present?
output += " - #{data_source.name}" if display_data_source && data_source
output += ' (inactive)' if display_inactive && !active?
output
end
def school_meter_attributes
school.meter_attributes_for(self)
end
def school_group_meter_attributes
school.school_group ? school.school_group.meter_attributes_for(self) : SchoolGroupMeterAttribute.none
end
def global_meter_attributes
GlobalMeterAttribute.for(self)
end
def all_meter_attributes
global_meter_attributes +
school_group_meter_attributes +
school_meter_attributes +
meter_attributes.active +
energy_tariff_meter_attributes
end
def energy_tariff_meter_attributes
attributes = []
school_attributes = school.all_energy_tariff_attributes(meter_type, applies_to_for_meter_system)
attributes += school_attributes unless school_attributes.nil?
# It should NOT filter the tariffs with which it is directly associated.
# If a meter is explicitly linked to a tariff then it applies to it, regardless.
attributes += energy_tariffs.enabled.usable.map(&:meter_attribute)
attributes
end
def applies_to_for_meter_system
return :both unless electricity?
case meter_system.to_sym
when :nhh_amr, :nhh, :smets2_smart then :non_half_hourly
when :hh then :half_hourly
else :both
end
end
def meter_attributes_to_analytics
MeterAttribute.to_analytics(all_meter_attributes)
end
def correct_mpan_check_digit?
return true if gas? || pseudo
mpan = mpan_mprn.to_s.last(13)
primes = [3, 5, 7, 13, 17, 19, 23, 29, 31, 37, 41, 43]
expected_check = (0..11).inject(0) { |sum, n| sum + (mpan[n, 1].to_i * primes[n]) } % 11 % 10
expected_check.to_s == mpan.last
end
def can_grant_consent?
meter_review.present? && !consent_granted
end
def can_withdraw_consent?
consent_granted
end
def open_issues_count
open_issues.count
end
def open_issues_as_list
open_issues.order(created_at: :asc).map { |issue| issue&.description&.body&.to_plain_text }
end
def open_issues
issues&.where(issue_type: 'issue')&.status_open
end
def has_solar_array?
return false unless electricity?
meter_attributes.where(
attribute_type: [:solar_pv_mpan_meter_mapping, :solar_pv], deleted_by: nil, replaced_by: nil
).any?
end
def dcc_meter?
dcc_meter_smets2? || dcc_meter_other?
end
def t_dcc_meter
I18n.t("meter.dcc_meter.#{dcc_meter}")
end
private
def pseudo_mpan_mprn_not_changed
return unless pseudo && mpan_mprn_changed?
errors.add(:mpan_mprn, 'Change of mpan mprn is not allowed for pseudo meters')
end
def pseudo_meter_type_not_changed
return unless pseudo && meter_type_changed?
errors.add(:meter_type, 'Change of meter type is not allowed for pseudo meters')
end
def real_electric?
!gas? && !pseudo?
end
end