From 8f8d066fc25ba57abbb33e97926e386150b96603 Mon Sep 17 00:00:00 2001 From: Feeph Aifeimei <55798703+feeph@users.noreply.github.com> Date: Wed, 14 Aug 2024 05:49:58 +0000 Subject: [PATCH 1/5] refactor: move 'calibrate_pwm_fan()' to a separate module The function does not necessarily need to be in feeph/emc2101/pwm.py and we would like to perform additional refactoring to make the function shorter and easier to understand. This would add more functions to pwm.py which reduces readability and maintainability. --- feeph/emc2101/__init__.py | 3 +- feeph/emc2101/calibration.py | 133 +++++++++++++++++++++++++++++++++ feeph/emc2101/pwm.py | 139 +---------------------------------- feeph/emc2101/utilities.py | 21 ++++++ tests/test_calibration.py | 77 +++++++++++++++++++ 5 files changed, 236 insertions(+), 137 deletions(-) create mode 100755 feeph/emc2101/calibration.py create mode 100755 feeph/emc2101/utilities.py create mode 100755 tests/test_calibration.py diff --git a/feeph/emc2101/__init__.py b/feeph/emc2101/__init__.py index 59f42ee..e7f08f1 100755 --- a/feeph/emc2101/__init__.py +++ b/feeph/emc2101/__init__.py @@ -46,6 +46,7 @@ # the following imports are provided for user convenience # flake8: noqa: F401 +from feeph.emc2101.calibration import calibrate_pwm_fan from feeph.emc2101.core import CONVERSIONS_PER_SECOND, DEFAULTS, ExternalSensorStatus, SpinUpDuration, SpinUpStrength from feeph.emc2101.fan_configs import FanConfig, RpmControlMode, Steps, export_fan_config, generic_pwm_fan -from feeph.emc2101.pwm import DeviceConfig, Emc2101_PWM, ExternalTemperatureSensorConfig, FanSpeedUnit, PinSixMode, TemperatureLimitType, calibrate_pwm_fan, emc2101_default_config, ets_2n3904, ets_2n3906 +from feeph.emc2101.pwm import DeviceConfig, Emc2101_PWM, ExternalTemperatureSensorConfig, FanSpeedUnit, PinSixMode, TemperatureLimitType, emc2101_default_config, ets_2n3904, ets_2n3906 diff --git a/feeph/emc2101/calibration.py b/feeph/emc2101/calibration.py new file mode 100755 index 0000000..48a02f9 --- /dev/null +++ b/feeph/emc2101/calibration.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +import logging +import time + +# module busio provides no type hints +import busio # type: ignore + +import feeph.emc2101.core +import feeph.emc2101.utilities +from feeph.emc2101.fan_configs import FanConfig, RpmControlMode, Steps + +LH = logging.getLogger('feeph.emc2101') + + +# This function has limited code coverage since it depends on active +# feedback from the underlying device. We will need a mock to be able +# to test the missing lines of code. +def calibrate_pwm_fan(i2c_bus: busio.I2C, model: str, pwm_frequency: int = 22500) -> FanConfig | None: + """ + walk through various settings and determine the fan's configuration + parameters + """ + LH.info("Calibrating fan parameters.") + pwm_d, pwm_f = feeph.emc2101.utilities.calculate_pwm_factors(pwm_frequency=pwm_frequency) + steps_list = list(range(pwm_f * 2)) + emc2101 = feeph.emc2101.core.Emc2101_core(i2c_bus=i2c_bus) + emc2101.configure_pin_six_as_tacho() + emc2101.configure_pwm_control(pwm_d=pwm_d, pwm_f=pwm_f, step_max=max(steps_list)) + # ----------------------------------------------------------------- + LH.debug("Disabling gradual speed rampup.") + # TODO disable gradual rampup + # TODO set initial driver strength to 100% + # ----------------------------------------------------------------- + LH.info("Testing if fan responds to PWM signal:") + LH.debug("speed control steps: %s", steps_list) + if len(steps_list) <= 2: + LH.warning("Fan does not have enough steps to calibrate!") + return None + step1 = steps_list[int(len(steps_list) / 2)] # pick something in the middle + step2 = steps_list[-2] # pick the second highest possible setting + emc2101.set_driver_strength(step1) + time.sleep(5) + dutycycle1 = int(step1 * 100 / len(steps_list)) + rpm1 = emc2101.get_rpm() + LH.debug("dutycycle: %i%% -> RPM: %i", dutycycle1, rpm1) + emc2101.set_driver_strength(step2) + time.sleep(5) + dutycycle2 = int(step2 * 100 / len(steps_list)) + rpm2 = emc2101.get_rpm() + LH.debug("dutycycle: %i%% -> RPM: %i", dutycycle2, rpm2) + if rpm1 is None or rpm2 is None: + LH.error("Unable to get a reliable RPM reading. Aborting.") + return None + if rpm1 * 100 / rpm2 < 96: + LH.info("Yes, it does. Observed an RPM change in response to PWM signal. (%i%%: %i -> %i%%: %i RPM)", dutycycle1, rpm1, dutycycle2, rpm2) + else: + LH.warning("Failed to observe a significant speed change in response to PWM signal! Aborting.") + LH.warning("Please verify wiring and configuration.") + return None + # ----------------------------------------------------------------- + LH.info("Mapping PWM dutycycle to RPM. Please wait.") + mappings = list() + for step in steps_list: + dutycycle = int(step * 100 / len(steps_list)) + # set fan speed and wait for the speed to settle + emc2101.set_driver_strength(step) + time.sleep(1) + readings = [99999, 99999, 99999] + for i in range(24): + cursor = i % len(readings) + rpm_cur = emc2101.get_rpm() + if rpm_cur is not None: + # order is important! (update readings before calculating the average) + readings[cursor] = rpm_cur + rpm_avg = sum(readings) / len(readings) + # calculate deviation from average + deviation = rpm_cur / rpm_avg + LH.debug("step: %2i i: %2i -> rpm: %4i deviation: %3.2f", step, cursor, rpm_cur, deviation) + if 0.99 <= deviation <= 1.01: + # RPM will never be exact and fluctuates slightly + # -> round to nearest factor of 5 + rpm = round(rpm_avg / 5) * 5 + LH.debug("Fan has settled: (step: %i -> dutycycle: %3i%%, rpm: %i)", step, dutycycle, rpm) + mappings.append((step, dutycycle, rpm)) + break + else: + time.sleep(0.5) + else: + LH.error("Unable to get a reliable RPM reading. Aborting.") + return None + else: + LH.warning("Fan never settled! (step: %i -> dutycycle: %3i%%, rpm: )", step, dutycycle) + mappings.append((step, dutycycle, rpm)) + + # determine maximum RPM + rpm_max = max([rpm for (_, _, rpm) in mappings]) + LH.info("Maximum RPM: %i", rpm_max) + + # prune steps + # - multiple steps may result in the same RPM (e.g. minimum RPM) + # - ensure each step is significantly different from the previous + # - ensure each step increases RPM + prune = list() + rpm_delta_min = rpm_max * 0.011 + for i in range(len(mappings) - 1): + step, _, rpm_this = mappings[i] + _, _, rpm_next = mappings[i + 1] + if rpm_this + rpm_delta_min <= rpm_next: + # significantly different from next element -> keep it + pass + else: + # within range of next element -> prune it + prune.append(step) + + steps: Steps = dict() + for step, dutycycle, rpm in mappings: + rpm_percent = rpm * 100 / rpm_max + LH.info("step: %2i dutycycle: %3i%% -> RPM: %5i (%3.0f%%)", step, dutycycle, rpm, rpm_percent) + if step not in prune: + steps[step] = (dutycycle, rpm) + + fan_profile = FanConfig( + model=model, + rpm_control_mode=RpmControlMode.PWM, + pwm_frequency=pwm_frequency, + minimum_duty_cycle=min([dutycycle for (_, (dutycycle, _)) in steps.items()]), # e.g. 20% + maximum_duty_cycle=max([dutycycle for (_, (dutycycle, _)) in steps.items()]), # typically 100% + minimum_rpm=min([rpm for (_, (_, rpm)) in steps.items() if rpm is not None]), + maximum_rpm=max([rpm for (_, (_, rpm)) in steps.items() if rpm is not None]), + steps=steps, + ) + return fan_profile diff --git a/feeph/emc2101/pwm.py b/feeph/emc2101/pwm.py index 32ba005..a257cb5 100755 --- a/feeph/emc2101/pwm.py +++ b/feeph/emc2101/pwm.py @@ -6,16 +6,15 @@ # Datasheet: https://ww1.microchip.com/downloads/en/DeviceDoc/2101.pdf import logging -import math -import time from enum import Enum from typing import Any # module busio provides no type hints import busio # type: ignore +import feeph.emc2101.utilities from feeph.emc2101.core import CONVERSIONS_PER_SECOND, Emc2101_core, ExternalSensorStatus, SpinUpDuration, SpinUpStrength -from feeph.emc2101.fan_configs import FanConfig, RpmControlMode, Steps, generic_pwm_fan +from feeph.emc2101.fan_configs import FanConfig, RpmControlMode, generic_pwm_fan LH = logging.getLogger(__name__) @@ -103,7 +102,7 @@ def __init__(self, i2c_bus: busio.I2C, device_config: DeviceConfig = emc2101_def LH.info("EMC2101 and connected fan both use PWM to control fan speed. Good.") from feeph.emc2101.scs import PWM scs = PWM(fan_config=fan_config) - pwm_d, pwm_f = calculate_pwm_factors(pwm_frequency=fan_config.pwm_frequency) + pwm_d, pwm_f = feeph.emc2101.utilities.calculate_pwm_factors(pwm_frequency=fan_config.pwm_frequency) emc2101.configure_pwm_control(pwm_d=pwm_d, pwm_f=pwm_f, step_max=max(scs.get_steps())) else: raise ValueError("fan has unsupported rpm control mode") @@ -378,138 +377,6 @@ def configure_external_temperature_sensor(self, ets_config: ExternalTemperatureS self._emc2101.configure_external_temperature_sensor(dif=dif, bcf=bcf) -def calibrate_pwm_fan(i2c_bus: busio.I2C, model: str, pwm_frequency: int = 22500) -> FanConfig | None: - """ - walk through various settings and determine the fan's configuration - parameters - """ - LH.info("Calibrating fan parameters.") - pwm_d, pwm_f = calculate_pwm_factors(pwm_frequency=pwm_frequency) - steps_list = list(range(pwm_f * 2)) - emc2101 = Emc2101_core(i2c_bus=i2c_bus) - emc2101.configure_pin_six_as_tacho() - emc2101.configure_pwm_control(pwm_d=pwm_d, pwm_f=pwm_f, step_max=max(steps_list)) - # ----------------------------------------------------------------- - LH.debug("Disabling gradual speed rampup.") - # TODO disable gradual rampup - # TODO set initial driver strength to 100% - # ----------------------------------------------------------------- - LH.info("Testing if fan responds to PWM signal:") - LH.debug("speed control steps: %s", steps_list) - step1 = steps_list[int(len(steps_list) / 2)] # pick something in the middle - step2 = steps_list[-2] # pick the second highest possible setting - if step1 == step2: - LH.warning("Fan does not have enough steps to calibrate!") - return None - emc2101.set_driver_strength(step1) - time.sleep(5) - dutycycle1 = int(step1 * 100 / len(steps_list)) - rpm1 = emc2101.get_rpm() - LH.debug("dutycycle: %i%% -> RPM: %i", dutycycle1, rpm1) - emc2101.set_driver_strength(step2) - time.sleep(5) - dutycycle2 = int(step2 * 100 / len(steps_list)) - rpm2 = emc2101.get_rpm() - LH.debug("dutycycle: %i%% -> RPM: %i", dutycycle2, rpm2) - if rpm1 is None or rpm2 is None: - LH.error("Unable to get a reliable RPM reading. Aborting.") - return None - if rpm1 * 100 / rpm2 < 96: - LH.info("Yes, it does. Observed an RPM change in response to PWM signal. (%i%%: %i -> %i%%: %i RPM)", dutycycle1, rpm1, dutycycle2, rpm2) - else: - LH.warning("Failed to observe a significant speed change in response to PWM signal! Aborting.") - LH.warning("Please verify wiring and configuration.") - return None - # ----------------------------------------------------------------- - LH.info("Mapping PWM dutycycle to RPM. Please wait.") - mappings = list() - for step in steps_list: - dutycycle = int(step * 100 / len(steps_list)) - # set fan speed and wait for the speed to settle - emc2101.set_driver_strength(step) - time.sleep(1) - readings = [99999, 99999, 99999] - for i in range(24): - cursor = i % len(readings) - rpm_cur = emc2101.get_rpm() - if rpm_cur is not None: - # order is important! (update readings before calculating the average) - readings[cursor] = rpm_cur - rpm_avg = sum(readings) / len(readings) - # calculate deviation from average - deviation = rpm_cur / rpm_avg - LH.debug("step: %2i i: %2i -> rpm: %4i deviation: %3.2f", step, cursor, rpm_cur, deviation) - if 0.99 <= deviation <= 1.01: - # RPM will never be exact and fluctuates slightly - # -> round to nearest factor of 5 - rpm = round(rpm_avg / 5) * 5 - LH.debug("Fan has settled: (step: %i -> dutycycle: %3i%%, rpm: %i)", step, dutycycle, rpm) - mappings.append((step, dutycycle, rpm)) - break - else: - time.sleep(0.5) - else: - LH.error("Unable to get a reliable RPM reading. Aborting.") - return None - else: - LH.warning("Fan never settled! (step: %i -> dutycycle: %3i%%, rpm: )", step, dutycycle) - mappings.append((step, dutycycle, rpm)) - - # determine maximum RPM - rpm_max = max([rpm for (_, _, rpm) in mappings]) - LH.info("Maximum RPM: %i", rpm_max) - - # prune steps - # - multiple steps may result in the same RPM (e.g. minimum RPM) - # - ensure each step is significantly different from the previous - # - ensure each step increases RPM - prune = list() - rpm_delta_min = rpm_max * 0.011 - for i in range(len(mappings) - 1): - step, _, rpm_this = mappings[i] - _, _, rpm_next = mappings[i + 1] - if rpm_this + rpm_delta_min <= rpm_next: - # significantly different from next element -> keep it - pass - else: - # within range of next element -> prune it - prune.append(step) - - steps: Steps = dict() - for step, dutycycle, rpm in mappings: - rpm_percent = rpm * 100 / rpm_max - LH.info("step: %2i dutycycle: %3i%% -> RPM: %5i (%3.0f%%)", step, dutycycle, rpm, rpm_percent) - if step not in prune: - steps[step] = (dutycycle, rpm) - - fan_profile = FanConfig( - model=model, - rpm_control_mode=RpmControlMode.PWM, - pwm_frequency=pwm_frequency, - minimum_duty_cycle=min([dutycycle for (_, (dutycycle, _)) in steps.items()]), # e.g. 20% - maximum_duty_cycle=max([dutycycle for (_, (dutycycle, _)) in steps.items()]), # typically 100% - minimum_rpm=min([rpm for (_, (_, rpm)) in steps.items() if rpm is not None]), - maximum_rpm=max([rpm for (_, (_, rpm)) in steps.items() if rpm is not None]), - steps=steps, - ) - return fan_profile - - -def calculate_pwm_factors(pwm_frequency: int) -> tuple[int, int]: - """ - calculate PWM_D and PWM_F for provided frequency - - this function minimizes PWM_D to allow for maximum resolution (PWM_F) - - PWM_F maxes out at 31 (0x1F) - """ - if 0 <= pwm_frequency <= 180000: - value1 = 360000 / (2 * pwm_frequency) - pwm_d = math.ceil(value1 / 31) - pwm_f = round(value1 / pwm_d) - return (pwm_d, pwm_f) - else: - raise ValueError("provided frequency is out of range") - - def parse_fanconfig_register(value: int) -> dict[str, Any]: # 0b00000000 # ^^-- tachometer input mode diff --git a/feeph/emc2101/utilities.py b/feeph/emc2101/utilities.py new file mode 100755 index 0000000..bd3dd43 --- /dev/null +++ b/feeph/emc2101/utilities.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +import logging +import math + +LH = logging.getLogger('feeph.emc2101') + + +def calculate_pwm_factors(pwm_frequency: int) -> tuple[int, int]: + """ + calculate PWM_D and PWM_F for provided frequency + - this function minimizes PWM_D to allow for maximum resolution (PWM_F) + - PWM_F maxes out at 31 (0x1F) + """ + if 0 <= pwm_frequency <= 180000: + value1 = 360000 / (2 * pwm_frequency) + pwm_d = math.ceil(value1 / 31) + pwm_f = round(value1 / pwm_d) + return (pwm_d, pwm_f) + else: + raise ValueError("provided frequency is out of range") diff --git a/tests/test_calibration.py b/tests/test_calibration.py new file mode 100755 index 0000000..74fb424 --- /dev/null +++ b/tests/test_calibration.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" +perform PWM related tests + +use simulated device: + pdm run pytest +use hardware device: + TEST_EMC2101_CHIP=y pdm run pytest +""" + +import os +import unittest + +# modules board and busio provide no type hints +import board # type: ignore +import busio # type: ignore +from feeph.i2c import EmulatedI2C + +import feeph.emc2101.calibration as sut # sytem under test +import feeph.emc2101.core + +if os.environ.get('TEST_EMC2101_CHIP', 'n') == 'y': + HAS_HARDWARE = True +else: + HAS_HARDWARE = False + + +# pylint: disable=protected-access +class TestCalibration(unittest.TestCase): + + def setUp(self): + self.i2c_adr = 0x4C + if HAS_HARDWARE: + self.i2c_bus = busio.I2C(scl=board.SCL, sda=board.SDA) + else: + # initialize read/write registers + registers = feeph.emc2101.core.DEFAULTS.copy() + # add readonly registers + registers[0x00] = 0x14 # chip temperature + registers[0x01] = 0x1B # external sensor temperature (high byte) + registers[0x02] = 0x00 # status register + registers[0x0F] = 0x00 # write only register, trigger temperature conversion + registers[0x10] = 0xE0 # external sensor temperature (low byte) + registers[0x46] = 0xFF # tacho reading (low byte) + registers[0x47] = 0xFF # tacho reading (high byte) + registers[0xFD] = 0x16 # product id + registers[0xFE] = 0x5D # manufacturer id + registers[0xFF] = 0x02 # revision + self.i2c_bus = EmulatedI2C(state={self.i2c_adr: registers}) + + # def tearDown(self): + # # restore original state after each run + # # (hardware is not stateless) + # self.emc2101.reset_device_registers() + + def test_unresponsive_device(self): + # ----------------------------------------------------------------- + computed = sut.calibrate_pwm_fan(i2c_bus=self.i2c_bus, model="fan") + expected = None + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_insufficient_steps(self): + # ----------------------------------------------------------------- + computed = sut.calibrate_pwm_fan(i2c_bus=self.i2c_bus, model="fan", pwm_frequency=180000) + expected = None + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_fixed_speed_fan(self): + self.i2c_bus._state[self.i2c_adr][0x46] = 0b0000_0000 + self.i2c_bus._state[self.i2c_adr][0x47] = 0x12 + # ----------------------------------------------------------------- + computed = sut.calibrate_pwm_fan(i2c_bus=self.i2c_bus, model="fan") + expected = None + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) From 65017781d5b57193b0b1b15499211e2ba1c45796 Mon Sep 17 00:00:00 2001 From: Feeph Aifeimei <55798703+feeph@users.noreply.github.com> Date: Wed, 14 Aug 2024 06:52:53 +0000 Subject: [PATCH 2/5] fix: fix typo in variable name --- feeph/emc2101/pwm.py | 12 ++++++------ tests/test_emc2101pwm.py | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/feeph/emc2101/pwm.py b/feeph/emc2101/pwm.py index a257cb5..6e1f4de 100755 --- a/feeph/emc2101/pwm.py +++ b/feeph/emc2101/pwm.py @@ -68,8 +68,8 @@ def __init__(self, ideality_factor: int, beta_factor: int): class TemperatureLimitType(Enum): - TO_COLD = 1 - TO_HOT = 2 + TOO_COLD = 1 + TOO_HOT = 2 # TODO add convenience function to refresh state @@ -300,9 +300,9 @@ def get_sensor_temperature_limit(self, limit_type: TemperatureLimitType) -> floa """ get upper/lower temperature alerting limit in °C """ - if limit_type == TemperatureLimitType.TO_COLD: + if limit_type == TemperatureLimitType.TOO_COLD: return self._emc2101.get_sensor_low_temperature_limit() - elif limit_type == TemperatureLimitType.TO_HOT: + elif limit_type == TemperatureLimitType.TOO_HOT: return self._emc2101.get_sensor_high_temperature_limit() else: raise ValueError("invalid limit type") @@ -314,9 +314,9 @@ def set_sensor_temperature_limit(self, value: float, limit_type: TemperatureLimi The fractional part has limited precision and will be clamped to the nearest available step. The clamped value is returned to the caller. """ - if limit_type == TemperatureLimitType.TO_COLD: + if limit_type == TemperatureLimitType.TOO_COLD: return self._emc2101.set_sensor_low_temperature_limit(value=value) - elif limit_type == TemperatureLimitType.TO_HOT: + elif limit_type == TemperatureLimitType.TOO_HOT: return self._emc2101.set_sensor_high_temperature_limit(value=value) else: raise ValueError("invalid limit type") diff --git a/tests/test_emc2101pwm.py b/tests/test_emc2101pwm.py index 79b2a0e..7021745 100755 --- a/tests/test_emc2101pwm.py +++ b/tests/test_emc2101pwm.py @@ -257,14 +257,14 @@ def test_sensor_temperature_limit_read_lower(self): bh.write_register(0x08, 0x12) # external sensor low limit (decimal) bh.write_register(0x14, 0b1110_0000) # external sensor low limit (fraction) # ----------------------------------------------------------------- - computed = self.emc2101.get_sensor_temperature_limit(limit_type=sut.TemperatureLimitType.TO_COLD) + computed = self.emc2101.get_sensor_temperature_limit(limit_type=sut.TemperatureLimitType.TOO_COLD) expected = 18.9 # ----------------------------------------------------------------- self.assertEqual(computed, expected, f"Got unexpected sensor temperature limit '{computed}'.") def test_sensor_temperature_limit_write_lower(self): # ----------------------------------------------------------------- - computed = self.emc2101.set_sensor_temperature_limit(5.91, limit_type=sut.TemperatureLimitType.TO_COLD) + computed = self.emc2101.set_sensor_temperature_limit(5.91, limit_type=sut.TemperatureLimitType.TOO_COLD) expected = 5.9 # ----------------------------------------------------------------- self.assertEqual(computed, expected, f"Got unexpected sensor temperature limit '{computed}'.") @@ -277,14 +277,14 @@ def test_sensor_temperature_limit_read_upper(self): bh.write_register(0x07, 0x54) # external sensor low limit (decimal) bh.write_register(0x13, 0b1110_0000) # external sensor low limit (fraction) # ----------------------------------------------------------------- - computed = self.emc2101.get_sensor_temperature_limit(limit_type=sut.TemperatureLimitType.TO_HOT) + computed = self.emc2101.get_sensor_temperature_limit(limit_type=sut.TemperatureLimitType.TOO_HOT) expected = 84.9 # ----------------------------------------------------------------- self.assertEqual(computed, expected, f"Got unexpected sensor temperature limit '{computed}'.") def test_sensor_temperature_limit_write_upper(self): # ----------------------------------------------------------------- - computed = self.emc2101.set_sensor_temperature_limit(84.91, limit_type=sut.TemperatureLimitType.TO_HOT) + computed = self.emc2101.set_sensor_temperature_limit(84.91, limit_type=sut.TemperatureLimitType.TOO_HOT) expected = 84.9 # ----------------------------------------------------------------- self.assertEqual(computed, expected, f"Got unexpected sensor temperature limit '{computed}'.") From f525e67f9a301c6134490bfd932112823ea1e290 Mon Sep 17 00:00:00 2001 From: Feeph Aifeimei <55798703+feeph@users.noreply.github.com> Date: Wed, 14 Aug 2024 07:27:50 +0000 Subject: [PATCH 3/5] fix: fix error handling conditions for pin 6 configuration --- feeph/emc2101/core.py | 28 ++++++++++++++-------------- tests/test_emc2101.py | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/feeph/emc2101/core.py b/feeph/emc2101/core.py index 4665b12..6f39f37 100755 --- a/feeph/emc2101/core.py +++ b/feeph/emc2101/core.py @@ -169,29 +169,29 @@ def describe_device(self): # --------------------------------------------------------------------- def configure_pin_six_as_alert(self) -> bool: - # set 0x03.2 to 0 - with BurstHandler(i2c_bus=self._i2c_bus, i2c_adr=self._i2c_adr) as bh: - cfg_register_value = bh.read_register(0x03) - if cfg_register_value is not None: + try: + # set 0x03.2 to 0 + with BurstHandler(i2c_bus=self._i2c_bus, i2c_adr=self._i2c_adr) as bh: + cfg_register_value = bh.read_register(0x03) bh.write_register(0x03, cfg_register_value & 0b1111_1011) # clear spin up behavior settings # (spin up is unavailable when pin 6 is in alert mode), bh.write_register(0x4B, 0b0000_0000) return True - else: - LH.error("Unable to read config register!") - return False + except RuntimeError: + LH.error("Unable to read config register!") + return False def configure_pin_six_as_tacho(self) -> bool: - # set 0x03.2 to 1 - with BurstHandler(i2c_bus=self._i2c_bus, i2c_adr=self._i2c_adr) as bh: - cfg_register_value = bh.read_register(0x03) - if cfg_register_value is not None: + try: + # set 0x03.2 to 1 + with BurstHandler(i2c_bus=self._i2c_bus, i2c_adr=self._i2c_adr) as bh: + cfg_register_value = bh.read_register(0x03) bh.write_register(0x03, cfg_register_value | 0b0000_0100) return True - else: - LH.error("Unable to read config register!") - return False + except RuntimeError: + LH.error("Unable to read config register!") + return False def configure_dac_control(self, step_max: int): # enable DAC control (set 0x03.4 to 1) diff --git a/tests/test_emc2101.py b/tests/test_emc2101.py index 2531150..19c0e8c 100755 --- a/tests/test_emc2101.py +++ b/tests/test_emc2101.py @@ -24,6 +24,7 @@ HAS_HARDWARE = False +# pylint: disable=protected-access class TestEmc2101(unittest.TestCase): def setUp(self): @@ -68,6 +69,15 @@ def test_pin_six_as_alert(self): self.assertEqual(bh.read_register(0x03), 0b0000_0000) self.assertEqual(bh.read_register(0x4B), 0b0000_0000) + @unittest.skipIf(HAS_HARDWARE, "Skipping forced failure test.") + def test_pin_six_as_alert_failure(self): + self.i2c_bus._lock_chance = 0 + # ----------------------------------------------------------------- + computed = self.emc2101.configure_pin_six_as_alert() + expected = False + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + def test_pin_six_as_tacho(self): # ----------------------------------------------------------------- computed = self.emc2101.configure_pin_six_as_tacho() @@ -77,3 +87,20 @@ def test_pin_six_as_tacho(self): with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: self.assertEqual(bh.read_register(0x03), 0b0000_0100) self.assertEqual(bh.read_register(0x4B), 0b0011_1111) + + @unittest.skipIf(HAS_HARDWARE, "Skipping forced failure test.") + def test_pin_six_as_tacho_failure(self): + self.i2c_bus._lock_chance = 0 + # ----------------------------------------------------------------- + computed = self.emc2101.configure_pin_six_as_tacho() + expected = False + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_pin_get_rpm_in_alert_mode(self): + self.emc2101.configure_pin_six_as_alert() + # ----------------------------------------------------------------- + computed = self.emc2101.get_rpm() + expected = None + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) From 864889f746b4c754f845c1487798e003514dec32 Mon Sep 17 00:00:00 2001 From: Feeph Aifeimei <55798703+feeph@users.noreply.github.com> Date: Wed, 14 Aug 2024 21:23:52 +0000 Subject: [PATCH 4/5] test: improve code coverage --- tests/test_calibration.py | 9 +- tests/test_conversions.py | 3 +- tests/test_emc2101.py | 101 +++++++++++-- tests/test_emc2101pwm.py | 222 +++++++++++++++++++++++++++- tests/test_fanconfig.py | 279 ++++++++++++++++++++++++++++++++++++ tests/test_scs_baseclass.py | 63 ++++++++ tests/test_scs_dac.py | 152 ++++++++++++++++++++ tests/test_scs_pwm.py | 156 ++++++++++---------- tests/test_utilities.py | 28 ++++ 9 files changed, 911 insertions(+), 102 deletions(-) create mode 100755 tests/test_fanconfig.py create mode 100755 tests/test_scs_baseclass.py create mode 100755 tests/test_scs_dac.py create mode 100755 tests/test_utilities.py diff --git a/tests/test_calibration.py b/tests/test_calibration.py index 74fb424..efb08a2 100755 --- a/tests/test_calibration.py +++ b/tests/test_calibration.py @@ -1,12 +1,5 @@ #!/usr/bin/env python3 -""" -perform PWM related tests - -use simulated device: - pdm run pytest -use hardware device: - TEST_EMC2101_CHIP=y pdm run pytest -""" +# pylint: disable=missing-class-docstring,missing-function-docstring,missing-module-docstring import os import unittest diff --git a/tests/test_conversions.py b/tests/test_conversions.py index a58e3b1..edd4f8d 100755 --- a/tests/test_conversions.py +++ b/tests/test_conversions.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -""" +# pylint: disable=missing-class-docstring,missing-function-docstring,missing-module-docstring import unittest diff --git a/tests/test_emc2101.py b/tests/test_emc2101.py index 19c0e8c..3bd6856 100755 --- a/tests/test_emc2101.py +++ b/tests/test_emc2101.py @@ -1,12 +1,5 @@ #!/usr/bin/env python3 -""" -perform PWM related tests - -use simulated device: - pdm run pytest -use hardware device: - TEST_EMC2101_CHIP=y pdm run pytest -""" +# pylint: disable=missing-class-docstring,missing-function-docstring,missing-module-docstring import os import unittest @@ -104,3 +97,95 @@ def test_pin_get_rpm_in_alert_mode(self): expected = None # ----------------------------------------------------------------- self.assertEqual(computed, expected) + + def test_configure_dac_control(self): + self.emc2101.configure_dac_control(15) + # ----------------------------------------------------------------- + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + computed = bh.read_register(0x03) & 0b0001_0000 + expected = 0b0001_0000 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + self.assertEqual(self.emc2101._step_max, 15) + + def test_configure_pwm_control(self): + self.emc2101.configure_pwm_control(pwm_d=0x12, pwm_f=0x34, step_max=15) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + self.assertFalse(bh.read_register(0x03) & 0b0001_0000) + self.assertEqual(bh.read_register(0x4D), 0x34) + self.assertEqual(bh.read_register(0x4E), 0x12) + + def test_configure_spinup_behaviour(self): + spinup_duration = sut.SpinUpDuration.TIME_0_80 # 0b...._.101 + spinup_strength = sut.SpinUpStrength.STRENGTH_50 # 0b...0_1... + fast_mode = False # 0b..0._.... + self.emc2101.configure_spinup_behaviour(spinup_strength=spinup_duration, spinup_duration=spinup_strength, fast_mode=fast_mode) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + self.assertEqual(bh.read_register(0x4B), 0b00001101) + + def test_set_minimum_rpm_too_low(self): + # due to the way EMC2101's registers are implemented the measured + # RPM can never be lower than 82 RPM + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, self.emc2101.configure_minimum_rpm, 80) + + def test_get_driver_strength(self): + # ----------------------------------------------------------------- + computed = self.emc2101.get_driver_strength() + expected = 0 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_set_driver_strength(self): + # ----------------------------------------------------------------- + computed = self.emc2101.set_driver_strength(2) + expected = True + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + self.assertEqual(bh.read_register(0x4C), 2) + + def test_set_driver_strength_oor(self): + # ----------------------------------------------------------------- + computed = self.emc2101.set_driver_strength(64) + expected = False + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + self.assertEqual(bh.read_register(0x4C), 0) + + def test_set_sensor_low_temperature_limit(self): + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, self.emc2101.set_sensor_low_temperature_limit, -50) + + def test_set_sensor_high_temperature_limit(self): + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, self.emc2101.set_sensor_high_temperature_limit, 150) + + @unittest.skipIf(HAS_HARDWARE, "Skipping forced failure test.") + def test_force_external_temperature_sensor_failure(self): + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + bh.write_register(0x02, 0b0000_0100) + # ----------------------------------------------------------------- + computed = self.emc2101.configure_external_temperature_sensor(dif=0x12, bcf=0x34) + expected = False + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + # --------------------------------------------------------------------- + # usage errors + # --------------------------------------------------------------------- + + def test_invalid_conversion_rate(self): + # ----------------------------------------------------------------- + computed = self.emc2101.set_temperature_conversion_rate("invalid-value") + expected = False + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) diff --git a/tests/test_emc2101pwm.py b/tests/test_emc2101pwm.py index 7021745..390d84e 100755 --- a/tests/test_emc2101pwm.py +++ b/tests/test_emc2101pwm.py @@ -7,6 +7,7 @@ use hardware device: TEST_EMC2101_CHIP=y pdm run pytest """ +# pylint: disable=missing-class-docstring,missing-function-docstring import math import os @@ -26,6 +27,7 @@ HAS_HARDWARE = False +# pylint: disable=too-many-public-methods,protected-access class TestEmc2101PWM(unittest.TestCase): def setUp(self): @@ -75,6 +77,32 @@ def tearDown(self): # nothing to do pass + # --------------------------------------------------------------------- + # initialization + # --------------------------------------------------------------------- + + def test_configure_pin6_invalid(self): + device_config = sut.DeviceConfig(rpm_control_mode=sut.RpmControlMode.PWM, pin_six_mode=None) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(NotImplementedError, sut.Emc2101_PWM, i2c_bus=self.i2c_bus, device_config=device_config, fan_config=self.fan_config) + + def test_configure_control_mode_mismatch(self): + # fan device and controller must both agree on how to control the + # fan's speed + fan_config = sut.FanConfig(model="Mockinator 2000", pwm_frequency=22500, rpm_control_mode=sut.RpmControlMode.VOLTAGE, minimum_duty_cycle=20, maximum_duty_cycle=100, minimum_rpm=100, maximum_rpm=2000, steps={}) + device_config = sut.DeviceConfig(rpm_control_mode=sut.RpmControlMode.PWM, pin_six_mode=sut.PinSixMode.TACHO) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.Emc2101_PWM, i2c_bus=self.i2c_bus, device_config=device_config, fan_config=fan_config) + + def test_configure_control_mode_unknown(self): + fan_config = sut.FanConfig(model="Mockinator 2000", pwm_frequency=22500, rpm_control_mode=None, minimum_duty_cycle=20, maximum_duty_cycle=100, minimum_rpm=100, maximum_rpm=2000, steps={}) + device_config = sut.DeviceConfig(rpm_control_mode=sut.RpmControlMode.PWM, pin_six_mode=sut.PinSixMode.TACHO) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.Emc2101_PWM, i2c_bus=self.i2c_bus, device_config=device_config, fan_config=fan_config) + # --------------------------------------------------------------------- # hardware details # --------------------------------------------------------------------- @@ -124,11 +152,54 @@ def test_configure_spinup_behaviour(self): spinup_strength = sut.SpinUpStrength.STRENGTH_50 # 0b...0_1... fast_mode = True # 0b..1._.... # ----------------------------------------------------------------- - self.emc2101.configure_spinup_behaviour(spinup_strength=spinup_strength, spinup_duration=spinup_duration, fast_mode=fast_mode) + computed = self.emc2101.configure_spinup_behaviour(spinup_strength=spinup_strength, spinup_duration=spinup_duration, fast_mode=fast_mode) + expected = True # ----------------------------------------------------------------- + self.assertEqual(computed, expected) with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: self.assertEqual(bh.read_register(0x4B), 0b0010_1101) + def test_configure_spinup_behaviour_alert(self): + # unable to configure spinup mode if the device is pin 6 is used as + # an alert pin (pin must be in tacho mode or we can't measure speed) + device_config = sut.DeviceConfig(rpm_control_mode=sut.RpmControlMode.PWM, pin_six_mode=sut.PinSixMode.ALERT) + emc2101_alert = sut.Emc2101_PWM(i2c_bus=self.i2c_bus, device_config=device_config, fan_config=self.fan_config) + spinup_duration = sut.SpinUpDuration.TIME_0_80 # 0b...._.101 + spinup_strength = sut.SpinUpStrength.STRENGTH_50 # 0b...0_1... + fast_mode = True # 0b..1._.... + # ----------------------------------------------------------------- + computed = emc2101_alert.configure_spinup_behaviour(spinup_strength=spinup_strength, spinup_duration=spinup_duration, fast_mode=fast_mode) + expected = False + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_configure_spinup_behaviour_invalid(self): + self.emc2101._pin_six_mode = None # force an invalid state + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(RuntimeError, self.emc2101.configure_spinup_behaviour, spinup_strength=0, spinup_duration=0, fast_mode=True) + + # result of this test on hardware is unpredictable + @unittest.skipIf(HAS_HARDWARE, "Skipping RPM test.") + def test_get_rpm(self): + # ----------------------------------------------------------------- + computed = self.emc2101.get_rpm() + expected = None + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_get_fixed_speed(self): + # ----------------------------------------------------------------- + computed = self.emc2101.get_fixed_speed(unit=sut.FanSpeedUnit.STEP) + expected = 0 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_set_fixed_speed_invalid(self): + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, self.emc2101.set_fixed_speed, value=0, unit=None) + # control duty cycle using manual control def test_duty_cycle_read_steps(self): @@ -252,6 +323,15 @@ def test_chip_temperature_limit_read(self): # ----------------------------------------------------------------- self.assertEqual(computed, expected, f"Got unexpected chip temperature limit '{computed}'.") + def test_chip_temperature_limit_write(self): + self.emc2101.set_chip_temperature_limit(56) + # ----------------------------------------------------------------- + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + computed = bh.read_register(0x05) + expected = 56 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + def test_sensor_temperature_limit_read_lower(self): with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: bh.write_register(0x08, 0x12) # external sensor low limit (decimal) @@ -292,6 +372,16 @@ def test_sensor_temperature_limit_write_upper(self): self.assertEqual(bh.read_register(0x07), 0x54) self.assertEqual(bh.read_register(0x13), 0b1110_0000) + def test_sensor_temperature_limit_read_invalid_limit(self): + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, self.emc2101.get_sensor_temperature_limit, limit_type='a') + + def test_sensor_temperature_limit_write_invalid_limit(self): + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, self.emc2101.set_sensor_temperature_limit, 5.91, limit_type='a') + # --------------------------------------------------------------------- # temperature measurements (external sensor) # --------------------------------------------------------------------- @@ -371,6 +461,25 @@ def test_sensor_temperature(self): else: self.assertTrue(math.isnan(computed)) + @unittest.skipIf(HAS_HARDWARE, "Skipping forced failure test.") + def test_sensor_state_invalid(self): + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + bh.write_register(0x01, 0b0111_1111) + bh.write_register(0x10, 0b1110_0100) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(RuntimeError, self.emc2101.get_external_sensor_state) + + @unittest.skipIf(HAS_HARDWARE, "Skipping forced failure test.") + def test_sensor_temperature_invalid(self): + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + bh.write_register(0x01, 0b0111_1111) + bh.write_register(0x10, 0b1110_0000) + # ----------------------------------------------------------------- + computed = self.emc2101.get_sensor_temperature() + # ----------------------------------------------------------------- + self.assertTrue(math.isnan(computed)) + # --------------------------------------------------------------------- # control fan speed (lookup table) # --------------------------------------------------------------------- @@ -544,6 +653,80 @@ def test_update_lookup_table_too_high(self): # ----------------------------------------------------------------- self.assertRaises(ValueError, self.emc2101.update_lookup_table, values=values, unit=sut.FanSpeedUnit.STEP) + def test_update_lookup_table_percent(self): + values = { + 16: 20, # temp+speed #1 + 40: 60, # temp+speed #2 + 72: 90, # temp+speed #3 + } + # ----------------------------------------------------------------- + computed = self.emc2101.update_lookup_table(values=values, unit=sut.FanSpeedUnit.PERCENT) + expected = True + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) # update was performed + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + self.assertEqual(bh.read_register(0x50), 16) + self.assertEqual(bh.read_register(0x51), 0x03) + self.assertEqual(bh.read_register(0x52), 40) + self.assertEqual(bh.read_register(0x53), 0x08) + self.assertEqual(bh.read_register(0x54), 72) + self.assertEqual(bh.read_register(0x55), 0x0D) + + # TODO properly validate the percentage range and perform suitable action + def test_update_lookup_table_percent_too_low(self): + values = { + 16: -1, + } + # ----------------------------------------------------------------- + computed = self.emc2101.update_lookup_table(values=values, unit=sut.FanSpeedUnit.PERCENT) + expected = True + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) # update was performed + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + self.assertEqual(bh.read_register(0x50), 16) + self.assertEqual(bh.read_register(0x51), 0x0E) + + # TODO properly validate the percentage range and perform suitable action + def test_update_lookup_table_percent_too_high(self): + values = { + 16: 101, + } + # ----------------------------------------------------------------- + computed = self.emc2101.update_lookup_table(values=values, unit=sut.FanSpeedUnit.PERCENT) + expected = True + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) # update was performed + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + self.assertEqual(bh.read_register(0x50), 16) + self.assertEqual(bh.read_register(0x51), 0x0E) + + def test_update_lookup_table_rpm(self): + values = { + 16: 700, # temp+speed #1 + 40: 800, # temp+speed #2 + 72: 900, # temp+speed #3 + } + # ----------------------------------------------------------------- + computed = self.emc2101.update_lookup_table(values=values, unit=sut.FanSpeedUnit.RPM) + expected = True + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) # update was performed + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + self.assertEqual(bh.read_register(0x50), 16) + self.assertEqual(bh.read_register(0x51), 0x08) + self.assertEqual(bh.read_register(0x52), 40) + self.assertEqual(bh.read_register(0x53), 0x09) + self.assertEqual(bh.read_register(0x54), 72) + self.assertEqual(bh.read_register(0x55), 0x0A) + + def test_update_lookup_table_invalid_unit(self): + values = { + 16: -65, # min temp is -64 + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, self.emc2101.update_lookup_table, values=values, unit=None) + def test_reset_lookup(self): with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: # initialize status register @@ -563,3 +746,40 @@ def test_reset_lookup(self): with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: for offset in range(0, 16): self.assertEqual(bh.read_register(0x50 + offset), 0x00) + + # --------------------------------------------------------------------- + # convenience functions + # --------------------------------------------------------------------- + + def test_read_fancfg_register(self): + # ----------------------------------------------------------------- + computed = self.emc2101.read_fancfg_register() + expected = 0b0010_0000 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_write_fancfg_register(self): + self.emc2101.write_fancfg_register(0b0110_0000) + # ----------------------------------------------------------------- + computed = self.emc2101.read_fancfg_register() + expected = 0b0110_0000 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_read_device_registers(self): + # this test is sloppy and only compares if we get the right keys, it + # does not check if the values are correct (could be random junk) + # ----------------------------------------------------------------- + computed = self.emc2101.read_device_registers().keys() + expected = feeph.emc2101.core.DEFAULTS.keys() + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_configure_external_temperature_sensor(self): + etsc = sut.ExternalTemperatureSensorConfig(ideality_factor=0x11, beta_factor=0x07) + self.emc2101.configure_external_temperature_sensor(ets_config=etsc) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + with BurstHandler(i2c_bus=self.i2c_bus, i2c_adr=self.i2c_adr) as bh: + self.assertEqual(bh.read_register(0x17), 0x11) + self.assertEqual(bh.read_register(0x18), 0x07) diff --git a/tests/test_fanconfig.py b/tests/test_fanconfig.py new file mode 100755 index 0000000..3608e1d --- /dev/null +++ b/tests/test_fanconfig.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +# pylint: disable=missing-class-docstring,missing-function-docstring,missing-module-docstring + +import unittest + +import feeph.emc2101.fan_configs as sut # sytem under test + + +class TestFanConfigs(unittest.TestCase): + + def test_pwmfan_without_frequency(self): + params = { + # fmt: off + 'model': 'brown matter acceleration device', + 'rpm_control_mode': sut.RpmControlMode.PWM, + 'minimum_duty_cycle': 20, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': None, + 'steps': {}, + # fmt: on + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.FanConfig, **params) + + def test_dutycycle_min_larger_than_max(self): + params = { + # fmt: off + 'model': 'brown matter entropy averaging device', + 'rpm_control_mode': sut.RpmControlMode.PWM, + 'minimum_duty_cycle': 120, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': 22500, + 'steps': {}, + # fmt: on + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.FanConfig, **params) + + def test_dutycycle_min_too_small(self): + params = { + # fmt: off + 'model': 'brown matter impacting device', + 'rpm_control_mode': sut.RpmControlMode.PWM, + 'minimum_duty_cycle': -1, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': 22500, + 'steps': {}, + # fmt: on + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.FanConfig, **params) + + def test_dutycycle_min_too_large(self): + params = { + # fmt: off + 'model': 'brown matter granularity changing device', + 'rpm_control_mode': sut.RpmControlMode.PWM, # RpmControlMode, + 'minimum_duty_cycle': 20, # int | None, + 'maximum_duty_cycle': 101, # int | None, + 'minimum_rpm': 700, # int, + 'maximum_rpm': 1400, # int, + 'pwm_frequency': 22500, # int | None = None, + 'steps': {}, # Steps | None = None, + # fmt: on + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.FanConfig, **params) + + +# pylint: disable=missing-class-docstring,missing-function-docstring +class TestFanConfigExporter(unittest.TestCase): + + def test_export_dacfan(self): + params = { + # fmt: off + 'model': 'Mockinator 2000 (DC)', + 'rpm_control_mode': sut.RpmControlMode.VOLTAGE, + 'minimum_duty_cycle': 20, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': None, + 'steps': { + 2: (20, None), + 4: (40, None), + }, + # fmt: on + } + fc = sut.FanConfig(**params) + # ----------------------------------------------------------------- + computed = sut.export_fan_config(fan_config=fc) + expected = { + 'model': 'Mockinator 2000 (DC)', + 'control_mode': 'VOLTAGE', + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'steps': { + 2: {'dutycycle': 20, 'rpm': None}, + 4: {'dutycycle': 40, 'rpm': None}, + }, + } + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_export_pwmfan(self): + params = { + # fmt: off + 'model': 'Mockinator 2000 (PWM)', + 'rpm_control_mode': sut.RpmControlMode.PWM, + 'minimum_duty_cycle': 20, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': 22500, + 'steps': { + 2: (20, 800), + 4: (40, 1300), + }, + # fmt: on + } + fc = sut.FanConfig(**params) + # ----------------------------------------------------------------- + computed = sut.export_fan_config(fan_config=fc) + expected = { + 'model': 'Mockinator 2000 (PWM)', + 'control_mode': 'PWM', + 'minimum_duty_cycle': 20, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': 22500, + 'steps': { + 2: {'dutycycle': 20, 'rpm': 800}, + 4: {'dutycycle': 40, 'rpm': 1300}, + }, + } + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_export_control_type_invalid(self): + params = { + # fmt: off + 'model': 'Mockinator 2000 (DC)', + 'rpm_control_mode': None, + 'minimum_duty_cycle': 20, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': None, + 'steps': { + 2: (20, None), + 4: (40, None), + }, + # fmt: on + } + fc = sut.FanConfig(**params) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.export_fan_config, fan_config=fc) + + +# pylint: disable=missing-class-docstring,missing-function-docstring +class TestFanConfigImporter(unittest.TestCase): + + def test_import_dacfan(self): + data = { + # fmt: off + 'model': 'Mockinator 2000 (DC)', + 'control_mode': 'VOLTAGE', + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'steps': { + 2: {'dutycycle': 20, 'rpm': None}, + 4: {'dutycycle': 40, 'rpm': None}, + }, + # fmt: on + } + fc = sut.import_fan_config(fan_config=data) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertEqual(fc.model, 'Mockinator 2000 (DC)') + self.assertEqual(fc.rpm_control_mode, sut.RpmControlMode.VOLTAGE) + self.assertEqual(fc.minimum_duty_cycle, None) + self.assertEqual(fc.maximum_duty_cycle, None) + self.assertEqual(fc.minimum_rpm, 700) + self.assertEqual(fc.maximum_rpm, 1400) + self.assertEqual(fc.pwm_frequency, 0) + self.assertEqual(fc.steps, {2: (20, None), 4: (40, None)}) + + def test_import_pwmfan(self): + data = { + # fmt: off + 'model': 'Mockinator 2000 (PWM)', + 'control_mode': 'PWM', + 'minimum_duty_cycle': 20, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': 22500, + 'steps': { + 2: {'dutycycle': 20, 'rpm': 800}, + 4: {'dutycycle': 40, 'rpm': 1300}, + }, + # fmt: on + } + fc = sut.import_fan_config(fan_config=data) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertEqual(fc.model, 'Mockinator 2000 (PWM)') + self.assertEqual(fc.rpm_control_mode, sut.RpmControlMode.PWM) + self.assertEqual(fc.minimum_duty_cycle, 20) + self.assertEqual(fc.maximum_duty_cycle, 100) + self.assertEqual(fc.minimum_rpm, 700) + self.assertEqual(fc.maximum_rpm, 1400) + self.assertEqual(fc.pwm_frequency, 22500) + self.assertEqual(fc.steps, {2: (20, 800), 4: (40, 1300)}) + + def test_import_control_type_invalid(self): + data = { + # fmt: off + 'model': 'Mockinator 2000 (INVALID)', + 'control_mode': None, + 'minimum_duty_cycle': 20, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': None, + 'steps': {}, + # fmt: on + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.import_fan_config, fan_config=data) + + def test_import_step_invalid_data(self): + data = { + # fmt: off + 'model': 'Mockinator 2000 (INVALID)', + 'control_mode': 'PWM', + 'minimum_duty_cycle': 20, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': None, + 'steps': { + 2: {'dutycycle': None, 'rpm': 800}, + }, + # fmt: on + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.import_fan_config, fan_config=data) + + def test_import_step_invalid_type(self): + data = { + # fmt: off + 'model': 'Mockinator 2000 (INVALID)', + 'control_mode': 'PWM', + 'minimum_duty_cycle': 20, + 'maximum_duty_cycle': 100, + 'minimum_rpm': 700, + 'maximum_rpm': 1400, + 'pwm_frequency': None, + 'steps': [], + # fmt: on + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.import_fan_config, fan_config=data) diff --git a/tests/test_scs_baseclass.py b/tests/test_scs_baseclass.py new file mode 100755 index 0000000..95b702f --- /dev/null +++ b/tests/test_scs_baseclass.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# pylint: disable=missing-class-docstring,missing-function-docstring,missing-module-docstring + +import unittest + +import feeph.emc2101.scs.base_class as sut + + +class IncompleteSpeedControlSetter(sut.SpeedControlSetter): + # !! DO NOT WRITE CODE LIKE THIS !! + # The purpose of this class is to disable all safety checks and + # brute-force the usage of abstract methods so we can test that + # the code correctly bugs out if this happens + # !! DO NOT WRITE CODE LIKE THIS !! + + def is_valid_step(self, value: int) -> bool: + # !! DANGER !! + return super().is_valid_step(value=value) # type: ignore [safe-super] + + # pylint: disable=useless-parent-delegation + def get_steps(self) -> list[int]: + # !! DANGER !! + return super().get_steps() # type: ignore [safe-super] + + def convert_percent2step(self, percent: int) -> int | None: + # !! DANGER !! + return super().convert_percent2step(percent=percent) # type: ignore [safe-super] + + def convert_step2percent(self, step: int) -> int: + # !! DANGER !! + return super().convert_step2percent(step=step) # type: ignore [safe-super] + + def convert_rpm2step(self, rpm: int) -> int | None: + # !! DANGER !! + return super().convert_rpm2step(rpm=rpm) # type: ignore [safe-super] + + def convert_step2rpm(self, step: int) -> int | None: + # !! DANGER !! + return super().convert_step2rpm(step=step) # type: ignore [safe-super] + + +class TestScsPwm(unittest.TestCase): + + # def test_abstract_base_class(self): + # self.assertRaises(TypeError, sut.Ads1x1xConfig) + + def test_abstract_method1(self): + self.assertRaises(TypeError, IncompleteSpeedControlSetter().is_valid_step(0)) + + def test_abstract_method2(self): + self.assertRaises(TypeError, IncompleteSpeedControlSetter().get_steps()) + + def test_abstract_method3(self): + self.assertRaises(TypeError, IncompleteSpeedControlSetter().convert_percent2step(0)) + + def test_abstract_method4(self): + self.assertRaises(TypeError, IncompleteSpeedControlSetter().convert_step2percent(0)) + + def test_abstract_method5(self): + self.assertRaises(TypeError, IncompleteSpeedControlSetter().convert_rpm2step(0)) + + def test_abstract_method6(self): + self.assertRaises(TypeError, IncompleteSpeedControlSetter().convert_step2rpm(0)) diff --git a/tests/test_scs_dac.py b/tests/test_scs_dac.py new file mode 100755 index 0000000..9a4f0db --- /dev/null +++ b/tests/test_scs_dac.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# pylint: disable=missing-class-docstring,missing-function-docstring,missing-module-docstring + +import unittest + +import feeph.emc2101.scs.dac as sut + + +# pylint: disable=protected-access +class TestScsDac(unittest.TestCase): + + def setUp(self): + self.dac = sut.DAC(minimum_duty_cycle=5, maximum_duty_cycle=90) + + # --------------------------------------------------------------------- + + def test_init_dutycycle_too_small(self): + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.DAC, minimum_duty_cycle=-1, maximum_duty_cycle=100) + + def test_init_dutycycle_too_high(self): + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.DAC, minimum_duty_cycle=0, maximum_duty_cycle=101) + + # --------------------------------------------------------------------- + + def test_is_valid_step(self): + # ----------------------------------------------------------------- + computed = self.dac.is_valid_step(3) + expected = True + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_is_valid_step_oor(self): + # ----------------------------------------------------------------- + computed = self.dac.is_valid_step(-1) + expected = False + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_valid_steps(self): + values = { + 0x00: False, + 0x01: False, + 0x02: False, + 0x03: True, + 0x04: True, + 0x05: True, + 0x06: True, + 0x07: True, + 0x08: True, + 0x09: True, + 0x0A: True, + 0x0B: True, + 0x0C: True, + 0x0D: True, + 0x0E: True, + 0x0F: False, + } + for dac_step, is_valid in values.items(): + computed = self.dac.is_valid_step(dac_step) + expected = is_valid + self.assertEqual(computed, expected) + + def test_dac_steps(self): + # ----------------------------------------------------------------- + computed = self.dac.get_steps() + expected = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_convert_percent2step(self): + values = { + # exact matches + 34: 3, + 40: 4, + # approximated matches + 36: 3, + 37: 4, + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + for duty_cycle, dac_step in values.items(): + computed = self.dac.convert_percent2step(duty_cycle) + expected = dac_step + self.assertEqual(computed, expected) + + # 0: ( 0, 0), # noqa: 201 + # 1: (22, 299), # noqa: 201 + # 2: (28, 349), # noqa: 201 + + def test_convert_percent2step_zero(self): + # inject a step with a 0% duty cycle to test the special handling + # of this value (prevent division by zero) + self.dac._steps[1] = (0, 400) + # ----------------------------------------------------------------- + computed = self.dac.convert_percent2step(1) + expected = 1 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_convert_step2percent(self): + values = { + 3: 34, + 4: 40, + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + for dac_step, duty_cycle in values.items(): + computed = self.dac.convert_step2percent(dac_step) + expected = duty_cycle + self.assertEqual(computed, expected) + + def test_convert_rpm2step(self): + values = { + # exact matches + 409: 3, + 479: 4, + # approximated matches + 440: 3, + 450: 4, + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + for rpm, dac_step in values.items(): + computed = self.dac.convert_rpm2step(rpm) + expected = dac_step + self.assertEqual(computed, expected) + + def test_convert_rpm2step_zero(self): + # inject a step with 0 RPM to test the special handling + # of this value (prevent division by zero) + self.dac._steps[1] = (10, 0) + # ----------------------------------------------------------------- + computed = self.dac.convert_rpm2step(1) + expected = 1 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_convert_step2rpm(self): + values = { + 3: 409, + 4: 479, + } + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + for dac_step, rpm in values.items(): + computed = self.dac.convert_step2rpm(dac_step) + expected = rpm + self.assertEqual(computed, expected) diff --git a/tests/test_scs_pwm.py b/tests/test_scs_pwm.py index 28c6fce..b1b62b2 100755 --- a/tests/test_scs_pwm.py +++ b/tests/test_scs_pwm.py @@ -1,14 +1,13 @@ #!/usr/bin/env python3 -""" - -""" +# pylint: disable=missing-class-docstring,missing-function-docstring,missing-module-docstring import unittest +import feeph.emc2101.scs.pwm as sut from feeph.emc2101.fan_configs import FanConfig, RpmControlMode -from feeph.emc2101.scs.pwm import PWM, calculate_pwm_factors +# pylint: disable=protected-access class TestScsPwm(unittest.TestCase): def setUp(self): @@ -30,9 +29,28 @@ def setUp(self): # fmt: on } self.fan_config = FanConfig(model="fan", rpm_control_mode=RpmControlMode.PWM, pwm_frequency=22500, minimum_duty_cycle=0, maximum_duty_cycle=100, minimum_rpm=100, maximum_rpm=2000, steps=self.steps) + self.pwm = sut.PWM(fan_config=self.fan_config) + + # --------------------------------------------------------------------- + + def test_init_no_dutycycle(self): + fan_config = FanConfig(model="fan", rpm_control_mode=RpmControlMode.PWM, pwm_frequency=22500, minimum_duty_cycle=None, maximum_duty_cycle=None, minimum_rpm=100, maximum_rpm=2000, steps=self.steps) + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.PWM, fan_config=fan_config) + + def test_init_no_steps(self): + fan_config = FanConfig(model="fan", rpm_control_mode=RpmControlMode.PWM, pwm_frequency=22500, minimum_duty_cycle=0, maximum_duty_cycle=100, minimum_rpm=100, maximum_rpm=2000, steps={}) + pwm = sut.PWM(fan_config=fan_config) + # ----------------------------------------------------------------- + computed = pwm.get_steps() + expected = list() + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + # --------------------------------------------------------------------- def test_valid_steps(self): - pwm = PWM(fan_config=self.fan_config) values = { 0x00: False, 0x01: False, @@ -52,34 +70,32 @@ def test_valid_steps(self): 0x0F: False, } for pwm_step, is_valid in values.items(): - computed = pwm.is_valid_step(pwm_step) + computed = self.pwm.is_valid_step(pwm_step) expected = is_valid self.assertEqual(computed, expected) + def test_pwm_settings(self): + # ----------------------------------------------------------------- + computed = self.pwm.get_pwm_settings() + expected = (1, 8) + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_pwm_frequency(self): + # ----------------------------------------------------------------- + computed = self.pwm.get_pwm_frequency() + expected = 22500.0 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + def test_pwm_steps(self): - pwm = PWM(fan_config=self.fan_config) # ----------------------------------------------------------------- - computed = pwm.get_steps() + computed = self.pwm.get_steps() expected = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] # ----------------------------------------------------------------- self.assertEqual(computed, expected) - def test_pwm_calculations(self): - values = { - 45000: (1, 4), - 22500: (1, 8), - 22000: (1, 8), # closest match is 22500 - 6000: (1, 30), # noqa: E131 - 5500: (2, 16), # noqa: E131 - 1000: (6, 30), # noqa: E131 - } - for pwm_frequency, pwm_settings in values.items(): - computed = calculate_pwm_factors(pwm_frequency=pwm_frequency) - expected = pwm_settings - self.assertEqual(computed, expected) - def test_convert_percent2step(self): - pwm = PWM(fan_config=self.fan_config) values = { # exact matches 34: 3, @@ -89,23 +105,31 @@ def test_convert_percent2step(self): 37: 4, } for duty_cycle, pwm_step in values.items(): - computed = pwm.convert_percent2step(duty_cycle) + computed = self.pwm.convert_percent2step(duty_cycle) expected = pwm_step self.assertEqual(computed, expected) + def test_convert_percent2step_zero(self): + # inject a step with a 0% duty cycle to test the special handling + # of this value (prevent division by zero) + self.pwm._steps[0] = (0, 400) + # ----------------------------------------------------------------- + computed = self.pwm.convert_percent2step(1) + expected = 0 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + def test_convert_step2percent(self): - pwm = PWM(fan_config=self.fan_config) values = { 3: 34, 4: 40, } for pwm_step, duty_cycle in values.items(): - computed = pwm.convert_step2percent(pwm_step) + computed = self.pwm.convert_step2percent(pwm_step) expected = duty_cycle self.assertEqual(computed, expected) def test_convert_rpm2step(self): - pwm = PWM(fan_config=self.fan_config) values = { # exact matches 409: 3, @@ -115,70 +139,36 @@ def test_convert_rpm2step(self): 450: 4, } for rpm, pwm_step in values.items(): - computed = pwm.convert_rpm2step(rpm) + computed = self.pwm.convert_rpm2step(rpm) expected = pwm_step self.assertEqual(computed, expected) + def test_convert_rpm2step_zero(self): + # inject a step with 0 RPM to test the special handling + # of this value (prevent division by zero) + self.pwm._steps[1] = (10, 0) + # ----------------------------------------------------------------- + computed = self.pwm.convert_rpm2step(1) + expected = 1 + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + + def test_convert_rpm2step_unknown(self): + # inject a step with unknown RPM to test the special handling + # of this value + self.pwm._steps[2] = (10, None) + # ----------------------------------------------------------------- + computed = self.pwm.convert_rpm2step(1) + expected = 3 # step 2 is defined but has no RPM, pick next + # ----------------------------------------------------------------- + self.assertEqual(computed, expected) + def test_convert_step2rpm(self): - pwm = PWM(fan_config=self.fan_config) values = { 3: 409, 4: 479, } for pwm_step, rpm in values.items(): - computed = pwm.convert_step2rpm(pwm_step) + computed = self.pwm.convert_step2rpm(pwm_step) expected = rpm self.assertEqual(computed, expected) - -# class PWM(SpeedControlSetter): -# def get_speed_mapping(self) -> SpeedMappings: -# """ -# define available control steps and their resulting fan speeds -# """ -# return self._steps.copy() - -# def calculate_pwm_frequency(pwm_f: int, pwm_d: int) -> float: -# """ -# calculate PWM frequency for provided PWM_D and PWM_F -# """ -# pwm_frequency = 360000/(2*pwm_f*pwm_d) -# return pwm_frequency - - -# def calculate_pwm_factors(pwm_frequency: int) -> tuple[int, int]: -# """ -# calculate PWM_D and PWM_F for provided frequency -# - this function minimizes PWM_D to allow for maximum resolution (PWM_F) -# - PWM_F maxes out at 31 (0x1F) -# """ -# if 0 <= pwm_frequency <= 180000: -# value1 = 360000/(2*pwm_frequency) -# pwm_d = math.ceil(value1 / 31) -# pwm_f = round(value1 / pwm_d) -# return (pwm_d, pwm_f) -# else: -# raise ValueError("provided frequency is out of range") - - -# def _convert_dutycycle_percentage2raw(value: int) -> int: -# """ -# convert the provided value from percentage to the internal value -# used by EMC2101 (0% -> 0x00, 100% -> 0x3F) -# """ -# # 0x3F = 63 -# if 0 <= value <= 100: -# return round(value * 63 / 100) -# else: -# raise ValueError("Percentage value must be in range 0 ≤ x ≤ 100!") - - -# def _convert_dutycycle_raw2percentage(value: int) -> int: -# """ -# convert the provided value from the internal value to percentage -# used by EMC2101 (0x00 -> 0%, 0x3F -> 100%) -# """ -# # 0x3F = 63 -# if 0 <= value <= 63: -# return round(value * 100 / 63) -# else: -# raise ValueError("Raw value must be in range 0 ≤ x ≤ 63!") diff --git a/tests/test_utilities.py b/tests/test_utilities.py new file mode 100755 index 0000000..65e1a6e --- /dev/null +++ b/tests/test_utilities.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# pylint: disable=missing-class-docstring,missing-function-docstring,missing-module-docstring + +import unittest + +import feeph.emc2101.utilities as sut # sytem under test + + +class TestFanConfigs(unittest.TestCase): + + def test_pwm_calculations(self): + values = { + 45000: (1, 4), + 22500: (1, 8), + 22000: (1, 8), # closest match is 22500 + 6000: (1, 30), # noqa: E131 + 5500: (2, 16), # noqa: E131 + 1000: (6, 30), # noqa: E131 + } + for pwm_frequency, pwm_settings in values.items(): + computed = sut.calculate_pwm_factors(pwm_frequency=pwm_frequency) + expected = pwm_settings + self.assertEqual(computed, expected) + + def test_pwm_factors_invalid(self): + # ----------------------------------------------------------------- + # ----------------------------------------------------------------- + self.assertRaises(ValueError, sut.calculate_pwm_factors, pwm_frequency=-1) From 95b8e989b3b954e6fecb2af87b40d8b48b744fcd Mon Sep 17 00:00:00 2001 From: Feeph Aifeimei <55798703+feeph@users.noreply.github.com> Date: Wed, 14 Aug 2024 21:25:07 +0000 Subject: [PATCH 5/5] fix: fix issues identified by newly added unit tests --- feeph/emc2101/core.py | 24 +------ feeph/emc2101/fan_configs.py | 109 +++++++++++++++++--------------- feeph/emc2101/pwm.py | 27 ++------ feeph/emc2101/scs/base_class.py | 2 - feeph/emc2101/scs/dac.py | 61 +++++++++--------- feeph/emc2101/scs/pwm.py | 41 ++---------- 6 files changed, 101 insertions(+), 163 deletions(-) diff --git a/feeph/emc2101/core.py b/feeph/emc2101/core.py index 6f39f37..77ea3b3 100755 --- a/feeph/emc2101/core.py +++ b/feeph/emc2101/core.py @@ -577,41 +577,19 @@ def configure_external_temperature_sensor(self, dif: int, bcf: int) -> bool: dev_status = bh.read_register(0x02) if not dev_status & 0b0000_0100: LH.debug("The diode fault bit is clear.") - bh.write_register(0x12, dif) + bh.write_register(0x17, dif) bh.write_register(0x18, bcf) return True else: LH.error("The diode fault bit is set: Sensor is faulty or missing.") return False - def _uses_alert_mode(self) -> bool: - return not self._uses_tacho_mode() - def _uses_tacho_mode(self) -> bool: with BurstHandler(i2c_bus=self._i2c_bus, i2c_adr=self._i2c_adr) as bh: status_register = bh.read_register(0x03) return bool(status_register & 0b0000_0100) -# def parse_fanconfig_register(value: int) -> dict[str, Any]: -# # 0b00000000 -# # ^^-- tachometer input mode -# # ^---- clock frequency override -# # ^----- clock select -# # ^------ polarity (0 = 100->0, 1 = 0->100) -# # ^------- configure lookup table (0 = on, 1 = off) -# config = { -# "tachometer input mode": value & 0b0000_0011, -# "clock frequency override": 'use frequency divider' if value & 0b0000_0100 else 'use clock select', -# "clock select base frequency": '1.4kHz' if value & 0b0000_1000 else '360kHz', -# "polarity": '0x00 = 100%, 0xFF = 0%' if value & 0b0001_0000 else '0x00 = 0%, 0xFF = 100%', -# "configure lookup table": 'allow dutycycle update' if value & 0b0010_0000 else 'disable dutycycle update', -# "external temperature setting": 'override external temperature' if value & 0b0100_0000 else 'measure external temperature', -# # the highest bit is unused -# } -# return config - - def _convert_rpm2tach(rpm: int) -> tuple[int, int]: # due to the way the conversion works the RPM can never # be less than 82 diff --git a/feeph/emc2101/fan_configs.py b/feeph/emc2101/fan_configs.py index f33d0b6..485c9ed 100755 --- a/feeph/emc2101/fan_configs.py +++ b/feeph/emc2101/fan_configs.py @@ -1,6 +1,4 @@ #!/usr/bin/env python3 -""" -""" from enum import Enum from typing import TypedDict @@ -21,17 +19,21 @@ class FanConfig: def __init__(self, model: str, rpm_control_mode: RpmControlMode, minimum_duty_cycle: int | None, maximum_duty_cycle: int | None, minimum_rpm: int, maximum_rpm: int, pwm_frequency: int | None = None, steps: Steps | None = None): self.model = model self.rpm_control_mode = rpm_control_mode + self.pwm_frequency = 0 if rpm_control_mode == RpmControlMode.PWM: if pwm_frequency is not None: self.pwm_frequency = pwm_frequency else: - raise ValueError("must provide a PWM frequency for PWM fans") - self.steps = steps + raise ValueError('must provide a PWM frequency for PWM fans') + if steps is None: + self.steps = dict() + else: + self.steps = steps self.minimum_duty_cycle: int | None = None self.maximum_duty_cycle: int | None = None if minimum_duty_cycle is not None and maximum_duty_cycle is not None: if minimum_duty_cycle > maximum_duty_cycle: - raise ValueError("minimum duty cycle must be smaller than maximum duty cycle") + raise ValueError('minimum duty cycle must be smaller than maximum duty cycle') if minimum_duty_cycle >= 0: self.minimum_duty_cycle = minimum_duty_cycle else: @@ -52,74 +54,79 @@ class FanConfigArgs(TypedDict): minimum_rpm: int maximum_rpm: int pwm_frequency: int | None - steps: Steps | None + steps: Steps -def export_fan_config(fan_config: FanConfig) -> dict[str, str | int | Steps | None]: +def export_fan_config(fan_config: FanConfig) -> dict[str, str | int | dict[int, dict[str, int | None]] | None]: + steps: dict[int, dict[str, int | None]] = dict() + for step, (dutycycle, rpm) in fan_config.steps.items(): + steps[step] = { + 'dutycycle': dutycycle, + 'rpm': rpm, + } if fan_config.rpm_control_mode == RpmControlMode.VOLTAGE: return { - "model": fan_config.model, - "control_mode": "VOLTAGE", - "minimum_rpm": fan_config.minimum_rpm, - "maximum_rpm": fan_config.maximum_rpm, - "steps": fan_config.steps, + 'model': fan_config.model, + 'control_mode': 'VOLTAGE', + 'minimum_rpm': fan_config.minimum_rpm, + 'maximum_rpm': fan_config.maximum_rpm, + 'steps': steps, } elif fan_config.rpm_control_mode == RpmControlMode.PWM: - steps: dict[int, dict[str, int | None]] | None = None - if fan_config.steps is not None: - steps = {} - for step, (dutycycle, rpm) in fan_config.steps.items(): - steps[step] = { - "dutycycle": dutycycle, - "rpm": rpm, - } return { - "model": fan_config.model, - "control_mode": "PWM", - "pwm_frequency": fan_config.pwm_frequency, - "minimum_duty_cycle": fan_config.minimum_duty_cycle, - "maximum_duty_cycle": fan_config.maximum_duty_cycle, - "minimum_rpm": fan_config.minimum_rpm, - "maximum_rpm": fan_config.maximum_rpm, - "steps": steps, # type: ignore + 'model': fan_config.model, + 'control_mode': 'PWM', + 'pwm_frequency': fan_config.pwm_frequency, + 'minimum_duty_cycle': fan_config.minimum_duty_cycle, + 'maximum_duty_cycle': fan_config.maximum_duty_cycle, + 'minimum_rpm': fan_config.minimum_rpm, + 'maximum_rpm': fan_config.maximum_rpm, + 'steps': steps, } else: - raise ValueError("unknown control type") + raise ValueError('unknown control type') -def import_fan_config(fan_config: dict[str, str | int | Steps]) -> FanConfig: - if fan_config["control_mode"] == "VOLTAGE": +def import_fan_config(fan_config: dict[str, str | int | dict[int, dict[str, int | None]]]) -> FanConfig: + steps: Steps = dict() + if 'steps' in fan_config and isinstance(fan_config['steps'], dict): + for step, step_record in fan_config['steps'].items(): + if step_record['dutycycle'] is not None: + steps[step] = (step_record['dutycycle'], step_record['rpm']) + else: + raise ValueError("dutycycle can't be empty") + if fan_config['control_mode'] == 'VOLTAGE': params_dac: FanConfigArgs = { - "model": str(fan_config["model"]), - "rpm_control_mode": RpmControlMode.VOLTAGE, - "pwm_frequency": None, - "minimum_duty_cycle": None, - "maximum_duty_cycle": None, - "minimum_rpm": fan_config["minimum_rpm"], # type: ignore - "maximum_rpm": fan_config["maximum_rpm"], # type: ignore - "steps": fan_config["steps"], # type: ignore + 'model': str(fan_config['model']), + 'rpm_control_mode': RpmControlMode.VOLTAGE, + 'pwm_frequency': None, + 'minimum_duty_cycle': None, + 'maximum_duty_cycle': None, + 'minimum_rpm': fan_config['minimum_rpm'], # type: ignore + 'maximum_rpm': fan_config['maximum_rpm'], # type: ignore + 'steps': steps, } return FanConfig(**params_dac) - elif fan_config["control_mode"] == "PWM": + elif fan_config['control_mode'] == 'PWM': params_pwm: FanConfigArgs = { - "model": str(fan_config["model"]), - "rpm_control_mode": RpmControlMode.PWM, - "pwm_frequency": fan_config["pwm_frequency"], # type: ignore - "minimum_duty_cycle": fan_config["minimum_duty_cycle"], # type: ignore - "maximum_duty_cycle": fan_config["maximum_duty_cycle"], # type: ignore - "minimum_rpm": fan_config["minimum_rpm"], # type: ignore - "maximum_rpm": fan_config["maximum_rpm"], # type: ignore - "steps": fan_config["steps"], # type: ignore + 'model': str(fan_config['model']), + 'rpm_control_mode': RpmControlMode.PWM, + 'pwm_frequency': fan_config['pwm_frequency'], # type: ignore + 'minimum_duty_cycle': fan_config['minimum_duty_cycle'], # type: ignore + 'maximum_duty_cycle': fan_config['maximum_duty_cycle'], # type: ignore + 'minimum_rpm': fan_config['minimum_rpm'], # type: ignore + 'maximum_rpm': fan_config['maximum_rpm'], # type: ignore + 'steps': steps, } return FanConfig(**params_pwm) else: - raise ValueError("unknown control type") + raise ValueError('unknown control type') # provide reasonable default configurations for DC and PWM fans # probably a bad idea to provide less than 50% supply voltage (fan might fail to start properly) -generic_dc_fan = FanConfig(model="generic DC fan", rpm_control_mode=RpmControlMode.VOLTAGE, minimum_duty_cycle=50, maximum_duty_cycle=100, minimum_rpm=100, maximum_rpm=2000) +generic_dc_fan = FanConfig(model='generic DC fan', rpm_control_mode=RpmControlMode.VOLTAGE, minimum_duty_cycle=50, maximum_duty_cycle=100, minimum_rpm=100, maximum_rpm=2000) # some fans treat a duty cycle of less than 20% as 'no signal' and go full speed instead -generic_pwm_fan = FanConfig(model="generic PWM fan", rpm_control_mode=RpmControlMode.PWM, minimum_duty_cycle=20, maximum_duty_cycle=100, minimum_rpm=100, maximum_rpm=2000, pwm_frequency=22500) +generic_pwm_fan = FanConfig(model='generic PWM fan', rpm_control_mode=RpmControlMode.PWM, minimum_duty_cycle=20, maximum_duty_cycle=100, minimum_rpm=100, maximum_rpm=2000, pwm_frequency=22500) diff --git a/feeph/emc2101/pwm.py b/feeph/emc2101/pwm.py index 6e1f4de..03de623 100755 --- a/feeph/emc2101/pwm.py +++ b/feeph/emc2101/pwm.py @@ -1,13 +1,10 @@ #!/usr/bin/env python3 -""" -""" # a reimplementation of https://github.com/adafruit/Adafruit_CircuitPython_EMC2101 # Datasheet: https://ww1.microchip.com/downloads/en/DeviceDoc/2101.pdf import logging from enum import Enum -from typing import Any # module busio provides no type hints import busio # type: ignore @@ -152,7 +149,7 @@ def configure_spinup_behaviour(self, spinup_strength: SpinUpStrength, spinup_dur LH.warning("Pin 6 is in alert mode. Can't configure spinup behavior.") return False else: - raise NotImplementedError("unsupported pin 6 mode") + raise RuntimeError('internal error - inconsistent state') def get_rpm(self) -> int | None: return self._emc2101.get_rpm() @@ -219,6 +216,7 @@ def set_fixed_speed(self, value: int, unit: FanSpeedUnit = FanSpeedUnit.PERCENT, def is_lookup_table_enabled(self) -> bool: return self._emc2101.is_lookup_table_enabled() + # TODO unit should be checked before entering the loop def update_lookup_table(self, values: dict[int, int], unit: FanSpeedUnit = FanSpeedUnit.PERCENT) -> bool: """ populate the lookup table with the provided values and @@ -296,6 +294,8 @@ def get_sensor_temperature(self) -> float: """ return self._emc2101.get_sensor_temperature() + # TODO redesign the sensor temperature limit related functions. The interface is weird. + def get_sensor_temperature_limit(self, limit_type: TemperatureLimitType) -> float: """ get upper/lower temperature alerting limit in °C @@ -375,22 +375,3 @@ def configure_external_temperature_sensor(self, ets_config: ExternalTemperatureS dif = ets_config.diode_ideality_factor bcf = ets_config.beta_compensation_factor self._emc2101.configure_external_temperature_sensor(dif=dif, bcf=bcf) - - -def parse_fanconfig_register(value: int) -> dict[str, Any]: - # 0b00000000 - # ^^-- tachometer input mode - # ^---- clock frequency override - # ^----- clock select - # ^------ polarity (0 = 100->0, 1 = 0->100) - # ^------- configure lookup table (0 = on, 1 = off) - config = { - "tachometer input mode": value & 0b0000_0011, - "clock frequency override": 'use frequency divider' if value & 0b0000_0100 else 'use clock select', - "clock select base frequency": '1.4kHz' if value & 0b0000_1000 else '360kHz', - "polarity": '0x00 = 100%, 0xFF = 0%' if value & 0b0001_0000 else '0x00 = 0%, 0xFF = 100%', - "configure lookup table": 'allow dutycycle update' if value & 0b0010_0000 else 'disable dutycycle update', - "external temperature setting": 'override external temperature' if value & 0b0100_0000 else 'measure external temperature', - # the highest bit is unused - } - return config diff --git a/feeph/emc2101/scs/base_class.py b/feeph/emc2101/scs/base_class.py index 9204e11..bdb04fe 100755 --- a/feeph/emc2101/scs/base_class.py +++ b/feeph/emc2101/scs/base_class.py @@ -1,6 +1,4 @@ #!/usr/bin/env python3 -""" -""" from abc import ABC, abstractmethod diff --git a/feeph/emc2101/scs/dac.py b/feeph/emc2101/scs/dac.py index e955166..9e2a26c 100755 --- a/feeph/emc2101/scs/dac.py +++ b/feeph/emc2101/scs/dac.py @@ -1,6 +1,4 @@ #!/usr/bin/env python3 -""" -""" from feeph.emc2101.scs.base_class import SpeedControlSetter @@ -14,24 +12,24 @@ def __init__(self, minimum_duty_cycle: int, maximum_duty_cycle: int): # TODO derive mapping from fan config self._steps = { # fmt: off - # RPM % - 3: ( 409, 34), # noqa: 201 - 4: ( 479, 40), # noqa: 201 - 5: ( 526, 44), # noqa: 201 - 6: ( 591, 49), # noqa: 201 - 7: ( 629, 52), # noqa: 201 - 8: ( 697, 58), # noqa: 201 - 9: ( 785, 65), # noqa: 201 - 10: ( 868, 72), # noqa: 201 - 11: ( 950, 79), # noqa: 201 - 12: (1040, 87), - 13: (1113, 93), - 14: (1194, 100), + # % RPM + 3: ( 34, 409), # noqa: 201 + 4: ( 40, 479), # noqa: 201 + 5: ( 44, 526), # noqa: 201 + 6: ( 49, 591), # noqa: 201 + 7: ( 52, 629), # noqa: 201 + 8: ( 58, 697), # noqa: 201 + 9: ( 65, 785), # noqa: 201 + 10: ( 72, 868), # noqa: 201 + 11: ( 79, 950), # noqa: 201 + 12: ( 87, 1040), # noqa: 201 + 13: ( 93, 1113), # noqa: 201 + 14: (100, 1194), # fmt: on } def is_valid_step(self, value: int) -> bool: - return value in self._steps.keys() + return value in self._steps def get_steps(self) -> list[int]: """ @@ -45,7 +43,7 @@ def convert_percent2step(self, percent: int) -> int | None: """ step_cur = None deviation_cur = None - for step_new, (_, percent_step) in self._steps.items(): + for step_new, (percent_step, _) in self._steps.items(): if percent_step == 0: percent_step = 1 deviation_new = abs(1 - percent / percent_step) @@ -55,7 +53,7 @@ def convert_percent2step(self, percent: int) -> int | None: return step_cur def convert_step2percent(self, step: int) -> int: - return self._steps[step][1] + return self._steps[step][0] def convert_rpm2step(self, rpm: int) -> int | None: """ @@ -63,7 +61,7 @@ def convert_rpm2step(self, rpm: int) -> int | None: """ step_cur = None deviation_cur = None - for step_new, (rpm_step, _) in self._steps.items(): + for step_new, (_, rpm_step) in self._steps.items(): if rpm_step == 0: rpm_step = 1 deviation_new = abs(1 - rpm / rpm_step) @@ -73,7 +71,7 @@ def convert_rpm2step(self, rpm: int) -> int | None: return step_cur def convert_step2rpm(self, step: int) -> int | None: - return self._steps[step][0] + return self._steps[step][1] def _convert_percentage2step(value: int) -> int: @@ -88,13 +86,16 @@ def _convert_percentage2step(value: int) -> int: raise ValueError("Percentage value must be in range 0 ≤ x ≤ 100!") -def _convert_step2percentage(value: int) -> int: - """ - convert the provided value from the internal value to percentage - used by EMC2101 (0x00 -> 0%, 0x3F -> 100%) - """ - # 0x3F = 63 - if 0 <= value <= 63: - return round(value * 100 / 63) - else: - raise ValueError("Raw value must be in range 0 ≤ x ≤ 63!") +# TODO decide what to do with this block +# ------------------------------------------------------------------------- +# def _convert_step2percentage(value: int) -> int: +# """ +# convert the provided value from the internal value to percentage +# used by EMC2101 (0x00 -> 0%, 0x3F -> 100%) +# """ +# # 0x3F = 63 +# if 0 <= value <= 63: +# return round(value * 100 / 63) +# else: +# raise ValueError("Raw value must be in range 0 ≤ x ≤ 63!") +# ------------------------------------------------------------------------- diff --git a/feeph/emc2101/scs/pwm.py b/feeph/emc2101/scs/pwm.py index 6c519aa..9f82d1d 100755 --- a/feeph/emc2101/scs/pwm.py +++ b/feeph/emc2101/scs/pwm.py @@ -3,8 +3,8 @@ """ import logging -import math +import feeph.emc2101.utilities from feeph.emc2101.fan_configs import FanConfig from feeph.emc2101.scs.base_class import SpeedControlSetter @@ -28,18 +28,11 @@ def __init__(self, fan_config: FanConfig): else: raise ValueError("PWM fans must configure minmum and maximum duty cycle") # calculate and configure PWM_D and PWM_F settings - (pwm_d, pwm_f) = calculate_pwm_factors(pwm_frequency=fan_config.pwm_frequency) + (pwm_d, pwm_f) = feeph.emc2101.utilities.calculate_pwm_factors(pwm_frequency=fan_config.pwm_frequency) LH.debug("PWM frequency: %dHz -> PWM_D: %i PWM_F: %i", fan_config.pwm_frequency, pwm_d, pwm_f) self._pwm_d = pwm_d self._pwm_f = pwm_f - if fan_config.steps is not None: - self._steps = fan_config.steps - else: - self._steps = {} - max_step = (pwm_f * 2) - 1 - for step in range(pwm_f * 2): - dutycycle = int(step * 100 / max_step) - self._steps[step] = (dutycycle, None) + self._steps = fan_config.steps def is_valid_step(self, value: int) -> bool: return value in self._steps.keys() @@ -62,8 +55,7 @@ def convert_percent2step(self, percent: int) -> int | None: """ step_cur = None deviation_cur = None - for step_new, record in self._steps.items(): - percent_step = record[0] + for step_new, (percent_step, _) in self._steps.items(): if percent_step == 0: percent_step = 1 deviation_new = abs(1 - percent / percent_step) @@ -81,8 +73,7 @@ def convert_rpm2step(self, rpm: int) -> int | None: """ step_cur = None deviation_cur = None - for step_new, record in self._steps.items(): - rpm_step = record[1] + for step_new, (_, rpm_step) in self._steps.items(): if rpm_step is not None: if rpm_step == 0: rpm_step = 1 @@ -104,31 +95,13 @@ def calculate_pwm_frequency(pwm_d: int, pwm_f: int) -> float: return pwm_frequency -def calculate_pwm_factors(pwm_frequency: int) -> tuple[int, int]: - """ - calculate PWM_D and PWM_F for provided frequency - - this function minimizes PWM_D to allow for maximum resolution (PWM_F) - - PWM_F maxes out at 31 (0x1F) - """ - if 0 <= pwm_frequency <= 180000: - value1 = 360000 / (2 * pwm_frequency) - pwm_d = math.ceil(value1 / 31) - pwm_f = round(value1 / pwm_d) - return (pwm_d, pwm_f) - else: - raise ValueError("provided frequency is out of range") - - def _convert_dutycycle_percentage2raw(value: int) -> int: """ convert the provided value from percentage to the internal value used by EMC2101 (0% -> 0x00, 100% -> 0x3F) """ - # 0x3F = 63 - if 0 <= value <= 100: - return round(value * 63 / 100) - else: - raise ValueError("Percentage value must be in range 0 ≤ x ≤ 100!") + # value range already verified by FanConfig + return round(value * 63 / 100) # 0x3F = 63 # TODO decide what to do with this block