From d13a3c9210c36581b359005ba4e06d3089b565cd Mon Sep 17 00:00:00 2001 From: Georg Ledermann Date: Thu, 14 Dec 2023 09:48:18 +0100 Subject: [PATCH] Add more config options to ENV CHARGER_PRICE_MODE CHARGER_PRICE_TIME_RANGE CHARGER_FORECAST_THRESHOLD --- .env.example | 9 ++++++++ app/config.rb | 35 +++++++++++++++++++++++++----- app/forecast_provider.rb | 2 +- app/prices_provider.rb | 19 ++++++++++++---- docker-compose.yml | 3 +++ test/cassettes/prices_success.yml | 2 +- test/config_test.rb | 36 ++++++++++++++++++++++++++++++- test/prices_provider_test.rb | 28 ++++++++++++++++++------ 8 files changed, 115 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index efbb18a..e7dc257 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,15 @@ SENEC_SCHEMA=https # Interval in seconds to check for charging CHARGER_INTERVAL=3600 +# Do you to charge when price level is VERY_CHEAP (strict) or do yo allow CHEAP (relaxed) prices as well? +CHARGER_PRICE_MODE=relaxed + +# How many hours in advance should the charger check for cheap prices? +CHARGER_PRICE_TIME_RANGE=4 + +# At what PV yield (in kWh) in the upcoming 24h should charging from the grid be avoided? +CHARGER_FORECAST_THRESHOLD=20 + # Tibber settings TIBBER_TOKEN=my-tibber-token TIBBER_INTERVAL=3600 diff --git a/app/config.rb b/app/config.rb index 894904a..b807345 100644 --- a/app/config.rb +++ b/app/config.rb @@ -3,6 +3,9 @@ :senec_host, :senec_schema, :charger_interval, + :charger_price_mode, + :charger_price_time_range, + :charger_forecast_threshold, :influx_schema, :influx_host, :influx_port, @@ -19,6 +22,9 @@ def initialize(*options) validate_url!(senec_url) validate_url!(influx_url) validate_interval!(charger_interval) + validate_price_mode!(charger_price_mode) + validate_price_time_range!(charger_price_time_range) + validate_forecast_threshold!(charger_forecast_threshold) end def influx_url @@ -36,17 +42,31 @@ def senec_connection private - def validate_interval!(charger_interval) - return if charger_interval.is_a?(Integer) && charger_interval.positive? + def validate_interval!(interval) + (interval.is_a?(Integer) && interval.positive?) || + throw("Interval is invalid: #{interval}") + end + + def validate_price_mode!(price_mode) + %i[strict relaxed].include?(price_mode) || + throw("Price mode is invalid: #{price_mode}") + end + + def validate_price_time_range!(price_time_range) + (price_time_range.is_a?(Integer) && price_time_range.positive?) || + throw("Time range is invalid: #{price_time_range}") + end - throw "Interval is invalid: #{charger_interval}" + def validate_forecast_threshold!(forecast_threshold) + (forecast_threshold.is_a?(Integer) && forecast_threshold.positive?) || + throw("Forecast threshold is invalid: #{forecast_threshold}") end def validate_url!(url) uri = URI.parse(url) - return if uri.is_a?(URI::HTTP) && !uri.host.nil? - throw "URL is invalid: #{url}" + (uri.is_a?(URI::HTTP) && !uri.host.nil?) || + throw("URL is invalid: #{url}") end def self.from_env(options = {}) @@ -55,6 +75,11 @@ def self.from_env(options = {}) senec_host: ENV.fetch('SENEC_HOST'), senec_schema: ENV.fetch('SENEC_SCHEMA', 'https'), charger_interval: ENV.fetch('CHARGER_INTERVAL', '3600').to_i, + charger_price_mode: ENV.fetch('CHARGER_PRICE_MODE', 'strict').to_sym, + charger_price_time_range: + ENV.fetch('CHARGER_PRICE_TIME_RANGE', '4').to_i, + charger_forecast_threshold: + ENV.fetch('CHARGER_FORECAST_THRESHOLD', '20').to_i, influx_host: ENV.fetch('INFLUX_HOST'), influx_schema: ENV.fetch('INFLUX_SCHEMA', 'http'), influx_port: ENV.fetch('INFLUX_PORT', '8086'), diff --git a/app/forecast_provider.rb b/app/forecast_provider.rb index d781646..3e3fb12 100644 --- a/app/forecast_provider.rb +++ b/app/forecast_provider.rb @@ -8,7 +8,7 @@ def initialize(config:) attr_reader :config def sunshine_ahead? - total_in_kwh > 20 + total_in_kwh > config.charger_forecast_threshold end def total_in_kwh diff --git a/app/prices_provider.rb b/app/prices_provider.rb index 1a33e85..120e2a2 100644 --- a/app/prices_provider.rb +++ b/app/prices_provider.rb @@ -7,10 +7,8 @@ def initialize(config:) attr_reader :config - ACCEPTED_LEVELS = %w[CHEAP VERY_CHEAP].freeze - def cheap_grid_power? - levels.count { |level| ACCEPTED_LEVELS.include?(level) } >= 4 + levels.count { |level| accepted_levels.include?(level) } >= time_range end def levels @@ -21,6 +19,19 @@ def levels private + def time_range + config.charger_price_time_range + end + + def accepted_levels + case config.charger_price_mode + when :strict + %w[VERY_CHEAP] + when :relaxed + %w[CHEAP VERY_CHEAP] + end + end + def raw # Is the last request less than 30min ago? return @raw if @raw && @last_query_at && @last_query_at > Time.now - 1800 @@ -31,7 +42,7 @@ def raw def query "from(bucket: \"#{config.influx_bucket}\") - |> range(start: now(), stop: 4h) + |> range(start: now(), stop: #{time_range}h) |> filter(fn: (r) => r[\"_measurement\"] == \"#{config.influx_measurement_prices}\") |> filter(fn: (r) => r[\"_field\"] == \"#{field}\") |> yield() diff --git a/docker-compose.yml b/docker-compose.yml index ec7d5e1..b73d8e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,9 @@ services: - SENEC_HOST - SENEC_SCHEMA - CHARGER_INTERVAL + - CHARGER_PRICE_MODE + - CHARGER_PRICE_TIME_RANGE + - CHARGER_FORECAST_THRESHOLD - INFLUX_HOST=influxdb - INFLUX_TOKEN=${INFLUX_TOKEN_READ} - INFLUX_ORG diff --git a/test/cassettes/prices_success.yml b/test/cassettes/prices_success.yml index f01be81..84cc6c7 100644 --- a/test/cassettes/prices_success.yml +++ b/test/cassettes/prices_success.yml @@ -38,6 +38,6 @@ http_interactions: - chunked body: encoding: UTF-8 - string: "#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,string,string,string\r\n#group,false,false,true,true,false,false,true,true\r\n#default,_result,,,,,,,\r\n,result,table,_start,_stop,_time,_value,_field,_measurement\r\n,,0,2023-12-12T18:01:02.272093971Z,2023-12-12T22:01:02.272093971Z,2023-12-12T19:00:00Z,NORMAL,level,my-prices\r\n,,0,2023-12-12T18:01:02.272093971Z,2023-12-12T22:01:02.272093971Z,2023-12-12T20:00:00Z,NORMAL,level,my-prices\r\n,,0,2023-12-12T18:01:02.272093971Z,2023-12-12T22:01:02.272093971Z,2023-12-12T21:00:00Z,NORMAL,level,my-prices\r\n,,0,2023-12-12T18:01:02.272093971Z,2023-12-12T22:01:02.272093971Z,2023-12-12T22:00:00Z,CHEAP,level,my-prices\r\n\r\n" + string: "#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,string,string,string\r\n#group,false,false,true,true,false,false,true,true\r\n#default,_result,,,,,,,\r\n,result,table,_start,_stop,_time,_value,_field,_measurement\r\n,,0,2023-12-12T18:01:02.272093971Z,2023-12-12T22:01:02.272093971Z,2023-12-12T19:00:00Z,CHEAP,level,my-prices\r\n,,0,2023-12-12T18:01:02.272093971Z,2023-12-12T22:01:02.272093971Z,2023-12-12T20:00:00Z,CHEAP,level,my-prices\r\n,,0,2023-12-12T18:01:02.272093971Z,2023-12-12T22:01:02.272093971Z,2023-12-12T21:00:00Z,CHEAP,level,my-prices\r\n,,0,2023-12-12T18:01:02.272093971Z,2023-12-12T22:01:02.272093971Z,2023-12-12T22:00:00Z,VERY_CHEAP,level,my-prices\r\n\r\n" recorded_at: Tue, 12 Dec 2023 18:01:02 GMT recorded_with: VCR 6.2.0 diff --git a/test/config_test.rb b/test/config_test.rb index 0ffcc08..22e5cbe 100644 --- a/test/config_test.rb +++ b/test/config_test.rb @@ -4,6 +4,9 @@ class ConfigTest < Minitest::Test VALID_OPTIONS = { senec_host: '192.168.1.2', charger_interval: 1800, + charger_price_time_range: 3, + charger_price_mode: :relaxed, + charger_forecast_threshold: 15, senec_schema: 'http', influx_host: 'influx.example.com', influx_schema: 'https', @@ -19,16 +22,20 @@ def test_valid_options Config.new(VALID_OPTIONS) end - def test_invalid_options + def test_invalid_options_blank assert_raises(Exception) { Config.new({}) } + end + def test_invalid_options_charger_interval error = assert_raises(Exception) do Config.new(VALID_OPTIONS.merge(charger_interval: 0)) end assert_match(/Interval is invalid/, error.message) + end + def test_invalid_options_influx_schema error = assert_raises(Exception) do Config.new(VALID_OPTIONS.merge(influx_schema: 'foo')) @@ -37,6 +44,33 @@ def test_invalid_options assert_match(/URL is invalid/, error.message) end + def test_invalid_options_price_mode + error = + assert_raises(Exception) do + Config.new(VALID_OPTIONS.merge(charger_price_mode: 'foo')) + end + + assert_match(/Price mode is invalid/, error.message) + end + + def test_invalid_options_price_time_range + error = + assert_raises(Exception) do + Config.new(VALID_OPTIONS.merge(charger_price_time_range: '-2')) + end + + assert_match(/Time range is invalid/, error.message) + end + + def test_invalid_options_forecast_threshold + error = + assert_raises(Exception) do + Config.new(VALID_OPTIONS.merge(charger_forecast_threshold: '-20')) + end + + assert_match(/Forecast threshold is invalid/, error.message) + end + def test_senec_methods config = Config.new(VALID_OPTIONS) diff --git a/test/prices_provider_test.rb b/test/prices_provider_test.rb index b2628b5..881bf5f 100644 --- a/test/prices_provider_test.rb +++ b/test/prices_provider_test.rb @@ -1,21 +1,35 @@ require 'test_helper' class PricesProviderTest < Minitest::Test - def test_success_request + def test_levels VCR.use_cassette('prices_success') do - assert_equal 4, prices_provider.levels.size - prices_provider.levels.each do |level| - assert_includes %w[NORMAL CHEAP VERY_CHEAP EXPENSIVE VERY_EXPENSIVE], - level + assert_equal %w[CHEAP CHEAP CHEAP VERY_CHEAP], prices_provider.levels + end + end + + def test_cheapest_grid_power_strict + config.stub :charger_price_mode, :strict do + VCR.use_cassette('prices_success') do + refute_predicate prices_provider, :cheap_grid_power? end + end + end - refute_predicate prices_provider, :cheap_grid_power? + def test_cheapest_grid_power_relaxed + config.stub :charger_price_mode, :relaxed do + VCR.use_cassette('prices_success') do + assert_predicate prices_provider, :cheap_grid_power? + end end end private def prices_provider - @prices_provider ||= PricesProvider.new(config: Config.from_env) + @prices_provider ||= PricesProvider.new(config:) + end + + def config + @config ||= Config.from_env end end