diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 421acce3..b166a34f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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 diff --git a/.gitignore b/.gitignore index 2d098e67..c5cb0612 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/assets/css/peak-shaving-charts.css b/docs/assets/css/peak-shaving-charts.css new file mode 100644 index 00000000..0be23b04 --- /dev/null +++ b/docs/assets/css/peak-shaving-charts.css @@ -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; +} diff --git a/docs/assets/js/peak-shaving-charts.js b/docs/assets/js/peak-shaving-charts.js new file mode 100644 index 00000000..0f318dc3 --- /dev/null +++ b/docs/assets/js/peak-shaving-charts.js @@ -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: + * + *
+ * + * + * 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 = ''; + 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 = 'Could not load scenario "' + + scenario + '": ' + err.message + '
'; + }); + } + + function renderSummary(container) { + loadCsv('summary').then(function (rows) { + var head = 'Could not load summary: ' + + err.message + '
'; + }); + } + + 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 = 'Chart library not loaded - ' + + 'charts unavailable.
'; + }); + 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); + } +})(); diff --git a/docs/assets/js/vendor/chart.umd.js b/docs/assets/js/vendor/chart.umd.js new file mode 100644 index 00000000..44f80651 --- /dev/null +++ b/docs/assets/js/vendor/chart.umd.js @@ -0,0 +1,14 @@ +/*! + * Chart.js v4.4.1 + * https://www.chartjs.org + * (c) 2023 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a