From e6fc705e6a47a42bf9d48ada8549b0554ec54f55 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 26 Jun 2013 09:00:08 +0200 Subject: [PATCH 001/113] Initial implementation --- .ruby-gemset | 1 + .ruby-version | 1 + Gemfile | 6 ++++++ Gemfile.lock | 31 ++++++++++++++++++++++++++++ README.md | 36 +++++++++++++++++++++++++++++++++ Rakefile | 34 +++++++++++++++++++++++++++++++ app/models/page.rb | 3 +++ ar-no-rails.rb | 14 +++++++++++++ config/database.yml | 5 +++++ db/migrate/001_creates_pages.rb | 8 ++++++++ smartmeter.rb | 18 +++++++++++++++++ 11 files changed, 157 insertions(+) create mode 100644 .ruby-gemset create mode 100644 .ruby-version create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 README.md create mode 100644 Rakefile create mode 100644 app/models/page.rb create mode 100644 ar-no-rails.rb create mode 100644 config/database.yml create mode 100644 db/migrate/001_creates_pages.rb create mode 100644 smartmeter.rb diff --git a/.ruby-gemset b/.ruby-gemset new file mode 100644 index 0000000..032c1c1 --- /dev/null +++ b/.ruby-gemset @@ -0,0 +1 @@ +smartmeter diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..970977c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-1.9.3-p125 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..8919c11 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "activerecord", "3.2.13" +gem "mysql2" +gem "serialport" +gem "state_pattern" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..35e0b20 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,31 @@ +GEM + remote: https://rubygems.org/ + specs: + activemodel (3.2.13) + activesupport (= 3.2.13) + builder (~> 3.0.0) + activerecord (3.2.13) + activemodel (= 3.2.13) + activesupport (= 3.2.13) + arel (~> 3.0.2) + tzinfo (~> 0.3.29) + activesupport (3.2.13) + i18n (= 0.6.1) + multi_json (~> 1.0) + arel (3.0.2) + builder (3.0.4) + i18n (0.6.1) + multi_json (1.7.7) + mysql2 (0.3.11) + serialport (1.1.0) + state_pattern (2.0.1) + tzinfo (0.3.37) + +PLATFORMS + ruby + +DEPENDENCIES + activerecord (= 3.2.13) + mysql2 + serialport + state_pattern diff --git a/README.md b/README.md new file mode 100644 index 0000000..6eab570 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +ActiveRecord Without Rails +========================== + +Just a simple example of using ActiveRecord migrations without Rails + +tasks you can do: + +* `rake db:create` +* `rake db:migrate` +* `rake db:drop` + +Or, you can run the thing to show that it'll connect + +``` +ruby ar-no-rails +``` + +Output: +> Count of Pages: 0 + +Lastly, you can IRB it to do stuff: + +$ irb + +``` +>> require "./ar-no-rails" +=> true +>> Page.new +=> # +>> Page.create content: "the-content" +=> # +``` + +Copyright +--------- +None. Really. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..04146e4 --- /dev/null +++ b/Rakefile @@ -0,0 +1,34 @@ +require "rubygems" +require "bundler/setup" + +require 'mysql2' +require 'active_record' +require 'yaml' + +namespace :db do + + desc "Migrate the db" + task :migrate do + connection_details = YAML::load(File.open('config/database.yml')) + ActiveRecord::Base.establish_connection(connection_details) + ActiveRecord::Migrator.migrate("db/migrate/") + end + + desc "Create the db" + task :create do + connection_details = YAML::load(File.open('config/database.yml')) + admin_connection = connection_details.merge({'database'=> 'mysql', + 'schema_search_path'=> 'public'}) + ActiveRecord::Base.establish_connection(admin_connection) + ActiveRecord::Base.connection.create_database(connection_details.fetch('database')) + end + + desc "drop the db" + task :drop do + connection_details = YAML::load(File.open('config/database.yml')) + admin_connection = connection_details.merge({'database'=> 'mysql', + 'schema_search_path'=> 'public'}) + ActiveRecord::Base.establish_connection(admin_connection) + ActiveRecord::Base.connection.drop_database(connection_details.fetch('database')) + end +end diff --git a/app/models/page.rb b/app/models/page.rb new file mode 100644 index 0000000..fd711fe --- /dev/null +++ b/app/models/page.rb @@ -0,0 +1,3 @@ +class Page < ActiveRecord::Base + +end diff --git a/ar-no-rails.rb b/ar-no-rails.rb new file mode 100644 index 0000000..3ff54ec --- /dev/null +++ b/ar-no-rails.rb @@ -0,0 +1,14 @@ +require "rubygems" +require "bundler/setup" +require "active_record" + +project_root = File.dirname(File.absolute_path(__FILE__)) +Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} + +connection_details = YAML::load(File.open('config/database.yml')) +ActiveRecord::Base.establish_connection(connection_details) + + +if __FILE__ == $0 + puts "Count of Pages: #{Page.count}" +end diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..e2dbeb2 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,5 @@ +host: 'localhost' +adapter: 'mysql2' +database: 'smartmeter' +username: 'root' +pool: 5 diff --git a/db/migrate/001_creates_pages.rb b/db/migrate/001_creates_pages.rb new file mode 100644 index 0000000..eed18e4 --- /dev/null +++ b/db/migrate/001_creates_pages.rb @@ -0,0 +1,8 @@ +class CreatesPages < ActiveRecord::Migration + def change + create_table :pages do |t| + t.text :content + t.boolean :published, default: false + end + end +end diff --git a/smartmeter.rb b/smartmeter.rb new file mode 100644 index 0000000..e815fb8 --- /dev/null +++ b/smartmeter.rb @@ -0,0 +1,18 @@ +require "rubygems" +require "bundler/setup" +require "active_record" +require "serialport" + +project_root = File.dirname(File.absolute_path(__FILE__)) +Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} + +connection_details = YAML::load(File.open('config/database.yml')) +ActiveRecord::Base.establish_connection(connection_details) + +# Open connection to serial port +ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) + +# read until newline +response = ser.readline("\r") +response.chomp! +print "#{response}\n" From 58eee12fb58f352711285fedc4bde382bce41c18 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 26 Jun 2013 15:05:20 +0200 Subject: [PATCH 002/113] Model for reading created State pattern implemented --- .project | 13 +++ Gemfile | 2 + app/helpers/ConfirmingSyncLossState.rb | 5 + app/helpers/ConfirmingSyncPatternState.rb | 21 ++++ app/helpers/InSyncState.rb | 18 ++++ app/helpers/SearchingForSyncState.rb | 13 +++ app/helpers/Synchronizer.rb | 8 ++ app/models/page.rb | 3 - app/models/reading.rb | 3 + db/migrate/001_creates_pages.rb | 8 -- db/migrate/001_creates_readings.rb | 15 +++ example_blurp.txt | 121 ++++++++++++++++++++++ smartmeter.rb | 24 +++-- 13 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 .project create mode 100644 app/helpers/ConfirmingSyncLossState.rb create mode 100644 app/helpers/ConfirmingSyncPatternState.rb create mode 100644 app/helpers/InSyncState.rb create mode 100644 app/helpers/SearchingForSyncState.rb create mode 100644 app/helpers/Synchronizer.rb delete mode 100644 app/models/page.rb create mode 100644 app/models/reading.rb delete mode 100644 db/migrate/001_creates_pages.rb create mode 100644 db/migrate/001_creates_readings.rb create mode 100644 example_blurp.txt diff --git a/.project b/.project new file mode 100644 index 0000000..0a8a20e --- /dev/null +++ b/.project @@ -0,0 +1,13 @@ + + + smartmeter + + + + + + + com.aptana.projects.webnature + com.aptana.ruby.core.rubynature + + diff --git a/Gemfile b/Gemfile index 8919c11..166797f 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,5 @@ gem "activerecord", "3.2.13" gem "mysql2" gem "serialport" gem "state_pattern" +#gem "daemon-kit" +#gem 'rufus-scheduler', '>= 2.0.3' diff --git a/app/helpers/ConfirmingSyncLossState.rb b/app/helpers/ConfirmingSyncLossState.rb new file mode 100644 index 0000000..628d9b5 --- /dev/null +++ b/app/helpers/ConfirmingSyncLossState.rb @@ -0,0 +1,5 @@ +class ConfirmingSyncLossState < StatePattern::State + def handle_byte_stream(bytes) + p "Please override" + end +end \ No newline at end of file diff --git a/app/helpers/ConfirmingSyncPatternState.rb b/app/helpers/ConfirmingSyncPatternState.rb new file mode 100644 index 0000000..a6157ca --- /dev/null +++ b/app/helpers/ConfirmingSyncPatternState.rb @@ -0,0 +1,21 @@ +class ConfirmingSyncPatternState < StatePattern::State + # Assumes that bytes[0] == Synchronizer::SYNC_PATTERN[0] + def handle_byte_stream(bytes) + idx = 0; + sync_length = Synchronizer::SYNC_PATTERN.length + + # confirm rest of sync pattern + while ((idx < sync_length) && bytes[idx] == Synchronizer::SYNC_PATTERN[idx]) do idx = idx+1 end + + if (idx == sync_length) + #p "Sync pattern confirmed" + transition_to(InSyncState) + else + #p "Back to SearchingForSync state. idx = #{idx}." + transition_to(SearchingForSyncState) + end + + # return the rest + return bytes[idx+1..-1] + end +end \ No newline at end of file diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb new file mode 100644 index 0000000..531a649 --- /dev/null +++ b/app/helpers/InSyncState.rb @@ -0,0 +1,18 @@ +class InSyncState < StatePattern::State + + END_OF_FRAME = "!\n" + + def handle_byte_stream(bytes) + idx = 0 + frame = "" + while (idx < bytes.length && bytes[idx] != END_OF_FRAME[0]) do + frame = frame + bytes[idx] + idx = idx +1 + end + p "------ FRAME -----" + frame_lines = frame.split("\n") + p frame_lines # should call to higher level + p "##################" + return "" + end +end \ No newline at end of file diff --git a/app/helpers/SearchingForSyncState.rb b/app/helpers/SearchingForSyncState.rb new file mode 100644 index 0000000..e07cd8c --- /dev/null +++ b/app/helpers/SearchingForSyncState.rb @@ -0,0 +1,13 @@ +class SearchingForSyncState < StatePattern::State + def handle_byte_stream(bytes) + idx = 0; + # spool unwanted bytes + while (bytes[idx] != Synchronizer::SYNC_PATTERN[0]) do idx = idx+1 end + + #p "Found pattern at idx = #{idx}" + transition_to(ConfirmingSyncPatternState) + + # return + return bytes[idx..-1] + end +end \ No newline at end of file diff --git a/app/helpers/Synchronizer.rb b/app/helpers/Synchronizer.rb new file mode 100644 index 0000000..cf164d5 --- /dev/null +++ b/app/helpers/Synchronizer.rb @@ -0,0 +1,8 @@ +class Synchronizer + include StatePattern + + SYNC_PATTERN = "\n/ISk5\\2ME382-1003\n\n" + + set_initial_state SearchingForSyncState + +end \ No newline at end of file diff --git a/app/models/page.rb b/app/models/page.rb deleted file mode 100644 index fd711fe..0000000 --- a/app/models/page.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Page < ActiveRecord::Base - -end diff --git a/app/models/reading.rb b/app/models/reading.rb new file mode 100644 index 0000000..fbf6dd5 --- /dev/null +++ b/app/models/reading.rb @@ -0,0 +1,3 @@ +class Reading < ActiveRecord::Base + +end diff --git a/db/migrate/001_creates_pages.rb b/db/migrate/001_creates_pages.rb deleted file mode 100644 index eed18e4..0000000 --- a/db/migrate/001_creates_pages.rb +++ /dev/null @@ -1,8 +0,0 @@ -class CreatesPages < ActiveRecord::Migration - def change - create_table :pages do |t| - t.text :content - t.boolean :published, default: false - end - end -end diff --git a/db/migrate/001_creates_readings.rb b/db/migrate/001_creates_readings.rb new file mode 100644 index 0000000..03f2d26 --- /dev/null +++ b/db/migrate/001_creates_readings.rb @@ -0,0 +1,15 @@ +class CreatesReadings < ActiveRecord::Migration + def change + create_table :readings do |t| + t.float :total_kwh_consumed_high + t.float :total_kwh_consumed_low + t.float :total_kwh_produced_high + t.float :total_kwh_produced_low + t.float :current_kw_consumed + t.float :current_kw_produced + t.float :total_m3_gas_consumed + t.boolean :high_tarif + t.timestamps + end + end +end diff --git a/example_blurp.txt b/example_blurp.txt new file mode 100644 index 0000000..c904326 --- /dev/null +++ b/example_blurp.txt @@ -0,0 +1,121 @@ + +/ISk5\2ME382-1003 + +0-0:96.1.1(4B413650303035313238303430383132) +1-0:1.8.1(00553.931*kWh) +1-0:1.8.2(00431.594*kWh) +1-0:2.8.1(00093.034*kWh) +1-0:2.8.2(00147.035*kWh) +0-0:96.14.0(0002) +1-0:1.7.0(0000.00*kW) +1-0:2.7.0(0000.28*kW) +0-0:17.0.0(0999.00*kW) +0-0:96.3.10(1) +0-0:96.13.1() +0-0:96.13.0() +0-1:24.1.0(3) +0-1:96.1.0(3238303131303031323439333134383132) +0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) +(00309.466) +0-1:24.4.0(1) +! +/ISk5\2ME382-1003 + +0-0:96.1.1(4B413650303035313238303430383132) +1-0:1.8.1(00553.931*kWh) +1-0:1.8.2(00431.594*kWh) +1-0:2.8.1(00093.034*kWh) +1-0:2.8.2(00147.036*kWh) +0-0:96.14.0(0002) +1-0:1.7.0(0000.00*kW) +1-0:2.7.0(0000.31*kW) +0-0:17.0.0(0999.00*kW) +0-0:96.3.10(1) +0-0:96.13.1() +0-0:96.13.0() +0-1:24.1.0(3) +0-1:96.1.0(3238303131303031323439333134383132) +0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) +(00309.466) +0-1:24.4.0(1) +! +/ISk5\2ME382-1003 + +0-0:96.1.1(4B413650303035313238303430383132) +1-0:1.8.1(00553.931*kWh) +1-0:1.8.2(00431.594*kWh) +1-0:2.8.1(00093.034*kWh) +1-0:2.8.2(00147.037*kWh) +0-0:96.14.0(0002) +1-0:1.7.0(0000.00*kW) +1-0:2.7.0(0000.32*kW) +0-0:17.0.0(0999.00*kW) +0-0:96.3.10(1) +0-0:96.13.1() +0-0:96.13.0() +0-1:24.1.0(3) +0-1:96.1.0(3238303131303031323439333134383132) +0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) +(00309.466) +0-1:24.4.0(1) +! +/ISk5\2ME382-1003 + +0-0:96.1.1(4B413650303035313238303430383132) +1-0:1.8.1(00553.931*kWh) +1-0:1.8.2(00431.594*kWh) +1-0:2.8.1(00093.034*kWh) +1-0:2.8.2(00147.038*kWh) +0-0:96.14.0(0002) +1-0:1.7.0(0000.00*kW) +1-0:2.7.0(0000.32*kW) +0-0:17.0.0(0999.00*kW) +0-0:96.3.10(1) +0-0:96.13.1() +0-0:96.13.0() +0-1:24.1.0(3) +0-1:96.1.0(3238303131303031323439333134383132) +0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) +(00309.466) +0-1:24.4.0(1) +! +/ISk5\2ME382-1003 + +0-0:96.1.1(4B413650303035313238303430383132) +1-0:1.8.1(00553.931*kWh) +1-0:1.8.2(00431.594*kWh) +1-0:2.8.1(00093.034*kWh) +1-0:2.8.2(00147.039*kWh) +0-0:96.14.0(0002) +1-0:1.7.0(0000.00*kW) +1-0:2.7.0(0000.32*kW) +0-0:17.0.0(0999.00*kW) +0-0:96.3.10(1) +0-0:96.13.1() +0-0:96.13.0() +0-1:24.1.0(3) +0-1:96.1.0(3238303131303031323439333134383132) +0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) +(00309.466) +0-1:24.4.0(1) +! +/ISk5\2ME382-1003 + +0-0:96.1.1(4B413650303035313238303430383132) +1-0:1.8.1(00553.931*kWh) +1-0:1.8.2(00431.594*kWh) +1-0:2.8.1(00093.034*kWh) +1-0:2.8.2(00147.040*kWh) +0-0:96.14.0(0002) +1-0:1.7.0(0000.00*kW) +1-0:2.7.0(0000.30*kW) +0-0:17.0.0(0999.00*kW) +0-0:96.3.10(1) +0-0:96.13.1() +0-0:96.13.0() +0-1:24.1.0(3) +0-1:96.1.0(3238303131303031323439333134383132) +0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) +(00309.466) +0-1:24.4.0(1) +! diff --git a/smartmeter.rb b/smartmeter.rb index e815fb8..c630477 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -2,17 +2,27 @@ require "rubygems" require "bundler/setup" require "active_record" require "serialport" +require "state_pattern" project_root = File.dirname(File.absolute_path(__FILE__)) Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} - +Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} + connection_details = YAML::load(File.open('config/database.yml')) ActiveRecord::Base.establish_connection(connection_details) -# Open connection to serial port -ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) +# # Open connection to serial port +# ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) +# +# # read until newline +# response = ser.readline("\r") +# response.chomp! +# print "#{response}\n" -# read until newline -response = ser.readline("\r") -response.chomp! -print "#{response}\n" +sync = Synchronizer.new +lines = File.read("example_blurp.txt")[rand(500)..-1] +while (lines.length > 0) + lines = sync.handle_byte_stream(lines) +end + +p sync From 099d90c9d80211c8caf4d28bb2c2b0f0b741b1c1 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 26 Jun 2013 16:03:28 -0400 Subject: [PATCH 003/113] Better handling of boundary conditions Trying with serialport.readpartial --- app/helpers/ConfirmingSyncPatternState.rb | 2 +- app/helpers/InSyncState.rb | 31 ++++++++++++++++++----- app/helpers/SearchingForSyncState.rb | 2 +- example_blurp.txt | 2 +- smartmeter.rb | 11 +++++--- 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/app/helpers/ConfirmingSyncPatternState.rb b/app/helpers/ConfirmingSyncPatternState.rb index a6157ca..7a46fe2 100644 --- a/app/helpers/ConfirmingSyncPatternState.rb +++ b/app/helpers/ConfirmingSyncPatternState.rb @@ -5,7 +5,7 @@ class ConfirmingSyncPatternState < StatePattern::State sync_length = Synchronizer::SYNC_PATTERN.length # confirm rest of sync pattern - while ((idx < sync_length) && bytes[idx] == Synchronizer::SYNC_PATTERN[idx]) do idx = idx+1 end + while (idx < bytes.length && idx < sync_length && bytes[idx] == Synchronizer::SYNC_PATTERN[idx]) do idx = idx+1 end if (idx == sync_length) #p "Sync pattern confirmed" diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 531a649..3e9e236 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -1,18 +1,35 @@ class InSyncState < StatePattern::State - END_OF_FRAME = "!\n" + # END_OF_FRAME = "!\n" def handle_byte_stream(bytes) idx = 0 + sync_pattern_length = Synchronizer::SYNC_PATTERN.length + #p "Sync length: #{sync_length}" + frame = "" - while (idx < bytes.length && bytes[idx] != END_OF_FRAME[0]) do + + while (idx+sync_pattern_length < bytes.length && !new_frame_starts(bytes,idx,sync_pattern_length)) do frame = frame + bytes[idx] idx = idx +1 + end - p "------ FRAME -----" - frame_lines = frame.split("\n") - p frame_lines # should call to higher level - p "##################" - return "" + + # did we reach the end of the frame? + if new_frame_starts(bytes,idx,sync_pattern_length) + p "------ FRAME -----" + frame_lines = frame.split("\n") + p frame_lines # should call to higher level + p "##################" + return bytes[idx+sync_pattern_length..-1] + else + return nil + end + end + + private + def new_frame_starts(bytes,idx,sync_pattern_length) + return bytes[idx..idx+sync_pattern_length-1].eql?(Synchronizer::SYNC_PATTERN) + end end \ No newline at end of file diff --git a/app/helpers/SearchingForSyncState.rb b/app/helpers/SearchingForSyncState.rb index e07cd8c..9b20521 100644 --- a/app/helpers/SearchingForSyncState.rb +++ b/app/helpers/SearchingForSyncState.rb @@ -2,7 +2,7 @@ class SearchingForSyncState < StatePattern::State def handle_byte_stream(bytes) idx = 0; # spool unwanted bytes - while (bytes[idx] != Synchronizer::SYNC_PATTERN[0]) do idx = idx+1 end + while (idx < bytes.length && bytes[idx] != Synchronizer::SYNC_PATTERN[0]) do idx = idx+1 end #p "Found pattern at idx = #{idx}" transition_to(ConfirmingSyncPatternState) diff --git a/example_blurp.txt b/example_blurp.txt index c904326..0066fd7 100644 --- a/example_blurp.txt +++ b/example_blurp.txt @@ -117,5 +117,5 @@ 0-1:96.1.0(3238303131303031323439333134383132) 0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) (00309.466) -0-1:24.4.0(1) +0-1:24.4.0(3) ! diff --git a/smartmeter.rb b/smartmeter.rb index c630477..188413c 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -12,7 +12,7 @@ connection_details = YAML::load(File.open('config/database.yml')) ActiveRecord::Base.establish_connection(connection_details) # # Open connection to serial port -# ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) +ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) # # # read until newline # response = ser.readline("\r") @@ -20,9 +20,12 @@ ActiveRecord::Base.establish_connection(connection_details) # print "#{response}\n" sync = Synchronizer.new -lines = File.read("example_blurp.txt")[rand(500)..-1] -while (lines.length > 0) +#lines = File.read("example_blurp.txt")[rand(1500)..-1] +lines = ser.readpartial(4096) # read max 4k of data +p "There are #{lines.length} characters" +while (!lines.nil? && lines.length > 0) lines = sync.handle_byte_stream(lines) + #p lines end -p sync +#p sync From 5e1712afacdd1384440856b1be1efa4acf827ffd Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 26 Jun 2013 22:39:51 +0200 Subject: [PATCH 004/113] Remove '\r' from serial datastream --- app/helpers/Synchronizer.rb | 2 +- smartmeter.rb | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/helpers/Synchronizer.rb b/app/helpers/Synchronizer.rb index cf164d5..a171021 100644 --- a/app/helpers/Synchronizer.rb +++ b/app/helpers/Synchronizer.rb @@ -5,4 +5,4 @@ class Synchronizer set_initial_state SearchingForSyncState -end \ No newline at end of file +end diff --git a/smartmeter.rb b/smartmeter.rb index 188413c..df12350 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -13,7 +13,11 @@ ActiveRecord::Base.establish_connection(connection_details) # # Open connection to serial port ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) -# +lines = "" +for i in 0..22 + lines = lines+ser.readline("\n").gsub(/\r/, '') +end + # # read until newline # response = ser.readline("\r") # response.chomp! @@ -21,8 +25,8 @@ ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) sync = Synchronizer.new #lines = File.read("example_blurp.txt")[rand(1500)..-1] -lines = ser.readpartial(4096) # read max 4k of data p "There are #{lines.length} characters" +p lines while (!lines.nil? && lines.length > 0) lines = sync.handle_byte_stream(lines) #p lines From 6443d70bcf904e6f0c99d1dbe4c2c55e607240ea Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 26 Jun 2013 16:45:00 -0400 Subject: [PATCH 005/113] use '!' as end of frame sync --- app/helpers/InSyncState.rb | 8 +++++--- smartmeter.rb | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 3e9e236..1efe2ab 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -1,10 +1,11 @@ class InSyncState < StatePattern::State - # END_OF_FRAME = "!\n" + END_OF_FRAME = "!\n" def handle_byte_stream(bytes) idx = 0 - sync_pattern_length = Synchronizer::SYNC_PATTERN.length + #sync_pattern_length = Synchronizer::SYNC_PATTERN.length + sync_pattern_length = END_OF_FRAME.length #p "Sync length: #{sync_length}" frame = "" @@ -30,6 +31,7 @@ class InSyncState < StatePattern::State private def new_frame_starts(bytes,idx,sync_pattern_length) - return bytes[idx..idx+sync_pattern_length-1].eql?(Synchronizer::SYNC_PATTERN) + #return bytes[idx..idx+sync_pattern_length-1].eql?(Synchronizer::SYNC_PATTERN) + return bytes[idx..idx+sync_pattern_length-1].eql?(END_OF_FRAME) end end \ No newline at end of file diff --git a/smartmeter.rb b/smartmeter.rb index df12350..954bfcb 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -14,8 +14,8 @@ ActiveRecord::Base.establish_connection(connection_details) # # Open connection to serial port ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) lines = "" -for i in 0..22 - lines = lines+ser.readline("\n").gsub(/\r/, '') +for i in 0..21 + lines = lines+ser.readline("\n").gsub(/\r/, '') end # # read until newline From bdcf92211fff3f7da12b946d29b4887dbf9a4d95 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 26 Jun 2013 17:46:21 -0400 Subject: [PATCH 006/113] First parsing of frame --- Gemfile | 3 +-- Gemfile.lock | 3 +++ app/helpers/InSyncState.rb | 24 +++++++++++++++++++++++- smartmeter.rb | 2 +- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 166797f..4dc5442 100644 --- a/Gemfile +++ b/Gemfile @@ -4,5 +4,4 @@ gem "activerecord", "3.2.13" gem "mysql2" gem "serialport" gem "state_pattern" -#gem "daemon-kit" -#gem 'rufus-scheduler', '>= 2.0.3' +gem 'rufus-scheduler' diff --git a/Gemfile.lock b/Gemfile.lock index 35e0b20..70d38fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -17,6 +17,8 @@ GEM i18n (0.6.1) multi_json (1.7.7) mysql2 (0.3.11) + rufus-scheduler (2.0.19) + tzinfo (>= 0.3.23) serialport (1.1.0) state_pattern (2.0.1) tzinfo (0.3.37) @@ -27,5 +29,6 @@ PLATFORMS DEPENDENCIES activerecord (= 3.2.13) mysql2 + rufus-scheduler serialport state_pattern diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 1efe2ab..725fe75 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -20,8 +20,9 @@ class InSyncState < StatePattern::State if new_frame_starts(bytes,idx,sync_pattern_length) p "------ FRAME -----" frame_lines = frame.split("\n") - p frame_lines # should call to higher level + p frame_lines p "##################" + handle_frame(frame_lines) return bytes[idx+sync_pattern_length..-1] else return nil @@ -34,4 +35,25 @@ class InSyncState < StatePattern::State #return bytes[idx..idx+sync_pattern_length-1].eql?(Synchronizer::SYNC_PATTERN) return bytes[idx..idx+sync_pattern_length-1].eql?(END_OF_FRAME) end + + def handle_frame(frame_lines) + frame_lines.each {| line| + if line.match(/1.8.1/) + total_kwh_consumed_high = line.split(/1-0:1.8.1\(|\*kWh\)/).join.to_f + p "Total kwh consumed (high): #{total_kwh_consumed_high}." + end + if line.match(/1.8.2/) + total_kwh_consumed_high = line.split(/1-0:1.8.2\(|\*kWh\)/).join.to_f + p "Total kwh consumed (low): #{total_kwh_consumed_high}." + end + if line.match(/2.8.1/) + total_kwh_produced_high = line.split(/1-0:2.8.1\(|\*kWh\)/).join.to_f + p "Total kwh consumed (high): #{total_kwh_produced_high}." + end + if line.match(/2.8.2/) + total_kwh_produced_low = line.split(/1-0:2.8.2\(|\*kWh\)/).join.to_f + p "Total kwh consumed (low): #{total_kwh_produced_low}." + end + } + end end \ No newline at end of file diff --git a/smartmeter.rb b/smartmeter.rb index 954bfcb..0ee2fa3 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -11,7 +11,7 @@ Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} connection_details = YAML::load(File.open('config/database.yml')) ActiveRecord::Base.establish_connection(connection_details) -# # Open connection to serial port +# Open connection to serial port ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) lines = "" for i in 0..21 From 1b04175394108585c67ca74a39c2a2e4999b6033 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 1 Jul 2013 02:35:21 -0500 Subject: [PATCH 007/113] handling of frame extended. Persisting Reading to database. --- app/helpers/InSyncState.rb | 52 ++++++++++++++++++++++++++++---------- smartmeter.rb | 43 ++++++++++++++++--------------- 2 files changed, 62 insertions(+), 33 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 725fe75..d1291f1 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -22,7 +22,8 @@ class InSyncState < StatePattern::State frame_lines = frame.split("\n") p frame_lines p "##################" - handle_frame(frame_lines) + reading = handle_frame(frame_lines) + p reading return bytes[idx+sync_pattern_length..-1] else return nil @@ -37,23 +38,48 @@ class InSyncState < StatePattern::State end def handle_frame(frame_lines) + # gas flag + next_is_gas = false + + # prepare DB record + reading = Reading.new + frame_lines.each {| line| - if line.match(/1.8.1/) - total_kwh_consumed_high = line.split(/1-0:1.8.1\(|\*kWh\)/).join.to_f - p "Total kwh consumed (high): #{total_kwh_consumed_high}." + if line.match(/1-0:1.8.1/) # Verbruik hoog tarief + reading.total_kwh_consumed_high = line.split(/1-0:1.8.1\(|\*kWh\)/).join.to_f + # p "Total kwh consumed (high): #{total_kwh_consumed_high}." end - if line.match(/1.8.2/) - total_kwh_consumed_high = line.split(/1-0:1.8.2\(|\*kWh\)/).join.to_f - p "Total kwh consumed (low): #{total_kwh_consumed_high}." + if line.match(/1-0:1.8.2/) # Verbruik laag tarief + reading.total_kwh_consumed_low = line.split(/1-0:1.8.2\(|\*kWh\)/).join.to_f + # p "Total kwh consumed (low): #{total_kwh_consumed_low}." end - if line.match(/2.8.1/) - total_kwh_produced_high = line.split(/1-0:2.8.1\(|\*kWh\)/).join.to_f - p "Total kwh consumed (high): #{total_kwh_produced_high}." + if line.match(/1-0:2.8.1/) # Teruglevering hoog tarief + reading.total_kwh_produced_high = line.split(/1-0:2.8.1\(|\*kWh\)/).join.to_f + # p "Total kwh produced (high): #{total_kwh_produced_high}." end - if line.match(/2.8.2/) - total_kwh_produced_low = line.split(/1-0:2.8.2\(|\*kWh\)/).join.to_f - p "Total kwh consumed (low): #{total_kwh_produced_low}." + if line.match(/1-0:2.8.2/) # Teruglevering laag tarief + reading.total_kwh_produced_low = line.split(/1-0:2.8.2\(|\*kWh\)/).join.to_f + # p "Total kwh produced (low): #{total_kwh_produced_low}." end + if line.match(/1-0:1.7.0/) # Actueel verbruik + reading.current_kw_consumed = line.split(/1-0:1.7.0\(|\*kW\)/).join.to_f + #p "Current kW consumed: #{current_kw_consumed}." + end + if line.match(/1-0:2.7.0/) # Actueel terug + reading.current_kw_produced = line.split(/1-0:2.7.0\(|\*kW\)/).join.to_f + # p "Current kW produced #{current_kw_produced}." + end + if next_is_gas && line.match(/\(/) + next_is_gas = false + reading.total_m3_gas_consumed = line.split(/\(|\)/).join.to_f + end + if line.match(/0-1:24.3.0/) # Gas verbruik (1x per uur een nieuwe stand) + next_is_gas = true # the usage is on the next line + end } + + reading.save + return reading + end end \ No newline at end of file diff --git a/smartmeter.rb b/smartmeter.rb index 0ee2fa3..29007d5 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -11,25 +11,28 @@ Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} connection_details = YAML::load(File.open('config/database.yml')) ActiveRecord::Base.establish_connection(connection_details) -# Open connection to serial port -ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) -lines = "" -for i in 0..21 - lines = lines+ser.readline("\n").gsub(/\r/, '') +if __FILE__ == $0 + begin + # Open connection to serial port + ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) + lines = "" + for i in 0..21 + lines = lines+ser.readline("\n").gsub(/\r/, '') + end + rescue + p "Serialport Error - reverting to 'example_blurp.txt'" + lines = File.read("example_blurp.txt")[rand(1500)..-1] + p "There are #{lines.length} characters" + p lines + end + + # + # Process the received lines + # + sync = Synchronizer.new + while (!lines.nil? && lines.length > 0) + lines = sync.handle_byte_stream(lines) + #p lines + end end - -# # read until newline -# response = ser.readline("\r") -# response.chomp! -# print "#{response}\n" - -sync = Synchronizer.new -#lines = File.read("example_blurp.txt")[rand(1500)..-1] -p "There are #{lines.length} characters" -p lines -while (!lines.nil? && lines.length > 0) - lines = sync.handle_byte_stream(lines) - #p lines -end - #p sync From 96071fb9b5d2e95cc454eaf0b1203238628872b8 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 1 Jul 2013 02:43:03 -0500 Subject: [PATCH 008/113] Added high_tarif reading --- app/helpers/InSyncState.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index d1291f1..1298750 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -67,7 +67,9 @@ class InSyncState < StatePattern::State end if line.match(/1-0:2.7.0/) # Actueel terug reading.current_kw_produced = line.split(/1-0:2.7.0\(|\*kW\)/).join.to_f - # p "Current kW produced #{current_kw_produced}." + end + if line.match(/0-0:96.3.10/) # Hoog/laag tarief + reading.high_tarif = line.split(/0-0:96.3.10\(|\)/).join.eql?("1") end if next_is_gas && line.match(/\(/) next_is_gas = false From 632c59580b02ea52ea06a9e11835884084ed62f4 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Tue, 16 Jul 2013 21:52:05 +0200 Subject: [PATCH 009/113] Continuous loop reading data --- app/helpers/InSyncState.rb | 12 +++++------- smartmeter.rb | 34 +++++++++++++++++++++------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 1298750..6270570 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -12,23 +12,21 @@ class InSyncState < StatePattern::State while (idx+sync_pattern_length < bytes.length && !new_frame_starts(bytes,idx,sync_pattern_length)) do frame = frame + bytes[idx] - idx = idx +1 - + idx = idx +1 end # did we reach the end of the frame? if new_frame_starts(bytes,idx,sync_pattern_length) - p "------ FRAME -----" frame_lines = frame.split("\n") - p frame_lines - p "##################" + p "------ FRAME -----" + # p frame_lines + # p "##################" reading = handle_frame(frame_lines) p reading return bytes[idx+sync_pattern_length..-1] else - return nil + return bytes end - end private diff --git a/smartmeter.rb b/smartmeter.rb index 29007d5..326d5f7 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -4,6 +4,8 @@ require "active_record" require "serialport" require "state_pattern" +MAX_BYTES = 100 + project_root = File.dirname(File.absolute_path(__FILE__)) Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} @@ -11,28 +13,34 @@ Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} connection_details = YAML::load(File.open('config/database.yml')) ActiveRecord::Base.establish_connection(connection_details) -if __FILE__ == $0 +def open_device begin # Open connection to serial port - ser = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) - lines = "" - for i in 0..21 - lines = lines+ser.readline("\n").gsub(/\r/, '') - end + io_device = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) rescue p "Serialport Error - reverting to 'example_blurp.txt'" - lines = File.read("example_blurp.txt")[rand(1500)..-1] - p "There are #{lines.length} characters" - p lines - end + io_device = File.open("example_blurp.txt") + end + return io_device +end +def read_from(source) + source.read(MAX_BYTES).gsub(/\r/, '') rescue "" +end + +if __FILE__ == $0 + source = open_device + buffer = [] + buffer = read_from(source) + # # Process the received lines # sync = Synchronizer.new - while (!lines.nil? && lines.length > 0) - lines = sync.handle_byte_stream(lines) - #p lines + while (buffer && buffer.length > 0) + p "BUFFER: #{buffer}." + buffer = sync.handle_byte_stream(buffer) + buffer = buffer + read_from(source) end end #p sync From aeb5e8435bef10891c2250bbcb08bb306e731d43 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Tue, 16 Jul 2013 23:10:17 +0200 Subject: [PATCH 010/113] return "" instead of nil --- app/helpers/ConfirmingSyncPatternState.rb | 2 +- app/helpers/InSyncState.rb | 2 +- app/helpers/SearchingForSyncState.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/helpers/ConfirmingSyncPatternState.rb b/app/helpers/ConfirmingSyncPatternState.rb index 7a46fe2..bfd8e93 100644 --- a/app/helpers/ConfirmingSyncPatternState.rb +++ b/app/helpers/ConfirmingSyncPatternState.rb @@ -16,6 +16,6 @@ class ConfirmingSyncPatternState < StatePattern::State end # return the rest - return bytes[idx+1..-1] + return bytes[idx+1..-1] || "" end end \ No newline at end of file diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 6270570..e41dbcc 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -23,7 +23,7 @@ class InSyncState < StatePattern::State # p "##################" reading = handle_frame(frame_lines) p reading - return bytes[idx+sync_pattern_length..-1] + return bytes[idx+sync_pattern_length..-1] || "" else return bytes end diff --git a/app/helpers/SearchingForSyncState.rb b/app/helpers/SearchingForSyncState.rb index 9b20521..b29a135 100644 --- a/app/helpers/SearchingForSyncState.rb +++ b/app/helpers/SearchingForSyncState.rb @@ -8,6 +8,6 @@ class SearchingForSyncState < StatePattern::State transition_to(ConfirmingSyncPatternState) # return - return bytes[idx..-1] + return bytes[idx..-1] || "" end end \ No newline at end of file From fa4dc7d31c198d7717e38a2f3973be43c146d2b7 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Tue, 16 Jul 2013 23:31:21 +0200 Subject: [PATCH 011/113] test serial output --- test-serial.rb | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 test-serial.rb diff --git a/test-serial.rb b/test-serial.rb new file mode 100644 index 0000000..b8c6160 --- /dev/null +++ b/test-serial.rb @@ -0,0 +1,35 @@ +require "rubygems" +require "bundler/setup" +require "active_record" +require "serialport" +require "state_pattern" + + +project_root = File.dirname(File.absolute_path(__FILE__)) +Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} +Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} + +connection_details = YAML::load(File.open('config/database.yml')) +ActiveRecord::Base.establish_connection(connection_details) + + +if __FILE__ == $0 + + #params for serial port + port_str = "/dev/ttyUSB1" #may be different for you + baud_rate = 9600 + data_bits = 7 + stop_bits = 1 + parity = SerialPort::EVEN + +sp = SerialPort.new(port_str, baud_rate, data_bits, stop_bits, parity) + +#just read forever +while true do + printf("%c", sp.getc) +end + +sp.close #see note 1 + +end + From 8e7fd64701dd6760a647197abd54077560cbe74e Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 17 Jul 2013 12:44:26 +0200 Subject: [PATCH 012/113] put serial_port in blocking read --- smartmeter.rb | 2 ++ test-serial.rb | 3 +++ 2 files changed, 5 insertions(+) diff --git a/smartmeter.rb b/smartmeter.rb index 326d5f7..666bc83 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -17,6 +17,8 @@ def open_device begin # Open connection to serial port io_device = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) + # Make reading blocking + io_device.read_timeout = 0 rescue p "Serialport Error - reverting to 'example_blurp.txt'" io_device = File.open("example_blurp.txt") diff --git a/test-serial.rb b/test-serial.rb index b8c6160..d7f0a91 100644 --- a/test-serial.rb +++ b/test-serial.rb @@ -24,6 +24,9 @@ if __FILE__ == $0 sp = SerialPort.new(port_str, baud_rate, data_bits, stop_bits, parity) +# Make reading blocking +sp.read_timeout = 0 + #just read forever while true do printf("%c", sp.getc) From 4b523f34a4a9b259e40f498ec117ccfa9e473214 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 17 Jul 2013 12:50:44 +0200 Subject: [PATCH 013/113] start at random position in file --- smartmeter.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/smartmeter.rb b/smartmeter.rb index 666bc83..127a6c9 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -22,6 +22,8 @@ def open_device rescue p "Serialport Error - reverting to 'example_blurp.txt'" io_device = File.open("example_blurp.txt") + # start at random place in the file + io_device.seek(rand(1500), IO::SEEK_SET) end return io_device end From edfb27ab98e2068df47794a54fb70d5680483eda Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 17 Jul 2013 13:48:35 +0200 Subject: [PATCH 014/113] prevent eql_readings to be saved to db. --- app/helpers/InSyncState.rb | 9 +++++++-- app/models/reading.rb | 11 +++++++++++ smartmeter.rb | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index e41dbcc..e914be2 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -77,8 +77,13 @@ class InSyncState < StatePattern::State next_is_gas = true # the usage is on the next line end } - - reading.save + + last_reading = Reading.last + if last_reading.eql_reading?(reading) + p "Nothing changed. Do not add to the database" + else + reading.save + end return reading end diff --git a/app/models/reading.rb b/app/models/reading.rb index fbf6dd5..570a678 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -1,3 +1,14 @@ class Reading < ActiveRecord::Base + + def eql_reading?(reading) + self.total_kwh_consumed_high == reading.total_kwh_consumed_high && + self.total_kwh_consumed_low == reading.total_kwh_consumed_low && + self.total_kwh_produced_high == reading.total_kwh_produced_high && + self.total_kwh_produced_low == reading.total_kwh_produced_low && + self.current_kw_consumed == reading.current_kw_consumed && + self.current_kw_produced = reading.current_kw_produced && + self.total_m3_gas_consumed = reading.total_m3_gas_consumed && + self.high_tarif == reading.high_tarif + end end diff --git a/smartmeter.rb b/smartmeter.rb index 127a6c9..d48fb6e 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -42,7 +42,7 @@ if __FILE__ == $0 # sync = Synchronizer.new while (buffer && buffer.length > 0) - p "BUFFER: #{buffer}." + # p "BUFFER: #{buffer}." buffer = sync.handle_byte_stream(buffer) buffer = buffer + read_from(source) end From fab18d422abc88cd82c232038081dd77e0ecd1a9 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 17 Jul 2013 14:03:35 +0200 Subject: [PATCH 015/113] bugfix --- app/helpers/InSyncState.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index e914be2..f9103e5 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -40,6 +40,7 @@ class InSyncState < StatePattern::State next_is_gas = false # prepare DB record + last_reading = Reading.last reading = Reading.new frame_lines.each {| line| @@ -78,8 +79,7 @@ class InSyncState < StatePattern::State end } - last_reading = Reading.last - if last_reading.eql_reading?(reading) + if last_reading && last_reading.eql_reading?(reading) p "Nothing changed. Do not add to the database" else reading.save From 4e9ccb42d739388fe8991c4290db6b2ef8905d3b Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 18 Jul 2013 20:59:46 +0200 Subject: [PATCH 016/113] run as daemon --- Gemfile | 1 + Gemfile.lock | 2 ++ daemonize.rb | 8 ++++++++ etc/smartmeter | 11 +++++++++++ 4 files changed, 22 insertions(+) create mode 100644 daemonize.rb create mode 100644 etc/smartmeter diff --git a/Gemfile b/Gemfile index 4dc5442..918f9ce 100644 --- a/Gemfile +++ b/Gemfile @@ -5,3 +5,4 @@ gem "mysql2" gem "serialport" gem "state_pattern" gem 'rufus-scheduler' +gem 'daemons' diff --git a/Gemfile.lock b/Gemfile.lock index 70d38fd..a446b03 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,6 +14,7 @@ GEM multi_json (~> 1.0) arel (3.0.2) builder (3.0.4) + daemons (1.1.9) i18n (0.6.1) multi_json (1.7.7) mysql2 (0.3.11) @@ -28,6 +29,7 @@ PLATFORMS DEPENDENCIES activerecord (= 3.2.13) + daemons mysql2 rufus-scheduler serialport diff --git a/daemonize.rb b/daemonize.rb new file mode 100644 index 0000000..cbb1646 --- /dev/null +++ b/daemonize.rb @@ -0,0 +1,8 @@ +require 'rubygems' +require 'daemons' + +pwd = Dir.pwd +Daemons.run_proc('smartmeter', {:dir_mode => :normal, :dir => pwd+"/pids"}) do + Dir.chdir(pwd) + exec "ruby smartmeter.rb" +end diff --git a/etc/smartmeter b/etc/smartmeter new file mode 100644 index 0000000..ad83f8a --- /dev/null +++ b/etc/smartmeter @@ -0,0 +1,11 @@ +#!/bin/bash +# +# Startup script (put in /etc/init.d/smartmeter) +# +# description: Starts Smartmeter as an unprivileged user. +# + +sudo -u www-data ruby /mnt/usb/ruby/smartmeter/daemonize.rb $1 +RETVAL=$? + +exit $RETVAL From 68ca2bcf7a99b87685c55df312058ac763323ae5 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 18 Jul 2013 21:23:18 +0200 Subject: [PATCH 017/113] bug fix --- app/models/reading.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/reading.rb b/app/models/reading.rb index 570a678..978dc1c 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -6,8 +6,8 @@ class Reading < ActiveRecord::Base self.total_kwh_produced_high == reading.total_kwh_produced_high && self.total_kwh_produced_low == reading.total_kwh_produced_low && self.current_kw_consumed == reading.current_kw_consumed && - self.current_kw_produced = reading.current_kw_produced && - self.total_m3_gas_consumed = reading.total_m3_gas_consumed && + self.current_kw_produced == reading.current_kw_produced && + self.total_m3_gas_consumed == reading.total_m3_gas_consumed && self.high_tarif == reading.high_tarif end From 65b4d7aa5bf779056fc93d66992a2966c15e6681 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 31 Jul 2013 11:58:29 +0200 Subject: [PATCH 018/113] reduce precision to 1 digit --- app/models/reading.rb | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/models/reading.rb b/app/models/reading.rb index 978dc1c..cdf66e4 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -10,5 +10,25 @@ class Reading < ActiveRecord::Base self.total_m3_gas_consumed == reading.total_m3_gas_consumed && self.high_tarif == reading.high_tarif end + + # reduce precision to 1 digit behind comma + def total_kwh_consumed_high=(kwh) + write_attribute(:total_kwh_consumed_high,kwh.round(1)) + end + + # reduce precision to 1 digit behind comma + def total_kwh_consumed_low=(kwh) + write_attribute(:total_kwh_consumed_low,kwh.round(1)) + end + + # reduce precision to 1 digit behind comma + def total_kwh_produced_high=(kwh) + write_attribute(:total_kwh_produced_high,kwh.round(1)) + end + + # reduce precision to 1 digit behind comma + def total_kwh_produced_low=(kwh) + write_attribute(:total_kwh_produced_low,kwh.round(1)) + end end From aed17df623688345f4e6c67883e1e664601aa45c Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Tue, 6 Aug 2013 17:44:39 +0200 Subject: [PATCH 019/113] Fix hoog/laag tarief indicatie --- app/helpers/InSyncState.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index f9103e5..c7fa7cb 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -67,8 +67,8 @@ class InSyncState < StatePattern::State if line.match(/1-0:2.7.0/) # Actueel terug reading.current_kw_produced = line.split(/1-0:2.7.0\(|\*kW\)/).join.to_f end - if line.match(/0-0:96.3.10/) # Hoog/laag tarief - reading.high_tarif = line.split(/0-0:96.3.10\(|\)/).join.eql?("1") + if line.match(/0-0:96.14.0/) # Hoog/laag tarief + reading.high_tarif = line.split(/0-0:96.14.0\(|\)/).join.eql?("0002") end if next_is_gas && line.match(/\(/) next_is_gas = false From 7a1ff21adfae338e3b1e7be0bb87967c5adb8578 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 7 Aug 2013 22:36:58 +0200 Subject: [PATCH 020/113] add helper methods --- app/models/reading.rb | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/app/models/reading.rb b/app/models/reading.rb index cdf66e4..6b1ec9d 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -30,5 +30,37 @@ class Reading < ActiveRecord::Base def total_kwh_produced_low=(kwh) write_attribute(:total_kwh_produced_low,kwh.round(1)) end - + + # calculate difference with another reading + # return a hash with differences (self - reading) + def diff(reading) + { :total_kwh_consumed_high => self.total_kwh_consumed_high - reading.total_kwh_consumed_high, + :total_kwh_consumed_low => self.total_kwh_consumed_low - reading.total_kwh_consumed_low, + :total_kwh_produced_high => self.total_kwh_produced_high - reading.total_kwh_produced_high, + :total_kwh_produced_low => self.total_kwh_produced_low - reading.total_kwh_produced_low, + :current_kw_consumed => self.current_kw_consumed - reading.current_kw_consumed, + :current_kw_produced => self.current_kw_produced - reading.current_kw_produced, + :total_m3_gas_consumed => self.total_m3_gas_consumed - reading.total_m3_gas_consumed } + end + + # + # Class methods + # + class << self + # return readings from beginning of 'from' until end of 'to' + def days(from, to) + Reading.where("created_at > :begin AND created_at < :end", { :begin => from.to_date.beginning_of_day, :end => to.to_date.end_of_day}) + end + + def day(date) + Reading.where("created_at > :begin AND created_at < :end", { :begin => date.to_date.beginning_of_day, :end => date.to_date.end_of_day}) + end + + def diff_on(date) + readings_on = day(date) + first = readings_on.first + last = readings_on.last + first.diff(last) + end + end end From 894932b7261f6e061e67cad6ce01170a84400426 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 7 Aug 2013 22:42:58 +0200 Subject: [PATCH 021/113] minor improvement (last - first) --- app/models/reading.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/models/reading.rb b/app/models/reading.rb index 6b1ec9d..d724fae 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -38,8 +38,6 @@ class Reading < ActiveRecord::Base :total_kwh_consumed_low => self.total_kwh_consumed_low - reading.total_kwh_consumed_low, :total_kwh_produced_high => self.total_kwh_produced_high - reading.total_kwh_produced_high, :total_kwh_produced_low => self.total_kwh_produced_low - reading.total_kwh_produced_low, - :current_kw_consumed => self.current_kw_consumed - reading.current_kw_consumed, - :current_kw_produced => self.current_kw_produced - reading.current_kw_produced, :total_m3_gas_consumed => self.total_m3_gas_consumed - reading.total_m3_gas_consumed } end @@ -60,7 +58,7 @@ class Reading < ActiveRecord::Base readings_on = day(date) first = readings_on.first last = readings_on.last - first.diff(last) + last.diff(first) end end end From 6fd8b79e1c4f3a34bd3e580ab10b58d24157c073 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 7 Aug 2013 22:55:01 +0200 Subject: [PATCH 022/113] rounding --- app/models/reading.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/reading.rb b/app/models/reading.rb index d724fae..eaa8a00 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -34,11 +34,11 @@ class Reading < ActiveRecord::Base # calculate difference with another reading # return a hash with differences (self - reading) def diff(reading) - { :total_kwh_consumed_high => self.total_kwh_consumed_high - reading.total_kwh_consumed_high, - :total_kwh_consumed_low => self.total_kwh_consumed_low - reading.total_kwh_consumed_low, - :total_kwh_produced_high => self.total_kwh_produced_high - reading.total_kwh_produced_high, - :total_kwh_produced_low => self.total_kwh_produced_low - reading.total_kwh_produced_low, - :total_m3_gas_consumed => self.total_m3_gas_consumed - reading.total_m3_gas_consumed } + { :total_kwh_consumed_high => (self.total_kwh_consumed_high - reading.total_kwh_consumed_high).round(1), + :total_kwh_consumed_low => (self.total_kwh_consumed_low - reading.total_kwh_consumed_low).round(1), + :total_kwh_produced_high => (self.total_kwh_produced_high - reading.total_kwh_produced_high).round(1), + :total_kwh_produced_low => (self.total_kwh_produced_low - reading.total_kwh_produced_low).round(1), + :total_m3_gas_consumed => (self.total_m3_gas_consumed - reading.total_m3_gas_consumed).round(3) } end # From ceaaddb55e23a8a70613995c519a689214cb0dc6 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 8 Aug 2013 23:01:44 +0200 Subject: [PATCH 023/113] mail results --- app/helpers/ReadingsMailer.rb | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 app/helpers/ReadingsMailer.rb diff --git a/app/helpers/ReadingsMailer.rb b/app/helpers/ReadingsMailer.rb new file mode 100644 index 0000000..c350a45 --- /dev/null +++ b/app/helpers/ReadingsMailer.rb @@ -0,0 +1,37 @@ +require "mail" + +class ReadingsMailer + OPTIONS = { :address => "mail.van-halteren.net", + :port => 465, + :domain => 'van-halteren.net', + :user_name => 'aart@van-halteren.net', + :password => '', + :authentication => 'plain', + :enable_ssl => true} + + # + # Class methods + # + class << self + def deliver + + mail = Mail.new do + #delivery_method :smtp, OPTIONS + to 'aart@van-halteren.net' + from 'SmartMeter ' + subject 'First multipart email sent with Mail' + + text_part do + body 'This is plain text' + end + + html_part do + content_type 'text/html; charset=UTF-8' + body '

This is HTML

' + end + end + + mail.deliver! + end + end +end \ No newline at end of file From f7c33549e8a539397f2f7a8818b9b5357eda0a4d Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 8 Aug 2013 23:03:27 +0200 Subject: [PATCH 024/113] gem mail added --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index 918f9ce..7ae9fa6 100644 --- a/Gemfile +++ b/Gemfile @@ -6,3 +6,4 @@ gem "serialport" gem "state_pattern" gem 'rufus-scheduler' gem 'daemons' +gem 'mail' From b51937f9da14a91be778017dd268567a97f10a36 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 8 Aug 2013 23:11:30 +0200 Subject: [PATCH 025/113] don't verify SSL certificate --- app/helpers/ReadingsMailer.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/helpers/ReadingsMailer.rb b/app/helpers/ReadingsMailer.rb index c350a45..9b66efe 100644 --- a/app/helpers/ReadingsMailer.rb +++ b/app/helpers/ReadingsMailer.rb @@ -1,14 +1,18 @@ require "mail" class ReadingsMailer - OPTIONS = { :address => "mail.van-halteren.net", + OPTS = { :address => "mail.van-halteren.net", :port => 465, :domain => 'van-halteren.net', :user_name => 'aart@van-halteren.net', :password => '', :authentication => 'plain', :enable_ssl => true} - + + OPTS_NO_SSL_VERIFY = { + :openssl_verify_mode => OpenSSL::SSL::VERIFY_NONE, + } + # # Class methods # @@ -16,7 +20,7 @@ class ReadingsMailer def deliver mail = Mail.new do - #delivery_method :smtp, OPTIONS + delivery_method :smtp, OPTS_NO_SSL_VERIFY to 'aart@van-halteren.net' from 'SmartMeter ' subject 'First multipart email sent with Mail' From 33c45fc0e56c1be829a245b1f7de6bdcce480541 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Fri, 9 Aug 2013 14:32:43 +0200 Subject: [PATCH 026/113] Added real content to email body --- app/helpers/ReadingsMailer.rb | 54 +++++++++++++++++++++-------------- app/models/reading.rb | 24 ++++++++++++---- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/app/helpers/ReadingsMailer.rb b/app/helpers/ReadingsMailer.rb index 9b66efe..b4ca14d 100644 --- a/app/helpers/ReadingsMailer.rb +++ b/app/helpers/ReadingsMailer.rb @@ -1,38 +1,50 @@ require "mail" class ReadingsMailer - OPTS = { :address => "mail.van-halteren.net", - :port => 465, - :domain => 'van-halteren.net', - :user_name => 'aart@van-halteren.net', - :password => '', - :authentication => 'plain', - :enable_ssl => true} - - OPTS_NO_SSL_VERIFY = { - :openssl_verify_mode => OpenSSL::SSL::VERIFY_NONE, + + SSL_OPTS = { + :openssl_verify_mode => OpenSSL::SSL::VERIFY_NONE, } + + # IMAP_OPTS = { :address => "mail.van-halteren.net", + # :port => 993, + # :user_name => 'aart@van-halteren.net', + # :password => 'XXXXX', + # :openssl_verify_mode => OpenSSL::SSL::VERIFY_NONE, + # :enable_ssl => true + # } # # Class methods # class << self - def deliver - - mail = Mail.new do - delivery_method :smtp, OPTS_NO_SSL_VERIFY - to 'aart@van-halteren.net' + def deliver + # Read SMTP options from smtp.yml + smtp_opts = YAML::load(File.open('config/smtp.yml')).symbolize_keys + smtp_opts.merge!(SSL_OPTS) if smtp_opts[:ssl] && smtp_opts[:ssl_verify_mode].eql?("none") + + # Fetch today's usage + usage_today = Reading.diff_on(Date.today) + + mail = Mail.new do + delivery_method :smtp, smtp_opts + to 'a.t.van.halteren@vu.nl' from 'SmartMeter ' - subject 'First multipart email sent with Mail' + subject 'SmartMeter report' text_part do - body 'This is plain text' + body "Summary for #{Date.today}\n + -------------------------------\n\n + Total kWH electricity consumed: #{usage_today[:total_kwh_consumed_high] + usage_today[:total_kwh_consumed_low]}\n + Total kWH electricity produced: #{usage_today[:total_kwh_produced_high] + usage_today[:total_kwh_produced_low]}\n + Total m3 gas consumed: #{usage_today[:total_m3_gas_consumed]}\n + " end - html_part do - content_type 'text/html; charset=UTF-8' - body '

This is HTML

' - end + # html_part do + # content_type 'text/html; charset=UTF-8' + # body '

This is HTML

' + # end end mail.deliver! diff --git a/app/models/reading.rb b/app/models/reading.rb index eaa8a00..b07260e 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -34,11 +34,19 @@ class Reading < ActiveRecord::Base # calculate difference with another reading # return a hash with differences (self - reading) def diff(reading) - { :total_kwh_consumed_high => (self.total_kwh_consumed_high - reading.total_kwh_consumed_high).round(1), - :total_kwh_consumed_low => (self.total_kwh_consumed_low - reading.total_kwh_consumed_low).round(1), - :total_kwh_produced_high => (self.total_kwh_produced_high - reading.total_kwh_produced_high).round(1), - :total_kwh_produced_low => (self.total_kwh_produced_low - reading.total_kwh_produced_low).round(1), - :total_m3_gas_consumed => (self.total_m3_gas_consumed - reading.total_m3_gas_consumed).round(3) } + if reading + { :total_kwh_consumed_high => (self.total_kwh_consumed_high - reading.total_kwh_consumed_high).round(1), + :total_kwh_consumed_low => (self.total_kwh_consumed_low - reading.total_kwh_consumed_low).round(1), + :total_kwh_produced_high => (self.total_kwh_produced_high - reading.total_kwh_produced_high).round(1), + :total_kwh_produced_low => (self.total_kwh_produced_low - reading.total_kwh_produced_low).round(1), + :total_m3_gas_consumed => (self.total_m3_gas_consumed - reading.total_m3_gas_consumed).round(3) } + else + { :total_kwh_consumed_high => self.total_kwh_consumed_high, + :total_kwh_consumed_low => self.total_kwh_consumed_low, + :total_kwh_produced_high => self.total_kwh_produced_high, + :total_kwh_produced_low => self.total_kwh_produced_low, + :total_m3_gas_consumed => self.total_m3_gas_consumed } + end end # @@ -58,7 +66,11 @@ class Reading < ActiveRecord::Base readings_on = day(date) first = readings_on.first last = readings_on.last - last.diff(first) + if last + last.diff(first) + else + { :total_kwh_consumed_high => 0, :total_kwh_consumed_low => 0, :total_kwh_produced_high => 0, :total_kwh_produced_low => 0, :total_m3_gas_consumed => 0 } + end end end end From 821406a5f9b04e5c9f65ccf7f7cf2f405127c752 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Fri, 9 Aug 2013 14:33:44 +0200 Subject: [PATCH 027/113] SMTP parameters in config --- config/smtp.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 config/smtp.yml diff --git a/config/smtp.yml b/config/smtp.yml new file mode 100644 index 0000000..709b3f8 --- /dev/null +++ b/config/smtp.yml @@ -0,0 +1,8 @@ +address: "mail.van-halteren.net" +port: 465 +domain: 'van-halteren.net' +user_name: 'aart@van-halteren.net' +password: 'secret' +authentication: "plain" +ssl: true +ssl_verify_mode: "none" From c1f3c7ec6cd7df1306f780984b8733487f198cc4 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Fri, 9 Aug 2013 15:14:59 +0200 Subject: [PATCH 028/113] Cron script added --- etc/daily_mailer | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100755 etc/daily_mailer diff --git a/etc/daily_mailer b/etc/daily_mailer new file mode 100755 index 0000000..6cfe946 --- /dev/null +++ b/etc/daily_mailer @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# load rvm ruby +source /usr/local/rvm/environments/ruby-1.9.3-p125@smartmeter + +cd /mnt/usb/ruby/smartmeter +ruby test-serial.rb + From b3b061a480b2bb85189afd3271204fd13c1237b3 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Fri, 9 Aug 2013 15:19:57 +0200 Subject: [PATCH 029/113] mailing from crontab --- etc/daily_mailer | 2 +- report_mailer.rb | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 report_mailer.rb diff --git a/etc/daily_mailer b/etc/daily_mailer index 6cfe946..0dc3e7c 100755 --- a/etc/daily_mailer +++ b/etc/daily_mailer @@ -4,5 +4,5 @@ source /usr/local/rvm/environments/ruby-1.9.3-p125@smartmeter cd /mnt/usb/ruby/smartmeter -ruby test-serial.rb +ruby report_mailer.rb diff --git a/report_mailer.rb b/report_mailer.rb new file mode 100644 index 0000000..7ca45f0 --- /dev/null +++ b/report_mailer.rb @@ -0,0 +1,16 @@ +require "rubygems" +require "bundler/setup" +require "active_record" +require "state_pattern" + +project_root = File.dirname(File.absolute_path(__FILE__)) +Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} +Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} + +connection_details = YAML::load(File.open('config/database.yml')) +ActiveRecord::Base.establish_connection(connection_details) + +if __FILE__ == $0 + ReadingsMailer.deliver +end +#p sync From 4c0c674f7b06478b5e3ea1707b5d836a40b8c62a Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Fri, 9 Aug 2013 17:32:21 +0200 Subject: [PATCH 030/113] html update --- app/helpers/ReadingsMailer.rb | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/helpers/ReadingsMailer.rb b/app/helpers/ReadingsMailer.rb index b4ca14d..fbdef4c 100644 --- a/app/helpers/ReadingsMailer.rb +++ b/app/helpers/ReadingsMailer.rb @@ -30,7 +30,7 @@ class ReadingsMailer delivery_method :smtp, smtp_opts to 'a.t.van.halteren@vu.nl' from 'SmartMeter ' - subject 'SmartMeter report' + subject "SmartMeter report for #{Date.today}" text_part do body "Summary for #{Date.today}\n @@ -41,10 +41,13 @@ class ReadingsMailer " end - # html_part do - # content_type 'text/html; charset=UTF-8' - # body '

This is HTML

' - # end + html_part do + content_type 'text/html; charset=UTF-8' + body "

Summary for #{Date.today}

" + + "

Total kWH electricity consumed: #{usage_today[:total_kwh_consumed_high] + usage_today[:total_kwh_consumed_low]}

" + + "

Total kWH electricity produced: #{usage_today[:total_kwh_produced_high] + usage_today[:total_kwh_produced_low]}

" + + "

Total m3 gas consumed: #{usage_today[:total_m3_gas_consumed]}

" + end end mail.deliver! From 516e09bfba6587b8b9362de6d8bb82d8c2c84d6f Mon Sep 17 00:00:00 2001 From: PCOG sites Date: Tue, 15 Jul 2014 17:04:33 +0200 Subject: [PATCH 031/113] Fixes for new compute --- pids/smartmeter.pid | 1 + 1 file changed, 1 insertion(+) create mode 100644 pids/smartmeter.pid diff --git a/pids/smartmeter.pid b/pids/smartmeter.pid new file mode 100644 index 0000000..100178f --- /dev/null +++ b/pids/smartmeter.pid @@ -0,0 +1 @@ +19407 From bab08f987d8b14ed3d0b01a7afb7016967aa5df8 Mon Sep 17 00:00:00 2001 From: PCOG sites Date: Tue, 15 Jul 2014 17:52:49 +0200 Subject: [PATCH 032/113] Fixes for new environment --- .ruby-version | 2 +- Gemfile.lock | 4 ++++ app/helpers/SearchingForSyncState.rb | 2 +- app/helpers/Synchronizer.rb | 4 ++-- daemonize.rb | 2 +- etc/daily_mailer | 4 ++-- etc/smartmeter | 4 +++- smartmeter.rb | 5 +++-- test-serial.rb | 3 ++- 9 files changed, 19 insertions(+), 11 deletions(-) mode change 100644 => 100755 etc/smartmeter diff --git a/.ruby-version b/.ruby-version index 970977c..02f2617 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-1.9.3-p125 +ruby-1.9.3-p484 diff --git a/Gemfile.lock b/Gemfile.lock index a446b03..61e23e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -16,6 +16,9 @@ GEM builder (3.0.4) daemons (1.1.9) i18n (0.6.1) + mail (2.6.1) + mime-types (>= 1.16, < 3) + mime-types (2.3) multi_json (1.7.7) mysql2 (0.3.11) rufus-scheduler (2.0.19) @@ -30,6 +33,7 @@ PLATFORMS DEPENDENCIES activerecord (= 3.2.13) daemons + mail mysql2 rufus-scheduler serialport diff --git a/app/helpers/SearchingForSyncState.rb b/app/helpers/SearchingForSyncState.rb index b29a135..d33916b 100644 --- a/app/helpers/SearchingForSyncState.rb +++ b/app/helpers/SearchingForSyncState.rb @@ -10,4 +10,4 @@ class SearchingForSyncState < StatePattern::State # return return bytes[idx..-1] || "" end -end \ No newline at end of file +end diff --git a/app/helpers/Synchronizer.rb b/app/helpers/Synchronizer.rb index a171021..1eed956 100644 --- a/app/helpers/Synchronizer.rb +++ b/app/helpers/Synchronizer.rb @@ -1,8 +1,8 @@ class Synchronizer - include StatePattern + include StatePattern SYNC_PATTERN = "\n/ISk5\\2ME382-1003\n\n" - set_initial_state SearchingForSyncState + set_initial_state ::SearchingForSyncState end diff --git a/daemonize.rb b/daemonize.rb index cbb1646..7d63480 100644 --- a/daemonize.rb +++ b/daemonize.rb @@ -1,4 +1,4 @@ -require 'rubygems' +#require 'rubygems' require 'daemons' pwd = Dir.pwd diff --git a/etc/daily_mailer b/etc/daily_mailer index 0dc3e7c..80d8900 100755 --- a/etc/daily_mailer +++ b/etc/daily_mailer @@ -1,8 +1,8 @@ #!/usr/bin/env bash # load rvm ruby -source /usr/local/rvm/environments/ruby-1.9.3-p125@smartmeter +source /usr/local/rvm/environments/ruby-1.9.3-p484@smartmeter -cd /mnt/usb/ruby/smartmeter +cd /home/pcog/smartmeter ruby report_mailer.rb diff --git a/etc/smartmeter b/etc/smartmeter old mode 100644 new mode 100755 index ad83f8a..8eb1521 --- a/etc/smartmeter +++ b/etc/smartmeter @@ -5,7 +5,9 @@ # description: Starts Smartmeter as an unprivileged user. # -sudo -u www-data ruby /mnt/usb/ruby/smartmeter/daemonize.rb $1 +# Create a wrapper using 'rvm alias smartmeter ruby-1.9.3-p484@smartmeter' + +sudo -u www-data /usr/local/rvm/wrappers/smartmeter/ruby /home/pcog/smartmeter/daemonize.rb $1 RETVAL=$? exit $RETVAL diff --git a/smartmeter.rb b/smartmeter.rb index d48fb6e..6547b11 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -1,4 +1,4 @@ -require "rubygems" +#require "rubygems" require "bundler/setup" require "active_record" require "serialport" @@ -8,6 +8,7 @@ MAX_BYTES = 100 project_root = File.dirname(File.absolute_path(__FILE__)) Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} +Dir.glob(project_root + "/app/helpers/SearchingForSyncState.rb").each{|f| require f} Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} connection_details = YAML::load(File.open('config/database.yml')) @@ -16,7 +17,7 @@ ActiveRecord::Base.establish_connection(connection_details) def open_device begin # Open connection to serial port - io_device = SerialPort.new("/dev/ttyUSB1", 9600, 7, 1, SerialPort::EVEN) + io_device = SerialPort.new("/dev/ttyUSB0", 9600, 7, 1, SerialPort::EVEN) # Make reading blocking io_device.read_timeout = 0 rescue diff --git a/test-serial.rb b/test-serial.rb index d7f0a91..59bf644 100644 --- a/test-serial.rb +++ b/test-serial.rb @@ -7,6 +7,7 @@ require "state_pattern" project_root = File.dirname(File.absolute_path(__FILE__)) Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} +Dir.glob(project_root + "/app/helpers/SearchingForSyncState.rb").each{|f| require f} Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} connection_details = YAML::load(File.open('config/database.yml')) @@ -16,7 +17,7 @@ ActiveRecord::Base.establish_connection(connection_details) if __FILE__ == $0 #params for serial port - port_str = "/dev/ttyUSB1" #may be different for you + port_str = "/dev/ttyUSB0" #may be different for you baud_rate = 9600 data_bits = 7 stop_bits = 1 From 99be5ac1000029e10bab5ccac2f385a901d52ee8 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Tue, 15 Jul 2014 17:57:58 +0200 Subject: [PATCH 033/113] Improved etc/init.d script --- etc/smartmeter | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/etc/smartmeter b/etc/smartmeter index 8eb1521..2fbfe47 100755 --- a/etc/smartmeter +++ b/etc/smartmeter @@ -5,9 +5,15 @@ # description: Starts Smartmeter as an unprivileged user. # -# Create a wrapper using 'rvm alias smartmeter ruby-1.9.3-p484@smartmeter' +if [[ -s "/usr/local/rvm/environments/ruby-1.9.3-p484@smartmeter" ]] +then + source "/usr/local/rvm/environments/ruby-1.9.3-p484@smartmeter" + exec sudo -u www-data ruby /mnt/usb/ruby/smartmeter/daemonize.rb $1 + RETVAL=$? -sudo -u www-data /usr/local/rvm/wrappers/smartmeter/ruby /home/pcog/smartmeter/daemonize.rb $1 -RETVAL=$? + exit $RETVAL +else + echo "ERROR: Missing RVM environment file: '/usr/local/rvm/environments/ruby-1.9.3-p484@smartmeter'" >&2 + exit 1 +fi -exit $RETVAL From f3eec8c1a1fd177cc062af3bd42703a66fcf4c25 Mon Sep 17 00:00:00 2001 From: PCOG sites Date: Tue, 15 Jul 2014 18:04:16 +0200 Subject: [PATCH 034/113] Using wrapper for init.d script --- etc/smartmeter | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/etc/smartmeter b/etc/smartmeter index 2fbfe47..9b225be 100755 --- a/etc/smartmeter +++ b/etc/smartmeter @@ -5,15 +5,16 @@ # description: Starts Smartmeter as an unprivileged user. # -if [[ -s "/usr/local/rvm/environments/ruby-1.9.3-p484@smartmeter" ]] +# Create a wrapper using 'rvm alias smartmeter ruby-1.9.3-p484@smartmeter' + +if [[ -s "/usr/local/rvm/wrappers/smartmeter/ruby" ]] then - source "/usr/local/rvm/environments/ruby-1.9.3-p484@smartmeter" - exec sudo -u www-data ruby /mnt/usb/ruby/smartmeter/daemonize.rb $1 + sudo -u www-data /usr/local/rvm/wrappers/smartmeter/ruby /home/pcog/smartmeter/daemonize.rb $1 RETVAL=$? exit $RETVAL else - echo "ERROR: Missing RVM environment file: '/usr/local/rvm/environments/ruby-1.9.3-p484@smartmeter'" >&2 + echo "ERROR: Missing RVM wrapper file: '/usr/local/rvm/wrappers/smartmeter'" >&2 exit 1 fi From 5dc8ffb24da6dd124acfd44d95f64148ca7349b9 Mon Sep 17 00:00:00 2001 From: PCOG sites Date: Tue, 15 Jul 2014 22:31:25 +0200 Subject: [PATCH 035/113] Fix for new environment --- report_mailer.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/report_mailer.rb b/report_mailer.rb index 7ca45f0..393010d 100644 --- a/report_mailer.rb +++ b/report_mailer.rb @@ -5,6 +5,7 @@ require "state_pattern" project_root = File.dirname(File.absolute_path(__FILE__)) Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} +Dir.glob(project_root + "/app/helpers/SearchingForSyncState.rb").each{|f| require f} Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} connection_details = YAML::load(File.open('config/database.yml')) From 619107a303fc62a0b288871def788924a768c541 Mon Sep 17 00:00:00 2001 From: PCOG sites Date: Tue, 15 Jul 2014 23:35:41 +0200 Subject: [PATCH 036/113] Write data to socket for EmonHub --- app/helpers/InSyncState.rb | 22 +++++++++++++++++++++- app/helpers/Synchronizer.rb | 2 +- smartmeter.rb | 1 + 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index c7fa7cb..9ebc52a 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -1,7 +1,17 @@ +require "socket" + class InSyncState < StatePattern::State END_OF_FRAME = "!\n" +# def initialize(stateful, previous_state) +# # open socket to EmonHub +# @hub = TCPSocket.new 'raspberrypi.lan', 5050 +# @hub.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true) +# super(stateful, previous_state) +# end + + def handle_byte_stream(bytes) idx = 0 #sync_pattern_length = Synchronizer::SYNC_PATTERN.length @@ -84,7 +94,17 @@ class InSyncState < StatePattern::State else reading.save end + + # Write to EmonHub + begin + TCPSocket.open("raspberrypi.lan",5050){|s| + s.write(sprintf("8 %d %d\r\n", reading.current_kw_consumed*1000, reading.current_kw_produced*1000)) + } + rescue + p "Socket problem." + end + # Result return reading end -end \ No newline at end of file +end diff --git a/app/helpers/Synchronizer.rb b/app/helpers/Synchronizer.rb index 1eed956..df24f98 100644 --- a/app/helpers/Synchronizer.rb +++ b/app/helpers/Synchronizer.rb @@ -2,7 +2,7 @@ class Synchronizer include StatePattern SYNC_PATTERN = "\n/ISk5\\2ME382-1003\n\n" - + set_initial_state ::SearchingForSyncState end diff --git a/smartmeter.rb b/smartmeter.rb index 6547b11..21521d3 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -34,6 +34,7 @@ def read_from(source) end if __FILE__ == $0 + # open serial port and read first bytes source = open_device buffer = [] buffer = read_from(source) From 625fd06a0f3209685b20aa7204553fa4e2b06ecd Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 19 Mar 2018 16:18:56 +0100 Subject: [PATCH 037/113] Dockerized --- Dockerfile | 20 ++++++++++ Gemfile | 2 +- Gemfile.lock | 60 +++++++++++++++++------------- config/database.yml | 3 +- db/migrate/001_creates_readings.rb | 2 +- docker-compose.yml | 16 ++++++++ 6 files changed, 74 insertions(+), 29 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0cbd466 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM ruby:2.4.3 + +ENV BUILD_PACKAGES="apt-utils build-essential curl less nodejs sudo wget zsh libmysqlclient-dev ruby-serialport libserialport-dev" + +# throw errors if Gemfile has been modified since Gemfile.lock +RUN \ + bundle config --global frozen 1 + + +WORKDIR /usr/src/app + +COPY Gemfile Gemfile.lock ./ +RUN \ + apt-get update -qq && \ + apt-get install -y $BUILD_PACKAGES && \ + bundle install + +COPY . . + +CMD ["cd /usr/src/app && ruby smartmeter.rb"] diff --git a/Gemfile b/Gemfile index 7ae9fa6..737de4b 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -gem "activerecord", "3.2.13" +gem "activerecord" gem "mysql2" gem "serialport" gem "state_pattern" diff --git a/Gemfile.lock b/Gemfile.lock index 61e23e6..d4ad127 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,40 +1,48 @@ GEM remote: https://rubygems.org/ specs: - activemodel (3.2.13) - activesupport (= 3.2.13) - builder (~> 3.0.0) - activerecord (3.2.13) - activemodel (= 3.2.13) - activesupport (= 3.2.13) - arel (~> 3.0.2) - tzinfo (~> 0.3.29) - activesupport (3.2.13) - i18n (= 0.6.1) - multi_json (~> 1.0) - arel (3.0.2) - builder (3.0.4) - daemons (1.1.9) - i18n (0.6.1) - mail (2.6.1) - mime-types (>= 1.16, < 3) - mime-types (2.3) - multi_json (1.7.7) - mysql2 (0.3.11) - rufus-scheduler (2.0.19) - tzinfo (>= 0.3.23) - serialport (1.1.0) - state_pattern (2.0.1) - tzinfo (0.3.37) + activemodel (5.1.5) + activesupport (= 5.1.5) + activerecord (5.1.5) + activemodel (= 5.1.5) + activesupport (= 5.1.5) + arel (~> 8.0) + activesupport (5.1.5) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (~> 0.7) + minitest (~> 5.1) + tzinfo (~> 1.1) + arel (8.0.0) + concurrent-ruby (1.0.5) + daemons (1.2.6) + et-orbi (1.0.9) + tzinfo + i18n (0.9.5) + concurrent-ruby (~> 1.0) + mail (2.7.0) + mini_mime (>= 0.1.1) + mini_mime (1.0.0) + minitest (5.11.3) + mysql2 (0.4.10) + rufus-scheduler (3.4.2) + et-orbi (~> 1.0) + serialport (1.3.1) + state_pattern (2.0.2) + thread_safe (0.3.6) + tzinfo (1.2.5) + thread_safe (~> 0.1) PLATFORMS ruby DEPENDENCIES - activerecord (= 3.2.13) + activerecord daemons mail mysql2 rufus-scheduler serialport state_pattern + +BUNDLED WITH + 1.16.1 diff --git a/config/database.yml b/config/database.yml index e2dbeb2..5e042af 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,5 +1,6 @@ -host: 'localhost' +host: 'smartmeter_db' adapter: 'mysql2' database: 'smartmeter' username: 'root' +password: 'rootme' pool: 5 diff --git a/db/migrate/001_creates_readings.rb b/db/migrate/001_creates_readings.rb index 03f2d26..9aeefb4 100644 --- a/db/migrate/001_creates_readings.rb +++ b/db/migrate/001_creates_readings.rb @@ -1,4 +1,4 @@ -class CreatesReadings < ActiveRecord::Migration +class CreatesReadings < ActiveRecord::Migration[4.2] def change create_table :readings do |t| t.float :total_kwh_consumed_high diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..23dd179 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3' +services: + db: + container_name: smartmeter_db + image: mysql + environment: + MYSQL_ROOT_PASSWORD: rootme + MYSQL_DATABASE: smartmeter + smartmeter: + container_name: smartmeter + build: . + command: 'ruby ./smartmeter.rb' + volumes: + - .:/usr/src/app + depends_on: + - db From e18a6262d780e066cd48511173134be632a62263 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 19 Mar 2018 17:37:12 +0100 Subject: [PATCH 038/113] Docker config update --- Dockerfile | 4 ++-- docker-compose.yml | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0cbd466..fd327f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM ruby:2.4.3 -ENV BUILD_PACKAGES="apt-utils build-essential curl less nodejs sudo wget zsh libmysqlclient-dev ruby-serialport libserialport-dev" +ENV BUILD_PACKAGES="apt-utils build-essential curl less nodejs sudo wget zsh libmysqlclient-dev libserialport-dev cron" # throw errors if Gemfile has been modified since Gemfile.lock RUN \ @@ -17,4 +17,4 @@ RUN \ COPY . . -CMD ["cd /usr/src/app && ruby smartmeter.rb"] +CMD ["ruby ./smartmeter.rb"] diff --git a/docker-compose.yml b/docker-compose.yml index 23dd179..1ce3989 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,14 +2,18 @@ version: '3' services: db: container_name: smartmeter_db + restart: unless-stopped image: mysql environment: MYSQL_ROOT_PASSWORD: rootme MYSQL_DATABASE: smartmeter smartmeter: container_name: smartmeter + restart: unless-stopped build: . command: 'ruby ./smartmeter.rb' + devices: + - "/dev/ttyUSB0:/dev/ttyUSB0" volumes: - .:/usr/src/app depends_on: From 88b53ec89ba21603fb8f6fc857c8d49daf9d9437 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 19 Mar 2018 18:05:27 +0100 Subject: [PATCH 039/113] Update ruby version and change crontab job --- .ruby-version | 2 +- etc/daily_mailer | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.ruby-version b/.ruby-version index 02f2617..b503b50 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-1.9.3-p484 +ruby-2.4.3 diff --git a/etc/daily_mailer b/etc/daily_mailer index 80d8900..34ff08e 100755 --- a/etc/daily_mailer +++ b/etc/daily_mailer @@ -1,7 +1,12 @@ +# +# Not used. We now call docker from crontab +# /usr/bin/docker run --network smartmeter_default --rm smartmeter_smartmeter ruby report_mailer.rb +# + #!/usr/bin/env bash # load rvm ruby -source /usr/local/rvm/environments/ruby-1.9.3-p484@smartmeter +source /usr/local/rvm/environments/ruby-2.4.1@smartmeter cd /home/pcog/smartmeter ruby report_mailer.rb From 712e62cbc477d6156665f95b89ea894c46e2ea5c Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 2 May 2018 13:42:00 +0200 Subject: [PATCH 040/113] New local network name --- app/helpers/InSyncState.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 9ebc52a..ce79db4 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -97,7 +97,7 @@ class InSyncState < StatePattern::State # Write to EmonHub begin - TCPSocket.open("raspberrypi.lan",5050){|s| + TCPSocket.open("raspberrypi.home.local",5050){|s| s.write(sprintf("8 %d %d\r\n", reading.current_kw_consumed*1000, reading.current_kw_produced*1000)) } rescue From 61f4a0ddc08f6f6f04045b565c8d895af0d428f7 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 9 Aug 2018 18:10:33 +0100 Subject: [PATCH 041/113] Fixed IP address --- app/helpers/InSyncState.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index ce79db4..aeda465 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -97,7 +97,7 @@ class InSyncState < StatePattern::State # Write to EmonHub begin - TCPSocket.open("raspberrypi.home.local",5050){|s| + TCPSocket.open("10.0.0.154",5050){|s| s.write(sprintf("8 %d %d\r\n", reading.current_kw_consumed*1000, reading.current_kw_produced*1000)) } rescue From 83364d13cfed4a9ac674a2784410610dc9a47ee3 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 9 Jan 2022 17:58:12 +0100 Subject: [PATCH 042/113] Cost calculations --- .ruby-version | 2 +- Dockerfile | 5 +- Gemfile.lock | 53 ++-- app/models/entsoe.rb | 35 +++ app/models/reading.rb | 256 ++++++++++++++++++ ar-no-rails.rb | 1 + .../002_add_created_at_index_to_readings.rb | 5 + 7 files changed, 328 insertions(+), 29 deletions(-) create mode 100644 app/models/entsoe.rb create mode 100644 db/migrate/002_add_created_at_index_to_readings.rb diff --git a/.ruby-version b/.ruby-version index b503b50..fa376ed 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-2.4.3 +ruby-2.7 diff --git a/Dockerfile b/Dockerfile index fd327f5..abd6ca0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -FROM ruby:2.4.3 +FROM ruby:2.7 -ENV BUILD_PACKAGES="apt-utils build-essential curl less nodejs sudo wget zsh libmysqlclient-dev libserialport-dev cron" +ENV BUILD_PACKAGES="apt-utils build-essential curl less nodejs sudo wget zsh libmariadb-dev libserialport-dev cron" # throw errors if Gemfile has been modified since Gemfile.lock RUN \ @@ -10,6 +10,7 @@ RUN \ WORKDIR /usr/src/app COPY Gemfile Gemfile.lock ./ + RUN \ apt-get update -qq && \ apt-get install -y $BUILD_PACKAGES && \ diff --git a/Gemfile.lock b/Gemfile.lock index d4ad127..c549dcc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,36 +1,37 @@ GEM remote: https://rubygems.org/ specs: - activemodel (5.1.5) - activesupport (= 5.1.5) - activerecord (5.1.5) - activemodel (= 5.1.5) - activesupport (= 5.1.5) - arel (~> 8.0) - activesupport (5.1.5) + activemodel (7.0.1) + activesupport (= 7.0.1) + activerecord (7.0.1) + activemodel (= 7.0.1) + activesupport (= 7.0.1) + activesupport (7.0.1) concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (~> 0.7) - minitest (~> 5.1) - tzinfo (~> 1.1) - arel (8.0.0) - concurrent-ruby (1.0.5) - daemons (1.2.6) - et-orbi (1.0.9) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + concurrent-ruby (1.1.9) + daemons (1.4.1) + et-orbi (1.2.6) tzinfo - i18n (0.9.5) + fugit (1.5.2) + et-orbi (~> 1.1, >= 1.1.8) + raabro (~> 1.4) + i18n (1.8.11) concurrent-ruby (~> 1.0) - mail (2.7.0) + mail (2.7.1) mini_mime (>= 0.1.1) - mini_mime (1.0.0) - minitest (5.11.3) - mysql2 (0.4.10) - rufus-scheduler (3.4.2) - et-orbi (~> 1.0) - serialport (1.3.1) + mini_mime (1.1.2) + minitest (5.15.0) + mysql2 (0.5.3) + raabro (1.4.0) + rufus-scheduler (3.8.0) + fugit (~> 1.1, >= 1.1.6) + serialport (1.3.2) state_pattern (2.0.2) - thread_safe (0.3.6) - tzinfo (1.2.5) - thread_safe (~> 0.1) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) PLATFORMS ruby @@ -45,4 +46,4 @@ DEPENDENCIES state_pattern BUNDLED WITH - 1.16.1 + 2.1.4 diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb new file mode 100644 index 0000000..6bad49b --- /dev/null +++ b/app/models/entsoe.rb @@ -0,0 +1,35 @@ +# +# Obtain energy prices from Entsoe (transparancy platform) +# See https://transparency.entsoe.eu/content/static_content/Static%20content/web%20api/Guide.html +# + +require 'open-uri' +require 'nokogiri' + +class Entsoe + + URL = 'https://transparency.entsoe.eu' + + def initialize(api_key) + @api_key = api_key + end + + def day_ahead_prices(date) + start_date = date.beginning_of_day + end_date = date.end_of_day + + # A44 - Document type => Price Document + # A01 - Process type => Day Ahead + # NL = '10YNL----------L' + + domain = '10YNL----------L' + url = URL + "/api?securityToken=%s&documentType=A44&processType=A01&in_Domain=%s&out_Domain=%s&periodStart=%s&periodEnd=%s" % [@api_key, domain, domain, start_date.iso8601, end_date.iso8601] + p url + end + + private + + def base_request(url) + doc = Nokogiri::XML(URI.open(url)) + end +end diff --git a/app/models/reading.rb b/app/models/reading.rb index b07260e..5b08fa0 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -1,3 +1,5 @@ +require 'open-uri' + class Reading < ActiveRecord::Base def eql_reading?(reading) @@ -49,6 +51,8 @@ class Reading < ActiveRecord::Base end end + + # # Class methods # @@ -62,6 +66,258 @@ class Reading < ActiveRecord::Base Reading.where("created_at > :begin AND created_at < :end", { :begin => date.to_date.beginning_of_day, :end => date.to_date.end_of_day}) end + EASY_ENERGY_TARIFFS = {} + + + # returns a hash with keys formatted "yyyy-mm-dd-hr" and values [usage, return] + # e.g. { 2021-12-16-06"=>[0.36058, 0.298] } + def easy_energy_tariffs(date) + p "Fetching EasyEnergy tariffs for %s" % date.strftime("%F-%H") + + # calculate offset (date is in UTC, and we want to have tariffs in Amsterdam zone) + zone = 'Amsterdam' + offset = DateTime.now.in_time_zone(zone).utc_offset + date = date.beginning_of_day.advance(seconds: offset) + + url = "https://mijn.easyenergy.com/nl/api/tariff/getapxtariffs?startTimestamp=%s&endTimestamp=%s&grouping=" % [date.strftime("%F %T"),date.advance(:hours => 24).strftime("%F %T")] + #p urlvandaag + json = JSON.load(open(url)) + # advancing with 1 hrs (to offset for something?) + json.map{|t| [DateTime.parse(t["Timestamp"]).strftime("%F-%H"), [t["TariffUsage"], t["TariffReturn"]]]}.to_h + end + + def add_tax(formatted_hour,usage_kwh,usage_kwh_cost,return_kwh, return_kwh_cost) + return nil if (usage_kwh.nil? || usage_kwh_cost.nil? || return_kwh.nil? || return_kwh_cost.nil?) + + year = Date.parse(formatted_hour).year + case year + when 2020 + usage_kwh * (usage_kwh_cost + 0.11822 + 0.03303) - return_kwh * (return_kwh_cost + 0.11822 + 0.03303) + when 2021 + # see https://www.vastelastenbond.nl/blog/overzicht-energiebelasting-en-ode-2021-2022-en-je-energierekening-2021/ + usage_kwh * (usage_kwh_cost + 0.11408 + 0.03630) - return_kwh * (return_kwh_cost + 0.11408 + 0.03630) + when 2022 + usage_kwh * (usage_kwh_cost + 0.04452 + 0.03691) - return_kwh * (return_kwh_cost + 0.04452 + 0.03691) + end + end + + def easy_energy_high_low(date) + hour_start = date.beginning_of_day + day_end = hour_start.advance(days: 1) + max_rate = 0.0 + min_rate = 100.0 + min_hour = nil + max_hour = nil + while(hour_start < day_end) do + easy_usage_rate, easy_return_rate = easy_energy_rate(hour_start.strftime("%F-%H")) + unless easy_usage_rate.nil? + # determine max + if easy_usage_rate > max_rate + max_rate = easy_usage_rate + max_hour = hour_start.strftime("%F-%H") + end + # determine min + if easy_usage_rate < min_rate + min_rate = easy_usage_rate + min_hour = hour_start.strftime("%F-%H") + end + end + hour_start = hour_start.advance(:hours => 1) + end + return min_hour,max_hour + end + + def easy_energy_rate(formatted_hour) + unless EASY_ENERGY_TARIFFS.key?(formatted_hour) + EASY_ENERGY_TARIFFS.merge!(easy_energy_tariffs(Date.parse(formatted_hour))) + end + EASY_ENERGY_TARIFFS[formatted_hour] + end + + def easy_energy_cost(formatted_hour, usage_kwh, return_kwh) + return nil if (usage_kwh.nil? || return_kwh.nil?) + + usage_kwh_cost, return_kwh_cost = easy_energy_rate(formatted_hour) + add_tax(formatted_hour, usage_kwh, usage_kwh_cost, return_kwh, return_kwh_cost) + end + + def oxxio_rate(formatted_hour, high_tariff) + year = Date.parse(formatted_hour).year + case year + when 2020 + high_tariff ? 0.07865 : 0.06215 + when 2021 + high_tariff ? 0.06782 : 0.05259 + when 2022 + high_tariff ? 0.23665 : 0.19408 + end + end + + def oxxio_energy_cost(formatted_hour, normaal_kwh, dal_kwh, year_shift=0) + return nil if (normaal_kwh.nil? || dal_kwh.nil?) + year = Date.parse(formatted_hour).year+year_shift + case year + when 2020 + normaal_kwh * (0.07865 + 0.11822 + 0.03303) + dal_kwh * (0.06215 + 0.11822 + 0.03303) + when 2021 + normaal_kwh * (0.06782 + 0.11408 + 0.03630) + dal_kwh * (0.05259 + 0.11408 + 0.03630) + when 2022 + normaal_kwh * (0.23665 + 0.04452 + 0.03691) + dal_kwh * (0.19408 + 0.04452 + 0.03691) + end + end + + + UNKNOWN_READING = { :total_kwh_consumed_high => nil, :total_kwh_consumed_low => nil, :total_kwh_produced_high => nil, :total_kwh_produced_low => nil, :total_m3_gas_consumed => nil} + + def format_cost(cost) + cost ? "EUR %0.03f" % cost : "EUR ?" + end + + @battery_kwh = 0.0 + BATTERY_MAX_KWH = 10.0 + + def charge_battery(kwh) + return 0.0 if kwh.nil? + if @battery_kwh + kwh <= BATTERY_MAX_KWH + @battery_kwh += kwh.to_f + p "Battery is now at %s kwh" % @battery_kwh + return kwh + else + old_kwh = @battery_kwh + @battery_kwh = BATTERY_MAX_KWH + p "Battery is now at %s kwh" % @battery_kwh + return (BATTERY_MAX_KWH-old_kwh) + end + end + + def discharge_battery(kwh) + return 0.0 if kwh.nil? + if @battery_kwh > kwh.to_f + @battery_kwh -= kwh.to_f + p "Battery is now at %s kwh" % @battery_kwh + return kwh + else + old_kwh = @battery_kwh + @battery_kwh = 0.0 + p "Battery is now at %s kwh" % @battery_kwh + return old_kwh + end + end + + MAX_CHARGE_KWH = 7.0 + + def hours(date, year_shift=0) + hour_start = date.beginning_of_day + day_end = hour_start.advance(days: 1) + result = [] + low_hour, high_hour = easy_energy_high_low(date) + low_usage_rate, low_return_rate = easy_energy_rate(low_hour) + + while(hour_start < day_end) do + hour_end = hour_start.end_of_hour + formatted_hour = hour_start.strftime("%F-%H") + + #p "Fetching meter readings between %s and %s" % [hour_start,hour_end] + hour_readings = Reading.where("created_at > :begin AND created_at < :end", {:begin => hour_start, :end => hour_end}) + hour_diff = hour_readings.last ? hour_readings.last.diff(hour_readings.first) : UNKNOWN_READING + # calculate cost of this hour + usage_kwh = hour_diff[:total_kwh_consumed_high] + hour_diff[:total_kwh_consumed_low] rescue nil + return_kwh = hour_diff[:total_kwh_produced_high] + hour_diff[:total_kwh_produced_low] rescue nil + easy_cost = easy_energy_cost(formatted_hour, usage_kwh, return_kwh) # without battery use + + if !usage_kwh.nil? + # charge battery with return_kwh + return_kwh -= charge_battery(return_kwh) + + if low_hour.eql?(formatted_hour) + # charge battery during lowest hour + usage_kwh += charge_battery(MAX_CHARGE_KWH) + else + easy_usage_rate, easy_return_rate = easy_energy_rate(formatted_hour) + # if rate > charge_rate + 0.05 || more than 5.0 kwh then discharge_battery + if @battery_kwh > 5.0 || (easy_usage_rate.to_f > (low_usage_rate.to_f + 0.05)) + usage_kwh -= discharge_battery(usage_kwh) + end + end + end + easy_cost_with_battery = easy_energy_cost(formatted_hour, usage_kwh, return_kwh) # with battery use + + normal_usage_kwh = hour_diff[:total_kwh_consumed_low] - hour_diff[:total_kwh_produced_low] rescue nil + low_usage_kwh = hour_diff[:total_kwh_consumed_high] - hour_diff[:total_kwh_produced_high] rescue nil + oxxio_cost = oxxio_energy_cost(formatted_hour,normal_usage_kwh, low_usage_kwh, year_shift) + oxxio_rate = oxxio_rate(formatted_hour, (hour_readings.first.high_tarif rescue true)) + + one_hour = [formatted_hour, + hour_diff[:total_kwh_consumed_high], + hour_diff[:total_kwh_consumed_low], + hour_diff[:total_kwh_produced_high], + hour_diff[:total_kwh_produced_low], + usage_kwh, + return_kwh, + @battery_kwh, + easy_usage_rate, + easy_return_rate, + easy_cost_with_battery, + easy_cost, + oxxio_rate, + oxxio_cost] + + p "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s" % (one_hour[0..7] + one_hour[8..13].map{|c| format_cost(c)}) + result << one_hour + + hour_start = hour_start.advance(:hours => 1) + end + result + end + + def summarize_hours(hours) + usage_kwh = hours.map{|e| (e[1] && e[2]) ? (e[1] + e[2]) : nil}.compact.sum + return_kwh = hours.map{|e| (e[3] && e[4]) ? (e[3] + e[4]) : nil}.compact.sum + usage_kwh_with_battery = hours.map{|e| e[5]}.compact.sum + return_kwh_with_battery = hours.map{|e| e[6]}.compact.sum + easy_cost_with_battery = hours.map{|e| e[10]}.compact.sum + easy_cost = hours.map{|e| e[11]}.compact.sum + oxxio_cost = hours.map{|e| e[13]}.compact.sum + + return usage_kwh, return_kwh, usage_kwh_with_battery, return_kwh_with_battery, format_cost(easy_cost_with_battery), format_cost(easy_cost), format_cost(oxxio_cost) + end + + def hours_in_month(date, year_shift=0) + day = date.beginning_of_month + last_day = date.end_of_month + all_hours = [] + while (day <= last_day) + all_hours += hours(day, year_shift) + day = day.advance(:days => 1) + end + all_hours + end + + # heel2021 = hours_in(Date.parse("2021-01-01"), Date.parse("2021-12-31"), 1) + + def hours_in(from,to, year_shift=0) + day = from.to_date + last_day = to.to_date + all_hours = [] + while (day <= last_day) + all_hours += hours(day, year_shift) + day = day.advance(:days => 1) + end + all_hours + end + + def write_csv(filename,hours) + CSV.open(filename, "wb") do |csv| + csv << ["hour", "kwh_consumed_low", "kwh_consumed_high", "kwh_produced_low", "kwh_produced_high", "easy_usage_rate", "easy_return_rate", "easy_cost", "oxxio_rate", "oxxio_cost"] + hours.each do |row| + csv << row + end + end + end + + + #### + def diff_on(date) readings_on = day(date) first = readings_on.first diff --git a/ar-no-rails.rb b/ar-no-rails.rb index 3ff54ec..60a853c 100644 --- a/ar-no-rails.rb +++ b/ar-no-rails.rb @@ -1,6 +1,7 @@ require "rubygems" require "bundler/setup" require "active_record" +require "open-uri" project_root = File.dirname(File.absolute_path(__FILE__)) Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} diff --git a/db/migrate/002_add_created_at_index_to_readings.rb b/db/migrate/002_add_created_at_index_to_readings.rb new file mode 100644 index 0000000..f527a6f --- /dev/null +++ b/db/migrate/002_add_created_at_index_to_readings.rb @@ -0,0 +1,5 @@ +class AddCreatedAtIndexToReadings < ActiveRecord::Migration[4.2] + def change + add_index :readings, :created_at + end +end From b67fbc21d1cabd0a8cc58529e3c010eebf959f99 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 9 Jan 2022 18:47:38 +0100 Subject: [PATCH 043/113] use fqdn --- app/helpers/InSyncState.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index aeda465..2c3d131 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -97,7 +97,7 @@ class InSyncState < StatePattern::State # Write to EmonHub begin - TCPSocket.open("10.0.0.154",5050){|s| + TCPSocket.open("printserver.home.local",5050){|s| s.write(sprintf("8 %d %d\r\n", reading.current_kw_consumed*1000, reading.current_kw_produced*1000)) } rescue From 6867f5260e93757f985e8c21b4570bd2ecc4cfe1 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 9 Jan 2022 18:49:26 +0100 Subject: [PATCH 044/113] configuration updates --- daemonize.rb | 3 ++- docker-compose.yml | 6 ++++-- test-serial.rb | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/daemonize.rb b/daemonize.rb index 7d63480..06ec7ce 100644 --- a/daemonize.rb +++ b/daemonize.rb @@ -1,7 +1,8 @@ #require 'rubygems' require 'daemons' -pwd = Dir.pwd +#pwd = Dir.pwd +pwd = '/home/pcog/smartmeter' Daemons.run_proc('smartmeter', {:dir_mode => :normal, :dir => pwd+"/pids"}) do Dir.chdir(pwd) exec "ruby smartmeter.rb" diff --git a/docker-compose.yml b/docker-compose.yml index 1ce3989..1f513b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,9 @@ services: db: container_name: smartmeter_db restart: unless-stopped - image: mysql + image: mysql + volumes: + - /home/pcog/smartmeter/data:/var/lib/mysql environment: MYSQL_ROOT_PASSWORD: rootme MYSQL_DATABASE: smartmeter @@ -13,7 +15,7 @@ services: build: . command: 'ruby ./smartmeter.rb' devices: - - "/dev/ttyUSB0:/dev/ttyUSB0" + - "/dev/ttyUSB1:/dev/ttyUSB0" volumes: - .:/usr/src/app depends_on: diff --git a/test-serial.rb b/test-serial.rb index 59bf644..5d6cc2a 100644 --- a/test-serial.rb +++ b/test-serial.rb @@ -17,7 +17,7 @@ ActiveRecord::Base.establish_connection(connection_details) if __FILE__ == $0 #params for serial port - port_str = "/dev/ttyUSB0" #may be different for you + port_str = "/dev/ttyUSB1" #may be different for you baud_rate = 9600 data_bits = 7 stop_bits = 1 From d371dd7492c78b7f82344085f4db81e128daed8c Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 9 Jan 2022 18:59:29 +0100 Subject: [PATCH 045/113] Adds nokogiri --- Gemfile | 1 + Gemfile.lock | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/Gemfile b/Gemfile index 737de4b..182522b 100644 --- a/Gemfile +++ b/Gemfile @@ -7,3 +7,4 @@ gem "state_pattern" gem 'rufus-scheduler' gem 'daemons' gem 'mail' +gem 'nokogiri' diff --git a/Gemfile.lock b/Gemfile.lock index c549dcc..940edce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -23,9 +23,14 @@ GEM mail (2.7.1) mini_mime (>= 0.1.1) mini_mime (1.1.2) + mini_portile2 (2.7.1) minitest (5.15.0) mysql2 (0.5.3) + nokogiri (1.13.0) + mini_portile2 (~> 2.7.0) + racc (~> 1.4) raabro (1.4.0) + racc (1.6.0) rufus-scheduler (3.8.0) fugit (~> 1.1, >= 1.1.6) serialport (1.3.2) @@ -41,6 +46,7 @@ DEPENDENCIES daemons mail mysql2 + nokogiri rufus-scheduler serialport state_pattern From 464a618dacc66e6a47d8a736aeaf31957ccb8933 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 9 Jan 2022 19:06:36 +0100 Subject: [PATCH 046/113] adds .dockerignore --- .dockerignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1292c31 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git +data From 6db550c11f463b440753f7fd42ad567bb25b2821 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 9 Jan 2022 19:32:45 +0100 Subject: [PATCH 047/113] use URI::open --- app/models/reading.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/reading.rb b/app/models/reading.rb index 5b08fa0..2651272 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -81,7 +81,7 @@ class Reading < ActiveRecord::Base url = "https://mijn.easyenergy.com/nl/api/tariff/getapxtariffs?startTimestamp=%s&endTimestamp=%s&grouping=" % [date.strftime("%F %T"),date.advance(:hours => 24).strftime("%F %T")] #p urlvandaag - json = JSON.load(open(url)) + json = JSON.load(URI::open(url)) # advancing with 1 hrs (to offset for something?) json.map{|t| [DateTime.parse(t["Timestamp"]).strftime("%F-%H"), [t["TariffUsage"], t["TariffReturn"]]]}.to_h end From feb26239f67e410fccf368fb2a60634e2975a957 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 9 Jan 2022 19:42:11 +0100 Subject: [PATCH 048/113] adds constants and class variables --- app/models/reading.rb | 46 ++++++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/app/models/reading.rb b/app/models/reading.rb index 2651272..066909f 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -1,7 +1,14 @@ require 'open-uri' +UNKNOWN_READING = { :total_kwh_consumed_high => nil, :total_kwh_consumed_low => nil, :total_kwh_produced_high => nil, :total_kwh_produced_low => nil, :total_m3_gas_consumed => nil} +EASY_ENERGY_TARIFFS = {} +BATTERY_MAX_KWH = 10.0 +MAX_CHARGE_KWH = 5.0 + class Reading < ActiveRecord::Base - + + @@battery_kwh = 0.0 + def eql_reading?(reading) self.total_kwh_consumed_high == reading.total_kwh_consumed_high && self.total_kwh_consumed_low == reading.total_kwh_consumed_low && @@ -65,10 +72,7 @@ class Reading < ActiveRecord::Base def day(date) Reading.where("created_at > :begin AND created_at < :end", { :begin => date.to_date.beginning_of_day, :end => date.to_date.end_of_day}) end - - EASY_ENERGY_TARIFFS = {} - - + # returns a hash with keys formatted "yyyy-mm-dd-hr" and values [usage, return] # e.g. { 2021-12-16-06"=>[0.36058, 0.298] } def easy_energy_tariffs(date) @@ -166,45 +170,37 @@ class Reading < ActiveRecord::Base end end - - UNKNOWN_READING = { :total_kwh_consumed_high => nil, :total_kwh_consumed_low => nil, :total_kwh_produced_high => nil, :total_kwh_produced_low => nil, :total_m3_gas_consumed => nil} - def format_cost(cost) cost ? "EUR %0.03f" % cost : "EUR ?" end - - @battery_kwh = 0.0 - BATTERY_MAX_KWH = 10.0 - + def charge_battery(kwh) return 0.0 if kwh.nil? - if @battery_kwh + kwh <= BATTERY_MAX_KWH - @battery_kwh += kwh.to_f - p "Battery is now at %s kwh" % @battery_kwh + if @@battery_kwh + kwh <= BATTERY_MAX_KWH + @@battery_kwh += kwh.to_f + p "Battery is now at %s kwh" % @@battery_kwh return kwh else old_kwh = @battery_kwh - @battery_kwh = BATTERY_MAX_KWH - p "Battery is now at %s kwh" % @battery_kwh + @@battery_kwh = BATTERY_MAX_KWH + p "Battery is now at %s kwh" % @@battery_kwh return (BATTERY_MAX_KWH-old_kwh) end end def discharge_battery(kwh) return 0.0 if kwh.nil? - if @battery_kwh > kwh.to_f - @battery_kwh -= kwh.to_f - p "Battery is now at %s kwh" % @battery_kwh + if @@battery_kwh > kwh.to_f + @@battery_kwh -= kwh.to_f + p "Battery is now at %s kwh" % @@battery_kwh return kwh else - old_kwh = @battery_kwh - @battery_kwh = 0.0 - p "Battery is now at %s kwh" % @battery_kwh + old_kwh = @@battery_kwh + @@battery_kwh = 0.0 + p "Battery is now at %s kwh" % @@battery_kwh return old_kwh end end - - MAX_CHARGE_KWH = 7.0 def hours(date, year_shift=0) hour_start = date.beginning_of_day From e6e5ac1791e19118f89090eccf24b06bdd61a67a Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 9 Jan 2022 19:46:30 +0100 Subject: [PATCH 049/113] adds constants and class variables 2 --- app/models/reading.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/reading.rb b/app/models/reading.rb index 066909f..230186e 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -181,7 +181,7 @@ class Reading < ActiveRecord::Base p "Battery is now at %s kwh" % @@battery_kwh return kwh else - old_kwh = @battery_kwh + old_kwh = @@battery_kwh @@battery_kwh = BATTERY_MAX_KWH p "Battery is now at %s kwh" % @@battery_kwh return (BATTERY_MAX_KWH-old_kwh) @@ -231,7 +231,7 @@ class Reading < ActiveRecord::Base else easy_usage_rate, easy_return_rate = easy_energy_rate(formatted_hour) # if rate > charge_rate + 0.05 || more than 5.0 kwh then discharge_battery - if @battery_kwh > 5.0 || (easy_usage_rate.to_f > (low_usage_rate.to_f + 0.05)) + if @@battery_kwh > 5.0 || (easy_usage_rate.to_f > (low_usage_rate.to_f + 0.05)) usage_kwh -= discharge_battery(usage_kwh) end end @@ -250,7 +250,7 @@ class Reading < ActiveRecord::Base hour_diff[:total_kwh_produced_low], usage_kwh, return_kwh, - @battery_kwh, + @@battery_kwh, easy_usage_rate, easy_return_rate, easy_cost_with_battery, From 8d43accec6f52fae5c0c6fb133f768d7d893c708 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 10 Jan 2022 16:55:12 +0100 Subject: [PATCH 050/113] Refactoring --- app/models/battery.rb | 37 ++++++ app/models/cost.rb | 253 ++++++++++++++++++++++++++++++++++++++++++ app/models/entsoe.rb | 71 ++++++++++-- app/models/reading.rb | 251 ++--------------------------------------- 4 files changed, 359 insertions(+), 253 deletions(-) create mode 100644 app/models/battery.rb create mode 100644 app/models/cost.rb diff --git a/app/models/battery.rb b/app/models/battery.rb new file mode 100644 index 0000000..c016c95 --- /dev/null +++ b/app/models/battery.rb @@ -0,0 +1,37 @@ +class Battery + + attr_accessor :battery_kwh, :battery_max_kwh + + def initialize(battery_capacity=10.0) + @battery_kwh = 0.0 + @battery_max_kwh = battery_capacity + end + + def charge(kwh) + return 0.0 if kwh.nil? + if battery_kwh + kwh <= battery_max_kwh + @battery_kwh += kwh.to_f + #p "Battery is now at %s kwh" % battery_kwh + return kwh + else + old_kwh = battery_kwh + battery_kwh = battery_max_kwh + #p "Battery is now at %s kwh" % battery_kwh + return (battery_max_kwh-old_kwh) + end + end + + def discharge(kwh) + return 0.0 if kwh.nil? + if battery_kwh > kwh.to_f + @battery_kwh -= kwh.to_f + #p "Battery is now at %s kwh" % battery_kwh + return kwh + else + old_kwh = battery_kwh + battery_kwh = 0.0 + #p "Battery is now at %s kwh" % battery_kwh + return old_kwh + end + end +end diff --git a/app/models/cost.rb b/app/models/cost.rb new file mode 100644 index 0000000..6909ebd --- /dev/null +++ b/app/models/cost.rb @@ -0,0 +1,253 @@ +require 'open-uri' +EASY_ENERGY_TARIFFS = {} + +class Cost + + + attr_reader :battery, :max_charge_kwh, :entsoe + + def initialize(battery_capacity=10.0, max_charge=5.0) + @entsoe = Entsoe.new + @max_charge_kwh = max_charge + @battery = Battery.new(battery_capacity) + end + + def format_cost(cost) + cost ? "EUR %0.03f" % cost : "EUR ?" + end + + def add_tax(formatted_hour,usage_kwh,usage_kwh_cost,return_kwh, return_kwh_cost) + return nil if (usage_kwh.nil? || usage_kwh_cost.nil? || return_kwh.nil? || return_kwh_cost.nil?) + + year = Date.parse(formatted_hour).year + case year + when 2020 + usage_kwh * (usage_kwh_cost + 0.11822 + 0.03303) - return_kwh * (return_kwh_cost + 0.11822 + 0.03303) + when 2021 + # see https://www.vastelastenbond.nl/blog/overzicht-energiebelasting-en-ode-2021-2022-en-je-energierekening-2021/ + usage_kwh * (usage_kwh_cost + 0.11408 + 0.03630) - return_kwh * (return_kwh_cost + 0.11408 + 0.03630) + when 2022 + usage_kwh * (usage_kwh_cost + 0.04452 + 0.03691) - return_kwh * (return_kwh_cost + 0.04452 + 0.03691) + end + end + + ###################################################### + # Easy Energy API (proprietary) - better to use entsoe + ###################################################### + + # returns a hash with keys formatted "yyyy-mm-dd-hr" and values [usage, return] + # e.g. { 2021-12-16-06"=>[0.36058, 0.298] } + def easy_energy_tariffs(date) + p "Fetching EasyEnergy tariffs for %s" % date.strftime("%F-%H") + + # calculate offset (date is in UTC, and we want to have tariffs in Amsterdam zone) + zone = 'Amsterdam' + offset = DateTime.now.in_time_zone(zone).utc_offset + date = date.beginning_of_day.advance(seconds: offset) + + url = "https://mijn.easyenergy.com/nl/api/tariff/getapxtariffs?startTimestamp=%s&endTimestamp=%s&grouping=" % [date.strftime("%F %T"),date.advance(:hours => 24).strftime("%F %T")] + #p urlvandaag + json = JSON.load(URI::open(url)) + # advancing with 1 hrs (to offset for something?) + json.map{|t| [DateTime.parse(t["Timestamp"]).strftime("%F-%H"), [t["TariffUsage"], t["TariffReturn"]]]}.to_h + end + + def easy_energy_rate(formatted_hour) + unless EASY_ENERGY_TARIFFS.key?(formatted_hour) + EASY_ENERGY_TARIFFS.merge!(easy_energy_tariffs(Date.parse(formatted_hour))) + end + EASY_ENERGY_TARIFFS[formatted_hour] + end + + def easy_energy_cost(formatted_hour, usage_kwh, return_kwh) + return nil if (usage_kwh.nil? || return_kwh.nil?) + + usage_kwh_cost, return_kwh_cost = easy_energy_rate(formatted_hour) + add_tax(formatted_hour, usage_kwh, usage_kwh_cost, return_kwh, return_kwh_cost) + end + + def easy_energy_high_low(date) + hour_start = date.beginning_of_day + day_end = hour_start.advance(days: 1) + max_rate = 0.0 + min_rate = 100.0 + min_hour = nil + max_hour = nil + while(hour_start < day_end) do + easy_usage_rate, easy_return_rate = easy_energy_rate(hour_start.strftime("%F-%H")) + unless easy_usage_rate.nil? + # determine max + if easy_usage_rate > max_rate + max_rate = easy_usage_rate + max_hour = hour_start.strftime("%F-%H") + end + # determine min + if easy_usage_rate < min_rate + min_rate = easy_usage_rate + min_hour = hour_start.strftime("%F-%H") + end + end + hour_start = hour_start.advance(:hours => 1) + end + return min_hour,max_hour + end + + ###################################################### + # Oxxio rates and cost + ###################################################### + + def oxxio_rate(formatted_hour, high_tariff) + year = Date.parse(formatted_hour).year + case year + when 2020 + high_tariff ? 0.07865 : 0.06215 + when 2021 + high_tariff ? 0.06782 : 0.05259 + when 2022 + high_tariff ? 0.23665 : 0.19408 + end + end + + def oxxio_energy_cost(formatted_hour, normaal_kwh, dal_kwh, year_shift=0) + return nil if (normaal_kwh.nil? || dal_kwh.nil?) + year = Date.parse(formatted_hour).year+year_shift + case year + when 2020 + normaal_kwh * (0.07865 + 0.11822 + 0.03303) + dal_kwh * (0.06215 + 0.11822 + 0.03303) + when 2021 + normaal_kwh * (0.06782 + 0.11408 + 0.03630) + dal_kwh * (0.05259 + 0.11408 + 0.03630) + when 2022 + normaal_kwh * (0.23665 + 0.04452 + 0.03691) + dal_kwh * (0.19408 + 0.04452 + 0.03691) + end + end + + # + # Entsoe + # + + def entsoe_energy_cost(formatted_hour, usage_kwh, return_kwh) + return nil if (usage_kwh.nil? || return_kwh.nil?) + + usage_kwh_cost = return_kwh_cost = entsoe.price_at(formatted_hour) + add_tax(formatted_hour, usage_kwh, usage_kwh_cost, return_kwh, return_kwh_cost) + end + + ###################################################### + # Calculate the per_hour usage and costs + ###################################################### + + def hours(date, year_shift=0) + hour_start = date.beginning_of_day + day_end = hour_start.advance(days: 1) + result = [] + lowest_hour, highest_hour,high_hours = entsoe.high_low_hours(date) + + while(hour_start < day_end) do + hour_end = hour_start.end_of_hour + formatted_hour = hour_start.strftime("%F-%H") + + #p "Fetching meter readings between %s and %s" % [hour_start,hour_end] + hour_readings = Reading.where("created_at > :begin AND created_at < :end", {:begin => hour_start, :end => hour_end}) + hour_diff = hour_readings.last ? hour_readings.last.diff(hour_readings.first) : UNKNOWN_READING + # calculate cost of this hour + usage_kwh = hour_diff[:total_kwh_consumed_high] + hour_diff[:total_kwh_consumed_low] rescue nil + return_kwh = hour_diff[:total_kwh_produced_high] + hour_diff[:total_kwh_produced_low] rescue nil + entsoe_cost = entsoe_energy_cost(formatted_hour, usage_kwh, return_kwh) # without battery use + + # + # Make battery work + # + if !usage_kwh.nil? + # charge battery with return_kwh + return_kwh -= battery.charge(return_kwh) + + if low_hour.eql?(formatted_hour) + # charge battery during lowest hour + usage_kwh += battery.charge(max_charge_kwh) + else + # if during expensive hours || more than kwh then discharge_battery + if (battery.battery_kwh > max_charge_kwh) || high_hours.include?(formatted_hour) + usage_kwh -= battery.discharge(usage_kwh) + end + end + end + entsoe_cost_with_battery = entsoe_energy_cost(formatted_hour, usage_kwh, return_kwh) # with battery use + + # + # end of battery work + # + + normal_usage_kwh = hour_diff[:total_kwh_consumed_low] - hour_diff[:total_kwh_produced_low] rescue nil + low_usage_kwh = hour_diff[:total_kwh_consumed_high] - hour_diff[:total_kwh_produced_high] rescue nil + oxxio_cost = oxxio_energy_cost(formatted_hour,normal_usage_kwh, low_usage_kwh, year_shift) + oxxio_rate = oxxio_rate(formatted_hour, (hour_readings.first.high_tarif rescue true)) + + one_hour = [formatted_hour, + hour_diff[:total_kwh_consumed_high], + hour_diff[:total_kwh_consumed_low], + hour_diff[:total_kwh_produced_high], + hour_diff[:total_kwh_produced_low], + usage_kwh, + return_kwh, + battery.battery_kwh, + entsoe.price_at(formatted_hour), # entsoe rate + entsoe_cost_with_battery, + entsoe_cost, + oxxio_rate, + oxxio_cost] + + p "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s" % (one_hour[0..7] + one_hour[8..12].map{|c| format_cost(c)}) + result << one_hour + + hour_start = hour_start.advance(:hours => 1) + end + result + end + + def summarize_hours(hours) + usage_kwh = hours.map{|e| (e[1] && e[2]) ? (e[1] + e[2]) : nil}.compact.sum + return_kwh = hours.map{|e| (e[3] && e[4]) ? (e[3] + e[4]) : nil}.compact.sum + usage_kwh_with_battery = hours.map{|e| e[5]}.compact.sum + return_kwh_with_battery = hours.map{|e| e[6]}.compact.sum + entsoe_cost_with_battery = hours.map{|e| e[9]}.compact.sum + entsoe_cost = hours.map{|e| e[10]}.compact.sum + oxxio_cost = hours.map{|e| e[12]}.compact.sum + + return usage_kwh, return_kwh, usage_kwh_with_battery, return_kwh_with_battery, format_cost(entsoe_cost_with_battery), format_cost(entsoe_cost), format_cost(oxxio_cost) + end + + def hours_in_month(date, year_shift=0) + day = date.beginning_of_month + last_day = date.end_of_month + all_hours = [] + while (day <= last_day) + all_hours += hours(day, year_shift) + day = day.advance(:days => 1) + end + all_hours + end + + # heel2021 = hours_in(Date.parse("2021-01-01"), Date.parse("2021-12-31"), 1) + + def hours_in(from,to, year_shift=0) + day = from.to_date + last_day = to.to_date + all_hours = [] + while (day <= last_day) + all_hours += hours(day, year_shift) + day = day.advance(:days => 1) + end + all_hours + end + + def write_csv(filename,hours) + CSV.open(filename, "wb") do |csv| + csv << ["hour", "kwh_consumed_low", "kwh_consumed_high", "kwh_produced_low", "kwh_produced_high", "easy_usage_rate", "easy_return_rate", "easy_cost", "oxxio_rate", "oxxio_cost"] + hours.each do |row| + csv << row + end + end + end + + +end diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index 6bad49b..d5f5092 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -7,29 +7,82 @@ require 'open-uri' require 'nokogiri' class Entsoe - - URL = 'https://transparency.entsoe.eu' - def initialize(api_key) + URL = 'https://transparency.entsoe.eu' + + attr_reader :storage_cost + + def initialize(api_key = "c2287e07-0c26-4950-b430-22b7f75a8f2e") @api_key = api_key + @kwh_prices = {} + @storage_cost = 0.05 # how much does it cost to store 1 kwh in battery end - def day_ahead_prices(date) + def price_at(formatted_hour) + unless @kwh_prices.key?(formatted_hour) + @kwh_prices.merge!(query_day_ahead_prices(Date.parse(formatted_hour))) + end + @kwh_prices[formatted_hour] + end + + def prices_at(date) + hour_start = date.beginning_of_day + day_end = hour_start.advance(days: 1) + result = {} + while(hour_start < day_end) do + formatted_hour = hour_start.strftime("%F-%H") + result.merge!({ formatted_hour => price_at(formatted_hour)}) + hour_start = hour_start.advance(:hours => 1) + end + result + end + + def high_low_hours(date) + sorted_prices = prices_at(date).to_a.sort_by(&:last) # sort according to price + lowest_hour = sorted_prices.first[0] + highest_hour = sorted_prices.last[0] + # calculate hours where rate > charge_rate + storage_cost (typically 0.05) + charge_rate = sorted_prices.first[1] # assume we charge at lowest hour + high_hours = sorted_prices.select{|p| p[1] > charge_rate + storage_cost}.to_h.keys + + return lowest_hour,highest_hour,high_hours + end + + def query_day_ahead_prices(date) start_date = date.beginning_of_day end_date = date.end_of_day # A44 - Document type => Price Document - # A01 - Process type => Day Ahead # NL = '10YNL----------L' domain = '10YNL----------L' - url = URL + "/api?securityToken=%s&documentType=A44&processType=A01&in_Domain=%s&out_Domain=%s&periodStart=%s&periodEnd=%s" % [@api_key, domain, domain, start_date.iso8601, end_date.iso8601] - p url + url = URL + "/api?securityToken=%s&documentType=A44&in_Domain=%s&out_Domain=%s&periodStart=%s&periodEnd=%s" % [@api_key, domain, domain, start_date.strftime("%Y%m%d%H%M"), end_date.strftime("%Y%m%d%H00")] + #p url + + base_request(date, url) end private - def base_request(url) + # get position and amount from XML snippet + # + # 1 + # 196.23 + # + # + # convert price to EUR per kwh, including VAT (21%) + # + def parse_point(xml) + return xml.children[1].text.to_i, ((xml.children[3].text.to_f/1000)*1.21).round(5) + end + + def base_request(date, url) + formatted_date = date.strftime("%F") + doc = Nokogiri::XML(URI.open(url)) - end + prices = doc.xpath('.//xmlns:Point').map{|p| parse_point(p)} + + #returns a hash with keys formatted "yyyy-mm-dd-hr" and values price (per kwh) + prices.map{|p| ["%s-%02d" % [formatted_date,(p[0]-1)], p[1]]}.to_h + end end diff --git a/app/models/reading.rb b/app/models/reading.rb index 230186e..548ed55 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -1,14 +1,9 @@ -require 'open-uri' UNKNOWN_READING = { :total_kwh_consumed_high => nil, :total_kwh_consumed_low => nil, :total_kwh_produced_high => nil, :total_kwh_produced_low => nil, :total_m3_gas_consumed => nil} -EASY_ENERGY_TARIFFS = {} -BATTERY_MAX_KWH = 10.0 -MAX_CHARGE_KWH = 5.0 + class Reading < ActiveRecord::Base - - @@battery_kwh = 0.0 - + def eql_reading?(reading) self.total_kwh_consumed_high == reading.total_kwh_consumed_high && self.total_kwh_consumed_low == reading.total_kwh_consumed_low && @@ -72,247 +67,15 @@ class Reading < ActiveRecord::Base def day(date) Reading.where("created_at > :begin AND created_at < :end", { :begin => date.to_date.beginning_of_day, :end => date.to_date.end_of_day}) end - - # returns a hash with keys formatted "yyyy-mm-dd-hr" and values [usage, return] - # e.g. { 2021-12-16-06"=>[0.36058, 0.298] } - def easy_energy_tariffs(date) - p "Fetching EasyEnergy tariffs for %s" % date.strftime("%F-%H") - - # calculate offset (date is in UTC, and we want to have tariffs in Amsterdam zone) - zone = 'Amsterdam' - offset = DateTime.now.in_time_zone(zone).utc_offset - date = date.beginning_of_day.advance(seconds: offset) - - url = "https://mijn.easyenergy.com/nl/api/tariff/getapxtariffs?startTimestamp=%s&endTimestamp=%s&grouping=" % [date.strftime("%F %T"),date.advance(:hours => 24).strftime("%F %T")] - #p urlvandaag - json = JSON.load(URI::open(url)) - # advancing with 1 hrs (to offset for something?) - json.map{|t| [DateTime.parse(t["Timestamp"]).strftime("%F-%H"), [t["TariffUsage"], t["TariffReturn"]]]}.to_h + + def max_charge_kwh=(kwh) + @@max_charge_kwh = kwh end - def add_tax(formatted_hour,usage_kwh,usage_kwh_cost,return_kwh, return_kwh_cost) - return nil if (usage_kwh.nil? || usage_kwh_cost.nil? || return_kwh.nil? || return_kwh_cost.nil?) - - year = Date.parse(formatted_hour).year - case year - when 2020 - usage_kwh * (usage_kwh_cost + 0.11822 + 0.03303) - return_kwh * (return_kwh_cost + 0.11822 + 0.03303) - when 2021 - # see https://www.vastelastenbond.nl/blog/overzicht-energiebelasting-en-ode-2021-2022-en-je-energierekening-2021/ - usage_kwh * (usage_kwh_cost + 0.11408 + 0.03630) - return_kwh * (return_kwh_cost + 0.11408 + 0.03630) - when 2022 - usage_kwh * (usage_kwh_cost + 0.04452 + 0.03691) - return_kwh * (return_kwh_cost + 0.04452 + 0.03691) - end + def max_charge_kwh + @@max_charge_kwh end - def easy_energy_high_low(date) - hour_start = date.beginning_of_day - day_end = hour_start.advance(days: 1) - max_rate = 0.0 - min_rate = 100.0 - min_hour = nil - max_hour = nil - while(hour_start < day_end) do - easy_usage_rate, easy_return_rate = easy_energy_rate(hour_start.strftime("%F-%H")) - unless easy_usage_rate.nil? - # determine max - if easy_usage_rate > max_rate - max_rate = easy_usage_rate - max_hour = hour_start.strftime("%F-%H") - end - # determine min - if easy_usage_rate < min_rate - min_rate = easy_usage_rate - min_hour = hour_start.strftime("%F-%H") - end - end - hour_start = hour_start.advance(:hours => 1) - end - return min_hour,max_hour - end - - def easy_energy_rate(formatted_hour) - unless EASY_ENERGY_TARIFFS.key?(formatted_hour) - EASY_ENERGY_TARIFFS.merge!(easy_energy_tariffs(Date.parse(formatted_hour))) - end - EASY_ENERGY_TARIFFS[formatted_hour] - end - - def easy_energy_cost(formatted_hour, usage_kwh, return_kwh) - return nil if (usage_kwh.nil? || return_kwh.nil?) - - usage_kwh_cost, return_kwh_cost = easy_energy_rate(formatted_hour) - add_tax(formatted_hour, usage_kwh, usage_kwh_cost, return_kwh, return_kwh_cost) - end - - def oxxio_rate(formatted_hour, high_tariff) - year = Date.parse(formatted_hour).year - case year - when 2020 - high_tariff ? 0.07865 : 0.06215 - when 2021 - high_tariff ? 0.06782 : 0.05259 - when 2022 - high_tariff ? 0.23665 : 0.19408 - end - end - - def oxxio_energy_cost(formatted_hour, normaal_kwh, dal_kwh, year_shift=0) - return nil if (normaal_kwh.nil? || dal_kwh.nil?) - year = Date.parse(formatted_hour).year+year_shift - case year - when 2020 - normaal_kwh * (0.07865 + 0.11822 + 0.03303) + dal_kwh * (0.06215 + 0.11822 + 0.03303) - when 2021 - normaal_kwh * (0.06782 + 0.11408 + 0.03630) + dal_kwh * (0.05259 + 0.11408 + 0.03630) - when 2022 - normaal_kwh * (0.23665 + 0.04452 + 0.03691) + dal_kwh * (0.19408 + 0.04452 + 0.03691) - end - end - - def format_cost(cost) - cost ? "EUR %0.03f" % cost : "EUR ?" - end - - def charge_battery(kwh) - return 0.0 if kwh.nil? - if @@battery_kwh + kwh <= BATTERY_MAX_KWH - @@battery_kwh += kwh.to_f - p "Battery is now at %s kwh" % @@battery_kwh - return kwh - else - old_kwh = @@battery_kwh - @@battery_kwh = BATTERY_MAX_KWH - p "Battery is now at %s kwh" % @@battery_kwh - return (BATTERY_MAX_KWH-old_kwh) - end - end - - def discharge_battery(kwh) - return 0.0 if kwh.nil? - if @@battery_kwh > kwh.to_f - @@battery_kwh -= kwh.to_f - p "Battery is now at %s kwh" % @@battery_kwh - return kwh - else - old_kwh = @@battery_kwh - @@battery_kwh = 0.0 - p "Battery is now at %s kwh" % @@battery_kwh - return old_kwh - end - end - - def hours(date, year_shift=0) - hour_start = date.beginning_of_day - day_end = hour_start.advance(days: 1) - result = [] - low_hour, high_hour = easy_energy_high_low(date) - low_usage_rate, low_return_rate = easy_energy_rate(low_hour) - - while(hour_start < day_end) do - hour_end = hour_start.end_of_hour - formatted_hour = hour_start.strftime("%F-%H") - - #p "Fetching meter readings between %s and %s" % [hour_start,hour_end] - hour_readings = Reading.where("created_at > :begin AND created_at < :end", {:begin => hour_start, :end => hour_end}) - hour_diff = hour_readings.last ? hour_readings.last.diff(hour_readings.first) : UNKNOWN_READING - # calculate cost of this hour - usage_kwh = hour_diff[:total_kwh_consumed_high] + hour_diff[:total_kwh_consumed_low] rescue nil - return_kwh = hour_diff[:total_kwh_produced_high] + hour_diff[:total_kwh_produced_low] rescue nil - easy_cost = easy_energy_cost(formatted_hour, usage_kwh, return_kwh) # without battery use - - if !usage_kwh.nil? - # charge battery with return_kwh - return_kwh -= charge_battery(return_kwh) - - if low_hour.eql?(formatted_hour) - # charge battery during lowest hour - usage_kwh += charge_battery(MAX_CHARGE_KWH) - else - easy_usage_rate, easy_return_rate = easy_energy_rate(formatted_hour) - # if rate > charge_rate + 0.05 || more than 5.0 kwh then discharge_battery - if @@battery_kwh > 5.0 || (easy_usage_rate.to_f > (low_usage_rate.to_f + 0.05)) - usage_kwh -= discharge_battery(usage_kwh) - end - end - end - easy_cost_with_battery = easy_energy_cost(formatted_hour, usage_kwh, return_kwh) # with battery use - - normal_usage_kwh = hour_diff[:total_kwh_consumed_low] - hour_diff[:total_kwh_produced_low] rescue nil - low_usage_kwh = hour_diff[:total_kwh_consumed_high] - hour_diff[:total_kwh_produced_high] rescue nil - oxxio_cost = oxxio_energy_cost(formatted_hour,normal_usage_kwh, low_usage_kwh, year_shift) - oxxio_rate = oxxio_rate(formatted_hour, (hour_readings.first.high_tarif rescue true)) - - one_hour = [formatted_hour, - hour_diff[:total_kwh_consumed_high], - hour_diff[:total_kwh_consumed_low], - hour_diff[:total_kwh_produced_high], - hour_diff[:total_kwh_produced_low], - usage_kwh, - return_kwh, - @@battery_kwh, - easy_usage_rate, - easy_return_rate, - easy_cost_with_battery, - easy_cost, - oxxio_rate, - oxxio_cost] - - p "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s" % (one_hour[0..7] + one_hour[8..13].map{|c| format_cost(c)}) - result << one_hour - - hour_start = hour_start.advance(:hours => 1) - end - result - end - - def summarize_hours(hours) - usage_kwh = hours.map{|e| (e[1] && e[2]) ? (e[1] + e[2]) : nil}.compact.sum - return_kwh = hours.map{|e| (e[3] && e[4]) ? (e[3] + e[4]) : nil}.compact.sum - usage_kwh_with_battery = hours.map{|e| e[5]}.compact.sum - return_kwh_with_battery = hours.map{|e| e[6]}.compact.sum - easy_cost_with_battery = hours.map{|e| e[10]}.compact.sum - easy_cost = hours.map{|e| e[11]}.compact.sum - oxxio_cost = hours.map{|e| e[13]}.compact.sum - - return usage_kwh, return_kwh, usage_kwh_with_battery, return_kwh_with_battery, format_cost(easy_cost_with_battery), format_cost(easy_cost), format_cost(oxxio_cost) - end - - def hours_in_month(date, year_shift=0) - day = date.beginning_of_month - last_day = date.end_of_month - all_hours = [] - while (day <= last_day) - all_hours += hours(day, year_shift) - day = day.advance(:days => 1) - end - all_hours - end - - # heel2021 = hours_in(Date.parse("2021-01-01"), Date.parse("2021-12-31"), 1) - - def hours_in(from,to, year_shift=0) - day = from.to_date - last_day = to.to_date - all_hours = [] - while (day <= last_day) - all_hours += hours(day, year_shift) - day = day.advance(:days => 1) - end - all_hours - end - - def write_csv(filename,hours) - CSV.open(filename, "wb") do |csv| - csv << ["hour", "kwh_consumed_low", "kwh_consumed_high", "kwh_produced_low", "kwh_produced_high", "easy_usage_rate", "easy_return_rate", "easy_cost", "oxxio_rate", "oxxio_cost"] - hours.each do |row| - csv << row - end - end - end - - - #### def diff_on(date) readings_on = day(date) From d6213e963aefdcdc64d507a8eab8373411fe2c1b Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 10 Jan 2022 16:59:35 +0100 Subject: [PATCH 051/113] fix --- app/models/cost.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 6909ebd..cc70b2d 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -161,7 +161,7 @@ class Cost # charge battery with return_kwh return_kwh -= battery.charge(return_kwh) - if low_hour.eql?(formatted_hour) + if lowest_hour.eql?(formatted_hour) # charge battery during lowest hour usage_kwh += battery.charge(max_charge_kwh) else From adb2e38efa5be63d7da9f10684c8f5590c9e6ff6 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 10 Jan 2022 20:16:58 +0100 Subject: [PATCH 052/113] No charging at some days --- app/models/cost.rb | 7 ++++--- app/models/entsoe.rb | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index cc70b2d..1a436a7 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -161,12 +161,13 @@ class Cost # charge battery with return_kwh return_kwh -= battery.charge(return_kwh) - if lowest_hour.eql?(formatted_hour) + if lowest_hour.eql?(formatted_hour) # lowest_hour = "" if small difference between high/low # charge battery during lowest hour usage_kwh += battery.charge(max_charge_kwh) else # if during expensive hours || more than kwh then discharge_battery - if (battery.battery_kwh > max_charge_kwh) || high_hours.include?(formatted_hour) + # if (battery.battery_kwh > max_charge_kwh) || high_hours.include?(formatted_hour) + if high_hours.include?(formatted_hour) usage_kwh -= battery.discharge(usage_kwh) end end @@ -196,7 +197,7 @@ class Cost oxxio_rate, oxxio_cost] - p "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s" % (one_hour[0..7] + one_hour[8..12].map{|c| format_cost(c)}) + p "%s,%s,%s,%s,%s,%s,%s,%03f,%s,%s,%s,%s,%s" % (one_hour[0..7] + one_hour[8..12].map{|c| format_cost(c)}) result << one_hour hour_start = hour_start.advance(:hours => 1) diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index d5f5092..394b03a 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -20,6 +20,7 @@ class Entsoe def price_at(formatted_hour) unless @kwh_prices.key?(formatted_hour) + p "Fetching Entsoe tariffs for %s" % formatted_hour @kwh_prices.merge!(query_day_ahead_prices(Date.parse(formatted_hour))) end @kwh_prices[formatted_hour] @@ -45,6 +46,10 @@ class Entsoe charge_rate = sorted_prices.first[1] # assume we charge at lowest hour high_hours = sorted_prices.select{|p| p[1] > charge_rate + storage_cost}.to_h.keys + # reset lowest_hour (effectively not charging), when price difference smaller than storage_cost + highest_price = sorted_prices.last[1] + lowest_hour = "" if (highest_price-charge_rate) < storage_cost + return lowest_hour,highest_hour,high_hours end From 849ce0ea1cf391cb65bdb60bde57186995e93259 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 10 Jan 2022 20:45:05 +0100 Subject: [PATCH 053/113] fixed battery --- app/models/battery.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/models/battery.rb b/app/models/battery.rb index c016c95..b7ac57b 100644 --- a/app/models/battery.rb +++ b/app/models/battery.rb @@ -7,6 +7,10 @@ class Battery @battery_max_kwh = battery_capacity end + def reset + @battery_kwh = 0.0 + end + def charge(kwh) return 0.0 if kwh.nil? if battery_kwh + kwh <= battery_max_kwh @@ -15,7 +19,7 @@ class Battery return kwh else old_kwh = battery_kwh - battery_kwh = battery_max_kwh + @battery_kwh = battery_max_kwh #p "Battery is now at %s kwh" % battery_kwh return (battery_max_kwh-old_kwh) end @@ -29,7 +33,7 @@ class Battery return kwh else old_kwh = battery_kwh - battery_kwh = 0.0 + @battery_kwh = 0.0 #p "Battery is now at %s kwh" % battery_kwh return old_kwh end From 1372f01000d1ec5831879373e41cb2e8741995e4 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 10 Jan 2022 21:22:51 +0100 Subject: [PATCH 054/113] No charging from grid during sunny months --- app/models/entsoe.rb | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index 394b03a..7d9f3b5 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -40,15 +40,23 @@ class Entsoe def high_low_hours(date) sorted_prices = prices_at(date).to_a.sort_by(&:last) # sort according to price - lowest_hour = sorted_prices.first[0] highest_hour = sorted_prices.last[0] - # calculate hours where rate > charge_rate + storage_cost (typically 0.05) - charge_rate = sorted_prices.first[1] # assume we charge at lowest hour - high_hours = sorted_prices.select{|p| p[1] > charge_rate + storage_cost}.to_h.keys - # reset lowest_hour (effectively not charging), when price difference smaller than storage_cost - highest_price = sorted_prices.last[1] - lowest_hour = "" if (highest_price-charge_rate) < storage_cost + # From Apr-Oct: do not charge; every hour that has cost > charge_rate is a high_hour + if [4,5,6,7,8,9,10].include?(date.month) + lowest_hour = "" # effectively no charging from grid + high_hours = sorted_prices.select{|p| p[1] > storage_cost} + else + lowest_hour = sorted_prices.first[0] + + # calculate hours where rate > charge_rate + storage_cost (typically 0.05) + charge_rate = sorted_prices.first[1] # assume we charge at lowest hour + high_hours = sorted_prices.select{|p| p[1] > charge_rate + storage_cost}.to_h.keys + + # reset lowest_hour (effectively not charging), when price difference smaller than storage_cost + highest_price = sorted_prices.last[1] + lowest_hour = "" if (highest_price-charge_rate) < storage_cost + end return lowest_hour,highest_hour,high_hours end From 86c09b6bf9114198113dc16595236d882b265af1 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 10 Jan 2022 21:27:09 +0100 Subject: [PATCH 055/113] Use sun power --- app/models/entsoe.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index 7d9f3b5..fc681a8 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -42,10 +42,10 @@ class Entsoe sorted_prices = prices_at(date).to_a.sort_by(&:last) # sort according to price highest_hour = sorted_prices.last[0] - # From Apr-Oct: do not charge; every hour that has cost > charge_rate is a high_hour + # From Apr-Oct: do not charge; every hour that has cost > 0 is a high_hour if [4,5,6,7,8,9,10].include?(date.month) lowest_hour = "" # effectively no charging from grid - high_hours = sorted_prices.select{|p| p[1] > storage_cost} + high_hours = sorted_prices.select{|p| p[1] > 0} else lowest_hour = sorted_prices.first[0] From 8725c12a68c7cbc9dd2be1feccf0f12816d13361 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 10 Jan 2022 21:33:46 +0100 Subject: [PATCH 056/113] bux fix --- app/models/entsoe.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index fc681a8..83f925b 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -45,7 +45,7 @@ class Entsoe # From Apr-Oct: do not charge; every hour that has cost > 0 is a high_hour if [4,5,6,7,8,9,10].include?(date.month) lowest_hour = "" # effectively no charging from grid - high_hours = sorted_prices.select{|p| p[1] > 0} + high_hours = sorted_prices.select{|p| p[1] > 0}.to_h.keys else lowest_hour = sorted_prices.first[0] From 28a806f584f3dd6bf5ed1e706569a07e22d4fa72 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 10 Jan 2022 22:27:25 +0100 Subject: [PATCH 057/113] Fewer no grid charge months --- app/models/cost.rb | 10 +++++----- app/models/entsoe.rb | 6 ++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 1a436a7..228bb4d 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -3,8 +3,8 @@ EASY_ENERGY_TARIFFS = {} class Cost - - attr_reader :battery, :max_charge_kwh, :entsoe + attr_accessor :max_charge_kwh + attr_reader :battery, :entsoe def initialize(battery_capacity=10.0, max_charge=5.0) @entsoe = Entsoe.new @@ -161,8 +161,8 @@ class Cost # charge battery with return_kwh return_kwh -= battery.charge(return_kwh) - if lowest_hour.eql?(formatted_hour) # lowest_hour = "" if small difference between high/low - # charge battery during lowest hour + if (lowest_hour.eql?(formatted_hour) || entsoe.price_at(formatted_hour) < 0) # lowest_hour = "" if small difference between high/low + # charge battery during lowest hour, or when prices are negative usage_kwh += battery.charge(max_charge_kwh) else # if during expensive hours || more than kwh then discharge_battery @@ -197,7 +197,7 @@ class Cost oxxio_rate, oxxio_cost] - p "%s,%s,%s,%s,%s,%s,%s,%03f,%s,%s,%s,%s,%s" % (one_hour[0..7] + one_hour[8..12].map{|c| format_cost(c)}) + p "%s,%s,%s,%s,%s,%s,%s,%0.02f,%s,%s,%s,%s,%s" % (one_hour[0..7] + one_hour[8..12].map{|c| format_cost(c)}) result << one_hour hour_start = hour_start.advance(:hours => 1) diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index 83f925b..66ed0bb 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -10,12 +10,14 @@ class Entsoe URL = 'https://transparency.entsoe.eu' + attr_accessor :no_grid_charge_months attr_reader :storage_cost def initialize(api_key = "c2287e07-0c26-4950-b430-22b7f75a8f2e") @api_key = api_key @kwh_prices = {} @storage_cost = 0.05 # how much does it cost to store 1 kwh in battery + @no_grid_charge_months = [5,6,7] # months where we assume there is enough surplus from sun power end def price_at(formatted_hour) @@ -42,8 +44,8 @@ class Entsoe sorted_prices = prices_at(date).to_a.sort_by(&:last) # sort according to price highest_hour = sorted_prices.last[0] - # From Apr-Oct: do not charge; every hour that has cost > 0 is a high_hour - if [4,5,6,7,8,9,10].include?(date.month) + # Some months: do not charge; every hour that has cost > 0 is a high_hour + if @no_grid_charge_months.include?(date.month) lowest_hour = "" # effectively no charging from grid high_hours = sorted_prices.select{|p| p[1] > 0}.to_h.keys else From 3ed8ea5f8a2fcf7da5d0aa7badb9d4cb8def87d5 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Tue, 11 Jan 2022 15:38:34 +0100 Subject: [PATCH 058/113] Make storage_cost configurable --- app/models/cost.rb | 6 +++--- app/models/entsoe.rb | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 228bb4d..d182458 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -6,8 +6,8 @@ class Cost attr_accessor :max_charge_kwh attr_reader :battery, :entsoe - def initialize(battery_capacity=10.0, max_charge=5.0) - @entsoe = Entsoe.new + def initialize(battery_capacity=10.0, max_charge=5.0, storage_cost=0.05) + @entsoe = Entsoe.new(storage_cost) @max_charge_kwh = max_charge @battery = Battery.new(battery_capacity) end @@ -197,7 +197,7 @@ class Cost oxxio_rate, oxxio_cost] - p "%s,%s,%s,%s,%s,%s,%s,%0.02f,%s,%s,%s,%s,%s" % (one_hour[0..7] + one_hour[8..12].map{|c| format_cost(c)}) + p "%s,%s,%s,%s,%s,%s,%s,%0.1f,%s,%s,%s,%s,%s" % (one_hour[0..7] + one_hour[8..12].map{|c| format_cost(c)}) result << one_hour hour_start = hour_start.advance(:hours => 1) diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index 66ed0bb..a7f78e2 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -13,10 +13,10 @@ class Entsoe attr_accessor :no_grid_charge_months attr_reader :storage_cost - def initialize(api_key = "c2287e07-0c26-4950-b430-22b7f75a8f2e") + def initialize(storage_cost = 0.05, api_key = "c2287e07-0c26-4950-b430-22b7f75a8f2e") @api_key = api_key @kwh_prices = {} - @storage_cost = 0.05 # how much does it cost to store 1 kwh in battery + @storage_cost = storage_cost # how much does it cost to store 1 kwh in battery @no_grid_charge_months = [5,6,7] # months where we assume there is enough surplus from sun power end From fca3fd401d487152157bbf7f29e581bffbc17b4e Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 12 Jan 2022 17:28:47 +0100 Subject: [PATCH 059/113] Adds rstudio --- docker-compose.yml | 12 +++ rstudio/.config/rstudio/rstudio-prefs.json | 1 + rstudio/.local/share/rstudio/addin_registry | 83 ++++++++++++++++++ .../rstudio/client-state/console.temporary | 3 + .../client-state/environment-grid.persistent | 3 + .../client-state/environment-panel.temporary | 8 ++ .../source-column-manager.persistent | 8 ++ .../client-state/workbenchp.persistent | 9 ++ rstudio/.local/share/rstudio/history_database | 9 ++ .../monitored/lists/command_palette_mru | 0 .../share/rstudio/monitored/lists/file_mru | 0 .../monitored/lists/help_history_links | 0 .../rstudio/monitored/lists/plot_publish_mru | 0 .../share/rstudio/monitored/lists/project_mru | 0 .../rstudio/monitored/lists/user_dictionary | 0 .../share/rstudio/notebooks/patch-chunk-names | 0 .../.local/share/rstudio/pcs/files-pane.pper | 9 ++ .../share/rstudio/pcs/packages-pane.pper | 7 ++ .../.local/share/rstudio/pcs/source-pane.pper | 3 + .../share/rstudio/pcs/windowlayoutstate.pper | 14 +++ .../share/rstudio/pcs/workbench-pane.pper | 5 ++ rstudio/.local/share/rstudio/persistent-state | 2 + .../projects_settings/last-project-path | 0 .../.local/share/rstudio/rstudio-server.json | 3 + .../active/session-c5601715/graphics-r3/INDEX | 0 .../session-c5601715/graphics-r3/empty.png | 0 .../session-c5601715/properites/executing | 1 + .../session-c5601715/properites/initial | 1 + .../session-c5601715/properites/last-used | 1 + .../session-c5601715/properites/project | 1 + .../session-c5601715/properites/r-version | 1 + .../properites/r-version-home | 1 + .../properites/r-version-label | 0 .../session-c5601715/properites/running | 1 + .../properites/save_prompt_required | 1 + .../session-c5601715/properites/working-dir | 1 + .../session-c5601715/session-persistent-state | 2 + .../suspended-session-data/environment_vars | 64 ++++++++++++++ .../suspended-session-data/libpaths | Bin 0 -> 133 bytes .../suspended-session-data/rversion | 1 + .../suspended-session-data/settings | 2 + .../rstudio/sources/s-c5601715/lock_file | 0 rstudio/.my.cnf | 3 + rstudio/Dockerfile | 17 ++++ 44 files changed, 277 insertions(+) create mode 100644 rstudio/.config/rstudio/rstudio-prefs.json create mode 100644 rstudio/.local/share/rstudio/addin_registry create mode 100644 rstudio/.local/share/rstudio/client-state/console.temporary create mode 100644 rstudio/.local/share/rstudio/client-state/environment-grid.persistent create mode 100644 rstudio/.local/share/rstudio/client-state/environment-panel.temporary create mode 100644 rstudio/.local/share/rstudio/client-state/source-column-manager.persistent create mode 100644 rstudio/.local/share/rstudio/client-state/workbenchp.persistent create mode 100644 rstudio/.local/share/rstudio/history_database create mode 100644 rstudio/.local/share/rstudio/monitored/lists/command_palette_mru create mode 100644 rstudio/.local/share/rstudio/monitored/lists/file_mru create mode 100644 rstudio/.local/share/rstudio/monitored/lists/help_history_links create mode 100644 rstudio/.local/share/rstudio/monitored/lists/plot_publish_mru create mode 100644 rstudio/.local/share/rstudio/monitored/lists/project_mru create mode 100644 rstudio/.local/share/rstudio/monitored/lists/user_dictionary create mode 100644 rstudio/.local/share/rstudio/notebooks/patch-chunk-names create mode 100644 rstudio/.local/share/rstudio/pcs/files-pane.pper create mode 100644 rstudio/.local/share/rstudio/pcs/packages-pane.pper create mode 100644 rstudio/.local/share/rstudio/pcs/source-pane.pper create mode 100644 rstudio/.local/share/rstudio/pcs/windowlayoutstate.pper create mode 100644 rstudio/.local/share/rstudio/pcs/workbench-pane.pper create mode 100644 rstudio/.local/share/rstudio/persistent-state create mode 100644 rstudio/.local/share/rstudio/projects_settings/last-project-path create mode 100644 rstudio/.local/share/rstudio/rstudio-server.json create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/graphics-r3/INDEX create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/graphics-r3/empty.png create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/executing create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/initial create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/last-used create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/project create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version-home create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version-label create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/running create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/save_prompt_required create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/working-dir create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/session-persistent-state create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/environment_vars create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/libpaths create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/rversion create mode 100644 rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/settings create mode 100644 rstudio/.local/share/rstudio/sources/s-c5601715/lock_file create mode 100644 rstudio/.my.cnf create mode 100644 rstudio/Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index 1f513b7..3d2182b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,3 +20,15 @@ services: - .:/usr/src/app depends_on: - db + rstudio: + container_name: rstudio + restart: unless-stopped + build: ./rstudio + environment: + PASSWORD: secret + volumes: + - ./rstudio:/home/rstudio + ports: + - 8787:8787 + + diff --git a/rstudio/.config/rstudio/rstudio-prefs.json b/rstudio/.config/rstudio/rstudio-prefs.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/rstudio/.config/rstudio/rstudio-prefs.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/addin_registry b/rstudio/.local/share/rstudio/addin_registry new file mode 100644 index 0000000..3495a13 --- /dev/null +++ b/rstudio/.local/share/rstudio/addin_registry @@ -0,0 +1,83 @@ +{ + "clipr::clipr_output": { + "name": "Output to clipboard", + "package": "clipr", + "title": "", + "description": "Copies the console output of a selected expression to the system clipboard", + "interactive": false, + "binding": "clipr_output", + "ordinal": 2 + }, + "clipr::clipr_result": { + "name": "Value to clipboard", + "package": "clipr", + "title": "", + "description": "Copies the results of a selected expression to the system clipboard", + "interactive": false, + "binding": "clipr_result", + "ordinal": 1 + }, + "devtools::document": { + "name": "Document a package", + "package": "devtools", + "title": "", + "description": "A wrapper for `roxygen`'s `roxygen2::roxygenize()`", + "interactive": true, + "binding": "document", + "ordinal": 6 + }, + "devtools::run_examples": { + "name": "Run examples", + "package": "devtools", + "title": "", + "description": "Runs R code in examples using `devtools::run_examples()`", + "interactive": true, + "binding": "run_examples", + "ordinal": 7 + }, + "devtools::test_active_file": { + "name": "Run a test file", + "package": "devtools", + "title": "", + "description": "Run the current test file, using `devtools::test_active_file()`.", + "interactive": true, + "binding": "test_active_file", + "ordinal": 3 + }, + "devtools::test_coverage": { + "name": "Report test coverage for a package", + "package": "devtools", + "title": "", + "description": "Calculate and report the test coverage for the current package, using `devtools::test_coverage()`.", + "interactive": true, + "binding": "test_coverage", + "ordinal": 5 + }, + "devtools::test_coverage_active_file": { + "name": "Report test coverage for a file", + "package": "devtools", + "title": "", + "description": "Calculate and report test coverage for the current test file, using `devtools::test_coverage_active_file()`.", + "interactive": true, + "binding": "test_coverage_active_file", + "ordinal": 4 + }, + "reprex::reprex_addin": { + "name": "Render reprex...", + "package": "reprex", + "title": "", + "description": "Run `reprex::reprex()` to prepare a reproducible example for sharing.", + "interactive": true, + "binding": "reprex_addin", + "ordinal": 8 + }, + "reprex::reprex_selection": { + "name": "Reprex selection", + "package": "reprex", + "title": "", + "description": "Prepare reprex from current selection", + "interactive": false, + "binding": "reprex_selection", + "ordinal": 9 + } +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/client-state/console.temporary b/rstudio/.local/share/rstudio/client-state/console.temporary new file mode 100644 index 0000000..274737b --- /dev/null +++ b/rstudio/.local/share/rstudio/client-state/console.temporary @@ -0,0 +1,3 @@ +{ + "input": "" +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/client-state/environment-grid.persistent b/rstudio/.local/share/rstudio/client-state/environment-grid.persistent new file mode 100644 index 0000000..5453579 --- /dev/null +++ b/rstudio/.local/share/rstudio/client-state/environment-grid.persistent @@ -0,0 +1,3 @@ +{ + "objectDisplayType": 0 +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/client-state/environment-panel.temporary b/rstudio/.local/share/rstudio/client-state/environment-panel.temporary new file mode 100644 index 0000000..2d16ead --- /dev/null +++ b/rstudio/.local/share/rstudio/client-state/environment-panel.temporary @@ -0,0 +1,8 @@ +{ + "environmentPanelSettings": { + "scroll_position": 0, + "expanded_objects": [], + "sort_column": 0, + "ascending_sort": true + } +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/client-state/source-column-manager.persistent b/rstudio/.local/share/rstudio/client-state/source-column-manager.persistent new file mode 100644 index 0000000..27894e0 --- /dev/null +++ b/rstudio/.local/share/rstudio/client-state/source-column-manager.persistent @@ -0,0 +1,8 @@ +{ + "column-info": { + "names": [ + "Source" + ], + "activeColumn": "Source" + } +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/client-state/workbenchp.persistent b/rstudio/.local/share/rstudio/client-state/workbenchp.persistent new file mode 100644 index 0000000..57349e8 --- /dev/null +++ b/rstudio/.local/share/rstudio/client-state/workbenchp.persistent @@ -0,0 +1,9 @@ +{ + "rightpanesize": { + "panelwidth": 1485, + "windowwidth": 1501, + "splitterpos": [ + 675 + ] + } +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/history_database b/rstudio/.local/share/rstudio/history_database new file mode 100644 index 0000000..6097470 --- /dev/null +++ b/rstudio/.local/share/rstudio/history_database @@ -0,0 +1,9 @@ +1641994941352:library(DBI) +1641994972245:install.packages("RMariaDB") +1641995012317:library(DBI) +1641996642217:library(DBI) +1641996662061:con <- dbConnect(RMariaDB::MariaDB()) +1641999172022:library(DBI) +1641999197937:con <- dbConnect(RMariaDB::MariaDB()) +1642003072441:con <- dbConnect(RMariaDB::MariaDB(),host="db") +1642003092194:con <- dbConnect(RMariaDB::MariaDB(),user="root",host="db") diff --git a/rstudio/.local/share/rstudio/monitored/lists/command_palette_mru b/rstudio/.local/share/rstudio/monitored/lists/command_palette_mru new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/monitored/lists/file_mru b/rstudio/.local/share/rstudio/monitored/lists/file_mru new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/monitored/lists/help_history_links b/rstudio/.local/share/rstudio/monitored/lists/help_history_links new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/monitored/lists/plot_publish_mru b/rstudio/.local/share/rstudio/monitored/lists/plot_publish_mru new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/monitored/lists/project_mru b/rstudio/.local/share/rstudio/monitored/lists/project_mru new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/monitored/lists/user_dictionary b/rstudio/.local/share/rstudio/monitored/lists/user_dictionary new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/notebooks/patch-chunk-names b/rstudio/.local/share/rstudio/notebooks/patch-chunk-names new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/pcs/files-pane.pper b/rstudio/.local/share/rstudio/pcs/files-pane.pper new file mode 100644 index 0000000..6ce8951 --- /dev/null +++ b/rstudio/.local/share/rstudio/pcs/files-pane.pper @@ -0,0 +1,9 @@ +{ + "sortOrder": [ + { + "columnIndex": 2, + "ascending": true + } + ], + "path": "~" +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/pcs/packages-pane.pper b/rstudio/.local/share/rstudio/pcs/packages-pane.pper new file mode 100644 index 0000000..f3be4df --- /dev/null +++ b/rstudio/.local/share/rstudio/pcs/packages-pane.pper @@ -0,0 +1,7 @@ +{ + "installOptions": { + "installFromRepository": true, + "libraryPath": "/usr/local/lib/R/site-library", + "installDependencies": true + } +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/pcs/source-pane.pper b/rstudio/.local/share/rstudio/pcs/source-pane.pper new file mode 100644 index 0000000..a528f3b --- /dev/null +++ b/rstudio/.local/share/rstudio/pcs/source-pane.pper @@ -0,0 +1,3 @@ +{ + "activeTab": -1 +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/pcs/windowlayoutstate.pper b/rstudio/.local/share/rstudio/pcs/windowlayoutstate.pper new file mode 100644 index 0000000..c5b57cb --- /dev/null +++ b/rstudio/.local/share/rstudio/pcs/windowlayoutstate.pper @@ -0,0 +1,14 @@ +{ + "left": { + "splitterpos": 319, + "topwindowstate": "HIDE", + "panelheight": 724, + "windowheight": 798 + }, + "right": { + "splitterpos": 478, + "topwindowstate": "NORMAL", + "panelheight": 724, + "windowheight": 798 + } +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/pcs/workbench-pane.pper b/rstudio/.local/share/rstudio/pcs/workbench-pane.pper new file mode 100644 index 0000000..75e70e9 --- /dev/null +++ b/rstudio/.local/share/rstudio/pcs/workbench-pane.pper @@ -0,0 +1,5 @@ +{ + "TabSet1": 0, + "TabSet2": 0, + "TabZoom": {} +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/persistent-state b/rstudio/.local/share/rstudio/persistent-state new file mode 100644 index 0000000..b70e0ab --- /dev/null +++ b/rstudio/.local/share/rstudio/persistent-state @@ -0,0 +1,2 @@ +activeClientUrl="http://localhost:8787/" +portToken="97cf3656f4f2" diff --git a/rstudio/.local/share/rstudio/projects_settings/last-project-path b/rstudio/.local/share/rstudio/projects_settings/last-project-path new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/rstudio-server.json b/rstudio/.local/share/rstudio/rstudio-server.json new file mode 100644 index 0000000..b07226d --- /dev/null +++ b/rstudio/.local/share/rstudio/rstudio-server.json @@ -0,0 +1,3 @@ +{ + "context_id": "6A00CEBA" +} \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/graphics-r3/INDEX b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/graphics-r3/INDEX new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/graphics-r3/empty.png b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/graphics-r3/empty.png new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/executing b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/executing new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/executing @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/initial b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/initial new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/initial @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/last-used b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/last-used new file mode 100644 index 0000000..6f3e111 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/last-used @@ -0,0 +1 @@ +1641999151566.000000 \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/project b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/project new file mode 100644 index 0000000..c86c3f3 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/project @@ -0,0 +1 @@ +none \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version new file mode 100644 index 0000000..cd9b8f5 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version @@ -0,0 +1 @@ +4.1.2 \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version-home b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version-home new file mode 100644 index 0000000..69df731 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version-home @@ -0,0 +1 @@ +/usr/local/lib/R \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version-label b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/r-version-label new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/running b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/running new file mode 100644 index 0000000..56a6051 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/running @@ -0,0 +1 @@ +1 \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/save_prompt_required b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/save_prompt_required new file mode 100644 index 0000000..56a6051 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/save_prompt_required @@ -0,0 +1 @@ +1 \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/working-dir b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/working-dir new file mode 100644 index 0000000..4977bc6 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/properites/working-dir @@ -0,0 +1 @@ +~ \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/session-persistent-state b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/session-persistent-state new file mode 100644 index 0000000..4f1edfe --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/session-persistent-state @@ -0,0 +1,2 @@ +abend="1" +active-client-id="cfb30428-aa38-4807-8006-002265146fba" diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/environment_vars b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/environment_vars new file mode 100644 index 0000000..f515392 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/environment_vars @@ -0,0 +1,64 @@ +CLICOLOR_FORCE="1" +CRAN="https://packagemanager.rstudio.com/cran/__linux__/focal/latest" +CWD="/" +DEFAULT_USER="rstudio" +DISPLAY=":0" +EDITOR="vi" +GIT_ASKPASS="rpostback-askpass" +HOME="/home/rstudio" +HOSTNAME="c54de4659c49" +LANG="en_US.UTF-8" +LD_LIBRARY_PATH="/usr/local/lib/R/lib:/lib:/usr/local/lib:/usr/lib/x86_64-linux-gnu:/usr/lib/jvm/java-11-openjdk-amd64/lib/server" +LN_S="ln -s" +LOGNAME="rstudio" +MAKE="make" +MPLENGINE="tkAgg" +PAGER="/usr/bin/pager" +PATH="/usr/lib/rstudio-server/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/rstudio-server/bin/postback" +RMARKDOWN_MATHJAX_PATH="/usr/lib/rstudio-server/resources/mathjax-27" +RSTUDIO="1" +RSTUDIO_CONSOLE_COLOR="256" +RSTUDIO_CONSOLE_WIDTH="104" +RSTUDIO_HTTP_REFERER="http://localhost:8787/" +RSTUDIO_PANDOC="/usr/lib/rstudio-server/bin/pandoc" +RSTUDIO_PROGRAM_MODE="server" +RSTUDIO_R_MODULE="" +RSTUDIO_R_PRELAUNCH_SCRIPT="" +RSTUDIO_R_REPO="" +RSTUDIO_R_VERSION_LABEL="" +RSTUDIO_SESSION_STREAM="rstudio-d" +RSTUDIO_USER_IDENTITY="rstudio" +RSTUDIO_USER_IDENTITY_DISPLAY="rstudio" +RSTUDIO_WINUTILS="bin/winutils" +RS_RPOSTBACK_PATH="/usr/lib/rstudio-server/bin/rpostback" +RS_SESSION_TMP_DIR="/var/run/rstudio-server/rstudio-rsession" +R_BROWSER="xdg-open" +R_BZIPCMD="/usr/bin/bzip2" +R_DOC_DIR="/usr/local/lib/R/doc" +R_GZIPCMD="/usr/bin/gzip" +R_HOME="/usr/local/lib/R" +R_INCLUDE_DIR="/usr/local/lib/R/include" +R_LIBS="/usr/local/lib/R/site-library:/usr/local/lib/R/library" +R_LIBS_SITE="" +R_LIBS_USER="~/R/x86_64-pc-linux-gnu-library/4.1" +R_PAPERSIZE="letter" +R_PDFVIEWER="/usr/bin/xdg-open" +R_PLATFORM="x86_64-pc-linux-gnu" +R_PRINTCMD="/usr/bin/lpr" +R_RD4PDF="times,inconsolata,hyper" +R_SESSION_TMPDIR="/tmp/RtmpL2PZYh" +R_SHARE_DIR="/usr/local/lib/R/share" +R_STRIP_SHARED_LIB="strip --strip-unneeded" +R_STRIP_STATIC_LIB="strip --strip-debug" +R_SYSTEM_ABI="linux,gcc,gxx,gfortran,gfortran" +R_TEXI2DVICMD="/usr/bin/texi2dvi" +R_UNZIPCMD="/usr/bin/unzip" +R_VERSION="4.1.2" +R_ZIPCMD="/usr/bin/zip" +S6_VERSION="v2.1.0.2" +SED="/usr/bin/sed" +SSH_ASKPASS="rpostback-askpass" +TAR="/usr/bin/tar" +TERM="xterm" +TZ="Etc/UTC" +USER="rstudio" diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/libpaths b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/libpaths new file mode 100644 index 0000000000000000000000000000000000000000..eb161023b5ffd880a8781dbdfc280900ea9dfa6d GIT binary patch literal 133 zcmWG?i7@7h;9_84U}j)pWMW`u1u_{}LqptjEf^SBn1CEc1{MZRAkC4JnG}#%l2Hs~ w3ji?_R9sfSw75t=CqFqcM<1v}KS;kgvm{j)$Sg`Ms)Q<%z)%E}XJGgT07QoyDF6Tf literal 0 HcmV?d00001 diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/rversion b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/rversion new file mode 100644 index 0000000..cd9b8f5 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/rversion @@ -0,0 +1 @@ +4.1.2 \ No newline at end of file diff --git a/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/settings b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/settings new file mode 100644 index 0000000..374a896 --- /dev/null +++ b/rstudio/.local/share/rstudio/sessions/active/session-c5601715/suspended-session-data/settings @@ -0,0 +1,2 @@ +packrat_mode_on="0" +r_profile_on_restore="1" diff --git a/rstudio/.local/share/rstudio/sources/s-c5601715/lock_file b/rstudio/.local/share/rstudio/sources/s-c5601715/lock_file new file mode 100644 index 0000000..e69de29 diff --git a/rstudio/.my.cnf b/rstudio/.my.cnf new file mode 100644 index 0000000..356ed80 --- /dev/null +++ b/rstudio/.my.cnf @@ -0,0 +1,3 @@ +[smartmeter] +user="root" +password="rootme" diff --git a/rstudio/Dockerfile b/rstudio/Dockerfile new file mode 100644 index 0000000..1d603f9 --- /dev/null +++ b/rstudio/Dockerfile @@ -0,0 +1,17 @@ +FROM rocker/tidyverse:latest + +RUN apt-get update \ + && apt-get install -y libmariadb-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/ \ + && rm -rf /tmp/downloaded_packages/ /tmp/*.rds + +## install packages from CRAN (and clean up) +RUN Rscript -e "install.packages(c('tidyverse','purr','psych','lme4','lmerTest','broom','doBy','reshape','emmeans','effects','mlr','randomForest','glmnet','foreign'), repos='https://cran.rstudio.com/')" \ + && rm -rf /tmp/downloaded_packages/ /tmp/*.rds + + +EXPOSE 8787 + +CMD ["/init"] + From 99337c6045aa0834309f498e5784a4e89f8d6c25 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 19 Jan 2022 15:52:20 +0100 Subject: [PATCH 060/113] Adds timezone to Entsoe prices --- app/models/entsoe.rb | 29 ++++++++++++++++++++--------- db/migrate/003_creates_prices.rb | 10 ++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 db/migrate/003_creates_prices.rb diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index a7f78e2..be62b5e 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -10,30 +10,32 @@ class Entsoe URL = 'https://transparency.entsoe.eu' - attr_accessor :no_grid_charge_months + attr_accessor :no_grid_charge_months, :zone attr_reader :storage_cost - def initialize(storage_cost = 0.05, api_key = "c2287e07-0c26-4950-b430-22b7f75a8f2e") + def initialize(zone = "Amsterdam", storage_cost = 0.05, api_key = "c2287e07-0c26-4950-b430-22b7f75a8f2e") @api_key = api_key @kwh_prices = {} @storage_cost = storage_cost # how much does it cost to store 1 kwh in battery @no_grid_charge_months = [5,6,7] # months where we assume there is enough surplus from sun power + @zone = zone end def price_at(formatted_hour) unless @kwh_prices.key?(formatted_hour) p "Fetching Entsoe tariffs for %s" % formatted_hour - @kwh_prices.merge!(query_day_ahead_prices(Date.parse(formatted_hour))) + prices = query_day_ahead_prices(Date.parse(formatted_hour)) + @kwh_prices.merge!(hourly_format(prices)) end @kwh_prices[formatted_hour] end def prices_at(date) - hour_start = date.beginning_of_day + hour_start = date.beginning_of_day.in_time_zone(@zone) day_end = hour_start.advance(days: 1) result = {} while(hour_start < day_end) do - formatted_hour = hour_start.strftime("%F-%H") + formatted_hour = hour_start.strftime("%F %H") result.merge!({ formatted_hour => price_at(formatted_hour)}) hour_start = hour_start.advance(:hours => 1) end @@ -79,6 +81,11 @@ class Entsoe private + def hourly_format(prices) + # in memory hash @kwh_prices is in @zone timezone + prices.to_a.map{|p| [p[0].in_time_zone(@zone).strftime("%F %H"),p[1]]}.to_h + end + # get position and amount from XML snippet # # 1 @@ -88,16 +95,20 @@ class Entsoe # convert price to EUR per kwh, including VAT (21%) # def parse_point(xml) - return xml.children[1].text.to_i, ((xml.children[3].text.to_f/1000)*1.21).round(5) + return xml.xpath(".//xmlns:position").text.to_i, ((xml.xpath(".//xmlns:price.amount").text.to_f/1000)*1.21).round(5) end def base_request(date, url) formatted_date = date.strftime("%F") doc = Nokogiri::XML(URI.open(url)) - prices = doc.xpath('.//xmlns:Point').map{|p| parse_point(p)} - #returns a hash with keys formatted "yyyy-mm-dd-hr" and values price (per kwh) - prices.map{|p| ["%s-%02d" % [formatted_date,(p[0]-1)], p[1]]}.to_h + prices = doc.xpath('.//xmlns:Point').map{|p| parse_point(p)} + # get start_time (in UTC) from XML docment + start_time = DateTime.parse(doc.xpath('.//xmlns:period.timeInterval//xmlns:start').text) + + #returns a hash with keys formatted "yyyy-mm-dd hr" and values price (per kwh) + # tag runs from 1-24. We need hours from 00-23, therefore substracting 1 + prices.map{|p| [start_time.advance(hours: (p[0]-1)), p[1]]}.to_h end end diff --git a/db/migrate/003_creates_prices.rb b/db/migrate/003_creates_prices.rb new file mode 100644 index 0000000..97cd471 --- /dev/null +++ b/db/migrate/003_creates_prices.rb @@ -0,0 +1,10 @@ +class CreatesPrices << ActiveRecord::Migration[4.2] + def change + create_table :prices do |t| + t.datetime :hour + t.float :usage_kwh + t.timestamps + end + + add_index :prices, :hour +end From 7969f4ee8650535d960920ef9c5b76c60d29e89c Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 14 Sep 2022 20:45:31 +0200 Subject: [PATCH 061/113] Changes dns name --- app/helpers/InSyncState.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 2c3d131..cfa3291 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -97,7 +97,7 @@ class InSyncState < StatePattern::State # Write to EmonHub begin - TCPSocket.open("printserver.home.local",5050){|s| + TCPSocket.open("printserver",5050){|s| s.write(sprintf("8 %d %d\r\n", reading.current_kw_consumed*1000, reading.current_kw_produced*1000)) } rescue From cd963090f2f5afe138bef948ddc0040c32ad9955 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 14 Sep 2022 20:46:52 +0200 Subject: [PATCH 062/113] Adds rstudio --- docker-compose.yml | 2 +- rstudio/Dockerfile | 4 +++ rstudio/Energy.Rmd | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100755 rstudio/Energy.Rmd diff --git a/docker-compose.yml b/docker-compose.yml index 3d2182b..a8c955c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: environment: PASSWORD: secret volumes: - - ./rstudio:/home/rstudio + - ./rstudio:/home/rstudio/smartmeter ports: - 8787:8787 diff --git a/rstudio/Dockerfile b/rstudio/Dockerfile index 1d603f9..8dc41f7 100644 --- a/rstudio/Dockerfile +++ b/rstudio/Dockerfile @@ -2,6 +2,8 @@ FROM rocker/tidyverse:latest RUN apt-get update \ && apt-get install -y libmariadb-dev \ + libicu-dev liblzma-dev libpcre3-dev libpng-dev \ + libv8-dev libbz2-dev libxml2-dev \ && apt-get clean \ && rm -rf /var/lib/apt/lists/ \ && rm -rf /tmp/downloaded_packages/ /tmp/*.rds @@ -10,6 +12,8 @@ RUN apt-get update \ RUN Rscript -e "install.packages(c('tidyverse','purr','psych','lme4','lmerTest','broom','doBy','reshape','emmeans','effects','mlr','randomForest','glmnet','foreign'), repos='https://cran.rstudio.com/')" \ && rm -rf /tmp/downloaded_packages/ /tmp/*.rds +RUN mkdir /home/rstudio/smartmeter +VOLUME /home/rstudio/smartmeter EXPOSE 8787 diff --git a/rstudio/Energy.Rmd b/rstudio/Energy.Rmd new file mode 100755 index 0000000..a2bee3a --- /dev/null +++ b/rstudio/Energy.Rmd @@ -0,0 +1,77 @@ +library(dplyr) +library(dbplyr) +library(ggplot2) +library(lubridate) +library(tidyr) + +# Access to our smartmeter DB +con <- DBI::dbConnect(RMariaDB::MariaDB(), host="db", dbname="smartmeter", user="root", password="rootme") + +DBI::dbListTables(con) + +# get some records +res <- DBI::dbSendQuery(con, "SELECT * FROM readings LIMIT 10") +DBI::dbFetch(res) + +# dplyr style of reading data from mysql +# con %>% tbl("readings") %>% show_query() + +# get all records from 2022 +energy <- con %>% tbl("readings") %>% + select(total_m3_gas_consumed, total_kwh_consumed_high, total_kwh_consumed_low, + total_kwh_produced_high, total_kwh_produced_low, high_tarif, created_at) %>% + filter(created_at > "2021-09-01", created_at < "2021-12-31") %>% + mutate(date = as.Date(created_at)) %>% + collect() + +# add hour column +energy <- energy %>% mutate(hour = format(strptime(created_at,"%Y-%m-%d %H:%M:%S"),'%H')) + +# group by hour +energy_per_hour <- energy %>% + mutate(total_usage_kwh = (total_kwh_consumed_high+total_kwh_consumed_low)) %>% + mutate(total_return_kwh = (total_kwh_produced_high+total_kwh_produced_low)) %>% + group_by(date,hour) %>% + summarize(max_m3_gas_consumed = max(total_m3_gas_consumed), + min_m3_gas_consumed = min(total_m3_gas_consumed), + max_usage_kwh = max(total_usage_kwh), + min_usage_kwh = min(total_usage_kwh), + max_return_kwh = max(total_return_kwh), + min_return_kwh = min(total_return_kwh)) %>% + mutate(usage_m3 = max_m3_gas_consumed-min_m3_gas_consumed) %>% + mutate(usage_kwh = max_usage_kwh-min_usage_kwh) %>% + mutate(return_kwh = max_return_kwh-min_return_kwh) %>% + select(-max_m3_gas_consumed, -min_m3_gas_consumed, -max_usage_kwh, -min_usage_kwh, -max_return_kwh, -min_return_kwh ) + +# mutate(usage_kwh = round(usage_kwh,1), return_kwh = round(return_kwh,1)) %>% +# and again, group by day +energy_per_day <- energy_per_hour %>% + group_by(date) %>% + summarize(usage_m3=round(sum(usage_m3),2), + usage_kwh=round(sum(usage_kwh),2), + return_kwh=round(sum(return_kwh),2)) + +# some plots +energy_per_hour %>% + mutate(usage_kwh = round(usage_kwh,1), return_kwh = round(return_kwh,1)) %>% + ggplot( aes(x=date, fill=hour, y=usage_kwh, text=as.character(date))) + + geom_bar(stat="identity") + + theme_bw() + + labs(x="Date", y="kwh") + +# daily usage/return of electricity +energy_per_day %>% pivot_longer(cols = usage_kwh:return_kwh) %>% + ggplot( aes(x=date, y=value, fill=name, text=as.character(date))) + + geom_bar(position="dodge", stat="identity") + + geom_text(aes(label=value), vjust=-0.3, hjust=1.2, size=2.5) + + theme_bw() + + labs(x="Date", y="kwh") + +# daily usage/return of gas +energy_per_day %>% pivot_longer(cols = usage_m3) %>% + ggplot( aes(x=date, y=value, fill=name, text=as.character(date))) + + geom_bar(position="dodge", stat="identity") + + geom_text(aes(label=value), vjust=-0.3, hjust=1.2, size=2.5) + + theme_bw() + + labs(x="Date", y="m3") + From 038b02350d485d27a1c4a02ead7f1cd316f69e6f Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 15 Sep 2022 18:51:45 +0200 Subject: [PATCH 063/113] Latest tarif info added --- app/models/cost.rb | 78 ++++++++++++++++++++++++++++++++------------ app/models/entsoe.rb | 9 ++--- 2 files changed, 62 insertions(+), 25 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index d182458..ca3dbc9 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -1,34 +1,51 @@ require 'open-uri' EASY_ENERGY_TARIFFS = {} +# See https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/zakelijk/overige_belastingen/belastingen_op_milieugrondslag/tarieven_milieubelastingen/tabellen_tarieven_milieubelastingen +# Without VAT +ENERGY_TAX_KWH = { 2020 => 0.09770, 2021 => 0.09428, 2022 => 0.03679} +ODE_KWH = { 2020 => 0.0273, 2021 => 0.0300, 2022 => 0.0305} +# merge by adding values +TAX_KWH = ENERGY_TAX_KWH.merge(ODE_KWH){|key, energy_tax, ode| energy_tax + ode} + + class Cost attr_accessor :max_charge_kwh attr_reader :battery, :entsoe - def initialize(battery_capacity=10.0, max_charge=5.0, storage_cost=0.05) - @entsoe = Entsoe.new(storage_cost) + def initialize(zone="Amsterdam", battery_capacity=10.0, max_charge=5.0, storage_cost=0.05) + @entsoe = Entsoe.new(zone, storage_cost) @max_charge_kwh = max_charge @battery = Battery.new(battery_capacity) end + # Government reduced VAT to 9% from 1 July 2022 until 31 Dec 2022 + def vat_at(date) + jul22 = Date.parse("2022-09-01") + dec22 = Date.parse("2022-12-31") + if (date >= jul22 && date <= dec22) + 0.09 + else + 0.21 + end + end + def format_cost(cost) cost ? "EUR %0.03f" % cost : "EUR ?" end + # Assume: usage_kwh_cost, return_kwh_cost already includes vat def add_tax(formatted_hour,usage_kwh,usage_kwh_cost,return_kwh, return_kwh_cost) return nil if (usage_kwh.nil? || usage_kwh_cost.nil? || return_kwh.nil? || return_kwh_cost.nil?) - + + vat = 1 + vat_at(Date.parse(formatted_hour)) year = Date.parse(formatted_hour).year - case year - when 2020 - usage_kwh * (usage_kwh_cost + 0.11822 + 0.03303) - return_kwh * (return_kwh_cost + 0.11822 + 0.03303) - when 2021 - # see https://www.vastelastenbond.nl/blog/overzicht-energiebelasting-en-ode-2021-2022-en-je-energierekening-2021/ - usage_kwh * (usage_kwh_cost + 0.11408 + 0.03630) - return_kwh * (return_kwh_cost + 0.11408 + 0.03630) - when 2022 - usage_kwh * (usage_kwh_cost + 0.04452 + 0.03691) - return_kwh * (return_kwh_cost + 0.04452 + 0.03691) - end + # calculate tax per kwh - which the sum of energy_tax and ode + tax = TAX_KWH[year]*vat + + usage_kwh * (usage_kwh_cost + tax) - return_kwh * (return_kwh_cost + tax) + end ###################################################### @@ -110,15 +127,34 @@ class Cost def oxxio_energy_cost(formatted_hour, normaal_kwh, dal_kwh, year_shift=0) return nil if (normaal_kwh.nil? || dal_kwh.nil?) - year = Date.parse(formatted_hour).year+year_shift - case year - when 2020 - normaal_kwh * (0.07865 + 0.11822 + 0.03303) + dal_kwh * (0.06215 + 0.11822 + 0.03303) - when 2021 - normaal_kwh * (0.06782 + 0.11408 + 0.03630) + dal_kwh * (0.05259 + 0.11408 + 0.03630) - when 2022 - normaal_kwh * (0.23665 + 0.04452 + 0.03691) + dal_kwh * (0.19408 + 0.04452 + 0.03691) + #year = Date.parse(formatted_hour).year+year_shift + date = Date.parse(formatted_hour).advance(years: year_shift) + case date.to_time.to_i + # Date.parse("2019-12-08").to_time.to_i + # From 8 Dec 2019 until 7 Dec 2020 + when 1575759600..1607295600 + #normaal_kwh * (0.07865 + 0.11822 + 0.03303) + dal_kwh * (0.06215 + 0.11822 + 0.03303) + normaal_kwh_cost = 0.07865 + dal_kwh_cost = 0.06215 + # From 8 Dec 2020 until 7 Dec 2021 + when 1607382000..1638831600 + #normaal_kwh * (0.06782 + 0.11408 + 0.03630) + dal_kwh * (0.05259 + 0.11408 + 0.03630) + normaal_kwh_cost = 0.06782 + dal_kwh_cost = 0.05259 + # From 8 Dec 2021 until 7 Sept 2022 + when 1638918000..1662501600 + # normaal_kwh * (0.23665 + 0.04452 + 0.03691) + dal_kwh * (0.19408 + 0.04452 + 0.03691) + normaal_kwh_cost = 0.23665 + dal_kwh_cost = 0.19408 + # From 8 Sept 2022 until 7 Dec 2022 + when 1662588000..1670367600 + normaal_kwh_cost = 0.60824 + dal_kwh_cost = 0.43701 end + normaal_cost = add_tax(formatted_hour, normaal_kwh,normaal_kwh_cost,0,0) # return_kwh already accounted for + dal_cost = add_tax(formatted_hour, dal_kwh, dal_kwh_cost,0,0) + # result + normaal_cost + dal_cost end # @@ -128,7 +164,7 @@ class Cost def entsoe_energy_cost(formatted_hour, usage_kwh, return_kwh) return nil if (usage_kwh.nil? || return_kwh.nil?) - usage_kwh_cost = return_kwh_cost = entsoe.price_at(formatted_hour) + usage_kwh_cost = return_kwh_cost = entsoe.price_at(formatted_hour)*(1+vat_at(Date.parse(formatted_hour))) add_tax(formatted_hour, usage_kwh, usage_kwh_cost, return_kwh, return_kwh_cost) end diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index be62b5e..337803d 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -67,7 +67,7 @@ class Entsoe def query_day_ahead_prices(date) start_date = date.beginning_of_day - end_date = date.end_of_day + end_date = date.end_of_day.advance(hours: -1) # A44 - Document type => Price Document # NL = '10YNL----------L' @@ -79,11 +79,12 @@ class Entsoe base_request(date, url) end + private def hourly_format(prices) # in memory hash @kwh_prices is in @zone timezone - prices.to_a.map{|p| [p[0].in_time_zone(@zone).strftime("%F %H"),p[1]]}.to_h + prices.to_a.map{|p| [p[0].in_time_zone(zone).strftime("%F %H"),p[1]]}.to_h end # get position and amount from XML snippet @@ -92,10 +93,10 @@ class Entsoe # 196.23 # # - # convert price to EUR per kwh, including VAT (21%) + # convert price to EUR per kwh, excluding VAT # def parse_point(xml) - return xml.xpath(".//xmlns:position").text.to_i, ((xml.xpath(".//xmlns:price.amount").text.to_f/1000)*1.21).round(5) + return xml.xpath(".//xmlns:position").text.to_i, (xml.xpath(".//xmlns:price.amount").text.to_f/1000).round(5) end def base_request(date, url) From 411ebafe54a68bab95693a0811f88001b306c1c1 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 15 Sep 2022 21:21:00 +0200 Subject: [PATCH 064/113] Adds timezone --- app/models/cost.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index ca3dbc9..ce5df05 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -12,9 +12,10 @@ TAX_KWH = ENERGY_TAX_KWH.merge(ODE_KWH){|key, energy_tax, ode| energy_tax + ode} class Cost attr_accessor :max_charge_kwh - attr_reader :battery, :entsoe + attr_reader :battery, :entsoe, :zone def initialize(zone="Amsterdam", battery_capacity=10.0, max_charge=5.0, storage_cost=0.05) + @zone = zone @entsoe = Entsoe.new(zone, storage_cost) @max_charge_kwh = max_charge @battery = Battery.new(battery_capacity) @@ -173,14 +174,14 @@ class Cost ###################################################### def hours(date, year_shift=0) - hour_start = date.beginning_of_day + hour_start = date.in_time_zone("Amsterdam").beginning_of_day day_end = hour_start.advance(days: 1) result = [] lowest_hour, highest_hour,high_hours = entsoe.high_low_hours(date) while(hour_start < day_end) do hour_end = hour_start.end_of_hour - formatted_hour = hour_start.strftime("%F-%H") + formatted_hour = hour_start.strftime("%F %H") #p "Fetching meter readings between %s and %s" % [hour_start,hour_end] hour_readings = Reading.where("created_at > :begin AND created_at < :end", {:begin => hour_start, :end => hour_end}) From cef30a9c9851aea636bc566769ca891ffcfc5d01 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 15 Sep 2022 21:45:57 +0200 Subject: [PATCH 065/113] Adjacent time intervals --- app/models/cost.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index ce5df05..97c1c0b 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -133,22 +133,22 @@ class Cost case date.to_time.to_i # Date.parse("2019-12-08").to_time.to_i # From 8 Dec 2019 until 7 Dec 2020 - when 1575759600..1607295600 + when 1575763200..1607385599 #normaal_kwh * (0.07865 + 0.11822 + 0.03303) + dal_kwh * (0.06215 + 0.11822 + 0.03303) normaal_kwh_cost = 0.07865 dal_kwh_cost = 0.06215 # From 8 Dec 2020 until 7 Dec 2021 - when 1607382000..1638831600 + when 1607385600..1638921599 #normaal_kwh * (0.06782 + 0.11408 + 0.03630) + dal_kwh * (0.05259 + 0.11408 + 0.03630) normaal_kwh_cost = 0.06782 dal_kwh_cost = 0.05259 # From 8 Dec 2021 until 7 Sept 2022 - when 1638918000..1662501600 + when 1638921600..1662595199 # normaal_kwh * (0.23665 + 0.04452 + 0.03691) + dal_kwh * (0.19408 + 0.04452 + 0.03691) normaal_kwh_cost = 0.23665 dal_kwh_cost = 0.19408 # From 8 Sept 2022 until 7 Dec 2022 - when 1662588000..1670367600 + when 1662595200..1670457599 normaal_kwh_cost = 0.60824 dal_kwh_cost = 0.43701 end From e0f5865cd8b0a751f5a691b86735e40e32273a04 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 15 Sep 2022 21:55:49 +0200 Subject: [PATCH 066/113] Catch date error --- app/models/entsoe.rb | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index 337803d..f180f20 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -105,11 +105,15 @@ class Entsoe doc = Nokogiri::XML(URI.open(url)) prices = doc.xpath('.//xmlns:Point').map{|p| parse_point(p)} - # get start_time (in UTC) from XML docment - start_time = DateTime.parse(doc.xpath('.//xmlns:period.timeInterval//xmlns:start').text) - - #returns a hash with keys formatted "yyyy-mm-dd hr" and values price (per kwh) - # tag runs from 1-24. We need hours from 00-23, therefore substracting 1 - prices.map{|p| [start_time.advance(hours: (p[0]-1)), p[1]]}.to_h + begin + # get start_time (in UTC) from XML docment + start_time = DateTime.parse(doc.xpath('.//xmlns:period.timeInterval//xmlns:start').text) + + #returns a hash with keys formatted "yyyy-mm-dd hr" and values price (per kwh) + # tag runs from 1-24. We need hours from 00-23, therefore substracting 1 + prices.map{|p| [start_time.advance(hours: (p[0]-1)), p[1]]}.to_h + rescue Date::Error => e + p e.message + end end end From 754a7d3997b4be3b5930d4632f3cab84507aeb9c Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 15 Sep 2022 22:22:27 +0200 Subject: [PATCH 067/113] take yearshift into account --- app/models/cost.rb | 5 +++-- app/models/entsoe.rb | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 97c1c0b..238eb13 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -177,11 +177,10 @@ class Cost hour_start = date.in_time_zone("Amsterdam").beginning_of_day day_end = hour_start.advance(days: 1) result = [] - lowest_hour, highest_hour,high_hours = entsoe.high_low_hours(date) + lowest_hour, highest_hour,high_hours = entsoe.high_low_hours(date.advance(years: year_shift)) while(hour_start < day_end) do hour_end = hour_start.end_of_hour - formatted_hour = hour_start.strftime("%F %H") #p "Fetching meter readings between %s and %s" % [hour_start,hour_end] hour_readings = Reading.where("created_at > :begin AND created_at < :end", {:begin => hour_start, :end => hour_end}) @@ -189,6 +188,8 @@ class Cost # calculate cost of this hour usage_kwh = hour_diff[:total_kwh_consumed_high] + hour_diff[:total_kwh_consumed_low] rescue nil return_kwh = hour_diff[:total_kwh_produced_high] + hour_diff[:total_kwh_produced_low] rescue nil + + formatted_hour = hour_start.advance(years: year_shift).strftime("%F %H") entsoe_cost = entsoe_energy_cost(formatted_hour, usage_kwh, return_kwh) # without battery use # diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index f180f20..cec44eb 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -114,6 +114,7 @@ class Entsoe prices.map{|p| [start_time.advance(hours: (p[0]-1)), p[1]]}.to_h rescue Date::Error => e p e.message + {} end end end From b5c6bf029ef42802aa3eb3fe1185d308e2ed5bcc Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Tue, 20 Sep 2022 15:42:44 +0200 Subject: [PATCH 068/113] Adds bar graphs with cost --- Gemfile | 4 +++ Gemfile.lock | 14 ++++++++ app/models/cost.rb | 82 +++++++++++++++++++--------------------------- ar-no-rails.rb | 3 +- 4 files changed, 53 insertions(+), 50 deletions(-) diff --git a/Gemfile b/Gemfile index 182522b..a847c88 100644 --- a/Gemfile +++ b/Gemfile @@ -8,3 +8,7 @@ gem 'rufus-scheduler' gem 'daemons' gem 'mail' gem 'nokogiri' +gem 'ruby-gr' +gem 'gr-plot' +gem 'histogram' +gem 'numo-narray' diff --git a/Gemfile.lock b/Gemfile.lock index 940edce..b9cfff9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,9 +15,13 @@ GEM daemons (1.4.1) et-orbi (1.2.6) tzinfo + fiddle (1.1.0) fugit (1.5.2) et-orbi (~> 1.1, >= 1.1.8) raabro (~> 1.4) + gr-plot (0.0.1) + ruby-gr + histogram (0.2.4.1) i18n (1.8.11) concurrent-ruby (~> 1.0) mail (2.7.1) @@ -29,8 +33,14 @@ GEM nokogiri (1.13.0) mini_portile2 (~> 2.7.0) racc (~> 1.4) + numo-narray (0.9.2.1) + pkg-config (1.4.9) raabro (1.4.0) racc (1.6.0) + ruby-gr (0.66.0.0) + fiddle + numo-narray + pkg-config rufus-scheduler (3.8.0) fugit (~> 1.1, >= 1.1.6) serialport (1.3.2) @@ -44,9 +54,13 @@ PLATFORMS DEPENDENCIES activerecord daemons + gr-plot + histogram mail mysql2 nokogiri + numo-narray + ruby-gr rufus-scheduler serialport state_pattern diff --git a/app/models/cost.rb b/app/models/cost.rb index 238eb13..41d616d 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -1,4 +1,5 @@ require 'open-uri' +require 'gr/plot' EASY_ENERGY_TARIFFS = {} # See https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/zakelijk/overige_belastingen/belastingen_op_milieugrondslag/tarieven_milieubelastingen/tabellen_tarieven_milieubelastingen @@ -50,65 +51,48 @@ class Cost end ###################################################### - # Easy Energy API (proprietary) - better to use entsoe + # Easy Energy - makes use of entsoe, adds EasyEnergy opslag ###################################################### - # returns a hash with keys formatted "yyyy-mm-dd-hr" and values [usage, return] - # e.g. { 2021-12-16-06"=>[0.36058, 0.298] } - def easy_energy_tariffs(date) - p "Fetching EasyEnergy tariffs for %s" % date.strftime("%F-%H") - - # calculate offset (date is in UTC, and we want to have tariffs in Amsterdam zone) - zone = 'Amsterdam' - offset = DateTime.now.in_time_zone(zone).utc_offset - date = date.beginning_of_day.advance(seconds: offset) - - url = "https://mijn.easyenergy.com/nl/api/tariff/getapxtariffs?startTimestamp=%s&endTimestamp=%s&grouping=" % [date.strftime("%F %T"),date.advance(:hours => 24).strftime("%F %T")] - #p urlvandaag - json = JSON.load(URI::open(url)) - # advancing with 1 hrs (to offset for something?) - json.map{|t| [DateTime.parse(t["Timestamp"]).strftime("%F-%H"), [t["TariffUsage"], t["TariffReturn"]]]}.to_h - end - def easy_energy_rate(formatted_hour) - unless EASY_ENERGY_TARIFFS.key?(formatted_hour) - EASY_ENERGY_TARIFFS.merge!(easy_energy_tariffs(Date.parse(formatted_hour))) - end - EASY_ENERGY_TARIFFS[formatted_hour] + year = Date.parse(formatted_hour).year + case year + when 2020..2022 + # opslag, zonder BTW + 0.00800 + end end def easy_energy_cost(formatted_hour, usage_kwh, return_kwh) return nil if (usage_kwh.nil? || return_kwh.nil?) - usage_kwh_cost, return_kwh_cost = easy_energy_rate(formatted_hour) + usage_kwh_cost = return_kwh_cost = (entsoe.price_at(formatted_hour)+easy_energy_rate(formatted_hour))*(1+vat_at(Date.parse(formatted_hour))) add_tax(formatted_hour, usage_kwh, usage_kwh_cost, return_kwh, return_kwh_cost) end - def easy_energy_high_low(date) - hour_start = date.beginning_of_day + def easy_energy_hours(date) + hour_start = date.in_time_zone(zone).beginning_of_day day_end = hour_start.advance(days: 1) - max_rate = 0.0 - min_rate = 100.0 - min_hour = nil - max_hour = nil + result = [] while(hour_start < day_end) do - easy_usage_rate, easy_return_rate = easy_energy_rate(hour_start.strftime("%F-%H")) - unless easy_usage_rate.nil? - # determine max - if easy_usage_rate > max_rate - max_rate = easy_usage_rate - max_hour = hour_start.strftime("%F-%H") - end - # determine min - if easy_usage_rate < min_rate - min_rate = easy_usage_rate - min_hour = hour_start.strftime("%F-%H") - end - end + formatted_hour = hour_start.strftime("%F %H") + result << easy_energy_cost(formatted_hour, 1, 0) hour_start = hour_start.advance(:hours => 1) - end - return min_hour,max_hour + end + result end + + def easy_energy_barplot(date) + hours = (0..23).to_a + costs = easy_energy_hours(date) + GR.barplot(hours,costs) + # make sure you have set GKS_WSTYPE=100 in the environment (for headless setup) + title = "Kosten per kwH (incl. belastingen en BTW) - %s" % date.strftime("%A, %e %B %Y") + xlabel = "uur" + ylabel = "EUR" + GR.savefig("easy_%s.png" % date.strftime("%F"), title: title, xlabel: xlabel, ylabel: ylabel) + end + ###################################################### # Oxxio rates and cost @@ -174,7 +158,7 @@ class Cost ###################################################### def hours(date, year_shift=0) - hour_start = date.in_time_zone("Amsterdam").beginning_of_day + hour_start = date.in_time_zone(zone).beginning_of_day day_end = hour_start.advance(days: 1) result = [] lowest_hour, highest_hour,high_hours = entsoe.high_low_hours(date.advance(years: year_shift)) @@ -190,7 +174,7 @@ class Cost return_kwh = hour_diff[:total_kwh_produced_high] + hour_diff[:total_kwh_produced_low] rescue nil formatted_hour = hour_start.advance(years: year_shift).strftime("%F %H") - entsoe_cost = entsoe_energy_cost(formatted_hour, usage_kwh, return_kwh) # without battery use + easy_cost = easy_energy_cost(formatted_hour, usage_kwh, return_kwh) # without battery use # # Make battery work @@ -210,7 +194,7 @@ class Cost end end end - entsoe_cost_with_battery = entsoe_energy_cost(formatted_hour, usage_kwh, return_kwh) # with battery use + easy_cost_with_battery = easy_energy_cost(formatted_hour, usage_kwh, return_kwh) # with battery use # # end of battery work @@ -230,8 +214,8 @@ class Cost return_kwh, battery.battery_kwh, entsoe.price_at(formatted_hour), # entsoe rate - entsoe_cost_with_battery, - entsoe_cost, + easy_cost_with_battery, + easy_cost, oxxio_rate, oxxio_cost] diff --git a/ar-no-rails.rb b/ar-no-rails.rb index 60a853c..4f93e91 100644 --- a/ar-no-rails.rb +++ b/ar-no-rails.rb @@ -2,6 +2,8 @@ require "rubygems" require "bundler/setup" require "active_record" require "open-uri" +require 'gr/plot' +require 'histogram' project_root = File.dirname(File.absolute_path(__FILE__)) Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} @@ -9,7 +11,6 @@ Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} connection_details = YAML::load(File.open('config/database.yml')) ActiveRecord::Base.establish_connection(connection_details) - if __FILE__ == $0 puts "Count of Pages: #{Page.count}" end From 6f33caa0d886802b75090674a528ccb1761a69bf Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Tue, 20 Sep 2022 18:10:02 +0200 Subject: [PATCH 069/113] Import debian packages and set environment variables for GR.rb --- Dockerfile | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index abd6ca0..11f759d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM ruby:2.7 -ENV BUILD_PACKAGES="apt-utils build-essential curl less nodejs sudo wget zsh libmariadb-dev libserialport-dev cron" +ENV BUILD_PACKAGES="apt-utils build-essential curl less nodejs sudo wget zsh libmariadb-dev libserialport-dev cron gr" # throw errors if Gemfile has been modified since Gemfile.lock RUN \ @@ -12,10 +12,17 @@ WORKDIR /usr/src/app COPY Gemfile Gemfile.lock ./ RUN \ + echo 'deb http://download.opensuse.org/repositories/science:/gr-framework/Debian_11/ /' | tee /etc/apt/sources.list.d/science:gr-framework.list && \ + curl -fsSL https://download.opensuse.org/repositories/science:gr-framework/Debian_11/Release.key | gpg --dearmor | tee /etc/apt/trusted.gpg.d/science_gr-framework.gpg > /dev/null && \ apt-get update -qq && \ apt-get install -y $BUILD_PACKAGES && \ bundle install COPY . . -CMD ["ruby ./smartmeter.rb"] +# Set required variables for GR.rb +# see https://github.com/red-data-tools/GR.rb +ENV GRDIR="/usr/gr" +ENV GKS_WSTYPE=100 + +CMD ["/bin/bash -c ruby ./smartmeter.rb"] From 5d86e65d8daa46445727f1c1726e0e0683bd025c Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 22 Sep 2022 19:20:07 +0200 Subject: [PATCH 070/113] Adds actual cost barplot --- app/models/cost.rb | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 41d616d..25f5eef 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -82,15 +82,44 @@ class Cost result end - def easy_energy_barplot(date) + def easy_energy_tariff_barplot(date) hours = (0..23).to_a costs = easy_energy_hours(date) GR.barplot(hours,costs) # make sure you have set GKS_WSTYPE=100 in the environment (for headless setup) - title = "Kosten per kwH (incl. belastingen en BTW) - %s" % date.strftime("%A, %e %B %Y") + title = "Tarief per kwH (incl. belastingen en BTW) - %s" % date.strftime("%A, %e %B %Y") xlabel = "uur" ylabel = "EUR" - GR.savefig("easy_%s.png" % date.strftime("%F"), title: title, xlabel: xlabel, ylabel: ylabel) + GR.savefig("easy_tariff_%s.png" % date.strftime("%F"), title: title, xlabel: xlabel, ylabel: ylabel) + end + + def easy_energy_cost_barplot(date) + hour_start = date.in_time_zone(zone).beginning_of_day + day_end = hour_start.advance(days: 1) + costs = [] + while(hour_start < day_end) do + # get usage_kwh/return_kwh for one hour + hour_end = hour_start.end_of_hour + hour_readings = Reading.where("created_at > :begin AND created_at < :end", {:begin => hour_start, :end => hour_end}) + hour_diff = hour_readings.last ? hour_readings.last.diff(hour_readings.first) : UNKNOWN_READING + usage_kwh = hour_diff[:total_kwh_consumed_high] + hour_diff[:total_kwh_consumed_low] + return_kwh = hour_diff[:total_kwh_produced_high] + hour_diff[:total_kwh_produced_low] + + formatted_hour = hour_start.strftime("%F %H") + costs << easy_energy_cost(formatted_hour, usage_kwh, return_kwh) + # do the next hour + hour_start = hour_start.advance(:hours => 1) + end + + # create plot + hours = (0..23).to_a + GR.barplot(hours,costs) + # make sure you have set GKS_WSTYPE=100 in the environment (for headless setup) + title = "Verbruikskosten (incl. belastingen en BTW) - %s" % date.strftime("%A, %e %B %Y") + xlabel = "uur" + ylabel = "EUR" + GR.savefig("easy_cost_%s.png" % date.strftime("%F"), title: title, xlabel: xlabel, ylabel: ylabel) + end From 9587277c65af966fed37a9099a13f73c76cff412 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 22 Sep 2022 22:02:28 +0200 Subject: [PATCH 071/113] Add kWH cost to daily email --- app/helpers/ReadingsMailer.rb | 12 ++++++++++-- app/models/cost.rb | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/helpers/ReadingsMailer.rb b/app/helpers/ReadingsMailer.rb index fbdef4c..5a4fa73 100644 --- a/app/helpers/ReadingsMailer.rb +++ b/app/helpers/ReadingsMailer.rb @@ -25,6 +25,9 @@ class ReadingsMailer # Fetch today's usage usage_today = Reading.diff_on(Date.today) + c = Cost.new + oxxio_cost = c.oxxio_energy_cost(Date.today.to_s,usage_today[:total_kwh_consumed_high]-usage_today[:total_kwh_produced_high], usage_today[:total_kwh_consumed_low]-usage_today[:total_kwh_produced_low]) + easy_cost = c.easy_energy_cost_barplot(Date.today) # side effect: generates a PNG mail = Mail.new do delivery_method :smtp, smtp_opts @@ -37,7 +40,9 @@ class ReadingsMailer -------------------------------\n\n Total kWH electricity consumed: #{usage_today[:total_kwh_consumed_high] + usage_today[:total_kwh_consumed_low]}\n Total kWH electricity produced: #{usage_today[:total_kwh_produced_high] + usage_today[:total_kwh_produced_low]}\n - Total m3 gas consumed: #{usage_today[:total_m3_gas_consumed]}\n + Total m3 gas consumed: #{usage_today[:total_m3_gas_consumed]}\n\n + kWH cost (Oxxio): EUR #{ oxxio_cost }\n + kWH cost (EasyEnergy): EUR #{ easy_cost }\n " end @@ -46,7 +51,10 @@ class ReadingsMailer body "

Summary for #{Date.today}

" + "

Total kWH electricity consumed: #{usage_today[:total_kwh_consumed_high] + usage_today[:total_kwh_consumed_low]}

" + "

Total kWH electricity produced: #{usage_today[:total_kwh_produced_high] + usage_today[:total_kwh_produced_low]}

" + - "

Total m3 gas consumed: #{usage_today[:total_m3_gas_consumed]}

" + "

Total m3 gas consumed: #{usage_today[:total_m3_gas_consumed]}

" + + "
" + + "

kWH cost (Oxxio): EUR #{ oxxio_cost}

" + + "

kWH cost (EasyEnergy): EUR #{ easy_cost}

" end end diff --git a/app/models/cost.rb b/app/models/cost.rb index 25f5eef..44c1967 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -118,8 +118,10 @@ class Cost title = "Verbruikskosten (incl. belastingen en BTW) - %s" % date.strftime("%A, %e %B %Y") xlabel = "uur" ylabel = "EUR" - GR.savefig("easy_cost_%s.png" % date.strftime("%F"), title: title, xlabel: xlabel, ylabel: ylabel) + GR.savefig("plots/easy_cost_%s.png" % date.strftime("%F"), title: title, xlabel: xlabel, ylabel: ylabel) + # return the sum cost + costs.sum end From 719e59c1e5ed8a7d78512673a6c807ffe9bfd037 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 22 Sep 2022 22:10:22 +0200 Subject: [PATCH 072/113] Adds date parameter to report_mailer --- app/helpers/ReadingsMailer.rb | 14 +++++++------- report_mailer.rb | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/helpers/ReadingsMailer.rb b/app/helpers/ReadingsMailer.rb index 5a4fa73..05b48be 100644 --- a/app/helpers/ReadingsMailer.rb +++ b/app/helpers/ReadingsMailer.rb @@ -18,25 +18,25 @@ class ReadingsMailer # Class methods # class << self - def deliver + def deliver(date) # Read SMTP options from smtp.yml smtp_opts = YAML::load(File.open('config/smtp.yml')).symbolize_keys smtp_opts.merge!(SSL_OPTS) if smtp_opts[:ssl] && smtp_opts[:ssl_verify_mode].eql?("none") # Fetch today's usage - usage_today = Reading.diff_on(Date.today) + usage_today = Reading.diff_on(date) c = Cost.new - oxxio_cost = c.oxxio_energy_cost(Date.today.to_s,usage_today[:total_kwh_consumed_high]-usage_today[:total_kwh_produced_high], usage_today[:total_kwh_consumed_low]-usage_today[:total_kwh_produced_low]) - easy_cost = c.easy_energy_cost_barplot(Date.today) # side effect: generates a PNG + oxxio_cost = c.oxxio_energy_cost(date.to_s,usage_today[:total_kwh_consumed_high]-usage_today[:total_kwh_produced_high], usage_today[:total_kwh_consumed_low]-usage_today[:total_kwh_produced_low]) + easy_cost = c.easy_energy_cost_barplot(date) # side effect: generates a PNG mail = Mail.new do delivery_method :smtp, smtp_opts to 'a.t.van.halteren@vu.nl' from 'SmartMeter ' - subject "SmartMeter report for #{Date.today}" + subject "SmartMeter report for #{date}" text_part do - body "Summary for #{Date.today}\n + body "Summary for #{date}\n -------------------------------\n\n Total kWH electricity consumed: #{usage_today[:total_kwh_consumed_high] + usage_today[:total_kwh_consumed_low]}\n Total kWH electricity produced: #{usage_today[:total_kwh_produced_high] + usage_today[:total_kwh_produced_low]}\n @@ -48,7 +48,7 @@ class ReadingsMailer html_part do content_type 'text/html; charset=UTF-8' - body "

Summary for #{Date.today}

" + + body "

Summary for #{date}

" + "

Total kWH electricity consumed: #{usage_today[:total_kwh_consumed_high] + usage_today[:total_kwh_consumed_low]}

" + "

Total kWH electricity produced: #{usage_today[:total_kwh_produced_high] + usage_today[:total_kwh_produced_low]}

" + "

Total m3 gas consumed: #{usage_today[:total_m3_gas_consumed]}

" + diff --git a/report_mailer.rb b/report_mailer.rb index 393010d..48d8e0e 100644 --- a/report_mailer.rb +++ b/report_mailer.rb @@ -12,6 +12,6 @@ connection_details = YAML::load(File.open('config/database.yml')) ActiveRecord::Base.establish_connection(connection_details) if __FILE__ == $0 - ReadingsMailer.deliver + ReadingsMailer.deliver(Date.today) end #p sync From befe79090b5897d64b10a9efeb61a5d2fce6c7a6 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 22 Sep 2022 22:17:51 +0200 Subject: [PATCH 073/113] Adds attachment to email --- app/helpers/ReadingsMailer.rb | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/helpers/ReadingsMailer.rb b/app/helpers/ReadingsMailer.rb index 05b48be..07ec11c 100644 --- a/app/helpers/ReadingsMailer.rb +++ b/app/helpers/ReadingsMailer.rb @@ -28,6 +28,9 @@ class ReadingsMailer c = Cost.new oxxio_cost = c.oxxio_energy_cost(date.to_s,usage_today[:total_kwh_consumed_high]-usage_today[:total_kwh_produced_high], usage_today[:total_kwh_consumed_low]-usage_today[:total_kwh_produced_low]) easy_cost = c.easy_energy_cost_barplot(date) # side effect: generates a PNG + # rounding + oxxio_cost = oxxio_cost.round(2) + easy_cost = easy_cost.round(2) mail = Mail.new do delivery_method :smtp, smtp_opts @@ -53,9 +56,13 @@ class ReadingsMailer "

Total kWH electricity produced: #{usage_today[:total_kwh_produced_high] + usage_today[:total_kwh_produced_low]}

" + "

Total m3 gas consumed: #{usage_today[:total_m3_gas_consumed]}

" + "
" + - "

kWH cost (Oxxio): EUR #{ oxxio_cost}

" + - "

kWH cost (EasyEnergy): EUR #{ easy_cost}

" - end + "

kWH cost (Oxxio): EUR #{ oxxio_cost}

" + + "

kWH cost (EasyEnergy): EUR #{ easy_cost}

" + end + + # add attachment + filename = "easy_cost_%s.png" % date.strftime("%F") + add_file :filename => filename, :content => File.read("plots/%s" % filename) end mail.deliver! From d7994688695244643a8683ff4deaec9c9d4d5bf6 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 3 Oct 2022 19:58:28 +0200 Subject: [PATCH 074/113] Email data from yesterday --- report_mailer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/report_mailer.rb b/report_mailer.rb index 48d8e0e..e2ef47d 100644 --- a/report_mailer.rb +++ b/report_mailer.rb @@ -12,6 +12,6 @@ connection_details = YAML::load(File.open('config/database.yml')) ActiveRecord::Base.establish_connection(connection_details) if __FILE__ == $0 - ReadingsMailer.deliver(Date.today) + ReadingsMailer.deliver(Date.today.advance(days: -1)) end #p sync From c7aa6e5d926d6425aa93d5bd6b2dd0ccb224a77f Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 23 Oct 2022 12:23:08 +0200 Subject: [PATCH 075/113] Email tariffs daily --- app/helpers/TariffsMailer.rb | 46 ++++++++++++++++++++++++++++++++++++ app/models/cost.rb | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 app/helpers/TariffsMailer.rb diff --git a/app/helpers/TariffsMailer.rb b/app/helpers/TariffsMailer.rb new file mode 100644 index 0000000..3d1fc8a --- /dev/null +++ b/app/helpers/TariffsMailer.rb @@ -0,0 +1,46 @@ +require "mail" + +class TariffsMailer + + SSL_OPTS = { + :openssl_verify_mode => OpenSSL::SSL::VERIFY_NONE, + } + + + # + # Class methods + # + class << self + def deliver(date) + # Read SMTP options from smtp.yml + smtp_opts = YAML::load(File.open('config/smtp.yml')).symbolize_keys + smtp_opts.merge!(SSL_OPTS) if smtp_opts[:ssl] && smtp_opts[:ssl_verify_mode].eql?("none") + + # Create png file + c = Cost.new + c.easy_energy_tariff_barplot(Date.today) + + mail = Mail.new do + delivery_method :smtp, smtp_opts + to 'a.t.van.halteren@vu.nl' + from 'SmartMeter ' + subject "EasyEnergy tariffs for #{date}" + + text_part do + body "Tariffs for #{date}\n" + end + + html_part do + content_type 'text/html; charset=UTF-8' + body "

Tariffs for #{date}" + end + + # add attachment + filename = "easy_tariff_%s.png" % date.strftime("%F") + add_file :filename => filename, :content => File.read("plots/%s" % filename) + end + + mail.deliver! + end + end +end \ No newline at end of file diff --git a/app/models/cost.rb b/app/models/cost.rb index 44c1967..b2369c3 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -90,7 +90,7 @@ class Cost title = "Tarief per kwH (incl. belastingen en BTW) - %s" % date.strftime("%A, %e %B %Y") xlabel = "uur" ylabel = "EUR" - GR.savefig("easy_tariff_%s.png" % date.strftime("%F"), title: title, xlabel: xlabel, ylabel: ylabel) + GR.savefig("plots/easy_tariff_%s.png" % date.strftime("%F"), title: title, xlabel: xlabel, ylabel: ylabel) end def easy_energy_cost_barplot(date) From 04bfa9420c8d42b1814fe9ebf3c0e85999c827a4 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 23 Oct 2022 13:42:52 +0200 Subject: [PATCH 076/113] Bug fix --- .gitignore | 3 +++ .project | 1 - app/helpers/TariffsMailer.rb | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ef1577 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.local +.config +.project diff --git a/.project b/.project index 0a8a20e..459fb3a 100644 --- a/.project +++ b/.project @@ -7,7 +7,6 @@ - com.aptana.projects.webnature com.aptana.ruby.core.rubynature diff --git a/app/helpers/TariffsMailer.rb b/app/helpers/TariffsMailer.rb index 3d1fc8a..b7a816f 100644 --- a/app/helpers/TariffsMailer.rb +++ b/app/helpers/TariffsMailer.rb @@ -18,7 +18,7 @@ class TariffsMailer # Create png file c = Cost.new - c.easy_energy_tariff_barplot(Date.today) + c.easy_energy_tariff_barplot(date) mail = Mail.new do delivery_method :smtp, smtp_opts From 2c4050fd65b87e7aec59667136094115780bece5 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 23 Oct 2022 14:02:37 +0200 Subject: [PATCH 077/113] Mail to multiple recipients --- app/helpers/TariffsMailer.rb | 2 +- tariff_mailer.rb | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tariff_mailer.rb diff --git a/app/helpers/TariffsMailer.rb b/app/helpers/TariffsMailer.rb index b7a816f..2c2aaf8 100644 --- a/app/helpers/TariffsMailer.rb +++ b/app/helpers/TariffsMailer.rb @@ -22,7 +22,7 @@ class TariffsMailer mail = Mail.new do delivery_method :smtp, smtp_opts - to 'a.t.van.halteren@vu.nl' + to ['Mannetje ','Vrouwtje '] from 'SmartMeter ' subject "EasyEnergy tariffs for #{date}" diff --git a/tariff_mailer.rb b/tariff_mailer.rb new file mode 100644 index 0000000..3ac7e59 --- /dev/null +++ b/tariff_mailer.rb @@ -0,0 +1,18 @@ +require "rubygems" +require "bundler/setup" +require "active_record" +require "state_pattern" + +project_root = File.dirname(File.absolute_path(__FILE__)) +Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} +Dir.glob(project_root + "/app/helpers/SearchingForSyncState.rb").each{|f| require f} +Dir.glob(project_root + "/app/helpers/*.rb").each{|f| require f} + +connection_details = YAML::load(File.open('config/database.yml')) +ActiveRecord::Base.establish_connection(connection_details) + +if __FILE__ == $0 + # Tomorrows tariffs delivered today + TariffsMailer.deliver(Date.today.advance(days: 1)) +end +#p sync From 9b53b959a4f622cf1a36f4aff20a7c2a38583f11 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 21 Nov 2022 16:55:12 +0100 Subject: [PATCH 078/113] Updated cost model for EasyEnergy --- app/models/cost.rb | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index b2369c3..79ef5ee 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -56,10 +56,23 @@ class Cost def easy_energy_rate(formatted_hour) year = Date.parse(formatted_hour).year + month = Date.parse(formatted_hour).month + case year - when 2020..2022 + when 2020..2021 # opslag, zonder BTW 0.00800 + when 2022 + case month + when 1..11 + # opslag, before increase + 0.00800 + when 12 + # opslag, met BTW: 0,01962 + 0.018 + end + when 2023 + 0.018 end end From 9fe59147ba87f0870c51b1e2244f5bec68810b16 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Fri, 9 Dec 2022 12:23:29 +0100 Subject: [PATCH 079/113] Updated time interval for Oxxio cost --- app/models/cost.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 79ef5ee..ed460a2 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -175,10 +175,15 @@ class Cost # normaal_kwh * (0.23665 + 0.04452 + 0.03691) + dal_kwh * (0.19408 + 0.04452 + 0.03691) normaal_kwh_cost = 0.23665 dal_kwh_cost = 0.19408 - # From 8 Sept 2022 until 7 Dec 2022 - when 1662595200..1670457599 + # From 8 Sept 2022 until 31 December 2025 (may need to extend this) + when 1662595200..1767139200 normaal_kwh_cost = 0.60824 dal_kwh_cost = 0.43701 + else + # catch-all, incase 'formated_hour' is outside any of the cases + normaal_kwh_cost = 0.0 + dal_kwh_cost = 0.0 + end end normaal_cost = add_tax(formatted_hour, normaal_kwh,normaal_kwh_cost,0,0) # return_kwh already accounted for dal_cost = add_tax(formatted_hour, dal_kwh, dal_kwh_cost,0,0) From 10bd014433e5e2487969727445d5bb1e40d999fb Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Fri, 9 Dec 2022 12:25:33 +0100 Subject: [PATCH 080/113] bug fix --- app/models/cost.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index ed460a2..c928344 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -183,7 +183,6 @@ class Cost # catch-all, incase 'formated_hour' is outside any of the cases normaal_kwh_cost = 0.0 dal_kwh_cost = 0.0 - end end normaal_cost = add_tax(formatted_hour, normaal_kwh,normaal_kwh_cost,0,0) # return_kwh already accounted for dal_cost = add_tax(formatted_hour, dal_kwh, dal_kwh_cost,0,0) From d92d240b10469c5d6768c3efa1c3bc9abb07db06 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 1 Jan 2023 17:57:13 +0100 Subject: [PATCH 081/113] Prices 2023 added --- Gemfile | 1 + Gemfile.lock | 5 +++++ app/models/cost.rb | 16 ++++++++++++---- frankenergy.py | 46 +++++++++++++++++++++++++++++++++++++++++++++ stacked_bar.png | Bin 0 -> 69155 bytes 5 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 frankenergy.py create mode 100644 stacked_bar.png diff --git a/Gemfile b/Gemfile index a847c88..0ba5c40 100644 --- a/Gemfile +++ b/Gemfile @@ -12,3 +12,4 @@ gem 'ruby-gr' gem 'gr-plot' gem 'histogram' gem 'numo-narray' +gem 'gruff' diff --git a/Gemfile.lock b/Gemfile.lock index b9cfff9..0232c94 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,6 +21,9 @@ GEM raabro (~> 1.4) gr-plot (0.0.1) ruby-gr + gruff (0.19.0) + histogram + rmagick (>= 4.2) histogram (0.2.4.1) i18n (1.8.11) concurrent-ruby (~> 1.0) @@ -37,6 +40,7 @@ GEM pkg-config (1.4.9) raabro (1.4.0) racc (1.6.0) + rmagick (5.0.0) ruby-gr (0.66.0.0) fiddle numo-narray @@ -55,6 +59,7 @@ DEPENDENCIES activerecord daemons gr-plot + gruff histogram mail mysql2 diff --git a/app/models/cost.rb b/app/models/cost.rb index c928344..42b47ff 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -4,8 +4,8 @@ EASY_ENERGY_TARIFFS = {} # See https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/zakelijk/overige_belastingen/belastingen_op_milieugrondslag/tarieven_milieubelastingen/tabellen_tarieven_milieubelastingen # Without VAT -ENERGY_TAX_KWH = { 2020 => 0.09770, 2021 => 0.09428, 2022 => 0.03679} -ODE_KWH = { 2020 => 0.0273, 2021 => 0.0300, 2022 => 0.0305} +ENERGY_TAX_KWH = { 2020 => 0.09770, 2021 => 0.09428, 2022 => 0.03679, 2023 => 0.12599 } +ODE_KWH = { 2020 => 0.0273, 2021 => 0.0300, 2022 => 0.0305, 2023 => 0.0} # merge by adding values TAX_KWH = ENERGY_TAX_KWH.merge(ODE_KWH){|key, energy_tax, ode| energy_tax + ode} @@ -151,6 +151,9 @@ class Cost high_tariff ? 0.06782 : 0.05259 when 2022 high_tariff ? 0.23665 : 0.19408 + when 2023 + # rate excl. VAT + high_tariff ? 0.47758 : 0.34165 end end @@ -175,10 +178,15 @@ class Cost # normaal_kwh * (0.23665 + 0.04452 + 0.03691) + dal_kwh * (0.19408 + 0.04452 + 0.03691) normaal_kwh_cost = 0.23665 dal_kwh_cost = 0.19408 - # From 8 Sept 2022 until 31 December 2025 (may need to extend this) - when 1662595200..1767139200 + # From 8 Sept 2022 until 31 December 2022 + when 1662595200..1672527599 normaal_kwh_cost = 0.60824 dal_kwh_cost = 0.43701 + # From 1 Jan 2023 until 31 December 2023 + when 1672527600..1704063599 + vat = 1 + vat_at(Date.parse(formatted_hour)) + normaal_kwh_cost = 0.47758*vat + dal_kwh_cost = 0.34165*vat else # catch-all, incase 'formated_hour' is outside any of the cases normaal_kwh_cost = 0.0 diff --git a/frankenergy.py b/frankenergy.py new file mode 100644 index 0000000..ade0eef --- /dev/null +++ b/frankenergy.py @@ -0,0 +1,46 @@ +import requests +from datetime import datetime, timedelta +import csv + +def FrankEnergy(): + now = datetime.now() + + yesterday = datetime.now() + timedelta(days=-1) + startdate = yesterday.strftime("%Y-%m-%d") + enddate = now.strftime("%Y-%m-%d") + + if int(now.strftime("%H")) > 15: + tomorrow = datetime.now() + timedelta(days=2) + startdate = now.strftime("%Y-%m-%d") + enddate = tomorrow.strftime("%Y-%m-%d") + + headers = { "content-type":"application/json" } + + query = f"""query MarketPrices {{ + marketPricesGas(startDate: "{startdate}", endDate: "{enddate}") {{ + from + till + marketPrice + priceIncludingMarkup + }} + }}""" + + response = requests.post('https://graphcdn.frankenergie.nl', json={'query': query}, headers=headers) + data = response.json() + + frank_gas_file = "frank_gas.csv" + + frank_headers = ['till', 'from', 'marketPrice', 'priceIncludingMarkup'] + + with open(frank_gas_file, 'w') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames = frank_headers) + writer.writeheader() + writer.writerows(data['data']['marketPricesGas']) + + for gas in data['data']['marketPricesGas']: + print(gas['till'], gas['from'],gas['marketPrice'], gas['priceIncludingMarkup']) + + +if __name__ == "__main__": + FrankEnergy() + diff --git a/stacked_bar.png b/stacked_bar.png new file mode 100644 index 0000000000000000000000000000000000000000..99dbbdec5d5f1a48a2f8d1a3c88b470b427703c1 GIT binary patch literal 69155 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 Date: Sun, 22 Jan 2023 17:23:49 +0100 Subject: [PATCH 082/113] Use gruff gem to generate bar graphs --- Gemfile | 1 + Gemfile.lock | 1 + app/helpers/TariffsMailer.rb | 2 +- app/models/cost.rb | 43 ++++--- config/locales/nl.yml | 213 +++++++++++++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 config/locales/nl.yml diff --git a/Gemfile b/Gemfile index 0ba5c40..c61cae7 100644 --- a/Gemfile +++ b/Gemfile @@ -12,4 +12,5 @@ gem 'ruby-gr' gem 'gr-plot' gem 'histogram' gem 'numo-narray' +gem 'i18n' gem 'gruff' diff --git a/Gemfile.lock b/Gemfile.lock index 0232c94..c6ed4ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -61,6 +61,7 @@ DEPENDENCIES gr-plot gruff histogram + i18n mail mysql2 nokogiri diff --git a/app/helpers/TariffsMailer.rb b/app/helpers/TariffsMailer.rb index 2c2aaf8..7d0e40a 100644 --- a/app/helpers/TariffsMailer.rb +++ b/app/helpers/TariffsMailer.rb @@ -3,7 +3,7 @@ require "mail" class TariffsMailer SSL_OPTS = { - :openssl_verify_mode => OpenSSL::SSL::VERIFY_NONE, + :openssl_verify_mode => OpenSSL::SSL::VERIFY_NONE, } diff --git a/app/models/cost.rb b/app/models/cost.rb index 42b47ff..ae82895 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -1,5 +1,7 @@ require 'open-uri' -require 'gr/plot' +require 'i18n' +require 'gruff' + EASY_ENERGY_TARIFFS = {} # See https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/zakelijk/overige_belastingen/belastingen_op_milieugrondslag/tarieven_milieubelastingen/tabellen_tarieven_milieubelastingen @@ -20,6 +22,11 @@ class Cost @entsoe = Entsoe.new(zone, storage_cost) @max_charge_kwh = max_charge @battery = Battery.new(battery_capacity) + + # Allow for Dutch titles in graphs + I18n.load_path += Dir[File.expand_path("config/locales") + "/*.yml"] + I18n.available_locales = [:en, :nl] + I18n.locale = :nl end # Government reduced VAT to 9% from 1 July 2022 until 31 Dec 2022 @@ -98,12 +105,16 @@ class Cost def easy_energy_tariff_barplot(date) hours = (0..23).to_a costs = easy_energy_hours(date) - GR.barplot(hours,costs) - # make sure you have set GKS_WSTYPE=100 in the environment (for headless setup) - title = "Tarief per kwH (incl. belastingen en BTW) - %s" % date.strftime("%A, %e %B %Y") - xlabel = "uur" - ylabel = "EUR" - GR.savefig("plots/easy_tariff_%s.png" % date.strftime("%F"), title: title, xlabel: xlabel, ylabel: ylabel) + g = Gruff::StackedBar.new() + g.title = "Tarief per kwH (incl. belastingen en BTW) - %s" % I18n.localize(date, format: "%A, %e %B %Y") + g.x_axis_label = "uur" + #g.y_axis_label = "EUR" + g.y_axis_label_format = lambda do |value| + "€ %.2f" % value + end + g.labels = hours + g.data :costs, costs + g.write("plots/easy_tariff_%s.png" % date.strftime("%F")) end def easy_energy_cost_barplot(date) @@ -126,13 +137,17 @@ class Cost # create plot hours = (0..23).to_a - GR.barplot(hours,costs) - # make sure you have set GKS_WSTYPE=100 in the environment (for headless setup) - title = "Verbruikskosten (incl. belastingen en BTW) - %s" % date.strftime("%A, %e %B %Y") - xlabel = "uur" - ylabel = "EUR" - GR.savefig("plots/easy_cost_%s.png" % date.strftime("%F"), title: title, xlabel: xlabel, ylabel: ylabel) - + g = Gruff::StackedBar.new() + g.title = "Verbruikskosten (incl. belastingen en BTW) - %s" % I18n.localize(date, format: "%A, %e %B %Y") + g.x_axis_label = "uur" + #g.y_axis_label = "EUR" + g.y_axis_label_format = lambda do |value| + "€ %.2f" % value + end + g.labels = hours + g.data :costs, costs + g.write("plots/easy_tariff_%s.png" % date.strftime("%F")) + # return the sum cost costs.sum end diff --git a/config/locales/nl.yml b/config/locales/nl.yml new file mode 100644 index 0000000..2f9ec0f --- /dev/null +++ b/config/locales/nl.yml @@ -0,0 +1,213 @@ +--- +nl: + activerecord: + errors: + messages: + record_invalid: 'Validatie mislukt: %{errors}' + restrict_dependent_destroy: + has_one: Kan item niet verwijderen omdat %{record} afhankelijk is + has_many: Kan item niet verwijderen omdat afhankelijke %{record} bestaan + date: + abbr_day_names: + - zo + - ma + - di + - wo + - do + - vr + - za + abbr_month_names: + - + - jan + - feb + - mrt + - apr + - mei + - jun + - jul + - aug + - sep + - okt + - nov + - dec + day_names: + - zondag + - maandag + - dinsdag + - woensdag + - donderdag + - vrijdag + - zaterdag + formats: + default: "%d-%m-%Y" + long: "%e %B %Y" + short: "%e %b" + month_names: + - + - januari + - februari + - maart + - april + - mei + - juni + - juli + - augustus + - september + - oktober + - november + - december + order: + - :day + - :month + - :year + datetime: + distance_in_words: + about_x_hours: + one: ongeveer een uur + other: ongeveer %{count} uur + about_x_months: + one: ongeveer een maand + other: ongeveer %{count} maanden + about_x_years: + one: ongeveer een jaar + other: ongeveer %{count} jaar + almost_x_years: + one: bijna een jaar + other: bijna %{count} jaar + half_a_minute: een halve minuut + less_than_x_seconds: + one: minder dan een seconde + other: minder dan %{count} seconden + less_than_x_minutes: + one: minder dan een minuut + other: minder dan %{count} minuten + over_x_years: + one: meer dan een jaar + other: meer dan %{count} jaar + x_seconds: + one: "%{count} seconde" + other: "%{count} seconden" + x_minutes: + one: "%{count} minuut" + other: "%{count} minuten" + x_days: + one: "%{count} dag" + other: "%{count} dagen" + x_months: + one: "%{count} maand" + other: "%{count} maanden" + x_years: + one: "%{count} jaar" + other: "%{count} jaar" + prompts: + second: seconde + minute: minuut + hour: uur + day: dag + month: maand + year: jaar + errors: + format: "%{attribute} %{message}" + messages: + accepted: moet worden geaccepteerd + blank: moet opgegeven zijn + confirmation: komt niet overeen met %{attribute} + empty: moet opgegeven zijn + equal_to: moet gelijk zijn aan %{count} + even: moet even zijn + exclusion: is gereserveerd + greater_than: moet groter zijn dan %{count} + greater_than_or_equal_to: moet groter dan of gelijk zijn aan %{count} + inclusion: is niet in de lijst opgenomen + invalid: is ongeldig + less_than: moet minder zijn dan %{count} + less_than_or_equal_to: moet minder dan of gelijk zijn aan %{count} + model_invalid: 'Validatie mislukt: %{errors}' + not_a_number: is geen getal + not_an_integer: moet een geheel getal zijn + odd: moet oneven zijn + other_than: moet anders zijn dan %{count} + present: moet leeg zijn + required: moet bestaan + taken: is al in gebruik + too_long: + one: is te lang (maximaal %{count} teken) + other: is te lang (maximaal %{count} tekens) + too_short: + one: is te kort (minimaal %{count} teken) + other: is te kort (minimaal %{count} tekens) + wrong_length: + one: heeft onjuiste lengte (moet %{count} teken lang zijn) + other: heeft onjuiste lengte (moet %{count} tekens lang zijn) + template: + body: 'Er zijn problemen met de volgende velden:' + header: + one: "%{model} niet opgeslagen: %{count} fout gevonden" + other: "%{model} niet opgeslagen: %{count} fouten gevonden" + helpers: + select: + prompt: Maak een keuze + submit: + create: "%{model} toevoegen" + submit: "%{model} opslaan" + update: "%{model} bijwerken" + number: + currency: + format: + delimiter: "." + format: "%u %n" + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + unit: "€" + format: + delimiter: "." + precision: 2 + separator: "," + significant: false + strip_insignificant_zeros: false + human: + decimal_units: + format: "%n %u" + units: + billion: miljard + million: miljoen + quadrillion: biljard + thousand: duizend + trillion: biljoen + unit: '' + format: + delimiter: '' + precision: 3 + significant: true + strip_insignificant_zeros: true + storage_units: + format: "%n %u" + units: + byte: + one: byte + other: bytes + gb: GB + kb: KB + mb: MB + tb: TB + percentage: + format: + delimiter: '' + format: "%n%" + precision: + format: + delimiter: '' + support: + array: + last_word_connector: " en " + two_words_connector: " en " + words_connector: ", " + time: + am: "'s ochtends" + formats: + default: "%a %d %b %Y %H:%M:%S %Z" + long: "%d %B %Y %H:%M" + short: "%d %b %H:%M" + pm: "'s middags" \ No newline at end of file From db3be6b0d746c37695051424a8b4b08cef6facdc Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 22 Jan 2023 17:36:39 +0100 Subject: [PATCH 083/113] - Remove old GR dependencies --- Dockerfile | 2 +- Gemfile | 3 --- app/models/cost.rb | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 11f759d..e299a5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM ruby:2.7 -ENV BUILD_PACKAGES="apt-utils build-essential curl less nodejs sudo wget zsh libmariadb-dev libserialport-dev cron gr" +ENV BUILD_PACKAGES="apt-utils build-essential curl less nodejs sudo wget zsh libmariadb-dev libserialport-dev cron" # throw errors if Gemfile has been modified since Gemfile.lock RUN \ diff --git a/Gemfile b/Gemfile index c61cae7..fb5bd36 100644 --- a/Gemfile +++ b/Gemfile @@ -8,9 +8,6 @@ gem 'rufus-scheduler' gem 'daemons' gem 'mail' gem 'nokogiri' -gem 'ruby-gr' -gem 'gr-plot' -gem 'histogram' gem 'numo-narray' gem 'i18n' gem 'gruff' diff --git a/app/models/cost.rb b/app/models/cost.rb index ae82895..74103ed 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -146,7 +146,7 @@ class Cost end g.labels = hours g.data :costs, costs - g.write("plots/easy_tariff_%s.png" % date.strftime("%F")) + g.write("plots/easy_cost_%s.png" % date.strftime("%F")) # return the sum cost costs.sum From c66618d015fb7ba588e47b999b3d382a27e084ed Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 22 Jan 2023 17:38:34 +0100 Subject: [PATCH 084/113] Remove gr dependencies --- Dockerfile | 2 -- Gemfile.lock | 11 ----------- 2 files changed, 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index e299a5c..acaf616 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,6 @@ WORKDIR /usr/src/app COPY Gemfile Gemfile.lock ./ RUN \ - echo 'deb http://download.opensuse.org/repositories/science:/gr-framework/Debian_11/ /' | tee /etc/apt/sources.list.d/science:gr-framework.list && \ - curl -fsSL https://download.opensuse.org/repositories/science:gr-framework/Debian_11/Release.key | gpg --dearmor | tee /etc/apt/trusted.gpg.d/science_gr-framework.gpg > /dev/null && \ apt-get update -qq && \ apt-get install -y $BUILD_PACKAGES && \ bundle install diff --git a/Gemfile.lock b/Gemfile.lock index c6ed4ed..7616c11 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,12 +15,9 @@ GEM daemons (1.4.1) et-orbi (1.2.6) tzinfo - fiddle (1.1.0) fugit (1.5.2) et-orbi (~> 1.1, >= 1.1.8) raabro (~> 1.4) - gr-plot (0.0.1) - ruby-gr gruff (0.19.0) histogram rmagick (>= 4.2) @@ -37,14 +34,9 @@ GEM mini_portile2 (~> 2.7.0) racc (~> 1.4) numo-narray (0.9.2.1) - pkg-config (1.4.9) raabro (1.4.0) racc (1.6.0) rmagick (5.0.0) - ruby-gr (0.66.0.0) - fiddle - numo-narray - pkg-config rufus-scheduler (3.8.0) fugit (~> 1.1, >= 1.1.6) serialport (1.3.2) @@ -58,15 +50,12 @@ PLATFORMS DEPENDENCIES activerecord daemons - gr-plot gruff - histogram i18n mail mysql2 nokogiri numo-narray - ruby-gr rufus-scheduler serialport state_pattern From 7084e0fc989f149ed24653da108a769839252709 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sun, 22 Jan 2023 17:45:15 +0100 Subject: [PATCH 085/113] One more dependency gone --- Dockerfile | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index acaf616..3b7ca67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,9 +18,4 @@ RUN \ COPY . . -# Set required variables for GR.rb -# see https://github.com/red-data-tools/GR.rb -ENV GRDIR="/usr/gr" -ENV GKS_WSTYPE=100 - CMD ["/bin/bash -c ruby ./smartmeter.rb"] From a3c8db704b163ac4fe08b546913d1ccc4e36eb19 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 16 Feb 2023 10:11:12 +0100 Subject: [PATCH 086/113] New Entsoe URL --- app/models/entsoe.rb | 2 +- stacked_bar.png | Bin 69155 -> 0 bytes 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 stacked_bar.png diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index cec44eb..1ffb162 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -8,7 +8,7 @@ require 'nokogiri' class Entsoe - URL = 'https://transparency.entsoe.eu' + URL = 'https://web-api.tp.entsoe.eu/api' attr_accessor :no_grid_charge_months, :zone attr_reader :storage_cost diff --git a/stacked_bar.png b/stacked_bar.png deleted file mode 100644 index 99dbbdec5d5f1a48a2f8d1a3c88b470b427703c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69155 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 Date: Thu, 16 Feb 2023 10:22:30 +0100 Subject: [PATCH 087/113] Better URL --- app/models/entsoe.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index 1ffb162..4090a44 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -8,7 +8,7 @@ require 'nokogiri' class Entsoe - URL = 'https://web-api.tp.entsoe.eu/api' + URL = 'https://web-api.tp.entsoe.eu' attr_accessor :no_grid_charge_months, :zone attr_reader :storage_cost From e74f488668f5e023b0548ef656071660233dad53 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 20 Feb 2023 15:14:17 +0100 Subject: [PATCH 088/113] Use regular bar graph i.s.o. stacked bar graph. --- app/models/cost.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 74103ed..480f8de 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -105,7 +105,7 @@ class Cost def easy_energy_tariff_barplot(date) hours = (0..23).to_a costs = easy_energy_hours(date) - g = Gruff::StackedBar.new() + g = Gruff::Bar.new() g.title = "Tarief per kwH (incl. belastingen en BTW) - %s" % I18n.localize(date, format: "%A, %e %B %Y") g.x_axis_label = "uur" #g.y_axis_label = "EUR" @@ -137,7 +137,7 @@ class Cost # create plot hours = (0..23).to_a - g = Gruff::StackedBar.new() + g = Gruff::Bar.new g.title = "Verbruikskosten (incl. belastingen en BTW) - %s" % I18n.localize(date, format: "%A, %e %B %Y") g.x_axis_label = "uur" #g.y_axis_label = "EUR" From ccb8589a4bcaaed746a62aa81faf6e0b5fc07b0e Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 20 Feb 2023 18:13:50 +0100 Subject: [PATCH 089/113] Refactor to prepare for getting monthly overview --- app/helpers/ReadingsMailer.rb | 7 +++-- app/models/cost.rb | 57 +++++++++++++++++++++++++++-------- app/models/reading.rb | 19 ++++++++++++ 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/app/helpers/ReadingsMailer.rb b/app/helpers/ReadingsMailer.rb index 07ec11c..ce8792b 100644 --- a/app/helpers/ReadingsMailer.rb +++ b/app/helpers/ReadingsMailer.rb @@ -25,6 +25,9 @@ class ReadingsMailer # Fetch today's usage usage_today = Reading.diff_on(date) + consumption_today, production_today = Reading.consumed_and_produced_for_diff(usage_today) + + # Calculate costs for oxxio and easy energy c = Cost.new oxxio_cost = c.oxxio_energy_cost(date.to_s,usage_today[:total_kwh_consumed_high]-usage_today[:total_kwh_produced_high], usage_today[:total_kwh_consumed_low]-usage_today[:total_kwh_produced_low]) easy_cost = c.easy_energy_cost_barplot(date) # side effect: generates a PNG @@ -41,8 +44,8 @@ class ReadingsMailer text_part do body "Summary for #{date}\n -------------------------------\n\n - Total kWH electricity consumed: #{usage_today[:total_kwh_consumed_high] + usage_today[:total_kwh_consumed_low]}\n - Total kWH electricity produced: #{usage_today[:total_kwh_produced_high] + usage_today[:total_kwh_produced_low]}\n + Total kWH electricity consumed: #{consumption_today}\n + Total kWH electricity produced: #{production_today}\n Total m3 gas consumed: #{usage_today[:total_m3_gas_consumed]}\n\n kWH cost (Oxxio): EUR #{ oxxio_cost }\n kWH cost (EasyEnergy): EUR #{ easy_cost }\n diff --git a/app/models/cost.rb b/app/models/cost.rb index 480f8de..3a86cac 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -116,24 +116,57 @@ class Cost g.data :costs, costs g.write("plots/easy_tariff_%s.png" % date.strftime("%F")) end - - def easy_energy_cost_barplot(date) - hour_start = date.in_time_zone(zone).beginning_of_day - day_end = hour_start.advance(days: 1) + + # calculate the hourly cost between start_hour and end_hour + def easy_energy_hourly_cost_between(start_hour, end_hour) + begin_hour = start_hour costs = [] - while(hour_start < day_end) do + while(begin_hour < end_hour) do # get usage_kwh/return_kwh for one hour - hour_end = hour_start.end_of_hour - hour_readings = Reading.where("created_at > :begin AND created_at < :end", {:begin => hour_start, :end => hour_end}) + begin_hour_plus1 = begin_hour.end_of_hour + hour_readings = Reading.where("created_at > :begin AND created_at < :end", {:begin => begin_hour, :end => begin_hour_plus1}) hour_diff = hour_readings.last ? hour_readings.last.diff(hour_readings.first) : UNKNOWN_READING - usage_kwh = hour_diff[:total_kwh_consumed_high] + hour_diff[:total_kwh_consumed_low] - return_kwh = hour_diff[:total_kwh_produced_high] + hour_diff[:total_kwh_produced_low] - - formatted_hour = hour_start.strftime("%F %H") + # helper to remove distiction between low/high tarif consumption + usage_kwh, return_kwh = Reading.consumed_and_produced_for_diff(hour_diff) + + formatted_hour = begin_hour.strftime("%F %H") costs << easy_energy_cost(formatted_hour, usage_kwh, return_kwh) # do the next hour - hour_start = hour_start.advance(:hours => 1) + begin_hour = begin_hour.advance(:hours => 1) end + # give the result + costs + end + + def easy_energy_daily_cost_between(start_day, end_day) + curr_day = start_day + costs = [] + while (curr_day <= end_day) do + hour_start = curr_day.in_time_zone(zone).beginning_of_day + hour_end = hour_start.advance(days: 1) + + costs_24hours = easy_energy_hourly_cost_between(hour_start,hour_end) + if costs_24hours.any?{ |e| e.nil? } + p "Not all Reading data between %s and %s is available!" % [I18n.localize(start_day, format: "%e %B %Y"), I18n.localize(end_day, format: "%e %B %Y")] + end + # add the sum of 24 hours + costs << costs_24hours.compact.sum + # do the next day + curr_day = curr_day.advance(:days => 1) + end + # give the result + costs + end + + def easy_energy_hourly_cost_for(date) + hour_start = date.in_time_zone(zone).beginning_of_day + day_end = hour_start.advance(days: 1) + easy_energy_hourly_cost_between(hour_start, day_end) + end + + def easy_energy_cost_barplot(date) + # get array with 24 hourly cost values + costs = easy_energy_hourly_cost_for(date) # create plot hours = (0..23).to_a diff --git a/app/models/reading.rb b/app/models/reading.rb index 548ed55..83a7325 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -76,6 +76,14 @@ class Reading < ActiveRecord::Base @@max_charge_kwh end + # do not make distinction between high and low consumption/production + def consumed_and_produced_for_diff(d) + + usage_kwh = (d[:total_kwh_consumed_high].nil? || d[:total_kwh_consumed_low].nil?) ? nil : d[:total_kwh_consumed_high] + d[:total_kwh_consumed_low] + return_kwh = (d[:total_kwh_produced_high].nil? || d[:total_kwh_produced_low].nil?) ? nil : d[:total_kwh_produced_high] + d[:total_kwh_produced_low] + + return usage_kwh, return_kwh + end def diff_on(date) readings_on = day(date) @@ -87,5 +95,16 @@ class Reading < ActiveRecord::Base { :total_kwh_consumed_high => 0, :total_kwh_consumed_low => 0, :total_kwh_produced_high => 0, :total_kwh_produced_low => 0, :total_m3_gas_consumed => 0 } end end + + def diff_between(from_date, to_date) + readings_on_days = days(from_date, to_date) + first = readings_on_days.first + last = readings_on_days.last + if last + last.diff(first) + else + { :total_kwh_consumed_high => 0, :total_kwh_consumed_low => 0, :total_kwh_produced_high => 0, :total_kwh_produced_low => 0, :total_m3_gas_consumed => 0 } + end + end end end From 534e8dc294b416ba99c65ae30e1ba3e1a6178162 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Tue, 21 Feb 2023 22:43:22 +0100 Subject: [PATCH 090/113] Send monthly reports --- app/helpers/ReadingsMailer.rb | 78 +++++++++++++++++++++++++++++++++-- app/models/cost.rb | 71 +++++++++++++++++++++++++++---- app/models/reading.rb | 36 +++++++++++++++- report_mailer.rb | 9 +++- 4 files changed, 182 insertions(+), 12 deletions(-) diff --git a/app/helpers/ReadingsMailer.rb b/app/helpers/ReadingsMailer.rb index ce8792b..26d6c18 100644 --- a/app/helpers/ReadingsMailer.rb +++ b/app/helpers/ReadingsMailer.rb @@ -26,10 +26,12 @@ class ReadingsMailer # Fetch today's usage usage_today = Reading.diff_on(date) consumption_today, production_today = Reading.consumed_and_produced_for_diff(usage_today) + net_consumption_high, net_consumption_low = Reading.net_consumed_high_and_low_for_diff(usage_today) # Calculate costs for oxxio and easy energy c = Cost.new - oxxio_cost = c.oxxio_energy_cost(date.to_s,usage_today[:total_kwh_consumed_high]-usage_today[:total_kwh_produced_high], usage_today[:total_kwh_consumed_low]-usage_today[:total_kwh_produced_low]) + oxxio_normaal_cost, oxxio_dal_cost = c.oxxio_energy_cost(date.to_s,net_consumption_high,net_consumption_low) + oxxio_cost = oxxio_normaal_cost+oxxio_dal_cost easy_cost = c.easy_energy_cost_barplot(date) # side effect: generates a PNG # rounding oxxio_cost = oxxio_cost.round(2) @@ -55,8 +57,8 @@ class ReadingsMailer html_part do content_type 'text/html; charset=UTF-8' body "

Summary for #{date}

" + - "

Total kWH electricity consumed: #{usage_today[:total_kwh_consumed_high] + usage_today[:total_kwh_consumed_low]}

" + - "

Total kWH electricity produced: #{usage_today[:total_kwh_produced_high] + usage_today[:total_kwh_produced_low]}

" + + "

Total kWH electricity consumed: #{consumption_today}

" + + "

Total kWH electricity produced: #{production_today}

" + "

Total m3 gas consumed: #{usage_today[:total_m3_gas_consumed]}

" + "
" + "

kWH cost (Oxxio): EUR #{ oxxio_cost}

" + @@ -70,5 +72,75 @@ class ReadingsMailer mail.deliver! end + + # default is current year + def deliver_for_month(month, year=nil) + return if (month <1 || month >12) + + year = Date.today.year unless year + date = Date.parse("%s%.2d01" % [year,month]) + date_str = date.strftime("%B %Y") + + # Read SMTP options from smtp.yml + smtp_opts = YAML::load(File.open('config/smtp.yml')).symbolize_keys + smtp_opts.merge!(SSL_OPTS) if smtp_opts[:ssl] && smtp_opts[:ssl_verify_mode].eql?("none") + + # Fetch today's usage + usage_month = Reading.diff_in_month(date) + consumption_month, production_month = Reading.consumed_and_produced_for_diff(usage_month) + net_consumption_normaal, net_consumption_dal = Reading.net_consumed_high_and_low_for_diff(usage_month) + + + # Calculate costs for oxxio and easy energy + c = Cost.new + vat = 1 + c.vat_at(date) + + # cost with VAT, but without energiebelasting. + oxxio_raw_cost = c.oxxio_energy_cost(date.to_s,net_consumption_normaal, net_consumption_dal, false).sum.round(2) + easy_energy_raw_cost = c.easy_energy_raw_cost_in_month(date).compact.sum.round(2) + + # cost with VAT and with energiebelasting + oxxio_cost = c.oxxio_energy_cost(date.to_s,net_consumption_normaal, net_consumption_dal).sum.round(2) + easy_energy_cost = c.easy_energy_cost_in_month(date).compact.sum.round(2) + + # opslag Easy Energy + easy_energy_opslag = (c.easy_energy_rate(date.to_s)*vat*(net_consumption_normaal + net_consumption_dal)).round(2) + + mail = Mail.new do + delivery_method :smtp, smtp_opts + to 'a.t.van.halteren@vu.nl' + from 'SmartMeter ' + subject "SmartMeter Month report for #{date_str}" + + text_part do + body "Summary for #{date_str}\n + -------------------------------\n\n + Total kWH electricity consumed: #{consumption_month}\n + Total kWH electricity produced: #{production_month}\n + Total m3 gas consumed: #{usage_month[:total_m3_gas_consumed]}\n\n + Levering kWH cost (Oxxio) : EUR #{ oxxio_raw_cost }\n + Levering kWH cost (EasyEnergy): EUR #{ easy_energy_raw_cost }\n + Total kWH cost (Oxxio) : EUR #{ oxxio_cost }\n + Total kWH cost (EasyEnergy): EUR #{ easy_energy_cost }, inclusief opslag van EUR #{ easy_energy_opslag }\n + " + end + + html_part do + content_type 'text/html; charset=UTF-8' + body "

Summary for #{date_str}

" + + "

Total kWH electricity consumed: #{consumption_month}

" + + "

Total kWH electricity produced: #{production_month}

" + + "

Total m3 gas consumed: #{usage_month[:total_m3_gas_consumed]}

" + + "
" + + "

Levering kWH cost (Oxxio): EUR #{ oxxio_raw_cost}

" + + "

Levering kWH cost (EasyEnergy): EUR #{ easy_energy_raw_cost}

" + + "

Total kWH cost (Oxxio): EUR #{ oxxio_cost}

" + + "

Total kWH cost (EasyEnergy): EUR #{ easy_energy_cost} , inclusief opslag van EUR #{ easy_energy_opslag }

" + end + + end + + mail.deliver! + end end end \ No newline at end of file diff --git a/app/models/cost.rb b/app/models/cost.rb index 3a86cac..c7b377c 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -45,7 +45,9 @@ class Cost end # Assume: usage_kwh_cost, return_kwh_cost already includes vat - def add_tax(formatted_hour,usage_kwh,usage_kwh_cost,return_kwh, return_kwh_cost) + # calling this with only 'usage_kwh' parameter returns the tax amount for those kwh only. + # + def add_tax(formatted_hour,usage_kwh,usage_kwh_cost=0.0,return_kwh=0.0, return_kwh_cost=0.0) return nil if (usage_kwh.nil? || usage_kwh_cost.nil? || return_kwh.nil? || return_kwh_cost.nil?) vat = 1 + vat_at(Date.parse(formatted_hour)) @@ -117,6 +119,32 @@ class Cost g.write("plots/easy_tariff_%s.png" % date.strftime("%F")) end + # calculate the hourly cost (raw!) between start_hour and end_hour + # raw means: entsoe prices + VAT + def easy_energy_raw_hourly_cost_between(start_hour, end_hour) + begin_hour = start_hour + costs = [] + while(begin_hour < end_hour) do + # get usage_kwh/return_kwh for one hour + begin_hour_plus1 = begin_hour.end_of_hour + hour_readings = Reading.where("created_at > :begin AND created_at < :end", {:begin => begin_hour, :end => begin_hour_plus1}) + hour_diff = hour_readings.last ? hour_readings.last.diff(hour_readings.first) : UNKNOWN_READING + # helper to remove distiction between low/high tarif consumption + usage_kwh, return_kwh = Reading.consumed_and_produced_for_diff(hour_diff) + + formatted_hour = begin_hour.strftime("%F %H") + # calculate RAW price (entsoe price + VAT) + kwh_price = entsoe.price_at(formatted_hour)*(1+vat_at(Date.parse(formatted_hour))) + hour_cost = (usage_kwh.nil? || return_kwh.nil? || kwh_price.nil?) ? nil : ((usage_kwh-return_kwh)*kwh_price) + costs << hour_cost + + # do the next hour + begin_hour = begin_hour.advance(:hours => 1) + end + # give the result + costs + end + # calculate the hourly cost between start_hour and end_hour def easy_energy_hourly_cost_between(start_hour, end_hour) begin_hour = start_hour @@ -158,6 +186,26 @@ class Cost costs end + # calculate leveringskosten (entsoe prices + VAT) in one month + # date = arbitrary day for the month + def easy_energy_raw_cost_in_month(date) + hour_start = date.beginning_of_month.in_time_zone(zone).beginning_of_day + hour_end = date.end_of_month.in_time_zone(zone).end_of_day + # can't get cost for the future + hour_end = Time.now if hour_end > Time.now + + easy_energy_raw_hourly_cost_between(hour_start,hour_end) + end + + def easy_energy_cost_in_month(date) + hour_start = date.beginning_of_month.in_time_zone(zone).beginning_of_day + hour_end = date.end_of_month.in_time_zone(zone).end_of_day + # can't get cost for the future + hour_end = Time.now if hour_end > Time.now + + easy_energy_hourly_cost_between(hour_start,hour_end) + end + def easy_energy_hourly_cost_for(date) hour_start = date.in_time_zone(zone).beginning_of_day day_end = hour_start.advance(days: 1) @@ -204,8 +252,9 @@ class Cost high_tariff ? 0.47758 : 0.34165 end end - - def oxxio_energy_cost(formatted_hour, normaal_kwh, dal_kwh, year_shift=0) + + # Optional: raw! (tarif + VAT) cost + def oxxio_energy_cost(formatted_hour, normaal_kwh, dal_kwh, with_tax=true, year_shift=0) return nil if (normaal_kwh.nil? || dal_kwh.nil?) #year = Date.parse(formatted_hour).year+year_shift date = Date.parse(formatted_hour).advance(years: year_shift) @@ -236,16 +285,24 @@ class Cost normaal_kwh_cost = 0.47758*vat dal_kwh_cost = 0.34165*vat else + p "Not supported interval Oxxio for value: %d" % date.to_time.to_i # catch-all, incase 'formated_hour' is outside any of the cases normaal_kwh_cost = 0.0 dal_kwh_cost = 0.0 end - normaal_cost = add_tax(formatted_hour, normaal_kwh,normaal_kwh_cost,0,0) # return_kwh already accounted for - dal_cost = add_tax(formatted_hour, dal_kwh, dal_kwh_cost,0,0) + + if with_tax + normaal_cost = add_tax(formatted_hour, normaal_kwh,normaal_kwh_cost) # return_kwh already accounted for + dal_cost = add_tax(formatted_hour, dal_kwh, dal_kwh_cost) + else + normaal_cost = normaal_kwh*normaal_kwh_cost + dal_cost = dal_kwh*dal_kwh_cost + end + # result - normaal_cost + dal_cost + return normaal_cost, dal_cost end - + # # Entsoe # diff --git a/app/models/reading.rb b/app/models/reading.rb index 83a7325..66f9920 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -67,6 +67,16 @@ class Reading < ActiveRecord::Base def day(date) Reading.where("created_at > :begin AND created_at < :end", { :begin => date.to_date.beginning_of_day, :end => date.to_date.end_of_day}) end + + def in_month(date) + hour_start = date.beginning_of_month.beginning_of_day + hour_end = date.end_of_month.end_of_day + # can't get cost for the future + hour_end = Time.now if hour_end > Time.now + + Reading.where("created_at > :begin AND created_at < :end", { :begin => hour_start, :end => hour_end}) + + end def max_charge_kwh=(kwh) @@max_charge_kwh = kwh @@ -78,12 +88,19 @@ class Reading < ActiveRecord::Base # do not make distinction between high and low consumption/production def consumed_and_produced_for_diff(d) - usage_kwh = (d[:total_kwh_consumed_high].nil? || d[:total_kwh_consumed_low].nil?) ? nil : d[:total_kwh_consumed_high] + d[:total_kwh_consumed_low] return_kwh = (d[:total_kwh_produced_high].nil? || d[:total_kwh_produced_low].nil?) ? nil : d[:total_kwh_produced_high] + d[:total_kwh_produced_low] return usage_kwh, return_kwh end + + # calculate net_consumption (= consumption-production) + def net_consumed_high_and_low_for_diff(d) + net_consumed_high = (d[:total_kwh_consumed_high].nil? || d[:total_kwh_produced_high].nil?) ? nil : d[:total_kwh_consumed_high]-d[:total_kwh_produced_high] + net_consumed_low = (d[:total_kwh_consumed_low].nil? || d[:total_kwh_produced_low].nil?) ? nil : d[:total_kwh_consumed_low]-d[:total_kwh_produced_low] + + return net_consumed_high, net_consumed_low + end def diff_on(date) readings_on = day(date) @@ -106,5 +123,22 @@ class Reading < ActiveRecord::Base { :total_kwh_consumed_high => 0, :total_kwh_consumed_low => 0, :total_kwh_produced_high => 0, :total_kwh_produced_low => 0, :total_m3_gas_consumed => 0 } end end + + def diff_in_month(date) + hour_start = date.beginning_of_month.beginning_of_day + hour_end = date.end_of_month.end_of_day + # can't get cost for the future + hour_end = Time.now if hour_end > Time.now + + readings_in_month = Reading.where("created_at > :begin AND created_at < :end", { :begin => hour_start, :end => hour_end}) + last = readings_in_month.last + if last + last.diff(readings_in_month.first) + else + { :total_kwh_consumed_high => 0, :total_kwh_consumed_low => 0, :total_kwh_produced_high => 0, :total_kwh_produced_low => 0, :total_m3_gas_consumed => 0 } + end + + end + end end diff --git a/report_mailer.rb b/report_mailer.rb index e2ef47d..59b121a 100644 --- a/report_mailer.rb +++ b/report_mailer.rb @@ -12,6 +12,13 @@ connection_details = YAML::load(File.open('config/database.yml')) ActiveRecord::Base.establish_connection(connection_details) if __FILE__ == $0 - ReadingsMailer.deliver(Date.today.advance(days: -1)) + yesterday = Date.today.advance(days: -1) + ReadingsMailer.deliver(yesterday) + + # are we at the start of a month? + if (Date.today.beginning_of_month == Date.today) + # then also send report for previous month (and year) + ReadingsMailer.deliver_for_month(yesterday.month, yesterday.year) + end end #p sync From ad65a482891b9b1f10526453ed1d0d93d5e2a6be Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Fri, 24 Mar 2023 11:27:32 +0100 Subject: [PATCH 091/113] Container uses /dev/ttyUSB0 --- test-serial.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-serial.rb b/test-serial.rb index 5d6cc2a..59bf644 100644 --- a/test-serial.rb +++ b/test-serial.rb @@ -17,7 +17,7 @@ ActiveRecord::Base.establish_connection(connection_details) if __FILE__ == $0 #params for serial port - port_str = "/dev/ttyUSB1" #may be different for you + port_str = "/dev/ttyUSB0" #may be different for you baud_rate = 9600 data_bits = 7 stop_bits = 1 From 99c2f010940c08cf6b4a1532a765da7e4063cc16 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Fri, 24 Mar 2023 11:29:07 +0100 Subject: [PATCH 092/113] Using puts instead of p --- .gitignore | 1 + app/helpers/InSyncState.rb | 2 +- docker-compose.yml | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4ef1577..efe1657 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .local .config .project +*.pid diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index cfa3291..b11f3ba 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -28,7 +28,7 @@ class InSyncState < StatePattern::State # did we reach the end of the frame? if new_frame_starts(bytes,idx,sync_pattern_length) frame_lines = frame.split("\n") - p "------ FRAME -----" + puts "--- FRAME ---" # p frame_lines # p "##################" reading = handle_frame(frame_lines) diff --git a/docker-compose.yml b/docker-compose.yml index a8c955c..b6fbcc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: image: mysql volumes: - /home/pcog/smartmeter/data:/var/lib/mysql + ports: + - 3306:3306 environment: MYSQL_ROOT_PASSWORD: rootme MYSQL_DATABASE: smartmeter From 3b45e92734ff49fde203929e212b58377a64eb83 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 3 Apr 2024 20:00:19 +0200 Subject: [PATCH 093/113] Closing tag --- app/helpers/TariffsMailer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/TariffsMailer.rb b/app/helpers/TariffsMailer.rb index 7d0e40a..97c3b32 100644 --- a/app/helpers/TariffsMailer.rb +++ b/app/helpers/TariffsMailer.rb @@ -32,7 +32,7 @@ class TariffsMailer html_part do content_type 'text/html; charset=UTF-8' - body "

Tariffs for #{date}" + body "

Tariffs for #{date} " end # add attachment From 9be1ad71c2bf6657ab36a746ba057f692186f098 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 6 Jan 2025 10:05:25 +0100 Subject: [PATCH 094/113] Cost 2024 added --- app/models/cost.rb | 14 ++++++++++++-- ar-no-rails.rb | 4 ++-- docker-compose.yml | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index c7b377c..317e605 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -6,8 +6,8 @@ EASY_ENERGY_TARIFFS = {} # See https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/zakelijk/overige_belastingen/belastingen_op_milieugrondslag/tarieven_milieubelastingen/tabellen_tarieven_milieubelastingen # Without VAT -ENERGY_TAX_KWH = { 2020 => 0.09770, 2021 => 0.09428, 2022 => 0.03679, 2023 => 0.12599 } -ODE_KWH = { 2020 => 0.0273, 2021 => 0.0300, 2022 => 0.0305, 2023 => 0.0} +ENERGY_TAX_KWH = { 2020 => 0.09770, 2021 => 0.09428, 2022 => 0.03679, 2023 => 0.12599, 2024 => 0.10880 } +ODE_KWH = { 2020 => 0.0273, 2021 => 0.0300, 2022 => 0.0305, 2023 => 0.0, 2024 => 0.0} # merge by adding values TAX_KWH = ENERGY_TAX_KWH.merge(ODE_KWH){|key, energy_tax, ode| energy_tax + ode} @@ -82,6 +82,9 @@ class Cost end when 2023 0.018 + when 2024 + # opslag met BTW: 0,02178 + 0.018457 end end @@ -250,6 +253,8 @@ class Cost when 2023 # rate excl. VAT high_tariff ? 0.47758 : 0.34165 + when 2024 + 0.25767769 end end @@ -284,6 +289,11 @@ class Cost vat = 1 + vat_at(Date.parse(formatted_hour)) normaal_kwh_cost = 0.47758*vat dal_kwh_cost = 0.34165*vat + # From 1 Jan 2024 until 31 December 2024 + when 1704063600..1735603199 + vat = 1 + vat_at(Date.parse(formatted_hour)) + normaal_kwh_cost = 0.25767769*vat + dal_kwh_cost = 0.25767769*vat else p "Not supported interval Oxxio for value: %d" % date.to_time.to_i # catch-all, incase 'formated_hour' is outside any of the cases diff --git a/ar-no-rails.rb b/ar-no-rails.rb index 4f93e91..ef5b5a3 100644 --- a/ar-no-rails.rb +++ b/ar-no-rails.rb @@ -2,8 +2,8 @@ require "rubygems" require "bundler/setup" require "active_record" require "open-uri" -require 'gr/plot' -require 'histogram' +#require 'gr/plot' +#require 'histogram' project_root = File.dirname(File.absolute_path(__FILE__)) Dir.glob(project_root + "/app/models/*.rb").each{|f| require f} diff --git a/docker-compose.yml b/docker-compose.yml index b6fbcc0..0381a56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: db: container_name: smartmeter_db restart: unless-stopped - image: mysql + image: mysql:8.3 volumes: - /home/pcog/smartmeter/data:/var/lib/mysql ports: From 5cd52b8332f882423bf7ab73272698135f82a2e7 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 6 Jan 2025 10:14:39 +0100 Subject: [PATCH 095/113] Add cost for 2025 --- app/models/cost.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 317e605..1145ab7 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -6,8 +6,8 @@ EASY_ENERGY_TARIFFS = {} # See https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/zakelijk/overige_belastingen/belastingen_op_milieugrondslag/tarieven_milieubelastingen/tabellen_tarieven_milieubelastingen # Without VAT -ENERGY_TAX_KWH = { 2020 => 0.09770, 2021 => 0.09428, 2022 => 0.03679, 2023 => 0.12599, 2024 => 0.10880 } -ODE_KWH = { 2020 => 0.0273, 2021 => 0.0300, 2022 => 0.0305, 2023 => 0.0, 2024 => 0.0} +ENERGY_TAX_KWH = { 2020 => 0.09770, 2021 => 0.09428, 2022 => 0.03679, 2023 => 0.12599, 2024 => 0.10880, 2025 => 0.10154} +ODE_KWH = { 2020 => 0.0273, 2021 => 0.0300, 2022 => 0.0305, 2023 => 0.0, 2024 => 0.0, 2025 => 0.0} # merge by adding values TAX_KWH = ENERGY_TAX_KWH.merge(ODE_KWH){|key, energy_tax, ode| energy_tax + ode} @@ -255,6 +255,8 @@ class Cost high_tariff ? 0.47758 : 0.34165 when 2024 0.25767769 + when 2025 + high_tariff ? 0.2695 : 0.2296 end end From 11025018cc7cce2277b1fed809a46bae32965022 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 6 Jan 2025 11:34:30 +0100 Subject: [PATCH 096/113] Add 2025 cost --- app/models/cost.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 1145ab7..4f32499 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -295,7 +295,11 @@ class Cost when 1704063600..1735603199 vat = 1 + vat_at(Date.parse(formatted_hour)) normaal_kwh_cost = 0.25767769*vat - dal_kwh_cost = 0.25767769*vat + dal_kwh_cost = 0.25767769*vat + when 1735711200..1767160800 + vat = 1 + vat_at(Date.parse(formatted_hour)) + normaal_kwh_cost = 0.2695*vat + dal_kwh_cost = 0.2296*vat else p "Not supported interval Oxxio for value: %d" % date.to_time.to_i # catch-all, incase 'formated_hour' is outside any of the cases From 16abdc9f37e14eca1809e76172e37a85e7e7f351 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 6 Jan 2025 12:00:53 +0100 Subject: [PATCH 097/113] Add easy energy tariff 2025 --- app/models/cost.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/models/cost.rb b/app/models/cost.rb index 4f32499..6edd8b3 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -85,6 +85,9 @@ class Cost when 2024 # opslag met BTW: 0,02178 0.018457 + when 2025 + # opslag met BTW: 0,02178 + 0.018457 end end From d8b4cb1d686240cf33ef8348a694348a3f0c41f5 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 6 Jan 2025 13:48:49 +0100 Subject: [PATCH 098/113] Upgrade gems --- Gemfile.lock | 85 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7616c11..0166094 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,47 +1,82 @@ GEM remote: https://rubygems.org/ specs: - activemodel (7.0.1) - activesupport (= 7.0.1) - activerecord (7.0.1) - activemodel (= 7.0.1) - activesupport (= 7.0.1) - activesupport (7.0.1) + activemodel (7.1.5.1) + activesupport (= 7.1.5.1) + activerecord (7.1.5.1) + activemodel (= 7.1.5.1) + activesupport (= 7.1.5.1) + timeout (>= 0.4.0) + activesupport (7.1.5.1) + base64 + benchmark (>= 0.3) + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) + mutex_m + securerandom (>= 0.3) tzinfo (~> 2.0) - concurrent-ruby (1.1.9) + base64 (0.2.0) + benchmark (0.4.0) + bigdecimal (3.1.9) + concurrent-ruby (1.3.4) + connection_pool (2.4.1) daemons (1.4.1) - et-orbi (1.2.6) + date (3.4.1) + drb (2.2.1) + et-orbi (1.2.11) tzinfo - fugit (1.5.2) - et-orbi (~> 1.1, >= 1.1.8) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) - gruff (0.19.0) + gruff (0.24.0) histogram - rmagick (>= 4.2) + rmagick (>= 5.3) histogram (0.2.4.1) - i18n (1.8.11) + i18n (1.14.6) concurrent-ruby (~> 1.0) - mail (2.7.1) + logger (1.6.4) + mail (2.8.1) mini_mime (>= 0.1.1) - mini_mime (1.1.2) - mini_portile2 (2.7.1) - minitest (5.15.0) - mysql2 (0.5.3) - nokogiri (1.13.0) - mini_portile2 (~> 2.7.0) + net-imap + net-pop + net-smtp + mini_mime (1.1.5) + mini_portile2 (2.8.8) + minitest (5.25.4) + mutex_m (0.3.0) + mysql2 (0.5.6) + net-imap (0.4.18) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.0) + net-protocol + nokogiri (1.15.7) + mini_portile2 (~> 2.8.2) racc (~> 1.4) numo-narray (0.9.2.1) + observer (0.1.2) + pkg-config (1.5.8) raabro (1.4.0) - racc (1.6.0) - rmagick (5.0.0) - rufus-scheduler (3.8.0) - fugit (~> 1.1, >= 1.1.6) + racc (1.8.1) + rmagick (5.5.0) + observer (~> 0.1) + pkg-config (~> 1.4) + rufus-scheduler (3.9.2) + fugit (~> 1.1, >= 1.11.1) + securerandom (0.3.2) serialport (1.3.2) state_pattern (2.0.2) - tzinfo (2.0.4) + timeout (0.4.3) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) PLATFORMS From 87b6ef791af4a734374164ef174dc123491a0801 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 6 Jan 2025 14:13:27 +0100 Subject: [PATCH 099/113] Add net-http gem --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index fb5bd36..4b71a07 100644 --- a/Gemfile +++ b/Gemfile @@ -11,3 +11,4 @@ gem 'nokogiri' gem 'numo-narray' gem 'i18n' gem 'gruff' +gem 'net-http' # to avoid error: uninitialized constant ... From abe93284603de722c0fb58ee38e4cd4bcd7dd94f Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Mon, 6 Jan 2025 14:17:20 +0100 Subject: [PATCH 100/113] bundle update --- Gemfile.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 0166094..030190f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -50,6 +50,8 @@ GEM minitest (5.25.4) mutex_m (0.3.0) mysql2 (0.5.6) + net-http (0.6.0) + uri net-imap (0.4.18) date net-protocol @@ -78,6 +80,7 @@ GEM timeout (0.4.3) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + uri (1.0.2) PLATFORMS ruby @@ -89,6 +92,7 @@ DEPENDENCIES i18n mail mysql2 + net-http nokogiri numo-narray rufus-scheduler From 40d4956be3cf0f6db16f77209c5e6fd583809d96 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Wed, 16 Jul 2025 15:20:39 +0200 Subject: [PATCH 101/113] New smartmeter --- app/helpers/ConfirmingSyncPatternState.rb | 6 +++--- app/helpers/InSyncState.rb | 5 +++-- app/helpers/Synchronizer.rb | 3 ++- app/models/reading.rb | 13 ++++++++++--- docker-compose.yml | 1 - smartmeter.rb | 2 +- test-serial.rb | 6 +++--- 7 files changed, 22 insertions(+), 14 deletions(-) diff --git a/app/helpers/ConfirmingSyncPatternState.rb b/app/helpers/ConfirmingSyncPatternState.rb index bfd8e93..406a941 100644 --- a/app/helpers/ConfirmingSyncPatternState.rb +++ b/app/helpers/ConfirmingSyncPatternState.rb @@ -8,14 +8,14 @@ class ConfirmingSyncPatternState < StatePattern::State while (idx < bytes.length && idx < sync_length && bytes[idx] == Synchronizer::SYNC_PATTERN[idx]) do idx = idx+1 end if (idx == sync_length) - #p "Sync pattern confirmed" + p "Sync pattern confirmed" transition_to(InSyncState) else - #p "Back to SearchingForSync state. idx = #{idx}." + p "Back to SearchingForSync state. idx = #{idx}." transition_to(SearchingForSyncState) end # return the rest return bytes[idx+1..-1] || "" end -end \ No newline at end of file +end diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index b11f3ba..1e818c7 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -2,7 +2,7 @@ require "socket" class InSyncState < StatePattern::State - END_OF_FRAME = "!\n" + END_OF_FRAME = "!****\n" # def initialize(stateful, previous_state) # # open socket to EmonHub @@ -42,7 +42,8 @@ class InSyncState < StatePattern::State private def new_frame_starts(bytes,idx,sync_pattern_length) #return bytes[idx..idx+sync_pattern_length-1].eql?(Synchronizer::SYNC_PATTERN) - return bytes[idx..idx+sync_pattern_length-1].eql?(END_OF_FRAME) + #return bytes[idx..idx+sync_pattern_length-1].eql?(END_OF_FRAME) + return bytes[idx].eql?(END_OF_FRAME[0]) end def handle_frame(frame_lines) diff --git a/app/helpers/Synchronizer.rb b/app/helpers/Synchronizer.rb index df24f98..53f9424 100644 --- a/app/helpers/Synchronizer.rb +++ b/app/helpers/Synchronizer.rb @@ -1,7 +1,8 @@ class Synchronizer include StatePattern - SYNC_PATTERN = "\n/ISk5\\2ME382-1003\n\n" + SYNC_PATTERN = "/CTA5ZIV\-METER\n\n" + #SYNC_PATTERN = "\n/ISk5\\2ME382-1003\n\n" set_initial_state ::SearchingForSyncState diff --git a/app/models/reading.rb b/app/models/reading.rb index 66f9920..40e05e6 100644 --- a/app/models/reading.rb +++ b/app/models/reading.rb @@ -3,14 +3,21 @@ UNKNOWN_READING = { :total_kwh_consumed_high => nil, :total_kwh_consumed_low => class Reading < ActiveRecord::Base - + + # Round up with 1 (default) decimals + def round_up(number, decimals = 1) + factor = 10 ** decimals + (number * factor).ceil / factor.to_f + end + + def eql_reading?(reading) self.total_kwh_consumed_high == reading.total_kwh_consumed_high && self.total_kwh_consumed_low == reading.total_kwh_consumed_low && self.total_kwh_produced_high == reading.total_kwh_produced_high && self.total_kwh_produced_low == reading.total_kwh_produced_low && - self.current_kw_consumed == reading.current_kw_consumed && - self.current_kw_produced == reading.current_kw_produced && + round_up(self.current_kw_consumed) == round_up(reading.current_kw_consumed) && + round_up(self.current_kw_produced) == round_up(reading.current_kw_produced) && self.total_m3_gas_consumed == reading.total_m3_gas_consumed && self.high_tarif == reading.high_tarif end diff --git a/docker-compose.yml b/docker-compose.yml index 0381a56..750d236 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3' services: db: container_name: smartmeter_db diff --git a/smartmeter.rb b/smartmeter.rb index 21521d3..6013542 100644 --- a/smartmeter.rb +++ b/smartmeter.rb @@ -17,7 +17,7 @@ ActiveRecord::Base.establish_connection(connection_details) def open_device begin # Open connection to serial port - io_device = SerialPort.new("/dev/ttyUSB0", 9600, 7, 1, SerialPort::EVEN) + io_device = SerialPort.new("/dev/ttyUSB0", 115200, 8, 1, SerialPort::NONE) # Make reading blocking io_device.read_timeout = 0 rescue diff --git a/test-serial.rb b/test-serial.rb index 59bf644..447f694 100644 --- a/test-serial.rb +++ b/test-serial.rb @@ -18,10 +18,10 @@ if __FILE__ == $0 #params for serial port port_str = "/dev/ttyUSB0" #may be different for you - baud_rate = 9600 - data_bits = 7 + baud_rate = 115200 + data_bits = 8 stop_bits = 1 - parity = SerialPort::EVEN + parity = SerialPort::NONE sp = SerialPort.new(port_str, baud_rate, data_bits, stop_bits, parity) From 92ae0eee50c5d5dcdb4a878a4aca98e8ecf0cb27 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 17 Jul 2025 12:30:09 +0200 Subject: [PATCH 102/113] New gas reading logic --- app/helpers/InSyncState.rb | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 1e818c7..9e2fb25 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -47,9 +47,8 @@ class InSyncState < StatePattern::State end def handle_frame(frame_lines) - # gas flag - next_is_gas = false - + gas_pattern = /^([0-1:24\.2\.1]+)\((\d{12}[SW])\)\((\d{5}\.\d{3})\*m3\)$/ + # prepare DB record last_reading = Reading.last reading = Reading.new @@ -80,13 +79,10 @@ class InSyncState < StatePattern::State end if line.match(/0-0:96.14.0/) # Hoog/laag tarief reading.high_tarif = line.split(/0-0:96.14.0\(|\)/).join.eql?("0002") - end - if next_is_gas && line.match(/\(/) - next_is_gas = false - reading.total_m3_gas_consumed = line.split(/\(|\)/).join.to_f - end - if line.match(/0-1:24.3.0/) # Gas verbruik (1x per uur een nieuwe stand) - next_is_gas = true # the usage is on the next line + end + if match = line.match(/0-1:24.3.0/) # Gas verbruik (1x per uur een nieuwe stand) + p "Gas reading: #{match[1]} (#{match[2]})" + reading.total_m3_gas_consumed = match[3].to_f end } From b303331581b29494a05deb28ea25f0f990e10cc5 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 17 Jul 2025 12:32:28 +0200 Subject: [PATCH 103/113] bug fix --- app/helpers/InSyncState.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 9e2fb25..7881ab6 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -80,9 +80,9 @@ class InSyncState < StatePattern::State if line.match(/0-0:96.14.0/) # Hoog/laag tarief reading.high_tarif = line.split(/0-0:96.14.0\(|\)/).join.eql?("0002") end - if match = line.match(/0-1:24.3.0/) # Gas verbruik (1x per uur een nieuwe stand) - p "Gas reading: #{match[1]} (#{match[2]})" - reading.total_m3_gas_consumed = match[3].to_f + if match = line.match(gas_pattern) # Gas verbruik (1x per uur een nieuwe stand) + p "Gas reading: #{match[1]} (#{match[2]})" + reading.total_m3_gas_consumed = match[3].to_f end } From c165f24632176fe05ffd2f399512ec39fce878a0 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 17 Jul 2025 12:37:41 +0200 Subject: [PATCH 104/113] extra test --- app/helpers/InSyncState.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 7881ab6..52cfd04 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -79,7 +79,11 @@ class InSyncState < StatePattern::State end if line.match(/0-0:96.14.0/) # Hoog/laag tarief reading.high_tarif = line.split(/0-0:96.14.0\(|\)/).join.eql?("0002") - end + end + if line.match(/0-1:24.2.1/) + p "Gas reading found." + p "Reading time: #{line.split(/0-1:24.2.1\(|\)/).join}" + end if match = line.match(gas_pattern) # Gas verbruik (1x per uur een nieuwe stand) p "Gas reading: #{match[1]} (#{match[2]})" reading.total_m3_gas_consumed = match[3].to_f From 968a5cea2f5a0294c1ee0faf0dfa7ff85b345ca5 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 17 Jul 2025 12:42:13 +0200 Subject: [PATCH 105/113] two stage matching --- app/helpers/InSyncState.rb | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 52cfd04..bfda5f2 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -47,7 +47,6 @@ class InSyncState < StatePattern::State end def handle_frame(frame_lines) - gas_pattern = /^([0-1:24\.2\.1]+)\((\d{12}[SW])\)\((\d{5}\.\d{3})\*m3\)$/ # prepare DB record last_reading = Reading.last @@ -82,11 +81,10 @@ class InSyncState < StatePattern::State end if line.match(/0-1:24.2.1/) p "Gas reading found." - p "Reading time: #{line.split(/0-1:24.2.1\(|\)/).join}" - end - if match = line.match(gas_pattern) # Gas verbruik (1x per uur een nieuwe stand) - p "Gas reading: #{match[1]} (#{match[2]})" - reading.total_m3_gas_consumed = match[3].to_f + + match = line.match(/^([0-1:24\.2\.1]+)\(([^)]+)\)\(([\d.]+)\*m3\)$/) # Gas verbruik (1x per uur een nieuwe stand) + p "Gas reading: #{match[1]} (#{match[2]})" + reading.total_m3_gas_consumed = match[3].to_f end } From 211b6e7cac35526d19d9e88ada8944b3caf10b85 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 17 Jul 2025 12:46:16 +0200 Subject: [PATCH 106/113] debug info --- app/helpers/InSyncState.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index bfda5f2..04d05b3 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -80,9 +80,9 @@ class InSyncState < StatePattern::State reading.high_tarif = line.split(/0-0:96.14.0\(|\)/).join.eql?("0002") end if line.match(/0-1:24.2.1/) - p "Gas reading found." + p "Gas reading found: #{line}" - match = line.match(/^([0-1:24\.2\.1]+)\(([^)]+)\)\(([\d.]+)\*m3\)$/) # Gas verbruik (1x per uur een nieuwe stand) + match = line.match(/^(0-1:24.2.1)\(([^)]+)\)\(([\d.]+)\*m3\)$/) # Gas verbruik (1x per uur een nieuwe stand) p "Gas reading: #{match[1]} (#{match[2]})" reading.total_m3_gas_consumed = match[3].to_f end From 63b2260ba0b316d30c06b0d9305f187bb993519e Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 17 Jul 2025 12:49:46 +0200 Subject: [PATCH 107/113] refactor --- app/helpers/InSyncState.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 04d05b3..1bbd58e 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -79,11 +79,9 @@ class InSyncState < StatePattern::State if line.match(/0-0:96.14.0/) # Hoog/laag tarief reading.high_tarif = line.split(/0-0:96.14.0\(|\)/).join.eql?("0002") end - if line.match(/0-1:24.2.1/) - p "Gas reading found: #{line}" - - match = line.match(/^(0-1:24.2.1)\(([^)]+)\)\(([\d.]+)\*m3\)$/) # Gas verbruik (1x per uur een nieuwe stand) - p "Gas reading: #{match[1]} (#{match[2]})" + # example line: "0-1:24.2.1(250717121000S)(00000.474*m3)" + if match = line.match(/^(0-1:24.2.1)\(([^)]+)\)\(([\d.]+)\*m3\)$/) # Gas verbruik (1x per uur een nieuwe stand) + #p "Gas reading: #{match[1]} (#{match[2]})" reading.total_m3_gas_consumed = match[3].to_f end } From 24dd351249f716e3eec0eb3ad1bf0586aff5341f Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Thu, 17 Jul 2025 13:11:28 +0200 Subject: [PATCH 108/113] Convert gas timestamp to datetime object --- app/helpers/InSyncState.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/helpers/InSyncState.rb b/app/helpers/InSyncState.rb index 1bbd58e..c5fbd7a 100644 --- a/app/helpers/InSyncState.rb +++ b/app/helpers/InSyncState.rb @@ -82,6 +82,8 @@ class InSyncState < StatePattern::State # example line: "0-1:24.2.1(250717121000S)(00000.474*m3)" if match = line.match(/^(0-1:24.2.1)\(([^)]+)\)\(([\d.]+)\*m3\)$/) # Gas verbruik (1x per uur een nieuwe stand) #p "Gas reading: #{match[1]} (#{match[2]})" + #datetime = DateTime.strptime(match[2][0..11], "%y%m%d%H%M%S") + #p "Gas reading at #{datetime}." reading.total_m3_gas_consumed = match[3].to_f end } From b40f4d2dc6faee4df6d6bda314ab59998d0a3869 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sat, 3 Jan 2026 19:58:03 +0100 Subject: [PATCH 109/113] Add 2026 tarifs and rates --- app/models/cost.rb | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 6edd8b3..04ec750 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -6,8 +6,8 @@ EASY_ENERGY_TARIFFS = {} # See https://www.belastingdienst.nl/wps/wcm/connect/bldcontentnl/belastingdienst/zakelijk/overige_belastingen/belastingen_op_milieugrondslag/tarieven_milieubelastingen/tabellen_tarieven_milieubelastingen # Without VAT -ENERGY_TAX_KWH = { 2020 => 0.09770, 2021 => 0.09428, 2022 => 0.03679, 2023 => 0.12599, 2024 => 0.10880, 2025 => 0.10154} -ODE_KWH = { 2020 => 0.0273, 2021 => 0.0300, 2022 => 0.0305, 2023 => 0.0, 2024 => 0.0, 2025 => 0.0} +ENERGY_TAX_KWH = { 2020 => 0.09770, 2021 => 0.09428, 2022 => 0.03679, 2023 => 0.12599, 2024 => 0.10880, 2025 => 0.10154, 2026 => 0.09157 } +ODE_KWH = { 2020 => 0.0273, 2021 => 0.0300, 2022 => 0.0305, 2023 => 0.0, 2024 => 0.0, 2025 => 0.0, 2026 =>0.0} # merge by adding values TAX_KWH = ENERGY_TAX_KWH.merge(ODE_KWH){|key, energy_tax, ode| energy_tax + ode} @@ -82,10 +82,7 @@ class Cost end when 2023 0.018 - when 2024 - # opslag met BTW: 0,02178 - 0.018457 - when 2025 + when 2024..2026 # opslag met BTW: 0,02178 0.018457 end @@ -93,7 +90,8 @@ class Cost def easy_energy_cost(formatted_hour, usage_kwh, return_kwh) return nil if (usage_kwh.nil? || return_kwh.nil?) - + p "easy_energy_cost for " + formatted_hour + usage_kwh_cost = return_kwh_cost = (entsoe.price_at(formatted_hour)+easy_energy_rate(formatted_hour))*(1+vat_at(Date.parse(formatted_hour))) add_tax(formatted_hour, usage_kwh, usage_kwh_cost, return_kwh, return_kwh_cost) end @@ -303,6 +301,10 @@ class Cost vat = 1 + vat_at(Date.parse(formatted_hour)) normaal_kwh_cost = 0.2695*vat dal_kwh_cost = 0.2296*vat + when 1767225600..1785887999 # 2026 full year + vat = 1 + vat_at(Date.parse(formatted_hour)) + normaal_kwh_cost = 0.23186*vat + dal_kwh_cost = 0.22442*vat else p "Not supported interval Oxxio for value: %d" % date.to_time.to_i # catch-all, incase 'formated_hour' is outside any of the cases From 85eaceaad2a6474694997d515018d32a2b37ab33 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sat, 3 Jan 2026 20:11:49 +0100 Subject: [PATCH 110/113] New blurp for new meter --- example_blurp.txt | 306 ++++++++++++++++++++++++++++------------------ 1 file changed, 185 insertions(+), 121 deletions(-) diff --git a/example_blurp.txt b/example_blurp.txt index 0066fd7..08ae9be 100644 --- a/example_blurp.txt +++ b/example_blurp.txt @@ -1,121 +1,185 @@ - -/ISk5\2ME382-1003 - -0-0:96.1.1(4B413650303035313238303430383132) -1-0:1.8.1(00553.931*kWh) -1-0:1.8.2(00431.594*kWh) -1-0:2.8.1(00093.034*kWh) -1-0:2.8.2(00147.035*kWh) -0-0:96.14.0(0002) -1-0:1.7.0(0000.00*kW) -1-0:2.7.0(0000.28*kW) -0-0:17.0.0(0999.00*kW) -0-0:96.3.10(1) -0-0:96.13.1() -0-0:96.13.0() -0-1:24.1.0(3) -0-1:96.1.0(3238303131303031323439333134383132) -0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) -(00309.466) -0-1:24.4.0(1) -! -/ISk5\2ME382-1003 - -0-0:96.1.1(4B413650303035313238303430383132) -1-0:1.8.1(00553.931*kWh) -1-0:1.8.2(00431.594*kWh) -1-0:2.8.1(00093.034*kWh) -1-0:2.8.2(00147.036*kWh) -0-0:96.14.0(0002) -1-0:1.7.0(0000.00*kW) -1-0:2.7.0(0000.31*kW) -0-0:17.0.0(0999.00*kW) -0-0:96.3.10(1) -0-0:96.13.1() -0-0:96.13.0() -0-1:24.1.0(3) -0-1:96.1.0(3238303131303031323439333134383132) -0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) -(00309.466) -0-1:24.4.0(1) -! -/ISk5\2ME382-1003 - -0-0:96.1.1(4B413650303035313238303430383132) -1-0:1.8.1(00553.931*kWh) -1-0:1.8.2(00431.594*kWh) -1-0:2.8.1(00093.034*kWh) -1-0:2.8.2(00147.037*kWh) -0-0:96.14.0(0002) -1-0:1.7.0(0000.00*kW) -1-0:2.7.0(0000.32*kW) -0-0:17.0.0(0999.00*kW) -0-0:96.3.10(1) -0-0:96.13.1() -0-0:96.13.0() -0-1:24.1.0(3) -0-1:96.1.0(3238303131303031323439333134383132) -0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) -(00309.466) -0-1:24.4.0(1) -! -/ISk5\2ME382-1003 - -0-0:96.1.1(4B413650303035313238303430383132) -1-0:1.8.1(00553.931*kWh) -1-0:1.8.2(00431.594*kWh) -1-0:2.8.1(00093.034*kWh) -1-0:2.8.2(00147.038*kWh) -0-0:96.14.0(0002) -1-0:1.7.0(0000.00*kW) -1-0:2.7.0(0000.32*kW) -0-0:17.0.0(0999.00*kW) -0-0:96.3.10(1) -0-0:96.13.1() -0-0:96.13.0() -0-1:24.1.0(3) -0-1:96.1.0(3238303131303031323439333134383132) -0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) -(00309.466) -0-1:24.4.0(1) -! -/ISk5\2ME382-1003 - -0-0:96.1.1(4B413650303035313238303430383132) -1-0:1.8.1(00553.931*kWh) -1-0:1.8.2(00431.594*kWh) -1-0:2.8.1(00093.034*kWh) -1-0:2.8.2(00147.039*kWh) -0-0:96.14.0(0002) -1-0:1.7.0(0000.00*kW) -1-0:2.7.0(0000.32*kW) -0-0:17.0.0(0999.00*kW) -0-0:96.3.10(1) -0-0:96.13.1() -0-0:96.13.0() -0-1:24.1.0(3) -0-1:96.1.0(3238303131303031323439333134383132) -0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) -(00309.466) -0-1:24.4.0(1) -! -/ISk5\2ME382-1003 - -0-0:96.1.1(4B413650303035313238303430383132) -1-0:1.8.1(00553.931*kWh) -1-0:1.8.2(00431.594*kWh) -1-0:2.8.1(00093.034*kWh) -1-0:2.8.2(00147.040*kWh) -0-0:96.14.0(0002) -1-0:1.7.0(0000.00*kW) -1-0:2.7.0(0000.30*kW) -0-0:17.0.0(0999.00*kW) -0-0:96.3.10(1) -0-0:96.13.1() -0-0:96.13.0() -0-1:24.1.0(3) -0-1:96.1.0(3238303131303031323439333134383132) -0-1:24.3.0(130626090000)(00)(60)(1)(0-1:24.2.1)(m3) -(00309.466) -0-1:24.4.0(3) -! +3233) +1-0:1.8.1(001402.671*kWh) +1-0:1.8.2(001130.208*kWh) +1-0:2.8.1(000080.565*kWh) +1-0:2.8.2(000205.556*kWh) +0-0:96.14.0(0001) +1-0:1.7.0(01.283*kW) +1-0:2.7.0(00.000*kW) +0-0:96.7.21(00015) +0-0:96.7.9(00004) +1-0:99.97.0(0)(0-0:96.7.19) +1-0:32.32.0(00000) +1-0:32.36.0(00003) +0-0:96.13.0() +1-0:32.7.0(236.0*V) +1-0:31.7.0(005*A) +1-0:21.7.0(01.283*kW) +1-0:22.7.0(00.000*kW) +0-1:24.1.0(003) +0-1:96.1.0(4730303933303034333734333338333235) +0-1:24.2.1(260103201000W)(00272.130*m3) +!7603 +/CTA5ZIV-METER + +1-3:2.671*kWh) +1-0:1.8.2(001130.208*kWh) +1-0:2.8.1(000080.565*kWh) +1-0:2.8.2(000205.556*kWh) +0-0:96.14.0(0001) +1-0:1.7.0(01.286*kW) +1-0:2.7.0(00.000*kW) +0-0:96.7.21(00015) +0-0:96.7.9(00004) +1-0:99.97.0(0)(0-0:96.7.19) +1-0:32.32.0(00000) +1-0:32.36.0(00003) +0-0:96.13.0() +1-0:32.7.0(236.0*V) +1-0:31.7.0(005*A) +1-0:21.7.0(01.286*kW) +1-0:22.7.0(00.000*kW) +0-1:24.1.0(003) +0-1:96.1.0(4730303933303034333734333338333235) +0-1:24.2.1(260103201000W)(00272.130*m3) +!B5E2 +/CTA5ZIV-METER + +1-3:0.2.8(50) +0-0::1.8.2(001130.208*kWh) +1-0:2.8.1(000080.565*kWh) +1-0:2.8.2(000205.556*kWh) +0-0:96.14.0(0001) +1-0:1.7.0(01.335*kW) +1-0:2.7.0(00.000*kW) +0-0:96.7.21(00015) +0-0:96.7.9(00004) +1-0:99.97.0(0)(0-0:96.7.19) +1-0:32.32.0(00000) +1-0:32.36.0(00003) +0-0:96.13.0() +1-0:32.7.0(236.0*V) +1-0:31.7.0(005*A) +1-0:21.7.0(01.335*kW) +1-0:22.7.0(00.000*kW) +0-1:24.1.0(003) +0-1:96.1.0(4730303933303034333734333338333235) +0-1:24.2.1(260103201000W)(00272.130*m3) +!6D5D +/CTA5ZIV-METER + +1-3:0.2.8(50) +0-0:1.0.0(260103201104W) + +1-0:2.8.1(000080.565*kWh) +1-0:2.8.2(000205.556*kWh) +0-0:96.14.0(0001) +1-0:1.7.0(01.276*kW) +1-0:2.7.0(00.000*kW) +0-0:96.7.21(00015) +0-0:96.7.9(00004) +1-0:99.97.0(0)(0-0:96.7.19) +1-0:32.32.0(00000) +1-0:32.36.0(00003) +0-0:96.13.0() +1-0:32.7.0(236.0*V) +1-0:31.7.0(005*A) +1-0:21.7.0(01.276*kW) +1-0:22.7.0(00.000*kW) +0-1:24.1.0(003) +0-1:96.1.0(4730303933303034333734333338333235) +0-1:24.2.1(260103201000W)(00272.130*m3) +!FDB1 +3233) +1-0:1.8.1(001402.673*kWh) +1-0:1.8.2(001130.208*kWh) +1-0:2.8.1(000080.565*kWh) +1-0:2.8.2(000205.556*kWh) +0-0:96.14.0(0001) +1-0:1.7.0(01.344*kW) +1-0:2.7.0(00.000*kW) +0-0:96.7.21(00015) +0-0:96.7.9(00004) +1-0:99.97.0(0)(0-0:96.7.19) +1-0:32.32.0(00000) +1-0:32.36.0(00003) +0-0:96.13.0() +1-0:32.7.0(236.0*V) +1-0:31.7.0(006*A) +1-0:21.7.0(01.344*kW) +1-0:22.7.0(00.000*kW) +0-1:24.1.0(003) +0-1:96.1.0(4730303933303034333734333338333235) +0-1:24.2.1(260103201000W)(00272.130*m3) +!E9B8 +/CTA5ZIV-METER + +1-3:0.2.3*kWh) +1-0:1.8.2(001130.208*kWh) +1-0:2.8.1(000080.565*kWh) +1-0:2.8.2(000205.556*kWh) +0-0:96.14.0(0001) +1-0:1.7.0(01.319*kW) +1-0:2.7.0(00.000*kW) +0-0:96.7.21(00015) +0-0:96.7.9(00004) +1-0:99.97.0(0)(0-0:96.7.19) +1-0:32.32.0(00000) +1-0:32.36.0(00003) +0-0:96.13.0() +1-0:32.7.0(236.0*V) +1-0:31.7.0(005*A) +1-0:21.7.0(01.319*kW) +1-0:22.7.0(00.000*kW) +0-1:24.1.0(003) +0-1:96.1.0(4730303933303034333734333338333235) +0-1:24.2.1(260103201000W)(00272.130*m3) +!F0CD +/CTA5ZIV-METER + +1-3:0.2.8(50) +0-0:1.0.0(2001130.208*kWh) +1-0:2.8.1(000080.565*kWh) +1-0:2.8.2(000205.556*kWh) +0-0:96.14.0(0001) +1-0:1.7.0(01.336*kW) +1-0:2.7.0(00.000*kW) +0-0:96.7.21(00015) +0-0:96.7.9(00004) +1-0:99.97.0(0)(0-0:96.7.19) +1-0:32.32.0(00000) +1-0:32.36.0(00003) +0-0:96.13.0() +1-0:32.7.0(236.0*V) +1-0:31.7.0(005*A) +1-0:21.7.0(01.336*kW) +1-0:22.7.0(00.000*kW) +0-1:24.1.0(003) +0-1:96.1.0(4730303933303034333734333338333235) +0-1:24.2.1(260103201000W)(00272.130*m3) +!CCE2 +/CTA5ZIV-METER + +1-3:0.2.8(50) +0-0:1.0.0(260103201108W) +0-0:96.1.1(4530303839303031303230373731393233) +1-0:1.8.1(001402.674*kWh) +1-0:1.8.2(001130.208*kWh) +1-0:2.8.1(000080.565*kWh) +1-0:2.8.2(000205.556*kWh) +0-0:96.14.0(0001) +1-0:1.7.0(01.319*kW) +1-0:2.7.0(00.000*kW) +0-0:96.7.21(00015) +0-0:96.7.9(00004) +1-0:99.97.0(0)(0-0:96.7.19) +1-0:32.32.0(00000) +1-0:32.36.0(00003) +0-0:96.13.0() +1-0:32.7.0(236.0*V) +1-0:31.7.0(005*A) +1-0:21.7.0(01.319*kW) +1-0:22.7.0(00.000*kW) +0-1:24.1.0-1:96.1.0(4730303933303034333734333338333235) +0-1:24.2.1(260103201000W)(00272.130*m3) +!B4B3 From 72cd6445282190bef0efcf447599204a8404d64d Mon Sep 17 00:00:00 2001 From: = <=> Date: Sat, 3 Jan 2026 20:42:22 +0100 Subject: [PATCH 111/113] Fix: Interpolate missing ENTSOE price data points Handle missing hourly price data from ENTSOE API by interpolating values from adjacent time slots instead of returning nil. --- .gitignore | 1 + app/models/entsoe.rb | 50 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index efe1657..12e19dc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .config .project *.pid +data diff --git a/app/models/entsoe.rb b/app/models/entsoe.rb index 4090a44..51edfca 100644 --- a/app/models/entsoe.rb +++ b/app/models/entsoe.rb @@ -103,15 +103,61 @@ class Entsoe formatted_date = date.strftime("%F") doc = Nokogiri::XML(URI.open(url)) + + #p "Entsoe prices: %s" % doc prices = doc.xpath('.//xmlns:Point').map{|p| parse_point(p)} begin # get start_time (in UTC) from XML docment start_time = DateTime.parse(doc.xpath('.//xmlns:period.timeInterval//xmlns:start').text) + # Get resolution to determine expected number of positions + resolution = doc.xpath('.//xmlns:resolution').text + interval_minutes = resolution == 'PT15M' ? 15 : 60 + expected_positions = (24 * 60) / interval_minutes + + # Create hash from available prices + price_hash = prices.to_h + + # Fill in missing positions by interpolating from adjacent values + complete_prices = {} + (1..expected_positions).each do |position| + if price_hash.key?(position) + complete_prices[position] = price_hash[position] + else + # Find previous and next available prices for interpolation + prev_price = (position-1).downto(1).find { |p| price_hash.key?(p) } + next_price = (position+1).upto(expected_positions).find { |p| price_hash.key?(p) } + + if prev_price && next_price + # Interpolate between previous and next + complete_prices[position] = ((price_hash[prev_price] + price_hash[next_price]) / 2.0).round(5) + elsif prev_price + # Use previous price as fallback + complete_prices[position] = price_hash[prev_price] + elsif next_price + # Use next price as fallback + complete_prices[position] = price_hash[next_price] + end + + # Calculate the formatted hour for the warning message + hour_offset = interval_minutes == 15 ? (position - 1) / 4 : (position - 1) + missing_hour = start_time.advance(hours: hour_offset).in_time_zone(zone).strftime("%F %H") + p "Warning: Missing Entsoe data for #{missing_hour}, interpolated value: #{complete_prices[position]}" + end + end + #returns a hash with keys formatted "yyyy-mm-dd hr" and values price (per kwh) - # tag runs from 1-24. We need hours from 00-23, therefore substracting 1 - prices.map{|p| [start_time.advance(hours: (p[0]-1)), p[1]]}.to_h + # tag runs from 1-96 for 15min intervals. Convert to hourly by taking first interval of each hour + if interval_minutes == 15 + # For 15-minute intervals, use the first interval of each hour (positions 1, 5, 9, 13, ...) + complete_prices.select { |pos, _| (pos - 1) % 4 == 0 } + .map { |pos, price| [start_time.advance(hours: ((pos - 1) / 4)), price] } + .to_h + else + # For hourly data (position runs from 1-24, we need hours from 00-23) + complete_prices.map { |pos, price| [start_time.advance(hours: (pos - 1)), price] }.to_h + end rescue Date::Error => e p e.message {} From 947062391592f513e723157d1b8044538d021f02 Mon Sep 17 00:00:00 2001 From: Aart van Halteren Date: Sat, 3 Jan 2026 21:07:29 +0100 Subject: [PATCH 112/113] Fix Oxxio rates 2026 --- app/models/cost.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/models/cost.rb b/app/models/cost.rb index 04ec750..e7a37e8 100644 --- a/app/models/cost.rb +++ b/app/models/cost.rb @@ -90,7 +90,7 @@ class Cost def easy_energy_cost(formatted_hour, usage_kwh, return_kwh) return nil if (usage_kwh.nil? || return_kwh.nil?) - p "easy_energy_cost for " + formatted_hour + #p "easy_energy_cost for " + formatted_hour usage_kwh_cost = return_kwh_cost = (entsoe.price_at(formatted_hour)+easy_energy_rate(formatted_hour))*(1+vat_at(Date.parse(formatted_hour))) add_tax(formatted_hour, usage_kwh, usage_kwh_cost, return_kwh, return_kwh_cost) @@ -258,6 +258,8 @@ class Cost 0.25767769 when 2025 high_tariff ? 0.2695 : 0.2296 + when 2026 + high_tariff ? 0.23186 : 0.22442 end end @@ -302,9 +304,8 @@ class Cost normaal_kwh_cost = 0.2695*vat dal_kwh_cost = 0.2296*vat when 1767225600..1785887999 # 2026 full year - vat = 1 + vat_at(Date.parse(formatted_hour)) - normaal_kwh_cost = 0.23186*vat - dal_kwh_cost = 0.22442*vat + normaal_kwh_cost = 0.19161 + dal_kwh_cost = 0.18547 else p "Not supported interval Oxxio for value: %d" % date.to_time.to_i # catch-all, incase 'formated_hour' is outside any of the cases From ca5a4a1b5d85e77a3dfa4b10c7186619d0207237 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 23 Mar 2026 16:23:15 +0100 Subject: [PATCH 113/113] Fixes active record 7 compatibility --- Rakefile | 2 +- db/migrate/003_creates_prices.rb | 3 ++- docker-compose.yml | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Rakefile b/Rakefile index 04146e4..3f596c4 100644 --- a/Rakefile +++ b/Rakefile @@ -11,7 +11,7 @@ namespace :db do task :migrate do connection_details = YAML::load(File.open('config/database.yml')) ActiveRecord::Base.establish_connection(connection_details) - ActiveRecord::Migrator.migrate("db/migrate/") + ActiveRecord::MigrationContext.new("db/migrate/").migrate end desc "Create the db" diff --git a/db/migrate/003_creates_prices.rb b/db/migrate/003_creates_prices.rb index 97cd471..8894b47 100644 --- a/db/migrate/003_creates_prices.rb +++ b/db/migrate/003_creates_prices.rb @@ -1,4 +1,4 @@ -class CreatesPrices << ActiveRecord::Migration[4.2] +class CreatesPrices < ActiveRecord::Migration[4.2] def change create_table :prices do |t| t.datetime :hour @@ -7,4 +7,5 @@ class CreatesPrices << ActiveRecord::Migration[4.2] end add_index :prices, :hour + end end diff --git a/docker-compose.yml b/docker-compose.yml index 750d236..ba1d007 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: restart: unless-stopped image: mysql:8.3 volumes: - - /home/pcog/smartmeter/data:/var/lib/mysql + - $PWD/data:/var/lib/mysql ports: - 3306:3306 environment: @@ -15,8 +15,8 @@ services: restart: unless-stopped build: . command: 'ruby ./smartmeter.rb' - devices: - - "/dev/ttyUSB1:/dev/ttyUSB0" + #devices: + # - "/dev/ttyUSB1:/dev/ttyUSB0" volumes: - .:/usr/src/app depends_on: