hitl_tester.modules.bms.thermal_chamber_modbus

This API communicates with a Watlow F4T controller through TCP, allowing the HITL to control the thermal chamber that's attached to the controller. For register information, see "F4T Modbus Registers (Map 1)" pg 227 in "F4T Setup Operation 16802414 Rev A".

(c) 2020-2024 TurnAround Factor, Inc.

#

CUI DISTRIBUTION CONTROL

Controlled by: DLA J68 R&D SBIP

CUI Category: Small Business Research and Technology

Distribution/Dissemination Controls: PROTECTED BY SBIR DATA RIGHTS

POC: GOV SBIP Program Manager Denise Price, 571-767-0111

Distribution authorized to U.S. Government Agencies only, to protect information not owned by the

U.S. Government and protected by a contractor’s ‘limited rights’ statement, or received with the understanding that

it is not routinely transmitted outside the U.S. Government (determination made September 14, 2024). Other requests

for this document shall be referred to ACOR DLA Logistics Operations (J-68), 8725 John J. Kingman Rd., Suite 4317,

Fort Belvoir, VA 22060-6221

#

SBIR DATA RIGHTS

Contract No.:SP4701-23-C-0083

Contractor Name: TurnAround Factor, Inc.

Contractor Address: 10365 Wood Park Ct. Suite 313 / Ashland, VA 23005

Expiration of SBIR Data Rights Period: September 24, 2029

The Government's rights to use, modify, reproduce, release, perform, display, or disclose technical data or computer

software marked with this legend are restricted during the period shown as provided in paragraph (b)(4) of the Rights

in Noncommercial Technical Data and Computer Software--Small Business Innovative Research (SBIR) Program clause

contained in the above identified contract. No restrictions apply after the expiration date shown above. Any

reproduction of technical data, computer software, or portions thereof marked with this legend must also reproduce

the markings.

  1"""
  2This API communicates with a Watlow F4T controller through TCP, allowing the HITL to control the thermal chamber
  3that's attached to the controller.
  4For register information, see "F4T Modbus Registers (Map 1)" pg 227 in "F4T Setup Operation 16802414 Rev A".
  5
  6# (c) 2020-2024 TurnAround Factor, Inc.
  7#
  8# CUI DISTRIBUTION CONTROL
  9# Controlled by: DLA J68 R&D SBIP
 10# CUI Category: Small Business Research and Technology
 11# Distribution/Dissemination Controls: PROTECTED BY SBIR DATA RIGHTS
 12# POC: GOV SBIP Program Manager Denise Price, 571-767-0111
 13# Distribution authorized to U.S. Government Agencies only, to protect information not owned by the
 14# U.S. Government and protected by a contractor’s ‘limited rights’ statement, or received with the understanding that
 15# it is not routinely transmitted outside the U.S. Government (determination made September 14, 2024). Other requests
 16# for this document shall be referred to ACOR DLA Logistics Operations (J-68), 8725 John J. Kingman Rd., Suite 4317,
 17# Fort Belvoir, VA 22060-6221
 18#
 19# SBIR DATA RIGHTS
 20# Contract No.:SP4701-23-C-0083
 21# Contractor Name: TurnAround Factor, Inc.
 22# Contractor Address: 10365 Wood Park Ct. Suite 313 / Ashland, VA 23005
 23# Expiration of SBIR Data Rights Period: September 24, 2029
 24# The Government's rights to use, modify, reproduce, release, perform, display, or disclose technical data or computer
 25# software marked with this legend are restricted during the period shown as provided in paragraph (b)(4) of the Rights
 26# in Noncommercial Technical Data and Computer Software--Small Business Innovative Research (SBIR) Program clause
 27# contained in the above identified contract. No restrictions apply after the expiration date shown above. Any
 28# reproduction of technical data, computer software, or portions thereof marked with this legend must also reproduce
 29# the markings.
 30"""
 31
 32from __future__ import annotations
 33
 34import atexit
 35import time
 36from datetime import datetime
 37from typing import cast
 38
 39import pytest
 40from chamberconnectlibrary.watlowf4t import WatlowF4T
 41
 42from hitl_tester.modules.bms_types import TimeoutExceededError, BMSFlags
 43from hitl_tester.modules.logger import logger
 44
 45
 46class ThermalChamber:
 47    """Watlow F4T controller command wrapper."""
 48
 49    def __init__(self):
 50        """Initialize the Watlow F4T controller."""
 51        self._controller = None
 52        if (
 53            hasattr(pytest, "flags")
 54            and isinstance(pytest.flags, BMSFlags)
 55            and "thermal_chamber_address" in pytest.flags.config
 56        ):
 57            logger.write_info_to_report("Connecting to thermal chamber...")
 58            self._controller = WatlowF4T(
 59                alarms=8,  # the number of available alarms
 60                profiles=True,  # the controller has programming
 61                loops=1,  # the number of control loops (ie temperature)
 62                cond_event=9,  # the event that enables/disables conditioning
 63                cond_event_toggle=False,  # is the condition momentary(False), or maintained(True)
 64                run_module=None,  # The io module that has the chamber run output
 65                run_io=None,  # The run output on the module that has the chamber run out put
 66                limits=[],  # A list of modules that contain limit type cards.
 67                interface="TCP",
 68                host=pytest.flags.config["thermal_chamber_address"],
 69            )
 70            self._controller.connect()
 71            self.internal_units = "C"
 72            self.display_units = "C"
 73
 74        @atexit.register
 75        def __atexit__():
 76            """Configure a safe shut down for when the class instance is destroyed."""
 77            logger.write_info_to_report("Stopping thermal chamber loop")
 78            # self.stop_loop()  # FIXME(JA): test shutting down the chamber
 79
 80    @property
 81    def controller(self) -> WatlowF4T:
 82        """Check if the controller was set up."""
 83        if self._controller:
 84            return self._controller
 85        raise RuntimeError(
 86            "Attempted to access thermal chamber, but ip was never provided. "
 87            "Did you set the property in the HITL hardware config?"
 88        )
 89
 90    @property
 91    def air_temperature(self) -> float:
 92        """Get process value
 93        :returns: the current chamber temperature
 94        """
 95        return float(self.controller.get_loop_pv(1)["air"])  # Working Process Value, reg 2820 [CHAMBER TEMPERATURE]
 96
 97    @property
 98    def set_point_temperature(self) -> float:
 99        """Get set point
100        :returns: the set point temperature
101        """
102        return float(
103            self.controller.get_loop_sp(1)["constant"]
104        )  # Set point/Closed-Loop Set Point, reg 2782/2810, default 75.0°F
105
106    @set_point_temperature.setter
107    def set_point_temperature(self, new_temp: float):
108        """Apply new set point
109        :param new_temp: the new set point
110        """
111        self.controller.set_loop_sp(1, new_temp)  # Set point, reg 2782, default 75.0°F
112
113    def set_room_temperature(self):
114        """Set the target temperature to room temperature"""
115        old_units = self.internal_units
116        self.internal_units = "C"
117        self.set_point_temperature = 23.0  # Room temperature
118        self.internal_units = old_units
119
120    def block_until_set_point_reached(self, timeout: float | None = None, period: float = 1, buffer: float = 1):
121        """Wait until the thermal chamber has reached its set point
122        :param timeout: raise TimeoutError after a certain amount of seconds has passed
123        :param period: how often the temperature is checked in seconds
124        :param buffer: Since measured temperature may not be precise, we actually wait until set_point +- buffer
125        """
126        logger.write_debug_to_report(f"Blocking until {self.set_point_temperature} {self.internal_units}")
127        start_time = time.perf_counter()
128        while not self.set_point_temperature - buffer <= self.air_temperature <= self.set_point_temperature + buffer:
129            if timeout is not None and time.perf_counter() - start_time >= timeout:
130                raise TimeoutExceededError(f"Temperature was not reached after {timeout} seconds")
131            logger.write_debug_to_report(
132                f"Current temp: {self.air_temperature} - Target temp: {self.set_point_temperature} +- {buffer}"
133            )
134            time.sleep(period)
135
136    @property
137    def status(self) -> str:
138        """Read controller status
139        :returns: the chamber status
140        """
141        return str(self.controller.get_status())
142
143    def start_loop(self):
144        """Start loop 1 on chamber"""
145        current_status = self.controller.get_status()
146        if not any(status_type in current_status for status_type in ("Program Running", "Program Paused", "Constant")):
147            self.controller.const_start()
148
149    def stop_loop(self):
150        """Stop loop 1 on chamber"""
151        self.controller.stop()
152
153    @property
154    def date_and_time(self) -> datetime:
155        """Read current date on controller"""
156        return cast(datetime, self.controller.get_datetime())
157
158    @date_and_time.setter
159    def date_and_time(self, new_date: datetime):
160        """Write new date to controller
161        :param new_date: the date to set the controller to
162        """
163        self.controller.set_datetime(new_date)
164
165    @property
166    def internal_units(self) -> str:
167        """Read internal temperature units
168        :returns: 'C' for Celsius, 'F' for Fahrenheit
169        """
170        eth_rtu_temp_val = self.controller.client.read_holding(
171            14080 if self.controller.interface == "RTU" else 6730, 1
172        )[0]
173        return str(self.controller.watlow_val_dict[eth_rtu_temp_val])
174
175    @internal_units.setter
176    def internal_units(self, new_units: str):
177        """Write internal temperature units
178        :param new_units: 'C' for Celsius, 'F' for Fahrenheit
179        """
180        unit_value = {"C": 15, "F": 30}.get(new_units.upper())
181        self.controller.client.write_holding(14080 if self.controller.interface == "RTU" else 6730, unit_value)
182
183    @property
184    def display_units(self) -> str:
185        """Read display temperature units
186        :returns: 'C' for Celsius, 'F' for Fahrenheit
187        """
188        display_temp_val = self.controller.client.read_holding(1328, 1)[0]
189        return str(self.controller.watlow_val_dict[display_temp_val])
190
191    @display_units.setter
192    def display_units(self, new_units: str):
193        """Write display temperature units
194        :param new_units: 'C' for Celsius, 'F' for Fahrenheit
195        """
196        unit_value = {"C": 15, "F": 30}.get(new_units.upper())
197        self.controller.client.write_holding(1328, unit_value)
class ThermalChamber:
 47class ThermalChamber:
 48    """Watlow F4T controller command wrapper."""
 49
 50    def __init__(self):
 51        """Initialize the Watlow F4T controller."""
 52        self._controller = None
 53        if (
 54            hasattr(pytest, "flags")
 55            and isinstance(pytest.flags, BMSFlags)
 56            and "thermal_chamber_address" in pytest.flags.config
 57        ):
 58            logger.write_info_to_report("Connecting to thermal chamber...")
 59            self._controller = WatlowF4T(
 60                alarms=8,  # the number of available alarms
 61                profiles=True,  # the controller has programming
 62                loops=1,  # the number of control loops (ie temperature)
 63                cond_event=9,  # the event that enables/disables conditioning
 64                cond_event_toggle=False,  # is the condition momentary(False), or maintained(True)
 65                run_module=None,  # The io module that has the chamber run output
 66                run_io=None,  # The run output on the module that has the chamber run out put
 67                limits=[],  # A list of modules that contain limit type cards.
 68                interface="TCP",
 69                host=pytest.flags.config["thermal_chamber_address"],
 70            )
 71            self._controller.connect()
 72            self.internal_units = "C"
 73            self.display_units = "C"
 74
 75        @atexit.register
 76        def __atexit__():
 77            """Configure a safe shut down for when the class instance is destroyed."""
 78            logger.write_info_to_report("Stopping thermal chamber loop")
 79            # self.stop_loop()  # FIXME(JA): test shutting down the chamber
 80
 81    @property
 82    def controller(self) -> WatlowF4T:
 83        """Check if the controller was set up."""
 84        if self._controller:
 85            return self._controller
 86        raise RuntimeError(
 87            "Attempted to access thermal chamber, but ip was never provided. "
 88            "Did you set the property in the HITL hardware config?"
 89        )
 90
 91    @property
 92    def air_temperature(self) -> float:
 93        """Get process value
 94        :returns: the current chamber temperature
 95        """
 96        return float(self.controller.get_loop_pv(1)["air"])  # Working Process Value, reg 2820 [CHAMBER TEMPERATURE]
 97
 98    @property
 99    def set_point_temperature(self) -> float:
100        """Get set point
101        :returns: the set point temperature
102        """
103        return float(
104            self.controller.get_loop_sp(1)["constant"]
105        )  # Set point/Closed-Loop Set Point, reg 2782/2810, default 75.0°F
106
107    @set_point_temperature.setter
108    def set_point_temperature(self, new_temp: float):
109        """Apply new set point
110        :param new_temp: the new set point
111        """
112        self.controller.set_loop_sp(1, new_temp)  # Set point, reg 2782, default 75.0°F
113
114    def set_room_temperature(self):
115        """Set the target temperature to room temperature"""
116        old_units = self.internal_units
117        self.internal_units = "C"
118        self.set_point_temperature = 23.0  # Room temperature
119        self.internal_units = old_units
120
121    def block_until_set_point_reached(self, timeout: float | None = None, period: float = 1, buffer: float = 1):
122        """Wait until the thermal chamber has reached its set point
123        :param timeout: raise TimeoutError after a certain amount of seconds has passed
124        :param period: how often the temperature is checked in seconds
125        :param buffer: Since measured temperature may not be precise, we actually wait until set_point +- buffer
126        """
127        logger.write_debug_to_report(f"Blocking until {self.set_point_temperature} {self.internal_units}")
128        start_time = time.perf_counter()
129        while not self.set_point_temperature - buffer <= self.air_temperature <= self.set_point_temperature + buffer:
130            if timeout is not None and time.perf_counter() - start_time >= timeout:
131                raise TimeoutExceededError(f"Temperature was not reached after {timeout} seconds")
132            logger.write_debug_to_report(
133                f"Current temp: {self.air_temperature} - Target temp: {self.set_point_temperature} +- {buffer}"
134            )
135            time.sleep(period)
136
137    @property
138    def status(self) -> str:
139        """Read controller status
140        :returns: the chamber status
141        """
142        return str(self.controller.get_status())
143
144    def start_loop(self):
145        """Start loop 1 on chamber"""
146        current_status = self.controller.get_status()
147        if not any(status_type in current_status for status_type in ("Program Running", "Program Paused", "Constant")):
148            self.controller.const_start()
149
150    def stop_loop(self):
151        """Stop loop 1 on chamber"""
152        self.controller.stop()
153
154    @property
155    def date_and_time(self) -> datetime:
156        """Read current date on controller"""
157        return cast(datetime, self.controller.get_datetime())
158
159    @date_and_time.setter
160    def date_and_time(self, new_date: datetime):
161        """Write new date to controller
162        :param new_date: the date to set the controller to
163        """
164        self.controller.set_datetime(new_date)
165
166    @property
167    def internal_units(self) -> str:
168        """Read internal temperature units
169        :returns: 'C' for Celsius, 'F' for Fahrenheit
170        """
171        eth_rtu_temp_val = self.controller.client.read_holding(
172            14080 if self.controller.interface == "RTU" else 6730, 1
173        )[0]
174        return str(self.controller.watlow_val_dict[eth_rtu_temp_val])
175
176    @internal_units.setter
177    def internal_units(self, new_units: str):
178        """Write internal temperature units
179        :param new_units: 'C' for Celsius, 'F' for Fahrenheit
180        """
181        unit_value = {"C": 15, "F": 30}.get(new_units.upper())
182        self.controller.client.write_holding(14080 if self.controller.interface == "RTU" else 6730, unit_value)
183
184    @property
185    def display_units(self) -> str:
186        """Read display temperature units
187        :returns: 'C' for Celsius, 'F' for Fahrenheit
188        """
189        display_temp_val = self.controller.client.read_holding(1328, 1)[0]
190        return str(self.controller.watlow_val_dict[display_temp_val])
191
192    @display_units.setter
193    def display_units(self, new_units: str):
194        """Write display temperature units
195        :param new_units: 'C' for Celsius, 'F' for Fahrenheit
196        """
197        unit_value = {"C": 15, "F": 30}.get(new_units.upper())
198        self.controller.client.write_holding(1328, unit_value)

Watlow F4T controller command wrapper.

ThermalChamber()
50    def __init__(self):
51        """Initialize the Watlow F4T controller."""
52        self._controller = None
53        if (
54            hasattr(pytest, "flags")
55            and isinstance(pytest.flags, BMSFlags)
56            and "thermal_chamber_address" in pytest.flags.config
57        ):
58            logger.write_info_to_report("Connecting to thermal chamber...")
59            self._controller = WatlowF4T(
60                alarms=8,  # the number of available alarms
61                profiles=True,  # the controller has programming
62                loops=1,  # the number of control loops (ie temperature)
63                cond_event=9,  # the event that enables/disables conditioning
64                cond_event_toggle=False,  # is the condition momentary(False), or maintained(True)
65                run_module=None,  # The io module that has the chamber run output
66                run_io=None,  # The run output on the module that has the chamber run out put
67                limits=[],  # A list of modules that contain limit type cards.
68                interface="TCP",
69                host=pytest.flags.config["thermal_chamber_address"],
70            )
71            self._controller.connect()
72            self.internal_units = "C"
73            self.display_units = "C"
74
75        @atexit.register
76        def __atexit__():
77            """Configure a safe shut down for when the class instance is destroyed."""
78            logger.write_info_to_report("Stopping thermal chamber loop")
79            # self.stop_loop()  # FIXME(JA): test shutting down the chamber

Initialize the Watlow F4T controller.

controller: chamberconnectlibrary.watlowf4t.WatlowF4T
81    @property
82    def controller(self) -> WatlowF4T:
83        """Check if the controller was set up."""
84        if self._controller:
85            return self._controller
86        raise RuntimeError(
87            "Attempted to access thermal chamber, but ip was never provided. "
88            "Did you set the property in the HITL hardware config?"
89        )

Check if the controller was set up.

air_temperature: float
91    @property
92    def air_temperature(self) -> float:
93        """Get process value
94        :returns: the current chamber temperature
95        """
96        return float(self.controller.get_loop_pv(1)["air"])  # Working Process Value, reg 2820 [CHAMBER TEMPERATURE]

Get process value :returns: the current chamber temperature

set_point_temperature: float
 98    @property
 99    def set_point_temperature(self) -> float:
100        """Get set point
101        :returns: the set point temperature
102        """
103        return float(
104            self.controller.get_loop_sp(1)["constant"]
105        )  # Set point/Closed-Loop Set Point, reg 2782/2810, default 75.0°F

Get set point :returns: the set point temperature

def set_room_temperature(self):
114    def set_room_temperature(self):
115        """Set the target temperature to room temperature"""
116        old_units = self.internal_units
117        self.internal_units = "C"
118        self.set_point_temperature = 23.0  # Room temperature
119        self.internal_units = old_units

Set the target temperature to room temperature

def block_until_set_point_reached( self, timeout: float | None = None, period: float = 1, buffer: float = 1):
121    def block_until_set_point_reached(self, timeout: float | None = None, period: float = 1, buffer: float = 1):
122        """Wait until the thermal chamber has reached its set point
123        :param timeout: raise TimeoutError after a certain amount of seconds has passed
124        :param period: how often the temperature is checked in seconds
125        :param buffer: Since measured temperature may not be precise, we actually wait until set_point +- buffer
126        """
127        logger.write_debug_to_report(f"Blocking until {self.set_point_temperature} {self.internal_units}")
128        start_time = time.perf_counter()
129        while not self.set_point_temperature - buffer <= self.air_temperature <= self.set_point_temperature + buffer:
130            if timeout is not None and time.perf_counter() - start_time >= timeout:
131                raise TimeoutExceededError(f"Temperature was not reached after {timeout} seconds")
132            logger.write_debug_to_report(
133                f"Current temp: {self.air_temperature} - Target temp: {self.set_point_temperature} +- {buffer}"
134            )
135            time.sleep(period)

Wait until the thermal chamber has reached its set point

Parameters
  • timeout: raise TimeoutError after a certain amount of seconds has passed
  • period: how often the temperature is checked in seconds
  • buffer: Since measured temperature may not be precise, we actually wait until set_point +- buffer
status: str
137    @property
138    def status(self) -> str:
139        """Read controller status
140        :returns: the chamber status
141        """
142        return str(self.controller.get_status())

Read controller status :returns: the chamber status

def start_loop(self):
144    def start_loop(self):
145        """Start loop 1 on chamber"""
146        current_status = self.controller.get_status()
147        if not any(status_type in current_status for status_type in ("Program Running", "Program Paused", "Constant")):
148            self.controller.const_start()

Start loop 1 on chamber

def stop_loop(self):
150    def stop_loop(self):
151        """Stop loop 1 on chamber"""
152        self.controller.stop()

Stop loop 1 on chamber

date_and_time: datetime.datetime
154    @property
155    def date_and_time(self) -> datetime:
156        """Read current date on controller"""
157        return cast(datetime, self.controller.get_datetime())

Read current date on controller

internal_units: str
166    @property
167    def internal_units(self) -> str:
168        """Read internal temperature units
169        :returns: 'C' for Celsius, 'F' for Fahrenheit
170        """
171        eth_rtu_temp_val = self.controller.client.read_holding(
172            14080 if self.controller.interface == "RTU" else 6730, 1
173        )[0]
174        return str(self.controller.watlow_val_dict[eth_rtu_temp_val])

Read internal temperature units :returns: 'C' for Celsius, 'F' for Fahrenheit

display_units: str
184    @property
185    def display_units(self) -> str:
186        """Read display temperature units
187        :returns: 'C' for Celsius, 'F' for Fahrenheit
188        """
189        display_temp_val = self.controller.client.read_holding(1328, 1)[0]
190        return str(self.controller.watlow_val_dict[display_temp_val])

Read display temperature units :returns: 'C' for Celsius, 'F' for Fahrenheit