Skip to content
Open
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
21 changes: 14 additions & 7 deletions lox-sim/src/blocks/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ macro_rules! passthrough_io_block {
// ---------------------------------------------------------------------------

/// Input reference — proxy that forwards named inputs to the block graph.
/// I→Q (digital) and AI→AQ (analog).
///
/// A ref is fed on exactly one side (I or AI) but the Miniserver mirrors the
/// signal on BOTH outputs: consumers routinely read Q from an AI-fed ref
/// (r50 corpus: `ref.AI <- mem.AQ` with `monoflop.InputTrigger: ref.Q`).
/// Q is the digital view (non-zero → 1), AQ the analog value; the unfed
/// side idles at 0, so combining the two inputs is lossless.
#[derive(Clone, Copy)]
pub struct InputRef;

Expand All @@ -60,7 +65,9 @@ impl Block for InputRef {
) -> Vec<Signal> {
let i = inputs.first().copied().unwrap_or(0.0);
let ai = inputs.get(1).copied().unwrap_or(0.0);
vec![i, ai]
let q = if i != 0.0 || ai != 0.0 { 1.0 } else { 0.0 };
let aq = if ai != 0.0 { ai } else { i };

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Compiled execution does not mirror InputRef. CompiledGraph::from_graph has no InputRef arm, so this block falls through to EvalStep::Copy, which only copies the first input to the first output. In a Docker repro with AI=42, SimEngine returns Q=1, AQ=42, but CompiledGraph returns Q=0, AQ=0. Please add an InputRef-specific compiled step covering both inputs and outputs, plus an enabled equivalence case using the real I, AI, Q, AQ layout.

vec![q, aq]
}

fn block_type(&self) -> &str {
Expand Down Expand Up @@ -260,12 +267,12 @@ mod tests {
use super::*;
use crate::blocks::create_block;
#[test]
fn input_ref_passthrough() {
fn input_ref_mirrors_fed_side_to_both_outputs() {
let mut block = InputRef;
// I=42, AI=0 → Q=42, AQ=0
assert_eq!(block.eval(&[42.0], &[], 0.0, &[]), vec![42.0, 0.0]);
// I=0, AI=99 → Q=0, AQ=99
assert_eq!(block.eval(&[0.0, 99.0], &[], 0.0, &[]), vec![0.0, 99.0]);
// I=42, AI unfed → Q=1 (digital view), AQ=42
assert_eq!(block.eval(&[42.0], &[], 0.0, &[]), vec![1.0, 42.0]);
// I unfed, AI=99 → Q=1, AQ=99 (AI-fed refs serve Q consumers)
assert_eq!(block.eval(&[0.0, 99.0], &[], 0.0, &[]), vec![1.0, 99.0]);
// empty → Q=0, AQ=0
assert_eq!(block.eval(&[], &[], 0.0, &[]), vec![0.0, 0.0]);
}
Expand Down
22 changes: 19 additions & 3 deletions lox-sim/src/blocks/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,18 @@ impl Block for PushButton {
) -> Vec<Signal> {
let trigger = inputs.first().copied().unwrap_or(0.0);
let force_on = inputs.get(1).copied().unwrap_or(0.0);
let reset = inputs.get(2).copied().unwrap_or(0.0);
let disable = inputs.get(3).copied().unwrap_or(0.0);
let prev_trigger = prev_inputs.first().copied().unwrap_or(0.0);
let previous = self.is_on;

if is_high(force_on) {
// WARNING: Assumed behavior — not validated against Miniserver.
// Assumption: Reset dominates On; InputDisable gates only the trigger.
if is_high(reset) {
self.is_on = false;
} else if is_high(force_on) {
self.is_on = true;
} else if !is_high(prev_trigger) && is_high(trigger) {
} else if !is_high(disable) && !is_high(prev_trigger) && is_high(trigger) {
self.is_on = !self.is_on;
}

Expand Down Expand Up @@ -329,10 +335,20 @@ impl Block for PushButton2 {
prev_inputs: &[Signal],
) -> Vec<Signal> {
let trigger = inputs.first().copied().unwrap_or(0.0);
let reset = inputs.get(2).copied().unwrap_or(0.0);
let disable = inputs.get(3).copied().unwrap_or(0.0);
let prev_trigger = prev_inputs.first().copied().unwrap_or(0.0);
let dc_window = params.first().copied().unwrap_or(0.4).max(0.0);
let previous = self.is_on;
let rising = !is_high(prev_trigger) && is_high(trigger);
// WARNING: Assumed behavior — not validated against Miniserver.
// Assumption: Reset dominates and cancels a pending double-click;
// InputDisable gates only the trigger.
if is_high(reset) {
self.is_on = false;
self.awaiting_second = false;
}
let rising =
!is_high(reset) && !is_high(disable) && !is_high(prev_trigger) && is_high(trigger);
let mut double_click = false;

if self.awaiting_second {
Expand Down
8 changes: 7 additions & 1 deletion lox-sim/src/blocks/timers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,16 @@ impl Block for Monoflop {
prev_inputs: &[Signal],
) -> Vec<Signal> {
let trigger = inputs.first().copied().unwrap_or(0.0);
let reset = inputs.get(1).copied().unwrap_or(0.0);
let prev_trigger = prev_inputs.first().copied().unwrap_or(0.0);
let duration = params.first().copied().unwrap_or(1.0).max(0.0);

if !is_high(prev_trigger) && is_high(trigger) {
// WARNING: Assumed behavior — not validated against Miniserver.
// Assumption: Reset aborts the running pulse and blocks retriggering
// while held.
if is_high(reset) {
self.remaining = 0.0;
} else if !is_high(prev_trigger) && is_high(trigger) {
self.remaining = duration.max(dt);
}

Expand Down
37 changes: 30 additions & 7 deletions lox-sim/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ pub enum EvalStep {
Monoflop {
trigger: usize,
prev_trigger: usize,
/// Reset signal slot; `usize::MAX` when the block has no Reset wire.
reset: usize,
param_duration: usize,
output: usize,
state_idx: usize,
Expand Down Expand Up @@ -217,6 +219,10 @@ pub enum EvalStep {
trigger: usize,
prev_trigger: usize,
force_on: usize,
/// Reset/InputDisable signal slots; `usize::MAX` when the block has
/// no such connector (PushButtonSel layouts vary).
reset: usize,
disable: usize,
/// outputs: [Q, Qoff, Qon]
outputs: [usize; 3],
state_idx: usize,
Expand Down Expand Up @@ -384,11 +390,13 @@ impl CompiledGraph {
// For feedback wires, we'll read from prev_signals instead.
let feedback_wires = &topo.feedback_wires;

// Build input source map: for each input connector, where does its value come from?
// Build input source map: for each input or parameter connector,
// where does its value come from? (Parameters can be wire-driven
// too, e.g. Formula Input1-Input4.)
let mut input_source: Vec<(usize, bool)> = vec![(0, false); n_conn];
for (cid, src) in input_source.iter_mut().enumerate() {
let is_input = graph.connector(cid).dir == ConnectorDir::Input;
if is_input {
let is_sink = graph.connector(cid).dir != ConnectorDir::Output;
if is_sink {
match graph.input_source_of(cid) {
Some(from) => {
let is_fb = feedback_wires.contains(&(from, cid));
Expand Down Expand Up @@ -420,7 +428,9 @@ impl CompiledGraph {
let prev_inputs: Vec<usize> = resolved_inputs.clone();

let outputs = &info.outputs;
let params = &info.params;
// Wired parameters read their driving output's signal; unwired
// ones read their own connector (holding the Def= value).
let params: Vec<usize> = info.params.iter().map(|&cid| input_source[cid].0).collect();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Wired Formula parameters are still ignored by compiled execution. Resolving parameter source indices here works for compiled block types such as Gain, but Formula has no compiled match arm and falls through to EvalStep::Copy. With Input1 wired to 5 and expression I1*2, the Docker repro returns 10 from SimEngine and 5 from CompiledGraph. Since parsed Formula operands are parameters rather than regular inputs, please implement compiled Formula semantics using these resolved parameter slots and cover a parsed/wired Formula in equivalence tests.


let step = match block_type {
"And" => EvalStep::And {
Expand Down Expand Up @@ -520,6 +530,7 @@ impl CompiledGraph {
EvalStep::Monoflop {
trigger: resolved_inputs.first().copied().unwrap_or(0),
prev_trigger: prev_inputs.first().copied().unwrap_or(0),
reset: resolved_inputs.get(1).copied().unwrap_or(usize::MAX),
param_duration: params.first().copied().unwrap_or(0),
output: outputs[0],
state_idx: si,
Expand Down Expand Up @@ -662,6 +673,8 @@ impl CompiledGraph {
trigger: resolved_inputs.first().copied().unwrap_or(0),
prev_trigger: prev_inputs.first().copied().unwrap_or(0),
force_on: resolved_inputs.get(1).copied().unwrap_or(0),
reset: resolved_inputs.get(2).copied().unwrap_or(usize::MAX),
disable: resolved_inputs.get(3).copied().unwrap_or(usize::MAX),
outputs: [
*outputs.first().unwrap_or(&0),
*outputs.get(1).unwrap_or(&0),
Expand Down Expand Up @@ -969,18 +982,22 @@ impl CompiledGraph {
EvalStep::Monoflop {
trigger,
prev_trigger,
reset,
param_duration,
output,
state_idx,
} => {
let trig = self.signals[*trigger];
let prev_trig = self.prev_signals[*prev_trigger];
let rst = self.signals.get(*reset).copied().unwrap_or(0.0);
let duration = self.signals[*param_duration].max(0.0);
let out = *output;
let si = *state_idx;

if let BlockState::Timer { remaining, .. } = &mut self.state[si] {
if prev_trig < 0.5 && trig >= 0.5 {
if rst >= 0.5 {
*remaining = 0.0;
} else if prev_trig < 0.5 && trig >= 0.5 {
*remaining = duration.max(dt);
}
let q = *remaining > 0.0;
Expand Down Expand Up @@ -1315,20 +1332,26 @@ impl CompiledGraph {
trigger,
prev_trigger,
force_on,
reset,
disable,
outputs,
state_idx,
} => {
let trig = self.signals[*trigger];
let prev_trig = self.prev_signals[*prev_trigger];
let force = self.signals[*force_on];
let rst = self.signals.get(*reset).copied().unwrap_or(0.0);
let dis = self.signals.get(*disable).copied().unwrap_or(0.0);
let outs = *outputs;
let si = *state_idx;

if let BlockState::PushButton { is_on } = &mut self.state[si] {
let previous = *is_on;
if force >= 0.5 {
if rst >= 0.5 {
*is_on = false;
} else if force >= 0.5 {
*is_on = true;
} else if prev_trig < 0.5 && trig >= 0.5 {
} else if dis < 0.5 && prev_trig < 0.5 && trig >= 0.5 {
*is_on = !*is_on;
}
let qon = !previous && *is_on;
Expand Down
50 changes: 43 additions & 7 deletions lox-sim/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ struct BlockEvalInfo {
input_sources: Vec<(ConnectorId, bool)>,
/// Connector IDs for outputs.
output_cids: Vec<ConnectorId>,
/// Connector IDs for parameters.
param_cids: Vec<ConnectorId>,
/// `(source_cid, is_feedback)` for each parameter connector. A wired
/// parameter resolves to its driving output; an unwired one resolves
/// to itself (holding the Def= value).
param_sources: Vec<(ConnectorId, bool)>,
/// Whether the block uses prev_inputs for edge detection.
edge_sensitive: bool,
/// Whether the block has time-dependent state (timers, counters) that
Expand Down Expand Up @@ -167,13 +169,28 @@ impl SimEngine {
}
})
.collect();
// Parameters resolve through wires exactly like inputs: a
// wired parameter (Formula Input1, comparator Input2)
// reads its source's signal; an unwired one reads its own
// connector (the Def= value).
let param_sources: Vec<(ConnectorId, bool)> = info
.params
.iter()
.map(|&cid| match graph.input_source_of(cid) {
Some(src) => {
let is_fb = topo.feedback_wires.contains(&(src, cid));
(src, is_fb)
}
None => (cid, false),
})
.collect();
let n_inputs = input_sources.len();
let edge_sensitive = blocks[bid].is_edge_sensitive();
let time_dependent = blocks[bid].is_time_dependent();
BlockEvalInfo {
input_sources,
output_cids: info.outputs.clone(),
param_cids: info.params.clone(),
param_sources,
edge_sensitive,
time_dependent,
last_prev_inputs: vec![0.0; n_inputs],
Expand Down Expand Up @@ -550,8 +567,18 @@ impl SimEngine {
})
.collect();

// Gather params.
let params: Vec<f64> = ei.param_cids.iter().map(|&cid| self.signals[cid]).collect();
// Gather params (wired parameters read their source's signal).
let params: Vec<f64> = ei
.param_sources
.iter()
.map(|&(src, is_fb)| {
if is_fb {
self.prev_signals[src]
} else {
self.signals[src]
}
})
.collect();

// Gather previous-tick inputs (for edge detection).
let prev_inputs: Vec<f64> = ei
Expand Down Expand Up @@ -782,8 +809,17 @@ impl SimEngine {
})
.collect();

let param_duals: Vec<DualNumber> =
ei.param_cids.iter().map(|&cid| dual_signals[cid]).collect();
let param_duals: Vec<DualNumber> = ei
.param_sources
.iter()
.map(|&(src, is_fb)| {
if is_fb {
DualNumber::constant(self.prev_signals[src])
} else {
dual_signals[src]
}
})
.collect();

// Compute dual outputs using the block type's semantics.
let out_duals =
Expand Down
5 changes: 4 additions & 1 deletion lox-sim/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ impl SimGraph {
if self.connectors[from].dir != ConnectorDir::Output {
return Err(GraphError::ConnectorNotOutput(from));
}
if self.connectors[to].dir != ConnectorDir::Input {
// Parameter connectors accept wires too: Loxone lets any parameter
// (e.g. Formula Input1-Input4, comparator Input2) be driven by a
// wire instead of a fixed value.
if self.connectors[to].dir == ConnectorDir::Output {
return Err(GraphError::ConnectorNotInput(to));
}
if self.input_source.contains_key(&to) {
Expand Down
6 changes: 3 additions & 3 deletions lox-sim/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ fn block_signature(
&["Q1", "Q2", "Q3", "Q4", "AQ"],
&["Time", "V1", "V2", "V3", "V4"],
),
"Monoflop" => (&["InputTrigger"], &["Q"], &["Time"]),
"Monoflop" => (&["InputTrigger", "Reset"], &["Q"], &["Time"]),
"Minmax" => (
&["Input1", "Input2", "Input3", "Input4"],
&["AQmin", "AQmax"],
Expand Down Expand Up @@ -605,12 +605,12 @@ fn block_signature(
&["TimeHigh", "TimeLow"],
),
"PushButton" | "PushButton2" | "PushButton2Sel" => (
&["InputTrigger", "On"],
&["InputTrigger", "On", "Reset", "InputDisable"],
&["Q", "Qoff", "Qon", "AQ"],
&["Min", "Max"],
),
"PushButtonSel" => (
&["InputTrigger", "InputPos", "Reset"],
&["InputTrigger", "InputPos", "Reset", "InputDisable"],
&["AQ"],
&["Min", "Max", "Step", "Repeat", "Def"],
),
Expand Down