Add machine workshop, typed logic, mechanics and sensor-driven ECUs
This commit is contained in:
231
tests/machine_tests.cpp
Normal file
231
tests/machine_tests.cpp
Normal file
@@ -0,0 +1,231 @@
|
||||
#include "MachineTestControls.hpp"
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include "FluidSim/Constants.hpp"
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace fluidsim::port;
|
||||
using namespace fluidsim::port::machine_math;
|
||||
namespace {
|
||||
void check(bool v,const std::string& why){if(!v)throw std::runtime_error(why);}
|
||||
bool close(float a,float b,float tolerance=1e-5f){return std::abs(a-b)<=tolerance;}
|
||||
template<class F>void rejects(F f,const char* why){bool rejected=false;try{f();}catch(const std::exception&){rejected=true;}check(rejected,why);}
|
||||
const MachinePartState& state(const MachineSnapshot& s,unsigned id){for(auto& p:s.parts)if(p.id==id)return p;throw std::runtime_error("Missing part snapshot");}
|
||||
std::uint32_t duct(MachineDocument& d,MachinePort a,MachinePort b){const auto start=machine_port_position(*d.find(a.part),a.port),end=machine_port_position(*d.find(b.part),b.port);auto& p=d.add(MachinePartKind::Pipe);const auto id=p.id;p.position={};p.points={start,end};d.connect({id,0},a);d.connect({id,1},b);return id;}
|
||||
void run(MachineSimulation& sim,int frames){for(int i=0;i<frames;++i){const auto a=sim.process();check(std::isfinite(a.left)&&std::isfinite(a.right),"Nonfinite machine audio");}}
|
||||
}
|
||||
int main(int argc,char** argv)try{
|
||||
if(argc>=2&&std::string(argv[1])=="--mixture-sweep"){
|
||||
std::cout<<"case,rpm,charge_median,charge_max,cylinder_bulk_median,pipe_bulk_median,fuel_flow_g_s,fuel_cut_samples,spark_cut_samples,valve_float_samples\n";
|
||||
for(const char* id:{"single-450","flat6"}){if(argc==3&&std::string(id)=="flat6")break;auto design=argc==3?load_machine(argv[2]):machine_from_engine(scenario_example(id));machine_test::drive(design,3000);unsigned cylinder=0,pipe=0,carb=0;
|
||||
for(const auto& p:design.parts){if(!cylinder&&p.kind==MachinePartKind::Cylinder)cylinder=p.id;if(p.kind==MachinePartKind::Carburetor)carb=p.id;if(p.name=="Exhaust atmosphere")for(const auto& l:design.links){const auto other=l.a.part==p.id?l.b.part:l.b.part==p.id?l.a.part:0;if(const auto p=design.find(other);p&&p->kind==MachinePartKind::Pipe)pipe=other;}}
|
||||
if(!pipe)for(const auto& p:design.parts)if(p.kind==MachinePartKind::Pipe)pipe=p.id;
|
||||
design.probes={{1,cylinder,"Fresh charge",MachineQuantity::ChargeAfr},{2,cylinder,"Cylinder bulk",MachineQuantity::AirFuelRatio},{3,pipe,"Exhaust bulk",MachineQuantity::AirFuelRatio}};MachineSimulation sim(design);sim.set_controls(1);
|
||||
for(float rpm:{3000.f,5000.f,6500.f,7000.f,7500.f,10000.f,12000.f,13000.f,14000.f,16000.f}){machine_test::target_speed(design,rpm);sim.apply_tuning(design);std::array<std::vector<float>,3> samples;unsigned fuel_cut=0,spark_cut=0,floating=0;double fuel_flow=0;unsigned count=0;
|
||||
for(int i=0;i<22050;++i){sim.process();if(i>=11025&&i%16==0){const auto m=sim.measure();for(int channel=0;channel<3;++channel)if(std::isfinite(m.values[channel]))samples[channel].push_back(m.values[channel]);const auto s=sim.snapshot();const auto& c=state(s,cylinder);fuel_cut+=c.fuel_cut;spark_cut+=c.ignition_cut;floating+=c.valve_float;if(carb)fuel_flow+=state(s,carb).fuel_flow*1000;++count;}}
|
||||
for(auto& channel:samples)std::sort(channel.begin(),channel.end());const auto median=[&](int channel){const auto& values=samples[channel];return values.empty()?std::numeric_limits<float>::quiet_NaN():values[values.size()/2];};
|
||||
std::cout<<(argc==3?"saved":id)<<','<<rpm<<','<<median(0)<<','<<(samples[0].empty()?0:samples[0].back())<<','<<median(1)<<','<<median(2)<<','<<fuel_flow/count<<','<<fuel_cut<<','<<spark_cut<<','<<floating<<std::endl;
|
||||
}
|
||||
}return 0;
|
||||
}
|
||||
MachineDocument d;auto& p=d.add(MachinePartKind::Pipe,{.2f,.3f,-.1f});const auto pipe_id=p.id;p.points={{0,0,0},{.1f,0,0},{.1f,.2f,0}};p.diameter=.02f;p.outlet_diameter=.06f;p.rotation={20,40,15};
|
||||
check(close(machine_pipe_length(p),.3f),"Pipe length must follow the edited centerline");
|
||||
const auto world=machine_world_point(p,{.2f,.4f,.7f});check(length(subtract(machine_local_point(p,world),{.2f,.4f,.7f}))<1e-6f,"Part rotation inverse is incorrect");
|
||||
MachineSimulation profile(d);const auto s=profile.snapshot();check(close(state(s,pipe_id).volume,machine_part_volume(p),1e-9f),"Profiled pipe cell volume differs from visible frustum volume");
|
||||
run(profile,512);const auto equilibrium=profile.snapshot();for(auto pressure:state(equilibrium,pipe_id).pipe_pressures)check(close(pressure,101325,1),"Sealed equilibrium should remain steady");
|
||||
// Visual path refinement must not refine the fluid mesh or shrink its CFL timestep.
|
||||
MachineDocument sparse;auto& straight=sparse.add(MachinePartKind::Pipe);straight.points={{0,0,0},{.3f,0,0}};straight.diameter=.02f;straight.outlet_diameter=.06f;
|
||||
auto dense=sparse;dense.parts[0].points.clear();for(int i=0;i<=64;++i)dense.parts[0].points.push_back({.3f*i/64,0,0});
|
||||
MachineSimulation coarse_mesh(sparse),dense_route(dense);const auto coarse_info=coarse_mesh.snapshot(),dense_info=dense_route.snapshot();
|
||||
check(coarse_info.cells==dense_info.cells&&coarse_info.cells==12,"Visual knots changed solver cell count");
|
||||
check(close(coarse_info.minimum_cell_length,dense_info.minimum_cell_length,1e-7f),"Visual knots changed the CFL-limiting length");
|
||||
check(close(coarse_info.parts[0].volume,dense_info.parts[0].volume,1e-9f),"Visual refinement changed tapered gas volume");
|
||||
MachineDocument short_duct;short_duct.add(MachinePartKind::Pipe).points={{0,0,0},{.005f,0,0}};MachineSimulation real_short_pipe(short_duct);run(real_short_pipe,128);
|
||||
const auto short_info=real_short_pipe.snapshot();check(close(short_info.minimum_cell_length,.005f,1e-8f)&&short_info.substeps>=256,"A real short duct lost its physical length or CFL substeps");
|
||||
MachineDocument shaped;auto& sculpted=shaped.add(MachinePartKind::Pipe);const auto shaped_id=sculpted.id;
|
||||
sculpted.points={{0,0,0},{.1f,0,0},{.105f,0,0},{.3f,0,0}};sculpted.point_diameters={.02f,.1f,.025f,.06f};
|
||||
const auto frustum=[](double length,double a,double b){return float(double(pi)/12*length*(a*a+a*b+b*b));};
|
||||
const float expected_volume=frustum(.1,.02,.1)+frustum(.005,.1,.025)+frustum(.195,.025,.06);
|
||||
check(close(machine_part_volume(sculpted),expected_volume,1e-9f),"Per-point pipe volume is not the sum of its frustums");
|
||||
check(close(machine_pipe_diameter(sculpted,.1f),.1f,1e-6f)&&close(machine_pipe_diameter(sculpted,.1025f),.0625f,1e-6f),"Per-point diameter interpolation is incorrect");
|
||||
for(float density:{10.f,40.f,200.f}){auto refined=shaped;refined.cells_per_meter=density;MachineSimulation simulation(refined);const auto info=simulation.snapshot();
|
||||
check(info.cells==std::uint64_t(profile_section_cells(.3f,1/density)),"Profile points forced extra fluid cells");
|
||||
check(close(state(info,shaped_id).volume,expected_volume,2e-9f),"Mesh refinement changed the per-point pipe volume");try{run(simulation,256);}catch(const std::exception& e){throw std::runtime_error("Per-point profile at "+std::to_string(density)+" cells/m: "+e.what());}
|
||||
const auto settled=simulation.snapshot();check(settled.positivity_repairs==0,"Per-point pipe equilibrium needed positivity repairs");for(float pressure:state(settled,shaped_id).pipe_pressures)check(close(pressure,101325,2),"Per-point pipe failed to preserve static equilibrium");
|
||||
}
|
||||
for(auto method:{PipeSolver::Rusanov,PipeSolver::Hlle,PipeSolver::Hllc,PipeSolver::Muscl}){auto flow=shaped;flow.cells_per_meter=200;flow.solver=method;
|
||||
const auto supply=flow.add(MachinePartKind::Reservoir,machine_port_position(*flow.find(shaped_id),0)).id;flow.find(supply)->pressure=102000;
|
||||
const auto outlet=flow.add(MachinePartKind::Reservoir,machine_port_position(*flow.find(shaped_id),1)).id;
|
||||
flow.connect({shaped_id,0},{supply,0});flow.connect({shaped_id,1},{outlet,0});MachineSimulation simulation(flow);
|
||||
try{run(simulation,2048);}catch(const std::exception& e){throw std::runtime_error(std::string("Per-point pressure-driven ")+pipe_solver_name(method)+": "+e.what());}
|
||||
const auto result=simulation.snapshot();check(result.positivity_repairs==0&&state(result,shaped_id).flow>0,"Per-point constriction did not sustain stable forward flow");
|
||||
}
|
||||
auto inserted=sculpted;machine_insert_pipe_point(inserted,0,{.035f,0,0});check(inserted.point_diameters.size()==inserted.points.size()&&close(inserted.point_diameters[1],.048f,1e-6f),"Inserted point did not inherit its interpolated diameter");
|
||||
check(close(machine_part_volume(inserted),expected_volume,1e-9f),"Inserting a profile point changed gas volume");machine_remove_pipe_point(inserted,1);check(inserted.points==sculpted.points&&inserted.point_diameters==sculpted.point_diameters,"Removing the inserted point lost the diameter profile");
|
||||
auto malformed=shaped;malformed.parts[0].point_diameters.pop_back();rejects([&]{validate_machine(malformed);},"Mismatched point diameters were accepted");
|
||||
malformed=shaped;malformed.parts[0].point_diameters[1]=0;rejects([&]{validate_machine(malformed);},"Zero point diameter was accepted");
|
||||
|
||||
auto& chamber=d.add(MachinePartKind::Chamber,{.4f,.4f,.1f});const auto chamber_id=chamber.id;chamber.size={.15f,.1f,.2f};chamber.rotation={30,25,60};
|
||||
d.connect({pipe_id,1},{chamber_id,0});validate_machine(d);
|
||||
d.find(chamber_id)->position.x+=.1f;d.find(chamber_id)->rotation.z+=40;d.sync_connections(chamber_id);validate_machine(d);
|
||||
check(length(subtract(machine_port_position(*d.find(pipe_id),1),machine_port_position(*d.find(chamber_id),0)))<1e-6f,"Moving a component detached its pipe");
|
||||
MachineSimulation with_tank(d);check(close(state(with_tank.snapshot(),chamber_id).volume,.003f,1e-8f),"Rotated tank volume must equal visible dimensions");
|
||||
rejects([&]{d.connect({pipe_id,1},{chamber_id,1});},"An occupied pipe endpoint was connected twice");
|
||||
auto broken=d;broken.links[0].b.part=999;rejects([&]{validate_machine(broken);},"Dangling references were accepted");
|
||||
broken=d;broken.parts.front().points[1]=broken.parts.front().points[0];rejects([&]{validate_machine(broken);},"Collapsed pipe segment was accepted");
|
||||
broken=d;broken.find(chamber_id)->size.x=0;rejects([&]{validate_machine(broken);},"Zero volume was accepted");
|
||||
broken=d;broken.find(chamber_id)->position.y+=.1f;rejects([&]{validate_machine(broken);},"A connection with a physical gap was accepted");
|
||||
|
||||
{MachineDocument feedback;const auto pipe=feedback.add(MachinePartKind::Pipe).id;
|
||||
const auto air=feedback.add(MachinePartKind::Reservoir,{.4f,.3f,0}).id;
|
||||
const auto ecu=feedback.add(MachinePartKind::Ecu,{0,.6f,0}).id;
|
||||
const auto cylinder=feedback.add(MachinePartKind::Cylinder).id;
|
||||
const auto original_points=feedback.find(pipe)->points;
|
||||
const auto air_preview=machine_connection_preview(feedback,{pipe,1},{air,0});
|
||||
check(air_preview.allowed&&air_preview.pipe.has_value(),"Valid connection has no snapped pipe preview");
|
||||
check(length(subtract(machine_port_position(*air_preview.pipe,1),machine_port_position(*feedback.find(air),0)))<1e-6f,"Preview did not snap to the target port");
|
||||
check(feedback.links.empty()&&feedback.find(pipe)->points==original_points,"Connection preview mutated the design");
|
||||
check(!machine_connection_preview(feedback,{pipe,1},{ecu,0}).allowed,"Air/control mismatch was accepted in preview");
|
||||
check(!machine_connection_preview(feedback,{air,0},{cylinder,0}).allowed,"Preview connected two air components without a pipe");
|
||||
check(machine_connection_preview(feedback,{ecu,0},{cylinder,2}).allowed,"Valid ECU control wire was rejected");
|
||||
check(!machine_connection_preview(feedback,{ecu,0},{cylinder,0}).allowed,"ECU preview connected to a cylinder air port");
|
||||
feedback.find(air)->position=machine_port_position(*feedback.find(pipe),0);
|
||||
check(!machine_connection_preview(feedback,{pipe,1},{air,0}).allowed,"Preview allowed a collapsed pipe");
|
||||
feedback.find(air)->position={.4f,.3f,0};feedback.connect({pipe,1},{air,0});
|
||||
const auto branch=feedback.add(MachinePartKind::Pipe,{-.4f,.25f,0}).id;
|
||||
const auto occupied=machine_connection_preview(feedback,{branch,0},{air,0});check(!occupied.allowed&&!occupied.message.empty(),"Occupied atmosphere preview lacks an explanation");
|
||||
}
|
||||
{auto bench=machine_engine_example();unsigned cylinder=0,throttle=0,ecu=0;
|
||||
for(const auto& part:bench.parts){if(part.kind==MachinePartKind::Cylinder)cylinder=part.id;if(part.kind==MachinePartKind::Throttle)throttle=part.id;if(part.kind==MachinePartKind::Ecu)ecu=part.id;}
|
||||
const auto has_check=[&](unsigned id,const std::string& title){const auto checks=machine_checks(bench);return std::any_of(checks.begin(),checks.end(),[&](const auto& item){return item.part==id&&item.title==title;});};
|
||||
check(machine_has_fuel_source(bench,cylinder)&&!has_check(cylinder,"No fuel source"),"Wired injection engine was reported dry");
|
||||
bench.disconnect({cylinder,2});check(!machine_has_fuel_source(bench,cylinder)&&has_check(cylinder,"No fuel source"),"Missing injection assignment was not diagnosed");
|
||||
bench.find(throttle)->pedal_control=false;bench.find(throttle)->opening=0;
|
||||
check(!has_check(throttle,"Fixed throttle is closed"),"ECU-controlled throttle was diagnosed from its unused fixed opening");
|
||||
bench.disconnect({throttle,2});check(has_check(throttle,"Fixed throttle is closed")&&has_check(ecu,"ECU has no assigned parts"),"Closed throttle / unused ECU checks are missing");
|
||||
bench.find(throttle)->kind=MachinePartKind::Carburetor;check(machine_has_fuel_source(bench,cylinder)&&!has_check(cylinder,"No fuel source"),"Connected carburetor was not recognized as a fuel source");
|
||||
bench.disconnect({cylinder,0});check(!machine_has_fuel_source(bench,cylinder),"Fuel reachability crossed the cylinder from its exhaust");
|
||||
}
|
||||
|
||||
const auto dir=std::filesystem::temp_directory_path()/("fluidsim-machine-tests-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));std::filesystem::create_directories(dir);
|
||||
const auto file=dir/"test.machine.toml";save_machine(d,file);const auto loaded=load_machine(file);
|
||||
check(loaded.parts.size()==d.parts.size()&&loaded.links.size()==d.links.size(),"Save/load changed the machine topology");
|
||||
check(length(subtract(loaded.find(chamber_id)->rotation,d.find(chamber_id)->rotation))<1e-6f,"Save/load lost 3D rotation");
|
||||
check(loaded.find(pipe_id)->points==d.find(pipe_id)->points,"Save/load changed the pipe centerline");
|
||||
{
|
||||
auto source=machine_engine_example();const auto cylinder=std::find_if(source.parts.begin(),source.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Cylinder;})->id;
|
||||
source.probes.push_back({1,cylinder,"Cylinder pressure",MachineQuantity::Pressure,.5f,14.7f});
|
||||
source.probes.push_back({2,0,"Shaft RPM",MachineQuantity::Rpm,.5f,14.7f});
|
||||
std::vector<std::uint32_t> selected;for(const auto& part:source.parts)selected.push_back(part.id);
|
||||
auto assembly=machine_extract_assembly(source,selected,"Intake module");
|
||||
check(assembly.parts.size()==source.parts.size()&&assembly.links.size()==source.links.size(),"Assembly extraction lost parts or internal air/control connections");
|
||||
check(assembly.probes.size()==1&&assembly.probes.front().name=="Cylinder pressure","Assembly extraction lost a component probe");
|
||||
check(length(machine_selection_center(assembly,selected))<1e-6f,"Assembly was not centered for reuse");
|
||||
const auto assembly_file=dir/"intake.assembly.toml";save_machine(assembly,assembly_file);assembly=load_machine(assembly_file);
|
||||
MachineDocument destination=machine_pump_example();const auto prior_parts=destination.parts.size(),prior_links=destination.links.size();
|
||||
destination.probes.push_back({1,destination.parts.front().id,"Tank pressure",MachineQuantity::Pressure,.5f,14.7f});
|
||||
const auto inserted=machine_insert_assembly(destination,assembly,{1,.35f,.5f});
|
||||
check(inserted.size()==assembly.parts.size()&&destination.parts.size()==prior_parts+assembly.parts.size(),"Assembly insertion did not remap and append parts");
|
||||
check(destination.links.size()==prior_links+assembly.links.size(),"Assembly insertion did not recreate internal connections");
|
||||
const auto remapped=[&](std::uint32_t id){for(std::size_t i=0;i<assembly.parts.size();++i)if(assembly.parts[i].id==id)return inserted[i];throw std::runtime_error("Missing assembly part");};
|
||||
check(destination.probes.size()==2&&destination.probes.back().part==remapped(cylinder)&&destination.probes.back().id!=1,"Assembly insertion did not remap its probe");
|
||||
for(std::size_t i=0;i<assembly.links.size();++i){const auto& original=assembly.links[i];const auto& copy=destination.links[prior_links+i];check(copy.a.part==remapped(original.a.part)&©.b.part==remapped(original.b.part)&©.a.port==original.a.port&©.b.port==original.b.port&©.kind==original.kind&©.discharge==original.discharge&©.loss==original.loss&©.inertance_length==original.inertance_length,"Copied link changed its endpoints or connection settings");}
|
||||
for(std::size_t i=0;i<inserted.size();++i){const auto& copy=*destination.find(inserted[i]);check(length(subtract(copy.position,add(assembly.parts[i].position,{1,.35f,.5f})))<1e-6f&©.rotation==assembly.parts[i].rotation&©.points==assembly.parts[i].points&©.point_diameters==assembly.parts[i].point_diameters,"Assembly placement changed local geometry or relative positions");}
|
||||
validate_machine(destination);
|
||||
const auto ecu=std::find_if(source.parts.begin(),source.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Ecu;})->id;
|
||||
const auto intake=std::find_if(source.links.begin(),source.links.end(),[&](const auto& link){return link.b==MachinePort{cylinder,0};})->a.part;
|
||||
const auto subset=machine_extract_assembly(source,{ecu,cylinder,intake},"Cylinder module");
|
||||
check(subset.parts.size()==3&&subset.links.size()==2,"Subset copy retained external links or lost an internal connection");
|
||||
for(const auto& link:subset.links)check(subset.find(link.a.part)&&subset.find(link.b.part),"Assembly connection reaches outside the selected parts");
|
||||
rejects([&]{machine_extract_assembly(source,{},"Empty");},"An empty selection was copied");
|
||||
MachineDocument empty;rejects([&]{machine_insert_assembly(destination,empty,{});},"An empty assembly was inserted");
|
||||
MachineDocument offset;auto& offpipe=offset.add(MachinePartKind::Pipe,{2,1,-3});offpipe.points={{1,0,0},{3,0,0}};offpipe.rotation={0,45,0};
|
||||
const auto pipe_assembly=machine_extract_assembly(offset,{offpipe.id},"Offset pipe");const auto centered_path=machine_pipe_path(pipe_assembly.parts.front());
|
||||
check(length(add(centered_path.front(),centered_path.back()))<1e-6f&&close(machine_pipe_length(pipe_assembly.parts.front()),2),"Reusable pipe assembly centered its part origin instead of its visible geometry");
|
||||
}
|
||||
const auto shaped_file=dir/"profile.machine.toml";save_machine(shaped,shaped_file);const auto restored_profile=load_machine(shaped_file);
|
||||
check(restored_profile.parts[0].point_diameters==sculpted.point_diameters&&restored_profile.parts[0].points==sculpted.points,"Save/load lost per-point diameters");
|
||||
{std::ifstream input(shaped_file);const std::string text((std::istreambuf_iterator<char>(input)),{});check(text.find("point_diameters_m")!=std::string::npos&&text.find("outlet_diameter_m")==std::string::npos,"Saved pipes still use endpoint diameter fields");}
|
||||
const auto legacy_file=dir/"legacy.machine.toml";
|
||||
const std::string pipe_record="\n[[parts]]\nid = 1\nkind = 'pipe'\npoints_m = [[0,0,0],[0.1,0,0],[0.1,0.2,0]]\n";
|
||||
for(int version:{1,2}){{std::ofstream output(legacy_file);output<<"format = "<<version<<pipe_record<<"diameter_m = 0.02\noutlet_diameter_m = 0.08\n";}
|
||||
const auto legacy=load_machine(legacy_file);const auto& diameters=legacy.parts[0].point_diameters;check(diameters.size()==3&&close(diameters[0],.02f)&&close(diameters[1],.04f)&&close(diameters[2],.08f),"Legacy taper was not migrated by arclength");
|
||||
check(close(machine_part_volume(legacy.parts[0]),frustum(.3,.02,.08),1e-9f),"Legacy migration changed pipe gas volume");
|
||||
}
|
||||
for(const auto* bad:{"","point_diameters_m = [0.02]\n","point_diameters_m = [0.02, -0.03, 0.04]\n","point_diameters_m = [0.02, nan, 0.04]\n","point_diameters_m = [0.02, 'bad', 0.04]\n"}){
|
||||
{std::ofstream output(legacy_file);output<<"format = 3"<<pipe_record<<bad;}rejects([&]{load_machine(legacy_file);},"Malformed version 3 point diameters were accepted");
|
||||
}
|
||||
save_machine(loaded,file);check(!std::filesystem::exists(file.string()+".bak"),"Successful save left a backup behind");
|
||||
{std::ofstream invalid(dir/"invalid.machine.toml");invalid<<"format = 999\nparts = []\n";}
|
||||
rejects([&]{load_machine(dir/"invalid.machine.toml");},"Unsupported file version was accepted");
|
||||
|
||||
for(int method=0;method<3;++method){
|
||||
MachineDocument network;const auto node=network.add(MachinePartKind::Junction,{0,.3f,0}).id;network.find(node)->junction_method=method;
|
||||
for(int branch=0;branch<3;++branch){auto& pipe=network.add(MachinePartKind::Pipe);pipe.position={};const unsigned port=method==0?unsigned(branch):0;const auto start=machine_port_position(*network.find(node),port);pipe.points={start,add(start,branch==0?Vec3{-.2f,0,0}:branch==1?Vec3{.2f,0,0}:Vec3{0,-.2f,0})};const auto id=pipe.id;network.connect({id,0},{node,port});}
|
||||
validate_machine(network);MachineSimulation simulation(network);run(simulation,1024);check(simulation.snapshot().positivity_repairs==0,"Junction equilibrium required positivity repairs");
|
||||
}
|
||||
// A closed throttle must isolate a high-pressure supply; opening it must charge the tank.
|
||||
const auto valve_test=[](bool open){MachineDocument doc;const auto supply=doc.add(MachinePartKind::Reservoir,{-.4f,.3f,0}).id;doc.find(supply)->pressure=120000;
|
||||
const auto valve=doc.add(MachinePartKind::Throttle,{0,.3f,0}).id;doc.find(valve)->pedal_control=false;doc.find(valve)->opening=open?1.f:0.f;
|
||||
const auto tank=doc.add(MachinePartKind::Chamber,{.4f,.3f,0}).id;duct(doc,{supply,0},{valve,0});duct(doc,{valve,1},{tank,0});MachineSimulation sim(doc);run(sim,8820);return state(sim.snapshot(),tank).pressure;};
|
||||
const float closed=valve_test(false),open=valve_test(true);check(std::abs(closed-101325)<1,"A closed throttle leaked");check(open>closed+1000,"Opening throttle did not increase downstream pressure");
|
||||
|
||||
auto pump=machine_pump_example();validate_machine(pump);const auto tank_id=pump.parts.front().id;
|
||||
MachineSimulation pumping(pump);run(pumping,44100);const auto pump_state=pumping.snapshot();
|
||||
check(state(pump_state,tank_id).pressure<99000,"Piston pump did not evacuate its tank");check(pump_state.positivity_repairs==0,"Pump example required positivity repairs");
|
||||
std::cout<<"Vacuum tank after 1 s: "<<state(pump_state,tank_id).pressure<<" Pa\n";
|
||||
MachineSimulation pump_coarse(pump,44100),pump_fine(pump,88200);run(pump_coarse,8820);run(pump_fine,17640);
|
||||
const auto pc=pump_coarse.snapshot(),pf=pump_fine.snapshot();check(std::abs(state(pc,tank_id).pressure-state(pf,tank_id).pressure)<.01f*state(pf,tank_id).pressure,"Pump pressure failed sample-rate refinement");
|
||||
auto engine=machine_engine_example();machine_test::drive(engine,1800);const auto test_load=engine.add(MachinePartKind::Brake).id;engine.find(test_load)->brake.constant=0;engine.connect({machine_primary_shaft(engine),0},{test_load,0});unsigned cylinder_probe=0,downpipe_probe=0;for(const auto& p:engine.parts){if(p.kind==MachinePartKind::Cylinder)cylinder_probe=p.id;if(p.kind==MachinePartKind::Pipe)downpipe_probe=p.id;}
|
||||
engine.probes={{1,cylinder_probe,"Cylinder pressure",MachineQuantity::Pressure},{2,downpipe_probe,"Downpipe flow",MachineQuantity::MassFlow},{3,downpipe_probe,"Downpipe AFR",MachineQuantity::AirFuelRatio},{4,downpipe_probe,"Downpipe lambda",MachineQuantity::Lambda},{5,cylinder_probe,"Fresh charge",MachineQuantity::ChargeAfr},{6,0,"Tachometer",MachineQuantity::Rpm},{7,0,"Torque",MachineQuantity::Torque},{8,0,"Power",MachineQuantity::Power}};
|
||||
validate_machine(engine);MachineSimulation combustion(engine);check(std::isnan(combustion.measure().values[2]),"An empty exhaust reported fabricated AFR");combustion.set_controls(.5f);float peak=0;bool fired=false,exhaust_mixture=false,charge_mixture=false;
|
||||
for(int i=0;i<22050;++i){auto a=combustion.process();check(std::isfinite(a.left),"Engine audio became nonfinite");if(i%64==0){for(auto& part:combustion.snapshot().parts)if(engine.find(part.id)->kind==MachinePartKind::Cylinder){peak=std::max(peak,part.pressure);fired|=part.combustion;}
|
||||
const auto readings=combustion.measure();if(std::isfinite(readings.values[2])){exhaust_mixture=true;check(close(readings.values[2]/14.7f,readings.values[3],1e-4f),"Downpipe lambda does not match transported bulk AFR");}charge_mixture|=std::isfinite(readings.values[4]);check(close(readings.values[5],1800,.01f),"Tachometer did not read the simulated shaft");}}
|
||||
check(peak>500000,"Engine cylinder did not develop compression/combustion pressure");std::cout<<"Engine peak: "<<peak<<" Pa\n";
|
||||
check(fired,"Assigned ECU did not inject fuel and ignite the charge");
|
||||
check(exhaust_mixture&&charge_mixture,"Cylinder/exhaust instruments never measured fuel-bearing gas");
|
||||
const auto unchanged=combustion.snapshot();engine.find(test_load)->brake.constant=1.5f;for(auto& p:engine.parts)if(p.kind==MachinePartKind::Ecu){p.ecu.idle_rpm=1600;p.ecu.power_afr=12.2f;}combustion.apply_tuning(engine);
|
||||
check(combustion.snapshot().time==unchanged.time&&state(combustion.snapshot(),cylinder_probe).pressure==state(unchanged,cylinder_probe).pressure,"Live tuning reset time or cylinder state");
|
||||
auto changed_geometry=engine;changed_geometry.parts.front().position.x+=.1f;rejects([&]{combustion.apply_tuning(changed_geometry);},"Live tuning accepted changed geometry");
|
||||
auto changed_pipe=engine;changed_pipe.find(downpipe_probe)->point_diameters[0]*=1.1f;rejects([&]{combustion.apply_tuning(changed_pipe);},"Live tuning resized a pipe");
|
||||
run(combustion,256);check(combustion.snapshot().time>unchanged.time,"Live tuning stopped the simulation");
|
||||
const auto instrument_file=dir/"instruments.machine.toml";save_machine(engine,instrument_file);check(load_machine(instrument_file).probes==engine.probes,"Save/load lost instrument settings");
|
||||
auto bad_probe=engine;bad_probe.probes[0].part=999999;rejects([&]{validate_machine(bad_probe);},"Missing probe target was accepted");bad_probe=engine;bad_probe.probes.push_back(bad_probe.probes.front());rejects([&]{validate_machine(bad_probe);},"Too many probes were accepted");
|
||||
MachineSimulation cold_start(machine_engine_example());cold_start.set_controls(.15f,true);bool started=false;
|
||||
for(int i=0;i<88200;++i){cold_start.process();if(i%128==0)for(auto& p:cold_start.snapshot().parts)started|=p.combustion;}
|
||||
check(started&&cold_start.snapshot().rpm>500,"Starter could not start the zero-RPM EFI bench");cold_start.set_controls(0,false);run(cold_start,44100);check(cold_start.snapshot().rpm>500,"EFI bench stalled immediately after starter release");
|
||||
auto dry=engine;std::erase_if(dry.links,[](auto& l){return l.kind==MachineConnectionKind::Control;});MachineSimulation motoring(dry);run(motoring,16000);for(auto& p:motoring.snapshot().parts)if(dry.find(p.id)->kind==MachinePartKind::Cylinder)check(!p.combustion&&p.fuel_mass==0&&!p.fuel_source,"An unassigned ECU generated hidden fuel");
|
||||
auto no_inject=engine;for(auto& p:no_inject.parts)if(p.kind==MachinePartKind::Ecu)p.injection_enabled=false;MachineSimulation ignition_only(no_inject);run(ignition_only,16000);for(auto& p:ignition_only.snapshot().parts)if(no_inject.find(p.id)->kind==MachinePartKind::Cylinder)check(!p.combustion&&p.fuel_mass==0,"Ignition-only ECU injected fuel");
|
||||
MachineDocument shared;auto& node=shared.add(MachinePartKind::Junction,{0,.3f,0});const auto node_id=node.id;
|
||||
for(int i=0;i<8;++i){auto& p=shared.add(MachinePartKind::Pipe);p.position={};const float a=2*pi*i/8;p.points={{0,.3f,0},{.3f*std::cos(a),.3f,.3f*std::sin(a)}};shared.connect({p.id,0},{node_id,0});}validate_machine(shared);MachineSimulation multiway(shared);run(multiway,1024);
|
||||
auto group=shared;std::vector<unsigned> group_ids;for(auto& p:group.parts)group_ids.push_back(p.id);group.transform_selection(group_ids,{0,.3f,0},{.2f,.1f,-.1f},1,45);validate_machine(group);check(group.links.size()==shared.links.size(),"Group transform lost junction links");
|
||||
MachineDocument tee;const auto p1=tee.add(MachinePartKind::Pipe,{-.15f,.3f,0}).id,p2=tee.add(MachinePartKind::Pipe,{.15f,.3f,0}).id;tee.connect({p1,1},{p2,0});const auto p3=tee.add(MachinePartKind::Pipe,{0,.5f,0}).id;tee.connect({p3,0},{p1,1});validate_machine(tee);check(tee.parts.size()==4&&tee.links.size()==3,"Joining a third pipe failed to create a shared node");MachineSimulation tee_sim(tee);run(tee_sim,1024);
|
||||
Ecu limiter;limiter.rev_limit_rpm=5000;limiter.rev_resume_rpm=4500;limiter.rev_limit_cut_time=.05f;limiter.step(.001f,1,5100);check(limiter.ignition_cut(),"Limiter failed to engage");for(int i=0;i<20;++i)limiter.step(.001f,1,4000);check(limiter.rev_limited(),"Limiter ignored minimum cutoff duration");for(int i=0;i<40;++i)limiter.step(.001f,1,4000);check(!limiter.rev_limited(),"Limiter did not resume below its hysteresis RPM");
|
||||
for(const char* id:{"single-450","twin","flat6","v8-h"}){const auto& preset=scenario_example(id);auto imported=machine_from_engine(preset);float inlet_volume=0,inlet_length=0;
|
||||
for(auto& p:imported.parts)if(p.id==1||p.name=="Inlet metering stub"||p.kind==MachinePartKind::Carburetor||p.kind==MachinePartKind::Throttle){inlet_volume+=machine_part_volume(p);inlet_length+=p.kind==MachinePartKind::Pipe?machine_pipe_length(p):p.size.x;}
|
||||
const float original_length=preset.engine.geometry.pipes.front().length(),diameter=preset.engine.pipes.front().diameter;check(close(inlet_length,original_length,1e-6f)&&close(inlet_volume,original_length*pi*.25f*diameter*diameter,1e-8f),"Inlet metering assembly changed imported route length or gas volume");
|
||||
save_machine(imported,dir/(std::string(id)+".machine.toml"));auto restored=load_machine(dir/(std::string(id)+".machine.toml"));check(restored.parts.size()==imported.parts.size()&&restored.links.size()==imported.links.size(),"Import roundtrip lost topology");MachineSimulation sim(restored);run(sim,2205);std::cout<<"Imported "<<id<<": "<<imported.parts.size()<<" parts\n";
|
||||
if(std::string(id)=="single-450"){
|
||||
machine_test::drive(restored,3500);std::vector<unsigned> controllers;unsigned carb_id=0;for(auto& p:restored.parts){if(p.kind==MachinePartKind::Ecu)controllers.push_back(p.id);if(p.kind==MachinePartKind::Carburetor)carb_id=p.id;}for(auto controller:controllers)restored.remove(controller);
|
||||
bool burning=false;MachineSimulation carb(restored);carb.set_controls(.25f);for(int i=0;i<44100;++i){carb.process();if(i%64==0)for(auto& p:carb.snapshot().parts)burning|=p.combustion;}check(burning,"A connected carburetor without an ECU failed to deliver combustible fuel");
|
||||
const auto fuel_rate=[&]{double sum=0;for(int i=0;i<4536;++i){carb.process();if(i%24==0)sum+=state(carb.snapshot(),carb_id).fuel_flow;}return sum;};const auto original=fuel_rate();
|
||||
auto tuned=restored;tuned.find(carb_id)->carburetor.pilot_jet_diameter*=1.25f;tuned.find(carb_id)->carburetor.main_jet_diameter*=1.25f;tuned.find(carb_id)->carburetor.needle_jet_diameter*=1.25f;const auto time=carb.snapshot().time;carb.apply_tuning(tuned);check(carb.snapshot().time==time,"Live carb tuning restarted the machine");
|
||||
check(fuel_rate()>original*1.05,"Changing carb jets live did not increase delivered fuel");
|
||||
}
|
||||
}
|
||||
if(argc==3&&std::string(argv[1])=="--imports"){for(const char* id:{"single-450","twin","flat6","v8-h"})save_machine(machine_from_engine(scenario_example(id)),std::filesystem::path(argv[2])/(std::string(id)+"-imported.machine.toml"));}
|
||||
if(argc>=2&&std::string(argv[1])=="--rpm-sweep"){
|
||||
for(float rpm:{12000.f,16000.f,20000.f,24000.f}){auto fast=engine;machine_test::drive(fast,rpm);for(auto& p:fast.parts)if(p.kind==MachinePartKind::Ecu)p.ecu.rev_limit_enabled=false;MachineSimulation sim(fast);sim.set_controls(.75f);bool fire=false,floating=false;
|
||||
for(int i=0;i<13230;++i){sim.process();if(i%64==0)for(auto& p:sim.snapshot().parts){fire|=p.combustion;floating|=p.valve_float;}}
|
||||
std::cout<<"High RPM: "<<rpm<<" actual="<<sim.snapshot().rpm<<" fired="<<fire<<" valve_float="<<floating<<" repairs="<<sim.snapshot().positivity_repairs<<'\n';}
|
||||
auto free=engine;machine_test::free(free,4000);for(auto& p:free.parts)if(p.kind==MachinePartKind::Ecu)p.ecu.rev_limit_enabled=false;MachineSimulation sim(free);sim.set_controls(1);for(int i=0;i<1323000;++i){sim.process();if(i%44100==0){const auto s=sim.snapshot();std::cout<<"Free RPM after "<<i/44100<<"s: "<<s.rpm<<" torque="<<s.torque<<'\n';}}
|
||||
}
|
||||
if(argc==3&&std::string(argv[1])=="--examples"){const std::filesystem::path output=argv[2];save_machine(pump,output/"vacuum-pump.machine.toml");save_machine(engine,output/"single-cylinder.machine.toml");}
|
||||
for(auto& entry:std::filesystem::directory_iterator(dir))std::filesystem::remove(entry.path());std::filesystem::remove(dir);
|
||||
std::cout<<"Machine geometry, topology, serialization, valves, junctions, pump, and engine checks passed\n";
|
||||
return 0;
|
||||
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}
|
||||
Reference in New Issue
Block a user