15 Commits

Author SHA1 Message Date
P43YM
82d8b7ed8d data/static 2025-02-28 23:08:53 +03:00
P43YM
8b88d133d4 Merge branch 'master' of https://github.com/P43YM/OTGateway 2025-02-28 22:25:05 +03:00
P43YM
bae540d67a Cleaning 2025-02-28 22:24:45 +03:00
P43YM
68514d3c9f Cleaning 2025-02-28 22:15:04 +03:00
P43YM
c3b127c868 Cleaning for chart code 2025-02-28 20:19:03 +03:00
P43YM
b7334b5f3e Snap to points in Chart 2025-02-28 19:20:42 +03:00
P43YM
7c2059d7c6 ouch! 2025-02-28 18:29:47 +03:00
P43YM
66ced5f8d0 Oops, I forgot. 2025-02-28 18:25:01 +03:00
P43YM
ba255c1bd1 Cleaning of equitherm function and onChange listener for chart 2025-02-28 17:53:01 +03:00
P43YM
54095892e1 Wrong call for tatget temp, should be just target 2025-02-27 17:06:59 +03:00
P43YM
5dd76c9168 Typo in Hahelper 2025-02-27 12:52:51 +03:00
P43YM
1f72286be7 Update Equitherm.h 2025-02-27 03:14:47 +03:00
P43YM
40767fbb99 Merge pull request #1 from P43YM/main
Main
2025-02-27 03:13:50 +03:00
P43YM
cb34e073a7 New equitherm agorithm and crart for it 2025-02-27 03:10:14 +03:00
P43YM
8ba73093af Chart and new algorithm for equitherm 2025-02-27 02:50:19 +03:00
10 changed files with 263 additions and 19 deletions

View File

@@ -16,14 +16,16 @@ public:
float Kn = 0.0; float Kn = 0.0;
float Kk = 0.0; float Kk = 0.0;
float Kt = 0.0; float Kt = 0.0;
float Ke = 1.3;
Equitherm() = default; Equitherm() = default;
// kn, kk, kt // kn, kk, kt, Ke
Equitherm(float new_kn, float new_kk, float new_kt) { Equitherm(float new_kn, float new_kk, float new_kt, float new_ke) {
Kn = new_kn; Kn = new_kn;
Kk = new_kk; Kk = new_kk;
Kt = new_kt; Kt = new_kt;
Ke = new_ke;
} }
// лимит выходной величины // лимит выходной величины
@@ -34,7 +36,7 @@ public:
// возвращает новое значение при вызове // возвращает новое значение при вызове
datatype getResult() { datatype getResult() {
datatype output = getResultN() + getResultK() + getResultT(); datatype output = getResultN() + Kk + getResultT();
output = constrain(output, _minOut, _maxOut); // ограничиваем выход output = constrain(output, _minOut, _maxOut); // ограничиваем выход
return output; return output;
} }
@@ -42,21 +44,16 @@ public:
private: private:
unsigned short _minOut = 20, _maxOut = 90; unsigned short _minOut = 20, _maxOut = 90;
// температура контура отопления в зависимости от наружной температуры datatype getResultN()
datatype getResultN() { {
float a = (-0.21 * Kn) - 0.06; // a = -0,21k — 0,06 float tempDelta = targetTemp - outdoorTemp,
float b = (6.04 * Kn) + 1.98; // b = 6,04k + 1,98 maxPoint = targetTemp - (_maxOut - targetTemp) / Kn,
float c = (-5.06 * Kn) + 18.06; // с = -5,06k + 18,06 sf = (_maxOut - targetTemp) / pow(targetTemp - maxPoint, 1.0 / Ke),
float x = (-0.2 * outdoorTemp) + 5; // x = -0.2*t1 + 5 T_rad = targetTemp + sf * (tempDelta >= 0 ? pow(tempDelta, 1.0 / Ke) : -pow(-tempDelta, 1.0 / Ke));
return (a * x * x) + (b * x) + c; // Tn = ax2 + bx + c return T_rad > _maxOut ? _maxOut : T_rad;
} }
// поправка на желаемую комнатную температуру // Реакция на разницу с целевой температурой
datatype getResultK() {
return (targetTemp - 20) * Kk;
}
// Расчет поправки (ошибки) термостата
datatype getResultT() { datatype getResultT() {
return constrain((targetTemp - indoorTemp), -3, 3) * Kt; return constrain((targetTemp - indoorTemp), -3, 3) * Kt;
} }

View File

@@ -886,6 +886,29 @@ public:
return this->publish(this->makeConfigTopic(FPSTR(HA_ENTITY_NUMBER), F("equitherm_k_factor")).c_str(), doc); return this->publish(this->makeConfigTopic(FPSTR(HA_ENTITY_NUMBER), F("equitherm_k_factor")).c_str(), doc);
} }
bool publishInputEquithermFactorE(bool enabledByDefault = true) {
JsonDocument doc;
doc[FPSTR(HA_AVAILABILITY)][FPSTR(HA_TOPIC)] = this->statusTopic.c_str();
doc[FPSTR(HA_ENABLED_BY_DEFAULT)] = enabledByDefault;
doc[FPSTR(HA_UNIQUE_ID)] = this->getObjectIdWithPrefix(F("equitherm_e"));
doc[FPSTR(HA_OBJECT_ID)] = doc[FPSTR(HA_UNIQUE_ID)];
doc[FPSTR(HA_ENTITY_CATEGORY)] = FPSTR(HA_ENTITY_CATEGORY_CONFIG);
doc[FPSTR(HA_NAME)] = F("Equitherm Exponent E");
doc[FPSTR(HA_ICON)] = F("mdi:alpha-e-circle-outline");
doc[FPSTR(HA_STATE_TOPIC)] = this->settingsTopic.c_str();
doc[FPSTR(HA_VALUE_TEMPLATE)] = F("{{ value_json.equitherm.e_factor|float(0)|round(2) }}");
doc[FPSTR(HA_COMMAND_TOPIC)] = this->setSettingsTopic.c_str();
doc[FPSTR(HA_COMMAND_TEMPLATE)] = F("{\"equitherm\": {\"e_factor\" : {{ value }}}}");
doc[FPSTR(HA_MIN)] = 1;
doc[FPSTR(HA_MAX)] = 2;
doc[FPSTR(HA_STEP)] = 0.01f;
doc[FPSTR(HA_MODE)] = FPSTR(HA_MODE_BOX);
doc[FPSTR(HA_EXPIRE_AFTER)] = this->expireAfter;
doc.shrinkToFit();
return this->publish(this->makeConfigTopic(FPSTR(HA_ENTITY_NUMBER), F("equitherm_e_factor")).c_str(), doc);
}
bool publishInputEquithermFactorT(bool enabledByDefault = true) { bool publishInputEquithermFactorT(bool enabledByDefault = true) {
JsonDocument doc; JsonDocument doc;
doc[FPSTR(HA_AVAILABILITY)][0][FPSTR(HA_TOPIC)] = this->statusTopic.c_str(); doc[FPSTR(HA_AVAILABILITY)][0][FPSTR(HA_TOPIC)] = this->statusTopic.c_str();

View File

@@ -172,6 +172,7 @@ protected:
etRegulator.setLimits(minTemp, maxTemp); etRegulator.setLimits(minTemp, maxTemp);
etRegulator.Kn = settings.equitherm.n_factor; etRegulator.Kn = settings.equitherm.n_factor;
etRegulator.Kk = settings.equitherm.k_factor; etRegulator.Kk = settings.equitherm.k_factor;
etRegulator.Ke = settings.equitherm.e_factor;
etRegulator.targetTemp = targetTemp; etRegulator.targetTemp = targetTemp;
etRegulator.outdoorTemp = outdoorTemp; etRegulator.outdoorTemp = outdoorTemp;
float etResult = etRegulator.getResult(); float etResult = etRegulator.getResult();

View File

@@ -137,6 +137,7 @@ struct Settings {
float n_factor = 0.7f; float n_factor = 0.7f;
float k_factor = 3.0f; float k_factor = 3.0f;
float t_factor = 2.0f; float t_factor = 2.0f;
float e_factor = 1.3f;
} equitherm; } equitherm;
struct { struct {

View File

@@ -73,6 +73,7 @@ const char S_DNS[] PROGMEM = "dns";
const char S_DT[] PROGMEM = "dt"; const char S_DT[] PROGMEM = "dt";
const char S_D_FACTOR[] PROGMEM = "d_factor"; const char S_D_FACTOR[] PROGMEM = "d_factor";
const char S_D_MULTIPLIER[] PROGMEM = "d_multiplier"; const char S_D_MULTIPLIER[] PROGMEM = "d_multiplier";
const char S_E_FACTOR[] PROGMEM = "e_factor";
const char S_EMERGENCY[] PROGMEM = "emergency"; const char S_EMERGENCY[] PROGMEM = "emergency";
const char S_ENABLED[] PROGMEM = "enabled"; const char S_ENABLED[] PROGMEM = "enabled";
const char S_ENV[] PROGMEM = "env"; const char S_ENV[] PROGMEM = "env";

View File

@@ -502,6 +502,7 @@ void settingsToJson(const Settings& src, JsonVariant dst, bool safe = false) {
equitherm[FPSTR(S_ENABLED)] = src.equitherm.enabled; equitherm[FPSTR(S_ENABLED)] = src.equitherm.enabled;
equitherm[FPSTR(S_N_FACTOR)] = roundf(src.equitherm.n_factor, 3); equitherm[FPSTR(S_N_FACTOR)] = roundf(src.equitherm.n_factor, 3);
equitherm[FPSTR(S_K_FACTOR)] = roundf(src.equitherm.k_factor, 3); equitherm[FPSTR(S_K_FACTOR)] = roundf(src.equitherm.k_factor, 3);
equitherm[FPSTR(S_E_FACTOR)] = roundf(src.equitherm.e_factor, 3);
equitherm[FPSTR(S_T_FACTOR)] = roundf(src.equitherm.t_factor, 3); equitherm[FPSTR(S_T_FACTOR)] = roundf(src.equitherm.t_factor, 3);
auto pid = dst[FPSTR(S_PID)].to<JsonObject>(); auto pid = dst[FPSTR(S_PID)].to<JsonObject>();
@@ -1100,6 +1101,14 @@ bool jsonToSettings(const JsonVariantConst src, Settings& dst, bool safe = false
} }
} }
if (!src[FPSTR(S_EQUITHERM)][FPSTR(S_E_FACTOR)].isNull()) {
float value = src[FPSTR(S_EQUITHERM)][FPSTR(S_E_FACTOR)].as<float>();
if (value >= 1 && value <= 2 && fabsf(value - dst.equitherm.e_factor) > 0.0001f) {
dst.equitherm.e_factor = roundf(value, 3);
changed = true;
}
}
if (!src[FPSTR(S_EQUITHERM)][FPSTR(S_T_FACTOR)].isNull()) { if (!src[FPSTR(S_EQUITHERM)][FPSTR(S_T_FACTOR)].isNull()) {
float value = src[FPSTR(S_EQUITHERM)][FPSTR(S_T_FACTOR)].as<float>(); float value = src[FPSTR(S_EQUITHERM)][FPSTR(S_T_FACTOR)].as<float>();

View File

@@ -342,9 +342,14 @@
"equitherm": { "equitherm": {
"n": "N factor", "n": "N factor",
"k": "K factor", "k": "K factor",
"e": "Exponent E",
"t": { "t": {
"title": "T factor", "title": "T factor",
"note": "Not used if PID is enabled" "note": "Not used if PID is enabled"
},
"chart": {
"radiatorTemp": "Radiator Temperature (°C)",
"outdoorTemp": "Outdoor Temperature (°C)"
} }
}, },

View File

@@ -342,9 +342,14 @@
"equitherm": { "equitherm": {
"n": "Fattore N", "n": "Fattore N",
"k": "Fattore K", "k": "Fattore K",
"e": "Esponente E",
"t": { "t": {
"title": "Fattore T", "title": "Fattore T",
"note": "Non usato se PID è attivato" "note": "Non usato se PID è attivato"
},
"chart": {
"radiatorTemp": "Temperatura Del Radiatore (°C)",
"outdoorTemp": "Outdoor Temperature (°C)"
} }
}, },

View File

@@ -342,9 +342,14 @@
"equitherm": { "equitherm": {
"n": "Коэффициент N", "n": "Коэффициент N",
"k": "Коэффициент K", "k": "Коэффициент K",
"e": "Экспонента E",
"t": { "t": {
"title": "Коэффициент T", "title": "Коэффициент T",
"note": "Не используется, если ПИД включен" "note": "Не используется, если ПИД включен"
},
"chart": {
"radiatorTemp": "Температура радиатора (°C)",
"outdoorTemp": "Наружная температура (°C)"
} }
}, },

View File

@@ -264,6 +264,7 @@
<details> <details>
<summary><b data-i18n>settings.section.equitherm</b></summary> <summary><b data-i18n>settings.section.equitherm</b></summary>
<canvas id="equithermChart" width="400" height="200"></canvas>
<div> <div>
<div id="equitherm-settings-busy" aria-busy="true"></div> <div id="equitherm-settings-busy" aria-busy="true"></div>
<form action="/api/settings" id="equitherm-settings" class="hidden"> <form action="/api/settings" id="equitherm-settings" class="hidden">
@@ -285,6 +286,11 @@
<input type="number" inputmode="decimal" name="equitherm[k_factor]" min="0" max="10" step="0.01" required> <input type="number" inputmode="decimal" name="equitherm[k_factor]" min="0" max="10" step="0.01" required>
</label> </label>
<label>
<span data-i18n>settings.equitherm.e</span>
<input type="number" inputmode="decimal" name="equitherm[e_factor]" min="1" max="2" step="0.01" required>
</label>
<label> <label>
<span data-i18n>settings.equitherm.t.title</span> <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> <input type="number" inputmode="decimal" name="equitherm[t_factor]" min="0" max="10" step="0.01" required>
@@ -299,6 +305,8 @@
<hr /> <hr />
<details> <details>
<summary><b data-i18n>settings.section.pid</b></summary> <summary><b data-i18n>settings.section.pid</b></summary>
<div> <div>
@@ -756,6 +764,7 @@
</footer> </footer>
<script src="/static/app.js?{BUILD_TIME}"></script> <script src="/static/app.js?{BUILD_TIME}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const lang = new Lang(document.getElementById('lang')); const lang = new Lang(document.getElementById('lang'));
@@ -885,6 +894,7 @@
setCheckboxValue("[name='equitherm[enabled]']", data.equitherm.enabled); setCheckboxValue("[name='equitherm[enabled]']", data.equitherm.enabled);
setInputValue("[name='equitherm[n_factor]']", data.equitherm.n_factor); setInputValue("[name='equitherm[n_factor]']", data.equitherm.n_factor);
setInputValue("[name='equitherm[k_factor]']", data.equitherm.k_factor); setInputValue("[name='equitherm[k_factor]']", data.equitherm.k_factor);
setInputValue("[name='equitherm[e_factor]']", data.equitherm.e_factor);
setInputValue("[name='equitherm[t_factor]']", data.equitherm.t_factor); setInputValue("[name='equitherm[t_factor]']", data.equitherm.t_factor);
setBusy('#equitherm-settings-busy', '#equitherm-settings', false); setBusy('#equitherm-settings-busy', '#equitherm-settings', false);
@@ -909,6 +919,9 @@
setInputValue("[name='pid[deadband][thresholdHigh]']", data.pid.deadband.thresholdHigh); setInputValue("[name='pid[deadband][thresholdHigh]']", data.pid.deadband.thresholdHigh);
setInputValue("[name='pid[deadband][thresholdLow]']", data.pid.deadband.thresholdLow); setInputValue("[name='pid[deadband][thresholdLow]']", data.pid.deadband.thresholdLow);
setBusy('#pid-settings-busy', '#pid-settings', false); setBusy('#pid-settings-busy', '#pid-settings', false);
}; };
try { try {
@@ -961,6 +974,190 @@
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
//График
let equithermChart;
async function fetchSettings() {
try {
const response = await fetch("/api/settings", {
cache: "no-cache",
credentials: "include"
});
if (!response.ok) {
throw new Error('Response not valid');
}
return await response.json();
} catch (error) {
console.log(error);
}
}
// Считаем температуру
function calculateTRad(targetTemp, outdoorTemp, maxOut, Kn, Ke, Kk) {
let tempDelta = targetTemp - outdoorTemp;
const maxPoint = targetTemp - (maxOut - targetTemp) / Kn;
let base = targetTemp - maxPoint;
if (base <= 0) {
base = 0.0001;
}
const sf = (maxOut - targetTemp) / Math.pow(base, 1.0 / Ke);
let T_rad = targetTemp + sf * (tempDelta >= 0 ? Math.pow(tempDelta, 1.0 / Ke) : -Math.pow(-tempDelta, 1.0 / Ke)) + Kk;
return Math.min(T_rad, maxOut);
}
// Генерируем данные для графика
function generateChartData(targetTemp, maxOut, Kn, Ke, Kk) {
const outdoorTemps = [];
const predictedTRad = [];
for (let temp = 25; temp >= -30; temp -= 1) {
outdoorTemps.push(temp);
predictedTRad.push(calculateTRad(targetTemp, temp, maxOut, Kn, Ke, Kk).toFixed(1));
}
return { outdoorTemps, predictedTRad };
}
// Создаем график
function createChart(outdoorTemps, predictedTRad) {
const ctx = document.getElementById('equithermChart').getContext('2d');
const canvasHeight = ctx.canvas.height;
const gradient = ctx.createLinearGradient(0, canvasHeight, 0, 0);
gradient.addColorStop(0, 'rgba(75, 192, 192, 1)');
gradient.addColorStop(0.5, 'rgba(255, 99, 132, 1)');
equithermChart = new Chart(ctx, {
type: 'line',
data: {
labels: outdoorTemps,
datasets: [{
label: 'Температура Радиатора (°C)',
borderColor: gradient,
borderWidth: 1,
fill: false,
tension: 0.1,
pointRadius: 2,
pointHoverRadius: 4,
data: predictedTRad
}]
},
options: {
responsive: true,
interaction: {
mode: 'nearest',
intersect: false
},
plugins: {
tooltip: {
enabled: true,
position: 'nearest',
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: 'Наружная температура (°C)'
}
},
y: {
display: true,
title: {
display: true,
text: 'Температура Радиатора (°C)'
}
}
}
}
});
}
// Инициализируем график
async function initChart() {
try {
const result = await fetchSettings();
const { heating, equitherm } = result;
const targetTemp = heating?.target ?? 24;
const maxOut = heating?.maxTemp ?? 90;
const Kn = equitherm?.n_factor ?? 1;
const Ke = equitherm?.e_factor ?? 1.3;
const Kk = equitherm?.k_factor ?? 0;
const { outdoorTemps, predictedTRad } = generateChartData(targetTemp, maxOut, Kn, Ke, Kk);
createChart(outdoorTemps, predictedTRad);
document.getElementById('equitherm-settings-busy').classList.add('hidden');
document.getElementById('equitherm-settings').classList.remove('hidden');
} catch (error) {
console.log(error);
}
}
function updateChart(formData) {
if (!equithermChart) return;
fetchSettings()
.then(result => {
const targetTemp = result?.heating?.target ?? 24;
const maxOut = result?.heating?.maxTemp ?? 90;
const Kn = parseFloat(formData.get('equitherm[n_factor]')) || 1;
const Ke = parseFloat(formData.get('equitherm[e_factor]')) || 1.3;
const Kk = parseFloat(formData.get('equitherm[k_factor]')) || 0;
const { outdoorTemps, predictedTRad } = generateChartData(targetTemp, maxOut, Kn, Ke, Kk);
equithermChart.data.labels = outdoorTemps;
equithermChart.data.datasets[0].data = predictedTRad;
equithermChart.update();
})
.catch(error => console.log(error));
}
// Слушаем отправку
const form = document.getElementById('equitherm-settings');
form.addEventListener('submit', (e) => {
const formData = new FormData(form);
updateChart(formData);
});
// Слушаем кнопку сохранить
const equithermSection = document.querySelector('details');
const saveButton = equithermSection.querySelector('button[data-i18n="button.save"]');
saveButton.addEventListener('click', () => {
const form = document.getElementById('equitherm-settings');
const formData = new FormData(form);
updateChart(formData);
});
//Следим за изменениями в полях
document.querySelectorAll('#equitherm-settings input').forEach(input => {
input.addEventListener('change', () => {
const form = document.getElementById('equitherm-settings');
const formData = new FormData(form);
updateChart(formData);
});
});
// инициализируем график
initChart();
}); });
</script> </script>
</body> </body>