Battery timeline modal + switch to real 2025 data
Modal: - Click any battery row to open a modal with three uPlot charts: irradiance (W/m²), battery SoC (kWh), net meter flow (kWh/h). - Date picker + prev/next buttons + 1/3/7-day window selector. - 'Battery on' toggle flips the SoC and grid charts to the no-battery baseline; summary numbers update accordingly. - New /api/timeline endpoint returns the full per-hour series for any (battery, scenario, window). Data: - Re-exported prices + solar from data-vm for 2025-01-01 → 2025-09-01 (8 months — solar coverage limit). All three signals now share real 2025 timestamps; no more month/day alignment fudge. - Re-processed dad's CSV against the new window so power_w is real net consumption per actual hour. - Compute_leaderboard now applies an 8766/window-hours annualisation factor to year-1 savings, capt kWh, throughput, and total surplus so payback / 10-yr net stay correct on the shorter window. - Defaults: demand 1010 (= dad's net over 8 months, scale 1.0), start 2025-06-15. Tests pass. Local run: PORT=8765 uv run python -m pluginbattery.web, click any row.
This commit is contained in:
parent
10cc32ddc0
commit
b39501ca21
7 changed files with 17794 additions and 26343 deletions
14616
data/raw/p1_hourly.csv
14616
data/raw/p1_hourly.csv
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -255,3 +255,179 @@ captureInitialFromDOM();
|
||||||
repaint(); // honour saved filter + sort on first paint
|
repaint(); // honour saved filter + sort on first paint
|
||||||
|
|
||||||
form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); });
|
form.addEventListener("submit", (e) => { e.preventDefault(); recalc(); });
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Battery timeline modal ────────────────────────────────────────────
|
||||||
|
const modal = document.getElementById("timeline-modal");
|
||||||
|
const tlTitle = document.getElementById("tl-title");
|
||||||
|
const tlDate = document.getElementById("tl-date");
|
||||||
|
const tlDays = document.getElementById("tl-days");
|
||||||
|
const tlOnBox = document.getElementById("tl-battery-on");
|
||||||
|
const tlPrev = document.getElementById("tl-prev");
|
||||||
|
const tlNext = document.getElementById("tl-next");
|
||||||
|
const tlStatus = document.getElementById("tl-status");
|
||||||
|
const tlSummary = document.getElementById("tl-summary");
|
||||||
|
|
||||||
|
let activeBattery = null; // the row clicked
|
||||||
|
let tlData = null; // last fetched timeline payload
|
||||||
|
let charts = { irr: null, soc: null, grid: null };
|
||||||
|
|
||||||
|
function openModalForBattery(row) {
|
||||||
|
activeBattery = row;
|
||||||
|
tlTitle.textContent = `${row.title} — ${fmtNum(row.capacity_kwh)} kWh / ${fmtNum(row.power_kw, 1)} kW`;
|
||||||
|
if (!tlDate.value) tlDate.value = "2025-06-15";
|
||||||
|
modal.hidden = false;
|
||||||
|
refreshTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modal.hidden = true;
|
||||||
|
for (const c of Object.values(charts)) c?.destroy?.();
|
||||||
|
charts = { irr: null, soc: null, grid: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.querySelectorAll('[data-close="modal"]').forEach(
|
||||||
|
(el) => el.addEventListener("click", closeModal),
|
||||||
|
);
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && !modal.hidden) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
function shiftDate(days) {
|
||||||
|
const d = new Date(tlDate.value || "2024-06-17");
|
||||||
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
|
tlDate.value = d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
tlPrev.addEventListener("click", () => { shiftDate(-parseInt(tlDays.value, 10)); refreshTimeline(); });
|
||||||
|
tlNext.addEventListener("click", () => { shiftDate(+parseInt(tlDays.value, 10)); refreshTimeline(); });
|
||||||
|
tlDate.addEventListener("change", refreshTimeline);
|
||||||
|
tlDays.addEventListener("change", refreshTimeline);
|
||||||
|
tlOnBox.addEventListener("change", () => paintTimeline(tlData)); // local toggle, no refetch
|
||||||
|
|
||||||
|
async function refreshTimeline() {
|
||||||
|
if (!activeBattery) return;
|
||||||
|
tlStatus.textContent = "Loading…";
|
||||||
|
const sc = readScenario();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
cap: activeBattery.capacity_kwh,
|
||||||
|
power: activeBattery.power_kw,
|
||||||
|
allows_export: activeBattery.category === "hybrid",
|
||||||
|
start: tlDate.value || "2024-06-17",
|
||||||
|
days: tlDays.value,
|
||||||
|
demand_kwh: sc.demand_kwh, retail: sc.retail,
|
||||||
|
pv_kwp: sc.pv_kwp, fixed_rate: sc.fixed_rate,
|
||||||
|
saldering: sc.saldering, eta: sc.eta,
|
||||||
|
});
|
||||||
|
if (sc.export_rate !== null) params.set("export_rate", sc.export_rate);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/timeline?${params}`);
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${r.status}`);
|
||||||
|
}
|
||||||
|
tlData = await r.json();
|
||||||
|
paintTimeline(tlData);
|
||||||
|
tlStatus.textContent = "";
|
||||||
|
} catch (e) {
|
||||||
|
tlStatus.textContent = `Error: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintTimeline(d) {
|
||||||
|
if (!d) return;
|
||||||
|
const batteryOn = tlOnBox.checked;
|
||||||
|
|
||||||
|
// Summary, switching when toggle flips.
|
||||||
|
const s = d.summary;
|
||||||
|
const cost = batteryOn ? s.cost_with_battery_eur : s.cost_no_battery_eur;
|
||||||
|
const imports = batteryOn ? s.imports_with_bat : s.imports_no_bat;
|
||||||
|
const exports_ = batteryOn ? s.exports_with_bat : s.exports_no_bat;
|
||||||
|
const savings = batteryOn ? s.savings_eur : 0;
|
||||||
|
tlSummary.replaceChildren();
|
||||||
|
for (const [k, v] of [
|
||||||
|
["window cost", `€${cost.toFixed(2)}`],
|
||||||
|
["imports", `${imports.toFixed(1)} kWh`],
|
||||||
|
["exports", `${exports_.toFixed(1)} kWh`],
|
||||||
|
["battery throughput", batteryOn
|
||||||
|
? `${s.battery_charge_kwh.toFixed(1)} in / ${s.battery_discharge_kwh.toFixed(1)} out kWh`
|
||||||
|
: `— (battery off)`],
|
||||||
|
["window saving", `€${savings.toFixed(2)}`],
|
||||||
|
]) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
const lbl = document.createElement("span"); lbl.textContent = k;
|
||||||
|
const val = document.createElement("strong"); val.textContent = v;
|
||||||
|
row.append(lbl, val); tlSummary.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X axis: unix seconds (uPlot wants epoch seconds)
|
||||||
|
const xs = d.hours.map(h => Date.parse(h.ts) / 1000);
|
||||||
|
const irr = d.hours.map(h => h.irradiance);
|
||||||
|
const soc = batteryOn ? d.hours.map(h => h.soc) : d.hours.map(() => 0);
|
||||||
|
const grid = batteryOn ? d.hours.map(h => h.grid_with_bat)
|
||||||
|
: d.hours.map(h => h.grid_no_bat);
|
||||||
|
|
||||||
|
drawChart("irr", ["Time", "W/m²"], xs, irr, "tl-chart-irradiance",
|
||||||
|
{ stroke: "#fbbf24", fill: "rgba(251, 191, 36, 0.2)" });
|
||||||
|
drawChart("soc", ["Time", "kWh"], xs, soc, "tl-chart-soc",
|
||||||
|
{ stroke: "#4ade80", fill: "rgba(74, 222, 128, 0.2)" });
|
||||||
|
drawChart("grid", ["Time", "kWh/h"], xs, grid, "tl-chart-grid",
|
||||||
|
{ stroke: batteryOn ? "#93c5fd" : "#fb7185",
|
||||||
|
fill: batteryOn ? "rgba(147, 197, 253, 0.18)" : "rgba(251, 113, 133, 0.18)",
|
||||||
|
zeroline: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart(key, axes, xs, ys, containerId, opts = {}) {
|
||||||
|
const el = document.getElementById(containerId);
|
||||||
|
if (charts[key]) charts[key].destroy();
|
||||||
|
el.innerHTML = "";
|
||||||
|
const data = [xs, ys];
|
||||||
|
const u = new uPlot({
|
||||||
|
width: el.clientWidth, height: el.clientHeight,
|
||||||
|
cursor: { y: false, lock: false },
|
||||||
|
legend: { show: false },
|
||||||
|
series: [
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
stroke: opts.stroke || "#93c5fd",
|
||||||
|
fill: opts.fill,
|
||||||
|
width: 1.5,
|
||||||
|
points: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
axes: [
|
||||||
|
{ stroke: "#9aa6b2",
|
||||||
|
grid: { stroke: "#2a323b", width: 1 },
|
||||||
|
ticks: { stroke: "#2a323b" } },
|
||||||
|
{ stroke: "#9aa6b2",
|
||||||
|
grid: { stroke: "#2a323b", width: 1 },
|
||||||
|
ticks: { stroke: "#2a323b" },
|
||||||
|
size: 45 },
|
||||||
|
],
|
||||||
|
hooks: opts.zeroline ? {
|
||||||
|
drawAxes: [(u) => {
|
||||||
|
const ctx = u.ctx;
|
||||||
|
const y0 = u.valToPos(0, "y", true);
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = "#9aa6b2"; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(u.bbox.left, y0);
|
||||||
|
ctx.lineTo(u.bbox.left + u.bbox.width, y0);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}],
|
||||||
|
} : {},
|
||||||
|
}, data, el);
|
||||||
|
charts[key] = u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook up battery row clicks.
|
||||||
|
tbody.addEventListener("click", (e) => {
|
||||||
|
// Ignore link clicks — let those go to the shop page if user explicitly wants it.
|
||||||
|
if (e.target.closest("a")) return;
|
||||||
|
const tr = e.target.closest("tr");
|
||||||
|
if (!tr) return;
|
||||||
|
const idx = Array.from(tbody.children).indexOf(tr);
|
||||||
|
const visible = applySort(applyFilter(lastBatteries));
|
||||||
|
if (visible[idx]) openModalForBattery(visible[idx]);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,53 @@ dl.legend {
|
||||||
}
|
}
|
||||||
dl.legend dt { font-weight: 600; color: var(--text); }
|
dl.legend dt { font-weight: 600; color: var(--text); }
|
||||||
dl.legend dd { margin: 0; }
|
dl.legend dd { margin: 0; }
|
||||||
|
|
||||||
|
/* Make leaderboard rows look clickable */
|
||||||
|
table#leaderboard tbody tr { cursor: pointer; }
|
||||||
|
table#leaderboard tbody tr a { color: inherit; text-decoration: underline dotted; text-underline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal { position: fixed; inset: 0; z-index: 100; display: flex;
|
||||||
|
align-items: stretch; justify-content: center; }
|
||||||
|
.modal[hidden] { display: none; }
|
||||||
|
.modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.7); }
|
||||||
|
.modal-card {
|
||||||
|
position: relative; background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; max-width: 1200px; width: calc(100% - 2rem);
|
||||||
|
margin: 1rem auto; padding: 1.25rem; overflow-y: auto; max-height: calc(100vh - 2rem);
|
||||||
|
}
|
||||||
|
.modal-card header { display: flex; justify-content: space-between;
|
||||||
|
align-items: baseline; margin-bottom: 0.75rem; }
|
||||||
|
.modal-card h2 { margin: 0; font-size: 1.1rem; }
|
||||||
|
.modal-close { background: none; border: 0; font-size: 1.6rem; line-height: 1;
|
||||||
|
color: var(--muted); cursor: pointer; padding: 0 0.5rem; }
|
||||||
|
.modal-close:hover { color: var(--text); }
|
||||||
|
.modal-controls { display: flex; gap: 0.6rem; align-items: center;
|
||||||
|
flex-wrap: wrap; margin-bottom: 0.75rem; font-size: 0.85rem; }
|
||||||
|
.modal-controls button, .modal-controls input, .modal-controls select {
|
||||||
|
background: var(--panel2); color: var(--text); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 0.35rem 0.6rem; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.modal-controls button { cursor: pointer; }
|
||||||
|
.modal-summary {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.5rem 1.25rem; padding: 0.6rem 0.75rem; background: var(--panel2);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.modal-summary div { display: flex; justify-content: space-between; gap: 0.5rem; }
|
||||||
|
.modal-summary div strong { color: var(--accent); }
|
||||||
|
.modal-charts { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
.modal-charts figure { margin: 0; background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 0.6rem 0.75rem 0.4rem; }
|
||||||
|
.modal-charts figcaption { font-size: 0.78rem; color: var(--muted); margin-bottom: 0.3rem; }
|
||||||
|
.modal-charts .chart { width: 100%; height: 110px; }
|
||||||
|
.modal-charts figure:nth-child(3) .chart { height: 200px; } /* taller grid panel */
|
||||||
|
|
||||||
|
/* uPlot dark-theme tweaks */
|
||||||
|
.uplot { color: var(--text); }
|
||||||
|
.uplot .u-legend { color: var(--text); font-size: 0.75rem; }
|
||||||
|
.uplot .u-axis { color: var(--muted); }
|
||||||
table#leaderboard tbody tr:hover { background: var(--panel2); }
|
table#leaderboard tbody tr:hover { background: var(--panel2); }
|
||||||
table#leaderboard tbody tr:first-child { background: rgba(74, 222, 128, 0.08); }
|
table#leaderboard tbody tr:first-child { background: rgba(74, 222, 128, 0.08); }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Honest battery payback — vs thuisbatterijgids.nl</title>
|
<title>Honest battery payback — vs thuisbatterijgids.nl</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=14">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=16">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -155,6 +156,44 @@
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='app.js') }}?v=14"></script>
|
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}?v=16"></script>
|
||||||
|
|
||||||
|
<div id="timeline-modal" class="modal" hidden>
|
||||||
|
<div class="modal-backdrop" data-close="modal"></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<header>
|
||||||
|
<h2 id="tl-title">Battery timeline</h2>
|
||||||
|
<button class="modal-close" data-close="modal" aria-label="close">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="modal-controls">
|
||||||
|
<button id="tl-prev">◀ prev day</button>
|
||||||
|
<input type="date" id="tl-date">
|
||||||
|
<button id="tl-next">next day ▶</button>
|
||||||
|
<select id="tl-days">
|
||||||
|
<option value="1">1 day</option>
|
||||||
|
<option value="3">3 days</option>
|
||||||
|
<option value="7" selected>7 days</option>
|
||||||
|
</select>
|
||||||
|
<label class="filter-toggle"><input type="checkbox" id="tl-battery-on" checked> battery on</label>
|
||||||
|
<span id="tl-status" class="status"></span>
|
||||||
|
</div>
|
||||||
|
<div id="tl-summary" class="modal-summary"></div>
|
||||||
|
<div class="modal-charts">
|
||||||
|
<figure>
|
||||||
|
<figcaption>Solar irradiance — proxy for sun (W/m², from our weather station, season-aligned)</figcaption>
|
||||||
|
<div id="tl-chart-irradiance" class="chart"></div>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<figcaption>Battery state of charge (kWh)</figcaption>
|
||||||
|
<div id="tl-chart-soc" class="chart"></div>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<figcaption>Net meter flow (kWh per hour). Positive = importing, negative = exporting.</figcaption>
|
||||||
|
<div id="tl-chart-grid" class="chart"></div>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -180,9 +180,17 @@ def compute_leaderboard(
|
||||||
avg_epex = float(df["epex_eur_per_kwh"].mean())
|
avg_epex = float(df["epex_eur_per_kwh"].mean())
|
||||||
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
|
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
|
||||||
|
|
||||||
|
# Annualisation factor: our data window may be shorter than a year (e.g.,
|
||||||
|
# 8 months when solar coverage limits the overlap). Scale all per-window
|
||||||
|
# extensive quantities (savings, kWh shifted, captured, surplus) so they
|
||||||
|
# represent a full year. Intensive metrics like % full / % saturated stay
|
||||||
|
# as-is.
|
||||||
|
annualize = 8766.0 / len(df)
|
||||||
|
|
||||||
# Total PV / surplus available across the whole scenario (no battery).
|
# Total PV / surplus available across the whole scenario (no battery).
|
||||||
pv_col = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(len(df))
|
pv_col = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(len(df))
|
||||||
total_surplus_kwh = float(np.maximum(0.0, pv_col - df["demand_kwh"].to_numpy()).sum())
|
window_surplus = float(np.maximum(0.0, pv_col - df["demand_kwh"].to_numpy()).sum())
|
||||||
|
total_surplus_kwh = window_surplus * annualize
|
||||||
|
|
||||||
# Collect every distinct (capacity, power) the catalog asks for, then run
|
# Collect every distinct (capacity, power) the catalog asks for, then run
|
||||||
# the per-spec LPs in parallel. linprog/HiGHS release the GIL so threads
|
# the per-spec LPs in parallel. linprog/HiGHS release the GIL so threads
|
||||||
|
|
@ -238,11 +246,11 @@ def compute_leaderboard(
|
||||||
rows = []
|
rows = []
|
||||||
for b, cap, pw, price, allows_export in valid_rows:
|
for b, cap, pw, price, allows_export in valid_rows:
|
||||||
payload = lp_cache[(round(cap, 3), round(pw, 3), allows_export)]
|
payload = lp_cache[(round(cap, 3), round(pw, 3), allows_export)]
|
||||||
lp_year1 = payload["year1"]
|
lp_year1 = payload["year1"] * annualize
|
||||||
captured_kwh = payload["captured_kwh"]
|
captured_kwh = payload["captured_kwh"] * annualize
|
||||||
pct_full = payload["pct_full"]
|
pct_full = payload["pct_full"] # already a ratio
|
||||||
pct_saturated = payload["pct_saturated"]
|
pct_saturated = payload["pct_saturated"] # already a ratio
|
||||||
discharge_kwh = payload["discharge_kwh"]
|
discharge_kwh = payload["discharge_kwh"] * annualize
|
||||||
cycles_per_year = discharge_kwh / cap if cap > 0 else 0.0
|
cycles_per_year = discharge_kwh / cap if cap > 0 else 0.0
|
||||||
captured_pct = (captured_kwh / total_surplus_kwh * 100.0) if total_surplus_kwh > 0 else 0.0
|
captured_pct = (captured_kwh / total_surplus_kwh * 100.0) if total_surplus_kwh > 0 else 0.0
|
||||||
lp_payback = payback_years(lp_year1, price, inflation)
|
lp_payback = payback_years(lp_year1, price, inflation)
|
||||||
|
|
@ -313,10 +321,11 @@ def compute_leaderboard(
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
DEFAULTS = dict(
|
DEFAULTS = dict(
|
||||||
# Dad's actual: 2350 kWh/yr net (P1 data already nets his 3 kWp PV
|
# Window: 2025-01-01 → 2025-09-01 (8 months of dad's actual data with
|
||||||
# exports), retail €0.25, terugleveringskosten −€0.106/kWh.
|
# matching 2025 prices + solar). Net consumption over those 8 months
|
||||||
# Set pv_kwp = 0 because the 3 kWp solar is already inside the P1.
|
# is 1010 kWh; setting demand_kwh = 1010 gives scale factor 1.0 so the
|
||||||
demand_kwh=2350.0, retail=0.25, pv_kwp=0.0, pv_yield=875.0,
|
# P1 series passes through unscaled.
|
||||||
|
demand_kwh=1010.0, retail=0.25, pv_kwp=0.0, pv_yield=875.0,
|
||||||
fixed_rate=False, saldering=False, eta=0.88, inflation=0.03,
|
fixed_rate=False, saldering=False, eta=0.88, inflation=0.03,
|
||||||
export_rate=-0.106,
|
export_rate=-0.106,
|
||||||
)
|
)
|
||||||
|
|
@ -356,6 +365,99 @@ def calculate():
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/timeline")
|
||||||
|
def timeline():
|
||||||
|
"""Per-hour LP trajectory for a battery over a chosen window.
|
||||||
|
|
||||||
|
Returns both 'with battery' and 'without battery' series so the UI can
|
||||||
|
toggle between them without round-tripping. Defaults to a sunny mid-June
|
||||||
|
week.
|
||||||
|
"""
|
||||||
|
q = request.args
|
||||||
|
try:
|
||||||
|
cap = float(q.get("cap", "1.92"))
|
||||||
|
power = float(q.get("power", "0.8"))
|
||||||
|
allows_export = q.get("allows_export", "false").lower() == "true"
|
||||||
|
start_raw = q.get("start", "2025-06-15")
|
||||||
|
days = int(q.get("days", "7"))
|
||||||
|
|
||||||
|
demand_kwh = float(q.get("demand_kwh", DEFAULTS["demand_kwh"]))
|
||||||
|
retail = float(q.get("retail", DEFAULTS["retail"]))
|
||||||
|
pv_kwp = float(q.get("pv_kwp", DEFAULTS["pv_kwp"]))
|
||||||
|
pv_yield = float(q.get("pv_yield", DEFAULTS["pv_yield"]))
|
||||||
|
fixed_rate = q.get("fixed_rate", "false").lower() == "true"
|
||||||
|
saldering = q.get("saldering", "false").lower() == "true"
|
||||||
|
eta = float(q.get("eta", DEFAULTS["eta"]))
|
||||||
|
raw_export = q.get("export_rate")
|
||||||
|
if raw_export in (None, "", "null"):
|
||||||
|
export_rate = None
|
||||||
|
else:
|
||||||
|
export_rate = float(raw_export)
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
return jsonify({"error": f"bad query param: {e}"}), 400
|
||||||
|
|
||||||
|
df = _build_df(demand_kwh, retail, pv_kwp, pv_yield, fixed_rate, saldering, export_rate)
|
||||||
|
bat = Battery(
|
||||||
|
capacity_kwh=cap, max_charge_kw=power, max_discharge_kw=power,
|
||||||
|
round_trip_eff=eta, allows_export=allows_export,
|
||||||
|
)
|
||||||
|
out = simulate(df, bat, oracle_daily_schedule(df, bat))
|
||||||
|
|
||||||
|
import pandas as _pd
|
||||||
|
start_ts = _pd.Timestamp(start_raw, tz="UTC")
|
||||||
|
end_ts = start_ts + _pd.Timedelta(days=days)
|
||||||
|
win = out.loc[(out.index >= start_ts) & (out.index < end_ts)]
|
||||||
|
if win.empty:
|
||||||
|
return jsonify({"error": f"no data for {start_raw} + {days} days "
|
||||||
|
f"(window: {out.index[0].date()}..{out.index[-1].date()})"}), 400
|
||||||
|
|
||||||
|
g_no = (win["demand_kwh"] - win.get("pv_kwh", 0)).to_numpy()
|
||||||
|
g_yes = win["grid_kwh_with_battery"].to_numpy()
|
||||||
|
imp_p = win["eur_per_kwh"].to_numpy()
|
||||||
|
exp_p = win.get("export_eur_per_kwh", win["eur_per_kwh"]).to_numpy()
|
||||||
|
cost_no = np.where(g_no > 0, g_no * imp_p, g_no * exp_p)
|
||||||
|
cost_yes = np.where(g_yes > 0, g_yes * imp_p, g_yes * exp_p)
|
||||||
|
|
||||||
|
hours_payload = []
|
||||||
|
for i, (ts, r) in enumerate(win.iterrows()):
|
||||||
|
hours_payload.append({
|
||||||
|
"ts": ts.isoformat(),
|
||||||
|
"irradiance": float(r.get("irradiance_w_m2", 0.0)),
|
||||||
|
"price": float(r["eur_per_kwh"]),
|
||||||
|
"export_price": float(r.get("export_eur_per_kwh", r["eur_per_kwh"])),
|
||||||
|
"soc": float(r["soc_kwh"]),
|
||||||
|
"charge": float(r["charge_kwh"]),
|
||||||
|
"discharge": float(r["discharge_kwh"]),
|
||||||
|
"grid_no_bat": float(g_no[i]),
|
||||||
|
"grid_with_bat": float(g_yes[i]),
|
||||||
|
"cost_no_bat": float(cost_no[i]),
|
||||||
|
"cost_with_bat": float(cost_yes[i]),
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"battery": {"capacity_kwh": cap, "power_kw": power,
|
||||||
|
"allows_export": allows_export},
|
||||||
|
"scenario": {"demand_kwh": demand_kwh, "retail": retail,
|
||||||
|
"pv_kwp": pv_kwp, "saldering": saldering,
|
||||||
|
"fixed_rate": fixed_rate, "eta": eta,
|
||||||
|
"export_rate": export_rate},
|
||||||
|
"window": {"start": start_ts.isoformat(), "days": days,
|
||||||
|
"hours": len(hours_payload)},
|
||||||
|
"summary": {
|
||||||
|
"cost_no_battery_eur": float(cost_no.sum()),
|
||||||
|
"cost_with_battery_eur": float(cost_yes.sum()),
|
||||||
|
"savings_eur": float((cost_no - cost_yes).sum()),
|
||||||
|
"imports_no_bat": float(np.maximum(g_no, 0).sum()),
|
||||||
|
"imports_with_bat": float(np.maximum(g_yes, 0).sum()),
|
||||||
|
"exports_no_bat": float(-np.minimum(g_no, 0).sum()),
|
||||||
|
"exports_with_bat": float(-np.minimum(g_yes, 0).sum()),
|
||||||
|
"battery_charge_kwh": float(win["charge_kwh"].sum()),
|
||||||
|
"battery_discharge_kwh": float(win["discharge_kwh"].sum()),
|
||||||
|
},
|
||||||
|
"hours": hours_payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/healthz")
|
@app.route("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
return {"ok": True, "rows": len(_BASE_DF), "catalog": len(_CATALOG)}
|
return {"ok": True, "rows": len(_BASE_DF), "catalog": len(_CATALOG)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue