Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ jobs:
- name: Install documentation dependencies
run: pip install "mkdocs-material>=9.5,<10" "mkdocs-llmstxt>=0.5.0,<1.0"

- name: Install batcontrol (for documentation data generation)
run: pip install -e .

- name: Generate peak shaving scenario data
run: python scripts/generate_peak_shaving_csv.py

- name: Build documentation
run: mkdocs build --strict

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,8 @@ lib64
lib

site/

# Peak shaving scenario datasets for the documentation.
# Generated from scripts/data/peak_shaving_example_day.{csv,yaml} by
# scripts/generate_peak_shaving_csv.py during the documentation build.
docs/assets/data/peak_shaving/
29 changes: 29 additions & 0 deletions docs/assets/css/peak-shaving-charts.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* Layout for the peak shaving scenario charts (see peak-shaving-charts.js). */

.ps-chart {
position: relative;
width: 100%;
height: 420px;
margin: 1.2rem 0 1.8rem;
}

@media screen and (max-width: 60em) {
.ps-chart {
height: 340px;
}
}

.ps-summary {
overflow-x: auto;
}

.ps-summary table {
width: 100%;
font-size: 0.78rem;
}

.ps-error {
color: #e34948;
font-size: 0.8rem;
font-style: italic;
}
306 changes: 306 additions & 0 deletions docs/assets/js/peak-shaving-charts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
/*
* Renders the peak shaving scenario charts in the batcontrol documentation.
*
* Data comes from CSV files generated by
* scripts/generate_peak_shaving_csv.py into assets/data/peak_shaving/.
* The markdown only carries placeholders:
*
* <div class="ps-chart" data-scenario="time"></div>
* <div class="ps-summary"></div>
*
* Requires Chart.js (loaded via extra_javascript in mkdocs.yml).
*/
(function () {
'use strict';

// Resolve the site root from this script's own URL so the CSV paths work
// on every page depth and under the /batcontrol/ project sub-path.
var SCRIPT_SRC = (document.currentScript && document.currentScript.src) || '';
var SITE_ROOT = SCRIPT_SRC.replace(/assets\/js\/peak-shaving-charts\.js.*$/, '');
var DATA_DIR = SITE_ROOT + 'assets/data/peak_shaving/';

// Palette shared with scripts/plot_solar_limit_day.py
var C_PV = '#2a78d6';
var C_LIMIT = '#0b0b0b';
var C_CHARGE = '#1baf7a';
var C_LOST = '#e34948';
var C_SOC = '#eb6834';
var C_BASE = '#898781';

var FEED_IN_LIMIT_W = 4000;
// The example day is only interesting around daylight; the CSV
// still carries all 96 slots.
var X_FROM = '05:00';
var X_TO = '21:00';

var cache = {};
var charts = [];

function isDark() {
return document.body.getAttribute('data-md-color-scheme') === 'slate';
}

function themeColors() {
return isDark()
? { text: '#c9c7c0', grid: 'rgba(255,255,255,0.10)', ink: '#e8e6e0' }
: { text: '#52514e', grid: 'rgba(0,0,0,0.08)', ink: '#0b0b0b' };
}

function parseCsv(text) {
var lines = text.trim().split(/\r?\n/);
var header = lines[0].split(',');
return lines.slice(1).map(function (line) {
var cells = line.split(',');
var row = {};
header.forEach(function (key, i) {
var raw = cells[i];
if (raw === undefined || raw === '') {
row[key] = null;
} else if (key === 'time' || key === 'config' || key === 'label' ||
key === 'full_at') {
row[key] = raw;
} else {
var num = Number(raw);
row[key] = isNaN(num) ? raw : num;
}
});
return row;
});
}

function loadCsv(name) {
if (!cache[name]) {
cache[name] = fetch(DATA_DIR + name + '.csv')
.then(function (resp) {
if (!resp.ok) { throw new Error('HTTP ' + resp.status); }
return resp.text();
})
.then(parseCsv);
}
return cache[name];
}

function area(color, alpha) {
return color + alpha;
}

function buildChart(canvas, rows) {
var theme = themeColors();
var labels = rows.map(function (r) { return r.time; });

var data = {
labels: labels,
datasets: [
{
label: 'PV surplus (W)',
data: rows.map(function (r) { return r.surplus_w; }),
borderColor: C_PV,
backgroundColor: area(C_PV, '20'),
borderWidth: 1.5,
fill: true,
pointRadius: 0,
tension: 0.25,
yAxisID: 'w'
},
{
label: 'Battery charge (W)',
data: rows.map(function (r) { return r.charge_w; }),
borderColor: C_CHARGE,
backgroundColor: area(C_CHARGE, '45'),
borderWidth: 1.5,
fill: true,
pointRadius: 0,
stepped: true,
yAxisID: 'w'
},
{
label: 'Curtailed (W)',
data: rows.map(function (r) { return r.curtailed_w || null; }),
borderColor: C_LOST,
backgroundColor: area(C_LOST, '55'),
borderWidth: 1,
fill: 'origin',
pointRadius: 0,
stepped: true,
yAxisID: 'w'
},
{
label: 'Feed-in limit (W)',
data: labels.map(function () { return FEED_IN_LIMIT_W; }),
borderColor: theme.text,
borderWidth: 1,
borderDash: [2, 4],
fill: false,
pointRadius: 0,
yAxisID: 'w'
},
{
label: 'SoC (%)',
data: rows.map(function (r) { return r.soc_pct; }),
borderColor: C_SOC,
borderWidth: 2.5,
fill: false,
pointRadius: 0,
tension: 0.2,
yAxisID: 'soc'
},
{
label: 'SoC without peak shaving (%)',
data: rows.map(function (r) { return r.soc_baseline_pct; }),
borderColor: C_BASE,
borderWidth: 1.5,
borderDash: [4, 4],
fill: false,
pointRadius: 0,
tension: 0.2,
yAxisID: 'soc'
},
{
// Last in the list so the limit stays visible on top of the
// battery-charge area, which usually traces the same value.
label: 'Applied charge limit (W)',
data: rows.map(function (r) { return r.limit_w; }),
borderColor: theme.ink,
borderWidth: 2.2,
borderDash: [6, 3],
fill: false,
pointRadius: 0,
stepped: true,
spanGaps: false,
yAxisID: 'w'
}
]
};

return new Chart(canvas, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
labels: {
color: theme.text, boxWidth: 12, boxHeight: 2,
usePointStyle: false, font: { size: 11 }
}
},
tooltip: {
callbacks: {
label: function (ctx) {
if (ctx.parsed.y === null) { return null; }
var unit = ctx.dataset.yAxisID === 'soc' ? ' %' : ' W';
return ctx.dataset.label + ': ' +
Math.round(ctx.parsed.y) + unit;
}
}
}
},
scales: {
x: {
min: X_FROM,
max: X_TO,
ticks: {
color: theme.text, maxRotation: 0, autoSkip: true,
maxTicksLimit: 13, font: { size: 10 }
},
grid: { color: theme.grid }
},
w: {
position: 'left',
title: { display: true, text: 'Power (W)', color: theme.text },
ticks: { color: theme.text, font: { size: 10 } },
grid: { color: theme.grid },
beginAtZero: true
},
soc: {
position: 'right',
min: 0,
max: 100,
title: { display: true, text: 'SoC (%)', color: C_SOC },
ticks: { color: C_SOC, font: { size: 10 } },
grid: { drawOnChartArea: false }
}
}
}
});
}

function renderChart(container) {
var scenario = container.getAttribute('data-scenario');
if (!scenario) { return; }

container.innerHTML = '<canvas></canvas>';
var canvas = container.querySelector('canvas');

loadCsv(scenario).then(function (rows) {
charts.push({ chart: buildChart(canvas, rows), rows: rows,
canvas: canvas, container: container });
}).catch(function (err) {
container.innerHTML = '<p class="ps-error">Could not load scenario "' +
scenario + '": ' + err.message + '</p>';
});
}

function renderSummary(container) {
loadCsv('summary').then(function (rows) {
var head = '<thead><tr>' +
'<th>Configuration</th><th>Battery full at</th>' +
'<th>Charged</th><th>Exported</th><th>Curtailed</th>' +
'</tr></thead>';
var body = rows.map(function (r) {
var lost = r.curtailed_kwh > 0
? '<strong>' + r.curtailed_kwh.toFixed(2) + ' kWh</strong>'
: r.curtailed_kwh.toFixed(2) + ' kWh';
return '<tr><td>' + r.label + '</td><td>' + r.full_at + '</td><td>' +
r.charged_kwh.toFixed(2) + ' kWh</td><td>' +
r.exported_kwh.toFixed(2) + ' kWh</td><td>' + lost + '</td></tr>';
}).join('');
container.innerHTML = '<table>' + head + '<tbody>' + body +
'</tbody></table>';
}).catch(function (err) {
container.innerHTML = '<p class="ps-error">Could not load summary: ' +
err.message + '</p>';
});
}

function rerenderForTheme() {
charts.forEach(function (entry) {
entry.chart.destroy();
entry.chart = buildChart(entry.canvas, entry.rows);
});
}

function init() {
if (typeof Chart === 'undefined') {
// Fail visibly rather than leaving empty boxes on the page.
document.querySelectorAll('.ps-chart, .ps-summary').forEach(function (el) {
el.innerHTML = '<p class="ps-error">Chart library not loaded - ' +
'charts unavailable.</p>';
});
return;
}
charts = [];
document.querySelectorAll('.ps-chart').forEach(renderChart);
document.querySelectorAll('.ps-summary').forEach(renderSummary);
}

// Re-render when the mkdocs-material palette toggle flips light/dark.
new MutationObserver(function (mutations) {
mutations.forEach(function (m) {
if (m.attributeName === 'data-md-color-scheme') { rerenderForTheme(); }
});
}).observe(document.body, { attributes: true });

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

// mkdocs-material instant navigation swaps the content without a reload.
if (window.document$ && typeof window.document$.subscribe === 'function') {
window.document$.subscribe(init);
}
})();
14 changes: 14 additions & 0 deletions docs/assets/js/vendor/chart.umd.js

Large diffs are not rendered by default.

Loading