From 968c47b404b350b971e91cffe88df35d65d6b0bd Mon Sep 17 00:00:00 2001 From: 00asdf Date: Sat, 26 Apr 2025 01:42:59 +0200 Subject: [PATCH] weather sensitive pid controller --- .gitignore | 1 + .idea/artifacts/VFHeatControl_jar.xml | 11 ++ .../asdf00/visionfive/hc/GlobalDataStore.kt | 7 +- .../visionfive/hc/control/PidController.kt | 37 +++++- .../dev/asdf00/visionfive/hc/server/Main.kt | 112 ++++++++++++++---- src/main/resources/META-INF/MANIFEST.MF | 3 + 6 files changed, 147 insertions(+), 24 deletions(-) create mode 100644 .idea/artifacts/VFHeatControl_jar.xml create mode 100644 src/main/resources/META-INF/MANIFEST.MF diff --git a/.gitignore b/.gitignore index 3d901d4..8978832 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ bin/ .DS_Store /.idea/misc.xml /userlist.txt +/VFHeatControl.jar diff --git a/.idea/artifacts/VFHeatControl_jar.xml b/.idea/artifacts/VFHeatControl_jar.xml new file mode 100644 index 0000000..02af514 --- /dev/null +++ b/.idea/artifacts/VFHeatControl_jar.xml @@ -0,0 +1,11 @@ + + + $PROJECT_DIR$ + + + + + + + + \ No newline at end of file diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt index 01a94a8..7e57426 100644 --- a/src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt +++ b/src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt @@ -5,13 +5,16 @@ import java.util.* object GlobalDataStore { @Volatile var temperature = 0.0 @Volatile var tempUpdated = Date() - @Volatile var isSunny = true + + @Volatile var isSunny = false + @Volatile var lowerTime = Date() @Volatile var resetTime = Date() @Volatile var targetTemp = 22.5 + @Volatile var reduction = 2.0 @Volatile var pval = 0.0 @Volatile var ival = 1.0 @Volatile var dval = 2.0 - const val API = "https://api.open-meteo.com/v1/forecast?latitude=47.6649&longitude=14.3401&daily=sunshine_duration,daylight_duration&hourly=temperature_2m&timezone=Europe%2FBerlin&forecast_days=1&format=json&timeformat=unixtime" + const val API = "https://api.open-meteo.com/v1/forecast?latitude=47.6649&longitude=14.3401&daily=sunshine_duration,daylight_duration,sunrise&hourly=temperature_2m&timezone=Europe/Berlin&forecast_days=1&format=json&timeformat=unixtime" } \ No newline at end of file diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/control/PidController.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/control/PidController.kt index 7ce2188..bc55c52 100644 --- a/src/main/kotlin/dev/asdf00/visionfive/hc/control/PidController.kt +++ b/src/main/kotlin/dev/asdf00/visionfive/hc/control/PidController.kt @@ -1,4 +1,39 @@ package dev.asdf00.visionfive.hc.control -class PidController { +import dev.asdf00.visionfive.hc.GlobalDataStore +import dev.asdf00.visionfive.hc.gpio.Controller +import java.util.* + +class PidController(vararg val controlUnits: Controller) { + init { + if (controlUnits.size < 1) + throw IllegalArgumentException("PID-Controller needs motor control units to act") + } + + private var errorInt = 0.0 + private var errorLast = 0.0 + + private fun step(actual: Double, desired: Double): Double { + val error = desired - actual + errorInt = Math.clamp(errorInt + error, 0.0, 1.0) + val rv = GlobalDataStore.pval * error + + GlobalDataStore.ival * errorInt + + GlobalDataStore.dval * (error - errorLast) + errorLast = error + return rv + } + + fun tick() { + val now = Date() + val myTarget = + if (GlobalDataStore.isSunny && now.after(GlobalDataStore.lowerTime) && now.before(GlobalDataStore.resetTime)) + GlobalDataStore.targetTemp - GlobalDataStore.reduction + else + GlobalDataStore.targetTemp + val newState = Math.clamp(step(GlobalDataStore.temperature, myTarget), 0.0, 1.0) + + controlUnits.forEach { + it.state = newState + } + } } \ No newline at end of file diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt index f91f77b..8d3af6f 100644 --- a/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt +++ b/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt @@ -3,6 +3,9 @@ package dev.asdf00.visionfive.hc.server import com.sun.net.httpserver.HttpExchange import dev.asdf00.visionfive.hc.GlobalDataStore +import dev.asdf00.visionfive.hc.control.PidController +import dev.asdf00.visionfive.hc.gpio.Controller +import dev.asdf00.visionfive.hc.gpio.usingPins import org.json.JSONArray import org.json.JSONException @@ -17,6 +20,8 @@ import java.nio.charset.StandardCharsets import java.time.Duration import java.util.* +const val DEBUG = true + fun main() { // out-facing web page val server = buildWebServer(8030) { @@ -167,7 +172,10 @@ fun main() { // new values try { val data = JSONObject(exchange.requestBody.readAllBytes().utf8()).toMap() - if (!data.containsKey("temp") || !data.containsKey("p") || !data.containsKey("i") || !data.containsKey("d")) { + if (!data.containsKey("temp") || !data.containsKey("p") || !data.containsKey("i") || !data.containsKey( + "d" + ) + ) { exchange.replyCat(400) } else { val temp = data["temp"].toString().toDouble() @@ -239,26 +247,47 @@ fun main() { } } if (maxTempTime > 0) { - val daylight = ((data["daily"] as? JSONObject)?.get("daylight_duration") as? JSONArray)?.let { - if (it.length() == 1) - (it[0] as? BigDecimal)?.let { it.toDouble() } - else - null - } ?: 0.0 - val sun = ((data["daily"] as? JSONObject)?.get("sunshine_duration") as? JSONArray)?.let { - if (it.length() == 1) - (it[0] as? BigDecimal)?.let { it.toDouble() } - else - null - } ?: 0.0 + val daylight = + ((data["daily"] as? JSONObject)?.get("daylight_duration") as? JSONArray)?.let { + if (it.length() == 1) + (it[0] as? BigDecimal)?.let { it.toDouble() } + else + null + } ?: 0.0 - GlobalDataStore.resetTime = Calendar.getInstance().also { - it.set(Calendar.HOUR_OF_DAY, maxTempTime) - it.set(Calendar.MINUTE, 30) - it.set(Calendar.SECOND, 0) - }.time - GlobalDataStore.isSunny = sun > daylight / 2 - success = true + val sunlight = + ((data["daily"] as? JSONObject)?.get("sunshine_duration") as? JSONArray)?.let { + if (it.length() == 1) + (it[0] as? BigDecimal)?.let { it.toDouble() } + else + null + } ?: 0.0 + + val sunrise = ((data["daily"] as? JSONObject)?.get("sunrise") as? JSONArray)?.let { + if (it.length() == 1) + (it[0] as? Number)?.let { unixTime -> + Calendar.getInstance().also { + it.time = Date(unixTime.toLong() * 1000) + it.add(Calendar.MINUTE, ((daylight / 12) / 60).toInt()) + }.time + } + else + null + } + + if (sunrise != null) { + GlobalDataStore.lowerTime = sunrise + GlobalDataStore.resetTime = Calendar.getInstance().also { + it.set(Calendar.HOUR_OF_DAY, maxTempTime) + it.set(Calendar.MINUTE, 0) + it.set(Calendar.SECOND, 0) + it.add(Calendar.MINUTE, ((sunlight / 12) / 60).toInt()) + }.time + GlobalDataStore.isSunny = sunlight > daylight / 2 + success = true + } else { + println("error reading weather API response") + } } else { println("error reading weather API response") } @@ -279,19 +308,60 @@ fun main() { } } + val controller = Thread { + usingPins( + 55 to false, 42 to false, 43 to false, 47 to false, 38 to true, 54 to true, // motor 1 + 51 to false, 50 to false, 56 to false, 40 to false, 36 to true, 61 to true, // motor 2 + 45 to false, 37 to false, 39 to false, 63 to false, 60 to true, 44 to true, // motor 3 + ) { pins -> + val c1 = Controller(pins[4], pins[5], pins[0], pins[1], pins[2], pins[3]) + val c2 = Controller(pins[10], pins[11], pins[6], pins[7], pins[8], pins[9]) + val c3 = Controller(pins[16], pins[17], pins[12], pins[13], pins[14], pins[15]) + val pidController = PidController(c1, c2, c3) + + var cycles = -1 + while (!Thread.interrupted()) { + if (cycles < 0) { + c1.runCalibrationSequence() + c2.runCalibrationSequence() + c3.runCalibrationSequence() + cycles = 24 * 60 // run once per day + } + cycles-- + pidController.tick() + // tick every minute + Thread.sleep(Duration.ofMinutes(1)) + } + } + } + forecastQuery.start() server.start() tempServer.start() - println("HttpServer is running, press CTRL+C to exit") + if (!DEBUG) { + // only start the controller on the real thing since GPIO pins are only available there + controller.start() + } else { + println("running in DEBUG MODE!") + } + println("HttpServer is running, press ENTER to exit") while (!Thread.interrupted()) { println() print("Username to add: ") val userName = readln() + if (userName == "") + break print("Password for '$userName': ") val pwd = readln() LoginBehavior.Users[userName] = pwd } + println("shutdown requested ...") + if (!DEBUG) { + controller.interrupt() + } forecastQuery.interrupt() + server.stop(1) + tempServer.stop(1) } fun String.uft8() = toByteArray(StandardCharsets.UTF_8) diff --git a/src/main/resources/META-INF/MANIFEST.MF b/src/main/resources/META-INF/MANIFEST.MF new file mode 100644 index 0000000..7d962a4 --- /dev/null +++ b/src/main/resources/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Main-Class: dev.asdf00.visionfive.hc.server.MainKt +