node-red-contrib-freya-nodes 0.0.21 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/nodes/circadian-core/circadian-core-node.html +46 -64
- package/nodes/circadian-core/circadian-core-node.js +154 -13
- package/nodes/circadian-core/location-config-node.html +131 -0
- package/nodes/circadian-core/location-config-node.js +15 -0
- package/nodes/humidity-controller/humidity-controller-node.html +89 -3
- package/nodes/humidity-controller/humidity-controller-node.js +102 -14
- package/nodes/temperature-controller/temperature-controller-node.html +50 -29
- package/nodes/temperature-controller/temperature-controller-node.js +97 -30
- package/package.json +6 -1
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
category: 'Freya Vivariums',
|
|
4
4
|
color: "#A2CA6F",
|
|
5
5
|
defaults: {
|
|
6
|
-
name: {value:""}
|
|
6
|
+
name: {value:""},
|
|
7
|
+
locationConfig: {value:"", type:"location-config", required:true},
|
|
8
|
+
phaseShift: {value: 0.0, required: true, validate: RED.validators.number()},
|
|
9
|
+
tickInterval: {value: 60, required: true, validate: function(v) { return RED.validators.number()(v) && parseInt(v) >= 1; }},
|
|
10
|
+
decimals: {value: 1, required: true, validate: function(v) { return RED.validators.number()(v) && parseInt(v) >= 0 && parseInt(v) <= 4; }}
|
|
7
11
|
},
|
|
8
12
|
inputs:1,
|
|
9
13
|
outputs:2,
|
|
@@ -23,74 +27,26 @@
|
|
|
23
27
|
</div>
|
|
24
28
|
<hr/>
|
|
25
29
|
<div class="form-row">
|
|
26
|
-
|
|
30
|
+
<label for="node-input-locationConfig"><i class="fa fa-map-marker"></i> Location Config</label>
|
|
31
|
+
<input type="text" id="node-input-locationConfig">
|
|
32
|
+
<i class="fa fa-info-circle" title="Select a location configuration node that defines geographic and planetary parameters."></i>
|
|
27
33
|
</div>
|
|
34
|
+
<hr/>
|
|
28
35
|
<div class="form-row">
|
|
29
|
-
<label for="node-input-
|
|
30
|
-
<input type="number" id="node-input-
|
|
31
|
-
<i class="fa fa-info-circle" title="
|
|
36
|
+
<label for="node-input-phaseShift"><i class="fa fa-clock-o"></i> Phase Shift (°)</label>
|
|
37
|
+
<input type="number" id="node-input-phaseShift" placeholder="0" step="1" min="-180" max="180">
|
|
38
|
+
<i class="fa fa-info-circle" title="Phase shift in degrees relative to solar cycle. 0° = peak at solar noon, 180° = peak at solar midnight, 30-45° = afternoon temperature lag."></i>
|
|
32
39
|
</div>
|
|
33
40
|
<div class="form-row">
|
|
34
|
-
<label for="node-input-
|
|
35
|
-
<input type="
|
|
36
|
-
<
|
|
41
|
+
<label for="node-input-tickInterval"><i class="fa fa-clock-o"></i> Tick Interval (seconds)</label>
|
|
42
|
+
<input type="number" id="node-input-tickInterval" placeholder="60" step="1" min="1">
|
|
43
|
+
<div class="form-tips">How often to automatically recalculate and emit target values</div>
|
|
37
44
|
</div>
|
|
38
|
-
<hr/>
|
|
39
45
|
<div class="form-row">
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
</
|
|
46
|
+
<label for="node-input-decimals"><i class="fa fa-calculator"></i> Decimal Places</label>
|
|
47
|
+
<input type="number" id="node-input-decimals" placeholder="1" step="1" min="0" max="4">
|
|
48
|
+
<div class="form-tips">Number of decimal places to round the output target value (0-4)</div>
|
|
43
49
|
</div>
|
|
44
|
-
<!-- Advanced settings -->
|
|
45
|
-
<div id="advanced-settings" style="display:none;">
|
|
46
|
-
<div class="form-row">
|
|
47
|
-
<h2><i class="fa fa-globe"></i> Planetary settings</h2>
|
|
48
|
-
</div>
|
|
49
|
-
<div class="form-row">
|
|
50
|
-
<label for="node-input-axialTilt">Axial Tilt (°)</label>
|
|
51
|
-
<input type="number" id="node-input-axialTilt" placeholder="23.44">
|
|
52
|
-
<i class="fa fa-info-circle" title="Angle between the rotation axis and orbital plane. Controls seasonal variation. Earth = 23.44°"></i>
|
|
53
|
-
</div>
|
|
54
|
-
<div class="form-row">
|
|
55
|
-
<label for="node-input-orbitalPeriod">Orbital Period (days)</label>
|
|
56
|
-
<input type="number" id="node-input-orbitalPeriod" placeholder="365.25">
|
|
57
|
-
<i class="fa fa-info-circle" title="Length of a full year (orbit around the sun). Earth = 365.25 days."></i>
|
|
58
|
-
</div>
|
|
59
|
-
<div class="form-row">
|
|
60
|
-
<label for="node-input-rotationalPeriod">Rotational Period (hours)</label>
|
|
61
|
-
<input type="number" id="node-input-rotationalPeriod" placeholder="24">
|
|
62
|
-
<i class="fa fa-info-circle" title="Length of a full day-night cycle. Earth = 24 hours."></i>
|
|
63
|
-
</div>
|
|
64
|
-
<hr/>
|
|
65
|
-
<div class="form-row">
|
|
66
|
-
<h2><i class="fa fa-clock-o"></i> Time</h2>
|
|
67
|
-
</div>
|
|
68
|
-
<div class="form-row">
|
|
69
|
-
<label for="node-input-timeScale">Time Scale</label>
|
|
70
|
-
<input type="number" id="node-input-timeScale" placeholder="1.0">
|
|
71
|
-
<i class="fa fa-info-circle" title="Controls simulation speed. 1.0 = real time, 2.0 = twice as fast, 0.5 = half speed."></i>
|
|
72
|
-
</div>
|
|
73
|
-
|
|
74
|
-
</div>
|
|
75
|
-
<!--- END Advanced settings -->
|
|
76
|
-
<script>
|
|
77
|
-
$(function() {
|
|
78
|
-
$('#toggle-advanced').on('click', function(e) {
|
|
79
|
-
e.preventDefault();
|
|
80
|
-
var adv = $('#advanced-settings');
|
|
81
|
-
var icon = $(this).find('i');
|
|
82
|
-
if (adv.is(':visible')) {
|
|
83
|
-
adv.hide();
|
|
84
|
-
icon.removeClass('fa-caret-down').addClass('fa-caret-right');
|
|
85
|
-
$(this).contents().last()[0].textContent = ' Advanced settings';
|
|
86
|
-
} else {
|
|
87
|
-
adv.show();
|
|
88
|
-
icon.removeClass('fa-caret-right').addClass('fa-caret-down');
|
|
89
|
-
$(this).contents().last()[0].textContent = ' Advanced settings';
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
</script>
|
|
94
50
|
</script>
|
|
95
51
|
|
|
96
52
|
|
|
@@ -98,7 +54,33 @@
|
|
|
98
54
|
<script type="text/html" data-help-name="circadian core">
|
|
99
55
|
<p>
|
|
100
56
|
The <strong>Circadian Core</strong> dynamically computes target values for key vivarium variables
|
|
101
|
-
(temperature, humidity, light), creating natural circadian and seasonal rhythms
|
|
102
|
-
|
|
57
|
+
(temperature, humidity, light), creating natural circadian and seasonal rhythms based on
|
|
58
|
+
astronomical calculations and geographic location.
|
|
59
|
+
</p>
|
|
60
|
+
|
|
61
|
+
<h3>Configuration</h3>
|
|
62
|
+
<ul>
|
|
63
|
+
<li><strong>Location Config:</strong> Reference to a shared location configuration node</li>
|
|
64
|
+
<li><strong>Phase Shift:</strong> Degrees of lead/lag relative to solar cycle (0° = solar noon peak)</li>
|
|
65
|
+
</ul>
|
|
66
|
+
|
|
67
|
+
<h3>Input</h3>
|
|
68
|
+
<p>
|
|
69
|
+
Send numeric values with <code>msg.topic</code> set to:
|
|
70
|
+
</p>
|
|
71
|
+
<ul>
|
|
72
|
+
<li><code>"absoluteMin"</code> or <code>"min"</code> - Minimum value (winter night)</li>
|
|
73
|
+
<li><code>"absoluteMax"</code> or <code>"max"</code> - Maximum value (summer day)</li>
|
|
74
|
+
</ul>
|
|
75
|
+
|
|
76
|
+
<h3>Outputs</h3>
|
|
77
|
+
<ul>
|
|
78
|
+
<li><strong>Output 1:</strong> Target value - calculated setpoint based on current simulated time</li>
|
|
79
|
+
<li><strong>Output 2:</strong> Status - detailed information including simulated time and time scale</li>
|
|
80
|
+
</ul>
|
|
81
|
+
|
|
82
|
+
<p>
|
|
83
|
+
The node combines seasonal and diurnal cycles to interpolate between absolute min/max values,
|
|
84
|
+
automatically adjusting for day length variations, latitude effects, and custom planetary parameters.
|
|
103
85
|
</p>
|
|
104
86
|
</script>
|
|
@@ -1,22 +1,163 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
class AstronomicalCalculator {
|
|
3
|
+
static degreesToRadians(degrees) {
|
|
4
|
+
return degrees * Math.PI / 180;
|
|
5
|
+
}
|
|
6
|
+
static radiansToDegrees(radians) {
|
|
7
|
+
return radians * 180 / Math.PI;
|
|
8
|
+
}
|
|
9
|
+
static getDayOfYear(date) {
|
|
10
|
+
const start = new Date(date.getFullYear(), 0, 0);
|
|
11
|
+
const diff = date.getTime() - start.getTime();
|
|
12
|
+
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
13
|
+
}
|
|
14
|
+
static getSolarDeclination(dayOfYear, orbitalPeriod, axialTilt) {
|
|
15
|
+
const dayAngle = 2 * Math.PI * (dayOfYear - 81) / orbitalPeriod;
|
|
16
|
+
return this.degreesToRadians(axialTilt) * Math.sin(dayAngle);
|
|
17
|
+
}
|
|
18
|
+
static getDayLength(latitude, solarDeclination) {
|
|
19
|
+
const latRad = this.degreesToRadians(latitude);
|
|
20
|
+
const cosHourAngle = -Math.tan(latRad) * Math.tan(solarDeclination);
|
|
21
|
+
if (cosHourAngle > 1)
|
|
22
|
+
return 0;
|
|
23
|
+
if (cosHourAngle < -1)
|
|
24
|
+
return 24;
|
|
25
|
+
const hourAngle = Math.acos(cosHourAngle);
|
|
26
|
+
return 2 * this.radiansToDegrees(hourAngle) / 15;
|
|
27
|
+
}
|
|
28
|
+
static getSolarNoonOffset(longitude, timezone) {
|
|
29
|
+
const tzMatch = timezone.match(/UTC([+-]?\d+(?:\.\d+)?)/i);
|
|
30
|
+
const tzOffset = tzMatch ? parseFloat(tzMatch[1]) : 0;
|
|
31
|
+
const longitudeOffset = longitude / 15;
|
|
32
|
+
return longitudeOffset - tzOffset;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
11
35
|
const circadianCore = (RED) => {
|
|
12
36
|
function CircadianCoreNode(config) {
|
|
13
37
|
RED.nodes.createNode(this, config);
|
|
14
38
|
const node = this;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
39
|
+
const state = {};
|
|
40
|
+
const locationConfigNode = RED.nodes.getNode(config.locationConfig);
|
|
41
|
+
if (!locationConfigNode) {
|
|
42
|
+
node.status({ fill: 'red', shape: 'dot', text: 'no location config' });
|
|
43
|
+
node.error('Location configuration node not found');
|
|
44
|
+
}
|
|
45
|
+
this.locationConfig = RED.nodes.getNode(config.locationConfig);
|
|
46
|
+
this.phaseShift = config.phaseShift || 0.0;
|
|
47
|
+
this.tickInterval = config.tickInterval || 60;
|
|
48
|
+
this.decimals = config.decimals !== undefined ? config.decimals : 1;
|
|
49
|
+
this.locationConfig = locationConfigNode;
|
|
50
|
+
const getSimulatedTime = () => {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const epoch = new Date('2000-01-01T00:00:00Z').getTime();
|
|
53
|
+
const realElapsed = now - epoch;
|
|
54
|
+
const simulatedElapsed = realElapsed * this.locationConfig.timeScale;
|
|
55
|
+
return new Date(epoch + simulatedElapsed);
|
|
56
|
+
};
|
|
57
|
+
const getSeasonalFactor = (simulatedTime) => {
|
|
58
|
+
const dayOfYear = AstronomicalCalculator.getDayOfYear(simulatedTime);
|
|
59
|
+
const solarDeclination = AstronomicalCalculator.getSolarDeclination(dayOfYear, this.locationConfig.orbitalPeriod, this.locationConfig.axialTilt);
|
|
60
|
+
const latRad = AstronomicalCalculator.degreesToRadians(this.locationConfig.latitude);
|
|
61
|
+
const seasonalAmplitude = Math.abs(Math.sin(latRad)) * Math.sin(AstronomicalCalculator.degreesToRadians(this.locationConfig.axialTilt));
|
|
62
|
+
const seasonalAngle = 2 * Math.PI * (dayOfYear - 172) / this.locationConfig.orbitalPeriod;
|
|
63
|
+
const seasonalValue = Math.cos(seasonalAngle) * seasonalAmplitude;
|
|
64
|
+
return 0.5 + seasonalValue * 0.5;
|
|
65
|
+
};
|
|
66
|
+
const getDiurnalFactor = (simulatedTime) => {
|
|
67
|
+
const dayOfYear = AstronomicalCalculator.getDayOfYear(simulatedTime);
|
|
68
|
+
const solarDeclination = AstronomicalCalculator.getSolarDeclination(dayOfYear, this.locationConfig.orbitalPeriod, this.locationConfig.axialTilt);
|
|
69
|
+
const dayLength = AstronomicalCalculator.getDayLength(this.locationConfig.latitude, solarDeclination);
|
|
70
|
+
const solarNoonOffset = AstronomicalCalculator.getSolarNoonOffset(this.locationConfig.longitude, this.locationConfig.timezone);
|
|
71
|
+
const hoursFromMidnight = simulatedTime.getUTCHours() + simulatedTime.getUTCMinutes() / 60;
|
|
72
|
+
const solarNoon = 12 + solarNoonOffset;
|
|
73
|
+
let hoursFromSolarNoon = hoursFromMidnight - solarNoon;
|
|
74
|
+
if (hoursFromSolarNoon > 12)
|
|
75
|
+
hoursFromSolarNoon -= 24;
|
|
76
|
+
if (hoursFromSolarNoon < -12)
|
|
77
|
+
hoursFromSolarNoon += 24;
|
|
78
|
+
const phaseShiftRad = AstronomicalCalculator.degreesToRadians(this.phaseShift || 0);
|
|
79
|
+
const diurnalAngle = (2 * Math.PI * hoursFromSolarNoon / this.locationConfig.rotationalPeriod) + phaseShiftRad;
|
|
80
|
+
const dayFraction = dayLength / 24;
|
|
81
|
+
const maxDiurnalHours = dayLength / 2;
|
|
82
|
+
if (Math.abs(hoursFromSolarNoon) > maxDiurnalHours) {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
const diurnalValue = Math.cos(diurnalAngle);
|
|
86
|
+
return Math.max(0, diurnalValue) * dayFraction;
|
|
87
|
+
};
|
|
88
|
+
const calculateTarget = () => {
|
|
89
|
+
if (state.absoluteMin === undefined || state.absoluteMax === undefined) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const simulatedTime = getSimulatedTime();
|
|
93
|
+
const seasonalFactor = getSeasonalFactor(simulatedTime);
|
|
94
|
+
const diurnalFactor = getDiurnalFactor(simulatedTime);
|
|
95
|
+
const combinedFactor = seasonalFactor * 0.7 + diurnalFactor * 0.3;
|
|
96
|
+
return state.absoluteMin + (state.absoluteMax - state.absoluteMin) * combinedFactor;
|
|
97
|
+
};
|
|
98
|
+
const emitTarget = () => {
|
|
99
|
+
const target = calculateTarget();
|
|
100
|
+
if (target === null) {
|
|
101
|
+
node.status({ fill: 'yellow', shape: 'dot', text: 'awaiting setpoints' });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const roundedTarget = Number(target.toFixed(this.decimals));
|
|
105
|
+
const simulatedTime = getSimulatedTime();
|
|
106
|
+
node.send([
|
|
107
|
+
{ payload: roundedTarget, topic: 'target' },
|
|
108
|
+
{ payload: { target: roundedTarget, simulatedTime: simulatedTime.toISOString(), timeScale: this.locationConfig.timeScale }, topic: 'status' }
|
|
109
|
+
]);
|
|
110
|
+
const statusText = `Target: ${roundedTarget.toFixed(this.decimals)} | Time: ${simulatedTime.toLocaleTimeString()}`;
|
|
111
|
+
node.status({ fill: 'green', shape: 'dot', text: statusText });
|
|
112
|
+
state.lastCalculation = Date.now();
|
|
113
|
+
};
|
|
114
|
+
const startTickInterval = () => {
|
|
115
|
+
if (state.intervalId) {
|
|
116
|
+
clearInterval(state.intervalId);
|
|
117
|
+
}
|
|
118
|
+
const intervalMs = (this.tickInterval || 60) * 1000;
|
|
119
|
+
state.intervalId = setInterval(() => {
|
|
120
|
+
if (state.absoluteMin !== undefined && state.absoluteMax !== undefined) {
|
|
121
|
+
emitTarget();
|
|
122
|
+
}
|
|
123
|
+
}, intervalMs);
|
|
124
|
+
if (state.absoluteMin !== undefined && state.absoluteMax !== undefined) {
|
|
125
|
+
emitTarget();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
node.on('input', (msg, send, done) => {
|
|
129
|
+
if (msg.topic === 'absoluteMin' && typeof msg.payload === 'number') {
|
|
130
|
+
state.absoluteMin = msg.payload;
|
|
131
|
+
node.log(`Absolute minimum updated: ${msg.payload}`);
|
|
132
|
+
if (state.absoluteMin !== undefined && state.absoluteMax !== undefined) {
|
|
133
|
+
emitTarget();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else if (msg.topic === 'absoluteMax' && typeof msg.payload === 'number') {
|
|
137
|
+
state.absoluteMax = msg.payload;
|
|
138
|
+
node.log(`Absolute maximum updated: ${msg.payload}`);
|
|
139
|
+
if (state.absoluteMin !== undefined && state.absoluteMax !== undefined) {
|
|
140
|
+
emitTarget();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (typeof msg.payload === 'object' && msg.payload !== null) {
|
|
144
|
+
const payload = msg.payload;
|
|
145
|
+
if (typeof payload.min === 'number' && typeof payload.max === 'number') {
|
|
146
|
+
state.absoluteMin = payload.min;
|
|
147
|
+
state.absoluteMax = payload.max;
|
|
148
|
+
node.log(`Setpoints updated: min=${payload.min}, max=${payload.max}`);
|
|
149
|
+
emitTarget();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
18
152
|
done === null || done === void 0 ? void 0 : done();
|
|
19
|
-
})
|
|
153
|
+
});
|
|
154
|
+
node.on('close', () => {
|
|
155
|
+
if (state.intervalId) {
|
|
156
|
+
clearInterval(state.intervalId);
|
|
157
|
+
state.intervalId = undefined;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
node.status({ fill: 'yellow', shape: 'dot', text: 'awaiting setpoints' });
|
|
20
161
|
}
|
|
21
162
|
RED.nodes.registerType('circadian core', CircadianCoreNode);
|
|
22
163
|
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('location-config', {
|
|
3
|
+
category: 'config',
|
|
4
|
+
defaults: {
|
|
5
|
+
name: { value: "" },
|
|
6
|
+
latitude: { value: 50.98, validate: RED.validators.number() },
|
|
7
|
+
longitude: { value: 4.32, validate: RED.validators.number() },
|
|
8
|
+
timezone: { value: "UTC+1" },
|
|
9
|
+
axialTilt: { value: 23.44, validate: RED.validators.number() },
|
|
10
|
+
orbitalPeriod: { value: 365.25, validate: RED.validators.number() },
|
|
11
|
+
rotationalPeriod: { value: 24, validate: RED.validators.number() },
|
|
12
|
+
timeScale: { value: 1.0, validate: RED.validators.number() }
|
|
13
|
+
},
|
|
14
|
+
label: function() {
|
|
15
|
+
return this.name || "Location Config";
|
|
16
|
+
},
|
|
17
|
+
icon: "font-awesome/fa-map-marker"
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<script type="text/html" data-template-name="location-config">
|
|
22
|
+
<div class="form-row">
|
|
23
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
24
|
+
<input type="text" id="node-config-input-name" placeholder="Location Config">
|
|
25
|
+
</div>
|
|
26
|
+
<hr/>
|
|
27
|
+
|
|
28
|
+
<div class="form-row">
|
|
29
|
+
<h3><i class="fa fa-map-marker"></i> Geographic Location</h3>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="form-row">
|
|
32
|
+
<label for="node-config-input-latitude">Latitude (°)</label>
|
|
33
|
+
<input type="number" id="node-config-input-latitude" placeholder="50.98" step="0.01" min="-90" max="90">
|
|
34
|
+
<i class="fa fa-info-circle" title="The latitude of your location. Controls day length variation across the year. (e.g. 0° = Equator, 50° = Central Europe, -34° = Buenos Aires)"></i>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="form-row">
|
|
37
|
+
<label for="node-config-input-longitude">Longitude (°)</label>
|
|
38
|
+
<input type="number" id="node-config-input-longitude" placeholder="4.32" step="0.01" min="-180" max="180">
|
|
39
|
+
<i class="fa fa-info-circle" title="The longitude of your location. Used for precise solar calculations. (e.g. 0° = Greenwich, 4.32° = Brussels, -74° = New York)"></i>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="form-row">
|
|
42
|
+
<label for="node-config-input-timezone">Timezone</label>
|
|
43
|
+
<input type="text" id="node-config-input-timezone" placeholder="UTC+1">
|
|
44
|
+
<i class="fa fa-info-circle" title="Timezone offset from UTC. Aligns the simulated solar noon with your local wall clock. (e.g. UTC+1, UTC-5, UTC+9)"></i>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<hr/>
|
|
48
|
+
<div class="form-row">
|
|
49
|
+
<a href="#" id="toggle-planetary" style="text-decoration:none; color:#C0504D;">
|
|
50
|
+
<i class="fa fa-caret-right"></i> Planetary Parameters
|
|
51
|
+
</a>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<!-- Planetary settings -->
|
|
55
|
+
<div id="planetary-settings" style="display:none;">
|
|
56
|
+
<div class="form-row">
|
|
57
|
+
<h3><i class="fa fa-globe"></i> Planetary Settings</h3>
|
|
58
|
+
<p style="font-size: 12px; color: #666; margin: 5px 0;">
|
|
59
|
+
Advanced settings for simulating non-Earth environments or custom seasonal patterns.
|
|
60
|
+
</p>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="form-row">
|
|
63
|
+
<label for="node-config-input-axialTilt">Axial Tilt (°)</label>
|
|
64
|
+
<input type="number" id="node-config-input-axialTilt" placeholder="23.44" step="0.01" min="0" max="90">
|
|
65
|
+
<i class="fa fa-info-circle" title="Angle between the rotation axis and orbital plane. Controls seasonal variation intensity. Earth = 23.44°, Mars = 25.19°, Jupiter = 3.13°"></i>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="form-row">
|
|
68
|
+
<label for="node-config-input-orbitalPeriod">Orbital Period (days)</label>
|
|
69
|
+
<input type="number" id="node-config-input-orbitalPeriod" placeholder="365.25" step="0.01" min="1">
|
|
70
|
+
<i class="fa fa-info-circle" title="Length of a full year (orbit around the sun). Earth = 365.25 days, Mars = 687 days, Venus = 225 days"></i>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="form-row">
|
|
73
|
+
<label for="node-config-input-rotationalPeriod">Rotational Period (hours)</label>
|
|
74
|
+
<input type="number" id="node-config-input-rotationalPeriod" placeholder="24" step="0.01" min="0.1">
|
|
75
|
+
<i class="fa fa-info-circle" title="Length of a full day-night cycle. Earth = 24 hours, Mars = 24.6 hours, Venus = 5832 hours"></i>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="form-row">
|
|
78
|
+
<label for="node-config-input-timeScale">Time Scale</label>
|
|
79
|
+
<input type="number" id="node-config-input-timeScale" placeholder="1.0" step="0.1" min="0.1">
|
|
80
|
+
<i class="fa fa-info-circle" title="Time compression factor. 1.0 = real time, 4.0 = four day/night cycles per real day. All circadian nodes sharing this config run at the same simulated time."></i>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<script>
|
|
85
|
+
$(function() {
|
|
86
|
+
$('#toggle-planetary').on('click', function(e) {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
var planetary = $('#planetary-settings');
|
|
89
|
+
var icon = $(this).find('i');
|
|
90
|
+
if (planetary.is(':visible')) {
|
|
91
|
+
planetary.hide();
|
|
92
|
+
icon.removeClass('fa-caret-down').addClass('fa-caret-right');
|
|
93
|
+
$(this).contents().last()[0].textContent = ' Planetary Parameters';
|
|
94
|
+
} else {
|
|
95
|
+
planetary.show();
|
|
96
|
+
icon.removeClass('fa-caret-right').addClass('fa-caret-down');
|
|
97
|
+
$(this).contents().last()[0].textContent = ' Planetary Parameters';
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
</script>
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<script type="text/html" data-help-name="location-config">
|
|
105
|
+
<p>
|
|
106
|
+
The <strong>Location Configuration</strong> node stores geographic and planetary parameters
|
|
107
|
+
that can be shared across multiple circadian rhythm nodes in your flow.
|
|
108
|
+
</p>
|
|
109
|
+
|
|
110
|
+
<h3>Geographic Settings</h3>
|
|
111
|
+
<ul>
|
|
112
|
+
<li><strong>Latitude:</strong> Controls seasonal day length variation</li>
|
|
113
|
+
<li><strong>Longitude:</strong> Used for precise solar position calculations</li>
|
|
114
|
+
<li><strong>Timezone:</strong> Aligns simulation with local wall clock time</li>
|
|
115
|
+
</ul>
|
|
116
|
+
|
|
117
|
+
<h3>Planetary Parameters</h3>
|
|
118
|
+
<p>
|
|
119
|
+
Advanced settings for simulating non-Earth environments or creating custom seasonal patterns:
|
|
120
|
+
</p>
|
|
121
|
+
<ul>
|
|
122
|
+
<li><strong>Axial Tilt:</strong> Controls intensity of seasonal variation</li>
|
|
123
|
+
<li><strong>Orbital Period:</strong> Length of the year cycle</li>
|
|
124
|
+
<li><strong>Rotational Period:</strong> Length of the day-night cycle</li>
|
|
125
|
+
</ul>
|
|
126
|
+
|
|
127
|
+
<p>
|
|
128
|
+
This configuration can be referenced by multiple Circadian Core nodes,
|
|
129
|
+
allowing consistent location settings across your entire vivarium control system.
|
|
130
|
+
</p>
|
|
131
|
+
</script>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const locationConfig = (RED) => {
|
|
3
|
+
function LocationConfigNode(config) {
|
|
4
|
+
RED.nodes.createNode(this, config);
|
|
5
|
+
this.latitude = config.latitude || 50.98;
|
|
6
|
+
this.longitude = config.longitude || 4.32;
|
|
7
|
+
this.timezone = config.timezone || 'UTC+1';
|
|
8
|
+
this.axialTilt = config.axialTilt || 23.44;
|
|
9
|
+
this.orbitalPeriod = config.orbitalPeriod || 365.25;
|
|
10
|
+
this.rotationalPeriod = config.rotationalPeriod || 24;
|
|
11
|
+
this.timeScale = config.timeScale || 1.0;
|
|
12
|
+
}
|
|
13
|
+
RED.nodes.registerType('location-config', LocationConfigNode);
|
|
14
|
+
};
|
|
15
|
+
module.exports = locationConfig;
|
|
@@ -3,14 +3,49 @@
|
|
|
3
3
|
category: 'Freya Vivariums',
|
|
4
4
|
color: "#A2CA6F",
|
|
5
5
|
defaults: {
|
|
6
|
-
name: {value:""}
|
|
6
|
+
name: {value:""},
|
|
7
|
+
deadband: {value: 2.0, required: true, validate: RED.validators.number()},
|
|
8
|
+
minimumHumidity: {value: 0.0, required: true, validate: RED.validators.number()},
|
|
9
|
+
maximumHumidity: {value: 100.0, required: true, validate: RED.validators.number()}
|
|
7
10
|
},
|
|
8
11
|
inputs:1,
|
|
9
12
|
outputs:2,
|
|
10
|
-
outputLabels:["
|
|
13
|
+
outputLabels:["actuator commands", "status"],
|
|
11
14
|
icon: "font-awesome/fa-tint",
|
|
12
15
|
label: function() {
|
|
13
16
|
return this.name || "Humidity Controller";
|
|
17
|
+
},
|
|
18
|
+
oneditprepare: function() {
|
|
19
|
+
// Validation for deadband
|
|
20
|
+
$("#node-input-deadband").on('input', function() {
|
|
21
|
+
const val = parseFloat($(this).val());
|
|
22
|
+
if (isNaN(val) || val < 0) {
|
|
23
|
+
$(this).addClass('input-error');
|
|
24
|
+
} else {
|
|
25
|
+
$(this).removeClass('input-error');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Validation for minimum humidity
|
|
30
|
+
$("#node-input-minimumHumidity").on('input', function() {
|
|
31
|
+
const val = parseFloat($(this).val());
|
|
32
|
+
if (isNaN(val) || val < 0 || val > 100) {
|
|
33
|
+
$(this).addClass('input-error');
|
|
34
|
+
} else {
|
|
35
|
+
$(this).removeClass('input-error');
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Validation for maximum humidity
|
|
40
|
+
$("#node-input-maximumHumidity").on('input', function() {
|
|
41
|
+
const val = parseFloat($(this).val());
|
|
42
|
+
const min = parseFloat($("#node-input-minimumHumidity").val());
|
|
43
|
+
if (isNaN(val) || val < 0 || val > 100 || (!isNaN(min) && val <= min)) {
|
|
44
|
+
$(this).addClass('input-error');
|
|
45
|
+
} else {
|
|
46
|
+
$(this).removeClass('input-error');
|
|
47
|
+
}
|
|
48
|
+
});
|
|
14
49
|
}
|
|
15
50
|
});
|
|
16
51
|
</script>
|
|
@@ -20,8 +55,59 @@
|
|
|
20
55
|
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
|
|
21
56
|
<input type="text" id="node-input-name" placeholder="Name">
|
|
22
57
|
</div>
|
|
58
|
+
|
|
59
|
+
<div class="form-row">
|
|
60
|
+
<label for="node-input-deadband"><i class="fa fa-arrows-h"></i> Deadband (%)</label>
|
|
61
|
+
<input type="number" id="node-input-deadband" placeholder="2.0" step="0.1" min="0">
|
|
62
|
+
<div class="form-tips">Half-width around target humidity to prevent oscillation</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="form-row">
|
|
66
|
+
<label for="node-input-minimumHumidity"><i class="fa fa-thermometer-empty"></i> Minimum Humidity (%)</label>
|
|
67
|
+
<input type="number" id="node-input-minimumHumidity" placeholder="0.0" step="1" min="0" max="100">
|
|
68
|
+
<div class="form-tips">Safety floor - force humidification below this level</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div class="form-row">
|
|
72
|
+
<label for="node-input-maximumHumidity"><i class="fa fa-thermometer-full"></i> Maximum Humidity (%)</label>
|
|
73
|
+
<input type="number" id="node-input-maximumHumidity" placeholder="100.0" step="1" min="0" max="100">
|
|
74
|
+
<div class="form-tips">Safety ceiling - force dehumidification above this level</div>
|
|
75
|
+
</div>
|
|
23
76
|
</script>
|
|
24
77
|
|
|
25
78
|
<script type="text/html" data-help-name="humidity controller">
|
|
26
|
-
<p>
|
|
79
|
+
<p>Bang-bang humidity controller with deadband and safety overrides for vivarium environmental control.</p>
|
|
80
|
+
|
|
81
|
+
<h3>Input</h3>
|
|
82
|
+
<dl class="message-properties">
|
|
83
|
+
<dt>Target Humidity <span class="property-type">number</span></dt>
|
|
84
|
+
<dd>Target humidity percentage from circadian core or other source</dd>
|
|
85
|
+
|
|
86
|
+
<dt>Sensor Reading <span class="property-type">number</span></dt>
|
|
87
|
+
<dd>Current humidity reading from environment sensor with <code>msg.topic: "sensor"</code> and <code>msg.payload: number</code></dd>
|
|
88
|
+
</dl>
|
|
89
|
+
|
|
90
|
+
<h3>Outputs</h3>
|
|
91
|
+
<dl class="message-properties">
|
|
92
|
+
<dt>Output 1: Actuator Commands <span class="property-type">object</span></dt>
|
|
93
|
+
<dd>Commands for actuators: <code>{humidifier: "on/off", dehumidifier: "on/off"}</code></dd>
|
|
94
|
+
|
|
95
|
+
<dt>Output 2: Status <span class="property-type">object</span></dt>
|
|
96
|
+
<dd>Controller status including state, readings, safety overrides, and deadband</dd>
|
|
97
|
+
</dl>
|
|
98
|
+
|
|
99
|
+
<h3>Control Logic</h3>
|
|
100
|
+
<ul>
|
|
101
|
+
<li><strong>Humidifying:</strong> When reading < target - deadband</li>
|
|
102
|
+
<li><strong>Dehumidifying:</strong> When reading > target + deadband</li>
|
|
103
|
+
<li><strong>Idle:</strong> When within deadband (maintains previous state)</li>
|
|
104
|
+
<li><strong>Safety Override:</strong> Force humidification below minimum or dehumidification above maximum</li>
|
|
105
|
+
</ul>
|
|
106
|
+
|
|
107
|
+
<h3>Configuration</h3>
|
|
108
|
+
<ul>
|
|
109
|
+
<li><strong>Deadband:</strong> Half-width around target to prevent oscillation (default: 2.0%)</li>
|
|
110
|
+
<li><strong>Minimum Humidity:</strong> Safety floor for emergency humidification (default: 0%)</li>
|
|
111
|
+
<li><strong>Maximum Humidity:</strong> Safety ceiling for emergency dehumidification (default: 100%)</li>
|
|
112
|
+
</ul>
|
|
27
113
|
</script>
|
|
@@ -1,22 +1,110 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
-
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
-
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
-
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
-
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
-
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
-
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
-
});
|
|
10
|
-
};
|
|
11
2
|
const humidityController = (RED) => {
|
|
12
3
|
function HumidityControllerNode(config) {
|
|
13
4
|
RED.nodes.createNode(this, config);
|
|
14
5
|
const node = this;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
6
|
+
this.deadband = config.deadband || 2.0;
|
|
7
|
+
this.minimumHumidity = config.minimumHumidity || 0.0;
|
|
8
|
+
this.maximumHumidity = config.maximumHumidity || 100.0;
|
|
9
|
+
const state = {
|
|
10
|
+
currentState: 'idle',
|
|
11
|
+
safetyOverride: null
|
|
12
|
+
};
|
|
13
|
+
const calculateControl = () => {
|
|
14
|
+
if (state.currentHumidity === undefined || state.targetHumidity === undefined) {
|
|
15
|
+
return { humidifier: 'off', dehumidifier: 'off', state: 'idle' };
|
|
16
|
+
}
|
|
17
|
+
const humidity = state.currentHumidity;
|
|
18
|
+
const target = state.targetHumidity;
|
|
19
|
+
const deadband = this.deadband;
|
|
20
|
+
if (humidity < (this.minimumHumidity || 0.0)) {
|
|
21
|
+
state.safetyOverride = 'min';
|
|
22
|
+
state.currentState = 'humidifying';
|
|
23
|
+
return { humidifier: 'on', dehumidifier: 'off', state: 'humidifying', override: 'minimum humidity' };
|
|
24
|
+
}
|
|
25
|
+
if (humidity > (this.maximumHumidity || 100.0)) {
|
|
26
|
+
state.safetyOverride = 'max';
|
|
27
|
+
state.currentState = 'dehumidifying';
|
|
28
|
+
return { humidifier: 'off', dehumidifier: 'on', state: 'dehumidifying', override: 'maximum humidity' };
|
|
29
|
+
}
|
|
30
|
+
state.safetyOverride = null;
|
|
31
|
+
if (humidity < target - (deadband || 2.0)) {
|
|
32
|
+
state.currentState = 'humidifying';
|
|
33
|
+
return { humidifier: 'on', dehumidifier: 'off', state: 'humidifying' };
|
|
34
|
+
}
|
|
35
|
+
else if (humidity > target + (deadband || 2.0)) {
|
|
36
|
+
state.currentState = 'dehumidifying';
|
|
37
|
+
return { humidifier: 'off', dehumidifier: 'on', state: 'dehumidifying' };
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const humidifier = state.currentState === 'humidifying' ? 'on' : 'off';
|
|
41
|
+
const dehumidifier = state.currentState === 'dehumidifying' ? 'on' : 'off';
|
|
42
|
+
return { humidifier, dehumidifier, state: state.currentState };
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const updateStatus = (control) => {
|
|
46
|
+
if (state.currentHumidity === undefined || state.targetHumidity === undefined) {
|
|
47
|
+
node.status({ fill: 'yellow', shape: 'dot', text: 'awaiting inputs' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const humidity = state.currentHumidity.toFixed(1);
|
|
51
|
+
const target = state.targetHumidity.toFixed(1);
|
|
52
|
+
if (control.override) {
|
|
53
|
+
node.status({ fill: 'red', shape: 'dot', text: `SAFETY: ${control.override} (${humidity}%)` });
|
|
54
|
+
}
|
|
55
|
+
else if (control.state === 'humidifying') {
|
|
56
|
+
node.status({ fill: 'green', shape: 'dot', text: `Humidifying: ${humidity}% → ${target}%` });
|
|
57
|
+
}
|
|
58
|
+
else if (control.state === 'dehumidifying') {
|
|
59
|
+
node.status({ fill: 'blue', shape: 'dot', text: `Dehumidifying: ${humidity}% → ${target}%` });
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
node.status({ fill: 'grey', shape: 'dot', text: `Idle: ${humidity}% (${target}%)` });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const processControl = () => {
|
|
66
|
+
const control = calculateControl();
|
|
67
|
+
node.send([
|
|
68
|
+
{
|
|
69
|
+
payload: {
|
|
70
|
+
humidifier: control.humidifier,
|
|
71
|
+
dehumidifier: control.dehumidifier
|
|
72
|
+
},
|
|
73
|
+
topic: 'actuators'
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
payload: {
|
|
77
|
+
state: control.state,
|
|
78
|
+
target: state.targetHumidity,
|
|
79
|
+
reading: state.currentHumidity,
|
|
80
|
+
safetyOverride: control.override || null,
|
|
81
|
+
deadband: this.deadband
|
|
82
|
+
},
|
|
83
|
+
topic: 'status'
|
|
84
|
+
}
|
|
85
|
+
]);
|
|
86
|
+
updateStatus(control);
|
|
87
|
+
};
|
|
88
|
+
node.on('input', (msg, send, done) => {
|
|
89
|
+
if (msg.topic === 'sensor') {
|
|
90
|
+
if (typeof msg.payload === 'number') {
|
|
91
|
+
state.currentHumidity = msg.payload;
|
|
92
|
+
node.log(`Current humidity updated: ${msg.payload}%`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
if (typeof msg.payload === 'number') {
|
|
97
|
+
state.targetHumidity = msg.payload;
|
|
98
|
+
node.log(`Target humidity updated: ${msg.payload}%`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (state.targetHumidity !== undefined && state.currentHumidity !== undefined) {
|
|
102
|
+
processControl();
|
|
103
|
+
}
|
|
104
|
+
if (done)
|
|
105
|
+
done();
|
|
106
|
+
});
|
|
107
|
+
node.status({ fill: 'yellow', shape: 'dot', text: 'awaiting inputs' });
|
|
20
108
|
}
|
|
21
109
|
RED.nodes.registerType('humidity controller', HumidityControllerNode);
|
|
22
110
|
};
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
color: "#A2CA6F",
|
|
5
5
|
defaults: {
|
|
6
6
|
name: {value:""},
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
deadband: {value:1.0, required:true, validate:RED.validators.number()},
|
|
8
|
+
minimumTemperature: {value:10.0, required:true, validate:RED.validators.number()},
|
|
9
|
+
maximumTemperature: {value:50.0, required:true, validate:RED.validators.number()}
|
|
10
10
|
},
|
|
11
11
|
inputs:1,
|
|
12
12
|
outputs:2,
|
|
13
|
-
outputLabels:["
|
|
13
|
+
outputLabels:["actuator commands", "status"],
|
|
14
14
|
icon: "font-awesome/fa-thermometer-half",
|
|
15
15
|
label: function() {
|
|
16
16
|
return this.name||"Temperature Controller";
|
|
@@ -20,41 +20,62 @@
|
|
|
20
20
|
|
|
21
21
|
<script type="text/html" data-template-name="temperature controller">
|
|
22
22
|
<div class="form-row">
|
|
23
|
-
<label for="node-input-name"><i class="
|
|
24
|
-
<input type="text" id="node-input-name" placeholder="
|
|
23
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
24
|
+
<input type="text" id="node-input-name" placeholder="Temperature Controller">
|
|
25
25
|
</div>
|
|
26
26
|
<hr/>
|
|
27
|
-
<h2><i class="fa fa-thermometer-full"></i> Extremes</h2>
|
|
28
|
-
<p>The absolute maximum ratings</p>
|
|
29
27
|
<div class="form-row">
|
|
30
|
-
<
|
|
31
|
-
<input type="number" id="node-input-minimumTemperature" placeholder="25">
|
|
28
|
+
<h3><i class="fa fa-adjust"></i> Control Settings</h3>
|
|
32
29
|
</div>
|
|
33
30
|
<div class="form-row">
|
|
34
|
-
<label for="node-input-
|
|
35
|
-
<input type="number" id="node-input-
|
|
31
|
+
<label for="node-input-deadband">Deadband (°C)</label>
|
|
32
|
+
<input type="number" id="node-input-deadband" placeholder="1.0" step="0.1" min="0.1">
|
|
33
|
+
<i class="fa fa-info-circle" title="Half-width of the deadband around target. With target 25°C and deadband 1.0°C, controller is idle between 24-26°C."></i>
|
|
36
34
|
</div>
|
|
37
35
|
<hr/>
|
|
38
|
-
<h2><i class="fa fa-sliders"></i> P-controller</h2>
|
|
39
36
|
<div class="form-row">
|
|
40
|
-
<
|
|
41
|
-
|
|
37
|
+
<h3><i class="fa fa-shield"></i> Safety Limits</h3>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="form-row">
|
|
40
|
+
<label for="node-input-minimumTemperature">Minimum Temperature (°C)</label>
|
|
41
|
+
<input type="number" id="node-input-minimumTemperature" placeholder="10.0" step="0.1">
|
|
42
|
+
<i class="fa fa-info-circle" title="Absolute safety floor. Force heating if sensor reads below this, regardless of target."></i>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="form-row">
|
|
45
|
+
<label for="node-input-maximumTemperature">Maximum Temperature (°C)</label>
|
|
46
|
+
<input type="number" id="node-input-maximumTemperature" placeholder="50.0" step="0.1">
|
|
47
|
+
<i class="fa fa-info-circle" title="Absolute safety ceiling. Force cooling if sensor reads above this, regardless of target."></i>
|
|
42
48
|
</div>
|
|
43
49
|
</script>
|
|
44
50
|
|
|
45
51
|
<script type="text/html" data-help-name="temperature controller">
|
|
46
|
-
<p>The <b>Temperature Controller</b> node implements
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
</
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
<p>The <b>Temperature Controller</b> node implements bang-bang control with deadband for temperature regulation.</p>
|
|
53
|
+
|
|
54
|
+
<h3>Input</h3>
|
|
55
|
+
<dl class="message-properties">
|
|
56
|
+
<dt>Target Temperature <span class="property-type">number</span></dt>
|
|
57
|
+
<dd>Target temperature in degrees Celsius from circadian core or other source</dd>
|
|
58
|
+
|
|
59
|
+
<dt>Sensor Reading <span class="property-type">number</span></dt>
|
|
60
|
+
<dd>Current temperature reading from environment sensor with <code>msg.topic: "sensor"</code> and <code>msg.payload: number</code></dd>
|
|
61
|
+
</dl>
|
|
62
|
+
|
|
63
|
+
<h3>Outputs</h3>
|
|
64
|
+
<ul>
|
|
65
|
+
<li><strong>Output 1 (Actuators):</strong> <code>msg.payload</code> (object) - actuator commands: <code>{ heater: "on/off", cooler: "on/off" }</code></li>
|
|
66
|
+
<li><strong>Output 2 (Status):</strong> Status information for aggregation</li>
|
|
67
|
+
</ul>
|
|
68
|
+
|
|
69
|
+
<h3>Control Logic</h3>
|
|
70
|
+
<ul>
|
|
71
|
+
<li><strong>Heating:</strong> When reading < target - deadband</li>
|
|
72
|
+
<li><strong>Cooling:</strong> When reading > target + deadband</li>
|
|
73
|
+
<li><strong>Idle:</strong> When reading within deadband (maintains previous state)</li>
|
|
74
|
+
</ul>
|
|
75
|
+
|
|
76
|
+
<h3>Safety Overrides</h3>
|
|
77
|
+
<ul>
|
|
78
|
+
<li><strong>Emergency Heating:</strong> Forces heating if reading < minimum temperature</li>
|
|
79
|
+
<li><strong>Emergency Cooling:</strong> Forces cooling if reading > maximum temperature</li>
|
|
80
|
+
</ul>
|
|
60
81
|
</script>
|
|
@@ -1,43 +1,110 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
-
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
-
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
-
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
-
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
-
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
-
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
-
});
|
|
10
|
-
};
|
|
11
|
-
const temperature_controller_1 = require("./temperature-controller");
|
|
12
2
|
const temperatureController = (RED) => {
|
|
13
3
|
function TemperatureControllerNode(config) {
|
|
14
4
|
RED.nodes.createNode(this, config);
|
|
15
5
|
const node = this;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
6
|
+
this.deadband = config.deadband || 1.0;
|
|
7
|
+
this.minimumTemperature = config.minimumTemperature || 10.0;
|
|
8
|
+
this.maximumTemperature = config.maximumTemperature || 50.0;
|
|
9
|
+
const state = {
|
|
10
|
+
currentState: 'idle',
|
|
11
|
+
safetyOverride: null
|
|
12
|
+
};
|
|
13
|
+
const calculateControl = () => {
|
|
14
|
+
if (state.currentTemperature === undefined || state.targetTemperature === undefined) {
|
|
15
|
+
return { heater: 'off', cooler: 'off', state: 'idle' };
|
|
16
|
+
}
|
|
17
|
+
const temp = state.currentTemperature;
|
|
18
|
+
const target = state.targetTemperature;
|
|
19
|
+
const deadband = this.deadband;
|
|
20
|
+
if (temp < (this.minimumTemperature || 10.0)) {
|
|
21
|
+
state.safetyOverride = 'min';
|
|
22
|
+
state.currentState = 'heating';
|
|
23
|
+
return { heater: 'on', cooler: 'off', state: 'heating', override: 'minimum temperature' };
|
|
24
|
+
}
|
|
25
|
+
if (temp > (this.maximumTemperature || 50.0)) {
|
|
26
|
+
state.safetyOverride = 'max';
|
|
27
|
+
state.currentState = 'cooling';
|
|
28
|
+
return { heater: 'off', cooler: 'on', state: 'cooling', override: 'maximum temperature' };
|
|
29
|
+
}
|
|
30
|
+
state.safetyOverride = null;
|
|
31
|
+
if (temp < target - (deadband || 1.0)) {
|
|
32
|
+
state.currentState = 'heating';
|
|
33
|
+
return { heater: 'on', cooler: 'off', state: 'heating' };
|
|
34
|
+
}
|
|
35
|
+
else if (temp > target + (deadband || 1.0)) {
|
|
36
|
+
state.currentState = 'cooling';
|
|
37
|
+
return { heater: 'off', cooler: 'on', state: 'cooling' };
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const heater = state.currentState === 'heating' ? 'on' : 'off';
|
|
41
|
+
const cooler = state.currentState === 'cooling' ? 'on' : 'off';
|
|
42
|
+
return { heater, cooler, state: state.currentState };
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const updateStatus = (control) => {
|
|
46
|
+
if (state.currentTemperature === undefined || state.targetTemperature === undefined) {
|
|
47
|
+
node.status({ fill: 'yellow', shape: 'dot', text: 'awaiting inputs' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const temp = state.currentTemperature.toFixed(1);
|
|
51
|
+
const target = state.targetTemperature.toFixed(1);
|
|
52
|
+
if (control.override) {
|
|
53
|
+
node.status({ fill: 'red', shape: 'dot', text: `SAFETY: ${control.override} (${temp}°C)` });
|
|
54
|
+
}
|
|
55
|
+
else if (control.state === 'heating') {
|
|
56
|
+
node.status({ fill: 'green', shape: 'dot', text: `Heating: ${temp}°C → ${target}°C` });
|
|
28
57
|
}
|
|
29
|
-
else if (
|
|
30
|
-
|
|
31
|
-
controller.configure(parseFloat(setpoint), parseFloat(kp));
|
|
58
|
+
else if (control.state === 'cooling') {
|
|
59
|
+
node.status({ fill: 'blue', shape: 'dot', text: `Cooling: ${temp}°C → ${target}°C` });
|
|
32
60
|
}
|
|
33
61
|
else {
|
|
34
|
-
node.
|
|
62
|
+
node.status({ fill: 'grey', shape: 'dot', text: `Idle: ${temp}°C (${target}°C)` });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const processControl = () => {
|
|
66
|
+
const control = calculateControl();
|
|
67
|
+
node.send([
|
|
68
|
+
{
|
|
69
|
+
payload: {
|
|
70
|
+
heater: control.heater,
|
|
71
|
+
cooler: control.cooler
|
|
72
|
+
},
|
|
73
|
+
topic: 'actuators'
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
payload: {
|
|
77
|
+
state: control.state,
|
|
78
|
+
target: state.targetTemperature,
|
|
79
|
+
reading: state.currentTemperature,
|
|
80
|
+
safetyOverride: control.override || null,
|
|
81
|
+
deadband: this.deadband
|
|
82
|
+
},
|
|
83
|
+
topic: 'status'
|
|
84
|
+
}
|
|
85
|
+
]);
|
|
86
|
+
updateStatus(control);
|
|
87
|
+
};
|
|
88
|
+
node.on('input', (msg, send, done) => {
|
|
89
|
+
if (msg.topic === 'sensor') {
|
|
90
|
+
if (typeof msg.payload === 'number') {
|
|
91
|
+
state.currentTemperature = msg.payload;
|
|
92
|
+
node.log(`Current temperature updated: ${msg.payload}°C`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
if (typeof msg.payload === 'number') {
|
|
97
|
+
state.targetTemperature = msg.payload;
|
|
98
|
+
node.log(`Target temperature updated: ${msg.payload}°C`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (state.targetTemperature !== undefined && state.currentTemperature !== undefined) {
|
|
102
|
+
processControl();
|
|
35
103
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
controller.clear();
|
|
39
|
-
done();
|
|
104
|
+
if (done)
|
|
105
|
+
done();
|
|
40
106
|
});
|
|
107
|
+
node.status({ fill: 'yellow', shape: 'dot', text: 'awaiting inputs' });
|
|
41
108
|
}
|
|
42
109
|
RED.nodes.registerType('temperature controller', TemperatureControllerNode);
|
|
43
110
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-freya-nodes",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Custom nodes for Freya Vivarium Control System",
|
|
5
5
|
"author": "Sanne 'SpuQ' Santens",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,10 +25,12 @@
|
|
|
25
25
|
"deploy": "bash ./scripts/deploy.sh && rm -rf build/"
|
|
26
26
|
},
|
|
27
27
|
"node-red": {
|
|
28
|
+
"version": ">=4.0.9",
|
|
28
29
|
"nodes": {
|
|
29
30
|
"environment-sensor": "nodes/environment-sensor/environment-sensor-node.js",
|
|
30
31
|
"system-actuator": "nodes/system-actuators/system-actuators-node.js",
|
|
31
32
|
"circadian-core": "nodes/circadian-core/circadian-core-node.js",
|
|
33
|
+
"location-config": "nodes/circadian-core/location-config-node.js",
|
|
32
34
|
"humidity-controller": "nodes/humidity-controller/humidity-controller-node.js",
|
|
33
35
|
"lighting-controller": "nodes/lighting-controller/lighting-controller-node.js",
|
|
34
36
|
"precipitation-controller": "nodes/precipitation-controller/precipitation-controller-node.js",
|
|
@@ -36,6 +38,9 @@
|
|
|
36
38
|
"status-aggregator": "nodes/status-aggregator/status-aggregator-node.js"
|
|
37
39
|
}
|
|
38
40
|
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18.0.0"
|
|
43
|
+
},
|
|
39
44
|
"files": [
|
|
40
45
|
"icons",
|
|
41
46
|
"nodes"
|