use mako_engine::{
deadline::Deadline,
error::WorkflowError,
ids::DeadlineId,
workflow::{CommandPayload, EventPayload, Workflow, WorkflowOutput},
};
use serde::{Deserialize, Serialize};
pub const WORKFLOW_NAME: &str = "redispatch-aktivierung";
pub const IFTSTA_PIDS: &[u32] = &[21_037, 21_038];
pub const MSCONS_PIDS: &[u32] = &[13_020, 13_021, 13_022, 13_023, 13_026];
pub const ORDERS_PIDS: &[u32] = &[17_209, 17_210, 17_211];
pub const ORDRSP_PIDS: &[u32] = &[19_204, 19_301, 19_302];
pub const ACTIVATION_RESPONSE_WINDOW_LABEL: &str = "redispatch-activation-response-window";
pub const ACK_WINDOW_LABEL: &str = "redispatch-aktivierung-ack-window";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResponseType {
Confirmed,
PartialRejection,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum AktivierungEvent {
AcoReceived {
mrid: String,
ordered_mw: f64,
resource_id: String,
period: String,
sender: String,
receiver: String,
received_at: String,
},
AcoCascaded {
recipient_anb: String,
child_mrid: String,
},
AcrSent {
acr_mrid: String,
activated_mw: f64,
},
AarSent {
aar_mrid: String,
available_mw: f64,
reason_code: String,
},
AnbResponseReceived {
anb_id: String,
response_type: ResponseType,
response_mrid: String,
},
ResponseForwarded {
upstream_mrid: String,
response_type: ResponseType,
},
IftstaReceived {
pid: u32,
sender: String,
receiver: String,
message_ref: String,
},
DeadlineExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl EventPayload for AktivierungEvent {
fn event_type(&self) -> &'static str {
match self {
Self::AcoReceived { .. } => "AktivierungAcoReceived",
Self::AcoCascaded { .. } => "AktivierungAcoCascaded",
Self::AcrSent { .. } => "AktivierungAcrSent",
Self::AarSent { .. } => "AktivierungAarSent",
Self::AnbResponseReceived { .. } => "AktivierungAnbResponseReceived",
Self::ResponseForwarded { .. } => "AktivierungResponseForwarded",
Self::IftstaReceived { .. } => "AktivierungIftstaReceived",
Self::DeadlineExpired { .. } => "AktivierungDeadlineExpired",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AcoData {
pub mrid: String,
pub ordered_mw: f64,
pub resource_id: String,
pub period: String,
pub sender: String,
pub receiver: String,
pub received_at: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "status", content = "data")]
pub enum AktivierungState {
#[default]
New,
ActivationOrdered(AcoData),
DispatchedToAnb(AcoData),
Confirmed {
data: AcoData,
acr_mrid: String,
},
PartialRejection {
data: AcoData,
aar_mrid: String,
},
Done(AcoData),
DeadlineExpired {
reason: String,
},
}
impl AktivierungState {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::New => "New",
Self::ActivationOrdered(_) => "ActivationOrdered",
Self::DispatchedToAnb(_) => "DispatchedToAnb",
Self::Confirmed { .. } => "Confirmed",
Self::PartialRejection { .. } => "PartialRejection",
Self::Done(_) => "Done",
Self::DeadlineExpired { .. } => "DeadlineExpired",
}
}
}
#[derive(Clone)]
pub enum AktivierungCommand {
ReceiveAco {
mrid: String,
ordered_mw: f64,
resource_id: String,
period: String,
sender: String,
receiver: String,
received_at: String,
},
CascadeToAnb {
recipient_anb: String,
child_mrid: String,
},
SendAcr {
acr_mrid: String,
activated_mw: f64,
},
SendAar {
aar_mrid: String,
available_mw: f64,
reason_code: String,
},
RecordAnbResponse {
anb_id: String,
response_type: ResponseType,
response_mrid: String,
},
ForwardResponse {
upstream_mrid: String,
response_type: ResponseType,
},
ReceiveIftsta {
pid: u32,
sender: String,
receiver: String,
message_ref: String,
},
TimeoutExpired {
deadline_id: DeadlineId,
label: Box<str>,
},
}
impl CommandPayload for AktivierungCommand {}
pub struct AktivierungWorkflow;
impl Workflow for AktivierungWorkflow {
type State = AktivierungState;
type Event = AktivierungEvent;
type Command = AktivierungCommand;
fn on_deadline(deadline: &Deadline, state: &Self::State) -> Option<Self::Command> {
match (deadline.label(), state) {
(
ACTIVATION_RESPONSE_WINDOW_LABEL,
AktivierungState::ActivationOrdered(_) | AktivierungState::DispatchedToAnb(_),
) => Some(AktivierungCommand::TimeoutExpired {
deadline_id: deadline.deadline_id(),
label: deadline.label().into(),
}),
_ => None,
}
}
fn apply(state: Self::State, event: &Self::Event) -> Self::State {
match event {
AktivierungEvent::AcoReceived {
mrid,
ordered_mw,
resource_id,
period,
sender,
receiver,
received_at,
} => AktivierungState::ActivationOrdered(AcoData {
mrid: mrid.clone(),
ordered_mw: *ordered_mw,
resource_id: resource_id.clone(),
period: period.clone(),
sender: sender.clone(),
receiver: receiver.clone(),
received_at: received_at.clone(),
}),
AktivierungEvent::AcoCascaded { .. } => match state {
AktivierungState::ActivationOrdered(data) => {
AktivierungState::DispatchedToAnb(data)
}
other => other,
},
AktivierungEvent::AcrSent { acr_mrid, .. } => match state {
AktivierungState::ActivationOrdered(data)
| AktivierungState::DispatchedToAnb(data) => AktivierungState::Confirmed {
data,
acr_mrid: acr_mrid.clone(),
},
other => other,
},
AktivierungEvent::AarSent { aar_mrid, .. } => match state {
AktivierungState::ActivationOrdered(data)
| AktivierungState::DispatchedToAnb(data) => AktivierungState::PartialRejection {
data,
aar_mrid: aar_mrid.clone(),
},
other => other,
},
AktivierungEvent::AnbResponseReceived { .. } => {
state
}
AktivierungEvent::ResponseForwarded { .. } => match state {
AktivierungState::DispatchedToAnb(data) => AktivierungState::Done(data),
other => other,
},
AktivierungEvent::IftstaReceived { .. } => state,
AktivierungEvent::DeadlineExpired { label, .. } => match state {
AktivierungState::Confirmed { .. }
| AktivierungState::PartialRejection { .. }
| AktivierungState::Done(_)
| AktivierungState::DeadlineExpired { .. } => state,
_ => AktivierungState::DeadlineExpired {
reason: format!("deadline expired: {label}"),
},
},
}
}
#[allow(clippy::too_many_lines)]
fn handle(
state: &Self::State,
command: Self::Command,
) -> Result<WorkflowOutput<Self::Event>, WorkflowError> {
match command {
AktivierungCommand::ReceiveAco {
mrid,
ordered_mw,
resource_id,
period,
sender,
receiver,
received_at,
} => {
if !matches!(state, AktivierungState::New) {
return Ok(vec![].into());
}
Ok(vec![AktivierungEvent::AcoReceived {
mrid,
ordered_mw,
resource_id,
period,
sender,
receiver,
received_at,
}]
.into())
}
AktivierungCommand::CascadeToAnb {
recipient_anb,
child_mrid,
} => match state {
AktivierungState::ActivationOrdered(_) => Ok(vec![AktivierungEvent::AcoCascaded {
recipient_anb,
child_mrid,
}]
.into()),
AktivierungState::DispatchedToAnb(_) => Ok(vec![].into()),
other => Err(WorkflowError::rejected(format!(
"CascadeToAnb not valid in state {}",
other.label()
))),
},
AktivierungCommand::SendAcr {
acr_mrid,
activated_mw,
} => match state {
AktivierungState::ActivationOrdered(_) | AktivierungState::DispatchedToAnb(_) => {
Ok(vec![AktivierungEvent::AcrSent {
acr_mrid,
activated_mw,
}]
.into())
}
AktivierungState::Confirmed { .. } => Ok(vec![].into()),
other => Err(WorkflowError::rejected(format!(
"SendAcr not valid in state {}",
other.label()
))),
},
AktivierungCommand::SendAar {
aar_mrid,
available_mw,
reason_code,
} => match state {
AktivierungState::ActivationOrdered(_) | AktivierungState::DispatchedToAnb(_) => {
Ok(vec![AktivierungEvent::AarSent {
aar_mrid,
available_mw,
reason_code,
}]
.into())
}
AktivierungState::PartialRejection { .. } => Ok(vec![].into()),
other => Err(WorkflowError::rejected(format!(
"SendAar not valid in state {}",
other.label()
))),
},
AktivierungCommand::RecordAnbResponse {
anb_id,
response_type,
response_mrid,
} => match state {
AktivierungState::DispatchedToAnb(_) => {
Ok(vec![AktivierungEvent::AnbResponseReceived {
anb_id,
response_type,
response_mrid,
}]
.into())
}
other => Err(WorkflowError::rejected(format!(
"RecordAnbResponse not valid in state {}",
other.label()
))),
},
AktivierungCommand::ForwardResponse {
upstream_mrid,
response_type,
} => match state {
AktivierungState::DispatchedToAnb(_) => {
Ok(vec![AktivierungEvent::ResponseForwarded {
upstream_mrid,
response_type,
}]
.into())
}
AktivierungState::Done(_) => Ok(vec![].into()),
other => Err(WorkflowError::rejected(format!(
"ForwardResponse not valid in state {}",
other.label()
))),
},
AktivierungCommand::ReceiveIftsta {
pid,
sender,
receiver,
message_ref,
} => Ok(vec![AktivierungEvent::IftstaReceived {
pid,
sender,
receiver,
message_ref,
}]
.into()),
AktivierungCommand::TimeoutExpired { deadline_id, label } => match state {
AktivierungState::Confirmed { .. }
| AktivierungState::PartialRejection { .. }
| AktivierungState::Done(_)
| AktivierungState::DeadlineExpired { .. } => Ok(vec![].into()),
_ => Ok(vec![AktivierungEvent::DeadlineExpired { deadline_id, label }].into()),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mako_engine::ids::DeadlineId;
fn aco_data() -> AcoData {
AcoData {
mrid: "aco-001".into(),
ordered_mw: 50.0,
resource_id: "4012345678901".into(),
period: "2025-10-15T10:00:00Z/2025-10-15T11:00:00Z".into(),
sender: "4012345000001".into(),
receiver: "4012345000002".into(),
received_at: "2025-10-15T09:58:00Z".into(),
}
}
fn receive_aco_cmd() -> AktivierungCommand {
AktivierungCommand::ReceiveAco {
mrid: "aco-001".into(),
ordered_mw: 50.0,
resource_id: "4012345678901".into(),
period: "2025-10-15T10:00:00Z/2025-10-15T11:00:00Z".into(),
sender: "4012345000001".into(),
receiver: "4012345000002".into(),
received_at: "2025-10-15T09:58:00Z".into(),
}
}
#[test]
fn receive_aco_transitions_new_to_activation_ordered() {
let state = AktivierungState::New;
let output = AktivierungWorkflow::handle(&state, receive_aco_cmd()).unwrap();
assert_eq!(output.events.len(), 1);
let new_state = AktivierungWorkflow::apply(state, &output.events[0]);
assert!(matches!(new_state, AktivierungState::ActivationOrdered(_)));
}
#[test]
fn send_acr_from_activation_ordered_produces_confirmed() {
let state = AktivierungState::ActivationOrdered(aco_data());
let output = AktivierungWorkflow::handle(
&state,
AktivierungCommand::SendAcr {
acr_mrid: "acr-001".into(),
activated_mw: 50.0,
},
)
.unwrap();
let new_state = AktivierungWorkflow::apply(state, &output.events[0]);
assert!(matches!(new_state, AktivierungState::Confirmed { .. }));
}
#[test]
fn send_aar_from_activation_ordered_produces_partial_rejection() {
let state = AktivierungState::ActivationOrdered(aco_data());
let output = AktivierungWorkflow::handle(
&state,
AktivierungCommand::SendAar {
aar_mrid: "aar-001".into(),
available_mw: 30.0,
reason_code: "A96".into(),
},
)
.unwrap();
let new_state = AktivierungWorkflow::apply(state, &output.events[0]);
assert!(matches!(
new_state,
AktivierungState::PartialRejection { .. }
));
}
#[test]
fn timeout_in_confirmed_state_is_noop() {
let state = AktivierungState::Confirmed {
data: aco_data(),
acr_mrid: "acr-001".into(),
};
let output = AktivierungWorkflow::handle(
&state,
AktivierungCommand::TimeoutExpired {
deadline_id: DeadlineId::new(),
label: ACTIVATION_RESPONSE_WINDOW_LABEL.into(),
},
)
.unwrap();
assert!(output.events.is_empty());
}
#[test]
fn iftsta_accepted_in_any_state() {
for state in [
AktivierungState::New,
AktivierungState::ActivationOrdered(aco_data()),
AktivierungState::Confirmed {
data: aco_data(),
acr_mrid: "x".into(),
},
] {
let output = AktivierungWorkflow::handle(
&state,
AktivierungCommand::ReceiveIftsta {
pid: 21_037,
sender: "s".into(),
receiver: "r".into(),
message_ref: "ref-1".into(),
},
)
.unwrap();
assert!(matches!(
output.events.as_slice(),
[AktivierungEvent::IftstaReceived { pid: 21037, .. }]
));
}
}
}