Skip to content

Commit 293a65b

Browse files
Minipadaclaude
andcommitted
feat(dc_measurements): add the battery Measurement, with charge sessions and cycles
DC recorded what the computer was doing and nothing about what powers the robot. `dc_measurements/Battery` subscribes to `sensor_msgs/BatteryState` and reports charge percentage, voltage and current on the polling interval, plus one Record when a charging session starts and one when it ends -- charging is unavailable time, and that is the fact operations schedules around. Records are facts, not metrics: an `event` field names which of `sample`, `charge_session_start` (carrying the depth and duration of the discharge before it) and `charge_session_end` (carrying the session's duration) a Record is. Aggregation stays in the SQL views, where the window is a query parameter. The counting lives in `dc_common::BatteryCycleAccumulator` -- header-only, no ROS dependency, no clock of its own. Sessions are delimited by `power_supply_status`, never by a percentage threshold, so a noisy percentage cannot open and close them repeatedly, and cycles accumulate discharge depth rather than counting full discharges: two half discharges are one cycle. That is the accumulator half of #360, which #361 needs to exist at all; #360 stays open for its `StateTransitionDetector`. A NaN field is left out of the Record instead of written as null, so a pack reporting only a voltage still validates; before the topic publishes at all, no Record is emitted. `topic` is a parameter, so a robot with two packs runs one Measurement per pack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GEJQDuGUXVwF9sUz5E5M7x Signed-off-by: David Bensoussan <d.bensoussan@proton.me>
1 parent 9aa2ea7 commit 293a65b

10 files changed

Lines changed: 1103 additions & 0 deletions

File tree

dc_measurements/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ set(dependencies
2020
pluginlib
2121
rclcpp_components
2222
rclcpp_lifecycle
23+
sensor_msgs
2324
std_msgs
2425
tf2
2526
tf2_geometry_msgs
@@ -120,6 +121,9 @@ add_library(dc_string_match_condition SHARED plugins/conditions/string_match.cpp
120121
list(APPEND dc_condition_plugin_libs dc_string_match_condition)
121122

122123
# Measurement plugins
124+
add_library(dc_battery_measurement SHARED plugins/measurements/battery.cpp)
125+
list(APPEND dc_measurement_plugin_libs dc_battery_measurement)
126+
123127
add_library(dc_camera_measurement SHARED plugins/measurements/camera.cpp)
124128
list(APPEND dc_measurement_plugin_libs dc_camera_measurement)
125129

@@ -301,6 +305,7 @@ set(tests
301305
test_barcode_rotation
302306
test_code_pose
303307
test_incident_releaser
308+
test_measurement_battery
304309
test_measurement_bool_equal
305310
test_measurement_buffering
306311
test_measurement_camera
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// SPDX-FileCopyrightText: 2022-2026 David Bensoussan
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
#ifndef DC_MEASUREMENTS__PLUGINS__MEASUREMENTS__BATTERY_HPP_
5+
#define DC_MEASUREMENTS__PLUGINS__MEASUREMENTS__BATTERY_HPP_
6+
7+
#include <deque>
8+
#include <mutex>
9+
#include <string>
10+
#include <utility>
11+
12+
#include "dc_common/battery_cycle_accumulator.hpp"
13+
#include "dc_core/measurement.hpp"
14+
#include "dc_measurements/measurement.hpp"
15+
#include "dc_util/node_utils.hpp"
16+
#include "rclcpp/rclcpp.hpp"
17+
#include "sensor_msgs/msg/battery_state.hpp"
18+
19+
namespace dc_measurements
20+
{
21+
22+
class Battery : public dc_measurements::Measurement
23+
{
24+
public:
25+
Battery();
26+
~Battery() override;
27+
dc_interfaces::msg::StringStamped collect() override;
28+
29+
private:
30+
void batteryStateCb(const sensor_msgs::msg::BatteryState& msg);
31+
json sampleRecord() const;
32+
33+
rclcpp::Subscription<sensor_msgs::msg::BatteryState>::SharedPtr subscription_;
34+
std::string battery_topic_;
35+
double percentage_scale_{ 100.0 };
36+
37+
// The BatteryState callback and the polling timer run in different callback groups under a
38+
// multi-threaded executor, so everything they share is guarded.
39+
mutable std::mutex mutex_;
40+
dc_common::BatteryCycleAccumulator accumulator_;
41+
sensor_msgs::msg::BatteryState last_msg_;
42+
bool has_sample_{ false };
43+
// Session boundaries wait here for a poll to carry them out, one Record per poll, so they
44+
// travel the same publish path (Conditions, buffering, Group) as every other Record.
45+
std::deque<std::pair<json, rclcpp::Time>> pending_events_;
46+
47+
protected:
48+
/**
49+
* @brief Configuration of behavior action
50+
*/
51+
void onConfigure() override;
52+
void setValidationSchema() override;
53+
};
54+
55+
} // namespace dc_measurements
56+
57+
#endif // DC_MEASUREMENTS__PLUGINS__MEASUREMENTS__BATTERY_HPP_

dc_measurements/measurement_plugin.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ SPDX-License-Identifier: MPL-2.0
44
-->
55

66
<class_libraries>
7+
<library path="dc_battery_measurement">
8+
<class name="dc_measurements/Battery" type="dc_measurements::Battery" base_class_type="dc_core::Measurement">
9+
<description>
10+
dc_measurement_battery
11+
</description>
12+
</class>
13+
</library>
14+
715
<library path="dc_camera_measurement">
816
<class name="dc_measurements/Camera" type="dc_measurements::Camera" base_class_type="dc_core::Measurement">
917
<description>
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
// SPDX-FileCopyrightText: 2022-2026 David Bensoussan
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
#include "dc_measurements/plugins/measurements/battery.hpp"
5+
6+
#include <cmath>
7+
8+
namespace dc_measurements
9+
{
10+
11+
namespace
12+
{
13+
constexpr size_t kMaxPendingEvents = 64;
14+
15+
// sensor_msgs/BatteryState leaves most fields optional and signals "unmeasured" with NaN, so a
16+
// field the hardware doesn't fill is left out of the Record rather than written as null.
17+
void setIfMeasured(json& data, const std::string& key, float value)
18+
{
19+
if (!std::isnan(value))
20+
{
21+
data[key] = value;
22+
}
23+
}
24+
25+
std::string statusName(uint8_t status)
26+
{
27+
switch (status)
28+
{
29+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_STATUS_CHARGING:
30+
return "charging";
31+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_STATUS_DISCHARGING:
32+
return "discharging";
33+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_STATUS_NOT_CHARGING:
34+
return "not_charging";
35+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_STATUS_FULL:
36+
return "full";
37+
default:
38+
return "unknown";
39+
}
40+
}
41+
42+
std::string healthName(uint8_t health)
43+
{
44+
switch (health)
45+
{
46+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_HEALTH_GOOD:
47+
return "good";
48+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_HEALTH_OVERHEAT:
49+
return "overheat";
50+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_HEALTH_DEAD:
51+
return "dead";
52+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_HEALTH_OVERVOLTAGE:
53+
return "overvoltage";
54+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_HEALTH_UNSPEC_FAILURE:
55+
return "unspecified_failure";
56+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_HEALTH_COLD:
57+
return "cold";
58+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_HEALTH_WATCHDOG_TIMER_EXPIRE:
59+
return "watchdog_timer_expire";
60+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_HEALTH_SAFETY_TIMER_EXPIRE:
61+
return "safety_timer_expire";
62+
default:
63+
return "unknown";
64+
}
65+
}
66+
67+
std::string technologyName(uint8_t technology)
68+
{
69+
switch (technology)
70+
{
71+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_TECHNOLOGY_NIMH:
72+
return "nimh";
73+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_TECHNOLOGY_LION:
74+
return "lion";
75+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_TECHNOLOGY_LIPO:
76+
return "lipo";
77+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_TECHNOLOGY_LIFE:
78+
return "life";
79+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_TECHNOLOGY_NICD:
80+
return "nicd";
81+
case sensor_msgs::msg::BatteryState::POWER_SUPPLY_TECHNOLOGY_LIMN:
82+
return "limn";
83+
default:
84+
return "unknown";
85+
}
86+
}
87+
88+
void setIfPresent(json& data, const std::string& key, const std::optional<double>& value)
89+
{
90+
if (value)
91+
{
92+
data[key] = *value;
93+
}
94+
}
95+
96+
double toSeconds(dc_common::ChargingSession::Duration duration)
97+
{
98+
return std::chrono::duration<double>(duration).count();
99+
}
100+
} // namespace
101+
102+
Battery::Battery() : dc_measurements::Measurement()
103+
{
104+
}
105+
106+
Battery::~Battery() = default;
107+
108+
void Battery::onConfigure()
109+
{
110+
auto node = getNode();
111+
battery_topic_ = dc_util::get_str_type_param(node, measurement_name_, "topic", "/battery_state");
112+
// sensor_msgs/BatteryState specifies percentage on a 0-1 range; drivers that already publish
113+
// 0-100 are configured with a scale of 1.0.
114+
percentage_scale_ = dc_util::get_double_type_param(node, measurement_name_, "percentage_scale", 100.0);
115+
116+
subscription_ = node->create_subscription<sensor_msgs::msg::BatteryState>(
117+
battery_topic_, rclcpp::SensorDataQoS(), std::bind(&Battery::batteryStateCb, this, std::placeholders::_1));
118+
}
119+
120+
void Battery::setValidationSchema()
121+
{
122+
if (enable_validator_)
123+
{
124+
validateSchema("dc_measurements", "battery.json");
125+
}
126+
}
127+
128+
void Battery::batteryStateCb(const sensor_msgs::msg::BatteryState& msg)
129+
{
130+
const auto now = getNode()->get_clock()->now();
131+
const auto stamp = dc_common::BatteryCycleAccumulator::TimePoint(std::chrono::nanoseconds(now.nanoseconds()));
132+
133+
const std::lock_guard<std::mutex> lock(mutex_);
134+
last_msg_ = msg;
135+
has_sample_ = true;
136+
137+
std::optional<double> percentage;
138+
if (!std::isnan(msg.percentage))
139+
{
140+
percentage = msg.percentage * percentage_scale_;
141+
}
142+
143+
const auto update =
144+
accumulator_.update(percentage, static_cast<dc_common::PowerSupplyStatus>(msg.power_supply_status), stamp);
145+
146+
if (update.started)
147+
{
148+
const auto& session = *update.started;
149+
json event;
150+
event["event"] = "charge_session_start";
151+
event["session_id"] = session.sequence;
152+
event["discharge_depth_percent"] = session.preceding_discharge_depth;
153+
setIfPresent(event, "percentage", session.start_percentage);
154+
pending_events_.emplace_back(std::move(event), now);
155+
}
156+
if (update.ended)
157+
{
158+
const auto& session = *update.ended;
159+
json event;
160+
event["event"] = "charge_session_end";
161+
event["session_id"] = session.sequence;
162+
event["duration_sec"] = toSeconds(session.duration(stamp));
163+
setIfPresent(event, "start_percentage", session.start_percentage);
164+
setIfPresent(event, "end_percentage", session.end_percentage);
165+
if (session.start_percentage && session.end_percentage)
166+
{
167+
event["charged_percent"] = *session.end_percentage - *session.start_percentage;
168+
}
169+
pending_events_.emplace_back(std::move(event), now);
170+
}
171+
172+
// One event leaves per poll, so a pack whose status flaps far faster than the polling interval
173+
// would otherwise queue without bound. The oldest goes first: the recent boundaries are the
174+
// ones still worth reporting.
175+
while (pending_events_.size() > kMaxPendingEvents)
176+
{
177+
pending_events_.pop_front();
178+
RCLCPP_WARN_STREAM_THROTTLE(logger_, *getNode()->get_clock(), 10000,
179+
"Measurement " << measurement_name_
180+
<< ": charging session boundaries are arriving faster than the "
181+
"polling interval can report them; dropping the oldest.");
182+
}
183+
}
184+
185+
json Battery::sampleRecord() const
186+
{
187+
json data;
188+
data["event"] = "sample";
189+
data["power_supply_status"] = statusName(last_msg_.power_supply_status);
190+
data["present"] = last_msg_.present;
191+
192+
if (!std::isnan(last_msg_.percentage))
193+
{
194+
data["percentage"] = last_msg_.percentage * percentage_scale_;
195+
}
196+
setIfMeasured(data, "voltage", last_msg_.voltage);
197+
setIfMeasured(data, "current", last_msg_.current);
198+
setIfMeasured(data, "charge", last_msg_.charge);
199+
setIfMeasured(data, "capacity", last_msg_.capacity);
200+
setIfMeasured(data, "design_capacity", last_msg_.design_capacity);
201+
setIfMeasured(data, "temperature", last_msg_.temperature);
202+
203+
if (last_msg_.power_supply_health != sensor_msgs::msg::BatteryState::POWER_SUPPLY_HEALTH_UNKNOWN)
204+
{
205+
data["power_supply_health"] = healthName(last_msg_.power_supply_health);
206+
}
207+
if (last_msg_.power_supply_technology != sensor_msgs::msg::BatteryState::POWER_SUPPLY_TECHNOLOGY_UNKNOWN)
208+
{
209+
data["power_supply_technology"] = technologyName(last_msg_.power_supply_technology);
210+
}
211+
// State of health: what the pack still holds against what it was built to hold. Only the
212+
// hardware reporting both capacities can answer it.
213+
if (!std::isnan(last_msg_.capacity) && !std::isnan(last_msg_.design_capacity) && last_msg_.design_capacity > 0.0F)
214+
{
215+
data["health_percentage"] = 100.0 * last_msg_.capacity / last_msg_.design_capacity;
216+
}
217+
if (!last_msg_.location.empty())
218+
{
219+
data["location"] = last_msg_.location;
220+
}
221+
if (!last_msg_.serial_number.empty())
222+
{
223+
data["serial_number"] = last_msg_.serial_number;
224+
}
225+
226+
data["completed_cycles"] = accumulator_.completedCycles();
227+
if (const auto& session = accumulator_.openSession())
228+
{
229+
data["session_id"] = session->sequence;
230+
}
231+
return data;
232+
}
233+
234+
dc_interfaces::msg::StringStamped Battery::collect()
235+
{
236+
auto node = getNode();
237+
dc_interfaces::msg::StringStamped msg;
238+
msg.group_key = group_key_;
239+
240+
const std::lock_guard<std::mutex> lock(mutex_);
241+
242+
// A session boundary takes the poll it lands on: it is a fact about a moment, so it keeps the
243+
// timestamp of that moment rather than this poll's.
244+
if (!pending_events_.empty())
245+
{
246+
auto event = std::move(pending_events_.front());
247+
pending_events_.pop_front();
248+
msg.header.stamp = event.second;
249+
msg.data = event.first.dump(-1, ' ', true);
250+
return msg;
251+
}
252+
253+
// Nothing on the topic yet: report nothing rather than a Record full of absent fields.
254+
if (!has_sample_)
255+
{
256+
return msg;
257+
}
258+
259+
msg.header.stamp = node->get_clock()->now();
260+
msg.data = sampleRecord().dump(-1, ' ', true);
261+
return msg;
262+
}
263+
264+
} // namespace dc_measurements
265+
266+
#include "pluginlib/class_list_macros.hpp"
267+
PLUGINLIB_EXPORT_CLASS(dc_measurements::Battery, dc_core::Measurement)

0 commit comments

Comments
 (0)