diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..834c71f --- /dev/null +++ b/.gitignore @@ -0,0 +1,200 @@ +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python + +Controller/Data/* +*.csv +*.gz +.vscode + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python \ No newline at end of file diff --git a/Controller/controller.py b/Controller/controller.py new file mode 100644 index 0000000..6e2c492 --- /dev/null +++ b/Controller/controller.py @@ -0,0 +1,97 @@ +import time +import logging + +from zerolib.message import MessageType, ActionType, SensorDataMessage, EngineProgramSettingsMessage + +from sensor_controller import SensorController + +logger = logging.getLogger(__name__) + +class TestBenchController: + """ + Handle the mainloop of the controller logic + """ + + def __init__(self, peripheral_manager, dispatcher, sens_cfg, test_program): + self.peripheral_manager = peripheral_manager + self.sens_cfg = sens_cfg + + self.dispatcher = dispatcher + self.test_program = test_program + + self.sb_rx = SensorController(sens_cfg, peripheral_manager) + self.sb_rx.register_callback(self.data_handler) + + self.initialization_time = time.perf_counter() + + def data_handler(self, timestamp, data): + """ + data is an array of sensor, reading pairs. + """ + msg = SensorDataMessage(timestamp, data) + self.dispatcher.dispatch(msg) + + def send_engine_program_list(self, status): + if status is True: + logger.info("Sending engine program list to monitor...") + self.dispatcher.dispatch( + EngineProgramSettingsMessage( + False, ','.join( self.test_program.list_programs() ) + ) + ) + + def handler(self, msg): + if msg.get_type() == MessageType.ENGINE_PROGRAM_SETTINGS: + if msg.is_assigning is False: + logger.error("EngineProgramSettings message is set as non-assigning!") + return + + if msg.payload == "": + logger.error("No program is specified!") + return + + self.test_program.load(f"../Engine Test Programs/{msg.payload}.prog") + return + + if msg.get_type() != MessageType.ACTION: + logger.error("Non-action type message received. This should not happen.") + return + + action = msg.action + logger.info(f"Received action type {action}.") + match action: + ### GENERAL ABORT SEQUENCE + # Ensure that the propellant valves and fill valves are closed. + # Then, open the vent valve. + case ActionType.ABORT: + self.test_program.abort() + self.peripheral_manager.close_propellant_valves() + self.peripheral_manager.fill_valve.close() + self.peripheral_manager.vent_valve.open() + + ### BURN PHASE ABORT + # Similar to the general abort sequence, but we will make sure that + # the ignitor is safed and the vent valve is not opened (in case a + # recycle is possible) + case ActionType.ABORT_BURN_PHASE: + self.test_program.abort() + self.peripheral_manager.ignitor.safe() + self.peripheral_manager.close_propellant_valves() + + ### INITIATE BURN PHASE + # This is used to start the testing program (burn or cold flow) + case ActionType.BEGIN_BURN_PHASE: + self.test_program.run_program() + + ### All other cases are simple and handled by the hw interface. + case _: + self.peripheral_manager.execute_action(action) + + def mainloop(self): + self.sb_rx.start_collection() + + while True: + time.sleep(1) + + def run(self): + self.mainloop() diff --git a/Controller/interface.py b/Controller/interface.py new file mode 100644 index 0000000..306f4f3 --- /dev/null +++ b/Controller/interface.py @@ -0,0 +1,108 @@ +""" Zero Shield V2 HW Interface. Uses pigpio. Thread-safe. + +""" +from enum import Enum +from threading import Lock + +import logging +logger = logging.getLogger(__name__) + +import pigpio + +GPIO_LOCK = Lock() + +def synchronized(func): + def sync_func(*args, **kwargs): + with GPIO_LOCK: + func(*args, **kwargs) + + return sync_func + +class IOMapping(Enum): + IGNITOR_RELAY = 23 + HEATING_RELAY = 24 + WARNING_LIGHT = 25 + DANGER_LIGHT = 8 + +class ServoMapping(Enum): + FILL_VALVE = 16 + VENT_VALVE = 26 + FUEL_VALVE = 13 # S2 + OXIDIZER_VALVE = 12 # S1 + +SERVO_PWM_FREQ = 180 #hz + +class HardwareInterface: + """ Controls the hardware outputs of the Zero Shield V2. + """ + def __init__(self,): + self.pi = pigpio.pi() + self.servo_state = {} + self.set_modes() + + def check_status(self): + # Raise error if attempting to use IO on uninitialized or stopped status + if not self.status: + raise RuntimeError("Hardware interface is not active!") + + @synchronized + def set_modes(self): + for io in IOMapping: + self.pi.set_mode(io.value, pigpio.OUTPUT) + + for io in ServoMapping: + self.pi.set_mode(io.value, pigpio.OUTPUT) + self.servo_state[io] = False + + self.status = True + + @synchronized + def activate_relay(self, relay): + self.check_status() + logger.debug(f"Set pin {relay} to HIGH.") + self.pi.write(relay, pigpio.HIGH) + + @synchronized + def deactivate_relay(self, relay): + self.check_status() + logger.debug(f"Set pin {relay} to LOW.") + self.pi.write(relay, pigpio.LOW) + + @synchronized + def set_servo(self, servo, duty, hardware=False): + # duty should be 0 to 1. + self.check_status() + if servo not in ServoMapping: + raise RuntimeError("Unknown servo object!") + pwm_type = "hardware" if hardware else "software" + + if duty == 0: + self.servo_state[servo] = False + + if hardware: + self.pi.hardware_PWM(servo.value, SERVO_PWM_FREQ, 0) + else: + self.pi.set_PWM_dutycycle(servo.value, 0) + self.pi.set_PWM_frequency(servo.value, 0) + + logger.debug(f"Disabled {pwm_type} PWM on {servo}.") + else: + if not self.servo_state[servo]: + self.servo_state[servo] = True + if not hardware: + self.pi.set_PWM_frequency(servo.value, SERVO_PWM_FREQ) + + if hardware: + self.pi.hardware_PWM( + servo.value, SERVO_PWM_FREQ, + int( duty * 0.0025 * SERVO_PWM_FREQ * 1000000 ) + ) + else: + self.pi.set_servo_pulsewidth(servo.value, duty*2500) + + logger.debug(f"Set servo output to {duty*100:.4g}% on {servo} ({pwm_type} PWM).") + + @synchronized + def teardown(self): + self.pi.stop() + self.status = False diff --git a/Controller/main.py b/Controller/main.py new file mode 100644 index 0000000..5f5acef --- /dev/null +++ b/Controller/main.py @@ -0,0 +1,97 @@ +### ADD IMPORT DIRECTORY +import sys +sys.path.append('../') + +### LOGGING SETUP +import logging +from zerolib.standard import logging_config +logging.basicConfig(**logging_config) + +### IMPORTS +import os +import signal +from argparse import ArgumentParser +from pint import UnitRegistry + +from zerolib.communications import MessageServer, DEFAULT_MONITOR_PORT +from zerolib.sensorcfg import SensorConfiguration +from zerolib.standard import formatter_config, sensor_cfg_location +from zerolib.message import LogForwarder +from zerolib.datalogging import LogLogger + +from controller import TestBenchController + +from interface import HardwareInterface +from peripherals import PeripheralManager +from program import EngineTestProgram + +#from hanging_threads import start_monitoring +#monitoring_thread = start_monitoring(seconds_frozen=0.5, test_interval=50) + +### ARGUMENT PARSING +parser = ArgumentParser(prog="Zero Controller Software") +parser.add_argument( + "--dest", + default = "127.0.0.1", + help = "The IP address of the monitor. If unspecified, localhost is used." +) +args = parser.parse_args() + +### SETUP +u = UnitRegistry() + +if not args.dest: + logging.warning("Monitor IP not specified so using localhost.") +server = MessageServer() +server.connect(args.dest, DEFAULT_MONITOR_PORT) + +# Log forwarding to monitor +log_fw = LogForwarder() +log_fw.set_callback(server.dispatch) +formatter = logging.Formatter(**formatter_config) +log_fw.setFormatter(formatter) +logging.getLogger().addHandler(log_fw) + +# Log saving to disk +log_writer = LogLogger() +log_writer.setFormatter(formatter) +log_writer.start() +logging.getLogger().addHandler(log_writer) + +# Read the sensor configuration +sens_cfg = SensorConfiguration(sensor_cfg_location) +sens_cfg.read_config() + +# Initialize the hardware interface and peripheral manager +interface = HardwareInterface() +peripheral_manager = PeripheralManager(interface) +peripheral_manager.set_default_states() + +# Initialize the valve programming for this test +program = EngineTestProgram(peripheral_manager) + +# Initialize the controller +controller = TestBenchController(peripheral_manager, server, sens_cfg, program) + +### SETUP SERVER +server.register_request_hook(controller.handler) +server.register_connection_hook(controller.send_engine_program_list) +server.run() + +def teardown_handler(*args, **kwargs): + # Teardown the pigpio interface. Prevent KeyboardInterruprt during the + # teardown process. + signal.signal(signal.SIGINT, signal.SIG_IGN) + print("Cleaning up...") + peripheral_manager.teardown() + controller.sb_rx.data_logger.close() + log_writer.cleanup() + os.kill(os.getpid(), signal.SIGTERM) + +signal.signal(signal.SIGINT, teardown_handler) + +### RUN MAINLOOP +try: + controller.run() +finally: + teardown_handler() diff --git a/Controller/peripherals.py b/Controller/peripherals.py new file mode 100644 index 0000000..54a53d4 --- /dev/null +++ b/Controller/peripherals.py @@ -0,0 +1,125 @@ +""" Implements the PeripheralManager which interfaces with the Zero Shield V2. + +""" +from enum import Enum + +from interface import IOMapping, ServoMapping +from servo import ServoBallValve, ValveCalibration +from zerolib.enums import ActionType + +class LightStatus(Enum): + Safe = 1 + Warning = 2 + Danger = 3 + +""" +Set this value appropriately if the vent valve is the throttling object. (I.e if +there is not a flow control orifice downstream of the vent valve) +""" +VENT_THROTTLE = 1 + +class RelayDevice: + def __init__(self, io_mapping, hw_interface): + self.hw_intf = hw_interface + self.io_port = io_mapping.value + + def fire(self): + self.hw_intf.activate_relay(self.io_port) + + def safe(self): + self.hw_intf.deactivate_relay(self.io_port) + +class PeripheralManager: + """ + Peripherals are hardcoded as these are not expected to change. + """ + def __init__(self, hw_interface): + self.hw_intf = hw_interface + + ### VALVES + self.fill_valve = ServoBallValve( + ServoMapping.FILL_VALVE, hw_interface, **ValveCalibration.FILL_VALVE.value + ) + self.vent_valve = ServoBallValve( + ServoMapping.VENT_VALVE, hw_interface, **ValveCalibration.VENT_VALVE.value + ) + self.fuel_valve = ServoBallValve( + ServoMapping.FUEL_VALVE, hw_interface, + **ValveCalibration.OXIDIZER_VALVE.value, hardware_pwm=True + ) + self.oxidizer_valve = ServoBallValve( + ServoMapping.OXIDIZER_VALVE, hw_interface, + **ValveCalibration.FUEL_VALVE.value, hardware_pwm=True + ) # SWAPPED FOR THIS TEST + + ### RELAY-CONTROLLED DEVICES + self.tank_heater = RelayDevice(IOMapping.HEATING_RELAY, hw_interface) + self.ignitor = RelayDevice(IOMapping.IGNITOR_RELAY, hw_interface) + self.warning_light = RelayDevice(IOMapping.WARNING_LIGHT, hw_interface) + self.danger_light = RelayDevice(IOMapping.DANGER_LIGHT, hw_interface) + + self.valves = [ + self.fill_valve, self.vent_valve, self.fuel_valve, self.oxidizer_valve + ] + + def set_default_states(self): + for valve in self.valves: + valve.close() + + self.ignitor.safe() + self.tank_heater.safe() + self.warning_light.safe() + self.danger_light.safe() + + def teardown(self): + for valve in self.valves: + valve.close() + + self.ignitor.safe() + self.tank_heater.safe() + self.warning_light.safe() + self.danger_light.safe() + + self.hw_intf.teardown() + + def close_propellant_valves(self): + self.fuel_valve.close() + self.oxidizer_valve.close() + + def set_light_status(self, status): + match status: + case LightStatus.Safe: + self.warning_light.safe() + self.danger_light.safe() + case LightStatus.Warning: + self.warning_light.fire() + self.danger_light.safe() + case LightStatus.Danger: + self.warning_light.fire() + self.danger_light.fire() + + def execute_action(self, action): + match action: + ### FILL VALVE CONTROLS + case ActionType.OPEN_FILL: + self.fill_valve.open() + case ActionType.CLOSE_FILL: + self.fill_valve.close() + + ### VENT VALVE CONTROLS + case ActionType.OPEN_VENT: + self.vent_valve.set_throttle(VENT_THROTTLE) + case ActionType.CLOSE_VENT: + self.vent_valve.close() + + ### TANK HEATING + case ActionType.ENABLE_TANK_HEATING: + self.tank_heater.fire() + case ActionType.DISABLE_TANK_HEATING: + self.tank_heater.safe() + + ### IGNITOR + case ActionType.FIRE_IGNITOR: + self.ignitor.fire() + case ActionType.SAFE_IGNITOR: + self.ignitor.safe() \ No newline at end of file diff --git a/Controller/program.py b/Controller/program.py new file mode 100644 index 0000000..2006353 --- /dev/null +++ b/Controller/program.py @@ -0,0 +1,118 @@ +""" Implements the logic executing valve programs. + +""" +import time +import logging +import os +from threading import Thread + +logger = logging.getLogger(__name__) + +class EngineTestProgram: + """ + Load and execute a testing program. The program should be specified as a + csv file with time, fuel valve position, oxidizer valve position, and ignitor status. + """ + def __init__(self, peripheral_manager): + self.program = [] + self.thread = None + self.running = False + self.pm = peripheral_manager + self.callback = None + + def list_programs(self): + return [ + filename.split(".prog")[0] + for filename in os.listdir("../Engine Test Programs/") + if filename.endswith(".prog") + ] + + + def load(self, filename): + if not os.path.exists(filename): + logger.error(f"File {filename} does not exist!") + return + + if self.running is True: + logger.error("Cannot set engine program while another is running!") + return + + with open(filename, "r") as f: + self.program = [ + list(map(float, row.split(','))) for row in f.readlines() + ] + + for row in self.program: + if len(row) != 4: + logger.error("Malformed engine program.") + print("Bad row:", row) + + logger.info(f"Successfully loaded program {filename}") + + def run_program(self, callback=None): + self.running = True + self.callback = callback + self.thread = Thread(target=self._run, name="EngineProgramMainThread") + self.thread.start() + + def abort(self): + if self.thread is not None: + self.running = False + self.thread.join() + self.thread = None + else: + logger.warning("Tried to abort test but no test is running!") + + def _run(self): + i = 0 + stime = time.perf_counter() + logger.warning("Executing valve program...") + ign_off = True + + while self.running: + ctime = time.perf_counter() - stime + + t2, fp2, op2, ign2 = self.program[i+1] + + if ctime > t2: + i += 1 + if i+1 == len(self.program): + break + continue + + t1, fp1, op1, ign1 = self.program[i] + + if t2 == t1: + lerp = 1. + else: + lerp = (ctime - t1) / (t2 - t1) + + fp = fp1 + (fp2 - fp1) * lerp + op = op1 + (op2 - op1) * lerp + ign = ign1 + (ign2 - ign1) * lerp + + self.pm.fuel_valve.set_throttle(fp) + self.pm.oxidizer_valve.set_throttle(op) + + if ign > 0.5: + if ign_off: + self.pm.ignitor.fire() + ign_off = False + logger.warning("Ignitor activated.") + elif not ign_off: + self.pm.ignitor.safe() + ign_off = True + logger.warning("Ignitor safed.") + + # Give other parts of the program time to run + time.sleep(0.01) + + self.pm.close_propellant_valves() + self.pm.ignitor.safe() + + self.running = False + + logger.warning("Valve program complete.") + + if self.callback: + self.callback() diff --git a/Controller/sensor_array.py b/Controller/sensor_array.py new file mode 100644 index 0000000..d029ef4 --- /dev/null +++ b/Controller/sensor_array.py @@ -0,0 +1,138 @@ +""" Interface to the sensor hardware. + +Adafruit drivers are used as much of the work has already been done. The only +exception is the ADS1120 chip which did not have a driver available. +""" +import collections +import logging +import time + +import board +import adafruit_bitbangio as bitbangio + +from digitalio import DigitalInOut +from adafruit_max31855 import MAX31855 +from adafruit_tca9548a import TCA9548A +from cedargrove_nau7802 import NAU7802, ConversionRate + +from zerolib.ads1120 import ADS1120 +from zerolib.enums import SensorType + +from sensor_calib import apply_calibration + +logger = logging.getLogger(__name__) + +### PINOUT +# TC Sensor Bindings +TC_CS = [ + board.D6, # TC 1 CS on GPIO 6 + board.D17, # TC 2 CS on GPIO 17 + board.D27, # TC 3 CS on GPIO 27 + board.D22, # TC 4 CS on GPIO 22 + board.D5 # TC 5 CS on GPIO 5 +] + +ADC_CS = board.D4 # ADC CS on GPIO 4 +# ADC Sensor Bindings +ADC_CHANNELS = { + SensorType.BATTERY_LEVEL : 2, + SensorType.CC_PRESSURE : 0, + SensorType.TANK_PRESSURE : 1 +} + +# Time in seconds between log propagation. +LOG_TIMEOUT = 1 + + +class SensorArray: + """ + LC1-3 are the mdot load cells + LC4 is the thrust load cell + """ + def __init__(self, peripheral_manager): + # Store a ref to the perf_mgr to read valve states + self.perf_mgr = peripheral_manager + + # Using software SPI, hardware SPI appears to not work + self.spi = bitbangio.SPI(board.D11, MISO=board.D9, MOSI=board.D10) + # Hardware I2C. Make sure to boost the Pi I2C freq to 400khz! + self.i2c = board.I2C() + + # Default MAX31855 baudrate is set to 100khz. Increase this to 2 MHz + self.TCs = [ + MAX31855(self.spi, DigitalInOut(pin)) + for pin in TC_CS + ] + + # Using custom ADS1120 driver. Important to initialize after all of the + # CS pins have been defined and TCs instantiated to prevent SPI collision. + self.ADC = ADS1120(self.spi, DigitalInOut(ADC_CS)) + self.ADC.initialize() + + # TC9548A IC is used to multiplex the 4x NAU7802 load cells + self.I2C_switch = TCA9548A(self.i2c) + # The first four channels of the switch are used + self.LCs = [ + NAU7802(self.I2C_switch[i], address=0x2A, active_channels=1) + for i in range(4) + ] + for lc in self.LCs: + lc._c2_conv_rate = ConversionRate.RATE_80SPS + [lc.enable(True) for lc in self.LCs] + + # Sensors are read frequently, so we need to throttle logs to prevent + # flooding the console. + self.last_log_time = collections.defaultdict(lambda: 0) + + def read(self, sensor): + # Perform sensor lookup and call the respective method. + try: + reading = None + + match sensor.get_type(): + case SensorType.CC_PRESSURE | SensorType.TANK_PRESSURE: + reading = self.ADC.read(ADC_CHANNELS[sensor.get_type()]) + + case SensorType.BATTERY_LEVEL: + reading = self.ADC.read( + ADC_CHANNELS[sensor.get_type()] + (sensor.get_number()-1) + ) + + case SensorType.LOAD_CELL: + reading = self.LCs[sensor.get_number() - 1].read() + + case SensorType.THRUST: + reading = self.LCs[3].read() + + case SensorType.THERMOCOUPLE: + reading = self.TCs[sensor.get_number() - 1].temperature + + case SensorType.OXIDIZER_VALVE_THROTTLE: + reading = self.perf_mgr.oxidizer_valve.get_state() + + case SensorType.FUEL_VALVE_THROTTLE: + reading = self.perf_mgr.fuel_valve.get_state() + + case _: + logger.error("Tried to read from a sensor that does not exist!") + return + + return apply_calibration(sensor, reading) + except Exception as e: + self.log_error(sensor, e) + + def log_error(self, sensor, error): + # Log a sensor reading failure, with timeout to prevent flooding. + error_time = time.perf_counter() + if error_time - self.last_log_time[sensor] < LOG_TIMEOUT: + return + + logger.critical(f"{sensor.get_name()}: {error}") + self.last_log_time[sensor] = error_time + + def is_physical_sensor(self, sensor): + """ + return True if the sensor is not a computed quantity -- i.e it is + an actual sensor to be read by this class. + """ + return sensor.get_rate() is not None diff --git a/Controller/sensor_calib.py b/Controller/sensor_calib.py new file mode 100644 index 0000000..6664895 --- /dev/null +++ b/Controller/sensor_calib.py @@ -0,0 +1,71 @@ +""" Handles the application of calibration data on sensors. + +The apply_calibration function is used to call the correct calibration function +with the data provided in this file. +""" +import numpy as np + +from scipy.interpolate import interp1d + +from zerolib.enums import SensorType + + +def linear_calibration(calib_data, rval): + return (rval - calib_data[0]) * calib_data[1] + +def fn_calibration(calib_fn, rval): + return calib_fn(rval) + + +CALIBRATION_FUNCTIONS = { + SensorType.LOAD_CELL : linear_calibration, + SensorType.THRUST : linear_calibration, + SensorType.CC_PRESSURE : linear_calibration, + SensorType.TANK_PRESSURE : linear_calibration, + SensorType.BATTERY_LEVEL : fn_calibration, +} + +CALIBRATION_DATA = { + SensorType.LOAD_CELL : { + 1 : ( 696.467*423.076, 1/(-423.076*1000)), + 2 : (-1835.83*437.764, 1/( 437.764*1000)), + 3 : ( 2666.21*436.016, 1/(-436.016*1000)) + }, + + SensorType.THRUST : (-145500, -0.0000267753), + + # PTs are 0.5-4.5V. + SensorType.CC_PRESSURE : (0.5, 300/4.), + SensorType.TANK_PRESSURE : (0.5, 1000/4.), + + # Interpolate from data at https://blog.ampow.com/lipo-voltage-chart/ + SensorType.BATTERY_LEVEL : { + 1: interp1d( + x = np.array([ + 0.0, 13.09, 14.43, 14.75, 14.83, 14.91, 14.99, 15.06, 15.14, 15.18, + 16.26, 15.34, 15.42, 15.50, 15.66, 15.81, 15.93, 16.09, 16.33, 16.45, + 16.6, 16.8, 20.0 + ]) / 4, # Hardware uses 4:1 voltage divider. + y = [0] + [x for x in range(0, 101, 5)] + [100], + kind = "linear" + ), + 2: lambda x: x # TODO: Calibrate servo battery sensor. + }, + + # Thermocouples, valve sensors do not require calibration +} + + +def apply_calibration(sensor, reading): + # Some sensors don't require calibration + if sensor.get_type() not in CALIBRATION_DATA.keys(): + return reading + + # If there are multiple sensors connected of the same type, get the + # respective calibration data. + if sensor.get_number(): + cdata = CALIBRATION_DATA[sensor.get_type()][sensor.get_number()] + else: + cdata = CALIBRATION_DATA[sensor.get_type()] + + return CALIBRATION_FUNCTIONS[sensor.get_type()](cdata, reading) diff --git a/Controller/sensor_controller.py b/Controller/sensor_controller.py new file mode 100644 index 0000000..4eed2fc --- /dev/null +++ b/Controller/sensor_controller.py @@ -0,0 +1,133 @@ +""" Main logic for reading the sensors. + +""" +import time +import logging +import numpy as np + +logger = logging.getLogger(__name__) + +from threading import Thread + +try: + from sensor_array import SensorArray +except (NotImplementedError, AttributeError): + logger.critical("Sensor driver import failed! This can be ignored if running in debug mode.") + +from zerolib.datalogging import DataLogger + +DATA_DELAY = 1/60 # Send data at a peak of 60 Hz + +class SensorController: + """ Class to continously sample the sensors at (roughly) the specified rates. + """ + def __init__(self, sensor_config, peripheral_manager): + self.thread = None + self.running = False + self.data_callback = None + self.init_time = time.perf_counter() + + self.p_mgr = peripheral_manager + self.sens_cfg = sensor_config + self.array = SensorArray(peripheral_manager) + + self.physical_sensors = [ + sensor for sensor in sensor_config.get_sensors() + if self.array.is_physical_sensor(sensor) + ] + self.num_sensors = len(self.physical_sensors) + + # Target time per loop iteration + self.loop_timestep = None + # Number of iterations per reading for each sensor + self.mods = {} + + self.data_logger = DataLogger() + self.data_logger.start() + # Make the header + self.data_logger.add_row( + "Time [s]," + ','.join([ + f"{sensor.get_name()} [{sensor.get_units()[0]}]" + for sensor in self.physical_sensors + ]) + ) + + self.compute_delay_parameters() + + def register_callback(self, fn): + self.data_callback = fn + + def compute_delay_parameters(self): + hfreq = max([sensor.get_rate() for sensor in self.physical_sensors]) + + self.loop_timestep = 1/hfreq + self.mods = { + sensor : int( hfreq / sensor.get_rate() ) + for sensor in self.physical_sensors + } + + def mainloop(self): + i = 0 + last_time = time.perf_counter() + + data_row = {sensor:[] for sensor in self.physical_sensors} + next_cb_time = time.perf_counter() + DATA_DELAY + + while True: + timestamp = last_time-self.init_time + row = f"{timestamp}," + + j = 0 + for sensor, mod in self.mods.items(): + # Readings are staggered to reduce jitter + j += 1 + if (i+j) % mod == 0: + reading = self.array.read(sensor) + + if reading is None: + # There was an error... Logs are sent to the monitor. + row += "Ø," + continue + + data_row[sensor].append(reading) + row += f"{reading}," + else: + row += "Ø," + + if time.perf_counter() > next_cb_time: + # Average collected data + avg_data = [ + (sensor.get_id(), np.mean(values)) + for sensor, values in data_row.items() + if values + ] + # Pass it to the callback + self.data_callback(timestamp, avg_data) + # Refresh the params + next_cb_time = time.perf_counter() + DATA_DELAY + data_row = {sensor:[] for sensor in self.physical_sensors} + + self.data_logger.add_row(row[:-1]) + i += 1 + + sleep_time = self.loop_timestep - (time.perf_counter() - last_time) + if sleep_time > 0: + time.sleep(sleep_time) + + last_time = time.perf_counter() + + def start_collection(self): + # Entry point. + if not self.data_callback: + raise RuntimeError("Data callback must be provided!") + + self.thread = Thread( + target=self.mainloop, + daemon=True, name="SensorControllerMainThread" + ) + self.running = True + self.thread.start() + + def stop(self): + self.running = False + self.thread.join() \ No newline at end of file diff --git a/Controller/servo.py b/Controller/servo.py new file mode 100644 index 0000000..eaa9969 --- /dev/null +++ b/Controller/servo.py @@ -0,0 +1,70 @@ +""" Servo ball valve control. + +""" +from enum import Enum + +class ValveCalibration(Enum): + # Calibration data for the valves + FUEL_VALVE = { + "closed_duty" : 0.98, + "cracking_duty" : 0.93, + "open_duty" : 0.74 + } + OXIDIZER_VALVE = { + "closed_duty" : 0.95, + "cracking_duty" : 0.91, + "open_duty" : 0.71 + } + FILL_VALVE = { + "closed_duty" : 0.98, + "cracking_duty" : 0.92, + "open_duty" : 0.74 + } + VENT_VALVE = { + "closed_duty" : 0.95, + "cracking_duty" : 0.89, + "open_duty" : 0.71 + } + +class ServoBallValve: + """ + Class implementing servo ball valve control. movement_range cooresponds to + the range of pwm signal with the first value being the minimum (closed) + state and the second value being the fully opened state. throttle_range + likewise corresponds to the pwm values with minimum and maximum throttle, + i.e, when the valve first cracks open and first fully opens. + """ + def __init__( + self, io_mapping, hw_interface, closed_duty=None, cracking_duty=None, + open_duty=None, hardware_pwm=False + ): + if closed_duty is None or cracking_duty is None or open_duty is None: + raise RuntimeError("All duty values must be specified!") + + self.port = io_mapping + self.hw_itf = hw_interface + self.closed_duty = closed_duty + self.cracking_duty = cracking_duty + self.open_duty = open_duty + self.throttle_state = -1 + self.hardware_pwm = hardware_pwm + + def get_state(self): + # return 0 for closed, 1 for open, and in between for throttled. + return self.throttle_state + + def set_throttle(self, throttle): + self.throttle_state = throttle + + x = self.cracking_duty + ( + self.open_duty - self.cracking_duty + ) * throttle + self.hw_itf.set_servo(self.port, x, hardware=self.hardware_pwm) + + def open(self): + self.throttle_state = 1 + self.hw_itf.set_servo(self.port, self.open_duty, hardware=self.hardware_pwm) + + def close(self): + self.throttle_state = 0 + self.hw_itf.set_servo(self.port, self.closed_duty, hardware=self.hardware_pwm) \ No newline at end of file diff --git a/Controller/servo_calib.py b/Controller/servo_calib.py new file mode 100644 index 0000000..ee228d3 --- /dev/null +++ b/Controller/servo_calib.py @@ -0,0 +1,16 @@ +### ADD IMPORT DIRECTORY +import sys +sys.path.append('../') + +### LOGGING SETUP +import logging +from zerolib.standard import logging_config +logging.basicConfig(**logging_config) + +from interface import HardwareInterface, ServoMapping + +interface = HardwareInterface() + +while True: + x = float(input("Enter the duty cycle: ")) + interface.set_servo(ServoMapping.FUEL_VALVE, x) diff --git a/Engine Test Programs/static-fire.prog b/Engine Test Programs/static-fire.prog new file mode 100644 index 0000000..b0bb840 --- /dev/null +++ b/Engine Test Programs/static-fire.prog @@ -0,0 +1,300 @@ +0.0, 0.00858112684477125, 0.00783930385940432, 1.0 +0.048494983277591976, 0.012116687724701666, 0.013035587027217535, 1.0 +0.09698996655518395, 0.015190891337412435, 0.017053341703185954, 1.0 +0.14548494983277593, 0.018331991460828367, 0.021178773483777127, 1.0 +0.1939799331103679, 0.021546973739765864, 0.025402232816658727, 1.0 +0.24247491638795987, 0.02484394140384857, 0.029752643642487017, 1.0 +0.29096989966555187, 0.028221818973627157, 0.03421308909539874, 1.0 +0.3394648829431438, 0.03168569547262638, 0.038806110513938175, 1.0 +0.3879598662207358, 0.03525163289612032, 0.0435377888614359, 1.0 +0.4364548494983278, 0.03891598595741858, 0.0484020350099044, 1.0 +0.48494983277591974, 0.042688851416561674, 0.053437709628675376, 1.0 +0.5334448160535117, 0.04392847944630371, 0.062298946775038215, 1.0 +0.5819397993311037, 0.04399273537834992, 0.07345774135563424, 1.0 +0.6304347826086957, 0.04405140447885722, 0.08540760664799497, 1.0 +0.6789297658862876, 0.04410865341364931, 0.09826348357735712, 1.0 +0.7274247491638797, 0.044164583869851504, 0.11213917756634202, 1.0 +0.7759197324414716, 0.04421603995424351, 0.12708234596430504, 1.0 +0.8244147157190636, 0.04426800758828713, 0.14313276580738774, 1.0 +0.8729096989966556, 0.04431981370065938, 0.16024130830819328, 1.0 +0.9214046822742475, 0.04436707899331362, 0.1782426368310171, 1.0 +0.9698996655518395, 0.04441701556849685, 0.19685066504939303, 1.0 +1.0183946488294315, 0.04445129528920472, 0.20851239613008582, 1.0 +1.0668896321070234, 0.04445820948738611, 0.20855614048996582, 1.0 +1.1153846153846154, 0.04446647835759815, 0.20860070260973854, 1.0 +1.1638795986622075, 0.04447474863135526, 0.20864676762577597, 1.0 +1.2123745819397993, 0.04448301679860114, 0.20869130366475494, 1.0 +1.2608695652173914, 0.04449127949346609, 0.2087350943496315, 1.0 +1.3093645484949834, 0.04449953348804354, 0.20877894265080826, 1.0 +1.3578595317725752, 0.04450777568804204, 0.208826177821537, 1.0 +1.4063545150501673, 0.04451600312975437, 0.2088701202185742, 1.0 +1.4548494983277593, 0.044524212979196114, 0.20891409989147836, 1.0 +1.5033444816053512, 0.0445324025334904, 0.20895811086430266, 1.0 +1.5518394648829432, 0.044540616922761594, 0.20900214756420865, 1.0 +1.6003344481605353, 0.04454885637209141, 0.2090462048120792, 1.0 +1.648829431438127, 0.04455665649793267, 0.20909027781537545, 1.0 +1.6973244147157192, 0.044564861573663656, 0.2091343621538909, 1.0 +1.7458193979933112, 0.044572899959348405, 0.20918179020394487, 1.0 +1.794314381270903, 0.044580919679734377, 0.20922600285004359, 1.0 +1.842809364548495, 0.04458892139665439, 0.20927050407033204, 1.0 +1.8913043478260871, 0.0445969057685421, 0.20931497095672172, 1.0 +1.939799331103679, 0.04460487345008026, 0.20935933425538297, 1.0 +1.988294314381271, 0.04461282509304722, 0.20940441159600562, 1.0 +2.036789297658863, 0.05402151356145676, 0.24752011714201427, 0.0 +2.085284280936455, 0.06758592501274638, 0.29165641105659135, 0.0 +2.1337792642140467, 0.08280025895011553, 0.32817098770524367, 0.0 +2.182274247491639, 0.10000325325147516, 0.35868832317786764, 0.0 +2.230769230769231, 0.11965048108944301, 0.38506741263567107, 0.0 +2.279264214046823, 0.1422023689155495, 0.4083719335623095, 0.0 +2.327759197324415, 0.16789984427506058, 0.4294029716292431, 0.0 +2.376254180602007, 0.19636338135381431, 0.4488703750109749, 0.0 +2.4247491638795986, 0.22633033579493994, 0.4670836063158628, 0.0 +2.4732441471571907, 0.25602299775345067, 0.4844075984239794, 0.0 +2.5217391304347827, 0.284141587084935, 0.5010836782952676, 0.0 +2.5702341137123748, 0.31022762852926317, 0.5172319225390128, 0.0 +2.618729096989967, 0.33435601145192073, 0.5331035192963038, 0.0 +2.667224080267559, 0.35693168735303016, 0.5488100839659987, 0.0 +2.7157190635451505, 0.3783674162881997, 0.5645732550148054, 0.0 +2.7642140468227425, 0.3990241291784807, 0.5804724189801858, 0.0 +2.8127090301003346, 0.41928577867512395, 0.5967599977235997, 0.0 +2.8612040133779266, 0.43945207935535224, 0.613561385218859, 0.0 +2.9096989966555187, 0.4599628488566692, 0.6312252361135796, 0.0 +2.9581939799331107, 0.4812254479422811, 0.649976380822298, 0.0 +3.0066889632107023, 0.5006375491754869, 0.6674730754725489, 0.0 +3.0551839464882944, 0.5013109725420948, 0.6680514595095429, 0.0 +3.1036789297658864, 0.5019884583168388, 0.6686298170111727, 0.0 +3.1521739130434785, 0.5026828697038956, 0.6692105922944697, 0.0 +3.2006688963210705, 0.5033781345666691, 0.6697846431526004, 0.0 +3.249163879598662, 0.5040732032499093, 0.6703618371423766, 0.0 +3.297658862876254, 0.5047682251987679, 0.6709349985777707, 0.0 +3.3461538461538463, 0.5054600572525803, 0.6715062667242366, 0.0 +3.3946488294314383, 0.5061534816712382, 0.6720772687638279, 0.0 +3.4431438127090304, 0.5068556795226298, 0.6726609223506073, 0.0 +3.4916387959866224, 0.5075571600713689, 0.6732450041497023, 0.0 +3.540133779264214, 0.5082605716843026, 0.6738251151827683, 0.0 +3.588628762541806, 0.5089615870734499, 0.6744043510612878, 0.0 +3.637123745819398, 0.5096620175820731, 0.6749835401960063, 0.0 +3.68561872909699, 0.5103617376012777, 0.6755602358132536, 0.0 +3.7341137123745822, 0.5110596810014087, 0.6761364278484944, 0.0 +3.7826086956521743, 0.5117582178500357, 0.6767127534597112, 0.0 +3.831103678929766, 0.5124560051878883, 0.6772871297696736, 0.0 +3.879598662207358, 0.5131543150775111, 0.6778602128388685, 0.0 +3.92809364548495, 0.5138542791679641, 0.6784343455804717, 0.0 +3.976588628762542, 0.5145615357451615, 0.6790202126784262, 0.0 +4.025083612040134, 0.5152646531557599, 0.6796011437296258, 0.0 +4.073578595317726, 0.5159729252427078, 0.680183099206612, 0.0 +4.122073578595318, 0.5166831900691675, 0.6807651161206563, 0.0 +4.17056856187291, 0.5174210468426006, 0.6813431026069652, 0.0 +4.219063545150502, 0.5181575165674677, 0.6819237600650718, 0.0 +4.2675585284280935, 0.5188924102690844, 0.6825007477440278, 0.0 +4.316053511705686, 0.5196266537379891, 0.6830763817368082, 0.0 +4.364548494983278, 0.5203626774460274, 0.6836511369319469, 0.0 +4.41304347826087, 0.5210967933831282, 0.6842254610867867, 0.0 +4.461538461538462, 0.5218302416218287, 0.6848045895106581, 0.0 +4.510033444816054, 0.5225777274716523, 0.68544122027705, 0.0 +4.558528428093646, 0.5233235502321999, 0.6860804407437869, 0.0 +4.607023411371237, 0.524067570586865, 0.6867142419715005, 0.0 +4.65551839464883, 0.5248144842241012, 0.6873514851047677, 0.0 +4.7040133779264215, 0.5255577399270606, 0.687982473963131, 0.0 +4.752508361204014, 0.5263007609791437, 0.6886165157636095, 0.0 +4.801003344481606, 0.5270435784468029, 0.6892456718302584, 0.0 +4.849498327759197, 0.5277845597426365, 0.6898756724299251, 0.0 +4.89799331103679, 0.5285298057785888, 0.6905025425408271, 0.0 +4.946488294314381, 0.5292673097315218, 0.6911310624983231, 0.0 +4.994983277591974, 0.5300096103127335, 0.6917568546345234, 0.0 +5.043478260869565, 0.530756020237156, 0.6923881748508968, 0.0 +5.091973244147157, 0.5315069819172862, 0.6930284635148876, 0.0 +5.1404682274247495, 0.5322979587576155, 0.6936684598419117, 0.0 +5.188963210702341, 0.5330930466901613, 0.6943056436393011, 0.0 +5.237458193979934, 0.5338876608170534, 0.6949409200752247, 0.0 +5.285953177257525, 0.5346838433738789, 0.6955771244394412, 0.0 +5.334448160535118, 0.5354777076728131, 0.6962101649500343, 0.0 +5.382943143812709, 0.5362724231997565, 0.6968442795154889, 0.0 +5.431438127090301, 0.5370615517440509, 0.6974753889897266, 0.0 +5.479933110367893, 0.537855831907854, 0.6981074314919252, 0.0 +5.528428093645485, 0.5386468385122688, 0.6987368517385463, 0.0 +5.5769230769230775, 0.539441461915353, 0.6993668555422307, 0.0 +5.625418060200669, 0.5402350610135553, 0.6999967973151953, 0.0 +5.673913043478261, 0.5410391484199892, 0.7006409428130019, 0.0 +5.722408026755853, 0.5418413303423759, 0.7012824212569391, 0.0 +5.770903010033445, 0.5426491294122344, 0.7019227597575268, 0.0 +5.819397993311037, 0.5434538895045733, 0.7025624626773372, 0.0 +5.867892976588629, 0.5442571534827909, 0.7032018963670545, 0.0 +5.9163879598662215, 0.5450602455055166, 0.7038386153456531, 0.0 +5.964882943143813, 0.545863482618267, 0.7045315094357277, 0.0 +6.013377926421405, 0.5373127360127333, 0.6999383652358293, 0.0 +6.061872909698997, 0.5066095814489956, 0.6821881077395586, 0.0 +6.110367892976589, 0.47883615048462574, 0.6659086559045904, 0.0 +6.158862876254181, 0.45260039178387057, 0.6508309670415785, 0.0 +6.207357859531773, 0.42701934986776247, 0.6366157659999028, 0.0 +6.2558528428093645, 0.4012830103311526, 0.6229667152863446, 0.0 +6.304347826086957, 0.37473079569677753, 0.6098542024528213, 0.0 +6.352842809364549, 0.346459675527464, 0.5971325229690335, 0.0 +6.401337792642141, 0.3156816688100598, 0.5847611074443044, 0.0 +6.449832775919733, 0.28146449924381056, 0.5725332945436481, 0.0 +6.498327759197324, 0.24347145606214093, 0.5604491035556709, 0.0 +6.546822742474917, 0.24233747708340833, 0.5602034516176059, 0.0 +6.595317725752508, 0.24257842403021537, 0.5603746639500136, 0.0 +6.643812709030101, 0.2428237821277748, 0.5605485550623982, 0.0 +6.6923076923076925, 0.2430644263727473, 0.5607220534131472, 0.0 +6.740802675585285, 0.24330830038953374, 0.5608944203795683, 0.0 +6.789297658862877, 0.24354803841562414, 0.5610675320896807, 0.0 +6.837792642140468, 0.2437916047526012, 0.5612413132593174, 0.0 +6.886287625418061, 0.24403217986428885, 0.5614150148265383, 0.0 +6.934782608695652, 0.2442734386395842, 0.5615864417836062, 0.0 +6.983277591973245, 0.24451480548235782, 0.56176152239575, 0.0 +7.031772575250836, 0.24475609923373784, 0.5619301211427205, 0.0 +7.080267558528428, 0.24499795156594378, 0.562104326858075, 0.0 +7.1287625418060205, 0.2452404198175343, 0.5622752239244112, 0.0 +7.177257525083612, 0.24548322330438407, 0.5624470076542808, 0.0 +7.225752508361205, 0.24572082362066586, 0.5626177040951821, 0.0 +7.274247491638796, 0.2459537805495885, 0.5627886664551403, 0.0 +7.322742474916389, 0.24619301287768236, 0.5629648248634629, 0.0 +7.37123745819398, 0.24643466330280983, 0.563139917993599, 0.0 +7.419732441471572, 0.24667135466282297, 0.5633129548094038, 0.0 +7.4682274247491645, 0.24691550197364692, 0.5634888351269982, 0.0 +7.516722408026756, 0.24715106356678584, 0.5636640711203248, 0.0 +7.565217391304349, 0.24739316950010073, 0.5638371595757413, 0.0 +7.61371237458194, 0.24763076749158444, 0.5640136168345206, 0.0 +7.662207357859532, 0.24787188478566258, 0.564185963952693, 0.0 +7.710702341137124, 0.24811043203394909, 0.5643615125715288, 0.0 +7.759197324414716, 0.24835125179402381, 0.564534984551453, 0.0 +7.807692307692308, 0.24858831105101578, 0.5647080040063134, 0.0 +7.8561872909699, 0.24882774280375383, 0.5648844266232866, 0.0 +7.904682274247492, 0.2490668799469616, 0.565055498332618, 0.0 +7.953177257525084, 0.24930334122852177, 0.5652288631254334, 0.0 +8.001672240802677, 0.24954338927007474, 0.5654039527443246, 0.0 +8.050167224080267, 0.24978270410722184, 0.5655756899485811, 0.0 +8.09866220735786, 0.25001935231300776, 0.5657507205405685, 0.0 +8.147157190635452, 0.2502567297114573, 0.5659215540033621, 0.0 +8.195652173913043, 0.2504947078423942, 0.5660950607005161, 0.0 +8.244147157190636, 0.25073457901233376, 0.5662691292227473, 0.0 +8.292642140468228, 0.250972542316143, 0.5664399156103055, 0.0 +8.34113712374582, 0.2512087424631425, 0.566614590244827, 0.0 +8.389632107023411, 0.2514480163419747, 0.5667855786152783, 0.0 +8.438127090301004, 0.25168423886188174, 0.5669584866358998, 0.0 +8.486622073578596, 0.2519274913196477, 0.5671338312819695, 0.0 +8.535117056856187, 0.2805075215451404, 0.5764678762666359, 0.0 +8.58361204013378, 0.3163759030077153, 0.589499320695357, 0.0 +8.632107023411372, 0.34869020426713393, 0.6028811310993555, 0.0 +8.680602006688964, 0.3783614369227252, 0.616673142607229, 0.0 +8.729096989966555, 0.40651005937012685, 0.631044456619973, 0.0 +8.777591973244148, 0.4339948986933807, 0.6462324848762343, 0.0 +8.82608695652174, 0.4617404837726344, 0.6625089492228711, 0.0 +8.87458193979933, 0.4908074192517664, 0.6800986913736241, 0.0 +8.923076923076923, 0.5226452734313212, 0.6994959497264747, 0.0 +8.971571906354516, 0.5595004230742703, 0.7212636410291047, 0.0 +9.020066889632108, 0.585724083422174, 0.7361544387558704, 0.0 +9.068561872909699, 0.5867942321761609, 0.7369530233907088, 0.0 +9.117056856187292, 0.5878685557285294, 0.7377516248462469, 0.0 +9.165551839464884, 0.5889369799870252, 0.7385501596927041, 0.0 +9.214046822742475, 0.5900114699335308, 0.7393463102205563, 0.0 +9.262541806020067, 0.5910923801044751, 0.7401525258434513, 0.0 +9.31103678929766, 0.5921862366316126, 0.740970243967046, 0.0 +9.35953177257525, 0.593281189677359, 0.7417861300587514, 0.0 +9.408026755852843, 0.5943755702690883, 0.7426030076243827, 0.0 +9.456521739130435, 0.5954685843683372, 0.7434180557140061, 0.0 +9.505016722408028, 0.5966680208281288, 0.7443046592603756, 0.0 +9.553511705685619, 0.5978782010220244, 0.7452216490495399, 0.0 +9.602006688963211, 0.5990855717795803, 0.7461364116090969, 0.0 +9.650501672240804, 0.6002967139249689, 0.7470503120575224, 0.0 +9.698996655518394, 0.601509873791972, 0.7479637325560241, 0.0 +9.747491638795987, 0.6027190013592149, 0.7488756858538753, 0.0 +9.79598662207358, 0.6039292322562604, 0.7497860060536504, 0.0 +9.844481605351172, 0.6051397113165515, 0.7506935853307731, 0.0 +9.892976588628763, 0.606349153383247, 0.7516045507469628, 0.0 +9.941471571906355, 0.6075605171580134, 0.752511641650953, 0.0 +9.989966555183948, 0.608774652050915, 0.7534245916629203, 0.0 +10.038461538461538, 0.6100137272660262, 0.7543535645866403, 0.0 +10.08695652173913, 0.6112531775674477, 0.7552860659037812, 0.0 +10.135451505016723, 0.6124920844683096, 0.756217084924978, 0.0 +10.183946488294314, 0.613864804266175, 0.7571482223370666, 0.0 +10.232441471571907, 0.6152481118962134, 0.758077481439017, 0.0 +10.280936454849499, 0.6166326274597281, 0.7590068386410181, 0.0 +10.329431438127092, 0.6180178849376352, 0.7599345598743987, 0.0 +10.377926421404682, 0.6194001840553819, 0.7608614087767755, 0.0 +10.426421404682275, 0.6207853834070739, 0.7617860452996329, 0.0 +10.474916387959867, 0.622171638229455, 0.7627106721826515, 0.0 +10.523411371237458, 0.6235536252167974, 0.7636369608790508, 0.0 +10.57190635451505, 0.6249396003787798, 0.7646012726181457, 0.0 +10.620401337792643, 0.6263242493330041, 0.7656490909426203, 0.0 +10.668896321070235, 0.6277101535299947, 0.7666983502473796, 0.0 +10.717391304347826, 0.6290954026049809, 0.7677463483423872, 0.0 +10.765886287625419, 0.6305571157157152, 0.76879886690501, 0.0 +10.814381270903011, 0.6321592549760309, 0.7698780185325234, 0.0 +10.862876254180602, 0.6337612947870577, 0.7709566670445144, 0.0 +10.911371237458194, 0.6353638914869952, 0.7720308483022652, 0.0 +10.959866220735787, 0.6369630804766042, 0.7731052213278722, 0.0 +11.008361204013378, 0.6385705930307087, 0.7741824150074784, 0.0 +11.05685618729097, 0.6401720250139786, 0.7752565821983768, 0.0 +11.105351170568563, 0.6417763094966484, 0.7763273498758755, 0.0 +11.153846153846155, 0.643379248589292, 0.7773992966274579, 0.0 +11.202341137123746, 0.6449818880681736, 0.7784719221467032, 0.0 +11.250836120401338, 0.6465876390058773, 0.7795416980503214, 0.0 +11.29933110367893, 0.6482471416079473, 0.7806108806468479, 0.0 +11.347826086956522, 0.6500743546790306, 0.7816780547481144, 0.0 +11.396321070234114, 0.6519025978555666, 0.7827436186390021, 0.0 +11.444816053511706, 0.65373290985825, 0.783811700319142, 0.0 +11.493311036789299, 0.655565065421854, 0.7848770810472097, 0.0 +11.54180602006689, 0.6573932532755119, 0.7860281508606688, 0.0 +11.590301003344482, 0.659253430668677, 0.7872745684452149, 0.0 +11.638795986622075, 0.6611297832619102, 0.7885389395589348, 0.0 +11.687290969899665, 0.6630070544509788, 0.789800245249015, 0.0 +11.735785953177258, 0.6648886975701329, 0.7910631808962733, 0.0 +11.78428093645485, 0.6668717465013799, 0.7923232100100372, 0.0 +11.832775919732443, 0.6690291334776869, 0.7935831372493418, 0.0 +11.881270903010034, 0.6711895803875987, 0.7948411610103536, 0.0 +11.929765886287626, 0.6733498609427504, 0.7960962413221735, 0.0 +11.978260869565219, 0.6755114706956956, 0.7973559312026985, 0.0 +12.02675585284281, 0.6776724897853716, 0.7986110995646768, 0.0 +12.075250836120402, 0.679836701240697, 0.7998644264303164, 0.0 +12.123745819397994, 0.6819990549404997, 0.8011168372461431, 0.0 +12.172240802675585, 0.6841635588691665, 0.8023711461672722, 0.0 +12.220735785953178, 0.6865776915324817, 0.8036216379044013, 0.0 +12.26923076923077, 0.6890838499293472, 0.8048733287130108, 0.0 +12.317725752508363, 0.6915968058076737, 0.8061221192021567, 0.0 +12.366220735785953, 0.6941042112559148, 0.8074392489259284, 0.0 +12.414715719063546, 0.6966232232547913, 0.8088965993218858, 0.0 +12.463210702341138, 0.699201857565926, 0.8103980766673691, 0.0 +12.511705685618729, 0.7017818430242355, 0.8119013749727252, 0.0 +12.560200668896321, 0.7044393962378047, 0.813398922310017, 0.0 +12.608695652173914, 0.7074478878314499, 0.8148978229653933, 0.0 +12.657190635451506, 0.7104596816556447, 0.8163970275421143, 0.0 +12.705685618729097, 0.713474006197341, 0.8178928716560697, 0.0 +12.75418060200669, 0.716486169882846, 0.8193897042437344, 0.0 +12.802675585284282, 0.7195005500653235, 0.8208872790822606, 0.0 +12.851170568561873, 0.7225145805110622, 0.8223819470080451, 0.0 +12.899665551839465, 0.7258822648690005, 0.8238746953656514, 0.0 +12.948160535117058, 0.7294254651181353, 0.8253661747924265, 0.0 +12.996655518394649, 0.7329739817348001, 0.8268564700655636, 0.0 +13.045150501672241, 0.6524973668186036, 0.7653983054237263, 0.0 +13.093645484949834, 0.6009605056780571, 0.7196244708550217, 0.0 +13.142140468227426, 0.5628945066648953, 0.6830477439967524, 0.0 +13.190635451505017, 0.5322124861348267, 0.6521994836595213, 0.0 +13.23913043478261, 0.5055332033582318, 0.6243757615264812, 0.0 +13.287625418060202, 0.481385614924292, 0.5986063357061526, 0.0 +13.336120401337793, 0.4587226423516482, 0.5738891176564316, 0.0 +13.384615384615385, 0.43701404891669066, 0.5497050997052878, 0.0 +13.433110367892978, 0.41564448091273554, 0.5253849841294749, 0.0 +13.48160535117057, 0.3942639624065577, 0.500501727490927, 0.0 +13.53010033444816, 0.37241034051771615, 0.47435612039308456, 0.0 +13.578595317725753, 0.34963864172817244, 0.44620236036157823, 0.0 +13.627090301003346, 0.3255305136651809, 0.41485840137138547, 0.0 +13.675585284280936, 0.29971078471486406, 0.37870591325610264, 0.0 +13.724080267558529, 0.2718078785681734, 0.33473429731075843, 0.0 +13.772575250836121, 0.24188340488056798, 0.2776382125810799, 0.0 +13.821070234113714, 0.21099123000515504, 0.2038038534301104, 0.0 +13.869565217391305, 0.18079266667683505, 0.1300157686585009, 0.0 +13.918060200668897, 0.15295426175665286, 0.07422876881054921, 0.0 +13.96655518394649, 0.12814313751623915, 0.03260935167130668, 0.0 +14.01505016722408, 0.10683461987556667, 0.007253928647592732, 0.0 +14.063545150501673, 0.09365832056514237, 0.007260943636683992, 0.0 +14.112040133779265, 0.08154163965577226, 0.0072701788592424346, 0.0 +14.160535117056856, 0.0703171621390716, 0.007278286779110303, 0.0 +14.209030100334449, 0.059871617798126026, 0.007281922298670874, 0.0 +14.257525083612041, 0.05014085944768775, 0.007287742860905993, 0.0 +14.306020066889634, 0.04100007637167277, 0.007291862912435623, 0.0 +14.354515050167224, 0.03238617499358108, 0.007295523322363335, 0.0 +14.403010033444817, 0.024238547405261664, 0.007297349268782941, 0.0 +14.45150501672241, 0.01649228216398732, 0.007298279176120482, 0.0 +14.5, 0.008581210526173628, 0.007298279176120482, 0.0 diff --git a/Engine Test Programs/vent_fuel.prog b/Engine Test Programs/vent_fuel.prog new file mode 100644 index 0000000..5808780 --- /dev/null +++ b/Engine Test Programs/vent_fuel.prog @@ -0,0 +1,4 @@ +0,0,0,0 +1,1,0,0 +20,1,0,0 +21,0,0,0 \ No newline at end of file diff --git a/Engine Test Programs/vent_ox.prog b/Engine Test Programs/vent_ox.prog new file mode 100644 index 0000000..2ce1bd2 --- /dev/null +++ b/Engine Test Programs/vent_ox.prog @@ -0,0 +1,4 @@ +0,0,0,0 +1,0,1,0 +20,0,1,0 +21,0,0,0 \ No newline at end of file diff --git a/Monitor/Inconsolata.ttf b/Monitor/Inconsolata.ttf new file mode 100644 index 0000000..95ad718 Binary files /dev/null and b/Monitor/Inconsolata.ttf differ diff --git a/Monitor/UbuntuMono-Regular.ttf b/Monitor/UbuntuMono-Regular.ttf new file mode 100644 index 0000000..4977028 Binary files /dev/null and b/Monitor/UbuntuMono-Regular.ttf differ diff --git a/Monitor/action_dispatcher.py b/Monitor/action_dispatcher.py new file mode 100644 index 0000000..b12d1d5 --- /dev/null +++ b/Monitor/action_dispatcher.py @@ -0,0 +1,56 @@ +import logging + +from zerolib.enums import ActionType +from zerolib.message import ActionMessage +from zerolib.communications import DEFAULT_CONTROLLER_PORT + +logger = logging.getLogger(__name__) + +ACTION_BUTTONS = [ + "Fill Valve", + "Vent Valve", + "Tank Heating", + "Fire Ignitor", + "Startup Sequence", + "Abort", +] + +class ActionDispatcher: + def __init__(self, zmq_server, dest="", port=DEFAULT_CONTROLLER_PORT): + self.dest_addr = dest + self.dest_port = port + + self.dispatcher = zmq_server + + def set_dest(self, dest): + self.dest_addr = dest + self.dispatcher.connect(self.dest_addr, self.dest_port) + + def get_callback(self, action): + """ + Generates a callback for the requested action button. The callback is + given one value (the state of the button) which determines which action + to send. + """ + tv = fv = None + + match action: + case "Fill Valve": + tv, fv = ActionType.OPEN_FILL, ActionType.CLOSE_FILL + case "Tank Heating": + tv, fv = ActionType.ENABLE_TANK_HEATING, ActionType.DISABLE_TANK_HEATING + case "Fire Ignitor": + tv, fv = ActionType.FIRE_IGNITOR, ActionType.SAFE_IGNITOR + case "Startup Sequence": + tv, fv = ActionType.BEGIN_BURN_PHASE, ActionType.ABORT_BURN_PHASE + case "Abort": + tv, fv = ActionType.ABORT, ActionType.ABORT + case "Vent Valve": + tv, fv = ActionType.OPEN_VENT, ActionType.CLOSE_VENT + + def f(x): + axn = tv if x else fv + self.dispatcher.dispatch(ActionMessage(axn)) + logger.info(f"Sending action {axn.name} to client.") + + return f \ No newline at end of file diff --git a/Monitor/dpg_demo.py b/Monitor/dpg_demo.py new file mode 100644 index 0000000..cf6b64f --- /dev/null +++ b/Monitor/dpg_demo.py @@ -0,0 +1,12 @@ +import dearpygui.dearpygui as dpg +import dearpygui.demo as demo + +dpg.create_context() +dpg.create_viewport(title='Custom Title', width=600, height=600) + +demo.show_demo() + +dpg.setup_dearpygui() +dpg.show_viewport() +dpg.start_dearpygui() +dpg.destroy_context() \ No newline at end of file diff --git a/Monitor/icon.ico b/Monitor/icon.ico new file mode 100644 index 0000000..d38f492 Binary files /dev/null and b/Monitor/icon.ico differ diff --git a/Monitor/icon.png b/Monitor/icon.png new file mode 100644 index 0000000..532fa82 Binary files /dev/null and b/Monitor/icon.png differ diff --git a/Monitor/main.py b/Monitor/main.py new file mode 100644 index 0000000..5fb7c99 --- /dev/null +++ b/Monitor/main.py @@ -0,0 +1,119 @@ +### ADD IMPORT DIRECTORY +import sys +sys.path.append('../') + +import os +import signal +import dearpygui.dearpygui as dpg +from pint import UnitRegistry + +from sensorgrid import SensorGrid +from plotarray import PlotArray +from menu import Menu +from action_dispatcher import ActionDispatcher, ACTION_BUTTONS +from message_handler import MessageHandler + +from zerolib.communications import MessageServer, DEFAULT_CONTROLLER_PORT, DEFAULT_MONITOR_PORT +from zerolib.sensorcfg import SensorConfiguration +from zerolib.standard import logging_config, sensor_cfg_location +from zerolib.message import EngineProgramSettingsMessage + +### LOGGING SETUP +import logging +logging.basicConfig(**logging_config) + +### SETUP +units = UnitRegistry() + +dpg.create_context() +with dpg.font_registry(): + font = dpg.add_font("UbuntuMono-Regular.ttf", 12.5) + small_font = dpg.add_font("UbuntuMono-Regular.ttf", 11.5) +dpg.bind_font(font) +dpg.create_viewport( + title='Zero Monitor', small_icon="icon.ico", large_icon="icon.ico" +) + +### GUI CREATION +sens_cfg = SensorConfiguration(sensor_cfg_location) +sens_cfg.read_config() + +sn_grid = SensorGrid(sens_cfg) +sn_grid.create_grid() + +tab_labels = [ + "Primary Sensors", + "Secondary Sensors", + "Tertiary Sensors" +] +plt_arrs = [] + +with dpg.window() as w: + bg_window = w + + with dpg.group(parent=bg_window): + with dpg.tab_bar() as tb: + for i in range(sens_cfg.get_tab_count()): + with dpg.tab(label=tab_labels[i]) as tab: + plt_arrs.append( + PlotArray( + dpg, tab, units, sn_grid.get_grid(i) + ) + ) + + menu = Menu(dpg, bg_window, small_font=small_font, + indicators = [ + "Connection" + ], + buttons = ACTION_BUTTONS + ) + +logging.getLogger().addHandler(menu.get_log_handler()) + +dpg.setup_dearpygui() +dpg.show_viewport() +dpg.set_primary_window(bg_window, True) + +### SETUP SERVER +server = MessageServer(host="0.0.0.0", port=DEFAULT_MONITOR_PORT) + +# Register the button callbacks +dispatcher = ActionDispatcher(server, port=DEFAULT_CONTROLLER_PORT) +for button in ACTION_BUTTONS: + menu.set_button_callback(button, dispatcher.get_callback(button)) + +msg_handler = MessageHandler(units, sens_cfg, menu, plt_arrs) +server.register_request_hook(msg_handler.handle) + +menu_indicator_cb = menu.get_indicator_callback("Connection") +def connection_hook(status): + # Called when the connection status is changed. Update the GUI and register + # the new ip with the dispatcher if available. + menu_indicator_cb(status) + msg_handler.update_offset() + +def program_callback(selected_program): + # Called when a program is selected by the user. + msg = EngineProgramSettingsMessage(True, selected_program) + server.dispatch(msg) +menu.set_program_dropdown_callback(program_callback) + +server.register_connection_hook(connection_hook) +server.run() + +dpg.set_viewport_title(f"Zero Monitor") +dpg.maximize_viewport() + +# Join the server threads on exit +dpg.set_exit_callback(server.stop) + +### RENDER LOOP +while dpg.is_dearpygui_running(): + dpg.render_dearpygui_frame() + + [arr.update_plot_ranges() for arr in plt_arrs] + + menu.tick() + +### TEARDOWN +os.kill(os.getpid(), signal.SIGTERM) \ No newline at end of file diff --git a/Monitor/menu.py b/Monitor/menu.py new file mode 100644 index 0000000..95d7d69 --- /dev/null +++ b/Monitor/menu.py @@ -0,0 +1,154 @@ +import logging +from logging import Handler, LogRecord, Formatter + +from zerolib.standard import formatter_config + +logger = logging.getLogger(__name__) + +class LogHandler(Handler): + def handle(self, record: LogRecord): + msg = self.format(record) + if hasattr(self, "callback_fn"): + self.callback_fn(msg) + + def set_callback(self, fn): + self.callback_fn = fn + +RED = (255,0,0) +GREEN = (0,255,0) + +class Indicator: + def __init__(self, dpg, label): + self.dpg = dpg + self.enabled = False + + with dpg.group(horizontal=True, height=20): + dpg.add_text(label) + with dpg.drawlist(width=10, height=20): + self.indicator = dpg.draw_circle((5, 10), 5, fill=RED) + + def set(self, status): + self.dpg.configure_item(self.indicator, fill=GREEN if status else RED) + + +class Button: + def __init__(self, dpg, label): + self.callback = None + self.label = label + + dpg.add_checkbox(label=label, callback=self.callback_fn) + + def callback_fn(self, _, value): + if self.callback: + self.callback(value) + else: + logger.warning(f"{self.label} button pressed but no callback configured!") + + def register_callback(self, fn): + self.callback = fn + + +class ProgramDropdown: + def __init__(self, dpg): + self.dpg = dpg + self.callback = None + + self.combo = dpg.add_combo(callback=self.callback_fn) + + def callback_fn(self, _, value): + if self.callback: + self.callback(value) + else: + logger.warning(f"Attempted to select program but no callback configured!") + + def register_callback(self, fn): + self.callback = fn + + def set_programs(self, program_list): + self.dpg.configure_item(self.combo, items=program_list) + logger.info("Updated program list.") + + +class Menu: + def __init__(self, dpg, master, small_font = None, + indicators = [], buttons = [] + ): + self.dpg = dpg + self.master = master + + # button callbacks + self.indicators = {} + self.buttons = {} + self.program_dropdown = None + self.tank_heating_callback = None + + self.pause_scroll = False + + with dpg.group(label="Control Panel", parent=master, height=200) as window: + self.window = window + + with dpg.group(horizontal=True): + # Add logbox + with dpg.child(width=1200): + with dpg.group(horizontal=True): + dpg.add_text("Logs") + dpg.add_checkbox(label="Freeze", default_value=False, callback=self.freeze) + + with dpg.table(header_row=False, scrollY=True) as table: + self.logger_box = table + dpg.add_table_column() + + if small_font: + dpg.bind_item_font(self.logger_box, small_font) + + # Add control panel + with dpg.child(): + self.program_dropdown = ProgramDropdown(dpg) + + for indicator in indicators: + self.indicators[indicator] = Indicator(dpg, indicator) + + for button in buttons: + self.buttons[button] = Button(dpg, button) + + def freeze(self, _, val): + self.pause_scroll = val + + def get_indicator_callback(self, indicator): + return self.indicators[indicator].set + + def set_button_callback(self, button, fn): + self.buttons[button].register_callback(fn) + + def set_program_dropdown_callback(self, fn): + self.program_dropdown.register_callback(fn) + + def add_log(self, item): + color = None + + ltext = item.lower() + if "warning" in ltext: + color = (255, 168, 82) + elif "error" in ltext: + color = (255, 80, 80) + elif "critical" in ltext: + color = (255, 82, 212) + elif "controller" in ltext: + color = (180, 180, 180) + + with self.dpg.table_row(parent=self.logger_box): + self.dpg.add_text(item, color=color) + + def set_programs(self, program_list): + self.program_dropdown.set_programs(program_list) + + def get_log_handler(self): + handler = LogHandler() + handler.set_callback(self.add_log) + formatter = Formatter(**formatter_config) + handler.setFormatter(formatter) + return handler + + def tick(self): + if not self.pause_scroll: + self.dpg.set_y_scroll(self.logger_box, self.dpg.get_y_scroll_max(self.logger_box)) \ No newline at end of file diff --git a/Monitor/message_handler.py b/Monitor/message_handler.py new file mode 100644 index 0000000..024bdfb --- /dev/null +++ b/Monitor/message_handler.py @@ -0,0 +1,54 @@ +""" Processor for incoming sensor data. + +""" +import time + +from zerolib.enums import SensorType, MessageType, SENSOR_UNITS + +class MessageHandler: + """ Handles incoming messages from the controller. + + """ + def __init__(self, units, sensor_config, menu, plot_arrays): + self.units = units + self.sens_cfg = sensor_config + self.menu = menu + self.plt_arrs = plot_arrays + + self.tank_mass_sensor = sensor_config.get_by_type(SensorType.TANK_MASS)[0] + + self.start_time = time.perf_counter() + self.offset = 0 + + def handle_sensor_data(self, msg): + load_cell_data = 0 + + for dp in msg.data: + id, val = dp + sensor = self.sens_cfg.get(s_id=id) + + val = self.units.Quantity(val, SENSOR_UNITS[sensor.get_type()][0]) + plt_arr = self.plt_arrs[sensor.get_tab()] + plt_arr.add_datapoint(id, (msg.timestamp + self.offset, val)) + + if sensor.get_type() == SensorType.LOAD_CELL: + load_cell_data += val + + if load_cell_data != 0: + plt_arr = self.plt_arrs[self.tank_mass_sensor.get_tab()] + plt_arr.add_datapoint( + self.tank_mass_sensor.get_id(), + (msg.timestamp + self.offset, load_cell_data) + ) + + def update_offset(self): + self.offset = time.perf_counter() - self.start_time + + def handle(self, msg): + match msg.get_type(): + case MessageType.SENSOR_DATA: + self.handle_sensor_data(msg) + case MessageType.NOTIFICATION: + self.menu.add_log(f"{msg.notification} (CONTROLLER)") + case MessageType.ENGINE_PROGRAM_SETTINGS: + self.menu.set_programs(msg.payload.split(',')) \ No newline at end of file diff --git a/Monitor/plot.py b/Monitor/plot.py new file mode 100644 index 0000000..3b57dd8 --- /dev/null +++ b/Monitor/plot.py @@ -0,0 +1,139 @@ +import numpy as np +import bisect + +class Plot: + def __init__(self, dpg, units, sensor): + self.dpg = dpg + self.u = units + + self.retain = 10 + + self.desc = sensor.get_name() + self.id = sensor.get_id() + + self.data_range = np.array(sensor.get_range()) + self.xdata = [] + self.ydata = [] + + self.fixed_range = True + self.paused = False + + self.x_axis = None + self.y_axis = None + + self.available_units = sensor.get_units() + self.y_units = self.u(self.available_units[0]) + self.update_units(None, self.available_units[0]) + + # create GUI + with dpg.group(label=self.desc) as window: + self.window = window + + with dpg.plot(anti_aliased=True, width=-1, height=270) as plot: + self.plot = plot + self.x_axis = dpg.add_plot_axis(dpg.mvXAxis, label="Time (s)") + self.y_axis = dpg.add_plot_axis(dpg.mvYAxis, label=self.y_label) + self.series = dpg.add_line_series(self.xdata, self.ydata, parent=self.y_axis) + + with dpg.group(horizontal=True): + if len(self.available_units) > 1: + dpg.add_text("Units") + dpg.add_combo( + items = self.available_units, callback = self.update_units, + default_value = self.available_units[0], width=50 + ) + dpg.add_text(" Window") + else: + dpg.add_text("Window") + + dpg.add_combo( + items = [5, 10, 20, 60, 120, 240], + callback = self.update_x_window, default_value=10, width=45 + ) + + dpg.add_text(" Auto Range") + dpg.add_checkbox(default_value = False, callback = self.toggle_fixed) + + dpg.add_text(" Pause") + dpg.add_checkbox(default_value = False, callback = self.pause) + + self.update_range() + + def update_x_window(self, _, xwin): + self.retain = int(xwin) + + def get_xlim_idx(self, xlim): + xmin, xmax = xlim + + return bisect.bisect_right(self.xdata, xmin), bisect.bisect_left(self.xdata, xmax) + + def update_units(self, _, new_units): + new_units = self.u(new_units) + + self.data_range = self.u.Quantity(self.data_range, self.y_units ).to(new_units).m + self.ydata = list(self.u.Quantity(np.array(self.ydata), self.y_units).to(new_units).m) + + self.y_units = new_units + u_label = f"{new_units.units:~P}" + if u_label != "": + self.y_label = f"{self.desc} [{u_label}]" + else: + self.y_label = f"{self.desc}" + + if self.y_axis: + self.dpg.set_item_label(self.y_axis, self.y_label) + + def update_range(self): + if not self.y_axis or not self.x_axis or not self.xdata or not self.ydata: + return + + li, ri = self.get_xlim_idx(self.dpg.get_axis_limits(self.x_axis)) + + if self.fixed_range: + padding = abs(self.data_range[1]) * 0.025 + self.dpg.set_axis_limits( + self.y_axis, + self.data_range[0] - padding, + self.data_range[1] + padding + ) + else: + ymin = min(self.ydata[li:ri]) + ymax = max(self.ydata[li:ri]) + + padding = 0.01 * max(abs(ymin), abs(ymax)) + if padding == 0: + padding = abs(self.data_range[1]) * 0.025 + + self.dpg.set_axis_limits(self.y_axis, ymin-padding, ymax+padding) + + self.update_series(li, ri) + + if self.paused: + self.dpg.set_axis_limits_auto(self.x_axis) + else: + if len(self.xdata) > 1: + latest_x = self.xdata[-1] + self.dpg.set_axis_limits( + self.x_axis, + max(latest_x-self.retain, self.xdata[0]), + latest_x + ) + + def toggle_fixed(self, _, val): + self.fixed_range = not val + if val: + self.dpg.set_axis_limits_auto(self.y_axis) + + def pause(self, _, val): + self.paused = val + + if not self.paused: + self.update_range() + + def add_datapoint(self, dp): + x, y = dp + self.xdata.append(x) + self.ydata.append(y.to(self.y_units).m) + + def update_series(self, li, ri): + self.dpg.set_value(self.series, [self.xdata[li:ri], self.ydata[li:ri]]) \ No newline at end of file diff --git a/Monitor/plotarray.py b/Monitor/plotarray.py new file mode 100644 index 0000000..325a499 --- /dev/null +++ b/Monitor/plotarray.py @@ -0,0 +1,31 @@ +from plot import Plot + + +class PlotArray: + def __init__(self, dpg, master, units, sensors): + self.dpg = dpg + self.master = master + self.units = units + self.id_mapping = {} + + self.plots = [] + with dpg.table(header_row=False): + [dpg.add_table_column() for _ in range(len(sensors[0]))] + + for i in range(len(sensors)): + self.plots.append([]) + num_plots = len(sensors[i]) + + with dpg.table_row(): + for j in range(num_plots): + sensor = sensors[i][j] + self.id_mapping[sensor.get_id()] = (i, j) + self.plots[-1].append(Plot(dpg, units, sensor)) + + + def add_datapoint(self, sensor_id, datapoint): + i, j = self.id_mapping[sensor_id] + self.plots[i][j].add_datapoint(datapoint) + + def update_plot_ranges(self): + [[plot.update_range() for plot in row] for row in self.plots] \ No newline at end of file diff --git a/Monitor/sensorgrid.py b/Monitor/sensorgrid.py new file mode 100644 index 0000000..d53032a --- /dev/null +++ b/Monitor/sensorgrid.py @@ -0,0 +1,36 @@ +from math import floor + +class SensorGrid: + def __init__(self, sensor_config): + self.sens_cfg = sensor_config + self.grids = [] + + def tile(self, sensors, arr): + num_rows = floor( len(sensors)**0.5 ) + num_cols = len(sensors) // num_rows + + for i in range(num_rows - 1): + arr.append([]) + for j in range(num_cols): + arr[-1].append( + sensors[i*num_cols+j] + ) + + rem = len(sensors) - ( num_rows - 1 ) * num_cols + if rem > 0: + arr.append([]) + for i in range(len(sensors)-rem, len(sensors)): + arr[-1].append( + sensors[i] + ) + + def create_grid(self): + for i in range(self.sens_cfg.get_tab_count()): + self.grids.append([]) + self.tile([ + sensor for sensor in self.sens_cfg.get_sensors() + if sensor.get_tab() == i + ], self.grids[-1]) + + def get_grid(self, i): + return self.grids[i] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..221bf1f --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Zero Test Bench Software Stack +Author: Cristian Bicheru +For questions, feel free to reach out to `c.bicheru0@gmail.com`. + +## Introduction +The hardware stack consists of a laptop and a Raspberry Pi 4B. Each device has +its own software located in the appropriate directories. ZeroMQ is used to link +the monitor software on the laptop to the controller software on the Pi. Onboard +sensors and their settings are defined in `sensors.cfg`. + +`zerolib` is a library containing code used by both the monitor and the +controller. + +The `Engine Test Programs` directory contains valve throttle profiles to be +executed during a cold flow or static fire test. The format is a 4 column +csv file. The first column is the time (in seconds) since the test begins, the +second column holds the desired fuel valve throttle position and the third column +holds the desired oxidizer valve throttle position at this timestep. The last +column controls the torch ignitor state. A 1 turns the ignitor on at this +timestep and a 0 turns it off. Between timesteps, the throttles are linearly +interpolated based on the current time. + +## Testing Procedure +Once the all of the hardware is setup, an ethernet link should be established +between the laptop and the Raspberry Pi. Then, the battery pack is connected to +the electrical box. + +At this point, an SSH link is established to the Pi. The `pigpiod` service is +started using `./start_pigpiod.sh`. Then, the controller is started with +`./start_controller.sh`. Once the controller software is finished initializing, +the monitor should begin to show live data collected from the controller. Any +warnings/errors from the Pi are shown either in the SSH console or the monitor +software. + +## Pi Setup +To improve the reliability and performance of the Pi, it is running a custom +compiled kernel with `PREEMPT_RT` enabled. The Pi is also overlocked to 2 GHz. + +## Video Feed +To establish a live camera feed, connect an android phone to the Pi via USB and +run the `./start_camera.sh` command. This will forward the 8080 port on the +phone to 8081 on the Pi. A camera server (e.g. IP Webcam) can then be run on the +phone (broadcasting on port 8080) and accessed on the laptop. \ No newline at end of file diff --git a/Scripts/convert_to_csv.py b/Scripts/convert_to_csv.py new file mode 100644 index 0000000..5f2438f --- /dev/null +++ b/Scripts/convert_to_csv.py @@ -0,0 +1,24 @@ +""" Convert datalogger output to a standard csv file. +""" +import sys +import zlib + +CHECK_AMT = 10 + +filename = sys.argv[1] +output = sys.argv[2] + +print(f"Reading file {filename} ...") + +with open(filename, "rb") as f: + data = f.read() + +decompressor = zlib.decompressobj() +data = decompressor.decompress(data) + +print(f"Writing to {output} ...") +with open(output, "w") as f: + try: + f.write(data.decode()) + except UnicodeDecodeError: + f.write(data[:-1].decode()) diff --git a/Scripts/plot.py b/Scripts/plot.py new file mode 100644 index 0000000..9a6e83f --- /dev/null +++ b/Scripts/plot.py @@ -0,0 +1,32 @@ +import sys + +import numpy as np +import matplotlib.pyplot as plt + +filename = sys.argv[1] +idx = int(sys.argv[2]) + +print(f"Plotting index {idx} of file {filename}.") + +with open(filename, "r") as f: + data = f.readlines() + col_name = data[0].strip().split(',')[idx] + print(f"Reading column {col_name}...") + + data = [x.strip().split(',') for x in data[1:-1]] + t = np.array([float(x[0]) for x in data if x[idx] != "Ø"]) + y = np.array([float(x[idx]) for x in data if x[idx] != "Ø"]) + + +t_freq = 1. / np.diff(t, 1) +mean_freq = np.mean(t_freq) +stddev_freq = np.std(t_freq) + +print(f"Mean sample frequency: {mean_freq:.3g} Hz") +print(f"Standard deviation: {stddev_freq:.3g} Hz") + +plt.plot(t, y) +plt.title("Sensor Data Plot") +plt.xlabel("Time [s]") +plt.ylabel(col_name) +plt.show() diff --git a/Scripts/stats.py b/Scripts/stats.py new file mode 100644 index 0000000..3c88a12 --- /dev/null +++ b/Scripts/stats.py @@ -0,0 +1,30 @@ +import sys + +import numpy as np + +filename = sys.argv[1] +idx = int(sys.argv[2]) + +print(f"Computing statistics of index {idx} of file {filename}.") + +with open(filename, "r") as f: + data = f.readlines() + col_name = data[0].strip().split(',')[idx] + print(f"Reading column {col_name}...") + + data = [x.strip().split(',') for x in data[1:-1]] + t = np.array([float(x[0]) for x in data if x[idx] != "Ø"]) + y = np.array([float(x[idx]) for x in data if x[idx] != "Ø"]) + + +t_diff = np.diff(t, 1) +t_freq = 1. / t_diff +mean_freq = np.mean(t_freq) +stddev_freq = np.std(t_freq) +mean_timestep = np.mean(t_diff) +max_timestep = np.max(t_diff) + +print(f"Mean sample frequency: {mean_freq:.3g} Hz") +print(f"Standard deviation: {stddev_freq:.3g} Hz") +print(f"Mean sample timestep: {mean_timestep :.3g} s") +print(f"Max sample timestep: {max_timestep: .3g} s") diff --git a/fix_perms.sh b/fix_perms.sh new file mode 100644 index 0000000..442373c --- /dev/null +++ b/fix_perms.sh @@ -0,0 +1,2 @@ +sudo chown root.gpio /dev/gpiomem +sudo chmod g+rw /dev/gpiomem diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..c632e75 --- /dev/null +++ b/pylintrc @@ -0,0 +1,400 @@ +# This Pylint rcfile contains a best-effort configuration to uphold the +# best-practices and style described in the Google Python style guide: +# https://google.github.io/styleguide/pyguide.html +# +# Its canonical open-source location is: +# https://google.github.io/styleguide/pylintrc + +[MAIN] + +# Files or directories to be skipped. They should be base names, not paths. +ignore=third_party + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=no + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=4 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=R, + abstract-method, + apply-builtin, + arguments-differ, + attribute-defined-outside-init, + backtick, + bad-option-value, + basestring-builtin, + buffer-builtin, + c-extension-no-member, + consider-using-enumerate, + cmp-builtin, + cmp-method, + coerce-builtin, + coerce-method, + delslice-method, + div-method, + eq-without-hash, + execfile-builtin, + file-builtin, + filter-builtin-not-iterating, + fixme, + getslice-method, + global-statement, + hex-method, + idiv-method, + implicit-str-concat, + import-error, + import-self, + import-star-module-level, + input-builtin, + intern-builtin, + invalid-str-codec, + locally-disabled, + long-builtin, + long-suffix, + map-builtin-not-iterating, + misplaced-comparison-constant, + missing-function-docstring, + metaclass-assignment, + next-method-called, + next-method-defined, + no-absolute-import, + no-init, # added + no-member, + no-name-in-module, + no-self-use, + nonzero-method, + oct-method, + old-division, + old-ne-operator, + old-octal-literal, + old-raise-syntax, + parameter-unpacking, + print-statement, + raising-string, + range-builtin-not-iterating, + raw_input-builtin, + rdiv-method, + reduce-builtin, + relative-import, + reload-builtin, + round-builtin, + setslice-method, + signature-differs, + standarderror-builtin, + suppressed-message, + sys-max-int, + trailing-newlines, + unichr-builtin, + unicode-builtin, + unnecessary-pass, + unpacking-in-except, + useless-else-on-loop, + useless-suppression, + using-cmp-argument, + wrong-import-order, + xrange-builtin, + zip-builtin-not-iterating, + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=main,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl + +# Regular expression matching correct function names +function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ + +# Regular expression matching correct variable names +variable-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct constant names +const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct attribute names +attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ + +# Regular expression matching correct argument names +argument-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class names +class-rgx=^_?[A-Z][a-zA-Z0-9]*$ + +# Regular expression matching correct module names +module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$ + +# Regular expression matching correct method names +method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=12 + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=80 + +# TODO(https://github.com/pylint-dev/pylint/issues/3352): Direct pylint to exempt +# lines made too long by directives to pytype. + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=(?x)( + ^\s*(\#\ )??$| + ^\s*(from\s+\S+\s+)?import\s+.+$) + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=yes + +# Maximum number of lines in a module +max-module-lines=99999 + +# String used as indentation unit. The internal Google style guide mandates 2 +# spaces. Google's externaly-published style guide says 4, consistent with +# PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google +# projects (like TensorFlow). +# This software will use 4... +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=TODO + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=yes + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging,absl.logging,tensorflow.io.logging + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec, + sets + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant, absl + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls, + class_ + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c0230fd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +pyzmq +dearpygui +numpy +scipy +pint +psutil \ No newline at end of file diff --git a/requirements_pi.txt b/requirements_pi.txt new file mode 100644 index 0000000..9012551 --- /dev/null +++ b/requirements_pi.txt @@ -0,0 +1,11 @@ +pyzmq +numpy +scipy +pint +adafruit-blinka +adafruit-circuitpython-max31855 +adafruit-circuitpython-bitbangio +adafruit-circuitpython-tca9548a +cedargrove-nau7802 +RPi.GPIO +psutil \ No newline at end of file diff --git a/sensors.cfg b/sensors.cfg new file mode 100644 index 0000000..a3fbc84 --- /dev/null +++ b/sensors.cfg @@ -0,0 +1,98 @@ +[Thrust Sensor] +ID = 1 +Type = THRUST +Rate = 80 + +[Tank Mass] +ID = 2 +Type = TANK_MASS + +[Thermocouple 1] +ID = 3 +Type = THERMOCOUPLE +Rate = 10 +Number = 1 +Tab = 3 + +[Thermocouple 2] +ID = 4 +Type = THERMOCOUPLE +Rate = 10 +Number = 2 +Tab = 3 + +[Thermocouple 3] +ID = 5 +Type = THERMOCOUPLE +Rate = 10 +Number = 3 +Tab = 3 + +[Thermocouple 4] +ID = 6 +Type = THERMOCOUPLE +Rate = 10 +Number = 4 +Tab = 3 + +[Thermocouple 5] +ID = 7 +Type = THERMOCOUPLE +Rate = 10 +Number = 5 +Tab = 2 + +[Tank Pressure] +ID = 8 +Type = TANK_PRESSURE +Rate = 200 + +[CC Pressure] +ID = 9 +Type = CC_PRESSURE +Rate = 400 + +[Main Battery Level] +ID = 10 +Type = BATTERY_LEVEL +Rate = 10 +Tab = 2 +Number = 1 + +[Tank Load Cell 1] +ID = 11 +Type = LOAD_CELL +Rate = 80 +Number = 1 +Tab = 2 + +[Tank Load Cell 2] +ID = 12 +Type = LOAD_CELL +Rate = 80 +Number = 2 +Tab = 2 + +[Tank Load Cell 3] +ID = 13 +Type = LOAD_CELL +Rate = 80 +Number = 3 +Tab = 2 + +[Fuel Valve Throttle] +ID = 14 +Type = FUEL_VALVE_THROTTLE +Rate = 80 + +[Oxidizer Valve Throttle] +ID = 15 +Type = OXIDIZER_VALVE_THROTTLE +Rate = 80 + +[Servo Battery Level] +ID = 16 +Type = BATTERY_LEVEL +#Rate = 10 +Tab = 2 +Number = 2 \ No newline at end of file diff --git a/start_camera.sh b/start_camera.sh new file mode 100644 index 0000000..4598c44 --- /dev/null +++ b/start_camera.sh @@ -0,0 +1,3 @@ +adb forward tcp:8080 tcp:8080 +screen -S gnirehtet -dm bash -c 'gnirehtet run; exec sh' +screen -S socat -dm socat tcp-listen:8081,reuseaddr,fork tcp:localhost:8080 diff --git a/start_controller.sh b/start_controller.sh new file mode 100644 index 0000000..8547ff3 --- /dev/null +++ b/start_controller.sh @@ -0,0 +1,6 @@ +./fix_perms.sh +cd Controller +taskset -c 3 python3.11 main.py "$@" --dest ${SSH_CLIENT%% *} & +sudo renice -n -20 -p $! +sudo cpufreq-set -r --governor performance +wait diff --git a/start_pigpiod.sh b/start_pigpiod.sh new file mode 100644 index 0000000..4abe98f --- /dev/null +++ b/start_pigpiod.sh @@ -0,0 +1 @@ +sudo nice -n -20 taskset -c 2 pigpiod diff --git a/zerolib/__init__.py b/zerolib/__init__.py new file mode 100644 index 0000000..7a61f12 --- /dev/null +++ b/zerolib/__init__.py @@ -0,0 +1 @@ +LIB_VERSION_STRING = "v1.0" \ No newline at end of file diff --git a/zerolib/ads1120.py b/zerolib/ads1120.py new file mode 100644 index 0000000..9e00823 --- /dev/null +++ b/zerolib/ads1120.py @@ -0,0 +1,107 @@ +""" High throughput ADS1120 driver. + +Does not support all chip features. This driver runs the chip in "turbo mode" +with all inputs assumed to be single-ended, and an analog vref provided. +""" +import time + +from adafruit_bus_device.spi_device import SPIDevice +from busio import SPI +from digitalio import DigitalInOut + +CMD_NOP = 0xFF +CMD_WREG = 0x40 +CMD_RREG = 0x20 +CMD_RESET = 0x06 +CMD_START_SYNC = 0x08 + +class ADS1120: + """ Baseline high-throughput ADS1120 driver. + + Does not support all features on the chip. This particular driver runs the + ADC in turbo mode at 2k SPS. The gain is set to 1 and the PGA bypassed. No + 50/60 Hz rejection is performed and the voltage reference is set to AVDD. + + Many implementations seem to add random delays everywhere. They are not + necessary except after the chip resets and in order to give the chip time to + acquire a new reading. + """ + def __init__(self, spi : SPI, cs : DigitalInOut): + # IMPORTANT: phase=1 + self.dev = SPIDevice(spi, cs, baudrate=1000000, phase=1) + + # Remember the multiplexer state, switch if necessary to perform a read. + self.mux_state = None + + def initialize(self): + self.reset() + # Chip needs 50us, we will give it 1ms + time.sleep(0.001) + + # REG0: MUX[3:0], GAIN[2:0], PGA_BYPASS + self.write_register(0, 0b10000001) + # REG1: DR[2:0], MODE[1:0], CM, TS, BCS + self.write_register(1, 0b11010100) + # REG2: VREF[1:0], 50/60[1:0], PSW, IDAC[2:0] + self.write_register(2, 0b11000000) + # REG3: I1MUX[2:0], I2MUC[2:0], DRDYM, 0 + # Defaults are ok. + + self.start_sync() + + def read(self, mux_state): + if self.mux_state != mux_state: + self.set_multiplexer(mux_state) + time.sleep(1/1800) # Allow the sensor time to acquire a reading + + return self._read() + + def reset(self): + self.send_command(CMD_RESET) + + def start_sync(self): + self.send_command(CMD_START_SYNC) + + def _read(self): + rdata = bytearray(2) + + with self.dev as spi: + spi.readinto(rdata, write_value=CMD_NOP) + + val = (rdata[0] << 8) | rdata[1] + return val / 2**15 * 5 # Converted to voltage + + def read_register(self, address): + recv = bytearray(1) + + with self.dev as spi: + spi.write([ (address<<2) | CMD_RREG ]) + spi.readinto(recv, write_value=CMD_NOP) + + return recv[0] + + def send_command(self, cmd): + with self.dev as spi: + spi.write([cmd]) + + def write_register(self, address, byte): + with self.dev as spi: + spi.write([ + (address<<2) | CMD_WREG, + byte + ]) + + def print_registers(self): + reg0 = self.read_register(0) + reg1 = self.read_register(1) + reg2 = self.read_register(2) + reg3 = self.read_register(3) + print("----REGISTERS----") + print(reg0, reg1, reg2, reg3) + + def set_multiplexer(self, value): + if not isinstance(value, int) or not 0 <= value <= 3: + raise RuntimeError(f"Bad mux value {value}.") + + self.mux_state = value + self.write_register(0, 0b10000001 + (value<<4)) \ No newline at end of file diff --git a/zerolib/communications.py b/zerolib/communications.py new file mode 100644 index 0000000..489e22a --- /dev/null +++ b/zerolib/communications.py @@ -0,0 +1,302 @@ +"""Handles communications between the controller and monitor software. + +The communications library defines the MessageServer class, used to create a +bidrectional link between two applications. The link is powered by a PyZMQ +socket. Only zerolib.Message instances can be sent across the link. The messages +are serialized to a bytearray before transmission to save bandwidth (data is not +sent as plaintext). +""" +import zmq +import time +import logging +import traceback + +from threading import Thread, Event +from queue import Queue + +from zerolib.message import Message, MessageType + +logger = logging.getLogger(__name__) + +# Heartbeat constants +PING_BYTES = b"ZERO PING" +HEARTBEAT_HZ = 10 + +# Default port mappings +DEFAULT_MONITOR_PORT = 9376 +DEFAULT_CONTROLLER_PORT = 9378 + +class ThreadedBidirectionalSocket: + """ Threaded wrapper around a PyZMQ socket. + + PyZMQ sockets are not thread-safe so they cannot be used by more than one + thread. This class solves this issue by caching send/recv operations into + a queue (caching operations are thread-safe) and executing them in a single + thread. + + Caching is done in the send_queue and receive_queue. Incoming requests + are detected by the Poller object and then pushed to the receive_queue. + Outgoing requests are cached to the send_queue and pushed as fast as + possible. + + NOTE: Binding the socket or connecting to a destination should be done + before creating an instance. + """ + + def __init__(self, context): + self.context = context + self.host = None + self.dest = None + + self.send_queue = Queue() + self.receive_queue = Queue() + + self.push_thread = None + self.pull_thread = None + self.running = False + + def send(self, msg): + self.send_queue.put(msg) + + def recv(self): + return self.receive_queue.get() + + def bind(self, host): + if self.running: + raise RuntimeError("Attempted to bind socket while it is running!") + self.host = host + + def connect(self, dest): + if self.running: + raise RuntimeError("Attempted to connect socket while it is running!") + self.dest = dest + + def recv_loop(self): + pull_socket = self.context.socket(zmq.PULL) + pull_socket.setsockopt(zmq.CONFLATE, 1) + + if self.host: + pull_socket.bind(self.host[0]) + elif self.dest: + pull_socket.connect(self.dest[1]) + + while self.running: + self.receive_queue.put(pull_socket.recv()) + + def send_loop(self): + push_socket = self.context.socket(zmq.PUSH) + push_socket.setsockopt(zmq.CONFLATE, 1) + push_socket.setsockopt(zmq.IMMEDIATE, 1) + + if self.host: + push_socket.bind(self.host[1]) + elif self.dest: + push_socket.connect(self.dest[0]) + + while self.running: + push_socket.send(self.send_queue.get()) + + def run(self): + if not self.host and not self.dest: + raise RuntimeError("Attempted to spawn an unconnected socket!") + + self.push_thread = Thread( + target=self.send_loop, + daemon=True, name="ThreadedSocketPushThread" + ) + self.pull_thread = Thread( + target=self.recv_loop, + daemon=True, name="ThreadedSocketPullThread" + ) + self.running = True + self.push_thread.start() + self.pull_thread.start() + + def stop(self): + self.running = False + self.push_thread.join() + self.pull_thread.join() + + +class MessageServer: + """ + Bidirectional Zero MQ server. Both the controller and monitor run an + instance of this class to communicate. A simple zmq pair type socket is used + instead of a traditional TCP socket. + + NOTE: Zero MQ sockets are **NOT** thread-safe!! So, socket operations must + be handled by a single thread. To facilitate this, a ThreadedSocket object + is used, which is just a zmq Socket wrapped in a send/receive queue. + """ + def __init__(self, host=None, port=None, timeout=0.5): + # Create a ZMQ context, allowing up to 4 threads to be used for I/O + self.context = zmq.Context(4) + self.socket = ThreadedBidirectionalSocket(self.context) + + # Only the server needs to bind to a port + if host and port: + self.socket.bind([ + f"tcp://{host}:{port}", + f"tcp://{host}:{port+1}" + ]) + logger.info(f"Server running at {host}, ports {port}, {port+1}.") + else: + logger.info("Client ZMQ socket initialized.") + + # Function hooks on request or connection status change + self.request_hook = None + self.connection_hook = None + + # Used to determine connection status changes + self.connection_event = Event() + self.last_msg_time = 0 + self.timeout = timeout + self.connection_status = False + + # Three threads are required for server operation. The self.running + # variable is continutally checked against within the thread loops. + # Setting it to false will terminate threads. + self.threads = [None, None, None] + self.running = False # Lock not required, Python assignments are atomic. + + def connect(self, host, port): + """ + Connect to another MessageServer. + """ + self.socket.connect([ + f"tcp://{host}:{port}", + f"tcp://{host}:{port+1}" + ]) + logger.info(f"Connecting to {host}, ports {port}, {port+1}...") + + def dispatch(self, msg, log=True): + """ + Send a zerolib.Message to another MessageServer. + """ + try: + self.socket.send(msg.to_bytes()) + except: + logger.error("Error sending message.") + self.format_traceback() + + if log and msg.get_type() != MessageType.SENSOR_DATA: + logger.debug(f"Sent message of type {msg.get_type()}.") + + def register_request_hook(self, fn): + self.request_hook = fn + logger.info("Registered request hook.") + + def register_connection_hook(self, fn): + self.connection_hook = fn + logger.info("Registered connection hook.") + + def format_traceback(self): + print() + print("-"*10 + " TRACEBACK " + "-"*10) + print(traceback.format_exc()) + print("-"*31) + print() + + def update_connection_hook(self, status): + if self.connection_hook: + try: + self.connection_hook(status) + except: + logger.error( + "Error calling connection status hook with status {status}." + ) + self.format_traceback() + + def connection_polling_loop(self): + """ + The code waits until the connection event is triggered, executes the + connection hook if it exists, then it sleeps for the timeout duration and + if the connection has been dropped, it runs the appropriate callback. + """ + while self.running: + # Blocks until the event is triggered by the receiver loop + self.connection_event.wait() + + logger.info("Client connected.") + self.connection_status = True + self.update_connection_hook(True) + + while self.running: + # Sleep for self.timeout seconds. If no message has been received + # within the timespan, consider the connection to be timed out. + time.sleep(self.timeout) + + if time.perf_counter() - self.last_msg_time > self.timeout: + self.connection_event.clear() + logger.warning("Client timed out.") + self.connection_status = False + self.update_connection_hook(False) + break + + def receiver_loop(self): + """ + Main receiver loop. The code blocks until a message has been received by + the ZMQ socket. Then, if it is a simple ping message, it is ignored. + Otherwise, attempt to deserialize the bytes into a Message instance. + """ + while self.running: + # Blocks until a message has been received + msg_bytes = self.socket.recv() + + # Update the connection status + self.connection_event.set() + self.last_msg_time = time.perf_counter() + + if msg_bytes == PING_BYTES: + # This is just a heartbeat signal, don't proceed + continue + + try: + msg = Message.from_bytes(msg_bytes) + except: + logger.error("Error decoding message.") + # Dump the traceback into the console for debugging + self.format_traceback() + return + + # Execute the callback with the deserialized message + if self.request_hook: + try: + self.request_hook(msg) + except: + logger.error( + f"Failed to execute request hook on {msg.get_type()}." + ) + self.format_traceback() + + def heartbeat_loop(self): + """ + Continously ping the other server. + """ + while self.running: + self.socket.send(PING_BYTES) + time.sleep(1/HEARTBEAT_HZ) + + def run(self): + self.threads = [ + Thread( + target=self.connection_polling_loop, + daemon=True, name="MessageServerPollingThread" + ), + Thread( + target=self.receiver_loop, + daemon=True, name="MessageServerReceivingThread" + ), + Thread( + target=self.heartbeat_loop, + daemon=True, name="MessageServerHeartbeatThread" + ) + ] + self.running = True + self.socket.run() + [thd.start() for thd in self.threads] + + def stop(self): + self.running = False + [thd.join() for thd in self.threads] + self.socket.stop() diff --git a/zerolib/datalogging.py b/zerolib/datalogging.py new file mode 100644 index 0000000..8ad4dcc --- /dev/null +++ b/zerolib/datalogging.py @@ -0,0 +1,66 @@ +""" Datalogging class. Resistant to ctrl-c or process termination. Thread-safe. + +Also implements the LogLogger class for writing logs to disk. +""" +import zlib +import datetime +import logging + +from threading import Thread +from queue import Queue, Empty + +class DataLogger: + def __init__(self, filename=None, debug=False, prefix=None): + if not filename: + filename = datetime.datetime.today().strftime('%Y %b %d %I.%M %p') + + if debug: + filename = f"DEBUG {filename}.csv.gz" + else: + filename = f"{filename}.csv.gz" + + if prefix: + filename = f"{prefix} {filename}" + + self.filename = filename + self.data_queue = Queue() + self.thread = None + self.running = False + + def add_row(self, row): + self.data_queue.put((row + "\n").encode()) + + def mainloop(self): + compressor = zlib.compressobj(level=3) + + with open(f"Data/{self.filename}", mode="wb") as file: + while self.running: + try: + file.write(compressor.compress(self.data_queue.get(timeout=1))) + except Empty: + pass + file.write(compressor.flush()) + + def start(self): + self.thread = Thread(target=self.mainloop, name="DataLoggerThread", daemon=True) + self.running = True + self.thread.start() + + def close(self): + self.running = False + self.thread.join() + + +class LogLogger(logging.Handler, DataLogger): + """ Write all logs to disk. + + """ + def __init__(self, filename=None, debug=False): + DataLogger.__init__(self, filename, debug, "LOG") + logging.Handler.__init__(self) + + def cleanup(self): + DataLogger.close(self) + + def handle(self, record): + self.add_row(self.format(record)) diff --git a/zerolib/enums.py b/zerolib/enums.py new file mode 100644 index 0000000..7491391 --- /dev/null +++ b/zerolib/enums.py @@ -0,0 +1,109 @@ +""" Standard enums used by zerolib. + +Common enums are stored here. +""" +from enum import Enum + +### MESSAGE TYPES +class MessageType(Enum): + SENSOR_DATA = 1 + ACTION = 2 + NOTIFICATION = 3 + ENGINE_PROGRAM_SETTINGS = 4 + +### ACTIONS +class ActionType(Enum): + OPEN_FILL = 1 + CLOSE_FILL = 2 + ENABLE_TANK_HEATING = 3 + DISABLE_TANK_HEATING = 4 + FIRE_IGNITOR = 5 + SAFE_IGNITOR = 6 + BEGIN_BURN_PHASE = 7 + ABORT_BURN_PHASE = 8 + ABORT = 9 + OPEN_VENT = 10 + CLOSE_VENT = 11 + +### SENSORS +class SensorType(Enum): + THRUST = 1 + TANK_MASS = 2 + THERMOCOUPLE = 3 + TANK_PRESSURE = 4 + CC_PRESSURE = 5 + BATTERY_LEVEL = 6 + LOAD_CELL = 7 + FUEL_VALVE_THROTTLE = 8 + OXIDIZER_VALVE_THROTTLE = 9 + MDOT = 10 + +# The first value is used for logging and communications, as well as the default +# for plotting. +SENSOR_UNITS = { + SensorType.THRUST : ["lbf", "kN"], + SensorType.TANK_MASS : ["kg", "lb"], + SensorType.THERMOCOUPLE : ["degC", "K"], + SensorType.TANK_PRESSURE : ["psi", "MPa", "bar"], + SensorType.CC_PRESSURE : ["psi", "MPa", "bar"], + SensorType.BATTERY_LEVEL : ["dimensionless"], + SensorType.LOAD_CELL : ["kg", "lb"], + SensorType.FUEL_VALVE_THROTTLE : ["dimensionless"], + SensorType.OXIDIZER_VALVE_THROTTLE : ["dimensionless"], + SensorType.MDOT : ["kg/s", "g/s"] +} + +# The range should be specified in the default units. +SENSOR_RANGE = { + SensorType.THRUST : [-20, 220], + SensorType.TANK_MASS : [0, 35], + SensorType.THERMOCOUPLE : [-20, 300], + SensorType.TANK_PRESSURE : [0, 1000], + SensorType.CC_PRESSURE : [0, 300], + SensorType.BATTERY_LEVEL : [0, 100], + SensorType.LOAD_CELL : [0, 10], + SensorType.FUEL_VALVE_THROTTLE : [0, 1], + SensorType.OXIDIZER_VALVE_THROTTLE : [0, 1], + SensorType.MDOT : [0, 1] +} + +# Standard deviation of measured noise in default units. +SENSOR_NOISE = { + SensorType.THRUST : 1, + SensorType.TANK_MASS : 0.1, + SensorType.THERMOCOUPLE : 0.1, + SensorType.TANK_PRESSURE : 10, + SensorType.CC_PRESSURE : 3, + SensorType.BATTERY_LEVEL : 1, + SensorType.LOAD_CELL : 0.1, + SensorType.FUEL_VALVE_THROTTLE : 0, + SensorType.OXIDIZER_VALVE_THROTTLE : 0, + SensorType.MDOT : 0.01 +} + +# The type of the raw reading supplied by the sensor board. See the +# documentation for the python library 'struct' for information about the type. +SENSOR_READING_TYPE = { + SensorType.THRUST : "I", + SensorType.THERMOCOUPLE : "H", + SensorType.TANK_PRESSURE : "H", + SensorType.CC_PRESSURE : "H", + SensorType.BATTERY_LEVEL : "H", + SensorType.LOAD_CELL : "I" + +} + +def get_default_sensor_units(u): + return { + stype : u( SENSOR_UNITS[stype][0] ) for stype in SensorType + } + +def get_estimated_sensor_noise(u): + du = get_default_sensor_units(u) + #for stype in du: + # if du[stype].is_compatible_with(u("K")): + # du[stype] = u(f"delta_{str(du[stype].units)}") + + return { + stype : u.Quantity(SENSOR_NOISE[stype], du[stype]) for stype in SensorType + } diff --git a/zerolib/message.py b/zerolib/message.py new file mode 100644 index 0000000..4a5236f --- /dev/null +++ b/zerolib/message.py @@ -0,0 +1,192 @@ +""" Defines the Message ABC and its implementations. + +These messages are transmitted over a MessageServer. Messages can be serialized +and deserialized for lower bandwidth requirements. The following message types +exist: + SensorDataMessage - Arbitrary length message containing sensor datapoints. + ActionMessage - Carries only an item from the ActionType enum. + NotificationMessage - Carries a string. Encoded/decoded with UTF-8. +""" +import struct +import logging + +from abc import ABC, abstractmethod + +from zerolib.enums import MessageType, ActionType, SENSOR_READING_TYPE + +logger = logging.getLogger(__name__) + +# Each sensor data point is formatted as follows: +# Byte 1: Sensor ID (uchar8) +# Byte 2-5: Sensor Reading (float64) +SENSOR_DATA_FORMAT = struct.Struct(" str: + return self.name + + def get_type(self) -> SensorType: + return self.type + + def get_id(self) -> int: + return self.s_id + + def get_rate(self) -> int | None: + return self.rate + + def get_number(self) -> int | None: + return self.number + + def get_tab(self) -> int: + return self.tab + + def get_units(self) -> list[str]: + return SENSOR_UNITS[self.type] + + def get_range(self) -> list[float]: + return SENSOR_RANGE[self.type] + + def get_noise(self) -> float: + return SENSOR_NOISE[self.type] + + def get_reading_type(self) -> str: + return SENSOR_READING_TYPE[self.type] diff --git a/zerolib/signal.py b/zerolib/signal.py new file mode 100644 index 0000000..760b1d4 --- /dev/null +++ b/zerolib/signal.py @@ -0,0 +1,47 @@ +""" Real-time data interpretation algorithms. +""" +import numpy as np + +from scipy import signal + +def ema(arr, k=0.3): + ret = [arr[0]] + for x in arr[1:]: + ret.append(x * k + ret[-1] * (1-k)) + return np.array(ret) + +def get_crit_freq(sr): + return (sr/1000)**0.5 * 5 + +class GenericSensorDenoiser: + """ Denoising algorithms for analog sensors. + + """ + def __init__(self, sample_rate): + self.freq = sample_rate + self.filter = signal.butter(3, get_crit_freq(sample_rate), output="sos", fs=sample_rate) + self.lookback = max(int(self.freq**0.75), 20) + + self.signal = [] + self.filtered_signal = [] + + def update(self, y): + if not self.signal: + self.signal = [y for _ in range(self.lookback)] + + self.signal.append(y) + sample = self.signal[-self.lookback:] + + output = signal.sosfiltfilt(self.filter, sample)[-1] + self.filtered_signal.append(output) + + return output + + def get_value(self): + if self.filtered_signal: + return self.filtered_signal[-1] + else: + return 0 + + def get_curve(self): + return self.filtered_signal \ No newline at end of file diff --git a/zerolib/standard.py b/zerolib/standard.py new file mode 100644 index 0000000..9c5e9cf --- /dev/null +++ b/zerolib/standard.py @@ -0,0 +1,31 @@ +""" +This file contains code which appears across the Monitor/Controller codebases +for the purpose of standardization. +""" +import os +import logging + +logging_config = { + "format" : "%(asctime)s | %(levelname)s: %(message)s [%(name)s line %(lineno)d in %(threadName)s]", + "level" : logging.INFO, + "datefmt" : "%m/%d/%Y %I:%M:%S %p" +} + +formatter_config = { + "fmt" : logging_config["format"], + "datefmt" : logging_config["datefmt"] +} + +def get_sensor_cfg_location(): + """ + The sensors.cfg file is intended to be in the root directory, so just + recurse backwards until it exists. + """ + path = "." + + while "sensors.cfg" not in os.listdir(path): + path = f"../{path}" + + return path[:-1] + "sensors.cfg" + +sensor_cfg_location = get_sensor_cfg_location()