"""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
[docs]
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)
[docs]
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)
[docs]
@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")
[docs]
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
[docs]
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}