diff --git a/.github/workflows/hassfest.yml b/.github/workflows/hassfest.yml index 8934329..78e94d3 100644 --- a/.github/workflows/hassfest.yml +++ b/.github/workflows/hassfest.yml @@ -20,6 +20,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Run hassfest uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/precommit.yml b/.github/workflows/precommit.yml index af88ea9..f5933ed 100644 --- a/.github/workflows/precommit.yml +++ b/.github/workflows/precommit.yml @@ -22,12 +22,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" cache: 'pip' # caching pip dependencies diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index aef0d2a..b874a63 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -22,12 +22,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" cache: 'pip' # caching pip dependencies diff --git a/.github/workflows/release-versioning.yml b/.github/workflows/release-versioning.yml index 3e2d43d..b70ab52 100644 --- a/.github/workflows/release-versioning.yml +++ b/.github/workflows/release-versioning.yml @@ -19,13 +19,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 ref: main - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Create GitHub Release from tag uses: softprops/action-gh-release@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bda5721..80d4572 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get version id: version diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..edbe8de --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,57 @@ +name: Mark & close stale issues/PRs + +on: + schedule: + # Dagelijks om 02:17 UTC + - cron: "17 2 * * *" + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v10 + with: + # ALGEMEEN + repo-token: ${{ secrets.GITHUB_TOKEN }} + operations-per-run: 200 + days-before-stale: 7 # Issues worden 'stale' na 60 dagen inactiviteit + days-before-close: 14 # en gesloten 14 dagen later + stale-issue-label: "stale" + stale-pr-label: "stale" + exempt-issue-labels: "pinned,security,backlog,never-stale" + exempt-pr-labels: "work-in-progress,never-stale" + exempt-all-milestones: true # Items met milestone overslaan + exempt-assignees: "" # Vul evt. gebruikers in, kommagescheiden + remove-stale-when-updated: true + ignore-updates: false # Als iemand reageert of labelt, wordt 'stale' verwijderd + ascending: false + + # ISSUES + stale-issue-message: | + 💤 Deze issue heeft 60 dagen geen activiteit gehad en is gemarkeerd als **stale**. + Als er binnen 14 dagen geen nieuwe activiteit is, wordt deze automatisch gesloten. + Voeg svp een update toe of label met `never-stale` om dit te voorkomen. + close-issue-message: | + 🔒 Deze issue is automatisch gesloten wegens inactiviteit. + Als dit nog steeds actueel is, heropen of maak een nieuwe issue met de laatste context. Bedankt! + + # PULL REQUESTS + days-before-pr-stale: 30 # PRs iets sneller stale + days-before-pr-close: 10 + stale-pr-message: | + 💤 Deze pull request is 30 dagen inactief en is gemarkeerd als **stale**. + Reageer of push nieuwe commits binnen 10 dagen om sluiten te voorkomen. + Label met `never-stale` om uit te sluiten. + close-pr-message: | + 🔒 Deze pull request is automatisch gesloten wegens inactiviteit. + Heropen gerust wanneer je verder wilt gaan. + + # FILTERS (optioneel; laat leeg om alles te laten meedraaien) + only-labels: "" # Bv. "triage" om alleen issues met dat label te targeten + any-of-labels: "" # Bv. "question,help wanted" + exempt-draft-pr: true # Draft PRs overslaan diff --git a/README.md b/README.md index 51832aa..f3f7170 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,22 @@ A PID controller continuously corrects the difference between a **setpoint** and +### Common pitfalls with the I term + +Several reported issues stem from the interaction between the **integral term** and the controller's configured limits. Keep the following points in mind when tuning: + +- **Integral sensor shows the clamped output** – The integration exposes a diagnostic entity for the I contribution. When `Output Min`/`Output Max` are set, the value you see in the UI is the clamped contribution after limits are applied. This means that with `Ki = 0` the integral value will simply remain at the boundary (e.g. `Output Min = 3` results in `I = 3`). This is expected behaviour and not a sign that the integral is "stuck". +- **Choose a suitable start value** – When the controller is initialised it sets the integral term to the configured start value, but still clamps it within your `Output Min`/`Output Max` range. If you want the controller to begin from `0`, select the **PID Start Value** option `zero_start` (or call the `simple_pid_controller.set_output` service with the `preset` parameter) and temporarily widen the output range if needed. Otherwise the integral will be forced to the lowest allowed value. +- **Ki must be non-zero to change the integral** – Setting `Ki = 0` effectively freezes the integral term. Use a small but non-zero value if you need the controller to move away from the clamp over time. You can combine this with **Windup Protection** (toggle entity) to prevent large overshoots once the setpoint is reached. + +If your process requires a non-zero minimum output (for example, EV charging currents that must stay above 6 A), start your tuning with: + +1. `PID Start Value = zero_start` so the controller can compute from a neutral baseline. +2. A modest proportional gain (`Kp`) that does not immediately drive the output below the minimum. +3. A small integral gain (`Ki`) to let the controller move away from the clamped value without causing overshoot. + +These steps help avoid the "integral stuck at minimum" effect while keeping the controller within the bounds your hardware requires. + --- ### How it works in practice diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 8fac8bf..79ccd76 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import entity_registry as er +from collections import deque from dataclasses import dataclass import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -86,6 +87,14 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.last_contributions = (None, None, None) # (P, I, D) self.last_known_output = None + self.input_history: deque[float] = deque(maxlen=10) + self.output_history: deque[float] = deque(maxlen=10) + self.pid_parameter_history: deque[dict[str, float | None]] = deque(maxlen=10) + self.pid_contribution_history: deque[dict[str, float | None]] = deque(maxlen=10) + self.sample_time_history: deque[float | None] = deque(maxlen=10) + self.last_update_timestamp: float | None = None + self.last_measured_sample_time: float | None = None + def _get_entity_id(self, platform: str, key: str) -> str | None: """Lookup the real entity_id in the registry by unique_id == '_'.""" registry = er.async_get(self.hass) diff --git a/custom_components/simple_pid_controller/diagnostics.py b/custom_components/simple_pid_controller/diagnostics.py index 7cfa04b..41dec3d 100644 --- a/custom_components/simple_pid_controller/diagnostics.py +++ b/custom_components/simple_pid_controller/diagnostics.py @@ -13,6 +13,17 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" handle = entry.runtime_data.handle + sensor_state = hass.states.get(handle.sensor_entity_id) + input_sensor_info: dict[str, Any] | None = None + if sensor_state is not None: + input_sensor_info = { + "entity_id": sensor_state.entity_id, + "state": sensor_state.state, + "attributes": dict(sensor_state.attributes), + "last_changed": sensor_state.last_changed.isoformat(), + "last_updated": sensor_state.last_updated.isoformat(), + } + return { "entry_data": entry.as_dict(), "data": { @@ -22,5 +33,12 @@ async def async_get_config_entry_diagnostics( "input_range_max": handle.input_range_max, "output_range_min": handle.output_range_min, "output_range_max": handle.output_range_max, + "input_sensor": input_sensor_info, + "history": { + "input": list(handle.input_history), + "output": list(handle.output_history), + "pid_contributions": list(handle.pid_contribution_history), + "sample_time": list(handle.sample_time_history), + }, }, } diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 7504fa0..b429ceb 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -76,7 +76,7 @@ "name": "Setpoint", "key": "setpoint", "unit": "", - "step": 1.0, + "step": 0.01, "default": 0.5, "entity_category": None, }, diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index b529036..c3d315f 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -13,6 +13,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from datetime import timedelta +from time import perf_counter from simple_pid import PID from typing import Any @@ -47,6 +48,8 @@ async def update_pid(): if input_value is None: raise ValueError("Input sensor not available") + handle.input_history.append(input_value) + # Read parameters from UI kp = handle.get_number("kp") ki = handle.get_number("ki") @@ -65,6 +68,15 @@ async def update_pid(): handle.pid.tunings = (kp, ki, kd) handle.pid.setpoint = setpoint + handle.pid_parameter_history.append( + { + "kp": kp, + "ki": ki, + "kd": kd, + "setpoint": setpoint, + } + ) + if windup_protection: handle.pid.output_limits = (out_min, out_max) else: @@ -85,10 +97,20 @@ async def update_pid(): handle.pid.proportional_on_measurement = p_on_m + now = perf_counter() + if handle.last_update_timestamp is None: + handle.last_measured_sample_time = None + else: + handle.last_measured_sample_time = now - handle.last_update_timestamp + handle.last_update_timestamp = now + + handle.sample_time_history.append(handle.last_measured_sample_time) + output = handle.pid(input_value) # save last know output handle.last_known_output = output + handle.output_history.append(output) # save last I contribution last_i = handle.last_contributions[1] @@ -101,6 +123,15 @@ async def update_pid(): handle.pid.components[1] - last_i, ) + handle.pid_contribution_history.append( + { + "p": handle.last_contributions[0], + "i": handle.last_contributions[1], + "d": handle.last_contributions[2], + "i_delta": handle.last_contributions[3], + } + ) + _LOGGER.debug( "PID input=%s setpoint=%s kp=%s ki=%s kd=%s => output=%s [P=%s, I=%s, D=%s, dI=%s]", input_value, @@ -151,6 +182,9 @@ async def start_refresh(_: Any) -> None: ), PIDContributionSensor(hass, entry, "error", "Error", coordinator), PIDContributionSensor(hass, entry, "pid_i_delta", "I delta", coordinator), + PIDSampleTimeSensor( + hass, entry, "actual_sample_time", "Actual Sample Time", coordinator + ), ] ) @@ -263,3 +297,31 @@ def native_value(self): "pid_i_delta": contributions[3], }.get(self._key) return round(value, 3) if value is not None else None + + +class PIDSampleTimeSensor(CoordinatorEntity[PIDDataCoordinator], SensorEntity): + """Sensor exposing the measured sample time between PID updates.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + key: str, + name: str, + coordinator: PIDDataCoordinator, + ) -> None: + super().__init__(coordinator) + + BasePIDEntity.__init__(self, hass, entry, key, name) + + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self._attr_entity_registry_enabled_default = False + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_native_unit_of_measurement = "s" + + @property + def native_value(self) -> float | None: + sample_time = self._handle.last_measured_sample_time + if sample_time is None: + return None + return round(sample_time, 3)