diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fb29237 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +--- +name: Test + lint +on: + pull_request: + push: + tags-ignore: + - '*' + paths-ignore: + - '*.md' + workflow_dispatch: +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Run lints + run: bundle exec rubocop + + test: + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} + strategy: + matrix: + ruby-version: ['3.1', '3.2'] + experimental: [false] + include: + - ruby-version: 'head' + experimental: true + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Run tests + env: + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} + run: bundle exec rspec diff --git a/.rubocop.yml b/.rubocop.yml index e961d8c..ddb9aee 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,43 +1,52 @@ +--- inherit_from: .rubocop_todo.yml +require: +- rubocop-minitest +- rubocop-rspec + AllCops: + NewCops: enable Exclude: - 'vendor/**/*' DefaultFormatter: progress DisplayCopNames: true DisplayStyleGuide: true - ExtraDetails: true - TargetRubyVersion: 2.6 - -Style/StringLiterals: - EnforcedStyle: single_quotes - SupportedStyles: - - single_quotes - - double_quotes + ExtraDetails: 3.2 -# Metrics/AbcSize: -# # Target: 15 -# Max: 175 -Metrics/LineLength: +# Overrides. +Layout/LineLength: AllowHeredoc: true AllowURI: true + Exclude: + - 'spec/**/*_spec.rb' URISchemes: - http - https - # Target: 80 - # Max: 210 -# Metrics/MethodLength: -# # Target 10 -# Max: 80 -# Metrics/ClassLength: -# Max: 600 -# Metrics/CyclomaticComplexity: -# # Target 6 -# Max: 29 -# Metrics/PerceivedComplexity: -# # Target 7 -# Max: 29 + Max: 210 +Metrics/AbcSize: + # Target: 15 + Max: 190 Metrics/BlockLength: Exclude: - 'spec/**/*' - 'aemo.gemspec' + # Target: 25 + Max: 40 +Metrics/ClassLength: + Max: 600 +Metrics/CyclomaticComplexity: + # Target 6 + Max: 40 +Metrics/MethodLength: + # Target 10 + Max: 80 +Metrics/PerceivedComplexity: + # Target 7 + Max: 38 +Style/StringLiterals: + EnforcedStyle: single_quotes + SupportedStyles: + - single_quotes + - double_quotes + diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index e285ed5..f2a471c 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,40 +1,55 @@ +--- # This configuration was generated by # `rubocop --auto-gen-config` -# on 2018-05-01 11:12:22 +1000 using RuboCop version 0.50.0. +# on 2023-12-19 05:58:24 UTC using RuboCop version 1.59.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 18 -Metrics/AbcSize: - Max: 165 +# Offense count: 8 +# Configuration parameters: Prefixes, AllowedPatterns. +# Prefixes: when, with, without +RSpec/ContextWording: + Exclude: + - 'spec/lib/aemo/nem12_spec.rb' + - 'spec/lib/aemo/nmi/allocation_spec.rb' + - 'spec/lib/aemo/nmi_spec.rb' -# Offense count: 1 -# Configuration parameters: CountComments, ExcludedMethods. -Metrics/BlockLength: - Max: 35 +# Offense count: 6 +# Configuration parameters: CountAsOne. +RSpec/ExampleLength: + Max: 23 -# Offense count: 3 -# Configuration parameters: CountComments. -Metrics/ClassLength: - Max: 499 +# Offense count: 44 +# Configuration parameters: AssignmentOnly. +RSpec/InstanceVariable: + Exclude: + - 'spec/lib/aemo/market/interval_spec.rb' + - 'spec/lib/aemo/market/node_spec.rb' + - 'spec/lib/aemo/nmi_spec.rb' + - 'spec/lib/aemo/region_spec.rb' -# Offense count: 8 -Metrics/CyclomaticComplexity: - Max: 29 +# Offense count: 6 +RSpec/MultipleExpectations: + Max: 5 -# Offense count: 191 -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. -# URISchemes: http, https -Metrics/LineLength: - Max: 192 +# Offense count: 10 +# Configuration parameters: AllowedGroups. +RSpec/NestedGroups: + Max: 4 -# Offense count: 14 -# Configuration parameters: CountComments. -Metrics/MethodLength: - Max: 51 +# Offense count: 2 +# Configuration parameters: AllowedPatterns. +# AllowedPatterns: ^expect_, ^assert_ +RSpec/NoExpectationExample: + Exclude: + - 'spec/lib/aemo/msats_spec.rb' -# Offense count: 7 -Metrics/PerceivedComplexity: - Max: 29 +# Offense count: 17 +RSpec/RepeatedDescription: + Exclude: + - 'spec/lib/aemo/market/interval_spec.rb' + - 'spec/lib/aemo/msats_spec.rb' + - 'spec/lib/aemo/nem12_spec.rb' + - 'spec/lib/aemo/nmi_spec.rb' diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 17c818b..0000000 --- a/.travis.yml +++ /dev/null @@ -1,50 +0,0 @@ ---- -env: - global: - - CC_TEST_REPORTER_ID=20981d9b6c53ed895076e87fd8f8d5782800824f86eb501bfccb525dab7deca1 -language: ruby -bundler_args: --retry=3 --jobs=3 -cache: bundler -sudo: false -rvm: -- 3.1.0-preview1 -- 3.0.3 -- 3.0.2 -- 3.0.1 -- 3.0.0 -- 2.7.5 -- 2.7.4 -- 2.7.3 -- 2.7.2 -- 2.7.1 -- 2.7.0 -- 2.6.9 -- 2.6.8 -- 2.6.7 -- 2.6.6 -- 2.6.5 -- 2.6.4 -- 2.6.3 -- 2.6.2 -- 2.6.1 -- 2.6.0 -- ruby-head -matrix: - allow_failures: - - rvm: ruby-head - - rvm: 3.1.0-preview1 - fast_finish: true -install: -- gem install bundler -- bundle install -before_script: -- curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter -- chmod +x ./cc-test-reporter -- ./cc-test-reporter before-build -script: -- bundle exec rspec -after_script: -- ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT -addons: - code_climate: - repo_token: 20981d9b6c53ed895076e87fd8f8d5782800824f86eb501bfccb525dab7deca1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eb3a1c..91b9ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # AEMO Gem Changelog +## [v0.6.0] (2023-12-20) + +### Added + +* Ability to output a valid NEM12 file from a single, or an array of, `AEMO::NEM12` with `to_nem12_csv`. +* `AEMO::Time` for customised formatting, parsing, and validating in `NEMTIME`. +* Github actions for tests and lints. + +### Changed + +* Correctly set `AEMO::NEM12` header value set at parsing. + ## [v0.5.1] (2023-06-16) ### Changed diff --git a/Gemfile b/Gemfile index a65b2c7..0df1cf9 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,26 @@ source 'https://rubygems.org' -source 'https://rubygems.org' do - # Specify your gem's dependencies in aemo.gemspec - gemspec +# Specify your gem's production dependencies in [aemo.gemspec](./aemo.gemspec). +gemspec + +# Specify the development/test gems here. +group :development, :test do + gem 'addressable', '~> 2.8', '>= 2.8.0' + gem 'awesome_print', '~> 1.8', '>= 1.8.0' + gem 'coveralls_reborn', '~> 0.28', '>= 0.28.0' + gem 'ffi', '~> 1.16', '> 1.9.18' + gem 'guard-yard', '~> 2.2', '>= 2.2.0' + gem 'listen', '~> 3.1', '>= 3.5.0' + gem 'minitest', '~> 5.14', '>= 5.14.2' + gem 'rack', '~> 3.0', '>= 3.0.8' + gem 'rdoc', '~> 6.3', '>= 5.1.0' + gem 'rspec', '~> 3.7', '>= 3.7.0' + gem 'rubocop', '~> 1.59', '>= 0.52.1' + gem 'rubocop-minitest' + gem 'rubocop-rspec' + gem 'simplecov', '~> 0.14', '>= 0.14.1' + gem 'timecop', '~> 0.9', '>= 0.9.1' + gem 'webmock', '~> 3.1', '>= 3.1.0' + gem 'yard', '~> 0.9', '>= 0.9.11' end diff --git a/Gemfile.lock b/Gemfile.lock index 2d2c640..2a0f938 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ PATH remote: . specs: - aemo (0.5.1) - activesupport (>= 4.2.6, < 7.1) + aemo (0.6.0) + activesupport (>= 4.2.6, < 7.2) httparty (~> 0.21, >= 0.21.0) json (>= 1.7.5, < 3) multi_xml (~> 0.6, >= 0.5.0) @@ -12,38 +12,39 @@ PATH GEM remote: https://rubygems.org/ specs: - activesupport (7.0.5) + activesupport (7.1.2) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.1) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) - ast (2.4.0) ast (2.4.2) - awesome_print (1.8.0) awesome_print (1.9.2) + base64 (0.2.0) + bigdecimal (3.1.5) coderay (1.1.3) - concurrent-ruby (1.1.10) concurrent-ruby (1.2.2) - coveralls (0.8.23) - json (>= 1.8, < 3) - simplecov (~> 0.16.1) - term-ansicolor (~> 1.3) - thor (>= 0.19.4, < 2.0) - tins (~> 1.6) + connection_pool (2.4.1) + coveralls_reborn (0.28.0) + simplecov (~> 0.22.0) + term-ansicolor (~> 1.7) + thor (~> 1.2) + tins (~> 1.32) crack (0.4.5) rexml diff-lcs (1.5.0) - docile (1.3.2) docile (1.4.0) - ffi (1.15.5) - formatador (0.2.5) + drb (2.2.0) + ruby2_keywords + ffi (1.16.3) formatador (1.1.0) - guard (2.18.0) + guard (2.18.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) lumberjack (>= 1.0.12, < 2.0) @@ -55,57 +56,51 @@ GEM guard-yard (2.2.1) guard (>= 1.1.0) yard (>= 0.7.0) - hashdiff (1.0.0) - hashdiff (1.0.1) + hashdiff (1.1.0) httparty (0.21.0) mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) i18n (1.14.1) concurrent-ruby (~> 1.0) - json (2.3.1) - json (2.6.3) + json (2.7.1) + language_server-protocol (3.17.0.3) listen (3.8.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - lumberjack (1.0.13) - lumberjack (1.2.8) - method_source (0.9.2) + lumberjack (1.2.10) method_source (1.0.0) - mini_mime (1.1.2) - minitest (5.18.0) + mini_mime (1.1.5) + minitest (5.20.0) multi_xml (0.6.0) + mutex_m (0.2.0) nenv (0.3.0) - nokogiri (1.15.2-x86_64-darwin) + nokogiri (1.15.5-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.15.5-x86_64-linux) racc (~> 1.4) notiffany (0.1.3) nenv (~> 0.1) shellany (~> 0.0) - parallel (1.17.0) - parallel (1.23.0) - parser (2.7.2.0) + parallel (1.24.0) + parser (3.2.2.4) ast (~> 2.4.1) - powerpack (0.1.2) - powerpack (0.1.3) + racc pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) - psych (5.1.0) + psych (5.1.2) stringio - public_suffix (4.0.6) - public_suffix (5.0.1) - racc (1.6.2) - racc (1.7.1) - rack (3.0.6.1) + public_suffix (5.0.4) + racc (1.7.3) rack (3.0.8) - rainbow (3.0.0) rainbow (3.1.1) - rb-fsevent (0.11.0) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) - rdoc (6.5.0) + rdoc (6.6.2) psych (>= 4.0.0) - rexml (3.2.5) + regexp_parser (2.8.3) + rexml (3.2.6) rspec (3.12.0) rspec-core (~> 3.12.0) rspec-expectations (~> 3.12.0) @@ -115,63 +110,84 @@ GEM rspec-expectations (3.12.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-mocks (3.12.5) + rspec-mocks (3.12.6) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-support (3.12.0) - rubocop (0.52.1) + rspec-support (3.12.1) + rubocop (1.59.0) + json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 2.4.0.2, < 3.0) - powerpack (~> 0.1) + parser (>= 3.2.2.4) rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.30.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.0, >= 1.0.1) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.30.0) + parser (>= 3.2.1.0) + rubocop-capybara (2.19.0) + rubocop (~> 1.41) + rubocop-factory_bot (2.24.0) + rubocop (~> 1.33) + rubocop-minitest (0.34.1) + rubocop (>= 1.39, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + rubocop-rspec (2.25.0) + rubocop (~> 1.40) + rubocop-capybara (~> 2.17) + rubocop-factory_bot (~> 2.22) ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) shellany (0.0.1) - simplecov (0.16.1) + simplecov (0.22.0) docile (~> 1.1) - json (>= 1.8, < 3) - simplecov-html (~> 0.10.0) - simplecov-html (0.10.2) - stringio (3.0.7) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.4) + stringio (3.1.0) sync (0.5.0) term-ansicolor (1.7.1) tins (~> 1.0) - thor (1.2.2) - timecop (0.9.6) + thor (1.3.0) + timecop (0.9.8) tins (1.32.1) sync tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (1.8.0) - webmock (3.18.1) + unicode-display_width (2.5.0) + webmock (3.19.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - yard (0.9.20) yard (0.9.34) - zeitwerk (2.6.7) PLATFORMS x86_64-darwin-19 + x86_64-darwin-22 + x86_64-linux DEPENDENCIES - addressable (~> 2.8, >= 2.8.0)! + addressable (~> 2.8, >= 2.8.0) aemo! - awesome_print (~> 1.8, >= 1.8.0)! - coveralls (~> 0.8, >= 0.8.21)! - ffi (~> 1, > 1.9.18)! - guard-yard (~> 2.2, >= 2.2.0)! - listen (~> 3.1, >= 3.5.0)! - minitest (~> 5.14, >= 5.14.2)! - rack (~> 3, >= 2.0.4)! - rdoc (~> 6.3, >= 5.1.0)! - rspec (~> 3.7, >= 3.7.0)! - rubocop (~> 0.52.1, >= 0.52.1)! - simplecov (~> 0.14, >= 0.14.1)! - timecop (~> 0.9, >= 0.9.1)! - webmock (~> 3.1, >= 3.1.0)! - yard (~> 0.9, >= 0.9.11)! + awesome_print (~> 1.8, >= 1.8.0) + coveralls_reborn (~> 0.28, >= 0.28.0) + ffi (~> 1.16, > 1.9.18) + guard-yard (~> 2.2, >= 2.2.0) + listen (~> 3.1, >= 3.5.0) + minitest (~> 5.14, >= 5.14.2) + rack (~> 3.0, >= 3.0.8) + rdoc (~> 6.3, >= 5.1.0) + rspec (~> 3.7, >= 3.7.0) + rubocop (~> 1.59, >= 0.52.1) + rubocop-minitest + rubocop-rspec + simplecov (~> 0.14, >= 0.14.1) + timecop (~> 0.9, >= 0.9.1) + webmock (~> 3.1, >= 3.1.0) + yard (~> 0.9, >= 0.9.11) BUNDLED WITH - 2.3.16 + 2.5.1 diff --git a/README.md b/README.md index b5ebf2f..f292ae2 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ Makes working with AEMO data more pleasant. [![Gem Version](https://badge.fury.io/rb/aemo.svg)](http://badge.fury.io/rb/aemo) -[![Build Status](https://app.travis-ci.com/jufemaiz/aemo.svg?branch=main)](https://app.travis-ci.com/jufemaiz/aemo) +[![Build Status](https://github.com/jufemaiz/aemo/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/jufemaiz/aemo/actions) [![Maintainability](https://api.codeclimate.com/v1/badges/f16f9df6762d9870cd2c/maintainability)](https://codeclimate.com/github/jufemaiz/aemo/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/f16f9df6762d9870cd2c/test_coverage)](https://codeclimate.com/github/jufemaiz/aemo/test_coverage) [![Coverage Status](https://coveralls.io/repos/github/jufemaiz/aemo/badge.svg?branch=master)](https://coveralls.io/github/jufemaiz/aemo?branch=master) [![Known Vulnerabilities](https://snyk.io/test/github/jufemaiz/aemo/badge.svg)](https://snyk.io/test/github/jufemaiz/aemo) [![Help Contribute to Open Source](https://www.codetriage.com/jufemaiz/aemo/badges/users.svg)](https://www.codetriage.com/jufemaiz/aemo) -[![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=jufemaiz_aemo)](https://sonarcloud.io/summary/new_code?id=jufemaiz_aemo) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=jufemaiz_aemo&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=jufemaiz_aemo) # Documentation @@ -20,17 +20,12 @@ Makes working with AEMO data more pleasant. ## Ruby Versions Supported * ruby-head (failures allowed) -* 3.1.0-preview1 (failures allowed) -* 3.0 (.0, .1, .2, .3) -* 2.7 (.0, .1, .2, .3, .4, .5) -* 2.6 (.0, .1, .2, .3, .4, .5, .6, .7, .8, .9) +* 3.2 +* 3.1 ### Deprecated -* 2.5 -* 2.4 -* 2.3 -* 2.2 +* < 3.1 ## Manually from RubyGems.org diff --git a/aemo.gemspec b/aemo.gemspec index 4802f2d..fec71c0 100644 --- a/aemo.gemspec +++ b/aemo.gemspec @@ -1,49 +1,36 @@ # frozen_string_literal: true -# encoding: UTF-8 -$LOAD_PATH.push File.expand_path('../lib', __FILE__) +$LOAD_PATH.push File.expand_path('lib', __dir__) require 'aemo/version' Gem::Specification.new do |s| s.name = 'aemo' s.version = AEMO::VERSION s.platform = Gem::Platform::RUBY - s.date = '2020-12-11' s.summary = 'Gem providing functionality for the Australian Energy Market Operator data' - s.description = 'Gem providing functionality for the Australian Energy Market Operator data. Supports NMIs, NEM12, MSATS Web Services and more' + s.description = 'Gem providing functionality for the Australian Energy Market Operator data.' \ + 'Supports NMIs, NEM12, MSATS Web Services and more' s.authors = ['Joel Courtney', 'Stuart Auld', 'Neil Parikh', 'Olivier Nsabimana'] - s.email = ['joel@aceteknologi.com', 'stuart@aceteknologi.com', 'neilparikh107@gmail.com', 'onsabimana@cozero.com.au'] + s.email = [ + 'joel@aceteknologi.com', 'stuart@aceteknologi.com', + 'neilparikh107@gmail.com', 'onsabimana@cozero.com.au' + ] s.homepage = 'https://github.com/jufemaiz/aemo' s.license = 'MIT' s.files = Dir['lib/**/*', 'spec/**/*', 'bin/*'] s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } - s.test_files = s.files.grep(%r{^(test|spec|features)/}) s.require_paths = ['lib'] - s.required_ruby_version = '>= 2.6.0' + s.required_ruby_version = '>= 3.1.0' # Production Dependencies - s.add_dependency 'activesupport', '>= 4.2.6', '< 7.1' - s.add_dependency 'httparty', '~> 0.21', '>= 0.21.0' + s.add_dependency 'activesupport', '>= 4.2.6', '< 7.2' + s.add_dependency 'httparty', '~> 0.21', '>= 0.21.0' s.add_dependency 'json', '>= 1.7.5', '< 3' s.add_dependency 'multi_xml', '~> 0.6', '>= 0.5.0' s.add_dependency 'nokogiri', '~> 1.14', '>= 1.14.3' s.add_dependency 'rexml' - # Development Dependencies - s.add_development_dependency 'addressable', '~> 2.8', '>= 2.8.0' - s.add_development_dependency 'awesome_print', '~> 1.8', '>= 1.8.0' - s.add_development_dependency 'coveralls', '~> 0.8', '>= 0.8.21' - s.add_development_dependency 'ffi', '~> 1', '> 1.9.18' - s.add_development_dependency 'guard-yard', '~> 2.2', '>= 2.2.0' - s.add_development_dependency 'listen', '~> 3.1', '>= 3.5.0' - s.add_development_dependency 'minitest', '~> 5.14', '>= 5.14.2' - s.add_development_dependency 'rack', '>= 2.0.4', '~> 3' - s.add_development_dependency 'rdoc', '~> 6.3', '>= 5.1.0' - s.add_development_dependency 'rspec', '~> 3.7', '>= 3.7.0' - s.add_development_dependency 'rubocop', '~> 0.52.1', '>= 0.52.1' - s.add_development_dependency 'simplecov', '~> 0.14', '>= 0.14.1' - s.add_development_dependency 'timecop', '~> 0.9', '>= 0.9.1' - s.add_development_dependency 'webmock', '~> 3.1', '>= 3.1.0' - s.add_development_dependency 'yard', '~> 0.9', '>= 0.9.11' + # Stay safe! + s.metadata['rubygems_mfa_required'] = 'true' end diff --git a/lib/aemo.rb b/lib/aemo.rb index 678fd9d..465ebfc 100644 --- a/lib/aemo.rb +++ b/lib/aemo.rb @@ -4,17 +4,19 @@ require 'httparty' require 'csv' -require 'aemo/region.rb' -require 'aemo/market.rb' -require 'aemo/market/interval.rb' -require 'aemo/market/node.rb' -require 'aemo/meter.rb' -require 'aemo/nem12.rb' -require 'aemo/nmi.rb' -require 'aemo/msats.rb' -require 'aemo/register.rb' -require 'aemo/version.rb' -require 'aemo/exceptions/invalid_nmi_allocation_type.rb' +require 'aemo/struct' +require 'aemo/time' +require 'aemo/region' +require 'aemo/market' +require 'aemo/market/interval' +require 'aemo/market/node' +require 'aemo/meter' +require 'aemo/nem12' +require 'aemo/nmi' +require 'aemo/msats' +require 'aemo/register' +require 'aemo/version' +require 'aemo/exceptions/invalid_nmi_allocation_type' # AEMO Module to encapsulate all AEMO classes module AEMO diff --git a/lib/aemo/dispatchable.rb b/lib/aemo/dispatchable.rb index b5ecfee..fdc09f1 100644 --- a/lib/aemo/dispatchable.rb +++ b/lib/aemo/dispatchable.rb @@ -3,7 +3,7 @@ module AEMO class Region DISPATCH_TYPE = ['Generator', 'Load Norm Off', 'Network Service Provider'].freeze - CATEGORY = ['Market', 'Non-Market'].freeze - CLASSIFICATION = ['Scheduled', 'Semi-Scheduled', 'Non-Scheduled'].freeze + CATEGORY = %w[Market Non-Market].freeze + CLASSIFICATION = %w[Scheduled Semi-Scheduled Non-Scheduled].freeze end end diff --git a/lib/aemo/exceptions/invalid_nmi_allocation_type.rb b/lib/aemo/exceptions/invalid_nmi_allocation_type.rb index 2f39c9f..78f4fb8 100644 --- a/lib/aemo/exceptions/invalid_nmi_allocation_type.rb +++ b/lib/aemo/exceptions/invalid_nmi_allocation_type.rb @@ -8,7 +8,7 @@ module AEMO # @since 0.3.0 class InvalidNMIAllocationType < ArgumentError DEFAULT_MESSAGE = 'Not a valid allocation type, try one of ' \ - "#{AEMO::NMI::Allocation::SUPPORTED_TYPES.join(' |')}" + "#{AEMO::NMI::Allocation::SUPPORTED_TYPES.join(' |')}".freeze # Initialize an InvalidNMIAllocationType # diff --git a/lib/aemo/exceptions/time_error.rb b/lib/aemo/exceptions/time_error.rb new file mode 100644 index 0000000..b452fca --- /dev/null +++ b/lib/aemo/exceptions/time_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module AEMO + # AEMO::TimeError + # + # @author Joel Courtney + # @abstract An exception for time errors. + # @since 0.6.0 + class TimeError < ArgumentError + DEFAULT_MESSAGE = 'Not a valid time' + + # Initialize an TimeError + # + # @param [String] msg the error message + # @return [AEMO::TimeError] + def initialize(msg: DEFAULT_MESSAGE) + super + end + end +end diff --git a/lib/aemo/market.rb b/lib/aemo/market.rb index 4c81d1b..ab7a448 100644 --- a/lib/aemo/market.rb +++ b/lib/aemo/market.rb @@ -20,8 +20,7 @@ def current_dispatch(region) region = AEMO::Region.new(region) if region.is_a?(String) response = get "/mms.GRAPHS/GRAPHS/GRAPH_5#{region}1.csv" - values = parse_response(response) - values + parse_response(response) end # Description of method @@ -32,8 +31,7 @@ def current_trading(region) region = AEMO::Region.new(region) if region.is_a?(String) response = get "/mms.GRAPHS/GRAPHS/GRAPH_30#{region}1.csv" - values = parse_response(response) - values + parse_response(response) end # Return an array of historic trading values based on a start and finish diff --git a/lib/aemo/market/interval.rb b/lib/aemo/market/interval.rb index b0857cb..532ef01 100644 --- a/lib/aemo/market/interval.rb +++ b/lib/aemo/market/interval.rb @@ -22,7 +22,7 @@ class Interval # @param [Hash] options Hash of optional data values # @return [AEMO::Market::Interval] def initialize(datetime, options = {}) - @datetime = Time.parse("#{datetime} +1000") + @datetime = ::Time.parse("#{datetime} +1000") @region = options['REGION'] @total_demand = options['TOTALDEMAND'] @rrp = options['RRP'] @@ -35,7 +35,7 @@ def initialize(datetime, options = {}) # All AEMO Data aggregates to the trailing edge of the period (this makes it difficult to do daily aggregations :( ) # @param [Boolean] trailing_edge selection of either the trailing edge of the period or the rising edge of the period for the date time # @return [Time] a time object of the trailing edge of the interval - def datetime(trailing_edge = true) + def datetime(trailing_edge: true) t = @datetime # If the datetime requested is the trailing edge, offset as per interval requirement unless trailing_edge @@ -51,7 +51,7 @@ def datetime(trailing_edge = true) # @return [Time] the time of the def interval_length - Time.at(300) + ::Time.at(300) end # @return [Symbol] :dispatch or :trading @@ -72,7 +72,7 @@ def trading? # @return [Float] the value of the interval in Australian Dollars def value @value ||= Float::NAN - @value = (@total_demand * @rrp).round(2) if @total_demand.class == Float && @rrp.class == Float + @value = (@total_demand * @rrp).round(2) if @total_demand.instance_of?(Float) && @rrp.instance_of?(Float) @value end end diff --git a/lib/aemo/market/node.rb b/lib/aemo/market/node.rb index 34df525..c75b9fb 100644 --- a/lib/aemo/market/node.rb +++ b/lib/aemo/market/node.rb @@ -13,7 +13,11 @@ class Node attr_accessor :identifier def initialize(identifier) - raise ArgumentError, "Node Identifier '#{identifier}' is not valid." unless IDENTIFIERS.include?(identifier.upcase) + unless IDENTIFIERS.include?(identifier.upcase) + raise ArgumentError, + "Node Identifier '#{identifier}' is not valid." + end + @identifier = identifier @current_trading = [] @current_dispatch = [] @@ -23,7 +27,7 @@ def initialize(identifier) # # @return [Array] def current_dispatch - @current_dispatch = AEMO::Market.current_dispatch(@identifier) if @current_dispatch.empty? || @current_dispatch.last.datetime != (Time.now - Time.now.to_i % 300) + @current_dispatch = AEMO::Market.current_dispatch(@identifier) if @current_dispatch.empty? || @current_dispatch.last.datetime != (::Time.now - (::Time.now.to_i % 300)) @current_dispatch end @@ -31,7 +35,9 @@ def current_dispatch # # @return [Array] def current_trading - if @current_trading.empty? || @current_trading.select { |i| i.period_type == 'TRADE' }.last.datetime != (Time.now - Time.now.to_i % 300) + if @current_trading.empty? || @current_trading.select do |i| + i.period_type == 'TRADE' + end.last.datetime != (::Time.now - (::Time.now.to_i % 300)) @current_trading = AEMO::Market.current_trading(@identifier) end @current_trading diff --git a/lib/aemo/msats.rb b/lib/aemo/msats.rb index b684d96..e9b67c2 100644 --- a/lib/aemo/msats.rb +++ b/lib/aemo/msats.rb @@ -13,9 +13,9 @@ module AEMO class MSATS # Globally set request headers HEADERS = { - 'User-Agent' => 'Ruby.AEMO.MSATS.Api', - 'Accept' => 'text/xml', - 'Content-Type' => 'text/xml' + 'User-Agent' => 'Ruby.AEMO.MSATS.Api', + 'Accept' => 'text/xml', + 'Content-Type' => 'text/xml' }.freeze # We like to party @@ -31,8 +31,7 @@ class MSATS # Class Methods class << self - attr_accessor :auth - attr_accessor :participant_id + attr_accessor :auth, :participant_id # Single NMI Master (C4) Report # /C4/PARTICIPANT_IDENTIFIER?transactionId=XXX&nmi=XXX&checksum=X&type=XXX&reason=XXX @@ -52,22 +51,23 @@ def c4(nmi, from_date, to_date, as_at_date, options = {}) options[:inittransId] ||= nil query = { - transactionId: transaction_id, - # Note: AEMO has case sensitivity but no consistency across requests. - NMI: nmi.nmi, - fromDate: from_date, - toDate: to_date, - asatDate: as_at_date, - participantId: @participant_id, - roleId: options[:role_id], - inittransId: options[:init_trans_id] + transactionId: transaction_id, + # NOTE: AEMO has case sensitivity but no consistency across requests. + NMI: nmi.nmi, + fromDate: from_date, + toDate: to_date, + asatDate: as_at_date, + participantId: @participant_id, + roleId: options[:role_id], + inittransId: options[:init_trans_id] } - response = get("/C4/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query: query, verify: (options[:verify_ssl] != false)) - if response.response.code != '200' - response - else + response = get("/C4/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query:, + verify: (options[:verify_ssl] != false)) + if response.response.code == '200' response.parsed_response['aseXML']['Transactions']['Transaction']['ReportResponse']['ReportResults'] + else + response end end @@ -77,13 +77,14 @@ def c4(nmi, from_date, to_date, as_at_date, options = {}) # @return [Hash] The report results from the MSATS Limits web service query def msats_limits(options = {}) query = { - transactionId: transaction_id + transactionId: transaction_id } - response = get("/MSATSLimits/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query: query, verify: (options[:verify_ssl] != false)) - if response.response.code != '200' - response - else + response = get("/MSATSLimits/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query:, + verify: (options[:verify_ssl] != false)) + if response.response.code == '200' response.parsed_response['aseXML']['Transactions']['Transaction']['ReportResponse']['ReportResults'] + else + response end end @@ -93,21 +94,30 @@ def msats_limits(options = {}) # @param [Integer] delivery_point_identifier Delivery Point Identifier # @return [Hash] The response def nmi_discovery_by_delivery_point_identifier(jurisdiction_code, delivery_point_identifier, options = {}) - raise ArgumentError, 'jurisdiction_code is not valid' unless %w[ACT NEM NSW QLD SA VIC TAS].include?(jurisdiction_code) - raise ArgumentError, 'delivery_point_identifier is not valid' unless delivery_point_identifier.respond_to?('to_i') - raise ArgumentError, 'delivery_point_identifier is not valid' if delivery_point_identifier.to_i < 10_000_000 || delivery_point_identifier.to_i > 99_999_999 + raise ArgumentError, 'jurisdiction_code is not valid' unless %w[ACT NEM NSW QLD SA VIC + TAS].include?(jurisdiction_code) + + unless delivery_point_identifier.respond_to?('to_i') + raise ArgumentError, + 'delivery_point_identifier is not valid' + end + if delivery_point_identifier.to_i < 10_000_000 || delivery_point_identifier.to_i > 99_999_999 + raise ArgumentError, + 'delivery_point_identifier is not valid' + end query = { - transactionId: transaction_id, + transactionId: transaction_id, jurisdictionCode: jurisdiction_code, deliveryPointIdentifier: delivery_point_identifier.to_i } - response = get("/NMIDiscovery/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query: query, verify: (options[:verify_ssl] != false)) - if response.response.code != '200' - response - else + response = get("/NMIDiscovery/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query:, + verify: (options[:verify_ssl] != false)) + if response.response.code == '200' response.parsed_response['aseXML']['Transactions']['Transaction']['NMIDiscoveryResponse']['NMIStandingData'] + else + response end end @@ -117,19 +127,21 @@ def nmi_discovery_by_delivery_point_identifier(jurisdiction_code, delivery_point # @param [Integer] meter_serial_number The meter's serial number # @return [Hash] The response def nmi_discovery_by_meter_serial_number(jurisdiction_code, meter_serial_number, options = {}) - raise ArgumentError, 'jurisdiction_code is not valid' unless %w[ACT NEM NSW QLD SA VIC TAS].include?(jurisdiction_code) + raise ArgumentError, 'jurisdiction_code is not valid' unless %w[ACT NEM NSW QLD SA VIC + TAS].include?(jurisdiction_code) query = { - transactionId: transaction_id, + transactionId: transaction_id, jurisdictionCode: jurisdiction_code, meterSerialNumber: meter_serial_number.to_i } - response = get("/NMIDiscovery/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query: query, verify: (options[:verify_ssl] != false)) - if response.response.code != '200' - response - else + response = get("/NMIDiscovery/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query:, + verify: (options[:verify_ssl] != false)) + if response.response.code == '200' response.parsed_response['aseXML']['Transactions']['Transaction']['NMIDiscoveryResponse']['NMIStandingData'] + else + response end end @@ -139,7 +151,8 @@ def nmi_discovery_by_meter_serial_number(jurisdiction_code, meter_serial_number, # @param [Integer] meter_serial_number The meter's serial number # @return [Hash] The response def nmi_discovery_by_address(jurisdiction_code, options = {}) - raise ArgumentError, 'jurisdiction_code is not valid' unless %w[ACT NEM NSW QLD SA VIC TAS].include?(jurisdiction_code) + raise ArgumentError, 'jurisdiction_code is not valid' unless %w[ACT NEM NSW QLD SA VIC + TAS].include?(jurisdiction_code) options[:building_or_property_name] ||= nil options[:location_descriptor] ||= nil @@ -158,7 +171,7 @@ def nmi_discovery_by_address(jurisdiction_code, options = {}) options[:state_or_territory] ||= jurisdiction_code query = { - transactionId: transaction_id, + transactionId: transaction_id, jurisdictionCode: jurisdiction_code, buildingOrPropertyName: options[:building_or_property_name], locationDescriptor: options[:location_descriptor], @@ -177,12 +190,13 @@ def nmi_discovery_by_address(jurisdiction_code, options = {}) stateOrTerritory: options[:state_or_territory] } - response = get("/NMIDiscovery/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query: query, verify: (options[:verify_ssl] != false)) - if response.response.code != '200' - response - else + response = get("/NMIDiscovery/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query:, + verify: (options[:verify_ssl] != false)) + if response.response.code == '200' myresponse = response.parsed_response['aseXML']['Transactions']['Transaction']['NMIDiscoveryResponse']['NMIStandingData'] myresponse.is_a?(Hash) ? [myresponse] : myresponse + else + response end end @@ -204,11 +218,12 @@ def nmi_detail(nmi, options = {}) reason: options[:reason] } - response = get("/NMIDetail/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query: query, verify: (options[:verify_ssl] != false)) - if response.response.code != '200' - response - else + response = get("/NMIDetail/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query:, + verify: (options[:verify_ssl] != false)) + if response.response.code == '200' response.parsed_response['aseXML']['Transactions']['Transaction']['NMIStandingDataResponse']['NMIStandingData'] + else + response end end @@ -218,13 +233,14 @@ def nmi_detail(nmi, options = {}) # @return [Hash] The report results from the Participant System Status web service query def system_status(options = {}) query = { - transactionId: transaction_id + transactionId: transaction_id } - response = get("/ParticipantSystemStatus/#{@participant_id}", basic_auth: @auth, headers: HEADERS, query: query, verify: (options[:verify_ssl] != false)) - if response.response.code != '200' - response - else + response = get("/ParticipantSystemStatus/#{@participant_id}", basic_auth: @auth, headers: HEADERS, + query:, verify: (options[:verify_ssl] != false)) + if response.response.code == '200' response.parsed_response['aseXML']['Transactions']['Transaction']['ReportResponse']['ReportResults'] + else + response end end @@ -235,7 +251,7 @@ def system_status(options = {}) # @return [Hash] authentication credentials def authorize(participant_id, username, password) @participant_id = participant_id - @auth = { username: username, password: password } + @auth = { username:, password: } end # Check if credentials are available to use @@ -249,7 +265,7 @@ def can_authenticate? # # @return [String] the transaction id def transaction_id - Digest::SHA1.hexdigest(Time.now.to_s)[0..35] + Digest::SHA1.hexdigest(::Time.now.to_s)[0..35] end end diff --git a/lib/aemo/nem12.rb b/lib/aemo/nem12.rb index 744d0d3..9b731ae 100644 --- a/lib/aemo/nem12.rb +++ b/lib/aemo/nem12.rb @@ -14,6 +14,9 @@ module AEMO # Namespace for classes and modules that handle AEMO Gem NEM12 interactions # @since 0.1.4 class NEM12 + CRLF = "\r\n" + CSV_SEPARATOR = ',' + @file_contents = nil @header = nil @nmi_data_details = [] @@ -26,10 +29,108 @@ class NEM12 attr_reader :data_details, :interval_data, :interval_events attr_accessor :file_contents, :header, :nmi_data_details, :nmi + # Class methods. + class << self + # @param [String] path_to_file the path to a file + # @return [Array] NEM12 object + def parse_nem12_file(path_to_file, strict: true) + parse_nem12(File.read(path_to_file), strict:) + end + + # @param [String] contents the path to a file + # @param [Boolean] strict + # @return [Array] An array of NEM12 objects + def parse_nem12(contents, strict: true) + file_contents = contents.tr("\r", "\n").tr("\n\n", "\n").split("\n").delete_if(&:empty?) + # nothing to further process + return [] if file_contents.empty? + + unless file_contents.first.parse_csv[0] == '100' + raise ArgumentError, + 'First row should be have a RecordIndicator of 100 and be of type Header Record' + end + + nem12s = [] + header = AEMO::NEM12.parse_nem12_100(file_contents.first, strict:) + file_contents.each do |line| + case line[0..2].to_i + when 200 + nem12s << AEMO::NEM12.new('') + nem12s.last.header = header + nem12s.last.parse_nem12_200(line, strict:) + when 300 + nem12s.last.parse_nem12_300(line, strict:) + when 400 + nem12s.last.parse_nem12_400(line, strict:) + # when 500 + # nem12s.last.parse_nem12_500(line, strict: strict) + # when 900 + # nem12s.last.parse_nem12_900(line, strict: strict) + end + end + # Return the array of NEM12 groups + nem12s + end + + # Parses the header record + # @param [String] line A single line in string format + # @param [Boolean] strict + # @return [Hash] the line parsed into a hash of information + def parse_nem12_100(line, strict: true) # rubocop:disable Naming/VariableNumber + csv = line.parse_csv + + raise ArgumentError, 'RecordIndicator is not 100' if csv[0] != '100' + raise ArgumentError, 'VersionHeader is not NEM12' if csv[1] != 'NEM12' + + raise ArgumentError, 'Time is not valid' if strict && !AEMO::Time.valid_timestamp12?(csv[2]) + + raise ArgumentError, 'FromParticipant is not valid' if csv[3].match(/.{1,10}/).nil? + raise ArgumentError, 'ToParticipant is not valid' if csv[4].match(/.{1,10}/).nil? + + datetime = strict && AEMO::Time.valid_timestamp12?(csv[2]) ? AEMO::Time.parse_timestamp12(csv[2]) : nil + + { + record_indicator: csv[0].to_i, + version_header: csv[1], + datetime:, + from_participant: csv[3], + to_participant: csv[4] + } + end + + # Default NEM12 100 row record. + # + # @return [String] + def default_nem12_100 # rubocop:disable Naming/VariableNumber + timestamp = AEMO::Time.format_timestamp12(::Time.now) + + "100,NEM12,#{timestamp},ENOSI,ENOSI#{CRLF}" + end + + # Default NEM12 100 row record. + # + # @return [String] + def default_nem12_900 # rubocop:disable Naming/VariableNumber + "900#{CRLF}" + end + + # For a list of nem12s, turn into a single NEM12 CSV string with default header row. + # + # @param [Array] nem12s + # @return [String] + def to_nem12_csv(nem12s:) + [ + default_nem12_100, + nem12s.map(&:to_nem12_200_csv), + default_nem12_900 + ].flatten.join + end + end + # Initialize a NEM12 file # @param [string] nmi # @param [Hash] options - def initialize(nmi, options = {}) + def initialize(nmi, options: {}) @nmi = AEMO::NMI.new(nmi) unless nmi.empty? @data_details = [] @interval_data = [] @@ -41,49 +142,42 @@ def initialize(nmi, options = {}) # Returns the NMI Identifier or nil def nmi_identifier - @nmi.nil? ? nil : @nmi.nmi - end - - # Parses the header record - # @param [String] line A single line in string format - # @param [Hash] options - # @return [Hash] the line parsed into a hash of information - def self.parse_nem12_100(line, options = {}) - csv = line.parse_csv - - raise ArgumentError, 'RecordIndicator is not 100' if csv[0] != '100' - raise ArgumentError, 'VersionHeader is not NEM12' if csv[1] != 'NEM12' - raise ArgumentError, 'Time is not valid' if options[:strict] && (csv[2].match(/\d{12}/).nil? || csv[2] != Time.parse("#{csv[2]}00").strftime('%Y%m%d%H%M')) - raise ArgumentError, 'FromParticipant is not valid' if csv[3].match(/.{1,10}/).nil? - raise ArgumentError, 'ToParticipant is not valid' if csv[4].match(/.{1,10}/).nil? - - { - record_indicator: csv[0].to_i, - version_header: csv[1], - datetime: Time.parse("#{csv[2]}+1000"), - from_participant: csv[3], - to_participant: csv[4] - } + @nmi&.nmi end # Parses the NMI Data Details # @param [String] line A single line in string format - # @param [Hash] options + # @param [Boolean] strict # @return [Hash] the line parsed into a hash of information - def parse_nem12_200(line, options = {}) + def parse_nem12_200(line, strict: true) # rubocop:disable Naming/VariableNumber csv = line.parse_csv raise ArgumentError, 'RecordIndicator is not 200' if csv[0] != '200' raise ArgumentError, 'NMI is not valid' unless AEMO::NMI.valid_nmi?(csv[1]) - raise ArgumentError, 'NMIConfiguration is not valid' if options[:strict] && (csv[2].nil? || csv[2].match(/.{1,240}/).nil?) + + if strict && (csv[2].nil? || csv[2].match(/.{1,240}/).nil?) + raise ArgumentError, + 'NMIConfiguration is not valid' + end + raise ArgumentError, 'RegisterID is not valid' if !csv[3].nil? && csv[3].match(/.{1,10}/).nil? raise ArgumentError, 'NMISuffix is not valid' if csv[4].nil? || csv[4].match(/[A-HJ-NP-Z][1-9A-HJ-NP-Z]/).nil? - raise ArgumentError, 'MDMDataStreamIdentifier is not valid' if !csv[5].nil? && !csv[5].empty? && !csv[5].match(/^\s*$/) && csv[5].match(/[A-Z0-9]{2}/).nil? - raise ArgumentError, 'MeterSerialNumber is not valid' if !csv[6].nil? && !csv[6].empty? && !csv[6].match(/^\s*$/) && csv[6].match(/[A-Z0-9]{2}/).nil? + + if !csv[5].nil? && !csv[5].empty? && !csv[5].match(/^\s*$/) && csv[5].match(/[A-Z0-9]{2}/).nil? + raise ArgumentError, + 'MDMDataStreamIdentifier is not valid' + end + + if !csv[6].nil? && !csv[6].empty? && !csv[6].match(/^\s*$/) && csv[6].match(/[A-Z0-9]{2}/).nil? + raise ArgumentError, + 'MeterSerialNumber is not valid' + end + raise ArgumentError, 'UOM is not valid' if csv[7].nil? || csv[7].upcase.match(/[A-Z0-9]{2}/).nil? raise ArgumentError, 'UOM is not valid' unless UOM.keys.map(&:upcase).include?(csv[7].upcase) raise ArgumentError, 'IntervalLength is not valid' unless %w[1 5 10 15 30].include?(csv[8]) - # raise ArgumentError, 'NextScheduledReadDate is not valid' if csv[9].match(/\d{8}/).nil? || csv[9] != Time.parse('#{csv[9]}').strftime('%Y%m%d') + + # raise ArgumentError, 'NextScheduledReadDate is not valid' if !AEMO::Time.valid_timestamp8?(csv[9]) @nmi = AEMO::NMI.new(csv[1]) @@ -103,43 +197,64 @@ def parse_nem12_200(line, options = {}) end # @param [String] line A single line in string format - # @param [Hash] options + # @param [Boolean] strict # @return [Array of hashes] the line parsed into a hash of information - def parse_nem12_300(line, options = {}) + def parse_nem12_300(line, strict: true) # rubocop:disable Naming/VariableNumber csv = line.parse_csv - raise TypeError, 'Expected NMI Data Details to exist with IntervalLength specified' if @data_details.last.nil? || @data_details.last[:interval_length].nil? + + if @data_details.last.nil? || @data_details.last[:interval_length].nil? + raise TypeError, + 'Expected NMI Data Details to exist with IntervalLength specified' + end # ref: AEMO's MDFF Spec NEM12 and NEM13 v1.01 (2014-05-14) record_fixed_fields = %w[RecordIndicator IntervalDate QualityMethod ReasonCode ReasonDescription UpdateDatetime MSATSLoadDateTime] number_of_intervals = 1440 / @data_details.last[:interval_length] + raise TypeError, 'Invalid record length' if csv.length != record_fixed_fields.length + number_of_intervals intervals_offset = number_of_intervals + 2 raise ArgumentError, 'RecordIndicator is not 300' if csv[0] != '300' - raise ArgumentError, 'IntervalDate is not valid' if csv[1].match(/\d{8}/).nil? || csv[1] != Time.parse(csv[1].to_s).strftime('%Y%m%d') + raise ArgumentError, 'IntervalDate is not valid' unless AEMO::Time.valid_timestamp8?(csv[1]) + (2..(number_of_intervals + 1)).each do |i| raise ArgumentError, "Interval number #{i - 1} is not valid" if csv[i].nil? || csv[i].match(/\d+(\.\d+)?/).nil? end - raise ArgumentError, 'QualityMethod is not valid' unless csv[intervals_offset + 0].class == String + + raise ArgumentError, 'QualityMethod is not valid' unless csv[intervals_offset + 0].instance_of?(String) raise ArgumentError, 'QualityMethod does not have valid length' unless [1, 3].include?(csv[intervals_offset + 0].length) - raise ArgumentError, 'QualityMethod does not have valid QualityFlag' unless QUALITY_FLAGS.keys.include?(csv[intervals_offset + 0][0]) + + unless QUALITY_FLAGS.keys.include?(csv[intervals_offset + 0][0]) + raise ArgumentError, + 'QualityMethod does not have valid QualityFlag' + end + unless %w[A N V].include?(csv[intervals_offset + 0][0]) raise ArgumentError, 'QualityMethod does not have valid length' unless csv[intervals_offset + 0].length == 3 - raise ArgumentError, 'QualityMethod does not have valid MethodFlag' unless METHOD_FLAGS.keys.include?(csv[intervals_offset + 0][1..2].to_i) - end - unless %w[A N E].include?(csv[intervals_offset + 0][0]) - raise ArgumentError, 'ReasonCode is not valid' unless REASON_CODES.keys.include?(csv[intervals_offset + 1].to_i) + + unless METHOD_FLAGS.keys.include?(csv[intervals_offset + 0][1..2].to_i) + raise ArgumentError, + 'QualityMethod does not have valid MethodFlag' + end end - if !csv[intervals_offset + 1].nil? && csv[intervals_offset + 1].to_i.zero? - raise ArgumentError, 'ReasonDescription is not valid' unless csv[intervals_offset + 2].class == String && !csv[intervals_offset + 2].empty? + + raise ArgumentError, 'ReasonCode is not valid' if !%w[A N E].include?(csv[intervals_offset + 0][0]) && !REASON_CODES.keys.include?(csv[intervals_offset + 1].to_i) + + if !csv[intervals_offset + 1].nil? && csv[intervals_offset + 1].to_i.zero? && !(csv[intervals_offset + 2].instance_of?(String) && !csv[intervals_offset + 2].empty?) + raise ArgumentError, + 'ReasonDescription is not valid' end - if options[:strict] - if csv[intervals_offset + 3].match(/\d{14}/).nil? || csv[intervals_offset + 3] != Time.parse(csv[intervals_offset + 3].to_s).strftime('%Y%m%d%H%M%S') - raise ArgumentError, 'UpdateDateTime is not valid' + + if strict + unless AEMO::Time.valid_timestamp14?(csv[intervals_offset + 3]) + raise ArgumentError, + 'UpdateDateTime is not valid' end - if !csv[intervals_offset + 4].blank? && csv[intervals_offset + 4].match(/\d{14}/).nil? || !csv[intervals_offset + 4].blank? && csv[intervals_offset + 4] != Time.parse(csv[intervals_offset + 4].to_s).strftime('%Y%m%d%H%M%S') - raise ArgumentError, 'MSATSLoadDateTime is not valid' + + if !csv[intervals_offset + 4].blank? && !AEMO::Time.valid_timestamp14?(csv[intervals_offset + 4]) + raise ArgumentError, + 'MSATSLoadDateTime is not valid' end end @@ -159,18 +274,18 @@ def parse_nem12_300(line, options = {}) updated_at = nil msats_load_at = nil - if options[:strict] - updated_at = Time.parse(csv[intervals_offset + 3]) unless csv[intervals_offset + 3].blank? - msats_load_at = Time.parse(csv[intervals_offset + 4]) unless csv[intervals_offset + 4].blank? + if strict + updated_at = AEMO::Time.parse_timestamp14(csv[intervals_offset + 3]) unless csv[intervals_offset + 3].blank? + msats_load_at = AEMO::Time.parse_timestamp14(csv[intervals_offset + 4]) unless csv[intervals_offset + 4].blank? end base_interval = { data_details: @data_details.last, - datetime: Time.parse("#{csv[1]}000000+1000"), + datetime: AEMO::Time.parse_timestamp8(csv[1]), value: nil, - flag: flag, - updated_at: updated_at, - msats_load_at: msats_load_at + flag:, + updated_at:, + msats_load_at: } intervals = [] @@ -185,14 +300,19 @@ def parse_nem12_300(line, options = {}) end # @param [String] line A single line in string format - # @param [Hash] options + # @param [Boolean] strict # @return [Hash] the line parsed into a hash of information - def parse_nem12_400(line, options = {}) + def parse_nem12_400(line, strict: true) # rubocop:disable Lint/UnusedMethodArgument,Naming/VariableNumber csv = line.parse_csv raise ArgumentError, 'RecordIndicator is not 400' if csv[0] != '400' raise ArgumentError, 'StartInterval is not valid' if csv[1].nil? || csv[1].match(/^\d+$/).nil? raise ArgumentError, 'EndInterval is not valid' if csv[2].nil? || csv[2].match(/^\d+$/).nil? - raise ArgumentError, 'QualityMethod is not valid' if csv[3].nil? || csv[3].match(/^([AN]|([AEFNSV]\d{2}))$/).nil? + + if csv[3].nil? || csv[3].match(/^([AN]|([AEFNSV]\d{2}))$/).nil? + raise ArgumentError, + 'QualityMethod is not valid' + end + # raise ArgumentError, 'ReasonCode is not valid' if (csv[4].nil? && csv[3].match(/^ANE/)) || csv[4].match(/^\d{3}?$/) || csv[3].match(/^ANE/) # raise ArgumentError, 'ReasonDescription is not valid' if (csv[4].nil? && csv[3].match(/^ANE/)) || ( csv[5].match(/^$/) && csv[4].match(/^0$/) ) @@ -204,7 +324,8 @@ def parse_nem12_400(line, options = {}) interval_start_point = @interval_data.length - number_of_intervals # For each of these - base_interval_event = { datetime: nil, quality_method: csv[3], reason_code: (csv[4].nil? ? nil : csv[4].to_i), reason_description: csv[5] } + base_interval_event = { datetime: nil, quality_method: csv[3], reason_code: csv[4]&.to_i, + reason_description: csv[5] } # Interval Numbers are 1-indexed ((csv[1].to_i)..(csv[2].to_i)).each do |i| @@ -229,16 +350,16 @@ def parse_nem12_400(line, options = {}) # What even is a 500 row? # # @param [String] line A single line in string format - # @param [Hash] _options + # @param [Boolean] strict # @return [Hash] the line parsed into a hash of information - def parse_nem12_500(_line, _options = {}); end + def parse_nem12_500(_line, strict: true); end # rubocop:disable Naming/VariableNumber # 900 is the last row a NEM12 should see... # # @param [String] line A single line in string format - # @param [Hash] _options + # @param [Boolean] strict # @return [Hash] the line parsed into a hash of information - def parse_nem12_900(_line, _options = {}); end + def parse_nem12_900(_line, strict: true); end # rubocop:disable Naming/VariableNumber # Turns the flag to a string # @@ -272,48 +393,133 @@ def to_a def to_csv headers = %w[nmi suffix units datetime value flags] ([headers] + to_a.map do |row| - row[3] = row[3].strftime('%Y%m%d%H%M%S%z') + row[3] = row[3].strftime('%Y%m%d%TH%M%S%z') row end).map do |row| row.join(', ') end.join("\n") end - # @param [String] path_to_file the path to a file - # @return [Array] NEM12 object - def self.parse_nem12_file(path_to_file, strict = true) - parse_nem12(File.read(path_to_file), strict) + # Output the AEMO::NEM12 to a valid NEM12 CSV string. + # + # @return [String] + def to_nem12_csv + [ + to_nem12_100_csv, + to_nem12_200_csv, + to_nem12_900_csv + ].flatten.join end - # @param [String] contents the path to a file - # @param [Boolean] strict - # @return [Array] An array of NEM12 objects - def self.parse_nem12(contents, strict = true) - file_contents = contents.tr("\r", "\n").tr("\n\n", "\n").split("\n").delete_if(&:empty?) - # nothing to further process - return [] if file_contents.empty? - - raise ArgumentError, 'First row should be have a RecordIndicator of 100 and be of type Header Record' unless file_contents.first.parse_csv[0] == '100' - - nem12s = [] - AEMO::NEM12.parse_nem12_100(file_contents.first, strict: strict) - file_contents.each do |line| - case line[0..2].to_i - when 200 - nem12s << AEMO::NEM12.new('') - nem12s.last.parse_nem12_200(line, strict: strict) - when 300 - nem12s.last.parse_nem12_300(line, strict: strict) - when 400 - nem12s.last.parse_nem12_400(line, strict: strict) - # when 500 - # nem12s.last.parse_nem12_500(line, strict: strict) - # when 900 - # nem12s.last.parse_nem12_900(line, strict: strict) + # Output the AEMO::NEM12 to a valid NEM12 100 row CSV string. + # + # @return [String] + def to_nem12_100_csv + return self.class.default_nem12_100 if header.nil? + + [ + header[:record_indicator], + header[:version_header], + AEMO::Time.format_timestamp12(header[:datetime]), + header[:from_participant], + header[:to_participant] + ].join(CSV_SEPARATOR) + CRLF + end + + # Output the AEMO::NEM12 to a valid NEM12 200 row CSV string. + # + # @return [String] + def to_nem12_200_csv + return nil if data_details.length != 1 + + data_detail = data_details.first + + [ + [ + data_detail[:record_indicator], + data_detail[:nmi], + data_detail[:nmi_configuration], + data_detail[:register_id], + data_detail[:nmi_suffix], + data_detail[:mdm_data_streaming_identifier], + data_detail[:meter_serial_number], + data_detail[:uom], + data_detail[:interval_length], + data_detail[:next_scheduled_read_date] # NOTE: this is not turned into a timestamp. + ].join(CSV_SEPARATOR), + to_nem12_300_csv + ].flatten.join(CRLF) + end + + # Output the AEMO::NEM12 to a valid NEM12 300 row CSV string. + # + # @return [String] + def to_nem12_300_csv + lines = [] + + daily_datas = interval_data.group_by do |x| + AEMO::Time.format_timestamp8(x[:datetime] - 1.second) + end + daily_datas.keys.sort.each do |key| + daily_data = daily_datas[key].sort_by { |x| x[:datetime] } + has_flags = daily_data.map { |x| x[:flag]&.any? }.uniq.include?(true) + + lines << [ + '300', + key, + daily_data.map { |x| x[:value] }, + has_flags ? 'V' : 'A', + '', + '', + daily_data.first[:updated_at] ? AEMO::Time.format_timestamp14(daily_data.first[:updated_at]) : nil, + daily_data.first[:msats_load_at] ? AEMO::Time.format_timestamp14(daily_data.first[:msats_load_at]) : nil + ].flatten.join(CSV_SEPARATOR) + + next unless has_flags + + lines << to_nem12_400_csv(daily_data:) + end + + lines.join(CRLF) + CRLF + end + + # Output the AEMO::NEM12 to a valid NEM12 400 row CSV string. + # + # @param [Array] daily_data + # @return [String] + def to_nem12_400_csv(daily_data:) + daily_data.sort_by! { |x| x[:datetime] } + + nem12_400_rows = [] + + daily_data.each_with_index do |x, i| + nem12_400_rows << { flag: x[:flag], start_index: i + 1, finish_index: i + 1 } if nem12_400_rows.empty? + + if nem12_400_rows.last[:flag] == x[:flag] + nem12_400_rows.last[:finish_index] = i + 1 + next end + + nem12_400_rows << { flag: x[:flag], start_index: i + 1, finish_index: i + 1 } end - # Return the array of NEM12 groups - nem12s + + nem12_400_rows.map do |row| + [ + '400', + row[:start_index], + row[:finish_index], + row[:flag].nil? ? 'A' : "#{row[:flag][:quality_flag]}#{row[:flag][:method_flag]}", + row[:flag].nil? ? '' : row[:flag][:reason_code], + '' + ].join(CSV_SEPARATOR) + end.join(CRLF) + end + + # Output the AEMO::NEM12 to a valid NEM12 900 row CSV string. + # + # @return [String] + def to_nem12_900_csv + self.class.default_nem12_900 end end end diff --git a/lib/aemo/nem12/data_stream_suffix.rb b/lib/aemo/nem12/data_stream_suffix.rb index d83705e..65ee3c0 100644 --- a/lib/aemo/nem12/data_stream_suffix.rb +++ b/lib/aemo/nem12/data_stream_suffix.rb @@ -29,7 +29,7 @@ class NEM12 'U' => { stream: 'Check', description: '', units: 'kVAh' }, 'Y' => { stream: 'Check', description: 'Q Metering', units: 'Qh' }, 'W' => { stream: 'Check', description: 'Par Metering Path', units: '' }, - 'Z' => { stream: 'Check', description: 'Volts or V2h or Amps or A2h', units: '' }, + 'Z' => { stream: 'Check', description: 'Volts or V2h or Amps or A2h', units: '' } # Net Meter Streams # AEMO: NOTE THAT D AND J ARE PREVIOUSLY DEFINED # 'D' => { stream: 'Net', description: 'Net', units: 'kWh' }, diff --git a/lib/aemo/nem12/quality_method.rb b/lib/aemo/nem12/quality_method.rb index c7c9d5c..1eb8356 100644 --- a/lib/aemo/nem12/quality_method.rb +++ b/lib/aemo/nem12/quality_method.rb @@ -5,12 +5,12 @@ module AEMO # @since 0.1.4 class NEM12 QUALITY_FLAGS = { - 'A' => 'Actual Data', - 'E' => 'Forward Estimated Data', - 'F' => 'Final Substituted Data', - 'N' => 'Null Data', - 'S' => 'Substituted Data', - 'V' => 'Variable Data' + 'A' => 'Actual Data', + 'E' => 'Forward Estimated Data', + 'F' => 'Final Substituted Data', + 'N' => 'Null Data', + 'S' => 'Substituted Data', + 'V' => 'Variable Data' }.freeze METHOD_FLAGS = { @@ -23,17 +23,21 @@ class NEM12 17 => { type: %w[SUB], installation_type: [1, 2, 3, 4], short_descriptor: 'Linear', description: '' }, 18 => { type: %w[SUB], installation_type: [1, 2, 3, 4], short_descriptor: 'Alternate', description: '' }, 19 => { type: %w[SUB], installation_type: [1, 2, 3, 4], short_descriptor: 'Zero', description: '' }, - 20 => { type: %w[SUB], installation_type: [1, 2, 3, 4], short_descriptor: 'Churn Correction (Like Day)', description: '' }, - 21 => { type: %w[SUB], installation_type: [1, 2, 3, 4], short_descriptor: 'Five-minute No Historical Data', description: '' }, + 20 => { type: %w[SUB], installation_type: [1, 2, 3, 4], short_descriptor: 'Churn Correction (Like Day)', + description: '' }, + 21 => { type: %w[SUB], installation_type: [1, 2, 3, 4], short_descriptor: 'Five-minute No Historical Data', + description: '' }, 51 => { type: %w[EST SUB], installation_type: 5, short_descriptor: 'Previous Year', description: '' }, 52 => { type: %w[EST SUB], installation_type: 5, short_descriptor: 'Previous Read', description: '' }, 53 => { type: %w[SUB], installation_type: 5, short_descriptor: 'Revision', description: '' }, 54 => { type: %w[SUB], installation_type: 5, short_descriptor: 'Linear', description: '' }, 55 => { type: %w[SUB], installation_type: 5, short_descriptor: 'Agreed', description: '' }, - 56 => { type: %w[EST SUB], installation_type: 5, short_descriptor: 'Prior to First Read - Agreed', description: '' }, + 56 => { type: %w[EST SUB], installation_type: 5, short_descriptor: 'Prior to First Read - Agreed', + description: '' }, 57 => { type: %w[EST SUB], installation_type: 5, short_descriptor: 'Customer Class', description: '' }, 58 => { type: %w[EST SUB], installation_type: 5, short_descriptor: 'Zero', description: '' }, - 59 => { type: %w[EST SUB], installation_type: 5, short_descriptor: 'Five-minute No Historical Data', description: '' }, + 59 => { type: %w[EST SUB], installation_type: 5, short_descriptor: 'Five-minute No Historical Data', + description: '' }, 61 => { type: %w[EST SUB], installation_type: 6, short_descriptor: 'Previous Year', description: '' }, 62 => { type: %w[EST SUB], installation_type: 6, short_descriptor: 'Previous Read', description: '' }, 63 => { type: %w[EST SUB], installation_type: 6, short_descriptor: 'Customer Class', description: '' }, diff --git a/lib/aemo/nem12/unit_of_measurement.rb b/lib/aemo/nem12/unit_of_measurement.rb index ef170d0..6adbad6 100644 --- a/lib/aemo/nem12/unit_of_measurement.rb +++ b/lib/aemo/nem12/unit_of_measurement.rb @@ -5,55 +5,55 @@ module AEMO # @since 0.1.4 class NEM12 UOM = { - 'MWh' => { name: 'Megawatt Hour', multiplier: 1e6 }, - 'kWh' => { name: 'Kilowatt Hour', multiplier: 1e3 }, - 'Wh' => { name: 'Watt Hour', multiplier: 1 }, - 'MW' => { name: 'Megawatt', multiplier: 1e6 }, - 'kW' => { name: 'Kilowatt', multiplier: 1e3 }, - 'W' => { name: 'Watt', multiplier: 1 }, + 'MWh' => { name: 'Megawatt Hour', multiplier: 1e6 }, + 'kWh' => { name: 'Kilowatt Hour', multiplier: 1e3 }, + 'Wh' => { name: 'Watt Hour', multiplier: 1 }, + 'MW' => { name: 'Megawatt', multiplier: 1e6 }, + 'kW' => { name: 'Kilowatt', multiplier: 1e3 }, + 'W' => { name: 'Watt', multiplier: 1 }, 'MVArh' => { name: 'Megavolt Ampere Reactive Hour', multiplier: 1e6 }, 'kVArh' => { name: 'Kilovolt Ampere Reactive Hour', multiplier: 1e3 }, - 'VArh' => { name: 'Volt Ampere Reactive Hour', multiplier: 1 }, - 'MVAr' => { name: 'Megavolt Ampere Reactive', multiplier: 1e6 }, - 'kVAr' => { name: 'Kilovolt Ampere Reactive', multiplier: 1e3 }, - 'VAr' => { name: 'Volt Ampere Reactive', multiplier: 1 }, - 'MVAh' => { name: 'Megavolt Ampere Hour', multiplier: 1e6 }, - 'kVAh' => { name: 'Kilovolt Ampere Hour', multiplier: 1e3 }, - 'VAh' => { name: 'Volt Ampere Hour', multiplier: 1 }, - 'MVA' => { name: 'Megavolt Ampere', multiplier: 1e6 }, - 'kVA' => { name: 'Kilovolt Ampere', multiplier: 1e3 }, - 'VA' => { name: 'Volt Ampere', multiplier: 1 }, - 'kV' => { name: 'Kilovolt', multiplier: 1e3 }, - 'V' => { name: 'Volt', multiplier: 1 }, - 'kA' => { name: 'Kiloampere', multiplier: 1e3 }, - 'A' => { name: 'Ampere', multiplier: 1 }, - 'pf' => { name: 'Power Factor', multiplier: 1 } + 'VArh' => { name: 'Volt Ampere Reactive Hour', multiplier: 1 }, + 'MVAr' => { name: 'Megavolt Ampere Reactive', multiplier: 1e6 }, + 'kVAr' => { name: 'Kilovolt Ampere Reactive', multiplier: 1e3 }, + 'VAr' => { name: 'Volt Ampere Reactive', multiplier: 1 }, + 'MVAh' => { name: 'Megavolt Ampere Hour', multiplier: 1e6 }, + 'kVAh' => { name: 'Kilovolt Ampere Hour', multiplier: 1e3 }, + 'VAh' => { name: 'Volt Ampere Hour', multiplier: 1 }, + 'MVA' => { name: 'Megavolt Ampere', multiplier: 1e6 }, + 'kVA' => { name: 'Kilovolt Ampere', multiplier: 1e3 }, + 'VA' => { name: 'Volt Ampere', multiplier: 1 }, + 'kV' => { name: 'Kilovolt', multiplier: 1e3 }, + 'V' => { name: 'Volt', multiplier: 1 }, + 'kA' => { name: 'Kiloampere', multiplier: 1e3 }, + 'A' => { name: 'Ampere', multiplier: 1 }, + 'pf' => { name: 'Power Factor', multiplier: 1 } }.freeze UOM_NON_SPEC_MAPPING = { - 'MWH' => 'MWh', - 'KWH' => 'kWh', - 'WH' => 'Wh', - 'MW' => 'MW', - 'KW' => 'kW', - 'W' => 'W', + 'MWH' => 'MWh', + 'KWH' => 'kWh', + 'WH' => 'Wh', + 'MW' => 'MW', + 'KW' => 'kW', + 'W' => 'W', 'MVARH' => 'MVArh', 'KVARH' => 'kVArh', - 'VARH' => 'VArh', - 'MVAR' => 'MVAr', - 'KVAR' => 'kVAr', - 'VAR' => 'VAr', - 'MVAH' => 'MVAh', - 'KVAH' => 'kVAh', - 'VAH' => 'VAh', - 'MVA' => 'MVA', - 'KVA' => 'kVA', - 'VA' => 'VA', - 'KV' => 'kV', - 'V' => 'V', - 'KA' => 'kA', - 'A' => 'A', - 'PF' => 'pf' + 'VARH' => 'VArh', + 'MVAR' => 'MVAr', + 'KVAR' => 'kVAr', + 'VAR' => 'VAr', + 'MVAH' => 'MVAh', + 'KVAH' => 'kVAh', + 'VAH' => 'VAh', + 'MVA' => 'MVA', + 'KVA' => 'kVA', + 'VA' => 'VA', + 'KV' => 'kV', + 'V' => 'V', + 'KA' => 'kA', + 'A' => 'A', + 'PF' => 'pf' }.freeze end end diff --git a/lib/aemo/nem13.rb b/lib/aemo/nem13.rb index a322c2c..12c05ce 100644 --- a/lib/aemo/nem13.rb +++ b/lib/aemo/nem13.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true module AEMO - class NEM13 - end + class NEM13; end # rubocop:disable Lint/EmptyClass end diff --git a/lib/aemo/nmi.rb b/lib/aemo/nmi.rb index ac283c2..e5eccc3 100644 --- a/lib/aemo/nmi.rb +++ b/lib/aemo/nmi.rb @@ -5,7 +5,7 @@ require 'time' require 'ostruct' -require 'aemo/nmi/allocation.rb' +require 'aemo/nmi/allocation' module AEMO # [AEMO::NMI] @@ -22,11 +22,11 @@ class NMI 'ACT' => 'Australian Capital Territory', 'NSW' => 'New South Wales', 'QLD' => 'Queensland', - 'SA' => 'South Australia', + 'SA' => 'South Australia', 'TAS' => 'Tasmania', 'VIC' => 'Victoria', - 'WA' => 'Western Australia', - 'NT' => 'Northern Territory' + 'WA' => 'Western Australia', + 'NT' => 'Northern Territory' }.freeze # Transmission Node Identifier Codes are loaded from a json file @@ -79,7 +79,7 @@ class << self # @param [String] nmi the nmi to be checked # @return [Boolean] whether or not the nmi is valid def valid_nmi?(nmi) - ((nmi.length == 10) && !nmi.match(/^([A-HJ-NP-Z\d]{10})/).nil?) + (nmi.length == 10) && !nmi.match(/^([A-HJ-NP-Z\d]{10})/).nil? end # A function to calculate the checksum value for a given National Meter @@ -159,14 +159,13 @@ def valid_checksum?(checksum_value) # Identifier def checksum summation = 0 - @nmi.reverse.split(//).each_index do |i| + @nmi.reverse.chars.each_index do |i| value = nmi[nmi.length - i - 1].ord value *= 2 if i.even? - value = value.to_s.split(//).map(&:to_i).reduce(:+) + value = value.to_s.chars.map(&:to_i).reduce(:+) summation += value end - checksum = (10 - (summation % 10)) % 10 - checksum + (10 - (summation % 10)) % 10 end # Provided MSATS is configured, gets the MSATS data for the NMI @@ -174,6 +173,7 @@ def checksum # @return [Hash] MSATS NMI Detail data def raw_msats_nmi_detail(options = {}) raise ArgumentError, 'MSATS has no authentication credentials' unless AEMO::MSATS.can_authenticate? + AEMO::MSATS.nmi_detail(@nmi, options) end @@ -233,7 +233,7 @@ def parse_msats_detail data_streams = @msats_detail['DataStreams']['DataStream'] data_streams = [data_streams] if data_streams.is_a?(Hash) # Deal with issue of only one existing data_streams.each do |stream| - @data_streams << OpenStruct.new( + @data_streams << Struct::DataStream.new( suffix: stream['Suffix'], profile_name: stream['ProfileName'], averaged_daily_load: stream['AveragedDailyLoad'], @@ -270,10 +270,10 @@ def meters_by_status(status = 'C') @meters.select { |x| x.status == status.to_s } end - # Returns the data_stream OpenStructs for the requested status (A/I) + # Returns the data_stream Structs for the requested status (A/I) # # @param [String] status the stateus [A|I] - # @return [Array] Returns an array of OpenStructs for the + # @return [Array] Returns an array of Structs for the # current Meters def data_streams_by_status(status = 'A') @data_streams.select { |x| x.status == status.to_s } @@ -297,19 +297,20 @@ def current_annual_load # A function to return the distribution loss factor value for a given date # - # @param [DateTime, Time] datetime the date for the distribution loss factor + # @param [DateTime, ::Time] datetime the date for the distribution loss factor # value # @return [nil, float] the distribution loss factor value - def dlfc_value(datetime = Time.now) + def dlfc_value(datetime = ::Time.now) if @dlf.nil? raise 'No DLF set, ensure that you have set the value either via the' \ 'update_from_msats! function or manually' end raise 'DLF is invalid' unless DLF_CODES.keys.include?(@dlf) - raise 'Invalid date' unless [DateTime, Time].include?(datetime.class) + raise 'Invalid date' unless [DateTime, ::Time].include?(datetime.class) + possible_values = DLF_CODES[@dlf].select do |x| - Time.parse(x['FromDate']) <= datetime && - Time.parse(x['ToDate']) >= datetime + ::Time.parse(x['FromDate']) <= datetime && + ::Time.parse(x['ToDate']) >= datetime end if possible_values.empty? nil @@ -320,61 +321,70 @@ def dlfc_value(datetime = Time.now) # A function to return the distribution loss factor value for a given date # - # @param [DateTime, Time] start the date for the distribution loss factor value - # @param [DateTime, Time] finish the date for the distribution loss factor value + # @param [DateTime, ::Time] start the date for the distribution loss factor value + # @param [DateTime, ::Time] finish the date for the distribution loss factor value # @return [Array(Hash)] array of hashes of start, finish and value - def dlfc_values(start = Time.now, finish = Time.now) + def dlfc_values(start = ::Time.now, finish = ::Time.now) if @dlf.nil? - raise 'No DLF set, ensure that you have set the value either via the '\ + raise 'No DLF set, ensure that you have set the value either via the ' \ 'update_from_msats! function or manually' end raise 'DLF is invalid' unless DLF_CODES.keys.include?(@dlf) - raise 'Invalid start' unless [DateTime, Time].include?(start.class) - raise 'Invalid finish' unless [DateTime, Time].include?(finish.class) + raise 'Invalid start' unless [DateTime, ::Time].include?(start.class) + raise 'Invalid finish' unless [DateTime, ::Time].include?(finish.class) raise 'start cannot be after finish' if start > finish - DLF_CODES[@dlf].reject { |x| start > Time.parse(x['ToDate']) || finish < Time.parse(x['FromDate']) } + + DLF_CODES[@dlf].reject { |x| start > ::Time.parse(x['ToDate']) || finish < ::Time.parse(x['FromDate']) } .map { |x| { 'start' => x['FromDate'], 'finish' => x['ToDate'], 'value' => x['Value'].to_f } } end # A function to return the transmission node identifier loss factor value for a given date # - # @param [DateTime, Time] datetime the date for the distribution loss factor value + # @param [DateTime, ::Time] datetime the date for the distribution loss factor value # @return [nil, float] the transmission node identifier loss factor value - def tni_value(datetime = Time.now) + def tni_value(datetime = ::Time.now) if @tni.nil? - raise 'No TNI set, ensure that you have set the value either via the '\ + raise 'No TNI set, ensure that you have set the value either via the ' \ 'update_from_msats! function or manually' end raise 'TNI is invalid' unless TNI_CODES.keys.include?(@tni) - raise 'Invalid date' unless [DateTime, Time].include?(datetime.class) - possible_values = TNI_CODES[@tni].select { |x| Time.parse(x['FromDate']) <= datetime && datetime <= Time.parse(x['ToDate']) } + raise 'Invalid date' unless [DateTime, ::Time].include?(datetime.class) + + possible_values = TNI_CODES[@tni].select do |x| + ::Time.parse(x['FromDate']) <= datetime && datetime <= ::Time.parse(x['ToDate']) + end return nil if possible_values.empty? - possible_values = possible_values.first['mlf_data']['loss_factors'].select { |x| Time.parse(x['start']) <= datetime && datetime <= Time.parse(x['finish']) } + + possible_values = possible_values.first['mlf_data']['loss_factors'].select do |x| + ::Time.parse(x['start']) <= datetime && datetime <= ::Time.parse(x['finish']) + end return nil if possible_values.empty? + possible_values.first['value'].to_f end # A function to return the transmission node identifier loss factor value for a given date # - # @param [DateTime, Time] start the date for the distribution loss factor value - # @param [DateTime, Time] finish the date for the distribution loss factor value + # @param [DateTime, ::Time] start the date for the distribution loss factor value + # @param [DateTime, ::Time] finish the date for the distribution loss factor value # @return [Array(Hash)] array of hashes of start, finish and value - def tni_values(start = Time.now, finish = Time.now) + def tni_values(start = ::Time.now, finish = ::Time.now) if @tni.nil? - raise 'No TNI set, ensure that you have set the value either via the '\ + raise 'No TNI set, ensure that you have set the value either via the ' \ 'update_from_msats! function or manually' end raise 'TNI is invalid' unless TNI_CODES.keys.include?(@tni) - raise 'Invalid start' unless [DateTime, Time].include?(start.class) - raise 'Invalid finish' unless [DateTime, Time].include?(finish.class) + raise 'Invalid start' unless [DateTime, ::Time].include?(start.class) + raise 'Invalid finish' unless [DateTime, ::Time].include?(finish.class) raise 'start cannot be after finish' if start > finish possible_values = TNI_CODES[@tni].reject do |tni_code| - start > Time.parse(tni_code['ToDate']) || - finish < Time.parse(tni_code['FromDate']) + start > ::Time.parse(tni_code['ToDate']) || + finish < ::Time.parse(tni_code['FromDate']) end return nil if possible_values.empty? + possible_values.map { |x| x['mlf_data']['loss_factors'] } end end diff --git a/lib/aemo/nmi/allocation.rb b/lib/aemo/nmi/allocation.rb index 60eae11..675eb29 100644 --- a/lib/aemo/nmi/allocation.rb +++ b/lib/aemo/nmi/allocation.rb @@ -155,7 +155,7 @@ class Allocation type: 'electricity', includes: [ /^(SAAA[A-HJ-NP-VX-Z\d][A-HJ-NP-Z\d]{5})$/, - /^(SASMPL[\d]{4})$/, + /^(SASMPL\d{4})$/, /^(200[12]\d{6})$/ ], excludes: [] @@ -405,8 +405,8 @@ def all # Enumerable support # # @return [Enumerator] - def each(&block) - all.each(&block) + def each(&) + all.each(&) end # Finds the Allocation that encompasses a given NMI @@ -444,7 +444,7 @@ def initialize(title, type, opts = {}) @friendly_title = opts.fetch(:friendly_title, title) @exclude_nmi_patterns = opts.fetch(:excludes, []) @include_nmi_patterns = opts.fetch(:includes, []) - @region = AEMO::Region.new(opts.fetch(:region)) if opts.dig(:region) + @region = AEMO::Region.new(opts.fetch(:region)) if opts[:region] end private @@ -456,9 +456,8 @@ def initialize(title, type, opts = {}) # @return [Symbol] def parse_allocation_type(type) type_sym = type.to_sym - unless SUPPORTED_TYPES.include?(type_sym) - raise AEMO::InvalidNMIAllocationType - end + raise AEMO::InvalidNMIAllocationType unless SUPPORTED_TYPES.include?(type_sym) + type_sym rescue NoMethodError raise AEMO::InvalidNMIAllocationType diff --git a/lib/aemo/region.rb b/lib/aemo/region.rb index 01f4c49..621d8dc 100644 --- a/lib/aemo/region.rb +++ b/lib/aemo/region.rb @@ -12,11 +12,11 @@ class Region 'ACT' => 'Australian Capital Territory', 'NSW' => 'New South Wales', 'QLD' => 'Queensland', - 'SA' => 'South Australia', + 'SA' => 'South Australia', 'TAS' => 'Tasmania', 'VIC' => 'Victoria', - 'NT' => 'Northern Territory', - 'WA' => 'Western Australia' + 'NT' => 'Northern Territory', + 'WA' => 'Western Australia' }.freeze attr_accessor :region @@ -34,6 +34,7 @@ def all # @return [self] def initialize(region) raise ArgumentError, "Region '#{region}' is not valid." unless valid_region?(region) + @region = region.upcase @full_name = REGIONS[@region] @current_trading = [] diff --git a/lib/aemo/register.rb b/lib/aemo/register.rb index f3191d0..9e1ed18 100644 --- a/lib/aemo/register.rb +++ b/lib/aemo/register.rb @@ -28,14 +28,14 @@ def initialize(opts = {}) # @return [AEMO::Register] description of returned object def self.from_hash(register) AEMO::Register.new( - controlled_load: register['ControlledLoad'] == 'Y', - dial_format: register['DialFormat'], - multiplier: register['Multiplier'], + controlled_load: register['ControlledLoad'] == 'Y', + dial_format: register['DialFormat'], + multiplier: register['Multiplier'], network_tariff_code: register['NetworkTariffCode'], - register_id: register['RegisterID'], - status: register['Status'], - time_of_day: register['TimeOfDay'], - unit_of_measure: register['UnitOfMeasure'] + register_id: register['RegisterID'], + status: register['Status'], + time_of_day: register['TimeOfDay'], + unit_of_measure: register['UnitOfMeasure'] ) end end diff --git a/lib/aemo/struct.rb b/lib/aemo/struct.rb new file mode 100644 index 0000000..e663ec3 --- /dev/null +++ b/lib/aemo/struct.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Struct.new('DataStream', :suffix, :profile_name, :averaged_daily_load, :data_stream_type, :status, keyword_init: true) diff --git a/lib/aemo/time.rb b/lib/aemo/time.rb new file mode 100644 index 0000000..cbf14b2 --- /dev/null +++ b/lib/aemo/time.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'time' +require 'active_support/all' + +require_relative 'exceptions/time_error' + +module AEMO + # [AEMO::Time] provides time helpers for AEMO services. + module Time + NEMTIMEZONE = 'Australia/Brisbane' + TIMESTAMP14 = '%Y%m%d%H%M%S' + TIMESTAMP14_PATTERN = /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}$/ + TIMESTAMP12 = '%Y%m%d%H%M' + TIMESTAMP12_PATTERN = /^\d{4}\d{2}\d{2}\d{2}\d{2}$/ + TIMESTAMP8 = '%Y%m%d' + TIMESTAMP8_PATTERN = /^\d{4}\d{2}\d{2}$/ + + class << self + # Format a time to a timestamp 14. + # + # @param [Time] time + # @return [String] + def format_timestamp14(time) + time.in_time_zone(NEMTIMEZONE).strftime(TIMESTAMP14) + end + + # Format a time to a timestamp 12. + # + # @param [Time] time + # @return [String] + def format_timestamp12(time) + time.in_time_zone(NEMTIMEZONE).strftime(TIMESTAMP12) + end + + # Format a time to a timestamp 8. + # + # @param [Time] time + # @return [String] + def format_timestamp8(time) + time.in_time_zone(NEMTIMEZONE).strftime(TIMESTAMP8) + end + + # Parse a 14 character timestamp. + # + # @param [String] string + # @raise [AEMO::TimeError] + # @return [Time] + def parse_timestamp14(string) + raise AEMO::TimeError unless string.match(TIMESTAMP14_PATTERN) + + ::Time.find_zone(NEMTIMEZONE).strptime(string, TIMESTAMP14) + end + + # Parse a 12 character timestamp. + # + # @param [String] string + # @return [Time] + def parse_timestamp12(string) + raise AEMO::TimeError unless string.match(TIMESTAMP12_PATTERN) + + ::Time.find_zone(NEMTIMEZONE).strptime(string, TIMESTAMP12) + end + + # Parse an 8 character date. + # + # @param [String] string + # @return [Time] + def parse_timestamp8(string) + raise AEMO::TimeError unless string.match(TIMESTAMP8_PATTERN) + + ::Time.find_zone(NEMTIMEZONE).strptime(string, TIMESTAMP8) + end + + # Check if a string is a valid timestamp 14. + # + # @param [String] string + # @return [Boolean] + def valid_timestamp14?(string) + parse_timestamp14(string) + + true + rescue AEMO::TimeError + false + end + + # Check if a string is a valid timestamp 12. + # + # @param [String] string + # @return [Boolean] + def valid_timestamp12?(string) + parse_timestamp12(string) + + true + rescue AEMO::TimeError + false + end + + # Check if a string is a valid timestamp 8. + # + # @param [String] string + # @return [Boolean] + def valid_timestamp8?(string) + parse_timestamp8(string) + + true + rescue AEMO::TimeError + false + end + end + end +end diff --git a/lib/aemo/version.rb b/lib/aemo/version.rb index fc8de4b..57f268b 100644 --- a/lib/aemo/version.rb +++ b/lib/aemo/version.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -# -*- coding: UTF-8 -*- # # Copyright 2014 Joel Courtney # @@ -24,7 +23,7 @@ # @author Joel Courtney module AEMO # aemo version - VERSION = '0.5.1' + VERSION = '0.6.0' # aemo version split amongst different revisions MAJOR_VERSION, MINOR_VERSION, REVISION = VERSION.split('.').map(&:to_i) diff --git a/lib/data/xml_to_json.rb b/lib/data/xml_to_json.rb index bd3adb7..13b9ea3 100644 --- a/lib/data/xml_to_json.rb +++ b/lib/data/xml_to_json.rb @@ -21,7 +21,7 @@ @mlf_data[row['TNI']] ||= { location: row['Location'], voltage: row['Voltage'], loss_factors: [] } - row.headers.select { |x| x =~ /^FY\d{2}$/ }.sort.reverse.each do |fin_year| + row.headers.grep(/^FY\d{2}$/).sort.reverse.each do |fin_year| year = "20#{fin_year.match(/FY(\d{2})/)[1]}".to_i @mlf_data[row['TNI']][:loss_factors] << { start: Time.parse("#{year - 1}-07-01T00:00:00+1000"), @@ -74,7 +74,5 @@ output_data[code] << output_data_instance end - File.open(File.join(@path, output_file), 'w') do |write_file| - write_file.write(output_data.to_json) - end + File.write(File.join(@path, output_file), output_data.to_json) end diff --git a/spec/aemo_spec.rb b/spec/aemo_spec.rb index dbbedc2..d415ccc 100644 --- a/spec/aemo_spec.rb +++ b/spec/aemo_spec.rb @@ -1,10 +1,3 @@ # frozen_string_literal: true require File.expand_path(File.join(File.dirname(__FILE__), 'spec_helper')) - -describe AEMO do - before(:each) do - @klass = Class.new - @klass.instance_eval { include AEMO } - end -end diff --git a/spec/lib/aemo/market/interval_spec.rb b/spec/lib/aemo/market/interval_spec.rb index 15e0e65..5908538 100644 --- a/spec/lib/aemo/market/interval_spec.rb +++ b/spec/lib/aemo/market/interval_spec.rb @@ -8,42 +8,60 @@ expect(AEMO::Market::Interval::INTERVALS).to eq(trading: 'Trading', dispatch: 'Dispatch') end end + describe 'AEMO::Market::Interval instance methods' do - before(:each) do - @interval = AEMO::Market::Interval.new('2016-03-01T00:30:00', 'REGION' => 'NSW', 'TOTALDEMAND' => 1000.23, 'RRP' => 76.54, 'PERIODTYPE' => 'TRADING') + before do + @interval = described_class.new('2016-03-01T00:30:00', 'REGION' => 'NSW', 'TOTALDEMAND' => 1000.23, + 'RRP' => 76.54, 'PERIODTYPE' => 'TRADING') end + it 'creates a valid interval' do - expect { AEMO::Market::Interval.new('2016-03-01T00:30:00', 'REGION' => 'NSW', 'TOTALDEMAND' => 1000.23, 'RRP' => 76.54, 'PERIODTYPE' => 'TRADING') }.not_to raise_error + expect do + described_class.new('2016-03-01T00:30:00', 'REGION' => 'NSW', 'TOTALDEMAND' => 1000.23, 'RRP' => 76.54, + 'PERIODTYPE' => 'TRADING') + end.not_to raise_error end + it 'has a trailing datetime' do expect(@interval.datetime).to eq(Time.parse('2016-03-01T00:30:00+1000')) end + it 'has a leading datetime' do - expect(@interval.datetime(false)).to eq(Time.parse('2016-03-01T00:00:00+1000')) + expect(@interval.datetime(trailing_edge: false)).to eq(Time.parse('2016-03-01T00:00:00+1000')) end + it 'has a leading datetime for dispatch' do - @interval = AEMO::Market::Interval.new('2016-03-01T00:30:00', 'REGION' => 'NSW', 'TOTALDEMAND' => 1000.23, 'RRP' => 76.54, 'PERIODTYPE' => '') - expect(@interval.datetime(false)).to eq(Time.parse('2016-03-01T00:25:00+1000')) + @interval = described_class.new('2016-03-01T00:30:00', 'REGION' => 'NSW', 'TOTALDEMAND' => 1000.23, + 'RRP' => 76.54, 'PERIODTYPE' => '') + expect(@interval.datetime(trailing_edge: false)).to eq(Time.parse('2016-03-01T00:25:00+1000')) end + it 'has an interval length' do expect(@interval.interval_length).to eq(Time.at(300)) end + it 'is a trading interval' do expect(@interval.interval_type).to eq(:trading) end + it 'is a trading interval' do - expect(@interval.trading?).to eq(true) - expect(@interval.dispatch?).to eq(false) + expect(@interval.trading?).to be(true) + expect(@interval.dispatch?).to be(false) end + it 'is a dispatch interval' do - @interval = AEMO::Market::Interval.new('2016-03-01T00:30:00', 'REGION' => 'NSW', 'TOTALDEMAND' => 1000.23, 'RRP' => 76.54, 'PERIODTYPE' => '') + @interval = described_class.new('2016-03-01T00:30:00', 'REGION' => 'NSW', 'TOTALDEMAND' => 1000.23, + 'RRP' => 76.54, 'PERIODTYPE' => '') expect(@interval.interval_type).to eq(:dispatch) end + it 'is a dispatch interval' do - @interval = AEMO::Market::Interval.new('2016-03-01T00:30:00', 'REGION' => 'NSW', 'TOTALDEMAND' => 1000.23, 'RRP' => 76.54, 'PERIODTYPE' => '') - expect(@interval.trading?).to eq(false) - expect(@interval.dispatch?).to eq(true) + @interval = described_class.new('2016-03-01T00:30:00', 'REGION' => 'NSW', 'TOTALDEMAND' => 1000.23, + 'RRP' => 76.54, 'PERIODTYPE' => '') + expect(@interval.trading?).to be(false) + expect(@interval.dispatch?).to be(true) end + it 'has a valid value' do expect(@interval.value).to eq((@interval.total_demand * @interval.rrp).round(2)) end diff --git a/spec/lib/aemo/market/node_spec.rb b/spec/lib/aemo/market/node_spec.rb index 52187ed..50c2793 100644 --- a/spec/lib/aemo/market/node_spec.rb +++ b/spec/lib/aemo/market/node_spec.rb @@ -4,30 +4,32 @@ describe AEMO::Market::Node do describe '.IDENTIFIERS' do - it 'should be an array' do + it 'is an array' do expect(AEMO::Market::Node::IDENTIFIERS).to be_instance_of(Array) end end describe 'creating a node' do - it 'should raise an error if invalid region' do - expect { AEMO::Market::Node.new('BOTTOMS') }.to raise_error(ArgumentError) + it 'raises an error if invalid region' do + expect { described_class.new('BOTTOMS') }.to raise_error(ArgumentError) end - it 'should create if node valid' do - expect { AEMO::Market::Node.new('NSW') }.not_to raise_error + + it 'creates if node valid' do + expect { described_class.new('NSW') }.not_to raise_error end end describe 'AEMO::Region instance methods' do - before(:each) do - @nsw = AEMO::Market::Node.new('NSW') + before do + @nsw = described_class.new('NSW') end describe 'AEMO::Region dispatch information' do - it 'should return current dispatch data' do + it 'returns current dispatch data' do expect(@nsw.current_dispatch.count).to eq(AEMO::Market.current_dispatch(@nsw.identifier).count) end - it 'should return current trading data' do + + it 'returns current trading data' do expect(@nsw.current_trading.count).to eq(AEMO::Market.current_trading(@nsw.identifier).count) end end diff --git a/spec/lib/aemo/market_spec.rb b/spec/lib/aemo/market_spec.rb index b52b713..2419d76 100644 --- a/spec/lib/aemo/market_spec.rb +++ b/spec/lib/aemo/market_spec.rb @@ -14,25 +14,26 @@ describe '.current_dispatch' do it 'has an array of data' do - expect(AEMO::Market.current_dispatch('NSW').class).to eq(Array) + expect(described_class.current_dispatch('NSW').class).to eq(Array) end end describe '.current_trading' do it 'has an array of data' do - expect(AEMO::Market.current_trading('NSW').class).to eq(Array) + expect(described_class.current_trading('NSW').class).to eq(Array) end end describe '.historic_trading_by_range' do it 'has an array of data' do - expect(AEMO::Market.historic_trading_by_range('NSW', Date.parse('2015-01-01'), Date.parse('2015-02-28')).class).to eq(Array) + expect(described_class.historic_trading_by_range('NSW', Date.parse('2015-01-01'), + Date.parse('2015-02-28')).class).to eq(Array) end end describe '.historic_trading' do it 'has an array of data' do - expect(AEMO::Market.historic_trading('NSW', 2015, 1).class).to eq(Array) + expect(described_class.historic_trading('NSW', 2015, 1).class).to eq(Array) end end end diff --git a/spec/lib/aemo/meter_spec.rb b/spec/lib/aemo/meter_spec.rb index d0d11c1..6d8466f 100644 --- a/spec/lib/aemo/meter_spec.rb +++ b/spec/lib/aemo/meter_spec.rb @@ -5,14 +5,14 @@ describe AEMO::Meter do describe 'instance methods' do it 'creates a new instance' do - expect(AEMO::Meter.new).to be_a AEMO::Meter + expect(described_class.new).to be_a described_class end it 'can be initialized from MSATS mumbo jumbo' do AEMO::MSATS.authorize('ER', 'ER', 'ER') nmi_detail_query = AEMO::MSATS.nmi_detail('4001234567') meter = nmi_detail_query['MeterRegister']['Meter'].first - expect(AEMO::Meter.from_hash(meter)).to be_a AEMO::Meter + expect(described_class.from_hash(meter)).to be_a described_class end end end diff --git a/spec/lib/aemo/msats_spec.rb b/spec/lib/aemo/msats_spec.rb index 727f422..cec6da3 100644 --- a/spec/lib/aemo/msats_spec.rb +++ b/spec/lib/aemo/msats_spec.rb @@ -5,37 +5,39 @@ describe AEMO::MSATS do describe 'instance methods' do it 'creates a new instance' do - expect(AEMO::MSATS.new.class).to eq(AEMO::MSATS) + expect(described_class.new.class).to eq(described_class) end end describe 'class methods' do describe 'nmi details' do describe 'valid MSATS user' do - before(:each) do - AEMO::MSATS.authorize('ER', 'ER', 'ER') + before do + described_class.authorize('ER', 'ER', 'ER') end - it 'should get data for a valid nmi' do - nmi_detail_query = AEMO::MSATS.nmi_detail('4001234567') + it 'gets data for a valid nmi' do + nmi_detail_query = described_class.nmi_detail('4001234567') expect(nmi_detail_query.class).to eq(Hash) end - it 'should get a 404 for a nonexistent nmi' do + + it 'gets a 404 for a nonexistent nmi' do # nmi_detail_query = AEMO::MSATS.nmi_detail('4001234566') # TODO workout what the different errors are here... end - it 'should raise an error for a bad nmi' do - expect { AEMO::MSATS.nmi_detail('BOBISAFISH') }.to raise_error(ArgumentError) + + it 'raises an error for a bad nmi' do + expect { described_class.nmi_detail('BOBISAFISH') }.to raise_error(ArgumentError) end end describe 'invalid MSATS user' do - before(:each) do - AEMO::MSATS.authorize('NOTER', 'NOTER', 'NOTER') + before do + described_class.authorize('NOTER', 'NOTER', 'NOTER') end - it 'should get data for a valid nmi' do - nmi_detail_query = AEMO::MSATS.nmi_detail('4001234567') + it 'gets data for a valid nmi' do + nmi_detail_query = described_class.nmi_detail('4001234567') expect(nmi_detail_query.class).to eq(HTTParty::Response) end end @@ -43,25 +45,26 @@ describe '#c4' do describe 'valid MSATS account' do - before(:each) do - AEMO::MSATS.authorize('ER', 'ER', 'ER') + before do + described_class.authorize('ER', 'ER', 'ER') end - it 'should return a hash of information' do - expect(AEMO::MSATS.c4('4001234567', Time.now, Time.now, Time.now)).to be_a(Hash) + it 'returns a hash of information' do + expect(described_class.c4('4001234567', Time.now, Time.now, Time.now)).to be_a(Hash) end - it 'should raise an error for a bad nmi' do - expect { AEMO::MSATS.c4('BOBISAFISH') }.to raise_error(ArgumentError) + + it 'raises an error for a bad nmi' do + expect { described_class.c4('BOBISAFISH') }.to raise_error(ArgumentError) end end describe 'invalid MSATS account' do - before(:each) do - AEMO::MSATS.authorize('NOTER', 'NOTER', 'NOTER') + before do + described_class.authorize('NOTER', 'NOTER', 'NOTER') end - it 'should return response' do - expect(AEMO::MSATS.c4( + it 'returns response' do + expect(described_class.c4( '4001234567', Time.now, Time.now, @@ -73,80 +76,89 @@ describe '#msats_limits' do describe 'valid MSATS user' do - before(:each) do - AEMO::MSATS.authorize('ER', 'ER', 'ER') + before do + described_class.authorize('ER', 'ER', 'ER') end - it 'should give details of msats_limits' do - expect(AEMO::MSATS.msats_limits.class).to eq(Hash) + it 'gives details of msats_limits' do + expect(described_class.msats_limits.class).to eq(Hash) end end describe 'invalid MSATS user' do - before(:each) do - AEMO::MSATS.authorize('NOTER', 'NOTER', 'NOTER') + before do + described_class.authorize('NOTER', 'NOTER', 'NOTER') end - it 'should not give details of msats_limits' do - expect(AEMO::MSATS.msats_limits.class).to eq(HTTParty::Response) + it 'does not give details of msats_limits' do + expect(described_class.msats_limits.class).to eq(HTTParty::Response) end end end describe '#nmi_discovery_by_*' do describe 'valid MSATS user' do - before(:each) do - AEMO::MSATS.authorize('ER', 'ER', 'ER') + before do + described_class.authorize('ER', 'ER', 'ER') end - it 'should find by address' do - expect(AEMO::MSATS.nmi_discovery_by_address('NSW', house_number: 6, street_name: 'Macquarie', suburb_or_place_or_locality: 'Sydney', postcode: 2000).class).to eq(Array) + + it 'finds by address' do + expect(described_class.nmi_discovery_by_address('NSW', house_number: 6, street_name: 'Macquarie', + suburb_or_place_or_locality: 'Sydney', postcode: 2000).class).to eq(Array) end - it 'should find by meter_serial_number' do - expect(AEMO::MSATS.nmi_discovery_by_meter_serial_number('NSW', 666).class).to eq(Array) + + it 'finds by meter_serial_number' do + expect(described_class.nmi_discovery_by_meter_serial_number('NSW', 666).class).to eq(Array) end - it 'should find by meter_serial_number' do - expect { AEMO::MSATS.nmi_discovery_by_delivery_point_identifier('NSW', 666) }.to raise_error(ArgumentError) + + it 'finds by meter_serial_number' do + expect { described_class.nmi_discovery_by_delivery_point_identifier('NSW', 666) }.to raise_error(ArgumentError) end - it 'should find by meter_serial_number' do - expect(AEMO::MSATS.nmi_discovery_by_delivery_point_identifier('NSW', 10_000_001).class).to eq(Array) + + it 'finds by meter_serial_number' do + expect(described_class.nmi_discovery_by_delivery_point_identifier('NSW', 10_000_001).class).to eq(Array) end end describe 'invalid MSATS user' do - before(:each) do - AEMO::MSATS.authorize('NOTER', 'NOTER', 'NOTER') + before do + described_class.authorize('NOTER', 'NOTER', 'NOTER') end - it 'should find by address' do - expect(AEMO::MSATS.nmi_discovery_by_address('NSW', house_number: 6, street_name: 'Macquarie', suburb_or_place_or_locality: 'Sydney', postcode: 2000).class).to eq(HTTParty::Response) + it 'finds by address' do + expect(described_class.nmi_discovery_by_address('NSW', house_number: 6, street_name: 'Macquarie', + suburb_or_place_or_locality: 'Sydney', postcode: 2000).class).to eq(HTTParty::Response) end - it 'should find by meter_serial_number' do - expect(AEMO::MSATS.nmi_discovery_by_meter_serial_number('NSW', 666).class).to eq(HTTParty::Response) + + it 'finds by meter_serial_number' do + expect(described_class.nmi_discovery_by_meter_serial_number('NSW', 666).class).to eq(HTTParty::Response) end - it 'should find by meter_serial_number' do - expect(AEMO::MSATS.nmi_discovery_by_delivery_point_identifier('NSW', 10_000_001).class).to eq(HTTParty::Response) + + it 'finds by meter_serial_number' do + expect(described_class.nmi_discovery_by_delivery_point_identifier('NSW', + 10_000_001).class).to eq(HTTParty::Response) end end end describe '#system_status' do describe 'valid MSATS user' do - before(:each) do - AEMO::MSATS.authorize('ER', 'ER', 'ER') + before do + described_class.authorize('ER', 'ER', 'ER') end - it 'should provide a status' do - AEMO::MSATS.system_status + it 'provides a status' do + described_class.system_status end end describe 'invalid MSATS user' do - before(:each) do - AEMO::MSATS.authorize('NOTER', 'NOTER', 'NOTER') + before do + described_class.authorize('NOTER', 'NOTER', 'NOTER') end - it 'should provide a status' do - expect(AEMO::MSATS.system_status.class).to eq(HTTParty::Response) + it 'provides a status' do + expect(described_class.system_status.class).to eq(HTTParty::Response) end end end diff --git a/spec/lib/aemo/nem12_spec.rb b/spec/lib/aemo/nem12_spec.rb index 248f55b..40efeae 100644 --- a/spec/lib/aemo/nem12_spec.rb +++ b/spec/lib/aemo/nem12_spec.rb @@ -6,97 +6,208 @@ describe AEMO::NEM12 do let(:json) { JSON.parse(fixture('nmi_checksum.json').read) } - describe '::RECORD_INDICATORS' do - it 'should be a hash' do - expect(AEMO::NEM12::RECORD_INDICATORS.class).to eq(Hash) + describe '#parse_nem12' do + it 'rejects an empty NEM12 string' do + expect(described_class.parse_nem12('')).to eq([]) end end - describe '#nmi_identifier' do - it 'returns the NMI identifier or nil' do - Dir.entries(File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'NEM12')) - .reject { |f| %w[. .. .DS_Store].include?(f) } - .each do |file| - AEMO::NEM12.parse_nem12_file(fixture(File.join('NEM12', file))).each do |nem12| - expect(nem12.nmi_identifier).to be_a String - end + describe '.parse_nem12_file' do + let(:files) do + Dir.entries(File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'NEM12')).reject do |f| + %w[. .. .DS_Store].include?(f) + end + end + + it 'parses a file' do + files.each do |file| + expect(described_class.parse_nem12_file(fixture(File.join('NEM12', file))).length).not_to eq(0) end end end - describe '#parse_nem12' do - it 'should reject an empty NEM12 string' do - expect(AEMO::NEM12.parse_nem12('')).to eq([]) + describe '.parse_nem12_100' do + it 'raises datetime error' do + expect { described_class.parse_nem12_100('100,NEM12,666,CNRGYMDP,NEMMCO') }.to raise_error(ArgumentError) + end + + it 'raises datetime error' do + expect do + described_class.parse_nem12_100('100,NEM12,666,CNRGYMDP,NEMMCO', strict: true) + end.to raise_error(ArgumentError) + end + + it 'does not raise an error' do + expect { described_class.parse_nem12_100('100,NEM12,201603010000,CNRGYMDP,NEMMCO', strict: true) }.not_to raise_error end end - describe '.parse_nem12_file' do - it 'should parse a file' do - Dir.entries(File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'NEM12')).reject { |f| %w[. .. .DS_Store].include?(f) }.each do |file| - expect(AEMO::NEM12.parse_nem12_file(fixture(File.join('NEM12', file))).length).not_to eq(0) + describe '.to_nem12_csv' do + let(:now) { Time.parse('2023-04-05T06:07:08+10:00') } + + context 'with empty nem12s' do + let(:nem12s) { [] } + let(:expected) { ['100,NEM12,202304050607,ENOSI,ENOSI', '900', ''].join("\r\n") } + + before { Timecop.freeze(now) } + after { Timecop.return } + + it 'returns expected' do + expect(described_class.to_nem12_csv(nem12s:)).to eq(expected) end end - end - describe '#parse_nem12_100' do - it 'should raise datetime error' do - expect { AEMO::NEM12.parse_nem12_100('100,NEM12,666,CNRGYMDP,NEMMCO') }.to raise_error(ArgumentError) + context 'with non-empty nem12s not containing flags' do + let(:nem12_filepath) do + File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'NEM12', 'NEM12#000000000000001#CNRGYMDP#NEMMCO.csv') + end + let(:nem12s) { described_class.parse_nem12_file(nem12_filepath) } + let(:expected) do + [ + '100,NEM12,202304050607,ENOSI,ENOSI', + '200,NEM1201002,E1E2,E1,E1,N1,01002,KWH,30,', + '300,20050315,300.0,266.1,191.55,247.8,288.6,280.8,282.45,206.1,204.75,289.5,390.6,360.15,407.7,432.6,435.0,491.85,600.9,541.95,474.6,565.35,548.55,491.85,593.25,602.4,571.35,450.15,509.4,559.95,522.0,520.95,541.2,538.05,484.8,330.9,329.25,331.65,330.75,333.75,335.25,294.15,185.25,184.8,186.45,256.8,329.7,320.1,316.5,321.15,A,,,20050316014209,', + '200,NEM1201002,E1E2,E2,E2,N2,01002,KWH,30,', + '300,20050315,113.1,87.6,33.75,60.6,81.9,79.5,81.15,39.75,25.65,58.8,174.3,197.1,390.6,392.55,394.2,418.5,484.95,407.7,407.7,466.5,455.7,386.85,486.9,489.45,465.15,360.0,387.45,458.7,381.9,424.8,446.25,444.45,383.55,172.65,154.05,164.4,164.25,174.45,170.1,110.1,44.55,44.25,39.9,72.3,109.05,102.45,103.95,103.05,A,,,20050316014209,', + '200,NEM1201002,E1E2,E1,E1,N1,01002,KWH,30,', + '300,20050316,321.9,326.4,302.7,298.65,304.5,295.35,309.75,312.3,312.0,338.1,376.95,411.9,546.45,548.25,497.85,528.45,580.8,525.0,435.6,569.4,587.4,577.05,487.35,492.15,588.45,455.55,553.65,515.4,539.7,561.6,540.3,555.6,493.8,349.5,323.7,321.3,322.2,317.55,318.45,324.45,324.6,324.6,321.9,318.75,318.6,317.85,317.1,321.3,A,,,20050317032944,', + '200,NEM1201002,E1E2,E2,E2,N2,01002,KWH,30,', + '300,20050316,104.85,103.5,82.95,64.8,72.3,71.4,72.9,80.1,94.5,121.05,185.1,282.9,427.8,427.95,322.8,381.45,464.4,413.4,332.25,442.95,472.8,466.35,401.55,400.2,470.7,352.8,426.3,381.3,435.45,453.75,433.2,453.0,388.35,226.05,148.05,149.55,142.65,115.65,105.75,107.1,109.35,111.45,107.1,104.25,103.05,101.85,103.2,107.55,A,,,20050317032944,', + '200,NEM1201002,E1E2,E1,E1,N1,01002,KWH,30,', + '300,20050317,322.35,318.0,302.4,294.45,298.65,296.4,315.15,315.6,326.55,358.2,389.7,397.2,513.15,511.2,520.65,510.3,543.45,549.9,419.85,529.2,527.85,506.1,535.05,538.05,369.6,380.85,555.15,558.6,477.9,334.5,337.2,335.25,339.15,335.55,316.2,312.75,312.6,320.25,320.25,315.75,317.25,315.6,314.7,315.3,315.0,315.75,314.4,315.9,A,,,20050318014032,', + '200,NEM1201002,E1E2,E2,E2,N2,01002,KWH,30,', + '300,20050317,104.1,100.8,85.8,70.5,70.95,69.45,53.25,55.65,66.6,106.8,182.1,266.55,408.9,402.15,410.7,414.45,436.2,427.8,325.35,442.2,439.2,432.15,435.3,418.8,283.5,294.0,450.45,455.4,368.4,156.0,160.5,137.55,138.0,134.4,112.35,105.6,103.35,111.75,111.15,107.55,106.5,103.95,102.3,105.75,103.8,97.5,99.75,102.0,A,,,20050318014032,', + '200,NEM1201002,E1E2,E1,E1,N1,01002,KWH,30,', + '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,', + '200,NEM1201002,E1E2,E2,E2,N2,01002,KWH,30,', + '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', + '' + ].join("\r\n") + end + + before { Timecop.freeze(now) } + after { Timecop.return } + + it 'returns expected' do + expect(described_class.to_nem12_csv(nem12s:)).to eq(expected) + end end - it 'should raise datetime error' do - expect { AEMO::NEM12.parse_nem12_100('100,NEM12,666,CNRGYMDP,NEMMCO', strict: true) }.to raise_error(ArgumentError) + + context 'with non-empty nem12s containing flags' do + let(:nem12_filepath) do + File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'NEM12', 'NEM12#000000000000004#CNRGYMDP#NEMMCO.csv') + end + let(:nem12s) { described_class.parse_nem12_file(nem12_filepath) } + let(:expected) do + [ + '100,NEM12,202304050607,ENOSI,ENOSI', + '200,NEM1204062,E1,E1,E1,N1,04062,KWH,30,20050503', + '300,20040527,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.735,0.625,0.618,0.63,0.893,1.075,1.263,1.505,1.645,1.073,0.938,1.15,0.75,1.35,1.093,0.973,1.018,0.735,0.718,0.735,0.64,0.638,0.65,0.645,0.73,0.63,0.673,0.688,0.663,0.625,0.628,0.628,0.633,0.645,0.625,0.62,0.623,0.78,V,,,20040609153903,', + '400,1,10,F52,71,', + '400,11,48,E52,,', + '200,NEM1204062,E1,E1,E1,N1,04062,KWH,30,20050503', + '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,', + '400,1,48,E52,,', + '200,NEM1204062,E1,E1,E1,N1,04062,KWH,30,20050503', + '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,', + '400,1,48,E52,,', + '900', + '' + ].join("\r\n") + end + + before { Timecop.freeze(now) } + after { Timecop.return } + + it 'returns expected' do + expect(described_class.to_nem12_csv(nem12s:)).to eq(expected) + end end - it 'should not raise an error' do - expect { AEMO::NEM12.parse_nem12_100('100,NEM12,201603010000,CNRGYMDP,NEMMCO', strict: true) }.not_to raise_error + end + + describe '::RECORD_INDICATORS' do + it 'is a hash' do + expect(AEMO::NEM12::RECORD_INDICATORS.class).to eq(Hash) + end + end + + describe '#nmi_identifier' do + it 'returns the NMI identifier or nil' do + Dir.entries(File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'NEM12')) + .reject { |f| %w[. .. .DS_Store].include?(f) } + .each do |file| + described_class.parse_nem12_file(fixture(File.join('NEM12', file))).each do |nem12| + expect(nem12.nmi_identifier).to be_a String + end + end end end describe '#parse_nem12_200' do context 'non-strict mode' do - it 'should not raise validation warning with bad NMI configuration' do - expect(AEMO::NEM12.parse_nem12_file(fixture(File.join('NEM12-Errors', 'NEM12#DerpyNMIConfig#CNRGYMDP#NEMMCO.csv')), false)) + it 'does not raise validation warning with bad NMI configuration' do + expect(described_class.parse_nem12_file( + fixture(File.join('NEM12-Errors', 'NEM12#DerpyNMIConfig#CNRGYMDP#NEMMCO.csv')), strict: false + )) .to be_truthy end end context 'strict mode (default)' do - it 'should raise validation warning with bad NMI configuration' do - expect { AEMO::NEM12.parse_nem12_file(fixture(File.join('NEM12-Errors', 'NEM12#DerpyNMIConfig#CNRGYMDP#NEMMCO.csv'))) } + it 'raises validation warning with bad NMI configuration' do + expect do + described_class.parse_nem12_file(fixture(File.join('NEM12-Errors', 'NEM12#DerpyNMIConfig#CNRGYMDP#NEMMCO.csv'))) + end .to raise_error(ArgumentError, 'NMIConfiguration is not valid') end end end describe '#parse_nem12_300' do - it 'should raise invalid record length error' do + it 'raises invalid record length error' do bad_file = fixture(File.join('NEM12-Errors', 'NEM12#InvalidIntervalDataLength#CNRGYMDP#NEMMCO.csv')) - expect { AEMO::NEM12.parse_nem12_file(bad_file) }.to raise_error(TypeError, 'Invalid record length') + expect { described_class.parse_nem12_file(bad_file) }.to raise_error(TypeError, 'Invalid record length') end - it 'should raise argument error on 300 empty cells' do + it 'raises argument error on 300 empty cells' do nem12_empty_cells_300_record = fixture(File.join('NEM12-Errors', 'NEM12#EmptyCells300Record#CNRGYMDP#NEMMCO.csv')) - expect { AEMO::NEM12.parse_nem12_file(nem12_empty_cells_300_record) }.to raise_error(ArgumentError) + expect { described_class.parse_nem12_file(nem12_empty_cells_300_record) }.to raise_error(ArgumentError) end end describe '#parse_nem12_400' do - it 'should raise argument error on 400 empty cells' do + it 'raises argument error on 400 empty cells' do nem12_empty_cells_400_record = fixture(File.join('NEM12-Errors', 'NEM12#EmptyCells400Record#CNRGYMDP#NEMMCO.csv')) - expect { AEMO::NEM12.parse_nem12_file(nem12_empty_cells_400_record) }.to raise_error(ArgumentError) + expect { described_class.parse_nem12_file(nem12_empty_cells_400_record) }.to raise_error(ArgumentError) end end - describe '#parse_nem12_500' do - end - - describe '#parse_nem12_900' do - end - describe '#flag_to_s' do it 'converts the flags to a string' do flag = { quality_flag: 'S', method_flag: 11, reason_code: 53 } - nem12 = AEMO::NEM12.new('NEEE000010') + nem12 = described_class.new('NEEE000010') expect(nem12.flag_to_s(flag)) .to eq 'Substituted Data - Check - Bees/Wasp In Meter Box' end end + + describe '#to_nem12_csv' do + let(:nem12_filepath) do + File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'NEM12', 'NEM12#000000000000004#CNRGYMDP#NEMMCO.csv') + end + let(:nem12) { described_class.parse_nem12_file(nem12_filepath).first } + let(:expected) do + "100,NEM12,200505121137,CNRGYMDP,NEMMCO\r +200,NEM1204062,E1,E1,E1,N1,04062,KWH,30,20050503\r +300,20040527,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.735,0.625,0.618,0.63,0.893,1.075,1.263,1.505,1.645,1.073,0.938,1.15,0.75,1.35,1.093,0.973,1.018,0.735,0.718,0.735,0.64,0.638,0.65,0.645,0.73,0.63,0.673,0.688,0.663,0.625,0.628,0.628,0.633,0.645,0.625,0.62,0.623,0.78,V,,,20040609153903,\r +400,1,10,F52,71,\r +400,11,48,E52,,\r +900\r +" + end + + it 'returns a correct NEM12 file' do + expect(nem12.to_nem12_csv).to eq(expected) + end + end end diff --git a/spec/lib/aemo/nmi/allocation_spec.rb b/spec/lib/aemo/nmi/allocation_spec.rb index f6c92cc..4930684 100644 --- a/spec/lib/aemo/nmi/allocation_spec.rb +++ b/spec/lib/aemo/nmi/allocation_spec.rb @@ -7,21 +7,21 @@ # CLASS METHODS # --- describe '.all' do - it 'should return an array of all the Allocations' do - expect(AEMO::NMI::Allocation.all).to be_a Array - expect(AEMO::NMI::Allocation.all.first).to be_a AEMO::NMI::Allocation + it 'returns an array of all the Allocations' do + expect(described_class.all).to be_a Array + expect(described_class.all.first).to be_a described_class end end describe '.each' do - it 'should return an enumerable of all the Allocations' do - expect(AEMO::NMI::Allocation.each).to be_a Enumerable + it 'returns an enumerable of all the Allocations' do + expect(described_class.each).to be_a Enumerable end end describe '.first' do - it 'should return the first Allocation' do - expect(AEMO::NMI::Allocation.first).to be_a AEMO::NMI::Allocation + it 'returns the first Allocation' do + expect(described_class.first).to be_a described_class end end @@ -30,20 +30,25 @@ # --- describe '#initialize' do context 'valid' do - it 'should return an Allocation' do - expect(AEMO::NMI::Allocation.new('My great LNSP', :electricity)).to be_a AEMO::NMI::Allocation - expect(AEMO::NMI::Allocation.new('My great LNSP', 'electricity')).to be_a AEMO::NMI::Allocation - expect(AEMO::NMI::Allocation.new('My great gas LNSP', :gas)).to be_a AEMO::NMI::Allocation - expect(AEMO::NMI::Allocation.new('My great gas LNSP', 'gas')).to be_a AEMO::NMI::Allocation + it 'returns an Allocation' do + expect(described_class.new('My great LNSP', :electricity)).to be_a described_class + expect(described_class.new('My great LNSP', 'electricity')).to be_a described_class + expect(described_class.new('My great gas LNSP', :gas)).to be_a described_class + expect(described_class.new('My great gas LNSP', 'gas')).to be_a described_class end end + context 'invalid' do - it 'should raise an InvalidNMIAllocationType error' do - expect { AEMO::NMI::Allocation.new('My terrible LNSP', :water) }.to raise_error AEMO::InvalidNMIAllocationType - expect { AEMO::NMI::Allocation.new('My terrible LNSP', :broccoli) }.to raise_error AEMO::InvalidNMIAllocationType - expect { AEMO::NMI::Allocation.new('My terrible LNSP', 'Ch4') }.to raise_error AEMO::InvalidNMIAllocationType - expect { AEMO::NMI::Allocation.new('My terrible LNSP', 'Natural gas') }.to raise_error AEMO::InvalidNMIAllocationType - expect { AEMO::NMI::Allocation.new('My terrible LNSP', :coal) }.to raise_error AEMO::InvalidNMIAllocationType + it 'raises an InvalidNMIAllocationType error' do + expect { described_class.new('My terrible LNSP', :water) }.to raise_error AEMO::InvalidNMIAllocationType + expect do + described_class.new('My terrible LNSP', :broccoli) + end.to raise_error AEMO::InvalidNMIAllocationType + expect { described_class.new('My terrible LNSP', 'Ch4') }.to raise_error AEMO::InvalidNMIAllocationType + expect do + described_class.new('My terrible LNSP', 'Natural gas') + end.to raise_error AEMO::InvalidNMIAllocationType + expect { described_class.new('My terrible LNSP', :coal) }.to raise_error AEMO::InvalidNMIAllocationType end end end diff --git a/spec/lib/aemo/nmi_spec.rb b/spec/lib/aemo/nmi_spec.rb index cdbb88c..951cd3c 100644 --- a/spec/lib/aemo/nmi_spec.rb +++ b/spec/lib/aemo/nmi_spec.rb @@ -10,12 +10,13 @@ # CLASS CONSTANTS # --- describe '::TNI_CODES' do - it 'should be a hash' do + it 'is a hash' do expect(AEMO::NMI::TNI_CODES.class).to eq(Hash) end end + describe '::DLF_CODES' do - it 'should be a hash' do + it 'is a hash' do expect(AEMO::NMI::DLF_CODES.class).to eq(Hash) end end @@ -25,52 +26,57 @@ # --- describe '.valid_nmi?(nmi)' do context 'valid' do - it 'should validate nmi' do + it 'validates nmi' do json.each do |nmi| - expect(AEMO::NMI.valid_nmi?(nmi['nmi'])).to eq(true) + expect(described_class.valid_nmi?(nmi['nmi'])).to be(true) end end end + context 'invalid' do - it 'should invalidate' do - expect(AEMO::NMI.valid_nmi?('OOOOOOOOOO')).to eq(false) + it 'invalidates' do + expect(described_class.valid_nmi?('OOOOOOOOOO')).to be(false) end - it 'should invalidate' do - expect(AEMO::NMI.valid_nmi?('NM100')).to eq(false) + + it 'invalidates' do + expect(described_class.valid_nmi?('NM100')).to be(false) end - it 'should invalidate' do - expect { AEMO::NMI.valid_nmi? }.to raise_error(ArgumentError) + + it 'invalidates' do + expect { described_class.valid_nmi? }.to raise_error(ArgumentError) end end end describe '.valid_checksum?(nmi, checksum)' do - it 'should validate valid nmi and checksums' do + it 'validates valid nmi and checksums' do json.each do |nmi| - expect(AEMO::NMI.valid_checksum?(nmi['nmi'], nmi['checksum'])).to eq(true) + expect(described_class.valid_checksum?(nmi['nmi'], nmi['checksum'])).to be(true) end end end describe '.network(nmi)' do - it 'should return a network for an allocated NMI' do - network = AEMO::NMI.network('NCCCC00000') + it 'returns a network for an allocated NMI' do + network = described_class.network('NCCCC00000') expect(network.title).to eq('Ausgrid') end - it 'should return NIL for an unallocated NMI' do - network = AEMO::NMI.network('ZZZZZZZZZZZZ') - expect(network).to eq(nil) + + it 'returns NIL for an unallocated NMI' do + network = described_class.network('ZZZZZZZZZZZZ') + expect(network).to be_nil end end describe '.allocation(nmi)' do - it 'should return an Allocation for a NMI' do - allocation = AEMO::NMI.allocation('NCCCC00000') + it 'returns an Allocation for a NMI' do + allocation = described_class.allocation('NCCCC00000') expect(allocation.title).to eq('Ausgrid') end - it 'should return NIL for an unallocated NMI' do - allocation = AEMO::NMI.allocation('ZZZZZZZZZZZZ') - expect(allocation).to eq(nil) + + it 'returns NIL for an unallocated NMI' do + allocation = described_class.allocation('ZZZZZZZZZZZZ') + expect(allocation).to be_nil end end @@ -79,145 +85,161 @@ # --- describe '#initialize' do context 'valid' do - it 'should return a valid NMI' do - expect(AEMO::NMI.new('NM10000000')).to be_a(AEMO::NMI) + it 'returns a valid NMI' do + expect(described_class.new('NM10000000')).to be_a(described_class) end - it 'should return a valid NMI with MSATS' do - expect(AEMO::NMI.new('NM10000000', msats_detail: {})).to be_a(AEMO::NMI) + + it 'returns a valid NMI with MSATS' do + expect(described_class.new('NM10000000', msats_detail: {})).to be_a(described_class) end end + context 'invalid' do - it 'should raise an ArgumentError error' do - expect { AEMO::NMI.new('OOOOOOOOOO') }.to raise_error(ArgumentError) + it 'raises an ArgumentError error' do + expect { described_class.new('OOOOOOOOOO') }.to raise_error(ArgumentError) end - it 'should raise an ArgumentError error' do - expect { AEMO::NMI.new('NM100') }.to raise_error(ArgumentError) + + it 'raises an ArgumentError error' do + expect { described_class.new('NM100') }.to raise_error(ArgumentError) end - it 'should raise an ArgumentError error' do - expect { AEMO::NMI.new }.to raise_error(ArgumentError) + + it 'raises an ArgumentError error' do + expect { described_class.new }.to raise_error(ArgumentError) end end end describe '#valid_nmi?' do - it 'should validate nmi' do + it 'validates nmi' do json.each do |nmi| - a_nmi = AEMO::NMI.new(nmi['nmi']) - expect(a_nmi.valid_nmi?).to eq(true) + a_nmi = described_class.new(nmi['nmi']) + expect(a_nmi.valid_nmi?).to be(true) end end end describe '#checksum' do - it 'should return NMI\'s checksum' do + it 'returns NMI checksum' do json.each do |nmi| - a_nmi = AEMO::NMI.new(nmi['nmi']) + a_nmi = described_class.new(nmi['nmi']) expect(a_nmi.checksum).to eq(nmi['checksum']) end end end describe '#valid_checksum?(checksum)' do - it 'should validate valid checksums' do + it 'validates valid checksums' do json.each do |nmi| - a_nmi = AEMO::NMI.new(nmi['nmi']) - expect(a_nmi.valid_checksum?(nmi['checksum'])).to eq(true) + a_nmi = described_class.new(nmi['nmi']) + expect(a_nmi.valid_checksum?(nmi['checksum'])).to be(true) end end - it 'should not validate invalid checksums' do + + it 'does not validate invalid checksums' do json.each do |nmi| - a_nmi = AEMO::NMI.new(nmi['nmi']) - expect(a_nmi.valid_checksum?((1 + nmi['checksum']) % 10)).to eq(false) + a_nmi = described_class.new(nmi['nmi']) + expect(a_nmi.valid_checksum?((1 + nmi['checksum']) % 10)).to be(false) end end end describe '#network' do # Positive test cases - it 'should return a network for a valid NMI' do + it 'returns a network for a valid NMI' do json.each do |nmi| - a_nmi = AEMO::NMI.new(nmi['nmi']) + a_nmi = described_class.new(nmi['nmi']) next if a_nmi.network.nil? + expect(a_nmi.network).to be_a AEMO::NMI::Allocation expect(a_nmi.allocation).to be_a AEMO::NMI::Allocation end end + # Negative test cases - it 'should not return a network for a NMI not allocated to a network' do + it 'does not return a network for a NMI not allocated to a network' do json.each do |nmi| - a_nmi = AEMO::NMI.new(nmi['nmi']) + a_nmi = described_class.new(nmi['nmi']) expect(a_nmi.network.class).to eq(NilClass) if a_nmi.network.nil? end end end describe '#friendly_address' do - it 'should return the empty string if the address is not a hash' do - nmi = AEMO::NMI.new('4001234567') + it 'returns the empty string if the address is not a hash' do + nmi = described_class.new('4001234567') nmi.address = 'An address' expect(nmi.friendly_address).to eq('') end - it 'should return a friendly address if the address is a hash' do - nmi = AEMO::NMI.new('4001234567') + + it 'returns a friendly address if the address is a hash' do + nmi = described_class.new('4001234567') nmi.address = { number: '1', street: 'Bob', street_type: 'Street' } expect(nmi.friendly_address).to eq('1, Bob, Street') end - it 'should return a friendly address if the address is a nested hash' do - nmi = AEMO::NMI.new('4001234567') + + it 'returns a friendly address if the address is a nested hash' do + nmi = described_class.new('4001234567') nmi.address = { house: { number: '1', suffix: 'B' }, street: 'Bob', street_type: 'Street' } expect(nmi.friendly_address).to eq('1 B, Bob, Street') end end describe '#current_daily_load' do - it 'should return zero for no data' do - @nmi = AEMO::NMI.new('4001234567') + it 'returns zero for no data' do + @nmi = described_class.new('4001234567') expect(@nmi.current_daily_load).to eq(0) end end describe '#current_annual_load' do - it 'should return zero for no data' do - @nmi = AEMO::NMI.new('4001234567') + it 'returns zero for no data' do + @nmi = described_class.new('4001234567') expect(@nmi.current_annual_load).to eq(0) end end describe '#meters_by_status' do - before(:each) do - @nmi = AEMO::NMI.new('4001234567') - @nmi.meters = [OpenStruct.new(status: 'C'), OpenStruct.new(status: 'R')] + before do + @nmi = described_class.new('4001234567') + @nmi.meters = [Struct::DataStream.new(status: 'C'), Struct::DataStream.new(status: 'R')] end - it 'should return current meters' do + + it 'returns current meters' do expect(@nmi.meters_by_status.count).to eq(1) end - it 'should return retired meters' do + + it 'returns retired meters' do expect(@nmi.meters_by_status('R').count).to eq(1) end - it 'should return zero meters for weird status' do + + it 'returns zero meters for weird status' do expect(@nmi.meters_by_status('TMP').count).to eq(0) end end describe 'distribution loss factors' do - before(:each) do - @nmi = AEMO::NMI.new('4001234567') + before do + @nmi = described_class.new('4001234567') @nmi.dlf = 'BL0A' @nmi.tni = 'NGN2' end + it 'has a valid DLF Code' do expect(@nmi.dlf).to eq('BL0A') end + it 'has a DLF value' do Timecop.freeze('2016-06-01T00:00:00+1000') do expect(@nmi.dlfc_value.class).to eq(Float) end end + it 'has DLF values' do Timecop.freeze('2016-06-01T00:00:00+1000') do expect(@nmi.dlfc_values.class).to eq(Array) end end + it 'has historical values for DLF values' do valid_dlfc_values = [ { datetime: '2003-06-01T00:00:00+1000', value: 1.0713 }, @@ -247,8 +269,8 @@ end describe 'transmission node identifiers and loss factors' do - before(:each) do - @nmi = AEMO::NMI.new('4001234567') + before do + @nmi = described_class.new('4001234567') @nmi.dlf = 'BL0A' @nmi.tni = 'NGN2' end @@ -256,16 +278,19 @@ it 'has a valid TNI Code' do expect(@nmi.tni).to eq('NGN2') end + it 'has a TNI value' do Timecop.freeze('2016-06-01T00:00:00+1000') do expect(@nmi.tni_value(Time.now).class).to eq(Float) end end + it 'has TNI values' do Timecop.freeze('2016-06-01T00:00:00+1000') do expect(@nmi.tni_values.class).to eq(Array) end end + it 'has historical values for TNI values' do valid_tni_values = [ { datetime: '2014-06-01T00:00:00+1000', value: 1.0383 }, @@ -284,15 +309,16 @@ end describe 'MSATS functions' do - it 'should get data' do + it 'gets data' do AEMO::MSATS.authorize('ER', 'ER', 'ER') - nmi = AEMO::NMI.new('4001234567') + nmi = described_class.new('4001234567') nmi.update_from_msats! - expect(nmi.msats_detail).to_not eq(nil) + expect(nmi.msats_detail).not_to be_nil end - it 'should return the hash of raw data' do + + it 'returns the hash of raw data' do AEMO::MSATS.authorize('ER', 'ER', 'ER') - nmi = AEMO::NMI.new('4001234567') + nmi = described_class.new('4001234567') expect(nmi.raw_msats_nmi_detail.class).to eq(Hash) end end diff --git a/spec/lib/aemo/region_spec.rb b/spec/lib/aemo/region_spec.rb index 3035d5b..febfb94 100644 --- a/spec/lib/aemo/region_spec.rb +++ b/spec/lib/aemo/region_spec.rb @@ -4,55 +4,60 @@ describe AEMO::Region do describe '.REGIONS' do - it 'should be a hash' do + it 'is a hash' do expect(AEMO::Region::REGIONS).to be_instance_of(Hash) end end describe 'creating a region' do - it 'should raise an error if invalid region' do - expect { AEMO::Region.new('BOTTOMS') }.to raise_error(ArgumentError) + it 'raises an error if invalid region' do + expect { described_class.new('BOTTOMS') }.to raise_error(ArgumentError) end - it 'should create if region valid' do - expect { AEMO::Region.new('NSW') }.not_to raise_error + + it 'creates if region valid' do + expect { described_class.new('NSW') }.not_to raise_error end end describe 'AEMO::Region instance methods' do - before(:each) do - @nsw = AEMO::Region.new('NSW') + before do + @nsw = described_class.new('NSW') end - it 'should have an abbreviation' do + + it 'has an abbreviation' do expect(@nsw.abbr).to eq('NSW') end - it 'should have a fullname' do + + it 'has a fullname' do expect(@nsw.fullname).to eq('New South Wales') end - it 'should have to_s method' do + + it 'has to_s method' do expect(@nsw.to_s).to eq('NSW') end - it 'should have a valid region' do - expect(@nsw.send(:valid_region?, 'NSW')).to eq(true) + it 'has a valid region' do + expect(@nsw.send(:valid_region?, 'NSW')).to be(true) end - it 'should have return invalid for invalid region' do - expect(@nsw.send(:valid_region?, 'BOB')).to eq(false) + it 'has return invalid for invalid region' do + expect(@nsw.send(:valid_region?, 'BOB')).to be(false) end describe 'AEMO::Region dispatch information' do - it 'should return current dispatch data' do + it 'returns current dispatch data' do expect(@nsw.current_dispatch.count).to eq(AEMO::Market.current_dispatch(@nsw.abbr).count) end - it 'should return current trading data' do + + it 'returns current trading data' do expect(@nsw.current_trading.count).to eq(AEMO::Market.current_trading(@nsw.abbr).count) end end end describe 'AEMO::Region class methods' do - it 'should return all regions' do - expect(AEMO::Region.all.count).to eq(AEMO::Region::REGIONS.keys.count) + it 'returns all regions' do + expect(described_class.all.count).to eq(AEMO::Region::REGIONS.keys.count) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 682d6e7..8079cc2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -18,7 +18,7 @@ RSpec.configure do |config| # WebMock - config.before(:each) do + config.before do csv_headers = { 'Content-Type' => 'text/csv' } xml_headers = { 'Content-Type' => 'text/xml' } @@ -45,43 +45,43 @@ .to_return(status: 200, body: File.new('spec/fixtures/Market/DATA201502_NSW1.csv'), headers: csv_headers) # MSATS - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/C4\/ER}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/C4/ER}) .with(headers: { 'Accept' => ['text/xml'], 'Content-Type' => 'text/xml' }) .to_return(status: 200, body: File.new('spec/fixtures/MSATS/c4.xml'), headers: xml_headers) - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/MSATSLimits\/ER}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/MSATSLimits/ER}) .with(headers: { 'Accept' => ['text/xml'], 'Content-Type' => 'text/xml' }) .to_return(status: 200, body: File.new('spec/fixtures/MSATS/msats_limits.xml'), headers: xml_headers) - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/NMIDetail\/ER}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/NMIDetail/ER}) .with(headers: { 'Accept' => ['text/xml'], 'Content-Type' => 'text/xml' }) .to_return(status: 200, body: File.new('spec/fixtures/MSATS/nmi_details.xml'), headers: xml_headers) - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/NMIDiscovery\/ER}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/NMIDiscovery/ER}) .with(headers: { 'Accept' => ['text/xml'], 'Content-Type' => 'text/xml' }) .to_return(status: 200, body: File.new('spec/fixtures/MSATS/nmi_discovery_by_address.xml'), headers: xml_headers) - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/ParticipantSystemStatus\/ER}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/ParticipantSystemStatus/ER}) .with(headers: { 'Accept' => 'text/xml', 'Content-Type' => 'text/xml' }) .to_return(status: 200, body: File.new('spec/fixtures/MSATS/participant_system_status.xml'), headers: xml_headers) # MSATS ERRORS # Invalid MSATS User - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/C4\/NOTER}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/C4/NOTER}) .with(headers: { 'Accept' => ['text/xml'], 'Content-Type' => 'text/xml' }) .to_return(status: 404, body: '', headers: xml_headers) - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/MSATSLimits\/NOTER}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/MSATSLimits/NOTER}) .with(headers: { 'Accept' => ['text/xml'], 'Content-Type' => 'text/xml' }) .to_return(status: 404, body: '', headers: xml_headers) - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/NMIDetail\/NOTER}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/NMIDetail/NOTER}) .with(headers: { 'Accept' => ['text/xml'], 'Content-Type' => 'text/xml' }) .to_return(status: 404, body: '', headers: xml_headers) - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/NMIDiscovery\/NOTER}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/NMIDiscovery/NOTER}) .with(headers: { 'Accept' => ['text/xml'], 'Content-Type' => 'text/xml' }) .to_return(status: 404, body: '', headers: xml_headers) - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/ParticipantSystemStatus\/NOTER}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/ParticipantSystemStatus/NOTER}) .with(headers: { 'Accept' => 'text/xml', 'Content-Type' => 'text/xml' }) .to_return(status: 404, body: '', headers: xml_headers) # Data errors - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/C4\/ER\?.+?NMI=4001234566.+?}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/C4/ER\?.+?NMI=4001234566.+?}) .with(headers: { 'Accept' => ['text/xml'], 'Content-Type' => 'text/xml' }) .to_return(status: 404, body: '', headers: xml_headers) - stub_request(:get, %r{msats.prod.nemnet.net.au\/msats\/ws\/NMIDetail\/ER\?.+?nmi=4001234566.+?}) + stub_request(:get, %r{msats.prod.nemnet.net.au/msats/ws/NMIDetail/ER\?.+?nmi=4001234566.+?}) .with(headers: { 'Accept' => ['text/xml'], 'Content-Type' => 'text/xml' }) .to_return(status: 404, body: '', headers: xml_headers) end