aemo 0.7.2 → 0.8.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.
data/lib/aemo/nem12.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'csv'
4
4
  require 'time'
5
5
 
6
+ require 'aemo/meter_data/flag'
6
7
  require 'aemo/nem12/data_stream_suffix'
7
8
  require 'aemo/nem12/quality_method'
8
9
  require 'aemo/nem12/reason_codes'
@@ -17,6 +18,11 @@ module AEMO
17
18
  CRLF = "\r\n"
18
19
  CSV_SEPARATOR = ','
19
20
 
21
+ # Backward compatibility: delegate constants to Flag class
22
+ QUALITY_FLAGS = ::AEMO::MeterData::Flag::QUALITY_FLAGS
23
+ METHOD_FLAGS = ::AEMO::MeterData::Flag::METHOD_FLAGS
24
+ REASON_CODES = ::AEMO::MeterData::Flag::REASON_CODES
25
+
20
26
  @file_contents = nil
21
27
  @header = nil
22
28
  @nmi_data_details = []
@@ -258,17 +264,8 @@ module AEMO
258
264
  end
259
265
  end
260
266
 
261
- # Deal with flags if necessary
262
- flag = nil
263
- # Based on QualityMethod and ReasonCode
264
- if csv[intervals_offset + 0].length == 3 || !csv[intervals_offset + 1].nil?
265
- flag ||= { quality_flag: nil, method_flag: nil, reason_code: nil }
266
- if csv[intervals_offset + 0].length == 3
267
- flag[:quality_flag] = csv[intervals_offset + 0][0]
268
- flag[:method_flag] = csv[intervals_offset + 0][1, 2].to_i
269
- end
270
- flag[:reason_code] = csv[intervals_offset + 1].to_i unless csv[intervals_offset + 1].nil?
271
- end
267
+ # Deal with flags - explicitly set all flag values
268
+ flag = AEMO::MeterData::Flag.from_quality_method_reason_code(quality_method: csv[intervals_offset + 0], reason_code: csv[intervals_offset + 1], validate: strict)
272
269
 
273
270
  # Deal with updated_at & msats_load_at
274
271
  updated_at = nil
@@ -302,7 +299,7 @@ module AEMO
302
299
  # @param [String] line A single line in string format
303
300
  # @param [Boolean] strict
304
301
  # @return [Hash] the line parsed into a hash of information
305
- def parse_nem12_400(line, strict: true) # rubocop:disable Lint/UnusedMethodArgument,Naming/VariableNumber
302
+ def parse_nem12_400(line, strict: true) # rubocop:disable Naming/VariableNumber
306
303
  csv = line.parse_csv
307
304
  raise ArgumentError, 'RecordIndicator is not 400' if csv[0] != '400'
308
305
  raise ArgumentError, 'StartInterval is not valid' if csv[1].nil? || csv[1].match(/^\d+$/).nil?
@@ -318,32 +315,30 @@ module AEMO
318
315
 
319
316
  interval_events = []
320
317
 
321
- # Only need to update flags for EFSV
322
- unless %w[A N].include? csv[3]
323
- number_of_intervals = 1440 / @data_details.last[:interval_length]
324
- interval_start_point = @interval_data.length - number_of_intervals
325
-
326
- # For each of these
327
- base_interval_event = { datetime: nil, quality_method: csv[3], reason_code: csv[4]&.to_i,
328
- reason_description: csv[5] }
329
-
330
- # Interval Numbers are 1-indexed
331
- ((csv[1].to_i)..(csv[2].to_i)).each do |i|
332
- interval_event = base_interval_event.dup
333
- interval_event[:datetime] = @interval_data[interval_start_point + (i - 1)][:datetime]
334
- interval_events << interval_event
335
- # Create flag details
336
- flag ||= { quality_flag: nil, method_flag: nil, reason_code: nil }
337
- unless interval_event[:quality_method].nil?
338
- flag[:quality_flag] = interval_event[:quality_method][0]
339
- flag[:method_flag] = interval_event[:quality_method][1, 2].to_i
340
- end
341
- flag[:reason_code] = interval_event[:reason_code] unless interval_event[:reason_code].nil?
342
- # Update with flag details
343
- @interval_data[interval_start_point + (i - 1)][:flag] = flag
344
- end
345
- @interval_events += interval_events
318
+ number_of_intervals = 1440 / @data_details.last[:interval_length]
319
+ interval_start_point = @interval_data.length - number_of_intervals
320
+
321
+ # For each of these
322
+ # Parse reason code - only set if present and not empty
323
+ parsed_reason_code = nil
324
+ parsed_reason_code = csv[4].to_i unless csv[4].nil? || csv[4].empty?
325
+
326
+ base_interval_event = { datetime: nil, quality_method: csv[3], reason_code: parsed_reason_code,
327
+ reason_description: csv[5] }
328
+
329
+ # Interval Numbers are 1-indexed
330
+ ((csv[1].to_i)..(csv[2].to_i)).each do |i|
331
+ interval_event = base_interval_event.dup
332
+ interval_event[:datetime] = @interval_data[interval_start_point + (i - 1)][:datetime]
333
+ interval_events << interval_event
334
+
335
+ flag = AEMO::MeterData::Flag.from_quality_method_reason_code(quality_method: interval_event[:quality_method], reason_code: interval_event[:reason_code], validate: strict)
336
+
337
+ # Update with flag details
338
+ @interval_data[interval_start_point + (i - 1)][:flag] = flag
346
339
  end
340
+ @interval_events += interval_events
341
+
347
342
  interval_events
348
343
  end
349
344
 
@@ -361,20 +356,6 @@ module AEMO
361
356
  # @return [Hash] the line parsed into a hash of information
362
357
  def parse_nem12_900(_line, strict: true); end # rubocop:disable Naming/VariableNumber
363
358
 
364
- # Turns the flag to a string
365
- #
366
- # @param [Hash] flag the object of a flag
367
- # @return [nil, String] a hyphenated string for the flag or nil
368
- def flag_to_s(flag)
369
- flag_to_s = []
370
- unless flag.nil?
371
- flag_to_s << QUALITY_FLAGS[flag[:quality_flag]] unless QUALITY_FLAGS[flag[:quality_flag]].nil?
372
- flag_to_s << METHOD_FLAGS[flag[:method_flag]][:short_descriptor] unless METHOD_FLAGS[flag[:method_flag]].nil?
373
- flag_to_s << REASON_CODES[flag[:reason_code]] unless REASON_CODES[flag[:reason_code]].nil?
374
- end
375
- flag_to_s.empty? ? nil : flag_to_s.join(' - ')
376
- end
377
-
378
359
  # @return [Array] array of a NEM12 file a given Meter + Data Stream for easy reading
379
360
  def to_a
380
361
  @interval_data.map do |d|
@@ -384,7 +365,7 @@ module AEMO
384
365
  d[:data_details][:uom],
385
366
  d[:datetime],
386
367
  d[:value],
387
- flag_to_s(d[:flag])
368
+ d[:flag]&.to_s
388
369
  ]
389
370
  end
390
371
  end
@@ -462,20 +443,36 @@ module AEMO
462
443
  end
463
444
  daily_datas.keys.sort.each do |key|
464
445
  daily_data = daily_datas[key].sort_by { |x| x[:datetime] }
465
- has_flags = daily_data.map { |x| x[:flag]&.any? }.uniq.include?(true)
446
+
447
+ # Check if all intervals have identical flags
448
+ # If so, use that flag's quality method in the 300 record
449
+ # Otherwise use 'V' (variable data) and generate 400 records
450
+ first_flag = daily_data.first[:flag]
451
+ all_same_flag = daily_data.all? { |x| x[:flag] == first_flag }
452
+
453
+ if all_same_flag
454
+ # All intervals have the same flag - use it in the 300 record
455
+ quality_method = first_flag&.to_quality_method || ''
456
+ reason_code = first_flag&.reason_code || ''
457
+ else
458
+ # Intervals have different flags - use 'V' and generate 400 records
459
+ quality_method = 'V'
460
+ reason_code = ''
461
+ end
466
462
 
467
463
  lines << [
468
464
  '300',
469
465
  key,
470
466
  daily_data.map { |x| x[:value] },
471
- has_flags ? 'V' : 'A',
472
- '',
467
+ quality_method,
468
+ reason_code,
473
469
  '',
474
470
  daily_data.first[:updated_at] ? AEMO::Time.format_timestamp14(daily_data.first[:updated_at]) : nil,
475
471
  daily_data.first[:msats_load_at] ? AEMO::Time.format_timestamp14(daily_data.first[:msats_load_at]) : nil
476
472
  ].flatten.join(CSV_SEPARATOR)
477
473
 
478
- next unless has_flags
474
+ # Generate 400 records only if we have variable data
475
+ next if all_same_flag
479
476
 
480
477
  lines << to_nem12_400_csv(daily_data:)
481
478
  end
@@ -504,12 +501,25 @@ module AEMO
504
501
  end
505
502
 
506
503
  nem12_400_rows.map do |row|
504
+ flag = row[:flag]
505
+ # Default to 'A' if flag is nil, otherwise use explicit quality flag
506
+ quality_flag = flag.nil? ? 'A' : flag.quality_flag
507
+ method_flag = flag&.method_flag
508
+ reason_code = flag&.reason_code
509
+
510
+ # Build quality method string (quality flag + optional method flag)
511
+ quality_method = if method_flag.nil?
512
+ quality_flag
513
+ else
514
+ "#{quality_flag}#{format('%02d', method_flag)}"
515
+ end
516
+
507
517
  [
508
518
  '400',
509
519
  row[:start_index],
510
520
  row[:finish_index],
511
- row[:flag].nil? ? 'A' : "#{row[:flag][:quality_flag]}#{row[:flag][:method_flag]}",
512
- row[:flag].nil? ? '' : row[:flag][:reason_code],
521
+ quality_method,
522
+ reason_code || '',
513
523
  ''
514
524
  ].join(CSV_SEPARATOR)
515
525
  end.join(CRLF)
data/lib/aemo/version.rb CHANGED
@@ -23,7 +23,7 @@
23
23
  # @author Joel Courtney <euphemize@gmail.com>
24
24
  module AEMO
25
25
  # aemo version
26
- VERSION = '0.7.2'
26
+ VERSION = '0.8.0'
27
27
 
28
28
  # aemo version split amongst different revisions
29
29
  MAJOR_VERSION, MINOR_VERSION, REVISION = VERSION.split('.').map(&:to_i)
@@ -40,39 +40,39 @@ end
40
40
  # Now to create the DLF and TNI output JSON files for use
41
41
  @files.select { |x| ['aemo-tni.xml', 'aemo-dlf.xml'].include?(x) }
42
42
  .each do |file|
43
- output_file = file.gsub('.xml', '.json')
44
- output_data = {}
45
- open_file = File.open(File.join(@path, file))
46
- xml = Nokogiri::XML(open_file) do |c|
47
- c.options = Nokogiri::XML::ParseOptions::NOBLANKS
48
- end
49
- open_file.close
43
+ output_file = file.gsub('.xml', '.json')
44
+ output_data = {}
45
+ open_file = File.open(File.join(@path, file))
46
+ xml = Nokogiri::XML(open_file) do |c|
47
+ c.options = Nokogiri::XML::ParseOptions::NOBLANKS
48
+ end
49
+ open_file.close
50
50
 
51
- xml.xpath('//Row').each do |row|
52
- row_children = row.children
53
- code = row_children.find { |x| x.name == 'Code' }.children.first.text
54
- output_data[code] ||= []
55
- output_data_instance = {}
56
- row_children.each do |row_child|
57
- output_data_instance[row_child.name] = row_child.children.first.text
58
- end
59
- if file =~ /tni/
60
- puts "output_data_instance: #{output_data_instance.inspect}"
61
- output_data_instance[:mlf_data] = {}
62
- unless @mlf_data[code].nil?
63
- output_data_instance[:mlf_data] = @mlf_data[code].deep_dup
64
- output_data_instance[:mlf_data][:loss_factors].reject! do |x|
65
- Time.parse(output_data_instance['ToDate']) < x[:start] ||
66
- Time.parse(output_data_instance['FromDate']) >= x[:finish]
51
+ xml.xpath('//Row').each do |row|
52
+ row_children = row.children
53
+ code = row_children.find { |x| x.name == 'Code' }.children.first.text
54
+ output_data[code] ||= []
55
+ output_data_instance = {}
56
+ row_children.each do |row_child|
57
+ output_data_instance[row_child.name] = row_child.children.first.text
58
+ end
59
+ if file =~ /tni/
60
+ puts "output_data_instance: #{output_data_instance.inspect}"
61
+ output_data_instance[:mlf_data] = {}
62
+ unless @mlf_data[code].nil?
63
+ output_data_instance[:mlf_data] = @mlf_data[code].deep_dup
64
+ output_data_instance[:mlf_data][:loss_factors].reject! do |x|
65
+ Time.parse(output_data_instance['ToDate']) < x[:start] ||
66
+ Time.parse(output_data_instance['FromDate']) >= x[:finish]
67
+ end
68
+ puts 'output_data_instance[:mlf_data][:loss_factors]: ' \
69
+ "#{output_data_instance[:mlf_data][:loss_factors].inspect}"
70
+ end
71
+ elsif file =~ /dlf/
72
+ output_data_instance[:nsp_code] = @dlf_data[code]
73
+ end
74
+ output_data[code] << output_data_instance
67
75
  end
68
- puts 'output_data_instance[:mlf_data][:loss_factors]: ' \
69
- "#{output_data_instance[:mlf_data][:loss_factors].inspect}"
70
- end
71
- elsif file =~ /dlf/
72
- output_data_instance[:nsp_code] = @dlf_data[code]
73
- end
74
- output_data[code] << output_data_instance
75
- end
76
76
 
77
- File.write(File.join(@path, output_file), output_data.to_json)
77
+ File.write(File.join(@path, output_file), output_data.to_json)
78
78
  end
@@ -0,0 +1,14 @@
1
+ 100,NEM12,200505181432,ENOSI,NEMMCO
2
+ 200,4123456789,E1B1,E1,E1,E1,B1234567,KWH,5,20260211
3
+ 300,20251227,0.5120,0.4930,0.4170,0.3730,0.3850,0.4170,0.3900,0.4020,0.3910,0.3730,0.4830,0.5080,0.4060,0.4040,0.3640,0.3780,0.3070,0.2620,0.3280,0.2940,0.2930,0.3320,0.2960,0.3290,0.3060,0.2550,0.2920,0.2890,0.4040,0.4200,0.4030,0.4130,0.3780,0.3430,0.4050,0.3440,0.3590,0.3320,0.3050,0.3180,0.2850,0.2770,0.3250,0.2960,0.3140,0.3390,0.2970,0.3240,0.3060,0.3010,0.3900,0.3250,0.3110,0.3310,0.3200,0.3810,0.3060,0.2720,0.3150,0.2980,0.2760,0.2470,0.2160,0.3190,0.3180,0.3060,0.3310,0.2980,0.3140,0.3180,0.2420,0.2760,0.2570,0.2330,0.2700,0.2430,0.2530,0.2610,0.2200,0.3910,0.3660,0.3130,0.2800,0.2280,0.2330,0.2430,0.1850,0.2040,0.2230,0.1830,0.1930,0.1150,0.0730,0.0940,0.0230,0.0070,0.0040,0.0040,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0320,0.0090,0.1130,0.0280,0.0230,0.0000,0.0000,0.0040,0.0010,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.1710,0.0000,0.0000,0.1780,0.0000,0.0000,0.1930,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0190,0.0020,0.0000,0.0000,0.0000,0.0050,0.0140,0.0220,0.0130,0.0640,0.0440,0.0700,0.2380,0.1860,0.1960,0.2000,0.1410,0.0580,0.0170,0.0030,0.0170,0.0750,0.0370,0.0820,0.1060,0.1000,0.1240,0.0900,0.1430,0.1460,0.1510,0.2060,0.1830,0.2180,0.2280,0.1970,0.2380,0.2190,0.1940,0.2420,0.2130,0.2200,0.2300,0.1940,0.2660,0.4730,0.4500,0.5530,0.5600,0.5680,0.6040,0.5620,0.5870,0.6150,0.5770,0.6100,0.6000,0.5710,0.6140,0.5810,0.5820,0.6090,0.5720,0.6590,0.6520,0.7140,0.8750,0.7740,0.8690,0.9730,0.9240,1.0040,0.9260,0.9540,0.9420,0.9540,0.9040,0.9130,0.8790,0.9200,0.8610,0.8650,0.9030,0.8840,0.9090,0.8910,0.7720,0.7800,0.7550,0.7470,0.8830,0.8760,0.8310,0.7710,0.7040,0.7240,0.7540,0.8530,0.8490,0.7540,0.7180,V,,,20251228011206,
4
+ 400,1,60,A,,
5
+ 400,61,61,A,79,
6
+ 400,62,288,A,,
7
+ 500,O,,20251228000000,079499.641
8
+ 200,4123456789,E1B1,B1,B1,B1,B1234567,KWH,5,20260211
9
+ 300,20251227,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0170,0.0400,0.0540,0.1690,0.1730,0.2440,0.2320,0.2400,0.3070,0.2730,0.0240,0.0480,0.0170,0.0340,0.1590,0.3290,0.4210,0.1430,0.1240,0.1670,0.2100,0.3860,0.3710,0.2060,0.1680,0.3940,0.4270,0.3920,0.4480,0.4470,0.4280,0.4480,0.4390,0.4310,0.0640,0.3180,0.4450,0.0430,0.3560,0.4490,0.0590,0.3600,0.5000,0.4820,0.5650,0.5720,0.5390,0.5650,0.5430,0.5470,0.5780,0.5330,0.5690,0.5760,0.5530,0.6200,0.5850,0.5880,0.5870,0.5780,0.6130,0.6050,0.5490,0.6050,0.5570,0.4580,0.4770,0.5630,0.5390,0.5510,0.5830,0.4430,0.5850,0.5460,0.5410,0.5600,0.4840,0.4970,0.4790,0.3430,0.1860,0.2020,0.1000,0.0410,0.1300,0.1250,0.1820,0.1490,0.2100,0.1510,0.1440,0.1530,0.0960,0.0860,0.0860,0.0540,0.0810,0.0150,0.0500,0.0150,0.0180,0.0140,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0210,0.0150,0.0410,0.0290,0.0220,0.0000,0.0040,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,0.0000,V,,,20251228011206,
10
+ 400,1,60,A,,
11
+ 400,61,61,A,79,
12
+ 400,62,288,A,,
13
+ 500,O,,20251228000000,027575.424
14
+ 900
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe AEMO::MeterData::Flag do
6
+ describe '.new' do
7
+ it 'creates a flag with all attributes' do
8
+ flag = described_class.new(quality_flag: 'E', method_flag: 52, reason_code: 1)
9
+ expect(flag.quality_flag).to eq('E')
10
+ expect(flag.method_flag).to eq(52)
11
+ expect(flag.reason_code).to eq(1)
12
+ end
13
+
14
+ it 'creates a flag with nil attributes' do
15
+ flag = described_class.new
16
+ expect(flag.quality_flag).to be_nil
17
+ expect(flag.method_flag).to be_nil
18
+ expect(flag.reason_code).to be_nil
19
+ end
20
+
21
+ it 'validates quality flag' do
22
+ expect { described_class.new(quality_flag: 'X') }.to raise_error(ArgumentError, /Invalid quality flag/)
23
+ end
24
+
25
+ it 'validates method flag' do
26
+ expect { described_class.new(method_flag: 999) }.to raise_error(ArgumentError, /Invalid method flag/)
27
+ end
28
+
29
+ it 'validates reason code' do
30
+ expect { described_class.new(reason_code: 999) }.to raise_error(ArgumentError, /Invalid reason code/)
31
+ end
32
+
33
+ it 'validates flag combinations' do
34
+ expect { described_class.new(quality_flag: 'E') }.to raise_error(ArgumentError, /requires a method flag/)
35
+ end
36
+ end
37
+
38
+ describe '.from_hash' do
39
+ it 'creates a flag from a hash' do
40
+ flag = described_class.from_hash({ quality_flag: 'E', method_flag: 52, reason_code: 1 })
41
+ expect(flag.quality_flag).to eq('E')
42
+ expect(flag.method_flag).to eq(52)
43
+ expect(flag.reason_code).to eq(1)
44
+ end
45
+
46
+ it 'returns nil for nil input' do
47
+ expect(described_class.from_hash(nil)).to be_nil
48
+ end
49
+
50
+ it 'returns the flag if already a Flag instance' do
51
+ flag = described_class.new(quality_flag: 'A')
52
+ expect(described_class.from_hash(flag)).to eq(flag)
53
+ end
54
+ end
55
+
56
+ describe '.normalize' do
57
+ it 'normalizes nil to actual data flag' do
58
+ flag = described_class.normalize(nil)
59
+ expect(flag.quality_flag).to eq('A')
60
+ expect(flag.method_flag).to be_nil
61
+ expect(flag.reason_code).to be_nil
62
+ end
63
+
64
+ it 'removes method flag from A quality flag' do
65
+ flag = described_class.normalize({ quality_flag: 'A', method_flag: 52 })
66
+ expect(flag.quality_flag).to eq('A')
67
+ expect(flag.method_flag).to be_nil
68
+ end
69
+
70
+ it 'removes method flag and reason code from V quality flag' do
71
+ flag = described_class.normalize({ quality_flag: 'V', method_flag: 52, reason_code: 1 })
72
+ expect(flag.quality_flag).to eq('V')
73
+ expect(flag.method_flag).to be_nil
74
+ expect(flag.reason_code).to be_nil
75
+ end
76
+
77
+ it 'preserves E quality flag with method flag' do
78
+ flag = described_class.normalize({ quality_flag: 'E', method_flag: 52 })
79
+ expect(flag.quality_flag).to eq('E')
80
+ expect(flag.method_flag).to eq(52)
81
+ end
82
+ end
83
+
84
+ describe '#to_quality_method' do
85
+ it 'returns A for nil quality flag' do
86
+ flag = described_class.new
87
+ expect(flag.to_quality_method).to eq('A')
88
+ end
89
+
90
+ it 'returns quality flag when method flag is nil' do
91
+ flag = described_class.new(quality_flag: 'A')
92
+ expect(flag.to_quality_method).to eq('A')
93
+ end
94
+
95
+ it 'returns quality flag with formatted method flag' do
96
+ flag = described_class.new(quality_flag: 'E', method_flag: 52)
97
+ expect(flag.to_quality_method).to eq('E52')
98
+ end
99
+ end
100
+
101
+ describe '#to_s' do
102
+ it 'returns nil for nil flag' do
103
+ flag = described_class.new
104
+ expect(flag.to_s).to be_nil
105
+ end
106
+
107
+ it 'returns quality flag description' do
108
+ flag = described_class.new(quality_flag: 'A')
109
+ expect(flag.to_s).to eq('Actual Data')
110
+ end
111
+
112
+ it 'returns quality flag and method flag description' do
113
+ flag = described_class.new(quality_flag: 'E', method_flag: 52)
114
+ expect(flag.to_s).to eq('Forward Estimated Data - Previous Read')
115
+ end
116
+
117
+ it 'returns quality flag, method flag, and reason code description' do
118
+ flag = described_class.new(quality_flag: 'E', method_flag: 52, reason_code: 1)
119
+ expect(flag.to_s).to eq('Forward Estimated Data - Previous Read - Meter/Equipment Changed')
120
+ end
121
+ end
122
+
123
+ describe '#to_h' do
124
+ it 'returns a hash representation' do
125
+ flag = described_class.new(quality_flag: 'E', method_flag: 52, reason_code: 1)
126
+ expect(flag.to_h).to eq({ quality_flag: 'E', method_flag: 52, reason_code: 1 })
127
+ end
128
+ end
129
+
130
+ describe '#==' do
131
+ it 'compares two flags' do
132
+ flag1 = described_class.new(quality_flag: 'E', method_flag: 52, reason_code: 1)
133
+ flag2 = described_class.new(quality_flag: 'E', method_flag: 52, reason_code: 1)
134
+ expect(flag1).to eq(flag2)
135
+ end
136
+
137
+ it 'compares flag with hash' do
138
+ flag = described_class.new(quality_flag: 'E', method_flag: 52, reason_code: 1)
139
+ expect(flag).to eq({ quality_flag: 'E', method_flag: 52, reason_code: 1 })
140
+ end
141
+ end
142
+ end
@@ -80,7 +80,8 @@ describe AEMO::NEM12 do
80
80
  '200,NEM1201002,E1E2,E1,E1,N1,01002,KWH,30,',
81
81
  '300,20050318,315.15,313.8,296.55,298.5,295.2,298.95,300.75,322.95,330.45,350.7,345.75,346.95,345.9,348.6,300.15,337.5,336.75,345.9,330.45,327.15,334.8,345.75,335.85,320.1,325.5,325.2,326.4,330.6,332.7,332.25,321.0,316.5,299.85,302.4,301.05,263.85,255.45,142.05,138.3,138.3,136.8,138.3,136.05,135.75,135.75,136.65,136.05,130.8,A,,,20050319014041,',
82
82
  '200,NEM1201002,E1E2,E2,E2,N2,01002,KWH,30,',
83
- '300,20050318,103.05,98.85,81.15,75.6,72.15,73.95,74.55,81.15,89.25,124.2,125.7,128.7,136.8,151.05,177.9,174.45,204.0,210.15,180.15,164.4,187.95,211.95,193.8,122.85,124.8,121.2,129.3,131.25,130.95,130.05,118.05,105.75,77.1,75.9,75.0,51.15,44.4,12.3,12.75,12.15,12.45,11.55,14.4,14.55,15.0,14.7,15.75,21.9,A,,,20050319014041,', '900',
83
+ '300,20050318,103.05,98.85,81.15,75.6,72.15,73.95,74.55,81.15,89.25,124.2,125.7,128.7,136.8,151.05,177.9,174.45,204.0,210.15,180.15,164.4,187.95,211.95,193.8,122.85,124.8,121.2,129.3,131.25,130.95,130.05,118.05,105.75,77.1,75.9,75.0,51.15,44.4,12.3,12.75,12.15,12.45,11.55,14.4,14.55,15.0,14.7,15.75,21.9,A,,,20050319014041,',
84
+ '900',
84
85
  ''
85
86
  ].join("\r\n")
86
87
  end
@@ -106,11 +107,9 @@ describe AEMO::NEM12 do
106
107
  '400,1,10,F52,71,',
107
108
  '400,11,48,E52,,',
108
109
  '200,NEM1204062,E1,E1,E1,N1,04062,KWH,30,20050503',
109
- '300,20040528,0.68,0.653,0.62,0.623,0.618,0.625,0.613,0.623,0.618,0.615,0.613,0.76,0.665,0.638,0.61,0.648,0.65,0.645,0.895,0.668,0.645,0.648,0.655,0.73,0.695,0.67,0.638,0.643,0.64,0.723,0.653,0.645,0.633,0.71,0.683,0.648,0.625,0.63,0.625,0.63,0.638,0.635,0.633,0.638,0.673,0.765,0.65,0.628,V,,,20040609000001,',
110
- '400,1,48,E52,,',
110
+ '300,20040528,0.68,0.653,0.62,0.623,0.618,0.625,0.613,0.623,0.618,0.615,0.613,0.76,0.665,0.638,0.61,0.648,0.65,0.645,0.895,0.668,0.645,0.648,0.655,0.73,0.695,0.67,0.638,0.643,0.64,0.723,0.653,0.645,0.633,0.71,0.683,0.648,0.625,0.63,0.625,0.63,0.638,0.635,0.633,0.638,0.673,0.765,0.65,0.628,E52,,,20040609000001,',
111
111
  '200,NEM1204062,E1,E1,E1,N1,04062,KWH,30,20050503',
112
- '300,20040529,0.633,0.613,0.628,0.618,0.625,0.623,0.623,0.613,0.655,0.663,0.645,0.708,0.608,0.618,0.63,0.625,0.62,0.635,0.63,0.638,0.693,0.71,0.683,0.645,0.638,0.653,0.653,0.648,0.655,0.745,0.69,0.695,0.68,0.643,0.645,0.635,0.628,0.625,0.635,0.628,0.673,0.688,0.685,0.66,0.638,0.718,0.638,0.63,V,,,20040609000001,',
113
- '400,1,48,E52,,',
112
+ '300,20040529,0.633,0.613,0.628,0.618,0.625,0.623,0.623,0.613,0.655,0.663,0.645,0.708,0.608,0.618,0.63,0.625,0.62,0.635,0.63,0.638,0.693,0.71,0.683,0.645,0.638,0.653,0.653,0.648,0.655,0.745,0.69,0.695,0.68,0.643,0.645,0.635,0.628,0.625,0.635,0.628,0.673,0.688,0.685,0.66,0.638,0.718,0.638,0.63,E52,,,20040609000001,',
114
113
  '900',
115
114
  ''
116
115
  ].join("\r\n")
@@ -136,9 +135,9 @@ describe AEMO::NEM12 do
136
135
  Dir.entries(File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'NEM12'))
137
136
  .reject { |f| %w[. .. .DS_Store].include?(f) }
138
137
  .each do |file|
139
- described_class.parse_nem12_file(fixture(File.join('NEM12', file))).each do |nem12|
140
- expect(nem12.nmi_identifier).to be_a String
141
- end
138
+ described_class.parse_nem12_file(fixture(File.join('NEM12', file))).each do |nem12|
139
+ expect(nem12.nmi_identifier).to be_a String
140
+ end
142
141
  end
143
142
  end
144
143
  end
@@ -173,6 +172,31 @@ describe AEMO::NEM12 do
173
172
  nem12_empty_cells_300_record = fixture(File.join('NEM12-Errors', 'NEM12#EmptyCells300Record#CNRGYMDP#NEMMCO.csv'))
174
173
  expect { described_class.parse_nem12_file(nem12_empty_cells_300_record) }.to raise_error(ArgumentError)
175
174
  end
175
+
176
+ it 'parses actual data flags explicitly' do
177
+ nem12_filepath = fixture(File.join('NEM12', 'NEM12#000000000000001#CNRGYMDP#NEMMCO.csv'))
178
+ nem12s = described_class.parse_nem12_file(nem12_filepath)
179
+ expect(nem12s.first.interval_data.first[:flag].quality_flag).to eq('A')
180
+ expect(nem12s.first.interval_data.first[:flag].method_flag).to be_nil
181
+ expect(nem12s.first.interval_data.first[:flag].reason_code).to be_nil
182
+ end
183
+
184
+ it 'parses substituted data flags with method flags' do
185
+ nem12_filepath = fixture(File.join('NEM12', 'NEM12#000000000000004#CNRGYMDP#NEMMCO.csv'))
186
+ nem12s = described_class.parse_nem12_file(nem12_filepath)
187
+ # First 10 intervals have F52 flag
188
+ expect(nem12s.first.interval_data.first[:flag].quality_flag).to eq('F')
189
+ expect(nem12s.first.interval_data.first[:flag].method_flag).to eq(52)
190
+ expect(nem12s.first.interval_data.first[:flag].reason_code).to eq(71)
191
+ end
192
+
193
+ it 'parses estimated data flags' do
194
+ nem12_filepath = fixture(File.join('NEM12', 'NEM12#000000000000004#CNRGYMDP#NEMMCO.csv'))
195
+ nem12s = described_class.parse_nem12_file(nem12_filepath)
196
+ # Intervals 11-48 have E52 flag
197
+ expect(nem12s.first.interval_data[10][:flag].quality_flag).to eq('E')
198
+ expect(nem12s.first.interval_data[10][:flag].method_flag).to eq(52)
199
+ end
176
200
  end
177
201
 
178
202
  describe '#parse_nem12_400' do
@@ -180,14 +204,36 @@ describe AEMO::NEM12 do
180
204
  nem12_empty_cells_400_record = fixture(File.join('NEM12-Errors', 'NEM12#EmptyCells400Record#CNRGYMDP#NEMMCO.csv'))
181
205
  expect { described_class.parse_nem12_file(nem12_empty_cells_400_record) }.to raise_error(ArgumentError)
182
206
  end
207
+
208
+ it 'parses 400 records and updates interval flags' do
209
+ nem12_filepath = fixture(File.join('NEM12', 'NEM12#000000000000004#CNRGYMDP#NEMMCO.csv'))
210
+ nem12s = described_class.parse_nem12_file(nem12_filepath)
211
+ # Verify that 400 records override the V flag from 300 record
212
+ expect(nem12s.first.interval_data.first[:flag].quality_flag).to eq('F')
213
+ expect(nem12s.first.interval_data[10][:flag].quality_flag).to eq('E')
214
+ end
183
215
  end
184
216
 
185
- describe '#flag_to_s' do
186
- it 'converts the flags to a string' do
187
- flag = { quality_flag: 'S', method_flag: 11, reason_code: 53 }
217
+ describe 'flag handling edge cases' do
218
+ it 'handles actual data with reason code' do
219
+ nem12_filepath = fixture(File.join('NEM12', 'NEM12.actual_with_reason_code.csv'))
220
+ nem12s = described_class.parse_nem12_file(nem12_filepath)
221
+ # Check that actual data with reason code is parsed correctly
222
+ interval_with_reason = nem12s.first.interval_data.find { |x| x[:flag].reason_code == 79 }
223
+ expect(interval_with_reason).not_to be_nil
224
+ expect(interval_with_reason[:flag].quality_flag).to eq('A')
225
+ expect(interval_with_reason[:flag].method_flag).to be_nil
226
+ expect(interval_with_reason[:flag].reason_code).to eq(79)
227
+ end
228
+
229
+ it 'maintains backward compatibility with nil flags' do
188
230
  nem12 = described_class.new('NEEE000010')
189
- expect(nem12.flag_to_s(flag))
190
- .to eq 'Substituted Data - Check - Bees/Wasp In Meter Box'
231
+ nem12.instance_variable_set(:@data_details, [{ interval_length: 30 }])
232
+ nem12.instance_variable_set(:@interval_data, [
233
+ { flag: nil, value: 1.0, datetime: Time.now, data_details: { interval_length: 30 } }
234
+ ])
235
+ # Should not raise error
236
+ expect { nem12.to_nem12_300_csv }.not_to raise_error
191
237
  end
192
238
  end
193
239
 
@@ -210,4 +256,104 @@ describe AEMO::NEM12 do
210
256
  expect(nem12.to_nem12_csv).to eq(expected)
211
257
  end
212
258
  end
259
+
260
+ describe '#to_nem12_300_csv with uniform flags' do
261
+ let(:nem12) { described_class.new('NEEE000010') }
262
+ let(:base_time) { Time.parse('2024-01-01 00:30:00 +1000') }
263
+
264
+ before do
265
+ nem12.instance_variable_set(:@data_details, [{ interval_length: 30 }])
266
+ end
267
+
268
+ it 'uses quality method from uniform actual data flags without 400 records' do
269
+ intervals = (0...48).map do |i|
270
+ {
271
+ flag: AEMO::MeterData::Flag.new(quality_flag: 'A', method_flag: nil, reason_code: nil),
272
+ value: 1.0 + i,
273
+ datetime: base_time + (i * 30 * 60),
274
+ data_details: { interval_length: 30 }
275
+ }
276
+ end
277
+ nem12.instance_variable_set(:@interval_data, intervals)
278
+
279
+ output = nem12.to_nem12_300_csv
280
+ expect(output).to include('300,20240101')
281
+ expect(output).to include(',A,,,')
282
+ expect(output).not_to include('400,')
283
+ end
284
+
285
+ it 'uses quality method from uniform estimated data flags without 400 records' do
286
+ intervals = (0...48).map do |i|
287
+ {
288
+ flag: AEMO::MeterData::Flag.new(quality_flag: 'E', method_flag: 52, reason_code: nil),
289
+ value: 1.0 + i,
290
+ datetime: base_time + (i * 30 * 60),
291
+ data_details: { interval_length: 30 }
292
+ }
293
+ end
294
+ nem12.instance_variable_set(:@interval_data, intervals)
295
+
296
+ output = nem12.to_nem12_300_csv
297
+ expect(output).to include('300,20240101')
298
+ expect(output).to include(',E52,,,')
299
+ expect(output).not_to include('400,')
300
+ end
301
+
302
+ it 'uses quality method from uniform substituted data flags with reason code' do
303
+ intervals = (0...48).map do |i|
304
+ {
305
+ flag: AEMO::MeterData::Flag.new(quality_flag: 'S', method_flag: 11, reason_code: 53),
306
+ value: 1.0 + i,
307
+ datetime: base_time + (i * 30 * 60),
308
+ data_details: { interval_length: 30 }
309
+ }
310
+ end
311
+ nem12.instance_variable_set(:@interval_data, intervals)
312
+
313
+ output = nem12.to_nem12_300_csv
314
+ expect(output).to include('300,20240101')
315
+ expect(output).to include(',S11,53,')
316
+ expect(output).not_to include('400,')
317
+ end
318
+
319
+ it 'uses V and generates 400 records when flags differ' do
320
+ intervals = (0...48).map do |i|
321
+ flag = if i < 10
322
+ AEMO::MeterData::Flag.new(quality_flag: 'F', method_flag: 52, reason_code: 71)
323
+ else
324
+ AEMO::MeterData::Flag.new(quality_flag: 'E', method_flag: 52, reason_code: nil)
325
+ end
326
+ {
327
+ flag:,
328
+ value: 1.0 + i,
329
+ datetime: base_time + (i * 30 * 60),
330
+ data_details: { interval_length: 30 }
331
+ }
332
+ end
333
+ nem12.instance_variable_set(:@interval_data, intervals)
334
+
335
+ output = nem12.to_nem12_300_csv
336
+ expect(output).to include('300,20240101')
337
+ expect(output).to include(',V,,,')
338
+ expect(output).to include('400,1,10,F52,71,')
339
+ expect(output).to include('400,11,48,E52,,')
340
+ end
341
+
342
+ it 'handles nil flags as nil data' do
343
+ intervals = (0...48).map do |i|
344
+ {
345
+ flag: nil,
346
+ value: 1.0 + i,
347
+ datetime: base_time + (i * 30 * 60),
348
+ data_details: { interval_length: 30 }
349
+ }
350
+ end
351
+ nem12.instance_variable_set(:@interval_data, intervals)
352
+
353
+ output = nem12.to_nem12_300_csv
354
+ expect(output).to include('300,20240101')
355
+ expect(output).to include(',,,,')
356
+ expect(output).not_to include('400,')
357
+ end
358
+ end
213
359
  end