From b5d2338f89ff8cd16be5acf2b99660ec66c734ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Tue, 8 Apr 2025 14:58:16 +0200 Subject: [PATCH 01/30] Add basic layout panels --- .../myheatpump/myheatpump.js | 36 ++++++++++++++- .../myheatpump/myheatpump.php | 33 +++++++++++++ apps/OpenEnergyMonitor/myheatpump/style.css | 46 +++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js index 964f0d0..511291c 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js @@ -574,4 +574,38 @@ $("#clear-daily-data").click(function () { } } }); -}); \ No newline at end of file +}); + +// --- Heat Loss Panel Toggle --- +$("#heatloss-toggle").click(function () { + var $contentBlock = $("#heatloss-block"); + var $toggleText = $("#heatloss-toggle-text"); // Get the text span + var $arrow = $("#heatloss-arrow"); // Get the arrow span + + if ($contentBlock.is(":visible")) { + $contentBlock.slideUp(); // Animate hiding + $(this).css("background-color", ""); // Reset background if needed + + // Update the text content of the text span + $toggleText.text("SHOW HEAT LOSS ANALYSIS"); + // Update the HTML content (the arrow character) of the arrow span + $arrow.html("►"); // Right Arrow ► + + } else { + $contentBlock.slideDown(); // Animate showing + $(this).css("background-color", "#4a6d8c"); // Darker background when open (optional) + + // Update the text content of the text span + $toggleText.text("HIDE HEAT LOSS ANALYSIS"); + // Update the HTML content (the arrow character) of the arrow span + $arrow.html("▼"); // Down Arrow ▼ + + // Accessing daily_data example: + if (typeof daily_data !== 'undefined' && daily_data.combined_elec_kwh) { + console.log("Heat Loss Panel: Accessing daily_data.combined_elec_kwh, number of days:", daily_data.combined_elec_kwh.length); + } else { + console.log("Heat Loss Panel: daily_data not yet available or empty."); + } + } +}); +// --- End Heat Loss Panel Toggle --- \ No newline at end of file diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index 99fe0e6..b82af43 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -402,7 +402,40 @@ + + +
+
+ +
+ +
+ SHOW HEAT LOSS ANALYSIS + +
+
+ + + +
+
+ + diff --git a/apps/OpenEnergyMonitor/myheatpump/style.css b/apps/OpenEnergyMonitor/myheatpump/style.css index fcef775..04772a2 100644 --- a/apps/OpenEnergyMonitor/myheatpump/style.css +++ b/apps/OpenEnergyMonitor/myheatpump/style.css @@ -99,4 +99,50 @@ float: right; padding:11px; font-size:14px; +} + +/* Styles for the new Heat Loss Panel */ +#heatloss-toggle { + /* Inherits .bluenav */ + padding: 8px 15px; /* Keep padding inside the toggle */ + text-align: left; + /* width: calc(100% - 30px); */ /* REMOVE THIS LINE */ + box-sizing: border-box; /* Add this for robust padding handling */ + display: block; /* Make it take up available block width */ + transition: background-color 0.3s; + cursor: pointer; + border-bottom: 1px solid #eee; + /* Optional: Add a min-height if needed, but likely not the issue */ + /* min-height: 38px; */ /* Match .bluenav height if necessary */ +} + +#heatloss-toggle:hover { + background-color: #5a7d9c; +} + +#heatloss-arrow { + float: right; + margin-right: 10px; + font-size: 14px; + line-height: 1.5; /* Adjust if vertical alignment is off */ +} + +#heatloss-block { + border: 1px solid #ddd; + border-top: none; + background-color: #f9f9f9; +} + +#heatloss-plot-bound, +#heatloss-controls { + background-color: #fff; +} + +#heatloss-plot, +#heatloss-controls { + display: flex; + align-items: center; + justify-content: center; + color: #aaa; + font-style: italic; } \ No newline at end of file From d26ab165a3929c0417cf824bf245b9fe4f5b892e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Tue, 8 Apr 2025 15:34:37 +0200 Subject: [PATCH 02/30] Plot working and linked to bar graph --- .../myheatpump/myheatpump.js | 8 + .../myheatpump/myheatpump.php | 3 +- .../myheatpump/myheatpump_bargraph.js | 15 ++ .../myheatpump/myheatpump_heatloss.js | 197 ++++++++++++++++++ 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js index 511291c..10d00c2 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js @@ -436,6 +436,11 @@ function resize() { placeholder.height(height - top_offset); + // Add resize logic for the heat loss plot if it's visible + if ($("#heatloss-block").is(":visible")) { + plotHeatLossScatter(); // <<< CALL REMAINS + } + if (viewmode == "bargraph") { bargraph_draw(); @@ -599,6 +604,9 @@ $("#heatloss-toggle").click(function () { $toggleText.text("HIDE HEAT LOSS ANALYSIS"); // Update the HTML content (the arrow character) of the arrow span $arrow.html("▼"); // Down Arrow ▼ + resize(); + + plotHeatLossScatter(); // Accessing daily_data example: if (typeof daily_data !== 'undefined' && daily_data.combined_elec_kwh) { diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index b82af43..38f7fb8 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -475,8 +475,9 @@ config.db = ; - + + diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_bargraph.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_bargraph.js index d10fc61..8146f8f 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_bargraph.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_bargraph.js @@ -427,6 +427,16 @@ function bargraph_draw() { var plot = $.plot($('#placeholder'), bargraph_series, options); $('#placeholder').append("
"); } + + if ($("#heatloss-block").is(":visible")) { + // Check if the function exists before calling (good practice) + if (typeof plotHeatLossScatter === 'function') { + console.log("Bargraph drawn, updating visible Heat Loss plot."); + plotHeatLossScatter(); + } else { + console.warn("bargraph_draw: plotHeatLossScatter function not found. Ensure myheatpump_heatloss.js is loaded."); + } + } } function bargraph_tooltip(item) @@ -544,6 +554,11 @@ $(".bargraph_mode").click(function () { bargraph_mode = mode; bargraph_draw(); + + // If the heat loss panel is visible, update its plot too + if ($("#heatloss-block").is(":visible")) { + plotHeatLossScatter(); // <<< CALL REMAINS + } }); $('.bargraph-day').click(function () { diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js new file mode 100644 index 0000000..927f2b0 --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -0,0 +1,197 @@ +// /var/www/emoncms/Modules/app/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js + +/** + * Plots a scatter graph of Daily Heat Output vs Daily (Tin - Tout) difference. + * Uses data prepared for the main bar graph, filtered by the current bargraph_mode. + * Assumes global variables like `daily_data`, `bargraph_mode`, `feeds`, `flot_font_size` are available. + */ +function plotHeatLossScatter() { + console.log("Attempting to plot Heat Loss Scatter for mode:", bargraph_mode); + var plotDiv = $("#heatloss-plot"); + var plotBound = $("#heatloss-plot-bound"); // To potentially resize if needed + + // --- 1. Data Access and Preparation --- + + // Check if essential daily data is available + if (typeof daily_data === 'undefined' || $.isEmptyObject(daily_data)) { + console.log("Heat Loss Plot: daily_data not available."); + plotDiv.html("

Daily data not loaded yet.

"); + return; + } + + // Determine the keys based on the current bargraph mode (global variable) + var heatKey = bargraph_mode + "_heat_kwh"; // e.g., combined_heat_kwh, running_heat_kwh + var insideTKey = "combined_roomT_mean"; // Daily average room temp seems only stored as combined + var outsideTKey = "combined_outsideT_mean"; // Daily average outside temp seems only stored as combined + + // Check if the necessary data *arrays* exist within daily_data + // Also check if the corresponding feeds were configured in the first place (uses global 'feeds') + var isDataSufficient = true; + var messages = []; + + if (!daily_data[heatKey] || daily_data[heatKey].length === 0) { + isDataSufficient = false; + messages.push(`Heat data ('${heatKey}') not found for the selected mode.`); + console.log("Heat Loss Plot: Missing or empty data for key:", heatKey); + } + if (!feeds["heatpump_roomT"]) { + isDataSufficient = false; + messages.push("Room Temperature feed not configured in the app setup."); + console.log("Heat Loss Plot: Room Temperature feed not configured."); + } else if (!daily_data[insideTKey] || daily_data[insideTKey].length === 0) { + isDataSufficient = false; + // It's possible the feed exists but daily processing hasn't run or included it + messages.push(`Inside temperature data ('${insideTKey}') not found or empty.`); + console.log("Heat Loss Plot: Missing or empty data for key:", insideTKey); + } + if (!feeds["heatpump_outsideT"]) { + isDataSufficient = false; + messages.push("Outside Temperature feed not configured in the app setup."); + console.log("Heat Loss Plot: Outside Temperature feed not configured."); + } else if (!daily_data[outsideTKey] || daily_data[outsideTKey].length === 0) { + isDataSufficient = false; + messages.push(`Outside temperature data ('${outsideTKey}') not found or empty.`); + console.log("Heat Loss Plot: Missing or empty data for key:", outsideTKey); + } + + if (!isDataSufficient) { + var messageHtml = "

Cannot plot heat loss:
" + messages.join("
") + "

"; + plotDiv.html(messageHtml); + return; + } + + // Create lookup maps for temperatures for easier matching by timestamp + var insideTMap = new Map(daily_data[insideTKey]); + var outsideTMap = new Map(daily_data[outsideTKey]); + + var scatterData = []; + var heatDataArray = daily_data[heatKey]; + + console.log("Heat Loss Plot: Processing", heatDataArray.length, "days of data for mode", bargraph_mode); + + // Iterate through the heat data (which is specific to the mode) + for (var i = 0; i < heatDataArray.length; i++) { + var timestamp = heatDataArray[i][0]; + var heatValue = heatDataArray[i][1]; + + // Get corresponding temperatures using the timestamp + var insideTValue = insideTMap.get(timestamp); + var outsideTValue = outsideTMap.get(timestamp); + + // Ensure all data points for this day are valid numbers + if (heatValue !== null && typeof heatValue === 'number' && + insideTValue !== null && typeof insideTValue === 'number' && + outsideTValue !== null && typeof outsideTValue === 'number') + { + // Calculate delta T + var deltaT = insideTValue - outsideTValue; + + // Add the point [deltaT, heatValue] to our scatter data array + // Only include days with positive heat output and reasonable deltaT + if (heatValue > 0 && deltaT > -10 && deltaT < 40) { // Basic sanity check + scatterData.push([deltaT, heatValue]); + } + } + } + + console.log("Heat Loss Plot: Prepared", scatterData.length, "valid scatter points."); + + if (scatterData.length === 0) { + plotDiv.html("

No valid data points found for this mode to plot heat loss.

"); + return; + } + + // --- 2. Plotting --- + + var plotSeries = [{ + data: scatterData, + points: { + show: true, + radius: 3, + fill: true, + fillColor: "rgba(255, 99, 71, 0.6)" // Tomato color with some transparency + }, + lines: { show: false }, // Ensure lines are off for scatter + color: 'rgb(255, 99, 71)', // Tomato color + label: 'Daily Heat Loss (' + bargraph_mode + ')' // Label indicates mode + }]; + + var plotOptions = { + xaxis: { + axisLabel: "Temperature Difference (T_inside - T_outside) [°C]", + axisLabelUseCanvas: true, + axisLabelFontSizePixels: 12, + axisLabelFontFamily: 'Verdana, Arial, Helvetica, Tahoma, sans-serif', + axisLabelPadding: 5, + font: { size: flot_font_size, color: "#555" }, // Assumes global flot_font_size + // Let flot determine min/max automatically for scatter + }, + yaxis: { + axisLabel: "Daily Heat Output [kWh]", + axisLabelUseCanvas: true, + axisLabelFontSizePixels: 12, + axisLabelFontFamily: 'Verdana, Arial, Helvetica, Tahoma, sans-serif', + axisLabelPadding: 5, + font: { size: flot_font_size, color: "#555" }, // Assumes global flot_font_size + min: 0 // Heat output shouldn't be negative in this context + }, + grid: { + show: true, + color: "#aaa", + hoverable: true, + clickable: false, // Disable click-through for scatter for now + borderWidth: { top: 0, right: 0, bottom: 1, left: 1 }, + borderColor: "#ccc", + }, + tooltip: { // Enable basic flot tooltips + show: true, + content: "ΔT: %x.1 °C
Heat: %y.1 kWh", + shifts: { + x: 10, + y: 20 + }, + defaultTheme: false, // Use Emoncms styling if available, else default flot + lines: false // Tooltip for points only + }, + legend: { + show: true, + position: "nw" // North-West corner + } + // Selection not typically needed for this type of scatter + // selection: { mode: "xy" }, + }; + + // Ensure the plot container is visible and sized before plotting + var plotWidth = plotBound.width(); + var plotHeight = plotBound.height(); // Or set a fixed height like 400px initially + if (plotHeight < 200) plotHeight = 400; // Ensure minimum height + + plotDiv.width(plotWidth); + plotDiv.height(plotHeight); + + try { + // Clear previous plot content/messages + plotDiv.empty(); + $.plot(plotDiv, plotSeries, plotOptions); + console.log("Heat Loss Plot: Plot generated successfully."); + } catch (e) { + console.error("Heat Loss Plot: Error during flot plotting:", e); + plotDiv.html("

Error generating plot.

"); + } +} + +// Optional: If you want custom tooltips for this plot specifically, +// you could add the hover binding here. +/* +$('#heatloss-plot').bind("plothover", function (event, pos, item) { + $("#tooltip").remove(); // Remove any existing tooltip + if (item) { + var x = item.datapoint[0].toFixed(1); + var y = item.datapoint[1].toFixed(1); + var content = "ΔT: " + x + " °C
Heat: " + y + " kWh"; + + // Assuming 'tooltip' function is globally available from vis.helper.js or similar + tooltip(item.pageX, item.pageY, content); + } +}); +*/ \ No newline at end of file From af9e56d0825976a366216defc3f903d0c90fc7ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Tue, 8 Apr 2025 16:02:04 +0200 Subject: [PATCH 03/30] Included axis labels + tooltip libraries --- .../myheatpump/lib/jquery.flot.axislabels.js | 466 ++++++++++++++ .../myheatpump/lib/jquery.flot.tooltip.js | 606 ++++++++++++++++++ .../myheatpump/lib/jquery.flot.tooltip.min.js | 12 + .../lib/jquery.flot.tooltip.source.js | 595 +++++++++++++++++ .../myheatpump/myheatpump.php | 6 +- 5 files changed, 1684 insertions(+), 1 deletion(-) create mode 100644 apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.axislabels.js create mode 100644 apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.js create mode 100644 apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.min.js create mode 100644 apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.source.js diff --git a/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.axislabels.js b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.axislabels.js new file mode 100644 index 0000000..c4b3bca --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.axislabels.js @@ -0,0 +1,466 @@ +/* +Axis Labels Plugin for flot. +http://github.com/markrcote/flot-axislabels + +Original code is Copyright (c) 2010 Xuan Luo. +Original code was released under the GPLv3 license by Xuan Luo, September 2010. +Original code was rereleased under the MIT license by Xuan Luo, April 2012. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +(function ($) { + var options = { + axisLabels: { + show: true + } + }; + + function canvasSupported() { + return !!document.createElement('canvas').getContext; + } + + function canvasTextSupported() { + if (!canvasSupported()) { + return false; + } + var dummy_canvas = document.createElement('canvas'); + var context = dummy_canvas.getContext('2d'); + return typeof context.fillText == 'function'; + } + + function css3TransitionSupported() { + var div = document.createElement('div'); + return typeof div.style.MozTransition != 'undefined' // Gecko + || typeof div.style.OTransition != 'undefined' // Opera + || typeof div.style.webkitTransition != 'undefined' // WebKit + || typeof div.style.transition != 'undefined'; + } + + + function AxisLabel(axisName, position, padding, plot, opts) { + this.axisName = axisName; + this.position = position; + this.padding = padding; + this.plot = plot; + this.opts = opts; + this.width = 0; + this.height = 0; + } + + AxisLabel.prototype.cleanup = function() { + }; + + + CanvasAxisLabel.prototype = new AxisLabel(); + CanvasAxisLabel.prototype.constructor = CanvasAxisLabel; + function CanvasAxisLabel(axisName, position, padding, plot, opts) { + AxisLabel.prototype.constructor.call(this, axisName, position, padding, + plot, opts); + } + + CanvasAxisLabel.prototype.calculateSize = function() { + if (!this.opts.axisLabelFontSizePixels) + this.opts.axisLabelFontSizePixels = 14; + if (!this.opts.axisLabelFontFamily) + this.opts.axisLabelFontFamily = 'sans-serif'; + + var textWidth = this.opts.axisLabelFontSizePixels + this.padding; + var textHeight = this.opts.axisLabelFontSizePixels + this.padding; + if (this.position == 'left' || this.position == 'right') { + this.width = this.opts.axisLabelFontSizePixels + this.padding; + this.height = 0; + } else { + this.width = 0; + this.height = this.opts.axisLabelFontSizePixels + this.padding; + } + }; + + CanvasAxisLabel.prototype.draw = function(box) { + if (!this.opts.axisLabelColour) + this.opts.axisLabelColour = 'black'; + var ctx = this.plot.getCanvas().getContext('2d'); + ctx.save(); + ctx.font = this.opts.axisLabelFontSizePixels + 'px ' + + this.opts.axisLabelFontFamily; + ctx.fillStyle = this.opts.axisLabelColour; + var width = ctx.measureText(this.opts.axisLabel).width; + var height = this.opts.axisLabelFontSizePixels; + var x, y, angle = 0; + if (this.position == 'top') { + x = box.left + box.width/2 - width/2; + y = box.top + height*0.72; + } else if (this.position == 'bottom') { + x = box.left + box.width/2 - width/2; + y = box.top + box.height - height*0.72; + } else if (this.position == 'left') { + x = box.left + height*0.72; + y = box.height/2 + box.top + width/2; + angle = -Math.PI/2; + } else if (this.position == 'right') { + x = box.left + box.width - height*0.72; + y = box.height/2 + box.top - width/2; + angle = Math.PI/2; + } + ctx.translate(x, y); + ctx.rotate(angle); + ctx.fillText(this.opts.axisLabel, 0, 0); + ctx.restore(); + }; + + + HtmlAxisLabel.prototype = new AxisLabel(); + HtmlAxisLabel.prototype.constructor = HtmlAxisLabel; + function HtmlAxisLabel(axisName, position, padding, plot, opts) { + AxisLabel.prototype.constructor.call(this, axisName, position, + padding, plot, opts); + this.elem = null; + } + + HtmlAxisLabel.prototype.calculateSize = function() { + var elem = $('
' + + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(elem); + // store height and width of label itself, for use in draw() + this.labelWidth = elem.outerWidth(true); + this.labelHeight = elem.outerHeight(true); + elem.remove(); + + this.width = this.height = 0; + if (this.position == 'left' || this.position == 'right') { + this.width = this.labelWidth + this.padding; + } else { + this.height = this.labelHeight + this.padding; + } + }; + + HtmlAxisLabel.prototype.cleanup = function() { + if (this.elem) { + this.elem.remove(); + } + }; + + HtmlAxisLabel.prototype.draw = function(box) { + this.plot.getPlaceholder().find('#' + this.axisName + 'Label').remove(); + this.elem = $('
' + + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(this.elem); + if (this.position == 'top') { + this.elem.css('left', box.left + box.width/2 - this.labelWidth/2 + + 'px'); + this.elem.css('top', box.top + 'px'); + } else if (this.position == 'bottom') { + this.elem.css('left', box.left + box.width/2 - this.labelWidth/2 + + 'px'); + this.elem.css('top', box.top + box.height - this.labelHeight + + 'px'); + } else if (this.position == 'left') { + this.elem.css('top', box.top + box.height/2 - this.labelHeight/2 + + 'px'); + this.elem.css('left', box.left + 'px'); + } else if (this.position == 'right') { + this.elem.css('top', box.top + box.height/2 - this.labelHeight/2 + + 'px'); + this.elem.css('left', box.left + box.width - this.labelWidth + + 'px'); + } + }; + + + CssTransformAxisLabel.prototype = new HtmlAxisLabel(); + CssTransformAxisLabel.prototype.constructor = CssTransformAxisLabel; + function CssTransformAxisLabel(axisName, position, padding, plot, opts) { + HtmlAxisLabel.prototype.constructor.call(this, axisName, position, + padding, plot, opts); + } + + CssTransformAxisLabel.prototype.calculateSize = function() { + HtmlAxisLabel.prototype.calculateSize.call(this); + this.width = this.height = 0; + if (this.position == 'left' || this.position == 'right') { + this.width = this.labelHeight + this.padding; + } else { + this.height = this.labelHeight + this.padding; + } + }; + + CssTransformAxisLabel.prototype.transforms = function(degrees, x, y) { + var stransforms = { + '-moz-transform': '', + '-webkit-transform': '', + '-o-transform': '', + '-ms-transform': '' + }; + if (x != 0 || y != 0) { + var stdTranslate = ' translate(' + x + 'px, ' + y + 'px)'; + stransforms['-moz-transform'] += stdTranslate; + stransforms['-webkit-transform'] += stdTranslate; + stransforms['-o-transform'] += stdTranslate; + stransforms['-ms-transform'] += stdTranslate; + } + if (degrees != 0) { + var rotation = degrees / 90; + var stdRotate = ' rotate(' + degrees + 'deg)'; + stransforms['-moz-transform'] += stdRotate; + stransforms['-webkit-transform'] += stdRotate; + stransforms['-o-transform'] += stdRotate; + stransforms['-ms-transform'] += stdRotate; + } + var s = 'top: 0; left: 0; '; + for (var prop in stransforms) { + if (stransforms[prop]) { + s += prop + ':' + stransforms[prop] + ';'; + } + } + s += ';'; + return s; + }; + + CssTransformAxisLabel.prototype.calculateOffsets = function(box) { + var offsets = { x: 0, y: 0, degrees: 0 }; + if (this.position == 'bottom') { + offsets.x = box.left + box.width/2 - this.labelWidth/2; + offsets.y = box.top + box.height - this.labelHeight; + } else if (this.position == 'top') { + offsets.x = box.left + box.width/2 - this.labelWidth/2; + offsets.y = box.top; + } else if (this.position == 'left') { + offsets.degrees = -90; + offsets.x = box.left - this.labelWidth/2 + this.labelHeight/2; + offsets.y = box.height/2 + box.top; + } else if (this.position == 'right') { + offsets.degrees = 90; + offsets.x = box.left + box.width - this.labelWidth/2 + - this.labelHeight/2; + offsets.y = box.height/2 + box.top; + } + offsets.x = Math.round(offsets.x); + offsets.y = Math.round(offsets.y); + + return offsets; + }; + + CssTransformAxisLabel.prototype.draw = function(box) { + this.plot.getPlaceholder().find("." + this.axisName + "Label").remove(); + var offsets = this.calculateOffsets(box); + this.elem = $('
' + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(this.elem); + }; + + + IeTransformAxisLabel.prototype = new CssTransformAxisLabel(); + IeTransformAxisLabel.prototype.constructor = IeTransformAxisLabel; + function IeTransformAxisLabel(axisName, position, padding, plot, opts) { + CssTransformAxisLabel.prototype.constructor.call(this, axisName, + position, padding, + plot, opts); + this.requiresResize = false; + } + + IeTransformAxisLabel.prototype.transforms = function(degrees, x, y) { + // I didn't feel like learning the crazy Matrix stuff, so this uses + // a combination of the rotation transform and CSS positioning. + var s = ''; + if (degrees != 0) { + var rotation = degrees/90; + while (rotation < 0) { + rotation += 4; + } + s += ' filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=' + rotation + '); '; + // see below + this.requiresResize = (this.position == 'right'); + } + if (x != 0) { + s += 'left: ' + x + 'px; '; + } + if (y != 0) { + s += 'top: ' + y + 'px; '; + } + return s; + }; + + IeTransformAxisLabel.prototype.calculateOffsets = function(box) { + var offsets = CssTransformAxisLabel.prototype.calculateOffsets.call( + this, box); + // adjust some values to take into account differences between + // CSS and IE rotations. + if (this.position == 'top') { + // FIXME: not sure why, but placing this exactly at the top causes + // the top axis label to flip to the bottom... + offsets.y = box.top + 1; + } else if (this.position == 'left') { + offsets.x = box.left; + offsets.y = box.height/2 + box.top - this.labelWidth/2; + } else if (this.position == 'right') { + offsets.x = box.left + box.width - this.labelHeight; + offsets.y = box.height/2 + box.top - this.labelWidth/2; + } + return offsets; + }; + + IeTransformAxisLabel.prototype.draw = function(box) { + CssTransformAxisLabel.prototype.draw.call(this, box); + if (this.requiresResize) { + this.elem = this.plot.getPlaceholder().find("." + this.axisName + + "Label"); + // Since we used CSS positioning instead of transforms for + // translating the element, and since the positioning is done + // before any rotations, we have to reset the width and height + // in case the browser wrapped the text (specifically for the + // y2axis). + this.elem.css('width', this.labelWidth); + this.elem.css('height', this.labelHeight); + } + }; + + + function init(plot) { + plot.hooks.processOptions.push(function (plot, options) { + + if (!options.axisLabels.show) + return; + + // This is kind of a hack. There are no hooks in Flot between + // the creation and measuring of the ticks (setTicks, measureTickLabels + // in setupGrid() ) and the drawing of the ticks and plot box + // (insertAxisLabels in setupGrid() ). + // + // Therefore, we use a trick where we run the draw routine twice: + // the first time to get the tick measurements, so that we can change + // them, and then have it draw it again. + var secondPass = false; + + var axisLabels = {}; + var axisOffsetCounts = { left: 0, right: 0, top: 0, bottom: 0 }; + + var defaultPadding = 2; // padding between axis and tick labels + plot.hooks.draw.push(function (plot, ctx) { + var hasAxisLabels = false; + if (!secondPass) { + // MEASURE AND SET OPTIONS + $.each(plot.getAxes(), function(axisName, axis) { + var opts = axis.options // Flot 0.7 + || plot.getOptions()[axisName]; // Flot 0.6 + + // Handle redraws initiated outside of this plug-in. + if (axisName in axisLabels) { + axis.labelHeight = axis.labelHeight - + axisLabels[axisName].height; + axis.labelWidth = axis.labelWidth - + axisLabels[axisName].width; + opts.labelHeight = axis.labelHeight; + opts.labelWidth = axis.labelWidth; + axisLabels[axisName].cleanup(); + delete axisLabels[axisName]; + } + + if (!opts || !opts.axisLabel || !axis.show) + return; + + hasAxisLabels = true; + var renderer = null; + + if (!opts.axisLabelUseHtml && + navigator.appName == 'Microsoft Internet Explorer') { + var ua = navigator.userAgent; + var re = new RegExp("MSIE ([0-9]{1,}[\.0-9]{0,})"); + if (re.exec(ua) != null) { + rv = parseFloat(RegExp.$1); + } + if (rv >= 9 && !opts.axisLabelUseCanvas && !opts.axisLabelUseHtml) { + renderer = CssTransformAxisLabel; + } else if (!opts.axisLabelUseCanvas && !opts.axisLabelUseHtml) { + renderer = IeTransformAxisLabel; + } else if (opts.axisLabelUseCanvas) { + renderer = CanvasAxisLabel; + } else { + renderer = HtmlAxisLabel; + } + } else { + if (opts.axisLabelUseHtml || (!css3TransitionSupported() && !canvasTextSupported()) && !opts.axisLabelUseCanvas) { + renderer = HtmlAxisLabel; + } else if (opts.axisLabelUseCanvas || !css3TransitionSupported()) { + renderer = CanvasAxisLabel; + } else { + renderer = CssTransformAxisLabel; + } + } + + var padding = opts.axisLabelPadding === undefined ? + defaultPadding : opts.axisLabelPadding; + + axisLabels[axisName] = new renderer(axisName, + axis.position, padding, + plot, opts); + + // flot interprets axis.labelHeight and .labelWidth as + // the height and width of the tick labels. We increase + // these values to make room for the axis label and + // padding. + + axisLabels[axisName].calculateSize(); + + // AxisLabel.height and .width are the size of the + // axis label and padding. + // Just set opts here because axis will be sorted out on + // the redraw. + + opts.labelHeight = axis.labelHeight + + axisLabels[axisName].height; + opts.labelWidth = axis.labelWidth + + axisLabels[axisName].width; + }); + + // If there are axis labels, re-draw with new label widths and + // heights. + + if (hasAxisLabels) { + secondPass = true; + plot.setupGrid(); + plot.draw(); + } + } else { + secondPass = false; + // DRAW + $.each(plot.getAxes(), function(axisName, axis) { + var opts = axis.options // Flot 0.7 + || plot.getOptions()[axisName]; // Flot 0.6 + if (!opts || !opts.axisLabel || !axis.show) + return; + + axisLabels[axisName].draw(axis.box); + }); + } + }); + }); + } + + + $.plot.plugins.push({ + init: init, + options: options, + name: 'axisLabels', + version: '2.0' + }); +})(jQuery); diff --git a/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.js b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.js new file mode 100644 index 0000000..ff606c6 --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.js @@ -0,0 +1,606 @@ +/* + * jquery.flot.tooltip + * + * description: easy-to-use tooltips for Flot charts + * version: 0.9.0 + * authors: Krzysztof Urbas @krzysu [myviews.pl],Evan Steinkerchner @Roundaround + * website: https://github.com/krzysu/flot.tooltip + * + * build on 2016-07-26 + * released under MIT License, 2012 +*/ +(function ($) { + // plugin options, default values + var defaultOptions = { + tooltip: { + show: false, + cssClass: "flotTip", + content: "%s | X: %x | Y: %y", + // allowed templates are: + // %s -> series label, + // %c -> series color, + // %lx -> x axis label (requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels), + // %ly -> y axis label (requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels), + // %x -> X value, + // %y -> Y value, + // %x.2 -> precision of X value, + // %p -> percent + // %n -> value (not percent) of pie chart + xDateFormat: null, + yDateFormat: null, + monthNames: null, + dayNames: null, + shifts: { + x: 10, + y: 20 + }, + defaultTheme: true, + snap: true, + lines: false, + clickTips: false, + + // callbacks + onHover: function (flotItem, $tooltipEl) {}, + + $compat: false + } + }; + + // dummy default options object for legacy code (<0.8.5) - is deleted later + defaultOptions.tooltipOpts = defaultOptions.tooltip; + + // object + var FlotTooltip = function (plot) { + // variables + this.tipPosition = {x: 0, y: 0}; + + this.init(plot); + }; + + // main plugin function + FlotTooltip.prototype.init = function (plot) { + var that = this; + + // detect other flot plugins + var plotPluginsLength = $.plot.plugins.length; + this.plotPlugins = []; + + if (plotPluginsLength) { + for (var p = 0; p < plotPluginsLength; p++) { + this.plotPlugins.push($.plot.plugins[p].name); + } + } + + plot.hooks.bindEvents.push(function (plot, eventHolder) { + + // get plot options + that.plotOptions = plot.getOptions(); + + // for legacy (<0.8.5) implementations + if (typeof(that.plotOptions.tooltip) === 'boolean') { + that.plotOptions.tooltipOpts.show = that.plotOptions.tooltip; + that.plotOptions.tooltip = that.plotOptions.tooltipOpts; + delete that.plotOptions.tooltipOpts; + } + + // if not enabled return + if (that.plotOptions.tooltip.show === false || typeof that.plotOptions.tooltip.show === 'undefined') return; + + // shortcut to access tooltip options + that.tooltipOptions = that.plotOptions.tooltip; + + if (that.tooltipOptions.$compat) { + that.wfunc = 'width'; + that.hfunc = 'height'; + } else { + that.wfunc = 'innerWidth'; + that.hfunc = 'innerHeight'; + } + + // create tooltip DOM element + var $tip = that.getDomElement(); + + // bind event + $( plot.getPlaceholder() ).bind("plothover", plothover); + if (that.tooltipOptions.clickTips) { + $( plot.getPlaceholder() ).bind("plotclick", plotclick); + } + that.clickmode = false; + + $(eventHolder).bind('mousemove', mouseMove); + }); + + plot.hooks.shutdown.push(function (plot, eventHolder){ + $(plot.getPlaceholder()).unbind("plothover", plothover); + $(plot.getPlaceholder()).unbind("plotclick", plotclick); + plot.removeTooltip(); + $(eventHolder).unbind("mousemove", mouseMove); + }); + + function mouseMove(e){ + var pos = {}; + pos.x = e.pageX; + pos.y = e.pageY; + plot.setTooltipPosition(pos); + } + + /** + * open the tooltip (if not already open) and freeze it on the current position till the next click + */ + function plotclick(event, pos, item) { + if (! that.clickmode) { + // it is the click activating the clicktip + plothover(event, pos, item); + if (that.getDomElement().is(":visible")) { + $(plot.getPlaceholder()).unbind("plothover", plothover); + that.clickmode = true; + } + } else { + // it is the click deactivating the clicktip + $( plot.getPlaceholder() ).bind("plothover", plothover); + plot.hideTooltip(); + that.clickmode = false; + } + } + + function plothover(event, pos, item) { + // Simple distance formula. + var lineDistance = function (p1x, p1y, p2x, p2y) { + return Math.sqrt((p2x - p1x) * (p2x - p1x) + (p2y - p1y) * (p2y - p1y)); + }; + + // Here is some voodoo magic for determining the distance to a line form a given point {x, y}. + var dotLineLength = function (x, y, x0, y0, x1, y1, o) { + if (o && !(o = + function (x, y, x0, y0, x1, y1) { + if (typeof x0 !== 'undefined') return { x: x0, y: y }; + else if (typeof y0 !== 'undefined') return { x: x, y: y0 }; + + var left, + tg = -1 / ((y1 - y0) / (x1 - x0)); + + return { + x: left = (x1 * (x * tg - y + y0) + x0 * (x * -tg + y - y1)) / (tg * (x1 - x0) + y0 - y1), + y: tg * left - tg * x + y + }; + } (x, y, x0, y0, x1, y1), + o.x >= Math.min(x0, x1) && o.x <= Math.max(x0, x1) && o.y >= Math.min(y0, y1) && o.y <= Math.max(y0, y1)) + ) { + var l1 = lineDistance(x, y, x0, y0), l2 = lineDistance(x, y, x1, y1); + return l1 > l2 ? l2 : l1; + } else { + var a = y0 - y1, b = x1 - x0, c = x0 * y1 - y0 * x1; + return Math.abs(a * x + b * y + c) / Math.sqrt(a * a + b * b); + } + }; + + if (item) { + plot.showTooltip(item, that.tooltipOptions.snap ? item : pos); + } else if (that.plotOptions.series.lines.show && that.tooltipOptions.lines === true) { + var maxDistance = that.plotOptions.grid.mouseActiveRadius; + + var closestTrace = { + distance: maxDistance + 1 + }; + + var ttPos = pos; + + $.each(plot.getData(), function (i, series) { + var xBeforeIndex = 0, + xAfterIndex = -1; + + // Our search here assumes our data is sorted via the x-axis. + // TODO: Improve efficiency somehow - search smaller sets of data. + for (var j = 1; j < series.data.length; j++) { + if (series.data[j - 1][0] <= pos.x && series.data[j][0] >= pos.x) { + xBeforeIndex = j - 1; + xAfterIndex = j; + } + } + + if (xAfterIndex === -1) { + plot.hideTooltip(); + return; + } + + var pointPrev = { x: series.data[xBeforeIndex][0], y: series.data[xBeforeIndex][1] }, + pointNext = { x: series.data[xAfterIndex][0], y: series.data[xAfterIndex][1] }; + + var distToLine = dotLineLength(series.xaxis.p2c(pos.x), series.yaxis.p2c(pos.y), series.xaxis.p2c(pointPrev.x), + series.yaxis.p2c(pointPrev.y), series.xaxis.p2c(pointNext.x), series.yaxis.p2c(pointNext.y), false); + + if (distToLine < closestTrace.distance) { + + var closestIndex = lineDistance(pointPrev.x, pointPrev.y, pos.x, pos.y) < + lineDistance(pos.x, pos.y, pointNext.x, pointNext.y) ? xBeforeIndex : xAfterIndex; + + var pointSize = series.datapoints.pointsize; + + // Calculate the point on the line vertically closest to our cursor. + var pointOnLine = [ + pos.x, + pointPrev.y + ((pointNext.y - pointPrev.y) * ((pos.x - pointPrev.x) / (pointNext.x - pointPrev.x))) + ]; + + var item = { + datapoint: pointOnLine, + dataIndex: closestIndex, + series: series, + seriesIndex: i + }; + + closestTrace = { + distance: distToLine, + item: item + }; + + if (that.tooltipOptions.snap) { + ttPos = { + pageX: series.xaxis.p2c(pointOnLine[0]), + pageY: series.yaxis.p2c(pointOnLine[1]) + }; + } + } + }); + + if (closestTrace.distance < maxDistance + 1) + plot.showTooltip(closestTrace.item, ttPos); + else + plot.hideTooltip(); + } else { + plot.hideTooltip(); + } + } + + // Quick little function for setting the tooltip position. + plot.setTooltipPosition = function (pos) { + var $tip = that.getDomElement(); + + var totalTipWidth = $tip.outerWidth() + that.tooltipOptions.shifts.x; + var totalTipHeight = $tip.outerHeight() + that.tooltipOptions.shifts.y; + if ((pos.x - $(window).scrollLeft()) > ($(window)[that.wfunc]() - totalTipWidth)) { + pos.x -= totalTipWidth; + pos.x = Math.max(pos.x, 0); + } + if ((pos.y - $(window).scrollTop()) > ($(window)[that.hfunc]() - totalTipHeight)) { + pos.y -= totalTipHeight; + } + + /* + The section applies the new positioning ONLY if pos.x and pos.y + are numbers. If they are undefined or not a number, use the last + known numerical position. This hack fixes a bug that kept pie + charts from keeping their tooltip positioning. + */ + + if (isNaN(pos.x)) { + that.tipPosition.x = that.tipPosition.xPrev; + } + else { + that.tipPosition.x = pos.x; + that.tipPosition.xPrev = pos.x; + } + if (isNaN(pos.y)) { + that.tipPosition.y = that.tipPosition.yPrev; + } + else { + that.tipPosition.y = pos.y; + that.tipPosition.yPrev = pos.y; + } + + }; + + // Quick little function for showing the tooltip. + plot.showTooltip = function (target, position, targetPosition) { + var $tip = that.getDomElement(); + + // convert tooltip content template to real tipText + var tipText = that.stringFormat(that.tooltipOptions.content, target); + if (tipText === '') + return; + + $tip.html(tipText); + plot.setTooltipPosition({ x: that.tipPosition.x, y: that.tipPosition.y }); + $tip.css({ + left: that.tipPosition.x + that.tooltipOptions.shifts.x, + top: that.tipPosition.y + that.tooltipOptions.shifts.y + }).show(); + + // run callback + if (typeof that.tooltipOptions.onHover === 'function') { + that.tooltipOptions.onHover(target, $tip); + } + }; + + // Quick little function for hiding the tooltip. + plot.hideTooltip = function () { + that.getDomElement().hide().html(''); + }; + + plot.removeTooltip = function() { + that.getDomElement().remove(); + }; + }; + + /** + * get or create tooltip DOM element + * @return jQuery object + */ + FlotTooltip.prototype.getDomElement = function () { + var $tip = $('
'); + if (this.tooltipOptions && this.tooltipOptions.cssClass) { + $tip = $('.' + this.tooltipOptions.cssClass); + + if( $tip.length === 0 ){ + $tip = $('
').addClass(this.tooltipOptions.cssClass); + $tip.appendTo('body').hide().css({position: 'absolute'}); + + if(this.tooltipOptions.defaultTheme) { + $tip.css({ + 'background': '#fff', + 'z-index': '1040', + 'padding': '0.4em 0.6em', + 'border-radius': '0.5em', + 'font-size': '0.8em', + 'border': '1px solid #111', + 'display': 'none', + 'white-space': 'nowrap' + }); + } + } + } + + return $tip; + }; + + /** + * core function, create tooltip content + * @param {string} content - template with tooltip content + * @param {object} item - Flot item + * @return {string} real tooltip content for current item + */ + FlotTooltip.prototype.stringFormat = function (content, item) { + var percentPattern = /%p\.{0,1}(\d{0,})/; + var seriesPattern = /%s/; + var colorPattern = /%c/; + var xLabelPattern = /%lx/; // requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels, will be ignored if plugin isn't loaded + var yLabelPattern = /%ly/; // requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels, will be ignored if plugin isn't loaded + var xPattern = /%x\.{0,1}(\d{0,})/; + var yPattern = /%y\.{0,1}(\d{0,})/; + var xPatternWithoutPrecision = "%x"; + var yPatternWithoutPrecision = "%y"; + var customTextPattern = "%ct"; + var nPiePattern = "%n"; + + var x, y, customText, p, n; + + // for threshold plugin we need to read data from different place + if (typeof item.series.threshold !== "undefined") { + x = item.datapoint[0]; + y = item.datapoint[1]; + customText = item.datapoint[2]; + } + + // for CurvedLines plugin we need to read data from different place + else if (typeof item.series.curvedLines !== "undefined") { + x = item.datapoint[0]; + y = item.datapoint[1]; + } + + else if (typeof item.series.lines !== "undefined" && item.series.lines.steps) { + x = item.series.datapoints.points[item.dataIndex * 2]; + y = item.series.datapoints.points[item.dataIndex * 2 + 1]; + // TODO: where to find custom text in this variant? + customText = ""; + } else { + x = item.series.data[item.dataIndex][0]; + y = item.series.data[item.dataIndex][1]; + customText = item.series.data[item.dataIndex][2]; + } + + // I think this is only in case of threshold plugin + if (item.series.label === null && item.series.originSeries) { + item.series.label = item.series.originSeries.label; + } + + // if it is a function callback get the content string + if (typeof(content) === 'function') { + content = content(item.series.label, x, y, item); + } + + // the case where the passed content is equal to false + if (typeof(content) === 'boolean' && !content) { + return ''; + } + + /* replacement of %ct and other multi-character templates must + precede the replacement of single-character templates + to avoid conflict between '%c' and '%ct' and similar substrings + */ + if (customText) { + content = content.replace(customTextPattern, customText); + } + + // percent match for pie charts and stacked percent + if (typeof (item.series.percent) !== 'undefined') { + p = item.series.percent; + } else if (typeof (item.series.percents) !== 'undefined') { + p = item.series.percents[item.dataIndex]; + } + if (typeof p === 'number') { + content = this.adjustValPrecision(percentPattern, content, p); + } + + // replace %n with number of items represented by slice in pie charts + if (item.series.hasOwnProperty('pie')) { + if (typeof item.series.data[0][1] !== 'undefined') { + n = item.series.data[0][1]; + } + } + if (typeof n === 'number') { + content = content.replace(nPiePattern, n); + } + + // series match + if (typeof(item.series.label) !== 'undefined') { + content = content.replace(seriesPattern, item.series.label); + } else { + //remove %s if label is undefined + content = content.replace(seriesPattern, ""); + } + + // color match + if (typeof(item.series.color) !== 'undefined') { + content = content.replace(colorPattern, item.series.color); + } else { + //remove %s if color is undefined + content = content.replace(colorPattern, ""); + } + + // x axis label match + if (this.hasAxisLabel('xaxis', item)) { + content = content.replace(xLabelPattern, item.series.xaxis.options.axisLabel); + } else { + //remove %lx if axis label is undefined or axislabels plugin not present + content = content.replace(xLabelPattern, ""); + } + + // y axis label match + if (this.hasAxisLabel('yaxis', item)) { + content = content.replace(yLabelPattern, item.series.yaxis.options.axisLabel); + } else { + //remove %ly if axis label is undefined or axislabels plugin not present + content = content.replace(yLabelPattern, ""); + } + + // time mode axes with custom dateFormat + if (this.isTimeMode('xaxis', item) && this.isXDateFormat(item)) { + content = content.replace(xPattern, this.timestampToDate(x, this.tooltipOptions.xDateFormat, item.series.xaxis.options)); + } + if (this.isTimeMode('yaxis', item) && this.isYDateFormat(item)) { + content = content.replace(yPattern, this.timestampToDate(y, this.tooltipOptions.yDateFormat, item.series.yaxis.options)); + } + + // set precision if defined + if (typeof x === 'number') { + content = this.adjustValPrecision(xPattern, content, x); + } + if (typeof y === 'number') { + content = this.adjustValPrecision(yPattern, content, y); + } + + // change x from number to given label, if given + if (typeof item.series.xaxis.ticks !== 'undefined') { + + var ticks; + if (this.hasRotatedXAxisTicks(item)) { + // xaxis.ticks will be an empty array if tickRotor is being used, but the values are available in rotatedTicks + ticks = 'rotatedTicks'; + } else { + ticks = 'ticks'; + } + + // see https://github.com/krzysu/flot.tooltip/issues/65 + var tickIndex = item.dataIndex + item.seriesIndex; + + for (var xIndex in item.series.xaxis[ticks]) { + if (item.series.xaxis[ticks].hasOwnProperty(tickIndex) && !this.isTimeMode('xaxis', item)) { + var valueX = (this.isCategoriesMode('xaxis', item)) ? item.series.xaxis[ticks][tickIndex].label : item.series.xaxis[ticks][tickIndex].v; + if (valueX === x) { + content = content.replace(xPattern, item.series.xaxis[ticks][tickIndex].label.replace(/\$/g, '$$$$')); + } + } + } + } + + // change y from number to given label, if given + if (typeof item.series.yaxis.ticks !== 'undefined') { + for (var yIndex in item.series.yaxis.ticks) { + if (item.series.yaxis.ticks.hasOwnProperty(yIndex)) { + var valueY = (this.isCategoriesMode('yaxis', item)) ? item.series.yaxis.ticks[yIndex].label : item.series.yaxis.ticks[yIndex].v; + if (valueY === y) { + content = content.replace(yPattern, item.series.yaxis.ticks[yIndex].label.replace(/\$/g, '$$$$')); + } + } + } + } + + // if no value customization, use tickFormatter by default + if (typeof item.series.xaxis.tickFormatter !== 'undefined') { + //escape dollar + content = content.replace(xPatternWithoutPrecision, item.series.xaxis.tickFormatter(x, item.series.xaxis).replace(/\$/g, '$$')); + } + if (typeof item.series.yaxis.tickFormatter !== 'undefined') { + //escape dollar + content = content.replace(yPatternWithoutPrecision, item.series.yaxis.tickFormatter(y, item.series.yaxis).replace(/\$/g, '$$')); + } + + return content; + }; + + // helpers just for readability + FlotTooltip.prototype.isTimeMode = function (axisName, item) { + return (typeof item.series[axisName].options.mode !== 'undefined' && item.series[axisName].options.mode === 'time'); + }; + + FlotTooltip.prototype.isXDateFormat = function (item) { + return (typeof this.tooltipOptions.xDateFormat !== 'undefined' && this.tooltipOptions.xDateFormat !== null); + }; + + FlotTooltip.prototype.isYDateFormat = function (item) { + return (typeof this.tooltipOptions.yDateFormat !== 'undefined' && this.tooltipOptions.yDateFormat !== null); + }; + + FlotTooltip.prototype.isCategoriesMode = function (axisName, item) { + return (typeof item.series[axisName].options.mode !== 'undefined' && item.series[axisName].options.mode === 'categories'); + }; + + // + FlotTooltip.prototype.timestampToDate = function (tmst, dateFormat, options) { + var theDate = $.plot.dateGenerator(tmst, options); + return $.plot.formatDate(theDate, dateFormat, this.tooltipOptions.monthNames, this.tooltipOptions.dayNames); + }; + + // + FlotTooltip.prototype.adjustValPrecision = function (pattern, content, value) { + + var precision; + var matchResult = content.match(pattern); + if( matchResult !== null ) { + if(RegExp.$1 !== '') { + precision = RegExp.$1; + value = value.toFixed(precision); + + // only replace content if precision exists, in other case use thickformater + content = content.replace(pattern, value); + } + } + return content; + }; + + // other plugins detection below + + // check if flot-axislabels plugin (https://github.com/markrcote/flot-axislabels) is used and that an axis label is given + FlotTooltip.prototype.hasAxisLabel = function (axisName, item) { + return ($.inArray('axisLabels', this.plotPlugins) !== -1 && typeof item.series[axisName].options.axisLabel !== 'undefined' && item.series[axisName].options.axisLabel.length > 0); + }; + + // check whether flot-tickRotor, a plugin which allows rotation of X-axis ticks, is being used + FlotTooltip.prototype.hasRotatedXAxisTicks = function (item) { + return ($.inArray('tickRotor',this.plotPlugins) !== -1 && typeof item.series.xaxis.rotatedTicks !== 'undefined'); + }; + + // + var init = function (plot) { + new FlotTooltip(plot); + }; + + // define Flot plugin + $.plot.plugins.push({ + init: init, + options: defaultOptions, + name: 'tooltip', + version: '0.8.5' + }); + +})(jQuery); diff --git a/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.min.js b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.min.js new file mode 100644 index 0000000..451811a --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.min.js @@ -0,0 +1,12 @@ +/* + * jquery.flot.tooltip + * + * description: easy-to-use tooltips for Flot charts + * version: 0.9.0 + * authors: Krzysztof Urbas @krzysu [myviews.pl],Evan Steinkerchner @Roundaround + * website: https://github.com/krzysu/flot.tooltip + * + * build on 2016-07-26 + * released under MIT License, 2012 +*/ +!function(a){var b={tooltip:{show:!1,cssClass:"flotTip",content:"%s | X: %x | Y: %y",xDateFormat:null,yDateFormat:null,monthNames:null,dayNames:null,shifts:{x:10,y:20},defaultTheme:!0,snap:!0,lines:!1,clickTips:!1,onHover:function(a,b){},$compat:!1}};b.tooltipOpts=b.tooltip;var c=function(a){this.tipPosition={x:0,y:0},this.init(a)};c.prototype.init=function(b){function c(a){var c={};c.x=a.pageX,c.y=a.pageY,b.setTooltipPosition(c)}function d(c,d,g){f.clickmode?(a(b.getPlaceholder()).bind("plothover",e),b.hideTooltip(),f.clickmode=!1):(e(c,d,g),f.getDomElement().is(":visible")&&(a(b.getPlaceholder()).unbind("plothover",e),f.clickmode=!0))}function e(c,d,e){var g=function(a,b,c,d){return Math.sqrt((c-a)*(c-a)+(d-b)*(d-b))},h=function(a,b,c,d,e,f,h){if(!h||(h=function(a,b,c,d,e,f){if("undefined"!=typeof c)return{x:c,y:b};if("undefined"!=typeof d)return{x:a,y:d};var g,h=-1/((f-d)/(e-c));return{x:g=(e*(a*h-b+d)+c*(a*-h+b-f))/(h*(e-c)+d-f),y:h*g-h*a+b}}(a,b,c,d,e,f),h.x>=Math.min(c,e)&&h.x<=Math.max(c,e)&&h.y>=Math.min(d,f)&&h.y<=Math.max(d,f))){var i=d-f,j=e-c,k=c*f-d*e;return Math.abs(i*a+j*b+k)/Math.sqrt(i*i+j*j)}var l=g(a,b,c,d),m=g(a,b,e,f);return l>m?m:l};if(e)b.showTooltip(e,f.tooltipOptions.snap?e:d);else if(f.plotOptions.series.lines.show&&f.tooltipOptions.lines===!0){var i=f.plotOptions.grid.mouseActiveRadius,j={distance:i+1},k=d;a.each(b.getData(),function(a,c){for(var e=0,i=-1,l=1;l=d.x&&(e=l-1,i=l);if(-1===i)return void b.hideTooltip();var m={x:c.data[e][0],y:c.data[e][1]},n={x:c.data[i][0],y:c.data[i][1]},o=h(c.xaxis.p2c(d.x),c.yaxis.p2c(d.y),c.xaxis.p2c(m.x),c.yaxis.p2c(m.y),c.xaxis.p2c(n.x),c.yaxis.p2c(n.y),!1);if(oh;h++)this.plotPlugins.push(a.plot.plugins[h].name);b.hooks.bindEvents.push(function(b,g){if(f.plotOptions=b.getOptions(),"boolean"==typeof f.plotOptions.tooltip&&(f.plotOptions.tooltipOpts.show=f.plotOptions.tooltip,f.plotOptions.tooltip=f.plotOptions.tooltipOpts,delete f.plotOptions.tooltipOpts),f.plotOptions.tooltip.show!==!1&&"undefined"!=typeof f.plotOptions.tooltip.show){f.tooltipOptions=f.plotOptions.tooltip,f.tooltipOptions.$compat?(f.wfunc="width",f.hfunc="height"):(f.wfunc="innerWidth",f.hfunc="innerHeight");f.getDomElement();a(b.getPlaceholder()).bind("plothover",e),f.tooltipOptions.clickTips&&a(b.getPlaceholder()).bind("plotclick",d),f.clickmode=!1,a(g).bind("mousemove",c)}}),b.hooks.shutdown.push(function(b,f){a(b.getPlaceholder()).unbind("plothover",e),a(b.getPlaceholder()).unbind("plotclick",d),b.removeTooltip(),a(f).unbind("mousemove",c)}),b.setTooltipPosition=function(b){var c=f.getDomElement(),d=c.outerWidth()+f.tooltipOptions.shifts.x,e=c.outerHeight()+f.tooltipOptions.shifts.y;b.x-a(window).scrollLeft()>a(window)[f.wfunc]()-d&&(b.x-=d,b.x=Math.max(b.x,0)),b.y-a(window).scrollTop()>a(window)[f.hfunc]()-e&&(b.y-=e),isNaN(b.x)?f.tipPosition.x=f.tipPosition.xPrev:(f.tipPosition.x=b.x,f.tipPosition.xPrev=b.x),isNaN(b.y)?f.tipPosition.y=f.tipPosition.yPrev:(f.tipPosition.y=b.y,f.tipPosition.yPrev=b.y)},b.showTooltip=function(a,c,d){var e=f.getDomElement(),g=f.stringFormat(f.tooltipOptions.content,a);""!==g&&(e.html(g),b.setTooltipPosition({x:f.tipPosition.x,y:f.tipPosition.y}),e.css({left:f.tipPosition.x+f.tooltipOptions.shifts.x,top:f.tipPosition.y+f.tooltipOptions.shifts.y}).show(),"function"==typeof f.tooltipOptions.onHover&&f.tooltipOptions.onHover(a,e))},b.hideTooltip=function(){f.getDomElement().hide().html("")},b.removeTooltip=function(){f.getDomElement().remove()}},c.prototype.getDomElement=function(){var b=a("
");return this.tooltipOptions&&this.tooltipOptions.cssClass&&(b=a("."+this.tooltipOptions.cssClass),0===b.length&&(b=a("
").addClass(this.tooltipOptions.cssClass),b.appendTo("body").hide().css({position:"absolute"}),this.tooltipOptions.defaultTheme&&b.css({background:"#fff","z-index":"1040",padding:"0.4em 0.6em","border-radius":"0.5em","font-size":"0.8em",border:"1px solid #111",display:"none","white-space":"nowrap"}))),b},c.prototype.stringFormat=function(a,b){var c,d,e,f,g,h=/%p\.{0,1}(\d{0,})/,i=/%s/,j=/%c/,k=/%lx/,l=/%ly/,m=/%x\.{0,1}(\d{0,})/,n=/%y\.{0,1}(\d{0,})/,o="%x",p="%y",q="%ct",r="%n";if("undefined"!=typeof b.series.threshold?(c=b.datapoint[0],d=b.datapoint[1],e=b.datapoint[2]):"undefined"!=typeof b.series.curvedLines?(c=b.datapoint[0],d=b.datapoint[1]):"undefined"!=typeof b.series.lines&&b.series.lines.steps?(c=b.series.datapoints.points[2*b.dataIndex],d=b.series.datapoints.points[2*b.dataIndex+1],e=""):(c=b.series.data[b.dataIndex][0],d=b.series.data[b.dataIndex][1],e=b.series.data[b.dataIndex][2]),null===b.series.label&&b.series.originSeries&&(b.series.label=b.series.originSeries.label),"function"==typeof a&&(a=a(b.series.label,c,d,b)),"boolean"==typeof a&&!a)return"";if(e&&(a=a.replace(q,e)),"undefined"!=typeof b.series.percent?f=b.series.percent:"undefined"!=typeof b.series.percents&&(f=b.series.percents[b.dataIndex]),"number"==typeof f&&(a=this.adjustValPrecision(h,a,f)),b.series.hasOwnProperty("pie")&&"undefined"!=typeof b.series.data[0][1]&&(g=b.series.data[0][1]),"number"==typeof g&&(a=a.replace(r,g)),a="undefined"!=typeof b.series.label?a.replace(i,b.series.label):a.replace(i,""),a="undefined"!=typeof b.series.color?a.replace(j,b.series.color):a.replace(j,""),a=this.hasAxisLabel("xaxis",b)?a.replace(k,b.series.xaxis.options.axisLabel):a.replace(k,""),a=this.hasAxisLabel("yaxis",b)?a.replace(l,b.series.yaxis.options.axisLabel):a.replace(l,""),this.isTimeMode("xaxis",b)&&this.isXDateFormat(b)&&(a=a.replace(m,this.timestampToDate(c,this.tooltipOptions.xDateFormat,b.series.xaxis.options))),this.isTimeMode("yaxis",b)&&this.isYDateFormat(b)&&(a=a.replace(n,this.timestampToDate(d,this.tooltipOptions.yDateFormat,b.series.yaxis.options))),"number"==typeof c&&(a=this.adjustValPrecision(m,a,c)),"number"==typeof d&&(a=this.adjustValPrecision(n,a,d)),"undefined"!=typeof b.series.xaxis.ticks){var s;s=this.hasRotatedXAxisTicks(b)?"rotatedTicks":"ticks";var t=b.dataIndex+b.seriesIndex;for(var u in b.series.xaxis[s])if(b.series.xaxis[s].hasOwnProperty(t)&&!this.isTimeMode("xaxis",b)){var v=this.isCategoriesMode("xaxis",b)?b.series.xaxis[s][t].label:b.series.xaxis[s][t].v;v===c&&(a=a.replace(m,b.series.xaxis[s][t].label.replace(/\$/g,"$$$$")))}}if("undefined"!=typeof b.series.yaxis.ticks)for(var w in b.series.yaxis.ticks)if(b.series.yaxis.ticks.hasOwnProperty(w)){var x=this.isCategoriesMode("yaxis",b)?b.series.yaxis.ticks[w].label:b.series.yaxis.ticks[w].v;x===d&&(a=a.replace(n,b.series.yaxis.ticks[w].label.replace(/\$/g,"$$$$")))}return"undefined"!=typeof b.series.xaxis.tickFormatter&&(a=a.replace(o,b.series.xaxis.tickFormatter(c,b.series.xaxis).replace(/\$/g,"$$"))),"undefined"!=typeof b.series.yaxis.tickFormatter&&(a=a.replace(p,b.series.yaxis.tickFormatter(d,b.series.yaxis).replace(/\$/g,"$$"))),a},c.prototype.isTimeMode=function(a,b){return"undefined"!=typeof b.series[a].options.mode&&"time"===b.series[a].options.mode},c.prototype.isXDateFormat=function(a){return"undefined"!=typeof this.tooltipOptions.xDateFormat&&null!==this.tooltipOptions.xDateFormat},c.prototype.isYDateFormat=function(a){return"undefined"!=typeof this.tooltipOptions.yDateFormat&&null!==this.tooltipOptions.yDateFormat},c.prototype.isCategoriesMode=function(a,b){return"undefined"!=typeof b.series[a].options.mode&&"categories"===b.series[a].options.mode},c.prototype.timestampToDate=function(b,c,d){var e=a.plot.dateGenerator(b,d);return a.plot.formatDate(e,c,this.tooltipOptions.monthNames,this.tooltipOptions.dayNames)},c.prototype.adjustValPrecision=function(a,b,c){var d,e=b.match(a);return null!==e&&""!==RegExp.$1&&(d=RegExp.$1,c=c.toFixed(d),b=b.replace(a,c)),b},c.prototype.hasAxisLabel=function(b,c){return-1!==a.inArray("axisLabels",this.plotPlugins)&&"undefined"!=typeof c.series[b].options.axisLabel&&c.series[b].options.axisLabel.length>0},c.prototype.hasRotatedXAxisTicks=function(b){return-1!==a.inArray("tickRotor",this.plotPlugins)&&"undefined"!=typeof b.series.xaxis.rotatedTicks};var d=function(a){new c(a)};a.plot.plugins.push({init:d,options:b,name:"tooltip",version:"0.8.5"})}(jQuery); \ No newline at end of file diff --git a/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.source.js b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.source.js new file mode 100644 index 0000000..0e49edc --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/lib/jquery.flot.tooltip.source.js @@ -0,0 +1,595 @@ +(function ($) { + // plugin options, default values + var defaultOptions = { + tooltip: { + show: false, + cssClass: "flotTip", + content: "%s | X: %x | Y: %y", + // allowed templates are: + // %s -> series label, + // %c -> series color, + // %lx -> x axis label (requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels), + // %ly -> y axis label (requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels), + // %x -> X value, + // %y -> Y value, + // %x.2 -> precision of X value, + // %p -> percent + // %n -> value (not percent) of pie chart + xDateFormat: null, + yDateFormat: null, + monthNames: null, + dayNames: null, + shifts: { + x: 10, + y: 20 + }, + defaultTheme: true, + snap: true, + lines: false, + clickTips: false, + + // callbacks + onHover: function (flotItem, $tooltipEl) {}, + + $compat: false + } + }; + + // dummy default options object for legacy code (<0.8.5) - is deleted later + defaultOptions.tooltipOpts = defaultOptions.tooltip; + + // object + var FlotTooltip = function (plot) { + // variables + this.tipPosition = {x: 0, y: 0}; + + this.init(plot); + }; + + // main plugin function + FlotTooltip.prototype.init = function (plot) { + var that = this; + + // detect other flot plugins + var plotPluginsLength = $.plot.plugins.length; + this.plotPlugins = []; + + if (plotPluginsLength) { + for (var p = 0; p < plotPluginsLength; p++) { + this.plotPlugins.push($.plot.plugins[p].name); + } + } + + plot.hooks.bindEvents.push(function (plot, eventHolder) { + + // get plot options + that.plotOptions = plot.getOptions(); + + // for legacy (<0.8.5) implementations + if (typeof(that.plotOptions.tooltip) === 'boolean') { + that.plotOptions.tooltipOpts.show = that.plotOptions.tooltip; + that.plotOptions.tooltip = that.plotOptions.tooltipOpts; + delete that.plotOptions.tooltipOpts; + } + + // if not enabled return + if (that.plotOptions.tooltip.show === false || typeof that.plotOptions.tooltip.show === 'undefined') return; + + // shortcut to access tooltip options + that.tooltipOptions = that.plotOptions.tooltip; + + if (that.tooltipOptions.$compat) { + that.wfunc = 'width'; + that.hfunc = 'height'; + } else { + that.wfunc = 'innerWidth'; + that.hfunc = 'innerHeight'; + } + + // create tooltip DOM element + var $tip = that.getDomElement(); + + // bind event + $( plot.getPlaceholder() ).bind("plothover", plothover); + if (that.tooltipOptions.clickTips) { + $( plot.getPlaceholder() ).bind("plotclick", plotclick); + } + that.clickmode = false; + + $(eventHolder).bind('mousemove', mouseMove); + }); + + plot.hooks.shutdown.push(function (plot, eventHolder){ + $(plot.getPlaceholder()).unbind("plothover", plothover); + $(plot.getPlaceholder()).unbind("plotclick", plotclick); + plot.removeTooltip(); + $(eventHolder).unbind("mousemove", mouseMove); + }); + + function mouseMove(e){ + var pos = {}; + pos.x = e.pageX; + pos.y = e.pageY; + plot.setTooltipPosition(pos); + } + + /** + * open the tooltip (if not already open) and freeze it on the current position till the next click + */ + function plotclick(event, pos, item) { + if (! that.clickmode) { + // it is the click activating the clicktip + plothover(event, pos, item); + if (that.getDomElement().is(":visible")) { + $(plot.getPlaceholder()).unbind("plothover", plothover); + that.clickmode = true; + } + } else { + // it is the click deactivating the clicktip + $( plot.getPlaceholder() ).bind("plothover", plothover); + plot.hideTooltip(); + that.clickmode = false; + } + } + + function plothover(event, pos, item) { + // Simple distance formula. + var lineDistance = function (p1x, p1y, p2x, p2y) { + return Math.sqrt((p2x - p1x) * (p2x - p1x) + (p2y - p1y) * (p2y - p1y)); + }; + + // Here is some voodoo magic for determining the distance to a line form a given point {x, y}. + var dotLineLength = function (x, y, x0, y0, x1, y1, o) { + if (o && !(o = + function (x, y, x0, y0, x1, y1) { + if (typeof x0 !== 'undefined') return { x: x0, y: y }; + else if (typeof y0 !== 'undefined') return { x: x, y: y0 }; + + var left, + tg = -1 / ((y1 - y0) / (x1 - x0)); + + return { + x: left = (x1 * (x * tg - y + y0) + x0 * (x * -tg + y - y1)) / (tg * (x1 - x0) + y0 - y1), + y: tg * left - tg * x + y + }; + } (x, y, x0, y0, x1, y1), + o.x >= Math.min(x0, x1) && o.x <= Math.max(x0, x1) && o.y >= Math.min(y0, y1) && o.y <= Math.max(y0, y1)) + ) { + var l1 = lineDistance(x, y, x0, y0), l2 = lineDistance(x, y, x1, y1); + return l1 > l2 ? l2 : l1; + } else { + var a = y0 - y1, b = x1 - x0, c = x0 * y1 - y0 * x1; + return Math.abs(a * x + b * y + c) / Math.sqrt(a * a + b * b); + } + }; + + if (item) { + plot.showTooltip(item, that.tooltipOptions.snap ? item : pos); + } else if (that.plotOptions.series.lines.show && that.tooltipOptions.lines === true) { + var maxDistance = that.plotOptions.grid.mouseActiveRadius; + + var closestTrace = { + distance: maxDistance + 1 + }; + + var ttPos = pos; + + $.each(plot.getData(), function (i, series) { + var xBeforeIndex = 0, + xAfterIndex = -1; + + // Our search here assumes our data is sorted via the x-axis. + // TODO: Improve efficiency somehow - search smaller sets of data. + for (var j = 1; j < series.data.length; j++) { + if (series.data[j - 1][0] <= pos.x && series.data[j][0] >= pos.x) { + xBeforeIndex = j - 1; + xAfterIndex = j; + } + } + + if (xAfterIndex === -1) { + plot.hideTooltip(); + return; + } + + var pointPrev = { x: series.data[xBeforeIndex][0], y: series.data[xBeforeIndex][1] }, + pointNext = { x: series.data[xAfterIndex][0], y: series.data[xAfterIndex][1] }; + + var distToLine = dotLineLength(series.xaxis.p2c(pos.x), series.yaxis.p2c(pos.y), series.xaxis.p2c(pointPrev.x), + series.yaxis.p2c(pointPrev.y), series.xaxis.p2c(pointNext.x), series.yaxis.p2c(pointNext.y), false); + + if (distToLine < closestTrace.distance) { + + var closestIndex = lineDistance(pointPrev.x, pointPrev.y, pos.x, pos.y) < + lineDistance(pos.x, pos.y, pointNext.x, pointNext.y) ? xBeforeIndex : xAfterIndex; + + var pointSize = series.datapoints.pointsize; + + // Calculate the point on the line vertically closest to our cursor. + var pointOnLine = [ + pos.x, + pointPrev.y + ((pointNext.y - pointPrev.y) * ((pos.x - pointPrev.x) / (pointNext.x - pointPrev.x))) + ]; + + var item = { + datapoint: pointOnLine, + dataIndex: closestIndex, + series: series, + seriesIndex: i + }; + + closestTrace = { + distance: distToLine, + item: item + }; + + if (that.tooltipOptions.snap) { + ttPos = { + pageX: series.xaxis.p2c(pointOnLine[0]), + pageY: series.yaxis.p2c(pointOnLine[1]) + }; + } + } + }); + + if (closestTrace.distance < maxDistance + 1) + plot.showTooltip(closestTrace.item, ttPos); + else + plot.hideTooltip(); + } else { + plot.hideTooltip(); + } + } + + // Quick little function for setting the tooltip position. + plot.setTooltipPosition = function (pos) { + var $tip = that.getDomElement(); + + var totalTipWidth = $tip.outerWidth() + that.tooltipOptions.shifts.x; + var totalTipHeight = $tip.outerHeight() + that.tooltipOptions.shifts.y; + if ((pos.x - $(window).scrollLeft()) > ($(window)[that.wfunc]() - totalTipWidth)) { + pos.x -= totalTipWidth; + pos.x = Math.max(pos.x, 0); + } + if ((pos.y - $(window).scrollTop()) > ($(window)[that.hfunc]() - totalTipHeight)) { + pos.y -= totalTipHeight; + } + + /* + The section applies the new positioning ONLY if pos.x and pos.y + are numbers. If they are undefined or not a number, use the last + known numerical position. This hack fixes a bug that kept pie + charts from keeping their tooltip positioning. + */ + + if (isNaN(pos.x)) { + that.tipPosition.x = that.tipPosition.xPrev; + } + else { + that.tipPosition.x = pos.x; + that.tipPosition.xPrev = pos.x; + } + if (isNaN(pos.y)) { + that.tipPosition.y = that.tipPosition.yPrev; + } + else { + that.tipPosition.y = pos.y; + that.tipPosition.yPrev = pos.y; + } + + }; + + // Quick little function for showing the tooltip. + plot.showTooltip = function (target, position, targetPosition) { + var $tip = that.getDomElement(); + + // convert tooltip content template to real tipText + var tipText = that.stringFormat(that.tooltipOptions.content, target); + if (tipText === '') + return; + + $tip.html(tipText); + plot.setTooltipPosition({ x: that.tipPosition.x, y: that.tipPosition.y }); + $tip.css({ + left: that.tipPosition.x + that.tooltipOptions.shifts.x, + top: that.tipPosition.y + that.tooltipOptions.shifts.y + }).show(); + + // run callback + if (typeof that.tooltipOptions.onHover === 'function') { + that.tooltipOptions.onHover(target, $tip); + } + }; + + // Quick little function for hiding the tooltip. + plot.hideTooltip = function () { + that.getDomElement().hide().html(''); + }; + + plot.removeTooltip = function() { + that.getDomElement().remove(); + }; + }; + + /** + * get or create tooltip DOM element + * @return jQuery object + */ + FlotTooltip.prototype.getDomElement = function () { + var $tip = $('
'); + if (this.tooltipOptions && this.tooltipOptions.cssClass) { + $tip = $('.' + this.tooltipOptions.cssClass); + + if( $tip.length === 0 ){ + $tip = $('
').addClass(this.tooltipOptions.cssClass); + $tip.appendTo('body').hide().css({position: 'absolute'}); + + if(this.tooltipOptions.defaultTheme) { + $tip.css({ + 'background': '#fff', + 'z-index': '1040', + 'padding': '0.4em 0.6em', + 'border-radius': '0.5em', + 'font-size': '0.8em', + 'border': '1px solid #111', + 'display': 'none', + 'white-space': 'nowrap' + }); + } + } + } + + return $tip; + }; + + /** + * core function, create tooltip content + * @param {string} content - template with tooltip content + * @param {object} item - Flot item + * @return {string} real tooltip content for current item + */ + FlotTooltip.prototype.stringFormat = function (content, item) { + var percentPattern = /%p\.{0,1}(\d{0,})/; + var seriesPattern = /%s/; + var colorPattern = /%c/; + var xLabelPattern = /%lx/; // requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels, will be ignored if plugin isn't loaded + var yLabelPattern = /%ly/; // requires flot-axislabels plugin https://github.com/markrcote/flot-axislabels, will be ignored if plugin isn't loaded + var xPattern = /%x\.{0,1}(\d{0,})/; + var yPattern = /%y\.{0,1}(\d{0,})/; + var xPatternWithoutPrecision = "%x"; + var yPatternWithoutPrecision = "%y"; + var customTextPattern = "%ct"; + var nPiePattern = "%n"; + + var x, y, customText, p, n; + + // for threshold plugin we need to read data from different place + if (typeof item.series.threshold !== "undefined") { + x = item.datapoint[0]; + y = item.datapoint[1]; + customText = item.datapoint[2]; + } + + // for CurvedLines plugin we need to read data from different place + else if (typeof item.series.curvedLines !== "undefined") { + x = item.datapoint[0]; + y = item.datapoint[1]; + } + + else if (typeof item.series.lines !== "undefined" && item.series.lines.steps) { + x = item.series.datapoints.points[item.dataIndex * 2]; + y = item.series.datapoints.points[item.dataIndex * 2 + 1]; + // TODO: where to find custom text in this variant? + customText = ""; + } else { + x = item.series.data[item.dataIndex][0]; + y = item.series.data[item.dataIndex][1]; + customText = item.series.data[item.dataIndex][2]; + } + + // I think this is only in case of threshold plugin + if (item.series.label === null && item.series.originSeries) { + item.series.label = item.series.originSeries.label; + } + + // if it is a function callback get the content string + if (typeof(content) === 'function') { + content = content(item.series.label, x, y, item); + } + + // the case where the passed content is equal to false + if (typeof(content) === 'boolean' && !content) { + return ''; + } + + /* replacement of %ct and other multi-character templates must + precede the replacement of single-character templates + to avoid conflict between '%c' and '%ct' and similar substrings + */ + if (customText) { + content = content.replace(customTextPattern, customText); + } + + // percent match for pie charts and stacked percent + if (typeof (item.series.percent) !== 'undefined') { + p = item.series.percent; + } else if (typeof (item.series.percents) !== 'undefined') { + p = item.series.percents[item.dataIndex]; + } + if (typeof p === 'number') { + content = this.adjustValPrecision(percentPattern, content, p); + } + + // replace %n with number of items represented by slice in pie charts + if (item.series.hasOwnProperty('pie')) { + if (typeof item.series.data[0][1] !== 'undefined') { + n = item.series.data[0][1]; + } + } + if (typeof n === 'number') { + content = content.replace(nPiePattern, n); + } + + // series match + if (typeof(item.series.label) !== 'undefined') { + content = content.replace(seriesPattern, item.series.label); + } else { + //remove %s if label is undefined + content = content.replace(seriesPattern, ""); + } + + // color match + if (typeof(item.series.color) !== 'undefined') { + content = content.replace(colorPattern, item.series.color); + } else { + //remove %s if color is undefined + content = content.replace(colorPattern, ""); + } + + // x axis label match + if (this.hasAxisLabel('xaxis', item)) { + content = content.replace(xLabelPattern, item.series.xaxis.options.axisLabel); + } else { + //remove %lx if axis label is undefined or axislabels plugin not present + content = content.replace(xLabelPattern, ""); + } + + // y axis label match + if (this.hasAxisLabel('yaxis', item)) { + content = content.replace(yLabelPattern, item.series.yaxis.options.axisLabel); + } else { + //remove %ly if axis label is undefined or axislabels plugin not present + content = content.replace(yLabelPattern, ""); + } + + // time mode axes with custom dateFormat + if (this.isTimeMode('xaxis', item) && this.isXDateFormat(item)) { + content = content.replace(xPattern, this.timestampToDate(x, this.tooltipOptions.xDateFormat, item.series.xaxis.options)); + } + if (this.isTimeMode('yaxis', item) && this.isYDateFormat(item)) { + content = content.replace(yPattern, this.timestampToDate(y, this.tooltipOptions.yDateFormat, item.series.yaxis.options)); + } + + // set precision if defined + if (typeof x === 'number') { + content = this.adjustValPrecision(xPattern, content, x); + } + if (typeof y === 'number') { + content = this.adjustValPrecision(yPattern, content, y); + } + + // change x from number to given label, if given + if (typeof item.series.xaxis.ticks !== 'undefined') { + + var ticks; + if (this.hasRotatedXAxisTicks(item)) { + // xaxis.ticks will be an empty array if tickRotor is being used, but the values are available in rotatedTicks + ticks = 'rotatedTicks'; + } else { + ticks = 'ticks'; + } + + // see https://github.com/krzysu/flot.tooltip/issues/65 + var tickIndex = item.dataIndex + item.seriesIndex; + + for (var xIndex in item.series.xaxis[ticks]) { + if (item.series.xaxis[ticks].hasOwnProperty(tickIndex) && !this.isTimeMode('xaxis', item)) { + var valueX = (this.isCategoriesMode('xaxis', item)) ? item.series.xaxis[ticks][tickIndex].label : item.series.xaxis[ticks][tickIndex].v; + if (valueX === x) { + content = content.replace(xPattern, item.series.xaxis[ticks][tickIndex].label.replace(/\$/g, '$$$$')); + } + } + } + } + + // change y from number to given label, if given + if (typeof item.series.yaxis.ticks !== 'undefined') { + for (var yIndex in item.series.yaxis.ticks) { + if (item.series.yaxis.ticks.hasOwnProperty(yIndex)) { + var valueY = (this.isCategoriesMode('yaxis', item)) ? item.series.yaxis.ticks[yIndex].label : item.series.yaxis.ticks[yIndex].v; + if (valueY === y) { + content = content.replace(yPattern, item.series.yaxis.ticks[yIndex].label.replace(/\$/g, '$$$$')); + } + } + } + } + + // if no value customization, use tickFormatter by default + if (typeof item.series.xaxis.tickFormatter !== 'undefined') { + //escape dollar + content = content.replace(xPatternWithoutPrecision, item.series.xaxis.tickFormatter(x, item.series.xaxis).replace(/\$/g, '$$')); + } + if (typeof item.series.yaxis.tickFormatter !== 'undefined') { + //escape dollar + content = content.replace(yPatternWithoutPrecision, item.series.yaxis.tickFormatter(y, item.series.yaxis).replace(/\$/g, '$$')); + } + + return content; + }; + + // helpers just for readability + FlotTooltip.prototype.isTimeMode = function (axisName, item) { + return (typeof item.series[axisName].options.mode !== 'undefined' && item.series[axisName].options.mode === 'time'); + }; + + FlotTooltip.prototype.isXDateFormat = function (item) { + return (typeof this.tooltipOptions.xDateFormat !== 'undefined' && this.tooltipOptions.xDateFormat !== null); + }; + + FlotTooltip.prototype.isYDateFormat = function (item) { + return (typeof this.tooltipOptions.yDateFormat !== 'undefined' && this.tooltipOptions.yDateFormat !== null); + }; + + FlotTooltip.prototype.isCategoriesMode = function (axisName, item) { + return (typeof item.series[axisName].options.mode !== 'undefined' && item.series[axisName].options.mode === 'categories'); + }; + + // + FlotTooltip.prototype.timestampToDate = function (tmst, dateFormat, options) { + var theDate = $.plot.dateGenerator(tmst, options); + return $.plot.formatDate(theDate, dateFormat, this.tooltipOptions.monthNames, this.tooltipOptions.dayNames); + }; + + // + FlotTooltip.prototype.adjustValPrecision = function (pattern, content, value) { + + var precision; + var matchResult = content.match(pattern); + if( matchResult !== null ) { + if(RegExp.$1 !== '') { + precision = RegExp.$1; + value = value.toFixed(precision); + + // only replace content if precision exists, in other case use thickformater + content = content.replace(pattern, value); + } + } + return content; + }; + + // other plugins detection below + + // check if flot-axislabels plugin (https://github.com/markrcote/flot-axislabels) is used and that an axis label is given + FlotTooltip.prototype.hasAxisLabel = function (axisName, item) { + return ($.inArray('axisLabels', this.plotPlugins) !== -1 && typeof item.series[axisName].options.axisLabel !== 'undefined' && item.series[axisName].options.axisLabel.length > 0); + }; + + // check whether flot-tickRotor, a plugin which allows rotation of X-axis ticks, is being used + FlotTooltip.prototype.hasRotatedXAxisTicks = function (item) { + return ($.inArray('tickRotor',this.plotPlugins) !== -1 && typeof item.series.xaxis.rotatedTicks !== 'undefined'); + }; + + // + var init = function (plot) { + new FlotTooltip(plot); + }; + + // define Flot plugin + $.plot.plugins.push({ + init: init, + options: defaultOptions, + name: 'tooltip', + version: '0.8.5' + }); + +})(jQuery); diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index 38f7fb8..49a67b6 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -13,6 +13,10 @@ + + + + @@ -475,7 +479,7 @@ config.db = ; - + From 8ec30b8d397a1c1daca8e67987f4d761c6c1c43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Tue, 8 Apr 2025 16:22:10 +0200 Subject: [PATCH 04/30] Display power in kW averaged over a day instead of energy in kWh --- apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index 927f2b0..5bfb086 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -72,7 +72,7 @@ function plotHeatLossScatter() { // Iterate through the heat data (which is specific to the mode) for (var i = 0; i < heatDataArray.length; i++) { var timestamp = heatDataArray[i][0]; - var heatValue = heatDataArray[i][1]; + var heatValue = heatDataArray[i][1] / 24.0; // convert from kWh to kW // Get corresponding temperatures using the timestamp var insideTValue = insideTMap.get(timestamp); @@ -118,7 +118,7 @@ function plotHeatLossScatter() { var plotOptions = { xaxis: { - axisLabel: "Temperature Difference (T_inside - T_outside) [°C]", + axisLabel: "Temperature Difference (T_inside - T_outside) [K]", axisLabelUseCanvas: true, axisLabelFontSizePixels: 12, axisLabelFontFamily: 'Verdana, Arial, Helvetica, Tahoma, sans-serif', @@ -127,7 +127,7 @@ function plotHeatLossScatter() { // Let flot determine min/max automatically for scatter }, yaxis: { - axisLabel: "Daily Heat Output [kWh]", + axisLabel: "Heat Output [kW]", axisLabelUseCanvas: true, axisLabelFontSizePixels: 12, axisLabelFontFamily: 'Verdana, Arial, Helvetica, Tahoma, sans-serif', From cc6116ca880de5e970c383e75ea1fc11a13b5950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Tue, 8 Apr 2025 20:30:07 +0200 Subject: [PATCH 05/30] Prepare for regression analysis --- .../myheatpump/myheatpump.js | 81 +++++++++++----- .../myheatpump/myheatpump.php | 21 ++++- .../myheatpump/myheatpump_heatloss.js | 16 +++- apps/OpenEnergyMonitor/myheatpump/style.css | 94 +++++++++++++++++++ 4 files changed, 185 insertions(+), 27 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js index 10d00c2..1fdb153 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js @@ -584,36 +584,73 @@ $("#clear-daily-data").click(function () { // --- Heat Loss Panel Toggle --- $("#heatloss-toggle").click(function () { var $contentBlock = $("#heatloss-block"); - var $toggleText = $("#heatloss-toggle-text"); // Get the text span - var $arrow = $("#heatloss-arrow"); // Get the arrow span + var $toggleText = $("#heatloss-toggle-text"); + var $arrow = $("#heatloss-arrow"); if ($contentBlock.is(":visible")) { - $contentBlock.slideUp(); // Animate hiding - $(this).css("background-color", ""); // Reset background if needed - - // Update the text content of the text span + // Hiding Logic (Stays the same) + $contentBlock.slideUp(); // Start hiding animation + $(this).css("background-color", ""); $toggleText.text("SHOW HEAT LOSS ANALYSIS"); - // Update the HTML content (the arrow character) of the arrow span - $arrow.html("►"); // Right Arrow ► + $arrow.html("►"); } else { - $contentBlock.slideDown(); // Animate showing - $(this).css("background-color", "#4a6d8c"); // Darker background when open (optional) - - // Update the text content of the text span + // Showing Logic (Modified) + // These updates can happen immediately + $(this).css("background-color", "#4a6d8c"); $toggleText.text("HIDE HEAT LOSS ANALYSIS"); - // Update the HTML content (the arrow character) of the arrow span - $arrow.html("▼"); // Down Arrow ▼ - resize(); + $arrow.html("▼"); + + // Start the slideDown animation AND provide a callback function + $contentBlock.slideDown(function() { + // --- This code runs AFTER slideDown completes --- + console.log("Heat Loss Panel: slideDown complete."); + // Now it's safe to resize and plot + resize(); // Ensure container dimensions are recalculated based on final state + plotHeatLossScatter(); // Plot into the correctly sized, visible container + + // Debugging log at the correct time + if (typeof daily_data !== 'undefined' && daily_data.combined_elec_kwh) { + console.log("Heat Loss Panel (Post-Slide): Accessing daily_data..."); + } else { + console.log("Heat Loss Panel (Post-Slide): daily_data not yet available..."); + } + // --- End of callback --- + }); + } +}); +// --- End Heat Loss Panel Toggle --- + +// 1. Minimum Delta T Input Change +$("#heatloss_min_deltaT").on('input change', function() { + // Only replot if the panel is actually visible + if ($("#heatloss-block").is(":visible")) { + // Basic validation if needed (e.g., ensure it's a number) + // let value = parseFloat($(this).val()); + // if (!isNaN(value)) { ... } + plotHeatLossScatter(); // Call the plotting function (defined in heatloss.js) + } +}); + +// 2. Fixed Room Temperature Checkbox Change +$("#heatloss_fixed_roomT_check").on('change', function() { + var isChecked = $(this).is(":checked"); + // Enable/disable the associated value input based on checkbox state + $("#heatloss_fixed_roomT_value").prop('disabled', !isChecked); + + // Replot if the panel is visible + if ($("#heatloss-block").is(":visible")) { plotHeatLossScatter(); + } +}); - // Accessing daily_data example: - if (typeof daily_data !== 'undefined' && daily_data.combined_elec_kwh) { - console.log("Heat Loss Panel: Accessing daily_data.combined_elec_kwh, number of days:", daily_data.combined_elec_kwh.length); - } else { - console.log("Heat Loss Panel: daily_data not yet available or empty."); - } +// 3. Fixed Room Temperature Value Input Change +$("#heatloss_fixed_roomT_value").on('input change', function() { + // Only replot if the panel is visible AND the checkbox is checked + if ($("#heatloss-block").is(":visible") && $("#heatloss_fixed_roomT_check").is(":checked")) { + plotHeatLossScatter(); } }); -// --- End Heat Loss Panel Toggle --- \ No newline at end of file + +// --- End Heat Loss Control Event Listeners --- \ No newline at end of file diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index 49a67b6..0f0daab 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -429,8 +429,23 @@
-
- Controls Placeholder +
+ + +
+ + + °C +
+ + +
+ Fixed room temperature: + + + + °C +
@@ -479,7 +494,7 @@ config.db = ; - + diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index 5bfb086..c978831 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -1,5 +1,3 @@ -// /var/www/emoncms/Modules/app/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js - /** * Plots a scatter graph of Daily Heat Output vs Daily (Tin - Tout) difference. * Uses data prepared for the main bar graph, filtered by the current bargraph_mode. @@ -11,6 +9,20 @@ function plotHeatLossScatter() { var plotBound = $("#heatloss-plot-bound"); // To potentially resize if needed // --- 1. Data Access and Preparation --- + // get min deltaT from input field + var minDeltaT = parseFloat($("#heatloss_min_deltaT").val()); + if (isNaN(minDeltaT)) { // Handle cases where input might be empty or invalid + minDeltaT = -Infinity; // Effectively no minimum if invalid + console.warn("Heat Loss Plot: Invalid Minimum ΔT input, using no minimum."); + } + + // get option to use fixed room temperature + var useFixedRoomT = $("#heatloss_fixed_roomT_check").is(":checked"); + var fixedRoomTValue = parseFloat($("#heatloss_fixed_roomT_value").val()); + if (isNaN(fixedRoomTValue)) { + fixedRoomTValue = 20; // Default if invalid + console.warn("Heat Loss Plot: Invalid Fixed Room Temp input, using default 20°C."); + } // Check if essential daily data is available if (typeof daily_data === 'undefined' || $.isEmptyObject(daily_data)) { diff --git a/apps/OpenEnergyMonitor/myheatpump/style.css b/apps/OpenEnergyMonitor/myheatpump/style.css index 04772a2..1a6737d 100644 --- a/apps/OpenEnergyMonitor/myheatpump/style.css +++ b/apps/OpenEnergyMonitor/myheatpump/style.css @@ -145,4 +145,98 @@ justify-content: center; color: #aaa; font-style: italic; +} + +/* Styles for the new Heat Loss Panel Controls */ +#heatloss-controls { + font-size: 13px; + color: #333; + text-align: left; +} + +/* --- Style for the input-group like layout --- */ +.heatloss-control-group.input-like-group { + display: flex; /* Enable Flexbox */ + align-items: stretch; /* Make items fill height */ + width: fit-content; /* Adjust group width to content */ + border: 1px solid #ccc;/* Mimic input-group border */ + border-radius: 4px; /* Rounded corners */ + margin-bottom: 10px; /* Spacing like mb-3 */ + overflow: hidden; /* Keep rounded corners nice */ +} + +/* Style for text/unit addons */ +.heatloss-addon-label, +.heatloss-addon-unit { + display: flex; /* Use flex for vertical centering inside addon */ + align-items: center; /* Center text vertically */ + padding: 6px 12px; /* Bootstrap-like padding */ + background-color: #e9ecef; /* Light grey background */ + border-right: 1px solid #ccc; /* Separator */ + white-space: nowrap; /* Prevent label wrapping */ +} +.heatloss-addon-unit { + border-right: none; /* No border on the far right */ + border-left: 1px solid #ccc; /* Add border before unit */ +} + +/* Style for the checkbox within the group */ +.input-like-group .heatloss-control-checkbox { + display: flex; /* Use flex for centering */ + align-items: center; /* Center checkbox vertically */ + padding: 0 10px; /* Add some horizontal padding */ + border-right: 1px solid #ccc; /* Separator */ + /* Remove default margins that might mess alignment */ + margin: 0; +} + +/* Style for the number input within the group */ +.input-like-group .heatloss-control-input { + /* Remove default borders/padding, handled by group */ + border: none; + padding: 6px 8px; + width: 60px; /* Keep fixed width */ + /* Make it align nicely if flex items stretch */ + line-height: inherit; /* Inherit line height */ + font-size: inherit; /* Inherit font size */ + flex-grow: 0; /* Don't allow input to grow */ + flex-shrink: 0; /* Don't allow input to shrink */ + border-radius: 0; /* No radius inside the group */ +} + +.input-like-group .heatloss-control-input:focus { + outline: none; /* Remove focus outline inside group */ + box-shadow: none; +} + + +/* --- Style for the simpler layout (Min Delta T) --- */ +/* Revert previous label width settings if not needed */ +.heatloss-control-group.simple { + display: flex; + align-items: center; + margin-bottom: 5px; +} +.heatloss-control-group.simple label { + margin-right: 5px; + width: auto; /* Let label take natural width */ + text-align: left; +} +.heatloss-control-group.simple .heatloss-control-input { + width: 60px; /* Keep specific width for this input */ + padding: 4px 6px; + border: 1px solid #ccc; + border-radius: 3px; + margin: 0 5px 0 0; /* Reset margin */ +} +.heatloss-control-group.simple .heatloss-unit { + margin-left: 3px; + color: #666; +} + + +/* Style disabled input (applies to both layouts) */ +.heatloss-control-input:disabled { + background-color: #eee; + cursor: not-allowed; } \ No newline at end of file From 6309038e165cff7950f4d339a0963857d560b527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Tue, 8 Apr 2025 20:31:52 +0200 Subject: [PATCH 06/30] Add regression function in separate file --- .../myheatpump/myheatpump_regression.js | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js new file mode 100644 index 0000000..e5c5c2b --- /dev/null +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js @@ -0,0 +1,52 @@ +/** + * Performs linear regression on paired data. + * y = mx + b + * @param {number[]} x - Array of x values (independent variable, e.g., time in seconds). + * @param {number[]} y - Array of y values (dependent variable, e.g., ln(deltaT/deltaT0)). + * @returns {object|null} Object with 'slope' (m) and 'intercept' (b), or null if regression is not possible. + */ +function linearRegression(x, y) { + const n = x.length; + if (n < 2 || n !== y.length) { + console.error("Linear regression requires at least 2 points and equal length arrays."); + return null; // Not enough data or mismatched arrays + } + + let sum_x = 0; + let sum_y = 0; + let sum_xy = 0; + let sum_xx = 0; + let sum_yy = 0; // Needed for R-squared, not strictly required for slope/intercept + + for (let i = 0; i < n; i++) { + sum_x += x[i]; + sum_y += y[i]; + sum_xy += x[i] * y[i]; + sum_xx += x[i] * x[i]; + sum_yy += y[i] * y[i]; + } + + const denominator = (n * sum_xx - sum_x * sum_x); + if (Math.abs(denominator) < 1e-10) { // Avoid division by zero if all x are the same + console.error("Linear regression failed: Denominator is zero (all x values are likely the same)."); + return null; + } + + const slope = (n * sum_xy - sum_x * sum_y) / denominator; + const intercept = (sum_y - slope * sum_x) / n; + + // Optional: Calculate R-squared (coefficient of determination) + let ssr = 0; + for (let i = 0; i < n; i++) { + const fit = slope * x[i] + intercept; + ssr += (fit - sum_y / n) ** 2; + } + const sst = sum_yy - (sum_y * sum_y) / n; + const r2 = (sst === 0) ? 1 : ssr / sst; // Handle case where all y are the same + + return { + slope: slope, + intercept: intercept, + r2: r2 // Uncomment if you want R-squared + }; +} \ No newline at end of file From 8ab43a5e719da028bc12f170bce2f8d89096cd9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Tue, 8 Apr 2025 20:33:03 +0200 Subject: [PATCH 07/30] Add regression function to imports --- apps/OpenEnergyMonitor/myheatpump/myheatpump.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index 0f0daab..804903c 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -494,7 +494,8 @@ config.db = ; - + + From 780284faeca643b7572b87ffa3aee394cc2b9f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Tue, 8 Apr 2025 21:57:25 +0200 Subject: [PATCH 08/30] Perform regression analysis and plot line --- .../myheatpump/myheatpump_heatloss.js | 202 +++++++++++++----- 1 file changed, 150 insertions(+), 52 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index c978831..45a449b 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -36,27 +36,27 @@ function plotHeatLossScatter() { var insideTKey = "combined_roomT_mean"; // Daily average room temp seems only stored as combined var outsideTKey = "combined_outsideT_mean"; // Daily average outside temp seems only stored as combined - // Check if the necessary data *arrays* exist within daily_data - // Also check if the corresponding feeds were configured in the first place (uses global 'feeds') + // Check feed configuration status + var roomTempFeedConfigured = !!feeds["heatpump_roomT"]; + var outsideTempFeedConfigured = !!feeds["heatpump_outsideT"]; + + // Determine if we *should* use the fixed room temperature value + // This is true if the user checked the box OR if the room temperature feed isn't configured anyway + var shouldUseFixedRoomT = useFixedRoomT || !roomTempFeedConfigured; + + // --- Check Data Sufficiency --- var isDataSufficient = true; var messages = []; + // 1. Check Heat Data (Mandatory) if (!daily_data[heatKey] || daily_data[heatKey].length === 0) { isDataSufficient = false; messages.push(`Heat data ('${heatKey}') not found for the selected mode.`); console.log("Heat Loss Plot: Missing or empty data for key:", heatKey); } - if (!feeds["heatpump_roomT"]) { - isDataSufficient = false; - messages.push("Room Temperature feed not configured in the app setup."); - console.log("Heat Loss Plot: Room Temperature feed not configured."); - } else if (!daily_data[insideTKey] || daily_data[insideTKey].length === 0) { - isDataSufficient = false; - // It's possible the feed exists but daily processing hasn't run or included it - messages.push(`Inside temperature data ('${insideTKey}') not found or empty.`); - console.log("Heat Loss Plot: Missing or empty data for key:", insideTKey); - } - if (!feeds["heatpump_outsideT"]) { + + // 2. Check Outside Temp Data (Mandatory) + if (!outsideTempFeedConfigured) { isDataSufficient = false; messages.push("Outside Temperature feed not configured in the app setup."); console.log("Heat Loss Plot: Outside Temperature feed not configured."); @@ -66,53 +66,143 @@ function plotHeatLossScatter() { console.log("Heat Loss Plot: Missing or empty data for key:", outsideTKey); } + // 3. Check Inside Temp Data (Only required if we are NOT using the fixed value) + if (!shouldUseFixedRoomT) { + // We need the dynamic data from the feed + if (!roomTempFeedConfigured) { + // This case is technically covered by shouldUseFixedRoomT, but belt-and-braces check + isDataSufficient = false; + messages.push("Room Temperature feed not configured (and fixed value not selected)."); + console.log("Heat Loss Plot: Room Temperature feed not configured (and fixed value not selected)."); + } else if (!daily_data[insideTKey] || daily_data[insideTKey].length === 0) { + isDataSufficient = false; + messages.push(`Inside temperature data ('${insideTKey}') not found or empty (and fixed value not selected).`); + console.log("Heat Loss Plot: Missing or empty inside temp data (and fixed value not selected):", insideTKey); + } + } + // If shouldUseFixedRoomT is true, we don't need to check for insideTKey data presence here. + if (!isDataSufficient) { var messageHtml = "

Cannot plot heat loss:
" + messages.join("
") + "

"; plotDiv.html(messageHtml); return; } - // Create lookup maps for temperatures for easier matching by timestamp - var insideTMap = new Map(daily_data[insideTKey]); + // --- Prepare Data for Plotting --- + console.log("Heat Loss Plot: Preparing data..."); + if (shouldUseFixedRoomT) { + console.log("Heat Loss Plot: Using fixed room temperature:", fixedRoomTValue, "°C"); + if (!roomTempFeedConfigured && !useFixedRoomT) { + console.log("Heat Loss Plot: Note - Fixed room temperature is being used because the Room Temperature feed is not configured."); + } + } else { + console.log("Heat Loss Plot: Using dynamic room temperature feed data ('" + insideTKey + "')."); + } + + + // Create lookup maps for temperatures var outsideTMap = new Map(daily_data[outsideTKey]); + // Only create insideTMap if we are using dynamic data and it exists + var insideTMap = (!shouldUseFixedRoomT && daily_data[insideTKey]) ? new Map(daily_data[insideTKey]) : null; var scatterData = []; var heatDataArray = daily_data[heatKey]; console.log("Heat Loss Plot: Processing", heatDataArray.length, "days of data for mode", bargraph_mode); + // arrays for regression + var xValues = []; // Will hold deltaT values + var yValues = []; // Will hold heatValue values + var minX = Infinity; + var maxX = -Infinity; + // Iterate through the heat data (which is specific to the mode) for (var i = 0; i < heatDataArray.length; i++) { var timestamp = heatDataArray[i][0]; var heatValue = heatDataArray[i][1] / 24.0; // convert from kWh to kW // Get corresponding temperatures using the timestamp - var insideTValue = insideTMap.get(timestamp); var outsideTValue = outsideTMap.get(timestamp); + var insideTValue; + + // *** Conditionally assign insideTValue *** + if (shouldUseFixedRoomT) { + insideTValue = fixedRoomTValue; // Use the fixed value + } else if (insideTMap) { + // Use the dynamic value from the map (we know insideTMap exists if !shouldUseFixedRoomT) + insideTValue = insideTMap.get(timestamp); + } else { + // This case should ideally not be reached due to sufficiency checks, but set to null as a fallback. + insideTValue = null; + console.warn("Heat Loss Plot: Unexpected condition - insideTMap is null when dynamic temp was expected for timestamp", timestamp); + } + // Ensure all data points for this day are valid numbers - if (heatValue !== null && typeof heatValue === 'number' && - insideTValue !== null && typeof insideTValue === 'number' && - outsideTValue !== null && typeof outsideTValue === 'number') + if (heatValue !== null && typeof heatValue === 'number' && !isNaN(heatValue) && + insideTValue !== null && typeof insideTValue === 'number' && !isNaN(insideTValue) && // Handles both fixed and dynamic cases + outsideTValue !== null && typeof outsideTValue === 'number' && !isNaN(outsideTValue)) { // Calculate delta T var deltaT = insideTValue - outsideTValue; // Add the point [deltaT, heatValue] to our scatter data array // Only include days with positive heat output and reasonable deltaT - if (heatValue > 0 && deltaT > -10 && deltaT < 40) { // Basic sanity check + if (heatValue > 0 && deltaT > minDeltaT) { // only plot data above min deltaT scatterData.push([deltaT, heatValue]); + xValues.push(deltaT); + yValues.push(heatValue); + if (deltaT < minX) minX = deltaT; + if (deltaT > maxX) maxX = deltaT; } + } else { + // Optional: Log skipped days if needed for debugging + // console.log("Heat Loss Plot: Skipping day", new Date(timestamp*1000).toLocaleDateString(), "due to null/invalid values (H/Ti/To):", heatValue, insideTValue, outsideTValue); } } console.log("Heat Loss Plot: Prepared", scatterData.length, "valid scatter points."); if (scatterData.length === 0) { - plotDiv.html("

No valid data points found for this mode to plot heat loss.

"); + let noDataReason = "No valid data points found for this mode"; + if (shouldUseFixedRoomT) { + noDataReason += " using fixed room temperature " + fixedRoomTValue + "°C"; + } + if (minDeltaT > -Infinity) { + noDataReason += " with Min ΔT > " + minDeltaT + "°C"; + } + noDataReason += "."; + plotDiv.html("

" + noDataReason + "

"); return; } + // --- Calculate Linear Regression --- + var regressionResult = linearRegression(xValues, yValues); + var regressionLineData = null; + var regressionLabel = ""; + + if (regressionResult) { + console.log("Heat Loss Plot: Regression successful", regressionResult); + const slope = regressionResult.slope; + const intercept = regressionResult.intercept; + const r2 = regressionResult.r2; + + const xIntercept = -intercept / slope; // x-intercept (where line crosses x-axis) + // Calculate y-values for the line at min and max observed x values + const y1 = slope * xIntercept + intercept; + const y2 = slope * maxX + intercept; + + regressionLineData = [[xIntercept, y1], [maxX, y2]]; + + // Create a label for the legend + regressionLabel = `Fit: HLC=${slope.toFixed(3)*1000} W/K` + + `, Int=${intercept.toFixed(3)*1000} W` + + ` (R²=${r2.toFixed(3)})`; // Heat Loss Coefficient (slope) in W/K + + } else { + console.warn("Heat Loss Plot: Linear regression could not be calculated."); + } + // --- 2. Plotting --- var plotSeries = [{ @@ -125,12 +215,29 @@ function plotHeatLossScatter() { }, lines: { show: false }, // Ensure lines are off for scatter color: 'rgb(255, 99, 71)', // Tomato color - label: 'Daily Heat Loss (' + bargraph_mode + ')' // Label indicates mode + label: 'Daily Heat Loss (' + bargraph_mode + (shouldUseFixedRoomT ? ', Fixed T_in=' + fixedRoomTValue + '°C' : '') + ')' // Add note to label if fixed T is used }]; + if (regressionLineData) { + plotSeries.push({ + data: regressionLineData, + lines: { + show: true, + lineWidth: 2 + }, + points: { show: false }, // Don't show points for the line itself + color: 'rgba(0, 0, 255, 0.8)', // Blue line + label: regressionLabel, + // Optional: make it dashed + // dashes: { show: true, lineWidth: 1, dashLength: [4, 4] }, + shadowSize: 0 // No shadow for the fit line + }); + } + var plotOptions = { xaxis: { - axisLabel: "Temperature Difference (T_inside - T_outside) [K]", + min: 0, + axisLabel: "Temperature Difference (T_inside - T_outside) [K or °C]", // Difference is the same unit axisLabelUseCanvas: true, axisLabelFontSizePixels: 12, axisLabelFontFamily: 'Verdana, Arial, Helvetica, Tahoma, sans-serif', @@ -139,7 +246,7 @@ function plotHeatLossScatter() { // Let flot determine min/max automatically for scatter }, yaxis: { - axisLabel: "Heat Output [kW]", + axisLabel: "Average Heat Output [kW]", // Corrected unit label (was kWh previously in tooltip comment) axisLabelUseCanvas: true, axisLabelFontSizePixels: 12, axisLabelFontFamily: 'Verdana, Arial, Helvetica, Tahoma, sans-serif', @@ -151,31 +258,38 @@ function plotHeatLossScatter() { show: true, color: "#aaa", hoverable: true, - clickable: false, // Disable click-through for scatter for now + clickable: false, borderWidth: { top: 0, right: 0, bottom: 1, left: 1 }, borderColor: "#ccc", }, - tooltip: { // Enable basic flot tooltips + tooltip: { show: true, - content: "ΔT: %x.1 °C
Heat: %y.1 kWh", - shifts: { - x: 10, - y: 20 + content: function(label, xval, yval, flotItem) { + // Custom tooltip content to handle multiple series + if (flotItem.series.points.show) { // Scatter points + return `Point:
ΔT: ${xval.toFixed(1)} °C
Avg Heat: ${yval.toFixed(2)} kW`; + } else if (flotItem.series.lines.show) { // Regression line + // Tooltip for the line itself is less useful, maybe show equation? + // Or just disable tooltip for the line by returning false? + return `${flotItem.series.label}
ΔT: ${xval.toFixed(1)} °C
Predicted Heat: ${yval.toFixed(2)} kW`; + // return false; // To disable tooltip for the line + } + return ''; // Default fallback }, - defaultTheme: false, // Use Emoncms styling if available, else default flot - lines: false // Tooltip for points only + // content: "ΔT: %x.1 °C
Avg Heat: %y.2 kW", // Original simple tooltip + shifts: { x: 10, y: 20 }, + defaultTheme: false, + lines: false // Tooltip based on nearest item, not line interpolation }, legend: { show: true, position: "nw" // North-West corner } - // Selection not typically needed for this type of scatter - // selection: { mode: "xy" }, }; // Ensure the plot container is visible and sized before plotting var plotWidth = plotBound.width(); - var plotHeight = plotBound.height(); // Or set a fixed height like 400px initially + var plotHeight = plotBound.height(); if (plotHeight < 200) plotHeight = 400; // Ensure minimum height plotDiv.width(plotWidth); @@ -190,20 +304,4 @@ function plotHeatLossScatter() { console.error("Heat Loss Plot: Error during flot plotting:", e); plotDiv.html("

Error generating plot.

"); } -} - -// Optional: If you want custom tooltips for this plot specifically, -// you could add the hover binding here. -/* -$('#heatloss-plot').bind("plothover", function (event, pos, item) { - $("#tooltip").remove(); // Remove any existing tooltip - if (item) { - var x = item.datapoint[0].toFixed(1); - var y = item.datapoint[1].toFixed(1); - var content = "ΔT: " + x + " °C
Heat: " + y + " kWh"; - - // Assuming 'tooltip' function is globally available from vis.helper.js or similar - tooltip(item.pageX, item.pageY, content); - } -}); -*/ \ No newline at end of file +} \ No newline at end of file From 061fff39eae43f14ad903604e21630b3747fc310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Wed, 9 Apr 2025 00:41:56 +0200 Subject: [PATCH 09/30] fixed xmax --- apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index 45a449b..1ab1df8 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -190,6 +190,7 @@ function plotHeatLossScatter() { const xIntercept = -intercept / slope; // x-intercept (where line crosses x-axis) // Calculate y-values for the line at min and max observed x values const y1 = slope * xIntercept + intercept; + maxX=35 const y2 = slope * maxX + intercept; regressionLineData = [[xIntercept, y1], [maxX, y2]]; From 6582433f52a7eb47bd3904d872ffcdf2998dd0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Wed, 9 Apr 2025 11:13:41 +0200 Subject: [PATCH 10/30] Design now follows emoncms style --- .../myheatpump/myheatpump.php | 37 +++--- apps/OpenEnergyMonitor/myheatpump/style.css | 108 ------------------ 2 files changed, 19 insertions(+), 126 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index 6daf162..b6bb0c8 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -445,8 +445,6 @@ diff --git a/apps/OpenEnergyMonitor/myheatpump/style.css b/apps/OpenEnergyMonitor/myheatpump/style.css index 1a6737d..8118d55 100644 --- a/apps/OpenEnergyMonitor/myheatpump/style.css +++ b/apps/OpenEnergyMonitor/myheatpump/style.css @@ -131,112 +131,4 @@ border: 1px solid #ddd; border-top: none; background-color: #f9f9f9; -} - -#heatloss-plot-bound, -#heatloss-controls { - background-color: #fff; -} - -#heatloss-plot, -#heatloss-controls { - display: flex; - align-items: center; - justify-content: center; - color: #aaa; - font-style: italic; -} - -/* Styles for the new Heat Loss Panel Controls */ -#heatloss-controls { - font-size: 13px; - color: #333; - text-align: left; -} - -/* --- Style for the input-group like layout --- */ -.heatloss-control-group.input-like-group { - display: flex; /* Enable Flexbox */ - align-items: stretch; /* Make items fill height */ - width: fit-content; /* Adjust group width to content */ - border: 1px solid #ccc;/* Mimic input-group border */ - border-radius: 4px; /* Rounded corners */ - margin-bottom: 10px; /* Spacing like mb-3 */ - overflow: hidden; /* Keep rounded corners nice */ -} - -/* Style for text/unit addons */ -.heatloss-addon-label, -.heatloss-addon-unit { - display: flex; /* Use flex for vertical centering inside addon */ - align-items: center; /* Center text vertically */ - padding: 6px 12px; /* Bootstrap-like padding */ - background-color: #e9ecef; /* Light grey background */ - border-right: 1px solid #ccc; /* Separator */ - white-space: nowrap; /* Prevent label wrapping */ -} -.heatloss-addon-unit { - border-right: none; /* No border on the far right */ - border-left: 1px solid #ccc; /* Add border before unit */ -} - -/* Style for the checkbox within the group */ -.input-like-group .heatloss-control-checkbox { - display: flex; /* Use flex for centering */ - align-items: center; /* Center checkbox vertically */ - padding: 0 10px; /* Add some horizontal padding */ - border-right: 1px solid #ccc; /* Separator */ - /* Remove default margins that might mess alignment */ - margin: 0; -} - -/* Style for the number input within the group */ -.input-like-group .heatloss-control-input { - /* Remove default borders/padding, handled by group */ - border: none; - padding: 6px 8px; - width: 60px; /* Keep fixed width */ - /* Make it align nicely if flex items stretch */ - line-height: inherit; /* Inherit line height */ - font-size: inherit; /* Inherit font size */ - flex-grow: 0; /* Don't allow input to grow */ - flex-shrink: 0; /* Don't allow input to shrink */ - border-radius: 0; /* No radius inside the group */ -} - -.input-like-group .heatloss-control-input:focus { - outline: none; /* Remove focus outline inside group */ - box-shadow: none; -} - - -/* --- Style for the simpler layout (Min Delta T) --- */ -/* Revert previous label width settings if not needed */ -.heatloss-control-group.simple { - display: flex; - align-items: center; - margin-bottom: 5px; -} -.heatloss-control-group.simple label { - margin-right: 5px; - width: auto; /* Let label take natural width */ - text-align: left; -} -.heatloss-control-group.simple .heatloss-control-input { - width: 60px; /* Keep specific width for this input */ - padding: 4px 6px; - border: 1px solid #ccc; - border-radius: 3px; - margin: 0 5px 0 0; /* Reset margin */ -} -.heatloss-control-group.simple .heatloss-unit { - margin-left: 3px; - color: #666; -} - - -/* Style disabled input (applies to both layouts) */ -.heatloss-control-input:disabled { - background-color: #eee; - cursor: not-allowed; } \ No newline at end of file From 659a1afc239ba8c83347e3d8ab5c652a8eda3573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Wed, 9 Apr 2025 21:06:20 +0200 Subject: [PATCH 11/30] Renamed to heat demand --- apps/OpenEnergyMonitor/myheatpump/myheatpump.js | 4 ++-- apps/OpenEnergyMonitor/myheatpump/myheatpump.php | 8 ++++---- apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js index c7b0056..f3c548e 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js @@ -602,14 +602,14 @@ $("#heatloss-toggle").click(function () { // Hiding Logic (Stays the same) $contentBlock.slideUp(); // Start hiding animation $(this).css("background-color", ""); - $toggleText.text("SHOW HEAT LOSS ANALYSIS"); + $toggleText.text("SHOW HEAT DEMAND ANALYSIS"); $arrow.html("►"); } else { // Showing Logic (Modified) // These updates can happen immediately $(this).css("background-color", "#4a6d8c"); - $toggleText.text("HIDE HEAT LOSS ANALYSIS"); + $toggleText.text("HIDE HEAT DEMAND ANALYSIS"); $arrow.html("▼"); // Start the slideDown animation AND provide a callback function diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index b6bb0c8..09c5f5b 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -438,7 +438,7 @@
- SHOW HEAT LOSS ANALYSIS + SHOW HEAT DEMAND ANALYSIS
@@ -453,7 +453,7 @@
- +
@@ -461,7 +461,7 @@
- +
Fixed room temperature @@ -519,7 +519,7 @@ config.db = ; - + diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index 1ab1df8..95b62f4 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -216,7 +216,7 @@ function plotHeatLossScatter() { }, lines: { show: false }, // Ensure lines are off for scatter color: 'rgb(255, 99, 71)', // Tomato color - label: 'Daily Heat Loss (' + bargraph_mode + (shouldUseFixedRoomT ? ', Fixed T_in=' + fixedRoomTValue + '°C' : '') + ')' // Add note to label if fixed T is used + label: 'Daily Heat Demand (' + bargraph_mode + (shouldUseFixedRoomT ? ', Fixed T_in=' + fixedRoomTValue + '°C' : '') + ')' // Add note to label if fixed T is used }]; if (regressionLineData) { From 00c4d6051b45bc1d43fabc25c93a781f60b4d00e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Wed, 9 Apr 2025 21:19:47 +0200 Subject: [PATCH 12/30] Draw regression line with more points to allow hover info with rpedicted heat loss --- .../myheatpump/myheatpump_heatloss.js | 89 ++++++++++++++++--- 1 file changed, 78 insertions(+), 11 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index 95b62f4..6dc5093 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -181,29 +181,96 @@ function plotHeatLossScatter() { var regressionLineData = null; var regressionLabel = ""; + let regressionLineData = []; // Initialize outside the if block to ensure it's always defined + let regressionLabel = "Fit: N/A"; // Default label + if (regressionResult) { console.log("Heat Loss Plot: Regression successful", regressionResult); const slope = regressionResult.slope; const intercept = regressionResult.intercept; const r2 = regressionResult.r2; - const xIntercept = -intercept / slope; // x-intercept (where line crosses x-axis) - // Calculate y-values for the line at min and max observed x values - const y1 = slope * xIntercept + intercept; - maxX=35 - const y2 = slope * maxX + intercept; + // Define the maximum X value for the line endpoint + const maxX = 35; // Or get this dynamically if needed + + // Check for non-zero slope to avoid division by zero + if (Math.abs(slope) > 1e-9) { // Use a small tolerance for floating point comparison + const xIntercept = -intercept / slope; // x-intercept (where line crosses x-axis) + + // --- Generate points from xIntercept to maxX with integer steps --- + + // 1. Collect all desired unique x-values using a Set + const xValuesSet = new Set(); + + // Add the start and end points + xValuesSet.add(xIntercept); + xValuesSet.add(maxX); + + // Determine the integer range to add + // Ensure we handle cases where xIntercept might be > maxX or vice-versa + const startX = Math.min(xIntercept, maxX); + const endX = Math.max(xIntercept, maxX); + const firstInteger = Math.ceil(startX); + const lastInteger = Math.floor(endX); + + // Add all integers within the calculated range [firstInteger, lastInteger] + // These integers must also be between the original xIntercept and maxX bounds + for (let xInt = firstInteger; xInt <= lastInteger; xInt++) { + // Ensure the integer point is actually within the desired line segment bounds + if (xInt >= Math.min(xIntercept, maxX) && xInt <= Math.max(xIntercept, maxX)) { + xValuesSet.add(xInt); + } + } + + // 2. Convert Set to an array and sort numerically + const sortedXValues = Array.from(xValuesSet).sort((a, b) => a - b); + + // 3. Calculate y for each x and create the final data array + regressionLineData = sortedXValues.map(x => { + const y = slope * x + intercept; + return [x, y]; + }); + + // --- End of point generation --- + + // Create a label for the legend + regressionLabel = `Fit: HLC=${(slope * 1000).toFixed(3)} W/K` + + `, Int=${(intercept * 1000).toFixed(3)} W` + + ` (R²=${r2.toFixed(3)})`; // Heat Loss Coefficient (slope) in W/K - regressionLineData = [[xIntercept, y1], [maxX, y2]]; + } else { + // Handle horizontal line (slope is effectively zero) + console.warn("Heat Loss Plot: Slope is near zero. Cannot calculate x-intercept. Drawing horizontal line."); + // Decide the range for the horizontal line. Maybe 0 to maxX? Or min/max observed X? + // Let's draw from 0 to maxX for this example. + const minXForLine = 0; + const maxXForLine = maxX; // Use the same maxX + + // Generate points similar to above, but y is constant (intercept) + const xValuesSet = new Set(); + xValuesSet.add(minXForLine); + xValuesSet.add(maxXForLine); + const firstInteger = Math.ceil(minXForLine); + const lastInteger = Math.floor(maxXForLine); + for (let xInt = firstInteger; xInt <= lastInteger; xInt++) { + xValuesSet.add(xInt); + } + const sortedXValues = Array.from(xValuesSet).sort((a, b) => a - b); + regressionLineData = sortedXValues.map(x => [x, intercept]); // Y is always the intercept - // Create a label for the legend - regressionLabel = `Fit: HLC=${slope.toFixed(3)*1000} W/K` + - `, Int=${intercept.toFixed(3)*1000} W` + - ` (R²=${r2.toFixed(3)})`; // Heat Loss Coefficient (slope) in W/K + regressionLabel = `Fit: HLC=0.000 W/K` + // Slope is 0 + `, Int=${(intercept * 1000).toFixed(3)} W` + + ` (R²=${r2.toFixed(3)})`; + } } else { console.warn("Heat Loss Plot: Linear regression could not be calculated."); + // Ensure regressionLineData is empty and label indicates failure + regressionLineData = []; + regressionLabel = "Fit: N/A"; } + // --- 2. Plotting --- var plotSeries = [{ @@ -272,7 +339,7 @@ function plotHeatLossScatter() { } else if (flotItem.series.lines.show) { // Regression line // Tooltip for the line itself is less useful, maybe show equation? // Or just disable tooltip for the line by returning false? - return `${flotItem.series.label}
ΔT: ${xval.toFixed(1)} °C
Predicted Heat: ${yval.toFixed(2)} kW`; + return `ΔT: ${xval.toFixed(1)} °C
Predicted Heat: ${yval.toFixed(2)} kW`; // return false; // To disable tooltip for the line } return ''; // Default fallback From 5dac1389eec059103c143315d59c496a2e2ef63d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Wed, 9 Apr 2025 21:28:28 +0200 Subject: [PATCH 13/30] Fix double init --- apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index 6dc5093..efc39d5 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -178,8 +178,6 @@ function plotHeatLossScatter() { // --- Calculate Linear Regression --- var regressionResult = linearRegression(xValues, yValues); - var regressionLineData = null; - var regressionLabel = ""; let regressionLineData = []; // Initialize outside the if block to ensure it's always defined let regressionLabel = "Fit: N/A"; // Default label From a97b5db555981231db981403336755cd5716d3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Wed, 9 Apr 2025 22:04:12 +0200 Subject: [PATCH 14/30] Include date in hover tooltip --- .../myheatpump/myheatpump_heatloss.js | 82 +++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index efc39d5..d48a037 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -120,7 +120,6 @@ function plotHeatLossScatter() { for (var i = 0; i < heatDataArray.length; i++) { var timestamp = heatDataArray[i][0]; var heatValue = heatDataArray[i][1] / 24.0; // convert from kWh to kW - // Get corresponding temperatures using the timestamp var outsideTValue = outsideTMap.get(timestamp); var insideTValue; @@ -149,9 +148,9 @@ function plotHeatLossScatter() { // Add the point [deltaT, heatValue] to our scatter data array // Only include days with positive heat output and reasonable deltaT if (heatValue > 0 && deltaT > minDeltaT) { // only plot data above min deltaT - scatterData.push([deltaT, heatValue]); - xValues.push(deltaT); - yValues.push(heatValue); + scatterData.push([deltaT, heatValue, timestamp]); + xValues.push(deltaT); + yValues.push(heatValue); if (deltaT < minX) minX = deltaT; if (deltaT > maxX) maxX = deltaT; } @@ -178,7 +177,6 @@ function plotHeatLossScatter() { // --- Calculate Linear Regression --- var regressionResult = linearRegression(xValues, yValues); - let regressionLineData = []; // Initialize outside the if block to ensure it's always defined let regressionLabel = "Fit: N/A"; // Default label @@ -232,8 +230,8 @@ function plotHeatLossScatter() { // --- End of point generation --- // Create a label for the legend - regressionLabel = `Fit: HLC=${(slope * 1000).toFixed(3)} W/K` + - `, Int=${(intercept * 1000).toFixed(3)} W` + + regressionLabel = `Fit: HLC=${(slope * 1000).toFixed(1)} W/K` + + `, Int=${(intercept * 1000).toFixed(1)} W` + ` (R²=${r2.toFixed(3)})`; // Heat Loss Coefficient (slope) in W/K } else { @@ -331,18 +329,74 @@ function plotHeatLossScatter() { tooltip: { show: true, content: function(label, xval, yval, flotItem) { - // Custom tooltip content to handle multiple series - if (flotItem.series.points.show) { // Scatter points - return `Point:
ΔT: ${xval.toFixed(1)} °C
Avg Heat: ${yval.toFixed(2)} kW`; + // flotItem contains: + // - xval, yval: The coordinates of the hovered point (deltaT, heatValue) + // - flotItem.series: The series object this point belongs to + // - flotItem.series.data: The original data array for this series (our scatterData) + // - flotItem.dataIndex: The index of the hovered point within flotItem.series.data + + if (flotItem.series.points.show) { // Check if it's our scatter series + var index = flotItem.dataIndex; + var seriesData = flotItem.series.data; // This should be our scatterData array + + // --- DEBUGGING --- + // console.log("Hover Index:", index); + // console.log("Series Data Length:", seriesData ? seriesData.length : 'N/A'); + // if (seriesData && index < seriesData.length) { + // console.log("Original Data Point at Index:", seriesData[index]); + // } else { + // console.log("Cannot access seriesData at index", index); + // } + // --- END DEBUGGING --- + + // Check if index and data are valid + if (index !== null && seriesData && index >= 0 && index < seriesData.length) { + var originalPoint = seriesData[index]; // Get the original [deltaT, heatValue, timestamp] array + + // Check if the original point has the expected structure (at least 3 elements) + if (originalPoint && originalPoint.length >= 3) { + var timestamp = originalPoint[2]; // Get the timestamp from the 3rd element + + // Make sure we actually got a number + if (timestamp === null || timestamp === undefined || isNaN(timestamp)) { + console.error("Tooltip - Invalid timestamp value found at index", index, ":", timestamp); + var dateString = "Error (Invalid Timestamp)"; + } else { + // Create Date object directly from the millisecond timestamp + var dateObject = new Date(timestamp); // Assuming timestamp is in ms + + // Check if the date object is valid + if (isNaN(dateObject.getTime())) { + console.error("Tooltip - 'Invalid Date' created from timestamp:", timestamp, "at index", index); + dateString = "Error (Invalid Date)"; + } else { + // Format the valid date + dateString = dateObject.toLocaleDateString(); + } + } + return `Date: ${dateString}
` + + `ΔT: ${xval.toFixed(1)} °C
` + + `Avg Heat: ${yval.toFixed(2)} kW`; + + } else { + console.error("Tooltip - Original data point at index", index, "does not have 3 elements:", originalPoint); + return `Date: Error (Data Format)
` + + `ΔT: ${xval.toFixed(1)} °C
` + + `Avg Heat: ${yval.toFixed(2)} kW`; + } + } else { + console.error("Tooltip - Could not retrieve original data point for index:", index); + return `Date: Error (Index)
` + + `ΔT: ${xval.toFixed(1)} °C
` + + `Avg Heat: ${yval.toFixed(2)} kW`; + } + } else if (flotItem.series.lines.show) { // Regression line - // Tooltip for the line itself is less useful, maybe show equation? - // Or just disable tooltip for the line by returning false? + // Tooltip for the line remains the same return `ΔT: ${xval.toFixed(1)} °C
Predicted Heat: ${yval.toFixed(2)} kW`; - // return false; // To disable tooltip for the line } return ''; // Default fallback }, - // content: "ΔT: %x.1 °C
Avg Heat: %y.2 kW", // Original simple tooltip shifts: { x: 10, y: 20 }, defaultTheme: false, lines: false // Tooltip based on nearest item, not line interpolation From b514185e5acaaddd303f36fa0a10422d9c291701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 10 Apr 2025 11:19:20 +0200 Subject: [PATCH 15/30] Added data splitting controls in GUI. --- .../myheatpump/myheatpump.js | 50 +++++++++++++++++++ .../myheatpump/myheatpump.php | 36 ++++++++++++- .../myheatpump/myheatpump_heatloss.js | 25 ++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js index f3c548e..cf79d40 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js @@ -664,6 +664,56 @@ $("#heatloss_fixed_roomT_value").on('input change', function() { } }); +// 4. Split Data Checkbox Change Event +$("#heatloss_split_data_check").on('change', function() { + var isChecked = $(this).is(":checked"); + var $radioButtons = $('input[name="heatloss_split_by"]'); + var $regressionCheck = $("#heatloss_split_regression_check"); + + // Enable/disable radio buttons + $radioButtons.prop('disabled', !isChecked); + // Enable/disable the regression checkbox + $regressionCheck.prop('disabled', !isChecked); + + // If main checkbox is unchecked, also uncheck radios and regression checkbox + if (!isChecked) { + $radioButtons.prop('checked', false); + $regressionCheck.prop('checked', false); + } + // Optional: If checking, and nothing is selected, select a default (e.g., year) + else if (isChecked && $radioButtons.filter(':checked').length === 0) { + $('#heatloss_split_by_year').prop('checked', true); + // Note: Manually setting 'checked' won't trigger its 'change' event here. + // If the plot needs to update immediately based on the default selection, + // you might need to explicitly call plotHeatLossScatter() here too, + // or trigger the change event: $('#heatloss_split_by_year').trigger('change'); + } + + // Replot if the panel is visible + if ($("#heatloss-block").is(":visible")) { + // Assuming plotHeatLossScatter is defined elsewhere + plotHeatLossScatter(); + } +}); + +// 5. Split Data Radio Button Change Event +$('input[name="heatloss_split_by"]').on('change', function() { + // Only replot if the panel is visible AND the main split checkbox is checked + if ($("#heatloss-block").is(":visible") && $("#heatloss_split_data_check").is(":checked")) { + // Assuming plotHeatLossScatter is defined elsewhere + plotHeatLossScatter(); + } +}); + +// 6. Split Regression Checkbox Change Event +$("#heatloss_split_regression_check").on('change', function() { + // Only replot if the panel is visible AND the main split checkbox is checked + if ($("#heatloss-block").is(":visible") && $("#heatloss_split_data_check").is(":checked")) { + // Assuming plotHeatLossScatter is defined elsewhere + plotHeatLossScatter(); + } +}); + // --- End Heat Loss Control Event Listeners --- $("#show_dhw_temp").click(function () { diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index 09c5f5b..0bc7713 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -468,9 +468,43 @@ °C Enable -
+ +
+ + + Split data by + + + + + + + + + split regression + +
+ + + +
diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index d48a037..f296d4b 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -24,6 +24,31 @@ function plotHeatLossScatter() { console.warn("Heat Loss Plot: Invalid Fixed Room Temp input, using default 20°C."); } + // --- Get options for splitting data --- + // Check if the main 'Split data by' checkbox is enabled + var splitDataEnabled = $("#heatloss_split_data_check").is(":checked"); + + // Determine the dimension to split by (null if splitting is disabled) + var splitByValue = null; // Default to null (no split dimension selected or splitting disabled) + if (splitDataEnabled) { + // Find the radio button that is checked within the 'heatloss_split_by' group + var $checkedRadio = $('input[name="heatloss_split_by"]:checked'); + + if ($checkedRadio.length > 0) { + // Get the value ('year' or 'season') from the checked radio button + splitByValue = $checkedRadio.val(); + } else { + // This case should ideally not happen if the UI logic correctly forces a selection + // when splitDataEnabled is true. But good to handle defensively. + console.warn("Heat Loss Plot: Split data enabled, but no split dimension (year/season) selected. Check UI logic."); + // Depending on desired behavior, you might want to force splitDataEnabled = false here, + // or default splitByValue = 'year'; For now, it stays null. + } + } + + // Check if the 'split regression' checkbox is enabled. + var splitRegressionEnabled = $("#heatloss_split_regression_check").is(":checked"); + // Check if essential daily data is available if (typeof daily_data === 'undefined' || $.isEmptyObject(daily_data)) { console.log("Heat Loss Plot: daily_data not available."); From 560dafdc8b1a0f8aef92c00c321350d9ab875fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Thu, 10 Apr 2025 11:35:45 +0200 Subject: [PATCH 16/30] Added backend for split plots/regression --- .../myheatpump/myheatpump_heatloss.js | 862 +++++++++++------- 1 file changed, 533 insertions(+), 329 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index f296d4b..ad0831a 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -1,347 +1,447 @@ /** - * Plots a scatter graph of Daily Heat Output vs Daily (Tin - Tout) difference. - * Uses data prepared for the main bar graph, filtered by the current bargraph_mode. - * Assumes global variables like `daily_data`, `bargraph_mode`, `feeds`, `flot_font_size` are available. + * Provides a rotating list of distinct colors for plotting multiple series. */ -function plotHeatLossScatter() { - console.log("Attempting to plot Heat Loss Scatter for mode:", bargraph_mode); - var plotDiv = $("#heatloss-plot"); - var plotBound = $("#heatloss-plot-bound"); // To potentially resize if needed +const plotColors = [ + '#FF6347', // Tomato + '#4682B4', // SteelBlue + '#32CD32', // LimeGreen + '#FFD700', // Gold + '#6A5ACD', // SlateBlue + '#DAA520', // GoldenRod + '#8A2BE2', // BlueViolet + '#5F9EA0', // CadetBlue + '#D2691E', // Chocolate + '#FF7F50', // Coral +]; +let colorIndex = 0; + +function getNextPlotColor() { + const color = plotColors[colorIndex % plotColors.length]; + colorIndex++; + return color; +} +function resetPlotColorIndex() { + colorIndex = 0; +} + + +/** + * Reads and validates inputs from the Heat Loss plot settings UI. + * @returns {object|null} An object containing validated configuration, or null if validation fails. + */ +function getHeatLossInputs() { + const config = {}; - // --- 1. Data Access and Preparation --- - // get min deltaT from input field - var minDeltaT = parseFloat($("#heatloss_min_deltaT").val()); - if (isNaN(minDeltaT)) { // Handle cases where input might be empty or invalid - minDeltaT = -Infinity; // Effectively no minimum if invalid + // Basic settings + config.bargraph_mode = bargraph_mode; // Use global bargraph_mode + config.minDeltaT = parseFloat($("#heatloss_min_deltaT").val()); + if (isNaN(config.minDeltaT)) { console.warn("Heat Loss Plot: Invalid Minimum ΔT input, using no minimum."); + config.minDeltaT = -Infinity; } - // get option to use fixed room temperature - var useFixedRoomT = $("#heatloss_fixed_roomT_check").is(":checked"); - var fixedRoomTValue = parseFloat($("#heatloss_fixed_roomT_value").val()); - if (isNaN(fixedRoomTValue)) { - fixedRoomTValue = 20; // Default if invalid + // Fixed Room Temperature settings + config.useFixedRoomT_input = $("#heatloss_fixed_roomT_check").is(":checked"); + config.fixedRoomTValue_input = parseFloat($("#heatloss_fixed_roomT_value").val()); + if (isNaN(config.fixedRoomTValue_input)) { + config.fixedRoomTValue_input = 20; // Default if invalid console.warn("Heat Loss Plot: Invalid Fixed Room Temp input, using default 20°C."); } + // Determine actual value to use (considering feed availability later) + config.fixedRoomTValue = config.fixedRoomTValue_input; - // --- Get options for splitting data --- - // Check if the main 'Split data by' checkbox is enabled - var splitDataEnabled = $("#heatloss_split_data_check").is(":checked"); + // Feed configuration status + config.roomTempFeedConfigured = !!feeds["heatpump_roomT"]; + config.outsideTempFeedConfigured = !!feeds["heatpump_outsideT"]; - // Determine the dimension to split by (null if splitting is disabled) - var splitByValue = null; // Default to null (no split dimension selected or splitting disabled) - if (splitDataEnabled) { - // Find the radio button that is checked within the 'heatloss_split_by' group - var $checkedRadio = $('input[name="heatloss_split_by"]:checked'); + // Decide if fixed T should be used + config.shouldUseFixedRoomT = config.useFixedRoomT_input || !config.roomTempFeedConfigured; + // Splitting settings + config.splitDataEnabled = $("#heatloss_split_data_check").is(":checked"); + config.splitByValue = null; + if (config.splitDataEnabled) { + var $checkedRadio = $('input[name="heatloss_split_by"]:checked'); if ($checkedRadio.length > 0) { - // Get the value ('year' or 'season') from the checked radio button - splitByValue = $checkedRadio.val(); + config.splitByValue = $checkedRadio.val(); // 'year' or 'season' } else { - // This case should ideally not happen if the UI logic correctly forces a selection - // when splitDataEnabled is true. But good to handle defensively. - console.warn("Heat Loss Plot: Split data enabled, but no split dimension (year/season) selected. Check UI logic."); - // Depending on desired behavior, you might want to force splitDataEnabled = false here, - // or default splitByValue = 'year'; For now, it stays null. + console.warn("Heat Loss Plot: Split data enabled, but no split dimension selected. Disabling split."); + config.splitDataEnabled = false; // Disable if no dimension selected } } + config.splitRegressionEnabled = config.splitDataEnabled && $("#heatloss_split_regression_check").is(":checked"); // Regression split only possible if data is split - // Check if the 'split regression' checkbox is enabled. - var splitRegressionEnabled = $("#heatloss_split_regression_check").is(":checked"); + // Keys for accessing data + config.heatKey = config.bargraph_mode + "_heat_kwh"; + config.insideTKey = "combined_roomT_mean"; + config.outsideTKey = "combined_outsideT_mean"; - // Check if essential daily data is available - if (typeof daily_data === 'undefined' || $.isEmptyObject(daily_data)) { - console.log("Heat Loss Plot: daily_data not available."); - plotDiv.html("

Daily data not loaded yet.

"); - return; - } - - // Determine the keys based on the current bargraph mode (global variable) - var heatKey = bargraph_mode + "_heat_kwh"; // e.g., combined_heat_kwh, running_heat_kwh - var insideTKey = "combined_roomT_mean"; // Daily average room temp seems only stored as combined - var outsideTKey = "combined_outsideT_mean"; // Daily average outside temp seems only stored as combined + console.log("Heat Loss Inputs:", config); + return config; +} - // Check feed configuration status - var roomTempFeedConfigured = !!feeds["heatpump_roomT"]; - var outsideTempFeedConfigured = !!feeds["heatpump_outsideT"]; - - // Determine if we *should* use the fixed room temperature value - // This is true if the user checked the box OR if the room temperature feed isn't configured anyway - var shouldUseFixedRoomT = useFixedRoomT || !roomTempFeedConfigured; +/** + * Checks if the necessary data is available for plotting based on the configuration. + * @param {object} config - The configuration object from getHeatLossInputs. + * @param {object} daily_data - The global daily_data object. + * @returns {{sufficient: boolean, messages: string[]}} An object indicating sufficiency and any error/warning messages. + */ +function checkDataSufficiency(config, daily_data) { + let isDataSufficient = true; + const messages = []; - // --- Check Data Sufficiency --- - var isDataSufficient = true; - var messages = []; + if (typeof daily_data === 'undefined' || $.isEmptyObject(daily_data)) { + return { sufficient: false, messages: ["Daily data not loaded yet."] }; + } - // 1. Check Heat Data (Mandatory) - if (!daily_data[heatKey] || daily_data[heatKey].length === 0) { + // 1. Check Heat Data + if (!daily_data[config.heatKey] || daily_data[config.heatKey].length === 0) { isDataSufficient = false; - messages.push(`Heat data ('${heatKey}') not found for the selected mode.`); - console.log("Heat Loss Plot: Missing or empty data for key:", heatKey); + messages.push(`Heat data ('${config.heatKey}') not found for the selected mode.`); + console.log("Heat Loss Plot: Missing or empty data for key:", config.heatKey); } - // 2. Check Outside Temp Data (Mandatory) - if (!outsideTempFeedConfigured) { - isDataSufficient = false; - messages.push("Outside Temperature feed not configured in the app setup."); - console.log("Heat Loss Plot: Outside Temperature feed not configured."); - } else if (!daily_data[outsideTKey] || daily_data[outsideTKey].length === 0) { + // 2. Check Outside Temp Data + if (!config.outsideTempFeedConfigured) { isDataSufficient = false; - messages.push(`Outside temperature data ('${outsideTKey}') not found or empty.`); - console.log("Heat Loss Plot: Missing or empty data for key:", outsideTKey); + messages.push("Outside Temperature feed not configured."); + console.log("Heat Loss Plot: Outside Temperature feed not configured."); + } else if (!daily_data[config.outsideTKey] || daily_data[config.outsideTKey].length === 0) { + isDataSufficient = false; + messages.push(`Outside temperature data ('${config.outsideTKey}') not found or empty.`); + console.log("Heat Loss Plot: Missing or empty data for key:", config.outsideTKey); } - // 3. Check Inside Temp Data (Only required if we are NOT using the fixed value) - if (!shouldUseFixedRoomT) { - // We need the dynamic data from the feed - if (!roomTempFeedConfigured) { - // This case is technically covered by shouldUseFixedRoomT, but belt-and-braces check - isDataSufficient = false; - messages.push("Room Temperature feed not configured (and fixed value not selected)."); - console.log("Heat Loss Plot: Room Temperature feed not configured (and fixed value not selected)."); - } else if (!daily_data[insideTKey] || daily_data[insideTKey].length === 0) { + // 3. Check Inside Temp Data (Only if needed) + if (!config.shouldUseFixedRoomT) { + if (!config.roomTempFeedConfigured) { + isDataSufficient = false; + messages.push("Room Temperature feed not configured (and fixed value not selected)."); + console.log("Heat Loss Plot: Room Temperature feed not configured (and fixed value not selected)."); + } else if (!daily_data[config.insideTKey] || daily_data[config.insideTKey].length === 0) { isDataSufficient = false; - messages.push(`Inside temperature data ('${insideTKey}') not found or empty (and fixed value not selected).`); - console.log("Heat Loss Plot: Missing or empty inside temp data (and fixed value not selected):", insideTKey); + messages.push(`Inside temperature data ('${config.insideTKey}') not found or empty (and fixed value not selected).`); + console.log("Heat Loss Plot: Missing or empty inside temp data (and fixed value not selected):", config.insideTKey); } } - // If shouldUseFixedRoomT is true, we don't need to check for insideTKey data presence here. - if (!isDataSufficient) { - var messageHtml = "

Cannot plot heat loss:
" + messages.join("
") + "

"; - plotDiv.html(messageHtml); - return; - } + return { sufficient: isDataSufficient, messages: messages }; +} - // --- Prepare Data for Plotting --- +/** + * Prepares the data for the heat loss scatter plot, handling splitting if enabled. + * @param {object} config - The configuration object. + * @param {object} daily_data - The global daily_data object. + * @returns {object|null} An object containing grouped data { groupKey: { data:[], xValues:[], yValues:[], label:'', color:'' } }, + * and overallMinX, overallMaxX, totalPoints. Returns null if no valid points found. + */ +function prepareHeatLossPlotData(config, daily_data) { console.log("Heat Loss Plot: Preparing data..."); - if (shouldUseFixedRoomT) { - console.log("Heat Loss Plot: Using fixed room temperature:", fixedRoomTValue, "°C"); - if (!roomTempFeedConfigured && !useFixedRoomT) { - console.log("Heat Loss Plot: Note - Fixed room temperature is being used because the Room Temperature feed is not configured."); + if (config.shouldUseFixedRoomT) { + console.log("Heat Loss Plot: Using fixed room temperature:", config.fixedRoomTValue, "°C"); + if (!config.roomTempFeedConfigured && config.useFixedRoomT_input) { + console.log("Heat Loss Plot: Note - Fixed room temperature is being used because the Room Temperature feed is not configured."); } } else { - console.log("Heat Loss Plot: Using dynamic room temperature feed data ('" + insideTKey + "')."); + console.log("Heat Loss Plot: Using dynamic room temperature feed data ('" + config.insideTKey + "')."); } + const outsideTMap = new Map(daily_data[config.outsideTKey]); + const insideTMap = (!config.shouldUseFixedRoomT && daily_data[config.insideTKey]) ? new Map(daily_data[config.insideTKey]) : null; + const heatDataArray = daily_data[config.heatKey]; - // Create lookup maps for temperatures - var outsideTMap = new Map(daily_data[outsideTKey]); - // Only create insideTMap if we are using dynamic data and it exists - var insideTMap = (!shouldUseFixedRoomT && daily_data[insideTKey]) ? new Map(daily_data[insideTKey]) : null; - - var scatterData = []; - var heatDataArray = daily_data[heatKey]; + console.log("Heat Loss Plot: Processing", heatDataArray.length, "days of data for mode", config.bargraph_mode); - console.log("Heat Loss Plot: Processing", heatDataArray.length, "days of data for mode", bargraph_mode); + // --- Data Grouping Logic --- + const groupedData = {}; + let overallMinX = Infinity; + let overallMaxX = -Infinity; + let totalPoints = 0; + resetPlotColorIndex(); // Reset colors for each plot generation - // arrays for regression - var xValues = []; // Will hold deltaT values - var yValues = []; // Will hold heatValue values - var minX = Infinity; - var maxX = -Infinity; + for (let i = 0; i < heatDataArray.length; i++) { + const timestamp = heatDataArray[i][0]; // Assuming timestamp is in milliseconds + const heatValue = heatDataArray[i][1] / 24.0; // kWh to kW - // Iterate through the heat data (which is specific to the mode) - for (var i = 0; i < heatDataArray.length; i++) { - var timestamp = heatDataArray[i][0]; - var heatValue = heatDataArray[i][1] / 24.0; // convert from kWh to kW - // Get corresponding temperatures using the timestamp - var outsideTValue = outsideTMap.get(timestamp); - var insideTValue; + const outsideTValue = outsideTMap.get(timestamp); + let insideTValue; - // *** Conditionally assign insideTValue *** - if (shouldUseFixedRoomT) { - insideTValue = fixedRoomTValue; // Use the fixed value + if (config.shouldUseFixedRoomT) { + insideTValue = config.fixedRoomTValue; } else if (insideTMap) { - // Use the dynamic value from the map (we know insideTMap exists if !shouldUseFixedRoomT) insideTValue = insideTMap.get(timestamp); } else { - // This case should ideally not be reached due to sufficiency checks, but set to null as a fallback. - insideTValue = null; - console.warn("Heat Loss Plot: Unexpected condition - insideTMap is null when dynamic temp was expected for timestamp", timestamp); + insideTValue = null; // Should not happen if sufficiency check passed } - - // Ensure all data points for this day are valid numbers + // Check validity if (heatValue !== null && typeof heatValue === 'number' && !isNaN(heatValue) && - insideTValue !== null && typeof insideTValue === 'number' && !isNaN(insideTValue) && // Handles both fixed and dynamic cases + insideTValue !== null && typeof insideTValue === 'number' && !isNaN(insideTValue) && outsideTValue !== null && typeof outsideTValue === 'number' && !isNaN(outsideTValue)) { - // Calculate delta T - var deltaT = insideTValue - outsideTValue; - - // Add the point [deltaT, heatValue] to our scatter data array - // Only include days with positive heat output and reasonable deltaT - if (heatValue > 0 && deltaT > minDeltaT) { // only plot data above min deltaT - scatterData.push([deltaT, heatValue, timestamp]); - xValues.push(deltaT); - yValues.push(heatValue); - if (deltaT < minX) minX = deltaT; - if (deltaT > maxX) maxX = deltaT; + const deltaT = insideTValue - outsideTValue; + + if (heatValue > 0 && deltaT > config.minDeltaT) { + let groupKey = "all_data"; // Default if splitting is disabled + let groupLabel = 'Daily Heat Demand (' + config.bargraph_mode + (config.shouldUseFixedRoomT ? ', Fixed T_in=' + config.fixedRoomTValue + '°C' : '') + ')'; + let groupColor = plotColors[0]; // Default color if no split + + // --- Determine Group Key if Splitting --- + if (config.splitDataEnabled) { + const date = new Date(timestamp); // Ensure timestamp is in ms + const year = date.getFullYear(); + const month = date.getMonth(); // 0-11 + + if (config.splitByValue === 'year') { + groupKey = String(year); + groupLabel = `${year}`; + } else if (config.splitByValue === 'season') { + // Season: July 1st to June 30th + // If month is July (6) or later, it belongs to the season starting this year + if (month >= 6) { + groupKey = `${year}/${year + 1}`; + groupLabel = `Season ${year}/${year + 1}`; + } else { // Otherwise, it belongs to the season that started last year + groupKey = `${year - 1}/${year}`; + groupLabel = `Season ${year - 1}/${year}`; + } + } + } + + // --- Initialize group if it doesn't exist --- + if (!groupedData[groupKey]) { + // Assign color when group is first created + if (config.splitDataEnabled) { + groupColor = getNextPlotColor(); + } + groupedData[groupKey] = { + data: [], // Holds [deltaT, heatValue, timestamp] + xValues: [], // Holds deltaT + yValues: [], // Holds heatValue (kW) + label: groupLabel, + color: groupColor + }; + } + + // --- Add data to the group --- + groupedData[groupKey].data.push([deltaT, heatValue, timestamp]); + groupedData[groupKey].xValues.push(deltaT); + groupedData[groupKey].yValues.push(heatValue); + + // Update overall bounds + if (deltaT < overallMinX) overallMinX = deltaT; + if (deltaT > overallMaxX) overallMaxX = deltaT; + totalPoints++; } - } else { - // Optional: Log skipped days if needed for debugging - // console.log("Heat Loss Plot: Skipping day", new Date(timestamp*1000).toLocaleDateString(), "due to null/invalid values (H/Ti/To):", heatValue, insideTValue, outsideTValue); } } - console.log("Heat Loss Plot: Prepared", scatterData.length, "valid scatter points."); + console.log("Heat Loss Plot: Prepared", totalPoints, "valid scatter points into", Object.keys(groupedData).length, "groups."); - if (scatterData.length === 0) { - let noDataReason = "No valid data points found for this mode"; - if (shouldUseFixedRoomT) { - noDataReason += " using fixed room temperature " + fixedRoomTValue + "°C"; - } - if (minDeltaT > -Infinity) { - noDataReason += " with Min ΔT > " + minDeltaT + "°C"; - } - noDataReason += "."; - plotDiv.html("

" + noDataReason + "

"); - return; + if (totalPoints === 0) { + return null; // Indicate no data } - // --- Calculate Linear Regression --- - var regressionResult = linearRegression(xValues, yValues); - let regressionLineData = []; // Initialize outside the if block to ensure it's always defined - let regressionLabel = "Fit: N/A"; // Default label + // Sort groups by key (e.g., year or season start year) for consistent legend order + const sortedGroupKeys = Object.keys(groupedData).sort(); + const sortedGroupedData = {}; + sortedGroupKeys.forEach(key => { + sortedGroupedData[key] = groupedData[key]; + }); + + + return { + groups: sortedGroupedData, + overallMinX: overallMinX, + overallMaxX: overallMaxX, + totalPoints: totalPoints + }; +} + + +/** + * Calculates linear regression and formats the result as a Flot series object + * with detailed points along the line for better hover interaction. + * @param {number[]} xValues - Array of x-coordinates. + * @param {number[]} yValues - Array of y-coordinates. + * @param {string} labelPrefix - Prefix for the legend label (e.g., "Fit", "2023 Fit"). + * @param {string} color - Color for the regression line. + * @param {number} minPlotX - Minimum x value boundary for plotting the line (e.g., 0). + * @param {number} maxPlotX - Maximum x value boundary for plotting the line (e.g., 35). + * @returns {object|null} A Flot series object for the regression line, or null if regression fails or line is invalid. + */ +function calculateRegressionSeries(xValues, yValues, labelPrefix, color, minPlotX = 0, maxPlotX = 35) { + if (!xValues || xValues.length < 2) { + console.warn("Heat Loss Plot: Not enough data points for regression for group:", labelPrefix); + return null; + } + + const regressionResult = linearRegression(xValues, yValues); + let regressionLineData = []; + let regressionLabel = `${labelPrefix}: N/A`; if (regressionResult) { - console.log("Heat Loss Plot: Regression successful", regressionResult); - const slope = regressionResult.slope; - const intercept = regressionResult.intercept; - const r2 = regressionResult.r2; - - // Define the maximum X value for the line endpoint - const maxX = 35; // Or get this dynamically if needed - - // Check for non-zero slope to avoid division by zero - if (Math.abs(slope) > 1e-9) { // Use a small tolerance for floating point comparison - const xIntercept = -intercept / slope; // x-intercept (where line crosses x-axis) - - // --- Generate points from xIntercept to maxX with integer steps --- - - // 1. Collect all desired unique x-values using a Set - const xValuesSet = new Set(); - - // Add the start and end points - xValuesSet.add(xIntercept); - xValuesSet.add(maxX); - - // Determine the integer range to add - // Ensure we handle cases where xIntercept might be > maxX or vice-versa - const startX = Math.min(xIntercept, maxX); - const endX = Math.max(xIntercept, maxX); - const firstInteger = Math.ceil(startX); - const lastInteger = Math.floor(endX); - - // Add all integers within the calculated range [firstInteger, lastInteger] - // These integers must also be between the original xIntercept and maxX bounds - for (let xInt = firstInteger; xInt <= lastInteger; xInt++) { - // Ensure the integer point is actually within the desired line segment bounds - if (xInt >= Math.min(xIntercept, maxX) && xInt <= Math.max(xIntercept, maxX)) { - xValuesSet.add(xInt); + const { slope, intercept, r2 } = regressionResult; + regressionLabel = `${labelPrefix}: HLC=${(slope * 1000).toFixed(1)} W/K` + + `, Int=${(intercept * 1000).toFixed(1)} W` + + ` (R²=${r2.toFixed(3)}, N=${xValues.length})`; + + // --- Determine the actual range for the line segment (respecting y >= 0 and plot bounds) --- + let startX = minPlotX; + let endX = maxPlotX; + const epsilon = 1e-9; // Tolerance for float comparisons + + if (Math.abs(slope) > epsilon) { + // Line has a non-zero slope + const xIntercept = -intercept / slope; + + // Calculate Y values at the plot boundaries + const yAtMinPlotX = slope * minPlotX + intercept; + const yAtMaxPlotX = slope * maxPlotX + intercept; + + // Adjust startX: must be >= minPlotX and where y >= 0 + if (yAtMinPlotX < -epsilon && xIntercept > minPlotX) { + // Line starts below 0 at minPlotX, but crosses y=0 later + startX = xIntercept; + } else if (yAtMinPlotX < -epsilon && xIntercept <= minPlotX) { + // Line is entirely below 0 at the start or crosses before minPlotX + console.warn(`Regression line for ${labelPrefix} starts below y=0.`); + // Check if it ever goes positive within the maxPlotX range + if (yAtMaxPlotX < -epsilon) { + console.warn(`Regression line for ${labelPrefix} is entirely below y=0 within plot range. Not plotting.`); + return null; // Don't plot if the entire segment in range is negative } - } - - // 2. Convert Set to an array and sort numerically - const sortedXValues = Array.from(xValuesSet).sort((a, b) => a - b); + // If it crosses later (yAtMaxPlotX is positive), start at xIntercept, but clamped by minPlotX + startX = Math.max(minPlotX, xIntercept); - // 3. Calculate y for each x and create the final data array - regressionLineData = sortedXValues.map(x => { - const y = slope * x + intercept; - return [x, y]; - }); + } else { + // Line starts at or above 0 at minPlotX + startX = minPlotX; + } - // --- End of point generation --- - // Create a label for the legend - regressionLabel = `Fit: HLC=${(slope * 1000).toFixed(1)} W/K` + - `, Int=${(intercept * 1000).toFixed(1)} W` + - ` (R²=${r2.toFixed(3)})`; // Heat Loss Coefficient (slope) in W/K + // Adjust endX: must be <= maxPlotX and where y >= 0 + if (yAtMaxPlotX < -epsilon && xIntercept < maxPlotX) { + // Line ends below 0 at maxPlotX, but crossed y=0 earlier + endX = xIntercept; + } else if (yAtMaxPlotX < -epsilon && xIntercept >= maxPlotX) { + // Line is already below 0 before or at maxPlotX + // This case *should* be covered by the startX logic if the line is entirely negative, + // but good to be explicit. If startX was valid, we must end where y crosses 0. + endX = Math.min(maxPlotX, xIntercept); + } else { + // Line ends at or above 0 at maxPlotX + endX = maxPlotX; + } } else { - // Handle horizontal line (slope is effectively zero) - console.warn("Heat Loss Plot: Slope is near zero. Cannot calculate x-intercept. Drawing horizontal line."); - // Decide the range for the horizontal line. Maybe 0 to maxX? Or min/max observed X? - // Let's draw from 0 to maxX for this example. - const minXForLine = 0; - const maxXForLine = maxX; // Use the same maxX - - // Generate points similar to above, but y is constant (intercept) - const xValuesSet = new Set(); - xValuesSet.add(minXForLine); - xValuesSet.add(maxXForLine); - const firstInteger = Math.ceil(minXForLine); - const lastInteger = Math.floor(maxXForLine); - for (let xInt = firstInteger; xInt <= lastInteger; xInt++) { - xValuesSet.add(xInt); + // Line is horizontal (slope is near zero) + if (intercept < -epsilon) { + // Horizontal line below y=0 + console.warn(`Regression line for ${labelPrefix} is horizontal and below y=0. Not plotting.`); + return null; } - const sortedXValues = Array.from(xValuesSet).sort((a, b) => a - b); - regressionLineData = sortedXValues.map(x => [x, intercept]); // Y is always the intercept + // Otherwise, the horizontal line is at y = intercept (>= 0) + // Use the original plot bounds for the horizontal line + startX = minPlotX; + endX = maxPlotX; + } + + // --- Generate detailed points for the calculated [startX, endX] segment --- - regressionLabel = `Fit: HLC=0.000 W/K` + // Slope is 0 - `, Int=${(intercept * 1000).toFixed(3)} W` + - ` (R²=${r2.toFixed(3)})`; + // Ensure startX is not greater than endX (could happen due to float issues or weird data) + if (startX > endX + epsilon) { + console.warn(`Regression line for ${labelPrefix}: Calculated startX (${startX.toFixed(2)}) is greater than endX (${endX.toFixed(2)}). Not plotting line.`); + return null; } + // Clamp startX and endX to be within the original min/max plot bounds just in case + startX = Math.max(minPlotX, startX); + endX = Math.min(maxPlotX, endX); + // Recalculate if clamping changed things significantly - might not be necessary if initial logic is robust + if (startX > endX + epsilon) { + console.warn(`Regression line for ${labelPrefix}: Clamped startX (${startX.toFixed(2)}) is greater than clamped endX (${endX.toFixed(2)}). Not plotting line.`); + return null; + } - } else { - console.warn("Heat Loss Plot: Linear regression could not be calculated."); - // Ensure regressionLineData is empty and label indicates failure - regressionLineData = []; - regressionLabel = "Fit: N/A"; - } + const xValuesSet = new Set(); - // --- 2. Plotting --- + // Add the precise start and end points + xValuesSet.add(startX); + xValuesSet.add(endX); - var plotSeries = [{ - data: scatterData, - points: { - show: true, - radius: 3, - fill: true, - fillColor: "rgba(255, 99, 71, 0.6)" // Tomato color with some transparency - }, - lines: { show: false }, // Ensure lines are off for scatter - color: 'rgb(255, 99, 71)', // Tomato color - label: 'Daily Heat Demand (' + bargraph_mode + (shouldUseFixedRoomT ? ', Fixed T_in=' + fixedRoomTValue + '°C' : '') + ')' // Add note to label if fixed T is used - }]; - - if (regressionLineData) { - plotSeries.push({ - data: regressionLineData, - lines: { - show: true, - lineWidth: 2 - }, - points: { show: false }, // Don't show points for the line itself - color: 'rgba(0, 0, 255, 0.8)', // Blue line - label: regressionLabel, - // Optional: make it dashed - // dashes: { show: true, lineWidth: 1, dashLength: [4, 4] }, - shadowSize: 0 // No shadow for the fit line + // Add integer points within the range + const firstInteger = Math.ceil(startX); + const lastInteger = Math.floor(endX); + + for (let xInt = firstInteger; xInt <= lastInteger; xInt++) { + // Ensure the integer point is strictly within the calculated segment bounds + if (xInt >= startX - epsilon && xInt <= endX + epsilon) { + xValuesSet.add(xInt); + } + } + + // Convert Set to sorted array and calculate y values + const sortedXValues = Array.from(xValuesSet).sort((a, b) => a - b); + regressionLineData = sortedXValues.map(x => { + const y = slope * x + intercept; + // Clamp y at 0, although the startX/endX logic should mostly prevent negative y + return [x, Math.max(0, y)]; }); + + // Final check: ensure we have at least two distinct points to draw a line + if (regressionLineData.length < 2) { + console.warn("Regression line for", labelPrefix, " resulted in less than 2 points after processing. Not plotting line."); + return null; + } + + } else { + console.warn("Heat Loss Plot: Linear regression calculation failed for group:", labelPrefix); + return null; } - var plotOptions = { + // Return the Flot series object + return { + data: regressionLineData, + lines: { show: true, lineWidth: 2 }, + points: { show: false }, // Keep points off for the line itself + color: color, + label: regressionLabel, + shadowSize: 0 + }; +} + + +/** + * Configures the options for the Flot plot. + * @param {boolean} splitDataEnabled - Whether data splitting is active (affects legend). + * @param {object[]} plotSeries - The array of series to be plotted (used for tooltip logic). + * @returns {object} The Flot options object. + */ +function getHeatLossPlotOptions(splitDataEnabled, plotSeries) { + // Find the first scatter series to access its original data structure for the tooltip + const scatterSeriesExample = plotSeries.find(s => s.points && s.points.show); + const originalDataAccessor = scatterSeriesExample ? scatterSeriesExample.originalDataAccessor : 'data'; // How to get original [x,y,ts] + + return { xaxis: { min: 0, - axisLabel: "Temperature Difference (T_inside - T_outside) [K or °C]", // Difference is the same unit + axisLabel: "Temperature Difference (T_inside - T_outside) [K or °C]", axisLabelUseCanvas: true, axisLabelFontSizePixels: 12, axisLabelFontFamily: 'Verdana, Arial, Helvetica, Tahoma, sans-serif', axisLabelPadding: 5, - font: { size: flot_font_size, color: "#555" }, // Assumes global flot_font_size - // Let flot determine min/max automatically for scatter + font: { size: flot_font_size, color: "#555" }, + // max: 35 // Optional: set a fixed max if desired }, yaxis: { - axisLabel: "Average Heat Output [kW]", // Corrected unit label (was kWh previously in tooltip comment) + min: 0, + axisLabel: "Average Heat Output [kW]", axisLabelUseCanvas: true, axisLabelFontSizePixels: 12, axisLabelFontFamily: 'Verdana, Arial, Helvetica, Tahoma, sans-serif', axisLabelPadding: 5, - font: { size: flot_font_size, color: "#555" }, // Assumes global flot_font_size - min: 0 // Heat output shouldn't be negative in this context + font: { size: flot_font_size, color: "#555" }, }, grid: { show: true, @@ -353,100 +453,204 @@ function plotHeatLossScatter() { }, tooltip: { show: true, - content: function(label, xval, yval, flotItem) { - // flotItem contains: - // - xval, yval: The coordinates of the hovered point (deltaT, heatValue) - // - flotItem.series: The series object this point belongs to - // - flotItem.series.data: The original data array for this series (our scatterData) - // - flotItem.dataIndex: The index of the hovered point within flotItem.series.data - - if (flotItem.series.points.show) { // Check if it's our scatter series - var index = flotItem.dataIndex; - var seriesData = flotItem.series.data; // This should be our scatterData array - - // --- DEBUGGING --- - // console.log("Hover Index:", index); - // console.log("Series Data Length:", seriesData ? seriesData.length : 'N/A'); - // if (seriesData && index < seriesData.length) { - // console.log("Original Data Point at Index:", seriesData[index]); - // } else { - // console.log("Cannot access seriesData at index", index); - // } - // --- END DEBUGGING --- - - // Check if index and data are valid - if (index !== null && seriesData && index >= 0 && index < seriesData.length) { - var originalPoint = seriesData[index]; // Get the original [deltaT, heatValue, timestamp] array - - // Check if the original point has the expected structure (at least 3 elements) + content: function(label, xval, yval, flotItem) { + // flotItem.seriesIndex: index in the plotSeries array + // flotItem.dataIndex: index within the data of that series + const currentSeries = plotSeries[flotItem.seriesIndex]; + + if (currentSeries.points && currentSeries.points.show) { // Scatter point + const originalDataArray = currentSeries[originalDataAccessor]; // Access the original data store + const index = flotItem.dataIndex; + + if (index !== null && originalDataArray && index >= 0 && index < originalDataArray.length) { + const originalPoint = originalDataArray[index]; // [deltaT, heatValue, timestamp] if (originalPoint && originalPoint.length >= 3) { - var timestamp = originalPoint[2]; // Get the timestamp from the 3rd element - - // Make sure we actually got a number - if (timestamp === null || timestamp === undefined || isNaN(timestamp)) { - console.error("Tooltip - Invalid timestamp value found at index", index, ":", timestamp); - var dateString = "Error (Invalid Timestamp)"; - } else { - // Create Date object directly from the millisecond timestamp - var dateObject = new Date(timestamp); // Assuming timestamp is in ms - - // Check if the date object is valid - if (isNaN(dateObject.getTime())) { - console.error("Tooltip - 'Invalid Date' created from timestamp:", timestamp, "at index", index); - dateString = "Error (Invalid Date)"; - } else { - // Format the valid date - dateString = dateObject.toLocaleDateString(); - } - } - return `Date: ${dateString}
` + + const timestamp = originalPoint[2]; + let dateString = "N/A"; + if (timestamp !== null && !isNaN(timestamp)) { + const dateObject = new Date(timestamp); + if (!isNaN(dateObject.getTime())) { + dateString = dateObject.toLocaleDateString(); + } else { dateString = "Invalid Date"; } + } else { dateString = "Invalid Timestamp"; } + + // Include series label if splitting is enabled + const seriesLabelInfo = splitDataEnabled ? `${currentSeries.label || 'Data'}
` : ''; + + return `${seriesLabelInfo}` + + `Date: ${dateString}
` + `ΔT: ${xval.toFixed(1)} °C
` + `Avg Heat: ${yval.toFixed(2)} kW`; - - } else { - console.error("Tooltip - Original data point at index", index, "does not have 3 elements:", originalPoint); - return `Date: Error (Data Format)
` + - `ΔT: ${xval.toFixed(1)} °C
` + - `Avg Heat: ${yval.toFixed(2)} kW`; - } - } else { - console.error("Tooltip - Could not retrieve original data point for index:", index); - return `Date: Error (Index)
` + - `ΔT: ${xval.toFixed(1)} °C
` + - `Avg Heat: ${yval.toFixed(2)} kW`; - } - - } else if (flotItem.series.lines.show) { // Regression line - // Tooltip for the line remains the same - return `ΔT: ${xval.toFixed(1)} °C
Predicted Heat: ${yval.toFixed(2)} kW`; + } else { return "Data Format Error"; } + } else { return "Data Index Error"; } + } else if (currentSeries.lines && currentSeries.lines.show) { // Regression line + return `${currentSeries.label || 'Fit'}
` + // Show regression label + `ΔT: ${xval.toFixed(1)} °C
` + + `Predicted Heat: ${yval.toFixed(2)} kW`; } - return ''; // Default fallback - }, + return ''; // Fallback + }, shifts: { x: 10, y: 20 }, defaultTheme: false, - lines: false // Tooltip based on nearest item, not line interpolation + lines: false }, legend: { show: true, - position: "nw" // North-West corner + position: "nw", // North-West corner + // Optional: more space if many legend items + // labelBoxBorderColor: "none", + // container: $("#heatloss-legend-container") // Define an external container if needed } }; +} + - // Ensure the plot container is visible and sized before plotting +/** + * Main function to plot the Heat Loss Scatter graph. + * Orchestrates input reading, data preparation, calculation, and plotting. + */ +function plotHeatLossScatter() { + console.log("Attempting to plot Heat Loss Scatter..."); + var plotDiv = $("#heatloss-plot"); + var plotBound = $("#heatloss-plot-bound"); + + // 1. Get Inputs & Config + const config = getHeatLossInputs(); + if (!config) return; // Should not happen with current getHeatLossInputs + + // 2. Check Data Sufficiency + const sufficiency = checkDataSufficiency(config, daily_data); + if (!sufficiency.sufficient) { + var messageHtml = "

Cannot plot heat loss:
" + sufficiency.messages.join("
") + "

"; + plotDiv.html(messageHtml); + return; + } + + // 3. Prepare Data (Handles Splitting) + const preparedData = prepareHeatLossPlotData(config, daily_data); + if (!preparedData || preparedData.totalPoints === 0) { + let noDataReason = "No valid data points found for this mode"; + if (config.shouldUseFixedRoomT) noDataReason += ` using fixed T_in=${config.fixedRoomTValue}°C`; + if (config.minDeltaT > -Infinity) noDataReason += ` with Min ΔT > ${config.minDeltaT}°C`; + if (config.splitDataEnabled) noDataReason += ` split by ${config.splitByValue}`; + noDataReason += "."; + plotDiv.html("

" + noDataReason + "

"); + return; + } + + // 4. Generate Plot Series (Scatter + Regression) + const plotSeries = []; + const allXValues = []; // For overall regression if needed + const allYValues = []; // For overall regression if needed + + // Create Scatter Series + for (const groupKey in preparedData.groups) { + const group = preparedData.groups[groupKey]; + if (group.data.length > 0) { + // Store original data separately for tooltip access if needed, Flot modifies the data array sometimes + const originalDataForTooltip = [...group.data]; + + plotSeries.push({ + // Use only x,y for plotting, keep original data separately + data: group.data.map(p => [p[0], p[1]]), + originalDataAccessor: 'originalData', // Custom property name + originalData: originalDataForTooltip, // Attach original data here + points: { show: true, radius: 3, fill: true, fillColor: hexToRgba(group.color, 0.6) }, + lines: { show: false }, + color: group.color, + label: group.label + ` (N=${group.data.length})`, + groupKey: groupKey // Store group key if needed later + }); + + // Collect all points for overall regression if needed + if (!config.splitRegressionEnabled) { + allXValues.push(...group.xValues); + allYValues.push(...group.yValues); + } + } + } + + // Create Regression Series + if (config.splitRegressionEnabled) { + // Calculate and add regression for each group + for (const groupKey in preparedData.groups) { + const group = preparedData.groups[groupKey]; + const regressionLine = calculateRegressionSeries( + group.xValues, + group.yValues, + `${group.label} Fit`, // Use group label in fit label + group.color, // Use same color as scatter + 0, // Min X for line start + 35 // Max X for line end (adjust as needed) + ); + if (regressionLine) { + plotSeries.push(regressionLine); + } + } + } else { + // Calculate and add a single overall regression line + if (allXValues.length >= 2) { + const overallRegressionLine = calculateRegressionSeries( + allXValues, + allYValues, + "Overall Fit", + 'rgba(0, 0, 255, 0.8)', // Specific color for overall fit (e.g., blue) + 0, + 35 + ); + if (overallRegressionLine) { + plotSeries.push(overallRegressionLine); + } + } else { + console.warn("Heat Loss Plot: Not enough data points (>=2) for overall regression."); + } + } + + + // 5. Get Plot Options + const plotOptions = getHeatLossPlotOptions(config.splitDataEnabled, plotSeries); + + // 6. Plotting var plotWidth = plotBound.width(); var plotHeight = plotBound.height(); - if (plotHeight < 200) plotHeight = 400; // Ensure minimum height + if (plotHeight < 300) plotHeight = 400; // Min height plotDiv.width(plotWidth); plotDiv.height(plotHeight); try { - // Clear previous plot content/messages - plotDiv.empty(); + plotDiv.empty(); // Clear previous content $.plot(plotDiv, plotSeries, plotOptions); console.log("Heat Loss Plot: Plot generated successfully."); } catch (e) { console.error("Heat Loss Plot: Error during flot plotting:", e); plotDiv.html("

Error generating plot.

"); } +} + +/** + * Helper function to convert hex color to rgba. + * Needed because Flot fillColor doesn't automatically inherit alpha from the main color. + * @param {string} hex - Hex color string (e.g., #FF6347). + * @param {number} alpha - Alpha value (0.0 to 1.0). + * @returns {string} rgba color string. + */ +function hexToRgba(hex, alpha) { + var r, g, b; + // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") + var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace(shorthandRegex, function(m, r, g, b) { + return r + r + g + g + b + b; + }); + + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result) { + r = parseInt(result[1], 16); + g = parseInt(result[2], 16); + b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } else { + // Fallback if hex is invalid + return `rgba(100, 100, 100, ${alpha})`; // Grey fallback + } } \ No newline at end of file From c50756a4462e6c052929243f010e3b8d31f6903a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 11 Apr 2025 10:20:05 +0200 Subject: [PATCH 17/30] Added combined_solar_kwh to db and schema --- apps/OpenEnergyMonitor/myheatpump/myheatpump_schema.php | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_schema.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump_schema.php index 1031a8a..89e4f06 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_schema.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_schema.php @@ -11,6 +11,7 @@ // Full period stats 'combined_elec_kwh' => array('type' => 'float', 'name'=>'Electricity consumption', 'group'=>'Stats: Combined', 'dp'=>0, 'unit'=>'kWh'), 'combined_heat_kwh' => array('type' => 'float', 'name'=>'Heat output', 'group'=>'Stats: Combined', 'dp'=>0, 'unit'=>'kWh'), + 'combined_solar_kwh' => array('type' => 'float', 'name'=>'Solar intput', 'group'=>'Stats: Combined', 'dp'=>2, 'unit'=>'kWh'), 'combined_cop' => array('type' => 'float', 'name'=>'COP', 'heading'=>"COP", 'group'=>'Stats: Combined', 'dp'=>1, 'unit'=>''), 'combined_data_length' => array('type' => 'float', 'name'=>'Data length', 'group'=>'Stats: Combined', 'dp'=>0, 'unit'=>''), 'combined_elec_mean' => array('type' => 'float', 'name'=>'Elec mean', 'group'=>'Stats: Combined', 'dp'=>0, 'unit'=>'W'), From 859c8be6c0ccd6d7f3b39e9d58b903f822bab210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 11 Apr 2025 10:40:27 +0200 Subject: [PATCH 18/30] Add solar to config & process it --- apps/OpenEnergyMonitor/myheatpump/myheatpump.js | 4 ++++ .../myheatpump/myheatpump_process.php | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js index cf79d40..0d5175a 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js @@ -47,6 +47,10 @@ config.app = { "auto_detect_cooling":{"type":"checkbox", "default":false, "name": "Auto detect cooling", "description":"Auto detect summer cooling if cooling status feed is not present"}, "enable_process_daily":{"type":"checkbox", "default":false, "name": "Enable daily pre-processor", "description":"Enable split between water and space heating in daily view"}, "start_date": { "type": "value", "default": 0, "name": "Start date", "description": _("Start date for all time values (unix timestamp)") }, + + // solar + "solar_elec_kwh": { "type": "feed", "autoname": "solar_elec_kwh", "description": "Cumulative solar energy kWh" }, + }; config.feeds = feed.list(); diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_process.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump_process.php index 20f7e20..7919239 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_process.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_process.php @@ -241,6 +241,16 @@ function get_heatpump_stats($feed,$app,$start,$end,$starting_power,$timezone = ' $heat_kwh = get_cumulative_kwh($feed,$app->config->heatpump_heat_kwh,$start,$end); } + $solar_kwh = null; // Initialize solar kWh variable + if (isset($app->config->solar_elec_kwh) && $app->config->solar_elec_kwh > 0) { // Check if solar feed is configured + $solar_kwh = get_cumulative_kwh($feed, $app->config->solar_elec_kwh, $start, $end); + if ($solar_kwh !== null) { + $solar_kwh = number_format($solar_kwh, 4, '.', '') * 1; // Format like the others + } + } + + $cop_stats["combined"]["solar_kwh"] = $solar_kwh; + $cop = null; if ($elec_kwh>0) { $cop = $heat_kwh / $elec_kwh; From 2ba6ab5c2bda17927d2826261e4ae0fd2544f530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 11 Apr 2025 20:43:46 +0200 Subject: [PATCH 19/30] Plotly for heat loss graph --- .../myheatpump/myheatpump.php | 7 +- .../myheatpump/myheatpump_heatloss.js | 533 ++++++++---------- 2 files changed, 229 insertions(+), 311 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index 0bc7713..c82ea38 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -446,8 +446,8 @@ From 26a610cf79336ba8770caa9967e95da4625ae0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 11 Apr 2025 21:03:00 +0200 Subject: [PATCH 21/30] Updated plotly to explicitly use 3.0.1 --- apps/OpenEnergyMonitor/myheatpump/myheatpump.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index 63c9318..f66cf6d 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -502,7 +502,7 @@
- +
@@ -560,8 +560,8 @@ config.db = ; - - + + From c11cda05a3fb229b8ca3a08d24144b674444ef9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 11 Apr 2025 22:12:17 +0200 Subject: [PATCH 22/30] Better colorscale --- .../myheatpump/myheatpump.php | 2 +- .../myheatpump/myheatpump_heatloss.js | 167 +++++++++++++++--- 2 files changed, 142 insertions(+), 27 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index f66cf6d..eca138a 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -560,7 +560,7 @@ config.db = ; - + diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index 6dfc14c..95a5888 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -71,10 +71,14 @@ function getHeatLossInputs() { } config.splitRegressionEnabled = config.splitDataEnabled && $("#heatloss_split_regression_check").is(":checked"); // Regression split only possible if data is split + // Color by solar gain? + config.solarColoringEnabled = $("#heatloss_solar_gain_color").is(":checked"); + // Keys for accessing data config.heatKey = config.bargraph_mode + "_heat_kwh"; config.insideTKey = "combined_roomT_mean"; config.outsideTKey = "combined_outsideT_mean"; + config.solarEKey = "combined_solar_kwh"; console.log("Heat Loss Inputs:", config); return config; @@ -152,6 +156,20 @@ function prepareHeatLossPlotData(config, daily_data) { const outsideTMap = new Map(daily_data[config.outsideTKey]); const insideTMap = (!config.shouldUseFixedRoomT && daily_data[config.insideTKey]) ? new Map(daily_data[config.insideTKey]) : null; const heatDataArray = daily_data[config.heatKey]; + + // --- START: Fetch and Map Solar Data --- + const solarDataArray = daily_data[config.solarEKey]; + let solarDataMap = null; + let solarDataAvailable = false; + if (solarDataArray && solarDataArray.length > 0) { + solarDataMap = new Map(solarDataArray); + solarDataAvailable = true; + console.log("Heat Loss Plot: Solar data found and mapped for key:", config.solarEKey); + } else { + console.log("Heat Loss Plot: Solar data not found or empty for key:", config.solarEKey); + // Continue without solar data, coloring will default later if enabled + } + // --- END: Fetch and Map Solar Data --- console.log("Heat Loss Plot: Processing", heatDataArray.length, "days of data for mode", config.bargraph_mode); @@ -177,6 +195,17 @@ function prepareHeatLossPlotData(config, daily_data) { insideTValue = null; // Should not happen if sufficiency check passed } + // --- START: Get Solar Value --- + let solarValue = null; // Default to null if not available or not found + if (solarDataAvailable && solarDataMap.has(timestamp)) { + const rawSolarValue = solarDataMap.get(timestamp); + // Ensure it's a valid number, otherwise keep it null + if (rawSolarValue !== null && typeof rawSolarValue === 'number' && !isNaN(rawSolarValue)) { + solarValue = rawSolarValue; + } + } + // --- END: Get Solar Value --- + // Check validity if (heatValue !== null && typeof heatValue === 'number' && !isNaN(heatValue) && insideTValue !== null && typeof insideTValue === 'number' && !isNaN(insideTValue) && @@ -216,6 +245,7 @@ function prepareHeatLossPlotData(config, daily_data) { xValues: [], // Holds deltaT yValues: [], // Holds heatValue (kW) timestamps: [], // Holds original timestamp for hover info + solarValues: [], // Holds solar values if available label: groupLabel // color property removed }; @@ -225,7 +255,7 @@ function prepareHeatLossPlotData(config, daily_data) { groupedData[groupKey].xValues.push(deltaT); groupedData[groupKey].yValues.push(heatValue); groupedData[groupKey].timestamps.push(timestamp); - + groupedData[groupKey].solarValues.push(solarValue); // Update overall bounds if (deltaT < overallMinX) overallMinX = deltaT; if (deltaT > overallMaxX) overallMaxX = deltaT; @@ -458,46 +488,126 @@ function plotHeatLossScatter() { const plotData = []; // Array to hold Plotly traces const allXValues = []; // For overall regression const allYValues = []; // For overall regression + const allSolarValues = []; resetPlotColorIndex(); // Reset colors for this plot generation + // --- Determine overall Solar Min/Max for consistent colorscale --- + let overallMinSolar = Infinity; + let overallMaxSolar = -Infinity; + let hasAnySolarData = false; + if (config.solarColoringEnabled) { + for (const groupKey in preparedData.groups) { + const group = preparedData.groups[groupKey]; + if (group.solarValues && group.solarValues.length > 0) { + group.solarValues.forEach(val => { + if (val !== null && typeof val === 'number' && !isNaN(val)) { + hasAnySolarData = true; + if (val < overallMinSolar) overallMinSolar = val; + if (val > overallMaxSolar) overallMaxSolar = val; + } + }); + } + } + // Handle case where no valid solar data exists despite checkbox being ticked + if (!hasAnySolarData) { + console.log("Heat Loss Plot: Solar coloring enabled, but no valid solar data found. Reverting to group colors."); + // config.solarColoringEnabled = false; // Or handle directly in loop + } else { + console.log(`Heat Loss Plot: Applying solar coloring. Solar range: [${overallMinSolar}, ${overallMaxSolar}]`); + } + } + // Create Scatter and potentially individual Regression Traces for (const groupKey in preparedData.groups) { const group = preparedData.groups[groupKey]; if (group.xValues.length > 0) { const groupColor = getNextPlotColor(); // Assign color per group + // --- Prepare Customdata for Hover --- + const customDataForHover = group.timestamps.map((ts, index) => { + let dateStr = "Invalid Date"; + try { + if (ts !== null && ts !== undefined && !isNaN(ts)) { + const dateObj = new Date(ts); + if (!isNaN(dateObj.getTime())) { + dateStr = dateObj.toLocaleDateString(); + } + } + } catch (e) { console.warn("Error formatting date:", e); dateStr = "Date Error"; } + + const solarVal = group.solarValues[index]; + // Format solar value nicely for hover, handle null/undefined + const solarStr = (solarVal !== null && typeof solarVal === 'number' && !isNaN(solarVal)) + ? solarVal.toFixed(2) + ' kWh' + : 'N/A'; + + return { date: dateStr, solar: solarStr, rawSolar: solarVal }; // Include raw value if needed for filtering later + }); + + let useSolarColoringForThisGroup = config.solarColoringEnabled && hasAnySolarData; + + let markerConfig = { + size: 6, + opacity: 0.7, + color: groupColor // Default to group color + }; + + if (useSolarColoringForThisGroup) { + // Check if this specific group has any valid solar values + const groupHasSolar = group.solarValues.some(sv => sv !== null && typeof sv === 'number' && !isNaN(sv)); + if (groupHasSolar) { + markerConfig = { + ...markerConfig, // Keep size, opacity + color: group.solarValues, // Use the array of solar values for color + colorscale: 'Jet', // Example: Yellow-Green-Blue + // Use cmin/cmax for consistent scale across groups if splitting + cmin: overallMinSolar, + cmax: overallMaxSolar, + colorbar: { + title: { + text: 'Solar Gain
(kWh/day)', // Add units, allow line break + side: 'right' + } + }, + // Ensure points with null solar value get a specific color (e.g., grey) + // Note: Plotly behavior with nulls in 'color' array can vary. + // A common approach is pre-filtering or assigning a value outside the cmin/cmax range. + // For simplicity here, we rely on Plotly's default (often transparent or lowest color). + }; + console.log(`Applying solar colorscale to group: ${group.label}`); + } else { + // This group has no solar data, use the group color + console.log(`Solar coloring enabled, but group ${group.label} has no solar data. Using group color.`); + useSolarColoringForThisGroup = false; // Fallback for this specific group + markerConfig.color = groupColor; // Already set, but being explicit + } + } else { + // Solar coloring not enabled OR no valid solar data found overall + markerConfig.color = groupColor; + } + // Create Scatter Trace + // --- Create Scatter Trace --- const scatterTrace = { x: group.xValues, y: group.yValues, mode: 'markers', type: 'scatter', - name: group.label + ` (N=${group.xValues.length})`, // Name for legend - marker: { - color: groupColor, - size: 6, - opacity: 0.7 - }, - // Pass timestamps as customdata for hovertemplate - customdata: group.timestamps.map(ts => { - try { - // Format date robustly - if (ts === null || ts === undefined || isNaN(ts)) return "Invalid Date"; - const dateObj = new Date(ts); - if (isNaN(dateObj.getTime())) return "Invalid Date"; - return dateObj.toLocaleDateString(); - } catch (e) { - console.warn("Error formatting date for tooltip:", e); - return "Date Error"; } - }), - // Define hover text format - hovertemplate: - `Date: %{customdata}
` + - `ΔT: %{x:.1f} K
` + // Use K for Kelvin difference - `Avg Heat: %{y:.2f} kW` + - ``, // Hides the trace name suffix in tooltip - legendgroup: groupKey // Group scatter and its line in legend + name: group.label + ` (N=${group.xValues.length})`, + marker: markerConfig, // Assign the configured marker object + customdata: customDataForHover, // Use the enhanced custom data + hovertemplate: // Updated hover template + `Date: %{customdata.date}
` + + `ΔT: %{x:.1f} K
` + + `Avg Heat: %{y:.2f} kW
` + + `Solar Gain: %{customdata.solar}` + // Add solar value + ``, + legendgroup: groupKey, + // If using solar coloring, consider hiding individual groups from legend + // unless splitting is also active, to avoid clutter. + // showlegend: !(useSolarColoringForThisGroup && !config.splitDataEnabled) + // Let's keep legend showing for now. }; plotData.push(scatterTrace); @@ -551,6 +661,11 @@ function plotHeatLossScatter() { const layout = getPlotlyLayoutOptions(); // Dynamically adjust x-axis range based on data if needed, otherwise uses defaults/rangemode // layout.xaxis.range = [0, Math.max(35, preparedData.overallMaxX * 1.1)]; + // If solar coloring was used, adjust right margin slightly for colorbar + + if (config.solarColoringEnabled && hasAnySolarData) { + layout.margin = { l: 60, r: 80, t: 30, b: 50 }; // Increased right margin + } // 6. Plotting with Plotly const plotConfig = { From 554d89ea3edd1ec97cd3c59cfd93904b7268ec59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Sun, 13 Apr 2025 21:14:53 +0200 Subject: [PATCH 23/30] Improved legend scaling, optional solar feed --- apps/OpenEnergyMonitor/myheatpump/myheatpump.js | 2 +- .../myheatpump/myheatpump_heatloss.js | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js index fd92f12..8f0a5e6 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.js @@ -49,7 +49,7 @@ config.app = { "start_date": { "type": "value", "default": 0, "name": "Start date", "description": _("Start date for all time values (unix timestamp)") }, // solar - "solar_elec_kwh": { "type": "feed", "autoname": "solar_elec_kwh", "description": "Cumulative solar energy kWh" }, + "solar_elec_kwh": { "type": "feed", "autoname": "solar_elec_kwh", "optional": true, "description": "Cumulative solar energy kWh" }, }; config.feeds = feed.list(); diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index 95a5888..42a9a1c 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -215,7 +215,7 @@ function prepareHeatLossPlotData(config, daily_data) { if (heatValue > 0 && deltaT > config.minDeltaT) { let groupKey = "all_data"; // Default if splitting is disabled - let groupLabel = 'Daily Heat Demand (' + config.bargraph_mode + (config.shouldUseFixedRoomT ? ', Fixed T_in=' + config.fixedRoomTValue + '°C' : '') + ')'; + let groupLabel = 'Daily Heat Demand
(' + config.bargraph_mode + (config.shouldUseFixedRoomT ? ', Fixed T_in=' + config.fixedRoomTValue + '°C' : '') + ')'; // groupColor removed // --- Determine Group Key if Splitting --- @@ -316,9 +316,9 @@ function calculatePlotlyRegressionTrace(xValues, yValues, traceNamePrefix, color const { slope, intercept, r2 } = regressionResult; // Format label string using the calculated r2 - regressionLabel = `HLC=${(slope * 1000).toFixed(1)} W/K` + - `, Int=${(intercept * 1000).toFixed(1)} W` + - ` (R²=${r2 !== undefined && r2 !== null ? r2.toFixed(3) : 'N/A'}, N=${xValues.length})`; + regressionLabel = `HLC=${(slope * 1000).toFixed(0)} W/K` + + `, Int=${(intercept * 1000).toFixed(0)} W` + + ` (R²=${r2 !== undefined && r2 !== null ? r2.toFixed(2) : 'N/A'})`; // --- Determine the actual range for the line segment [startX, endX] (respecting y >= 0 and plot bounds) --- @@ -566,7 +566,7 @@ function plotHeatLossScatter() { cmax: overallMaxSolar, colorbar: { title: { - text: 'Solar Gain
(kWh/day)', // Add units, allow line break + text: 'Solar Gain (kWh/day)', // Add units, allow line break side: 'right' } }, @@ -623,7 +623,7 @@ function plotHeatLossScatter() { ); if (regressionTrace) { // Prepend group label to the detailed regression fit label for clarity - regressionTrace.name = `${group.label} Fit: ${regressionTrace.name}`; + regressionTrace.name = `${group.label} Fit:
${regressionTrace.name}`; regressionTrace.legendgroup = groupKey; // Match legend group // Optionally shorten the scatter name if the fit line has full details // scatterTrace.name = group.label; @@ -633,6 +633,7 @@ function plotHeatLossScatter() { // Collect all points for a single overall regression allXValues.push(...group.xValues); allYValues.push(...group.yValues); + allSolarValues.push(...group.solarValues); } } } @@ -648,7 +649,7 @@ function plotHeatLossScatter() { 35 ); if (overallRegressionTrace) { - overallRegressionTrace.name = `Overall Fit: ${overallRegressionTrace.name}`; // Add prefix + overallRegressionTrace.name = `Overall Fit:
${overallRegressionTrace.name}`; // Add prefix plotData.push(overallRegressionTrace); } else { // Warning already logged in calculatePlotlyRegressionTrace or linearRegression @@ -684,5 +685,3 @@ function plotHeatLossScatter() { plotDiv.html("
Error generating plot. Check console.
"); } } - - From 50a7e09b6633791f8710544972ac7a3b1812e654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 11 Apr 2025 23:24:09 +0200 Subject: [PATCH 24/30] regression-js test - apparently only 1D --- .../myheatpump/myheatpump.php | 3 +- .../myheatpump/myheatpump_heatloss.js | 97 ++++++++++++ .../myheatpump/myheatpump_regression.js | 148 ++++++++++++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index eca138a..14a0a84 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -560,8 +560,9 @@ config.db = ; - + + diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index 42a9a1c..7138c23 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -638,6 +638,11 @@ function plotHeatLossScatter() { } } + // Perform the MULTILINEAR regression test on the overall aggregated data + console.log("--- Running Multilinear Regression Test (Overall Data) ---"); + performMultilinearRegressionTest(allXValues, allSolarValues, allYValues); + console.log("--- End Multilinear Regression Test ---"); + // Create Overall Regression Trace if not splitting regression if (!config.splitRegressionEnabled && allXValues.length >= 2) { const overallRegressionTrace = calculatePlotlyRegressionTrace( @@ -685,3 +690,95 @@ function plotHeatLossScatter() { plotDiv.html("
Error generating plot. Check console.
"); } } + + +/** + * Performs a multilinear regression test using Delta T and Solar Gain + * as independent variables to predict Heat Output. + * Logs the results to the console and returns the regression details. + * + * Filters out data points where any of the required values (heat, deltaT, solar) + * are null or non-numeric. + * + * @param {number[]} deltaTValues - Array of temperature differences (X1). + * @param {number[]} solarValues - Array of solar gain values (X2). + * @param {number[]} heatOutputValues - Array of heat output values (Y, dependent). + * @returns {object|null} The result object from multilinearRegression, or null if it fails. + */ +function performMultilinearRegressionTest(deltaTValues, solarValues, heatOutputValues) { + console.log("Attempting Multilinear Regression Test: Heat ~ DeltaT + SolarGain"); + + if (!deltaTValues || !solarValues || !heatOutputValues) { + console.warn("Multilinear Test: Missing one or more input data arrays."); + return null; + } + + const n_initial = heatOutputValues.length; + if (deltaTValues.length !== n_initial || solarValues.length !== n_initial) { + console.warn(`Multilinear Test: Input array lengths mismatch. Heat: ${n_initial}, DeltaT: ${deltaTValues.length}, Solar: ${solarValues.length}`); + return null; + } + + // Filter data: Keep only points where Heat, DeltaT, AND Solar are valid numbers + const filteredHeat = []; + const filteredDeltaT = []; + const filteredSolar = []; + + for (let i = 0; i < n_initial; i++) { + const heat = heatOutputValues[i]; + const deltaT = deltaTValues[i]; + const solar = solarValues[i]; + + // Check if all three values are valid numbers + if (heat !== null && typeof heat === 'number' && !isNaN(heat) && + deltaT !== null && typeof deltaT === 'number' && !isNaN(deltaT) && + solar !== null && typeof solar === 'number' && !isNaN(solar)) + { + filteredHeat.push(heat); + filteredDeltaT.push(deltaT); + filteredSolar.push(solar); + } + } + + const n_filtered = filteredHeat.length; + console.log(`Multilinear Test: Filtered data points from ${n_initial} to ${n_filtered} (removing points with missing heat, deltaT, or solar).`); + + // Check if enough data points remain for regression (need >= num_independent_vars + 1) + const num_independent_vars = 2; // DeltaT, Solar + if (n_filtered < num_independent_vars + 1) { + console.warn(`Multilinear Test: Not enough valid data points (${n_filtered}) for regression. Need at least ${num_independent_vars + 1}.`); + return null; + } + + // Prepare independent variables array for the regression function + // Format: [[x1_1, x1_2,...], [x2_1, x2_2,...]] + const independentVars = [filteredDeltaT, filteredSolar]; + + // Call the multilinear regression function (assuming it's globally available) + let regressionResult = null; + try { + // Assuming multilinearRegression is defined in myheatpump_regression.js and loaded + regressionResult = multilinearRegression(independentVars, filteredHeat); + } catch (e) { + console.error("Multilinear Test: Error calling multilinearRegression function:", e); + return null; + } + + + console.log(regressionResult); + // Log results + if (regressionResult) { + console.log("--- Multilinear Regression Fit Details ---"); + console.log(` Equation: Heat_kW ≈ ${regressionResult.intercept.toFixed(4)} + ( ${regressionResult.coefficients[0].toFixed(4)} * DeltaT ) + ( ${regressionResult.coefficients[1].toFixed(4)} * SolarGain_kWh )`); + console.log(` Intercept (β₀): ${regressionResult.intercept.toFixed(4)} kW`); + console.log(` Coefficient for DeltaT (β₁): ${regressionResult.coefficients[0].toFixed(4)} kW/K`); + console.log(` Coefficient for SolarGain (β₂): ${regressionResult.coefficients[1].toFixed(4)} kW/(kWh/day)`); + console.log(` R-squared: ${regressionResult.r2.toFixed(4)}`); + console.log(` Based on ${n_filtered} valid data points.`); + console.log("------------------------------------------"); + } else { + console.warn("Multilinear Test: Regression calculation failed or returned null."); + } + + return regressionResult; +} \ No newline at end of file diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js index e5c5c2b..6e3c338 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js @@ -49,4 +49,152 @@ function linearRegression(x, y) { intercept: intercept, r2: r2 // Uncomment if you want R-squared }; +} + +// ********************************************** +// ** NEW MULTILINEAR REGRESSION FUNCTION ** +// ********************************************** + +/** + * Performs Multilinear Regression using the 'regression-js' library. + * Finds coefficients (β₁, β₂, ...) and intercept (β₀) for the model: + * Y = β₀ + β₁X₁ + β₂X₂ + ... + β<0xE2><0x82><0x99>X<0xE2><0x82><0x99> + * + * @param {number[][]} independentVars - An array of arrays. Each inner array holds the data + * for one independent variable (X₁, X₂, ...). + * Example: [[x1_1, x1_2,...], [x2_1, x2_2,...], ...] + * @param {number[]} dependentVar - An array holding the data for the dependent variable (Y). + * Example: [y1, y2, y3,...] + * @param {number} [precision=6] - Optional number of decimal places for coefficients. + * + * @returns {object|null} An object containing: + * - `coefficients`: An array of coefficients [β₁, β₂, ...] corresponding + * to the order of `independentVars`. + * - `intercept`: The calculated intercept (β₀). + * - `r2`: The R-squared value (coefficient of determination). + * - `equation`: The raw equation array from the library [β₁, β₂, ..., β₀]. + * - `string`: A string representation of the equation. + * Or null if regression cannot be performed (e.g., insufficient data, library missing). + */ +function multilinearRegression(independentVars, dependentVar, precision = 6) { + + // ****** DEBUG LOG ADDED ****** + console.log("DEBUG multilinearRegression: Received independentVars with length:", independentVars ? independentVars.length : 'undefined/null'); + if (independentVars && independentVars.length > 0) { + console.log("DEBUG multilinearRegression: Length of first independent var array:", independentVars[0] ? independentVars[0].length : 'undefined/null'); + } + if (independentVars && independentVars.length > 1) { + console.log("DEBUG multilinearRegression: Length of second independent var array:", independentVars[1] ? independentVars[1].length : 'undefined/null'); + } + console.log("DEBUG multilinearRegression: Received dependentVar with length:", dependentVar ? dependentVar.length : 'undefined/null'); + // ****************************** + + // 1. Check if the regression library is available + if (typeof regression === 'undefined') { + console.error("Multilinear Regression Error: The 'regression-js' library is not loaded. Please ensure it's included via - - - + + + + diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js index 6e3c338..5ef949d 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js @@ -51,6 +51,112 @@ function linearRegression(x, y) { }; } + + +/** + * Performs Multilinear Regression using matrix calculations via math.js. + * Finds coefficients (β₁, β₂, ...) and intercept (β₀) for the model: + * Y = β₀ + β₁X₁ + β₂X₂ + ... + β<0xE2><0x82><0x99>X<0xE2><0x82><0x99> + * Calculates β = (XᵀX)⁻¹XᵀY + * + * @param {number[][]} independentVars - An array of arrays. Each inner array holds the data + * for one independent variable (X₁, X₂, ...). + * Example: [[x1_1, x1_2,...], [x2_1, x2_2,...], ...] + * @param {number[]} dependentVar - An array holding the data for the dependent variable (Y). + * Example: [y1, y2, y3,...] + * + * @returns {object|null} An object containing: + * - `coefficients`: An array [β₁, β₂, ...] corresponding to `independentVars`. + * - `intercept`: The calculated intercept (β₀). + * - `beta`: The full coefficient vector [β₀, β₁, β₂, ...]. + * - `r2`: The R-squared value (coefficient of determination). + * Or null if regression cannot be performed. + */ +function multilinearRegression(independentVars, dependentVar) { + // 1. Check if math.js library is available + if (typeof math === 'undefined') { + console.error("Multilinear Regression Error: The 'math.js' library is not loaded."); + return null; + } + + // 2. Input Validation (same as before) + if (!independentVars || independentVars.length === 0 || !dependentVar) { + console.error("Multilinear Regression Error: Invalid input variables provided."); + return null; + } + const numIndependentVars = independentVars.length; + const numDataPoints = dependentVar.length; + + if (numDataPoints <= numIndependentVars) { // Need more points than variables (incl intercept) + console.error(`Multilinear Regression Error: Insufficient data points (${numDataPoints}). Need more than ${numIndependentVars} for ${numIndependentVars} independent vars + intercept.`); + return null; + } + // Check array lengths consistency + for (let i = 0; i < numIndependentVars; i++) { + if (!independentVars[i] || independentVars[i].length !== numDataPoints) { + console.error(`Multilinear Regression Error: Independent variable #${i + 1} length mismatch.`); + return null; + } + } + // Optional: Check for non-numeric values (math.js might handle some, but good practice) + + + try { + // 3. Construct the Design Matrix X (add column of 1s for intercept) + const X_data = []; + for (let i = 0; i < numDataPoints; i++) { + const row = [1]; // Start with 1 for the intercept + for (let j = 0; j < numIndependentVars; j++) { + row.push(independentVars[j][i]); + } + X_data.push(row); + } + const X = math.matrix(X_data); + + // 4. Create the Dependent Variable Vector Y + const Y = math.matrix(dependentVar); + + // 5. Calculate Coefficients: β = (XᵀX)⁻¹XᵀY + const XT = math.transpose(X); + const XTX = math.multiply(XT, X); + const XTX_inv = math.inv(XTX); + const XTY = math.multiply(XT, Y); + const beta = math.multiply(XTX_inv, XTY).valueOf(); // .valueOf() converts math.js matrix to plain array + + // beta array is [intercept, coeff_X1, coeff_X2, ...] + const intercept = beta[0]; + const coefficients = beta.slice(1); + + // 6. Calculate R-squared (Optional but recommended) + const Y_mean = math.mean(dependentVar); + const Y_predicted = math.multiply(X, beta).valueOf(); // Get predicted Y values + + let ss_tot = 0; // Total sum of squares + let ss_res = 0; // Residual sum of squares + for (let i = 0; i < numDataPoints; i++) { + ss_tot += Math.pow(dependentVar[i] - Y_mean, 2); + ss_res += Math.pow(dependentVar[i] - Y_predicted[i], 2); + } + + const r2 = (ss_tot === 0) ? 1 : 1 - (ss_res / ss_tot); // Handle case where all Y are the same + + return { + coefficients: coefficients, // [β₁, β₂, ...] + intercept: intercept, // β₀ + beta: beta, // Full vector [β₀, β₁, β₂, ...] + r2: r2 + }; + + } catch (error) { + console.error("Error during multilinear regression calculation using math.js:", error); + // Common errors: Matrix not invertible (e.g., perfect collinearity), non-numeric data. + if (error.message && error.message.includes("Cannot calculate inverse")) { + console.error(" >> This often indicates perfect multicollinearity among independent variables."); + } + return null; + } +} + // ********************************************** // ** NEW MULTILINEAR REGRESSION FUNCTION ** // ********************************************** @@ -76,7 +182,7 @@ function linearRegression(x, y) { * - `string`: A string representation of the equation. * Or null if regression cannot be performed (e.g., insufficient data, library missing). */ -function multilinearRegression(independentVars, dependentVar, precision = 6) { +function multilinearRegression_rjs(independentVars, dependentVar, precision = 6) { // ****** DEBUG LOG ADDED ****** console.log("DEBUG multilinearRegression: Received independentVars with length:", independentVars ? independentVars.length : 'undefined/null'); From 97a217988ecd5d5b923574f5fb9e5c3b59f11b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20K=C3=BChne?= Date: Fri, 11 Apr 2025 23:47:50 +0200 Subject: [PATCH 26/30] Added detailed statistics vis jStat --- .../myheatpump/myheatpump.php | 4 +- .../myheatpump/myheatpump_heatloss.js | 47 ++- .../myheatpump/myheatpump_regression.js | 286 ++++++------------ 3 files changed, 134 insertions(+), 203 deletions(-) diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php index 6892b5e..9ec5559 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump.php +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump.php @@ -560,10 +560,10 @@ config.db = ; - + + - diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js index 7138c23..e8bf8f9 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_heatloss.js @@ -745,8 +745,9 @@ function performMultilinearRegressionTest(deltaTValues, solarValues, heatOutputV // Check if enough data points remain for regression (need >= num_independent_vars + 1) const num_independent_vars = 2; // DeltaT, Solar - if (n_filtered < num_independent_vars + 1) { - console.warn(`Multilinear Test: Not enough valid data points (${n_filtered}) for regression. Need at least ${num_independent_vars + 1}.`); + const p_params = num_independent_vars + 1; // Number of parameters + if (n_filtered <= p_params) { // Check against p_params now + console.warn(`Multilinear Test: Not enough valid data points (${n_filtered}). Need more than ${p_params} for regression inference.`); return null; } @@ -769,13 +770,45 @@ function performMultilinearRegressionTest(deltaTValues, solarValues, heatOutputV // Log results if (regressionResult) { console.log("--- Multilinear Regression Fit Details ---"); - console.log(` Equation: Heat_kW ≈ ${regressionResult.intercept.toFixed(4)} + ( ${regressionResult.coefficients[0].toFixed(4)} * DeltaT ) + ( ${regressionResult.coefficients[1].toFixed(4)} * SolarGain_kWh )`); - console.log(` Intercept (β₀): ${regressionResult.intercept.toFixed(4)} kW`); - console.log(` Coefficient for DeltaT (β₁): ${regressionResult.coefficients[0].toFixed(4)} kW/K`); - console.log(` Coefficient for SolarGain (β₂): ${regressionResult.coefficients[1].toFixed(4)} kW/(kWh/day)`); + console.log(` Model: Heat_kW = β₀ + β₁*DeltaT + β₂*SolarGain_kWh`); + console.log(` N = ${regressionResult.n}, Parameters (p) = ${regressionResult.p}, DF = ${regressionResult.degreesOfFreedom}`); console.log(` R-squared: ${regressionResult.r2.toFixed(4)}`); - console.log(` Based on ${n_filtered} valid data points.`); + console.log(` SSE: ${regressionResult.sse.toFixed(4)}`); + + // Format and log coefficient details + const paramNames = ['Intercept (β₀)', 'DeltaT (β₁)', 'SolarGain (β₂)']; + console.log("\n Parameter Estimates:"); + console.log(` ${'Parameter'.padEnd(18)} ${'Estimate'.padStart(12)} ${'Std. Error'.padStart(12)} ${'t-statistic'.padStart(12)} ${'p-value'.padStart(12)} ${'95% CI'.padStart(25)}`); + console.log(` ${'-'.repeat(18)} ${'-'.repeat(12)} ${'-'.repeat(12)} ${'-'.repeat(12)} ${'-'.repeat(12)} ${'-'.repeat(25)}`); + + if (regressionResult.beta && regressionResult.standardErrors) { // Check if inference results exist + regressionResult.beta.forEach((coeff, i) => { + const name = paramNames[i] || `Var_${i}`; // Fallback name + const estimateStr = coeff.toFixed(4).padStart(12); + const seStr = regressionResult.standardErrors[i].toFixed(4).padStart(12); + const tStatStr = regressionResult.tStats[i].toFixed(3).padStart(12); + // Format p-value: show "<0.001" or fixed decimals + let pValStr = "N/A"; + if (!isNaN(regressionResult.pValues[i])) { + pValStr = regressionResult.pValues[i] < 0.001 ? "<0.001" : regressionResult.pValues[i].toFixed(3); + } + pValStr = pValStr.padStart(12); + + const ci = regressionResult.confidenceIntervals[i]; + const ciStr = `[${ci[0].toFixed(4)}, ${ci[1].toFixed(4)}]`.padStart(25); + + console.log(` ${name.padEnd(18)} ${estimateStr} ${seStr} ${tStatStr} ${pValStr} ${ciStr}`); + }); + } else { + // Log basic coefficients if inference failed + console.log(` Intercept (β₀): ${regressionResult.intercept.toFixed(4)}`); + regressionResult.coefficients.forEach((coeff, i) => { + console.log(` Coefficient ${i+1}: ${coeff.toFixed(4)}`); + }); + console.log(" (Could not calculate SE, t-stats, p-values, or CI - check warnings/errors)"); + } console.log("------------------------------------------"); + } else { console.warn("Multilinear Test: Regression calculation failed or returned null."); } diff --git a/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js b/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js index 5ef949d..f7bc4d0 100644 --- a/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js +++ b/apps/OpenEnergyMonitor/myheatpump/myheatpump_regression.js @@ -55,57 +55,58 @@ function linearRegression(x, y) { /** * Performs Multilinear Regression using matrix calculations via math.js. + * Calculates coefficients, standard errors, t-stats, p-values, and confidence intervals. * Finds coefficients (β₁, β₂, ...) and intercept (β₀) for the model: * Y = β₀ + β₁X₁ + β₂X₂ + ... + β<0xE2><0x82><0x99>X<0xE2><0x82><0x99> * Calculates β = (XᵀX)⁻¹XᵀY * - * @param {number[][]} independentVars - An array of arrays. Each inner array holds the data - * for one independent variable (X₁, X₂, ...). - * Example: [[x1_1, x1_2,...], [x2_1, x2_2,...], ...] - * @param {number[]} dependentVar - An array holding the data for the dependent variable (Y). - * Example: [y1, y2, y3,...] + * @param {number[][]} independentVars - Array of arrays for independent variables (X₁, X₂, ...). + * @param {number[]} dependentVar - Array for the dependent variable (Y). + * @param {number} [confidenceLevel=0.95] - The desired confidence level for intervals (e.g., 0.95 for 95%). * - * @returns {object|null} An object containing: - * - `coefficients`: An array [β₁, β₂, ...] corresponding to `independentVars`. - * - `intercept`: The calculated intercept (β₀). - * - `beta`: The full coefficient vector [β₀, β₁, β₂, ...]. - * - `r2`: The R-squared value (coefficient of determination). - * Or null if regression cannot be performed. + * @returns {object|null} An object containing regression results including: + * - `coefficients`: Array [β₁, β₂, ...]. + * - `intercept`: The intercept (β₀). + * - `beta`: Full coefficient vector [β₀, β₁, β₂, ...]. + * - `r2`: R-squared value. + * - `standardErrors`: Array of SEs for [β₀, β₁, β₂, ...]. + * - `tStats`: Array of t-statistics for [β₀, β₁, β₂, ...]. + * - `pValues`: Array of p-values for [β₀, β₁, β₂, ...]. + * - `confidenceIntervals`: Array of CIs [[lower, upper], ...] for [β₀, β₁, β₂, ...]. + * - `degreesOfFreedom`: Error degrees of freedom (n-p). + * Or null if regression fails. */ -function multilinearRegression(independentVars, dependentVar) { - // 1. Check if math.js library is available +function multilinearRegression(independentVars, dependentVar, confidenceLevel = 0.95) { + // 1. Check if libraries are available if (typeof math === 'undefined') { console.error("Multilinear Regression Error: The 'math.js' library is not loaded."); return null; } - - // 2. Input Validation (same as before) - if (!independentVars || independentVars.length === 0 || !dependentVar) { - console.error("Multilinear Regression Error: Invalid input variables provided."); - return null; + if (typeof jStat === 'undefined') { + console.error("Multilinear Regression Error: The 'jStat' library is not loaded (needed for confidence intervals)."); + // Optionally, proceed without CIs, or return null + // return null; + console.warn(" >> Proceeding without calculating confidence intervals, p-values, etc."); } + + + // 2. Input Validation + if (!independentVars || independentVars.length === 0 || !dependentVar) { /* ... */ return null; } const numIndependentVars = independentVars.length; const numDataPoints = dependentVar.length; + const p = numIndependentVars + 1; // Number of parameters (coefficients + intercept) - if (numDataPoints <= numIndependentVars) { // Need more points than variables (incl intercept) - console.error(`Multilinear Regression Error: Insufficient data points (${numDataPoints}). Need more than ${numIndependentVars} for ${numIndependentVars} independent vars + intercept.`); + if (numDataPoints <= p) { // Need more points than parameters estimated + console.error(`Multilinear Regression Error: Insufficient data points (${numDataPoints}). Need more than ${p} for ${numIndependentVars} independent vars + intercept.`); return null; } - // Check array lengths consistency - for (let i = 0; i < numIndependentVars; i++) { - if (!independentVars[i] || independentVars[i].length !== numDataPoints) { - console.error(`Multilinear Regression Error: Independent variable #${i + 1} length mismatch.`); - return null; - } - } - // Optional: Check for non-numeric values (math.js might handle some, but good practice) - + // Check array lengths consistency... (same as before) try { - // 3. Construct the Design Matrix X (add column of 1s for intercept) + // 3. Construct Design Matrix X (with intercept column) const X_data = []; for (let i = 0; i < numDataPoints; i++) { - const row = [1]; // Start with 1 for the intercept + const row = [1]; // Intercept column for (let j = 0; j < numIndependentVars; j++) { row.push(independentVars[j][i]); } @@ -113,194 +114,91 @@ function multilinearRegression(independentVars, dependentVar) { } const X = math.matrix(X_data); - // 4. Create the Dependent Variable Vector Y + // 4. Create Dependent Variable Vector Y const Y = math.matrix(dependentVar); // 5. Calculate Coefficients: β = (XᵀX)⁻¹XᵀY const XT = math.transpose(X); const XTX = math.multiply(XT, X); - const XTX_inv = math.inv(XTX); + const XTX_inv = math.inv(XTX); // (XᵀX)⁻¹ const XTY = math.multiply(XT, Y); - const beta = math.multiply(XTX_inv, XTY).valueOf(); // .valueOf() converts math.js matrix to plain array + const beta_vector = math.multiply(XTX_inv, XTY); // Result is a column matrix + const beta = beta_vector.valueOf().flat(); // .valueOf().flat() converts math.js matrix to plain 1D array // beta array is [intercept, coeff_X1, coeff_X2, ...] const intercept = beta[0]; const coefficients = beta.slice(1); - // 6. Calculate R-squared (Optional but recommended) - const Y_mean = math.mean(dependentVar); - const Y_predicted = math.multiply(X, beta).valueOf(); // Get predicted Y values + // 6. Calculate Residuals and SSE + const Y_predicted_vector = math.multiply(X, beta_vector); + const residuals_vector = math.subtract(Y, Y_predicted_vector); + const residuals = residuals_vector.valueOf().flat(); // Plain 1D array of residuals + const ss_res = residuals.reduce((sum, r) => sum + r * r, 0); // Sum of Squared Errors (SSE) - let ss_tot = 0; // Total sum of squares - let ss_res = 0; // Residual sum of squares - for (let i = 0; i < numDataPoints; i++) { - ss_tot += Math.pow(dependentVar[i] - Y_mean, 2); - ss_res += Math.pow(dependentVar[i] - Y_predicted[i], 2); - } + // 7. Calculate R-squared + const Y_mean = math.mean(dependentVar); + const ss_tot = dependentVar.reduce((sum, y) => sum + Math.pow(y - Y_mean, 2), 0); + const r2 = (ss_tot === 0) ? 1 : 1 - (ss_res / ss_tot); + + // --- Statistical Inference Calculations --- + let standardErrors = null, tStats = null, pValues = null, confidenceIntervals = null; + const degreesOfFreedom = numDataPoints - p; + + if (degreesOfFreedom <= 0) { + console.warn("Multilinear Regression Warning: Degrees of freedom is not positive. Cannot calculate standard errors or confidence intervals."); + } else if (typeof jStat !== 'undefined') { // Only proceed if jStat is loaded and df > 0 + try { + // Estimate variance of residuals: s² = SSE / (n - p) + const residual_variance = ss_res / degreesOfFreedom; + + // Standard Errors: sqrt(diagonal elements of s² * (XᵀX)⁻¹) + // math.diag extracts the diagonal from the matrix + const diag_XTX_inv = math.diag(XTX_inv).valueOf(); // Get diagonal as plain array + standardErrors = diag_XTX_inv.map(d => math.sqrt(residual_variance * d)); + + // t-statistics: coefficient / standard_error + tStats = beta.map((b, i) => standardErrors[i] === 0 ? NaN : b / standardErrors[i]); // Avoid division by zero + + // p-values (two-tailed test: H0: βj = 0) + pValues = tStats.map(t => isNaN(t) ? NaN : jStat.studentt.cdf(-Math.abs(t), degreesOfFreedom) * 2); + + // Confidence Intervals: coeff ± t_crit * SE + const alpha = 1 - confidenceLevel; + const t_crit = jStat.studentt.inv(1 - alpha / 2, degreesOfFreedom); // Critical t-value + + confidenceIntervals = beta.map((b, i) => { + const marginOfError = t_crit * standardErrors[i]; + return [b - marginOfError, b + marginOfError]; + }); + + } catch (statError) { + console.error("Error during statistical inference calculations (SE, CI):", statError); + // Set inference results to null if calculation fails + standardErrors = null; tStats = null; pValues = null; confidenceIntervals = null; + } + } // End if jStat available and df > 0 - const r2 = (ss_tot === 0) ? 1 : 1 - (ss_res / ss_tot); // Handle case where all Y are the same return { coefficients: coefficients, // [β₁, β₂, ...] intercept: intercept, // β₀ beta: beta, // Full vector [β₀, β₁, β₂, ...] - r2: r2 + r2: r2, + standardErrors: standardErrors, // SE for [β₀, β₁, β₂, ...] or null + tStats: tStats, // t-stats for [β₀, β₁, β₂, ...] or null + pValues: pValues, // p-values for [β₀, β₁, β₂, ...] or null + confidenceIntervals: confidenceIntervals, // CIs for [β₀, β₁, β₂, ...] or null + degreesOfFreedom: degreesOfFreedom, + sse: ss_res, + n: numDataPoints, + p: p }; } catch (error) { console.error("Error during multilinear regression calculation using math.js:", error); - // Common errors: Matrix not invertible (e.g., perfect collinearity), non-numeric data. if (error.message && error.message.includes("Cannot calculate inverse")) { console.error(" >> This often indicates perfect multicollinearity among independent variables."); } return null; } -} - -// ********************************************** -// ** NEW MULTILINEAR REGRESSION FUNCTION ** -// ********************************************** - -/** - * Performs Multilinear Regression using the 'regression-js' library. - * Finds coefficients (β₁, β₂, ...) and intercept (β₀) for the model: - * Y = β₀ + β₁X₁ + β₂X₂ + ... + β<0xE2><0x82><0x99>X<0xE2><0x82><0x99> - * - * @param {number[][]} independentVars - An array of arrays. Each inner array holds the data - * for one independent variable (X₁, X₂, ...). - * Example: [[x1_1, x1_2,...], [x2_1, x2_2,...], ...] - * @param {number[]} dependentVar - An array holding the data for the dependent variable (Y). - * Example: [y1, y2, y3,...] - * @param {number} [precision=6] - Optional number of decimal places for coefficients. - * - * @returns {object|null} An object containing: - * - `coefficients`: An array of coefficients [β₁, β₂, ...] corresponding - * to the order of `independentVars`. - * - `intercept`: The calculated intercept (β₀). - * - `r2`: The R-squared value (coefficient of determination). - * - `equation`: The raw equation array from the library [β₁, β₂, ..., β₀]. - * - `string`: A string representation of the equation. - * Or null if regression cannot be performed (e.g., insufficient data, library missing). - */ -function multilinearRegression_rjs(independentVars, dependentVar, precision = 6) { - - // ****** DEBUG LOG ADDED ****** - console.log("DEBUG multilinearRegression: Received independentVars with length:", independentVars ? independentVars.length : 'undefined/null'); - if (independentVars && independentVars.length > 0) { - console.log("DEBUG multilinearRegression: Length of first independent var array:", independentVars[0] ? independentVars[0].length : 'undefined/null'); - } - if (independentVars && independentVars.length > 1) { - console.log("DEBUG multilinearRegression: Length of second independent var array:", independentVars[1] ? independentVars[1].length : 'undefined/null'); - } - console.log("DEBUG multilinearRegression: Received dependentVar with length:", dependentVar ? dependentVar.length : 'undefined/null'); - // ****************************** - - // 1. Check if the regression library is available - if (typeof regression === 'undefined') { - console.error("Multilinear Regression Error: The 'regression-js' library is not loaded. Please ensure it's included via