#
# ISC License
#
# Copyright (c) 2021, Autonomous Vehicle Systems Lab, University of Colorado at Boulder
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#
#
# Unit Test Script
# Module Name: motorThermal
# Author: João Vaz Carneiro
# Creation Date: March 4, 2021
#
import numpy as np
import pytest
from Basilisk.utilities import SimulationBaseClass
from Basilisk.simulation import motorThermal
from Basilisk.simulation import reactionWheelStateEffector
from Basilisk.simulation import spacecraft
from Basilisk.architecture import messaging
from Basilisk.architecture.bskLogging import BasiliskError
from Basilisk.utilities import macros
from Basilisk.utilities import simIncludeRW
# Fixed module configuration shared by the scenarios below. Each value is held
# constant so the expected temperature can be derived analytically from the
# module's heat-balance equations rather than from a stored regression vector.
DT = 0.1 # [s] task update period
N_STEPS = 10 # number of update steps simulated per scenario
THERMAL_RESISTANCE = 10.0 # [Celsius/W] ambient thermal resistance
HEAT_CAPACITY = 10.0 # [J/Celsius] motor heat capacity
[docs]
def referenceTemperatures(scenario, times):
r"""Reproduce the module heat balance step-by-step for the given log times.
This mirrors ``MotorThermal::computeTemperature``:
- ``wheelPower = Omega * u_current``
- ``frictionHeat = Omega * frictionTorque``
- ``heatGeneration = dt * (|wheelPower| / eff * (1 - eff) + |frictionHeat|)``
- ``heatDissipation = dt * (T - T_ambient) / R``
- ``T <- T + (heatGeneration - heatDissipation) / C``
``dt`` is taken from the spacing of the recorder time stamps so the reference
uses exactly the same time step the module saw (the module computes
``dt = CurrentSimNanos - prevTime`` and starts with ``prevTime`` at the
simulation start time).
"""
temperature = scenario["currentTemperature"]
previousNanos = 0
expected = []
for currentNanos in times:
dt = (currentNanos - previousNanos) * 1.0e-9
wheelPower = scenario["Omega"] * scenario["u_current"]
frictionHeat = scenario["Omega"] * scenario["frictionTorque"]
heatGeneration = dt * (
abs(wheelPower) / scenario["efficiency"] * (1.0 - scenario["efficiency"])
+ abs(frictionHeat)
)
heatDissipation = dt * (temperature - scenario["ambientTemperature"]) / THERMAL_RESISTANCE
temperature = temperature + (heatGeneration - heatDissipation) / HEAT_CAPACITY
expected.append(temperature)
previousNanos = currentNanos
return np.array(expected)
[docs]
def runScenario(scenario):
"""Drive the motorThermal module with a standalone reaction wheel state
message holding constant, fully-controlled inputs. Returns the recorded
log times (in nanoseconds) and the temperature history."""
unitTestSim = SimulationBaseClass.SimBaseClass()
process = unitTestSim.CreateNewProcess("TestProcess")
process.addTask(unitTestSim.CreateNewTask("unitTask", macros.sec2nano(DT)))
thermalModel = motorThermal.MotorThermal()
thermalModel.ModelTag = "rwThermals"
thermalModel.currentTemperature = scenario["currentTemperature"]
thermalModel.ambientTemperature = scenario["ambientTemperature"]
thermalModel.efficiency = scenario["efficiency"]
thermalModel.ambientThermalResistance = THERMAL_RESISTANCE
thermalModel.motorHeatCapacity = HEAT_CAPACITY
# Feed the module a stand-alone reaction wheel state message so the wheel
# speed, motor torque and friction torque are known exactly. This isolates
# the thermal model from the reaction wheel dynamics.
rwStatePayload = messaging.RWConfigLogMsgPayload()
rwStatePayload.Omega = scenario["Omega"] # [rad/s]
rwStatePayload.u_current = scenario["u_current"] # [N*m]
rwStatePayload.frictionTorque = scenario["frictionTorque"] # [N*m]
rwStateMsg = messaging.RWConfigLogMsg().write(rwStatePayload)
thermalModel.rwStateInMsg.subscribeTo(rwStateMsg)
unitTestSim.AddModelToTask("unitTask", thermalModel)
tempLog = thermalModel.temperatureOutMsg.recorder()
unitTestSim.AddModelToTask("unitTask", tempLog)
unitTestSim.InitializeSimulation()
unitTestSim.ConfigureStopTime(macros.sec2nano(DT * N_STEPS))
unitTestSim.ExecuteSimulation()
return np.array(tempLog.times()), np.array(tempLog.temperature)
# Each scenario isolates a single physical mechanism of the heat balance so that
# a failure points at the responsible term rather than at an opaque truth value.
# Units per scenario field: Omega [rad/s], u_current [N*m], frictionTorque [N*m],
# efficiency [-] (dimensionless), currentTemperature [Celsius],
# ambientTemperature [Celsius].
SCENARIOS = [
# Pure dissipation, motor hotter than ambient: no power and no friction means
# no heat is generated, so the temperature must decay monotonically toward
# the (lower) ambient temperature.
dict(name="dissipation_cooling",
Omega=0.0, u_current=0.0, frictionTorque=0.0, # [rad/s], [N*m], [N*m]
efficiency=0.5, # [-]
currentTemperature=20.0, ambientTemperature=0.0, # [Celsius], [Celsius]
mechanism="dissipation", direction="cooling"),
# Pure dissipation, motor colder than ambient: temperature must rise
# monotonically toward the (higher) ambient temperature.
dict(name="dissipation_warming",
Omega=0.0, u_current=0.0, frictionTorque=0.0, # [rad/s], [N*m], [N*m]
efficiency=0.5, # [-]
currentTemperature=0.0, ambientTemperature=20.0, # [Celsius], [Celsius]
mechanism="dissipation", direction="warming"),
# Pure inefficiency heating: friction is off and the motor starts at ambient
# temperature, so on the first step there is no dissipation and all heating
# comes from the motor power inefficiency term.
dict(name="inefficiency_only",
Omega=100.0, u_current=0.2, frictionTorque=0.0, # [rad/s], [N*m], [N*m]
efficiency=0.5, # [-]
currentTemperature=20.0, ambientTemperature=20.0, # [Celsius], [Celsius]
mechanism="inefficiency", direction="heating"),
# Pure friction heating: the motor torque is zero (so the inefficiency term
# vanishes regardless of efficiency) and the motor starts at ambient
# temperature, so the first-step heating comes solely from friction.
dict(name="friction_only",
Omega=100.0, u_current=0.0, frictionTorque=0.01, # [rad/s], [N*m], [N*m]
efficiency=0.5, # [-]
currentTemperature=20.0, ambientTemperature=20.0, # [Celsius], [Celsius]
mechanism="friction", direction="heating"),
# All three mechanisms active at once with the motor hotter than ambient.
dict(name="combined",
Omega=100.0, u_current=0.2, frictionTorque=0.01, # [rad/s], [N*m], [N*m]
efficiency=0.5, # [-]
currentTemperature=20.0, ambientTemperature=0.0, # [Celsius], [Celsius]
mechanism="combined", direction="heating"),
]
[docs]
@pytest.mark.parametrize("scenario", SCENARIOS, ids=[s["name"] for s in SCENARIOS])
@pytest.mark.parametrize("accuracy", [1e-8])
def test_motorThermal(show_plots, scenario, accuracy):
r"""
**Validation Test Description**
This unit test validates the :ref:`motorThermal` heat-balance model by
feeding it a stand-alone reaction wheel state message with fully controlled
wheel speed, motor torque and friction torque. Because the inputs are known
exactly, the expected temperature history is derived analytically from the
module equations instead of being compared against a stored regression
vector of "magic" truth numbers.
Five scenarios each isolate one part of the model:
- ``dissipation_cooling`` / ``dissipation_warming``: with no wheel power and
no friction the only active term is heat dissipation, so the temperature
must move monotonically toward the ambient temperature (down when the motor
is hotter, up when it is colder).
- ``inefficiency_only``: with friction disabled and the motor starting at the
ambient temperature, the first-step temperature rise must equal the motor
power inefficiency contribution ``dt * |Omega * u_current| / eff * (1 - eff) / C``.
- ``friction_only``: with the motor torque set to zero (which removes the
inefficiency term for any efficiency) and the motor starting at ambient,
the first-step temperature rise must equal the friction contribution
``dt * |Omega * frictionTorque| / C``.
- ``combined``: all three terms active at once, checked against the full
analytic heat-balance recurrence.
Every scenario also checks the complete temperature history against the
step-by-step analytic recurrence to the supplied accuracy.
**Test Parameters**
Args:
accuracy (float): absolute accuracy used to compare the module output to
the analytic reference temperatures.
**Description of Variables Being Tested**
The recorded ``temperature`` history is compared element-by-element against
``referenceTemperatures``, and the first-step temperature change is compared
against the closed-form contribution of the mechanism isolated by each
scenario.
"""
times, temperatures = runScenario(scenario)
expected = referenceTemperatures(scenario, times)
# The recorded history must match the analytic heat-balance recurrence.
# rtol=0.0 so the supplied accuracy is enforced as a pure absolute tolerance
# (NumPy otherwise applies a default rtol=1e-7).
np.testing.assert_allclose(
temperatures, expected, atol=accuracy, rtol=0.0,
err_msg=f"motorThermal temperature history mismatch for scenario '{scenario['name']}'",
)
# There must be at least one full update step (the sample at t = 0 uses
# dt = 0 and leaves the temperature unchanged at its initial value).
assert len(temperatures) > 1
assert temperatures[0] == pytest.approx(scenario["currentTemperature"], abs=accuracy)
firstStep = temperatures[1] - scenario["currentTemperature"]
# Independent, closed-form check of the mechanism isolated by each scenario.
if scenario["mechanism"] == "dissipation":
# No heat is generated, so the temperature relaxes toward ambient.
expectedFirstStep = -DT * (scenario["currentTemperature"] - scenario["ambientTemperature"]) / THERMAL_RESISTANCE / HEAT_CAPACITY
assert firstStep == pytest.approx(expectedFirstStep, abs=accuracy)
differences = np.diff(temperatures[1:])
if scenario["direction"] == "cooling":
assert np.all(differences < 0.0) # monotonically cooling
assert np.all(temperatures[1:] >= scenario["ambientTemperature"])
else:
assert np.all(differences > 0.0) # monotonically warming
assert np.all(temperatures[1:] <= scenario["ambientTemperature"])
elif scenario["mechanism"] == "inefficiency":
# First step starts at ambient, so dissipation is zero and the rise is
# entirely from the power inefficiency term.
wheelPower = scenario["Omega"] * scenario["u_current"]
expectedFirstStep = DT * abs(wheelPower) / scenario["efficiency"] * (1.0 - scenario["efficiency"]) / HEAT_CAPACITY
assert firstStep == pytest.approx(expectedFirstStep, abs=accuracy)
assert firstStep > 0.0
elif scenario["mechanism"] == "friction":
# Motor torque is zero, so the inefficiency term vanishes and the rise is
# entirely from friction.
frictionHeat = scenario["Omega"] * scenario["frictionTorque"]
expectedFirstStep = DT * abs(frictionHeat) / HEAT_CAPACITY
assert firstStep == pytest.approx(expectedFirstStep, abs=accuracy)
assert firstStep > 0.0
[docs]
def test_motorThermal_integration(show_plots):
r"""
**Validation Test Description**
Integration check that :ref:`motorThermal` works when wired to a live
:ref:`ReactionWheelStateEffector` (the connection used in real scenarios such
as :ref:`scenarioTempMeasurementAttitude`), rather than to a stand-alone
message. A single reaction wheel is spun under a constant motor torque with
friction enabled and the motor starts at the ambient temperature.
Because the wheel speed, motor torque and friction torque evolve with the
reaction wheel dynamics, this scenario does not assert exact temperatures
(those are covered analytically in ``test_motorThermal``). It verifies the
end-to-end message path instead: the temperatures must stay finite (no
``NaN`` from an unconnected or mis-typed message) and the motor must heat up,
since a spinning, loaded wheel generates heat and there is no dissipation at
the starting ambient temperature.
"""
unitTestSim = SimulationBaseClass.SimBaseClass()
process = unitTestSim.CreateNewProcess("TestProcess")
process.addTask(unitTestSim.CreateNewTask("unitTask", macros.sec2nano(DT)))
scObject = spacecraft.Spacecraft()
scObject.ModelTag = "spacecraftBody"
rwFactory = simIncludeRW.rwFactory()
rwFactory.create("Honeywell_HR16", [1, 0, 0], Omega=300., # [RPM]
maxMomentum=50., # [N*m*s]
useRWfriction=True)
rwStateEffector = reactionWheelStateEffector.ReactionWheelStateEffector()
rwStateEffector.ModelTag = "ReactionWheel"
rwFactory.addToSpacecraft(rwStateEffector.ModelTag, rwStateEffector, scObject)
cmdArray = messaging.ArrayMotorTorqueMsgPayload()
cmdArray.motorTorque = [0.2, 0., 0.] # [N*m]
cmdMsg = messaging.ArrayMotorTorqueMsg().write(cmdArray)
rwStateEffector.rwMotorCmdInMsg.subscribeTo(cmdMsg)
thermalModel = motorThermal.MotorThermal()
thermalModel.ModelTag = "rwThermals"
thermalModel.currentTemperature = 20. # [Celsius]
thermalModel.ambientTemperature = 20. # [Celsius]
thermalModel.efficiency = 0.5
thermalModel.ambientThermalResistance = THERMAL_RESISTANCE
thermalModel.motorHeatCapacity = HEAT_CAPACITY
thermalModel.rwStateInMsg.subscribeTo(rwStateEffector.rwOutMsgs[0])
# Order matters: the reaction wheel effector must update (and publish its
# state message) before motorThermal reads it within the same task step.
unitTestSim.AddModelToTask("unitTask", rwStateEffector)
unitTestSim.AddModelToTask("unitTask", scObject)
unitTestSim.AddModelToTask("unitTask", thermalModel)
tempLog = thermalModel.temperatureOutMsg.recorder()
unitTestSim.AddModelToTask("unitTask", tempLog)
unitTestSim.InitializeSimulation()
unitTestSim.ConfigureStopTime(macros.sec2nano(DT * N_STEPS))
unitTestSim.ExecuteSimulation()
temperatures = np.array(tempLog.temperature)
assert np.all(np.isfinite(temperatures)), "motorThermal produced non-finite temperatures"
assert temperatures[0] == pytest.approx(20., abs=1e-8)
assert temperatures[-1] > temperatures[0], "a spinning, loaded wheel should heat the motor"
def _baseConfiguredModel(unitTestSim, linkInput=True):
"""A motorThermal module with every Reset() precondition satisfied."""
thermalModel = motorThermal.MotorThermal()
thermalModel.ModelTag = "rwThermals"
thermalModel.currentTemperature = 20. # [Celsius]
thermalModel.efficiency = 0.5
thermalModel.ambientThermalResistance = THERMAL_RESISTANCE
thermalModel.motorHeatCapacity = HEAT_CAPACITY
if linkInput:
rwStateMsg = messaging.RWConfigLogMsg().write(messaging.RWConfigLogMsgPayload())
thermalModel.rwStateInMsg.subscribeTo(rwStateMsg)
unitTestSim.AddModelToTask("unitTask", thermalModel)
return thermalModel
# (break, match) pairs covering each guard in MotorThermal::Reset. Each case
# starts from a valid configuration and breaks exactly one precondition, so the
# matched message confirms the intended guard fired.
RESET_ERROR_CASES = [
("rwStateInMsg not linked", "not linked"),
("currentTemperature below absolute zero", "absolute zero"),
("efficiency at or above one", "efficiency"),
("efficiency at or below zero", "efficiency"),
("ambientThermalResistance not positive", "thermal resistance"),
("motorHeatCapacity not positive", "motor heat capacity"),
]
[docs]
@pytest.mark.parametrize("brokenPrecondition, expectedMessage", RESET_ERROR_CASES,
ids=[c[0] for c in RESET_ERROR_CASES])
def test_motorThermal_resetErrors(show_plots, brokenPrecondition, expectedMessage):
r"""
**Validation Test Description**
Checks that :ref:`motorThermal` rejects an invalid configuration at
``Reset()`` instead of running with it. Each case starts from a valid module
and breaks one precondition, then asserts that initialization raises a
``BasiliskError`` naming the offending quantity. These guards were previously
untested.
"""
unitTestSim = SimulationBaseClass.SimBaseClass()
process = unitTestSim.CreateNewProcess("TestProcess")
process.addTask(unitTestSim.CreateNewTask("unitTask", macros.sec2nano(DT)))
linkInput = brokenPrecondition != "rwStateInMsg not linked"
thermalModel = _baseConfiguredModel(unitTestSim, linkInput=linkInput)
if brokenPrecondition == "currentTemperature below absolute zero":
thermalModel.currentTemperature = -300. # [Celsius]
elif brokenPrecondition == "efficiency at or above one":
thermalModel.efficiency = 1.0
elif brokenPrecondition == "efficiency at or below zero":
thermalModel.efficiency = 0.0
elif brokenPrecondition == "ambientThermalResistance not positive":
thermalModel.ambientThermalResistance = 0. # [Celsius/W]
elif brokenPrecondition == "motorHeatCapacity not positive":
thermalModel.motorHeatCapacity = 0. # [J/Celsius]
with pytest.raises(BasiliskError, match=expectedMessage):
unitTestSim.InitializeSimulation()
if __name__ == "__main__":
for s in SCENARIOS:
test_motorThermal(False, s, 1e-8)
test_motorThermal_integration(False)
for brokenPrecondition, expectedMessage in RESET_ERROR_CASES:
test_motorThermal_resetErrors(False, brokenPrecondition, expectedMessage)
print("PASSED")