Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/lantern-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@ serde.workspace = true
thiserror.workspace = true
toml.workspace = true

[dev-dependencies]
proptest.workspace = true

[lints]
workspace = true
85 changes: 85 additions & 0 deletions crates/lantern-app/examples/poll_planner_benchmark.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
use std::{error::Error, fmt::Write as _, hint::black_box, time::Duration};

use lantern_app::{
FrequencyClass, PollCadences, PollPlanner, PollPlannerConfig, ReadSubscription, SubscriberId,
SubscriptionReason,
};
use lantern_profile::{MAX_PARAMETERS, ProfileFormat, parse_and_validate_profile};

const ITERATIONS: u32 = 10;

fn maximum_profile_json() -> Result<String, std::fmt::Error> {
let mut source = String::with_capacity(4 * 1024 * 1024);
source.push_str(
r#"{"schema_version":1,"profile_id":"benchmark.maximum","revision":1,"vendor":"Benchmark","family":"Planner","model":"Maximum","protocol":{"default_baud_rate":115200,"default_parity":"none","default_data_bits":8,"default_stop_bits":1,"response_timeout_ms":100,"default_slave_id":1,"rs485_mode":"adapter_managed"},"parameters":["#,
);
for index in 0..MAX_PARAMETERS {
if index > 0 {
source.push(',');
}
write!(
source,
r#"{{"id":"p{index:05}","code":"P{index:05}","name":"P{index:05}","table":"holding_registers","address":{{"notation":"pdu_zero_based","value":{index}}},"encoding":"unsigned16","quantity":"frequency","unit":"hz"}}"#,
)?;
}
source.push_str("]}");
Ok(source)
}

fn main() -> Result<(), Box<dyn Error>> {
let source = maximum_profile_json()?;
let parse_started = std::time::Instant::now();
let profile = parse_and_validate_profile(source.as_bytes(), ProfileFormat::Json)?;
let parse_elapsed = parse_started.elapsed();
let subscriber = SubscriberId::parse("maximum-profile-benchmark")?;
let subscriptions = profile
.parameters()
.keys()
.cloned()
.map(|parameter_id| {
ReadSubscription::new(
parameter_id,
FrequencyClass::Slow,
subscriber.clone(),
SubscriptionReason::Diagnostics,
false,
Duration::from_secs(3_600),
)
})
.collect::<Result<Vec<_>, _>>()?;
let cadence = Duration::from_secs(3_600);
let config = PollPlannerConfig::new(
PollCadences::new(cadence, cadence, cadence)?,
profile.protocol().default_link(),
Duration::ZERO,
Duration::ZERO,
700_000,
)?;
let planner = PollPlanner::new();
let now = std::time::Instant::now();
let planning_started = std::time::Instant::now();
let mut last_plan = None;
for _ in 0..ITERATIONS {
last_plan = Some(planner.build(&profile, subscriptions.clone(), config, now)?);
}
let planning_elapsed = planning_started.elapsed();
let plan = last_plan.ok_or("benchmark did not execute")?;
black_box(&plan);

println!("parameters={MAX_PARAMETERS}");
println!("profile_bytes={}", source.len());
println!("parse_ms={:.3}", parse_elapsed.as_secs_f64() * 1_000.0);
println!("iterations={ITERATIONS}");
println!(
"planning_total_ms={:.3}",
planning_elapsed.as_secs_f64() * 1_000.0
);
println!(
"planning_average_ms={:.3}",
planning_elapsed.as_secs_f64() * 1_000.0 / f64::from(ITERATIONS)
);
println!("blocks={}", plan.blocks().len());
println!("utilization_ppm={}", plan.utilization_ppm());
println!("rejections={}", plan.rejections().len());
Ok(())
}
239 changes: 228 additions & 11 deletions crates/lantern-app/src/bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,32 +37,224 @@ impl RequestClass {
}
}

/// Immutable request metadata with application-owned queue-class construction.
///
/// External consumers can create explicit interactive or background one-shot
/// requests. Periodic queue classes and `SafetyOneShot` are sealed inside
/// `lantern-app`, so a TUI, CSV writer, or other producer cannot self-promote.
///
/// ```compile_fail
/// use lantern_app::{BusRequestContext, RequestClass};
/// # use lantern_domain::{RequestId, SessionId};
/// # use std::time::Instant;
/// let _ = BusRequestContext {
/// request_id: RequestId::new(1),
/// session_id: SessionId::new(1),
/// class: RequestClass::SafetyOneShot,
/// deadline: Instant::now(),
/// operation_id: None,
/// };
/// ```
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BusRequestContext {
pub request_id: RequestId,
pub session_id: SessionId,
pub class: RequestClass,
pub deadline: Instant,
pub operation_id: Option<OperationId>,
request_id: RequestId,
session_id: SessionId,
class: RequestClass,
deadline: Instant,
operation_id: Option<OperationId>,
}

impl BusRequestContext {
/// Creates an explicit user-initiated one-shot context.
#[must_use]
pub const fn interactive(
request_id: RequestId,
session_id: SessionId,
deadline: Instant,
operation_id: Option<OperationId>,
) -> Self {
Self::new(
request_id,
session_id,
RequestClass::Interactive,
deadline,
operation_id,
)
}

/// Creates a low-priority application one-shot context.
#[must_use]
pub const fn background(
request_id: RequestId,
session_id: SessionId,
deadline: Instant,
operation_id: Option<OperationId>,
) -> Self {
Self::new(
request_id,
session_id,
RequestClass::Background,
deadline,
operation_id,
)
}

pub(crate) const fn periodic(
request_id: RequestId,
session_id: SessionId,
class: RequestClass,
deadline: Instant,
) -> Result<Self, BusError> {
if !class.is_periodic_allowed() || matches!(class, RequestClass::Interactive) {
return Err(BusError::InvalidRequest(
"periodic context requires an application polling class",
));
}
Ok(Self::new(request_id, session_id, class, deadline, None))
}

#[allow(dead_code)]
pub(crate) const fn safety_one_shot(
request_id: RequestId,
session_id: SessionId,
deadline: Instant,
operation_id: Option<OperationId>,
) -> Self {
Self::new(
request_id,
session_id,
RequestClass::SafetyOneShot,
deadline,
operation_id,
)
}

#[cfg(feature = "test-support")]
#[doc(hidden)]
#[must_use]
pub const fn test_only(
request_id: RequestId,
session_id: SessionId,
class: RequestClass,
deadline: Instant,
operation_id: Option<OperationId>,
) -> Self {
Self::new(request_id, session_id, class, deadline, operation_id)
}

const fn new(
request_id: RequestId,
session_id: SessionId,
class: RequestClass,
deadline: Instant,
operation_id: Option<OperationId>,
) -> Self {
Self {
request_id,
session_id,
class,
deadline,
operation_id,
}
}

#[must_use]
pub const fn request_id(self) -> RequestId {
self.request_id
}

#[must_use]
pub const fn session_id(self) -> SessionId {
self.session_id
}

#[must_use]
pub const fn class(self) -> RequestClass {
self.class
}

#[must_use]
pub const fn deadline(self) -> Instant {
self.deadline
}

#[must_use]
pub const fn operation_id(self) -> Option<OperationId> {
self.operation_id
}
}

/// Read request whose periodic construction is sealed inside `lantern-app`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReadBusRequest {
pub context: BusRequestContext,
pub slave: SlaveId,
pub function: ModbusFunction,
pub block: RegisterBlock,
pub periodic: bool,
context: BusRequestContext,
slave: SlaveId,
function: ModbusFunction,
block: RegisterBlock,
periodic: bool,
}

impl ReadBusRequest {
pub fn one_shot(
context: BusRequestContext,
slave: SlaveId,
function: ModbusFunction,
block: RegisterBlock,
) -> Result<Self, BusError> {
let request = Self {
context,
slave,
function,
block,
periodic: false,
};
request.validate()?;
Ok(request)
}

pub(crate) fn periodic(
context: BusRequestContext,
slave: SlaveId,
function: ModbusFunction,
block: RegisterBlock,
) -> Result<Self, BusError> {
let request = Self {
context,
slave,
function,
block,
periodic: true,
};
request.validate()?;
Ok(request)
}

#[cfg(feature = "test-support")]
#[doc(hidden)]
#[must_use]
pub fn test_only(
context: BusRequestContext,
slave: SlaveId,
function: ModbusFunction,
block: RegisterBlock,
periodic: bool,
) -> Self {
Self {
context,
slave,
function,
block,
periodic,
}
}

pub fn validate(&self) -> Result<(), BusError> {
if self.function.is_write() {
return Err(BusError::InvalidRequest(
"read request uses a write function",
));
}
if self.periodic && !self.context.class.is_periodic_allowed() {
if self.periodic && !self.context.class().is_periodic_allowed() {
return Err(BusError::InvalidRequest(
"periodic request cannot use SafetyOneShot",
));
Expand All @@ -72,6 +264,31 @@ impl ReadBusRequest {
.and_then(|()| self.function.validate_count(self.block.count()))
.map_err(|_| BusError::InvalidRequest("invalid Modbus read block"))
}

#[must_use]
pub const fn context(&self) -> BusRequestContext {
self.context
}

#[must_use]
pub const fn slave(&self) -> SlaveId {
self.slave
}

#[must_use]
pub const fn function(&self) -> ModbusFunction {
self.function
}

#[must_use]
pub const fn block(&self) -> RegisterBlock {
self.block
}

#[must_use]
pub const fn is_periodic(&self) -> bool {
self.periodic
}
}

/// A write capability produced only by the application write authority.
Expand Down
6 changes: 2 additions & 4 deletions crates/lantern-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
mod application;
mod bus;
mod clock;
mod poll;
mod ports;
mod profile_registry;
mod serial;
Expand All @@ -15,13 +16,10 @@ mod write_coordinator;
pub use application::*;
pub use bus::*;
pub use clock::*;
pub use poll::*;
pub use ports::*;
pub use profile_registry::*;
pub use serial::*;
pub use session::*;
pub use settings::*;
pub use write_coordinator::*;

/// Application-owned polling policy placeholder introduced fully by issue #10.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct PollPlanner;
Loading
Loading