Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Ostrom #16354

Merged
merged 39 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
3fcdc5f
Added ostrom tariff
kscholty Sep 26, 2024
8ae3ddc
New name for template
kscholty Sep 27, 2024
0ab6018
Deleted file for test purposes
kscholty Sep 27, 2024
3232dcb
Merge branch 'master' into tariff/ostrom
kscholty Sep 27, 2024
8365d4e
Revert "Deleted file for test purposes"
kscholty Sep 27, 2024
294357b
Linted template
kscholty Sep 27, 2024
0e69d9d
Automatic query of Simply Fai porices using
kscholty Sep 28, 2024
bc999ac
Merge branch 'master' into tariff/ostrom
kscholty Sep 28, 2024
ccc644f
Merge branch 'master' into tariff/ostrom
kscholty Sep 28, 2024
c35535e
Merge branch 'master' into tariff/ostrom
kscholty Sep 29, 2024
9df719f
Merge branch 'master' into tariff/ostrom
kscholty Sep 29, 2024
04ec7a2
Merge branch 'tariff/ostrom' of https://github.com/kscholty/evcc into…
kscholty Sep 29, 2024
919456c
Merge branch 'master' into tariff/ostrom
kscholty Oct 23, 2024
21f02b6
Add "skip test"
kscholty Oct 25, 2024
14b240b
Merge branch 'evcc-io:master' into tariff/ostrom
kscholty Oct 25, 2024
3921912
Merge branch 'master' into tariff/ostrom
kscholty Oct 30, 2024
9c2c1ee
Merge branch 'master' into tariff/ostrom
kscholty Nov 4, 2024
08d3cbe
Merge branch 'evcc-io:master' into tariff/ostrom
kscholty Nov 8, 2024
60c2a7c
Implemented changes for review comments
kscholty Nov 8, 2024
89bffac
Update tariff/ostrom.go
kscholty Nov 9, 2024
edf5629
Apply suggestions from code review
kscholty Nov 9, 2024
a937584
Merge branch 'evcc-io:master' into tariff/ostrom
kscholty Nov 9, 2024
67a9016
Implewmented more change requests.
kscholty Nov 9, 2024
6a48b43
Implemented change requests
kscholty Nov 9, 2024
32471e1
fixed lint error
kscholty Nov 9, 2024
f3c6d97
Changed ContractId in config to int64
kscholty Nov 10, 2024
b729ef7
Merge branch 'master' into tariff/ostrom
kscholty Nov 11, 2024
a68c41c
Merge branch 'evcc-io:master' into tariff/ostrom
kscholty Nov 13, 2024
81759a3
Merge branch 'master' into tariff/ostrom
kscholty Nov 19, 2024
b3ee25d
Merge branch 'evcc-io:master' into tariff/ostrom
kscholty Nov 20, 2024
41ce2dd
Implemented suggested changes
kscholty Nov 20, 2024
dd6adb7
Fixed index problem
kscholty Nov 20, 2024
063ac59
Simplify
andig Nov 21, 2024
77a8919
Panic on invalid type
andig Nov 21, 2024
62536cb
Simplify
andig Nov 21, 2024
c14c2e3
Merge branch 'evcc-io:master' into tariff/ostrom
kscholty Nov 21, 2024
603d06f
Using append for dynamic tarriffs.
kscholty Nov 21, 2024
050401a
Update tariff/ostrom.go
kscholty Nov 21, 2024
c20aa92
Removed lint problem
kscholty Nov 21, 2024
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
266 changes: 266 additions & 0 deletions tariff/ostrom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
package tariff

import (
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"time"

"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/tariff/ostrom"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"github.com/jinzhu/now"
"golang.org/x/oauth2"
)

type Ostrom struct {
*embed
*request.Helper
log *util.Logger
zip string
contractType string
cityId int // Required for the Fair tariff types
basic string
data *util.Monitor[api.Rates]
}

var _ api.Tariff = (*Ostrom)(nil)

func init() {
registry.Add("ostrom", NewOstromFromConfig)
}

func NewOstromFromConfig(other map[string]interface{}) (api.Tariff, error) {
var cc struct {
ClientId string
ClientSecret string
Contract string
kscholty marked this conversation as resolved.
Show resolved Hide resolved
}

if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}

if cc.ClientId == "" || cc.ClientSecret == "" {
return nil, errors.New("missing credentials")
kscholty marked this conversation as resolved.
Show resolved Hide resolved
}

basic := transport.BasicAuthHeader(cc.ClientId, cc.ClientSecret)
log := util.NewLogger("ostrom").Redact(basic)

t := &Ostrom{
log: log,
basic: basic,
contractType: ostrom.PRODUCT_DYNAMIC,
kscholty marked this conversation as resolved.
Show resolved Hide resolved
kscholty marked this conversation as resolved.
Show resolved Hide resolved
Helper: request.NewHelper(log),
data: util.NewMonitor[api.Rates](2 * time.Hour),
}

t.Client.Transport = &oauth2.Transport{
Base: t.Client.Transport,
Source: oauth.RefreshTokenSource(new(oauth2.Token), t),
kscholty marked this conversation as resolved.
Show resolved Hide resolved
}

contract, err := util.EnsureElementEx(cc.Contract, t.GetContracts,
func(c ostrom.Contract) (string, error) {
return strconv.FormatInt(c.Id, 10), nil
},
)
if err != nil {
return nil, err
}
done := make(chan error)

t.contractType = contract.Product
andig marked this conversation as resolved.
Show resolved Hide resolved
t.zip = contract.Address.Zip
if t.Type() == api.TariffTypePriceStatic {
t.cityId, err = t.getCityId()
if err != nil {
log.DEBUG.Println("Cannot query cityId for static price")
kscholty marked this conversation as resolved.
Show resolved Hide resolved
return nil, err
}
go t.runStatic(done)
} else {
go t.run(done)
}
err = <-done

return t, err
}

func addPrice(entry ostrom.ForecastInfo, rates api.Rates) api.Rates {
kscholty marked this conversation as resolved.
Show resolved Hide resolved
ts := entry.StartTimestamp.Local()
ar := api.Rate{
Start: ts,
End: ts.Add(time.Hour),
Price: (entry.Marketprice + entry.AdditionalCost) / 100.0, // Both values include VAT
}
return append(rates, ar)
}

func (t *Ostrom) getCityId() (int, error) {
var city ostrom.CityId

params := url.Values{
"zip": {t.zip},
}

uri := fmt.Sprintf("%s?%s", ostrom.URI_GET_CITYID, params.Encode())
if err := backoff.Retry(func() error {
kscholty marked this conversation as resolved.
Show resolved Hide resolved
return backoffPermanentError(t.GetJSON(uri, &city))
}, bo()); err != nil {
t.log.ERROR.Println(err)
return 0, err
}
return city[0].Id, nil
kscholty marked this conversation as resolved.
Show resolved Hide resolved
}

func (t *Ostrom) getFixedPrice() (float64, error) {
var tariffs ostrom.Tariffs

params := url.Values{
"cityId": {strconv.Itoa(t.cityId)},
"usage": {"1000"},
}

uri := fmt.Sprintf("%s?%s", ostrom.URI_GET_STATIC_PRICE, params.Encode())
if err := backoff.Retry(func() error {
return backoffPermanentError(t.GetJSON(uri, &tariffs))
}, bo()); err != nil {
return 0, err
}

for _, tariff := range tariffs.Ostrom {
if tariff.ProductCode == ostrom.PRODUCT_BASIC {
return tariff.UnitPricePerkWH, nil
}
}

return 0, errors.New("Could not find basic tariff in tariff response")
kscholty marked this conversation as resolved.
Show resolved Hide resolved
}

func (t *Ostrom) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) {
tokenURL := ostrom.URI_AUTH + "/oauth2/token"
dataReader := strings.NewReader("grant_type=client_credentials")

req, _ := request.New(http.MethodPost, tokenURL, dataReader, map[string]string{
"Authorization": t.basic,
"Content-Type": request.FormContent,
"Accept": request.JSONContent,
})

var res oauth2.Token
client := request.NewHelper(t.log)
andig marked this conversation as resolved.
Show resolved Hide resolved
err := client.DoJSON(req, &res)

if err != nil {
kscholty marked this conversation as resolved.
Show resolved Hide resolved
t.log.DEBUG.Printf("Requesting token failed with Error: %s\n", err.Error())
}

return util.TokenWithExpiry(&res), err
}

func (t *Ostrom) GetContracts() ([]ostrom.Contract, error) {
var res ostrom.Contracts

contractsURL := ostrom.URI_API + "/contracts"
err := t.GetJSON(contractsURL, &res)
kscholty marked this conversation as resolved.
Show resolved Hide resolved
return res.Data, err
}

// This function is used to calculate the prices for the Simplay Fair tarrifs
// using the price given in the configuration
// Unfortunately, the API does not allow to query the price for these yet.
func (t *Ostrom) runStatic(done chan error) {
var once sync.Once
var val ostrom.ForecastInfo
var err error
val.AdditionalCost = 0
kscholty marked this conversation as resolved.
Show resolved Hide resolved

tick := time.NewTicker(time.Hour)
for ; true; <-tick.C {
kscholty marked this conversation as resolved.
Show resolved Hide resolved
val.Marketprice, err = t.getFixedPrice()
kscholty marked this conversation as resolved.
Show resolved Hide resolved
if err == nil {
val.StartTimestamp = now.BeginningOfDay()
kscholty marked this conversation as resolved.
Show resolved Hide resolved
data := make(api.Rates, 0, 48)
for i := 0; i < 48; i++ {
data = addPrice(val, data)
val.StartTimestamp = val.StartTimestamp.Add(time.Hour)
kscholty marked this conversation as resolved.
Show resolved Hide resolved
}
mergeRates(t.data, data)
} else {
t.log.ERROR.Println(err)
kscholty marked this conversation as resolved.
Show resolved Hide resolved
}
once.Do(func() { close(done) })
}
}

// This function calls th ostrom API to query the
// dynamic prices
func (t *Ostrom) run(done chan error) {
var once sync.Once

tick := time.NewTicker(time.Hour)
for ; true; <-tick.C {
andig marked this conversation as resolved.
Show resolved Hide resolved
var res ostrom.Prices

start := now.BeginningOfDay()
end := start.AddDate(0, 0, 2)

params := url.Values{
"startDate": {start.Format(time.RFC3339)},
"endDate": {end.Format(time.RFC3339)},
"resolution": {"HOUR"},
"zip": {t.zip},
}

uri := fmt.Sprintf("%s/spot-prices?%s", ostrom.URI_API, params.Encode())
if err := backoff.Retry(func() error {
return backoffPermanentError(t.GetJSON(uri, &res))
}, bo()); err != nil {
once.Do(func() { done <- err })

t.log.ERROR.Println(err)
continue
}

data := make(api.Rates, 0, 48)
for _, val := range res.Data {
data = addPrice(val, data)
}

mergeRates(t.data, data)
once.Do(func() { close(done) })
}
}

// Rates implements the api.Tariff interface
func (t *Ostrom) Rates() (api.Rates, error) {
var res api.Rates
err := t.data.GetFunc(func(val api.Rates) {
res = slices.Clone(val)
})
return res, err
}

// Type implements the api.Tariff interface
func (t *Ostrom) Type() api.TariffType {
switch t.contractType {
case ostrom.PRODUCT_DYNAMIC:
return api.TariffTypePriceForecast
case ostrom.PRODUCT_FAIR, ostrom.PRODUCT_FAIR_CAP:
return api.TariffTypePriceStatic
default:
t.log.ERROR.Printf("Unknown tariff type %s\n", t.contractType)
andig marked this conversation as resolved.
Show resolved Hide resolved
return api.TariffTypePriceStatic
}
}
95 changes: 95 additions & 0 deletions tariff/ostrom/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package ostrom

import (
"time"
)

// URIs, production and sandbox
// see https://docs.ostrom-api.io/reference/environments

const (
URI_AUTH_PRODUCTION = "https://auth.production.ostrom-api.io"
URI_API_PRODUCTION = "https://production.ostrom-api.io"
URI_AUTH_SANDBOX = "https://auth.sandbox.ostrom-api.io"
URI_API_SANDBOX = "https://sandbox.ostrom-api.io"
URI_GET_CITYID = "https://api.ostrom.de/v1/addresses/cities"
URI_GET_STATIC_PRICE = "https://api.ostrom.de/v1/tariffs/city-id"
URI_AUTH = URI_AUTH_PRODUCTION
URI_API = URI_API_PRODUCTION
)

const (
PRODUCT_FAIR = "SIMPLY_FAIR"
PRODUCT_FAIR_CAP = "SIMPLY_FAIR_WITH_PRICE_CAP"
PRODUCT_DYNAMIC = "SIMPLY_DYNAMIC"
PRODUCT_BASIC = "basisProdukt"
)

type Prices struct {
Data []ForecastInfo
}

type ForecastInfo struct {
StartTimestamp time.Time `json:"date"`
Marketprice float64 `json:"grossKwhPrice"`
AdditionalCost float64 `json:"grossKwhTaxAndLevies"`
}

type Contracts struct {
Data []Contract
}

type Address struct {
Zip string `json:"zip"` //"22083",
City string `json:"city"` //"Hamburg",
Street string `json:"street"` //"Mozartstr.",
HouseNumber string `json:"housenumber"` //"35"
}

type Contract struct {
Id int64 `json:"id"` //"100523456",
Type string `json:"type"` //"ELECTRICITY",
Product string `json:"productCode"` //"SIMPLY_DYNAMIC",
Status string `json:"status"` //"ACTIVE",
FirstName string `json:"customerFirstName"` //"Max",
LastName string `json:"customerLastName"` //"Mustermann",
StartDate string `json:"startDate"` // "2024-03-22",
Dposit int `json:"currentMonthlyDepositAmount"` //120,
Address Address `json:"address"`
}

type CityId []struct {
Id int `json:"id"`
Postcode string `json:"postcode"`
Name string `json:"name"`
}

type Tariffs struct {
Ostrom []struct {
ProductCode string `json:"productCode"`
andig marked this conversation as resolved.
Show resolved Hide resolved
Tariff int `json:"tariff"`
BasicFee int `json:"basicFee"`
NetworkFee float64 `json:"networkFee"`
UnitPricePerkWH float64 `json:"unitPricePerkWH"`
TariffWithStormPreisBremse int `json:"tariffWithStormPreisBremse"`
StromPreisBremseUnitPrice int `json:"stromPreisBremseUnitPrice"`
AccumulatedUnitPriceWithStromPreisBremse float64 `json:"accumulatedUnitPriceWithStromPreisBremse"`
UnitPrice float64 `json:"unitPrice"`
EnergyConsumption int `json:"energyConsumption"`
BasePriceBrutto float64 `json:"basePriceBrutto"`
WorkingPriceBrutto float64 `json:"workingPriceBrutto"`
WorkingPriceNetto float64 `json:"workingPriceNetto"`
MeterChargeBrutto int `json:"meterChargeBrutto"`
WorkingPricePowerTax float64 `json:"workingPricePowerTax"`
AverageHourlyPriceToday float64 `json:"averageHourlyPriceToday,omitempty"`
MinHourlyPriceToday float64 `json:"minHourlyPriceToday,omitempty"`
MaxHourlyPriceToday float64 `json:"maxHourlyPriceToday,omitempty"`
} `json:"ostrom"`
Footprint struct {
Usage int `json:"usage"`
KgCO2Emissions int `json:"kgCO2Emissions"`
} `json:"footprint"`
IsPendingApplicationAllowed bool `json:"isPendingApplicationAllowed"`
Status string `json:"status"`
PartnerName any `json:"partnerName"`
}
26 changes: 26 additions & 0 deletions templates/definition/tariff/ostrom.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
template: ostrom
products:
- brand: Ostrom
requirements:
description:
en: "Create a 'Production Client' in the Ostrom developer portal: https://developer.ostrom-api.io/"
de: "Erzeuge einen 'Production Client' in dem Tibber-Entwicklerportal: https://developer.ostrom-api.io/"
evcc: ["skiptest"]
group: price
params:
- name: clientid
example: 476c477d8a039529478ebd690d35ddd80e3308ffc49b59c65b142321aee963a4
required: true
- name: clientsecret
example: 476c477d8a039529478ebd690d35ddd80e3308ffc49b59c65b142321aee963a4476c477d8a039529478ebd690d35ddd80e3308ffc49b59c65b142321aee963a4a
required: true
- name: contract
example: 100523456
help:
de: Nur erforderlich, wenn mehrere Verträge unter einem Benutzer existieren
en: Only required if multiple contracts belong to the same user
render: |
type: ostrom
ClientId: {{ .clientid }}
ClientSecret: {{ .clientsecret }}
Contract: {{ .contract }}
Loading