From a8f130608fd83c43b3d97f23f6b4324a5228755c Mon Sep 17 00:00:00 2001 From: Tomas Aas-Hansen Date: Mon, 15 Jun 2026 15:21:38 +0200 Subject: [PATCH 1/2] Added feature controller gain. Just a multiplyer on the controller (user) input. Camera, manipulator, LED-lights and IMU data were successfully tested with this commit. --- app.py | 1 + data/config.json | 11 +++ data/data.json | 26 +++---- lib/controller.py | 57 +++++++++++++-- routes.py | 55 +++++++++++++++ static/css/styles.css | 39 +++++++++++ static/js/configuration.js | 117 +++++++++++++++++++++++++++++++ static/templates/config.html | 40 +++++++++++ tests/test_controller.py | 15 ++++ tests/test_pid_runtime_routes.py | 24 +++++++ 10 files changed, 368 insertions(+), 17 deletions(-) diff --git a/app.py b/app.py index de9a120..082ac37 100644 --- a/app.py +++ b/app.py @@ -122,6 +122,7 @@ app.config["SETPOINT_OVERRIDE"] = init_setpoint_override(resource_monitor=app.config["RESOURCE"]) app.config["CONTROLLER"].set_setpoint_client(app.config["SETPOINT_OVERRIDE"]) app.config["CONTROLLER"].set_pid_rates(_config.get_section("pid_setpoint_rates") or {}) +app.config["CONTROLLER"].set_controller_gains(_config.get_section("controller_gains") or {}) # Start control loop telemetry receiver (UDP port 5005) app.config["CONTROL_TELEM"] = init_control_telemetry(port=5005) diff --git a/data/config.json b/data/config.json index 28b64b5..4efee1c 100644 --- a/data/config.json +++ b/data/config.json @@ -27,5 +27,16 @@ "roll": 45.0, "pitch": 45.0, "yaw": 60.0 + }, + "controller_gains": { + "master": 1.0, + "axes": { + "surge": 0.25, + "sway": 1.0, + "heave": 1.0, + "roll": 1.0, + "pitch": 1.0, + "yaw": 1.0 + } } } \ No newline at end of file diff --git a/data/data.json b/data/data.json index c3b2cd2..8f37f7f 100644 --- a/data/data.json +++ b/data/data.json @@ -1,14 +1,14 @@ { "imu": { - "yaw": -67.73, - "pitch": 9.46, - "roll": 161.53, - "yr": -0.13, - "pr": 0.37, - "rr": -0.11, - "ax": 0.11, - "ay": -0.009, - "az": 0.024 + "yaw": 128.39, + "pitch": 0.44, + "roll": -179.89, + "yr": -0.31, + "pr": -0.6, + "rr": 0.43, + "ax": -0.071, + "ay": -0.031, + "az": -0.041 }, "9dof": { "acceleration": { @@ -74,14 +74,14 @@ "dptSet": 0.0 }, "resources": { - "sequence": 5873, - "uptime_ms": 5881564, - "cpu_percent": 4, + "sequence": 9783, + "uptime_ms": 9796085, + "cpu_percent": 7, "heap_used_percent": 2, "heap_free_kb": 501, "heap_total_kb": 512, "thread_count": 20, - "udp_rx_count": 93052, + "udp_rx_count": 133160, "udp_rx_errors": 0 }, "control_telemetry": { diff --git a/lib/controller.py b/lib/controller.py index 3ef587e..d46d698 100644 --- a/lib/controller.py +++ b/lib/controller.py @@ -24,6 +24,7 @@ ATTITUDE_AXES = ("roll", "pitch", "yaw") ATTITUDE_LIMITS_DEG = {"roll": 180.0, "pitch": 90.0, "yaw": 180.0} DEFAULT_PID_SETPOINT_RATES = {axis: 90.0 for axis in ATTITUDE_AXES} +DEFAULT_CONTROLLER_GAINS = {"master": 1.0, "axes": {axis: 1.0 for axis in CONTROL_AXES}} def _use_sdl_gamecontroller(): @@ -53,6 +54,29 @@ def _neutral_axes(): return {axis: 0.0 for axis in CONTROL_AXES} +def _clean_controller_gains(gains): + cleaned = {"master": 1.0, "axes": {axis: 1.0 for axis in CONTROL_AXES}} + if not isinstance(gains, dict): + return cleaned + + try: + cleaned["master"] = _clamp(gains.get("master", 1.0), 0.0, 1.0) + except (TypeError, ValueError): + pass + + axes = gains.get("axes", {}) + if not isinstance(axes, dict): + axes = gains + for axis in CONTROL_AXES: + if axis not in axes: + continue + try: + cleaned["axes"][axis] = _clamp(axes[axis], 0.0, 1.0) + except (TypeError, ValueError): + pass + return cleaned + + def _controller_errors(): errors = [pygame.error] if sdl2 is not None: @@ -116,6 +140,7 @@ def __init__(self, bitmask_client: BitmaskClient = None, rate_hz: float = 60.0): self._pid_enabled = False self._pid_setpoints = {} self._pid_setpoint_rates = dict(DEFAULT_PID_SETPOINT_RATES) + self._controller_gains = _clean_controller_gains(DEFAULT_CONTROLLER_GAINS) self._last_pid_update = time.monotonic() self._last_manual_command = _neutral_axes() self._last_output_command = _neutral_axes() @@ -230,6 +255,22 @@ def set_pid_rates(self, rates): self._pid_setpoint_rates[axis] = _clamp(value, 0.0, 90.0) return dict(self._pid_setpoint_rates) + def get_controller_gains(self): + with self._runtime_lock: + return { + "master": self._controller_gains["master"], + "axes": dict(self._controller_gains["axes"]), + } + + def set_controller_gains(self, gains): + cleaned = _clean_controller_gains(gains) + with self._runtime_lock: + self._controller_gains = cleaned + return { + "master": cleaned["master"], + "axes": dict(cleaned["axes"]), + } + def get_control_state(self): with self._debug_lock: override_active = self._debug_override is not None @@ -246,6 +287,10 @@ def get_control_state(self): "pid_setpoints": dict(self._pid_setpoints), "active_setpoints": dict(self._pid_setpoints) if self._pid_enabled else {}, "pid_setpoint_rates": dict(self._pid_setpoint_rates), + "controller_gains": { + "master": self._controller_gains["master"], + "axes": dict(self._controller_gains["axes"]), + }, "control_path": control_path, "override_active": override_active, "manual_command_before_pid": dict(self._last_manual_command), @@ -556,13 +601,17 @@ def _dispatch_manual_axes(self, axes, source): now = time.monotonic() setpoints_to_send = None with self._runtime_lock: + gains = self._controller_gains + manual_after_gain = { + axis: manual[axis] * gains["master"] * gains["axes"].get(axis, 1.0) for axis in CONTROL_AXES + } if self._killed: output = _neutral_axes() - self._last_manual_command = dict(manual) + self._last_manual_command = dict(manual_after_gain) self._last_output_command = dict(output) self._last_runtime_source = "KILLED" else: - output = dict(manual) + output = dict(manual_after_gain) if self._pid_enabled: dt = _clamp(now - self._last_pid_update, 0.0, 0.25) self._last_pid_update = now @@ -571,14 +620,14 @@ def _dispatch_manual_axes(self, axes, source): output[axis] = 0.0 if axis not in self._pid_setpoints: continue - delta = manual[axis] * self._pid_setpoint_rates[axis] * dt + delta = manual_after_gain[axis] * self._pid_setpoint_rates[axis] * dt if abs(delta) < 0.000001: continue self._pid_setpoints[axis] = _clamp_setpoint(axis, self._pid_setpoints[axis] + delta) changed = True if changed: setpoints_to_send = dict(self._pid_setpoints) - self._last_manual_command = dict(manual) + self._last_manual_command = dict(manual_after_gain) self._last_output_command = dict(output) self._last_runtime_source = source diff --git a/routes.py b/routes.py index 2ecf7ca..59d188e 100644 --- a/routes.py +++ b/routes.py @@ -46,6 +46,7 @@ def _save_pid_configs(configs): TRANSLATIONAL_AXES = ("surge", "sway", "heave") ATTITUDE_AXES = ("roll", "pitch", "yaw") DEFAULT_PID_SETPOINT_RATES = {axis: 90.0 for axis in ATTITUDE_AXES} +DEFAULT_CONTROLLER_GAINS = {"master": 1.0, "axes": {axis: 1.0 for axis in CONTROL_AXES}} DEFAULT_IP_CAMERA_IP = "10.77.0.4" @@ -107,10 +108,41 @@ def _clean_pid_rates(data): return rates +def _clean_controller_gains(data): + gains = {"master": 1.0, "axes": {axis: 1.0 for axis in CONTROL_AXES}} + if not isinstance(data, dict): + return gains + + try: + value = float(data.get("master", DEFAULT_CONTROLLER_GAINS["master"])) + except (TypeError, ValueError): + value = DEFAULT_CONTROLLER_GAINS["master"] + if not math.isfinite(value): + value = DEFAULT_CONTROLLER_GAINS["master"] + gains["master"] = _clamp(value, 0.0, 1.0) + + axes = data.get("axes", {}) + if not isinstance(axes, dict): + axes = data + for axis in CONTROL_AXES: + try: + value = float(axes.get(axis, DEFAULT_CONTROLLER_GAINS["axes"][axis])) + except (AttributeError, TypeError, ValueError): + value = DEFAULT_CONTROLLER_GAINS["axes"][axis] + if not math.isfinite(value): + value = DEFAULT_CONTROLLER_GAINS["axes"][axis] + gains["axes"][axis] = _clamp(value, 0.0, 1.0) + return gains + + def _load_pid_rates(): return _clean_pid_rates(config_handler.get_section("pid_setpoint_rates") or {}) +def _load_controller_gains(): + return _clean_controller_gains(config_handler.get_section("controller_gains") or {}) + + def _save_pid_rates(rates): cleaned = _clean_pid_rates(rates) config_handler.update_data({"pid_setpoint_rates": cleaned}) @@ -120,6 +152,15 @@ def _save_pid_rates(rates): return cleaned +def _save_controller_gains(gains): + cleaned = _clean_controller_gains(gains) + config_handler.update_data({"controller_gains": cleaned}) + ctrl = current_app.config.get("CONTROLLER") + if ctrl and hasattr(ctrl, "set_controller_gains"): + ctrl.set_controller_gains(cleaned) + return cleaned + + def _coerce_attitude_setpoints(data): axes = {} for axis in ATTITUDE_AXES: @@ -721,6 +762,20 @@ def control_state(): return jsonify({"ok": False, "error": "Controller not available"}), 503 return jsonify({"ok": True, "state": ctrl.get_control_state()}) + @app.route("/api/controller/gains", methods=["GET", "POST"]) + def controller_gains(): + """Get or update controller input gain multipliers.""" + if request.method == "GET": + gains = _load_controller_gains() + ctrl = current_app.config.get("CONTROLLER") + if ctrl and hasattr(ctrl, "set_controller_gains"): + ctrl.set_controller_gains(gains) + return jsonify({"ok": True, "gains": gains}) + + data = request.get_json(force=True, silent=True) or {} + gains = _save_controller_gains(data) + return jsonify({"ok": True, "gains": gains}) + @app.route("/api/control/killswitch", methods=["POST"]) def control_killswitch(): ctrl = current_app.config.get("CONTROLLER") diff --git a/static/css/styles.css b/static/css/styles.css index 620f4e5..01354ad 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -392,6 +392,45 @@ body { color: #0dcaf0; } +.controller-gain-master { + padding: 14px; + background: #2d3238; + border: 1px solid #495057; + border-radius: 8px; +} + +.controller-gain-item { + padding: 12px; + background: #1e2124; + border: 1px solid #3a4148; + border-radius: 8px; +} + +.controller-gain-slider { + margin: 0; +} + +.controller-gain-slider::-webkit-slider-thumb { + background: #0dcaf0; +} + +.controller-gain-slider::-moz-range-thumb { + background: #0dcaf0; + border: 0; +} + +.gain-value { + min-width: 48px; + padding: 2px 6px; + border-radius: 6px; + background: rgba(13, 202, 240, 0.12); + color: #0dcaf0; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.85rem; + font-weight: 600; + text-align: right; +} + @media (max-width: 768px) { .resource-gauge-svg { max-width: 120px; diff --git a/static/js/configuration.js b/static/js/configuration.js index 1ed26cd..7184b37 100644 --- a/static/js/configuration.js +++ b/static/js/configuration.js @@ -11,12 +11,129 @@ document.addEventListener("DOMContentLoaded", function () { el.className = "small mt-2 " + cls; } + function setInlineFeedback(id, text, cls) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.className = "small " + cls; + } + + function clampGain(value) { + const number = parseFloat(value); + if (!Number.isFinite(number)) return 1; + return Math.max(0, Math.min(1, number)); + } + + function gainPercent(value) { + return Math.round(clampGain(value) * 100) + "%"; + } + const axesYaw = document.getElementById("axes-yaw"); const axesPitch = document.getElementById("axes-pitch"); const axesRoll = document.getElementById("axes-roll"); const pidRateRoll = document.getElementById("pid-rate-roll"); const pidRatePitch = document.getElementById("pid-rate-pitch"); const pidRateYaw = document.getElementById("pid-rate-yaw"); + const controllerGainMaster = document.getElementById("controller-gain-master"); + const controllerGainSliders = Array.from(document.querySelectorAll("[data-axis].controller-gain-slider")); + let controllerGainSaveTimer = null; + + function setGainBadge(text, cls) { + const badge = document.getElementById("controller-gain-status"); + if (!badge) return; + badge.textContent = text; + badge.className = "badge " + cls; + } + + function updateGainLabels() { + const masterValue = document.getElementById("controller-gain-master-value"); + if (masterValue && controllerGainMaster) masterValue.textContent = gainPercent(controllerGainMaster.value); + controllerGainSliders.forEach((slider) => { + const value = document.getElementById(slider.id + "-value"); + if (value) value.textContent = gainPercent(slider.value); + }); + } + + function readControllerGains() { + const axes = {}; + controllerGainSliders.forEach((slider) => { + axes[slider.dataset.axis] = clampGain(slider.value); + }); + return { + master: controllerGainMaster ? clampGain(controllerGainMaster.value) : 1, + axes: axes, + }; + } + + function fillControllerGains(gains) { + const safe = gains || {}; + const axes = safe.axes || {}; + if (controllerGainMaster) controllerGainMaster.value = clampGain(safe.master == null ? 1 : safe.master); + controllerGainSliders.forEach((slider) => { + const axis = slider.dataset.axis; + slider.value = clampGain(axes[axis] == null ? 1 : axes[axis]); + }); + updateGainLabels(); + } + + async function saveControllerGains() { + try { + setGainBadge("SAVING", "bg-warning text-dark"); + const res = await fetch("/api/controller/gains", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(readControllerGains()), + }); + const data = await res.json(); + if (data.ok && data.gains) { + fillControllerGains(data.gains); + setGainBadge("SAVED", "bg-success"); + setInlineFeedback("controller-gain-feedback", "Controller gain saved", "text-success"); + } else { + setGainBadge("ERROR", "bg-danger"); + setInlineFeedback("controller-gain-feedback", "Failed to save controller gain", "text-danger"); + } + } catch (error) { + setGainBadge("ERROR", "bg-danger"); + setInlineFeedback("controller-gain-feedback", "Error: " + error.message, "text-danger"); + } + } + + function queueControllerGainSave() { + updateGainLabels(); + setGainBadge("CHANGED", "bg-info text-dark"); + setInlineFeedback("controller-gain-feedback", "Saving...", "text-light-muted"); + clearTimeout(controllerGainSaveTimer); + controllerGainSaveTimer = setTimeout(saveControllerGains, 250); + } + + fetch("/api/controller/gains") + .then((r) => r.json()) + .then((data) => { + if (!data.ok || !data.gains) return; + fillControllerGains(data.gains); + setGainBadge("READY", "bg-success"); + }) + .catch(() => { + setGainBadge("ERROR", "bg-danger"); + }); + + [controllerGainMaster].concat(controllerGainSliders).forEach((slider) => { + if (!slider) return; + slider.addEventListener("input", queueControllerGainSave); + slider.addEventListener("change", queueControllerGainSave); + }); + + const resetControllerGains = document.getElementById("btn-reset-controller-gains"); + if (resetControllerGains) { + resetControllerGains.addEventListener("click", function () { + fillControllerGains({ + master: 1, + axes: { surge: 1, sway: 1, heave: 1, roll: 1, pitch: 1, yaw: 1 }, + }); + saveControllerGains(); + }); + } fetch("/api/pid/rates") .then((r) => r.json()) diff --git a/static/templates/config.html b/static/templates/config.html index ba60658..086ef9b 100644 --- a/static/templates/config.html +++ b/static/templates/config.html @@ -36,6 +36,46 @@
Input Source
+
+
+
+
+
+
Controller Gain
+ LOADING +
+ +
+
+ + 100% +
+ +
+ +
+ {% for axis in ["surge", "sway", "heave", "roll", "pitch", "yaw"] %} +
+
+
+ + 100% +
+ +
+
+ {% endfor %} +
+ +
+
+ +
+
+
+
+
+
diff --git a/tests/test_controller.py b/tests/test_controller.py index 399eb8d..5250a4f 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -95,6 +95,7 @@ def build_controller(controller=None, joystick=None): ctrl._pid_enabled = False ctrl._pid_setpoints = {} ctrl._pid_setpoint_rates = dict(controller_module.DEFAULT_PID_SETPOINT_RATES) + ctrl._controller_gains = controller_module._clean_controller_gains(controller_module.DEFAULT_CONTROLLER_GAINS) ctrl._last_pid_update = 0.0 ctrl._last_manual_command = {axis: 0.0 for axis in controller_module.CONTROL_AXES} ctrl._last_output_command = {axis: 0.0 for axis in controller_module.CONTROL_AXES} @@ -336,3 +337,17 @@ def test_pid_off_allows_direct_rotational_manual_control(): assert output["roll"] == pytest.approx(0.4) assert output["pitch"] == pytest.approx(-0.3) assert output["yaw"] == pytest.approx(0.2) + + +def test_controller_gains_scale_manual_output(): + ctrl = build_controller() + gains = ctrl.set_controller_gains({"master": 0.5, "axes": {"surge": 0.4, "yaw": 0.2}}) + + output = ctrl.apply_manual_axes_once({"surge": 1.0, "sway": 1.0, "yaw": -1.0}, source="HTTP") + + assert gains["master"] == 0.5 + assert output["surge"] == pytest.approx(0.2) + assert output["sway"] == pytest.approx(0.5) + assert output["yaw"] == pytest.approx(-0.1) + assert ctrl.bm.calls[-1]["surge"] == pytest.approx(0.2) + assert ctrl.bm.calls[-1]["yaw"] == pytest.approx(-0.1) diff --git a/tests/test_pid_runtime_routes.py b/tests/test_pid_runtime_routes.py index d3c5f82..32b171e 100644 --- a/tests/test_pid_runtime_routes.py +++ b/tests/test_pid_runtime_routes.py @@ -10,6 +10,7 @@ def __init__(self): self.pid_enabled = False self.setpoints = {} self.rates = {"roll": 90.0, "pitch": 90.0, "yaw": 90.0} + self.gains = {"master": 1.0, "axes": {axis: 1.0 for axis in routes.CONTROL_AXES}} def get_control_state(self): return { @@ -21,6 +22,7 @@ def get_control_state(self): "override_active": False, "manual_command_before_pid": {}, "topside_command": {}, + "controller_gains": self.gains, } def get_input_status(self): @@ -84,6 +86,13 @@ def set_pid_rates(self, rates): self.rates.update(rates) return dict(self.rates) + def set_controller_gains(self, gains): + self.gains = {"master": gains["master"], "axes": dict(gains["axes"])} + return {"master": self.gains["master"], "axes": dict(self.gains["axes"])} + + def get_controller_gains(self): + return {"master": self.gains["master"], "axes": dict(self.gains["axes"])} + class FakeSetpointOverride: def __init__(self): @@ -275,3 +284,18 @@ def fake_send_pid_gains(gains, timeout=1.0, max_retries=3): assert captured["gains"]["heave"] == {"kp": 0.0, "ki": 0.0, "kd": 0.0} assert captured["gains"]["roll"] == {"kp": 1.0, "ki": 2.0, "kd": 3.0} assert set(res.get_json()["gains"].keys()) == {"roll", "pitch", "yaw"} + + +def test_controller_gains_api_clamps_and_updates_controller(monkeypatch, tmp_path): + config_handler = routes.JSONDataHandler(file_path=tmp_path / "config.json") + monkeypatch.setattr(routes, "config_handler", config_handler) + client, ctrl, _override = make_client() + + res = client.post("/api/controller/gains", json={"master": 1.4, "axes": {"surge": 0.25, "yaw": -1}}) + + assert res.status_code == 200 + data = res.get_json() + assert data["gains"]["master"] == 1.0 + assert data["gains"]["axes"]["surge"] == 0.25 + assert data["gains"]["axes"]["yaw"] == 0.0 + assert ctrl.gains == data["gains"] From a3354802227aba9e7dd6499c335d42eca570c536 Mon Sep 17 00:00:00 2001 From: Tomas Aas-Hansen Date: Mon, 15 Jun 2026 16:54:37 +0200 Subject: [PATCH 2/2] Commit containing correct config-values and updated PID-values. --- data/config.json | 10 +++--- data/pid_configs.json | 78 +++++++++++++------------------------------ 2 files changed, 28 insertions(+), 60 deletions(-) diff --git a/data/config.json b/data/config.json index 4efee1c..9e38036 100644 --- a/data/config.json +++ b/data/config.json @@ -31,11 +31,11 @@ "controller_gains": { "master": 1.0, "axes": { - "surge": 0.25, - "sway": 1.0, - "heave": 1.0, - "roll": 1.0, - "pitch": 1.0, + "surge": 0.46, + "sway": 0.5, + "heave": 0.53, + "roll": 0.45, + "pitch": 0.5, "yaw": 1.0 } } diff --git a/data/pid_configs.json b/data/pid_configs.json index 7f3f399..aa82987 100644 --- a/data/pid_configs.json +++ b/data/pid_configs.json @@ -95,39 +95,7 @@ "kd": 0.0075 } }, - "temp1": { - "surge": { - "kp": 0.0, - "ki": 0.0, - "kd": 0.0 - }, - "sway": { - "kp": 0.0, - "ki": 0.0, - "kd": 0.0 - }, - "heave": { - "kp": 0.0, - "ki": 0.0, - "kd": 0.0 - }, - "roll": { - "kp": 0.0175, - "ki": 0.0, - "kd": 0.0016 - }, - "pitch": { - "kp": 0.012, - "ki": 0.0, - "kd": 0.0 - }, - "yaw": { - "kp": 0.008, - "ki": 0.0, - "kd": 0.0075 - } - }, - "temp2": { + "temp4": { "surge": { "kp": 0.0, "ki": 0.0, @@ -146,12 +114,12 @@ "roll": { "kp": 0.01775, "ki": 0.0, - "kd": 0.002 + "kd": 0.0025 }, "pitch": { "kp": 0.015, "ki": 0.0, - "kd": 0.0 + "kd": 0.00175 }, "yaw": { "kp": 0.008, @@ -159,7 +127,7 @@ "kd": 0.0075 } }, - "temp3": { + "manipulator": { "surge": { "kp": 0.0, "ki": 0.0, @@ -176,22 +144,22 @@ "kd": 0.0 }, "roll": { - "kp": 0.01775, - "ki": 0.0, - "kd": 0.0025 + "kp": 0.01725, + "ki": 0.004, + "kd": 0.002 }, "pitch": { - "kp": 0.015, - "ki": 0.0, - "kd": 0.00175 + "kp": 0.029, + "ki": 0.002, + "kd": 0.0018 }, "yaw": { - "kp": 0.008, - "ki": 0.0, - "kd": 0.0075 + "kp": 0.028, + "ki": 0.004, + "kd": 0.004 } }, - "temp4": { + "manipulator-fine": { "surge": { "kp": 0.0, "ki": 0.0, @@ -208,19 +176,19 @@ "kd": 0.0 }, "roll": { - "kp": 0.01775, - "ki": 0.0, - "kd": 0.0025 + "kp": 0.015, + "ki": 0.004, + "kd": 0.0019 }, "pitch": { - "kp": 0.015, - "ki": 0.0, - "kd": 0.00175 + "kp": 0.029, + "ki": 0.002, + "kd": 0.0018 }, "yaw": { - "kp": 0.008, - "ki": 0.0, - "kd": 0.0075 + "kp": 0.028, + "ki": 0.004, + "kd": 0.004 } } } \ No newline at end of file