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

Open
wants to merge 28 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
28 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
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
275 changes: 275 additions & 0 deletions tariff/ostrom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
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)
}

// Search for a contract in list of contracts
func ensureContractEx(cid string, contracts []ostrom.Contract) (ostrom.Contract, error) {
var zero ostrom.Contract

if cid != "" {
// cid defined
for _, contract := range contracts {
if cid == strconv.FormatInt(contract.Id, 10) {
return contract, nil
}
}
} else if len(contracts) == 1 {
// cid empty and exactly one object
return contracts[0], nil
}

return zero, fmt.Errorf("cannot find contract")
}

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, api.ErrMissingCredentials
}

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

t := &Ostrom{
log: log,
basic: basic,
contractType: ostrom.PRODUCT_DYNAMIC,
Helper: request.NewHelper(log),
data: util.NewMonitor[api.Rates](2 * time.Hour),
}

t.Client.Transport = &oauth2.Transport{
Base: t.Client.Transport,
Source: oauth.RefreshTokenSource(nil, t),
}

contracts, err := t.GetContracts()
if err != nil {
return nil, err
}
contract, err := ensureContractEx(cc.Contract, contracts)
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 {
return nil, err
}
go t.runStatic(done)
} else {
go t.run(done)
}
err = <-done

return t, err
}

func rate(entry ostrom.ForecastInfo) api.Rate {
ts := entry.StartTimestamp.Local()
return api.Rate{
Start: ts,
End: ts.Add(time.Hour),
Price: (entry.Marketprice + entry.AdditionalCost) / 100.0, // Both values include VAT
}
}

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 := t.GetJSON(uri, &city); err != nil {
return 0, err
}
if len(city) < 1 {
return 0, errors.New("city not found")
}
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("tariff not found")
}

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)
return util.TokenWithExpiry(&res), err
}

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

uri := ostrom.URI_API + "/contracts"
err := t.GetJSON(uri, &res)
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

tick := time.NewTicker(time.Hour)
for ; true; <-tick.C {
kscholty marked this conversation as resolved.
Show resolved Hide resolved
val.Marketprice, err = t.getFixedPrice()
Copy link
Member

Choose a reason for hiding this comment

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

Wozu braucht es val.Marketprice?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Der Gesamtpreis ergibt sich als "Marketprice+AdditionalCost", siehe rate(). Da steht also der Preis drin. Ich wollte so weit es geht dieselben Datenstrukturen und Funktionen für statische und dynamische Preise verwenden, daher wird hier auch für statische Preise ein ostrom.ForcastInfo verwendet, um so rate() nutzen zu können.

if err == nil {
val.StartTimestamp = now.BeginningOfDay()
kscholty marked this conversation as resolved.
Show resolved Hide resolved
data := make(api.Rates, 48)
for i := range data {
data[i] = rate(val)
val.StartTimestamp = val.StartTimestamp.Add(time.Hour)
Copy link
Member

Choose a reason for hiding this comment

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

Wozu braucht es val.StartTimestamp? Das sollte eine lokale Variable sein.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Das wird in rate() zur Berechnung von start-und Endzeit verwendet. Ansonsten, siehe mein Kommentar zu "Marketprice"

}
mergeRates(t.data, data)
} else {
t.log.ERROR.Println(err)
Copy link
Member

Choose a reason for hiding this comment

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

Das sieht falsch aus. Fehler zurück geben?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Das würde die Run Funktion ja beenden. Mein Gedanke war, dass es diesmal einen Fehler gegeben haben kann, dass aber den nächste Aufruf vielleicht funktioniert.

}
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 = append(data, rate(val))
}

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:
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 }}