Leaving Agile Behind: Our Move to EDF FreePhase Dynamic
We'd been long term Octopus Agile customers even though it wasn't tailored to us as we don't have solar or battery storage. I'd started to think about looking at alternatives but never got very far with it.
Switch to EDF using my link, and we both get £50 credit applied to our accounts.
However EDF have launched a new time-of-use tariff recently called "FreePhase" which got my attention. The tariff is a condensed version of Agile, split into 3 bands per day instead of Octopus's 48. This smooths out the spikes that occur with Agile over peak periods. The dynamic version updates rates daily based on the electricity market or you can opt for a static version with fixed rates.
Another major benefit is the time periods being clearly defined - peak is 4pm to 7pm, medium is 6am to 4pm, and 7pm-11pm, and off-peak is 11pm to 6am. These are colour coded: red, amber, green but be aware that green isn't always the cheapest slot of the day.
EDF Freephase would have been cheaper for our actual past usage. I downloaded our half hourly reads for 12 months and compared the rates between Agile and Freephase Dynamic against our past usage. Every single month would have been cheaper with EDF. When just comparing the evening peak slots (4-7pm) EDF Freephase would have saved us £32.22. My mind was made up.
Monthly Costs - Octopus Agile vs Freephase Dynamic
| Month | kWh | Days | Octopus total | EDF total | Variance |
|---|---|---|---|---|---|
| Jun 25 | 272.78 | 30 | £61.60 | £56.54 | +£5.06 |
| Jul 25 | 250.58 | 31 | £66.29 | £61.82 | +£4.47 |
| Aug 25 | 270.59 | 31 | £66.07 | £61.69 | +£4.37 |
| Sept 25 | 258.73 | 30 | £60.31 | £55.67 | +£4.64 |
| Oct 25 | 281.61 | 31 | £67.08 | £62.43 | +£4.65 |
| Nov 25 | 286.13 | 30 | £72.99 | £69.09 | +£3.90 |
| Dec 25 | 263.52 | 31 | £67.22 | £63.96 | +£3.26 |
| Jan 26 | 295.47 | 31 | £86.82 | £81.27 | +£5.55 |
| Feb 26 | 244.46 | 28 | £64.40 | £61.57 | +£2.83 |
| Mar 26 | 282.44 | 31 | £81.31 | £78.21 | +£3.10 |
| Apr 26 | 242.41 | 30 | £54.14 | £53.59 | +£0.55 |
| May 26 | 305.25 | 31 | £83.68 | £82.13 | +£1.56 |
| Jun 26 | 348.66 | 30 | £88.71 | £84.60 | +£4.11 |
| Jul 26 * | 199.51 | 15 | £49.28 | £45.93 | +£3.34 |
| Total | 3802.14 | 410 | £969.90 | £918.50 | +£51.40 |
EDF Freephase does not offer negative pricing so I calculated how much we'd 'made' from sub zero rates over the year. This came to £3.22 from negative pricing. First round is on me!
Switch to EDF using my link, and we both get £50 credit applied to our accounts.
Home Assistant
EDF uses the same backend as Octopus now so someone was able to port the excellent Octopus integration. You can find it here.
This will give you usage and rates with minimal fuss. One of my friends had no issues updating their target rate sensors after their switch.
I used Claude to build a custom button-card for Home Assistant that handles displaying rates in a wife friendly manner: current price front and centre, 24-hour colour strip showing cheap/mid/expensive at a glance, a countdown to the next price change and a banner that lights up when a free session is live or upcoming.
Here's what it looks like in practice and the full YAML so you can build your own version.

- Right now banner — current price in big text, plus whether it's about to rise or fall and in how long.
- 24h colour strip — a horizontal bar spanning the day, colour-coded from green (cheap) to red (expensive), with a marker showing where "now" sits.
- Free session banner — appears automatically when EDF announces a free electricity window, and pulses if it's live right now.
- Scrollable rate list — every remaining slot today and tomorrow, colour-coded, with free slots marked and struck-through.
- Updates automatically when tariff information is released in the early afternoon
Requirements
- custom:button-card installed via HACS
- The EDF Energy integration set up in Home Assistant, on a FreePhase Dynamic or similar half-hourly tariff.
Card Code
Swap YOUR_MPAN_YOUR_SERIAL and YOUR_ACCOUNT_ID for your own entity IDs before pasting this in (instructions below).
type: custom:button-card
entity: event.edf_energy_electricity_YOUR_MPAN_YOUR_SERIAL_current_day_rates
name: ' '
show_icon: false
show_state: false
show_label: false
triggers_update:
- sensor.edf_energy_electricity_YOUR_MPAN_YOUR_SERIAL_current_rate
- sensor.time
- event.edf_energy_electricity_YOUR_MPAN_YOUR_SERIAL_next_day_rates
- sensor.edf_energy_a_YOUR_ACCOUNT_ID_event_free_start
styles:
card:
- padding: 12px
grid:
- grid-template-areas: '"n" "header" "free" "strip" "rows"'
- grid-template-columns: 1fr
name:
- justify-self: start
- font-weight: bold
- padding-bottom: 6px
custom_fields:
header:
- justify-self: stretch
- padding-bottom: 8px
free:
- justify-self: stretch
strip:
- justify-self: stretch
- padding-bottom: 8px
rows:
- justify-self: stretch
custom_fields:
free: |
[[[
const fs = states['sensor.edf_energy_a_YOUR_ACCOUNT_ID_event_free_start'];
if (!fs || !fs.state || fs.state === 'unknown' || fs.state === 'unavailable') return '';
const start = new Date(fs.state);
const end = fs.attributes && fs.attributes.end ? new Date(fs.attributes.end) : null;
if (!end) return '';
const now = new Date();
if (now >= end) return '';
const name = (fs.attributes && fs.attributes.event_name) ? fs.attributes.event_name : 'Free session';
const fmt = t => t.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
const active = now >= start && now < end;
let when;
if (active) {
const ms = end - now;
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
when = `LIVE NOW · ${h > 0 ? h + 'h ' : ''}${m}m left`;
} else {
const ms = start - now;
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const days = Math.floor(h / 24);
when = days >= 1 ? `starts in ${days}d` : `starts in ${h > 0 ? h + 'h ' : ''}${m}m`;
}
const grad = active
? 'linear-gradient(135deg,#01579b,#0277bd)'
: 'linear-gradient(135deg,#013a6b,#01579b)';
const pulse = active ? 'box-shadow:0 0 0 0 rgba(2,119,189,0.7);animation:freepulse 2s infinite;' : '';
return `<style>@keyframes freepulse{0%{box-shadow:0 0 0 0 rgba(2,119,189,0.6);}70%{box-shadow:0 0 0 8px rgba(2,119,189,0);}100%{box-shadow:0 0 0 0 rgba(2,119,189,0);}}</style>
<div style="background:${grad};color:#fff;padding:10px 12px;border-radius:6px;margin-bottom:8px;${pulse}">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:0.95em;font-weight:bold;">⚡ FREE ELECTRICITY</span>
<span style="font-size:0.8em;opacity:0.9;">${fmt(start)}–${fmt(end)}</span>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:2px;">
<span style="font-size:0.85em;opacity:0.9;">${name}</span>
<span style="font-size:0.8em;font-weight:bold;">${when}</span>
</div>
</div>`;
]]]
header: |
[[[
const rates = (entity && entity.attributes && entity.attributes.rates) || [];
if (!rates.length) return '';
let groups = [], cur = null;
for (const r of rates) {
if (!cur || r.value_inc_vat !== cur.value) {
cur = { start: r.start, end: r.end, value: r.value_inc_vat };
groups.push(cur);
} else {
cur.end = r.end;
}
}
const now = new Date();
const idx = groups.findIndex(g => new Date(g.start) <= now && now < new Date(g.end));
if (idx < 0) return '';
const g = groups[idx];
const p = (g.value * 100).toFixed(2);
const v = parseFloat(p);
const c = v <= 10.0 ? '#1b5e20' : (v <= 20.0 ? '#388e3c' : (v <= 25.33 ? '#c8a800' : '#c62828'));
let next = groups[idx + 1];
if (!next) {
const t = states['event.edf_energy_electricity_YOUR_MPAN_YOUR_SERIAL_next_day_rates'];
const tr = (t && t.attributes && t.attributes.rates) || [];
if (tr.length) next = { value: tr[0].value_inc_vat };
}
let sub = '';
if (next) {
const ms = new Date(g.end) - now;
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const np = (next.value * 100).toFixed(2);
const nv = parseFloat(np);
const dir = nv < v ? '▼ drops to' : (nv > v ? '▲ rises to' : 'then');
sub = `${dir} ${np}p in ${h > 0 ? h + 'h ' : ''}${m}m`;
}
return `<div style="background:${c};color:#fff;padding:10px 12px;border-radius:6px;display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;flex-direction:column;align-items:flex-start;">
<span style="font-size:0.8em;opacity:0.85;">RIGHT NOW</span>
<span style="font-size:0.85em;">${sub}</span>
</div>
<span style="font-size:1.5em;font-weight:bold;">${p}p</span></div>`;
]]]
strip: |
[[[
const rates = (entity && entity.attributes && entity.attributes.rates) || [];
if (!rates.length) return '';
const fs = states['sensor.edf_energy_a_YOUR_ACCOUNT_ID_event_free_start'];
let freeStart = null, freeEnd = null;
if (fs && fs.state && fs.state !== 'unknown' && fs.state !== 'unavailable' && fs.attributes && fs.attributes.end) {
freeStart = new Date(fs.state).getTime();
freeEnd = new Date(fs.attributes.end).getTime();
}
const cuts = (freeStart !== null) ? [freeStart, freeEnd] : [];
const split = [];
for (const r of rates) {
const s = new Date(r.start).getTime();
const e = new Date(r.end).getTime();
const inner = cuts.filter(c => c > s && c < e).sort((a, b) => a - b);
let prev = s;
for (const c of inner) { split.push({ s: prev, e: c, value: r.value_inc_vat }); prev = c; }
split.push({ s: prev, e: e, value: r.value_inc_vat });
}
const isFree = (s, e) => freeStart !== null && s >= freeStart && e <= freeEnd;
let groups = [], cur = null;
for (const r of split) {
const f = isFree(r.s, r.e);
// Merge when contiguous and same free-state; for free segments ignore the
// underlying price (it's irrelevant when free) so they form one clean band.
if (cur && r.s === cur.e && f === cur.free && (f || r.value === cur.value)) {
cur.e = r.e;
} else {
cur = { s: r.s, e: r.e, value: r.value, free: f };
groups.push(cur);
}
}
const dayStart = split[0].s;
const dayEnd = split[split.length - 1].e;
const total = dayEnd - dayStart;
let bands = '';
for (const g of groups) {
const w = ((g.e - g.s) / total * 100).toFixed(3);
const v = g.value * 100;
if (g.free) {
bands += `<div style="width:${w}%;background:linear-gradient(135deg,#01579b,#0277bd);"></div>`;
} else {
const c = v <= 10.0 ? '#1b5e20' : (v <= 20.0 ? '#388e3c' : (v <= 25.33 ? '#c8a800' : '#c62828'));
bands += `<div style="width:${w}%;background:${c};"></div>`;
}
}
const now = Date.now();
let pos = ((now - dayStart) / total * 100);
pos = Math.max(0, Math.min(100, pos)).toFixed(2);
return `<div style="position:relative;padding-top:4px;">
<div style="display:flex;height:14px;border-radius:7px;overflow:hidden;">${bands}</div>
<div style="position:absolute;top:1px;left:${pos}%;width:2px;height:20px;background:#fff;box-shadow:0 0 4px rgba(0,0,0,0.7);transform:translateX(-1px);border-radius:1px;"></div>
<div style="display:flex;justify-content:space-between;font-size:0.7em;opacity:0.6;padding-top:2px;">
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
</div></div>`;
]]]
rows: |
[[[
const todayRates = (entity && entity.attributes && entity.attributes.rates) || [];
const tEnt = states['event.edf_energy_electricity_YOUR_MPAN_YOUR_SERIAL_next_day_rates'];
const tomorrowRates = (tEnt && tEnt.attributes && tEnt.attributes.rates) || [];
const raw = todayRates.concat(tomorrowRates);
if (!raw.length) return '';
const fs = states['sensor.edf_energy_a_YOUR_ACCOUNT_ID_event_free_start'];
let freeStart = null, freeEnd = null;
if (fs && fs.state && fs.state !== 'unknown' && fs.state !== 'unavailable' && fs.attributes && fs.attributes.end) {
freeStart = new Date(fs.state).getTime();
freeEnd = new Date(fs.attributes.end).getTime();
}
// Split any slot that the free window cuts through, so partial overlaps
// become a clean paid part + free part rather than mis-flagging the whole slot.
const cuts = (freeStart !== null) ? [freeStart, freeEnd] : [];
const all = [];
for (const r of raw) {
const s = new Date(r.start).getTime();
const e = new Date(r.end).getTime();
const inner = cuts.filter(c => c > s && c < e).sort((a, b) => a - b);
let prev = s;
for (const c of inner) {
all.push({ s: prev, e: c, value: r.value_inc_vat });
prev = c;
}
all.push({ s: prev, e: e, value: r.value_inc_vat });
}
const isFree = (s, e) => freeStart !== null && s >= freeStart && e <= freeEnd;
// Merge contiguous slots of the same price + free-state. Because today's and
// tomorrow's rates are concatenated above, a same-price slot ending at midnight
// today and one starting at midnight tomorrow are contiguous and merge into a
// single band that flows across the day boundary.
let groups = [], cur = null;
for (const r of all) {
const rFree = isFree(r.s, r.e);
if (cur && r.value === cur.value && r.s === cur.e && rFree === cur.free) {
cur.e = r.e;
} else {
cur = { s: r.s, e: r.e, value: r.value, free: rFree };
groups.push(cur);
}
}
const now = Date.now();
const fmt = t => new Date(t).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
// Boundary between today and tomorrow (start of tomorrow's first slot).
const boundary = tomorrowRates.length ? new Date(tomorrowRates[0].start).getTime() : null;
// If a merged band straddles the boundary (same price across midnight),
// tomorrow flows in seamlessly, so the TOMORROW divider would be redundant.
const spansBoundary = boundary !== null && groups.some(g => g.e > now && g.s < boundary && g.e > boundary);
let out = '';
let dividerShown = false;
for (const g of groups) {
if (g.e <= now) continue;
if (!spansBoundary && !dividerShown && boundary !== null && g.s >= boundary && g.s > now) {
out += `<div style="text-align:center;font-size:0.7em;opacity:0.55;margin:8px 0 4px;letter-spacing:0.5px;">TOMORROW</div>`;
dividerShown = true;
}
const p = (g.value * 100).toFixed(2);
const v = parseFloat(p);
if (g.free) {
out += `<div style="background:linear-gradient(135deg,#01579b,#0277bd);color:#fff;padding:6px 10px;margin:3px 0;border-radius:6px;display:flex;justify-content:space-between;">
<span>${fmt(g.s)}–${fmt(g.e)}</span><span><b>FREE</b> <span style="text-decoration:line-through;opacity:0.6;font-size:0.85em;">${p}p</span></span></div>`;
} else {
const c = v <= 10.0 ? '#1b5e20' : (v <= 20.0 ? '#388e3c' : (v <= 25.33 ? '#c8a800' : '#c62828'));
out += `<div style="background:${c};color:#fff;padding:6px 10px;margin:3px 0;border-radius:6px;display:flex;justify-content:space-between;">
<span>${fmt(g.s)}–${fmt(g.e)}</span><span><b>${p}p</b></span></div>`;
}
}
return `<div>${out}</div>`;
]]]
Setting This Up Yourself
- Find your real entity IDs. In Home Assistant, go to Settings → Devices & Services → Entities and search
edf_energy. You need four: the current day rates event, the current rate sensor, the next day rates event, and the event free start sensor. Note their exact entity IDs. - Replace the placeholders. In the YAML above, find-and-replace every
YOUR_MPAN_YOUR_SERIALwith the number from your ownedf_energy_electricity_*entities, and everyYOUR_ACCOUNT_IDwith the id from youredf_energy_a_*sensor. There are three of each — make sure you catch the ones buried inside the JavaScript template strings, not just the top-levelentity:andtriggers_update:lines. - Install
custom:button-cardif you don't already have it: HACS → Frontend → search "button-card" → Install, then reload your browser. - Add the card to your dashboard. Edit your dashboard → Add Card → scroll to the bottom and choose "Manual" (YAML mode) → paste in your edited YAML → Save.
- Check it renders. You should see a coloured "RIGHT NOW" price banner, a 24-hour colour strip with a time marker, and a scrollable list of rate bands. If it's blank, double-check your entity IDs, or check Developer Tools → States to confirm the EDF integration has actually populated the
ratesattribute yet.
The colour coding
Prices are bucketed into four bands, each with its own colour:
const c = v <= 10.0 ? '#1b5e20' : (v <= 20.0 ? '#388e3c' : (v <= 25.33 ? '#c8a800' : '#c62828'));
That line appears three times in the card — once each in the header, strip, and rows custom_fields blocks — so if you change the thresholds or colours, update all three to keep them consistent.
Switch to EDF using my link, and we both get £50 credit applied to our accounts.
This is post 60 of #100DaysToOffload.
To respond on your own website, write a post which contains a link to this post - then enter the URl of your page here. Learn more about WebMentions.