diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index c221f8b48..2379c9b27 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -236,7 +236,7 @@ import AnimatedNumber from "../AnimatedNumber.vue"; import settings from "../../settings"; import { CO2_TYPE } from "../../units"; import collector from "../../mixins/collector"; -import { energyByDay } from "../../utils/forecast"; + export default { name: "Energyflow", components: { @@ -390,9 +390,7 @@ export default { return this.fmtPricePerKWh(this.batteryGridChargeLimit, this.currency, true); }, solarForecast() { - const slots = this.forecast.solar || []; - if (slots.length === 0) return undefined; - return energyByDay(slots, 0); + return this.forecast?.solar?.today?.energy || undefined; }, }, watch: { diff --git a/assets/js/components/ForecastActiveSlot.vue b/assets/js/components/ForecastActiveSlot.vue index f0c368ac1..14e680137 100644 --- a/assets/js/components/ForecastActiveSlot.vue +++ b/assets/js/components/ForecastActiveSlot.vue @@ -1,23 +1,32 @@ diff --git a/assets/js/components/ForecastChart.vue b/assets/js/components/ForecastChart.vue index 49bb507fa..999efed83 100644 --- a/assets/js/components/ForecastChart.vue +++ b/assets/js/components/ForecastChart.vue @@ -32,10 +32,11 @@ import { registerChartComponents, commonOptions } from "./Sessions/chartConfig"; import formatter, { POWER_UNIT } from "../mixins/formatter"; import colors, { lighterColor } from "../colors"; import { - energyByDay, highestSlotIndexByDay, - type PriceSlot, ForecastType, + type PriceSlot, + type SolarDetails, + type TimeseriesEntry, } from "../utils/forecast"; registerChartComponents([ @@ -58,7 +59,7 @@ export default defineComponent({ mixins: [formatter], props: { grid: { type: Array as PropType }, - solar: { type: Array as PropType }, + solar: { type: Object as PropType }, co2: { type: Array as PropType }, currency: { type: String as PropType }, selected: { type: String as PropType }, @@ -66,29 +67,29 @@ export default defineComponent({ emits: ["selected"], data(): { selectedIndex: number | null; + startDate: Date; + interval: ReturnType | null; ignoreEvents: boolean; ignoreEventsTimeout: ReturnType | null; + animations: boolean; } { return { selectedIndex: null, + startDate: new Date(), + interval: null, ignoreEvents: false, ignoreEventsTimeout: null, + animations: false, }; }, computed: { - startDate() { - const now = new Date(); - const slots = this.grid || this.co2 || this.solar || []; - const currentSlot = slots.find(({ start, end }) => { - return new Date(start) <= now && new Date(end) > now; - }); - if (currentSlot) { - return new Date(currentSlot.start); - } - return now; + endDate() { + const end = new Date(this.startDate); + end.setHours(end.getHours() + 48); + return end; }, - solarSlots() { - return this.filterSlots(this.solar); + solarEntries() { + return this.filterEntries(this.solar?.timeseries || []); }, gridSlots() { return this.filterSlots(this.grid); @@ -96,21 +97,6 @@ export default defineComponent({ co2Slots() { return this.filterSlots(this.co2); }, - currentSlots() { - switch (this.selected) { - case ForecastType.Price: - return this.gridSlots; - case ForecastType.Solar: - return this.solarSlots; - case ForecastType.Co2: - return this.co2Slots; - default: - return []; - } - }, - selectedSlot() { - return this.selectedIndex !== null ? this.currentSlots[this.selectedIndex] : null; - }, maxPriceIndex() { return this.maxIndex(this.gridSlots); }, @@ -124,38 +110,54 @@ export default defineComponent({ return this.minIndex(this.co2Slots); }, maxSolarIndex() { - return this.maxIndex(this.solarSlots); + return this.maxEntryIndex(this.solarEntries); }, solarHighlights() { - return [0, 1, 2].map((day) => { - const energy = energyByDay(this.solarSlots, day); - const index = highestSlotIndexByDay(this.solarSlots, day); - return { index, energy }; - }); + const { today, tomorrow, dayAfterTomorrow } = this.solar || {}; + return [ + { + index: highestSlotIndexByDay(this.solarEntries, 0), + energy: today?.energy, + }, + { + index: highestSlotIndexByDay(this.solarEntries, 1), + energy: tomorrow?.energy, + }, + { + index: highestSlotIndexByDay(this.solarEntries, 2), + energy: dayAfterTomorrow?.energy, + }, + ]; }, chartData() { const datasets: unknown[] = []; - if (this.solarSlots.length > 0) { + if (this.solarEntries.length > 0) { const active = this.selected === ForecastType.Solar; const color = active ? colors.self : colors.border; datasets.push({ label: ForecastType.Solar, type: "line", - data: this.solarSlots.map((slot, index) => ({ - y: slot.price, - x: new Date(slot.start), - highlight: - active && - (this.selectedIndex !== null - ? this.selectedIndex === index - : this.solarHighlights.find(({ index: i }) => i === index)?.energy), - })), + data: this.solarEntries.map((entry, index) => { + return { + y: entry.val, + x: new Date(entry.ts), + highlight: + active && + (this.selectedIndex !== null + ? this.selectedIndex === index + : this.solarHighlights.find(({ index: i }) => i === index) + ?.energy), + }; + }), yAxisID: "yForecast", backgroundColor: lighterColor(color), borderColor: color, - fill: "origin", + fill: "start", tension: 0.5, pointRadius: 0, + animation: { + y: { duration: this.animations ? 500 : 0 }, + }, pointHoverRadius: active ? 4 : 0, spanGaps: true, order: active ? 0 : 1, @@ -254,16 +256,27 @@ export default defineComponent({ backgroundColor: function (context) { return context.dataset.borderColor; }, - align: function (context) { - // rotate label position to avoid horizontal clipping + align: function ({ chart, dataset, dataIndex }) { + const { min, max } = chart.scales.x; + const time = new Date(dataset.data[dataIndex].x).getTime(); + + // percent along the x axis (0: start, 1: end) + const percent = (time - min) / (max - min); + let adjust = 0; const step = 20; - const adjust = { - 0: step * 2, - 1: step * 1, - 46: step * -1, - 47: step * -2, - }; - return -90 + (adjust[context.dataIndex] || 0); + + // tilt label left/right if it's close to the edge + if (percent < 0.02) { + adjust = 2; + } else if (percent < 0.04) { + adjust = 1; + } else if (percent > 0.98) { + adjust = -2; + } else if (percent > 0.96) { + adjust = -1; + } + + return -90 + adjust * step; }, anchor: "end", offset: 8, @@ -310,7 +323,7 @@ export default defineComponent({ type: "timeseries", display: true, time: { unit: "day" }, - border: { display: false }, + border: { display: true }, grid: { display: true, color: colors.border, @@ -323,6 +336,8 @@ export default defineComponent({ return Array.isArray(label) ? 1 : 0; }, }, + min: this.startDate, + max: this.endDate, ticks: { color: colors.muted, autoSkip: false, @@ -333,6 +348,10 @@ export default defineComponent({ callback: function (value) { const date = new Date(value); const hour = date.getHours(); + const minute = date.getMinutes(); + if (minute !== 0) { + return ""; + } const hourFmt = vThis.hourShort(date); if (hour === 0) { return [hourFmt, vThis.weekdayShort(date)]; @@ -344,21 +363,70 @@ export default defineComponent({ }, }, }, - yForecast: { display: false, min: 0, max: this.yMax(this.solarSlots) }, + yForecast: { + display: false, + min: 0, + max: this.yMaxEntry(this.solarEntries, this.solar?.scale), + beginAtZero: true, + }, yCo2: { display: false, min: 0, max: this.yMax(this.co2Slots) }, yPrice: { display: false, min: 0, max: this.yMax(this.gridSlots) }, }, }; }, + selectedSlot() { + if (this.selectedIndex === null || !this.selected) return null; + + const slotMap = { + [ForecastType.Solar]: this.solarEntries, + [ForecastType.Price]: this.gridSlots, + [ForecastType.Co2]: this.co2Slots, + }; + + return slotMap[this.selected]?.[this.selectedIndex] ?? null; + }, }, watch: { selectedSlot(slot) { this.$emit("selected", slot); }, }, + mounted() { + this.interval = setTimeout(() => { + this.updateStartDate(); + }, 1000 * 60); + this.updateStartDate(); + setTimeout(() => { + this.animations = true; + }, 1000); + }, + beforeUnmount() { + clearTimeout(this.interval); + }, methods: { + updateStartDate() { + const now = new Date(); + now.setMinutes(0); + now.setSeconds(0); + now.setMilliseconds(0); + this.startDate = now; + }, filterSlots(slots: PriceSlot[] = []) { - return slots.filter((slot) => new Date(slot.end) > this.startDate).slice(0, 48); + return slots.filter( + (slot) => + new Date(slot.end) >= this.startDate && new Date(slot.start) <= this.endDate + ); + }, + filterEntries(entries: TimeseriesEntry[] = []) { + // include 1 hour before and after + const start = new Date(this.startDate); + start.setHours(start.getHours() - 1); + const end = new Date(this.endDate); + end.setHours(end.getHours() + 1); + + return entries.filter( + (entry) => new Date(entry.ts) >= start && new Date(entry.ts) <= end + ); }, onMouseLeave() { this.selectIndex(null, true); @@ -387,6 +455,12 @@ export default defineComponent({ const value = this.maxValue(slots); return value ? value * 1.15 : undefined; }, + yMaxEntry(entries: TimeseriesEntry[] = [], scale: number = 1): number | undefined { + const maxValue = this.maxEntryValue(entries); + if (!maxValue) return undefined; + // use scale and unscaled to determine max scale + return Math.max(maxValue * scale, maxValue) * 1.15; + }, maxIndex(slots: PriceSlot[] = []) { return slots.reduce((max, slot, index) => { return slot.price > slots[max].price ? index : max; @@ -400,6 +474,14 @@ export default defineComponent({ maxValue(slots: PriceSlot[] = []) { return slots[this.maxIndex(slots)]?.price || null; }, + maxEntryValue(entries: TimeseriesEntry[] = []) { + return entries[this.maxEntryIndex(entries)]?.val || null; + }, + maxEntryIndex(entries: TimeseriesEntry[] = []) { + return entries.reduce((max, entry, index) => { + return entry.val > entries[max].val ? index : max; + }, 0); + }, }, }); diff --git a/assets/js/components/ForecastDetails.vue b/assets/js/components/ForecastDetails.vue index bfe77e4da..c39385993 100644 --- a/assets/js/components/ForecastDetails.vue +++ b/assets/js/components/ForecastDetails.vue @@ -1,9 +1,11 @@ diff --git a/assets/js/components/ForecastModal.vue b/assets/js/components/ForecastModal.vue index 8bca85ee2..0627415ae 100644 --- a/assets/js/components/ForecastModal.vue +++ b/assets/js/components/ForecastModal.vue @@ -7,22 +7,44 @@ @open="modalVisible" @closed="modalInvisible" > -
- - +
+
+ + +
+ + + +
+ +
+ +
+
- - - - @@ -36,15 +58,15 @@ import ForecastChart from "./ForecastChart.vue"; import ForecastTypeSelect from "./ForecastTypeSelect.vue"; import ForecastDetails from "./ForecastDetails.vue"; import ForecastActiveSlot from "./ForecastActiveSlot.vue"; -import { type PriceSlot, ForecastType } from "../utils/forecast"; +import { + type PriceSlot, + type TimeseriesEntry, + type Forecast, + ForecastType, + adjustedSolar, +} from "../utils/forecast"; import formatter from "../mixins/formatter"; - -interface Forecast { - grid?: PriceSlot[]; - solar?: PriceSlot[]; - co2?: PriceSlot[]; -} - +import settings from "../settings"; export default defineComponent({ name: "ForecastModal", components: { @@ -62,7 +84,7 @@ export default defineComponent({ data: function (): { isModalVisible: boolean; selectedType: ForecastType; - selectedSlot: PriceSlot | null; + selectedSlot: PriceSlot | TimeseriesEntry | null; } { return { isModalVisible: false, @@ -71,13 +93,16 @@ export default defineComponent({ }; }, computed: { - selectedSlots() { - const slots = { - [ForecastType.Solar]: this.forecast.solar, - [ForecastType.Price]: this.forecast.grid, - [ForecastType.Co2]: this.forecast.co2, - }; - return slots[this.selectedType] || []; + solarAdjusted() { + return settings.solarAdjusted; + }, + showSolarAdjust() { + return !!this.forecast.solar && this.$hiddenFeatures(); + }, + solar() { + return this.showSolarAdjust && this.solarAdjusted + ? adjustedSolar(this.forecast.solar) + : this.forecast.solar; }, }, watch: { @@ -97,7 +122,7 @@ export default defineComponent({ modalInvisible() { this.isModalVisible = false; }, - updateSlot(slot: PriceSlot | null) { + updateSlot(slot: PriceSlot | TimeseriesEntry | null) { this.selectedSlot = slot; }, updateSelectedType() { @@ -114,6 +139,9 @@ export default defineComponent({ Object.values(ForecastType).find((type) => availableTypes[type]) || Object.values(ForecastType)[0]; }, + changeAdjusted() { + settings.solarAdjusted = !settings.solarAdjusted; + }, }, }); diff --git a/assets/js/settings.js b/assets/js/settings.js index 210584a5d..060997a65 100644 --- a/assets/js/settings.js +++ b/assets/js/settings.js @@ -11,7 +11,7 @@ const SAVINGS_PERIOD = "savings_period"; const SAVINGS_REGION = "savings_region"; const SESSIONS_GROUP = "sessions_group"; const SESSIONS_TYPE = "sessions_type"; - +const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted"; function read(key) { return window.localStorage[key]; } @@ -64,6 +64,7 @@ const settings = reactive({ savingsRegion: read(SAVINGS_REGION), sessionsGroup: read(SESSIONS_GROUP), sessionsType: read(SESSIONS_TYPE), + solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), }); watch(() => settings.locale, save(SETTINGS_LOCALE)); @@ -77,4 +78,5 @@ watch(() => settings.savingsPeriod, save(SAVINGS_PERIOD)); watch(() => settings.savingsRegion, save(SAVINGS_REGION)); watch(() => settings.sessionsGroup, save(SESSIONS_GROUP)); watch(() => settings.sessionsType, save(SESSIONS_TYPE)); +watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED)); export default settings; diff --git a/assets/js/utils/forecast.test.ts b/assets/js/utils/forecast.test.ts deleted file mode 100644 index 4930712ab..000000000 --- a/assets/js/utils/forecast.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { aggregateEnergy, type PriceSlot } from "./forecast"; - -const slots: PriceSlot[] = [ - { start: "2050-03-20T10:00:00+01:00", end: "2050-03-20T10:30:00+01:00", price: 2000 }, // 1kWh - { start: "2050-03-20T10:30:00+01:00", end: "2050-03-20T11:00:00+01:00", price: 4000 }, // 2kWh - { start: "2050-03-20T11:00:00+01:00", end: "2050-03-20T11:30:00+01:00", price: 3000 }, // 1.5kWh - { start: "2050-03-20T11:30:00+01:00", end: "2050-03-20T12:00:00+01:00", price: 2000 }, // 1kWh - { start: "2050-03-20T12:00:00+01:00", end: "2050-03-20T12:30:00+01:00", price: 0 }, // 0kWh - { start: "2050-03-20T12:30:00+01:00", end: "2050-03-20T13:00:00+01:00", price: 0 }, // 0kWh -]; - -describe("aggregateEnergy", () => { - test("aggregates energy from power values for 30min slots", () => { - const result = aggregateEnergy(slots); - expect(result).toBeCloseTo(5500); - }); - test("ignore slots in the past", () => { - // will ignore the first two slots - vi.setSystemTime("2050-03-20T11:00:00+01:00"); - const result = aggregateEnergy(slots, true); - expect(result).toBeCloseTo(2500); - }); - test("correctly split in-between slots", () => { - // start in the middle of the 4th slot - vi.setSystemTime("2050-03-20T11:45:00+01:00"); - const result = aggregateEnergy(slots, true); - expect(result).toBeCloseTo(500); - }); -}); diff --git a/assets/js/utils/forecast.ts b/assets/js/utils/forecast.ts index 2ded3dbb2..f4b7a06f3 100644 --- a/assets/js/utils/forecast.ts +++ b/assets/js/utils/forecast.ts @@ -1,34 +1,41 @@ +import deepCopy from "./deepClone"; + +export interface TimeseriesEntry { + val: number; + ts: string; +} + export interface PriceSlot { start: string; end: string; price: number; } +export interface EnergyByDay { + energy: number; + complete: boolean; +} + +export interface SolarDetails { + scale?: number; + today?: EnergyByDay; + tomorrow?: EnergyByDay; + dayAfterTomorrow?: EnergyByDay; + timeseries?: TimeseriesEntry[]; +} + +export interface Forecast { + grid?: PriceSlot[]; + co2?: PriceSlot[]; + solar?: SolarDetails; +} + export enum ForecastType { Solar = "solar", Price = "price", Co2 = "co2", } -export function aggregateEnergy(slots: PriceSlot[], ignorePast: boolean = false): number { - const now = new Date(); - return slots.reduce((acc: number, { start, end, price: power }: PriceSlot) => { - let startDate = new Date(start); - const endDate = new Date(end); - if (ignorePast) { - if (endDate < now) { - return acc; // ignore past slots - } - if (startDate < now) { - startDate = now; // count this slot from now on - } - } - const hours = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60); // convert ms to hours - const energy = power * hours; // Wh - return acc + energy; - }, 0); -} - // return the date in local YYYY-MM-DD format function toDayString(date: Date): string { const year = date.getFullYear(); @@ -38,22 +45,19 @@ function toDayString(date: Date): string { } // return only slots that are on a given date, ignores slots that are in the past -export function filterSlotsByDate(slots: PriceSlot[], dayString: string): PriceSlot[] { +export function filterEntriesByDate( + entries: TimeseriesEntry[], + dayString: string +): TimeseriesEntry[] { const now = new Date(); - return slots.filter(({ start, end }) => { - const isPast = new Date(end) < now; - const dateMatches = toDayString(new Date(start)) === dayString; + return entries.filter(({ ts }) => { + const isPast = new Date(ts) < now; + const dateMatches = toDayString(new Date(ts)) === dayString; return !isPast && dateMatches; }); } -// return the energy for a given day (0 = today, 1 = tomorrow, etc.) -export function energyByDay(slots: PriceSlot[], day: number = 0): number { - const dayString = dayStringByOffset(day); - const daySlots = filterSlotsByDate(slots, dayString); - return aggregateEnergy(daySlots, true); -} - +// return the date in local YYYY-MM-DD format export function dayStringByOffset(day: number): string { const date = new Date(); date.setDate(date.getDate() + day); @@ -61,10 +65,23 @@ export function dayStringByOffset(day: number): string { } // return the highest slot for a given day (0 = today, 1 = tomorrow, etc.) -export function highestSlotIndexByDay(slots: PriceSlot[], day: number = 0): number { +export function highestSlotIndexByDay(entries: TimeseriesEntry[], day: number = 0): number { const dayString = dayStringByOffset(day); - const daySlots = filterSlotsByDate(slots, dayString); - const sortedSlots = daySlots.sort((a, b) => b.price - a.price); - const highestSlot = sortedSlots[0] || {}; - return slots.findIndex((slot) => slot.start === highestSlot.start); + const dayEntries = filterEntriesByDate(entries, dayString); + const sortedEntries = dayEntries.sort((a, b) => b.val - a.val); + const highestEntry = sortedEntries[0] || {}; + return entries.findIndex((entry) => entry.ts === highestEntry.ts); +} + +export function adjustedSolar(solar: SolarDetails | undefined): SolarDetails | undefined { + if (!solar?.scale) return solar; + + const { scale } = solar; + const result = deepCopy(solar); + result.today.energy *= scale; + result.tomorrow.energy *= scale; + result.dayAfterTomorrow.energy *= scale; + result.timeseries?.forEach((entry) => (entry.val *= scale)); + result.scale = 1 / scale; // invert to allow back-adjustment + return result; } diff --git a/core/keys/site.go b/core/keys/site.go index 13f652bc4..a6ec22e9c 100644 --- a/core/keys/site.go +++ b/core/keys/site.go @@ -20,6 +20,8 @@ const ( SmartCostType = "smartCostType" Statistics = "statistics" Forecast = "forecast" + SolarAccYield = "solarAccYield" + SolarAccForecast = "solarAccForecast" TariffCo2 = "tariffCo2" TariffCo2Home = "tariffCo2Home" TariffCo2Loadpoints = "tariffCo2Loadpoints" diff --git a/core/meterenergy.go b/core/meterenergy.go new file mode 100644 index 000000000..2e90b2c21 --- /dev/null +++ b/core/meterenergy.go @@ -0,0 +1,58 @@ +package core + +import ( + "time" + + "github.com/benbjohnson/clock" + "github.com/jinzhu/now" + "github.com/samber/lo" +) + +type meterEnergy struct { + clock clock.Clock + updated time.Time + meter *float64 // kWh + Accumulated float64 `json:"accumulated"` // kWh +} + +// reset resets data +func (m *meterEnergy) reset() { + m.updated = time.Time{} + m.meter = nil + m.Accumulated = 0 +} + +func (m *meterEnergy) AccumulatedEnergy() float64 { + return m.Accumulated +} + +func (m *meterEnergy) AddMeterTotal(v float64) { + defer func() { + m.updated = m.clock.Now() + m.meter = lo.ToPtr(v) + }() + + if m.meter == nil { + return + } + + m.Accumulated += v - *m.meter +} + +func (m *meterEnergy) AddEnergy(v float64) { + defer func() { m.updated = m.clock.Now() }() + + if m.updated.IsZero() { + return + } + + m.Accumulated += v +} + +func (m *meterEnergy) AddPower(v float64) { + m.AddEnergy(v * m.clock.Since(m.updated).Hours() / 1e3) +} + +func beginningOfDay(t time.Time) time.Time { + return now.With(t).BeginningOfDay() +} diff --git a/core/meterenergy_test.go b/core/meterenergy_test.go new file mode 100644 index 000000000..9d017c685 --- /dev/null +++ b/core/meterenergy_test.go @@ -0,0 +1,31 @@ +package core + +import ( + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/jinzhu/now" + "github.com/stretchr/testify/assert" +) + +func TestMeterEnergy(t *testing.T) { + clock := clock.NewMock() + clock.Set(now.BeginningOfDay()) + + me := &meterEnergy{clock: clock} + + me.AddMeterTotal(10) + assert.Equal(t, 0.0, me.AccumulatedEnergy()) + me.AddMeterTotal(11) + assert.Equal(t, 1.0, me.AccumulatedEnergy()) + me.AddMeterTotal(11) + assert.Equal(t, 1.0, me.AccumulatedEnergy()) + + me.AddPower(1e3) + assert.Equal(t, 1.0, me.AccumulatedEnergy()) + + clock.Add(30 * time.Minute) + me.AddPower(1e3) + assert.Equal(t, 1.5, me.AccumulatedEnergy()) +} diff --git a/core/site.go b/core/site.go index 938671590..31f2dd62b 100644 --- a/core/site.go +++ b/core/site.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/benbjohnson/clock" "github.com/cenkalti/backoff/v4" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/cmd/shutdown" @@ -95,15 +96,18 @@ type Site struct { coordinator *coordinator.Coordinator // Vehicles prioritizer *prioritizer.Prioritizer // Power budgets stats *Stats // Stats + fcstEnergy *meterEnergy + pvEnergy map[string]*meterEnergy // cached state - gridPower float64 // Grid power - pvPower float64 // PV power - excessDCPower float64 // PV excess DC charge power (hybrid only) - auxPower float64 // Aux power - batteryPower float64 // Battery power (charge negative, discharge positive) - batterySoc float64 // Battery soc - batteryMode api.BatteryMode // Battery mode (runtime only, not persisted) + gridPower float64 // Grid power + pvPower float64 // PV power + excessDCPower float64 // PV excess DC charge power (hybrid only) + auxPower float64 // Aux power + batteryPower float64 // Battery power (charge negative, discharge positive) + batterySoc float64 // Battery soc + batteryCapacity float64 // Battery capacity + batteryMode api.BatteryMode // Battery mode (runtime only, not persisted) } // MetersConfig contains the site's meter configuration @@ -194,6 +198,9 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif return err } site.pvMeters = append(site.pvMeters, dev.Instance()) + + // accumulator + site.pvEnergy[ref] = &meterEnergy{clock: clock.New()} } // multiple batteries @@ -241,12 +248,14 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif // NewSite creates a Site with sane defaults func NewSite() *Site { - lp := &Site{ - log: util.NewLogger("site"), - Voltage: 230, // V + site := &Site{ + log: util.NewLogger("site"), + Voltage: 230, // V + pvEnergy: make(map[string]*meterEnergy), + fcstEnergy: &meterEnergy{clock: clock.New()}, } - return lp + return site } // restoreMetersAndTitle restores site meter configuration @@ -309,6 +318,23 @@ func (site *Site) restoreSettings() error { site.SetBatteryGridChargeLimit(&v) } + // restore accumulated energy + pvEnergy := make(map[string]float64) + fcstEnergy, err := settings.Float(keys.SolarAccForecast) + + if err == nil && settings.Json(keys.SolarAccYield, &pvEnergy) == nil { + site.fcstEnergy.Accumulated = fcstEnergy + + for _, name := range site.Meters.PVMetersRef { + if fcst, ok := pvEnergy[name]; ok { + site.pvEnergy[name].Accumulated = fcst + } else { + // TODO decide auto-reset? + site.log.WARN.Printf("cannot restore accumulated solar yield for: %s (may need to reset solar statistics)", name) + } + } + } + return nil } @@ -513,6 +539,20 @@ func (site *Site) updatePvMeters() { site.publish(keys.PvPower, site.pvPower) site.publish(keys.PvEnergy, totalEnergy) site.publish(keys.Pv, mm) + + // update solar yield + for i, name := range site.Meters.PVMetersRef { + if mm[i].Energy > 0 { + site.pvEnergy[name].AddMeterTotal(mm[i].Energy) + } else { + site.pvEnergy[name].AddPower(mm[i].Power) + } + } + + // store + if err := settings.SetJson(keys.SolarAccYield, site.pvEnergy); err != nil { + site.log.ERROR.Println("accumulated solar yield:", err) + } } // updateBatteryMeters updates battery meters @@ -564,6 +604,7 @@ func (site *Site) updateBatteryMeters() { totalCapacity = float64(len(site.batteryMeters)) } site.batterySoc = batterySocAcc / totalCapacity + site.batteryCapacity = totalCapacity site.batteryPower = lo.SumBy(mm, func(m measurement) float64 { return m.Power @@ -577,7 +618,7 @@ func (site *Site) updateBatteryMeters() { site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.batterySoc)) } - site.publish(keys.BatteryCapacity, totalCapacity) + site.publish(keys.BatteryCapacity, site.batteryCapacity) site.publish(keys.BatterySoc, site.batterySoc) site.publish(keys.BatteryPower, site.batteryPower) @@ -746,92 +787,6 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b return sitePower, batteryBuffered, batteryStart, nil } -// greenShare returns -// - the current green share, calculated for the part of the consumption between powerFrom and powerTo -// the consumption below powerFrom will get the available green power first -func (site *Site) greenShare(powerFrom float64, powerTo float64) float64 { - greenPower := math.Max(0, site.pvPower) + math.Max(0, site.batteryPower) - greenPowerAvailable := math.Max(0, greenPower-powerFrom) - - power := powerTo - powerFrom - share := math.Min(greenPowerAvailable, power) / power - - if math.IsNaN(share) { - if greenPowerAvailable > 0 { - share = 1 - } else { - share = 0 - } - } - - return share -} - -// effectivePrice calculates the real energy price based on self-produced and grid-imported energy. -func (site *Site) effectivePrice(greenShare float64) *float64 { - if grid, err := tariff.Now(site.GetTariff(api.TariffUsageGrid)); err == nil { - feedin, err := tariff.Now(site.GetTariff(api.TariffUsageFeedIn)) - if err != nil { - feedin = 0 - } - effPrice := grid*(1-greenShare) + feedin*greenShare - return &effPrice - } - return nil -} - -// effectiveCo2 calculates the amount of emitted co2 based on self-produced and grid-imported energy. -func (site *Site) effectiveCo2(greenShare float64) *float64 { - if co2, err := tariff.Now(site.GetTariff(api.TariffUsageCo2)); err == nil { - effCo2 := co2 * (1 - greenShare) - return &effCo2 - } - return nil -} - -func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints float64) { - site.publish(keys.GreenShareHome, greenShareHome) - site.publish(keys.GreenShareLoadpoints, greenShareLoadpoints) - - if v, err := tariff.Now(site.GetTariff(api.TariffUsageGrid)); err == nil { - site.publish(keys.TariffGrid, v) - } - if v, err := tariff.Now(site.GetTariff(api.TariffUsageFeedIn)); err == nil { - site.publish(keys.TariffFeedIn, v) - } - if v, err := tariff.Now(site.GetTariff(api.TariffUsageCo2)); err == nil { - site.publish(keys.TariffCo2, v) - } - if v, err := tariff.Now(site.GetTariff(api.TariffUsageSolar)); err == nil { - site.publish(keys.TariffSolar, v) - } - if v := site.effectivePrice(greenShareHome); v != nil { - site.publish(keys.TariffPriceHome, v) - } - if v := site.effectiveCo2(greenShareHome); v != nil { - site.publish(keys.TariffCo2Home, v) - } - if v := site.effectivePrice(greenShareLoadpoints); v != nil { - site.publish(keys.TariffPriceLoadpoints, v) - } - if v := site.effectiveCo2(greenShareLoadpoints); v != nil { - site.publish(keys.TariffCo2Loadpoints, v) - } - - // forecast - site.publish(keys.Forecast, struct { - Co2 api.Rates `json:"co2,omitempty"` - FeedIn api.Rates `json:"feedin,omitempty"` - Grid api.Rates `json:"grid,omitempty"` - Solar api.Rates `json:"solar,omitempty"` - }{ - Co2: tariff.Forecast(site.GetTariff(api.TariffUsageCo2)), - FeedIn: tariff.Forecast(site.GetTariff(api.TariffUsageFeedIn)), - Grid: tariff.Forecast(site.GetTariff(api.TariffUsageGrid)), - Solar: tariff.Forecast(site.GetTariff(api.TariffUsageSolar)), - }) -} - // updateLoadpoints updates all loadpoints' charge power func (site *Site) updateLoadpoints(rates api.Rates) float64 { var ( diff --git a/core/site_tariffs.go b/core/site_tariffs.go new file mode 100644 index 000000000..aaf5d8d21 --- /dev/null +++ b/core/site_tariffs.go @@ -0,0 +1,219 @@ +package core + +import ( + "encoding/json" + "maps" + "math" + "slices" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/core/keys" + "github.com/evcc-io/evcc/server/db/settings" + "github.com/evcc-io/evcc/tariff" + "github.com/samber/lo" +) + +// greenShare returns +// - the current green share, calculated for the part of the consumption between powerFrom and powerTo +// the consumption below powerFrom will get the available green power first +func (site *Site) greenShare(powerFrom float64, powerTo float64) float64 { + greenPower := math.Max(0, site.pvPower) + math.Max(0, site.batteryPower) + greenPowerAvailable := math.Max(0, greenPower-powerFrom) + + power := powerTo - powerFrom + share := math.Min(greenPowerAvailable, power) / power + + if math.IsNaN(share) { + if greenPowerAvailable > 0 { + share = 1 + } else { + share = 0 + } + } + + return share +} + +// effectivePrice calculates the real energy price based on self-produced and grid-imported energy. +func (site *Site) effectivePrice(greenShare float64) *float64 { + if grid, err := tariff.Now(site.GetTariff(api.TariffUsageGrid)); err == nil { + feedin, err := tariff.Now(site.GetTariff(api.TariffUsageFeedIn)) + if err != nil { + feedin = 0 + } + effPrice := grid*(1-greenShare) + feedin*greenShare + return &effPrice + } + return nil +} + +// effectiveCo2 calculates the amount of emitted co2 based on self-produced and grid-imported energy. +func (site *Site) effectiveCo2(greenShare float64) *float64 { + if co2, err := tariff.Now(site.GetTariff(api.TariffUsageCo2)); err == nil { + effCo2 := co2 * (1 - greenShare) + return &effCo2 + } + return nil +} + +// accumulatedEnergy calculates the energy consumption between from and to, +// assuming the rates containing the power at given timestamp. +// Result is in Wh +func accumulatedEnergy(rr timeseries, from, to time.Time) float64 { + var energy float64 + var last tsValue + + for _, r := range rr { + // fmt.Println(r.Start.Local().Format(time.RFC3339), r.End.Local().Format(time.RFC3339), r.Price) + + if !r.Timestamp.After(from) { + last = r + continue + } + + start := last.Timestamp + if start.Before(from) { + start = from + } + + end := r.Timestamp + if end.After(to) { + end = to + } + + energy += (r.Value + last.Value) / 2 * end.Sub(start).Hours() + + if !r.Timestamp.Before(to) { + break + } + + last = r + } + + return energy +} + +type ( + timeseries []tsValue + tsValue struct { + Timestamp time.Time `json:"ts"` + Value float64 `json:"val"` + } +) + +func (rr *timeseries) MarshalJSON() ([]byte, error) { + return json.Marshal(rr) +} + +func timestampSeries(rr api.Rates) timeseries { + return lo.Map(rr, func(r api.Rate, _ int) tsValue { + return tsValue{ + Timestamp: r.Start, + Value: r.Price, + } + }) +} + +func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints float64) { + site.publish(keys.GreenShareHome, greenShareHome) + site.publish(keys.GreenShareLoadpoints, greenShareLoadpoints) + + if v, err := tariff.Now(site.GetTariff(api.TariffUsageGrid)); err == nil { + site.publish(keys.TariffGrid, v) + } + if v, err := tariff.Now(site.GetTariff(api.TariffUsageFeedIn)); err == nil { + site.publish(keys.TariffFeedIn, v) + } + if v, err := tariff.Now(site.GetTariff(api.TariffUsageCo2)); err == nil { + site.publish(keys.TariffCo2, v) + } + if v, err := tariff.Now(site.GetTariff(api.TariffUsageSolar)); err == nil { + site.publish(keys.TariffSolar, v) + } + if v := site.effectivePrice(greenShareHome); v != nil { + site.publish(keys.TariffPriceHome, v) + } + if v := site.effectiveCo2(greenShareHome); v != nil { + site.publish(keys.TariffCo2Home, v) + } + if v := site.effectivePrice(greenShareLoadpoints); v != nil { + site.publish(keys.TariffPriceLoadpoints, v) + } + if v := site.effectiveCo2(greenShareLoadpoints); v != nil { + site.publish(keys.TariffCo2Loadpoints, v) + } + + type dailyDetails struct { + Yield float64 `json:"energy"` + Complete bool `json:"complete"` + } + + type solarDetails struct { + Scale *float64 `json:"scale,omitempty"` // scale factor yield/forecasted today + Today dailyDetails `json:"today,omitempty"` // tomorrow + Tomorrow dailyDetails `json:"tomorrow,omitempty"` // tomorrow + DayAfterTomorrow dailyDetails `json:"dayAfterTomorrow,omitempty"` // day after tomorrow + Timeseries timeseries `json:"timeseries,omitempty"` // timeseries of forecasted energy + } + + fc := struct { + Co2 api.Rates `json:"co2,omitempty"` + FeedIn api.Rates `json:"feedin,omitempty"` + Grid api.Rates `json:"grid,omitempty"` + Solar solarDetails `json:"solar,omitempty"` + }{ + Co2: tariff.Forecast(site.GetTariff(api.TariffUsageCo2)), + FeedIn: tariff.Forecast(site.GetTariff(api.TariffUsageFeedIn)), + Grid: tariff.Forecast(site.GetTariff(api.TariffUsageGrid)), + } + + // calculate adjusted solar forecast + solar := timestampSeries(tariff.Forecast(site.GetTariff(api.TariffUsageSolar))) + if len(solar) > 0 { + fc.Solar.Timeseries = solar + + last := solar[len(solar)-1].Timestamp + + bod := beginningOfDay(time.Now()) + eod := bod.AddDate(0, 0, 1) + eot := eod.AddDate(0, 0, 1) + + remainingToday := accumulatedEnergy(solar, time.Now(), eod) + tomorrow := accumulatedEnergy(solar, eod, eot) + dayAfterTomorrow := accumulatedEnergy(solar, eot, eot.AddDate(0, 0, 1)) + + fc.Solar.Today = dailyDetails{ + Yield: remainingToday, + Complete: !last.Before(eod), + } + fc.Solar.Tomorrow = dailyDetails{ + Yield: tomorrow, + Complete: !last.Before(eot), + } + fc.Solar.DayAfterTomorrow = dailyDetails{ + Yield: dayAfterTomorrow, + Complete: !last.Before(eot.AddDate(0, 0, 1)), + } + + // scale factor yield/forecasted today + const minEnergy = 0.1 + + // accumulate forecasted energy since last update + site.fcstEnergy.AddEnergy(accumulatedEnergy(solar, site.fcstEnergy.updated, time.Now()) / 1e3) + settings.SetFloat(keys.SolarAccForecast, site.fcstEnergy.Accumulated) + + produced := lo.SumBy(slices.Collect(maps.Values(site.pvEnergy)), func(v *meterEnergy) float64 { + return v.AccumulatedEnergy() + }) + + // TODO trace + site.log.DEBUG.Printf("solar forecast accumulated: %.1fkWh, produced %.1fkWh, scale %.1f", site.fcstEnergy.Accumulated, produced, produced/site.fcstEnergy.Accumulated) + + if produced > minEnergy /*kWh*/ && site.fcstEnergy.Accumulated > minEnergy /*kWh*/ { + fc.Solar.Scale = lo.ToPtr(produced / site.fcstEnergy.Accumulated) + } + } + + site.publish(keys.Forecast, fc) +} diff --git a/core/site_tariffs_test.go b/core/site_tariffs_test.go new file mode 100644 index 000000000..52b621da6 --- /dev/null +++ b/core/site_tariffs_test.go @@ -0,0 +1,43 @@ +package core + +import ( + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/jinzhu/now" + "github.com/stretchr/testify/assert" +) + +func TestAccumulatedEnergy(t *testing.T) { + clock := clock.NewMock() + clock.Set(now.BeginningOfDay()) + + rate := func(start int, val float64) tsValue { + return tsValue{ + Timestamp: clock.Now().Add(time.Duration(start) * time.Hour), + Value: val, + } + } + + rr := timeseries{rate(0, 0), rate(1, 1), rate(2, 2), rate(3, 3), rate(4, 4)} + + for i, tc := range []struct { + from, to float64 + expected float64 + }{ + {0, 0, 0}, + {0, 0.5, 0.25}, + {0, 1, 0.5}, + {1, 2, 1.5}, + {0, 2, 2}, + } { + t.Logf("%d. %+v", i+1, tc) + + from := clock.Now().Add(time.Duration(float64(time.Hour) * tc.from)) + to := clock.Now().Add(time.Duration(float64(time.Hour) * tc.to)) + + res := accumulatedEnergy(rr, from, to) + assert.Equal(t, tc.expected, res, "test case %d", i) + } +} diff --git a/i18n/en.toml b/i18n/en.toml index 3356eaeb8..d5c27a3ad 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -435,6 +435,7 @@ modalUpdateStatusStart = "Installation started:" [forecast] modalTitle = "Forecast" +solarAdjust = "Adjust solar forecast based on real production data." [forecast.co2] average = "Average" diff --git a/tariff/combined.go b/tariff/combined.go index 9f04fc206..597db9211 100644 --- a/tariff/combined.go +++ b/tariff/combined.go @@ -33,11 +33,9 @@ func (t *combined) Rates() (api.Rates, error) { } } - keys = slices.SortedFunc(slices.Values(keys), func(a, b time.Time) int { - return a.Compare(b) - }) - var res api.Rates + keys = slices.SortedFunc(slices.Values(keys), time.Time.Compare) + for _, ts := range keys { var rate api.Rate diff --git a/tariff/helper.go b/tariff/helper.go index 85d4f54e8..289e58c73 100644 --- a/tariff/helper.go +++ b/tariff/helper.go @@ -67,7 +67,7 @@ func mergeRatesAfter(data *util.Monitor[api.Rates], new api.Rates, now time.Time }) } -// BeginningOfDay returns the beginning of the current day -func BeginningOfDay() time.Time { +// beginningOfDay returns the beginning of the current day +func beginningOfDay() time.Time { return now.With(time.Now()).BeginningOfDay() } diff --git a/tariff/helper_test.go b/tariff/helper_test.go index 8605a643b..098a0d166 100644 --- a/tariff/helper_test.go +++ b/tariff/helper_test.go @@ -14,6 +14,8 @@ import ( func TestMergeRatesAfter(t *testing.T) { clock := clock.NewMock() + clock.Set(now.BeginningOfDay()) + rate := func(start int, val float64) api.Rate { return api.Rate{ Start: clock.Now().Add(time.Duration(start) * time.Hour), diff --git a/tariff/solcast.go b/tariff/solcast.go index 43a728677..a83b56c50 100644 --- a/tariff/solcast.go +++ b/tariff/solcast.go @@ -108,7 +108,7 @@ func (t *Solcast) run(interval time.Duration, done chan error) { data = append(data, rr) } - mergeRatesAfter(t.data, data, BeginningOfDay()) + mergeRatesAfter(t.data, data, beginningOfDay()) once.Do(func() { close(done) }) } } diff --git a/tariff/tariff.go b/tariff/tariff.go index 3b297df25..6b65d940e 100644 --- a/tariff/tariff.go +++ b/tariff/tariff.go @@ -115,7 +115,7 @@ func (t *Tariff) run(forecastG func() (string, error), done chan error, interval // only prune rates older than current period periodStart := now.With(time.Now()).BeginningOfHour() if t.typ == api.TariffTypeSolar { - periodStart = BeginningOfDay() + periodStart = beginningOfDay() } mergeRatesAfter(t.data, data, periodStart)