herosdevices.hardware.raspberrypi.max318xx.max31865 - Heros Devices 0.11.1 documentation

Skip to content
Back to top

# Source code for herosdevices.hardware.raspberrypi.max318xx.max31865

    """MAX31865 Resistance to Digital Converter.

    This module implements an interface for the MAX31865 resistance temperature detector (RTD) converter.
    The chip communicates over SPI. This implementation uses GPIO bit-banging on a Raspberry Pi.
    Using spidev would be more efficient, but initial attempts with spidev were unsuccessful.

    This code is based on the work of steve71: https://github.com/steve71/MAX31865

    Classes:
    - MAX31865: Provides methods to read resistance and temperature from PT100/PT1000 sensors.
      - Initialization allows configuration of SPI pins, reference resistance, and wiring type.
      - Methods to fetch resistance, convert resistance to temperature, and handle errors.

    Usage:
    - Instantiate multiple sensor objects for different chip select pins.
    - Loop to read and print temperature readings.
    - Gracefully handle interruption and clean GPIO resources.

    Notes:
    - Proper wiring and pull-down resistors are essential.
    - Optimized for use with a Raspberry Pi and RPi.GPIO.
    """

    # system packages
    import math
    import time
    from collections.abc import Callable

    import numpy as np

    from herosdevices.helper import log, mark_driver

    from .max318xx import FaultError, MAX318xx

    # IEC 751 coefficients for temperature calculations
    A = 3.908e-3
    B = -5.775e-7
    C = -4.183e-12

    def c_v_d(r: float, r_0: float) -> float:
        """Calculate temperature using Callendar-Van Dusen equation.

        This function converts resistance to temperature based on the Calendar-Van Dusen
        equation, which relates resistance in Ohms to temperature in Celsius. It uses numpy
        for polynomial root solving.

        Args:
            r (float): Resistance in Ohms.
            r_0 (float): Resistance at 0°C in Ohms.

        Returns:
            float: Estimated temperature in degrees Celsius.

        Raises:
            ImportError: If numpy is not installed.
            ValueError: If the polynomial root calculation encounters issues (e.g., negative discriminant).
        """
        coefficients = np.array([r_0 - r, r_0 * A, r_0 * B, -100.0 * r_0 * C, r_0 * C])
        roots = np.roots(coefficients[::-1])
        theta = roots[-1]
        return abs(theta)

    def c_v_d_quad(r: float, r_0: float) -> float:
        """Approximate temperature using a quadratic form of the Callendar-Van Dusen equation.

        This method offers a fast and reasonably accurate way to estimate temperature
        from resistance, suitable for class A PT100 sensors with about 0.2°C accuracy.

        Args:
            r (float): Resistance in Ohms.
            r_0 (float): Resistance at 0°C in Ohms.

        Returns:
            float: Estimated temperature in degrees Celsius.

        Raises:
            ValueError: If the discriminant in the quadratic formula is negative.
        """
        discriminant = (r_0 * A) ** 2 - 4.0 * r_0 * B * (r_0 - r)
        if discriminant < 0:
            raise ValueError("Negative discriminant in quadratic solution.")
        return (-r_0 * A + math.sqrt(discriminant)) / (2 * r_0 * B)

    @mark_driver(
        info="Resistance-to-digital converter for platinum RTDs",
        product_page="https://www.analog.com/en/products/max31865.html",
        state="alpha",
        requires={"RPi.GPIO": "RPi.GPIO"},
    )
    class MAX31865(MAX318xx):
        """
        Interface class for MAX31865 resistance temperature sensor.

        Args:
            cs_pin (int): Chip select GPIO pin (default: 8).
            miso_pin (int): MISO GPIO pin (default: 9).
            mosi_pin (int): MOSI GPIO pin (default: 10).
            clk_pin (int): Clock GPIO pin (default: 11).
            r_0 (float): Resistance at 0°C, in Ohms (default: 1000 for PT1000).
            r_ref (float): Reference resistor resistance, in Ohms (default: 400).
            three_wire (bool): Use 3-wire configuration (default: False).
            log (logging.Logger): Optional logger for debug messages.
        """

        def __init__(
            self,
            cs_pin: int = 8,
            miso_pin: int = 9,
            mosi_pin: int = 10,
            clk_pin: int = 11,
            r_0: float = 1e3,
            r_ref: float = 4e2,
            three_wire: bool = False,
        ) -> None:
            """
            Initialize MAX31865 sensor interface.

            Args:
                cs_pin (int): Chip select GPIO pin.
                miso_pin (int): MISO GPIO pin.
                mosi_pin (int): MOSI GPIO pin.
                clk_pin (int): Clock GPIO pin.
                r_0 (float): Resistance at 0°C, default 1000 Ohms.
                r_ref (float): Reference resistor value.
                three_wire (bool): True for 3-wire configuration.
                log (logging.Logger): Optional logger.
            """
            # TODO: this can not work, it takes a dict for cs_pin, not an int
            super().__init__(cs_pins=cs_pin, miso_pin=miso_pin, mosi_pin=mosi_pin, clk_pin=clk_pin)
            self.r_ref = r_ref
            self.r_0 = r_0
            log.debug(f"PT resistance: {self.r_0} Ohm, Reference: {self.r_ref} Ohm")
            # Configuration byte: One-shot or continuous settings, wire type
            self.config = 0b10100010 if not three_wire else 0b10110010
            log.debug(f"Three-wire setup: {three_wire}")

        def _raise_for_fault(self, out: list) -> None:
            """Raise fault exceptions based on status register bits.

            10 Mohm resistor is on breakout board to help
            detect cable faults
            bit 7: RTD High Threshold / cable fault open
            bit 6: RTD Low Threshold / cable fault short
            bit 5: REFIN- > 0.85 x VBias -> must be requested
            bit 4: REFIN- < 0.85 x VBias (FORCE- open) -> must be requested
            bit 3: RTDIN- < 0.85 x VBias (FORCE- open) -> must be requested
            bit 2: Overvoltage / undervoltage fault
            bits 1,0 don't care

            Args:
                out (list): Data read from sensor registers.

            Raises:
                FaultError: When faults such as open circuit or short circuit are detected.
            """
            [hft_msb, hft_lsb] = [out[3], out[4]]
            hft = ((hft_msb << 8) | hft_lsb) >> 1
            log.spam(f"High fault threshold: {hft:d}")
            [lft_msb, lft_lsb] = [out[5], out[6]]
            lft = ((lft_msb << 8) | lft_lsb) >> 1
            log.spam(f"Low fault threshold: {lft:d}")
            status = out[7]
            log.spam(f"Status byte: {status:x}")
            if status & 0x80:
                raise FaultError("High threshold limit reached (possible cable fault/open)")
            if status & 0x40:
                raise FaultError("Low threshold limit reached (possible short circuit)")
            if status & 0x04:
                raise FaultError("Overvoltage or undervoltage detected")

        def read_resistance(self, cs_pin: int) -> float:
            """Initiate resistance measurement and return resistance value.

            Returns:
                float: Resistance in Ohms.
            """
            self.write_register(0, self.config)
            time.sleep(0.1)
            out = self.read_register(cs_pin, 8, 0)
            conf_reg = out[0]
            log.spam(f"Configuration register: {conf_reg:x}")
            [rtd_msb, rtd_lsb] = [out[1], out[2]]
            rtd_adc_code = ((rtd_msb << 8) | rtd_lsb) >> 1
            resistance = (rtd_adc_code * self.r_ref) / 2**15
            log.debug(f"Resistance: {resistance:.3f} Ohm")
            self._raise_for_fault(out)
            return resistance

        def read_temp(self, convert: Callable[[float, float], float] = c_v_d_quad) -> dict:
            """Read resistance and convert to temperature.

            Args:
                convert (function): Function to convert resistance to temperature.
                    Defaults to c_v_d_quad.

            Returns:
                float: Temperature in Celsius.
            """
            return self._observable_data(convert)["temperature"]  # TODO: This calls the event. Is this what we want here?

        def _observable_data(self, convert: Callable[[float, float], float] = c_v_d_quad) -> dict:
            """Fetch fresh resistance and temperature data.

            Args:
                convert (function): Conversion function from resistance to temperature.

            Returns:
                dict: Dictionary with resistance and temperature.
            """
            log.spam(f"Using conversion method: {convert!r}")
            resistance_values = [self.read_resistance(cs_pin) for cs_pin in self.cs_pins]
            temperature_values = [convert(val, self.r_0) for val in resistance_values]
            res_dict = self.create_dict(resistance_values, "resistance", "Ohm")
            temp_dict = self.create_dict(temperature_values, "temperature", "degC")
            return {**res_dict, **temp_dict}