Add machine workshop, typed logic, mechanics and sensor-driven ECUs

This commit is contained in:
max
2026-11-02 07:27:13 +01:00
parent eb2b6366c2
commit abfcce0f46
99 changed files with 28835 additions and 145 deletions

84
tests/logic_tests.cpp Normal file
View File

@@ -0,0 +1,84 @@
#include "FluidSim/Machine.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <fstream>
#include <iostream>
#include <limits>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool condition,const char* why){if(!condition)throw std::runtime_error(why);}
bool close(double a,double b){return std::abs(a-b)<=1e-9*std::max({1.,std::abs(a),std::abs(b)});}
template<class F>void rejects(F&& f,const char* why){bool rejected=false;try{f();}catch(const std::exception&){rejected=true;}check(rejected,why);}
double number(const MachineLogicRuntime& r,std::uint32_t n,const char* port="out"){const auto value=r.value({n,port});check(value.validity==SignalValidity::Valid,"Numeric output unexpectedly invalid");return std::get<double>(value.value);}
bool bit(const MachineLogicRuntime& r,std::uint32_t n,const char* port="out"){const auto value=r.value({n,port});check(value.validity==SignalValidity::Valid,"Boolean output unexpectedly invalid");return std::get<bool>(value.value);}
std::uint32_t add(LogicGraph& g,LogicNodeKind kind){return g.add(kind).id;}
}
int main()try{
for(int i=0;i<logic_node_kind_count;++i){LogicNode node;node.kind=LogicNodeKind(i);check(!logic_node_ports(node).empty(),"Node kind has no declared typed ports");}
for(const auto kind:{LogicNodeKind::And,LogicNodeKind::Or,LogicNodeKind::Xor,LogicNodeKind::Nand,LogicNodeKind::Nor})for(int mask=0;mask<4;++mask){
MachineDocument doc;auto& g=doc.logic;const auto gate=add(g,kind),a=add(g,LogicNodeKind::BooleanConstant),b=add(g,LogicNodeKind::BooleanConstant);g.find(a)->boolean=(mask&1)!=0;g.find(b)->boolean=(mask&2)!=0;g.connect({gate,"in1"},{a,"out"});g.connect({b,"out"},{gate,"in2"});MachineLogicRuntime runtime(doc);runtime.evaluate(0,.001);
bool expected=false;if(kind==LogicNodeKind::And)expected=mask==3;else if(kind==LogicNodeKind::Or)expected=mask!=0;else if(kind==LogicNodeKind::Xor)expected=mask==1||mask==2;else if(kind==LogicNodeKind::Nand)expected=mask!=3;else expected=mask==0;
check(bit(runtime,gate)==expected,"Boolean gate truth table is wrong");check(runtime.value({gate,"out"}).timestamp==0,"Logic output has the wrong sample timestamp");
}
{
MachineDocument doc;auto& g=doc.logic;const auto gate=add(g,LogicNodeKind::And),source=add(g,LogicNodeKind::Switch),negated=add(g,LogicNodeKind::Not);g.find(gate)->input_count=3;
g.connect({source,"out"},{gate,"in1"});g.connect({source,"out"},{gate,"in2"});g.connect({source,"out"},{gate,"in3"});g.connect({gate,"out"},{negated,"in"});MachineLogicRuntime runtime(doc);runtime.evaluate(0,.01);check(bit(runtime,negated),"NOT / fan-out failed");runtime.set_manual(source,true);runtime.evaluate(.01,.01);check(bit(runtime,gate)&&!bit(runtime,negated),"Three-input AND did not use all inputs");
const auto before=g.links.size();check(!logic_connection_preview(g,{source,"out"},{gate,"in1"}).allowed&&g.links.size()==before,"Connection preview mutated the graph or accepted two drivers");rejects([&]{g.connect({source,"out"},{gate,"out"});},"Output-to-output connection was accepted");rejects([&]{g.connect({source,"missing"},{negated,"in"});},"Unknown stable pin was accepted");
auto broken=g;broken.disconnect({gate,"in3"});validate_logic_graph(broken);auto missing=doc;missing.logic=broken;rejects([&]{MachineLogicRuntime invalid(missing);},"Required unwired input did not block compilation");
rejects([&]{runtime.set_manual(source,1.);},"Boolean control accepted an implicit number cast");
}
{
MachineDocument doc;auto& g=doc.logic;const auto a=add(g,LogicNodeKind::Not),b=add(g,LogicNodeKind::Not);g.find(a)->name="First NOT";g.find(b)->name="Second NOT";g.connect({a,"out"},{b,"in"});std::string error;try{g.connect({b,"out"},{a,"in"});}catch(const std::exception& e){error=e.what();}check(g.links.size()==1&&error.find("First NOT")!=std::string::npos&&error.find("Second NOT")!=std::string::npos,"Cycle rejection omitted its path or changed the draft");
const auto delay=add(g,LogicNodeKind::BooleanDelay);g.connect({b,"out"},{delay,"in"});g.connect({delay,"out"},{a,"in"});MachineLogicRuntime feedback(doc);feedback.evaluate(0,.001);check(!bit(feedback,delay)&&bit(feedback,a)&&!bit(feedback,b),"Memory output was not initialized before combinational evaluation");feedback.evaluate(.001,.001);check(!bit(feedback,delay),"Delay did not commit stored state simultaneously");
auto cyclic=g;cyclic.disconnect({a,"in"});cyclic.links.push_back({{b,"out"},{a,"in"}});std::vector<std::uint32_t> path;rejects([&]{logic_evaluation_order(cyclic,&path);},"Cycle path query accepted a combinational cycle");check(path.size()==3&&path.front()==path.back()&&std::find(path.begin(),path.end(),a)!=path.end()&&std::find(path.begin(),path.end(),b)!=path.end(),"Cycle feedback did not identify the actual nodes");logic_evaluation_order(g,&path);check(path.empty(),"A valid graph retained a stale cycle highlight");
}
{
MachineDocument doc;auto& g=doc.logic;const auto delay=add(g,LogicNodeKind::NumberDelay),sum=add(g,LogicNodeKind::Add),one=add(g,LogicNodeKind::NumberConstant);g.find(one)->value=1;g.connect({delay,"out"},{sum,"a"});g.connect({one,"out"},{sum,"b"});g.connect({sum,"out"},{delay,"in"});MachineLogicRuntime feedback(doc);
for(int i=0;i<8;++i){feedback.evaluate(i*.01,.01);check(number(feedback,delay)==i&&number(feedback,sum)==i+1,"Delayed numeric recurrence depends on document order or leaks next state");}
}
{
MachineDocument doc;auto& g=doc.logic;const auto button=add(g,LogicNodeKind::Button),lamp=add(g,LogicNodeKind::Lamp),slider=add(g,LogicNodeKind::Slider);g.connect({button,"out"},{lamp,"in"});MachineLogicRuntime controls(doc);controls.set_manual(button,true);controls.set_manual(button,false);controls.set_manual(button,true);controls.set_manual(button,false);
for(int i=0;i<4;++i){controls.evaluate(i*.001,.001);check(bit(controls,lamp,"in")==!(i%2),"Short button events collapsed or remained pressed");}
controls.set_manual(button,true);controls.release_buttons();controls.evaluate(.004,.001);check(!bit(controls,button),"Focus-loss release left a queued button press");controls.set_manual(slider,.75);controls.evaluate(.005,.001);check(number(controls,slider)==.75,"Numeric manual control event was lost");rejects([&]{controls.set_manual(slider,1.5);},"Out-of-range slider control was accepted");rejects([&]{controls.set_manual(slider,true);},"Numeric control accepted Boolean coercion");
for(int i=0;i<10000;++i)controls.set_manual(slider,double(i%1000)/1000);controls.evaluate(.006,.001);check(close(number(controls,slider),.999),"Paused slider motion overflowed its event queue or lost the latest value");
MachineLogicRuntime fresh(doc);fresh.evaluate(0,.001);check(!bit(fresh,button)&&number(fresh,slider)==0,"Fresh Test inherited transient controls");
}
{
MachineDocument doc;auto& g=doc.logic;const auto a=add(g,LogicNodeKind::NumberConstant),b=add(g,LogicNodeKind::NumberConstant);g.find(a)->value=7;g.find(b)->value=3;
std::vector<std::pair<std::uint32_t,double>> expected;
for(const auto [kind,value]:{std::pair{LogicNodeKind::Add,10.},std::pair{LogicNodeKind::Subtract,4.},std::pair{LogicNodeKind::Multiply,21.},std::pair{LogicNodeKind::Divide,7./3},std::pair{LogicNodeKind::Minimum,3.},std::pair{LogicNodeKind::Maximum,7.}}){const auto n=add(g,kind);g.connect({a,"out"},{n,"a"});g.connect({b,"out"},{n,"b"});expected.emplace_back(n,value);}
const auto absolute=add(g,LogicNodeKind::Absolute),clamp=add(g,LogicNodeKind::Clamp),gain=add(g,LogicNodeKind::GainOffset),map=add(g,LogicNodeKind::MapRange);g.find(clamp)->low=2;g.find(clamp)->high=5;g.find(gain)->gain=2;g.find(gain)->offset=1;g.find(map)->input_low=0;g.find(map)->input_high=10;g.find(map)->output_low=0;g.find(map)->output_high=100;
for(auto id:{absolute,clamp,gain,map})g.connect({a,"out"},{id,"in"});expected.insert(expected.end(),{{absolute,7},{clamp,5},{gain,15},{map,70}});
const auto greater=add(g,LogicNodeKind::Greater),less=add(g,LogicNodeKind::Less),equal=add(g,LogicNodeKind::Equal),window=add(g,LogicNodeKind::Window);for(auto id:{greater,less,equal}){g.connect({a,"out"},{id,"a"});g.connect({b,"out"},{id,"b"});}g.find(window)->low=2;g.find(window)->high=8;g.connect({a,"out"},{window,"in"});
MachineLogicRuntime runtime(doc);runtime.evaluate(0,.001);for(const auto& [n,value]:expected)check(close(number(runtime,n),value),"Numeric arithmetic or mapping is incorrect");check(bit(runtime,greater)&&!bit(runtime,less)&&!bit(runtime,equal)&&bit(runtime,window),"Numeric comparisons are incorrect");
g.find(b)->value=0;runtime.apply_tuning(doc);runtime.evaluate(.001,.001);const auto divider=expected[3].first;check(runtime.value({divider,"out"}).validity==SignalValidity::Fault,"Division by zero silently produced an ordinary number");bool diagnosed=false;for(const auto& n:runtime.snapshot())if(n.id==divider)diagnosed=n.problem=="Division by zero";check(diagnosed,"Invalid math omitted its runtime diagnostic");g.find(b)->value=7+1e-7;runtime.apply_tuning(doc);runtime.evaluate(.002,.001);check(bit(runtime,equal),"Equal tolerance was ignored");
g.find(a)->value=std::numeric_limits<double>::max();g.find(gain)->gain=2;runtime.apply_tuning(doc);runtime.evaluate(.003,.001);check(runtime.value({gain,"out"}).validity==SignalValidity::Fault,"Arithmetic overflow was valid");
}
{
MachineDocument doc;auto& g=doc.logic;const auto n=add(g,LogicNodeKind::NumberConstant),threshold=add(g,LogicNodeKind::Hysteresis);g.find(threshold)->low=3;g.find(threshold)->high=7;g.connect({n,"out"},{threshold,"in"});MachineLogicRuntime runtime(doc);int tick=0;
for(const auto [input,expected]:{std::pair{5.,false},std::pair{8.,true},std::pair{5.,true},std::pair{2.,false}}){g.find(n)->value=input;runtime.apply_tuning(doc);runtime.evaluate(tick++*.001,.001);check(bit(runtime,threshold)==expected,"Hysteresis did not preserve its state in the deadband");}
}
{
MachineDocument doc;auto& g=doc.logic;const auto good=add(g,LogicNodeKind::NumberConstant),zero=add(g,LogicNodeKind::NumberConstant),bad=add(g,LogicNodeKind::Divide),select=add(g,LogicNodeKind::Select),condition=add(g,LogicNodeKind::BooleanConstant);g.find(good)->value=42;
g.connect({good,"out"},{bad,"a"});g.connect({zero,"out"},{bad,"b"});g.connect({condition,"out"},{select,"select"});g.connect({good,"out"},{select,"a"});g.connect({bad,"out"},{select,"b"});MachineLogicRuntime runtime(doc);runtime.evaluate(0,.001);check(number(runtime,select)==42,"Invalid unselected branch poisoned a valid selected value");for(const auto& state:runtime.snapshot())if(state.id==select)check(state.problem.empty(),"Selector reported an inactive-branch fault as an output fault");
}
{
MachineDocument doc;auto& g=doc.logic;const auto p=add(g,LogicNodeKind::NumberConstant),q=add(g,LogicNodeKind::NumberConstant),difference=add(g,LogicNodeKind::Subtract),target=add(g,LogicNodeKind::Readout);for(auto id:{p,q,difference}){g.find(id)->quantity=SignalQuantity::Pressure;g.find(id)->pressure_reference=PressureReference::Absolute;}g.find(p)->value=150000;g.find(q)->value=101325;g.find(target)->quantity=SignalQuantity::Pressure;g.find(target)->pressure_reference=PressureReference::Differential;
g.connect({p,"out"},{difference,"a"});g.connect({q,"out"},{difference,"b"});g.connect({difference,"out"},{target,"in"});check(!logic_connection_preview(g,{p,"out"},{target,"in"}).allowed,"Absolute and differential pressure implicitly mixed");MachineLogicRuntime runtime(doc);runtime.evaluate(0,.01);check(number(runtime,target,"in")==48675,"Pressure subtraction did not produce a differential quantity");
const auto speed=add(g,LogicNodeKind::Readout);g.find(speed)->quantity=SignalQuantity::AngularSpeed;check(!logic_connection_preview(g,{p,"out"},{speed,"in"}).allowed,"Pressure connected to angular speed");
}
{
MachineDocument doc;const auto host=doc.add(MachinePartKind::Chamber).id,sensor=doc.add_sensor(MachineSensorKind::Pressure,host).id;auto& g=doc.logic;const auto source=add(g,LogicNodeKind::Sensor),threshold=add(g,LogicNodeKind::Hysteresis),choose=add(g,LogicNodeKind::Select),convert=add(g,LogicNodeKind::BooleanToNumber),a=add(g,LogicNodeKind::NumberConstant),b=add(g,LogicNodeKind::NumberConstant);
g.find(source)->sensor=sensor;g.find(threshold)->quantity=SignalQuantity::Pressure;g.find(threshold)->pressure_reference=PressureReference::Absolute;g.find(threshold)->low=100000;g.find(threshold)->high=130000;g.find(a)->value=1;g.find(b)->value=9;g.find(convert)->false_value=-1;g.find(convert)->true_value=2;
g.connect({source,"value"},{threshold,"in"});g.connect({threshold,"out"},{choose,"select"});g.connect({a,"out"},{choose,"a"});g.connect({b,"out"},{choose,"b"});g.connect({threshold,"out"},{convert,"in"});
double time=0;SignalValue reading=SignalValue::number(140000);MachineLogicRuntime runtime(doc,[&](std::uint32_t id){MachineSensorState state;state.id=id;state.count=1;state.outputs[0]=reading;return state;});runtime.evaluate(time,.01);check(number(runtime,choose)==9&&number(runtime,convert)==2,"Typed number selection/conversion failed");
time=.01;reading=SignalValue::number(0,time);reading.validity=SignalValidity::Missing;runtime.evaluate(time,.01);check(runtime.value({threshold,"out"}).validity==SignalValidity::Missing&&runtime.value({choose,"out"}).validity==SignalValidity::Missing,"Missing sensor was converted to ordinary false or zero");
time=.02;reading=SignalValue::boolean(true,time);runtime.evaluate(time,.01);check(runtime.value({source,"value"}).validity==SignalValidity::Fault,"Sensor supplied the wrong runtime variant without a fault");
const auto file=std::filesystem::temp_directory_path()/("fluidsim-logic-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");save_machine(doc,file);const auto loaded=load_machine(file);check(loaded.logic==doc.logic,"Logic types, keys, parameters or layout did not round-trip");
{std::ofstream out(file);out<<"format=8\n[[logic.nodes]]\nid=1\nkind='unknown_future_gate'\n";}rejects([&]{load_machine(file);},"Unknown saved logic kind was silently accepted");
{std::ofstream out(file);out<<"format=8\n[[logic.nodes]]\nid=1\nkind='switch'\nboolean=1\n";}rejects([&]{load_machine(file);},"Saved Boolean control silently accepted a number");std::filesystem::remove(file);
}
std::cout<<"Typed graph ordering, gates, arithmetic, validity, manual events, pressure references and explicit delayed feedback passed\n";return 0;
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}