Add machine workshop, typed logic, mechanics and sensor-driven ECUs
This commit is contained in:
13
tests/MachineTestControls.hpp
Normal file
13
tests/MachineTestControls.hpp
Normal file
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <stdexcept>
|
||||
namespace machine_test {
|
||||
inline void target_speed(fluidsim::port::MachineDocument& d,float rpm){using namespace fluidsim::port;for(auto& part:d.parts)if(part.kind==MachinePartKind::DriveMotor&&machine_shaft_for(d,part.id)==machine_primary_shaft(d)){part.drive.target_speed=rpm*2*pi/60;return;}throw std::runtime_error("Test bench has no speed drive");}
|
||||
inline void free(fluidsim::port::MachineDocument& d,float rpm){using namespace fluidsim::port;const auto shaft=machine_primary_shaft(d);d.find(shaft)->shaft.initial_speed=rpm*2*pi/60;for(auto& p:d.parts)if(p.kind==MachinePartKind::DriveMotor&&machine_shaft_for(d,p.id)==shaft)p.drive.enabled=false;}
|
||||
inline void drive(fluidsim::port::MachineDocument& d,float rpm){
|
||||
using namespace fluidsim::port;const auto shaft=machine_primary_shaft(d);if(!shaft)throw std::runtime_error("Test bench needs a primary shaft");
|
||||
d.find(shaft)->shaft.initial_speed=rpm*2*pi/60;
|
||||
for(auto& part:d.parts)if(part.kind==MachinePartKind::DriveMotor&&machine_shaft_for(d,part.id)==shaft){part.drive.enabled=true;part.drive.speed_control=true;part.drive.target_speed=rpm*2*pi/60;return;}
|
||||
auto& motor=d.add(MachinePartKind::DriveMotor,machine_math::add(d.find(shaft)->position,{-.3f,0,0}));const auto id=motor.id;motor.name="Test speed drive";motor.drive.target_speed=rpm*2*pi/60;d.connect({shaft,0},{id,0});
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ using namespace fluidsim::port;
|
||||
namespace {
|
||||
void check(bool condition,const char* message) { if(!condition) throw std::runtime_error(message); }
|
||||
struct Outcome { std::array<double,3> mass{}, pressure{}; std::uint64_t fallbacks{}; };
|
||||
Outcome flow(bool reverse,bool rotate,float loss,bool mixed=false,bool swap_donors=false) {
|
||||
Outcome flow(bool reverse,bool rotate,float loss,bool mixed=false,bool swap_donors=false,bool legacy=false) {
|
||||
std::vector<Pipe1D> pipes;
|
||||
for(int j=0;j<3;++j) {
|
||||
pipes.emplace_back(.001f*(mixed?j+1:1),.2f,8);
|
||||
@@ -16,19 +16,21 @@ Outcome flow(bool reverse,bool rotate,float loss,bool mixed=false,bool swap_dono
|
||||
const double p=mixed?(j==0?120000:101325):101325;
|
||||
const double u=(swap_donors?(j==0?0:-50):(j==0?70:0))*(reverse?-1:1);
|
||||
cell={float(rho),float(rho*u),float(p/pipe_gm1+.5*rho*u*u),j==0?.7f:1.f,j==0?.1f:0.f};
|
||||
cell.fuel_equivalent=j==0?.15f:0.f;
|
||||
}
|
||||
}
|
||||
BoundarySystem boundaries(pipes);
|
||||
std::array<std::array<float,3>,3> axes{{{-1,0,0},{1,0,0},{0,1,0}}};
|
||||
if(rotate) for(auto& axis:axes) axis={-axis[1],axis[0],axis[2]};
|
||||
boundaries.add_junction({{0,reverse,axes[0]},{1,!reverse,axes[1]},{2,!reverse,axes[2]}},BoundarySystem::JunctionMethod::Characteristic,loss);
|
||||
boundaries.add_junction({{0,reverse,axes[0]},{1,!reverse,axes[1]},{2,!reverse,axes[2]}},legacy?BoundarySystem::JunctionMethod::LegacyRusanov:BoundarySystem::JunctionMethod::Characteristic,loss);
|
||||
boundaries.add_closed_end(0,!reverse);boundaries.add_closed_end(1,reverse);boundaries.add_closed_end(2,reverse);
|
||||
const auto totals=[&] {
|
||||
std::array<double,4> t{};
|
||||
std::array<double,5> t{};
|
||||
for(auto& p:pipes) for(int i=0;i<int(p.cells.size());++i) {
|
||||
const double m=p.cell_mass(i);
|
||||
t[0]+=m;t[1]+=double(p.cells[i].energy)*p.cell_area(i)*p.cell_length(i);
|
||||
t[2]+=m*p.cells[i].air;t[3]+=m*p.cells[i].fuel;
|
||||
t[4]+=m*p.cells[i].fuel_equivalent;
|
||||
}
|
||||
return t;
|
||||
};
|
||||
@@ -36,7 +38,7 @@ Outcome flow(bool reverse,bool rotate,float loss,bool mixed=false,bool swap_dono
|
||||
// End before waves reflected by the sealed far ends reach the fitting.
|
||||
for(int i=0;i<30;++i) solver.step(1e-5f);
|
||||
const auto final=totals();
|
||||
for(int j=0;j<4;++j) check(std::abs(final[j]-initial[j])<std::max(1e-10,std::abs(initial[j])*1e-5),"junction lost mass, energy, or species");
|
||||
for(int j=0;j<5;++j) check(std::abs(final[j]-initial[j])<std::max(1e-10,std::abs(initial[j])*1e-5),"junction lost mass, energy, or species (including burned-fuel origin)");
|
||||
Outcome result;
|
||||
for(int j=0;j<3;++j) {
|
||||
const int endpoint=(j==0?reverse:!reverse)?0:7;
|
||||
@@ -62,6 +64,7 @@ int main() {
|
||||
}
|
||||
flow(false,false,.5f,true);flow(true,true,.5f,true);
|
||||
flow(false,false,.5f,true,true);flow(true,true,.5f,true,true);
|
||||
flow(false,false,0,false,false,true);flow(true,true,0,false,false,true);
|
||||
// Supersonic endpoint data must explicitly select the conservative fallback.
|
||||
std::vector<Pipe1D> pipes;for(int j=0;j<2;++j) pipes.emplace_back(.001f,.1f,4);
|
||||
for(auto& cell:pipes[0].cells) { const float u=500;cell.momentum=cell.rho*u;cell.energy+=.5f*cell.rho*u*u; }
|
||||
|
||||
86
tests/logic_editor_tests.cpp
Normal file
86
tests/logic_editor_tests.cpp
Normal file
@@ -0,0 +1,86 @@
|
||||
#include "FluidSim/Desktop/LogicEditor.hpp"
|
||||
#include "FluidSim/Desktop/PortAppearance.hpp"
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace fluidsim::port;
|
||||
using namespace fluidsim::port::desktop;
|
||||
namespace {
|
||||
void check(bool condition,const char* why){if(!condition)throw std::runtime_error(why);}
|
||||
bool close(double a,double b,double tolerance=1e-5){return std::abs(a-b)<tolerance;}
|
||||
struct Fixture {
|
||||
MachineDocument doc;LogicEditor editor;std::unique_ptr<MachineSimulation> simulation;MachineSnapshot live;
|
||||
sf::Vector2u size{1920,1080};WorkshopLayout ui=WorkshopLayout::calculate(size);sf::FloatRect canvas=ui.viewport;
|
||||
std::vector<MachineDocument> history;std::string status;std::uint32_t physical{},focused{};bool shift{};
|
||||
LogicEditorHooks hooks(){return {doc,simulation?&live:nullptr,shift,
|
||||
[this](const std::function<void()>& action){const auto before=doc;try{action();validate_machine(doc);if(simulation)simulation->apply_tuning(doc);history.push_back(before);return true;}catch(const std::exception& e){doc=before;status=e.what();return false;}},
|
||||
[this](std::uint32_t id,bool value){simulation->set_logic_control(id,value);},[this](std::uint32_t id,double value){simulation->set_logic_control(id,value);},[this]{if(simulation)simulation->release_logic_buttons();},
|
||||
[this](std::uint32_t id){physical=id;},[this](std::uint32_t id){focused=id;},[this](const std::string& text){status=text;},[this]{load(machine_logic_example());}};}
|
||||
void load(MachineDocument d){simulation.reset();doc=std::move(d);editor.reset_document();editor.fit(doc.logic,canvas);history.clear();}
|
||||
void send(sf::Event e){auto h=hooks();editor.event(e,h,ui,canvas,true);}
|
||||
void key(sf::Keyboard::Key code,bool control=false){send(sf::Event::KeyPressed{.code=code,.control=control});}
|
||||
void text(const std::string& value){for(const auto c:value)send(sf::Event::TextEntered{std::uint32_t(c)});}
|
||||
void press(sf::Vector2f point,sf::Mouse::Button button=sf::Mouse::Button::Left){send(sf::Event::MouseButtonPressed{button,{int(std::round(point.x)),int(std::round(point.y))}});}
|
||||
void release(sf::Vector2f point,sf::Mouse::Button button=sf::Mouse::Button::Left){send(sf::Event::MouseButtonReleased{button,{int(std::round(point.x)),int(std::round(point.y))}});}
|
||||
void click(sf::Vector2f point,sf::Mouse::Button button=sf::Mouse::Button::Left){press(point,button);release(point,button);}
|
||||
void drag(sf::Vector2f from,sf::Vector2f to){press(from);send(sf::Event::MouseMoved{{int(std::round(to.x)),int(std::round(to.y))}});release(to);}
|
||||
sf::Vector2f pin(std::uint32_t id,const char* key){return editor.pin_position(*doc.logic.find(id),key,canvas);}
|
||||
sf::Vector2f control(std::uint32_t id,float fraction=.5f){const auto r=editor.node_rect(*doc.logic.find(id),canvas);const float zoom=r.size.x/240;return {r.position.x+(12+216*fraction)*zoom,r.position.y+r.size.y-26*zoom};}
|
||||
void start(){simulation=std::make_unique<MachineSimulation>(doc);step();}
|
||||
void step(int count=1){for(int i=0;i<count;++i)simulation->process();live=simulation->snapshot();}
|
||||
SignalValue value(std::uint32_t id,const char* key){const auto ports=logic_node_ports(*doc.logic.find(id));for(const auto& n:live.logic)if(n.id==id)for(std::size_t i=0;i<ports.size();++i)if(ports[i].key==key)return n.values[i];throw std::runtime_error("Missing live pin");}
|
||||
};
|
||||
}
|
||||
int main(int argc,char** argv)try{
|
||||
Fixture f;
|
||||
{
|
||||
f.load(machine_speed_density_example());std::uint32_t connector=0;for(const auto& n:f.doc.logic.nodes)if(n.kind==LogicNodeKind::Ecu)connector=n.id;
|
||||
const auto pins=logic_node_ports(*f.doc.logic.find(connector));check(pins.size()==4&&pins[2].key=="map"&&pins[2].pressure_reference==PressureReference::Absolute&&pins[3].key=="iat","Speed-density canvas connector has the wrong sensor schema");
|
||||
f.editor.reveal(connector,f.doc,f.canvas);f.click(f.editor.node_rect(*f.doc.logic.find(connector),f.canvas).position+sf::Vector2f{30,12});f.start();check(f.value(connector,"map").validity==SignalValidity::Valid&&f.value(connector,"iat").validity==SignalValidity::Valid,"Live speed-density canvas lost MAP/IAT values");
|
||||
}
|
||||
{
|
||||
f.load(machine_sensor_engine_example(true));std::uint32_t connector=0,target=0;for(const auto& n:f.doc.logic.nodes)if(n.kind==LogicNodeKind::Ecu){connector=n.id;target=n.part;}
|
||||
const auto ports=logic_node_ports(*f.doc.logic.find(connector));check(ports.size()==3&&ports[0].quantity==SignalQuantity::AngularSpeed&&ports[1].quantity==SignalQuantity::Angle&&ports[2].quantity==SignalQuantity::Fraction,"Alpha-N connector lost required typed sensor inputs");for(const auto& pin:ports)check(pin.direction==PortDirection::Input&&pin.required,"ECU inputs are not required ring ports");
|
||||
f.editor.reveal(connector,f.doc,f.canvas);f.click(f.editor.node_rect(*f.doc.logic.find(connector),f.canvas).position+sf::Vector2f{30,12});check(f.physical==target,"ECU graph selection did not select physical controller");
|
||||
f.key(sf::Keyboard::Key::C,true);f.key(sf::Keyboard::Key::V,true);const auto pasted=f.editor.selected_node();check(pasted!=connector&&f.doc.logic.find(pasted)->kind==LogicNodeKind::Ecu&&f.doc.logic.find(pasted)->part==0,"Copied ECU connector stole existing ownership");
|
||||
}
|
||||
{
|
||||
f.load({});f.key(sf::Keyboard::Key::F,true);f.text("Hysteresis");f.key(sf::Keyboard::Key::Enter);f.click({80,244});const auto point=f.canvas.position+sf::Vector2f{120,140};f.click(point);check(f.doc.logic.nodes.size()==1&&f.doc.logic.nodes[0].kind==LogicNodeKind::Hysteresis,"Searchable node library did not create the chosen kind");check(f.history.size()==1,"Node creation did not enter undo history");
|
||||
}
|
||||
MachineDocument wired;const auto a=wired.logic.add(LogicNodeKind::Switch,{0,0}).id,b=wired.logic.add(LogicNodeKind::Switch,{0,250}).id,gate=wired.logic.add(LogicNodeKind::And,{420,80}).id,number=wired.logic.add(LogicNodeKind::NumberConstant,{420,360}).id;f.load(wired);
|
||||
f.click(f.pin(a,"out"));f.click(f.pin(gate,"in1"));check(f.doc.logic.links.size()==1,"Click-to-connect failed");f.drag(f.pin(b,"out"),f.pin(gate,"in2"));check(f.doc.logic.links.size()==2,"Drag-to-connect failed");
|
||||
const auto before=f.doc.logic.links;f.click(f.pin(number,"out"));f.click(f.pin(gate,"in1"));check(f.doc.logic.links==before&&!f.status.empty(),"Incompatible preview/edit changed existing wiring");f.key(sf::Keyboard::Key::Escape);
|
||||
f.drag(f.pin(gate,"in2"),f.pin(a,"out"));check(f.doc.logic.links.size()==2&&f.doc.logic.links.back().from.node==a,"Rewiring a connected input retained its old driver or rejected fan-out");
|
||||
const auto rewired=f.doc.logic.links;f.drag(f.pin(gate,"in2"),f.pin(number,"out"));check(f.doc.logic.links==rewired,"Rejected rewire disconnected the original signal");f.key(sf::Keyboard::Key::Escape);f.click(f.pin(gate,"in1"),sf::Mouse::Button::Right);check(f.doc.logic.links.size()==1,"Right-click input did not disconnect its driver");
|
||||
{
|
||||
const auto old=f.doc.logic.find(a)->position;const auto r=f.editor.node_rect(*f.doc.logic.find(a),f.canvas);const auto from=r.position+sf::Vector2f{30,12},to=from+sf::Vector2f{80,25};const auto p=f.editor.unproject(from,f.canvas),q=f.editor.unproject(to,f.canvas);const auto count=f.history.size();f.drag(from,to);check(close(f.doc.logic.find(a)->position.x-old.x,q.x-p.x,2)&&close(f.doc.logic.find(a)->position.y-old.y,q.y-p.y,2),"Header drag did not move the selected graph node");check(f.history.size()==count+1,"A node drag created more than one undo entry");
|
||||
const auto anchor=f.canvas.position+f.canvas.size*.4f;const auto original=f.editor.unproject(anchor,f.canvas);f.send(sf::Event::MouseWheelScrolled{sf::Mouse::Wheel::Vertical,2,{int(anchor.x),int(anchor.y)}});const auto after=f.editor.unproject(anchor,f.canvas);check(close(original.x,after.x,1)&&close(original.y,after.y,1),"Wheel zoom did not preserve the point beneath the pointer");
|
||||
}
|
||||
{
|
||||
MachineDocument doc;const auto button=doc.logic.add(LogicNodeKind::Button,{0,0}).id,lamp=doc.logic.add(LogicNodeKind::Lamp,{420,0}).id;doc.logic.connect({button,"out"},{lamp,"in"});f.load(doc);f.start();f.press(f.control(button));f.step();check(std::get<bool>(f.value(lamp,"in").value),"Live momentary control did not reach the graph");f.send(sf::Event::FocusLost{});f.step();check(!std::get<bool>(f.value(lamp,"in").value),"Focus loss left a momentary button true");f.press(f.control(button));auto h=f.hooks();f.editor.deactivate(h);f.step();check(!std::get<bool>(f.value(lamp,"in").value),"Closing the graph left a held button active");
|
||||
const auto nodes=f.doc.logic.nodes.size();f.key(sf::Keyboard::Key::Delete);check(f.doc.logic.nodes.size()==nodes,"Test allowed node deletion");
|
||||
}
|
||||
{
|
||||
MachineDocument doc;const auto toggle=doc.logic.add(LogicNodeKind::Switch,{0,0}).id,lamp=doc.logic.add(LogicNodeKind::Lamp,{420,0}).id;doc.logic.connect({toggle,"out"},{lamp,"in"});f.load(doc);f.start();const auto time=f.live.time;f.click(f.control(toggle));f.click(f.control(toggle));check(f.live.time==time,"Editing controls advanced a paused simulation");f.step();check(!std::get<bool>(f.value(lamp,"in").value)&&!f.doc.logic.find(toggle)->boolean,"Two queued toggles collapsed or changed configured initial state");
|
||||
}
|
||||
{
|
||||
MachineDocument doc;const auto slider=doc.logic.add(LogicNodeKind::Slider,{0,0}).id,readout=doc.logic.add(LogicNodeKind::Readout,{420,0}).id;doc.logic.connect({slider,"out"},{readout,"in"});f.load(doc);f.start();f.click(f.control(slider,.75f));f.step();check(close(std::get<double>(f.value(readout,"in").value),.75,.01)&&f.doc.logic.find(slider)->value==0,"Live slider failed or changed its initial value");
|
||||
}
|
||||
{
|
||||
MachineDocument doc;const auto constant=doc.logic.add(LogicNodeKind::NumberConstant,{0,0}).id,readout=doc.logic.add(LogicNodeKind::Readout,{420,0}).id;doc.logic.connect({constant,"out"},{readout,"in"});f.load(doc);f.start();f.editor.select_node(constant,f.doc);const auto time=f.live.time;f.click({f.ui.inspector_x+40,342});f.text("-3.25");f.key(sf::Keyboard::Key::Enter);check(f.doc.logic.find(constant)->value==-3.25&&f.live.time==time,"Live constant property edit failed or restarted physics");f.step();check(std::get<double>(f.value(readout,"in").value)==-3.25,"Live constant edit did not reach the next sample");f.click({f.ui.inspector_x+40,342});f.text("1e999");f.key(sf::Keyboard::Key::Enter);check(f.editor.editing_text()&&f.doc.logic.find(constant)->value==-3.25,"Invalid numeric input was lost or applied");f.key(sf::Keyboard::Key::Escape);
|
||||
}
|
||||
{
|
||||
MachineDocument doc;const auto host=doc.add(MachinePartKind::Chamber).id,sensor=doc.add_sensor(MachineSensorKind::Pressure,host).id,reference=doc.logic.add(LogicNodeKind::Sensor,{0,0}).id;f.load(doc);f.editor.select_node(reference,f.doc);f.click({f.ui.inspector_x+60,208});check(f.doc.logic.find(reference)->sensor==sensor&&f.doc.sensors.size()==1&&f.doc.parts.size()==1,"Sensor reference created a duplicate physical sensor or failed to bind");f.click({f.ui.inspector_x+60,f.ui.height-92});check(f.focused==host,"Focus in 3D did not resolve the shared physical host");
|
||||
f.key(sf::Keyboard::Key::C,true);f.key(sf::Keyboard::Key::V,true);check(f.doc.logic.nodes.size()==2&&f.doc.logic.nodes.back().sensor==sensor,"Copied sensor node did not reference the same sensor entity");
|
||||
}
|
||||
{
|
||||
MachineDocument destination;const auto part=destination.add(MachinePartKind::Chamber).id;destination.add_sensor(MachineSensorKind::GasTemperature,part);f.load(destination);f.key(sf::Keyboard::Key::V,true);check(f.doc.logic.nodes.size()==1&&f.doc.logic.nodes[0].sensor==0&&!f.doc.logic.nodes[0].binding_label.empty(),"Cross-document graph paste silently matched a coincidental sensor ID or lost its intended binding");
|
||||
}
|
||||
if(argc==3&&std::string(argv[1])=="--ui-preview"){
|
||||
const std::filesystem::path output=argv[2];std::filesystem::create_directories(output);sf::ContextSettings settings;settings.depthBits=24;settings.majorVersion=2;settings.minorVersion=1;sf::RenderWindow window(sf::VideoMode({1920,1080}),"Logic editor review",sf::Style::Default,sf::State::Windowed,settings);window.setVisible(false);sf::Font font("C:/Windows/Fonts/consola.ttf");
|
||||
{MachineDocument shapes;const auto source=shapes.logic.add(LogicNodeKind::Switch,{0,0}).id,input=shapes.logic.add(LogicNodeKind::Lamp,{420,0}).id,number=shapes.logic.add(LogicNodeKind::NumberConstant,{0,250}).id,readout=shapes.logic.add(LogicNodeKind::Readout,{420,250}).id;shapes.logic.connect({source,"out"},{input,"in"});shapes.logic.connect({number,"out"},{readout,"in"});f.load(shapes);window.clear();auto h=f.hooks();f.editor.draw(window,&font,h,f.ui,f.canvas,true);sf::Texture pixels(f.size);pixels.update(window);const auto image=pixels.copyToImage();const auto sample=[&](std::uint32_t id,const char* key){const auto at=f.pin(id,key);return image.getPixel({unsigned(std::round(at.x)),unsigned(std::round(at.y))});};const auto red=sample(source,"out"),green=sample(number,"out"),ring=sample(input,"in");check(red.r>150&&red.g<150&&green.g>150&&green.r<150,"Rendered signal output circles do not use the declared Boolean/Number colors");check(ring.r<50&&ring.g<50&&ring.b<50,"Rendered input pin has a filled center instead of a ring");}
|
||||
for(const auto resolution:{sf::Vector2u{1920,1080},sf::Vector2u{1280,720}}){f.size=resolution;f.ui=WorkshopLayout::calculate(resolution);f.canvas=f.ui.viewport;window.setSize(resolution);f.load(machine_logic_example());auto capture=[&](const std::string& name){window.clear();auto h=f.hooks();f.editor.draw(window,&font,h,f.ui,f.canvas,true);sf::Texture pixels(resolution);pixels.update(window);check(pixels.copyToImage().saveToFile(output/(std::to_string(resolution.x)+"-"+name+".png")),"Logic screenshot save failed");};capture("graph-build");f.start();for(int frame=0;frame<10;++frame){f.step(1200);auto h=f.hooks();f.editor.draw(window,&font,h,f.ui,f.canvas,true);}capture("graph-live");for(const auto& n:f.doc.logic.nodes)if(n.kind==LogicNodeKind::Hysteresis){f.editor.reveal(n.id,f.doc,f.canvas);capture("threshold-inspector");break;}}
|
||||
}
|
||||
std::cout<<"Logic canvas placement, typed wiring/rewiring, undo, controls, focus release, binding and live inspector passed\n";return 0;
|
||||
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}
|
||||
84
tests/logic_tests.cpp
Normal file
84
tests/logic_tests.cpp
Normal 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;}
|
||||
338
tests/machine_builder_tests.cpp
Normal file
338
tests/machine_builder_tests.cpp
Normal file
@@ -0,0 +1,338 @@
|
||||
#include "MachineTestControls.hpp"
|
||||
#include "FluidSim/Desktop/MachineBuilder.hpp"
|
||||
#include "FluidSim/Desktop/WorkshopLayout.hpp"
|
||||
#include "FluidSim/Desktop/PortAppearance.hpp"
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include "FluidSim/Desktop/MachineRenderer3D.hpp"
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <fstream>
|
||||
#include <cstdlib>
|
||||
using namespace fluidsim::port;
|
||||
using namespace fluidsim::port::desktop;
|
||||
using namespace fluidsim::port::machine_math;
|
||||
void check(bool condition,const char* why){if(!condition)throw std::runtime_error(why);}
|
||||
void machine_test_directory(const std::string& value){
|
||||
#ifdef _WIN32
|
||||
_putenv_s("FLUIDSIM_MACHINE_DIR",value.c_str());
|
||||
#else
|
||||
if(value.empty())unsetenv("FLUIDSIM_MACHINE_DIR");else setenv("FLUIDSIM_MACHINE_DIR",value.c_str(),1);
|
||||
#endif
|
||||
}
|
||||
struct UiPreferenceFixture {
|
||||
std::filesystem::path path=std::filesystem::temp_directory_path()/("fluidsim-ui-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".txt");
|
||||
std::string previous;
|
||||
static void set(const std::string& value){
|
||||
#ifdef _WIN32
|
||||
_putenv_s("FLUIDSIM_UI_SETTINGS",value.c_str());
|
||||
#else
|
||||
if(value.empty())unsetenv("FLUIDSIM_UI_SETTINGS");else setenv("FLUIDSIM_UI_SETTINGS",value.c_str(),1);
|
||||
#endif
|
||||
}
|
||||
UiPreferenceFixture(){if(const char* old=std::getenv("FLUIDSIM_UI_SETTINGS"))previous=old;set(path.string());}
|
||||
~UiPreferenceFixture(){set(previous);std::error_code error;std::filesystem::remove(path,error);}
|
||||
};
|
||||
int main(int argc,char** argv)try{
|
||||
UiPreferenceFixture preferences;
|
||||
check(port_color(PortDomain::Boolean).r>port_color(PortDomain::Boolean).g,"Boolean ports are not red");
|
||||
check(port_color(PortDomain::Number).g>port_color(PortDomain::Number).r,"Number ports are not green");
|
||||
MachinePortDescriptor input_pin;input_pin.direction=PortDirection::Input;auto output_pin=input_pin;output_pin.direction=PortDirection::Output;
|
||||
check(!filled_port(input_pin)&&filled_port(output_pin),"Input/output silhouettes do not match ring/filled convention");
|
||||
const sf::Vector2u size{1440,900};const auto viewport=MachineBuilder::viewport(size);
|
||||
if(argc==3&&std::string(argv[1])=="--ui-preview"){
|
||||
const std::filesystem::path output=argv[2];std::filesystem::create_directories(output);
|
||||
sf::ContextSettings context;context.depthBits=24;context.majorVersion=2;context.minorVersion=1;
|
||||
sf::RenderWindow window(sf::VideoMode({1920,1080}),"Workshop UI review",sf::Style::Default,sf::State::Windowed,context);window.setVisible(false);
|
||||
sf::Font font("C:/Windows/Fonts/consola.ttf");MachineBuilder preview;
|
||||
auto click_preview=[&](sf::Vector2f at){const sf::Vector2i pos{int(at.x),int(at.y)};preview.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,pos},window.getSize());preview.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,pos},window.getSize());};
|
||||
auto key_preview=[&](sf::Keyboard::Key code){preview.handle_event(sf::Event::KeyPressed{.code=code},window.getSize());};
|
||||
auto capture=[&](const std::string& name){preview.draw(window,&font);sf::Texture image(window.getSize());image.update(window);check(image.copyToImage().saveToFile(output/name),"UI screenshot save failed");};
|
||||
for(const sf::Vector2u resolution:{sf::Vector2u{1920,1080},sf::Vector2u{1280,720}}){
|
||||
window.setSize(resolution);const auto ui=WorkshopLayout::calculate(resolution);const auto prefix=std::to_string(resolution.x)+"-";
|
||||
preview.set_document({});capture(prefix+"empty.png");
|
||||
auto design=machine_from_engine(scenario_example("flat6"));preview.set_document(design);
|
||||
click_preview({150,120}); // objects list
|
||||
const auto ecu=std::find_if(design.parts.begin(),design.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Ecu;});
|
||||
if(ecu!=design.parts.end()){
|
||||
click_preview({40,165});for(char c:std::string("ECU"))preview.handle_event(sf::Event::TextEntered{std::uint32_t(c)},resolution);key_preview(sf::Keyboard::Key::Enter);
|
||||
click_preview({85,242});capture(prefix+"ecu.png");
|
||||
click_preview({40,165});for(int i=0;i<3;++i)key_preview(sf::Keyboard::Key::Backspace);key_preview(sf::Keyboard::Key::Enter);
|
||||
}
|
||||
key_preview(sf::Keyboard::Key::H);capture(prefix+"checks.png");
|
||||
key_preview(sf::Keyboard::Key::M);capture(prefix+"meters.png");
|
||||
auto pump=machine_pump_example();pump.probes={{1,pump.parts.front().id,"Tank pressure",MachineQuantity::Pressure},{2,0,"Shaft speed",MachineQuantity::Rpm}};
|
||||
preview.set_document(pump);preview.start_test();check(preview.testing(),"Preview test did not start");preview.update();capture(prefix+"running.png");preview.pause_test();preview.step_test();key_preview(sf::Keyboard::Key::M);capture(prefix+"paused.png");preview.stop_test();
|
||||
click_preview({160,68});capture(prefix+"collapsed.png");click_preview({160,68});
|
||||
preview.set_document(load_machine(machine_directory()/"shaft-drive-bench.machine.toml"));click_preview({160,120});
|
||||
for(const auto& entry:std::vector<std::pair<std::string,std::string>>{{"Bench shaft","mechanical-shaft"},{"Flywheel","mechanical-flywheel"},{"Torque motor","mechanical-motor"},{"Adjustable brake","mechanical-brake"}}){
|
||||
preview.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::F,.control=true},resolution);for(char c:entry.first)preview.handle_event(sf::Event::TextEntered{std::uint32_t(c)},resolution);key_preview(sf::Keyboard::Key::Enter);click_preview({85,242});capture(prefix+entry.second+".png");
|
||||
}
|
||||
preview.start_test();preview.pause_test();for(int i=0;i<10;++i)preview.step_test();key_preview(sf::Keyboard::Key::M);capture(prefix+"mechanical-running.png");preview.stop_test();
|
||||
auto tapped=machine_pump_example();const auto tank=tapped.parts.front().id;tapped.add_sensor(MachineSensorKind::Pressure,tank);tapped.add_sensor(MachineSensorKind::GasTemperature,tank);tapped.add_sensor(MachineSensorKind::Crank,machine_primary_shaft(tapped));
|
||||
const auto delta=tapped.add_sensor(MachineSensorKind::DifferentialPressure,tank).id;for(const auto& p:tapped.parts)if(p.kind==MachinePartKind::Reservoir){tapped.find_sensor(delta)->reference={p.id,.5f,p.name};break;}
|
||||
preview.set_document(tapped);preview.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::F,.control=true},resolution);key_preview(sf::Keyboard::Key::Backspace);key_preview(sf::Keyboard::Key::Enter);
|
||||
const auto marker=builder_project(preview.camera(),preview.workspace_viewport(resolution),*machine_sensor_position(tapped,tapped.sensors[0].target));check(bool(marker),"Sensor preview marker is outside the viewport");click_preview(*marker+sf::Vector2f{0,22});capture(prefix+"sensors-build.png");
|
||||
preview.start_test();preview.pause_test();preview.step_test();capture(prefix+"sensors-live.png");
|
||||
click_preview({ui.inspector_x+ui.inspector_width-32,286});click_preview({ui.inspector_x+ui.inspector_width-32,286});capture(prefix+"sensors-crank.png");
|
||||
click_preview({ui.inspector_x+ui.inspector_width-32,286});capture(prefix+"sensors-differential.png");preview.stop_test();
|
||||
preview.set_document(machine_logic_example());preview.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::F,.control=true},resolution);for(char c:std::string("Bench speed drive"))preview.handle_event(sf::Event::TextEntered{std::uint32_t(c)},resolution);key_preview(sf::Keyboard::Key::Enter);click_preview({85,242});capture(prefix+"logic-owned-drive.png");
|
||||
preview.start_test();preview.pause_test();for(int i=0;i<50;++i)preview.step_test();key_preview(sf::Keyboard::Key::M);capture(prefix+"logic-pressure-stop.png");preview.stop_test();
|
||||
preview.set_document(machine_logic_example());preview.set_workspace_view(WorkshopView::Logic);capture(prefix+"logic-canvas-build.png");preview.start_test();preview.pause_test();for(int i=0;i<20;++i)preview.step_test();capture(prefix+"logic-canvas-live.png");preview.set_workspace_view(WorkshopView::Split);capture(prefix+"logic-split.png");preview.stop_test();preview.set_workspace_view(WorkshopView::ThreeD);
|
||||
for(const int preset:{0,1,2}){
|
||||
const bool injection=preset!=0;const auto design=preset==2?machine_speed_density_example():machine_sensor_engine_example(injection);const auto tag=preset==2?"speed-density-":injection?"alpha-n-":"ignition-";preview.set_document(design);preview.set_workspace_view(WorkshopView::Logic);key_preview(sf::Keyboard::Key::Home);std::uint32_t connector=0;std::string host;for(const auto& n:design.logic.nodes)if(n.kind==LogicNodeKind::Ecu)connector=n.id;for(const auto& p:design.parts)if(p.injector)host=p.name;
|
||||
click_preview(preview.logic_node_rectangle(connector,resolution).position+sf::Vector2f{24,12});capture(prefix+tag+"connector-build.png");preview.start_test();check(preview.testing(),"Sensor engine preview failed to start");preview.pause_test();for(int i=0;i<50;++i)preview.step_test();capture(prefix+tag+"connector-live.png");preview.set_workspace_view(WorkshopView::ThreeD);click_preview({ui.inspector_x+30,148});capture(prefix+tag+"ecu-live.png");
|
||||
if(injection){click_preview({160,120});preview.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::F,.control=true},resolution);for(char c:host)preview.handle_event(sf::Event::TextEntered{std::uint32_t(c)},resolution);key_preview(sf::Keyboard::Key::Enter);click_preview({85,242});capture(prefix+tag+"injector-live.png");}
|
||||
preview.stop_test();
|
||||
}
|
||||
const auto viewport=preview.workspace_viewport(resolution);check(viewport.position.x>ui.left.position.x+ui.left.size.x,"Viewport overlaps library");
|
||||
}
|
||||
std::cout<<"Workshop UI previews rendered\n";return 0;
|
||||
}
|
||||
{
|
||||
MachineBuilder ecu_ui;auto design=machine_engine_example();std::uint32_t ecu=0,host=0;for(const auto& p:design.parts){if(p.kind==MachinePartKind::Ecu)ecu=p.id;if(p.kind==MachinePartKind::Chamber&&p.position.x<0)host=p.id;}ecu_ui.set_document(design);const auto ui=WorkshopLayout::calculate(size);
|
||||
const auto click_ecu=[&](sf::Vector2f at){const sf::Vector2i point{int(at.x),int(at.y)};ecu_ui.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,point},size);ecu_ui.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,point},size);};
|
||||
const auto choose=[&](std::uint32_t id){ecu_ui.set_workspace_view(WorkshopView::ThreeD);click_ecu({160,120});ecu_ui.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::F,.control=true},size);for(char c:ecu_ui.document().find(id)->name)ecu_ui.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);ecu_ui.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::Enter},size);click_ecu({85,242});check(ecu_ui.selected_parts()==std::vector<std::uint32_t>{id},"ECU workflow object selection failed");};
|
||||
choose(ecu);click_ecu({ui.inspector_x+90,222});check(ecu_ui.document().find(ecu)->sensor_ecu.enabled,"ECU preset button did not select sensor ignition");click_ecu({ui.inspector_x+100,258});check(ecu_ui.workspace_view()==WorkshopView::Logic&&ecu_ui.document().logic.nodes.size()==1,"Physical ECU did not create grouped Logic connector");
|
||||
choose(ecu);click_ecu({ui.inspector_x+90,222});check(ecu_ui.document().find(ecu)->sensor_ecu.strategy==SensorEcuConfig::Strategy::AlphaN&&logic_node_ports(ecu_ui.document().logic.nodes[0]).size()==3,"Alpha-N preset did not require TPS on its existing connector");
|
||||
choose(ecu);click_ecu({ui.inspector_x+90,222});check(ecu_ui.document().find(ecu)->sensor_ecu.strategy==SensorEcuConfig::Strategy::SpeedDensity&&logic_node_ports(ecu_ui.document().logic.nodes[0]).size()==4,"Speed-density preset did not replace TPS with MAP/IAT");
|
||||
choose(host);click_ecu({ui.inspector_x+90,222});check(bool(ecu_ui.document().find(host)->injector),"Chamber inspector did not attach an injector");click_ecu({ui.inspector_x+100,258});check(ecu_ui.document().find(host)->injector->ecu==ecu,"Injector binding picker did not choose fuel ECU");
|
||||
ecu_ui.set_document(machine_sensor_engine_example(true));host=0;for(const auto& p:ecu_ui.document().parts)if(p.injector)host=p.id;ecu_ui.start_test();check(ecu_ui.testing(),"Alpha-N workshop Test failed to start");ecu_ui.pause_test();choose(host);click_ecu({ui.inspector_x+90,222});check(bool(ecu_ui.document().find(host)->injector),"Test allowed injector removal");ecu_ui.stop_test();
|
||||
}
|
||||
for(float yaw:{-110.f,-28.f,0.f,90.f})for(float pitch:{-50.f,0.f,60.f}){
|
||||
OrbitCamera camera;camera.yaw=yaw;camera.pitch=pitch;
|
||||
for(Vec3 point:{Vec3{0,.2f,0},Vec3{.2f,.3f,-.1f}}){auto projected=builder_project(camera,viewport,point);check(bool(projected),"Point in front of camera was not projected");auto ray=builder_ray(camera,viewport,*projected);check(length(cross(subtract(point,ray.origin),ray.direction))<1e-5f,"Mouse ray and render projection differ");}
|
||||
}
|
||||
MachinePart box;box.kind=MachinePartKind::Chamber;box.position={};box.size={.4f,.1f,.1f};box.rotation={0,0,90};
|
||||
check(bool(builder_pick_part(box,{{0,0,1},{0,0,-1}})),"Picking missed a rotated part");check(!builder_pick_part(box,{{.15f,0,1},{0,0,-1}}),"Picking ignored the part rotation");
|
||||
auto euler=builder_rotate_world({15,35,20},0,45);const auto transformed=rotate({0,1,0},euler),expected=rotate(rotate({0,1,0},{15,35,20}),{45,0,0});check(length(subtract(transformed,expected))<1e-5f,"World rotation composition is incorrect");
|
||||
MachinePart bulge;bulge.kind=MachinePartKind::Pipe;bulge.position={};bulge.points={{-.15f,0,0},{0,0,0},{.15f,0,0}};bulge.point_diameters={.02f,.08f,.02f};
|
||||
check(bool(builder_pick_part(bulge,{{0,.035f,1},{0,0,-1}})),"Picking missed the widened interior point");
|
||||
check(!builder_pick_part(bulge,{{-.15f,.035f,1},{0,0,-1}}),"Picking used an interior diameter at the narrow endpoint");
|
||||
MachineBuilder builder;auto doc=machine_pump_example();builder.set_document(doc);
|
||||
const auto tank_id=doc.parts.front().id;const auto original=doc.find(tank_id)->position;
|
||||
const auto click=[&](sf::Vector2f p){sf::Vector2i pos{int(std::round(p.x)),int(std::round(p.y))};builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,pos},size);builder.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,pos},size);};
|
||||
const auto key=[&](sf::Keyboard::Key code,bool control=false){builder.handle_event(sf::Event::KeyPressed{.code=code,.control=control},size);};
|
||||
key(sf::Keyboard::Key::Escape);check(!builder.finished(),"Escape exited the workshop");
|
||||
MachineBuilder exit_menu;exit_menu.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,{24,24}},size);check(exit_menu.finished()&&!exit_menu.wants_close_window(),"Menu did not explicitly leave the workshop");
|
||||
MachineBuilder close_window;close_window.handle_event(sf::Event::Closed{},size);check(close_window.finished()&&close_window.wants_close_window(),"Window close did not explicitly exit the application");
|
||||
auto center=builder_project(builder.camera(),builder.workspace_viewport(size),original);check(bool(center),"Tank is outside the builder camera");click(*center);key(sf::Keyboard::Key::G);
|
||||
const float gizmo=builder.camera().distance*.828427f/viewport.size.y*76;auto end=builder_project(builder.camera(),builder.workspace_viewport(size),add(original,{gizmo,0,0}));check(bool(end),"Gizmo projection failed");
|
||||
const sf::Vector2i start{int(end->x),int(end->y)},finish{int(end->x+50),int(end->y)};
|
||||
builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,start},size);builder.handle_event(sf::Event::MouseMoved{finish},size);builder.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,finish},size);
|
||||
check(builder.document().find(tank_id)->position.x>original.x+.01f,"Mouse move gizmo did not translate the selected part");validate_machine(builder.document());
|
||||
key(sf::Keyboard::Key::Z,true);check(length(subtract(builder.document().find(tank_id)->position,original))<1e-6f,"Undo did not restore the transform");
|
||||
key(sf::Keyboard::Key::D,true);check(builder.document().parts.size()==doc.parts.size()+1,"Duplicate did not add a part");key(sf::Keyboard::Key::Delete);check(builder.document().parts.size()==doc.parts.size(),"Delete did not remove a part");
|
||||
builder.set_document(doc);click(*builder_project(builder.camera(),builder.workspace_viewport(size),doc.parts[0].position));key(sf::Keyboard::Key::LShift);click(*builder_project(builder.camera(),builder.workspace_viewport(size),doc.parts[1].position));builder.handle_event(sf::Event::KeyReleased{.code=sf::Keyboard::Key::LShift},size);
|
||||
check(builder.selected_parts().size()==2,"Shift-click did not select two parts");key(sf::Keyboard::Key::D,true);check(builder.document().parts.size()==doc.parts.size()+2,"Group duplicate did not copy both parts");key(sf::Keyboard::Key::Delete);check(builder.document().parts.size()==doc.parts.size(),"Group delete did not remove both copies");
|
||||
builder.set_document(doc);key(sf::Keyboard::Key::A,true);check(builder.selected_parts().size()==doc.parts.size(),"Select all missed parts");key(sf::Keyboard::Key::D,true);check(builder.document().parts.size()==doc.parts.size()*2&&builder.document().links.size()==doc.links.size()*2,"Group duplication lost internal connections");validate_machine(builder.document());
|
||||
builder.set_document(doc);key(sf::Keyboard::Key::A,true);key(sf::Keyboard::Key::C,true);key(sf::Keyboard::Key::V,true);
|
||||
check(builder.document().parts.size()==doc.parts.size()*2&&builder.document().links.size()==doc.links.size()*2,"Copy/paste lost group parts or internal connections");validate_machine(builder.document());
|
||||
key(sf::Keyboard::Key::Escape);key(sf::Keyboard::Key::Escape);check(!builder.finished(),"Repeated Escape exited the workshop");
|
||||
builder.set_document(doc);key(sf::Keyboard::Key::V,true);check(builder.document().parts.size()==doc.parts.size()*2,"Loading a document discarded the copied group");
|
||||
builder.set_document(doc);key(sf::Keyboard::Key::A,true);
|
||||
const auto group_screen=builder_project(builder.camera(),builder.workspace_viewport(size),doc.parts.front().position);check(bool(group_screen),"Group is outside camera");
|
||||
builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Right,{int(group_screen->x),int(group_screen->y)}},size);
|
||||
check(builder.selected_parts().size()==doc.parts.size(),"Right-click collapsed the selected group");key(sf::Keyboard::Key::Escape);
|
||||
{
|
||||
const auto root=std::filesystem::temp_directory_path()/("fluidsim-assembly-ui-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
const auto* old_directory=std::getenv("FLUIDSIM_MACHINE_DIR");const std::string previous_directory=old_directory?old_directory:"";machine_test_directory(root.string());
|
||||
const float mx=float(size.x)*.5f-280,my=float(size.y)*.5f-185;
|
||||
click({450,26});click({mx+450,my+30});for(char c:std::string("pump-module"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);
|
||||
const auto saved=machine_assembly_directory()/"pump-module.assembly.toml";check(std::filesystem::exists(saved),"Save selected assembly did not create a file");
|
||||
check(load_machine(saved).links.size()==doc.links.size(),"Saved assembly lost internal connections");
|
||||
MachineDocument marker;marker.name="Marker";marker.add(MachinePartKind::Cap);
|
||||
for(int i=0;i<5;++i)save_machine(marker,machine_assembly_directory()/("0"+std::to_string(i)+".assembly.toml"));
|
||||
check(machine_assembly_files().size()==6,"Assembly library did not discover saved files");
|
||||
builder.set_document({});click({450,26});builder.handle_event(sf::Event::MouseWheelScrolled{.delta=-1,.position={800,400}},size);click({mx+480,my+76+4*47+17});
|
||||
check(builder.document().parts.size()==doc.parts.size()&&builder.document().links.size()==doc.links.size(),"Library could not browse and insert a saved connected assembly");validate_machine(builder.document());
|
||||
key(sf::Keyboard::Key::Z,true);check(builder.document().parts.empty(),"Assembly insertion did not undo as one action");key(sf::Keyboard::Key::Y,true);check(builder.document().parts.size()==doc.parts.size(),"Assembly insertion redo failed");
|
||||
for(const auto& path:machine_assembly_files())std::filesystem::remove(path);std::filesystem::remove(machine_assembly_directory());std::filesystem::remove(root);machine_test_directory(previous_directory);
|
||||
}
|
||||
// Atmosphere symbols share a point with their pipe end, and must remain
|
||||
// selectable/movable after Connect without implicitly starting another wire.
|
||||
MachineDocument ports;const auto pipe_id=ports.add(MachinePartKind::Pipe,{0,.25f,0}).id;
|
||||
ports.find(pipe_id)->points={{-.28f,0,0},{0,0,0}};ports.find(pipe_id)->diameter=ports.find(pipe_id)->outlet_diameter=.12f;
|
||||
const auto atmosphere_id=ports.add(MachinePartKind::Reservoir,{0,.25f,0}).id;
|
||||
const auto free_atmosphere_id=ports.add(MachinePartKind::Reservoir,{.25f,.25f,.1f}).id;
|
||||
ports.connect({pipe_id,1},{atmosphere_id,0});validate_machine(ports);
|
||||
const auto screen_part=[&](unsigned id){auto point=builder_project(builder.camera(),builder.workspace_viewport(size),builder.document().find(id)->position);check(bool(point),"Atmosphere is outside the builder camera");return *point;};
|
||||
const auto drag_body=[&](sf::Vector2f point){const sf::Vector2i from{int(std::round(point.x)),int(std::round(point.y))},to{from.x+45,from.y-30};
|
||||
builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,from},size);builder.handle_event(sf::Event::MouseMoved{to},size);builder.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,to},size);};
|
||||
builder.set_document(ports);key(sf::Keyboard::Key::Q);click(screen_part(atmosphere_id));
|
||||
check(builder.selected_parts()==std::vector<std::uint32_t>{atmosphere_id},"Attached pipe captured atmosphere selection");
|
||||
key(sf::Keyboard::Key::C);click(screen_part(atmosphere_id));check(builder.selected_parts()==std::vector<std::uint32_t>{atmosphere_id},"Connect selected the pipe instead of the atmosphere symbol");
|
||||
key(sf::Keyboard::Key::Escape);drag_body(screen_part(atmosphere_id));
|
||||
check(length(subtract(builder.document().find(atmosphere_id)->position,ports.find(atmosphere_id)->position))>.01f,"Escape did not return Connect to Move");
|
||||
check(length(subtract(machine_port_position(*builder.document().find(pipe_id),1),builder.document().find(atmosphere_id)->position))<1e-6f,"Moving atmosphere detached its pipe endpoint");validate_machine(builder.document());
|
||||
key(sf::Keyboard::Key::Z,true);check(length(subtract(builder.document().find(atmosphere_id)->position,ports.find(atmosphere_id)->position))<1e-6f,"Undo did not restore atmosphere and attached pipe");validate_machine(builder.document());
|
||||
for(auto exit_key:{sf::Keyboard::Key::G,sf::Keyboard::Key::Escape}){
|
||||
builder.set_document(ports);key(sf::Keyboard::Key::C);click(screen_part(free_atmosphere_id));key(exit_key);
|
||||
click(screen_part(free_atmosphere_id));
|
||||
// A selected port now starts a direct wire. Use the move gizmo to move
|
||||
// a boundary whose sole port occupies its entire symbol.
|
||||
const float handle=builder.camera().distance*.828427f/viewport.size.y*76;
|
||||
drag_body(*builder_project(builder.camera(),builder.workspace_viewport(size),add(builder.document().find(free_atmosphere_id)->position,{handle,0,0})));
|
||||
check(length(subtract(builder.document().find(free_atmosphere_id)->position,ports.find(free_atmosphere_id)->position))>.01f,"Move gizmo could not move a directly connectable boundary");
|
||||
check(builder.document().links.size()==ports.links.size(),"Moving atmosphere changed connections");validate_machine(builder.document());
|
||||
}
|
||||
builder.set_document(ports);drag_body(screen_part(free_atmosphere_id));
|
||||
check(length(subtract(builder.document().find(free_atmosphere_id)->position,ports.find(free_atmosphere_id)->position))>.01f,"Move required a separate selection click before dragging atmosphere");
|
||||
// Explicit connection still works after the selection/drag changes.
|
||||
builder.set_document(ports);key(sf::Keyboard::Key::C);click(screen_part(free_atmosphere_id));click(*builder_project(builder.camera(),builder.workspace_viewport(size),machine_port_position(*ports.find(pipe_id),0)));
|
||||
check(builder.document().links.size()==ports.links.size()+1,"Explicit atmosphere connection failed");validate_machine(builder.document());
|
||||
// Route insertion uses the clicked segment in perspective and preserves the
|
||||
// authored length/volume until its new bend is actually moved.
|
||||
MachineDocument route;const auto route_id=route.add(MachinePartKind::Pipe,{0,.3f,0}).id;
|
||||
route.find(route_id)->points={{-.4f,0,-.25f},{.4f,.15f,.3f}};route.find(route_id)->rotation={15,30,10};
|
||||
route.find(route_id)->point_diameters={.02f,.08f};
|
||||
builder.set_document(route);const auto& route_part=*route.find(route_id);const auto local=add(route_part.points[0],multiply(subtract(route_part.points[1],route_part.points[0]),.3f));
|
||||
const auto on_route=builder_project(builder.camera(),builder.workspace_viewport(size),machine_world_point(route_part,local));check(bool(on_route),"Route point is outside camera");
|
||||
const auto location=builder_pipe_location(builder.camera(),viewport,route_part,*on_route);check(location&&length(subtract(location->local_point,local))<1e-5f,"Bend insertion ignored perspective or part rotation");
|
||||
key(sf::Keyboard::Key::Q);click(*on_route);key(sf::Keyboard::Key::P);key(sf::Keyboard::Key::LControl);click(*on_route);builder.handle_event(sf::Event::KeyReleased{.code=sf::Keyboard::Key::LControl},size);
|
||||
const auto& edited=*builder.document().find(route_id);check(edited.points.size()==3,"Ctrl-click did not insert a pipe bend");
|
||||
check(std::abs(machine_pipe_length(edited)-machine_pipe_length(route_part))<1e-6f&&std::abs(machine_part_volume(edited)-machine_part_volume(route_part))<1e-8f,"Inserting a bend changed pipe length or volume");
|
||||
check(edited.point_diameters.size()==3&&std::abs(edited.point_diameters[1]-.038f)<.0002f,"Inserted bend lost the original interpolated diameter");
|
||||
// The selected point's diameter field changes only that point, including undo.
|
||||
click(WorkshopLayout::calculate(size).field_value(6).position+sf::Vector2f{16,10});for(char c:std::string("90"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);
|
||||
check(std::abs(builder.document().find(route_id)->point_diameters[1]-.09f)<1e-6f&&builder.document().find(route_id)->point_diameters.front()==.02f&&builder.document().find(route_id)->point_diameters.back()==.08f,"Point diameter edit changed the wrong points");
|
||||
key(sf::Keyboard::Key::Z,true);check(std::abs(builder.document().find(route_id)->point_diameters[1]-.038f)<.0002f,"Undo did not restore the point diameter");
|
||||
key(sf::Keyboard::Key::Z,true);check(builder.document().find(route_id)->points==route_part.points,"Undo did not restore the routed pipe");
|
||||
key(sf::Keyboard::Key::Y,true);check(builder.document().find(route_id)->points.size()==3,"Redo did not restore the inserted bend");
|
||||
// Checks select and focus the problem part without changing the document.
|
||||
MachineDocument dry;const auto dry_cylinder=dry.add(MachinePartKind::Cylinder,{.6f,.3f,0}).id;dry.add(MachinePartKind::Pipe,{-.6f,.3f,0});
|
||||
builder.set_document(dry);key(sf::Keyboard::Key::H);click({float(size.x)-280,235});
|
||||
check(builder.selected_parts()==std::vector<std::uint32_t>{dry_cylinder},"Clicking a check did not select the affected cylinder");
|
||||
check(length(subtract(builder.camera().target,dry.find(dry_cylinder)->position))<1e-5f,"Clicking a check did not focus the affected part");
|
||||
check(builder.document().links.empty()&&builder.document().parts.size()==dry.parts.size(),"Checks changed the machine");
|
||||
key(sf::Keyboard::Key::Home);const float whole_distance=builder.camera().distance;key(sf::Keyboard::Key::F);
|
||||
check(builder.camera().distance<whole_distance,"F did not focus the selected part");
|
||||
builder.set_document(doc);
|
||||
// Test mode compiles the design without modifying any geometry and preserves the camera.
|
||||
const auto camera=builder.camera();builder.start_test();check(builder.testing(),"Test did not start");builder.update();builder.stop_test();check(!builder.testing()&&builder.camera().target==camera.target,"Build/Test lost the camera");check(builder.document().parts.size()==doc.parts.size(),"Test changed the design");
|
||||
auto instrumented=doc;instrumented.probes={{1,tank_id,"Tank pressure",MachineQuantity::Pressure},{2,0,"Shaft speed",MachineQuantity::Rpm}};builder.set_document(instrumented);builder.start_test();builder.pause_test();const auto paused_time=builder.playback_stats().simulation_seconds;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(30));builder.update();check(builder.playback_stats().paused&&builder.playback_stats().simulation_seconds==paused_time,"Pause allowed physics to advance in the background");
|
||||
builder.set_test_speed(.1);check(builder.playback_stats().speed==.1&&builder.playback_stats().simulation_seconds==paused_time,"Changing speed while paused advanced the simulation");builder.step_test();
|
||||
check(std::abs(builder.playback_stats().simulation_seconds-paused_time-.01)<1e-7,"Single step did not advance exactly 10 ms of simulation time");check(!builder.measurements().empty()&&builder.measurements().back().count==2,"Instruments did not record simulation-time samples");
|
||||
builder.pause_test();const auto slow_start=builder.playback_stats().simulation_seconds;std::this_thread::sleep_for(std::chrono::milliseconds(150));builder.update();const auto slow_elapsed=builder.playback_stats().simulation_seconds-slow_start;check(slow_elapsed>.003&&slow_elapsed<.1,"Slow playback was stalled by old queued audio or ran at full speed");builder.pause_test();
|
||||
const auto ui=WorkshopLayout::calculate(size);const auto motor_id=std::find_if(instrumented.parts.begin(),instrumented.parts.end(),[](const auto& p){return p.kind==MachinePartKind::DriveMotor;})->id;
|
||||
click({160,120});key(sf::Keyboard::Key::F,true);for(char c:std::string("Bench speed drive"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);click({90,242});
|
||||
check(builder.selected_parts()==std::vector<std::uint32_t>{motor_id},"Objects list did not select the drive motor");click(ui.field_value(0).position+sf::Vector2f{16,10});for(char c:std::string("1200"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);
|
||||
check(std::abs(builder.document().find(motor_id)->drive.target_speed*60/(2*pi)-1200)<.01f,"Drive RPM could not be tuned live");const auto before_tuning_step=builder.playback_stats().simulation_seconds;builder.step_test();check(std::abs(builder.playback_stats().simulation_seconds-before_tuning_step-.01)<1e-7&&std::abs(builder.measurements().back().values[1]-1200)<.01f,"Live tuning reset time or failed to reach the running shaft");
|
||||
const auto cells=builder.document().cells_per_meter;click({ui.inspector_x+12+2.5f*(ui.inspector_width-34)/6+4,148});click(ui.field_value(0).position+sf::Vector2f{16,10});for(char c:std::string("80"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);check(builder.document().cells_per_meter==cells,"A rebuild-only property was editable during Test");
|
||||
key(sf::Keyboard::Key::Z,true);check(builder.document().find(motor_id)->drive.target_speed==instrumented.find(motor_id)->drive.target_speed,"Live tuning undo failed");key(sf::Keyboard::Key::Y,true);check(std::abs(builder.document().find(motor_id)->drive.target_speed*60/(2*pi)-1200)<.01f,"Live tuning redo failed");
|
||||
click(*builder_project(builder.camera(),builder.workspace_viewport(size),builder.document().find(tank_id)->position));const auto before_probe=builder.playback_stats().simulation_seconds;click({ui.inspector_x+ui.inspector_width*.5f,210});check(builder.document().probes.size()==3&&builder.playback_stats().simulation_seconds==before_probe,"Adding a temperature probe live reloaded physics");builder.step_test();check(builder.measurements().back().count==3,"Live instrument setup mixed trace schemas");
|
||||
click(*builder_project(builder.camera(),builder.workspace_viewport(size),builder.document().find(tank_id)->position)+sf::Vector2f{18,-18});click({ui.inspector_x+ui.inspector_width*.75f,484});
|
||||
check(builder.document().probes.size()==2&&std::none_of(builder.document().probes.begin(),builder.document().probes.end(),[](const auto& p){return p.quantity==MachineQuantity::Temperature;}),"Overlapping probe markers could not be selected independently");
|
||||
const auto csv=std::filesystem::temp_directory_path()/("fluidsim-probe-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".csv");check(builder.export_measurements(csv),"Instrument CSV export failed");{std::ifstream input(csv);std::string header;std::getline(input,header);check(header.find("time_s")!=std::string::npos&&header.find("Tank pressure [kPa abs]")!=std::string::npos,"CSV lost time or channel units");}std::filesystem::remove(csv);
|
||||
builder.stop_test();check(std::abs(builder.document().find(motor_id)->drive.target_speed*60/(2*pi)-1200)<.01f&&!builder.measurements().empty(),"Returning to Build lost live calibration or captured data");
|
||||
{
|
||||
MachineBuilder sensing;MachineDocument tapped;const auto pipe=tapped.add(MachinePartKind::Pipe,{}).id;tapped.add(MachinePartKind::Shaft,{.5f,0,0});sensing.set_document(tapped);
|
||||
const auto click_sensor=[&](sf::Vector2f at){const sf::Vector2i point{int(std::round(at.x)),int(std::round(at.y))};sensing.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,point},size);sensing.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,point},size);};
|
||||
const auto sensor_key=[&](sf::Keyboard::Key code,bool control=false){sensing.handle_event(sf::Event::KeyPressed{.code=code,.control=control},size);};
|
||||
const auto at=builder_project(sensing.camera(),sensing.workspace_viewport(size),machine_world_point(*tapped.find(pipe),{.09f,0,0}));check(bool(at),"Sensor tap is outside the camera");
|
||||
sensing.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Right,{int(std::round(at->x)),int(std::round(at->y))}},size);
|
||||
const auto menu_x=std::clamp(at->x,sensing.workspace_viewport(size).position.x,float(size.x)-210),menu_y=std::clamp(at->y,92.f,float(size.y)-420);
|
||||
click_sensor({menu_x+90,menu_y+311});click_sensor({ui.inspector_x+35,210});check(sensing.document().sensors.size()==1,"Add sensor from the pipe context menu failed");
|
||||
const auto sensor_id=sensing.document().sensors[0].id;check(sensing.document().sensors[0].target.part==pipe&&std::abs(sensing.document().sensors[0].target.position-.8f)<.02f,"Context sensor did not use the clicked pipe arclength");
|
||||
sensor_key(sf::Keyboard::Key::Z,true);check(sensing.document().sensors.empty(),"Sensor creation did not enter undo history");sensor_key(sf::Keyboard::Key::Y,true);check(sensing.document().sensors[0].id==sensor_id,"Sensor redo changed stable identity");
|
||||
sensing.start_test();check(sensing.testing(),"Instrumented physical machine did not start");sensing.pause_test();const auto time=sensing.playback_stats().simulation_seconds;
|
||||
click_sensor({ui.inspector_x+35,210});click_sensor({ui.inspector_x+ui.inspector_width-48,480});check(sensing.document().sensors.size()==1&&sensing.playback_stats().simulation_seconds==time,"Test permitted sensor topology edits or advanced time");sensing.step_test();sensing.stop_test();
|
||||
sensor_key(sf::Keyboard::Key::Delete);check(sensing.document().find(pipe)==nullptr&&sensing.document().find_sensor(sensor_id)&&sensing.document().find_sensor(sensor_id)->target.part==0,"Deleting a host removed its sensor instead of exposing an unresolved binding");
|
||||
sensor_key(sf::Keyboard::Key::H);click_sensor({ui.inspector_x+80,240});click_sensor({ui.inspector_x+ui.inspector_width-48,480});check(sensing.document().sensors.empty(),"Unresolved-sensor diagnostic did not navigate to its editable attachment");
|
||||
}
|
||||
{
|
||||
MachineBuilder logic;auto design=machine_logic_example();const auto master=std::find_if(design.logic.nodes.begin(),design.logic.nodes.end(),[](const auto& n){return n.kind==LogicNodeKind::Switch;})->id;
|
||||
const auto button=design.logic.add(LogicNodeKind::Button,{600,600}).id,lamp=design.logic.add(LogicNodeKind::Lamp,{900,600}).id;design.logic.find(button)->name="Held test button";design.logic.connect({button,"out"},{lamp,"in"});logic.set_document(design);
|
||||
const auto logic_click=[&](sf::Vector2f at){const sf::Vector2i point{int(std::round(at.x)),int(std::round(at.y))};logic.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,point},size);logic.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,point},size);};
|
||||
const auto logic_key=[&](sf::Keyboard::Key k,bool control=false){logic.handle_event(sf::Event::KeyPressed{.code=k,.control=control},size);};
|
||||
const auto output=[&](std::uint32_t id){for(const auto& n:logic.logic_values())if(n.id==id)return n.values.back();throw std::runtime_error("Missing shared live graph values");};
|
||||
logic_click({float(size.x)-121,68});check(logic.workspace_view()==WorkshopView::Logic,"Logic toolbar tab did not open the graph workspace");const auto original_camera=logic.camera();logic_key(sf::Keyboard::Key::Home);
|
||||
const auto master_rect=logic.logic_node_rectangle(master,size);logic_click(master_rect.position+sf::Vector2f{20,10});logic_click({ui.inspector_x+40,148});for(char c:std::string("Changed master"))logic.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);logic_key(sf::Keyboard::Key::Enter);check(logic.document().logic.find(master)->name=="Changed master","Logic name field did not apply");logic_key(sf::Keyboard::Key::Z,true);
|
||||
const auto speed_node=std::find_if(design.logic.nodes.begin(),design.logic.nodes.end(),[](const auto& n){return n.kind==LogicNodeKind::Slider;})->id,gate_node=std::find_if(design.logic.nodes.begin(),design.logic.nodes.end(),[](const auto& n){return n.kind==LogicNodeKind::And;})->id;const auto old_links=logic.document().logic.links;logic_click(logic.logic_pin_position(speed_node,"out",size));logic_click(logic.logic_pin_position(gate_node,"in1",size));check(logic.document().logic.links==old_links,"Rejected graph edit changed connections");logic_key(sf::Keyboard::Key::Escape);logic_key(sf::Keyboard::Key::Y,true);check(logic.document().logic.find(master)->name=="Changed master","A rejected graph edit destroyed redo history");for(const auto& p:design.parts)if(p.kind==MachinePartKind::Pipe)check(logic.document().find(p.id)->points==p.points,"Logic edits changed physical pipe geometry");
|
||||
const auto body=logic.logic_node_rectangle(design.logic.nodes.front().id,size);logic_click(body.position+sf::Vector2f{20,40});check(logic.selected_parts()==std::vector<std::uint32_t>{design.sensors[0].target.part},"Graph sensor selection did not share its physical host selection");
|
||||
logic.start_test();logic.pause_test();const auto paused=logic.playback_stats().simulation_seconds;const auto control=logic.logic_node_rectangle(master,size);const float scale=control.size.x/240;logic_click({control.position.x+control.size.x*.5f,control.position.y+control.size.y-26*scale});logic.step_test();check(!std::get<bool>(output(master).value)&&logic.document().logic.find(master)->boolean,"Live graph control changed its default or did not reach physics");
|
||||
const auto after_step=logic.playback_stats().simulation_seconds;logic.set_workspace_view(WorkshopView::Split);check(logic.playback_stats().simulation_seconds==after_step&&logic.testing(),"Switching to split view restarted or advanced paused physics");const auto physical=logic.workspace_viewport(size),graph=logic.logic_viewport(size);check(physical.position.x+physical.size.x<graph.position.x,"Split 3D and Logic viewports overlap");check(logic.camera().yaw==original_camera.yaw&&logic.camera().pitch==original_camera.pitch,"Switching workspaces changed the camera");
|
||||
logic.set_workspace_view(WorkshopView::Logic);logic_click({160,120});logic_key(sf::Keyboard::Key::F,true);for(char c:std::string("Held test button"))logic.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);logic_key(sf::Keyboard::Key::Enter);logic_click({80,244});logic_key(sf::Keyboard::Key::F);check(logic.selected_logic_node()==button,"Logic Objects did not select its saved node");const auto held=logic.logic_node_rectangle(button,size);const float zoom=held.size.x/240;const sf::Vector2i hold{int(held.position.x+held.size.x*.5f),int(held.position.y+held.size.y-26*zoom)};logic.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,hold},size);logic.step_test();check(std::get<bool>(output(button).value),"Holding a graph button did not survive physics stepping");logic.set_workspace_view(WorkshopView::ThreeD);logic.step_test();check(!std::get<bool>(output(button).value),"Closing Logic left its button pressed");check(logic.playback_stats().simulation_seconds>paused,"Graph/physics steps did not share simulation time");logic.stop_test();
|
||||
}
|
||||
// Layout rows, footer actions and physical viewport have disjoint bounds.
|
||||
for(const sf::Vector2u resolution:{sf::Vector2u{1280,720},sf::Vector2u{1920,1080}})for(float left:{220.f,420.f})for(float right:{280.f,520.f}){
|
||||
const auto layout=WorkshopLayout::calculate(resolution,left,right);
|
||||
check(layout.viewport.position.x>=layout.left.position.x+layout.left.size.x+8,"Library overlaps physical viewport");
|
||||
check(layout.viewport.position.x+layout.viewport.size.x<=layout.right.position.x-8,"Inspector overlaps physical viewport");
|
||||
check(layout.field_row(layout.field_rows()-1).position.y+48<=float(resolution.y)-164,"Fields overlap inspector footer");
|
||||
check(layout.library.position.y+layout.library_rows()*36<=float(resolution.y)-238,"Library rows overlap the preview");
|
||||
}
|
||||
{
|
||||
MachineBuilder responsive;responsive.set_document(doc);const auto before=responsive.workspace_viewport(size);const auto layout=WorkshopLayout::calculate(size);
|
||||
const sf::Vector2i from{int(layout.inspector_x-4),420},to{from.x-100,420};
|
||||
responsive.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,from},size);responsive.handle_event(sf::Event::MouseMoved{to},size);responsive.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,to},size);
|
||||
check(responsive.workspace_viewport(size).size.x<before.size.x-90,"Resizing inspector did not update viewport geometry");
|
||||
MachineBuilder restored;restored.restore_workspace_preferences();check(restored.workspace_viewport(size)==responsive.workspace_viewport(size),"Panel widths did not restore");
|
||||
responsive.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,{160,68}},size);responsive.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,{160,68}},size);
|
||||
check(responsive.workspace_viewport(size).size.x>before.size.x,"Collapsing inspector did not return viewport space");
|
||||
}
|
||||
// Search captures typing and routes a filtered library selection to placement.
|
||||
builder.set_document({});click({60,120});key(sf::Keyboard::Key::F,true);for(char c:std::string("Throttle"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);click({90,242});click({650,450});
|
||||
check(builder.document().parts.size()==1&&builder.document().parts.front().kind==MachinePartKind::Throttle,"Filtered library did not place the searched part");
|
||||
// Ports can be dragged directly without entering the optional Ports tool.
|
||||
builder.set_document(ports);key(sf::Keyboard::Key::Q);click(screen_part(free_atmosphere_id));
|
||||
const auto source=screen_part(free_atmosphere_id),target=*builder_project(builder.camera(),builder.workspace_viewport(size),machine_port_position(*ports.find(pipe_id),0));
|
||||
builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,{int(source.x),int(source.y)}},size);builder.handle_event(sf::Event::MouseMoved{{int(target.x),int(target.y)}},size);builder.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,{int(target.x),int(target.y)}},size);
|
||||
check(builder.document().links.size()==ports.links.size()+1,"Direct port dragging failed");validate_machine(builder.document());
|
||||
builder.set_document(doc);
|
||||
if(argc==3&&std::string(argv[1])=="--preview"){
|
||||
const std::filesystem::path output=argv[2];std::filesystem::create_directories(output);sf::ContextSettings context;context.depthBits=24;context.majorVersion=2;context.minorVersion=1;
|
||||
sf::RenderWindow window(sf::VideoMode(size),"Machine builder preview",sf::Style::Default,sf::State::Windowed,context);window.setVisible(false);sf::Font font("C:/Windows/Fonts/consola.ttf");
|
||||
const auto capture=[&](const char* file){builder.draw(window,&font);sf::Texture image(size);image.update(window);check(image.copyToImage().saveToFile(output/file),"Screenshot save failed");};
|
||||
{
|
||||
const auto* old_directory=std::getenv("FLUIDSIM_MACHINE_DIR");const std::string previous_directory=old_directory?old_directory:"";machine_test_directory(std::filesystem::absolute(output/"assembly-library").string());
|
||||
builder.set_document(machine_pump_example());key(sf::Keyboard::Key::A,true);capture("assembly-selection.png");
|
||||
const auto point=builder_project(builder.camera(),builder.workspace_viewport(size),builder.document().parts.front().position);check(bool(point),"Assembly preview is outside camera");
|
||||
builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Right,{int(point->x),int(point->y)}},size);capture("assembly-context.png");key(sf::Keyboard::Key::Escape);
|
||||
click({450,26});const float mx=float(size.x)*.5f-280,my=float(size.y)*.5f-185;click({mx+450,my+30});
|
||||
for(char c:std::string("Vacuum pump module"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);capture("assembly-save.png");key(sf::Keyboard::Key::Enter);
|
||||
click({450,26});capture("assembly-library.png");click({mx+480,my+93});capture("assembly-inserted.png");
|
||||
key(sf::Keyboard::Key::C,true);key(sf::Keyboard::Key::V,true);capture("assembly-pasted.png");machine_test_directory(previous_directory);
|
||||
}
|
||||
MachineRenderer3D cached;MachineDocument visual;auto& visual_pipe=visual.add(MachinePartKind::Pipe);const auto visual_id=visual_pipe.id;visual_pipe.points=bulge.points;visual_pipe.point_diameters=bulge.point_diameters;OrbitCamera view;
|
||||
cached.draw(window,visual,nullptr,viewport,view,0);const auto initial_builds=cached.stats().pipe_mesh_builds;check(initial_builds==1,"First pipe mesh was not cached");
|
||||
const auto check_overlay=[&]{sf::RectangleShape marker({24,24});marker.setPosition({8,8});marker.setFillColor(sf::Color::Magenta);window.draw(marker);sf::Texture pixels(size);pixels.update(window);check(pixels.copyToImage().getPixel({16,16})==sf::Color::Magenta,"Raw 3D rendering left SFML overlays invisible");};check_overlay();
|
||||
view.orbit({12,8});visual.parts[0].position.x+=.03f;visual.parts[0].rotation.z=15;
|
||||
cached.draw(window,visual,nullptr,viewport,view,visual_id);check(cached.stats().pipe_mesh_builds==initial_builds,"Camera, transform or selection rebuilt local pipe geometry");
|
||||
MachineSnapshot colored;MachinePartState ps;ps.id=visual_id;ps.pipe_pressures={95000,105000};colored.parts.push_back(ps);
|
||||
cached.draw(window,visual,&colored,viewport,view,0);check(cached.stats().pipe_mesh_builds==initial_builds,"Pressure colors rebuilt the pipe mesh");
|
||||
visual.parts[0].points.back().y+=.05f;cached.draw(window,visual,nullptr,viewport,view,0);check(cached.stats().pipe_mesh_builds==initial_builds+1,"Path edit failed to invalidate the mesh");
|
||||
visual.parts[0].point_diameters[1]+=.005f;cached.draw(window,visual,nullptr,viewport,view,0);check(cached.stats().pipe_mesh_builds==initial_builds+2,"Interior diameter edit failed to invalidate the mesh");
|
||||
cached.draw_icon(window,MachinePartKind::Pipe,{{0,0},{35,26}});cached.draw_icon(window,MachinePartKind::Cylinder,{{40,0},{35,26}});check(cached.stats().icon_atlas_builds==1,"Part icons rebuilt their atlas");
|
||||
check_overlay();
|
||||
visual.parts.clear();cached.draw(window,visual,nullptr,viewport,view,0);check(cached.stats().cached_pipes==0,"Deleted geometry remained cached");
|
||||
visual.next_id=visual_id;visual.add(MachinePartKind::Pipe).points={{0,0,0},{0,.2f,0}};cached.draw(window,visual,nullptr,viewport,view,0);check(cached.stats().pipe_mesh_builds==initial_builds+3,"Reused document IDs retained stale geometry");
|
||||
builder.set_document({});capture("empty.png");builder.set_document(machine_pump_example());click(*builder_project(builder.camera(),builder.workspace_viewport(size),builder.document().parts.front().position));capture("pump-build.png");
|
||||
key(sf::Keyboard::Key::C);capture("connections.png");builder.start_test();builder.update();capture("pump-test.png");builder.stop_test();builder.set_document(machine_engine_example());capture("engine-build.png");
|
||||
key(sf::Keyboard::Key::I);capture("import-menu.png");key(sf::Keyboard::Key::Escape);
|
||||
builder.set_document(machine_from_engine(scenario_example("v8-h")));key(sf::Keyboard::Key::A,true);capture("v8-import.png");
|
||||
builder.set_document(machine_from_engine(scenario_example("single-450")));capture("single-import.png");
|
||||
for(auto& p:builder.document().parts)if(p.kind==MachinePartKind::Ecu){key(sf::Keyboard::Key::Q);click(*builder_project(builder.camera(),builder.workspace_viewport(size),p.position));break;}capture("ecu-settings.png");
|
||||
builder.set_document(dry);key(sf::Keyboard::Key::H);capture("checks-missing-fuel.png");
|
||||
auto feedback=ports;const auto feedback_ecu=feedback.add(MachinePartKind::Ecu,{.3f,.5f,0}).id;builder.set_document(feedback);
|
||||
key(sf::Keyboard::Key::C);click(screen_part(free_atmosphere_id));
|
||||
const auto hover=[&](MachinePort port){const auto point=builder_project(builder.camera(),builder.workspace_viewport(size),machine_port_position(*builder.document().find(port.part),port.port));check(bool(point),"Preview target is outside camera");builder.handle_event(sf::Event::MouseMoved{{int(std::round(point->x)),int(std::round(point->y))}},size);};
|
||||
hover({pipe_id,0});capture("connect-air-ready.png");hover({feedback_ecu,0});capture("connect-incompatible.png");
|
||||
builder.set_document(route);key(sf::Keyboard::Key::Q);click(*builder_project(builder.camera(),builder.workspace_viewport(size),machine_world_point(route_part,local)));key(sf::Keyboard::Key::P);
|
||||
key(sf::Keyboard::Key::LControl);click(*builder_project(builder.camera(),builder.workspace_viewport(size),machine_world_point(route_part,local)));builder.handle_event(sf::Event::KeyReleased{.code=sf::Keyboard::Key::LControl},size);capture("inserted-bend.png");
|
||||
MachineDocument sculpt;sculpt.add(MachinePartKind::Pipe)=bulge;sculpt.parts[0].id=1;sculpt.parts[0].name="Per-point diameter pipe";builder.set_document(sculpt);key(sf::Keyboard::Key::Q);click(*builder_project(builder.camera(),builder.workspace_viewport(size),Vec3{}));key(sf::Keyboard::Key::P);click(*builder_project(builder.camera(),builder.workspace_viewport(size),Vec3{}));capture("point-diameters.png");
|
||||
auto gauges=machine_from_engine(scenario_example("single-450"));machine_test::drive(gauges,3000);unsigned cylinder=0,downpipe=0,carb=0;
|
||||
for(const auto& p:gauges.parts){if(p.kind==MachinePartKind::Cylinder)cylinder=p.id;if(p.kind==MachinePartKind::Carburetor)carb=p.id;if(p.name=="Exhaust atmosphere")for(const auto& link:gauges.links){const auto other=link.a.part==p.id?link.b.part:link.b.part==p.id?link.a.part:0;if(const auto part=gauges.find(other);part&&part->kind==MachinePartKind::Pipe)downpipe=other;}}
|
||||
gauges.probes={{1,cylinder,"Cylinder pressure",MachineQuantity::Pressure},{2,downpipe,"Downpipe lambda",MachineQuantity::Lambda},{3,cylinder,"Cylinder fresh charge",MachineQuantity::ChargeAfr}};
|
||||
builder.set_document(gauges);builder.set_test_speed(.1);builder.start_test();builder.pause_test();for(int i=0;i<100;++i)builder.step_test();key(sf::Keyboard::Key::M);capture("instruments-pressure.png");
|
||||
click({float(size.x)-50,318});capture("instruments-lambda.png");
|
||||
const auto center=builder_project(builder.camera(),builder.workspace_viewport(size),gauges.find(carb)->position);check(bool(center),"Carburetor is outside camera");bool selected_carb=false;
|
||||
for(int dy:{0,-8,8,-16,16}){for(int dx:{0,-8,8,-16,16}){click(*center+sf::Vector2f{float(dx),float(dy)});if(builder.selected_parts()==std::vector<std::uint32_t>{carb}){selected_carb=true;break;}}if(selected_carb)break;}
|
||||
check(selected_carb,"Could not select the carburetor for live tuning");click({ui.inspector_x+40,148});capture("live-carb-tuning.png");builder.stop_test();
|
||||
}
|
||||
std::cout<<"Builder selection, transforms, connected copy/paste, assembly library, undo/redo, explicit exit and mode switching passed\n";return 0;
|
||||
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}
|
||||
|
||||
70
tests/machine_logic_tests.cpp
Normal file
70
tests/machine_logic_tests.cpp
Normal file
@@ -0,0 +1,70 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace fluidsim::port;
|
||||
namespace {
|
||||
void check(bool value,const char* why){if(!value)throw std::runtime_error(why);}
|
||||
bool close(double a,double b,double tolerance=1e-5){return std::abs(a-b)<=tolerance*std::max({1.,std::abs(a),std::abs(b)});}
|
||||
template<class F>void rejects(F&& f,const char* why){bool caught=false;try{f();}catch(const std::exception&){caught=true;}check(caught,why);}
|
||||
void run(MachineSimulation& sim,int count){for(int i=0;i<count;++i){const auto a=sim.process();check(std::isfinite(a.left)&&std::isfinite(a.right),"Logic-controlled machine generated invalid audio");}}
|
||||
SignalValue value(const MachineSnapshot& snapshot,const MachineDocument& doc,std::uint32_t id,const char* key){const auto ports=logic_node_ports(*doc.logic.find(id));for(const auto& n:snapshot.logic)if(n.id==id)for(std::size_t p=0;p<ports.size();++p)if(ports[p].key==key)return n.values[p];throw std::runtime_error("Missing live logic port");}
|
||||
const MachinePartState& part(const MachineSnapshot& snapshot,std::uint32_t id){for(const auto& p:snapshot.parts)if(p.id==id)return p;throw std::runtime_error("Missing physical part");}
|
||||
std::uint32_t node(const MachineDocument& d,LogicNodeKind kind){for(const auto& n:d.logic.nodes)if(n.kind==kind)return n.id;throw std::runtime_error("Missing logic node kind");}
|
||||
}
|
||||
int main(int argc,char** argv)try{
|
||||
MachineDocument controlled;const auto shaft=controlled.add(MachinePartKind::Shaft).id,motor=controlled.add(MachinePartKind::DriveMotor).id;controlled.find(shaft)->shaft.inertia=1;controlled.find(motor)->drive={0,10,false,true};controlled.connect({shaft,0},{motor,0});
|
||||
const auto enable=controlled.logic.add(LogicNodeKind::Command).id;controlled.logic.find(enable)->part=motor;
|
||||
{MachineSimulation inactive(controlled,1000);check(!part(inactive.snapshot(),motor).actuator_enabled,"Signal-owned enable started in its manual state before the first sample");run(inactive,10);check(part(inactive.snapshot(),shaft).shaft_speed==0,"Unwired optional command used the manual enabled state instead of inactive default");}
|
||||
const auto toggle=controlled.logic.add(LogicNodeKind::Switch).id;controlled.logic.connect({toggle,"out"},{enable,"in"});
|
||||
const auto target=controlled.logic.add(LogicNodeKind::Command).id;controlled.logic.find(target)->part=motor;controlled.logic.find(target)->command=LogicCommand::MotorTorque;
|
||||
const auto amount=controlled.logic.add(LogicNodeKind::NumberConstant).id;controlled.logic.find(amount)->quantity=SignalQuantity::Torque;controlled.logic.find(amount)->value=3;controlled.logic.connect({amount,"out"},{target,"in"});
|
||||
MachineSimulation sim(controlled,1000);sim.set_logic_control(toggle,true);run(sim,10);check(close(part(sim.snapshot(),shaft).shaft_speed,.03),"Signal enable / torque target did not own the declared drive fields");
|
||||
sim.set_logic_control(toggle,false);run(sim,10);check(close(part(sim.snapshot(),shaft).shaft_speed,.03)&&part(sim.snapshot(),motor).torque==0,"Disabling a signal-owned motor continued to add drive work");
|
||||
auto tuned=controlled;tuned.logic.find(amount)->value=6;const auto time=sim.snapshot().time;sim.apply_tuning(tuned);check(sim.snapshot().time==time,"Constant tuning reset simulation time");sim.set_logic_control(toggle,true);run(sim,10);check(close(part(sim.snapshot(),shaft).shaft_speed,.09),"Live constant tuning did not update latched drive commands");
|
||||
auto retyped=tuned;retyped.logic.find(amount)->quantity=SignalQuantity::Power;rejects([&]{sim.apply_tuning(retyped);},"A live quantity edit changed compiled pin meaning");
|
||||
auto duplicate=controlled;const auto second=duplicate.logic.add(LogicNodeKind::Command).id;duplicate.logic.find(second)->part=motor;rejects([&]{validate_machine(duplicate);},"Two signal owners claimed the same motor field");
|
||||
{
|
||||
auto bad=controlled;bad.logic.disconnect({target,"in"});const auto divider=bad.logic.add(LogicNodeKind::Divide).id;bad.logic.find(divider)->quantity=SignalQuantity::Torque;const auto zero=bad.logic.add(LogicNodeKind::NumberConstant).id;bad.logic.connect({amount,"out"},{divider,"a"});bad.logic.connect({zero,"out"},{divider,"b"});bad.logic.connect({divider,"out"},{target,"in"});bad.logic.find(toggle)->boolean=true;MachineSimulation fault(bad,1000);run(fault,20);check(part(fault.snapshot(),shaft).shaft_speed==0&&value(fault.snapshot(),bad,target,"in").validity==SignalValidity::Fault,"Invalid numeric command drove the machine or hid its fault");
|
||||
}
|
||||
{
|
||||
auto engine=machine_engine_example();std::uint32_t throttle=0;for(const auto& p:engine.parts)if(p.kind==MachinePartKind::Throttle)throttle=p.id;const auto owner=engine.logic.add(LogicNodeKind::Command).id;engine.logic.find(owner)->command=LogicCommand::ThrottleOpening;engine.logic.find(owner)->part=throttle;rejects([&]{MachineSimulation conflicting(engine);},"Signal and legacy ECU simultaneously owned throttle opening");
|
||||
}
|
||||
{
|
||||
auto engine=machine_engine_example();const auto crank=machine_primary_shaft(engine),drive=engine.add(MachinePartKind::DriveMotor).id;engine.connect({crank,0},{drive,0});const auto command=engine.logic.add(LogicNodeKind::Command).id,speed=engine.logic.add(LogicNodeKind::NumberConstant).id;engine.logic.find(command)->part=drive;engine.logic.find(command)->command=LogicCommand::MotorSpeed;engine.logic.find(speed)->quantity=SignalQuantity::AngularSpeed;engine.logic.find(speed)->value=-10;engine.logic.connect({speed,"out"},{command,"in"});check(logic_node_ports(*engine.logic.find(command),&engine)[0].minimum==0,"Engine command metadata does not expose its forward-only range");MachineSimulation forward(engine);run(forward,16);check(value(forward.snapshot(),engine,command,"in").validity==SignalValidity::Fault&&part(forward.snapshot(),drive).command_target==0,"Negative speed on a forward-only engine stopped Test instead of applying its inactive fault fallback");engine.logic.find(speed)->value=20;forward.apply_tuning(engine);run(forward,16);check(close(part(forward.snapshot(),crank).shaft_speed,20),"A recovered speed command remained faulted");
|
||||
}
|
||||
{
|
||||
MachineDocument valve;const auto duct=valve.add(MachinePartKind::Pipe).id,body=valve.add(MachinePartKind::Throttle).id;valve.connect({duct,1},{body,0});const auto tps=valve.add_sensor(MachineSensorKind::ThrottlePosition,body).id;
|
||||
const auto opening=valve.logic.add(LogicNodeKind::Command).id;valve.logic.find(opening)->command=LogicCommand::ThrottleOpening;valve.logic.find(opening)->part=body;const auto slider=valve.logic.add(LogicNodeKind::Slider).id;valve.logic.find(slider)->quantity=SignalQuantity::Fraction;valve.logic.connect({slider,"out"},{opening,"in"});MachineSimulation commanded(valve);commanded.set_controls(.95f);commanded.set_logic_control(slider,.4);run(commanded,1);check(close(std::get<double>(commanded.sense(tps).outputs[0].value),.4),"TPS or valve ignored signal ownership and followed the pedal");
|
||||
}
|
||||
{
|
||||
MachineDocument pure;const auto button=pure.logic.add(LogicNodeKind::Button).id,lamp=pure.logic.add(LogicNodeKind::Lamp).id;pure.logic.connect({button,"out"},{lamp,"in"});MachineSimulation logic_only(pure,1000);logic_only.set_logic_control(button,true);logic_only.set_logic_control(button,false);run(logic_only,1);check(std::get<bool>(value(logic_only.snapshot(),pure,lamp,"in").value),"A short momentary press vanished before a physics boundary");run(logic_only,1);check(!std::get<bool>(value(logic_only.snapshot(),pure,lamp,"in").value),"A short momentary press remained true");check(logic_only.snapshot().cells==0&&logic_only.snapshot().parts.empty(),"Logic-only test invented physical gas parts");
|
||||
}
|
||||
{
|
||||
MachineDocument start;const auto crank=start.add(MachinePartKind::Shaft).id;start.find(crank)->shaft.inertia=1;start.find(crank)->shaft.starter_torque=5;const auto command=start.logic.add(LogicNodeKind::Command).id,button=start.logic.add(LogicNodeKind::BooleanConstant).id;start.logic.find(command)->part=crank;start.logic.find(command)->command=LogicCommand::Starter;start.logic.find(button)->boolean=true;start.logic.connect({button,"out"},{command,"in"});MachineSimulation starter(start,1000);run(starter,100);check(close(part(starter.snapshot(),crank).shaft_speed,.5),"Starter command did not supply its declared shaft torque");start.logic.find(button)->boolean=false;starter.apply_tuning(start);starter.set_controls(0,true);run(starter,100);check(close(part(starter.snapshot(),crank).shaft_speed,.5),"Keyboard starter competed with signal ownership");
|
||||
}
|
||||
{
|
||||
MachineDocument braking;const auto crank=braking.add(MachinePartKind::Shaft).id,drive=braking.add(MachinePartKind::DriveMotor).id,brake=braking.add(MachinePartKind::Brake).id;braking.find(crank)->shaft.inertia=1;braking.find(drive)->drive={0,4,false,true};braking.find(brake)->brake.constant=5;braking.connect({crank,0},{drive,0});braking.connect({crank,0},{brake,0});const auto command=braking.logic.add(LogicNodeKind::Command).id,toggle=braking.logic.add(LogicNodeKind::Switch).id;braking.logic.find(command)->part=brake;braking.logic.find(command)->command=LogicCommand::BrakeEnable;braking.logic.find(toggle)->boolean=true;braking.logic.connect({toggle,"out"},{command,"in"});MachineSimulation load(braking,1000);run(load,100);check(part(load.snapshot(),crank).shaft_speed==0,"Signal-enabled brake drove through a static hold");load.set_logic_control(toggle,false);run(load,100);check(close(part(load.snapshot(),crank).shaft_speed,.4),"Signal disable did not release the brake");
|
||||
}
|
||||
auto bench=machine_logic_example();const auto master=node(bench,LogicNodeKind::Switch),hysteresis=node(bench,LogicNodeKind::Hysteresis),pressure=node(bench,LogicNodeKind::Sensor);std::uint32_t bench_motor=0,tank=bench.sensors[0].target.part;for(const auto& p:bench.parts)if(p.kind==MachinePartKind::DriveMotor)bench_motor=p.id;
|
||||
MachineSimulation compressor(bench);bool stopped=false;double first_stop=0;for(int i=0;i<44100;++i){compressor.process();if(i%32==0){const auto snapshot=compressor.snapshot();const auto demand=value(snapshot,bench,hysteresis,"out");if(demand.validity==SignalValidity::Valid&&!std::get<bool>(demand.value)){check(part(snapshot,tank).pressure>=149000,"Compressor threshold switched off before its upper pressure");check(std::abs(part(snapshot,bench_motor).torque)<1e-8,"Compressor drive still supplied torque after demand switched off");stopped=true;first_stop=snapshot.time;break;}}}
|
||||
check(stopped&&first_stop>0,"Pressure feedback did not stop the compressor at its upper threshold");
|
||||
{
|
||||
auto counted=bench;counted.cells_per_meter=500;const auto memory=counted.logic.add(LogicNodeKind::NumberDelay).id,sum=counted.logic.add(LogicNodeKind::Add).id,one=counted.logic.add(LogicNodeKind::NumberConstant).id;counted.logic.find(one)->value=1;counted.logic.connect({memory,"out"},{sum,"a"});counted.logic.connect({one,"out"},{sum,"b"});counted.logic.connect({sum,"out"},{memory,"in"});MachineSimulation ticks(counted);run(ticks,10);const auto snapshot=ticks.snapshot();check(snapshot.substeps>10&&std::get<double>(value(snapshot,counted,memory,"out").value)==9,"Logic advanced at fluid CFL frequency instead of the configured sample boundary");
|
||||
}
|
||||
MachineSimulation manual(bench);manual.set_logic_control(master,false);run(manual,64);check(part(manual.snapshot(),bench_motor).shaft_speed==0,"Master switch failed to inhibit automatic compressor demand");
|
||||
auto missing=bench;missing.remove_sensor(bench.logic.find(pressure)->sensor);check(missing.logic.find(pressure)->sensor==0,"Deleted sensor reference can silently match a later sensor ID");MachineSimulation safe(missing);run(safe,64);check(part(safe.snapshot(),bench_motor).shaft_speed==0&&value(safe.snapshot(),missing,pressure,"value").validity==SignalValidity::Missing,"Missing pressure sensor enabled compressor drive");
|
||||
auto removed_host=bench;removed_host.remove(tank);MachineSimulation detached_sensor(removed_host);run(detached_sensor,64);check(!part(detached_sensor.snapshot(),bench_motor).actuator_enabled,"Unresolved physical sensor host enabled the controlled motor");
|
||||
auto wrong=bench;wrong.sensors[0].kind=MachineSensorKind::GasTemperature;MachineSimulation wrong_units(wrong);run(wrong_units,64);check(part(wrong_units.snapshot(),bench_motor).shaft_speed==0,"Mismatched sensor schema fed temperature into pressure control");
|
||||
{
|
||||
std::vector<std::uint32_t> all;for(const auto& p:bench.parts)all.push_back(p.id);const auto assembly=machine_extract_assembly(bench,all,"Automatic compressor module");check(assembly.logic.nodes.size()==bench.logic.nodes.size(),"Assembly dropped a connected control harness");auto combined=bench;machine_insert_assembly(combined,assembly,{2,.25f,0});check(combined.logic.nodes.size()==2*bench.logic.nodes.size()&&combined.sensors.size()==2*bench.sensors.size(),"Copied logic or sensors were not independently remapped");
|
||||
MachineSimulation independent(combined);independent.set_logic_control(master,false);run(independent,64);check(part(independent.snapshot(),bench_motor).shaft_speed==0,"Original master switch was lost during assembly insertion");bool copy_running=false;for(const auto& p:independent.snapshot().parts)if(p.id!=bench_motor&&combined.find(p.id)->kind==MachinePartKind::DriveMotor)copy_running=p.shaft_speed>80;check(copy_running,"Copied compressor still used the original master switch or sensor/command IDs");
|
||||
const auto incomplete=machine_extract_assembly(bench,{bench_motor},"External sensor harness");check(!incomplete.logic.nodes.empty()&&!incomplete.sensors.empty()&&incomplete.sensors[0].target.part==0,"Partial assembly silently retained external physical sensor IDs");
|
||||
}
|
||||
const auto file=std::filesystem::temp_directory_path()/("fluidsim-machine-logic-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");save_machine(bench,file);const auto restored=load_machine(file);check(restored.logic==bench.logic,"Saved compressor harness changed its topology or calibration");MachineSimulation roundtrip(restored);run(roundtrip,128);check(part(roundtrip.snapshot(),bench_motor).shaft_speed>80,"Saved graph did not operate its physical drive");std::filesystem::remove(file);
|
||||
if(argc==3&&std::string(argv[1])=="--write-example")save_machine(bench,argv[2]);
|
||||
std::cout<<"Per-sample logic, explicit actuator ownership/fallbacks, pressure-controlled compression, independent copied harnesses and persistence passed\n";return 0;
|
||||
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}
|
||||
84
tests/machine_mechanics_tests.cpp
Normal file
84
tests/machine_mechanics_tests.cpp
Normal file
@@ -0,0 +1,84 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include "MachineTestControls.hpp"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace fluidsim::port;
|
||||
namespace {
|
||||
void check(bool value,const char* why){if(!value)throw std::runtime_error(why);}
|
||||
bool close(double a,double b,double tolerance=1e-5){return std::abs(a-b)<=tolerance*std::max({1.,std::abs(a),std::abs(b)});}
|
||||
template<class F>void rejects(F&& f,const char* why){bool caught=false;try{f();}catch(const std::exception&){caught=true;}check(caught,why);}
|
||||
void run(MachineSimulation& s,int samples){for(int i=0;i<samples;++i){const auto audio=s.process();check(std::isfinite(audio.left)&&std::isfinite(audio.right),"Mechanical machine generated invalid audio");}}
|
||||
const ShaftState& shaft(const MachineSnapshot& state,std::uint32_t id){for(const auto& s:state.shafts)if(s.id==id)return s;throw std::runtime_error("Missing shaft snapshot");}
|
||||
void balance(const ShaftState& s){check(close(s.initial_energy+s.external_work+s.drive_work,s.kinetic_energy+s.friction_loss+s.brake_loss,1e-8),"Compiled machine lost rotational work");}
|
||||
std::uint32_t cylinder(const MachineDocument& d){return std::find_if(d.parts.begin(),d.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Cylinder;})->id;}
|
||||
}
|
||||
int main()try{
|
||||
MachineDocument bench;bench.name="Independent mechanical bench";
|
||||
const auto a=bench.add(MachinePartKind::Shaft,{}).id,b=bench.add(MachinePartKind::Shaft,{0,0,.5f}).id;
|
||||
bench.find(a)->shaft.inertia=2;bench.find(b)->shaft.inertia=4;
|
||||
const auto wheel=bench.add(MachinePartKind::Flywheel,{.3f,0,0}).id;bench.find(wheel)->shaft.inertia=1;bench.connect({wheel,0},{a,0});
|
||||
const auto motor=bench.add(MachinePartKind::DriveMotor,{-.3f,0,0}).id;bench.find(motor)->drive={0,6,false,true};bench.connect({a,0},{motor,0});
|
||||
const auto reverse=bench.add(MachinePartKind::DriveMotor,{-.3f,0,.5f}).id;bench.find(reverse)->drive={0,-4,false,true};bench.connect({b,0},{reverse,0});
|
||||
const auto brake=bench.add(MachinePartKind::Brake,{.3f,0,.2f}).id;bench.find(brake)->brake.constant=1;bench.connect({a,0},{brake,0});
|
||||
bench.probes={{1,a,"First RPM",MachineQuantity::Rpm},{2,b,"Second RPM",MachineQuantity::Rpm},{3,motor,"Motor torque",MachineQuantity::Torque},{4,brake,"Absorbed power",MachineQuantity::Power}};
|
||||
validate_machine(bench);check(machine_part_volume(*bench.find(a))==0&&machine_part_volume(*bench.find(motor))==0,"Mechanical parts added gas storage");
|
||||
check(!machine_connection_preview(bench,{a,0},{b,0}).allowed,"Direct shaft-to-shaft rigid constraint was accepted without a coupling");
|
||||
check(!machine_connection_preview(bench,{b,0},{wheel,0}).allowed,"Flywheel was attached to two independent shafts");
|
||||
for(int rate:{1000,4000}){MachineSimulation sim(bench,rate);run(sim,rate);const auto state=sim.snapshot();check(state.cells==0&&state.shafts.size()==2,"Pure mechanical machine required a hidden gas network");
|
||||
check(close(shaft(state,a).speed,5./3)&&close(shaft(state,b).speed,-1),"Compiled shafts share state or use the wrong effective inertia");
|
||||
check(close(shaft(state,a).inertia,3),"Compiled flywheel inertia was counted incorrectly");const auto values=sim.measure();check(close(values.values[0],5./3*60/(2*pi))&&close(values.values[1],-60/(2*pi))&&values.values[2]==6&&values.values[3]>=0,"Rotational probes lost their named targets or sign conventions");balance(shaft(state,a));balance(shaft(state,b));
|
||||
auto tuned=bench;tuned.find(motor)->drive.torque=9;const auto time=state.time;sim.apply_tuning(tuned);check(sim.snapshot().time==time,"Live motor tuning restarted the simulation");run(sim,rate);check(shaft(sim.snapshot(),a).speed>4,"Live motor tuning did not affect the running shaft");
|
||||
tuned.find(wheel)->shaft.inertia=2;rejects([&]{sim.apply_tuning(tuned);},"Flywheel inertia was rebuilt during Test");
|
||||
}
|
||||
auto detached=bench;detached.disconnect({wheel,0});rejects([&]{MachineSimulation invalid(detached);},"Unattached flywheel ran silently");
|
||||
const auto detached_checks=machine_checks(detached);check(std::any_of(detached_checks.begin(),detached_checks.end(),[&](const auto& c){return c.part==wheel&&c.title=="No shaft attached";}),"Unattached flywheel lacks a navigable check");
|
||||
|
||||
auto first=machine_engine_example();machine_test::drive(first,1200);const auto first_cylinder=cylinder(first),first_shaft=machine_shaft_for(first,first_cylinder);
|
||||
auto second=machine_engine_example();machine_test::drive(second,2400);const auto second_cylinder=cylinder(second);
|
||||
std::vector<std::uint32_t> selected;for(const auto& p:second.parts)selected.push_back(p.id);const auto assembly=machine_extract_assembly(second,selected,"Second engine");
|
||||
const auto ids=machine_insert_assembly(first,assembly,{1.5f,.3f,0});std::uint32_t copied_cylinder=0,copied_motor=0;
|
||||
for(std::size_t i=0;i<assembly.parts.size();++i){if(assembly.parts[i].id==second_cylinder)copied_cylinder=ids[i];if(assembly.parts[i].kind==MachinePartKind::DriveMotor)copied_motor=ids[i];}
|
||||
const auto second_shaft=machine_shaft_for(first,copied_cylinder);check(first_shaft&&second_shaft&&first_shaft!=second_shaft,"Assembly insertion aliased an existing shaft");
|
||||
first.probes={{1,first_cylinder,"First engine RPM",MachineQuantity::Rpm},{2,copied_cylinder,"Second engine RPM",MachineQuantity::Rpm}};
|
||||
MachineSimulation engines(first);run(engines,11025);const auto state=engines.snapshot();check(close(shaft(state,first_shaft).speed,1200*2*pi/60)&&close(shaft(state,second_shaft).speed,2400*2*pi/60),"Independent engines did not retain their speeds");
|
||||
check(close(engines.measure().values[0],1200)&&close(engines.measure().values[1],2400),"Cylinder probes used a global RPM");
|
||||
auto live=first;live.find(copied_motor)->drive.target_speed=3600*2*pi/60;engines.apply_tuning(live);check(engines.snapshot().time==state.time,"Second engine tuning reset the first engine");run(engines,128);check(close(engines.measure().values[0],1200)&&close(engines.measure().values[1],3600),"Tuning one motor changed another engine");balance(shaft(engines.snapshot(),first_shaft));balance(shaft(engines.snapshot(),second_shaft));
|
||||
auto mixed=first;std::uint32_t controller=0;for(const auto& link:mixed.links)if(link.kind==MachineConnectionKind::Control&&link.b.part==first_cylinder)controller=link.a.part;mixed.disconnect({copied_cylinder,2});mixed.connect({controller,0},{copied_cylinder,2});rejects([&]{MachineSimulation invalid(mixed);},"An ECU silently controlled cylinders on different shafts");
|
||||
auto unbound=first;unbound.disconnect({first_cylinder,machine_port_index(*unbound.find(first_cylinder),"shaft")});rejects([&]{MachineSimulation invalid(unbound);},"Cylinder without a shaft ran on an implicit crank");
|
||||
auto reverse_engine=first;reverse_engine.find(first_shaft)->shaft.initial_speed=-10;rejects([&]{MachineSimulation invalid(reverse_engine);},"Unsupported reverse engine timing was enabled");
|
||||
auto moved=first;std::vector<std::vector<Vec3>> paths;for(const auto& p:first.parts)if(p.kind==MachinePartKind::Pipe)paths.push_back(p.points);moved.transform_selection({first_shaft},{},{.2f,.1f,0},0);validate_machine(moved);std::size_t index=0;for(const auto& p:moved.parts)if(p.kind==MachinePartKind::Pipe)check(p.points==paths[index++],"Moving a shaft silently changed gas geometry");
|
||||
const auto checks=machine_checks(unbound);check(std::any_of(checks.begin(),checks.end(),[&](const auto& c){return c.part==first_cylinder&&c.title=="No shaft attached";}),"Unattached cylinder lacks a navigable check");
|
||||
{
|
||||
std::vector<std::uint32_t> all;for(const auto& p:bench.parts)all.push_back(p.id);const auto module=machine_extract_assembly(bench,all,"Mechanical module");auto destination=bench;const auto copied=machine_insert_assembly(destination,module,{2,0,0});
|
||||
check(destination.probes.size()==8&&destination.probes[4].part==copied[0]&&destination.probes[4].part!=a,"Copied rotational instrument did not follow its copied shaft");
|
||||
const auto source_motor=std::find_if(module.parts.begin(),module.parts.end(),[&](const auto& p){return p.id==motor;})-module.parts.begin();destination.find(copied[source_motor])->drive.torque=12;
|
||||
MachineSimulation copied_sim(destination,1000);run(copied_sim,1000);check(copied_sim.measure().values[4]>copied_sim.measure().values[0]*2,"Copied motor or instrument still referenced the original shaft");
|
||||
const auto loose=machine_extract_assembly(bench,{wheel},"Unattached flywheel");const auto inserted=machine_insert_assembly(destination,loose,{3,0,0});check(machine_shaft_for(destination,inserted.front())==0,"External assembly attachment accidentally matched a destination ID");rejects([&]{MachineSimulation invalid(destination);},"Unresolved external shaft attachment was silently repaired");
|
||||
}
|
||||
const auto file=std::filesystem::temp_directory_path()/("fluidsim-mechanical-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");save_machine(bench,file);const auto restored=load_machine(file);std::filesystem::remove(file);
|
||||
check(restored.parts.size()==bench.parts.size()&&restored.links.size()==bench.links.size()&&restored.primary_shaft==bench.primary_shaft,"Explicit mechanical topology did not round-trip");MachineSimulation restored_sim(restored,1000);run(restored_sim,1000);check(close(shaft(restored_sim.snapshot(),a).speed,5./3),"Saved motor/flywheel configuration changed behavior");
|
||||
{std::ofstream legacy(file);legacy<<R"(format = 4
|
||||
driven = true
|
||||
drive_rpm = 600.0
|
||||
shaft_inertia = 1.0
|
||||
friction_constant = 2.0
|
||||
friction_viscous = 0.0
|
||||
parts = []
|
||||
[[probes]]
|
||||
id = 1
|
||||
part = 0
|
||||
name = 'Legacy output torque'
|
||||
quantity = 7
|
||||
[[probes]]
|
||||
id = 2
|
||||
part = 0
|
||||
name = 'Legacy output power'
|
||||
quantity = 8
|
||||
)";}
|
||||
auto legacy=load_machine(file);check(legacy.probes[0].generated_output&&legacy.probes[0].part==legacy.primary_shaft,"Legacy torque gauge lost its measurement definition");MachineSimulation legacy_sim(legacy,1000);run(legacy_sim,10);check(close(legacy_sim.measure().values[0],-2)&&close(legacy_sim.measure().values[1],-2*600*2*pi/60),"Legacy gauges included speed-drive reaction torque");save_machine(legacy,file);check(load_machine(file).probes.front().generated_output,"Legacy gauge mode was not saved");std::filesystem::remove(file);
|
||||
std::cout<<"Mechanical parts, gas torque coupling, independent engines and targeted instruments passed\n";return 0;
|
||||
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}
|
||||
130
tests/machine_port_tests.cpp
Normal file
130
tests/machine_port_tests.cpp
Normal file
@@ -0,0 +1,130 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
using namespace fluidsim::port;
|
||||
namespace {
|
||||
void check(bool value,const char* why){if(!value)throw std::runtime_error(why);}
|
||||
template<class F>void rejects(F&& operation,const char* why){bool rejected=false;try{operation();}catch(const std::exception&){rejected=true;}check(rejected,why);}
|
||||
struct TempDirectory {
|
||||
std::filesystem::path path=std::filesystem::temp_directory_path()/("fluidsim-port-tests-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
TempDirectory(){std::filesystem::create_directories(path);}
|
||||
~TempDirectory(){std::error_code error;std::filesystem::remove_all(path,error);}
|
||||
};
|
||||
void write(const std::filesystem::path& file,const std::string& value){std::ofstream stream(file);stream.exceptions(std::ios::failbit|std::ios::badbit);stream<<value;}
|
||||
std::string read(const std::filesystem::path& file){std::ifstream stream(file);return {(std::istreambuf_iterator<char>(stream)),{}};}
|
||||
MachinePortDescriptor signal(PortDomain domain,PortDirection direction,SignalQuantity quantity=SignalQuantity::None){MachinePortDescriptor port;port.key="value";port.name="Value";port.domain=domain;port.direction=direction;port.quantity=quantity;port.cardinality=direction==PortDirection::Output?PortCardinality::Multiple:PortCardinality::Single;return port;}
|
||||
}
|
||||
int main()try{
|
||||
MachinePart undeclared;undeclared.kind=MachinePartKind(machine_part_kind_count);
|
||||
rejects([&]{machine_port_count(undeclared);},"Undeclared part silently acquired gas ports");
|
||||
rejects([&]{machine_part_volume(undeclared);},"Undeclared part silently acquired gas storage");
|
||||
for(int kind=0;kind<machine_part_kind_count;++kind){MachineDocument d;const auto& part=d.add(MachinePartKind(kind));std::set<std::string> keys;
|
||||
for(unsigned i=0;i<machine_port_count(part);++i){const auto descriptor=machine_port_descriptor(part,i);validate_port_descriptor(descriptor);check(keys.insert(descriptor.key).second,"Duplicate built-in stable port key");check(machine_port_index(part,descriptor.key)==i,"Stable port lookup changed an endpoint");}
|
||||
rejects([&]{machine_port_descriptor(part,machine_port_count(part));},"Out-of-range port acquired a descriptor");rejects([&]{machine_port_index(part,"unknown");},"Unknown port key was accepted");
|
||||
}
|
||||
auto boolean_out=signal(PortDomain::Boolean,PortDirection::Output),boolean_in=signal(PortDomain::Boolean,PortDirection::Input);
|
||||
auto number_out=signal(PortDomain::Number,PortDirection::Output,SignalQuantity::AngularSpeed),number_in=signal(PortDomain::Number,PortDirection::Input,SignalQuantity::AngularSpeed);
|
||||
check(port_compatibility(boolean_out,boolean_in).allowed&&port_compatibility(boolean_in,boolean_out).allowed,"Boolean input/output pair was rejected");
|
||||
check(port_compatibility(number_out,number_in).allowed,"Compatible numeric pair was rejected");
|
||||
check(!port_compatibility(boolean_out,number_in).allowed,"Implicit Boolean-to-number conversion was accepted");
|
||||
check(!port_compatibility(boolean_out,boolean_out).allowed&&!port_compatibility(boolean_in,boolean_in).allowed,"Signal direction mismatch was accepted");
|
||||
auto wrong_quantity=number_in;wrong_quantity.quantity=SignalQuantity::Pressure;wrong_quantity.pressure_reference=PressureReference::Absolute;check(!port_compatibility(number_out,wrong_quantity).allowed,"RPM connected to pressure");
|
||||
auto pressure_out=wrong_quantity;pressure_out.direction=PortDirection::Output;pressure_out.cardinality=PortCardinality::Multiple;auto gauge_in=wrong_quantity;gauge_in.pressure_reference=PressureReference::Gauge;
|
||||
check(!port_compatibility(pressure_out,gauge_in).allowed,"Absolute and gauge pressure were implicitly converted");
|
||||
auto faulty=number_in;faulty.quantity=SignalQuantity::None;rejects([&]{validate_port_descriptor(faulty);},"Untyped number was accepted");
|
||||
faulty=wrong_quantity;faulty.pressure_reference=PressureReference::None;rejects([&]{validate_port_descriptor(faulty);},"Pressure without a reference was accepted");
|
||||
faulty=boolean_in;faulty.cardinality=PortCardinality::Multiple;rejects([&]{validate_port_descriptor(faulty);},"Multiple input drivers were permitted");
|
||||
faulty=boolean_in;faulty.default_value=0.0;rejects([&]{validate_port_descriptor(faulty);},"Numeric Boolean default was accepted");
|
||||
faulty=boolean_in;faulty.required=true;faulty.default_value=false;rejects([&]{validate_port_descriptor(faulty);},"Required input silently acquired a default");
|
||||
number_in.minimum=0;number_in.maximum=100;number_in.default_value=20.0;validate_port_descriptor(number_in);
|
||||
check(signal_value_valid_for(SignalValue::number(30),number_in)&&!signal_value_valid_for(SignalValue::number(-1),number_in),"Numeric range check failed");
|
||||
check(!signal_value_valid_for(SignalValue::boolean(true),number_in)&&!signal_value_valid_for(SignalValue::number(1),boolean_in),"Signal value type was coerced");
|
||||
const auto nan=std::numeric_limits<double>::quiet_NaN();check(SignalValue::number(nan).validity==SignalValidity::Fault&&!signal_value_valid_for(SignalValue::number(1,nan),number_in),"Nonfinite signal was treated as valid");
|
||||
for(auto domain:{PortDomain::Gas,PortDomain::Rotational,PortDomain::Translational}){MachinePortDescriptor a;a.key="physical";a.name="Physical";a.domain=domain;validate_port_descriptor(a);check(port_compatibility(a,a).allowed,"Physical bidirectional domain was rejected");check(!port_compatibility(a,boolean_in).allowed,"Signal connected to a physical port");}
|
||||
check(std::string(signal_quantity_unit(SignalQuantity::AngularSpeed))=="rad/s","Internal speed units are not SI");
|
||||
|
||||
TempDirectory temp;const auto file=temp.path/"ports.machine.toml";
|
||||
const std::string legacy=R"(format = 4
|
||||
name = 'Legacy control'
|
||||
[[parts]]
|
||||
id = 1
|
||||
kind = 'ecu'
|
||||
[[parts]]
|
||||
id = 2
|
||||
kind = 'cylinder'
|
||||
[[parts]]
|
||||
id = 3
|
||||
kind = 'throttle'
|
||||
[[connections]]
|
||||
a_part = 1
|
||||
a_port = 0
|
||||
b_part = 2
|
||||
b_port = 2
|
||||
method = 4
|
||||
[[connections]]
|
||||
a_part = 1
|
||||
a_port = 0
|
||||
b_part = 3
|
||||
b_port = 2
|
||||
method = 4
|
||||
)";
|
||||
write(file,legacy);auto d=load_machine(file);check(std::count_if(d.links.begin(),d.links.end(),[](const auto& link){return link.kind==MachineConnectionKind::Control;})==2,"Legacy ECU fan-out was lost");
|
||||
check(machine_link_domain(d,d.links[0])==PortDomain::EcuAssignment,"Legacy control was reinterpreted as a signal");save_machine(d,file);const auto saved=read(file);
|
||||
check(saved.find("format = 10")!=std::string::npos&&saved.find("'assignments'")!=std::string::npos&&saved.find("'ecu_assignment'")!=std::string::npos,"Current format did not serialize stable port/method keys");
|
||||
const auto roundtrip=load_machine(file);check(roundtrip.links.size()==d.links.size()&&roundtrip.links[0].a.port==0&&roundtrip.links[0].b.port==2,"Stable key round-trip changed legacy wiring");
|
||||
auto multiple=d;multiple.links.push_back(multiple.links.front());rejects([&]{validate_machine(multiple);},"Duplicate driver was accepted");
|
||||
auto reversed=d;std::swap(reversed.links[0].a,reversed.links[0].b);rejects([&]{validate_machine(reversed);},"Noncanonical ECU connection passed document validation");
|
||||
check(!machine_connection_preview(d,{1,0},{2,0}).allowed,"Preview accepted a gas/control mismatch");
|
||||
|
||||
const auto custom=[](bool reorder){return std::string(R"(format = 5
|
||||
name = 'Stable custom ports'
|
||||
[[parts]]
|
||||
id = 1
|
||||
kind = 'pipe'
|
||||
position_m = [0,0,0]
|
||||
points_m = [[-0.2,0,0],[0,0,0]]
|
||||
point_diameters_m = [0.02,0.02]
|
||||
[[parts]]
|
||||
id = 2
|
||||
kind = 'chamber'
|
||||
position_m = [0,0,0]
|
||||
)")+(reorder?"port_coordinates = [[1,0,0],[0,0,0]]\nport_keys = ['tap_b','tap_a']\n":"port_coordinates = [[0,0,0],[1,0,0]]\nport_keys = ['tap_a','tap_b']\n")+R"([[connections]]
|
||||
a_part = 1
|
||||
a_port = 'end'
|
||||
b_part = 2
|
||||
b_port = 'tap_a'
|
||||
method = 'orifice'
|
||||
)";};
|
||||
write(file,custom(false));const auto original=load_machine(file);write(file,custom(true));const auto reordered=load_machine(file);
|
||||
check(original.links.front().b.port==0&&reordered.links.front().b.port==1,"Stable key did not follow a reordered physical port");
|
||||
check(machine_port_position(*original.find(2),0)==machine_port_position(*reordered.find(2),1),"Reordered port changed its physical endpoint");
|
||||
auto invalid=custom(false);invalid.replace(invalid.find("'tap_a'",invalid.find("[[connections]]")),7,"'unknown'");write(file,invalid);rejects([&]{load_machine(file);},"Unknown serialized port key silently reconnected");
|
||||
invalid=custom(false);invalid.replace(invalid.find("'orifice'"),9,"'unknown'");write(file,invalid);rejects([&]{load_machine(file);},"Unknown serialized method was accepted");
|
||||
auto duplicate=original;duplicate.find(2)->port_keys={"tap_a","tap_a"};rejects([&]{validate_machine(duplicate);},"Duplicate custom identities were accepted");
|
||||
write(file,R"(format = 4
|
||||
[[parts]]
|
||||
id = 1
|
||||
kind = 'pipe'
|
||||
point_diameters_m = [0.04,0.04]
|
||||
[[parts]]
|
||||
id = 2
|
||||
kind = 'junction'
|
||||
junction_method = 1
|
||||
position_m = [0.15,0.25,0]
|
||||
[[connections]]
|
||||
a_part = 1
|
||||
a_port = 1
|
||||
b_part = 2
|
||||
b_port = 5
|
||||
method = 0
|
||||
)");
|
||||
const auto alias=load_machine(file);check(alias.links.front().b.port==0&&machine_port_count(*alias.find(2))==1,"Legacy junction alias did not migrate to its shared port");
|
||||
for(const auto& source:machine_files()){auto machine=load_machine(source);const auto copy=temp.path/source.filename();save_machine(machine,copy);const auto loaded=load_machine(copy);check(loaded.parts.size()==machine.parts.size()&&loaded.links.size()==machine.links.size(),"Existing machine changed topology during migration");}
|
||||
std::cout<<"Typed port domains, quantities, validation and version 5 migration passed\n";return 0;
|
||||
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}
|
||||
66
tests/machine_sensor_tests.cpp
Normal file
66
tests/machine_sensor_tests.cpp
Normal file
@@ -0,0 +1,66 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#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,double tolerance=1e-5){return std::abs(a-b)<=tolerance*std::max({1.,std::abs(a),std::abs(b)});}
|
||||
template<class F>void rejects(F&& f,const char* why){bool caught=false;try{f();}catch(const std::exception&){caught=true;}check(caught,why);}
|
||||
double value(const MachineSensorState& s,unsigned output=0){check(s.status==MachineSensorStatus::Valid&&s.outputs.at(output).validity==SignalValidity::Valid,"Sensor output is unexpectedly invalid");return std::get<double>(s.outputs.at(output).value);}
|
||||
void run(MachineSimulation& sim,int count){for(int i=0;i<count;++i)sim.process();}
|
||||
}
|
||||
int main()try{
|
||||
MachineDocument doc;const auto pipe=doc.add(MachinePartKind::Pipe).id,tank=doc.add(MachinePartKind::Chamber,{.3f,.25f,0}).id,ambient=doc.add(MachinePartKind::Reservoir,{-.3f,.25f,0}).id;
|
||||
doc.find(tank)->pressure=200000;doc.connect({pipe,1},{tank,0});doc.connect({pipe,0},{ambient,0});
|
||||
const auto pressure=doc.add_sensor(MachineSensorKind::Pressure,tank).id;
|
||||
const auto difference=doc.add_sensor(MachineSensorKind::DifferentialPressure,tank).id;doc.find_sensor(difference)->reference={ambient,.5f,"Ambient reference"};
|
||||
const auto temperature=doc.add_sensor(MachineSensorKind::GasTemperature,pipe,.9f).id;
|
||||
const auto flow=doc.add_sensor(MachineSensorKind::MassFlow,pipe,.35f).id;
|
||||
for(int i=0;i<12;++i)doc.add_sensor(MachineSensorKind::Pressure,pipe,float(i)/11);
|
||||
doc.probes={{1,pipe,"Pipe flow",MachineQuantity::MassFlow,.35f}};
|
||||
validate_machine(doc);MachineSimulation sensed(doc);auto bare=doc;bare.sensors.clear();MachineSimulation baseline(bare);
|
||||
check(close(value(sensed.sense(pressure)),200000)&&close(value(sensed.sense(difference)),200000-101325),"Pressure taps lost absolute or differential reference");
|
||||
check(close(value(sensed.sense(temperature)),300)&&value(sensed.sense(flow))==0,"Initial sensor sample is missing or has the wrong units");
|
||||
check(sensed.snapshot().sensors.size()==16&&sensed.measure().count==1,"Sensors consume the instrument recording limit");
|
||||
bool reverse_flow=false;for(int i=0;i<2048;++i){const auto a=sensed.process(),b=baseline.process();check(a.left==b.left&&a.right==b.right,"Ideal sensor altered machine audio");reverse_flow|=value(sensed.sense(flow))<-.00001;}
|
||||
const auto measured=sensed.measure();check(close(value(sensed.sense(flow)),measured.values[0]),"Pipe sensor and targeted instrument disagree at the same location");
|
||||
check(reverse_flow,"Total mass flow lost the pipe Start-to-End sign");
|
||||
const auto a=sensed.snapshot(),b=baseline.snapshot();check(a.cells==b.cells&&a.substeps==b.substeps&&a.parts.size()==b.parts.size(),"Sensor added a physical volume or solver cells");
|
||||
for(std::size_t i=0;i<a.parts.size();++i)check(a.parts[i].pressure==b.parts[i].pressure&&a.parts[i].flow==b.parts[i].flow,"Sensor perturbed the gas state");
|
||||
for(const auto& sensor:a.sensors)for(unsigned i=0;i<sensor.count;++i)check(sensor.outputs[i].timestamp==a.time,"Sensor outputs have incoherent sample times");
|
||||
for(float mesh:{10.f,150.f}){auto remeshed=doc;remeshed.cells_per_meter=mesh;MachineSimulation sim(remeshed);check(close(value(sim.sense(temperature)),300),"Sensor bound to a stale cell after remeshing");check(remeshed.find_sensor(temperature)->target.position==.9f,"Remeshing changed normalized sensor location");}
|
||||
auto moved=doc;const auto original=machine_sensor_position(moved,moved.find_sensor(temperature)->target);moved.transform_selection({pipe,tank,ambient},{},{.1f,.2f,.3f});const auto after=machine_sensor_position(moved,moved.find_sensor(temperature)->target);
|
||||
check(original&&after&&close(after->x-original->x,.1)&&close(after->y-original->y,.2)&&close(after->z-original->z,.3),"Sensor marker did not follow its physical host");
|
||||
auto invalid=doc;invalid.find_sensor(temperature)->target.position=1.1f;rejects([&]{validate_machine(invalid);},"Out-of-pipe sensor position was accepted");
|
||||
rejects([&]{doc.add_sensor(MachineSensorKind::Crank,tank);},"Crank sensor attached to a gas tank");rejects([&]{doc.add_sensor(MachineSensorKind::MassFlow,tank);},"A gas volume silently supplied an ambiguous through-flow");
|
||||
auto unresolved=doc;unresolved.remove(tank);check(unresolved.find_sensor(pressure)&&unresolved.find_sensor(pressure)->target.part==0&&unresolved.find_sensor(pressure)->target.label==doc.find(tank)->name,"Deleting a host removed its sensor or lost the unresolved label");
|
||||
MachineSimulation missing(unresolved);check(missing.sense(pressure).status==MachineSensorStatus::MissingHost&&missing.sense(pressure).outputs[0].validity==SignalValidity::Missing,"Deleted host supplied plausible valid data");
|
||||
unresolved.add(MachinePartKind::Chamber);MachineSimulation still_missing(unresolved);check(still_missing.sense(pressure).status==MachineSensorStatus::MissingHost,"New part silently repaired a deleted sensor host");
|
||||
auto wrong=doc;wrong.find_sensor(pressure)->target.part=ambient;wrong.find(ambient)->kind=MachinePartKind::Cap;MachineSimulation incompatible(wrong);check(incompatible.sense(pressure).status==MachineSensorStatus::IncompatibleHost,"Incompatible sensor binding supplied a reading");
|
||||
|
||||
MachineDocument rotation;const auto shaft=rotation.add(MachinePartKind::Shaft).id;rotation.find(shaft)->shaft.initial_angle=-.5f;rotation.find(shaft)->shaft.initial_speed=-2;
|
||||
const auto crank=rotation.add_sensor(MachineSensorKind::Crank,shaft).id;rotation.find_sensor(crank)->angle_offset=.25f;MachineSimulation rotating(rotation,1000);
|
||||
auto pickup=rotating.sense(crank);check(value(pickup)==-2&&close(value(pickup,1),2*double(pi)-.25)&&std::get<bool>(pickup.outputs[2].value),"Crank pickup lost signed speed or wrapped angle");check(pickup.timing_shaft==shaft&&close(pickup.unwrapped_angle,-.25),"Ideal crank provenance is unavailable");
|
||||
run(rotating,4000);pickup=rotating.sense(crank);check(close(pickup.unwrapped_angle,-8.25)&&value(pickup,1)>=0&&value(pickup,1)<2*pi,"Crank pickup discarded unwrapped reverse crossings");
|
||||
for(unsigned i=0;i<3;++i){const auto descriptor=machine_sensor_output(*rotation.find_sensor(crank),i);check(descriptor.direction==PortDirection::Output&&descriptor.cardinality==PortCardinality::Multiple,"Sensor outputs cannot fan out");check(signal_value_valid_for(pickup.outputs[i],descriptor),"Crank values do not match typed outputs");check(pickup.outputs[i].timestamp==rotating.snapshot().time,"Crank channels are not synchronized");}
|
||||
auto changed=rotation;changed.find_sensor(crank)->angle_offset=1;rejects([&]{rotating.apply_tuning(changed);},"Timing attachment changed during Test");
|
||||
auto stopped=rotation;stopped.find(shaft)->shaft.initial_speed=0;MachineSimulation stationary(stopped);check(value(stationary.sense(crank))==0&&stationary.sense(crank).status==MachineSensorStatus::Valid,"Stopped crank was confused with a missing pickup");
|
||||
|
||||
MachineDocument throttle;const auto duct=throttle.add(MachinePartKind::Pipe).id,body=throttle.add(MachinePartKind::Throttle).id;throttle.connect({duct,1},{body,0});throttle.find(body)->pedal_control=false;throttle.find(body)->opening=.35f;
|
||||
const auto tps=throttle.add_sensor(MachineSensorKind::ThrottlePosition,body).id;MachineSimulation opening(throttle);opening.set_controls(.9f);run(opening,1);check(close(value(opening.sense(tps)),.35),"TPS read pedal demand instead of fixed mechanism opening");throttle.find(body)->opening=.6f;opening.apply_tuning(throttle);run(opening,1);check(close(value(opening.sense(tps)),.6),"TPS missed safe live throttle tuning");
|
||||
|
||||
const auto assembly=machine_extract_assembly(doc,{tank},"Tapped tank");check(assembly.sensors.size()==2&&assembly.find_sensor(difference)->reference.part==0,"Assembly lost sensors or retained an external reference ID");auto destination=doc;const auto inserted=machine_insert_assembly(destination,assembly,{1,.25f,0});check(destination.sensors.size()==18&&destination.sensors[16].target.part==inserted[0]&&destination.sensors[16].id!=pressure,"Copied sensor retained source identity or host");check(destination.sensors[17].reference.part==0,"Copied differential reference accidentally matched an existing host");
|
||||
const auto whole=machine_extract_assembly(doc,{pipe,tank,ambient},"Tapped network");MachineDocument empty;machine_insert_assembly(empty,whole,{});MachineSimulation copied(empty);check(close(value(copied.sense(empty.sensors[1].id)),200000-101325),"Internal sensor reference did not follow assembly remapping");
|
||||
|
||||
const auto file=std::filesystem::temp_directory_path()/("fluidsim-sensors-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");
|
||||
save_machine(destination,file);const auto restored=load_machine(file);check(restored.sensors==destination.sensors&&restored.next_sensor_id==destination.next_sensor_id,"Sensor identities, binding labels or locations did not round-trip");
|
||||
save_machine(unresolved,file);auto reloaded=load_machine(file);check(reloaded.next_id==unresolved.next_id&&reloaded.find_sensor(pressure)->target.part==0,"Reload reused deleted part IDs or rebound an unresolved sensor");
|
||||
reloaded.sensors.erase(reloaded.sensors.begin());const auto reserved=reloaded.next_sensor_id;save_machine(reloaded,file);auto repeat=load_machine(file);check(repeat.next_sensor_id==reserved,"Deleted sensor IDs can be reused after reload");
|
||||
{std::ofstream out(file);out<<"format=7\n[[sensors]]\nid=1\nkind='future_unknown_sensor'\n";}rejects([&]{load_machine(file);},"Unknown sensor kind silently loaded");std::filesystem::remove(file);
|
||||
{const auto example=load_machine(FLUIDSIM_SENSOR_EXAMPLE_FILE);MachineSimulation bench(example);run(bench,2048);check(example.sensors.size()==6&&close(value(bench.sense(5)),188.49556)&&close(value(bench.sense(6)),.45),"Sensor example lost its shaft or mechanism measurement");for(const auto& s:bench.snapshot().sensors)check(s.status==MachineSensorStatus::Valid,"Sensor example contains an unresolved or invalid attachment");}
|
||||
std::cout<<"Sensor attachments, coherent samples, typed outputs, unresolved bindings and assembly persistence passed\n";return 0;
|
||||
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}
|
||||
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;}
|
||||
79
tests/mechanics_tests.cpp
Normal file
79
tests/mechanics_tests.cpp
Normal file
@@ -0,0 +1,79 @@
|
||||
#include "FluidSim/Mechanics.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
using namespace fluidsim::port;
|
||||
namespace {
|
||||
void check(bool value,const char* why){if(!value)throw std::runtime_error(why);}
|
||||
bool close(double a,double b,double tolerance=1e-10){return std::abs(a-b)<=tolerance*std::max({1.,std::abs(a),std::abs(b)});}
|
||||
template<class F>void rejects(F&& f,const char* why){bool caught=false;try{f();}catch(const std::exception&){caught=true;}check(caught,why);}
|
||||
void balance(const ShaftState& s){check(close(s.initial_energy+s.external_work+s.drive_work,s.kinetic_energy+s.friction_loss+s.brake_loss,2e-9),"Mechanical work/energy balance failed");check(s.friction_loss>=0&&s.brake_loss>=0,"Mechanical drag created energy");}
|
||||
}
|
||||
int main()try{
|
||||
{
|
||||
MechanicalSystem m;m.add_shaft(1,{.inertia=2});m.add_shaft(2,{.inertia=.5,.initial_speed=10});m.add_drive(3,1,{DriveMode::Torque,4});
|
||||
for(int i=0;i<100;++i)m.step(.01);
|
||||
check(close(m.shaft(1).speed,2)&&close(m.shaft(1).angle,1),"Constant torque does not match analytic acceleration/angle");
|
||||
check(close(m.shaft(2).speed,10)&&close(m.shaft(2).angle,10),"Independent shaft was accelerated by another drive");
|
||||
check(close(m.drive(3).work,4)&&close(m.shaft(1).kinetic_energy,4),"Motor work did not become shaft kinetic energy");balance(m.shaft(1));balance(m.shaft(2));
|
||||
rejects([&]{m.add_flywheel(4,1,1);},"Topology changed after simulation began");
|
||||
}
|
||||
{
|
||||
MechanicalSystem m;m.add_shaft(1,{.inertia=1});m.add_flywheel(2,1,3);m.add_drive(3,1,{DriveMode::Torque,4});m.step(1);
|
||||
check(close(m.shaft(1).inertia,4)&&close(m.shaft(1).speed,1),"Flywheel inertia was omitted or counted twice");balance(m.shaft(1));
|
||||
}
|
||||
{
|
||||
MechanicalSystem m;m.add_shaft(1,{.inertia=1,.initial_speed=3});m.add_brake(2,1,{.constant=10});m.step(1);
|
||||
check(m.shaft(1).speed==0&&close(m.shaft(1).angle,.45),"Brake did not stop at the correct position within a timestep");
|
||||
check(close(m.brake(2).loss,4.5)&&m.brake(2).torque<0,"Brake did not absorb initial kinetic energy");m.step(1);
|
||||
check(m.shaft(1).speed==0&&close(m.shaft(1).angle,.45),"Brake pushed a stopped shaft into reverse");balance(m.shaft(1));
|
||||
}
|
||||
{
|
||||
MechanicalSystem m;m.add_shaft(1,{.inertia=1});m.add_drive(2,1,{DriveMode::Torque,4});m.add_brake(3,1,{.constant=10});m.step(1);
|
||||
check(m.shaft(1).speed==0&&close(m.brake(3).torque,-4)&&m.brake(3).power==0,"Static brake reaction or zero-speed power is wrong");
|
||||
m.set_drive(2,{DriveMode::Torque,12});m.step(1);check(close(m.shaft(1).speed,2),"Drive could not overcome static brake capacity");balance(m.shaft(1));
|
||||
}
|
||||
{
|
||||
MechanicalSystem m;m.add_shaft(1,{.inertia=1,.initial_speed=3});m.add_drive(2,1,{DriveMode::Torque,-20});m.add_brake(3,1,{.constant=10});m.step(1);
|
||||
check(m.shaft(1).speed<0&&m.shaft(1).angle<0,"Drive could not stop and reverse a shaft");check(m.brake(3).loss>0,"Brake generated energy through reversal");balance(m.shaft(1));
|
||||
}
|
||||
{
|
||||
MechanicalSystem m;m.add_shaft(1,{.inertia=2,.friction_viscous=1});m.add_drive(2,1,{DriveMode::Speed,10});m.add_brake(3,1,{.constant=3});m.step(.1);
|
||||
check(close(m.shaft(1).speed,10)&&close(m.shaft(1).angle,1),"Ideal drive did not impose its speed");
|
||||
check(close(m.drive(2).work,113)&&close(m.shaft(1).brake_loss,3)&&close(m.shaft(1).friction_loss,10),"Ideal drive did not account for startup work and losses");
|
||||
m.step(.1);check(close(m.drive(2).torque,13)&&close(m.drive(2).power,130),"Steady speed-drive reaction does not balance losses");
|
||||
m.set_drive(2,{DriveMode::Speed,0});m.step(.1);check(close(m.drive(2).power,-1000),"Ideal drive did not report recovered stopping energy");balance(m.shaft(1));
|
||||
}
|
||||
{
|
||||
MechanicalSystem m;m.add_shaft(1,{.inertia=.5,.initial_speed=-8,.friction_constant=.3,.friction_viscous=.2,.friction_quadratic=.02});m.add_drive(2,1,{DriveMode::Speed,-8});m.add_brake(3,1,{.constant=.4,.viscous=.1});m.step(.01);
|
||||
check(m.drive(2).torque<0&&m.drive(2).power>0&&m.brake(3).torque>0&&m.brake(3).power>0,"Reverse rotation used the wrong torque or power sign");balance(m.shaft(1));
|
||||
}
|
||||
for(double dt:{.01,.001}){
|
||||
MechanicalSystem m;m.add_shaft(1,{.inertia=2,.initial_speed=10,.friction_viscous=3});const int steps=int(std::round(1/dt));for(int i=0;i<steps;++i)m.step(dt);
|
||||
check(std::abs(m.shaft(1).speed-10*std::exp(-1.5))<(dt==.01?.0001:.000002),"Viscous damping failed timestep refinement against its analytic solution");balance(m.shaft(1));
|
||||
}
|
||||
{
|
||||
MechanicalSystem m;m.add_shaft(1,{.inertia=.7,.initial_speed=4,.friction_constant=.2,.friction_viscous=.04,.friction_quadratic=.006});m.add_drive(2,1,{DriveMode::Torque,1});m.add_brake(3,1,{.constant=.1,.viscous=.02,.quadratic=.003});
|
||||
for(int i=0;i<10000;++i){m.add_torque(1,std::sin(i*.01)*6);if(i%1200==0)m.set_drive(2,{DriveMode::Torque,i%2400==0?-2.:3.});m.step(.001);if(i%100==0)balance(m.shaft(1));}
|
||||
balance(m.shaft(1));
|
||||
}
|
||||
{
|
||||
MechanicalSystem m;m.add_shaft(1,{.inertia=1,.initial_speed=1,.forward_only=true});m.add_drive(2,1,{DriveMode::Torque,-10});m.step(1);
|
||||
check(m.shaft(1).speed==0&&m.shaft(1).constraint_torque>0,"Forward-only timing constraint failed to hold at rest");balance(m.shaft(1));
|
||||
rejects([&]{m.set_drive(2,{DriveMode::Speed,-1});},"Forward-only shaft accepted a reverse speed command");
|
||||
}
|
||||
{
|
||||
MechanicalSystem m;m.add_shaft(1,{});m.add_drive(2,1,{DriveMode::Speed,10});
|
||||
rejects([&]{m.add_drive(3,1,{DriveMode::Speed,10});},"Multiple ideal drives acquired undefined reaction sharing");
|
||||
m.add_drive(3,1,{DriveMode::Torque,1});rejects([&]{m.set_drive(3,{DriveMode::Speed,10});},"Tuning introduced a conflicting speed source");
|
||||
rejects([&]{m.add_brake(2,1,{});},"Duplicate mechanical ID was accepted");
|
||||
rejects([&]{m.add_flywheel(4,99,1);},"Flywheel attached to a nonexistent shaft");
|
||||
rejects([&]{m.add_shaft(4,{.inertia=0});},"Zero inertia was accepted");
|
||||
rejects([&]{m.add_brake(4,1,{.constant=-1});},"Negative brake loss was accepted");
|
||||
rejects([&]{m.step(0);},"Zero timestep was accepted");
|
||||
rejects([&]{m.add_torque(1,std::numeric_limits<double>::quiet_NaN());},"Nonfinite torque was accepted");
|
||||
}
|
||||
std::cout<<"Independent shafts, flywheel inertia, drive/brake work and stopping/reversal passed\n";return 0;
|
||||
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}
|
||||
50
tests/menu_tests.cpp
Normal file
50
tests/menu_tests.cpp
Normal file
@@ -0,0 +1,50 @@
|
||||
#include "FluidSim/Desktop/ScenarioMenu.hpp"
|
||||
#include "FluidSim/Desktop/WindowPresentation.hpp"
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
using namespace fluidsim::port;
|
||||
using namespace fluidsim::port::desktop;
|
||||
void check(bool value,const char* message){if(!value)throw std::runtime_error(message);}
|
||||
void key(ScenarioMenu& menu,sf::Keyboard::Key code,sf::Vector2u size={1280,720}){menu.handle_event(sf::Event::KeyPressed{.code=code},size);}
|
||||
void click(ScenarioMenu& menu,sf::Vector2i at,sf::Vector2u size={1280,720}){menu.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,at},size);menu.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,at},size);}
|
||||
int main(int argc,char** argv)try{
|
||||
if(argc==2&&std::string(argv[1])=="--window-check"){
|
||||
sf::RenderWindow window(sf::VideoMode({640,480}),"FluidSim maximize check");maximize_window(window);
|
||||
check(window_is_maximized(window),"Window did not enter the native maximized state");window.close();std::cout<<"Native window maximization passed\n";return 0;
|
||||
}
|
||||
const auto examples=scenario_examples();check(!examples.empty(),"Scenario catalog is empty");
|
||||
ScenarioMenu workshop(examples,{"example.machine.toml"});check(workshop.screen()==MenuScreen::Home,"Menu did not start on Home");
|
||||
key(workshop,sf::Keyboard::Key::Enter);check(workshop.screen()==MenuScreen::Workshop&&!workshop.take_choice(),"Workshop navigation launched a machine immediately");
|
||||
key(workshop,sf::Keyboard::Key::Enter);auto choice=workshop.take_choice();check(choice&&!choice->example&&choice->machine.empty()&&!choice->play,"New machine choice is invalid");
|
||||
key(workshop,sf::Keyboard::Key::Down);key(workshop,sf::Keyboard::Key::Space);choice=workshop.take_choice();check(choice&&choice->machine=="example.machine.toml"&&choice->play,"Saved machine Play did not preserve its path");
|
||||
key(workshop,sf::Keyboard::Key::Enter);choice=workshop.take_choice();check(choice&&!choice->play&&!choice->example,"Saved machine Edit was treated as Play");
|
||||
key(workshop,sf::Keyboard::Key::Escape);check(workshop.screen()==MenuScreen::Home&&!workshop.finished(),"Back closed the application instead of returning Home");
|
||||
key(workshop,sf::Keyboard::Key::Down);key(workshop,sf::Keyboard::Key::Enter);check(workshop.screen()==MenuScreen::Scenarios,"Engine Scenarios did not open its own browser");
|
||||
click(workshop,{350,180});check(!workshop.take_choice(),"Selecting an engine row started it immediately");
|
||||
key(workshop,sf::Keyboard::Key::Enter);choice=workshop.take_choice();check(choice&&choice->example==0,"Engine browser lost catalog index mapping");
|
||||
// Required keyboard focus keeps search text separate from navigation shortcuts.
|
||||
click(workshop,{350,120});for(char c:std::string("no-such-engine-xyz"))workshop.handle_event(sf::Event::TextEntered{std::uint32_t(c)},{1280,720});
|
||||
key(workshop,sf::Keyboard::Key::Enter);key(workshop,sf::Keyboard::Key::Enter);check(!workshop.take_choice(),"Empty search launched an engine");
|
||||
key(workshop,sf::Keyboard::Key::Escape);key(workshop,sf::Keyboard::Key::Enter);check(workshop.screen()==MenuScreen::Scenarios,"Returning to browser did not clear search");
|
||||
click(workshop,{100,206});key(workshop,sf::Keyboard::Key::End);key(workshop,sf::Keyboard::Key::Enter);choice=workshop.take_choice();check(choice&&choice->example&&examples[*choice->example].category==examples.front().category,"Category selection started an engine from another category");
|
||||
key(workshop,sf::Keyboard::Key::B);choice=workshop.take_choice();check(choice&&!choice->example&&choice->machine.empty(),"New machine keyboard shortcut failed");
|
||||
ScenarioMenu empty({});key(empty,sf::Keyboard::Key::Enter);key(empty,sf::Keyboard::Key::Enter);check(bool(empty.take_choice()),"Empty engine catalog disabled the workshop");
|
||||
ScenarioMenu quit(examples);key(quit,sf::Keyboard::Key::End);key(quit,sf::Keyboard::Key::Enter);check(quit.finished(),"Quit did not close the menu");
|
||||
if(argc==3&&std::string(argv[1])=="--preview"){
|
||||
const std::filesystem::path directory=argv[2];std::filesystem::create_directories(directory);
|
||||
sf::ContextSettings context;context.depthBits=24;context.majorVersion=2;context.minorVersion=1;
|
||||
sf::RenderWindow window(sf::VideoMode({1920,1080}),"Main menu review",sf::Style::Default,sf::State::Windowed,context);window.setVisible(false);sf::Font font("C:/Windows/Fonts/consola.ttf");
|
||||
for(const sf::Vector2u size:{sf::Vector2u{1920,1080},sf::Vector2u{1280,720}}){
|
||||
window.setSize(size);ScenarioMenu menu(examples,machine_files());
|
||||
const auto capture=[&](const std::string& name){menu.draw(window,&font);sf::Texture image(size);image.update(window);check(image.copyToImage().saveToFile(directory/(std::to_string(size.x)+"-"+name+".png")),"Menu screenshot save failed");};
|
||||
capture("home");key(menu,sf::Keyboard::Key::Down,size);key(menu,sf::Keyboard::Key::Enter,size);capture("engines");
|
||||
click(menu,{350,120},size);const auto it=std::find_if(examples.begin(),examples.end(),[](const auto& example){return example.id=="flat6";});
|
||||
if(it!=examples.end())for(char c:it->title)menu.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(menu,sf::Keyboard::Key::Enter,size);capture("engine-search");
|
||||
key(menu,sf::Keyboard::Key::Escape,size);key(menu,sf::Keyboard::Key::Up,size);key(menu,sf::Keyboard::Key::Enter,size);capture("workshop");
|
||||
key(menu,sf::Keyboard::Key::Down,size);capture("saved-machine");
|
||||
}
|
||||
}
|
||||
std::cout<<"Main menu, workshop and scenario navigation passed\n";return 0;
|
||||
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}
|
||||
@@ -81,10 +81,15 @@ int main() {
|
||||
const auto small_intake_pulse=desktop::pressure_tint(104325),small_exhaust_pulse=desktop::pressure_tint(104325,true);
|
||||
require(vacuum_before.x==vacuum_after.x&&vacuum_before.y==vacuum_after.y&&vacuum_before.z==vacuum_after.z,
|
||||
"exhaust highlighting must not change the established underpressure palette");
|
||||
require(small_intake_pulse.x>.9f&&small_intake_pulse.y>.8f&&small_intake_pulse.z<.1f,
|
||||
"a small positive intake pulse must be visible as yellow");
|
||||
require(small_exhaust_pulse.x>.95f&&small_exhaust_pulse.y<.4f,
|
||||
"a small positive exhaust pulse must be visible as orange-red");
|
||||
require(small_intake_pulse.x>atm.x&&small_intake_pulse.y>=atm.y&&small_intake_pulse.z<=atm.z,
|
||||
"a positive intake pulse must move continuously toward warmer colors");
|
||||
require(small_exhaust_pulse.x>small_intake_pulse.x,
|
||||
"the same exhaust pulse must reach warmer colors sooner than intake");
|
||||
const auto near_atmosphere=desktop::pressure_tint(101325.1f),strong_intake=desktop::pressure_tint(251325);
|
||||
require(std::abs(near_atmosphere.x-atm.x)<.001f&&std::abs(near_atmosphere.y-atm.y)<.001f,
|
||||
"the positive pressure palette must remain continuous at atmosphere");
|
||||
require(strong_intake.x>.95f&&strong_intake.y<.9f&&strong_intake.z<.1f,
|
||||
"strong intake overpressure must reach warm colors");
|
||||
Ecu fast_actuator;
|
||||
for(int i=0;i<230;++i)fast_actuator.step(1.f/44100,1,5000);
|
||||
require(fast_actuator.throttle_opening()>.99f,"throttle actuator must settle within about 5 ms");
|
||||
@@ -140,6 +145,7 @@ int main() {
|
||||
for (int i = 8; i < 12; ++i) audio_ring.write(float(i), -float(i));
|
||||
require(audio_ring.read_interpolated(left, right, 1.0) == 4 && left[0] == 7.0f && left[3] == 10.0f,
|
||||
"ring must resume continuously after underrun and wraparound");
|
||||
audio_ring.discard();require(audio_ring.available_samples()==0,"Discard must remove old playback latency while stopped");for(int i=100;i<105;++i)audio_ring.write(float(i),-float(i));require(audio_ring.read_interpolated(left,right,1)==4&&left[0]==100&&left[3]==103,"A restarted consumer replayed discarded audio");
|
||||
SoundProcessor linear_source(44100, 1.0f);
|
||||
linear_source.process(0.0f);
|
||||
const float response = linear_source.process(0.01f);
|
||||
@@ -283,6 +289,17 @@ int main() {
|
||||
require(std::abs(profiled_pipe.cell_mass(1) - 1.225f * 0.002f * 0.2f) < 1e-8f &&
|
||||
std::abs(profiled_pipe.minimum_cell_length() - 0.1f) < 1e-8f,
|
||||
"profiled pipes must retain each cell's area and length for mass/CFL calculations");
|
||||
Pipe1D contraction{{.001f,.00005f,.001f},{.01f,.01f,.01f}};
|
||||
Pipe1D uniform_comparison{.001f,.03f,3};
|
||||
require(contraction.prepare_step()<uniform_comparison.prepare_step()*.1f&&std::abs(contraction.stable_timestep()-contraction.prepare_step())<1e-10f,
|
||||
"sharp contractions must limit CFL by face area relative to stored volume");
|
||||
for(auto method:{PipeSolver::Rusanov,PipeSolver::Hlle,PipeSolver::Hllc,PipeSolver::Muscl}){
|
||||
std::vector<Pipe1D> pipes;pipes.emplace_back(.001f,1.f,40);auto& pipe=pipes[0];pipe.solver=method;
|
||||
for(std::size_t i=0;i<pipe.cells.size();++i){auto& cell=pipe.cells[i];cell.rho=1.2f;cell.momentum=12;cell.energy=101325/pipe_gm1+60;cell.air=0;cell.fuel=0;cell.fuel_equivalent=i<10?.02f:0;}
|
||||
const auto marker_mass=[&]{double total=0;for(std::size_t i=0;i<pipe.cells.size();++i)total+=pipe.cell_mass(int(i))*pipe.cells[i].fuel_equivalent;return total;};const auto before=marker_mass();
|
||||
BoundarySystem boundaries(pipes);boundaries.add_connection(0,true,0,false);Solver transport(pipes,boundaries);for(int i=0;i<2000;++i)transport.step(1.f/44100);
|
||||
require(std::abs(marker_mass()-before)<before*.0005&&pipe.cells[25].fuel_equivalent>0,"Burned-fuel origin must survive conservative gas transport");
|
||||
}
|
||||
|
||||
Vehicle bike;
|
||||
bike.set_first_gear();
|
||||
|
||||
141
tests/sensor_ecu_tests.cpp
Normal file
141
tests/sensor_ecu_tests.cpp
Normal file
@@ -0,0 +1,141 @@
|
||||
#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;}
|
||||
55
tests/speed_density_tests.cpp
Normal file
55
tests/speed_density_tests.cpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#include "FluidSim/Machine.hpp"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
#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-8*std::max({1e-12,std::abs(a),std::abs(b)});}
|
||||
template<class F>void rejects(F action,const char* why){bool rejected=false;try{action();}catch(const std::exception&){rejected=true;}check(rejected,why);}
|
||||
SensorEcuMeasurements measurements(double pressure=100000,double temperature=300,double rpm=3000){
|
||||
SensorEcuMeasurements m;m.speed=SignalValue::number(rpm*2*double(pi)/60);m.angle=SignalValue::number(.125);m.speed.timing=m.angle.timing=SignalTiming{1,1,0,.125,std::get<double>(m.speed.value)};m.map=SignalValue::number(pressure);m.iat=SignalValue::number(temperature);return m;
|
||||
}
|
||||
SensorEcuState estimate(SensorEcuConfig config,SensorEcuMeasurements m){SensorEcu ecu(std::move(config));return ecu.sample(m,0);}
|
||||
}
|
||||
int main(int argc,char** argv)try{
|
||||
if(argc==3&&std::string(argv[1])=="--write-example"){save_machine(machine_speed_density_example(),argv[2]);return 0;}
|
||||
for(int i=0;i<sensor_ecu_strategy_count;++i){const auto strategy=SensorEcuConfig::Strategy(i);check(sensor_ecu_strategy_from_key(sensor_ecu_strategy_key(strategy))==strategy,"ECU strategy key failed round-trip");}
|
||||
rejects([]{sensor_ecu_strategy_from_key("unknown_future_strategy");},"Unknown ECU strategy silently fell back");
|
||||
SensorEcuConfig config;config.strategy=SensorEcuConfig::Strategy::SpeedDensity;config.displacement=.0005;config.target_afr=14.7;config.volumetric_efficiency={1,1,1,1};
|
||||
const auto full=estimate(config,measurements());const double air_mass=100000*.0005/(287*300.);
|
||||
check(!full.sensor_fault&&close(full.estimated_air_per_cycle,air_mass)&&close(full.fuel_per_cycle,air_mass/14.7),"Speed-density disagrees with the dry ideal-gas charge at 100 kPa/300 K/500 cc");
|
||||
check(close(full.fuel_mass_flow,air_mass/14.7*25),"Speed-density four-stroke mass-per-cycle conversion failed");
|
||||
const auto half=estimate(config,measurements(50000)),hot=estimate(config,measurements(100000,600));
|
||||
check(close(half.fuel_mass_flow,full.fuel_mass_flow*.5)&&close(hot.fuel_mass_flow,full.fuel_mass_flow*.5),"Fuel estimate did not respond to wired MAP/IAT");
|
||||
check(close(estimate(config,measurements(1e308,1e308)).fuel_mass_flow,full.fuel_mass_flow*300/100000),"Finite high P/T ratio overflowed into zero fueling");
|
||||
auto large=config;large.displacement*=2;check(close(estimate(large,measurements()).fuel_per_cycle,full.fuel_per_cycle*2),"Displacement calibration was ignored");
|
||||
auto table=config;table.volumetric_efficiency={.5,.7,.9,1.1};const auto interpolated=estimate(table,measurements(60000));check(close(interpolated.volumetric_efficiency,.8),"RPM/MAP VE table did not interpolate both axes");
|
||||
const auto stopped=estimate(config,measurements(100000,300,0));check(!stopped.sensor_fault&&stopped.fuel_mass_flow==0,"Stopped crank was a fault or delivered fuel");
|
||||
for(int failure=0;failure<7;++failure){auto m=measurements();switch(failure){case 0:m.map={};break;case 1:m.iat={};break;case 2:m.map=SignalValue::number(0);break;case 3:m.iat=SignalValue::number(0);break;case 4:m.map.timestamp=-1;break;case 5:m.iat=SignalValue::boolean(true);break;case 6:m.iat.validity=SignalValidity::Fault;break;}const auto state=estimate(config,m);check(state.sensor_fault&&!state.spark_events&&state.fuel_mass_flow==0,"Invalid required MAP/IAT retained ECU outputs");}
|
||||
auto bad=config;bad.ve_pressure={100000,20000};rejects([&]{SensorEcu invalid(bad);},"Reversed VE pressure axis accepted");bad=config;bad.volumetric_efficiency.pop_back();rejects([&]{SensorEcu invalid(bad);},"Mismatched VE dimensions accepted");bad=config;bad.displacement=0;rejects([&]{SensorEcu invalid(bad);},"Zero displacement accepted");
|
||||
LogicNode schema;schema.kind=LogicNodeKind::Ecu;schema.ecu_strategy=SensorEcuConfig::Strategy::SpeedDensity;const auto ports=logic_node_ports(schema);check(ports.size()==4&&ports[2].key=="map"&&ports[2].quantity==SignalQuantity::Pressure&&ports[2].pressure_reference==PressureReference::Absolute&&ports[3].key=="iat"&&ports[3].quantity==SignalQuantity::Temperature,"Speed-density connector schema lost MAP/IAT quantities");
|
||||
for(const auto& port:ports)check(port.required&&port.direction==PortDirection::Input&&port.domain==PortDomain::Number,"Speed-density required inputs are not typed Number rings");
|
||||
auto gauge=ports[2];gauge.direction=PortDirection::Output;gauge.pressure_reference=PressureReference::Gauge;check(!port_compatibility(gauge,ports[2]).allowed,"MAP accepted gauge pressure without a conversion");
|
||||
|
||||
auto doc=machine_speed_density_example();std::uint32_t ecu=0,host=0,cylinder=0,connector=0,pressure=0,temperature=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;}
|
||||
for(const auto& n:doc.logic.nodes){if(n.kind==LogicNodeKind::Ecu)connector=n.id;if(n.kind==LogicNodeKind::Sensor&&n.sensor_kind==MachineSensorKind::Pressure)pressure=n.id;if(n.kind==LogicNodeKind::Sensor&&n.sensor_kind==MachineSensorKind::GasTemperature)temperature=n.id;}
|
||||
check(doc.sensors.size()==3&&doc.logic.nodes.size()==4,"Basic speed-density example demanded extra sensors");
|
||||
MachineSimulation sim(doc,22050);bool fuel=false,fire=false;for(int i=0;i<22050;++i){sim.process();if(i%16==0)for(const auto& p:sim.snapshot().parts)if(p.id==cylinder){fuel|=p.fuel_mass>0;fire|=p.combustion;}}
|
||||
check(fuel&&fire,"Wired speed-density example failed actual fuel transport/combustion");
|
||||
auto missing=doc;missing.logic.disconnect({connector,"iat"});rejects([&]{MachineSimulation invalid(missing);},"Unwired required IAT was silently defaulted");
|
||||
auto gauge_doc=doc;gauge_doc.logic.find(pressure)->sensor_kind=MachineSensorKind::DifferentialPressure;check(!logic_connection_preview(gauge_doc.logic,{pressure,"value"},{connector,"map"}).allowed,"Differential-pressure source accepted as MAP");
|
||||
auto calibrated=doc;calibrated.find(ecu)->sensor_ecu.displacement*=2;rejects([&]{sim.apply_tuning(calibrated);},"Displacement changed during Test without Build");
|
||||
const auto path=std::filesystem::temp_directory_path()/("fluidsim-sd-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");save_machine(doc,path);const auto loaded=load_machine(path);std::filesystem::remove(path);
|
||||
check(loaded.logic==doc.logic&&loaded.find(ecu)->sensor_ecu==doc.find(ecu)->sensor_ecu&&loaded.find(host)->injector==doc.find(host)->injector,"Speed-density calibration, schema or injector failed save/load");
|
||||
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,"Speed-density engine");MachineDocument pasted;machine_insert_assembly(pasted,assembly,{});MachineSimulation copied(pasted,22050);copied.process();
|
||||
auto routed=doc;const auto manual=routed.logic.add(LogicNodeKind::Switch).id,zero=routed.logic.add(LogicNodeKind::NumberConstant).id,select=routed.logic.add(LogicNodeKind::Select).id;routed.logic.find(zero)->quantity=routed.logic.find(select)->quantity=SignalQuantity::Pressure;routed.logic.find(zero)->pressure_reference=routed.logic.find(select)->pressure_reference=PressureReference::Absolute;
|
||||
routed.logic.disconnect({connector,"map"});routed.logic.connect({pressure,"value"},{select,"a"});routed.logic.connect({zero,"out"},{select,"b"});routed.logic.connect({manual,"out"},{select,"select"});routed.logic.connect({select,"out"},{connector,"map"});
|
||||
MachineSimulation switched(routed,22050);for(int i=0;i<10000;++i)switched.process();switched.set_logic_control(manual,true);for(int i=0;i<1000;++i)switched.process();for(const auto& p:switched.snapshot().parts){if(p.id==ecu)check(p.sensor_fault&&p.ignition_cut,"Invalid MAP failed to cut ECU outputs");if(p.id==host)check(p.fuel_flow==0,"Invalid MAP retained injector delivery");if(p.id==cylinder)check(!p.combustion,"Invalid MAP retained controlled combustion");}
|
||||
switched.set_logic_control(manual,false);bool recovered=false;for(int i=0;i<10000;++i){switched.process();if(i%16==0)for(const auto& p:switched.snapshot().parts)if(p.id==cylinder)recovered|=p.combustion;}check(recovered,"Restored MAP failed to recover physical combustion");
|
||||
auto no_host=doc;no_host.find_sensor(no_host.logic.find(temperature)->sensor)->target.part=0;MachineSimulation absent(no_host,22050);absent.process();for(const auto& p:absent.snapshot().parts)if(p.id==host)check(p.injector_delivered==0,"Missing IAT host used hidden gas temperature");
|
||||
std::cout<<"Speed-density ideal-gas/VE calibration, MAP/IAT typing, live faults, combustion and persistence passed\n";return 0;
|
||||
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}
|
||||
Reference in New Issue
Block a user