• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jufemaiz / aemo / #347

21 Oct 2025 05:37AM UTC coverage: 97.266% (-0.08%) from 97.344%
#347

push

web-flow
Merge 823491a24 into e94462536

1174 of 1207 relevant lines covered (97.27%)

599.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.77
/lib/aemo/nmi.rb
1
# frozen_string_literal: true
2

3
require 'csv'
1✔
4
require 'json'
1✔
5
require 'time'
1✔
6
require 'ostruct'
1✔
7

8
require 'aemo/nmi/allocation'
1✔
9

10
module AEMO
1✔
11
  # [AEMO::NMI]
12
  #
13
  # AEMO::NMI acts as an object to simplify access to data and information
14
  #   about a NMI and provide verification of the NMI value
15
  #
16
  # @author Joel Courtney
17
  # @abstract Model for a National Metering Identifier.
18
  # @since 2014-12-05
19
  class NMI
1✔
20
    # Operational Regions for the NMI
21
    REGIONS = {
1✔
22
      'ACT' => 'Australian Capital Territory',
23
      'NSW' => 'New South Wales',
24
      'QLD' => 'Queensland',
25
      'SA' => 'South Australia',
26
      'TAS' => 'Tasmania',
27
      'VIC' => 'Victoria',
28
      'WA' => 'Western Australia',
29
      'NT' => 'Northern Territory'
30
    }.freeze
31

32
    # Transmission Node Identifier Codes are loaded from a json file
33
    #  Obtained from http://www.nemweb.com.au/
34
    #
35
    #  See /lib/data for further data manipulation required
36
    TNI_CODES = JSON.parse(
1✔
37
      File.read(
38
        File.join(File.dirname(__FILE__), '..', 'data', 'aemo-tni.json')
39
      )
40
    ).freeze
41

42
    # Distribution Loss Factor Codes are loaded from a json file
43
    #  Obtained from MSATS, matching to DNSP from file
44
    # https://www.aemo.com.au/-/media/Files/Electricity/NEM/
45
    #   Security_and_Reliability/Loss_Factors_and_Regional_Boundaries/
46
    #   2016/DLF_V3_2016_2017.pdf
47
    #
48
    #  Last accessed 2017-08-01
49
    #  See /lib/data for further data manipulation required
50
    DLF_CODES = JSON.parse(
1✔
51
      File.read(
52
        File.join(File.dirname(__FILE__), '..', 'data', 'aemo-dlf.json')
53
      )
54
    ).freeze
55

56
    # [String] National Meter Identifier
57
    @nmi                          = nil
1✔
58
    @msats_detail                 = nil
1✔
59
    @tni                          = nil
1✔
60
    @dlf                          = nil
1✔
61
    @customer_classification_code = nil
1✔
62
    @customer_threshold_code      = nil
1✔
63
    @jurisdiction_code            = nil
1✔
64
    @classification_code          = nil
1✔
65
    @status                       = nil
1✔
66
    @address                      = nil
1✔
67
    @meters                       = nil
1✔
68
    @roles                        = nil
1✔
69
    @data_streams                 = nil
1✔
70

71
    attr_accessor :nmi, :msats_detail, :tni, :dlf,
1✔
72
                  :customer_classification_code, :customer_threshold_code,
73
                  :jurisdiction_code, :classification_code, :status, :address,
74
                  :meters, :roles, :data_streams
75

76
    class << self
1✔
77
      # A function to validate the NMI provided
78
      #
79
      # @param [String] nmi the nmi to be checked
80
      # @return [Boolean] whether or not the nmi is valid
81
      def valid_nmi?(nmi)
1✔
82
        (nmi.length == 10) && !nmi.match(/^([A-HJ-NP-Z\d]{10})/).nil?
1,513✔
83
      end
84

85
      # A function to calculate the checksum value for a given National Meter
86
      # Identifier
87
      #
88
      # @param [String] nmi the NMI to check the checksum against
89
      # @param [Integer] checksum_value the checksum value to check against the
90
      #   current National Meter Identifier's checksum value
91
      # @return [Boolean] whether or not the checksum is valid
92
      def valid_checksum?(nmi, checksum_value)
1✔
93
        nmi = AEMO::NMI.new(nmi)
30✔
94
        nmi.valid_checksum?(checksum_value)
30✔
95
      end
96

97
      # Find the Network for a given NMI
98
      #
99
      # @param [String] nmi NMI
100
      # @returns [AEMO::NMI::Allocation] The Network information
101
      def network(nmi)
1✔
102
        AEMO::NMI::Allocation.find_by_nmi(nmi)
117✔
103
      end
104

105
      alias allocation network
1✔
106
    end
107

108
    # Initialize a NMI file
109
    #
110
    # @param [String] nmi the National Meter Identifier (NMI)
111
    # @param [Hash] options a hash of options
112
    # @option options [Hash] :msats_detail MSATS details as per
113
    #   #parse_msats_detail requirements
114
    # @return [AEMO::NMI] an instance of AEMO::NMI is returned
115
    def initialize(nmi, options = {})
1✔
116
      raise ArgumentError, 'NMI is not a string' unless nmi.is_a?(String)
846✔
117
      raise ArgumentError, 'NMI is not 10 characters' unless nmi.length == 10
846✔
118
      raise ArgumentError, 'NMI is not constructed with valid characters' unless AEMO::NMI.valid_nmi?(nmi)
845✔
119

120
      @nmi          = nmi
843✔
121
      @meters       = []
843✔
122
      @roles        = {}
843✔
123
      @data_streams = []
843✔
124
      @msats_detail = options[:msats_detail]
843✔
125

126
      parse_msats_detail unless @msats_detail.nil?
843✔
127
    end
128

129
    # A function to validate the instance's nmi value
130
    #
131
    # @return [Boolean] whether or not the nmi is valid
132
    def valid_nmi?
1✔
133
      AEMO::NMI.valid_nmi?(@nmi)
30✔
134
    end
135

136
    # Find the Network of NMI
137
    #
138
    # @returns [Hash] The Network information
139
    def network
1✔
140
      AEMO::NMI.network(@nmi)
113✔
141
    end
142

143
    alias allocation network
1✔
144

145
    # A function to calculate the checksum value for a given
146
    # National Meter Identifier
147
    #
148
    # @param [Integer] checksum_value the checksum value to check against the
149
    #   current National Meter Identifier's checksum value
150
    # @return [Boolean] whether or not the checksum is valid
151
    def valid_checksum?(checksum_value)
1✔
152
      checksum_value == checksum
90✔
153
    end
154

155
    # Checksum is a function to calculate the checksum value for a given
156
    # National Meter Identifier
157
    #
158
    # @return [Integer] the checksum value for the current National Meter
159
    #   Identifier
160
    def checksum
1✔
161
      summation = 0
125✔
162
      @nmi.reverse.chars.each_index do |i|
125✔
163
        value = nmi[nmi.length - i - 1].ord
1,250✔
164
        value *= 2 if i.even?
1,250✔
165
        value = value.to_s.chars.map(&:to_i).reduce(:+)
1,250✔
166
        summation += value
1,250✔
167
      end
168
      (10 - (summation % 10)) % 10
125✔
169
    end
170

171
    # Provided MSATS is configured, gets the MSATS data for the NMI
172
    #
173
    # @return [Hash] MSATS NMI Detail data
174
    def raw_msats_nmi_detail(options = {})
1✔
175
      raise ArgumentError, 'MSATS has no authentication credentials' unless AEMO::MSATS.can_authenticate?
2✔
176

177
      AEMO::MSATS.nmi_detail(@nmi, options)
2✔
178
    end
179

180
    # Provided MSATS is configured, uses the raw MSATS data to augment NMI
181
    # information
182
    #
183
    # @return [self] returns self
184
    def update_from_msats!(options = {})
1✔
185
      # Update local cache
186
      @msats_detail = raw_msats_nmi_detail(options)
1✔
187
      parse_msats_detail
1✔
188
      self
1✔
189
    end
190

191
    # Turns raw MSATS junk into useful things
192
    #
193
    # @return [self] returns self
194
    def parse_msats_detail
1✔
195
      # Set the details if there are any
196
      unless @msats_detail['MasterData'].nil?
2✔
197
        @tni                          = @msats_detail['MasterData']['TransmissionNodeIdentifier']
1✔
198
        @dlf                          = @msats_detail['MasterData']['DistributionLossFactorCode']
1✔
199
        @customer_classification_code = @msats_detail['MasterData']['CustomerClassificationCode']
1✔
200
        @customer_threshold_code      = @msats_detail['MasterData']['CustomerThresholdCode']
1✔
201
        @jurisdiction_code            = @msats_detail['MasterData']['JurisdictionCode']
1✔
202
        @classification_code          = @msats_detail['MasterData']['NMIClassificationCode']
1✔
203
        @status                       = @msats_detail['MasterData']['Status']
1✔
204
        @address                      = @msats_detail['MasterData']['Address']
1✔
205
      end
206
      @meters                       ||= []
2✔
207
      @roles                        ||= {}
2✔
208
      @data_streams                 ||= []
2✔
209
      # Meters
210
      unless @msats_detail['MeterRegister'].nil?
2✔
211
        meters = @msats_detail['MeterRegister']['Meter']
1✔
212
        meters = [meters] if meters.is_a?(Hash)
1✔
213
        meters.reject { |x| x['Status'].nil? }.each do |meter|
5✔
214
          @meters << AEMO::Meter.from_hash(meter)
1✔
215
        end
216
        meters.select { |x| x['Status'].nil? }.each do |registers|
5✔
217
          m = @meters.find { |x| x.serial_number == registers['SerialNumber'] }
6✔
218
          m.registers << AEMO::Register.from_hash(
3✔
219
            registers['RegisterConfiguration']['Register']
220
          )
221
        end
222
      end
223
      # Roles
224
      unless @msats_detail['RoleAssignments'].nil?
2✔
225
        role_assignments = @msats_detail['RoleAssignments']['RoleAssignment']
1✔
226
        role_assignments = [role_assignments] if role_assignments.is_a?(Hash)
1✔
227
        role_assignments.each do |role|
1✔
228
          @roles[role['Role']] = role['Party']
4✔
229
        end
230
      end
231
      # DataStreams
232
      unless @msats_detail['DataStreams'].nil?
2✔
233
        data_streams = @msats_detail['DataStreams']['DataStream']
1✔
234
        data_streams = [data_streams] if data_streams.is_a?(Hash) # Deal with issue of only one existing
1✔
235
        data_streams.each do |stream|
1✔
236
          @data_streams << Struct::DataStream.new(
3✔
237
            suffix: stream['Suffix'],
238
            profile_name: stream['ProfileName'],
239
            averaged_daily_load: stream['AveragedDailyLoad'],
240
            data_stream_type: stream['DataStreamType'],
241
            status: stream['Status']
242
          )
243
        end
244
      end
245
      self
2✔
246
    end
247

248
    # Returns a nice address from the structured one AEMO sends us
249
    #
250
    # @return [String]
251
    def friendly_address
1✔
252
      friendly_address = ''
3✔
253
      if @address.is_a?(Hash)
3✔
254
        friendly_address = @address.values.map do |x|
2✔
255
          if x.is_a?(Hash)
6✔
256
            x = x.values.map { |y| y.is_a?(Hash) ? y.values.join(' ') : y }.join(' ')
3✔
257
          end
258
          x
6✔
259
        end.join(', ')
260
      end
261
      friendly_address
3✔
262
    end
263

264
    # Returns the meters for the requested status (C/R)
265
    #
266
    # @param [String] status the stateus [C|R]
267
    # @return [Array<AEMO::Meter>] Returns an array of AEMO::Meters with the
268
    #   status provided
269
    def meters_by_status(status = 'C')
1✔
270
      @meters.select { |x| x.status == status.to_s }
9✔
271
    end
272

273
    # Returns the data_stream Structs for the requested status (A/I)
274
    #
275
    # @param [String] status the stateus [A|I]
276
    # @return [Array<Struct>] Returns an array of Structs for the
277
    #   current Meters
278
    def data_streams_by_status(status = 'A')
1✔
279
      @data_streams.select { |x| x.status == status.to_s }
2✔
280
    end
281

282
    # The current daily load in kWh
283
    #
284
    # @return [Integer] the current daily load for the meter in kWh
285
    def current_daily_load
1✔
286
      data_streams_by_status.map { |x| x.averaged_daily_load.to_i }
2✔
287
                            .inject(0, :+)
288
    end
289

290
    # The current annual load in MWh
291
    #
292
    # @todo Use TimeDifference for more accurate annualised load
293
    # @return [Integer] the current annual load for the meter in MWh
294
    def current_annual_load
1✔
295
      (current_daily_load * 365.2425 / 1000).to_i
1✔
296
    end
297

298
    # A function to return the distribution loss factor value for a given date
299
    #
300
    # @param [DateTime, ::Time] datetime the date for the distribution loss factor
301
    #   value
302
    # @return [nil, float] the distribution loss factor value
303
    def dlfc_value(datetime = ::Time.now)
1✔
304
      if @dlf.nil?
17✔
305
        raise 'No DLF set, ensure that you have set the value either via the' \
×
306
              'update_from_msats! function or manually'
307
      end
308
      raise 'DLF is invalid' unless DLF_CODES.keys.include?(@dlf)
17✔
309
      raise 'Invalid date' unless [DateTime, ::Time].include?(datetime.class)
17✔
310

311
      possible_values = DLF_CODES[@dlf].select do |x|
17✔
312
        datetime.between?(::Time.parse(x['FromDate']), ::Time.parse(x['ToDate']))
255✔
313
      end
314
      if possible_values.empty?
17✔
315
        nil
×
316
      else
317
        possible_values.first['Value'].to_f
17✔
318
      end
319
    end
320

321
    # A function to return the distribution loss factor value for a given date
322
    #
323
    # @param [DateTime, ::Time] start the date for the distribution loss factor value
324
    # @param [DateTime, ::Time] finish the date for the distribution loss factor value
325
    # @return [Array(Hash)] array of hashes of start, finish and value
326
    def dlfc_values(start = ::Time.now, finish = ::Time.now)
1✔
327
      if @dlf.nil?
1✔
328
        raise 'No DLF set, ensure that you have set the value either via the ' \
×
329
              'update_from_msats! function or manually'
330
      end
331
      raise 'DLF is invalid' unless DLF_CODES.keys.include?(@dlf)
1✔
332
      raise 'Invalid start' unless [DateTime, ::Time].include?(start.class)
1✔
333
      raise 'Invalid finish' unless [DateTime, ::Time].include?(finish.class)
1✔
334
      raise 'start cannot be after finish' if start > finish
1✔
335

336
      DLF_CODES[@dlf].reject { |x| start > ::Time.parse(x['ToDate']) || finish < ::Time.parse(x['FromDate']) }
16✔
337
                     .map { |x| { 'start' => x['FromDate'], 'finish' => x['ToDate'], 'value' => x['Value'].to_f } }
1✔
338
    end
339

340
    # A function to return the transmission node identifier loss factor value for a given date
341
    #
342
    # @param [DateTime, ::Time] datetime the date for the distribution loss factor value
343
    # @return [nil, float] the transmission node identifier loss factor value
344
    def tni_value(datetime = ::Time.now)
1✔
345
      if @tni.nil?
6✔
346
        raise 'No TNI set, ensure that you have set the value either via the ' \
×
347
              'update_from_msats! function or manually'
348
      end
349
      raise 'TNI is invalid' unless TNI_CODES.keys.include?(@tni)
6✔
350
      raise 'Invalid date' unless [DateTime, ::Time].include?(datetime.class)
6✔
351

352
      possible_values = TNI_CODES[@tni].select do |x|
6✔
353
        datetime.between?(::Time.parse(x['FromDate']), ::Time.parse(x['ToDate']))
12✔
354
      end
355
      return nil if possible_values.empty?
6✔
356

357
      possible_values = possible_values.first['mlf_data']['loss_factors'].select do |x|
6✔
358
        datetime.between?(::Time.parse(x['start']), ::Time.parse(x['finish']))
30✔
359
      end
360
      return nil if possible_values.empty?
6✔
361

362
      possible_values.first['value'].to_f
6✔
363
    end
364

365
    # A function to return the transmission node identifier loss factor value for a given date
366
    #
367
    # @param [DateTime, ::Time] start the date for the distribution loss factor value
368
    # @param [DateTime, ::Time] finish the date for the distribution loss factor value
369
    # @return [Array(Hash)] array of hashes of start, finish and value
370
    def tni_values(start = ::Time.now, finish = ::Time.now)
1✔
371
      if @tni.nil?
1✔
372
        raise 'No TNI set, ensure that you have set the value either via the ' \
×
373
              'update_from_msats! function or manually'
374
      end
375
      raise 'TNI is invalid' unless TNI_CODES.keys.include?(@tni)
1✔
376
      raise 'Invalid start' unless [DateTime, ::Time].include?(start.class)
1✔
377
      raise 'Invalid finish' unless [DateTime, ::Time].include?(finish.class)
1✔
378
      raise 'start cannot be after finish' if start > finish
1✔
379

380
      possible_values = TNI_CODES[@tni].reject do |tni_code|
1✔
381
        start > ::Time.parse(tni_code['ToDate']) ||
2✔
382
          finish < ::Time.parse(tni_code['FromDate'])
383
      end
384

385
      return nil if possible_values.empty?
1✔
386

387
      possible_values.map { |x| x['mlf_data']['loss_factors'] }
2✔
388
    end
389
  end
390
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc