Files
FluidSimCpp/tests/sensor_ecu_tests.cpp

142 lines
18 KiB
C++

#include "FluidSim/Machine.hpp"
#include "FluidSim/FuelInjector.hpp"
#include "FluidSim/Fluid.hpp"
#include <cmath>
#include <chrono>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool ok,const char* why){if(!ok)throw std::runtime_error(why);}
struct Pickup {
SignalValue speed,angle;
Pickup(double time,double unwrapped,double velocity=10,std::uint32_t sensor=1,std::uint32_t shaft=1){
const double period=2*double(pi);double wrapped=std::fmod(unwrapped,period);if(wrapped<0)wrapped+=period;
speed=SignalValue::number(velocity,time);angle=SignalValue::number(wrapped,time);
speed.timing=angle.timing=SignalTiming{sensor,shaft,time,unwrapped,velocity};
}
};
}
int main(int argc,char** argv)try{
if(argc==3&&std::string(argv[1])=="--write-examples"){
const std::filesystem::path directory=argv[2];save_machine(machine_sensor_engine_example(false),directory/"sensor-ignition-bench.machine.toml");save_machine(machine_sensor_engine_example(true),directory/"sensor-alpha-n-bench.machine.toml");return 0;
}
const double period=2*double(pi);
Pickup stopped(0,0,0);check(bool(ecu_crank_sample(stopped.speed,stopped.angle,0)),"Stopped pickup rejected");
check(!ecu_crank_sample(SignalValue::number(10),SignalValue::number(0),0),"Constants gained crank timing authority");
Pickup a(0,period-.2),b(.01,period+.1);
auto mismatch=b.angle;mismatch.timing->sensor=2;check(!ecu_crank_sample(b.speed,mismatch,.01),"Independent pickups accepted as a coherent pair");
mismatch=b.angle;mismatch.timing->shaft=2;check(!ecu_crank_sample(b.speed,mismatch,.01),"Different shafts accepted");
mismatch=b.angle;mismatch.value=.3;check(!ecu_crank_sample(b.speed,mismatch,.01),"Changed angle retained authority");
mismatch=b.angle;mismatch.timing->sampled_at=0;check(!ecu_crank_sample(b.speed,mismatch,.01),"Stale acquisition time accepted");
Pickup reverse(0,0,-1);check(!ecu_crank_sample(reverse.speed,reverse.angle,0),"Reverse crank drove forward-only ignition");
CrankIgnitionClock clock;check(clock.sample(a.speed,a.angle,0,0).sparks==0,"Initial sample invented a spark");
auto event=clock.sample(b.speed,b.angle,.01,0);check(event.valid&&event.sparks==1,"Wrapped crossing lost a spark");
check(!clock.sample(b.speed,b.angle,.01,0).valid,"Repeated sample generated timing");
clock.reset();Pickup before(1,-.4),after(1.01,-.2);
clock.sample(before.speed,before.angle,1,20);event=clock.sample(after.speed,after.angle,1.01,20);check(event.valid&&event.sparks==1,"BTDC crossing did not use pickup angle");
clock.reset();Pickup many(2,4*period+.1);clock.sample(b.speed,b.angle,.01,0);event=clock.sample(many.speed,many.angle,2,0);check(event.valid&&event.sparks==3,"Multiple unwrapped revolutions were lost");
clock.reset();clock.sample(stopped.speed,stopped.angle,0,10);Pickup still(.1,0,0);event=clock.sample(still.speed,still.angle,.1,30);check(event.valid&&!event.sparks,"Stationary timing fired from an advance change");
clock.reset();Pickup early(0,-.4),fired(.01,-.3),retarded(.02,.2);
clock.sample(early.speed,early.angle,0,20);check(clock.sample(fired.speed,fired.angle,.01,20).sparks==1,"Advanced spark was missed");
check(clock.sample(retarded.speed,retarded.angle,.02,-10).sparks==0,"Advance change fired twice for the same revolution");
clock.reset();Pickup phase_a(0,pi-.2),phase_b(.01,pi+.1);clock.sample(phase_a.speed,phase_a.angle,0,0,180);check(clock.sample(phase_b.speed,phase_b.angle,.01,0,180).sparks==1,"Explicit cylinder TDC calibration was ignored");
// Actual machine -> sensor reference -> arithmetic/delay demonstrates that
// only unchanged routing keeps current physical acquisition evidence.
MachineDocument doc;const auto shaft=doc.add(MachinePartKind::Shaft).id;doc.find(shaft)->shaft.initial_speed=10;
const auto sensor=doc.add_sensor(MachineSensorKind::Crank,shaft).id;
auto& g=doc.logic;const auto source=g.add(LogicNodeKind::Sensor).id;g.find(source)->sensor=sensor;g.find(source)->sensor_kind=MachineSensorKind::Crank;
const auto delay=g.add(LogicNodeKind::NumberDelay).id;g.find(delay)->quantity=SignalQuantity::Angle;g.connect({source,"angle"},{delay,"in"});
const auto gain=g.add(LogicNodeKind::GainOffset).id;g.find(gain)->quantity=SignalQuantity::Angle;g.find(gain)->gain=1;g.find(gain)->offset=0;g.connect({source,"angle"},{gain,"in"});
MachineSimulation machine(doc,1000);auto state=machine.sense(sensor);
check(state.outputs[0].timing&&state.outputs[0].timing==state.outputs[1].timing,"Physical crank channels lack shared evidence");
MachineLogicRuntime runtime(doc,[&](std::uint32_t id){return machine.sense(id);});runtime.evaluate(0,.001);
check(bool(ecu_crank_sample(runtime.value({source,"speed"}),runtime.value({source,"angle"}),0)),"Graph dropped physical crank evidence");
check(!runtime.value({gain,"out"}).timing,"Arithmetic kept timing authority even with identity calibration");
machine.process();runtime.evaluate(machine.snapshot().time,.001);
const auto delayed=runtime.value({delay,"out"});check(delayed.timing&&delayed.timestamp!=delayed.timing->sampled_at,"Delay rewrote acquisition time");
check(!ecu_crank_sample(runtime.value({source,"speed"}),delayed,machine.snapshot().time),"Delayed angle accepted as current crank timing");
{
SensorEcuConfig config;config.strategy=SensorEcuConfig::Strategy::AlphaN;
config.fuel_rpm={0,6000};config.fuel_tps={0,1};config.fuel_per_cycle={0.000002,0.00001,0.000006,0.00003};
SensorEcu ecu(config);Pickup mid(0,0,3000*period/60);
auto result=ecu.sample(mid.speed,mid.angle,SignalValue::number(.5,0),0);
check(!result.sensor_fault&&std::abs(result.fuel_per_cycle-.000012)<1e-12,"Alpha-N did not interpolate RPM/TPS table in kg/cycle");
check(std::abs(result.fuel_mass_flow-.0003)<1e-12,"Four-stroke per-cylinder kg/cycle to kg/s conversion failed");
Volume0D intake(.001f);FuelInjectorConfig rail;rail.supply=FuelInjectorConfig::Supply::IdealRail;FuelInjector delivery(rail);
delivery.deliver(intake,SignalValue::number(result.fuel_mass_flow,0),0,.02);
check(std::abs(intake.fuel_mass()-.000006)<1e-11,"Alpha-N demand failed to reach the physical gas host");
Pickup next(.01,period,mid.speed.timing->angular_speed);
result=ecu.sample(next.speed,next.angle,SignalValue{},.01);
check(result.sensor_fault&&!result.spark_events&&!result.fuel_mass_flow,"Missing required TPS retained actuator commands");
result=ecu.sample(next.speed,next.angle,SignalValue::number(.5,.01),.01);check(!result.sensor_fault&&!result.spark_events,"Recovery replayed old spark crossings");
Pickup limited(.02,2*period,7100*period/60);result=ecu.sample(limited.speed,limited.angle,SignalValue::number(1,.02),.02);
check(result.rev_limited&&!result.sensor_fault&&!result.spark_events&&!result.fuel_mass_flow,"Rev limiter failed to cut fuel and spark");
Pickup held(.03,3*period,6900*period/60);result=ecu.sample(held.speed,held.angle,SignalValue::number(1,.03),.03);check(result.rev_limited,"Limiter omitted hysteresis");
Pickup resumed(.04,4*period,6700*period/60);result=ecu.sample(resumed.speed,resumed.angle,SignalValue::number(1,.04),.04);check(!result.rev_limited&&result.fuel_mass_flow>0&&result.spark_events==1,"Limiter failed to resume cleanly");
auto bad=config;bad.fuel_rpm={100,100};bool rejected=false;try{SensorEcu invalid(bad);}catch(const std::invalid_argument&){rejected=true;}check(rejected,"Non-increasing fuel table axis accepted");
SensorEcu ignition(SensorEcuConfig{});result=ignition.sample(mid.speed,mid.angle,SignalValue{},0);check(!result.sensor_fault&&!result.fuel_mass_flow,"Ignition-only ECU demanded TPS or created fuel");
}
{
Volume0D volume(.001f);const auto initial_mass=volume.mass(),initial_energy=volume.internal_energy();
FuelInjectorConfig config;config.initial_fuel_mass=.00001;config.maximum_mass_flow=.001;
FuelInjector injector(config);
for(int i=0;i<16;++i)injector.deliver(volume,SignalValue::number(.01,i*.01),i*.01,.01);
const auto state=injector.state();
check(state.saturated&&state.empty&&state.delivered_mass<=config.initial_fuel_mass,"Injector exceeded supply or omitted capacity saturation");
check(std::abs(double(volume.fuel_mass())-state.delivered_mass)<1e-10&&std::abs(double(volume.mass()-initial_mass)-state.delivered_mass)<1e-9,"Injected fuel did not enter conserved host mass");
const double expected_energy=state.delivered_mass*volume.gas_constant/(volume.gamma-1)*config.fuel_temperature;
check(std::abs(volume.internal_energy()-initial_energy-expected_energy)<1e-4,"Injector omitted sensible energy");
check(volume.fuel_equivalent_fraction()>0,"Injector omitted transported fuel tracer");
const auto before=volume.fuel_mass();auto fault=SignalValue::number(.001,1);fault.validity=SignalValidity::Fault;
check(injector.deliver(volume,fault,1,.01).command_fault&&volume.fuel_mass()==before,"Faulting command delivered fuel");
FuelInjectorConfig ideal;ideal.supply=FuelInjectorConfig::Supply::IdealRail;FuelInjector continuous(ideal);
continuous.deliver(volume,SignalValue::number(.001,1),1,.01);check(continuous.state().mass_flow>0&&!continuous.state().empty,"Declared ideal rail failed to supply fuel");
const auto held=volume.fuel_mass();continuous.deliver(volume,SignalValue::number(.001,1),2,.01);check(continuous.state().command_fault&&volume.fuel_mass()==held,"Stale command retained injector flow");
}
{
Crankshaft crank;FourStrokeCylinder cylinder;cylinder.transported_fuel=true;cylinder.initialize(crank);
cylinder.fuel_mass=.000001f;cylinder.air_mass=cylinder.fuel_mass*14.7f;cylinder.fuel_equivalent_mass=cylinder.fuel_mass;cylinder.fuel_injected=true;
cylinder.heat_transfer=0;cylinder.energy_variation=0;
cylinder.ignition_command=FourStrokeCylinder::IgnitionCommand{false,false};
// Cross the legacy internal spark coordinate without a wired event.
crank.previous_angle=(720-cylinder.spark_advance-1)*pi/180;crank.angle=(720-cylinder.spark_advance+1)*pi/180;
cylinder.pre_step(crank,.0001f,0);check(!cylinder.combustion_active,"External timing fell back to hidden cylinder phase");
// Fire at a different physical angle: event authority is the wire.
crank.previous_angle=crank.angle=0;cylinder.ignition_command=FourStrokeCylinder::IgnitionCommand{true,false};
cylinder.pre_step(crank,.0001f,0);check(cylinder.combustion_active&&!cylinder.ignition_command->fire,"External pulse failed to ignite or was not consumed");
const auto initial_fuel=cylinder.fuel_mass;
for(int i=0;i<100;++i){crank.previous_angle=crank.angle;crank.angle+=pi/180;cylinder.pre_step(crank,.0001f,0);}
check(!cylinder.combustion_active&&cylinder.fuel_mass<initial_fuel,"External spark burn did not progress from its event angle");
const auto last_charge=cylinder.charge_afr;cylinder.ignition_command=FourStrokeCylinder::IgnitionCommand{true,false};cylinder.pre_step(crank,.0001f,0);check(cylinder.charge_afr==last_charge,"Exhaust wasted spark overwrote trapped-charge AFR");
cylinder.fuel_injected=true;cylinder.ignition_command=FourStrokeCylinder::IgnitionCommand{true,true};cylinder.pre_step(crank,.0001f,0);check(!cylinder.combustion_active,"Cut command still fired");
}
for(const bool injection:{false,true}){
auto doc=machine_sensor_engine_example(injection);std::uint32_t ecu=0,host=0,cylinder=0;for(const auto& p:doc.parts){if(p.kind==MachinePartKind::Ecu)ecu=p.id;if(p.injector)host=p.id;if(p.kind==MachinePartKind::Cylinder)cylinder=p.id;}
MachineSimulation engine(doc,22050);bool fire=false,fuel=false;for(int i=0;i<22050;++i){engine.process();if(i%16==0)for(const auto& p:engine.snapshot().parts)if(p.id==cylinder){fire|=p.combustion;fuel|=p.fuel_mass>0;}}
check(fuel,"Sensor engine example never transported fuel to cylinder");check(fire,injection?"Alpha-N example never combusted":"Carburetor ignition example never combusted");
const auto before_tuning=engine.snapshot();double old_advance=0,old_delivered=0;for(const auto& p:before_tuning.parts){if(p.id==ecu)old_advance=p.spark_advance;if(p.id==host)old_delivered=p.injector_delivered;}
auto tuned=doc;for(auto& point:tuned.find(ecu)->ecu.ignition_map)point.degrees_btdc+=5;engine.apply_tuning(tuned);check(engine.snapshot().time==before_tuning.time,"Ignition tuning restarted engine time");engine.process();for(const auto& p:engine.snapshot().parts){if(p.id==ecu)check(std::abs(p.spark_advance-old_advance-5)<.001,"Sensor ECU ignored live ignition calibration");if(injection&&p.id==host)check(p.injector_delivered>old_delivered,"Live ignition tuning reset injector delivery history");}
const auto path=std::filesystem::temp_directory_path()/("fluidsim-ecu-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");save_machine(doc,path);const auto restored=load_machine(path);std::filesystem::remove(path);
check(restored.logic==doc.logic&&restored.find(ecu)->sensor_ecu==doc.find(ecu)->sensor_ecu,"ECU connector or fuel calibration failed save/load");
if(injection)check(restored.find(host)->injector==doc.find(host)->injector,"Injector attachment failed save/load");
auto wrong=doc;const auto foreign=wrong.add(MachinePartKind::Shaft).id;wrong.find(foreign)->shaft.initial_speed=3000*2*pi/60;for(auto& sensor:wrong.sensors)if(sensor.kind==MachineSensorKind::Crank)sensor.target.part=foreign;
MachineSimulation other(wrong,22050);for(int i=0;i<32;++i)other.process();for(const auto& p:other.snapshot().parts){if(p.id==cylinder)check(p.ignition_cut&&!p.combustion,"ECU accepted timing from a different engine shaft");if(injection&&p.id==host)check(p.fuel_flow==0,"Wrong-shaft timing still delivered fuel");}
auto unbound=doc;std::uint32_t connector=0;for(const auto& node:unbound.logic.nodes)if(node.kind==LogicNodeKind::Ecu)connector=node.id;unbound.logic.disconnect({connector,"angle"});bool rejected=false;try{MachineSimulation invalid(unbound);}catch(const std::invalid_argument&){rejected=true;}check(rejected,"Missing required ECU angle wire accepted");
auto fake=doc;fake.logic.disconnect({connector,"speed"});fake.logic.disconnect({connector,"angle"});const auto numeric_speed=fake.logic.add(LogicNodeKind::NumberConstant).id,numeric_angle=fake.logic.add(LogicNodeKind::NumberConstant).id;fake.logic.find(numeric_speed)->quantity=SignalQuantity::AngularSpeed;fake.logic.find(numeric_speed)->value=3000*2*pi/60;fake.logic.find(numeric_angle)->quantity=SignalQuantity::Angle;fake.logic.connect({numeric_speed,"out"},{connector,"speed"});fake.logic.connect({numeric_angle,"out"},{connector,"angle"});MachineSimulation no_pickup(fake,22050);for(int i=0;i<32;++i)no_pickup.process();const auto faulted=no_pickup.snapshot();for(const auto& p:faulted.parts){if(p.id==ecu)check(p.sensor_fault&&p.ignition_cut,"Typed numbers acquired physical timing authority");if(injection&&p.id==host)check(p.injector_delivered==0,"Fake timing created injected fuel");}for(const auto& n:faulted.logic)if(n.id==connector)check(!n.problem.empty(),"Timing rejection was invisible in graph diagnostics");
std::vector<std::uint32_t> ids;for(const auto& p:doc.parts)ids.push_back(p.id);const auto assembly=machine_extract_assembly(doc,ids,"Sensor engine assembly");MachineDocument copy;machine_insert_assembly(copy,assembly,{});MachineSimulation copied(copy,22050);copied.process();check(copy.logic.nodes.size()==doc.logic.nodes.size(),"Assembly lost ECU harness");
if(injection){
auto removed=doc;removed.remove(ecu);check(removed.find(host)->injector->ecu==0&&!removed.find(host)->injector->ecu_label.empty(),"Deleted ECU left an accidental injector binding");const auto partial=machine_extract_assembly(doc,{host},"Injector host");check(partial.find(host)->injector->ecu==0,"Partial assembly retained external injector ECU ID");
auto routed=doc;const auto source=std::find_if(routed.logic.nodes.begin(),routed.logic.nodes.end(),[](const auto& n){return n.kind==LogicNodeKind::Sensor&&n.sensor_kind==MachineSensorKind::Crank;})->id;
const auto choose=routed.logic.add(LogicNodeKind::Switch).id,constant=routed.logic.add(LogicNodeKind::NumberConstant).id,selector=routed.logic.add(LogicNodeKind::Select).id;routed.logic.find(constant)->quantity=routed.logic.find(selector)->quantity=SignalQuantity::Angle;
routed.logic.disconnect({connector,"angle"});routed.logic.connect({source,"angle"},{selector,"a"});routed.logic.connect({constant,"out"},{selector,"b"});routed.logic.connect({choose,"out"},{selector,"select"});routed.logic.connect({selector,"out"},{connector,"angle"});
MachineSimulation controlled(routed,22050);for(int i=0;i<10000;++i)controlled.process();controlled.set_logic_control(choose,true);for(int i=0;i<1000;++i)controlled.process();for(const auto& p:controlled.snapshot().parts){if(p.id==cylinder)check(p.ignition_cut&&!p.combustion,"Lost crank timing failed to stop controlled combustion");if(p.id==host)check(p.fuel_flow==0,"Lost crank timing retained fuel delivery");}
controlled.set_logic_control(choose,false);bool recovered=false;for(int i=0;i<10000;++i){controlled.process();if(i%16==0)for(const auto& p:controlled.snapshot().parts)if(p.id==cylinder)recovered|=p.combustion;}check(recovered,"Recovered physical crank timing failed to restart combustion");
auto exhausted=doc;exhausted.find(host)->injector->initial_mass=.0000001f;MachineSimulation dry(exhausted,22050);for(int i=0;i<64;++i)dry.process();for(const auto& p:dry.snapshot().parts)if(p.id==host)check(p.injector_empty&&p.fuel_flow==0&&p.injector_delivered<=exhausted.find(host)->injector->initial_mass,"Physical injector ignored finite fuel budget");
}
}
std::cout<<"Sensor-driven ignition/Alpha-N, saved machine bindings, physical combustion and injector delivery passed\n";
return 0;
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}