This commit is contained in:
Yurii
2025-09-08 01:48:12 +07:00
committed by GitHub
15 changed files with 485 additions and 170 deletions

View File

@@ -370,11 +370,26 @@
},
"equitherm": {
"n": "N factor",
"k": "K factor",
"t": {
"slope": {
"title": "Slope",
"note": "Heat loss compensation. Main tuning parameter."
},
"exponent": {
"title": "Exponent",
"note": "Radiator efficiency. Typical values: 1.1 - Floor heating, 1.2 - Cast iron, 1.3 - Panel radiators, 1.4 - Convectors."
},
"shift": {
"title": "Shift",
"note": "Compensates for additional heat losses (e.g., in pipes) or extra heat sources."
},
"targetDiffFactor": {
"title": "T factor",
"note": "Not used if PID is enabled"
},
"chart": {
"targetTemp": "Target indoor temperature",
"setpointTemp": "Heat carrier temperature",
"outdoorTemp": "Outdoor temperature"
}
},

View File

@@ -370,11 +370,26 @@
},
"equitherm": {
"n": "Fattore N",
"k": "Fattore K",
"t": {
"slope": {
"title": "Pendenza",
"note": "Compensazione delle perdite di calore. Principale parametro di regolazione."
},
"exponent": {
"title": "Esponente",
"note": "Efficienza del radiatore. Valori tipici: 1.1 - Riscaldamento a pavimento, 1.2 - Radiatori in ghisa, 1.3 - Radiatori a pannelli, 1.4 - Convetttori."
},
"shift": {
"title": "Spostare",
"note": "Compensa le perdite di calore aggiuntive (ad esempio, nelle tubature) o fonti di calore extra."
},
"targetDiffFactor": {
"title": "Fattore T",
"note": "Non usato se PID è attivato"
},
"chart": {
"targetTemp": "Temperatura interna target",
"setpointTemp": "Temperatura del portatore di calore",
"outdoorTemp": "Temperatura esterna"
}
},

View File

@@ -370,11 +370,26 @@
},
"equitherm": {
"n": "Коэффициент N",
"k": "Коэффициент K",
"t": {
"slope": {
"title": "Наклон",
"note": "Компенсация теплопотерь. Основной параметр настройки."
},
"exponent": {
"title": "Экспонента",
"note": "Эффективность радиатора. Типичные значения: 1.1 - Тёплый пол, 1.2 - Чугунные радиаторы, 1.3 - Панельные радиаторы, 1.4 - Конвекторы."
},
"shift": {
"title": "Смещение",
"note": "Компенсирует дополнительные теплопотери (например, в трубах) или дополнительные источники тепла."
},
"targetDiffFactor": {
"title": "Коэффициент T",
"note": "Не используется, если ПИД включен"
},
"chart": {
"targetTemp": "Целевая внутренняя температура",
"setpointTemp": "Температура теплоносителя",
"outdoorTemp": "Наружная температура"
}
},

View File

@@ -240,7 +240,9 @@
setCheckboxValue("[name='filtering']", data.filtering, sensorForm);
setInputValue("[name='filteringFactor']", data.filteringFactor, {}, sensorForm);
sensorForm.querySelector("[name='type']").dispatchEvent(new Event("change"));
setTimeout(() => {
sensorForm.querySelector("[name='type']").dispatchEvent(new Event("change"));
}, 10);
setBusy(".form-busy", "form", false, sensorNode);
};

View File

@@ -340,7 +340,7 @@
</details>
<hr />
<details>
<summary><b data-i18n>settings.section.equitherm</b></summary>
<div>
@@ -353,21 +353,44 @@
</label>
</fieldset>
<div>
<div>
<canvas id="etChart"></canvas>
</div>
<label>
<div>
<span data-i18n>settings.equitherm.chart.targetTemp</span>: <b class="etChartTargetTempValue"></b>°
</div>
<input class="etChartTargetTemp" type="range" value="0" min="0" max="0" step="0.5">
</label>
</div>
<div class="grid">
<label>
<span data-i18n>settings.equitherm.n</span>
<input type="number" inputmode="decimal" name="equitherm[n_factor]" min="0.001" max="10" step="0.001" required>
<span data-i18n>settings.equitherm.slope.title</span>
<input type="number" inputmode="decimal" name="equitherm[slope]" min="0.001" max="10" step="0.001" required>
<small data-i18n>settings.equitherm.slope.note</small>
</label>
<label>
<span data-i18n>settings.equitherm.k</span>
<input type="number" inputmode="decimal" name="equitherm[k_factor]" min="0" max="10" step="0.01" required>
<span data-i18n>settings.equitherm.exponent.title</span>
<input type="number" inputmode="decimal" name="equitherm[exponent]" min="0.1" max="2" step="0.001" required>
<small data-i18n>settings.equitherm.exponent.note</small>
</label>
</div>
<div class="grid">
<label>
<span data-i18n>settings.equitherm.shift.title</span>
<input type="number" inputmode="decimal" name="equitherm[shift]" min="-15" max="15" step="0.01" required>
<small data-i18n>settings.equitherm.shift.note</small>
</label>
<label>
<span data-i18n>settings.equitherm.t.title</span>
<input type="number" inputmode="decimal" name="equitherm[t_factor]" min="0" max="10" step="0.01" required>
<small data-i18n>settings.equitherm.t.note</small>
<span data-i18n>settings.equitherm.targetDiffFactor.title</span>
<input type="number" inputmode="decimal" name="equitherm[targetDiffFactor]" min="0" max="10" step="0.01" required>
<small data-i18n>settings.equitherm.targetDiffFactor.note</small>
</label>
</div>
@@ -377,7 +400,7 @@
</details>
<hr />
<details>
<summary><b data-i18n>settings.section.pid</b></summary>
<div>
@@ -860,11 +883,169 @@
</footer>
<script src="/static/app.js?{BUILD_TIME}"></script>
<script src="/static/chart.js?{BUILD_TIME}"></script>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const lang = new Lang(document.getElementById('lang'));
lang.build();
let etChart = null;
let etChartConfig = {
slope: null,
exponent: null,
shift: null,
unitSystem: null,
targetTemp: null,
minTemp: null,
maxTemp: null
};
const hasNeedDecimationChart = () => {
return window.innerWidth <= 800;
}
const makeEquithermChart = () => {
if (etChart == null) {
const ctx = document.getElementById('etChart').getContext('2d');
try {
etChart = new Chart(ctx, {
type: 'line',
data: {
datasets: [{
borderColor: (context) => {
const chart = context.chart;
const {ctx, chartArea} = chart;
if (!chartArea) {
return;
}
const gradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top);
gradient.addColorStop(0, 'rgba(1, 114, 173, 1)');
gradient.addColorStop(0.5, 'rgba(255, 99, 132, 1)');
return gradient;
},
borderWidth: 3,
fill: false,
tension: 0.1,
pointRadius: 2,
pointHoverRadius: 4,
indexAxis: "x",
data: []
}]
},
options: {
responsive: true,
resizeDelay: 500,
parsing: false,
interaction: {
mode: 'nearest',
intersect: false
},
plugins: {
tooltip: {
enabled: true,
position: 'nearest',
displayColors: false,
callbacks: {
title: (items) => {
return `${i18n("settings.equitherm.chart.outdoorTemp")}: ${items[0].label}`;
}
}
},
legend: {
display: false
},
decimation: {
enabled: hasNeedDecimationChart(),
algorithm: 'lttb',
samples: 30,
threshold: 30
}
},
scales: {
x: {
display: true,
type: "linear",
reverse: true,
title: {
display: true
},
ticks: {
stepSize: 1,
format: {
style: "unit",
unit: "degree",
unitDisplay: "narrow"
}
}
},
y: {
display: true,
title: {
display: true
},
ticks: {
format: {
style: "unit",
unit: "degree",
unitDisplay: "narrow"
}
}
}
}
}
});
} catch (error) {
console.log(error);
}
}
if (!etChart) {
return;
}
while (etChart.data.datasets[0].data.length) {
etChart.data.datasets[0].data.pop();
}
for (let value = 30; value >= -30; value--) {
const outdoorTemp = etChartConfig.unitSystem == 0 ? value : c2f(value);
etChart.data.datasets[0].data.push({
x: parseFloat(outdoorTemp.toFixed(1)),
y: parseFloat(calculateEquithermTemp(outdoorTemp).toFixed(1))
});
}
etChart.data.datasets[0].label = i18n("settings.equitherm.chart.setpointTemp");
etChart.options.scales.x.title.text = i18n("settings.equitherm.chart.outdoorTemp");
etChart.options.scales.y.title.text = i18n("settings.equitherm.chart.setpointTemp");
etChart.update();
}
const calculateEquithermTemp = (outdoorTemp) => {
const tempDelta = etChartConfig.targetTemp - outdoorTemp;
const maxPoint = etChartConfig.targetTemp - (
etChartConfig.maxTemp - etChartConfig.targetTemp
) / etChartConfig.slope;
const sf = (etChartConfig.maxTemp - etChartConfig.targetTemp) / Math.pow(
etChartConfig.targetTemp - maxPoint,
1 / etChartConfig.exponent
);
const result = etChartConfig.targetTemp + etChartConfig.shift + sf * (
tempDelta >= 0
? Math.pow(tempDelta, 1 / etChartConfig.exponent)
: -(Math.pow(-(tempDelta), 1 / etChartConfig.exponent))
);
return Math.max(Math.min(result, etChartConfig.maxTemp), etChartConfig.minTemp);
}
const fillData = (data) => {
// System
setSelectValue("[name='system[logLevel]']", data.system.logLevel);
@@ -1012,9 +1193,10 @@
// Equitherm
setCheckboxValue("[name='equitherm[enabled]']", data.equitherm.enabled);
setInputValue("[name='equitherm[n_factor]']", data.equitherm.n_factor);
setInputValue("[name='equitherm[k_factor]']", data.equitherm.k_factor);
setInputValue("[name='equitherm[t_factor]']", data.equitherm.t_factor);
setInputValue("[name='equitherm[slope]']", data.equitherm.slope);
setInputValue("[name='equitherm[exponent]']", data.equitherm.exponent);
setInputValue("[name='equitherm[shift]']", data.equitherm.shift);
setInputValue("[name='equitherm[targetDiffFactor]']", data.equitherm.targetDiffFactor);
setBusy('#equitherm-settings-busy', '#equitherm-settings', false);
// PID
@@ -1038,6 +1220,24 @@
setInputValue("[name='pid[deadband][thresholdHigh]']", data.pid.deadband.thresholdHigh);
setInputValue("[name='pid[deadband][thresholdLow]']", data.pid.deadband.thresholdLow);
setBusy('#pid-settings-busy', '#pid-settings', false);
const etMinTemp = parseInt(data.system.unitSystem == 0 ? 5 : 41);
const etMaxTemp = parseInt(data.system.unitSystem == 0 ? 30 : 86);
const etTargetTemp = constrain(parseFloat(data.heating.target), etMinTemp, etMaxTemp);
setInputValue(".etChartTargetTemp", etTargetTemp.toFixed(1), {
"min": etMinTemp,
"max": etMaxTemp
});
etChartConfig.slope = data.equitherm.slope;
etChartConfig.exponent = data.equitherm.exponent;
etChartConfig.shift = data.equitherm.shift;
etChartConfig.unitSystem = data.system.unitSystem;
etChartConfig.minTemp = data.heating.minTemp;
etChartConfig.maxTemp = data.heating.maxTemp;
makeEquithermChart();
};
try {
@@ -1090,6 +1290,58 @@
} catch (error) {
console.log(error);
}
document.querySelector(".etChartTargetTemp").addEventListener("input", async (event) => {
setValue('.etChartTargetTempValue', parseFloat(event.target.value).toFixed(1));
});
document.querySelector(".etChartTargetTemp").addEventListener("change", async (event) => {
if (!event.target.checkValidity()) {
return;
}
etChartConfig.targetTemp = parseFloat(event.target.value);
setValue('.etChartTargetTempValue', etChartConfig.targetTemp.toFixed(1));
makeEquithermChart();
});
document.querySelector("[name='equitherm[slope]']").addEventListener("change", async (event) => {
if (!event.target.checkValidity()) {
return;
}
etChartConfig.slope = parseFloat(event.target.value);
makeEquithermChart();
});
document.querySelector("[name='equitherm[exponent]']").addEventListener("change", async (event) => {
if (!event.target.checkValidity()) {
return;
}
etChartConfig.exponent = parseFloat(event.target.value);
makeEquithermChart();
});
document.querySelector("[name='equitherm[shift]']").addEventListener("change", async (event) => {
if (!event.target.checkValidity()) {
return;
}
etChartConfig.shift = parseFloat(event.target.value);
makeEquithermChart();
});
window.addEventListener('resize', async (event) => {
if (etChart) {
etChart.resize();
if (etChart.options.plugins.decimation.enabled != hasNeedDecimationChart()) {
etChart.options.plugins.decimation.enabled = hasNeedDecimationChart();
etChart.update();
}
}
});
});
</script>
</body>

20
src_data/scripts/chart.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,13 @@ const setupForm = (formSelector, onResultCallback = null, noCastItems = []) => {
}
form.querySelectorAll('input').forEach(item => {
item.addEventListener('change', (e) => {
e.target.setAttribute('aria-invalid', !e.target.checkValidity());
item.addEventListener('change', (event) => {
if (!event.target.checkValidity()) {
event.target.setAttribute('aria-invalid', true);
} else if (event.target.hasAttribute('aria-invalid')) {
event.target.removeAttribute('aria-invalid');
}
})
});
@@ -630,6 +635,10 @@ const setCheckboxValue = (selector, value, parent = undefined) => {
}
item.checked = value;
setTimeout(() => {
item.dispatchEvent(new Event("change"));
}, 10);
}
const setRadioValue = (selector, value, parent = undefined) => {
@@ -643,7 +652,14 @@ const setRadioValue = (selector, value, parent = undefined) => {
}
for (let item of items) {
item.checked = item.value == value;
const checked = item.value == value;
if (item.checked != checked) {
item.checked = checked;
setTimeout(() => {
item.dispatchEvent(new Event("change"));
}, 10);
}
}
}
@@ -658,13 +674,17 @@ const setInputValue = (selector, value, attrs = {}, parent = undefined) => {
}
for (let item of items) {
item.value = value;
if (attrs instanceof Object) {
for (let attrKey of Object.keys(attrs)) {
item.setAttribute(attrKey, attrs[attrKey]);
}
}
item.value = value;
setTimeout(() => {
item.dispatchEvent(new Event("change"));
}, 10);
}
}
@@ -853,4 +873,12 @@ function dec2hex(i) {
function constrain(amt, low, high) {
return ((amt) < (low) ? (low) : ((amt) > (high) ? (high) : (amt)));
}
function c2f(value) {
return (9 / 5) * value + 32;
}
function f2c(value) {
return (value - 32) * (5 / 9);
}