Calculate a monthly grid fee
This guide calculates what a customer pays in grid fees for one month, from a tariff and the customer's hourly consumption. You can use it to show customers their expected cost, to compare tariffs, or to see how much moving consumption would save.
What you need
- A tariff, fetched with
GET /tariffs/{id}. The example usesGN6KW("Tidsindelad 6 kW, Max 63A"), which has a higher power fee on weekdays 07:00–20:00 in winter. - Hourly consumption for the month: the average power in kW for each hour. This is the same number as the kWh used in that hour. You get it from the customer's meter, for example through Home Assistant or the meter's own API. The example generates made-up data instead.
How it works
The code goes through the tariff's three fee groups (see Fees and price components):
- Fixed price: the annual fee divided by 12.
- Energy price: for each component, the kWh used while the component applies × the price. This includes energy tax.
- Power price:
- For
peakcomponents: for each day, take the highest hour while the component applies. Then take the average of the highest days (usually 3) and multiply by the price. - For
dynamiccomponents: fetch the hourly prices for the month and add up power × price for each hour.
- For
A component only counts in the hours where it applies: its validPeriod must cover the date, and the time and day must match its recurringPeriods and calendar patterns. See When a component applies.
Code
- Python
- JavaScript
- C#
"""Calculate the grid fee for one month, from a tariff and hourly consumption.
Requires Python 3.9 or later. Run with: python monthly_cost.py
"""
import json
import urllib.parse
import urllib.request
from datetime import date, datetime, timedelta
BASE_URL = "https://api.uat.goteborgenergi.cloud/gridtariff/v0"
TARIFF_ID = "3e90a7bf-fd2a-4134-8c79-49611e7fffef" # GN6KW, 2026
MONTH = "2026-02"
# Name your application, so it can be told apart from other clients.
USER_AGENT = "grid-tariff-example/1.0"
def example_consumption():
"""Hourly consumption for the month.
Each entry is the average power in kW during the hour, which equals the
energy in kWh used that hour. Timestamps are in Swedish local time with
UTC offset, like in the API. Replace this with data from your meter.
"""
hours = []
for day in range(1, 29):
for hour in range(24):
evening = 17 <= hour < 21
hours.append({
"start": f"{MONTH}-{day:02d}T{hour:02d}:00:00+01:00",
"kW": 3 + (day % 5) * 0.4 if evening else 0.8,
})
return hours
def get_json(path):
request = urllib.request.Request(BASE_URL + path, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request) as response:
return json.load(response)
def in_calendar_pattern(day, reference, calendar_patterns):
pattern = next(p for p in calendar_patterns if p["reference"] == reference)
weekday = date.fromisoformat(day).isoweekday() # Monday = 1 ... Sunday = 7
return weekday in pattern["days"] or day in pattern["dates"]
def is_active(component, start, calendar_patterns):
"""Is the component active during the hour starting at `start`?"""
day, time = start[:10], start[11:19]
valid = component["validPeriod"]
if not valid["fromIncluding"] <= day < valid["toExcluding"]:
return False
if not component.get("recurringPeriods"):
return True
for recurring in component["recurringPeriods"]:
if recurring["frequency"] != "P1D":
raise ValueError(f"Unsupported frequency {recurring['frequency']}")
for period in recurring["activePeriods"]:
to = "24:00:00" if period["toExcluding"] == "00:00:00" else period["toExcluding"]
refs = period["calendarPatternReferences"]
if (
period["fromIncluding"] <= time < to
and any(in_calendar_pattern(day, r, calendar_patterns) for r in refs["include"])
and not any(in_calendar_pattern(day, r, calendar_patterns) for r in refs["exclude"])
):
return True
return False
def peak_power(component, consumption, calendar_patterns):
"""Average of the N highest daily peaks among the hours where the component is active."""
settings = component["peakIdentificationSettings"]
if settings["peakIdentificationPeriod"] != "P1D" or settings["peakDuration"] != "PT1H":
raise ValueError(f"Unsupported peak settings in {component['name']}")
daily_peaks = {}
for hour in consumption:
if is_active(component, hour["start"], calendar_patterns):
day = hour["start"][:10]
daily_peaks[day] = max(daily_peaks.get(day, 0), hour["kW"])
highest = sorted(daily_peaks.values(), reverse=True)
highest = highest[: settings["numberOfPeaksForAverageCalculation"]]
return sum(highest) / len(highest) if highest else 0
def hourly_prices(component, consumption):
start = consumption[0]["start"]
end = (datetime.fromisoformat(consumption[-1]["start"]) + timedelta(hours=1)).isoformat()
query = urllib.parse.urlencode({
"componentId": component["id"],
"fromIncluding": start,
"toExcluding": end,
})
prices = get_json(f"/prices?{query}")
return {datetime.fromisoformat(p["start"]): p for p in prices["actual"] + prices["preview"]}
def monthly_cost(tariff, calendar_patterns, consumption):
lines = []
def add(name, quantity, price, factor):
lines.append({
"name": name,
"quantity": quantity,
"exVat": factor * price["priceExVat"],
"incVat": factor * price["priceIncVat"],
})
month_start = consumption[0]["start"][:7] + "-01"
for c in (tariff.get("fixedPrice") or {}).get("components", []):
valid = c["validPeriod"]
if not valid["fromIncluding"] <= month_start < valid["toExcluding"]:
continue
per_month = {"P1Y": 1 / 12, "P1M": 1}.get(c["pricedPeriod"])
if per_month is None:
raise ValueError(f"Unsupported pricedPeriod {c['pricedPeriod']}")
add(c["name"], "1 month" if per_month == 1 else "1/12 year", c["price"], per_month)
for c in (tariff.get("energyPrice") or {}).get("components", []):
if c["type"] != "fixed":
raise ValueError(f"Unsupported energy component type {c['type']}")
kwh = sum(h["kW"] for h in consumption if is_active(c, h["start"], calendar_patterns))
if kwh > 0:
add(c["name"], f"{kwh:.1f} kWh", c["price"], kwh)
for c in (tariff.get("powerPrice") or {}).get("components", []):
if c["type"] == "peak":
if c["unit"] != "kW":
raise ValueError(f"Unsupported unit {c['unit']} in {c['name']}")
kw = peak_power(c, consumption, calendar_patterns)
if kw > 0:
add(c["name"], f"{kw:.2f} kW", c["price"], kw)
elif c["type"] == "dynamic":
active = [h for h in consumption if is_active(c, h["start"], calendar_patterns)]
if not active:
continue
prices = hourly_prices(c, active)
ex_vat = inc_vat = 0
for hour in active:
price = prices.get(datetime.fromisoformat(hour["start"]))
if price is None:
raise ValueError(f"No price for {hour['start']}")
ex_vat += hour["kW"] * price["priceExVat"]
inc_vat += hour["kW"] * price["priceIncVat"]
lines.append({"name": c["name"], "quantity": f"{len(active)} hours",
"exVat": ex_vat, "incVat": inc_vat})
else:
raise ValueError(f"Unsupported power component type {c['type']}")
return lines
response = get_json(f"/tariffs/{TARIFF_ID}")
tariff = response["tariff"]
lines = monthly_cost(tariff, response["calendarPatterns"], example_consumption())
print(f"{tariff['name']} ({tariff['product']}), {MONTH}\n")
for line in lines:
print(f"{line['name']:<32} {line['quantity']:>12} {line['incVat']:>10.2f} SEK")
print(f"{'Total incl. VAT':<45} {sum(l['incVat'] for l in lines):>10.2f} SEK")
print(f"{'Total excl. VAT':<45} {sum(l['exVat'] for l in lines):>10.2f} SEK")
// Calculate the grid fee for one month, from a tariff and hourly consumption.
// Requires Node.js 20 or later. Run with: node monthly-cost.mjs
const BASE_URL = 'https://api.uat.goteborgenergi.cloud/gridtariff/v0';
const TARIFF_ID = '3e90a7bf-fd2a-4134-8c79-49611e7fffef'; // GN6KW, 2026
const MONTH = '2026-02';
// Hourly consumption for the month. Each entry is the average power in kW
// during the hour, which equals the energy in kWh used that hour.
// Timestamps are in Swedish local time with UTC offset, like in the API.
// Replace this with data from your meter.
function exampleConsumption() {
const hours = [];
for (let day = 1; day <= 28; day++) {
for (let hour = 0; hour < 24; hour++) {
const evening = hour >= 17 && hour < 21;
const date = `${MONTH}-${String(day).padStart(2, '0')}`;
hours.push({
start: `${date}T${String(hour).padStart(2, '0')}:00:00+01:00`,
kW: evening ? 3 + (day % 5) * 0.4 : 0.8,
});
}
}
return hours;
}
async function getJson(path) {
const response = await fetch(BASE_URL + path);
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response.json();
}
// ISO weekday: Monday = 1 ... Sunday = 7
function isoWeekday(date) {
return new Date(`${date}T00:00:00Z`).getUTCDay() || 7;
}
function inCalendarPattern(date, reference, calendarPatterns) {
const pattern = calendarPatterns.find((p) => p.reference === reference);
return pattern.days.includes(isoWeekday(date)) || pattern.dates.includes(date);
}
// Is the component active during the hour starting at `start`?
function isActive(component, start, calendarPatterns) {
const date = start.slice(0, 10);
const time = start.slice(11, 19);
const {fromIncluding, toExcluding} = component.validPeriod;
if (date < fromIncluding || date >= toExcluding) return false;
if (!component.recurringPeriods) return true;
return component.recurringPeriods.some((recurring) => {
if (recurring.frequency !== 'P1D') {
throw new Error(`Unsupported frequency ${recurring.frequency}`);
}
return recurring.activePeriods.some((period) => {
const to = period.toExcluding === '00:00:00' ? '24:00:00' : period.toExcluding;
const {include, exclude} = period.calendarPatternReferences;
return (
time >= period.fromIncluding &&
time < to &&
include.some((ref) => inCalendarPattern(date, ref, calendarPatterns)) &&
!exclude.some((ref) => inCalendarPattern(date, ref, calendarPatterns))
);
});
});
}
// Average of the N highest daily peaks among the hours where the component is active
function peakPower(component, consumption, calendarPatterns) {
const settings = component.peakIdentificationSettings;
if (settings.peakIdentificationPeriod !== 'P1D' || settings.peakDuration !== 'PT1H') {
throw new Error(`Unsupported peak settings in ${component.name}`);
}
const dailyPeaks = new Map();
for (const hour of consumption) {
if (!isActive(component, hour.start, calendarPatterns)) continue;
const date = hour.start.slice(0, 10);
dailyPeaks.set(date, Math.max(dailyPeaks.get(date) ?? 0, hour.kW));
}
const highest = [...dailyPeaks.values()]
.sort((a, b) => b - a)
.slice(0, settings.numberOfPeaksForAverageCalculation);
return highest.length ? highest.reduce((sum, kW) => sum + kW, 0) / highest.length : 0;
}
async function hourlyPrices(component, consumption) {
const from = encodeURIComponent(consumption[0].start);
const lastStart = new Date(consumption.at(-1).start);
// The API doesn't accept fractional seconds, so drop the ".000" from toISOString()
const to = encodeURIComponent(
new Date(lastStart.getTime() + 3600_000).toISOString().replace('.000Z', 'Z'),
);
const prices = await getJson(
`/prices?componentId=${component.id}&fromIncluding=${from}&toExcluding=${to}`,
);
return new Map(
[...prices.actual, ...prices.preview].map((p) => [Date.parse(p.start), p]),
);
}
async function monthlyCost(tariff, calendarPatterns, consumption) {
const lines = [];
const add = (name, quantity, price, factor) =>
lines.push({
name,
quantity,
exVat: factor * price.priceExVat,
incVat: factor * price.priceIncVat,
});
const month = consumption[0].start.slice(0, 7);
const monthStart = `${month}-01`;
for (const c of tariff.fixedPrice?.components ?? []) {
if (c.validPeriod.fromIncluding > monthStart || c.validPeriod.toExcluding <= monthStart) continue;
const perMonth = {P1Y: 1 / 12, P1M: 1}[c.pricedPeriod];
if (perMonth === undefined) throw new Error(`Unsupported pricedPeriod ${c.pricedPeriod}`);
add(c.name, perMonth === 1 ? '1 month' : '1/12 year', c.price, perMonth);
}
for (const c of tariff.energyPrice?.components ?? []) {
if (c.type !== 'fixed') throw new Error(`Unsupported energy component type ${c.type}`);
const kWh = consumption
.filter((hour) => isActive(c, hour.start, calendarPatterns))
.reduce((sum, hour) => sum + hour.kW, 0);
if (kWh > 0) add(c.name, `${kWh.toFixed(1)} kWh`, c.price, kWh);
}
for (const c of tariff.powerPrice?.components ?? []) {
if (c.type === 'peak') {
if (c.unit !== 'kW') throw new Error(`Unsupported unit ${c.unit} in ${c.name}`);
const kW = peakPower(c, consumption, calendarPatterns);
if (kW > 0) add(c.name, `${kW.toFixed(2)} kW`, c.price, kW);
} else if (c.type === 'dynamic') {
const active = consumption.filter((hour) => isActive(c, hour.start, calendarPatterns));
if (!active.length) continue;
const prices = await hourlyPrices(c, active);
let exVat = 0;
let incVat = 0;
for (const hour of active) {
const price = prices.get(Date.parse(hour.start));
if (!price) throw new Error(`No price for ${hour.start}`);
exVat += hour.kW * price.priceExVat;
incVat += hour.kW * price.priceIncVat;
}
lines.push({name: c.name, quantity: `${active.length} hours`, exVat, incVat});
} else {
throw new Error(`Unsupported power component type ${c.type}`);
}
}
return lines;
}
const {tariff, calendarPatterns} = await getJson(`/tariffs/${TARIFF_ID}`);
const lines = await monthlyCost(tariff, calendarPatterns, exampleConsumption());
console.log(`${tariff.name} (${tariff.product}), ${MONTH}\n`);
for (const line of lines) {
console.log(
`${line.name.padEnd(32)} ${line.quantity.padStart(12)} ${line.incVat.toFixed(2).padStart(10)} SEK`,
);
}
const total = lines.reduce((sum, line) => sum + line.incVat, 0);
const totalExVat = lines.reduce((sum, line) => sum + line.exVat, 0);
console.log(`${'Total incl. VAT'.padEnd(45)} ${total.toFixed(2).padStart(10)} SEK`);
console.log(`${'Total excl. VAT'.padEnd(45)} ${totalExVat.toFixed(2).padStart(10)} SEK`);
// Calculate the grid fee for one month, from a tariff and hourly consumption.
// Requires .NET 8 or later. Put this in Program.cs of a console project
// (dotnet new console) and run with: dotnet run
using System.Globalization;
using System.Net.Http.Json;
const string BaseUrl = "https://api.uat.goteborgenergi.cloud/gridtariff/v0";
const string TariffId = "3e90a7bf-fd2a-4134-8c79-49611e7fffef"; // GN6KW, 2026
const string Month = "2026-02";
using var http = new HttpClient();
var invariant = CultureInfo.InvariantCulture;
var response = await http.GetFromJsonAsync<TariffResponse>($"{BaseUrl}/tariffs/{TariffId}")
?? throw new InvalidOperationException("Empty response");
var tariff = response.Tariff;
var patterns = response.CalendarPatterns;
var consumption = ExampleConsumption();
var lines = new List<Line>();
var monthStart = Month + "-01";
foreach (var c in tariff.FixedPrice?.Components ?? [])
{
if (Before(monthStart, c.ValidPeriod.FromIncluding) || !Before(monthStart, c.ValidPeriod.ToExcluding)) continue;
var perMonth = c.PricedPeriod switch
{
"P1Y" => 1m / 12,
"P1M" => 1m,
_ => throw new NotSupportedException($"Unsupported pricedPeriod {c.PricedPeriod}"),
};
lines.Add(Charge(c, perMonth == 1 ? "1 month" : "1/12 year", perMonth));
}
foreach (var c in tariff.EnergyPrice?.Components ?? [])
{
if (c.Type != "fixed") throw new NotSupportedException($"Unsupported energy component type {c.Type}");
var kWh = consumption.Where(h => IsActive(c, h.Start)).Sum(h => h.KW);
if (kWh > 0) lines.Add(Charge(c, $"{kWh:F1} kWh", kWh));
}
foreach (var c in tariff.PowerPrice?.Components ?? [])
{
if (c.Type == "peak")
{
if (c.Unit != "kW") throw new NotSupportedException($"Unsupported unit {c.Unit} in {c.Name}");
var kW = PeakPower(c);
if (kW > 0) lines.Add(Charge(c, $"{kW:F2} kW", kW));
}
else if (c.Type == "dynamic")
{
var active = consumption.Where(h => IsActive(c, h.Start)).ToList();
if (active.Count == 0) continue;
var prices = await HourlyPrices(c, active);
decimal exVat = 0, incVat = 0;
foreach (var hour in active)
{
if (!prices.TryGetValue(hour.Start, out var price))
throw new InvalidOperationException($"No price for {hour.Start}");
exVat += hour.KW * price.PriceExVat;
incVat += hour.KW * price.PriceIncVat;
}
lines.Add(new Line(c.Name, $"{active.Count} hours", exVat, incVat));
}
else
{
throw new NotSupportedException($"Unsupported power component type {c.Type}");
}
}
Console.WriteLine($"{tariff.Name} ({tariff.Product}), {Month}\n");
foreach (var line in lines)
Console.WriteLine($"{line.Name,-32} {line.Quantity,12} {line.IncVat,10:F2} SEK");
Console.WriteLine($"{"Total incl. VAT",-45} {lines.Sum(l => l.IncVat),10:F2} SEK");
Console.WriteLine($"{"Total excl. VAT",-45} {lines.Sum(l => l.ExVat),10:F2} SEK");
// Hourly consumption for the month. KW is the average power during the hour,
// which equals the energy in kWh used that hour. Timestamps are in Swedish
// local time with UTC offset, like in the API. Replace this with data from your meter.
List<ConsumptionHour> ExampleConsumption()
{
var hours = new List<ConsumptionHour>();
for (var day = 1; day <= 28; day++)
{
for (var hour = 0; hour < 24; hour++)
{
var evening = hour >= 17 && hour < 21;
hours.Add(new ConsumptionHour(
DateTimeOffset.Parse($"{Month}-{day:D2}T{hour:D2}:00:00+01:00", invariant),
evening ? 3 + (day % 5) * 0.4m : 0.8m));
}
}
return hours;
}
static bool Before(string a, string b) => string.CompareOrdinal(a, b) < 0;
static Line Charge(Component c, string quantity, decimal factor) =>
new(c.Name, quantity, factor * c.Price!.PriceExVat, factor * c.Price.PriceIncVat);
bool InCalendarPattern(string date, string reference)
{
var pattern = patterns.First(p => p.Reference == reference);
var weekday = (int)DateOnly.ParseExact(date, "yyyy-MM-dd", invariant).DayOfWeek;
if (weekday == 0) weekday = 7; // Monday = 1 ... Sunday = 7
return pattern.Days.Contains(weekday) || pattern.Dates.Contains(date);
}
// Is the component active during the hour starting at `start`?
bool IsActive(Component c, DateTimeOffset start)
{
var date = start.ToString("yyyy-MM-dd", invariant);
var time = start.ToString("HH:mm:ss", invariant);
if (Before(date, c.ValidPeriod.FromIncluding) || !Before(date, c.ValidPeriod.ToExcluding)) return false;
if (c.RecurringPeriods is null) return true;
return c.RecurringPeriods.Any(recurring =>
{
if (recurring.Frequency != "P1D")
throw new NotSupportedException($"Unsupported frequency {recurring.Frequency}");
return recurring.ActivePeriods.Any(period =>
{
var to = period.ToExcluding == "00:00:00" ? "24:00:00" : period.ToExcluding;
var refs = period.CalendarPatternReferences;
return !Before(time, period.FromIncluding)
&& Before(time, to)
&& refs.Include.Any(r => InCalendarPattern(date, r))
&& !refs.Exclude.Any(r => InCalendarPattern(date, r));
});
});
}
// Average of the N highest daily peaks among the hours where the component is active
decimal PeakPower(Component c)
{
var settings = c.PeakIdentificationSettings!;
if (settings.PeakIdentificationPeriod != "P1D" || settings.PeakDuration != "PT1H")
throw new NotSupportedException($"Unsupported peak settings in {c.Name}");
var highest = consumption
.Where(h => IsActive(c, h.Start))
.GroupBy(h => h.Start.ToString("yyyy-MM-dd", invariant))
.Select(day => day.Max(h => h.KW))
.OrderByDescending(kW => kW)
.Take(settings.NumberOfPeaksForAverageCalculation)
.ToList();
return highest.Count > 0 ? highest.Average() : 0;
}
async Task<Dictionary<DateTimeOffset, HourlyPrice>> HourlyPrices(Component c, List<ConsumptionHour> hours)
{
var from = Uri.EscapeDataString(hours[0].Start.ToString("yyyy-MM-dd'T'HH:mm:sszzz", invariant));
var to = Uri.EscapeDataString(hours[^1].Start.AddHours(1).ToString("yyyy-MM-dd'T'HH:mm:sszzz", invariant));
var prices = await http.GetFromJsonAsync<PricesResponse>(
$"{BaseUrl}/prices?componentId={c.Id}&fromIncluding={from}&toExcluding={to}")
?? throw new InvalidOperationException("Empty response");
return prices.Actual.Concat(prices.Preview).ToDictionary(p => p.Start);
}
record ConsumptionHour(DateTimeOffset Start, decimal KW);
record Line(string Name, string Quantity, decimal ExVat, decimal IncVat);
record TariffResponse(Tariff Tariff, List<CalendarPattern> CalendarPatterns);
record Tariff(string Name, string Product, PriceGroup? FixedPrice, PriceGroup? EnergyPrice, PriceGroup? PowerPrice);
record PriceGroup(List<Component> Components);
record Component(
string Id, string Name, string Type, Period ValidPeriod, Price? Price, string? Unit,
string? PricedPeriod, PeakSettings? PeakIdentificationSettings, List<RecurringPeriod>? RecurringPeriods);
record Period(string FromIncluding, string ToExcluding);
record Price(decimal PriceExVat, decimal PriceIncVat);
record PeakSettings(string PeakIdentificationPeriod, string PeakDuration, int NumberOfPeaksForAverageCalculation);
record RecurringPeriod(string Frequency, List<ActivePeriod> ActivePeriods);
record ActivePeriod(string FromIncluding, string ToExcluding, PatternReferences CalendarPatternReferences);
record PatternReferences(List<string> Include, List<string> Exclude);
record CalendarPattern(string Reference, List<int> Days, List<string> Dates);
record PricesResponse(string Currency, string Unit, List<HourlyPrice> Actual, List<HourlyPrice> Preview);
record HourlyPrice(DateTimeOffset Start, DateTimeOffset End, decimal PriceExVat, decimal PriceIncVat);
Example output:
Tidsindelad 6 kW, Max 63A (GN6KW), 2026-02
Fast avgift 1/12 year 200.00 SEK
Elöverföringsavgift 873.6 kWh 56.78 SEK
Energiskatt 873.6 kWh 393.12 SEK
Tidsindelad, effektavgift hög 4.60 kW 621.00 SEK
Tidsindelad, effektavgift låg 4.60 kW 0.00 SEK
Total incl. VAT 1270.90 SEK
Total excl. VAT 1016.72 SEK
Limitations
The example covers the components that Göteborg Energi's tariffs for households and smaller businesses use. It stops with an error if it meets something it doesn't handle, rather than giving a wrong answer:
- Components that need other data than hourly power, such as reactive power (
kVAr) or a subscribed power level. - Peaks measured over other periods than one hour per day.
It also assumes that all consumption timestamps are in Swedish local time and that the whole month uses the same tariff version.
When you send a time range to /prices, don't include fractional seconds: 2026-02-01T00:00:00+01:00 and 2026-01-31T23:00:00Z work, but 2026-01-31T23:00:00.000Z is rejected. In JavaScript, toISOString() always adds .000, so remove it.