Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,14 @@ INFLUX_MEASUREMENT_FORECAST=my-forecast

# Timezone
TZ=Europe/Berlin

# Price calculation average window =============================
# If not set, price calculation will averaged for the next 24h
# which also averages out regular differences between day/night.

# Optional: Start of relevant time range for price calculation
# CHARGER_PRICE_COMPARISON_HOUR_START=6

# Optional: End of relevant time range for price calculation
# CHARGER_PRICE_COMPARISON_HOUR_END=20
# --------------------------------------------------------------
Comment thread
spyro2000 marked this conversation as resolved.
10 changes: 8 additions & 2 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ Metrics/MethodLength:
Max: 40

Metrics/AbcSize:
Max: 25
Max: 26

Metrics/CyclomaticComplexity:
Max: 10

Metrics/ClassLength:
Max: 110
Max: 140
Exclude:
- test/**/*

Expand All @@ -45,3 +48,6 @@ Layout/FirstArrayElementIndentation:

Minitest/MultipleAssertions:
Enabled: false

Layout/EndOfLine:
Enabled: false
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,7 @@ group :development, :test do

# A gem providing "time travel" and "time freezing" capabilities, making it dead simple to test time-dependent code. It provides a unified method to mock Time.now, Date.today, and DateTime.now in a single call. (https://github.com/travisjeffery/timecop)
gem 'timecop'

# Used for interacting with C libraries (moved out of the default standard library in Ruby 3.4)
gem 'fiddle'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really required on Windows? On MacOS and Linux, it is not.

Maybe we should write:

gem 'fiddle', platforms: :windows

end
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ GEM
net-http-persistent (>= 4.0.4, < 5)
faraday-request-timer (0.2.0)
faraday (>= 0.9.0)
fiddle (1.1.6)
hashdiff (1.2.1)
hashie (5.0.0)
influxdb-client (3.2.0)
Expand Down Expand Up @@ -121,12 +122,14 @@ GEM

PLATFORMS
ruby
x64-mingw-ucrt

DEPENDENCIES
base64
climate_control
csv
dotenv
fiddle
influxdb-client
minitest
minitest-silence
Expand Down
25 changes: 25 additions & 0 deletions app/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
:charger_price_max,
:charger_price_time_range,
:charger_forecast_threshold,
:charger_price_comparison_hour_start,
:charger_price_comparison_hour_end,
:charger_dry_run,
:influx_schema,
:influx_host,
Expand All @@ -26,6 +28,7 @@ def initialize(*options)
validate_price_max!(charger_price_max)
validate_price_time_range!(charger_price_time_range)
validate_forecast_threshold!(charger_forecast_threshold)
validate_price_comparison_hours!(charger_price_comparison_hour_start, charger_price_comparison_hour_end)
end

def influx_url
Expand Down Expand Up @@ -70,6 +73,26 @@ def validate_url!(url)
throw("URL is invalid: #{url}")
end

def validate_price_comparison_hours!(start_hour, end_hour)
# Valid if both are nil (feature disabled)
return if start_hour.nil? && end_hour.nil?

# Invalid if only one is set
if start_hour.nil? || end_hour.nil?
raise ArgumentError, 'Both start and end hour must be set for price comparison'
end

# Invalid if out of bounds (0-23)
unless (0..23).cover?(start_hour) && (0..23).cover?(end_hour)
raise ArgumentError, 'Price comparison hours must be between 0 and 23'
end

# Invalid if start is not before end
return if start_hour < end_hour

raise ArgumentError, 'Price comparison start hour must be before end hour'
end

def self.from_env(options = {})
new(
{
Expand All @@ -93,6 +116,8 @@ def self.from_env(options = {})
ENV.fetch('INFLUX_MEASUREMENT_PRICES', 'Prices'),
influx_measurement_forecast:
ENV.fetch('INFLUX_MEASUREMENT_FORECAST', 'Forecast'),
charger_price_comparison_hour_start: ENV['CHARGER_PRICE_COMPARISON_HOUR_START']&.to_i,
charger_price_comparison_hour_end: ENV['CHARGER_PRICE_COMPARISON_HOUR_END']&.to_i,
}.merge(options),
)
end
Expand Down
78 changes: 67 additions & 11 deletions app/prices_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,19 @@ def cheap_ahead?
end

def best_price_acceptable?
return false unless best_prices_average && prices_average
# We need a valid best price average
return false unless best_prices_average

best_prices_average <= prices_average * config.charger_price_max / 100
# Determine the reference price to compare against.
# Use the comparison average (filtered) if available, otherwise the full average.
# If a range is configured but no prices exist for it (ref_price is nil),
# we return false to be safe.
# 1. Try to get the average of the configured time window (comparison_average).
# 2. If not configured (or empty), fall back to the full 24h average (prices_average).
ref_price = comparison_average || prices_average
return false unless ref_price

best_prices_average <= ref_price * config.charger_price_max / 100
end

def best_prices_now?
Expand All @@ -33,16 +43,45 @@ def prices_average
average(prices)
end

def to_s # rubocop:disable Metrics/AbcSize
if prices.any?
<<~RESULT
Checked prices between #{prices.first.time.strftime('%A, %H:%M')} - #{end_time(prices).strftime('%A, %H:%M')}, ⌀ #{prices_average.round(2)}
Best #{config.charger_price_time_range}-hour range: #{best_prices.first.time.strftime('%A, %H:%M')} - #{end_time(best_prices).strftime('%A, %H:%M')}, ⌀ #{best_prices_average.round(2)}
Ratio best/average: #{(best_prices_average * 100 / prices_average).round(1)} %
RESULT
else
"No prices found between #{range_start} and #{range_stop}"
def to_s
subset = comparison_prices
return 'No prices available' if subset.empty?

ref_price = (comparison_average || prices_average).round(3)

# Combine the summaries
range_summary(subset, ref_price) + best_slot_summary(ref_price)
end

def range_summary(subset, ref_price)
start_time = subset.first.time.strftime('%H:%M')
end_time = subset.last.time.strftime('%H:%M')

msg = "Checked prices #{start_time}-#{end_time}"

if config.charger_price_comparison_hour_start
msg += " (filtered #{config.charger_price_comparison_hour_start}" \
":00-#{config.charger_price_comparison_hour_end}:00)"
end

msg + ", Ref Ø #{ref_price}"
end

def best_slot_summary(ref_price)
return '' unless best_prices&.any?

best_avg = best_prices_average.round(3)
slot_start = best_prices.first.time.strftime('%H:%M')
slot_end = best_prices.last.time.strftime('%H:%M')

# Calculate Ratio and Decision
ratio = ((best_avg / ref_price) * 100).round(1)
target_price = (ref_price * config.charger_price_max / 100).round(3)
is_cheap = best_avg <= target_price

"\n Best slot: #{slot_start} - #{slot_end} @ #{best_avg}" \
"\n Decision: #{ratio}% of Ref (Limit #{config.charger_price_max}% " \
"/ < #{target_price}) -> #{is_cheap ? 'CHEAP' : 'EXPENSIVE'}"
end

def end_time(price_list)
Expand All @@ -55,6 +94,23 @@ def end_time(price_list)

private

# Returns the average of the specific time window (if configured)
# Returns nil if the filtered list is empty
def comparison_average
average(comparison_prices)
end

def comparison_prices
# Because of Config validation, we know if one is set, both are set and valid.
return prices unless config.charger_price_comparison_hour_start && config.charger_price_comparison_hour_end

# Filter prices to only include those within the configured hour range
prices.select do |price|
hour = price.time.hour
hour.between?(config.charger_price_comparison_hour_start, config.charger_price_comparison_hour_end)
end
end

def average(cons)
return if cons.empty?

Expand Down
10 changes: 10 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
services:
senec-charger:

# OPTION A: Use to use latest official built
image: ghcr.io/solectrus/senec-charger:latest

# OPTION B: Use local build with local .env file
# build: .
# env_file:
# - .env

depends_on:
influxdb:
condition: service_healthy
Expand All @@ -14,6 +22,8 @@ services:
- CHARGER_PRICE_MAX
- CHARGER_PRICE_TIME_RANGE
- CHARGER_FORECAST_THRESHOLD
- CHARGER_PRICE_COMPARISON_HOUR_START
- CHARGER_PRICE_COMPARISON_HOUR_END
- CHARGER_DRY_RUN
- INFLUX_HOST=influxdb
- INFLUX_TOKEN=${INFLUX_TOKEN_READ}
Expand Down
58 changes: 58 additions & 0 deletions test/config_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,62 @@ def test_from_env
assert_equal 'my-prices', config.influx_measurement_prices
assert_equal 'my-forecast', config.influx_measurement_forecast
end

def test_price_comparison_config_valid
config = Config.new(VALID_OPTIONS.merge(
charger_price_comparison_hour_start: 6,
charger_price_comparison_hour_end: 18,
))

assert_equal 6, config.charger_price_comparison_hour_start
assert_equal 18, config.charger_price_comparison_hour_end
end

def test_price_comparison_config_missing_one
# Should fail if only start is provided
assert_raises(ArgumentError) do
Config.new(VALID_OPTIONS.merge(charger_price_comparison_hour_start: 6))
end

# Should fail if only end is provided
assert_raises(ArgumentError) do
Config.new(VALID_OPTIONS.merge(charger_price_comparison_hour_end: 18))
end
end

def test_price_comparison_config_out_of_bounds
# Start hour too low
assert_raises(ArgumentError) do
Config.new(VALID_OPTIONS.merge(
charger_price_comparison_hour_start: -1,
charger_price_comparison_hour_end: 10,
))
end

# End hour too high
assert_raises(ArgumentError) do
Config.new(VALID_OPTIONS.merge(
charger_price_comparison_hour_start: 10,
charger_price_comparison_hour_end: 24,
))
end
end

def test_price_comparison_config_invalid_order
# Start hour same as end hour
assert_raises(ArgumentError) do
Config.new(VALID_OPTIONS.merge(
charger_price_comparison_hour_start: 10,
charger_price_comparison_hour_end: 10,
))
end

# Start hour after end hour
assert_raises(ArgumentError) do
Config.new(VALID_OPTIONS.merge(
charger_price_comparison_hour_start: 12,
charger_price_comparison_hour_end: 10,
))
end
end
end
61 changes: 54 additions & 7 deletions test/prices_provider_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,41 @@ def test_best_prices_average

def test_to_s
VCR.use_cassette('prices_success') do
# Test with actual data from cassette, regardless of current time
output = prices_provider.to_s

assert_match(/Checked prices between/, output)
assert_match(/Best 4-hour range:/, output)
assert_match(%r{Ratio best/average:}, output)
assert_match(/⌀ \d+\.\d+/, output)
# Verify the structure of the new multi-line log
# Line 1: Basic info
assert_match(/Checked prices \d{2}:\d{2}-\d{2}:\d{2}/, output)
assert_match(/Ref Ø \d+\.\d+/, output)

# Line 2: Best slot
assert_match(/Best slot: \d{2}:\d{2} - \d{2}:\d{2} @ \d+\.\d+/, output)

# Line 3: Decision logic
assert_match(/Decision: \d+\.\d+% of Ref/, output)
assert_match(/-> (CHEAP|EXPENSIVE)/, output)
end
end

def test_to_s_empty
# Travel to a time where we don't have any prices
Timecop.travel('2023-05-02 12:10:00 +0200') do
VCR.use_cassette('prices_blank') do
assert_equal 'No prices found between 2023-05-02 12:00:00 +0200 and 2023-05-03 12:00:00 +0200',
prices_provider.to_s
# UPDATE: Expect the new simple message
assert_equal 'No prices available', prices_provider.to_s
end
end
end

def test_to_s_with_filter
# Verify that the log explicitly mentions the filter when active
config.stub :charger_price_comparison_hour_start, 6 do
config.stub :charger_price_comparison_hour_end, 20 do
VCR.use_cassette('prices_success') do
output = prices_provider.to_s

assert_match(/\(filtered 6:00-20:00\)/, output)
end
end
end
end
Expand Down Expand Up @@ -107,6 +126,34 @@ def test_cheap_now_relaxed_twelve_o_clock
end
end

def test_best_price_acceptable_moderate_with_comparison_range
# In the fake_prices data:
# - Best 4h average: 0.138
# - Global 24h average: 0.176
# - MODERATE threshold (70%): 0.176 * 0.7 = 0.123
# 0.138 is NOT <= 0.123, so this normally fails (refute_predicate).

# We configure the comparison range to 17:00 - 18:00.
# - Price at 17:00-18:00: 0.199
# - New Reference Average: 0.199
# - New Threshold (70%): 0.199 * 0.7 = 0.1393
# 0.138 IS <= 0.1393, so this should now PASS.

config.stub :charger_price_max, MODERATE do
config.stub :charger_price_comparison_hour_start, 17 do
config.stub :charger_price_comparison_hour_end, 18 do
VCR.use_cassette('prices_success') do
assert_predicate prices_provider, :best_price_acceptable?
end
end
end
end

# This prevents local .env settings from breaking standard tests
@config.charger_price_comparison_hour_start = nil
@config.charger_price_comparison_hour_end = nil
end

private

def prices_provider
Expand Down
Loading
Loading