Skip to content
67 changes: 67 additions & 0 deletions src/bounds.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// License: MIT
// Copyright © 2026 Frequenz Energy-as-a-Service GmbH

//! A representation of Bounds for any metric.
use crate::proto::common::metrics::Bounds as PbBounds;
use crate::quantity::{Current, Power, Quantity, ReactivePower};

/// A set of lower and upper bounds for any metric.
Comment on lines +4 to +9
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

Bounds is documented as working "for any metric", but From<Bounds<Q>> for PbBounds is only implemented for Power, Current, and ReactivePower. As a result, augment_electrical_component_bounds won’t compile for metrics whose QuantityType is Voltage, Frequency, etc. Either add the missing From<Bounds<...>> impls (if supported by the API) or narrow the docs/API bounds to the supported quantity types.

Suggested change
//! A representation of Bounds for any metric.
use crate::proto::common::metrics::Bounds as PbBounds;
use crate::quantity::{Current, Power, Quantity, ReactivePower};
/// A set of lower and upper bounds for any metric.
//! A generic representation of bounds for a metric quantity.
//!
//! `Bounds<Q>` itself can be used with any `Q: Quantity`, but conversion to
//! the protobuf `Bounds` type (`PbBounds`) is currently implemented only for
//! `Power`, `Current`, and `ReactivePower`.
use crate::proto::common::metrics::Bounds as PbBounds;
use crate::quantity::{Current, Power, Quantity, ReactivePower};
/// A set of lower and upper bounds for a metric quantity.

Copilot uses AI. Check for mistakes.
pub struct Bounds<Q: Quantity> {
/// The lower bound.
/// If None, there is no lower bound.
lower: Option<Q>,
/// The upper bound.
/// If None, there is no upper bound.
upper: Option<Q>,
}

impl<Q: Quantity> Bounds<Q> {
/// Creates a new `Bounds` with the given lower and upper bounds.
pub fn new(lower: Option<Q>, upper: Option<Q>) -> Self {
Self { lower, upper }
}

/// Returns the lower bound.
pub fn lower(&self) -> Option<Q> {
self.lower
}

/// Returns the upper bound.
pub fn upper(&self) -> Option<Q> {
self.upper
}
}

impl<Q: Quantity> From<(Option<Q>, Option<Q>)> for Bounds<Q> {
fn from(bounds: (Option<Q>, Option<Q>)) -> Self {
Self::new(bounds.0, bounds.1)
}
}

impl From<Bounds<Power>> for PbBounds {
fn from(bounds: Bounds<Power>) -> Self {
PbBounds {
lower: bounds.lower.map(|q| q.as_watts()),
upper: bounds.upper.map(|q| q.as_watts()),
}
}
}

impl From<Bounds<Current>> for PbBounds {
fn from(bounds: Bounds<Current>) -> Self {
PbBounds {
lower: bounds.lower.map(|q| q.as_amperes()),
upper: bounds.upper.map(|q| q.as_amperes()),
}
}
}

impl From<Bounds<ReactivePower>> for PbBounds {
fn from(bounds: Bounds<ReactivePower>) -> Self {
PbBounds {
lower: bounds.lower.map(|q| q.as_volt_amperes_reactive()),
upper: bounds.upper.map(|q| q.as_volt_amperes_reactive()),
}
}
}
7 changes: 6 additions & 1 deletion src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ mod microgrid_client_actor;
mod retry_tracker;

mod microgrid_api_client;
pub use microgrid_api_client::MicrogridApiClient;
pub(crate) use microgrid_api_client::MicrogridApiClient;

mod microgrid_client_handle;
pub use microgrid_client_handle::MicrogridClientHandle;

pub(crate) mod proto;
pub use proto::common::microgrid::electrical_components::{
ElectricalComponent, ElectricalComponentCategory,
};

#[cfg(test)]
pub(crate) mod test_utils;
18 changes: 15 additions & 3 deletions src/client/instruction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@

//! Instructions that can be sent to the client actor from client handles.

use chrono::TimeDelta;
use tokio::sync::{broadcast, oneshot};

use crate::{
Error,
proto::common::microgrid::electrical_components::{
ElectricalComponent, ElectricalComponentConnection, ElectricalComponentTelemetry,
proto::common::{
metrics::{Bounds, Metric},
microgrid::electrical_components::{
ElectricalComponent, ElectricalComponentCategory, ElectricalComponentConnection,
ElectricalComponentTelemetry,
},
},
};

Expand All @@ -21,12 +26,19 @@ pub(super) enum Instruction {
},
ListElectricalComponents {
electrical_component_ids: Vec<u64>,
electrical_component_categories: Vec<i32>,
electrical_component_categories: Vec<ElectricalComponentCategory>,
response_tx: oneshot::Sender<Result<Vec<ElectricalComponent>, Error>>,
},
ListElectricalComponentConnections {
source_electrical_component_ids: Vec<u64>,
destination_electrical_component_ids: Vec<u64>,
response_tx: oneshot::Sender<Result<Vec<ElectricalComponentConnection>, Error>>,
},
AugmentElectricalComponentBounds {
electrical_component_id: u64,
target_metric: Metric,
bounds: Vec<Bounds>,
request_lifetime: Option<TimeDelta>,
response_tx: oneshot::Sender<Result<Option<chrono::DateTime<chrono::Utc>>, Error>>,
},
}
14 changes: 14 additions & 0 deletions src/client/microgrid_api_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use tonic::transport::Channel;

use crate::proto::microgrid::{
AugmentElectricalComponentBoundsRequest, AugmentElectricalComponentBoundsResponse,
ListElectricalComponentConnectionsRequest, ListElectricalComponentConnectionsResponse,
ListElectricalComponentsRequest, ListElectricalComponentsResponse,
ReceiveElectricalComponentTelemetryStreamRequest,
Expand Down Expand Up @@ -44,6 +45,11 @@ pub trait MicrogridApiClient: Send + Sync + 'static {
&mut self,
request: impl tonic::IntoRequest<ReceiveElectricalComponentTelemetryStreamRequest> + Send,
) -> std::result::Result<tonic::Response<Self::TelemetryStream>, tonic::Status>;

async fn augment_electrical_component_bounds(
&mut self,
request: impl tonic::IntoRequest<AugmentElectricalComponentBoundsRequest> + Send,
) -> std::result::Result<tonic::Response<AugmentElectricalComponentBoundsResponse>, tonic::Status>;
}

/// Implement the MicrogridApiClient trait for the generated gRPC client.
Expand Down Expand Up @@ -78,4 +84,12 @@ impl MicrogridApiClient for crate::proto::microgrid::microgrid_client::Microgrid
self.receive_electrical_component_telemetry_stream(request)
.await
}

async fn augment_electrical_component_bounds(
&mut self,
request: impl tonic::IntoRequest<AugmentElectricalComponentBoundsRequest> + Send,
) -> std::result::Result<tonic::Response<AugmentElectricalComponentBoundsResponse>, tonic::Status>
{
self.augment_electrical_component_bounds(request).await
}
}
42 changes: 39 additions & 3 deletions src/client/microgrid_client_actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ use crate::{
ReceiveElectricalComponentTelemetryStreamResponse,
},
};
use std::collections::HashMap;

use chrono::DateTime;
use futures::{Stream, StreamExt};
use std::collections::HashMap;
use tokio::{
select,
sync::{broadcast, mpsc},
Expand Down Expand Up @@ -149,7 +149,10 @@ async fn handle_instruction<T: MicrogridApiClient>(
let components = client
.list_electrical_components(ListElectricalComponentsRequest {
electrical_component_ids,
electrical_component_categories,
electrical_component_categories: electrical_component_categories
.into_iter()
.map(|c| c as i32)
.collect(),
})
.await
.map_err(|e| Error::connection_failure(format!("list_components failed: {e}")))
Expand Down Expand Up @@ -177,6 +180,39 @@ async fn handle_instruction<T: MicrogridApiClient>(
.send(connections)
.map_err(|_| Error::internal("failed to send response"))?;
}
Some(Instruction::AugmentElectricalComponentBounds {
electrical_component_id,
target_metric,
bounds,
request_lifetime,
response_tx,
}) => {
let response = client
.augment_electrical_component_bounds(
crate::proto::microgrid::AugmentElectricalComponentBoundsRequest {
electrical_component_id,
target_metric: target_metric as i32,
bounds: bounds,
request_lifetime: request_lifetime.map(|d| d.as_seconds_f64() as u64),
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

request_lifetime.map(|d| d.as_seconds_f64() as u64) will silently turn negative TimeDelta values into huge u64s, and also truncates fractional seconds. Validate that the duration is non-negative and convert using an integer seconds API (e.g., num_seconds() with bounds checking) to avoid sending nonsensical lifetimes to the API.

Suggested change
request_lifetime: request_lifetime.map(|d| d.as_seconds_f64() as u64),
request_lifetime: request_lifetime.and_then(|d| {
let secs = d.num_seconds();
u64::try_from(secs).ok()
}),

Copilot uses AI. Check for mistakes.
},
)
.await
.map_err(|e| {
Error::api_server_error(format!(
"augment_electrical_component_bounds failed: {e}"
))
})
.map(|r| {
r.into_inner()
.valid_until_time
.map(|t| DateTime::from_timestamp(t.seconds, t.nanos as u32))
.flatten()
Comment on lines +205 to +209
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

The timestamp conversion casts t.nanos with as u32 and then drops invalid values by returning None (via from_timestamp(...).flatten()). Use a checked conversion for nanos (and ideally validate the 0..=999_999_999 range) and consider logging/returning an error if the server returns an invalid timestamp instead of silently discarding it.

Suggested change
.map(|r| {
r.into_inner()
.valid_until_time
.map(|t| DateTime::from_timestamp(t.seconds, t.nanos as u32))
.flatten()
.and_then(|r| {
let valid_until = r.into_inner().valid_until_time;
match valid_until {
None => Ok(None),
Some(t) => {
let nanos_u32 = u32::try_from(t.nanos).map_err(|_| {
tracing::error!(
"augment_electrical_component_bounds: invalid nanos value {} from server",
t.nanos
);
Error::api_server_error(
"server returned invalid timestamp (nanos out of range)",
)
})?;
if !(0..=999_999_999).contains(&nanos_u32) {
tracing::error!(
"augment_electrical_component_bounds: nanos value {} out of allowed range",
nanos_u32
);
return Err(Error::api_server_error(
"server returned invalid timestamp (nanos out of allowed range)",
));
}
DateTime::from_timestamp(t.seconds, nanos_u32)
.ok_or_else(|| {
tracing::error!(
"augment_electrical_component_bounds: invalid timestamp (seconds={}, nanos={}) from server",
t.seconds,
nanos_u32,
);
Error::api_server_error(
"server returned invalid timestamp",
)
})
.map(Some)
}
}

Copilot uses AI. Check for mistakes.
});

response_tx
.send(response)
.map_err(|_| Error::internal("failed to send response"))?;
}
None => {}
}

Expand Down
102 changes: 98 additions & 4 deletions src/client/microgrid_client_handle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@
//! Instructions received by this handle are sent to the microgrid client actor,
//! which owns the connection to the microgrid API service.

use chrono::TimeDelta;
use tokio::sync::{broadcast, mpsc, oneshot};
use tonic::transport::Channel;

use crate::{
Error,
Bounds, Error,
client::MicrogridApiClient,
metric::Metric,
proto::{
common::metrics::Bounds as PbBounds,
common::microgrid::electrical_components::{
ElectricalComponent, ElectricalComponentConnection, ElectricalComponentTelemetry,
ElectricalComponent, ElectricalComponentCategory, ElectricalComponentConnection,
ElectricalComponentTelemetry,
},
microgrid::microgrid_client::MicrogridClient,
},
Expand Down Expand Up @@ -99,7 +103,7 @@ impl MicrogridClientHandle {
pub async fn list_electrical_components(
&self,
electrical_component_ids: Vec<u64>,
electrical_component_categories: Vec<i32>,
electrical_component_categories: Vec<ElectricalComponentCategory>,
) -> Result<Vec<ElectricalComponent>, Error> {
Comment on lines 103 to 107
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

The list_electrical_components docs still reference ComponentCategory::COMPONENT_CATEGORY_* constants, but the API now takes ElectricalComponentCategory. Update the documentation example names to match the new enum/type so users can copy/paste working code.

Copilot uses AI. Check for mistakes.
let (response_tx, response_rx) = oneshot::channel();

Expand Down Expand Up @@ -154,6 +158,55 @@ impl MicrogridClientHandle {
.await
.map_err(|e| Error::internal(format!("failed to receive response: {e}")))?
}

/// Augments the overall bounds for a given metric of a given electrical
/// component with the provided bounds.
/// Returns the UTC time at which the provided bounds will expire and its
/// effects will no longer be visible in the overall bounds for the
/// given metric.
///
/// The request parameters allows users to select a duration until
/// which the bounds will stay in effect. If no duration is provided, then the
/// bounds will be removed after a default duration of 5 seconds.
///
/// Inclusion bounds give the range that the system will try to keep the
/// metric within. If the metric goes outside of these bounds, the system will
/// try to bring it back within the bounds.
/// If the bounds for a metric are [Symbol’s value as variable is void: lower_1End.
/// ---- values here are considered out of range.
/// ==== values here are considered within range.
///
Comment on lines +175 to +178
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

The doc comment contains a broken placeholder string ([Symbol’s value as variable is void: lower_1End.) which will render incorrectly in rustdoc and confuses the meaning of the bounds. Replace it with a valid ASCII diagram or remove the diagram entirely.

Suggested change
/// If the bounds for a metric are [Symbol’s value as variable is void: lower_1End.
/// ---- values here are considered out of range.
/// ==== values here are considered within range.
///
///
/// For example, if the bounds for a metric are:
/// ```text
/// -∞ lower upper +∞
/// ---- [====] ----
/// ```
/// then:
/// - `----` values are considered out of range.
/// - `====` values are considered within range.
///

Copilot uses AI. Check for mistakes.
/// Note that for power metrics, regardless of the bounds, 0W is always
/// allowed.
pub async fn augment_electrical_component_bounds<M, I>(
&mut self,
electrical_component_id: u64,
#[allow(unused_variables)] target_metric: M,
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

The target_metric parameter is intentionally unused (suppressed via #[allow(unused_variables)]), which is a confusing API surface. Prefer removing the parameter entirely and relying on the type parameter M, or rename it to _target_metric/use PhantomData<M> so callers aren't required to pass a value that is ignored.

Suggested change
#[allow(unused_variables)] target_metric: M,
_target_metric: M,

Copilot uses AI. Check for mistakes.
bounds: Vec<I>,
request_lifetime: Option<TimeDelta>,
Comment on lines +181 to +186
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

augment_electrical_component_bounds takes &mut self even though it only sends on an mpsc::Sender and does not mutate handle state. Making this &self would avoid unnecessary caller-side mutability and match the other handle APIs.

Copilot uses AI. Check for mistakes.
) -> Result<Option<chrono::DateTime<chrono::Utc>>, Error>
where
M: Metric,
Bounds<M::QuantityType>: Into<PbBounds>,
I: Into<Bounds<M::QuantityType>>,
{
let (response_tx, response_rx) = oneshot::channel();

self.instructions_tx
.send(Instruction::AugmentElectricalComponentBounds {
response_tx,
electrical_component_id,
target_metric: M::METRIC,
bounds: bounds.into_iter().map(|x| x.into().into()).collect(),
request_lifetime,
})
.await
.map_err(|_| Error::internal("failed to send instruction"))?;

response_rx
.await
.map_err(|e| Error::internal(format!("failed to receive response: {e}")))?
}
}

#[cfg(test)]
Expand All @@ -164,7 +217,10 @@ mod tests {
use crate::{
MicrogridClientHandle,
client::test_utils::{MockComponent, MockMicrogridApiClient},
proto::common::metrics::{SimpleMetricValue, metric_value_variant},
proto::common::{
metrics::{SimpleMetricValue, metric_value_variant},
microgrid::electrical_components::ElectricalComponentCategory,
},
};

fn new_client_handle() -> MicrogridClientHandle {
Expand Down Expand Up @@ -207,6 +263,44 @@ mod tests {
assert_eq!(component_ids, vec![1, 2, 3, 4, 5, 6, 7]);
}

#[tokio::test]
async fn test_list_electrical_components_with_filters() {
let handle = new_client_handle();

let components = handle
.list_electrical_components(vec![1, 2], vec![])
.await
.unwrap();
let component_ids: Vec<u64> = components.iter().map(|c| c.id).collect();
assert_eq!(component_ids, vec![1, 2]);

let components = handle
.list_electrical_components(
vec![],
vec![
ElectricalComponentCategory::Meter,
ElectricalComponentCategory::Battery,
],
)
.await
.unwrap();
let component_ids: Vec<u64> = components.iter().map(|c| c.id).collect();
assert_eq!(component_ids, vec![2, 3, 5, 7]);

let components = handle
.list_electrical_components(
vec![2, 3, 4],
vec![
ElectricalComponentCategory::Meter,
ElectricalComponentCategory::Battery,
],
)
.await
.unwrap();
let component_ids: Vec<u64> = components.iter().map(|c| c.id).collect();
assert_eq!(component_ids, vec![2, 3]);
}

#[tokio::test]
async fn test_list_electrical_component_connections() {
let handle = new_client_handle();
Expand Down
1 change: 1 addition & 0 deletions src/proto.rs → src/client/proto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ pub use pb::frequenz::api::microgrid::v1alpha18 as microgrid;
#[cfg(test)]
pub use pb::google;

mod electrical_component;
mod graph;
Loading
Loading