Refactor of the thermal task - #2609
Conversation
I kept getting these confused, and making it just very very verbose is I think the best way to make this clear.
labbott
left a comment
There was a problem hiding this comment.
I forgot to hit submit yesterday, I'll take another pass today
| // When we first start up, try to initialize the fan controller a few | ||
| // times, in case there's a transient I2C error. |
There was a problem hiding this comment.
Is this still the case? I see this was originally added for grapefruit as part of 725bdfdd8b01de8e095199ee3f0ee218ffe71414
There was a problem hiding this comment.
I'm not sure! I haven't actually tested this on hardware yet, and tried as best as possible to preserve the existing behavior, whatever it was.
There was a problem hiding this comment.
Also worth noting, thermal is only active for grapefruit-ruby, and I'm not sure where to even obtain one of those for testing.
| // This is mimicing the old state value logic for deciding if | ||
| // we persist the data in `run_control`, that ONLY cleared the | ||
| // persisted value if: | ||
| // | ||
| // - The sensor is not present AND removable | ||
| // - The sensor is error prone | ||
| // | ||
| // Replicate that logic here, doing some type shenanigans | ||
| // because we aren't round-tripping through the Sensor API | ||
| // anymore. | ||
| let se = SensorError::from(NoData::from(e)); | ||
| match (self.metadata.ty, se) { | ||
| (ChannelType::Removable, SensorError::NotPresent) => { | ||
| self.last_reading = TemperatureReading::Inactive; | ||
| } | ||
| (ChannelType::RemovableAndErrorProne, _) => { | ||
| self.last_reading = TemperatureReading::Inactive; | ||
| } | ||
| _ => { | ||
| // In all other cases, just leave whatever the last | ||
| // present value was so that the state estimation | ||
| // can continue estimating state. | ||
| } | ||
| } | ||
|
|
||
| // This logic comes from what was done in `read_sensors`, | ||
| // which is only deciding whether it's worth logging about. | ||
| // In either case, it will push NoData to the sensor api. | ||
| // | ||
| // This is *not* the same logic that is used above to decide | ||
| // whether we clear the previous state or not, despite being | ||
| // *very* similar! | ||
| let removable = matches!( | ||
| self.metadata.ty, | ||
| ChannelType::Removable | ||
| | ChannelType::RemovableAndErrorProne | ||
| ); | ||
| let removed = | ||
| e == SensorReadError::I2cError(ResponseCode::NoDevice); | ||
| let unexpected_failure = !(removable && removed); | ||
| if unexpected_failure { | ||
| InputReadingOutcome::UnacceptableMissing { id, err: e } | ||
| } else { | ||
| InputReadingOutcome::AcceptableMissing { id, err: e } | ||
| } |
There was a problem hiding this comment.
The comments are very good but I still find this tricky to follow. I don't have any great ideas here (might take a pass later)
There was a problem hiding this comment.
I think this could be simplified IMO, this is a little awkward in service of preserving the old behavior.
thermal taskthermal task
|
Taking this out of draft/WIP state, and marking it as ready for review. Builds are now passing for all targets, and I think this change is Good Actually, even if a bit large. It definitely need on-target testing still, but I'll need to coordinate that. It would be good to make sure there aren't any major review items to address first. |
|
Smoke testing on Hubris seems to work, the system is unloaded so there's not a lot of heat to react to, but the current software handles the transitions from A2 -> A0 and A0 -> A2 as I expect, and |
labbott
left a comment
There was a problem hiding this comment.
This is a big diff but I do think it's an improvement. I think keeping the existing behavior is the correct approach. I would also like to capture areas that are particularly weird and you think should be improved (maybe a single issue with some links code lines?)
| if idx >= MAX_FANS { | ||
| panic!("Out of range!"); |
There was a problem hiding this comment.
I don't love the extra potential runtime panic but it also didn't seem to increase the code size so 🤷
There was a problem hiding this comment.
This is a const-fn, and is only currently used to generate constants, which means that any panics that occur would be compile-time errors.
You are right, if someone inadvertently calls it at runtime, it would potentially panic in that case.
|
Capturing a list of "some things that are 'eh' about the current state, and would like to fix later, but didn't because I didn't want to churn the diff even more": All the
|
Wrote up #2609 (comment) for now, I can turn that into an issue (or issues) with correct line links once this merges! |
|
@labbott addressed most of the comments so far! |
hawkw
left a comment
There was a problem hiding this comment.
I haven't fully read through this change, but I wanted to leave the notes I have so far. Overall, I like the refactor, but have some nitpicks.
| use task_thermal_api::{SensorReadError, ThermalError}; | ||
|
|
||
| use crate::{ | ||
| __RINGBUF, Trace, |
There was a problem hiding this comment.
FWIW, the ringbuf crate also has a ringbuf_entry_root! macro which is intended to allow submodules to add entries to ringbufs declared in the crate root without having to import __RINGBUF. Not a big deal, as what you're doing also works fine in this case (as there is no additional ringbuf declared in this module, which ringbuf_entry_root! helps with). I just figured I'd mention it.
I've also seen code use ringbuf::ringbuf_entry_root as ringbuf_entry; in cases where a module doesn't have its own ringbuf. I dunno whether or not I love that pattern, but it's an existing convention... 🤷♀️
| #[allow(dead_code, clippy::upper_case_acronyms)] | ||
| pub enum Device { | ||
| Tmp117, | ||
| Tmp451(drv_i2c_devices::tmp451::Target), | ||
| CPU, | ||
| Dimm, | ||
| U2, | ||
| M2, | ||
| LM75, | ||
| } |
There was a problem hiding this comment.
turbo nitpick: I think the two "upper case acronyms" clippy doesn't like here are LM75 and CPU; what do you think about just doing this instead:
| #[allow(dead_code, clippy::upper_case_acronyms)] | |
| pub enum Device { | |
| Tmp117, | |
| Tmp451(drv_i2c_devices::tmp451::Target), | |
| CPU, | |
| Dimm, | |
| U2, | |
| M2, | |
| LM75, | |
| } | |
| #[allow(dead_code)] | |
| pub enum Device { | |
| Tmp117, | |
| Tmp451(drv_i2c_devices::tmp451::Target), | |
| Cpu, | |
| Dimm, | |
| U2, | |
| M2, | |
| Lm75, | |
| } |
I note that every other acronym in this enum (TMP117, TMP451, DIMM) are camel-cased...we could also fully embrace upper-case acronyms here, but if we're going to do that, I'd kinda like to see DIMM and CPU in the same case?)
There was a problem hiding this comment.
also, take it or leave it, but I kind of wonder if we ought to change this so that it's the name of the actual I2C device we use for each thing (i.e. Tse2005Av instead of Dimm, Sbtsi instead of CPU, and NvmeBmc instead of U2 and M2)? i'm thinking that because this enum is really being used to determine which I2C device's driver to use, rather than which component's thermal model to use (AFAICT?), we might be better off making that a bit more explicit --- in this case, the U2 and M2 variants don't actually seem necessary, since they're both just NVME BMC devices, and I can kind of imagine some future product where DIMM and/or CPU temperatures are read through a different I2C device (though we're probably not going to release, say, an intel compute sled any time soon, so that's probably not worth optimizing for...)
There was a problem hiding this comment.
ah, i see that this was copied directly from the old code, it just lived in a different file. in that case, feel free to ignore my nits for now, though it might be worth cleaning up later...
| /// Thermal properties of the associated component | ||
| model: ThermalProperties, | ||
|
|
||
| /// Mask with bits set based on the Bsp's `power_mode` bits |
There was a problem hiding this comment.
nitpick:
| /// Mask with bits set based on the Bsp's `power_mode` bits | |
| /// Mask with bits set based on the BSP's `power_mode` bits |
| /// InputChannelMetadata is the constant description portion of an InputChannel. | ||
| /// | ||
| /// We split it off because InputChannel is mutable to contain the last state, | ||
| /// and if we left it inlined, then we would end up including all of this | ||
| /// metadata in RAM, despite never changing it! So instead, we break it off and | ||
| /// have InputChannel hold an `&'static` reference instead, so we only waste | ||
| /// a wee little pointer (4 bytes) to flash space in each InputChannel entry, | ||
| /// instead of (at the time of writing) 36 bytes, which for example in Cosmo | ||
| /// that has 14 input channels, is over 500 bytes of RAM! |
There was a problem hiding this comment.
style nit: this feels like it ought to be on the type declaration, rather than on the impl block?
| /// instead of (at the time of writing) 36 bytes, which for example in Cosmo | ||
| /// that has 14 input channels, is over 500 bytes of RAM! | ||
| impl InputChannelMetadata { | ||
| pub const fn new( |
There was a problem hiding this comment.
style nit: the type is pub(crate) but the constructor is pub (i thought clippy didn't like this, but i guess it doesn't seem to care...)
| pub const fn new( | |
| pub(crate) const fn new( |
| } | ||
| } | ||
|
|
||
| pub fn do_reading( |
There was a problem hiding this comment.
nit: IMO, it would be nice if there was a doc comment on this explaining its relationship with status() --- it looks like do_reading actually asks the temperature sensor for what temperature it is, but returns a thing that represents the result of the I2C request, and status() returns a thing representing the current temperature or its non-existence, but I'd like to see that written down.
| /// Sensor was not read because the power mode indicated that it would not | ||
| /// be enabled in this state. | ||
| Unpowered { id: SensorId }, | ||
| /// This sensor was missing, but it's okay because it was either Removable | ||
| /// and not there, or Removable and Error Prone and had any kind of error. | ||
| AcceptableMissing { id: SensorId, err: SensorReadError }, | ||
| /// Any read error that didn't match the "Acceptable" cases listed above. | ||
| UnacceptableMissing { id: SensorId, err: SensorReadError }, | ||
| /// We read the data! Hooray! | ||
| Success { | ||
| id: SensorId, | ||
| now: u64, | ||
| value: Celsius, | ||
| }, |
There was a problem hiding this comment.
this is the type of thing where normally I would say "huh, it seems kind of like it might be easier to use this if we pulled the SensorId out into a struct so that you didn't have to unpack it from each variant"...but after actually looking at the code, I see that it doesn't matter and it's probably simpler to just leave it this way. carry on.
| pub enum FanStatus { | ||
| PresentSuccess { | ||
| rpm: Rpm, | ||
| sensor_id: SensorId, | ||
| }, | ||
| PresentError { | ||
| error: SensorReadError, | ||
| sensor_id: SensorId, | ||
| }, | ||
| #[allow(dead_code)] // Some bsps don't have removable fans | ||
| NotPresent { | ||
| sensor_id: SensorId, | ||
| }, |
There was a problem hiding this comment.
very unimportant style nit: in InputReadingOutcome and InputStatus, the sensor ID field is called id; here, it's called sensor_id. would maybe be worth making it consistent everywhere
| #[allow(dead_code)] // Not all bsps have dynamic inputs | ||
| pub(crate) struct DynamicInputChannel { | ||
| model: ThermalProperties, | ||
| pub sensor_id: SensorId, |
There was a problem hiding this comment.
similarly, InputStatus calls this id, while DynamicInputChannel calls it id...maybe worth using the same name?
| model: ThermalProperties { | ||
| target_temperature: Celsius(0.), | ||
| critical_temperature: Celsius(0.), | ||
| power_down_temperature: Celsius(0.), | ||
| temperature_slew_deg_per_sec: 0., | ||
| }, |
There was a problem hiding this comment.
I get why we're representing the thermal model of a dynamic input which hasn't been added as a ThermalProperties where everything is 0, and it saves at least a byte of enum discriminant and probably some padding versus making this an Option. But, it really doesn't feel good. We are initializing this to an invalid state: an input channel that is at its target temperature, its critical temperature, and its power-down temperature, all at the same time. And we're just relying on other code respecting the has_been_queried flag to avoid making the system thermal shutdown immediately...which it does, but it feels sketchy to me.
If we're going to do this, I'd feel a bit less sketched out if we initialized these to NaN instead of 0. That way, at least the comparisons against the threshold values would always compare false while we're in the uninitialized state, which feels less scary than initializing all dynamic channels to always be over the power-down temp...
| enum TemperatureReading { | ||
| /// Normal reading, timestamped using monotonic system time | ||
| Valid(TimestampedTemperatureReading), | ||
| #[allow(dead_code)] // Not all bsps have inputs! |
This is a WIP refactor in service of simplifying the current state tracking performed by the thermal task.
Some of this is still Not Great, particularly the places where I pass 2-4 closures into a single function.
However, by pushing most state tracking down into the BSP, I hope to make it easier to Trait-ify the BSP interface and make it easier to build a "host native" thermal loop for testing purposes.
(most of the builds are failing because I've only refactored
cosmoand none of the other bsps yet)