Skip to content

New State of Health #235

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 1 addition & 42 deletions pysquared/functions.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that you're removing things from functions.py. Thank you!

Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
import random
import time

import microcontroller

from .cdh import CommandDataHandler
from .config.config import Config
from .logger import Logger
Expand All @@ -24,7 +22,7 @@
from .watchdog import Watchdog

try:
from typing import List, OrderedDict
from typing import List
except Exception:
pass

Expand Down Expand Up @@ -111,45 +109,6 @@ def beacon(self) -> None:
def joke(self) -> None:
self.radio.send(random.choice(self.jokes))

def format_state_of_health(self, hardware: OrderedDict[str, bool]) -> str:
to_return: str = ""
for key, value in hardware.items():
to_return = to_return + key + "="
if value:
to_return += "1"
else:
to_return += "0"

if len(to_return) > 245:
return to_return

return to_return

def state_of_health(self) -> None:
self.state_list: list = []
# list of state information
try:
self.state_list: list[str] = [
f"PM:{self.cubesat.power_mode}",
f"VB:{self.cubesat.battery_voltage}",
f"ID:{self.cubesat.current_draw}",
f"IC:{self.cubesat.charge_current}",
f"UT:{self.cubesat.get_system_uptime}",
f"BN:{self.cubesat.boot_count.get()}",
f"MT:{microcontroller.cpu.temperature}",
f"RT:{self.radio.get_temperature()}",
f"AT:{self.imu.get_temperature()}",
f"BT:{self.last_battery_temp}",
f"EC:{self.logger.get_error_count()}",
f"AB:{int(self.cubesat.f_burned.get())}",
f"BO:{int(self.cubesat.f_brownout.get())}",
f"FK:{self.radio.get_modulation()}",
]
except Exception as e:
self.logger.error("Couldn't aquire data for the state of health: ", e)

self.radio.send("State of Health " + str(self.state_list))

def send_face(self) -> None:
"""Calls the data transmit function from the radio manager class"""

Expand Down
174 changes: 174 additions & 0 deletions pysquared/state_of_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
from collections import OrderedDict

import microcontroller

from pysquared.logger import Logger
from pysquared.protos.imu import IMUProto
from pysquared.protos.power_monitor import PowerMonitorProto
from pysquared.protos.radio import RadioProto

try:
from typing import Any, OrderedDict

from .nvm.counter import Counter
from .nvm.flag import Flag

except Exception:
pass


class StateOfHealth:
def __init__(
self,
logger: Logger,
battery_power_monitor: PowerMonitorProto,
solar_power_monitor: PowerMonitorProto,
radio_manager: RadioProto,
imu_manager: IMUProto,
boot_count: Flag,
burned_flag: Flag,
brownout_flag: Flag,
fsk_flag: Flag,
) -> None:
self.logger: Logger = logger
self.battery_power_monitor: PowerMonitorProto = battery_power_monitor
self.solar_power_monitor: PowerMonitorProto = solar_power_monitor
self.radio_manager: RadioProto = radio_manager
self.imu_manager: IMUProto = imu_manager
self.boot_count: Counter = boot_count
self.burned_flag: Flag = burned_flag
self.brownout_flag: Flag = brownout_flag
self.fsk_flag: Flag = fsk_flag

self.state: OrderedDict[str, Any] = OrderedDict(
[
# init all values in ordered dict to None
("system_voltage", None),
("system_current", None),
("solar_voltage", None),
("solar_current", None),
("battery_voltage", None),
("radio_temperature", None),
("radio_modulation", None),
("microcontroller_temperature", None),
("internal_temperature", None),
("error_count", None),
("boot_count", None),
("burned_flag", None),
("brownout_flag", None),
("fsk_flag", None),
]
)

def update(self):
"""
Update the state of health
"""
try:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking over the calls in this try/except I think that none of these calls have the possibility of raising an exception. What are you seeing?

self.state["system_voltage"] = self.system_voltage()
self.state["system_current"] = self.current_draw()
self.state["solar_voltage"] = self.solar_voltage()
self.state["solar_current"] = self.charge_current()
self.state["battery_voltage"] = self.battery_voltage()
self.state["radio_temperature"] = self.radio_manager.get_temperature()
self.state["radio_modulation"] = self.radio_manager.get_modulation()
self.state["microcontroller_temperature"] = microcontroller.cpu.temperature
self.state["internal_temperature"] = self.imu_manager.get_temperature()
self.state["error_count"] = self.logger.get_error_count()
self.state["boot_count"] = self.boot_count.get()
self.state["burned_flag"] = self.burned_flag.get()
self.state["brownout_flag"] = self.brownout_flag.get()
self.state["fsk_flag"] = self.fsk_flag.get()

except Exception as e:
self.logger.error("Couldn't acquire data for state of health", err=e)

self.logger.info("State of Health", state=self.state)

def system_voltage(self) -> float | None:
"""
Get the system voltage

:return: The system voltage in volts
:rtype: float | None

"""
if self.battery_power_monitor is not None:
voltage: float = 0
try:
for _ in range(50):
voltage += (
self.battery_power_monitor.get_bus_voltage()
+ self.battery_power_monitor.get_shunt_voltage()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does this idea for adding these two voltages together come from?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was based on the legacy implementation that we took from Max Holliday's PyCubed!

Good flag on this Nate, because looking at it again I realized that the implementation of system_voltage() and battery_voltage() is incorrect. The legacy PyCubed implementation uses an ADM1176 power monitor chip whereas our PySquared boards use the INA219 power monitor chip.

This code should actually be changed so the bus_voltage() + shunt_voltage() call is in the battery_voltage() instead. This is explained a bit in Adafruit's guide for the INA219:

    # INA219 measure bus voltage on the load side. So PSU voltage = bus_voltage + shunt_voltage
    print("Voltage (VIN+) : {:6.3f}   V".format(bus_voltage + shunt_voltage))

system_voltage() should just be based on bus_voltage, which is the actual amount of voltage available to the system vs what the voltage of the batteries is.

)
return voltage / 50
except Exception as e:
self.logger.error("Couldn't acquire system voltage", err=e)

def battery_voltage(self) -> float | None:
"""
Get the battery voltage

:return: The battery voltage in volts
:rtype: float | None

"""
if self.battery_power_monitor is not None:
voltage: float = 0
try:
for _ in range(50):
voltage += self.battery_power_monitor.get_bus_voltage()
return voltage / 50 + 0.2 # volts and correction factor
except Exception as e:
self.logger.error("Couldn't acquire battery voltage", err=e)

def current_draw(self) -> float | None:
"""
Get the current draw

:return: The current draw in amps
:rtype: float | None

"""
if self.battery_power_monitor is not None:
current: float = 0
try:
for _ in range(50):
current += self.battery_power_monitor.get_current()
return current / 50
except Exception as e:
self.logger.error("Couldn't acquire current draw", err=e)

def charge_current(self) -> float | None:
"""
Get the charge current

:return: The charge current in amps
:rtype: float | None

"""
if self.solar_power_monitor is not None:
current: float = 0
try:
for _ in range(50):
current += self.solar_power_monitor.get_current()
return current / 50
except Exception as e:
self.logger.error("Couldn't acquire charge current", err=e)

def solar_voltage(self) -> float | None:
"""
Get the solar voltage

:return: The solar voltage in volts
:rtype: float | None

"""
if self.solar_power_monitor is not None:
voltage: float = 0
try:
for _ in range(50):
voltage += self.solar_power_monitor.get_bus_voltage()
return voltage / 50
except Exception as e:
self.logger.error("Couldn't acquire solar voltage", err=e)
121 changes: 121 additions & 0 deletions tests/unit/test_state_of_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from unittest.mock import MagicMock

import microcontroller
import pytest

from mocks.circuitpython.byte_array import ByteArray
from pysquared.logger import Logger
from pysquared.nvm.counter import Counter
from pysquared.nvm.flag import Flag
from pysquared.protos.imu import IMUProto
from pysquared.protos.power_monitor import PowerMonitorProto
from pysquared.protos.radio import RadioProto
from pysquared.state_of_health import StateOfHealth


@pytest.fixture
def state_of_health():
# Mock dependencies
logger = MagicMock(spec=Logger)
battery_power_monitor = MagicMock(spec=PowerMonitorProto)
solar_power_monitor = MagicMock(spec=PowerMonitorProto)
radio_manager = MagicMock(spec=RadioProto)
imu_manager = MagicMock(spec=IMUProto)
radio_manager.get_temperature = MagicMock(return_value=25.0)
imu_manager.get_temperature = MagicMock(return_value=30.0)
boot_count = Counter(index=0, datastore=ByteArray(size=8))
burned_flag = Flag(index=0, bit_index=1, datastore=ByteArray(size=8))
brownout_flag = Flag(index=0, bit_index=2, datastore=ByteArray(size=8))
fsk_flag = Flag(index=0, bit_index=3, datastore=ByteArray(size=8))
microcontroller.cpu = MagicMock()

# Create StateOfHealth instance
return StateOfHealth(
logger=logger,
battery_power_monitor=battery_power_monitor,
solar_power_monitor=solar_power_monitor,
radio_manager=radio_manager,
imu_manager=imu_manager,
boot_count=boot_count,
burned_flag=burned_flag,
brownout_flag=brownout_flag,
fsk_flag=fsk_flag,
)


def test_system_voltage(state_of_health):
state_of_health.battery_power_monitor.get_bus_voltage.return_value = 3.7
state_of_health.battery_power_monitor.get_shunt_voltage.return_value = 0.1

result = state_of_health.system_voltage()

assert result == pytest.approx(3.8, rel=1e-2)
assert state_of_health.battery_power_monitor.get_bus_voltage.call_count == 50
assert state_of_health.battery_power_monitor.get_shunt_voltage.call_count == 50


def test_battery_voltage(state_of_health):
state_of_health.battery_power_monitor.get_bus_voltage.return_value = 3.7

result = state_of_health.battery_voltage()

assert result == pytest.approx(3.9, rel=1e-2)
assert state_of_health.battery_power_monitor.get_bus_voltage.call_count == 50


def test_current_draw(state_of_health):
state_of_health.battery_power_monitor.get_current.return_value = 1.5

result = state_of_health.current_draw()

assert result == pytest.approx(1.5, rel=1e-2)
assert state_of_health.battery_power_monitor.get_current.call_count == 50


def test_charge_current(state_of_health):
state_of_health.solar_power_monitor.get_current.return_value = 2.0

result = state_of_health.charge_current()

assert result == pytest.approx(2.0, rel=1e-2)
assert state_of_health.solar_power_monitor.get_current.call_count == 50


def test_solar_voltage(state_of_health):
state_of_health.solar_power_monitor.get_bus_voltage.return_value = 5.0

result = state_of_health.solar_voltage()

assert result == pytest.approx(5.0, rel=1e-2)
assert state_of_health.solar_power_monitor.get_bus_voltage.call_count == 50


def test_update(state_of_health):
# Mock return values for all dependencies
state_of_health.system_voltage = MagicMock(return_value=3.8)
state_of_health.current_draw = MagicMock(return_value=1.5)
state_of_health.solar_voltage = MagicMock(return_value=5.0)
state_of_health.charge_current = MagicMock(return_value=2.0)
state_of_health.battery_voltage = MagicMock(return_value=3.9)
state_of_health.radio_manager.get_temperature.return_value = 25.0
state_of_health.radio_manager.get_modulation.return_value = "FSK"
state_of_health.imu_manager.get_temperature.return_value = 30.0
state_of_health.logger.get_error_count.return_value = 0
microcontroller.cpu.temperature = 45.0

state_of_health.update()

assert state_of_health.state["system_voltage"] == 3.8
assert state_of_health.state["system_current"] == 1.5
assert state_of_health.state["solar_voltage"] == 5.0
assert state_of_health.state["solar_current"] == 2.0
assert state_of_health.state["battery_voltage"] == 3.9
assert state_of_health.state["radio_temperature"] == 25.0
assert state_of_health.state["radio_modulation"] == "FSK"
assert state_of_health.state["internal_temperature"] == 30.0
assert state_of_health.state["microcontroller_temperature"] == 45.0
assert state_of_health.state["error_count"] == 0
assert state_of_health.state["boot_count"] == 0
assert state_of_health.state["burned_flag"] is False
assert state_of_health.state["brownout_flag"] is False
assert state_of_health.state["fsk_flag"] is False
Loading