Skip to content

Commit

Permalink
Merge branch 'release/0.2.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
ledermann committed Dec 17, 2023
2 parents 9784796 + b0a43e0 commit 509dbc0
Show file tree
Hide file tree
Showing 24 changed files with 617 additions and 119 deletions.
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ 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

# Dry run mode: Do not actually start charging (default: false)
# CHARGER_DRY_RUN=true

# Tibber settings
TIBBER_TOKEN=my-tibber-token
TIBBER_INTERVAL=3600
Expand Down Expand Up @@ -32,3 +44,6 @@ INFLUX_USERNAME=my-user
INFLUX_BUCKET=my-bucket
INFLUX_MEASUREMENT_PRICES=my-prices
INFLUX_MEASUREMENT_FORECAST=my-forecast

# Timezone
TZ=Europe/Berlin
1 change: 1 addition & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
env:
CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
CI: true
TZ: Europe/Berlin

steps:
- name: Checkout the code
Expand Down
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
FROM ruby:3.2.2-alpine AS Builder
RUN apk add --no-cache build-base git
RUN apk add --no-cache build-base

WORKDIR /senec-charger
COPY Gemfile* /senec-charger/
Expand All @@ -11,6 +11,9 @@ RUN bundle config --local frozen 1 && \
FROM ruby:3.2.2-alpine
LABEL maintainer="[email protected]"

# Add tzdata to get correct timezone
RUN apk add --no-cache tzdata

# Decrease memory usage
ENV MALLOC_ARENA_MAX 2

Expand Down
15 changes: 8 additions & 7 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ GEM
net-http-persistent (~> 4.0)
faraday-request-timer (0.2.0)
faraday (>= 0.9.0)
hashdiff (1.0.1)
hashdiff (1.1.0)
influxdb-client (3.0.0)
json (2.7.1)
language_server-protocol (3.17.0.3)
Expand All @@ -30,7 +30,7 @@ GEM
minitest (~> 5.12)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
parallel (1.23.0)
parallel (1.24.0)
parser (3.2.2.4)
ast (~> 2.4.1)
racc
Expand All @@ -55,11 +55,12 @@ GEM
parser (>= 3.2.1.0)
rubocop-graphql (1.4.0)
rubocop (>= 0.90, < 2)
rubocop-minitest (0.33.0)
rubocop-minitest (0.34.1)
rubocop (>= 1.39, < 2.0)
rubocop-performance (1.19.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-ast (>= 1.30.0, < 2.0)
rubocop-performance (1.20.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.30.0, < 2.0)
rubocop-rake (0.6.0)
rubocop (~> 1.0)
ruby-progressbar (1.13.0)
Expand Down Expand Up @@ -104,4 +105,4 @@ DEPENDENCIES
webmock

BUNDLED WITH
2.4.22
2.5.1
37 changes: 32 additions & 5 deletions app/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
:senec_host,
:senec_schema,
:charger_interval,
:charger_price_mode,
:charger_price_time_range,
:charger_forecast_threshold,
:charger_dry_run,
:influx_schema,
:influx_host,
:influx_port,
Expand All @@ -19,6 +23,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
Expand All @@ -36,17 +43,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 = {})
Expand All @@ -55,6 +76,12 @@ 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,
charger_dry_run: ENV.fetch('CHARGER_DRY_RUN', 'false') == 'true',
influx_host: ENV.fetch('INFLUX_HOST'),
influx_schema: ENV.fetch('INFLUX_SCHEMA', 'http'),
influx_port: ENV.fetch('INFLUX_PORT', '8086'),
Expand Down
31 changes: 22 additions & 9 deletions app/forecast_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,47 @@ 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
return 0.0 unless raw[0]

raw[0].records[0].values['_value']
raw[0].records[0].value.round
end

def time_range
24
end

private

def raw
# Is the last request less than 30min ago?
return @raw if @raw && @last_query_at && @last_query_at > Time.now - 1800
# Cache for 1 minute
return @raw if @raw && @last_query_at && @last_query_at > Time.now - 60

@last_query_at = Time.now
@raw = client.create_query_api.query(query:)
end

def query
"from(bucket: \"#{config.influx_bucket}\")
|> range(start: now(), stop: 24h)
|> filter(fn: (r) => r[\"_measurement\"] == \"#{config.influx_measurement_forecast}\")
|> filter(fn: (r) => r[\"_field\"] == \"#{field}\")
<<~QUERY
from(bucket: "#{config.influx_bucket}")
|> range(start: #{range_start.to_i}, stop: #{range_stop.to_i})
|> filter(fn: (r) => r["_measurement"] == "#{config.influx_measurement_forecast}")
|> filter(fn: (r) => r["_field"] == "#{field}")
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> map(fn: (r) => ({ r with _value: r._value / 1000.0 }))
|> sum()
"
QUERY
end

def range_start
Time.now
end

def range_stop
range_start + (time_range * 3600)
end

def field
Expand Down
33 changes: 31 additions & 2 deletions app/loop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ def start
self.count += 1

puts "##{self.count} - #{Time.now}"
result = battery_action.perform!
puts RESULT_MESSAGES[result]
perform!

break if max_count && count >= max_count

Expand All @@ -46,6 +45,36 @@ def start

private

def perform!
result = battery_action.perform!
puts RESULT_MESSAGES[result]

case result
when :start_charge
log_fuel_charge
log_forecast
log_prices
when :not_empty, :still_charging, :allow_discharge
log_fuel_charge
when :sunshine_ahead
log_forecast
when :grid_power_not_cheap
log_prices
end
end

def log_fuel_charge
puts " Battery charge level: #{senec.bat_fuel_charge} %"
end

def log_forecast
puts " Forecast for the next #{forecast.time_range} hours: #{forecast.total_in_kwh} kWh"
end

def log_prices
puts " Prices in the next #{prices.time_range} hours: #{prices}"
end

def battery_action
@battery_action ||= BatteryAction.new(senec:, prices:, forecast:)
end
Expand Down
1 change: 1 addition & 0 deletions app/main.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
puts "Connecting to InfluxDB at #{config.influx_url}, " \
"bucket #{config.influx_bucket}, " \
"measurements #{config.influx_measurement_prices} and #{config.influx_measurement_forecast}"
puts '+++ DRY RUN MODE +++' if config.charger_dry_run
puts "\n"

Loop.start(config:)
79 changes: 65 additions & 14 deletions app/prices_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,90 @@ 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
return [] unless raw[0]
prices.map { |price| price[:level] }
end

def to_s
if prices.any?
prices
.map { |price| "#{price[:time]} #{price[:amount]} (#{price[:level]})" }
.join(', ')
else
"No prices found between #{range_start} and #{range_stop}"
end
end

raw[0].records.map { |r| r.values['_value'] }
def time_range
config.charger_price_time_range
end

private

def accepted_levels
case config.charger_price_mode
when :strict
%w[VERY_CHEAP]
when :relaxed
%w[CHEAP VERY_CHEAP]
end
end

# Return prices as an array of hashes (with keys: time, amount, level)
def prices
return [] unless amount_table && level_table

amount_table
.records
.zip(level_table.records)
.map do |amount, level|
{
time: Time.parse(amount.time).localtime.strftime('%H:%M'),
amount: amount.value,
level: level.value,
}
end
end

# Get the table with values for the "amount" field
def amount_table
raw.find { |table| table.records.first.field == 'amount' }
end

# Get the table with values for the "level" field
def level_table
raw.find { |table| table.records.first.field == 'level' }
end

def raw
# Is the last request less than 30min ago?
return @raw if @raw && @last_query_at && @last_query_at > Time.now - 1800
# Cache for 1 minute
return @raw if @raw && @last_query_at && @last_query_at > Time.now - 60

@last_query_at = Time.now
@raw = client.create_query_api.query(query:)
end

def query
"from(bucket: \"#{config.influx_bucket}\")
|> range(start: now(), stop: 4h)
|> filter(fn: (r) => r[\"_measurement\"] == \"#{config.influx_measurement_prices}\")
|> filter(fn: (r) => r[\"_field\"] == \"#{field}\")
<<~QUERY
from(bucket: "#{config.influx_bucket}")
|> range(start: #{range_start.to_i}, stop: #{range_stop.to_i})
|> filter(fn: (r) => r["_measurement"] == "#{config.influx_measurement_prices}")
|> filter(fn: (r) => r["_field"] == "level" or r["_field"] == "amount")
|> yield()
"
QUERY
end

def range_start
now = Time.now
Time.new(now.year, now.month, now.day, now.hour)
end

def field
'level'
def range_stop
range_start + (time_range * 3600)
end

def client
Expand Down
Loading

0 comments on commit 509dbc0

Please sign in to comment.